feat(workspace): run and watch loops from chat (ent#458, folds ent#338) - #2388
Conversation
/review Report — self-review + a live pass on the running instanceBranch: Critical Findings — 2 found in my own diff, both fixed in
|
obasilakis
left a comment
There was a problem hiding this comment.
Validated against the methodology checklist. The substance is in good shape — _reject_timeout_above_cap lands before the #1156 deadline comparison (and is pinned as such), the fail-open on an unreadable cap is argued rather than assumed, the dual-store WebSocket routing follows the reportsStore/fleetReportsStore precedent, and the second commit's fix — watching visible rather than reading it inside a participants-only watch — is the right shape for a derived auth flag. CI is green apart from one still-running pytest shard whose sibling seeds passed on both base and head. Security scans clean.
Three things before merge, one of which actually matters.
Blocking
The issue links are not closing keywords. The body says Related to Abilityai/trinity-enterprise#458 and Related to Abilityai/trinity-enterprise#338. .github/workflows/issue-status-on-merge.yml only promotes status-in-progress → status-in-dev on a closing keyword (Fixes / Closes / Resolves), so as written both issues strand in status-in-progress after this ships — the drift /groom keeps cleaning up.
Please rephrase to:
Fixes Abilityai/trinity-enterprise#458
Fixes Abilityai/trinity-enterprise#338
Worth noting for whoever merges: the reference is cross-tracker, so GitHub auto-closes nothing here regardless. Both issues need status-in-dev set by hand after merge, and a manual close at release. ent#458 in particular should stay open until ent#457 lands AC #3.
Docs polish
docs/memory/feature-flows.md — the index row is orphaned. It was appended at the very end of the file, after the closing See docs/TESTING_GUIDE.md for testing template and examples. line, rather than into the flows index table. It renders as a stray single-row table at the bottom of the page. Move it into the table proper.
docs/memory/feature-flows/workspace-loops.md has no ## Testing section. House style for flow docs is loose (dashboard-list-view.md runs five sections), but Testing is in every recent one, and the evidence already exists — it just did not make it out of the PR description and into the document. ## Security Considerations / ## Error Handling / ## Related Flows are also absent; those I would call optional given the precedent, but Testing should be there.
Non-blocking suggestions
_reject_timeout_above_cap'sexcept Exception: returnis silent. Fail-open is the correct call for a resource ceiling, but alogger.warningwould keep a repeatedly-failing cap read from being invisible — right now a persistently broken settings read looks identical to a fleet where nobody setstimeout_per_run.PortalLoops.vue::onStopassignsstore.errordirectly from the component. Pinia permits it; an action would keep the mutation surface in one place.- The unclaimed
vite buildfailure on a missingmermaidinAgentWorkspace.vueis a localnode_modulesgap, not a branch defect — CI'sbuildjob passed. Fine as stated; no action.
Nothing here touches the design decisions, which read as deliberate throughout — particularly refusing rather than clamping the per-run timeout (the bounds are on screen before Start, so a silent clamp would begin a loop under different guardrails than the user was shown), and max_runs_reached reading as Done rather than Stopped.
abd7efc to
d2fdbd2
Compare
Code review — re-review at
|
obasilakis
left a comment
There was a problem hiding this comment.
Re-reviewed at d2fdbd2. I read the three commits forward from the SHA my earlier review was submitted against (abd7efc), then read the current diff on its own merits — the backend guard, the store, the pure module, both mount points, and the two parents that supply the participants prop. CI is green at this head (23 successful, 3 skipped).
My earlier findings
"Related to" → "Fixes" (I marked this blocking) — withdrawn. The ask was wrong, and I should correct it rather than repeat it. I checked .github/workflows/issue-status-on-merge.yml properly this time. Its pattern is \b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s+#(\d+), which requires whitespace immediately before the #, and it calls addLabels against context.repo.owner / context.repo.repo — always this repo. So Fixes Abilityai/trinity-enterprise#458 matches nothing and would change no label. Worse, the shape that does match is the bare Fixes #458, which would have stamped status-in-dev on the unrelated public issue abilityai/trinity#458. Leave the body as it is. Both enterprise issues need status-in-dev set by hand after merge either way, and ent#458 should stay open until ent#457 lands AC #3.
docs/memory/feature-flows.md index row — addressed. It now sits in the conversation-flows table beside the other Workspace entries, in that table's three-column shape, and the stray one-row table past the end of the file is gone.
workspace-loops.md missing ## Testing — addressed. The section is there with the real suite names and counts.
Non-blocking, still open (still non-blocking): _reject_timeout_above_cap's except Exception: return is still silent, and PortalLoops.vue:241 still assigns store.error from the component. Neither needs to hold the PR.
The review comment standing at the current head
The comment posted at d2fdbd2 raised six findings after the last commit, so none of them have been responded to in code. I verified all six against the current tree rather than taking them on trust. All six reproduce. Two of them I consider blocking, for reasons below; the other four I would take as follow-ups.
Blocking
1. The participants watcher fires on prop identity, not content, so the panel fetches continuously. PortalLoops.vue:198 is computed(() => props.participants) and :252 is a non-deep watch over it, which compares with Object.is. Both parents hand it a freshly allocated array on every render: PortalConversation.vue:134 passes the inline literal :participants="[agent.name]", and PortalRoom.vue:126 passes agentParticipants, a computed whose .filter().map() at PortalRoom.vue:266 allocates a new array each time room.value is reassigned. store.setParticipants does early-return on unchanged content, but store.fetchLoops() at :257 runs unconditionally.
The two concrete consequences: in a 1:1 chat, input is v-model-bound in the same component (PortalConversation.vue:245), so every keystroke re-renders the parent and issues one GET /api/agents/{n}/loops?limit=20 per participant — and each of those responses runs db.list_loop_runs per row inside _build_status_response, so a 40-character message costs 40 requests and up to 40 × 20 extra queries. In a room, PortalRoom.vue:520 reassigns room.value on every 3s poll tick, so the panel fetches every three seconds for the life of the chat whether or not anything is running.
That second case is what makes this blocking rather than a performance nit: it contradicts the contract the store documents for itself at stores/portalLoops.js:42-48 ("only while something is actually active, so an idle Workspace tab issues no traffic at all") and the AC #4 claim in the PR body, and it runs at 4× the rate of the 12s backstop it is supposed to be quieter than. Watching a stable key (the joined names) or having the parents pass a stable array fixes it.
2. The same re-fire resets form.agent, and in a room that can start a loop on the wrong agent. PortalLoops.vue:256 assigns form.agent = names[0] || null unconditionally inside the watch callback. In a room with participants [alpha, beta], a user opens the start form and selects beta; within three seconds the room poll re-fires the watcher and the select reverts to alpha. onStart at :229 reads form.agent, so a Start clicked in that window runs up to 100 LLM turns against alpha — billed to alpha's owner, on an agent the user did not choose. The form-field assignment belongs inside a "participants actually changed" branch, next to the setParticipants guard that already has one.
Non-blocking
3. Unbounded expanded list in a flex column that has no room for it. The expanded container at PortalLoops.vue:37 has no max-height and no overflow-y-auto, the row loop at :64 renders every loop returned, and fetchLoops requests limit: 20 per participant with no status filter — so it includes terminal history, not just active loops. In a three-agent room that is up to 60 rows, each with a three-column bar grid. The panel's root is also a plain div with no shrink-0, while its sibling message pane is flex-1 min-h-0 overflow-y-auto (PortalConversation.vue:75, PortalRoom.vue:70), so the expanded panel takes its height out of the conversation. The design-system contract asks for bounded viewports for unbounded data; a max-h-* plus overflow-y-auto on the expanded container would settle it.
4. startPayload silently drops two fields the UI displays as a promise. FORM_INITIAL (portalLoopUtils.js:43) spreads GUARDRAIL_DEFAULTS, which carries on_failure and max_consecutive_failures, but the optional map at :222-228 omits both, so neither is sent. This is harmless only while those values happen to equal the server defaults — and PortalLoops.vue:143 already shows max_consecutive_failures to the user as a statement about this loop. Either include them in the payload or drop them from FORM_INITIAL.
5. The same class again, on the deadline. max_duration_seconds is in FORM_INITIAL and in startPayload, but no control sets it, so it is always null. The copy at PortalLoops.vue:144 reads "No time limit unless you set one", which describes a control the form does not have. ent#458 AC #1 names duration among the guardrails visible before start; the default is arguably visible, but the sentence promises more than the form offers. Either add the input or reword to say there is no time limit.
6. WS handling refetches more than it needs to. stores/portalLoops.js:149 short-circuits only when agent_name is truthy, so any loop_run_completed / loop_completed payload without it refetches every participant. The operator sibling at stores/loops.js:134-140 requires both agent_name and loop_id and does a targeted single-loop reconcile. Since a 100-run loop anywhere in the fleet emits 100 events, mirroring the operator store's shape is worth doing.
7. Auto-expand cannot fire. PortalLoops.vue:263 reads store.hasActive in onMounted, but the immediate watcher has only started the async fetchLoops() by then, and the matching branch of onUnmounted calls store.clear(), so nothing survives from a previous mount. A one-shot watch on store.hasActive would deliver the documented behaviour.
8. The unmount fallback stops the incoming panel's poll. PortalLoops.vue:275's else runs precisely in the branch the comment above it describes — the new panel already owns the store — so stopPolling() clears a timer the incoming panel armed, and _ensurePolling is only reached from fetchLoops's finally. That else-branch should be a no-op.
9. test_ent336_s03_slot_timeout_floor.py:246 now has a stale docstring. test_loop_timeout_above_the_agent_cap_does_not_fire still explains that "neither routers/loops.py nor loop_service.py compares it to the cap", which this PR makes untrue. The assertion is still correct and still passes; it is the explanation that now documents a fixed bug as live behaviour — on the one test that exists to explain why S-03's upward arm was narrowed. Its sibling at :264 explicitly asks for it to be revisited alongside.
10. Worth a release note. As ent#338 itself flags, this is a behaviour change to a user-facing validator: an existing MCP run_agent_loop caller passing timeout_per_run above the agent cap starts receiving a 400 where it previously succeeded. The refuse-rather-than-clamp choice is right and well argued, but the change deserves a line at release time.
One question, not a finding
POST /api/agents/{name}/loops is gated on get_authorized_agent, which admits owner, admin, and anyone the agent is shared with. A Workspace platform session's roster includes shared agents, so a role: user collaborator granted chat-only access via /share can now start up to 100 turns billed to the owner's subscription, with no cost budget by default. I checked before writing this up: the gate is unchanged by this PR, and AgentDetail.vue's Loops tab is already pushed unconditionally rather than behind canShare, so the same person could already do this from the operator surface. The reach is the same; what changes is that this becomes the default surface for exactly that population. Given the "a dispatch spends money, so it is never unconditional" reasoning behind ent#329's owner-only toggle, is an owner-level control over who may run loops something you want filed as a follow-up, or is the sharing grant deliberately taken as sufficient here?
Verdict
Requesting changes on items 1 and 2 only. The backend half is in good shape — the guard lands before the #1156 deadline comparison and is pinned as such, the fail-open is argued rather than assumed, and it matches ent#338's own fix sketch. The store's fetch-token, partial-failure and cooperative-stop handling are right, and the refusal to flatten stop_reason is the correct call. The two blocking items are both in the participants watcher and should be small: watch a stable key, and move the form.agent assignment into the changed branch. Nothing else here needs to hold the merge.
A collapsed strip above the composer, quiet until an agent in this chat is looping. Start with the guardrails visible first, Stop always available, each participant's active loops with how much of each bound is left. No new backend surface. ent#458 scopes this to the platform-authenticated door (ent#78), and a Workspace platform session already carries the operator's JWT — so it calls the existing operator loop endpoints. An external client never mounts the panel and could not reach them if it did. Hidden, not disabled: a disabled control advertises a capability a portal token can never satisfy. Live without a new transport. Loop events are already broadcast fleet-wide and /workspace renders inside the same app shell, so they were already arriving — just routed to the operator store, which filters to Agent Detail's agent. websocket.js now routes each event to TWO stores (reportsStore + fleetReportsStore shape, #918). Separate stores because stores/loops.js is agent-at-a-time by construction; one singleton would have each surface clearing the other's list on navigation (the skillsLibrary/skills split). A 12s backstop poll arms only while something is active, and an unknown status counts as NOT active — the panel cannot sit claiming work is in flight forever. What it refuses to flatten: stop_reason carries six situations with six different next actions, and max_runs_reached reads as Done rather than Stopped on both the completed and the stopped row. Headroom reports null for a guardrail never set, not 0% or 100% — "no budget" and "budget untouched" are different facts and a bar at either extreme asserts the wrong one. ent#338 folded in, and it is a real bypass rather than a display bug: task_execution_service reads the agent cap only when no timeout was passed, so an explicit timeout_per_run went straight to dispatch and a loop could run iterations longer than its owner's ceiling — times up to 100 runs. Now 400 with a structured agent_cap_seconds. Refuse, not clamp (mirroring #929 for schedules): the bounds are on screen before Start, so a silent clamp would begin a loop with different bounds than the user was shown. Runs before #1156's deadline check so that check can never quote a timeout the caller may not have; fails open on an unreadable cap, because this is a resource ceiling and the prior behaviour was no check at all. AC #3 (history in the Activity tab) is deferred to ent#457, which builds that tab — stated, not dropped: the issue forbids a parallel surface. Verified live: refusal at 7000s vs a 3600s cap with the structured detail, 202 at the boundary, the real list payload rendered through the pure rules (Done / 1-of-1 runs / no cost bar / quiet strip). Related to Abilityai/trinity-enterprise#458 Related to Abilityai/trinity-enterprise#338 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…red store (ent#458) Two findings from reviewing my own diff. C1 — the panel could render permanently empty. `visible` derives from `isPlatformSession`, which derives from authStore.isAuthenticated and the portal token, both settled asynchronously. The watch guarded on it but keyed only on the participants, so at mount it returned early and nothing re-ran it: the strip appeared and never fetched. Same class as AdminEmailNudge's `profileVerified` and the ent#384 tile's key-appearance watch — a derived auth flag needs a watcher, not a read. Now `watch([participants, visible])`. C2 — one mount point could wipe the other's list. PortalConversation and PortalRoom share the store singleton, and a room -> 1:1 switch can mount the new panel before the old unmounts, so an unconditional clear() on unmount would blow away the list the new panel had just set, with no watcher left to re-run. Unmount now clears only if the store still holds this instance's participants, and otherwise just stops the poll. Both pinned in portalLoops.spec.js (39 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flow its Testing section (ent#458) Two review asks, both in the docs. The index row was appended after `See docs/TESTING_GUIDE.md …` — past the end of every table — so it rendered as a stray one-row table at the bottom of the page rather than as an entry anyone would find. It now sits in the conversation-flows table beside the other Workspace flows, in that table's three-column shape. The flow doc had no `## Testing`. House style for these docs is loose, but Testing is in every recent one and the evidence already existed — it just never left the PR description. It names both suites with their real counts (5 backend, 39 frontend), says why the rules live in a pure module (vitest runs `environment: 'node'` with no mount harness, so a decision inside the SFC is one no test can reach), and records the live pass. Related to Abilityai/trinity-enterprise#458 Related to Abilityai/trinity-enterprise#338
…hree things downstream of that (ent#458) **A `GET /loops` per participant per keystroke.** The watch source was `participants`, a computed over `props.participants` — and both parents build that array fresh on every render: `PortalConversation` passes the inline literal `[agent.name]`, `PortalRoom` passes a computed re-derived from `room.value`, which its 3s poll reassigns. A non-deep watch compares with `Object.is`, so a new identity always fired. `store.setParticipants` early-returns on unchanged content; `fetchLoops()` did not. Typing a 40-character message issued 40 requests per participant, each running `_build_status_response` per loop and `db.list_loop_runs` per row — and a room polled unconditionally every 3s with nothing running, against the intended "an idle tab issues no traffic". Watching the joined KEY makes it fire on what actually changed. **The same re-fire discarded the user's agent selection.** `form.agent = names[0]` ran on every one of those, so picking `beta` in a room and waiting three seconds put it back to `alpha` — and a Start clicked in that window ran the loop on the wrong agent. The assignment is now inside the participants-actually-changed branch. **The unmount fallback killed the INCOMING panel's backstop poll.** That else-branch is reached exactly when `ownedKey` no longer matches the store, i.e. when the store already belongs to the new panel. `_ensurePolling()` only runs from `fetchLoops`'s finally, so if the new panel's first fetch had already armed the 12s timer, the outgoing panel cleared it and nothing re-armed — leaving the new panel with WS as its only update path, which is precisely the dropped-event failure the backstop exists to cover. The store is not ours any more: do nothing. **Auto-expand could never fire.** It read `store.hasActive` in `onMounted`, where it is always false — the immediate watcher only STARTS the async fetch, and the matching-key unmount branch calls `store.clear()`, so nothing survives a previous mount either. The strip rendered collapsed saying "N loops running" and never expanded. Now a one-shot watch on `hasActive`. Also: an event with no `agent_name` fell through the filter and refetched every participant (a 100-run loop anywhere in the fleet emits 100 of them) — an event that cannot say which agent it belongs to is not ours to act on, which is what the operator sibling already requires. And `startPayload` dropped `on_failure` and `max_consecutive_failures`, which `FORM_INITIAL` carries and the strip SHOWS to the user as a promise about this loop; harmless only while the omitted values match the server defaults. Related to Abilityai/trinity-enterprise#458 Related to Abilityai/trinity-enterprise#338
d2fdbd2 to
0f2e99a
Compare
Code review — re-review at
|
obasilakis
left a comment
There was a problem hiding this comment.
Approving at 0f2e99a3. Both blocking items are fixed, and I read the tree rather than the commit message for each.
1 — the participants watcher. participantsKey is the joined string and the watch source is [participantsKey, visible], so a re-rendered prop with identical contents no longer fires it. That closes the per-keystroke fetch in a 1:1 and the unconditional 3s fetch in a room, and restores the store's own "an idle Workspace tab issues no traffic at all".
2 — the agent selector. form.agent = names[0] now sits behind keyChanged, so a room's poll tick can't put the select back to the first participant under someone mid-selection.
You also took 4, 7 and 8 and half of 6, none of which I'd asked you to hold the PR for. The auto-expand fix in particular is the right shape: one-shot via a flag rather than the watcher's own stop handle, which with immediate: true would not be assigned yet when the callback runs — and hasActive can legitimately be true at mount, since the store is a singleton and a sibling panel may not have unmounted.
One thing I got wrong, and one small edge
Nothing to change for the merge, but both are worth having on the record.
if (keyChanged) is right for the re-fire it targets, but visible settles asynchronously — the C1 race the previous commit fixed. If the first watcher fire returns early on !isVisible and the second only flips visible, then keyChanged is false and form.agent is never seeded, so the select renders blank for the life of the chat. This is cosmetic, not a wrong-agent start — onStart falls back to participants.value[0], so a Start still runs on the agent the blank select would have shown. keyChanged || !form.agent covers it.
Three I would rather not see deferred, because they are text
Not merge blockers. But they are minutes, they carry no design question, and two of them are wrong today:
test_ent336_s03_slot_timeout_floor.py:246. The docstring still explains that neitherrouters/loops.pynorloop_service.pycompares a loop's timeout to the agent cap. This PR makes that untrue. The assertion is still correct and still passes; it is the explanation that now documents a fixed bug as live behaviour — on the one test that exists to say why S-03's upward arm was narrowed, and whose sibling at:264explicitly asks for it to be revisited alongside. A stale comment on a canary rationale is the kind that stays wrong for a year.- The start form's copy. It reads "No time limit unless you set one" and the form has no control that sets one —
max_duration_secondsis inFORM_INITIALand instartPayloadand is alwaysnull. This PR's own standard is the reason I'm raising it rather than shrugging: the headroom rule is "null for a guardrail that was never set — not 0%, not 100%; no budget and budget untouched are different facts". A sentence describing a field that does not exist is the same class. The one-line reword stops the inaccuracy; the input is the fuller answer to AC #1, which names duration among the guardrails visible before Start. - The release note. ent#338 changes a user-facing validator: an existing MCP
run_agent_loopcaller passingtimeout_per_runabove the agent cap now gets a 400 where it previously succeeded. Worth recording somewhere durable now rather than reconstructing it at freeze.
Fine as follow-ups
- 6, the half that remains. Requiring
agent_nameremoved the bad case, and that was the important half — an unattributed event no longer fans out. What is left is that the handler still callsfetchLoops()for an event that names exactly one loop, so the cost is one request per participant per event, each expanding into adb.list_loop_runsper row it returns. Bounded (1–3 participants, only loops in this chat), but a 100-run loop still pays 100×N list calls where the operator sibling pays 100 single-loop reads. - 3, the unbounded expanded list.
limit: 20per participant with no status filter includes terminal history, so a three-agent room reaches ~60 rows in a flex column whose sibling message pane isflex-1 min-h-0. - The silent
except Exception: returnin_reject_timeout_above_cap, andonStopassigningstore.errorfrom the component. Both still open, both still non-blocking.
I have the remainder implemented and green locally if it's useful — say the word and I'll hand you the branch rather than push into yours.
CI
Green. The two red pytest shards (base seed 67890, head seed 99999) are 25-minute runner timeouts that produced no JUnit XML, not test failures — regression diff reports no new failures across the surviving seeds on both sides, with head at 12417 tests to base's 12412.
At merge
As established last round, the Related to links are correct as written and should stay — the workflow's pattern needs whitespace before the # and only labels this repo, so a cross-tracker Fixes matches nothing and the bare form would stamp the unrelated public issue #458. Both enterprise issues need status-in-dev set by hand, and ent#458 should stay open until ent#457 lands AC #3.
Summary
A workspace user can start a loop from the chat it belongs to, watch it, and stop it — a collapsed strip above the composer that stays quiet until an agent in this chat is actually looping. Folds in ent#338, the per-run timeout that was never bounded by the agent's own ceiling.
No new backend surface, and that is the design
ent#458 scopes this to the platform-authenticated door (ent#78's auth-path invariant). A Workspace platform session already carries the operator's JWT — the same axios default header the rest of the app uses — and
routers/loops.pygates onget_current_user/get_authorized_agent. So the panel calls the existing operator endpoints. No route, no model, no migration.An external client holding a portal token never mounts it — hidden, not disabled: a disabled control advertises a capability that credential can never satisfy.
Live without a new transport
Loop events are already broadcast fleet-wide (#1106), and
/workspacerenders inside the same app shell whose root connects the WebSocket. They were already arriving; they were routed to the operator store, which filters to the agent on Agent Detail. Soutils/websocket.jsnow routes each event to two stores:stores/loops.jsLoopsPanelstores/portalLoops.jsPortalLoopsThe
reportsStore+fleetReportsStoreshape (#918); each a no-op when its surface isn't mounted. Separate stores becausestores/loops.jsis agent-at-a-time by construction (setAgent(name)replaces the list) while a room has several participants — one singleton would have each surface clearing the other's list on navigation (theskillsLibrary/skillssplit, ent#263).A 12s backstop poll arms only while something is active, so an idle tab issues no traffic, and an unknown status counts as not active — the panel cannot sit claiming work is in flight forever (AC #4).
What it refuses to flatten
stop_reasoncarries six situations with six different next actions.portalLoopUtils.loopStatusLabelis the one place that won't collapse them — and the one that matters most:max_runs_reachedreads as "Done", not "Stopped", on both the completed and the stopped row. Calling a loop that simply finished "Stopped" reads as a fault.Headroom reports
nullfor a guardrail that was never set — not 0%, not 100%. "No budget" and "budget untouched" are different facts, and a bar drawn at either extreme asserts the wrong one. An overshoot clamps to the end of the track, because the runtime lets the current run finish and cost can legitimately exceed its budget (#1155).Guardrail defaults shown before Start mirror
models.StartLoopRequestand are pinned against it (cross-language, cannot be imported).max_runsis deliberately excluded from that mirror: it is REQUIRED on the server, so the form's10is a suggestion — pinning it as a "default" would enshrine a fiction.ent#338 — a bypass, not a display bug
task_execution_servicereads the agent cap only when the caller passed notimeout_seconds, so an explicittimeout_per_runwent straight to dispatch. A loop could run iterations longer than its owner'sexecution_timeout_seconds— multiplied by up to 100 runs. Now 400 with a structuredagent_cap_seconds.Refuse, not clamp (mirroring #929 for schedules): this feature puts the bounds on screen before Start, so a silent clamp would begin a loop with different bounds than the user was shown. It runs before #1156's deadline check, so that check can never quote a per-run timeout the caller may not have. It fails open on an unreadable cap — a resource ceiling, not a security gate, and the prior behaviour was no check at all.
Deferred, stated rather than dropped
AC #3 (loop history in the Activity tab) waits on ent#457, which builds that tab. The issue explicitly says history lives there and forbids a parallel surface, so the honest sequencing is to render into it once it exists. ent#457 is open and assigned.
Test Plan
tests/unit/test_ent338_loop_timeout_cap.py— 5 tests: refusal above the cap with the structured detail, the boundary (equal is allowed),Nonenever even reads the cap (None > intis the TypeError bug/design: schedule vs agent timeout precedence is silent + SIGKILL error message is ambiguous #929's docstring calls out), fail-open on a cap read error, and a source pin that the guard precedes the feat(loops): loop-level wall-clock deadline (max_duration_seconds) #1156 comparison.src/frontend/tests/unit/portalLoops.spec.js— 37 tests: the terminal vocabulary incl.max_runs_reached→ Done, unknown status is not active, headroom null-vs-zero and overshoot clamp, the quiet strip, grouping, form pre-flight incl. the doom-loop1rejection and the ent#338 ceiling, payload omission of empty optionals, refusal-message parsing, the defaults↔models.pypin, and source assertions for the platform-only gate / always-available Stop / teaching empty state / semantic tokens only.123 passed, 1 skipped.1214 passed (57 files)— includes the raw-color and loading-gate ratchets.PortalLoops.vueuses semantic tokens only (action-primary,status-*); not in the offender list.timeout_per_run: 7000vs a 3600s cap → 400 withagent_cap_seconds: 3600;3600at the boundary → 202; the realGET /loopspayload fed through the pure rules →Done/1/1 runs (100%)/ costnull(no budget, correctly no bar) / stripnull(quiet);POST /loops/{id}/stop→{"status":"stopping"}, confirming stop is cooperative and the row does not go terminal on the click.vite buildfails locally onRollup failed to resolve import "mermaid"— a declared dependency missing from this machine'snode_modules, inAgentWorkspace.vue, which this branch does not touch. CI'snpm cihas it.Related to Abilityai/trinity-enterprise#458
Related to Abilityai/trinity-enterprise#338