Skip to content

feat(loops): run a list of tasks one at a time, each in its own session - #3

Open
miquido-adamk wants to merge 27 commits into
mainfrom
feat/task-loops
Open

miquido-adamk wants to merge 27 commits into
mainfrom
feat/task-loops

Conversation

@miquido-adamk

Copy link
Copy Markdown
Owner

Implements the task-loops spec from #1 (design-only) and closes #2.

A loop runs an ordered list of independent work items as a strict sequence of ordinary cezar tasks — one session, one worktree, one branch per item — advancing only when the previous item's run reaches a terminal state.

A scheduled task fires when the clock strikes; a loop fires when the previous run finishes. That barrier, and the set of ways an awaited run can fail to finish, is the substance of this PR.

The loop counter lives in persisted project-local state driven by the workspace coordinator, not in an agent's context window, so a restart resumes instead of losing the loop.

Commits

Commit What
55432ae9 Source-neutral launch adapter, provenance at construction
cd98d69d Loop definitions, receipts, and the completion barrier
83677bae Project-scoped API, controller, CEZ_LOOPS gating
0ca60bc2 Cockpit views, nav item, loop-change SSE signal

Deviation from the spec you should review first

The spec (Q2) says this is not implementable until upstream open-mercato#846 lands, because epic open-mercato#771 requires one coordinator and forbids "a second scheduler, store, route family, or launch path". open-mercato#846 is still open upstream (55 files, +4003).

I built it standalone anyway, on the automations pattern that exists in this tree, because the alternative was carrying 4,003 lines of someone else's unmerged, under-review work inside this PR — unreviewable here, and needing a rebase on every change to open-mercato#846. The cost, stated plainly: some of this overlaps what open-mercato#846 will eventually generalize, and reconciling them later is a refactor. That is a deliberate, reversible trade; importing open-mercato#846 was not.

The three non-terminal traps

The barrier's terminal set is done | review | cancelled | (failed with no pending autoResumeAt). Everything else is a bounded wait with a deadline and a durable reason. All three traps were verified in code, and none of them is waitingarmIdleTimer ends that session and it settles within IDLE_TIMEOUT_MS:

  • monitoring clears the idle timer and is bounded only by MAX_AUTO_CONTINUES.
  • failed + autoResumeAt means the run has an appointment to restart itself — treating failed as terminal would start two live children in a width-1 loop.
  • Restart transits a running child through failed before its continuation lands.

Plus a reconciling floor, because pruneOldRuns deletes runs with no emit at all: an awaited run can vanish in total silence, so the barrier reconciles against store state rather than trusting events.

Deliberately not done

  • Nothing is landed. reviewGateEnabled is off by default and settleSuccess skips the gate for autonomous runs, so each item ends as a branch, not a PR. That is a general defect of autonomous runs, not of loops (spec Q3), and fixing it inside loops/ would create two divergent settle behaviors. A loop therefore delivers N branches — less than the original request asked for.
  • groupId is not reused for parentage. POST /groups/:groupId/pick cancels, removeWorktrees and archives every non-winner, so one Compare→pick click on a loop parent would destroy every other child's work. Loop children carry no groupId, making that sweep unreachable by construction.
  • Composer Loop mode is not wired into the New task form's Start/Plan first control; creation is at /loops/new. The remaining spec item.
  • Issue-sourced queues, per-item landing, width > 1 — all separate slices.

Gating

CEZ_LOOPS=1, off by default, following open-mercato#801 exactly: every loops route always exists and answers 409 naming the flag, rather than degrading a read to 200 [] — an empty list reads as "you have configured none", and a client would then offer to create one against a 409ing POST.

Verification

npm run typecheck clean across all workspaces. Targeted suites green: loops + launch adapter + loops API (192), nav/shell/palette/copy (214), routes + api (179), workspace-events + BC inventory + versioned surface (95). The launch adapter's provenance test was confirmed to fail without the fix (git stash → red → restore), per AGENTS.md.

Not run: the full npm test sweep (exceeds 10 min here) and npm run test:e2e.

…tion

Phase 0 of .ai/specs/2026-08-19-task-loops.md, landed alone because it is
behavior-preserving: nothing routes through it yet.

Adds runs/launch-source.ts — one place that turns "a non-human source wants a
task run" into a started run, enforcing the two durability rules the automation
launch path does not:

- Provenance is written at CREATION, not patched on after startRun returns.
  The automation path calls updateRun(id, { automation }) afterwards, which
  leaves a window where the record exists with no provenance and the store has
  already emitted for it. A consumer that must attribute a run the moment it
  appears (the loop barrier) cannot tolerate that window.
- The launch is flushed synchronously, because runs.json saves are debounced and
  a crash in that window loses a record for work already paid for.

RunRecord gains an optional additive loop? provenance field on the same terms as
automation?, and StartRunInput gains a neutral provenance? that startRun writes
through createRun at construction.

GitHub Automations is deliberately NOT migrated: its launch path renders
untrusted GitHub event text and takes a GithubCandidate throughout, making it a
separate and riskier refactor than a mechanical extraction.

Regression coverage verified by stashing store.ts: the provenance-at-construction
and variant-provenance cases both go red without the fix.
Phase 1 core of .ai/specs/2026-08-19-task-loops.md. No routes and no UI yet, so
nothing reaches this code from the cockpit — it is the engine only.

loops/types.ts — the three project-local file schemas plus the module constants
the spec insists must NOT be per-loop knobs (launch deadline, stall deadline,
reconcile interval, compaction thresholds). Item status deliberately does not
live on the item: outcome is read from receipts, so there is one writer and no
way for the definition and the log to disagree.

loops/store.ts — read-modify-write, atomic tmp+rename at 0600, per-entry
salvage, append-only receipts with bounded compaction that retains every
unresolved row. Receipt reservation is keyed ${loopId}:${revision}:${itemId},
which is what makes a restart-time relaunch impossible for an item that already
launched. A status-only transition does not bump the revision, because receipt
keys derive from it and bumping on a pause would orphan the in-flight item.

loops/barrier.ts — the classification the spec calls the whole of the feature,
plus an O(1) awaited-run registry that diffs statuses so the store's
emit-on-every-mutation does not rewrite receipts dozens of times a second.
Covers all three non-terminal traps: a failed run holding an autoResumeAt
appointment is NOT terminal (advancing would put two live children in a width-1
loop), a vanished record is classified rather than waited on, and a run still
queued past the launch deadline is never-started. waiting is correctly NOT
treated as a trap.

A test caught a real bug while writing it: reading definitions through the file
schema let zod's .catch([]) on the loops array collapse the whole list to empty
when a single row was unsalvageable — the exact 'one bad row evicts the file'
failure per-entry salvage exists to prevent. Rows are now read from the raw
value and validated per element.
Wires the loop store and completion barrier up to the cockpit: a chained project-scoped route family under /api/v1/loops (+ /api/v1/p/:projectId parity), the LoopController that attaches per project and resumes at boot, and the CEZ_LOOPS capability gate.

Gating follows the automations precedent (open-mercato#801) exactly: every loops route always exists and answers 409 naming the flag while loops are off, rather than degrading a read to 200 [] — an empty list reads as "you have configured none", and a client would then offer to create one against a 409ing POST.

Contract schemas land in packages/contract/src/loops.ts and are validated as route middleware, so the routes reach AppType and the typed client. BACKWARD_COMPATIBILITY.md gains the §2 route inventory entry, and .env.example plus the README env table document CEZ_LOOPS.
Adds the Loops list, the detail view with its per-item timeline and paused banner, and a create form whose primary button is "Review and start" rather than a bare Start — beginning a loop spawns N unattended paid sessions, so the confirmation states the scale and that nothing is merged.

Per-item rows always render their reason line rather than hiding it behind a tooltip: "why did the loop stop here" is the only question the timeline exists to answer, and loopItemViewOf covers every receipt status so a new one cannot fall through to a blank row.

The Loops nav item is gated on capabilities.loops and carries NO forge gate, unlike Automations: a loop runs prompts the user wrote and never talks to a forge, so a repo with no remote can still run one. Sidebar, command palette and per-project groups all render through visibleNavItems, so they cannot disagree.

Wires the additive loop-change workspace SSE signal the controller already had a hook for but nothing emitted, following the automation-change precedent, and excludes it from the registry invalidation a running loop would otherwise trigger on every item advance.
Adds a third "Loop" radio to the composer's Start / Plan first segment, present only when capabilities.loops is on, which carries the typed text to /loops/new as seed items. Clicking it navigates and never POSTs — starting a loop spends money, so that stays behind the loops composer's own "Review and start" confirmation.

DEVIATION from the spec's UI section, recorded in the code: it specifies Loop as an in-place mode that swaps the composer's textarea for an items field and disables the parallel-variant multiplier. Doing that means turning planFirst into a three-state mode through new-task.tsx and its five sibling modules, so this keeps the composer's state machine untouched and hands off instead. The adjacency the spec cared about — Loop beside Start, as the sequential sibling of x1 — is preserved; the in-place swap is not.
The full-suite sweep caught a real regression the targeted runs missed: the loop detail view called window.confirm for its delete, which the design guardian forbids — a native dialog blocks the event loop and ignores the theme. Replaced with AlertDialog, matching accounts-section.

The create form's local helper was also named confirm(), which shadowed the native function and was indistinguishable from calling it. Renamed to startLoopNow.
@miquido-adamk

Copy link
Copy Markdown
Owner Author

🧪 Full-suite verification — and one real regression it caught

My earlier note said the full npm test sweep had not been run. It has now, and the honest result is not a clean bill of health, so here is the whole picture.

On this branch: Test Files 26 failed | 306 passed (332) · Tests 87 failed | 6150 passed | 6 skipped.

One genuine regression, caught and fixed — 0b47fce6

The targeted runs missed it; the full sweep found it. The loop detail view called window.confirm for its delete, which design-guardian.test.ts forbids outright — a native dialog blocks the event loop and ignores the theme. Replaced with AlertDialog, matching accounts-section.

The same commit renames a local helper that was called confirm(), which shadowed the native function and was indistinguishable from calling it. That is exactly the kind of thing a lint-shaped test earns its keep on.

Three failures verified as pre-existing, not from this branch

I created a throwaway worktree at pristine origin/main (9a8b24dd) with shared node_modules and ran the suspicious set there. All three fail identically on main:

Test On this branch On pristine origin/main
design-guardian › no-native-dialogs ❌ → ✅ fixed ✅ passes
directional-usage › compact direction order also fails
automations-api › preview checks without receipts also fails
automations-gate › scheduler starts once the flag is on also fails

The remaining ~22 files: environmental, and I have not individually attributed them

They cluster entirely in filesystem- and git-heavy suites this branch never touches — git-worktree, retention-*, autosave-*, run-isolation, run-lease, worktrees-api, projects-api, agent-config/seed. The sweep's own unhandled error names the cause:

ENOENT: open '.../\.ai/cezar/tmp/<runId>/cez-turnend-3PtG5B/.ai/cezar/runs/<id>.ndjson'
  ❯ RunManager.rescueStalledQueue packages/cezar/src/workflows/run.ts:1421

That is cezar's own turn-end machinery deleting its temp directory while the suite reads it — these tests were run inside a live cezar worktree with concurrent git and autosave activity, which is not a clean harness for suites that assert on real worktrees. Upstream open-mercato#804 already records known flakiness in this area (auto-resume.test.ts, also in the failing set).

A full baseline sweep on pristine origin/main is running to give an exact pre-existing count; I will post the diff when it lands. Until then, treat the 22 as unattributed rather than as passing.

Targeted suites, all green

Loops + launch adapter + loops API (192) · nav/shell/palette/copy (214+) · routes + api (179) · workspace-events + BC inventory + versioned surface (95) · composer incl. 3 new loop-radio cases (101) · design guardian + loop copy (23). npm run typecheck clean across all four workspaces.

npm run test:e2e has not been run.

projects-api asserts the health capabilities object with toEqual, so the additive loops key broke three cases. Adds it to the fixture type and the assertion, documenting that the zero-config answer is false because CEZ_LOOPS is opt-in — the same shape automations has.

Found by the full-suite sweep, not the targeted runs: this file was never in the set I had been running.
@miquido-adamk

Copy link
Copy Markdown
Owner Author

🧪 Verification, part 2 — a second real regression, and why e2e can't be trusted here

Continuing the attribution from the comment above. Two more findings, one of them another genuine bug of mine.

A second real regression, found and fixed — 99f820dc

projects-api.test.ts asserts the health capabilities object with toEqual, so the additive loops key broke three cases outright. Added it to the fixture type and the assertion, documenting that the zero-config answer is false because CEZ_LOOPS is opt-in — the same shape automations has.

Worth naming the process failure: this file was never in the set I had been running. Both regressions on this branch (this and the window.confirm one) were found by the full sweep and missed by targeted runs. That is the argument for running the whole suite before merge, not a subset.

The last failure in that file is environmental, and provably not mine

After the fix, one case still fails — on the very next line:

expect(body.repo).toBeNull(); // tmp dir — not a git repo
// Received: { root: "/Users/miquido/Projects/cezar", branch: "main", remote: "…" }

The test builds a temp dir and asserts git finds no repo there. It received the real cezar checkout, because the temp dir lives inside .ai/cezar/ and git discovery walks up into the repo. No diff in this branch can make git discovery find a repository — this is purely a function of where the suite ran. Same root cause as the ~20 other failures in git-worktree, retention-*, autosave-*, run-isolation, run-lease, worktrees-api.

npm run test:e2e — ran, and the result is invalid

TEST_E2E_STATUS=failed, 35 files / 22 tests. But the harness attached to the live cockpit running the very session that wrote this branch, rather than booting a clean CEZ_DRY_RUN=1 instance. The failures show real task titles where the specs expect an empty cockpit:

expect(browser.text('[data-slot="quick-list"]')).toContain('No tasks')
+ /om-brainstorm need to prepare new feature, which will allow us to create loop…
+ continuing loop feature
+ #1

That is upstream open-mercato#898 verbatim — "test-env reuse and teardown trust a recorded PID, not the instance's identity." So this run says nothing about the loops code, and e2e remains genuinely unverified. It needs a clean checkout with no cezar server running.

Where verification actually stands

Status
npm run typecheck ✅ clean, all four workspaces
Targeted suites (loops, adapter, API, nav/shell/palette, routes, composer, guardian, BC/parity) ✅ green
Regressions found by full sweep 2, both mine, both fixed (0b47fce6, 99f820dc)
Failures verified pre-existing on pristine origin/main 3 (directional-usage, automations-api, automations-gate)
~20 remaining full-sweep failures ⚠️ environmental — filesystem/git suites poisoned by running inside a live cezar worktree; untouched by this branch
npm run test:e2e unverified — harness bound to the live cockpit (open-mercato#898)

Recommended before merge: run npm test and npm run test:e2e in a clean clone with no cezar server running. I could not produce that environment from inside this session.

Type "fix all open issues one by one" and one cheap agent call turns it into a concrete ordered item list. Modelled on planner.ts (spec 008): same runner, same agent account, same one-retry parseStructured discipline, same allowedTools: [].

The expansion happens ONCE, at creation. The loop still stores a plain list, so the coordinator that advances it stays deterministic and restart-safe — there is no agent in the orchestrator seat deciding what comes next. Drafting must not quietly undo the reason loops are a persisted object rather than a parked session.

Forge context is fetched by the ROUTE and injected, so the planner keeps no tool access. Open PRs go in alongside open issues so the planner can apply the eligibility judgement open-mercato#881 documented: skip umbrella/tracking issues, decision records, and anything already covered by a PR, because batching those just creates conflicts. Issue text is fenced as untrusted data and control characters are stripped, so a hostile title cannot forge a new prompt section.

Unlike planChain there is NO silent single-item fallback: running a whole brief as one task would spend real money looking like success, so an undraftable brief returns zero items and says so, and the hand-written list stays the escape hatch.
Adds items to an existing loop, by hand or drafted from a brief, so a running loop can be extended conversationally instead of being edited as a form.

appendItems keeps the definition's revision STABLE, and that is the whole point rather than an oversight. Receipt keys are loopId:revision:itemId, so bumping the revision moves the in-flight item's reservation into a namespace nothing reads: startup reconciliation would see an unreserved item and relaunch work already running, putting two live children in a loop whose entire promise is width 1. updateLoop already documents the mirror image of this hazard for status-only patches. Keeping the revision is safe because appending changes no existing item's identity or position — each new item gets a fresh id, so its receipt key is unique under the current revision anyway.

A completed loop revives to running, because appending work to a finished loop can only mean "do this too", and leaving it completed while holding pending items would be a state with no automatic exit. A paused loop stays paused: the pause is a decision the user has not revisited. The controller re-attaches on append, since it detaches when a loop completes.

Documented in BACKWARD_COMPATIBILITY §2, which its own drift guard demanded.
Selecting Loop no longer navigates away. It reads the brief already typed in the composer, analyses it into items, and proposes them inline in a LoopReview panel that mirrors the existing PlanReview — so loop mode is the interaction plan-first already taught rather than a second vocabulary.

The drafted items stay editable: the agent chose which issues to include, and a human is the only one who can say it chose wrong. Drafting starts nothing, and an undraftable brief says so instead of quietly becoming a one-item loop.

Loop mode is LOCAL composer state, not part of the persisted draft, so planFirst and the draft/params/autostart modules are untouched — a three-state mode threaded through all of them would have been a far larger change to the most central UI in the app.

Also adds the per-loop landing policy as one enum (none | pr | merge) rather than two booleans, so "merge without opening a PR" is unrepresentable. Default none, which is the only value honouring the never-auto-merges invariant; the panel states plainly what each choice does to the repository, and the start confirmation corrects its own "nothing is merged" line when merge is selected.
Adds the per-loop landing policy: none (a branch, the default), pr (a draft PR per item), or merge (open the PR and land it once genuinely mergeable). merge is an explicit, per-loop reversal of AGENTS.md's never-auto-merges invariant and of open-mercato#771's exclusion of auto-merging item work; it is never a default, and the composer says plainly what it will do before you start it.

Landing is a SECOND non-terminal wait per item, not a step. A finished run is not a mergeable PR — checks have not started when the agent stops — so merging inline would either block the advance on CI or fail on every item and be decorative. It therefore waits in the reconciling sweep, with a deadline, because every non-terminal wait here needs one or it is a state whose only exit is a human noticing. Past LANDING_DEADLINE_MS the item records merge-blocked, its PR is LEFT OPEN, and the loop keeps draining rather than stalling the backlog on one red build.

The item is not settled until its PR lands: the receipt stays reserved through the wait, because marking it completed and merging afterwards would let the next item start from a base without it — which is the entire reason to merge at all.

stale-head is retried with a fresh sha rather than reported as a failure, which would strand a mergeable PR. A refusal waiting cannot fix (method disabled, no permission) blocks immediately instead of asking the same question for 30 minutes.

Forge work is injected as LoopLandingOps so loops/ never imports server/, and so all of this is tested with no gh, no remote and no network.
Auto-merge now needs CEZ_LOOP_AUTO_MERGE=1 as well as CEZ_LOOPS=1 — two locks, on the CEZ_DISABLE_REPO_LOCK pattern (exact value 1, off by default, documented as a dangerous escape hatch). Per-loop opt-in alone put a reversal of the never-auto-merges invariant one dropdown away from any cockpit user; requiring an operator to set an env var named as dangerous keeps it a decision somebody made on purpose.

Enforced at two points, not one. The loops routes answer 409 for landing: 'merge' without the flag, and the gate is re-read at MERGE TIME rather than captured once — so a loops.json written while the flag was on stops merging the moment an operator turns it off, leaving its PR open. The refusal is not marked stale, so it blocks the item immediately instead of re-asking the same disabled question until the deadline. Absent means off everywhere: fail-closed for a capability that writes to the default branch.

The composer omits the merge option entirely when the flag is unset, naming the flag instead — an option whose every submit 409s is worse than no option.

Also surfaces the landing policy on the loop detail header, which previously showed only progress, so what a running loop will do with finished work is visible without reopening the composer.

Documented in README (both copies), .env.example and BACKWARD_COMPATIBILITY sections 1 and 2.
…lete

Replaces the items textarea with the Workflows builder's own idiom: a count, then Auto / Import / Export, over numbered rows you can drag to reorder, click to edit, and remove. A loop's items and a workflow's step chain are the same kind of object to a user — an ordered list they assemble before approving it — so reusing that vocabulary means loop mode teaches nothing new. Native HTML5 drag, no library, exactly as plan-review does it.

Order is load-bearing rather than cosmetic here: under landing 'merge' item N+1 starts from a base containing N, which is why dragging is offered at all instead of leaving people to cut and paste lines. moveItem/removeItem/editItem are a tested module with the same defensive shape as moveStep — every op returns a new array and an out-of-range index is a no-op, so a drag released over nothing cannot corrupt the list.

Auto APPENDS rather than replaces, so running it twice, or after hand-writing items, never silently discards work. Import/Export use one-item-per-line text because that IS the loop's item format — a wrapper file would make a list of prompts harder to paste than it already is — and an exported file is therefore one you can hand-edit and paste straight back.

Extracts ONE LoopItemsEditor now used by both /loops/new and the composer panel; they had diverged into a textarea and a list, which is how two surfaces for one concept start disagreeing about what an item is.

Also fixes the reported defect: clicking Loop with an empty composer returned silently, which reads as a broken button rather than a precondition. It now says what to do, and the radio renders as selected while its panel is open — previously aria-checked was hardcoded false, so a mode the user HAD entered still looked unresponsive.
Each item can now name its own skill or workflow, picked on the row the way the composer picks one for a task. A loop still has ONE shared template and that stays the default — this is the per-item OVERRIDE, because a backlog is rarely homogeneous: one issue wants om-auto-fix-issue, the next is a spec wanting a different workflow, and forcing both through one template means splitting the loop in two. Absent means "use the loop's template", so every existing loop behaves exactly as before, and "Loop default" stays a reachable option rather than a one-way door.

A skill item runs as the same one-step inline chain the composer and the inbox already use (spec 008), so this needed no new launch mechanism.

Three defects found on the way, all reported from real use:

1. The planner received the composer text VERBATIM, including a leading /om-auto-fix-issue — so it was asked to split a command name into work items, which is why "fix all open issues" came back undraftable. splitBriefSkill now separates the skill from the description and carries the skill onto the task template, where it belongs.

2. Running Auto twice duplicated work. Client-side de-duplication could not have fixed it: two drafts of one issue are worded differently (open-mercato#24 appeared as two different sentences), so only something reading the text can tell they are the same. Already-listed items now travel to the planner as context, with an explicit instruction to judge by the work rather than the wording — and "nothing new to add" is reported as the legitimate answer it is, not as a drafting failure.

3. LoopReview hardcoded { autonomous } and silently dropped the skill, runner, model and worktree the composer was showing. The composer's real settings now apply to every item.

Two contract-hygiene fixes fell out: the loops contract had hand-rolled a runner enum that was missing  (it now reuses the canonical runnerSchema, with storage a tolerant superset for the legacy claude-cli id), and presentLoop spread storage objects whose .passthrough() index signature made the route wider than the contract asserted against it. It is now built key by key. The PUT route also stopped minting item-<i>-<Date.now()> ids; the store owns ids.
The previous version was the wrong shape twice over: a <select> of every skill on every row, sitting on "Loop default" next to a drafted prompt that plainly said "Run /om-auto-fix-issue on issue open-mercato#165". The skill was already in the text; the control just asked again, and every row read as an unanswered question.

Now: type / in an item and get skill suggestions, reusing the composer's own detectTrigger/applyCompletion caret math (open-mercato#380) rather than a second implementation — so "does a slash open the menu" has one answer in this app, and a path like src/api/client.ts still stays inert. On commit the leading /skill is LIFTED OUT of the prompt onto the item, so what you typed is the selection. A chip shows it, with x to clear, and nothing renders when the item just uses the loop's template.

A skill named in the BRIEF now applies to every item it produced. "fix all open issues using /om-auto-fix-issue" previously sent the slash to the planner (asking a model to split a command name into work) and then dropped the choice entirely.

Export writes the skill back as its /skill prefix so Export -> hand-edit -> Import is a real round trip. A per-item workflow has no /-spelling and therefore does not survive the text format; the line still exports, which the code states rather than leaving to discovery.
…p defaulting to quick-task

Three problems, all reported from real use.

1. A task started by a loop said nothing about it. The run has carried loop provenance since Phase 0, but the wire contract never did and nothing rendered it, so six sibling tasks running one skill against different issues were indistinguishable. The header now shows "Loop - item N", linking to the loop. Follows the automation chip's rule exactly: provenance is HISTORY and is always shown, only the link is gated, so a task's origin does not change because CEZ_LOOPS flipped.

2. /loops/new set no task template at all, so every item ran quick-task however plainly its own prompt named a skill — the header said quick-task while the prompt said om-auto-fix-issue. It now has a loop-level "Every item runs" picker, with the per-item source overriding it. A select is right there and wrong on a row: one deliberate choice for the whole loop, not a question repeated beside every prompt.

3. Cancelling an item advanced the loop.  is terminal, so the barrier read a human hitting Cancel as "this one is finished, start the next" and launched item N+1 seconds later. That is the opposite of the intent, and the one wrong guess here that spends money. Cancel now PAUSES with a reason naming the item; resume continues from there. skip-current stays the deliberate way to move on, and still never cancels the run — cancel means stop, skip means move on.

My own chip test caught a real trap: it waited on the chip, which renders as an unlinked span before health answers, so it asserted nothing. It now awaits the link.
A loop-level "every item runs X" was the wrong unit of decision. A backlog is not homogeneous — one issue wants om-auto-fix-issue on a cheap model, the next is a spec wanting a different workflow and a bigger one — so setting one skill for twenty unrelated items is not a choice anyone wants to make.

Each item now carries the composer's own controls: skill/workflow, runner, model, worktree, autonomous. Every pill is an OVERRIDE, so "Loop default" is a real reachable option and an untouched row inherits from the loop's template rather than pinning a value. The worktree and autonomous pills are tri-state selects rather than checkboxes, because a checkbox cannot express "inherit" and an item that silently pinned false would quietly opt out of the loop's own choice. Runner and model come from the composer's RUNNERS and useRunnerModels, so an item cannot offer a combination the composer would refuse.

The launch path merges item over template field by field with ?? rather than ||, since an explicit false is a real choice here.

AI generation now fills those settings instead of leaving them for someone to parse: the planner receives the SKILL CATALOGUE and returns {prompt, skill?} per item, so each row's pill arrives already set. A skill the catalogue does not contain is DROPPED rather than stored — a hallucinated name would fail at launch minutes later with a receipt blaming the workflow loader, and falling back to the loop's default is the behaviour the item would have had anyway. A bare string is still accepted because that is what a model shortcuts to, and rejecting it would burn a whole retry over formatting.
…lt, label every pill

Three fixes from one screenshot.

1. Items rendered as the literal string "[object Object]". The planner returns {prompt, skill?} objects now, and the composer's Loop panel still joined them into text and re-split it — which both produced that string AND discarded the skill the planner had just chosen. Both paths now share one draftItemsFromPlan helper, so the composer panel and /loops/new cannot disagree about what a drafted item is, with a regression test asserting no row ever contains "[object".

2. Removed the loop-level "Every item runs" picker. Each item carries its own skill — set by the planner or on the row — so a single default for a whole backlog was both the wrong unit of decision and a second place to get the same answer wrong. It was added two commits ago to fix everything running quick-task; the per-item mechanism is the real fix, and this is now redundant.

3. Five selects all reading "Loop default" was unreadable. A closed <select> shows only its chosen option, so the option TEXT is the only label a mouse user ever sees — each pill now names what it controls (skill:, agent:, model:, worktree:, autonomy:) in its options as well as its aria-label.
Four look-alike selects all reading "Loop default" left no way to tell which was which. A closed <select> shows only its chosen option, so the option TEXT is the only label a mouse user ever sees — each now reads skill:/agent:/model:/worktree:/autonomy: in both its options and its aria-label, and carries its own icon so the controls are distinguishable at a glance rather than only on reading.

Guarded by a test that asserts no pill renders a bare "Loop default" and that all five have distinct accessible names, so this cannot regress into look-alikes again.
Six sibling tasks one loop launched, one by one, otherwise flood the quick-list as
six unrelated rows. `groupRuns` now collapses runs sharing `run.loop.loopId` into
one tile too, ordered by item index rather than recency, with a link to the loop
instead of Compare (a loop's items are independent work, not interchangeable
attempts at one task, so there is nothing to compare).

The tile's title needs the loop's own name, which a run's `loop` provenance did
not carry. Denormalized `loopName` onto it at launch, the same reason
`automation.event` is a plain string rather than a lookup — additive and optional,
so an older run simply falls back to a generic label.
Adds a checkbox per row, a select-all (scoped to the current search/label filter,
never rows a filter is hiding), and a "Fix in loop" button that appears once
anything is checked and loops are enabled. It navigates to /loops/new with one
drafted item per selected issue/PR, each carrying its own skill — om-auto-fix-issue
for issues, om-pr-autopilot for PRs, per item rather than one skill for a mixed
batch. Selection clears on switching Issues/PRs, since the two tabs are two
different batches of work.

/loops/new now also reads its seed from router state, not just `?items=`: a
hand-off item's prompt is githubTaskRef verbatim (may embed a blank line and the
item's URL) and carries its own per-item skill, neither of which the
newline-per-item query-string format can hold.
Reproduced live: a loop stuck forever on item 1 with `awaitedRunId` pointing at a
run that had already reached `done` — 40 minutes earlier, per its own
`finishedAt`. `resumeFromDisk()` re-registers the await after a restart but never
checked whether the run it was re-awaiting had already reached a terminal state
WHILE THE PROCESS WAS DOWN. If so, there is no later mutation left to emit a
`run` event — nothing was ever going to wake the loop up again.

`advance()` is safe to call unconditionally at boot: it is the same idempotent
classification the event path already runs, so a genuinely still-running item
just costs one harmless `pending` check. Verified red-without-fix.
A launched item's own handoff.md (spec 007) now gets a "Loop context" section: the
loop's name, this item's position, and each sibling's outcome — done/skipped/
merged/etc for earlier items, "in progress (this task)" for this one, "pending"
for the rest. Loops advance strictly one item at a time behind a completion
barrier, so this is a one-time snapshot at THIS item's launch, not a live view —
there is never a second item genuinely in flight to watch change.

Computed in the controller (which already holds the loop and its receipts at
launch time) and denormalized onto the run's `loop` provenance as
`progressSnapshot`, storage-only — the wire contract doesn't need it, since the
same text is what `GET /runs/:id/handoff` already answers once seeded.
…eady has

The standalone New-loop page never offered a landing choice at all — it always
submitted with no `landing`, which the server defaults to `none` givin every
loop started from here a bare branch per item with no way to ask for a PR or a
merge. The composer's inline Loop panel (`loop-review.tsx`) already has this
control; this is the same three options, the same copy, and the same
CEZ_LOOP_AUTO_MERGE-gated `merge` choice, just on the other surface that creates
a loop.
…g it

Root cause of open-mercato#164 finishing "done" with nothing implemented: the idle timer
(armIdleTimer, 15 minutes) closed a session that was genuinely waiting on an
unanswered CEZ:ASK and reported that as an ordinary end_turn, which execute()
then finished the step 'done' — a task that asked something real and got no
reply was recorded as having succeeded.

RunManager now tracks openAsk (in-memory and persisted on the run) from the
moment an ask is raised — CEZ:ASK marker, native ask.requested, either
turn-end handler — cleared only by an actual reply (deliverMessage), a fresh
continuation, or an explicit Finish, never by the session just closing on its
own. A session that closes with openAsk still set ends 'failed', naming the
unanswered question, instead of 'done'.

The loop barrier treats an openAsk run as a new needs-input verdict, checked
before every status branch (it wins even once idle-timeout has settled the
run) — the controller pauses the loop for it without resolving the receipt or
forgetting the awaited run, since answering the question in its own task
thread lets that same run finish normally and settle the ordinary way. And
per the user's call: a loop item's genuine ask now blocks even under
autonomous mode — the mid-continuation nudge no longer overrides it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement: task loops — drain a list of work items as a sequence of separate sessions

1 participant