cyberine
Design notes

Loop Synchronous Dispatch + Verify Seam — Design Note

Status: shipped — T459 (beam-search forks) and T463 (non-code verify gate) are wired into the loop's syncDispatchTask path, see src/engine/autonomyLoop.ts:728-739,823,866.

Source cross-checked (every symbol verified against live source): src/engine/autonomyLoop.ts (run() ~L461-823; forkBeamSearchCandidates ~L389; verifyNonCodeResult ~L422), src/launchBg.ts (launchBackgroundRun L82, returns BgLaunch — detached/fire-and-forget), src/orchestrator/Orchestrator.ts (dispatch L32 returns Promise<Result[]> in-process; judge L?), src/orchestrator/buildAdapters.ts (buildDefaultAdapters L260, availableBuildWorkerIds L762), src/engine/beamSearch.ts (runBeamSearchFork L197, resolveBeamWidth L92, BEAM_WIDTH_CAP=5 L44, provisionWorktree/captureDiff/removeWorktree, DiffJudge.decide), src/engine/nonCodeVerifyGate.ts (runNonCodeVerifyGate L203, isNonCodeTask L94, GateVerdict L130), src/engine/harnessStatusDecider.ts (StatusSignals.diffGatePassed/nonCodeGatePassed, decideLoopStatus), src/engine/WorkflowEngine.ts (DiffJudge L466, isolate -> step.winnerOutput diff), src/engine/implementTaskWorkflow.ts (the standing design->implement fleet def), src/judges/DiffJudge.ts, src/tasks/manifestReader.ts (ManifestTask L7 — has NO fork/candidates field), docs/design/autonomy-loop.md (§2 STATUS, §5 beam-search, §8 verify gate).


1. The problem

AutonomyLoop.run() dispatches every task with launchBackgroundRun(prompt, cwd) (src/engine/autonomyLoop.ts ~L793, ~L607). That call spawns a detached npx tsx child, returns a BgLaunch ({runId, logPath, pid}) immediately, and the loop moves on. The loop never sees the task's Result, never sees its diff, and never blocks on completion. This is correct for fire-and-forget throughput (the operator pays ~0; runs land in .local/runs/<id>/ and are harvested later), but it has a structural consequence:

Two already-built features are dead code because both operate on a dispatched task's OUTPUT, which the async path never captures:

  • T459 beam-search (src/engine/beamSearch.ts + AutonomyLoop.forkBeamSearchCandidates): runs N candidate approaches in worktrees, applies + typechecks each diff via DiffJudge, keeps the winner, removes losers. It needs to RUN candidates in-process and judge their diffs. The module is complete and unit-correct; it has zero call sites in run().
  • T463 non-code gate (src/engine/nonCodeVerifyGate.ts + AutonomyLoop.verifyNonCodeResult): runs depth-check -> Adversarial/Llm judge -> intent-alignment over a task's RESULT text. It needs the dispatched task's Result. Complete and unit-correct; zero call sites in run().

Update 2026-09-22: both are now called from syncDispatchTask, which run() invokes when syncVerify is on — forkBeamSearchCandidates at src/engine/autonomyLoop.ts:866, verifyNonCodeResult at src/engine/autonomyLoop.ts:823. Neither is dead code any more.

Independently, the harness STATUS decider (src/engine/harnessStatusDecider.ts) exposes diffGatePassed and nonCodeGatePassed signals that NOTHING populates today — because the async loop has no result to gate on. docs/design/autonomy-loop.md §2/§8 lock done = the verify gate passes, but the loop currently flips no task to done via a gate at all; it dispatches and forgets.

Update 2026-09-22: syncDispatchTask now populates both signals — diffGatePassed from beamResult.winner !== null (src/engine/autonomyLoop.ts:867) and nonCodeGatePassed from verifyNonCodeResult(...).passed (:832) — and feeds them into decideLoopStatus (src/engine/harnessStatusDecider.ts:242), so the sync-verify path does drive done through a gate.

The fix is not "add a call site." It is a missing execution mode: the loop needs a path that runs a task synchronously, in-process, capturing its Result + diff, so the gates and beam-search have something to operate on. That path is the subject of this note.


2. Two dispatch modes (locked)

The loop keeps BOTH dispatch modes and chooses per-run:

  • Async-background (default, unchanged). launchBackgroundRun — detached child, fire-and-forget, operator pays ~0. This stays the default for cyberine loop so existing behavior and throughput are untouched. In this mode no gate runs; status is more/blocked/stuck from frontier + defer + circuit-breaker signals exactly as today.
  • Synchronous-verify (new, opt-in). The loop runs the task in-process, awaits its Result (and diff for code tasks), feeds it through the verify gate (DiffJudge for code, runNonCodeVerifyGate for non-code), and only then sets done via decideLoopStatus({ diffGatePassed | nonCodeGatePassed, ... }). Beam-search forks are available only in this mode (they are inherently synchronous).

Opt-in surface (additive, backward compatible):

  • CLI flag cyberine loop --verify enables synchronous-verify mode.
  • Env CYBERINE_LOOP_SYNC_VERIFY=1 is the equivalent for unattended/cron callers.
  • When neither is set, run() behaves byte-for-byte as today (async-bg, no gate). Every new branch in run() MUST be guarded on this mode flag.

Rationale: synchronous verify costs real wall-clock and tokens per task (it runs the work in-process and judges it), so it cannot be the default for a fire-and-forget fleet driver. But it is mandatory for the autonomy STATUS contract and for beam-search. Making it an explicit mode preserves both use-cases without a flag-day migration.


3. The synchronous execution path (locked)

The loop already has two in-process primitives to build on; do NOT invent a third runner:

  • Orchestrator.dispatch(task, signal, onProgress) -> Promise<Result[]> (src/orchestrator/Orchestrator.ts:32) runs all agents in-process and returns their Results. Agents come from buildDefaultAdapters() (src/orchestrator/buildAdapters.ts:260), the same pool the one-shot cli.ts path uses. This is the result-capturing path for NON-CODE tasks (the gate needs Result.output).
  • WorkflowEngine isolate-step path (src/engine/WorkflowEngine.ts, DiffJudge at L466) runs a step in a detached worktree, captures its diff into step.winnerOutput, and gates with DiffJudge. implementTaskWorkflow.ts is the standing design->implement fleet def that already does exactly this. This is the result-capturing path for CODE tasks (the gate needs a diff to git apply + tsc).

Synchronous-verify dispatch in run() therefore branches by task type:

  1. Classify the task: isNonCodeTask(task) (src/engine/nonCodeVerifyGate.ts:94) decides the branch.
  2. Code task -> run via the isolate+capture path (reuse implementTaskWorkflow / WorkflowEngine isolate step, or a thin wrapper that provisions one worktree, runs the worker, captureDiff), producing a diff. Run DiffJudge.decide(task, [resultWithDiff]); set diffGatePassed = verdict.winner !== null (i.e. the diff applied + typechecked).
  3. Non-code task -> run via Orchestrator.dispatch(task); take the winning Result; call this.verifyNonCodeResult({ manifestTask, result, goal }); set nonCodeGatePassed = verdict.passed.
  4. Status -> feed the populated signal into the existing decideLoopStatus(...) so the harness (not model prose) owns done. On gate-fail the task is NOT flipped to done; it stays runnable (retry next iteration) or, after the circuit-breaker threshold, stuck.

This reuses the verified DiffJudge gate that WorkflowEngine already runs; it does not add a parallel gate.


4. Closing T463 (non-code gate) — the smaller half

T463 is wired by step 3 above and needs no new module — runNonCodeVerifyGate and verifyNonCodeResult already exist. The only new code is, inside the synchronous-verify branch of run():

  • after Orchestrator.dispatch returns, pick the winning Result (the loop can reuse Orchestrator.judge with a default judge, or take the first non-error result for a single-agent dispatch),
  • if (isNonCodeTask(task)) { const v = await this.verifyNonCodeResult({ manifestTask: task, result, goal: this.goalText }); signals.nonCodeGatePassed = v.passed; },
  • thread signals into decideLoopStatus.

The GOAL string passed as goal MUST be the verbatim GOAL (autonomy-loop.md §9: never summarized) so the intent-alignment assertion is judged against the real goal. this.goalText already exists in run() (the brain.boot goal).


5. Closing T459 (beam-search) — needs a fork trigger

forkBeamSearchCandidates(candidates, taskPrompt, repoRoot, agents?) is ready, but it requires two inputs the loop cannot currently produce:

  • candidates: BeamCandidate[] — the competing approaches at a fork. The manifest has no way to express "this task is a correctness fork with these N approaches" (ManifestTask at src/tasks/manifestReader.ts:7 has id/status/title/deps/exec/model/blocker/do/area/line — no fork field).
  • agents: Agent[] — the exploration pool, built from buildDefaultAdapters(), narrowed to a cheap tier via routeTask (beam-search already imports routeTask; pickExplorationAgent selects the cheap one).

This note locks the fork trigger as a new optional manifest field, parsed by manifestReader:

## T999 [todo] <title>
fork:
  - <approach A descriptor>
  - <approach B descriptor>
  • fork: is an optional multi-line list (>=2 entries). Its presence marks the task as a bucket-1 correctness/quality fork (autonomy-loop.md §4). Each entry becomes a BeamCandidate (the descriptor is injected into that candidate's worker prompt as the approach to take).
  • A task WITHOUT fork: dispatches normally (single candidate) — full backward compatibility; no existing task changes shape.
  • In synchronous-verify mode, when task.fork is present run() routes through forkBeamSearchCandidates(candidates, prompt, repoRoot, agents) instead of single dispatch. The winner's diff is the task's result for the DiffJudge gate (step 2). Per §5 of autonomy-loop.md, losers are auto-removed immediately (runBeamSearchFork already removes losers; the CALLER must removeWorktree the winner after integrating — run() integrates the winning diff then removes its worktree).
  • Beam width is bounded by resolveBeamWidth() (env CYBERINE_BEAM_WIDTH, hard cap BEAM_WIDTH_CAP=5); sequential forks stay bounded because each fork judges-then-keeps-winner (no N^k tree held).

Fork detection is manifest-explicit by design, NOT a heuristic: a heuristic that guesses "this is a fork" would silently triple the cost of ordinary tasks. The author opts a task into beam-search by writing fork:. (A future heuristic auto-fork can layer on top, gated separately; out of scope here.)


6. Backward compatibility (locked)

  • Default cyberine loop (no --verify, no CYBERINE_LOOP_SYNC_VERIFY) is unchanged: async-bg dispatch, no gate, no beam-search. Every new branch guards on the mode flag.
  • ManifestTask.fork is optional; absent on every current task. manifestReader adds one optional field; runnable/statusWriter are untouched.
  • The new capsule/status fields reuse StatusSignals (already defined). No new judge, no new runner — DiffJudge, Orchestrator, WorkflowEngine, runBeamSearchFork, runNonCodeVerifyGate are all reused.
  • No barrel/index files; match the [autonomy-loop] stdout narration style.

7. Verification plan

npm run typecheck (tsc --noEmit) is the standing gate and must stay clean — verifiable offline now.

Behavior that needs a live gateway + provider keys (the T404 blocker — gateway not up in a headless bg session):

  • A code task with fork: spawns N worktrees, DiffJudge picks a winner, losers are removed, winner diff applies + typechecks, task flips to done via diffGatePassed.
  • A non-code task runs Orchestrator.dispatch, the result passes (or fails) runNonCodeVerifyGate, and nonCodeGatePassed drives the status — a thin result is rejected, not accepted.
  • --verify off: identical behavior to today (regression check via cyberine loop --dry-run diff of dispatched ids).

Offline-checkable without a gateway: typecheck; cyberine loop --verify --dry-run prints the per-task mode decision (sync vs async, fork vs single) without launching workers; a selftest: smoke that constructs an AutonomyLoop with a stub Agent returning a fixed diff/result and asserts the gate signal + status (no network).


8. Task mapping + sequencing

  1. New task T467 (or fold into T459) — fork-trigger manifest field. manifestReader parses optional fork: list into ManifestTask.fork?: string[]; document the syntax in USAGE.md. Prereq for T459 wiring. exec: agent, model: sonnet.
  2. T459 re-do — wire beam-search. Add the synchronous-verify mode plumbing (§2) + route fork: tasks through forkBeamSearchCandidates, integrate winner diff, gate via DiffJudge -> diffGatePassed. Depends on T457 (done) + the fork-field task. model: sonnet.
  3. T463 re-do — wire non-code gate. In synchronous-verify mode, capture the dispatched Result and route non-code tasks through verifyNonCodeResult -> nonCodeGatePassed -> decideLoopStatus. Depends on T457 (done) + shares the synchronous-verify mode plumbing with T459 (do T459's mode scaffold first, or build the mode scaffold as a shared step both consume). model: opus.
  4. T466 eval (already blocked on T459) becomes runnable once T459's beam-search is live.

Because T459 and T463 share the synchronous-verify mode scaffold (§2/§3), build the scaffold ONCE (as part of T459) and have T463 consume it — do NOT fan them out as two independent worktrees that each re-add the mode branch (guaranteed merge conflict on run()). Sequence: fork-field -> T459 (scaffold + beam) -> T463 (non-code gate on the scaffold) -> T466 eval.


9. Cross-check statement

Every symbol cited was verified against live source on 2026-06-27. Confirmed present: launchBackgroundRun (src/launchBg.ts:82, returns detached BgLaunch); Orchestrator.dispatch (src/orchestrator/Orchestrator.ts:32, returns in-process Result[]); buildDefaultAdapters (src/orchestrator/buildAdapters.ts:260); runBeamSearchFork/resolveBeamWidth/BEAM_WIDTH_CAP=5 (src/engine/beamSearch.ts:197/92/44); runNonCodeVerifyGate/isNonCodeTask/GateVerdict (src/engine/nonCodeVerifyGate.ts:203/94/130); AutonomyLoop.forkBeamSearchCandidates/verifyNonCodeResult (src/engine/autonomyLoop.ts:389/422) — both confirmed defined with zero call sites in run(); StatusSignals.diffGatePassed/nonCodeGatePassed + decideLoopStatus (src/engine/harnessStatusDecider.ts); DiffJudge used by WorkflowEngine (src/engine/WorkflowEngine.ts:466) and implementTaskWorkflow.ts (the standing isolate->winnerOutput gate pattern). Confirmed ABSENT: any fork/candidates field on ManifestTask (src/tasks/manifestReader.ts:7) and any synchronous dispatch in run() (only launchBackgroundRun at ~L607/793). The locked decisions in docs/design/autonomy-loop.md (§2 status, §5 beam-search bound, §8 per-task-type gate, §9 verbatim GOAL) are implemented by this seam without deviation.

Update 2026-09-22: §5/§8 of this note (the fork: manifest field and sync-verify mode) are now implemented (T474/T459/T463). ManifestTask.fork?: string[] exists (src/tasks/manifestReader.ts:42) and run() has the synchronous syncDispatchTask path (src/engine/autonomyLoop.ts:728), guarded by syncVerify.