Repository navigation
feat: make loop runs legible and task lists calm - #452
Conversation
Checkpoint via cy-loop-tasks (iteration 2, phase B mode=tasks). Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 4, phase B mode=tasks). Co-Authored-By: Codex <[email protected]>
Visual Contract row VC-08 was recorded as PASS against a capture that showed a terminal "Done" run with no partial signal at all. The story it targets staged no briefing read, and buildScenarioProps filled the gap with a manufactured verdict - tone "ok", a headline built from the loop name and round, no outcome, no artifacts - so the capture photographed a state nobody had staged. VC-03, VC-06 and VC-07 failed the same way inside their recorded scope boundaries. Make the omission impossible instead of papering over it: briefing is now required on LoopRunStoryScenario, the placeholder is deleted, and briefingFor copies the run's server-owned run_id, status, progress and usage while requiring each scenario to state the tone and headline the daemon writes. The compiler enumerates every site, so a scenario that has not staged a verdict cannot render one. Add registerPartialOutputsScenario for VC-08: a run that failed after seven of ten fix workers returned, labelling its output partial in the default register through the artifact's warning note, the join's partial chip and the fan-out's "partial 7 of 10" coverage - all server-owned reads, none derived client-side. Split the metric-ratchet scenarios and the durable lifecycle payloads into their own files to stay under the 500-line cap. Co-Authored-By: Claude Opus 5 <[email protected]>
The visual-contract evidence for task_04/task_05 was passing on captures that did not show their named state. The fixture layer was the reason, and it is what this fixes. `briefingFor` documented that a scenario may not overwrite the server-owned briefing fields, but `StoryVerdict` admitted all of them and `...verdict` was spread last, so only the comment stood between a story and a briefing that contradicted the run beside it. The type now excludes `run_id`/`status`/ `progress`/`usage`, the derived fields land after the caller's spread, and the two usage facts the record cannot supply — `cost_usd`, `duration` — are kept as a named optional. `makeBriefing` stopped being a second staging path: every register and visual-contract scenario derives its briefing from its own run, so the captures no longer photograph 82.4k tokens over a run recording 68k, a "Done" strip over a failed run, or a calm verdict over a cancelled one. Beat titles are the daemon's own (`Step <node> running`, `Round 2 started`) rather than invented fixture copy, and the long story stages the fold the daemon serves, so the paging row shows a coalesced beat and the way back instead of a wall of identical rows. The story clock is stamped once per load rather than pinned to a literal instant: projections took the pinned value and components render against the wall clock, so every beat read "1d ago" and a retry two minutes in the future read "next 1d ago". Three surfaces were understating what the product does, and one was leaking wire vocabulary into the default read: - the needs-you card printed `needs_approval · <gate id>`, and `on_exceeded: <enum>` on the budget gate — two enums and a machine id in the register task_05 requirement 1 keeps them out of. It now names the asking gate in words with its round, and leaves the budget policy to the sentence the usage rail already carries. - the request card never said who was asking, though `node_id` and `generation` were on the wire the whole time. - a degraded runs roster said the rows were the last read without saying how old it was, which reads as "this is current". - the roster answered "not started" about steps that had plainly run. A step whose timing did not survive now reads `unknown`. Stories now stage what the host wires: Compare/Fork on the generation history, `Browse loops` on the empty roster, an `attention` summary so the roster's needs-you group exists, and reduced motion through `MotionConfig` rather than a Storybook global that moved neither the context nor the media query. VC-22 gets a deep graph of its own instead of re-staging VC-21's wide one. Co-Authored-By: Claude Opus 5 <[email protected]>
Inspecting the remaining twenty-one visual-contract rows one image at a time turned up three more states the page was not actually in, plus a test that asserted a number instead of an invariant. The story pane reads the durable timeline, and several scenarios staged their events richly while staging no timeline at all — so a run four rounds deep with 21k tokens spent rendered "Nothing has happened in this run yet." and four contract rows captured that empty pane as their evidence. The read is now derived from the scenario's own events, mirroring what the daemon does with them at read time; kinds the mapping does not know are skipped rather than given an invented sentence. The terminal graph was drawing a finished run as though it were still on its way: the scenario staged roster rows for two earlier rounds only, so the round the graph draws had almost none and every unlisted step read "Reachable. Nothing has reached it yet." The terminal round is staged in full now, and the branch the run declined reads as declined. Cancellation cause and actor move onto the roster row. Strategy-canceled and operator-canceled share the neutral ramp by design, so the state word alone cannot tell them apart — the roster showed two identical grey `canceled` chips and the distinction US-012.EC-2 asks for was unreadable until the node panel was opened. The node panel's builder is shared rather than copied, so the runtime's own reason codes stay suppressed in both places. `expect(scenarios.length).toBeGreaterThanOrEqual(18)` was a structural threshold pretending to be a guard: the invariant is that a staged briefing agrees with its run, and freezing a fixture count would fail on a scenario being added or retired, neither of which can break that. It is now a plain non-vacuity check, and the sweep reaches every module that exports scenarios instead of two. Co-Authored-By: Claude Opus 5 <[email protected]>
VC-35 stages "dozens active", and the reference note is specific about where the scale goes: "Scale changes the count, not the composition. The needs-you group still sorts first and stays small; active collapses into one scrollable table." The story built its thirty runs by cycling every seed of `loopRunFixtures`. That list is mostly terminal runs, so thirty drawn from it in order inherited the mix two-fold and the roster sorted them into Needs you 2, Active 8, Recent 20 — the dozens landed in Recent, the one group the row exists to show held eight, and the needs-you group was twice the size the note calls small. The capture was passing on a state it did not stage. The scale fixture moves into its own module and partitions the seeds by the same two facts `groupOf` groups on — a served `attention` summary, and whether the status is terminal — rather than by naming ids. Thirty runs still, now one needs-you, twenty-six active and a settled tail of three, so the composition still reads as three groups while the count lands where the contract puts it. Reusing the roster's own predicate means a change to the grouping rule carries this fixture with it instead of silently dropping the bulk into the wrong group again, which is how the defect survived the first pass. The canonical `LoopRunsView` suite gains the invariant: at scale the three groups keep their order, Active holds the dozens, Needs you stays small, every run is accounted for, and no KPI band appears. Co-Authored-By: Claude Opus 5 <[email protected]>
Checkpoint via cy-loop-tasks (iteration 8, phase B mode=tasks). Co-Authored-By: Codex <[email protected]>
|
Too many files changed for review (521 files, 100 file limit). |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 420 files, which is 120 over the limit of 300. To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (101)
📒 Files selected for processing (420)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
React Doctor found 4 issues in 3 files · 4 warnings · score 92 / 100 (Great) · vs 4 warnings
|
|
|
||
| const dagNodes: LoopDagNode[] = []; | ||
| for (const nodeId of topoOrder(graph)) { | ||
| const authored = graph.nodes.find(entry => entry.id === nodeId); |
There was a problem hiding this comment.
React Doctor · react-doctor/js-index-maps (warning)
This gets slow as your list grows because array.find() runs inside a loop, so build a Map once before the loop for instant lookups
Fix → Build a Map once before the loop instead of calling array.find(...) inside it
| const dagNodes: LoopDagNode[] = []; | ||
| for (const nodeId of topoOrder(graph)) { | ||
| const authored = graph.nodes.find(entry => entry.id === nodeId); | ||
| const rollup = roundRollups.find(entry => entry.node_id === nodeId); |
There was a problem hiding this comment.
React Doctor · react-doctor/js-index-maps (warning)
This gets slow as your list grows because array.find() runs inside a loop, so build a Map once before the loop for instant lookups
Fix → Build a Map once before the loop instead of calling array.find(...) inside it
| ): LoopStepRow[] { | ||
| const steps: LoopStepRow[] = []; | ||
| for (const nodeId of orderedNodeIds(graph, index)) { | ||
| const rollup = index.rollups.find(entry => entry.node_id === nodeId); |
There was a problem hiding this comment.
React Doctor · react-doctor/js-index-maps (warning)
This gets slow as your list grows because array.find() runs inside a loop, so build a Map once before the loop for instant lookups
Fix → Build a Map once before the loop instead of calling array.find(...) inside it
| key: `${generation}:${nodeId}:container`, | ||
| nodeId, | ||
| isBranch: false, | ||
| kindLabel: loopDagKindLabel(graph?.nodes.find(entry => entry.id === nodeId)), |
There was a problem hiding this comment.
React Doctor · react-doctor/js-index-maps (warning)
This gets slow as your list grows because array.find() runs inside a loop, so build a Map once before the loop for instant lookups
Fix → Build a Map once before the loop instead of calling array.find(...) inside it
…op legibility) Pull from origin/main bringing 54 commits including: - feat: profiles — the who-is-working dimension (compozy#457) - build: prepare release v0.3.0 (compozy#450) - feat: complete production demo seed (compozy#453) - fix: recover ACP sessions after provider disconnects (compozy#454) - feat: make loop runs legible and task lists calm (compozy#452) - and many more Resolved Makefile .PHONY conflict by keeping both cross-build-windows and build-windows targets alongside the new test-e2e-desktop target.
What & why
Loops run through the autonomy kernel: every run creates one coordinator record plus one execution record per action node × fan-out item × round. That design is fine. What wasn't fine is that those records were the only thing anyone could see, and they were everywhere.
Three concrete failures, all confirmed on the owner's dev daemon before this branch started:
compozy task list, the HTTP/UDS catalog, and thecompozy__task_listnative tool. The client-side nesting that was supposed to contain this degraded back to a flood as soon as pagination kicked in.docs/qa/bugs/BUG-20260719-autonomous-progress-unobservable.md(P1, independently re-found 6 times) documents operators concluding "stalled" while real work was happening.revisao-paralelacrashed at ~17:03 on 2026-08-19. Fifteen minutes latercompozy task liststill showed both coordinatorsQUEUEDwith cells pending — permanently. Settlement only ever ran on the happy path (global_db_task_coordinator_settlement.go); nothing swept the other way.This branch fixes legibility and lifecycle integrity without touching the execution architecture. No second executor, no new queue, no new run or node lifecycle states. Loop records get classified at the source and filtered out of task listings by default; the Loops area becomes the complete two-register observability home; and every terminal transition settles its records, with an idempotent sweep that repairs the orphans that already exist.
Spec, ADRs and task breakdown:
.compozy/tasks/loop-task-legibility/(7 tasks,_spec.md, ADR-001..006).Implementation by surface
Runtime —
internal/loopThree computed projections over tables that already exist. No materialized state, so there is no second copy to reconcile.
roster.go,roster_projection.go,roster_rollup.go,roster_routes.go,roster_cursor.go— the node × generation roster. State comes from pinned topology plus durable outputs, controls, waits, attempts and route evidence. Fan-out arrives as derived rollup counts so a wide run never has to page every item.briefing.go,run_read_briefing.go— the briefing is a pure projection of the roster. Tone resolves by first match: approval > quarantine > request > failure > quota/backoff > running > terminal.timeline.go— durable, paged, resumable history overloop_run_events. Replaces the client-window-only story, so full history survives a reload and a long run.settlement.go,reconciler.go— the terminal settlement rule and the sweep.run_read_service.go,run_read.go,run_summary.go— assembly and the service interface.Long histories stay SQL-backed without N+1 reads: timeline and route evidence scan fixed-head backward batches, run ordering happens before keyset pagination, and attention/progress/fork summaries use constant-count batch queries.
Persistence —
internal/store/globaldb00081_schema.sql: adds nullableoutput_idandartifact_nametoloop_generation_outputs. Append-only;atlas.sumand the declarative source (schema/definitions/50_loops.sql) regenerated throughmake codegen.session_idfailed its SQL ownership fence because the predicate treated two NULLs as unequal, and a coordinator that reachedneeds_attentionthrough ordinary retry exhaustion blocked reconciliation. Both were reproduced red first, then fixed with focused store regressions.Daemon —
internal/daemonloop_reconciler_boot.go— one sweep at boot, always, regardless of interval. This is what retroactively repairs pre-existing orphans.loop_reconciler_runtime.go— the periodic ticker.loop_api_reads.go,loop_api_run_list.go— read wiring.internal/daemonremains the only composition root;mage Boundariesis unchanged.Repaired records are stamped
reason: "reconciled_run_terminal", which is deliberately distinguishable from natural settlement'sloop_run_terminal. The sweep is idempotent and never touches an active run.HTTP / UDS —
internal/apiThree new routes, identical on both transports:
GET /api/tasksgains two typed query fields:include_loop(bool, defaultfalse) andloop_run_id(string, implies include). Both are typed fields, never inferred from query text. Catalog items gain an optionalloopobject carrying run id, loop name, role, generation and item index — which is what lets the web delete its id-string parsing entirely. The single-task read carries the same object, so a deep link into a loop record never depends on list navigation.The timeline's first page is the newest window and always returns
head_seq. Older history pages backward on demand through an opaque cursor bound to{run, view, snapshot head, before}, so the page set can't shift under concurrent appends. Replaying a cursor against another run — or against a fork, which mints a new run id — returns409 timeline_branch_changedrather than splicing two histories together. The existing SSE stream is unchanged; live readers take one first page then attach withafter_sequence=head_seq, and the seam de-dupes by sequence.openapi/compozy.jsonand the generated TS types are regenerated; no route was removed.CLI —
internal/cliThree new verbs, wired in
loop.go:compozy loop why <run>— the same verdict the run page leads with, as one plain sentence plus the exact unblock command. Healthy and terminal runs answer too; it never returns an empty object. Terminal briefings carry outcome and artifacts as typed fields.compozy loop nodes --run <run> --all— the complete node × round roster, healthy nodes included. Workspace scope keeps its four exception states;--stateonly gainsrunning/queued/succeeded/allwhen--runis set.compozy loop events <run> --after <seq> --follow— durable timeline plus follow.--view notableby default,--view allfor the ordered union. Heartbeat-class chatter coalesces into one×Nentry whoseseqis the last raw sequence it spans, so resuming after it never replays folded events.--followexits 0 cleanly when the run goes terminal.compozy task listgains--include-loopand--loop-run <id>(the latter implies the former).--parent <task-id>keeps returning children with no flag, because explicit parentage is already an explicit request.compozy loop runsis now server-ordered end to end: needs-you first, then active, then terminal — before pagination, so the ordering holds across pages.Native tools & extensions
compozy__task_listgainsinclude_loopandloop_run_id(internal/tools/builtin/task_list_schema.go); the catalog digest intestdata/native-tool-catalog.jsonis regenerated.internal/extension/host_api_task_query.go,host_api.go,host_api_options.go), so bridges and extensions reading task listings inherit the calm default with no bespoke carve-out.Web —
web/src/systems/{tasks,loops}Tasks (calm default). The id-string regex and the client-side nesting are deleted, not patched. Loop records are excluded by default on every mode; a quiet reveal filter brings them back, visually distinguished and always linked to their run. Dashboard and inbox lanes stop counting them. Provenance is read from the structured
loopobject.Run page (two registers, one page, no mode toggle). The default register answers the 30-second briefing test in plain words: what's running, what needs you, how far along, what it produced — no machine ids in primary positions. Needs-you and failure never collapse under disclosure. New components include
loop-run-briefing.tsx,loop-run-steps-progress.tsx,loop-run-story.tsx,loop-run-artifact-list.tsx,loop-node-state-chip.tsx.The Inspect register sits one step deeper:
loop-run-dag.tsx(the live authored graph, the largest redesign target here),loop-node-roster.tsx,loop-node-panel.tsx,loop-generation-history.tsx,loop-run-events-lane.tsx.Runs roster re-ranks to lead with needs-you and plain outcomes, and renders a degraded-transport state honestly instead of pretending to be live.
Docs & official skill
packages/site/content/docs/cli/loop/{why,nodes,events,runs,respond,approve,index}.mdx,cli/task/list.mdx,configuration/config-toml.mdx,loops/{running,configure,human-requests}.mdx.skills/compozy/references/{loops,configuration,tasks-and-orchestration}.md.Behavioral contracts
404 loop_run_not_found— scoping, never a leak.404 loop_run_not_found,400 invalid_node_state(with the allowed list),400 invalid_cursor,400 invalid_query_field,409 timeline_branch_changed, plus CLI exit codes 1 and 2. Full table in_dx.md.Compozy Impact Audit
compozy__task_listgains typedinclude_loopandloop_run_idarguments and the optionalloopprovenance object on items; default behavior (argument absent) excludes loop records, matching every other surface. Schema ininternal/tools/builtin/task_list_schema.go; loop toolset descriptors touched inloops.goandloops_output_schemas.go; the catalog digest fixturetestdata/native-tool-catalog.jsonis regenerated and asserted bybuiltin_test.go. No tool was added, renamed or removed; no risk flag or capability gate changed.internal/extension/host_api_task_query.go,host_api.go,host_api_options.go,contract/sdk_enums.go), so extensions and bridges reading task listings inherit the calm default rather than special-casing it. Loop lifecycle hook bridges are extended for the new terminal/started payloads (internal/daemon/hooks_bridge_loop_{started,terminal}.go,hooks_bridge_watch_events.go). No new extension point was added — the spec explicitly predicted none and none was needed. Config lifecycle: one new key, covered below. MCP sidecars, skills/capabilities registries and resource registries: unchanged, and checked.workspace_idin the path and resolve the run inside that scope; a run id from another workspace returns404 loop_run_not_foundrather than a 403 that would confirm existence. The roster, briefing and timeline queries all carry the workspace predicate into SQL rather than filtering after the fetch. The newloopobject on catalog items is derived from rows already scoped by the catalog query. The existing per-run SSE stream is unchanged and keeps its workspace scoping. Web caches key on workspace, and a QA-found data-boundary defect here was fixed and locked: palette catalog carry-forward is now limited to the same workspace's null-client → attached-client transition, with a canonical hook regression proving workspace A's commands never render while workspace B is pending.LP-web-*scenarios andinternal/apihandler tests cover cross-workspace refusal.skills/compozy/references/loops.mddocumentsloop why,loop nodes --run --all,loop events --follow/--after/--viewand the settlement/audit reads;references/tasks-and-orchestration.mddocuments the calm task-list default and the--include-loop/--loop-runescape hatch;references/configuration.mddocuments[loops] reconcile_interval.Migrations & config lifecycle
Migration. One:
internal/store/globaldb/schema/migrations/00081_schema.sql, adding two nullable columns toloop_generation_outputs:Append-only: no existing migration's bytes, version, or order changed.
atlas.sumextended,schema/definitions/50_loops.sqlupdated as the declarative source, sqlc output regenerated viamake codegen. Both columns are nullable, so the change is additive for existing databases and needs no backfill.Config. One new key on the existing
[loops]section:"1m"(internal/config/loops.go).0sand negatives are rejected at validation withreconcile_interval must be positive; an invalid value preserves the prior config rather than half-applying.RestartRequired/DiffClassRestartRequiredininternal/config/lifecycle, with the classification asserted inlifecycle_test.go.packages/site/content/docs/configuration/config-toml.mdxand the official skill.How you verified it
Deep review
One deep-review round was run before the QA tail. It found 221 issues; all 221 were resolved — the remediation landed in
36ee47f1 fix: remediate loop legibility review findingsplus the follow-up fix commits on this branch.state.yamlrecordsrounds: 1,last_verdict: SHIP.QA execution
Full report:
docs/qa/reports/2026-08-21-loop-task-legibility.md. Run in fresh isolated labs with uniqueCOMPOZY_HOME, daemon ports and sockets.Before that full web lane went green, 23 deterministic failures across Dashboard, OS shell, Settings, Storybook and Worktrees were classified and repaired. Remediation used focused reruns only — no timeout was extended and no expectation was weakened. Where a strict assertion had been softened, it was restored: the loop state-chip accessibility check went back to
toHaveAccessibleName(/\S/)and passed in the complete lane.Scenario walks: all 13 planned tracker walks in
docs/qa/scenarios/ran only after the complete lane was green, plus three overlapping regression scenarios for gate provenance, pending-request provenance, and cancellation/duration truth. All pass.Settlement was verified against a real seeded crash boundary rather than a unit fixture:
looprun-c6fc681a5171ff54was left terminalcanceledwith three unsettled execution records. The boot sweep reportedruns_examined=1,records_settled=3,orphans_repaired=1; fresh public reads showed the coordinator and both cells canceled withreconciled_run_terminalon the coordinator timeline. A second boot reported zero examined, zero settled, zero repaired, and the repair event count stayed at1— the idempotency claim, checked rather than asserted.QA lab teardown is clean on every terminal path:
qa/teardown.jsonrecords"clean": truewith an empty survivor list, and ports 62112, 6006 and 6008 have no listener.make gate-fullStated plainly, because it matters for how you read the rest of this section:
make gate-fullwas attempted locally several times. Earlier attempts surfaced deterministic failures — formatting, React lint, Go lint, cursor, and settlement-authority issues — and every one of those was fixed and verified. Later full attempts were stopped or failed on machine-level timeouts, after the relevant focused race suites and the exact full Go lint command had already passed with 0 issues. At that point the maintainer explicitly waived another complete local run and delegated the final full gate to PR CI.So: no local
make gate-fullPASS is being claimed here. CI owns that verdict on this PR.Screenshots
All six are the
implementation.pngcaptures from the Visual Contract bundles under.compozy/tasks/loop-task-legibility/evidence/visual/, taken from Storybook stories at 1440×900. They are the implementation half of a reference/implementation parity bundle — not live daemon captures. Every bundle shown here carriesverdict: PASSwithblocking_divergences: 0.Run page, default read — healthy running register (
task_05/VC-01, storylooprunpage--register-running). Status in plain words, steps and round progress, the story, the usage rail. No machine ids in primary positions.Tasks, loop records revealed — the mixed list with the reveal filter on (
task_04/VC-02, storytaskslistsurface--revealed-loop-records). This is the opt-in view; the default has none of these rows. Revealed records are visually distinguished and linked back to their run.Inspect register, live run graph — the authored DAG rendering live (
task_05/VC-17, storylooprunregisters--graph-live). This is the view the P1 observability bug was about.Inspect register, node panel — one node opened: attempts, links to its session and execution record, and the verbs valid for its current state (
task_05/VC-23, storylooprunregisters--node-panel-open).Inspect register, complete node roster — every authored node × round, healthy ones included, not just exceptions (
task_05/VC-25, storylooprunregisters--roster-complete).Runs roster — needs-you runs ordered first, server-side, before pagination (
task_05/VC-33, storyloopruns--default).Rollout & risk
Size is the main risk. 521 files, +44,584 / −12,674, across runtime, store, daemon, API, CLI, web and docs. It is one program rather than one change, and it is best read task by task through
.compozy/tasks/loop-task-legibility/task_01..07.md. The mitigation is coverage: 42 visual contracts, a full daemon-served E2E lane, 13 scenario walks and the runtime lanes above.What is additive and low-risk:
[loops] reconcile_intervalhas a working default and rejects invalid values without partially applying.What changes behavior visibly, on purpose:
--include-loop/include_loop=true. This is the point of the change, and it is documented in the CLI reference, the config docs and the official skill.Escalation path if the sweep misbehaves: set
[loops] reconcile_intervalto a longer cadence and restart; boot-time repair still runs once, and inline settlement on terminal transitions is independent of the ticker.Not merged, not gated locally. PR CI owns the full monorepo gate for this branch — see the
make gate-fullnote above.make gatepasses locally — not claimed; see themake gate-fullsection. Focused race suites and the full Go lint command passed with 0 issues; the complete gate is delegated to CI.cy-loop-tasksrun and reviewed by the maintainer, who ran the focused race and lint checks and made the CI delegation call.