Skip to content

Make /loop relentless by default instead of stopping on completion #77

Closed

Description

@androidand
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 priority field (already referenced elsewhere in the
    repo, e.g. skein_set_priority) where present on a change, falling back to alphabetical.
  • IterationInfo.toolCalls counts 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, --eternal flag, priority-aware
    queue ordering, whole-turn tool-call counting.

Non-Goals

  • Not merging /loop and /backlog into 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 on fleet-instance-presence, but a more accurate peer/fleet
    roster (see fleet-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.ts and
    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) against openspec/changes/ before finalizing as completed.
    • File: packages/opencode/src/loop/loop.ts (runPromptThenMaybeQueue)
    • Validation: bun test test/loop/queue-mode.test.ts
  • 1.2 If an eligible change is found, transition the loop record into queue-style
    continuation (mode: "queue", seeded QueueState, then hand off to runQueue) instead of
    finalizing; log the transition and the next change. Also: apply the SAME QueueDenyRules
    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
    conflict QueueActiveError prevents 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"
  • 1.3 If no eligible change is found, finalize as completed exactly as today.
    • File: packages/opencode/src/loop/loop.ts
    • Validation: covered by existing loop.test.ts completion tests (no fixture backlog present)

Slice 2: Bounded stall reprieve

  • 2.1 On hitting noProgressLimit, send one harder-line directive iteration before
    finalizing as stalled, reusing runIteration's override parameter 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)"
  • 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 noProgressLimit iterations, not unbounded

Slice 3: --eternal opt-out

  • 3.1 Add eternal?: boolean to CreateInput (optional, default resolved to true in
    prompt mode) and eternal: boolean to Info (always resolved, false in queue mode since
    queue mode is already relentless by construction); CLI flag --eternal/--no-eternal and
    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)
  • 3.2 Thread eternal: false through 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 pins eternal: false),
      test/loop/queue-mode.test.ts — "eternal: false stops a completed prompt-mode loop even with
      backlog work pending"
  • 3.3 SDK: packages/sdk/js/src/v2/loop-args.ts gains eternal (--no-eternal flag,
    defaults true); regenerated packages/sdk/js/src/v2/gen/{sdk,types}.gen.ts via
    bun packages/sdk/js/script/build.ts (scoped regen — avoids script/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)

Slice 4: Priority-aware queue ordering

  • 4.1 Already implemented — resolveQueue/compareOrder in
    packages/opencode/src/loop/spec-queue/queue.ts already sort eligible changes by an
    explicit priority key read from each change's .openspec.yaml (default 100), then
    created date, 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)

Slice 5: Whole-turn tool-call counting

  • 5.1 Already implemented — runIteration in packages/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 message promptSvc.prompt returns, 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

Slice 6: Verification

  • 6.1 bun run typecheck (opencode, sdk, tui) and full bun test test/loop/ green — 153
    pass, 0 fail.
  • 6.2 Live check: run plain /loop on a prompt that completes quickly while
    openspec/changes/ has other eligible work; confirm it transitions to queue mode and keeps
    going, and that --eternal=false stops 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

  1. androidand commented on Sep 17, 2026

    @androidand
    OwnerAuthor

    Archived 2026-09-18 — see the change's Disposition section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions