Repository navigation
Make /loop relentless by default instead of stopping on completion #77
Description
Proposal
Why
/backlog (/loop --queue) already solved "never idle while planned work exists": disk-derived
done, quarantine-don't-halt, systemic-failure guard — all shipped in loop-spec-queue. Plain
/loop <prompt> never got the same treatment, and it is the one people actually reach for first.
The reported symptom: "the CURRENT change may be done, but there is still SHITLOADS of work to be
done" — a plain-mode loop hits COMPLETE_SIGNAL (correctly — loop-completion-contract works
exactly as specced) and stops, because a single user prompt has no notion of what else is planned.
That is not a bug in completion detection; it is the absence of a "what's next" step after
completion. The same gap applies to stalled / max_reached exits — a plain loop that stops for
any reason today just... stops, with no attempt to figure out whether stopping was actually right.
Two other loop-spec-queue follow-ups, already on record but never actioned, compound this:
queue ordering is alphabetical by slug rather than priority-aware, and IterationInfo.toolCalls
undercounts multi-step turns (only the last assistant message is inspected), which can trip the
prompt-mode no-progress guard on a turn that was actually productive — a second, independent way
a loop can stop when there was still real progress being made.
What Changes
1. On completion, plain /loop asks "is there more?" before stopping
When a plain-mode iteration emits the completion token, the loop does not exit immediately.
It runs one disk-derived check — the same eligible-change resolution loop-spec-queue already
has (openspec/changes/, excluding archive/, _repo/, and anything holding
.skein/blocker.md) — for unchecked-task changes in the current repo. If one exists, the loop
does not stop: it transitions into queue-style continuation, briefed on the next eligible change,
and reports that transition to the user rather than silently mutating what they asked for. If none
exists, it stops exactly as today.
This is the specific, minimal shape of "eternal by default": completion no longer means "the
process ends", it means "check whether the reason for running is actually gone."
2. stalled / max_reached gets a bounded retry with a directive prompt, not an immediate halt
Reuse the adaptive-continuation classification from fix-loop-reliability — stall and spin are
already detected and already produce directive prompts. Currently that directive is used within
the no-progress-streak counter, which still terminates the loop once the streak limit is reached.
Add one bounded extra attempt after the streak limit, using the harder-line stall reason
("You appear stuck — reassess and either make concrete progress or explain what is blocking you so this loop can stop honestly") before finally halting. This is a one-time reprieve, not an
unbounded retry loop — it must not reintroduce the failure mode fix-loop-stall calibrated away.
3. --eternal=false / a plain "stop" opt-out
Because this changes default behavior for existing /loop users, a flag (--eternal=false on
the CLI, an equivalent TUI flag) restores today's single-prompt-then-stop behavior. Default is on.
4. Fix the two documented loop-spec-queue follow-ups
- Priority-aware queue ordering: read a
priorityfield (already referenced elsewhere in the
repo, e.g.skein_set_priority) where present on a change, falling back to alphabetical. IterationInfo.toolCallscounts tool calls across the whole turn, not just the last assistant
message, so a multi-step turn is never miscounted as a stall.
Open Design Question — resolved during implementation
How far should plain /loop and /backlog converge? Implemented as: two entry points stay —
/loop is "work this prompt, then check for more"; /backlog is "work the whole backlog from
the start" — and the transition is automatic by default (eternal: true), not gated behind an
explicit nudge, per the original request's explicit framing ("this should be the default").
--no-eternal is the escape hatch for anyone who wants the old stop-on-completion behavior.
Worth a second look in review regardless: an unattended plain loop that silently becomes a
queue run is a bigger behavior change than a bug fix, even though it defaults on.
Capabilities
Modified Capabilities
loop-service: post-completion and post-stall continuation logic,--eternalflag, priority-aware
queue ordering, whole-turn tool-call counting.
Non-Goals
- Not merging
/loopand/backloginto a single command (see Open Design Question). - Not touching the authority ceiling (
QueueDenyRules) — an eternal plain loop gets no more
push/deploy authority than a queue loop has today. - Not addressing the removed stream-inactivity watchdog (
a90c49880b) — separate concern, noted
as a risk for anyone touching unattended-run reliability next.
Dependencies
- Builds on
loop-spec-queue,loop-completion-contract,fix-loop-reliability,fix-loop-stall
(all shipped). No hard dependency onfleet-instance-presence, but a more accurate peer/fleet
roster (seefleet-instance-presence) makes the fan-out nudge this loop already emits more
useful — benefits from it, does not block on it.
Impact
- Modified:
packages/opencode/src/loop/loop.ts(completion/stall continuation,--eternal),
packages/opencode/src/loop/spec-queue/queue.ts(priority ordering),packages/opencode/src/loop/continuation.ts
(whole-turn tool-call counting),packages/opencode/src/cli/cmd/loop.tsand
packages/tui/src/component/prompt/index.tsx(flag surface + transition messaging).
Disposition (2026-09-18)
Shipped — live checks moved. Shipped; live check 6.2 moved to fleet-live-verification.
Tasks
Slice 1: Post-completion continuation
- 1.1 On plain-mode completion, run the disk-derived eligible-change resolver
(resolveQueue) againstopenspec/changes/before finalizing ascompleted.- File:
packages/opencode/src/loop/loop.ts(runPromptThenMaybeQueue) - Validation:
bun test test/loop/queue-mode.test.ts
- File:
- 1.2 If an eligible change is found, transition the loop record into queue-style
continuation (mode: "queue", seededQueueState, then hand off torunQueue) instead of
finalizing; log the transition and the next change. Also: apply the SAMEQueueDenyRules
authority ceiling a loop created directly in queue mode gets, restored on exit — an eternal
plain loop must never end up with more authority than a queue loop has today. Also guards
against transitioning into a directory that already has an active queue loop (the same
conflictQueueActiveErrorprevents at creation time).- File:
packages/opencode/src/loop/loop.ts - Validation:
bun test test/loop/queue-mode.test.ts— "a completed prompt-mode loop continues
into backlog work instead of stopping"
- File:
- 1.3 If no eligible change is found, finalize as
completedexactly as today.- File:
packages/opencode/src/loop/loop.ts - Validation: covered by existing
loop.test.tscompletion tests (no fixture backlog present)
- File:
Slice 2: Bounded stall reprieve
- 2.1 On hitting
noProgressLimit, send one harder-line directive iteration before
finalizing asstalled, reusingrunIteration'soverrideparameter rather than a new
prompt-selection path.- File:
packages/opencode/src/loop/loop.ts - Validation:
bun test test/loop/loop.test.ts— "grants one bounded reprieve before stalling
when eternal (default)"
- File:
- 2.2 Track that the reprieve has been used (
Record_.stallReprieve: "pending" | "used") so a
second stall after the reprieve halts immediately — never more than one reprieve per loop
lifetime.- File:
packages/opencode/src/loop/loop.ts - Validation: same test — asserts exactly 2x
noProgressLimititerations, not unbounded
- File:
Slice 3: --eternal opt-out
- 3.1 Add
eternal?: booleantoCreateInput(optional, default resolved totruein
prompt mode) andeternal: booleantoInfo(always resolved,falsein queue mode since
queue mode is already relentless by construction); CLI flag--eternal/--no-eternaland
TUI--no-eternal.- File:
packages/opencode/src/loop/loop.ts,packages/opencode/src/cli/cmd/loop.ts,
packages/tui/src/component/prompt/index.tsx - Validation:
bun run typecheck(opencode, tui, sdk all clean)
- File:
- 3.2 Thread
eternal: falsethrough to skip both Slice 1 and Slice 2 behavior, restoring
prior stop-on-completion/stop-on-stall behavior exactly.- File:
packages/opencode/src/loop/loop.ts - Validation:
bun test test/loop/loop.test.ts("stalls after..." now pinseternal: false),
test/loop/queue-mode.test.ts— "eternal: false stops a completed prompt-mode loop even with
backlog work pending"
- File:
- 3.3 SDK:
packages/sdk/js/src/v2/loop-args.tsgainseternal(--no-eternalflag,
defaultstrue); regeneratedpackages/sdk/js/src/v2/gen/{sdk,types}.gen.tsvia
bun packages/sdk/js/script/build.ts(scoped regen — avoidsscript/generate.ts's
whole-repo reformat side effect noted in prior session notes).- File:
packages/sdk/js/src/v2/loop-args.ts,packages/sdk/js/src/v2/gen/*.gen.ts - Validation:
bun run typecheck(sdk)
- File:
Slice 4: Priority-aware queue ordering
- 4.1 Already implemented —
resolveQueue/compareOrderin
packages/opencode/src/loop/spec-queue/queue.tsalready sort eligible changes by an
explicitprioritykey read from each change's.openspec.yaml(default 100), then
createddate, then slug. No code change needed; the "known follow-up" this slice was
tracking predates the current tree.- Evidence:
packages/opencode/src/loop/spec-queue/queue.ts:36-96(ChangeOrder,
readOrder,compareOrder)
- Evidence:
Slice 5: Whole-turn tool-call counting
- 5.1 Already implemented —
runIterationinpackages/opencode/src/loop/loop.ts(not
continuation.ts, correcting this task's original file reference) sums tool-call parts
across every assistant message created since the iteration started
(turnMessages/countedTools), not just the messagepromptSvc.promptreturns, with a
documented incident ("observed on a real run that had just deleted four files") explaining
why. No code change needed; the "known follow-up" this slice was tracking predates the
current tree.- Evidence:
packages/opencode/src/loop/loop.ts:422-442
- Evidence:
Slice 6: Verification
- 6.1
bun run typecheck(opencode, sdk, tui) and fullbun test test/loop/green — 153
pass, 0 fail. - 6.2 Live check: run plain
/loopon a prompt that completes quickly while
openspec/changes/has other eligible work; confirm it transitions to queue mode and keeps
going, and that--eternal=falsestops it at completion as before. Covered by an
integration test (queue-mode.test.ts) but not yet observed on a real fleet run.
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Plan changes
11 done
Activity
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.