SLOPSHOPPER

Factory loop

Draft, not yet shipped: the issue orchestrator as one deterministic code loop inside its Claude Code session. Every minute, or at once when an agent or the…

newguardcommandtoolprocesstimer
★ 2v0.40.0no licenseupdated 2026-10-16cbundy/dev-system/plugins/factory-loop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory-loop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /factory ⎿ factory-loop: Factory loop: no pass yet. ⎿ factory-loop: Directives: none. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

factory-loop (draft, not shipped)

The issue orchestrator as one deterministic code loop inside its Claude Code session. Every minute it reads the factory's whole state, computes what to do with a pure plan() function, and does it: claims, heartbeats, sweeps, agents, gate responses, guards, merges and the status title. The model is woken only for judgment (sequencing a flagged merge into a release, a re-drive, a release) and to talk to the owner; agents read the code.

State of this build

Both halves are built. In observe mode (the default) every pass reads the factory, plans, logs its decisions and shows its status in the session title, and executes nothing: every event its commands cause goes to a local sink. In act mode, once every safety rail passes (SPEC 11.2), it also carries the plan out: it keeps a ledger of what it started, runs each action through its executor, spawns its agents (its own copies of the callum-flow adjudicator and designer, and callum-flow's implementer and fixer), and wakes the model for judgment. Act mode has run only in tests so far; its first real run is on dev-system-testbed (PLAN stage D). The plugin is not listed in the marketplace (DECISIONS D-02).

PieceFileState
The decision tablehooks/plan.tsdone, pure
Parsers, event derivation, state assemblyhooks/parse.ts, hooks/derive.ts, hooks/state.tsdone, pure
Observe adaptershooks/observe.tsdone; running as a shadow in spike-reconcile-mod
The pass driver: the store, the scheduler, the decision log, the hookshooks/register.tsdone
The act step and the ledgerhooks/act.ts, hooks/ledger.ts, hooks/exec.tsdone
Executors: commands, guard and merge, respond, agent spawns, prompts and reply parsershooks/act-cli.ts, hooks/act-merge.ts, hooks/act-agents.ts, hooks/prompts.ts, hooks/replies.tsdone; not yet run against real agents
Wakes and the answer toolhooks/wake.tsdone
The agents' answer tools and the repair messagehooks/agent-tools.ts, agents/ (the loop's adjudicator and designer, D-70)done; no agent can reach the tools yet (SPEC 13)
The answer script and the contract it shares with the loop (a temporary workaround, D-74)bin/factory-answer, hooks/contract.mjsdone; not yet run by real agents
Safety railshooks/rails.tsdone
/factory and the control toolhooks/control.tsdone
The judge skillskills/judge/SKILL.mddrafted; not yet tried on a real wake
--once watcher modes, the loop event statefeatures/src/callum-tools/, images/base/callum-flow-event (repo root)done; ship in the next base image release
Teststests/scenarios with the plan's properties on every plan, parsers, each executor, the rails, the driver in the engine's test host, the answer contract the script shares, and one issue from claim to merge
Replay harnessscripts/factory-loop-replay.js (repo root)done; results in docs/factory-loop/REPLAY-REPORT.md

Run it in observe mode

Load the folder with claude --plugin-dir plugins/factory-loop, or name it in CLAUDE_CODE_PLUGIN_DIRS, and set ${CLAUDE_CONFIG_DIR:-~/.claude}/factory-loop/config.json:

{ "mode": "observe", "repo": "cbundy/dev-system", "runSource": "events",
  "shadowDevice": "coder-dev-system-orchestrator", "checkout": "/path/to/dev-system" }

Decisions go to ${CLAUDE_CONFIG_DIR}/factory-loop/<owner>__<repo>/decisions-YYYY-MM-DD.jsonl. Owner directives go in control.json beside them, best set with /factory (below). Create ${CLAUDE_CONFIG_DIR}/factory-loop/OFF to stop it within a minute.

Run it in act mode

Act mode replaces the released orchestrator; never run both on one repo. Load the plugin as above and set the workspace file:

{ "mode": "act", "repo": "<owner>/<repo>", "checkout": "/path/to/the/repo",
  "toolsDir": "/path/to/dev-system/features/src/callum-tools",
  "binDir": "/path/to/dev-system/images/base" }

toolsDir and binDir point at a checkout of this branch until a base image release carries the --once modes and the loop event state; without them the loop uses the image's tools. Then every pass checks, before it acts:

  • the checkout's origin is repo;
  • DEV_ROLE=orchestrator, or "allowActOutsideOrchestrator": true in the repo's .claude/factory-loop.json (for a testbed);
  • no orchestrator-watch.sh is armed for the repo, and none has been for 10 minutes;
  • no other orchestrator workspace on the repo has sent a heartbeat in the last 2 minutes, and this one has a DEV_MACHINE_NAME;
  • no other session in this workspace holds the act lock.

While any fails, the pass runs as observe mode does, the title says observe, and the decision log's rails field says why (executed says whether a pass acted). An armed old watcher or another orchestrator's heartbeat is also told to you, once each time it starts. /factory mode observe or the OFF file stops act mode at the next pass.

The /factory command

/factory <op> sets the loop's owner directives from any client, at once, even during a long wake turn; in chat, the model does the same through mcp__factory-loop__control when you ask. Each change is logged and asks for a pass at once. The ops:

  • status (or no op): the last pass and the directives in force; help lists the ops;
  • pause <N> / unpause <N>: no action at all on N, though its claim still renews;
  • skip <N> / noskip <N>: never claim N;
  • priority <N...>|off: claim these first, in order;
  • only <N...>|off: claim only these, in order (a tracker stands for its open sub-issues);
  • serial on|off: one issue in flight at a time;
  • new on|off: off starts no new claims, designs or implementations;
  • override <pct> <hours>|off: run past the usage tiers up to pct, for up to 168 hours;
  • resume <N|run id>: release a hold on an issue (a pending decision) or a run (an escalation, a merge hold, the review cap). For an escalated gate, follow the notice's explanation of what a plain resume will do. To request a change, record note <N> <change> after the escalation, then resume <run id>; see the gate rules for resume-note eligibility and routing;
  • retry <N>: let N's exhausted actions run again;
  • update [<reason>]|off: drain, then restart this workspace once nothing is in flight;
  • drop <N>: abandon N and stop its agents;
  • note [<N>] <text>: an instruction the next agent prompts carry, for N or for all;
  • mode off|observe|act: written to the workspace file, from the next pass;
  • trace on|off: trace mode (below), written to the workspace file, from the next pass.

A malformed op changes nothing and answers with its usage.

Trace mode

Every decision-log line says what triggered its pass and how long it took (trigger, ms). For more, turn trace mode on: /factory trace on, "trace": true in the workspace file, or "trace": true in the repo's .claude/factory-loop.json (the workspace file wins). Each pass then writes one record to trace-YYYY-MM-DD.jsonl beside the decision log: each phase's time, every command with its time, exit and bytes, which sources came from the cache, each action's times, the wakes' latencies, and what each issue waits on and since when. The trace files are kept under traceMaxBytes (100 MiB) in all: the oldest go first, and a day's file that alone fills the cap is cut short. SPEC 12.1 explains each field, with jq examples.

How agents answer

The intended design is that the loop's agents answer through validated tools (mcp__factory-loop__verdict, design and handoff). But on Claude Code 2.1.296 and 2.1.299 an agent the loop spawns from its timer never reaches them (SPEC 13). So, as a temporary workaround, agents record their answer with a checked script, bin/factory-answer: each prompt names the script and an answer file for that one attempt, the script checks the JSON against the same contract the tools use (hooks/contract.mjs) and names every problem, and the loop reads the file when the agent's turn ends (SPEC 7.3a, DECISIONS D-74). Text stays the fallback, and the decision log says which way each answer came (script, tool, text, repair).

answerChannel picks the channel, in the repo's .claude/factory-loop.json or in the workspace file (which wins): script (the default, the workaround), tool (the tools), or text (the text contract alone). The tools stay registered and served whatever it says. Run the timer-spawn probe (docs/factory-loop/probes/timer-spawn-probe) on each Claude Code upgrade; once its timer-spawned agent reaches the tool, set answerChannel to tool and retire the script. answerTools, the setting it replaced, is no longer read.

Pinned Claude Code version

Proven on Claude Code 2.1.296, the version the spike tested in the orchestrators' exact launch mode. The mod API is early access and may change between releases. Before running the loop on a newer Claude Code:

  1. Run npm test: claude plugin validate and claude plugin test check this plugin against the new build's engine.
  2. Type-check against the new declarations: load the plugin-authoring skill, which writes them and names their path, then run tsc with the tsconfig.json from that file's header, including hooks/, types/ and tests/.
  3. Re-run the spike's five checks in a throwaway workspace (load, timer, title through /rename, agents in bypass mode, restart), as its README describes, and the wake probe (docs/factory-loop/probes/wake-probe/), which proves the judgment wake and its answers.
  4. Run the timer-spawn probe (docs/factory-loop/probes/timer-spawn-probe/, its header says how): once an agent spawned from a timer reaches the probe's tool, the answer script can give way to the tools (above).

Develop it

npm test runs the plugin's tests through claude plugin test, and ten node tests at the repo root: tests/factory-loop-plan.test.js checks that plan.ts stays pure; factory-loop-agents.test.js checks that every agent the loop spawns lists its answer tool, and that the loop's adjudicator and designer stay the callum-flow definitions but for tools: and the one sentence that lets the adjudicator run the answer script; factory-loop-safety.test.js scans every string in hooks/ and the answer script for a forbidden command, and the script's source for any write but its one answer file; factory-loop-answer.test.js runs the answer script for real: its exit codes, every problem it names (held to the same table of cases as the loop's own reading, tests/answer-cases.mjs), its atomic write and every path it must refuse, and runs the answer block of a rendered prompt through bash as an agent copies it; factory-loop-judge-skill.test.js checks the judge skill covers every wake, TELL kind and op; and factory-loop-respond-launch.test.js, factory-loop-fast-forward.test.js, factory-loop-update-checkout.test.js, factory-loop-prune.test.js and factory-loop-loop-events.test.js run the respond launch, the fast-forward, the main checkout's update, the daily prune and every loop note for real, against scratch directories. plan.ts also imports under plain Node, which the replay harness uses.

Source 73 files
hooks/register.ts 1688 lines
1// factory-loop: the issue orchestrator as one code loop (docs/factory-loop/SPEC.md).
2//
3// Every passEveryMs, or at once when pinged, a pass reads the factory (observe.ts) and plans
4// (plan.ts). In act mode, once the safety rails pass (rails.ts: the act lock, the role, the old
5// watcher, another orchestrator's heartbeat), it runs plan's actions through their executors
6// (act.ts) and records each in the ledger (ledger.ts), which it keeps in $.store. In observe mode
7// (the default), or while a rail holds act mode back, it executes nothing and every command's
8// event writes go to a sink (SPEC 11.1). Each pass appends one decision-log line and sets the
9// session title. This is the only module that holds $: it builds the engine calls the others
10// make, and wires the hooks that feed the ledger between passes (agents' answers, recorded with
11// the answer script, in text or through their answer tools, and their repair messages; wake
12// turns) and the owner's controls
13// (/factory, the control and answer tools), and keeps the loop's own agent types (agents/) from
14// the model. The setting `planner` picks the layer whose plan acts (v1's plan.ts or v2/plan.ts),
15// and the v2 shadow plans beside v1 and is only logged (v2 SPEC 2.3); v2's records and the PR heads
16// it has seen live in the store beside the ledger (records:<repo>, heads:<repo>).
17import type { AgentSpawnArgs, EngineInterface, Register } from 'claude-code'
18
19import {
20  TOOL_LATE,
21  actPass,
22  agentAnswered,
23  agentToolAnswered,
24  breakerEventArgv,
25  capEventArgv,
26  capKey,
27  commandOf,
28  keep,
29  logLoop,
30  loggedOf,
31  passErrorTell,
32  planPass,
33  retried,
34  sendNotice,
35  transition,
36  updateDone,
37  wakeAnswered,
38  wakeEnded,
39  wakeStarted,
40} from './act'
41import type { ActEnv, ActReport, Answered, Between, Claim, Live, Repair, Shadowed, ToolAnswered } from './act'
42import { SUBAGENT_OF, stillAtWork } from './act-agents'
43import { AGENT_TOOLS, DESIGN, HANDOFF, VERDICT } from './agent-tools'
44import { DEFAULT_CONFIG, EMPTY_CONTROL, jsonObject } from './config'
45import { loopBaseOf, loopDirOf } from './contract.mjs'
46import { CONTROL_TOOL, FACTORY_COMMAND, controlTool, issuesOf, readControl, retryText, runOp, writeControl } from './control'
47import type { FileIo } from './control'
48import { LOOP_FILES_KEEP_DAYS, pruneArgvs } from './exec'
49import type { ActIo } from './exec'
50import { finish, load, onLoad, save } from './ledger'
51import type { StoreIo } from './ledger'
52import { GITHUB_BACKOFF_MS, emptyCache, observe } from './observe'
53import type { Io, Runtime, Stored } from './observe'
54import { issueFromBranch } from './plan'
55import {
56  EMPTY_RAIL_MEMORY,
57  LOCK_CAS_SCRIPT,
58  actLockPath,
59  checkRails,
60  decideLock,
61  holdActLock,
62  leaseMsOf,
63  newScheduler,
64  nextPass,
65  noticeTell,
66  parseLease,
67  passEnded,
68  pingsIgnored,
69  releaseActLock,
70  requestPass,
71  roleProblem,
72  waitOver,
73} from './rails'
74import type { BreakerNotice, LockIo, Notice, PassSource, RailEnv, RailMemory } from './rails'
75import { digestOf } from './state'
76import {
77  CUT_ROOM,
78  attemptOf,
79  begin,
80  byteLength,
81  called,
82  carve,
83  cutLine,
84  isCutLine,
85  lockTimed,
86  newTracer,
87  spent,
88  traceName,
89  traceRecord,
90  traceRoom,
91  tracedActIo,
92  tracedIo,
93  waitSince,
94  waitingRows,
95  wakeRows,
96} from './trace'
97import type { Tracer } from './trace'
98import type { Action, AgentStatus, AgentType, Config, LedgerEntry, Ms, PlanResult, SourceName, State } from './types'
99import { logOwnerOp } from './v2/act-events'
100import type { V2Stored } from './v2/observe'
101import { opLanded, parkedOf } from './v2/ops'
102import { transitionsOf } from './v2/plan'
103import { stateCounts } from './v2/rules-state'
104import { newSettingsCache, repoLoaded } from './v2/settings'
105import type { SettingsIo } from './v2/settings'
106import { keepV2, loadV2, newV2Live, noteIdle, noteKillSwitch, noteWritten, notingIo, readStoredV2, storedV2, v2IoOf } from './v2/store'
107import type { V2StoreIo } from './v2/store'
108import { STEP_AGENT } from './v2/types'
109import type { V2Io } from './v2/types'
110import { BASH_TIMEOUT_MS, processIdOf, withSlotLine } from './v2/workspace'
111import { ANSWER_TOOL, orphanedWakes } from './wake'
112
113const HOUR = 3_600_000
114const FIRST_PASS_AFTER_MS = 5_000
115const STATE_KEEP_BYTES = 3_500_000
116// recorded states per day at most (observe mode writes one per changed digest): one every five
117// minutes on average, which the replay needs, while a churning digest cannot fill the disk
118const STATES_PER_DAY = 288
119// SPEC 6.1: stop-agent 30 s, title 10 s; the strict lib has no setTimeout, so $.clock times them
120const STOP_TIMEOUT_MS = 30_000
121const RENAME_TIMEOUT_MS = 10_000
122// $.agent.spawn resolves once the agent started; one that never does must not hold the pass
123const SPAWN_TIMEOUT_MS = 60_000
124// the agent list an interim answer is checked against: a hang must not hold the hooks behind it
125const LIST_TIMEOUT_MS = 10_000
126// $.session.send resolves once the repair message is queued (#548); one that hangs is undelivered
127const REPAIR_TIMEOUT_MS = 10_000
128// $.prompt.submit resolves once its turn starts, which waits for the session to be idle: a wake
129// is taken as queued after this, so a busy session never holds a pass (a late drop still counts)
130const SUBMIT_GRACE_MS = 5_000
131// three passes in a row that throw tell the owner (SPEC 10.9)
132const ERROR_PASSES = 3
133// The engine gives each hook a budget of its own time per dispatch (HookBudget, 10 s: awaiting
134// the hook queue counts, a $ call does not), and a tool call whose hook outruns it is left with
135// "no tool.call hook answered this call" (stage D run 3, #558). The loop's tool hooks decide
136// against what is in memory and answer at once; one whose own work is not done within this
137// bound refuses, asking for text.
138const TOOL_ANSWER_MS = 2_000
139const DEFAULT_TOOLS_DIR = '/usr/local/share/callum-tools'
140
141const lastPassRef = { plugin: 'factory-loop', key: 'lastPass' } as const
142
143let started: Promise<void> | undefined
144// a session no person reaches (a `-p` run, the SDK) never runs the loop: every Claude process in
145// the workspace loads the plugin, the pipeline's own review runs included, and only the
146// interactive session may observe or act (D-61)
147let headless = false
148let loadedAt = 0
149let cwd = '.'
150let passNo = 0
151let lastTitle: string | null = null
152let lastDigest: string | null = null
153// states written today, and the day the loop dir was last pruned (YYYY-MM-DD)
154let statesToday = { day: '', count: 0 }
155let prunedDay = ''
156const cache = emptyCache()
157
158// The pass scheduler (SPEC 2): one pass at a time, 5 s apart, pings first, with the ping-storm
159// breaker (D-39); the timer re-armed when config.passEveryMs changes.
160let sched = newScheduler()
161let timer: { ms: Ms; cancel: () => void } | null = null
162// passes in a row that threw, since when, and whether the owner was told (SPEC 10.9)
163let failures = 0
164let failingSince: Ms | null = null
165let errorTold = false
166
167// The ledger (SPEC 7), loaded from $.store by the first act-mode pass or hook that needs it.
168// Every transition replaces live.ledger whole, synchronously (act.ts Live).
169// basis: the act lock as it stood when the store was loaded; a write goes back to the store only
170// while the lock names this session or still stands so (mayWrite)
171const live: Live & { loaded: boolean; repo: string; basis: string | null } = { ledger: [], loaded: false, repo: '', basis: null }
172// v2's records and the heads it has seen, kept as the ledger is (v2/store.ts), and the repo-local
173// settings as origin's default branch holds them under planner v2 (v2/settings.ts)
174const liveV2 = newV2Live()
175const settingsCache = newSettingsCache()
176// the last pass's runtime and state, for the hooks between passes
177let lastRt: Runtime | null = null
178let lastState: State | null = null
179// this session held the act lock at some pass, so session.end releases it
180let heldLock = false
181// what asked for the next pass, in the order it came: the decision log's trigger (#552)
182let asked: string[] = []
183const ASKED_MAX = 20
184// for the trace (#552): when each skip was first seen in a run of passes (trace.ts waitSince),
185// when each wake attempt's turn started, the ended wakes already reported, the last pass's start,
186// and the day and cap a trace file was cut short at
187let waits = new Map<string, Ms>()
188const delivered = new Map<string, Ms>()
189const reportedWakes = new Set<string>()
190let prevPassAt: Ms = 0
191let traceCut: string | null = null
192// the day and cap whose trace file this load has read for an earlier load's cut (writeTrace)
193let traceChecked: string | null = null
194// agents whose answer is being handled, when each was first listed with its turn ended (idle or
195// completed), and when each was sent its repair message (act.ts)
196const between: Between = { answering: new Set(), completedSeen: new Map(), repairing: new Map() }
197// the loop's own agent types with a repair message of the loop's on its way, and how many: the
198// engine resumes an agent only as a type it offers the model, so the loop offers its hidden types
199// for just that long (repairTo, #559)
200const resuming = new Map<string, number>()
201let lastWakeAt = 0
202// dev-restart-self returned (rule H4): the workspace is about to be replaced, so nothing more is
203// started in this module's life, and the next load finds the update done (act.ts updateDone)
204let restartAsked: Ms | null = null
205// the store refused the last ledger write (persist); null once one succeeds
206let saveProblem: string | null = null
207// serialized work: ledger saves, the act lock's writes, the hooks that change the ledger, and the
208// owner's ops
209let saving: Promise<void> = Promise.resolve()
210let locking: Promise<unknown> = Promise.resolve()
211let loading: Promise<boolean> | null = null
212let hooking: Promise<void> = Promise.resolve()
213let ops: Promise<unknown> = Promise.resolve()
214
215// One row of $.agent.list(), as observe and the reconciling read it.
216type AgentRow = { id: string; type: string; status: AgentStatus }
217
218// Options as the manifest's userConfig gives them; the workspace config file overrides them.
219type Options = { mode?: unknown; repo?: unknown; shadowDevice?: unknown; runSource?: unknown }
220let options: Options = {}
221
222// The workspace file's keys that are not settings: the plugin's options and the runtime's own.
223// Every other key there is a setting, over the repo's file (v2 SPEC section 15: the workspace
224// file may set any key and wins); answerTools, which answerChannel replaced (#561), is passed on
225// only to be named as replaced.
226const RUNTIME_KEYS: readonly string[] = ['mode', 'repo', 'shadowDevice', 'runSource', 'checkout', 'poolSize', 'toolsDir', 'binDir']
227
228async function text($: EngineInterface, path: string): Promise<string | null> {
229  try {
230    return (await $.fs.exists(path)) ? String(await $.fs.read(path)) : null
231  } catch {
232    return null
233  }
234}
235
236const str = (v: unknown) => (typeof v === 'string' ? v : '')
237
238// configDir: CLAUDE_CONFIG_DIR, else ~/.claude, with no doubled or trailing /: every path the loop
239// issues is built on it, and the answer script refuses a path that is not plain.
240async function configDir($: EngineInterface) {
241  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) || '~'}/.claude`
242  return dir.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1')
243}
244
245// runtime: the settings one pass runs with (SPEC 4.1): plugin options, then the workspace
246// file ${CLAUDE_CONFIG_DIR}/factory-loop/config.json, then the repo's .claude/factory-loop.json.
247// env is every command's base environment: the repo, the configured labels (callum-flow-claim
248// and callum-flow-sweep read them), binDir ahead of PATH when the workspace file names one (a
249// testbed runs the branch's own CLIs), and the event sink when the mode is not act.
250async function runtime($: EngineInterface): Promise<Runtime> {
251  const dir = await configDir($)
252  const base = loopBaseOf(dir)
253  const file = jsonObject(await text($, `${base}/config.json`))
254  const pick = (key: keyof Options) => (typeof file[key] === 'string' && file[key] !== '' ? file[key] : options[key])
255  const checkout = str(file.checkout) || cwd
256  let repo = typeof pick('repo') === 'string' && pick('repo') !== '' ? String(pick('repo')) : ''
257  if (repo === '') repo = await originOf($, checkout)
258  const requested = pick('mode')
259  const mode = requested === 'off' || requested === 'act' ? requested : 'observe'
260  const shadow = typeof pick('shadowDevice') === 'string' ? String(pick('shadowDevice')) : ''
261  const host = (await $.env.get('DEV_MACHINE_NAME')) || ''
262  // the shadow device stands in only while nothing can be executed
263  const device = shadow !== '' && mode !== 'act' ? shadow : host
264  // the repo-local settings, durations and all (SPEC 4.2), then the workspace file's over them.
265  // Under planner v2 the repo's are origin's default branch's. What either gets wrong is logged.
266  const over = Object.fromEntries(Object.entries(file).filter(([key]) => !RUNTIME_KEYS.includes(key)))
267  const loaded = await repoLoaded(settingsIo($), settingsCache, checkout, jsonObject(await text($, `${checkout}/.claude/factory-loop.json`)), over)
268  const config: Config = { ...loaded.config, mode: mode as Config['mode'] }
269  const loopDir = loopDirOf(dir, repo)
270  const sink = mode === 'act' ? null : `${loopDir}/sink-events`
271  const env: Record<string, string> = {
272    CALLUM_FLOW_REPO: repo,
273    CALLUM_FLOW_READY_LABEL: config.readyLabel,
274    CALLUM_FLOW_IN_DEV_LABEL: config.inDevLabel,
275  }
276  const binDir = str(file.binDir)
277  if (binDir !== '') env.PATH = `${binDir}:${(await $.env.get('PATH')) || '/usr/local/bin:/usr/bin:/bin'}`
278  if (sink !== null) env.CALLUM_EVENTS_DIR = sink
279  return {
280    mode,
281    repo,
282    device,
283    runSource: pick('runSource') === 'events' ? 'events' : 'local',
284    poolSize: typeof file.poolSize === 'number' ? file.poolSize : 16,
285    cwd: checkout,
286    loopDir,
287    workspace: { session: await $.session.id(), process: processIdOf(await text($, '/proc/self/stat')) },
288    sink,
289    env,
290    toolsDir: str(file.toolsDir) || DEFAULT_TOOLS_DIR,
291    // where the answer script lives (#561): the plugin's own directory, wherever it was loaded from
292    pluginRoot: $.plugin.root,
293    config,
294    v2Config: { ...loaded.v2, mode: config.mode },
295    configProblems: loaded.problems,
296    repoSettings: loaded.raw,
297  }
298}
299
300// settingsIo and v2StoreIo: what v2/settings.ts and v2/store.ts run on.
301function settingsIo($: EngineInterface): SettingsIo {
302  return { run: (argv, opts) => $.process.run(argv, opts), now: () => $.clock.now() }
303}
304
305function v2StoreIo($: EngineInterface): V2StoreIo {
306  return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }
307}
308
309// originOf: the GitHub repo (owner/name) the checkout's origin names, or '' when it names none.
310async function originOf($: EngineInterface, checkout: string, env?: Record<string, string>): Promise<string> {
311  try {
312    const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], env === undefined ? { cwd: checkout, timeoutMs: 10_000 } : { cwd: checkout, env, timeoutMs: 10_000 })
313    return r.exitCode === 0 ? (r.stdout.trim().match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1] ?? '') : ''
314  } catch {
315    return ''
316  }
317}
318
319// observing: the runtime of a pass that executes nothing: its events go to the sink (SPEC 11.1).
320function observing(rt: Runtime): Runtime {
321  const sink = `${rt.loopDir}/sink-events`
322  return { ...rt, sink, env: { ...rt.env, CALLUM_EVENTS_DIR: sink } }
323}
324
325// acting: the runtime for work the loop did start in act mode (an agent's answer, a wake's
326// turn, a notice): its events are real ones.
327function acting(rt: Runtime): Runtime {
328  const { CALLUM_EVENTS_DIR: _sink, ...env } = rt.env
329  return { ...rt, sink: null, env }
330}
331
332// within: the work, or a rejection once ms pass on $.clock.
333function within<T>($: EngineInterface, ms: Ms, work: Promise<T>, what: string): Promise<T> {
334  return new Promise<T>((resolve, reject) => {
335    const t = $.clock.after(ms, () => reject(new Error(`${what}: no answer within ${ms / 1000} s`)))
336    work.then(
337      v => {
338        t.cancel()
339        resolve(v)
340      },
341      err => {
342        t.cancel()
343        reject(err)
344      },
345    )
346  })
347}
348
349function message(err: unknown): string {
350  return err instanceof Error ? err.message : String(err)
351}
352
353// io: the engine calls observe makes, as closures over $ (the validator keeps $ in this file).
354// The agent list is read once per pass, before observe (listAgents).
355function io($: EngineInterface, agents: AgentRow[]): Io {
356  return {
357    now: () => $.clock.now(),
358    run: (argv, init) => $.process.run(argv, init),
359    readFile: path => text($, path),
360    sessionLimits: async () => (await $.session.usage()).rateLimits,
361    agents: async () => agents,
362  }
363}
364
365// actIo: the engine calls the act half makes (exec.ts ActIo).
366function actIo($: EngineInterface): ActIo {
367  return {
368    now: () => $.clock.now(),
369    run: (argv, init) => $.process.run(argv, init),
370    spawnAgent: async input => {
371      // an agent step starts in its slot: the companion reads the slot line (v2 SPEC 10.3)
372      const args: AgentSpawnArgs = { subagentType: input.subagentType, prompt: withSlotLine(input.prompt, input.cwd), description: input.description }
373      if (input.cwd !== undefined) args.cwd = input.cwd
374      if (input.name !== undefined) args.name = input.name
375      if (input.model !== undefined) args.model = input.model
376      const r = await within($, SPAWN_TIMEOUT_MS, $.agent.spawn(args), 'agent spawn')
377      if (r.deny !== undefined) return { deny: r.deny }
378      return r.agentId === undefined ? {} : { agentId: r.agentId }
379    },
380    stopAgent: async agentId => {
381      const r = await within($, STOP_TIMEOUT_MS, $.tool.call({ tool: 'TaskStop', task_id: agentId }), 'TaskStop')
382      if (r.deny !== undefined) return { stopped: false, error: r.deny }
383      if (r.isError === true) return { stopped: false, error: r.text ?? 'TaskStop reported an error' }
384      return { stopped: true }
385    },
386    submit: async prompt => {
387      const settled = $.prompt.submit({ text: prompt }).then(
388        r => (r.drop === undefined ? null : `dropped: ${r.drop}`),
389        err => `refused: ${message(err)}`,
390      )
391      const early = await within($, SUBMIT_GRACE_MS, settled, 'submit').catch(() => 'queued')
392      if (early === 'queued') {
393        void settled.then(why => (why === null ? undefined : lateDrop($, prompt, why)))
394        return
395      }
396      if (early !== null) throw new Error(`the prompt was ${early}`)
397    },
398    rename: async title => {
399      await within($, RENAME_TIMEOUT_MS, $.command.run({ command: 'rename', args: title }), 'rename')
400    },
401    usage: async () => {
402      const limits = (await $.session.usage()).rateLimits
403      const pct = (kind: string) => limits.find(l => l.kind === kind)?.percentUsed ?? null
404      return { fiveHour: pct('five_hour'), sevenDay: pct('seven_day') }
405    },
406    readFile: path => text($, path),
407    writeFile: (path, body) => $.fs.write(path, body),
408    v2: v2Io($),
409  }
410}
411
412// v2Io: the live records for the transition executor, stored after every ledger save queued
413// before them (v2/store.ts), the driver's ping and the live ledger (v2 SPEC 3.3, 4.6).
414function v2Io($: EngineInterface): V2Io {
415  return v2IoOf(liveV2, v2StoreIo($), { ready: () => live.loaded && lastRt !== null, write: save => afterSaves($, lastRt, save), ping: why => ping(why), ledger: () => live.ledger })
416}
417
418function fileIo($: EngineInterface): FileIo {
419  return { readFile: path => text($, path), writeFile: (path, body) => $.fs.write(path, body) }
420}
421
422// lockIo: the act lock's reads and its one compare-and-set command (rails.ts LOCK_CAS_SCRIPT),
423// timed when a traced pass writes it.
424function lockIo($: EngineInterface, tr: Tracer | null = null): LockIo {
425  return {
426    readFile: path => text($, path),
427    cmd: (argv, init) =>
428      lockTimed(tr, () => $.process.run(argv, init.stdin === undefined ? { timeoutMs: init.timeoutMs } : { stdin: init.stdin, timeoutMs: init.timeoutMs })),
429  }
430}
431
432// lockOp: one write of the act lock at a time from this module (a pass's renewals, session.end).
433function lockOp<T>(work: () => Promise<T>): Promise<T> {
434  const run = locking.then(work)
435  locking = run.catch(() => undefined)
436  return run
437}
438
439async function append($: EngineInterface, path: string, line: string) {
440  await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', path], { stdin: line, timeoutMs: 10_000 })
441}
442
443// appendLine: one more decision-log line, beside the pass lines: an owner op, a turn's
444// problems, a pass that threw (SPEC 12).
445async function appendLine($: EngineInterface, rt: Runtime, line: Record<string, unknown>) {
446  const at = new Date(await $.clock.now()).toISOString()
447  await append($, `${rt.loopDir}/decisions-${at.slice(0, 10)}.jsonl`, JSON.stringify({ v: 1, at, ...line }) + '\n').catch(() => undefined)
448}
449
450// offTitle: the title while the loop is off (mode off or the kill switch).
451function offTitle(rt: Runtime): string {
452  return `🔄 ${rt.repo.split('/')[1] ?? 'loop'} · off`
453}
454
455async function setTitle($: EngineInterface, title: string) {
456  if (title === lastTitle) return
457  await $.command.run({ command: 'rename', args: title })
458  lastTitle = title
459}
460
461// wakeIdAt: a fresh wake id, from the clock so it stays unique across restarts (SPEC 8.1).
462function wakeIdAt(now: Ms): string {
463  lastWakeAt = Math.max(now, lastWakeAt + 1)
464  return `w${lastWakeAt.toString(36)}`
465}
466
467// --- the store: the ledger, the backoff, the queue's known list, rail memory, notices --------
468
469const keysOf = (repo: string) => ({
470  backoff: `backoff:${repo}`,
471  known: `queue-known:${repo}`,
472  rails: `rails:${repo}`,
473  notices: `notices:${repo}`,
474})
475
476// mayLoad: no other session holds a live act lease, so this one may load the store's ledger, which
477// is then fresh. The act lock is the one writer's lease (SPEC 11.2).
478async function mayLoad($: EngineInterface, rt: Runtime): Promise<{ ok: boolean; basis: string | null }> {
479  try {
480    const basis = await text($, actLockPath(rt.loopDir))
481    return { ok: decideLock(basis, await $.session.id(), await $.clock.now(), leaseMsOf(rt.config)).hold, basis }
482  } catch {
483    return { ok: false, basis: null }
484  }
485}
486
487// mayWrite: the ledger in memory is still the store's truth: the act lock names this session, or
488// stands as it did at the load (a session the rails keep in observe, sending a notice). A lease
489// another session wrote since, live or expired, means that session kept the ledger meanwhile: this
490// one never writes over it, and reloads the store before it acts again.
491async function mayWrite($: EngineInterface, rt: Runtime): Promise<boolean> {
492  try {
493    const now = await text($, actLockPath(rt.loopDir))
494    return parseLease(now)?.session === (await $.session.id()) || now === live.basis
495  } catch {
496    return false
497  }
498}
499
500// ensureLedger: the ledger in memory, loaded from the store once per module load (and again
501// when this session takes the act lock over from another), with every running command entry
502// marked lost (SPEC 7.5). A store that cannot be read leaves it unloaded: falling back to the
503// event log would overwrite the real ledger at the next save.
504async function ensureLedger($: EngineInterface, rt: Runtime, problems: string[]): Promise<boolean> {
505  if (live.loaded && live.repo === rt.repo) return true
506  // a pass and a hook that both need it load it once
507  loading ??= loadLedger($, rt, problems).finally(() => {
508    loading = null
509  })
510  return loading
511}
512
513async function loadLedger($: EngineInterface, rt: Runtime, problems: string[]): Promise<boolean> {
514  const may = await mayLoad($, rt)
515  if (!may.ok) return false
516  let stored: LedgerEntry[] | null
517  try {
518    stored = await load(storeIo($), rt.repo)
519  } catch (err) {
520    problems.push(`ledger: the store could not be read: ${message(err).slice(0, 200)}`)
521    return false
522  }
523  live.ledger = stored ?? []
524  live.logged = loggedOf(live.ledger)
525  live.loaded = true
526  live.repo = rt.repo
527  live.basis = may.basis
528  // v2's records and heads load with the ledger, whose lease keeps them too
529  await loadV2(liveV2, v2StoreIo($), rt.repo)
530  const now = await $.clock.now()
531  const env = hookEnv($, rt, now)
532  // running commands died with the old module; wakes another session submitted went with it
533  const moved = transition(live, l => onLoad(l, now))
534  moved.push(...closeOrphans(await $.session.id(), now))
535  await persist($, rt)
536  await logLoop(env, moved)
537  problems.push(...env.problems)
538  return true
539}
540
541// closeOrphans: the live ledger's wakes no one can answer any more lost at once (wake.ts
542// orphanedWakes, SPEC 7.5); the entries it moved, for the loop events.
543function closeOrphans(session: string, now: Ms): LedgerEntry[] {
544  const moved: LedgerEntry[] = []
545  for (const u of orphanedWakes(live.ledger, session, loadedAt)) moved.push(...transition(live, l => finish(l, u.key, u, now)))
546  return moved
547}
548
549// persist: write the live ledger, one save at a time; whether it was written. save prunes it, and
550// the memory takes what was written unless a transition came in meanwhile (the next save prunes
551// that). A store that refuses the write is kept in saveProblem until a save succeeds: the pass
552// reports it, and nothing starts while the ledger cannot be saved (act.ts actOne).
553function persist($: EngineInterface, rt: Runtime): Promise<boolean> {
554  const run = saving.then(async (): Promise<boolean> => {
555    if (!live.loaded) return false
556    if (!(await mayWrite($, rt))) {
557      // another session acts now: it keeps the ledger, and this one reloads before it acts again
558      live.loaded = false
559      return false
560    }
561    const snapshot = live.ledger
562    try {
563      const written = await save(storeIo($), rt.repo, snapshot, await $.clock.now())
564      if (live.ledger === snapshot) live.ledger = written
565      saveProblem = null
566      return true
567    } catch (err) {
568      saveProblem = message(err).slice(0, 200)
569      return false
570    }
571  })
572  saving = run.then(
573    () => undefined,
574    () => undefined,
575  )
576  return run.catch(() => false)
577}
578
579// afterSaves: a store write queued after every save before it, run only while this session may
580// still write the store (mayWrite); whether it ran without error.
581function afterSaves($: EngineInterface, rt: Runtime | null, write: () => Promise<void>): Promise<boolean> {
582  const run = saving.then(async (): Promise<boolean> => {
583    if (rt === null || !(await mayWrite($, rt))) return false
584    await write()
585    return true
586  })
587  saving = run.then(
588    () => undefined,
589    () => undefined,
590  )
591  return run.catch(() => false)
592}
593
594// storeIo: ledger.ts's StoreIo over $.store.
595function storeIo($: EngineInterface): StoreIo {
596  return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }
597}
598
599async function storeGet($: EngineInterface, key: string): Promise<unknown> {
600  try {
601    return await $.store.get(key)
602  } catch {
603    return undefined
604  }
605}
606
607async function storeSet($: EngineInterface, key: string, value: unknown, problems: string[]) {
608  try {
609    await $.store.set(key, value)
610  } catch (err) {
611    problems.push(`store: ${key} not saved: ${message(err).slice(0, 200)}`)
612  }
613}
614
615const isMs = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
616
617function railMemoryFrom(raw: unknown): RailMemory {
618  const o = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
619  const hb = o.heartbeat as Record<string, unknown> | null | undefined
620  const w = o.watcher as Record<string, unknown> | null | undefined
621  return {
622    heartbeat: hb && isMs(hb.since) && typeof hb.device === 'string' ? { since: hb.since, device: hb.device } : EMPTY_RAIL_MEMORY.heartbeat,
623    watcher: w && isMs(w.since) && typeof w.pid === 'string' ? { since: w.since, pid: w.pid, seenAt: isMs(w.seenAt) ? w.seenAt : w.since } : EMPTY_RAIL_MEMORY.watcher,
624  }
625}
626
627// Notices that come from outside plan wait here until no wake is open; caps are the hourly
628// rate-cap loop events already logged.
629type NoticeBook = { pending: Notice[]; caps: string[] }
630
631function noticeOf(raw: unknown): Notice | null {
632  const o = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : null
633  if (o === null || !isMs(o.since)) return null
634  if (o.kind === 'heartbeat-other' && typeof o.device === 'string') return { kind: o.kind, device: o.device, since: o.since }
635  if (o.kind === 'old-watcher' && typeof o.pid === 'string') return { kind: o.kind, pid: o.pid, since: o.since }
636  if (o.kind === 'breaker' && isMs(o.until) && isMs(o.passes)) return { kind: o.kind, since: o.since, until: o.until, passes: o.passes }
637  return null
638}
639
640function noticesFrom(raw: unknown): NoticeBook {
641  const o = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
642  const pending = Array.isArray(o.pending) ? o.pending.map(noticeOf).filter((n): n is Notice => n !== null) : []
643  const caps = Array.isArray(o.caps) ? o.caps.filter((c): c is string => typeof c === 'string') : []
644  return { pending, caps }
645}
646
647// stale: a notice whose spell ended before it could go out says nothing true any more.
648function stale(n: Notice, memory: RailMemory | null, now: Ms): boolean {
649  if (n.kind === 'breaker') return now >= n.until
650  if (memory === null) return false
651  if (n.kind === 'heartbeat-other') return memory.heartbeat?.since !== n.since
652  return memory.watcher?.since !== n.since
653}
654
655// --- the scheduler (SPEC 2, D-39) ---------------------------------------------------------------
656
657// request: a timer tick or a ping asks for a pass; the scheduler decides when it starts. The
658// pass runs with the $ of whoever asked (a timer's closure, a hook's own). `why` names the asker
659// for the pass's trigger; a ping the breaker ignores asks for nothing.
660async function request($: EngineInterface, source: PassSource, why: string) {
661  if (headless) return
662  const now = await $.clock.now()
663  if (!(source === 'ping' && pingsIgnored(sched, now)) && !asked.includes(why) && asked.length < ASKED_MAX) asked.push(why)
664  sched = requestPass(sched, source, now)
665  await step($)
666}
667
668// ask: request a pass with the $ the loop's timers run with, made once per load (ensure), so a
669// ping from a hook never runs a pass on that hook's own dispatch.
670let ask: ((source: PassSource, why: string) => void) | null = null
671
672// ping: an agent of the loop finished (`agent <id>`), a wake was answered (`wake <id>`), an owner
673// control arrived (`owner <op>`).
674function ping(why: string) {
675  ask?.('ping', why)
676}
677
678// background: queue what a tool call leaves for later with the $ the loop's timers run with, as
679// ask does, made once per load (ensure); saveNow saves the ledger with that $ at once.
680let background: ((after: After) => void) | null = null
681let saveNow: ((rt: Runtime) => void) | null = null
682
683async function step($: EngineInterface) {
684  const now = await $.clock.now()
685  const r = nextPass(sched, now)
686  sched = r.scheduler
687  if (r.next.do === 'start') {
688    // every request since the last pass started is this one's trigger
689    const trigger = asked.length > 0 ? asked.join(', ') : 'timer'
690    asked = []
691    void runPass($, r.notice, trigger)
692      .catch(() => undefined)
693      .finally(() => {
694        sched = passEnded(sched)
695        void step($).catch(() => undefined)
696      })
697  } else if (r.next.do === 'wait') {
698    $.clock.after(r.next.ms, () => {
699      sched = waitOver(sched)
700      void step($).catch(() => undefined)
701    })
702  }
703}
704
705function rearm($: EngineInterface, ms: Ms) {
706  if (timer?.ms === ms) return
707  timer?.cancel()
708  const t = $.clock.every(ms, () => void request($, 'timer', 'timer').catch(() => undefined))
709  timer = { ms, cancel: () => t.cancel() }
710}
711
712// --- one pass (SPEC 2) ----------------------------------------------------------------------------
713
714// listAgents: one agent list per pass. Only a successful read may mark agents lost.
715async function listAgents($: EngineInterface): Promise<{ ok: boolean; agents: AgentRow[] }> {
716  try {
717    return { ok: true, agents: (await $.agent.list()).map(a => ({ id: a.id, type: a.type, status: a.status })) }
718  } catch {
719    return { ok: false, agents: [] }
720  }
721}
722
723async function railEnv($: EngineInterface): Promise<RailEnv> {
724  return {
725    DEV_ROLE: await $.env.get('DEV_ROLE'),
726    ORCHESTRATOR_WATCH_LOCK_DIR: await $.env.get('ORCHESTRATOR_WATCH_LOCK_DIR'),
727    XDG_RUNTIME_DIR: await $.env.get('XDG_RUNTIME_DIR'),
728    TMPDIR: await $.env.get('TMPDIR'),
729  }
730}
731
732// hookEnv: an act step's environment outside a pass (a hook, a notice): real events.
733function hookEnv($: EngineInterface, rt: Runtime, now: Ms, session = ''): ActEnv {
734  return {
735    io: actIo($),
736    rt: acting(rt),
737    live,
738    commands: [],
739    wakeId: () => wakeIdAt(now),
740    session,
741    save: () => persist($, rt),
742    holds: async () => true,
743    killed: () => killed($),
744    problems: [],
745  }
746}
747
748// bareState: a state for an act step outside a pass when none was planned yet this load.
749function bareState(rt: Runtime, now: Ms): State {
750  const off = { ok: false, at: null }
751  const names: SourceName[] = ['factory', 'facts', 'runs', 'queue', 'github', 'usage', 'pool', 'milestone']
752  return {
753    now,
754    repo: rt.repo,
755    device: rt.device,
756    loadedAt,
757    sources: Object.fromEntries(names.map(n => [n, off])) as State['sources'],
758    usage: { fiveHour: null, sevenDay: null, fiveHourResetsAt: null, sevenDayResetsAt: null, unavailable: true },
759    issues: [],
760    queue: [],
761    runs: [],
762    pool: null,
763    agents: [],
764    milestone: null,
765    ledger: live.ledger,
766    control: EMPTY_CONTROL,
767    lastTitle,
768    githubBackoffUntil: null,
769    checkout: null,
770  }
771}
772
773async function runPass($: EngineInterface, breaker: BreakerNotice | null, trigger: string) {
774  // the pass's time runs from here, for the decision log and the trace (#552)
775  const t0 = performance.now()
776  let rt: Runtime | undefined
777  try {
778    rt = await runtime($)
779    lastRt = rt
780    rearm($, rt.config.passEveryMs)
781    await noteIdle(liveV2, v2StoreIo($), fileIo($), rt)
782    const off = rt.mode === 'off' || (await noteKillSwitch(liveV2, v2StoreIo($), rt, await killed($)))
783    if (off || rt.repo === '') {
784      await setTitle($, offTitle(rt))
785    } else {
786      passNo += 1
787      await pass($, rt, breaker, trigger, t0)
788    }
789    failures = 0
790    failingSince = null
791    errorTold = false
792  } catch (err) {
793    failures += 1
794    const now = await $.clock.now()
795    failingSince ??= now
796    if (rt !== undefined) {
797      await appendLine($, rt, { pass: passNo, trigger, ms: Math.round(performance.now() - t0), error: String(err).slice(0, 500), failures })
798      const name = rt.repo.split('/')[1] ?? 'loop'
799      const title = lastTitle === null ? `🔄 ${name} · error` : lastTitle.endsWith(' · error') ? lastTitle : `${lastTitle} · error`
800      await setTitle($, title).catch(() => undefined)
801      if (failures >= ERROR_PASSES && rt.mode === 'act' && !errorTold) errorTold = await tellError($, rt, now, String(err))
802    }
803  }
804}
805
806// tellError: three passes in a row threw; the owner hears of it once (SPEC 10.9), from a session
807// that may act: a role the rails refuse tells nothing, as checkRails does.
808async function tellError($: EngineInterface, rt: Runtime, now: Ms, error: string): Promise<boolean> {
809  try {
810    const problems: string[] = []
811    if (roleProblem(await $.env.get('DEV_ROLE'), rt.config) !== null || (await killed($))) return false
812    if (!(await ensureLedger($, rt, problems))) return false
813    const env = hookEnv($, rt, now, await $.session.id())
814    const sent = await sendNotice(env, lastState ?? bareState(rt, now), passErrorTell(failingSince ?? now, failures, error))
815    return sent === 'sent'
816  } catch {
817    return false
818  }
819}
820
821async function pass($: EngineInterface, rt: Runtime, breaker: BreakerNotice | null, trigger: string, t0: number) {
822  const now = await $.clock.now()
823  // trace mode (#552): the pass's phases, every engine call it makes and its totals; null when off
824  const tr = rt.config.trace ? newTracer(() => performance.now(), t0, now) : null
825  const keys = keysOf(rt.repo)
826  const listed = await called(tr, 'agent.list', () => listAgents($), r => (r.ok ? {} : { error: 'not read' }))
827  const commands: string[] = []
828  const problems: string[] = []
829  const aio = tr === null ? actIo($) : tracedActIo(tr, actIo($))
830  const keepInStore = (key: string, value: unknown) => spent(tr, 'persistMs', () => storeSet($, key, value, problems))
831  let railReasons: string[] = []
832  let memory: RailMemory | null = null
833  let book: NoticeBook = { pending: [], caps: [] }
834  let booked = JSON.stringify(book)
835  let act = false
836  let sid = ''
837
838  // the rails, at the start of every act-mode pass, before anything acts (SPEC 11.2)
839  begin(tr, 'rails')
840  if (rt.mode === 'act') {
841    sid = await $.session.id()
842    const remembered = railMemoryFrom(await storeGet($, keys.rails))
843    // the CLIs act on the checkout's own origin, the gh executors on rt.repo: they must be one
844    // repo, or a merge lands in one and its follow-ups in the other (SPEC 11.5)
845    const origin = await originOf($, rt.cwd, rt.env)
846    const refusals = origin.toLowerCase() === rt.repo.toLowerCase() ? [] : [`repo: the checkout ${rt.cwd} is ${origin === '' ? 'no GitHub repository' : origin}, not ${rt.repo}`]
847    // the rails' probes join the pass's command list; the lease's write does not
848    const logged = commandOf(aio, rt, commands)
849    const rails = await checkRails(
850      { cmd: (argv, init) => (argv[2] === LOCK_CAS_SCRIPT ? lockIo($, tr).cmd(argv, init) : logged(argv, init)), readFile: aio.readFile },
851      { repo: rt.repo, device: rt.device, session: sid, loopDir: rt.loopDir, config: rt.config, env: await railEnv($), now, memory: remembered, refusals },
852    )
853    memory = rails.memory
854    railReasons = rails.reasons
855    act = rails.act
856    if (JSON.stringify(rails.memory) !== JSON.stringify(remembered)) await keepInStore(keys.rails, rails.memory)
857    if (rails.lock !== null && rails.lock.held) {
858      heldLock = true
859      // another session's lease ran out under it: the store holds its ledger, the truth now
860      if (rails.lock.took !== null) live.loaded = false
861    }
862    book = noticesFrom(await storeGet($, keys.notices))
863    booked = JSON.stringify(book)
864    const fresh: Notice[] = breaker === null ? rails.notices : [...rails.notices, breaker]
865    for (const n of fresh) if (!book.pending.some(p => noticeTell(p).key === noticeTell(n).key)) book.pending.push(n)
866    if (breaker !== null) await commandOf(aio, acting(rt), commands)(breakerEventArgv(breaker), { timeoutMs: 60_000 }).catch(() => undefined)
867    if (act && restartAsked !== null) {
868      act = false
869      railReasons.push(`restart: dev-restart-self asked for a new workspace at ${new Date(restartAsked).toISOString()}; nothing acts until it restarts`)
870    }
871    if (act && !(await ensureLedger($, rt, problems))) {
872      act = false
873      railReasons.push('ledger: not loaded, so nothing is acted on this pass')
874    }
875  }
876
877  // observe: act mode reads the store's ledger, backoff and queue list; anything else runs as
878  // observe, its events to the sink
879  begin(tr, 'observe')
880  const passRt = act ? rt : rt.mode === 'act' ? observing(rt) : rt
881  let stored: Stored | undefined
882  if (act) {
883    const backoff = await storeGet($, keys.backoff)
884    const known = await storeGet($, keys.known)
885    stored = {
886      ledger: live.ledger.length > 0 ? live.ledger : null,
887      githubBackoffUntil: isMs(backoff) ? backoff : null,
888      queueKnown: Array.isArray(known) && known.every(n => typeof n === 'number') ? known : null,
889    }
890  }
891  // A shadow watches another session: this module's own load says nothing about when that
892  // session restarted, so the first-pass heartbeat (rule H1) is left to real lease timing.
893  const shadowing = rt.device !== ((await $.env.get('DEV_MACHINE_NAME')) || '')
894  const reads = tr === null ? io($, listed.agents) : tracedIo(tr, io($, listed.agents))
895  // v2's records and heads: act mode's own, else the store's, read and never written (v2 SPEC 2.3)
896  let v2Stored: V2Stored | undefined
897  if (rt.config.planner === 'v2' || rt.config.shadow === 'v2') v2Stored = await (act ? storedV2(liveV2, v2StoreIo($), rt.repo) : readStoredV2(v2StoreIo($), rt.repo))
898  const observed = await observe(reads, passRt, cache, shadowing ? 0 : loadedAt, lastTitle, stored, v2Stored)
899  let state = observed.state
900  if (act && rt.config.planner === 'v2') await keepV2(liveV2, observed.v2Heads, state, keepInStore)
901  // an acting pass renews its lease before every action and on a timer while it runs, so no
902  // step outlives it; once another session holds it, this pass runs nothing more and keeps the
903  // ledger no longer (the next pass that holds it reloads the store)
904  let holding = true
905  const renew = async (): Promise<boolean> => {
906    if (!holding) return false
907    const path = actLockPath(rt.loopDir)
908    // a save that found the lock another session's let the ledger go: nothing more acts on it
909    const lock = live.loaded ? await lockOp(async () => holdActLock(lockIo($, tr), path, sid, await $.clock.now(), leaseMsOf(rt.config))) : null
910    if (lock !== null && lock.held && lock.took === null) return true
911    holding = false
912    live.loaded = false
913    if (lock !== null && !lock.held) problems.push(lock.reason)
914    else {
915      // a lease taken over mid-pass, or written meanwhile, was another session's: its ledger is the truth
916      const other = lock?.took?.session ?? parseLease(await text($, path))?.session ?? 'unknown'
917      problems.push(`act lock: session ${other} held it during this pass; this one keeps the ledger no longer`)
918    }
919    return false
920  }
921  // a save writes only while this pass holds the lease, and persist checks it again
922  const save = async (): Promise<boolean> => {
923    if (act && !holding) return false
924    return spent(tr, 'persistMs', () => persist($, rt))
925  }
926  const holds = () => (act ? renew() : Promise.resolve(holding))
927  const env: ActEnv = { io: aio, rt, live, commands, wakeId: () => wakeIdAt(state.now), session: sid, save, holds, killed: () => killed($), problems }
928
929  let report: ActReport | null = null
930  let result: PlanResult
931  let shadow: Shadowed | null = null
932  // when plan handed the actions over, for the trace
933  let queuedAt: Ms | null = null
934  const told: string[] = []
935  const renewal = act ? $.clock.every(rt.config.passEveryMs, () => void renew().catch(() => undefined)) : null
936  try {
937    if (act) {
938      begin(tr, 'act')
939      // a lost store, rebuilt from the event log, becomes the ledger the loop keeps
940      if (observed.ledgerSource === 'events' && live.ledger.length === 0 && state.ledger.length > 0) {
941        live.ledger = [...state.ledger]
942        live.logged = loggedOf(live.ledger)
943        // a wake it rebuilt was open in an earlier session, which took its prompt along
944        const moved = closeOrphans(sid, state.now)
945        await spent(tr, 'persistMs', () => persist($, rt))
946        await logLoop(env, moved)
947      }
948      // the restart for an update ran: the update is done, so the drain ends (rule H4)
949      if (updateDone(live.ledger, state.control, loadedAt) && (await clearUpdate($, rt, state.control.updateDue?.at ?? null))) {
950        state = { ...state, control: { ...state.control, updateDue: null } }
951      }
952      const acted = await actPass(env, state, listed, between)
953      carve(tr, 'plan', acted.planned.ms)
954      queuedAt = acted.planned.at
955      state = acted.state
956      result = acted.result
957      shadow = acted.shadow
958      report = acted.report
959      lastState = state
960      if (report.outcomes.some(o => o.kind === 'restart-self' && o.status === 'done')) restartAsked = state.now
961      if (report.title !== null) lastTitle = report.title
962      // the kill switch stopped the pass between actions: the title says off now, not next pass
963      if (report.killed) await setTitle($, offTitle(rt))
964      // a rate-cap hold has no entry of its own: one loop event per cap per hour (SPEC 11.4)
965      const hour = `:${Math.floor(state.now / HOUR)}`
966      for (const capped of result.capped) {
967        const key = capKey(capped, state.now)
968        if (book.caps.includes(key)) continue
969        const r = await commandOf(env.io, rt, commands)(capEventArgv(capped, state.now), { timeoutMs: 60_000 }).catch(() => null)
970        if (r !== null && r.exitCode === 0) book.caps.push(key)
971      }
972      book.caps = book.caps.filter(k => k.endsWith(hour))
973    } else {
974      // a pass that may not act plans as observe mode does, and its title says so
975      const config: Config = rt.mode === 'act' ? { ...rt.config, mode: 'observe' } : rt.config
976      begin(tr, 'plan')
977      const planned = planPass(state, config, rt.v2Config)
978      result = planned.result
979      shadow = planned.shadow
980      begin(tr, 'act')
981      await setTitle($, result.title)
982    }
983
984    // notices from outside plan go out once no wake is open, acting or not (SPEC 9.2, 11.2), until a
985    // restart was asked for
986    if (rt.mode === 'act' && restartAsked === null) {
987      book.pending = book.pending.filter(n => !stale(n, memory, state.now))
988      if (book.pending.length > 0 && (act || (await ensureLedger($, rt, problems)))) {
989        const nenv: ActEnv = { ...env, rt: acting(rt) }
990        let waiting = false
991        for (const n of book.pending) {
992          const tell = noticeTell(n)
993          if (waiting || live.ledger.some(e => e.key === tell.key && e.status !== 'lost')) continue
994          const sent = await sendNotice(nenv, state, tell)
995          if (sent === 'sent') told.push(tell.key)
996          else waiting = true
997        }
998        // a notice stays until its tell ended: one the engine dropped after the submit (lost,
999        // lateDrop) goes out once more
1000        book.pending = book.pending.filter(n => {
1001          const e = live.ledger.find(x => x.key === noticeTell(n).key)
1002          return e === undefined || e.status === 'running' || e.status === 'lost'
1003        })
1004      }
1005      if (JSON.stringify(book) !== booked) await keepInStore(keys.notices, book)
1006    }
1007  } finally {
1008    renewal?.cancel()
1009  }
1010
1011  // what act mode keeps for the next pass: the queue's known list and the GitHub backoff
1012  if (act) {
1013    if (observed.queueKnown !== null) await keepInStore(keys.known, observed.queueKnown)
1014    const limited = report?.rateLimited === true ? state.now + GITHUB_BACKOFF_MS : 0
1015    const until = Math.max(state.githubBackoffUntil ?? 0, limited)
1016    if (until > state.now) await keepInStore(keys.backoff, until)
1017  }
1018
1019  if (act && saveProblem !== null) problems.push(`ledger: not saved, so nothing starts until a save succeeds: ${saveProblem}`)
1020  // each skip keeps the time it was first seen while it lasts, for the trace's waits
1021  waits = waitSince(waits, result.skipped, state.now)
1022  const digest = digestOf(state)
1023  const at = new Date(state.now).toISOString()
1024  const issues = state.issues
1025  begin(tr, 'log')
1026  const line = {
1027    v: 1,
1028    at,
1029    pass: passNo,
1030    // pass numbers restart when the module reloads; the load time tells the runs apart
1031    loaded: new Date(loadedAt).toISOString(),
1032    // what asked for the pass, and its time up to this line (#552)
1033    trigger,
1034    ms: Math.round(performance.now() - t0),
1035    mode: rt.mode,
1036    executed: act,
1037    device: rt.device,
1038    // the layer whose plan this is (v2 SPEC 2.3)
1039    planner: rt.config.planner,
1040    tier: result.tier,
1041    digest,
1042    sources: Object.fromEntries(
1043      [...Object.entries(state.sources), ...Object.entries(state.v2?.sources ?? {}).map(([k, v]) => [`v2.${k}`, v] as const)].map(([k, s]) => [k, s.ok ? 'ok' : `failed${s.error ? `: ${s.error}` : ''}`]),
1044    ),
1045    counts:
1046      rt.config.planner === 'v2'
1047        ? { queue: state.queue.length, ...stateCounts(state.v2?.records ?? []), owner: result.owner }
1048        : {
1049            queued: state.queue.length,
1050            dev: issues.filter(i => i.stage === 'designing' || i.stage === 'implementing').length,
1051            pipeline: issues.filter(i => i.stage === 'in pipeline' || i.stage === 'parked').length,
1052            merge: issues.filter(i => i.stage === 'ready to merge').length,
1053            owner: result.owner,
1054          },
1055    actions: result.actions.filter(a => a.kind !== 'title').map(summaryOf),
1056    transitions: rt.config.planner === 'v2' ? transitionsOf(result) : undefined,
1057    skipped: result.skipped.slice(0, 60),
1058    // the v2 shadow's plan, logged and never executed, and the K it planned with (v2's own default)
1059    shadow:
1060      shadow === null
1061        ? undefined
1062        : 'error' in shadow
1063          ? shadow
1064          : { k: (rt.v2Config ?? rt.config).maxInFlightIssues, actions: shadow.actions.filter(a => a.kind !== 'title').map(summaryOf), skipped: shadow.skipped.slice(0, 60), transitions: transitionsOf(shadow) },
1065    outcomes: report?.outcomes.filter(o => o.kind !== 'title').map(o => ({ key: o.key, status: o.status, ms: o.ms, summary: o.summary })),
1066    capped: result.capped.length > 0 ? result.capped : undefined,
1067    atCap: result.atCap ? true : undefined,
1068    rails: railReasons.length > 0 ? railReasons : undefined,
1069    ledgerSource: act ? observed.ledgerSource : undefined,
1070    breaker: breaker === null ? undefined : { since: new Date(breaker.since).toISOString(), until: new Date(breaker.until).toISOString(), passes: breaker.passes },
1071    told: told.length > 0 ? told : undefined,
1072    config: rt.configProblems.length > 0 ? rt.configProblems : undefined,
1073    problems: problems.length > 0 ? problems : undefined,
1074    commands: [...observed.commands, ...commands],
1075    title: result.title,
1076  }
1077  await append($, `${rt.loopDir}/decisions-${at.slice(0, 10)}.jsonl`, JSON.stringify(line) + '\n')
1078  // full states for the replay harness, in observe mode only (SPEC 4.2 recordStates), a day's worth
1079  // at most
1080  const day = at.slice(0, 10)
1081  if (statesToday.day !== day) statesToday = { day, count: 0 }
1082  if (digest !== lastDigest && rt.mode !== 'act' && statesToday.count < STATES_PER_DAY) {
1083    const body = JSON.stringify(state)
1084    if (body.length < STATE_KEEP_BYTES) {
1085      await $.fs.write(`${rt.loopDir}/states/${day}/${passNo}.json`, body)
1086      statesToday.count += 1
1087    }
1088    lastDigest = digest
1089  }
1090  // once a day, the loop dir's files older than the ledger's week go (SPEC 11.6)
1091  if (prunedDay !== day) {
1092    prunedDay = day
1093    for (const argv of pruneArgvs(rt.loopDir, LOOP_FILES_KEEP_DAYS)) await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => undefined)
1094  }
1095  await $.state.set(lastPassRef, {
1096    pass: passNo,
1097    at: state.now,
1098    mode: rt.mode,
1099    executed: act,
1100    rails: railReasons,
1101    tier: result.tier,
1102    title: result.title,
1103    actions: line.actions.length,
1104    owner: result.owner,
1105    planner: rt.config.planner,
1106    shadow: rt.config.shadow,
1107  })
1108  const ledger = act ? live.ledger : state.ledger
1109  if (tr !== null) {
1110    // ended wakes since the last pass, or since this module loaded: not the ledger's whole week
1111    const wakes = wakeRows(ledger, delivered, reportedWakes, Math.max(prevPassAt, loadedAt))
1112    for (const attempt of wakes.reported) reportedWakes.add(attempt)
1113    const record = traceRecord({
1114      tr,
1115      line: { at, pass: passNo, loaded: line.loaded, mode: rt.mode, executed: act, trigger },
1116      state,
1117      made: rt.runSource === 'events' ? ['runs', 'pool'] : [],
1118      outcomes: report?.outcomes ?? [],
1119      queuedAt,
1120      wakes: wakes.rows,
1121      waiting: waitingRows(state, result, ledger, waits),
1122    })
1123    await writeTrace($, rt, record).catch(() => undefined)
1124  }
1125  // the wake attempts a ledger let go are forgotten: the hooks time the live ledger's turns
1126  if (delivered.size > 0) {
1127    const held = new Set(live.ledger.map(attemptOf))
1128    for (const attempt of delivered.keys()) if (!held.has(attempt)) delivered.delete(attempt)
1129  }
1130  if (reportedWakes.size > 0) {
1131    const shown = new Set(ledger.map(attemptOf))
1132    for (const attempt of reportedWakes) if (!shown.has(attempt)) reportedWakes.delete(attempt)
1133  }
1134  prevPassAt = state.now
1135  // the title says error until the store takes the ledger again (SPEC 10.9)
1136  if (act && saveProblem !== null) throw new Error(`ledger: not saved: ${saveProblem}`)
1137}
1138
1139// writeTrace: the pass's trace record beside its decision log, within config traceMaxBytes
1140// (trace.ts traceRoom): the oldest trace files go first, and today's is cut short rather than
1141// pass the cap. A cut stays for the day across reloads: the first write of a load reads whether
1142// today's file already ends with the cut line (SPEC 12.1). A trace that cannot be written never
1143// fails the pass.
1144async function writeTrace($: EngineInterface, rt: Runtime, record: Record<string, unknown>) {
1145  const at = String(record.at)
1146  const day = at.slice(0, 10)
1147  const cap = rt.config.traceMaxBytes
1148  const dayCap = `${day}:${cap}`
1149  const line = JSON.stringify(record) + '\n'
1150  const files = (await $.fs.list(rt.loopDir)).filter(e => e.kind === 'file')
1151  const path = `${rt.loopDir}/${traceName(day)}`
1152  if (traceCut !== dayCap && traceChecked !== dayCap) {
1153    if (files.some(f => f.name === traceName(day) && f.size > 0) && (await endsCut($, path, cap))) traceCut = dayCap
1154    traceChecked = dayCap
1155  }
1156  const room = traceRoom(files, day, byteLength(line), cap, traceCut === dayCap)
1157  if (room.remove.length > 0) {
1158    const r = await $.process.run(['rm', '-f', '--', ...room.remove.map(name => `${rt.loopDir}/${name}`)], { timeoutMs: 10_000 })
1159    if (r.exitCode !== 0) return
1160  }
1161  if (room.write === 'record') await append($, path, line)
1162  if (room.write === 'cut') {
1163    await append($, path, cutLine(at, cap))
1164    traceCut = dayCap
1165  }
1166}
1167
1168// endsCut: the trace file's last line is the cut line of this cap. Only its tail is read, since
1169// the file may be far larger than $.fs.read takes; the cut line fits in CUT_ROOM bytes.
1170async function endsCut($: EngineInterface, path: string, cap: number): Promise<boolean> {
1171  const r = await $.process.run(['tail', '-c', String(CUT_ROOM), '--', path], { timeoutMs: 10_000 })
1172  return r.exitCode === 0 && isCutLine(r.stdout.trimEnd().split('\n').pop() ?? '', cap)
1173}
1174
1175function summaryOf(a: Action) {
1176  return {
1177    key: a.key,
1178    kind: a.kind,
1179    class: a.class,
1180    rule: a.rule,
1181    issue: a.issue,
1182    agent: a.kind === 'spawn' ? a.agent : undefined,
1183    step: a.kind === 'agent-step' ? a.step : undefined,
1184    run: 'run' in a && typeof a.run === 'string' ? a.run : undefined,
1185    pr: 'pr' in a && typeof a.pr === 'number' ? a.pr : undefined,
1186    reason: a.reason,
1187  }
1188}
1189
1190// clearUpdate: control.json without updateDue, once the restart for it ran (rule H4). Its line
1191// is appended outside the owner's op queue, which holds only file reads and writes (#558).
1192async function clearUpdate($: EngineInterface, rt: Runtime, at: Ms | null): Promise<boolean> {
1193  if (at === null) return false
1194  const run = ops.then(async () => {
1195    const fio = notingIo(fileIo($), noteWritten(liveV2, v2StoreIo($), rt))
1196    const loaded = await readControl(fio, rt.loopDir)
1197    if (loaded.error !== null || loaded.control.updateDue?.at !== at) return false
1198    await writeControl(fio, rt.loopDir, { ...loaded.control, updateDue: null })
1199    return true
1200  })
hooks/act.ts 746 lines
1// The act step (SPEC 2, 6, 7, 8, 10): the one dispatcher from an action's kind to its executor,
2// what an act-mode pass does once observe has read the factory (what the agent list proves,
3// wakes past their timeout, plan, then plan's actions in order: start each ledger entry,
4// pre-check usage for a token-spending class, run the executor, finish the entry with what it
5// reported), and what the hooks between passes do to the ledger (an agent's answer, in text or
6// through its answer tool, a wake's turn, the wake answer tool, `/factory retry`). Every entry
7// that moves gets its `loop` event, once.
8// register.ts holds $, builds the ActIo and keeps the store; everything here works through the
9// ActIo and the live ledger, so the tests drive whole passes, and the argv safety test every
10// executor, with a recording fake. The setting `planner` picks the layer that plans (v1's plan.ts,
11// or v2/plan.ts), and with the v2 shadow on, v2 plans beside v1 and is only logged (v2 SPEC 2.3).
12import { cliExecutors } from './act-cli'
13import { agentExecutors, agentOf, interim, onAgentAnswer, onToolAnswer, purposeOfKey, repairAskOf, scriptAnswered } from './act-agents'
14import { mergeExecutors } from './act-merge'
15import type { ActIo, AgentAnswer, EventFields, Exec, ExecCtx, Executor, RunInit, RunResult } from './exec'
16import { GITHUB_V2 } from './foundation'
17import { changedEntries, finish, loopEvent, reconcileAgents, retryIssue, start } from './ledger'
18import type { Runtime } from './observe'
19import { plan, turnEnded } from './plan'
20import { repairPrompt } from './prompts'
21import { preCheck } from './rails'
22import type { BreakerNotice } from './rails'
23import type { Action, ActionKind, AgentStatus, AgentType, Config, Control, LedgerEntry, Ms, PlanResult, State } from './types'
24import { planV2 } from './v2/plan'
25import { V2_EXECUTORS, executorOf } from './v2/registry'
26import { stepAnswered, stepAnswers, stepRecorded } from './v2/steps'
27import { STEP_AGENT, V2_ACTION_KINDS } from './v2/types'
28import { answerTool, expireWakes, onTurnComplete, onTurnStart, wakeExecutors } from './wake'
29import type { TurnEnd } from './wake'
30
31const HOUR = 3_600_000
32const SUMMARY_MAX = 200
33// callum-flow-event is one jq call and one append; a minute is ample
34const EVENT_TIMEOUT_MS = 60_000
35
36// Every kind's executor (SPEC 6.1): v1's, and v2's for its new kinds (v2/registry.ts). act.test.ts
37// checks that no ActionKind lacks one.
38export const EXECUTORS: Partial<Record<ActionKind, Executor>> = {
39  ...cliExecutors,
40  ...mergeExecutors,
41  ...agentExecutors,
42  ...wakeExecutors,
43  ...Object.fromEntries(V2_ACTION_KINDS.map(kind => [kind, V2_EXECUTORS[kind]])),
44}
45
46// The kinds that write to GitHub, held for the rest of a pass once one met a rate limit, as
47// plan holds them while the backoff lasts (plan.ts GITHUB, SPEC 10.2).
48export const GITHUB_KINDS: readonly ActionKind[] = [
49  'claim',
50  'heartbeat',
51  'sweep',
52  'guard',
53  'update-behind',
54  'fix-linkage',
55  'merge',
56  'rollout',
57  'close-issue',
58  'release-parent',
59  'abandon',
60  'milestone-later',
61  'milestone-expect',
62  'pr-reopen',
63  'pr-comment',
64  'pr-close',
65]
66
67const WAKES: readonly ActionKind[] = ['judge', 'task', 'tell']
68
69// dispatch: the action's executor, run once; under planner v2, v2's own for the kind first (a
70// reused kind may have a v2 behaviour), run again after a GitHub blip (registry.ts). A kind with no
71// executor, or an executor that throws, is a failed attempt; neither is a reason to stop the pass.
72export async function dispatch(ctx: ExecCtx, action: Action): Promise<Exec> {
73  const run = ctx.rt.config.planner === 'v2' ? executorOf(action.kind, EXECUTORS) : EXECUTORS[action.kind]
74  if (run === undefined) return { status: 'failed', summary: `no executor for ${action.kind}`, noRetry: true }
75  try {
76    return await run(ctx, action)
77  } catch (err) {
78    return { status: 'failed', summary: clip(`${action.kind}: the executor threw: ${message(err)}`) }
79  }
80}
81
82// --- commands and events (SPEC 6.1, 7.4, 11.1) ---------------------------------------------------
83
84// commandOf: ExecCtx.cmd: the argv from the main checkout (or init.cwd) with the runtime's
85// environment, which carries the repo, the configured labels and, when not acting, the event
86// sink. stdin passes through as given, an empty string included. Each command joins the pass's
87// list for the decision log.
88export function commandOf(io: ActIo, rt: Runtime, log: string[]): ExecCtx['cmd'] {
89  return (argv: string[], init: RunInit): Promise<RunResult> => {
90    log.push(argv.join(' ').slice(0, 160))
91    const run: RunInit & { cwd: string; env: Record<string, string> } = { cwd: init.cwd ?? rt.cwd, env: rt.env, timeoutMs: init.timeoutMs }
92    if (init.stdin !== undefined) run.stdin = init.stdin
93    return io.run(argv, run)
94  }
95}
96
97// eventArgv: `callum-flow-event <state>` with the fields as its flags.
98export function eventArgv(state: string, f: EventFields): string[] {
99  const argv = ['callum-flow-event', state]
100  if (f.issue !== undefined && f.issue !== null) argv.push('--issue', String(f.issue))
101  if (f.run !== undefined && f.run !== null) argv.push('--run', f.run)
102  if (f.branch !== undefined && f.branch !== null) argv.push('--branch', f.branch)
103  if (f.pr !== undefined && f.pr !== null) argv.push('--pr', String(f.pr))
104  if (f.head !== undefined && f.head !== null) argv.push('--head', f.head)
105  if (f.note !== undefined && f.note !== null) argv.push('--note', f.note)
106  return argv
107}
108
109// eventOf: ExecCtx.event. callum-flow-event exits non-zero only on a usage error (2); the error
110// carries the exit as `exitCode`, which the executors read to never retry it.
111export function eventOf(cmd: ExecCtx['cmd']): ExecCtx['event'] {
112  return async (state, fields) => {
113    const r = await cmd(eventArgv(state, fields), { timeoutMs: EVENT_TIMEOUT_MS })
114    if (r.exitCode === 0) return
115    const why = r.stderr.trim().split('\n').pop() ?? ''
116    throw Object.assign(new Error(`callum-flow-event ${state}: exit ${r.exitCode}${why === '' ? '' : `: ${why}`}`), { exitCode: r.exitCode })
117  }
118}
119
120// --- the live ledger ---------------------------------------------------------------------------
121
122// The ledger as the driver holds it: one array, replaced whole by every transition. The pass and
123// the hooks that fire during it (an agent's answer, a wake's turn) apply their transitions to the
124// array as it is then, with no await between the read and the write, so neither loses the other's.
125// logged: the loop event last logged for each key (logLoop); a ledger loaded from the store or the
126// event log starts it with loggedOf, since each of its entries was logged as it got there.
127export type Live = { ledger: LedgerEntry[]; logged?: Map<string, string> }
128
129// loggedOf: the loop event each entry of a loaded ledger already logged.
130export function loggedOf(ledger: readonly LedgerEntry[]): Map<string, string> {
131  const out = new Map<string, string>()
132  for (const entry of ledger) {
133    const argv = loopEvent(entry)
134    if (argv !== null) out.set(entry.key, JSON.stringify(argv))
135  }
136  return out
137}
138
139// transition: apply fn to the live ledger; the entries it changed, for their loop events.
140export function transition(live: Live, fn: (ledger: LedgerEntry[]) => LedgerEntry[]): LedgerEntry[] {
141  const before = live.ledger
142  live.ledger = fn(before)
143  return changedEntries(before, live.ledger)
144}
145
146// What an act step works with.
147export type ActEnv = {
148  io: ActIo
149  // the acting runtime: its env sends events to the real log
150  rt: Runtime
151  live: Live
152  // the pass's command list, for the decision log
153  commands: string[]
154  // a fresh wake id (SPEC 8.1 `id=`)
155  wakeId: () => string
156  // $.session.id(): the session a wake it submits belongs to ('' when unknown)
157  session: string
158  // writes the live ledger to the store: an attempt's start before its executor runs, and every
159  // transition before its loop event, so a reload at any point finds what already happened;
160  // false when it was not written
161  save: () => Promise<boolean>
162  // this session still holds the act lock, renewed now; once it does not, the pass runs nothing
163  // more. Asked before every action.
164  holds: () => Promise<boolean>
165  // the kill switch is set (SPEC 11.3): one stat, asked before every action but the title and
166  // before a notice goes out; once it is, the pass runs nothing more
167  killed: () => Promise<boolean>
168  // what went wrong outside any one action (a loop event not logged), for the decision log
169  problems: string[]
170}
171
172export function ctxOf(env: ActEnv, state: State, entry: LedgerEntry): ExecCtx {
173  const cmd = commandOf(env.io, env.rt, env.commands)
174  return { io: env.io, rt: env.rt, state, entry, cmd, event: eventOf(cmd), wakeId: env.wakeId }
175}
176
177// logLoop: the `loop` event of each entry a transition moved that logs one: agents, wakes and
178// owner notices (D-09, ledger.ts loopEvent), one per transition: an entry whose event would say
179// just what its key's last one said is not logged again. A wake's turn taking its id, its
180// reminder going out, and a turn that starts inside its submit (so turn.start moves the entry
181// before the executor finishes it) each change the entry but not its event (run 2, #536). A
182// failed log is noted, never retried.
183export async function logLoop(env: Pick<ActEnv, 'io' | 'rt' | 'commands' | 'problems' | 'live'>, entries: readonly LedgerEntry[]): Promise<void> {
184  const cmd = commandOf(env.io, env.rt, env.commands)
185  const logged = (env.live.logged ??= new Map())
186  for (const entry of entries) {
187    const argv = loopEvent(entry)
188    if (argv === null) continue
189    const said = JSON.stringify(argv)
190    if (logged.get(entry.key) === said) continue
191    logged.set(entry.key, said)
192    try {
193      const r = await cmd(argv, { timeoutMs: EVENT_TIMEOUT_MS })
194      if (r.exitCode !== 0) env.problems.push(clip(`loop event ${entry.key}: exit ${r.exitCode}`))
195    } catch (err) {
196      env.problems.push(clip(`loop event ${entry.key}: ${message(err)}`))
197    }
198  }
199  // a key the ledger no longer holds (pruned, or a notice a retry cleared) is forgotten
200  const held = new Set(env.live.ledger.map(e => e.key))
201  for (const key of logged.keys()) if (!held.has(key)) logged.delete(key)
202}
203
204// --- one pass's actions ------------------------------------------------------------------------
205
206// One action's result, for the decision log (SPEC 12); at: when it started, for the trace (#552),
207// absent for an action held before it started.
208export type ActOutcome = { key: string; kind: ActionKind; status: Exec['status']; ms: Ms; summary: string; at?: Ms }
209
210export type ActReport = {
211  outcomes: ActOutcome[]
212  // an executor met a GitHub rate limit: register sets the backoff (SPEC 10.2)
213  rateLimited: boolean
214  // the title the title action set, when it did
215  title: string | null
216  // the kill switch stopped the pass between actions: register shows the loop off at once
217  killed: boolean
218}
219
220// runActions: plan's actions in plan's order (finish in-flight work before starting new work).
221// The kill switch is asked before each action but the title (bookkeeping only): an action
222// already running finishes, and once the switch is seen every action left is skipped (#557).
223export async function runActions(env: ActEnv, state: State, actions: readonly Action[]): Promise<ActReport> {
224  const report: ActReport = { outcomes: [], rateLimited: false, title: null, killed: false }
225  const github = env.rt.config.planner === 'v2' ? GITHUB_V2 : GITHUB_KINDS
226  for (const action of actions) {
227    if (!report.killed && action.kind !== 'title' && (await env.killed())) report.killed = true
228    if (report.killed) {
229      report.outcomes.push({ key: action.key, kind: action.kind, status: 'skipped', ms: 0, summary: 'kill switch' })
230      continue
231    }
232    if (!(await env.holds())) {
233      report.outcomes.push({ key: action.key, kind: action.kind, status: 'skipped', ms: 0, summary: 'held: the act lock is no longer this session\'s' })
234      continue
235    }
236    if (report.rateLimited && github.includes(action.kind)) {
237      report.outcomes.push({ key: action.key, kind: action.kind, status: 'skipped', ms: 0, summary: 'held: GitHub rate limit met this pass' })
238      continue
239    }
240    const out = await actOne(env, state, action)
241    report.outcomes.push(out.outcome)
242    if (out.exec.status === 'failed' && out.exec.rateLimited === true) report.rateLimited = true
243    if (action.kind === 'title' && out.exec.status === 'done') report.title = action.text
244  }
245  return report
246}
247
248// actOne: one action through the ledger (SPEC 7): start (a retry keeps the key and counts an
249// attempt), the usage pre-check for start, develop and drive (a refusal puts the entry back as
250// it stood, SPEC 10.7), the running entry saved, the executor, then finish with its Exec and
251// { config, prev }. The title is compared, not keyed (SPEC 5.8 O1), so it runs without an entry.
252export async function actOne(env: ActEnv, state: State, action: Action): Promise<{ outcome: ActOutcome; exec: Exec }> {
253  const t0 = await env.io.now()
254  const done = async (exec: Exec) => {
255    const ms = (await env.io.now()) - t0
256    return { exec, outcome: { key: action.key, kind: action.kind, status: exec.status, ms, summary: clip(exec.summary), at: t0 } }
257  }
258  if (action.kind === 'title') return done(await dispatch(ctxOf(env, state, start([], action, t0).entry), action))
259
260  const s = start(env.live.ledger, action, t0, env.session)
261  // no loop event yet: the start and its finish are one transition, logged once
262  env.live.ledger = s.ledger
263  const opts = { config: env.rt.config, prev: s.prev }
264  const refused = await preCheck({ usage: env.io.usage, now: env.io.now }, action, env.rt.config, state.control.usageOverride)
265  if (refused !== null) {
266    const now = await env.io.now()
267    env.live.ledger = finish(env.live.ledger, action.key, refused, now, opts)
268    await env.save()
269    return done(refused)
270  }
271  // the entry is in the store, running, before anything happens: a reload, a crash or a restart
272  // (restart-self ends the session) mid-action finds it and marks it lost, so its attempt counts
273  // and its rule re-derives what to do (SPEC 7.5). A ledger that cannot be saved starts nothing.
274  if (!(await env.save())) {
275    const held: Exec = { status: 'skipped', summary: 'held: the ledger was not saved, so nothing starts' }
276    env.live.ledger = finish(env.live.ledger, action.key, held, await env.io.now(), opts)
277    return done(held)
278  }
279  const exec = await dispatch(ctxOf(env, state, s.entry), action)
280  await record(env, action.key, s.entry, exec, opts)
281  return done(exec)
282}
283
284// record: finish the attempt with the executor's Exec, saved before its loop event, unless a hook
285// already ended it: a wake answered (or a tell delivered) before its submit returned stays
286// answered, and an entry a later attempt or a hook replaced is left alone (SPEC 8.2, wake.ts
287// onTurnStart); the hook saved it.
288async function record(
289  env: ActEnv,
290  key: string,
291  started: LedgerEntry,
292  exec: Exec,
293  opts: { config: Runtime['config']; prev: LedgerEntry | null },
294): Promise<void> {
295  const now = await env.io.now()
296  const current = env.live.ledger.find(e => e.key === key)
297  if (current === undefined || current.startedAt !== started.startedAt || current.attempts !== started.attempts) return
298  if (exec.status === 'running' && current.status !== 'running') return
299  const moved = transition(env.live, l => finish(l, key, exec, now, opts))
300  await env.save()
301  await logLoop(env, moved)
302}
303
304// --- notices from outside plan (SPEC 9.2, 11.2, 11.4) --------------------------------------------
305
306// openWake: a judge, task or tell that is still running. Only one wake is open at a time (O2).
307export function openWake(ledger: readonly LedgerEntry[]): LedgerEntry | undefined {
308  return ledger.find(e => e.status === 'running' && WAKES.includes(e.kind))
309}
310
311// sendNotice: a tell that comes from outside plan (a rail, the ping breaker, failing passes) goes
312// out through the wake executor under its own key, once, and only while no wake is open: a key
313// the ledger holds was sent, unless it was lost (the engine dropped it before its turn); an open
314// wake keeps it waiting (the caller keeps it pending), as does the kill switch.
315export async function sendNotice(env: ActEnv, state: State, tell: Action): Promise<'sent' | 'waiting' | 'failed'> {
316  if (env.live.ledger.some(e => e.key === tell.key && e.status !== 'lost')) return 'sent'
317  if (openWake(env.live.ledger) !== undefined || (await env.killed())) return 'waiting'
318  const { exec } = await actOne(env, state, tell)
319  return exec.status === 'running' || exec.status === 'done' ? 'sent' : 'failed'
320}
321
322// passErrorTell: three passes in a row threw (SPEC 10.9): the owner hears of it once per spell.
323export function passErrorTell(since: Ms, passes: number, error: string): Action {
324  return {
325    kind: 'tell',
326    wake: 'pass-error',
327    key: `tell:pass-error:${since}`,
328    class: 'owner',
329    rule: 'O2',
330    reason: 'tell pass-error',
331    issue: null,
332    fields: { passes: String(passes) },
333    context: [`last error: ${error.replace(/\s+/g, ' ').slice(0, 300)}`],
334  }
335}
336
337// --- rate-cap holds (SPEC 11.4, D-24) ---------------------------------------------------------------
338
339// capKey: a rate cap plan tripped (a bucket, or '<kind>:<issue>') as its hourly loop event's key.
340export function capKey(capped: string, now: Ms): string {
341  return `cap:${capped}:${Math.floor(now / HOUR)}`
342}
343
344// capEventArgv: a rate-cap hold has no ledger entry of its own, so its `loop` event is built
345// here: one per capped bucket or kind and issue per hour, status held.
346export function capEventArgv(capped: string, now: Ms): string[] {
347  const [kind = capped, issue] = capped.split(':')
348  const argv = ['callum-flow-event', 'loop']
349  if (issue !== undefined && /^\d+$/.test(issue)) argv.push('--issue', issue)
350  argv.push('--note', `kind=${kind} key=${capKey(capped, now)} status=held`)
351  return argv
352}
353
354// breakerEventArgv: the ping-storm breaker opened (SPEC 2, 11.4, D-39): its `loop` event, beside
355// its one tell.
356export function breakerEventArgv(b: BreakerNotice): string[] {
357  return ['callum-flow-event', 'loop', '--note', `kind=breaker key=breaker:pings:${b.since} status=open passes=${b.passes} until=${b.until}`]
358}
359
360// --- what a reload and the agent list prove (SPEC 3.3, 7.5, 10.4) --------------------------------
361
362export type ListedAgent = { id: string; status: AgentStatus }
363
364// How long an agent sent the repair message counts as at work while the list still shows its
365// turn ended, idle or completed (reconcilable): the engine resumes it from its transcript (#548),
366// and when the list shows that is not proven. Past it, the usual one pass of grace, then lost.
367export const REPAIR_GRACE_MS = 5 * 60_000
368
369// reconcilable: the agent list as reconcileAgents should judge it. An agent listed idle has ended
370// its turn as one listed completed has (plan.ts turnEnded, #560), and each rule here reads them
371// alike. An agent whose answer is being handled counts as running, listed or not, so a pass in
372// between never marks it lost: that covers an agent whose repair message is on its way, since
373// agentAnswered holds it in `answering` until the engine answers the send. So does one whose
374// turn ended within REPAIR_GRACE_MS of its repair message being delivered (repairing: when each
375// went out, until its next turn.complete), for the loop awaits its reply; an agent first listed
376// with its turn ended counts as running for that pass, since its turn.complete may still be on
377// its way (seen: when each id was first so listed, idle then completed being one sighting). seen
378// and repairing are updated in place.
379export function reconcilable(
380  agents: readonly ListedAgent[],
381  answering: ReadonlySet<string>,
382  seen: Map<string, Ms>,
383  now: Ms,
384  repairing: Map<string, Ms>,
385): ListedAgent[] {
386  for (const [id, at] of [...repairing]) if (now - at > REPAIR_GRACE_MS) repairing.delete(id)
387  const out = agents.map(a => {
388    if (answering.has(a.id)) return { id: a.id, status: 'running' as AgentStatus }
389    if (!turnEnded(a.status)) return a
390    if (repairing.has(a.id)) return { id: a.id, status: 'running' as AgentStatus }
391    const first = seen.get(a.id)
392    if (first === undefined) seen.set(a.id, now)
393    return first === undefined || first === now ? { id: a.id, status: 'running' as AgentStatus } : a
394  })
395  for (const id of answering) if (!agents.some(a => a.id === id)) out.push({ id, status: 'running' })
396  for (const id of [...seen.keys()]) if (!agents.some(a => a.id === id && turnEnded(a.status))) seen.delete(id)
397  return out
398}
399
400// updateDone: rule H4's restart for this update ran and the workspace came back: its entry is
401// lost (the session restarted under it), or done before this module loaded. dev-restart-self
402// returns once Coder accepts the build, minutes before the container is replaced, so a restart
403// this module saw end is not done yet: the drain holds until the new session loads (D-15).
404// control.updateDue is then cleared, or the loop would drain forever. A failed restart keeps the
405// drain, and plan tells the owner (agent-failed).
406export function updateDone(ledger: readonly LedgerEntry[], control: Control, loadedAt: Ms): boolean {
407  const due = control.updateDue
408  if (due === null) return false
409  const restart = ledger.find(e => e.key === `restart:${due.at}`)
410  return restart?.status === 'lost' || (restart?.status === 'done' && (restart.endedAt ?? loadedAt) < loadedAt)
411}
412
413// --- one act-mode pass, from what observe read (SPEC 2) --------------------------------------------
414
415// What the hooks between passes share with the pass: the agents whose answer is being handled,
416// when each agent was first listed with its turn ended (idle or completed), and when each was
417// sent its repair message (reconcilable).
418export type Between = { answering: Set<string>; completedSeen: Map<string, Ms>; repairing: Map<string, Ms> }
419
420// The pass's agent list: only a read that worked may mark agents lost.
421export type Listed = { ok: boolean; agents: ListedAgent[] }
422
423// planned: when plan handed its actions over and how long it took (performance.now()), for the
424// trace (#552); shadow: the v2 shadow's plan, logged and never executed
425export type Acted = { state: State; result: PlanResult; report: ActReport; planned: { at: Ms; ms: number }; shadow: Shadowed | null }
426
427// The v2 shadow's plan, or why it could not plan: a shadow that throws never fails the pass.
428export type Shadowed = PlanResult | { error: string }
429
430// planPass: the planner's plan (v2 SPEC 2.3), and with planner v1 and the shadow on, the v2
431// shadow's beside it, which is logged and never executed. The shadow plans with the settings v2
432// would run with (v2Config: v2's defaults where no file sets a key), in the pass's mode.
433export function planPass(state: State, config: Config, v2Config: Config = config): { result: PlanResult; shadow: Shadowed | null } {
434  if (config.planner === 'v2') return { result: planV2(state, config), shadow: null }
435  const result = plan(state, config)
436  if (config.shadow !== 'v2') return { result, shadow: null }
437  try {
438    return { result, shadow: planV2(state, { ...v2Config, mode: config.mode }) }
439  } catch (err) {
440    return { result, shadow: { error: clip(`the v2 shadow threw: ${message(err)}`) } }
441  }
442}
443
444// finishAsRead: an answer read for an entry, applied only while the entry still runs as it was
445// read (its turn.complete may have finished it during the read); the entries it moved.
446function finishAsRead(env: ActEnv, was: LedgerEntry, exec: Exec, now: Ms): LedgerEntry[] {
447  const current = env.live.ledger.find(e => e.key === was.key)
448  if (current?.status !== 'running' || current.agentId !== was.agentId || current.startedAt !== was.startedAt) return []
449  return transition(env.live, l => finish(l, was.key, exec, now, { config: env.rt.config }))
450}
451
452// answeredBeforeLost: the agent entries the agent list would mark lost (gone from it, or its turn
453// ended with no answer read) whose agent recorded an answer with the answer script that passes its
454// checks (#561): each is finished with that answer, as its turn.complete would have finished it
455// (act-agents.ts scriptAnswered). An entry its turn.complete finished during the read is left
456// alone.
457async function answeredBeforeLost(env: ActEnv, state: State, agents: readonly ListedAgent[], now: Ms): Promise<LedgerEntry[]> {
458  const before = env.live.ledger
459  // reconcileAgents keeps the ledger's order, entry for entry
460  const reconciled = reconcileAgents(before, agents, now)
461  const lost = before.filter((e, i) => e.status === 'running' && reconciled[i]?.status === 'lost')
462  const moved: LedgerEntry[] = []
463  for (const was of lost) {
464    const exec = was.kind === 'agent-step' ? await stepRecorded(ctxOf(env, state, was), was, true) : await scriptAnswered(ctxOf(env, state, was), was)
465    if (exec !== null) moved.push(...finishAsRead(env, was, exec, now))
466  }
467  return moved
468}
469
470// actPass: the act-mode pass once the rails passed and observe read the factory: what the agent
471// list proves (SPEC 3.3, 10.4) and wakes past their timeout (SPEC 8.2) go into the ledger first,
472// then plan reads the state with that ledger, and its actions run in plan's order.
473export async function actPass(env: ActEnv, observed: State, listed: Listed, between: Between): Promise<Acted> {
474  const now = observed.now
475  const moved: LedgerEntry[] = []
476  if (listed.ok) {
477    const agents = reconcilable(listed.agents, between.answering, between.completedSeen, now, between.repairing)
478    moved.push(...(await answeredBeforeLost(env, observed, agents, now)))
479    moved.push(...transition(env.live, l => reconcileAgents(l, agents, now)))
480  }
481  for (const u of expireWakes(env.live.ledger, now, env.rt.config)) moved.push(...transition(env.live, l => finish(l, u.key, u, now)))
482  // under planner v2 a recorded answer ends its step when read (v2 SPEC 10.8)
483  if (env.rt.config.planner === 'v2') for (const r of await stepAnswers(env.live.ledger, e => ctxOf(env, observed, e))) moved.push(...finishAsRead(env, r.entry, r.exec, now))
484  if (moved.length > 0) {
485    await env.save()
486    await logLoop(env, moved)
487  }
488  const state = { ...observed, ledger: env.live.ledger }
489  const from = performance.now()
490  const { result, shadow } = planPass(state, env.rt.config, env.rt.v2Config)
491  const planned = { ms: performance.now() - from, at: await env.io.now() }
492  const report = await runActions(env, state, result.actions)
493  return { state, result, report, planned, shadow }
494}
495
496// --- between passes: the hooks that feed the ledger (SPEC 2, 8.2, 10.4) ----------------------------
497
498// The one repair message to an agent of the loop, of the type it runs as (a v1 agent's, or a v2
499// step's agent type, plugin:name): null once delivered, else why not, in the engine's own words.
500export type Repair = (agentId: string, text: string, type: AgentType | `${string}:${string}`) => Promise<string | null>
501
502// What an agent's answer did: 'applied' (its entry finished; repaired when a repair message went
503// out before it), 'repair' (unreadable, so the agent was asked once more), 'waiting' (interim),
504// 'recorded' (its tool already answered), 'dropped' or 'not ours'. key: its entry; via: how an
505// applied answer came (the answer script's file, #561, or its text); scriptProblem: what kept a
506// file it recorded out; undelivered: why its repair message was not delivered, whole, for the
507// decision log (the entry's summary keeps a clipped copy, #559).
508export type Answered = {
509  result: 'applied' | 'repair' | 'waiting' | 'recorded' | 'dropped' | 'not ours'
510  why?: string
511  key?: string
512  repaired?: boolean
513  via?: 'script' | 'text'
514  scriptProblem?: string
515  undelivered?: string
516}
517
518// agentAnswered: an agent of the loop finished (its turn.complete): its answer is read into the
519// outcome (act-agents.ts onAgentAnswer: the file it recorded with the answer script, else its
520// text; it logs the verdict and superseded events) and its entry finished. 'applied' asks for a
521// pass. An agent the ledger never held is 'not ours'; an answer for an entry that no longer runs
522// (an A1 stop timed it out, a retry replaced it) is logged and dropped, never applied, and the
523// closing words of an agent whose tool already answered change nothing. An interim answer (no
524// machine line, #545) from an agent atWork finds still at work leaves its entry running for its
525// next answer, up to its timeout (rule A1); atWork is asked only then. A final answer with no
526// machine line first gets one repair message to the same agent, naming what was wrong and asking
527// again on the channel its spawn was asked on (#548, #561): the entry runs on with its attempt
528// unspent, and only an answer still unreadable after it, or a message that could not be
529// delivered, fails the attempt (or sends the designer's report to the model, rule D3) as before.
530// A tool call is answered outside the hook queue (#558), so it may finish the entry while this
531// answer is read: the entry is checked again just before each change, with no await between, and
532// an entry its tool finished meanwhile is 'recorded', the text never read over it.
533export async function agentAnswered(
534  env: ActEnv,
535  state: State,
536  answer: AgentAnswer,
537  between: Between,
538  atWork: (agentId: string) => Promise<boolean> = async () => false,
539  repair: Repair = async () => 'the loop sends no messages from here',
540): Promise<Answered> {
541  const id = answer.agentId
542  const entry = env.live.ledger.find(e => e.agentId === id && (e.kind === 'spawn' || e.kind === 'delegate' || e.kind === 'agent-step'))
543  if (entry === undefined) return { result: 'not ours' }
544  if (entry.status === 'done') return { result: 'recorded', key: entry.key }
545  if (entry.status !== 'running') return { result: 'dropped', why: `late answer for ${entry.key} (${entry.status})` }
546  between.answering.add(id)
547  between.repairing.delete(id)
548  try {
549    // a v2 agent step's answer is read by steps.ts, and is never interim (v2 SPEC 10.8)
550    const step = entry.kind === 'agent-step' ? await stepAnswered(ctxOf(env, state, entry), entry, answer) : null
551    const read = step === null ? await onAgentAnswer(ctxOf(env, state, entry), entry, answer) : { exec: step.exec, problem: step.problem, via: 'script' as const, channel: 'script' as const }
552    let exec = read.exec
553    const ours = (e: LedgerEntry | undefined): e is LedgerEntry => e !== undefined && e.agentId === id && e.startedAt === entry.startedAt
554    const still = () => {
555      const current = env.live.ledger.find(e => e.key === entry.key)
556      return ours(current) && current.status === 'running' ? current : null
557    }
558    // the attempt no longer runs: its tool answered meanwhile, or a stop or a retry ended it
559    const ended = (): Answered => {
560      const current = env.live.ledger.find(e => e.key === entry.key)
561      return ours(current) && current.status === 'done' ? { result: 'recorded', key: entry.key } : { result: 'dropped', why: `answer for ${entry.key} came after the entry ended` }
562    }
563    if (still() === null) return ended()
564    const unread = step === null ? interim(answer, exec) : step.repair !== null
565    if (step === null && unread && (await atWork(id))) {
566      return { result: 'waiting', why: `interim answer for ${entry.key}: the agent still works, so its final answer is awaited` }
567    }
568    const at = await env.io.now()
569    const current = still()
570    if (current === null) return ended()
571    const repaired = current.reminded === true
572    const type = agentOf(entry)
573    let refused: string | undefined
574    if (unread && !repaired && (step !== null || type !== null)) {
575      const problem = read.problem ?? 'no machine line'
576      // the repair is on the entry before it goes out, so a reload never sends a second one
577      const marked = transition(env.live, l => finish(l, entry.key, { status: 'running', reminded: true }, at))
578      await env.save()
579      await logLoop(env, marked)
580      // the channel the spawn's prompt asked for (its record); text when it names none, or the
581      // script's job file is gone. A v2 step's own repair asks for the script again.
582      let ask: { text: string; type: AgentType | `${string}:${string}` } = { text: step?.repair ?? '', type: step?.subagentType ?? STEP_AGENT.build }
583      if (step === null && type !== null) {
584        const { channel, script } = await repairAskOf(env, entry, read.channel)
585        ask = { text: repairPrompt(type, purposeOfKey(entry.key), problem, channel, script), type }
586      }
587      const undelivered = await repair(id, ask.text, ask.type)
588      if (undelivered === null) {
589        // listed idle or completed until the message resumes it, which the list may show late: the
590        // passes take it as at work for a while, then count that listing afresh
591        between.completedSeen.delete(id)
592        between.repairing.set(id, at)
593        return { result: 'repair', why: problem, key: entry.key }
594      }
595      refused = undelivered
596      exec = { ...exec, summary: clip(`${exec.summary}; the repair message was not delivered: ${undelivered}`) }
597    }
598    const now = await env.io.now()
599    if (still() === null) return ended()
600    const moved = transition(env.live, l => finish(l, entry.key, exec, now, { config: env.rt.config }))
601    await env.save()
602    await logLoop(env, moved)
603    const applied: Answered = { result: 'applied', key: entry.key, repaired, via: read.via }
604    if (read.scriptProblem !== undefined) applied.scriptProblem = read.scriptProblem
605    if (refused !== undefined) applied.undelivered = refused
606    return applied
607  } finally {
608    between.answering.delete(id)
609  }
610}
611
612// The refusal of a tool call that could not be decided in time (#558): the agent answers in text.
613export const TOOL_LATE = 'Not recorded: the factory loop could not check this answer in time; reply with your text lines instead.'
614
615// Asked just before a tool call changes the ledger: false once the caller has answered the call
616// without it (register.ts bounded), and then nothing changes.
617export type Claim = () => boolean
618
619// What an answer tool's call did: ok and the text the agent reads; key: the entry it answered;
620// after: an accepted call's follow-up, run once the call has its answer (#558): its events, the
621// ledger saved and the entry's loop event, in that order (its input was saved before the call was
622// recorded); notes on what failed, for the decision log.
623export type ToolAnswered = { ok: boolean; text: string; key?: string; repaired?: boolean; after?: (env: ActEnv) => Promise<string[]> }
624
625// agentToolAnswered: an agent's answer tool (agent-tools.ts, #548), tied to the entry that
626// spawned it by the call's agentId. Only an agent whose entry still runs may answer: the main
627// conversation, an agent the loop did not start (an implementer's own sub-agents among them) and
628// one whose answer is in are refused. A call the contract refuses changes nothing and tells the
629// agent what to correct; an accepted one finishes the entry at once, as its text would (its
630// turn's closing words are then not read). Both channels share the reading, so a tool answer
631// means exactly what the same answer in text means. The call is answered before the engine gives
632// up on it (#558): the entry is checked and finished in the live ledger with no await between,
633// so the answer is true when given and every hook and pass after it sees the entry done, and
634// what is slow is left to `after`.
635export async function agentToolAnswered(
636  env: ActEnv,
637  state: State,
638  tool: string,
639  input: unknown,
640  agentId: string | undefined,
641  claim: Claim = () => true,
642): Promise<ToolAnswered> {
643  const no = (why: string, key?: string): ToolAnswered => (key === undefined ? { ok: false, text: `Not recorded: ${why}.` } : { ok: false, text: `Not recorded: ${why}.`, key })
644  if (agentId === undefined) return no('this tool takes the answer of an agent the factory loop started, and the main conversation is none')
645  const entry = env.live.ledger.find(e => e.agentId === agentId && (e.kind === 'spawn' || e.kind === 'delegate'))
646  if (entry === undefined) return no('the factory loop is waiting on no answer from this agent')
647  if (entry.status !== 'running') {
648    return no(entry.status === 'done' ? 'your answer is already recorded; end your turn' : `the factory loop no longer waits on this agent (${entry.status})`, entry.key)
649  }
650  const r = await onToolAnswer(ctxOf(env, state, entry), entry, tool, input)
651  if ('refused' in r) return no(r.refused, entry.key)
652  const now = await env.io.now()
653  const current = env.live.ledger.find(e => e.key === entry.key)
654  if (current === undefined || current.agentId !== agentId || current.status !== 'running' || current.startedAt !== entry.startedAt) {
655    return no('the factory loop stopped waiting on this agent meanwhile', entry.key)
656  }
657  if (!claim()) return { ok: false, text: TOOL_LATE, key: entry.key }
658  const moved = transition(env.live, l => finish(l, entry.key, r.exec, now, { config: env.rt.config }))
659  const after = async (later: ActEnv): Promise<string[]> => {
660    const notes = await r.after(ctxOf(later, state, entry))
661    await keep(later, moved)
662    return notes
663  }
664  return { ok: true, text: `Recorded: ${r.exec.summary}. End your turn now; no reply lines are needed.`, key: entry.key, repaired: current.reminded === true, after }
665}
666
667// keep: what follows a transition a hook made at once in the live ledger (#558): the ledger
668// saved, then the moved entries' loop events.
669export async function keep(env: ActEnv, moved: readonly LedgerEntry[]): Promise<void> {
670  if (moved.length === 0) return
671  await env.save()
672  await logLoop(env, moved)
673}
674
675const WAKE_HEAD = /^(JUDGE|TASK|TELL) /m
676
677// wakeStarted: a turn that opens with a wake's first line is that wake's turn (wake.ts
678// onTurnStart). A frame the engine puts before a plugin's prompt is skipped to that line.
679export async function wakeStarted(env: ActEnv, said: string, turnId: string): Promise<boolean> {
680  const at = said.search(WAKE_HEAD)
681  if (at < 0) return false
682  const now = await env.io.now()
683  const u = onTurnStart(env.live.ledger, said.slice(at), turnId)
684  if (u === null) return false
685  const moved = transition(env.live, l => finish(l, u.key, u, now))
686  await env.save()
687  await logLoop(env, moved)
688  return true
689}
690
691// wakeEnded: a main-loop turn ended (wake.ts onTurnComplete): its ANSWER blocks close their
692// wakes, a tell's own turn delivers it, an unanswered wake gets its one reminder. The caller
693// submits the prompts (never awaiting them inside the turn's own hook), logs the problems, and
694// pings when a wake moved.
695export async function wakeEnded(env: ActEnv, turn: TurnEnd): Promise<{ moved: boolean; prompts: string[]; problems: string[] }> {
696  const now = await env.io.now()
697  const out = onTurnComplete(env.live.ledger, turn, env.rt.repo)
698  const moved = out.updates.flatMap(u => transition(env.live, l => finish(l, u.key, u, now)))
699  if (moved.length > 0) {
700    await env.save()
701    await logLoop(env, moved)
702  }
703  return { moved: out.updates.length > 0, prompts: out.prompts, problems: out.problems }
704}
705
706// wakeAnswered: mcp__factory-loop__answer, checked as the ANSWER block is (wake.ts answerTool)
707// and recorded in the live ledger at once, with no await between the check and the change, as an
708// agent's tool is (#558); `moved` asks for keep, once the call has its answer. claim: as for
709// agentToolAnswered.
710export async function wakeAnswered(
711  env: ActEnv,
712  input: unknown,
713  agentId: string | undefined,
714  claim: Claim = () => true,
715): Promise<{ ok: boolean; text: string; moved: LedgerEntry[] }> {
716  const now = await env.io.now()
717  const r = answerTool(env.live.ledger, input, env.rt.repo, agentId === undefined ? {} : { agentId })
718  const update = r.update
719  if (update === null) return { ok: r.ok, text: r.text, moved: [] }
720  if (!claim()) return { ok: false, text: TOOL_LATE, moved: [] }
721  return { ok: r.ok, text: r.text, moved: transition(env.live, l => finish(l, update.key, update, now)) }
722}
723
724// retried: `/factory retry <N>` clears the issue's exhausted entries in the live ledger at once
725// (D-38, ledger.ts retryIssue): the keys it cleared, and the entries it moved, for keep once the
726// op has its answer (#558).
727export function retried(env: Pick<ActEnv, 'live' | 'rt'>, issue: number): { cleared: string[]; moved: LedgerEntry[] } {
728  let cleared: string[] = []
729  const moved = transition(env.live, l => {
730    const r = retryIssue(l, issue, env.rt.config)
731    cleared = r.cleared
732    return r.ledger
733  })
734  return { cleared, moved }
735}
736
737// --- small helpers --------------------------------------------------------------------------------
738
739function clip(text: string): string {
740  return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 3)}...` : text
741}
742
743function message(err: unknown): string {
744  return err instanceof Error ? err.message : String(err)
745}
746
hooks/act-agents.ts 647 lines
1// The agent executors (SPEC 6.1): `spawn` (designer, adjudicator, fixer) and `delegate` (route,
2// the `delegated` event, then the implementer), and onAgentAnswer and onToolAnswer, which read
3// an agent's answer, from its turn.complete (the file it recorded with the answer script, #561,
4// else its text) or its answer tool (#548), into the outcome plan reads next pass. They decide
5// nothing (plan did): the prompt comes from prompts.ts and the reading of the answer from
6// replies.ts and contract.mjs, all pure, and readAnswer, readScriptAnswer and readToolAnswer
7// below are pure too, so tests and the replay harness drive them without the engine. Every
8// answer in text is saved whole first (exec.ts answerPath), so a judge(report) or judge(handoff)
9// wake points the model at the file (SPEC 7.2); a tool call's input is saved once the call has
10// its answer (#558).
11import { TOOL_OF } from './agent-tools'
12import { CONTRACT_OF, checkAnswer, jobPathOf, jobText, parseAnswer } from './contract.mjs'
13import type { AnswerOutcome, Check, Job } from './contract.mjs'
14import { ANSWER_SCRIPT, answerPath, detailOf, rateLimited, scriptAnswerPath } from './exec'
15import type { AgentAnswer, EventFields, Exec, ExecCtx, Executor, RunResult, SpawnInput } from './exec'
16import type { Runtime } from './observe'
17import { descriptionOf, nameOf, promptOf, scriptShaped } from './prompts'
18import type { AgentAction, InFlight, PromptEnv, ScriptPlace } from './prompts'
19import { isBriefUrl, readAdjudicator, readDesigner, readHandoff } from './replies'
20import type { ActionKind, AdjudicatorPurpose, AgentStatus, AgentType, AnswerChannel, LedgerEntry, State } from './types'
21
22// callum-flow-implementer-route's timeout (SPEC 6.1: route 60 s).
23export const ROUTE_TIMEOUT_MS = 60_000
24// The exit recorded for a command that never exited (it could not start, or timed out).
25const NO_EXIT = -1
26const SUMMARY_MAX = 200
27const PURPOSES: readonly AdjudicatorPurpose[] = ['gate', 'weakening', 'merge']
28const CHANNELS: readonly AnswerChannel[] = ['script', 'tool', 'text']
29const MODEL = /^[A-Za-z0-9._-]+$/
30
31// --- pure helpers ------------------------------------------------------------------------------
32
33// agentOf: the agent an entry ran: its spawn's type; a delegate's is the implementer.
34export function agentOf(entry: LedgerEntry): AgentType | null {
35  if (entry.kind === 'delegate') return 'implementer'
36  return entry.kind === 'spawn' ? (entry.agent ?? null) : null
37}
38
39// purposeOfKey: an adjudicator's purpose, from plan's key families (adjudicate:,
40// adjudicate-weakening:, adjudicate-merge:).
41export function purposeOfKey(key: string): AdjudicatorPurpose | null {
42  if (key.startsWith('adjudicate-merge:')) return 'merge'
43  if (key.startsWith('adjudicate-weakening:')) return 'weakening'
44  if (key.startsWith('adjudicate:')) return 'gate'
45  return null
46}
47
48// routedModel: callum-flow-implementer-route prints exactly one line, the model alias the
49// implementer runs on (images/base/callum-flow-implementer-route); anything else is null.
50export function routedModel(stdout: string): string | null {
51  const lines = stdout
52    .split('\n')
53    .map(l => l.trim())
54    .filter(l => l !== '')
55  const only = lines.length === 1 ? lines[0] : undefined
56  return only !== undefined && MODEL.test(only) ? only : null
57}
58
59// modelLabelOf: the issue's `model:<alias>` label (lower-cased, the first in sorted order, as
60// plan reads it), which the implementer passes on to the sub-agents it spawns.
61export function modelLabelOf(state: State, issue: number | null): string | null {
62  const labels = (state.issues.find(i => i.number === issue)?.labels ?? [])
63    .map(l => l.toLowerCase())
64    .filter(l => l.startsWith('model:'))
65    .sort()
66  const first = labels[0]?.slice('model:'.length)
67  return first !== undefined && MODEL.test(first) ? first : null
68}
69
70// inFlightOf: the other issues' current branches, for the designer (orchestrator skill step 2).
71export function inFlightOf(state: State, issue: number | null): InFlight[] {
72  return state.runs
73    .filter(r => !r.superseded && r.issue !== issue)
74    .map(r => ({ branch: r.branch, issue: r.issue }))
75    .sort((a, b) => (a.branch < b.branch ? -1 : a.branch > b.branch ? 1 : 0))
76}
77
78export type SpawnOptions = {
79  repo: string
80  attempt: number
81  // the model to run on: the issue's label for a spawn, the route's answer for a delegate
82  model: string | null
83  modelOverride: string | null
84  inFlight: InFlight[]
85  notes?: readonly string[]
86  // the channel the prompt asks for (config answerChannel), and for the script channel the
87  // script and this attempt's answer file (#561)
88  channel?: AnswerChannel
89  script?: ScriptPlace | null
90}
91
92// notesOf: the owner's notes for an issue, and those for every issue, oldest first (SPEC 4.3).
93export function notesOf(state: State, issue: number | null): string[] {
94  return state.control.notes.filter(note => note.issue === null || note.issue === issue).map(note => note.text)
95}
96
97// The agent type each agent is spawned as. The callum-flow adjudicator and designer list their
98// tools, and an agent calls nothing its list leaves out, so they could never reach their answer
99// tools (#548): the loop spawns its own copies of them (agents/*.md), the same definitions with
100// ToolSearch and the answer tool added to `tools:`, which tests/factory-loop-agents.test.js keeps
101// in step. The implementer and fixer list no tools, so they inherit every one.
102export const SUBAGENT_OF: Record<AgentType, string> = {
103  adjudicator: 'factory-loop:adjudicator',
104  designer: 'factory-loop:designer',
105  implementer: 'callum-flow:implementer',
106  fixer: 'callum-flow:fixer',
107}
108
109// spawnInputOf: what $.agent.spawn gets: the agent's type, its prompt, and the issue in the
110// description and name (D-11). No model leaves the agent's frontmatter model in charge.
111export function spawnInputOf(action: AgentAction, opts: SpawnOptions): SpawnInput {
112  const type: AgentType = action.kind === 'delegate' ? 'implementer' : action.agent
113  const env: PromptEnv = { repo: opts.repo, modelOverride: opts.modelOverride, inFlight: opts.inFlight, notes: opts.notes ?? [], channel: opts.channel ?? 'text', script: opts.script ?? null }
114  const input: SpawnInput = {
115    subagentType: SUBAGENT_OF[type],
116    prompt: promptOf(action, env),
117    description: descriptionOf(action),
118    name: nameOf(action, opts.attempt),
119  }
120  if (opts.model !== null && MODEL.test(opts.model)) input.model = opts.model
121  return input
122}
123
124// --- what a spawn leaves for its answer -----------------------------------------------------------
125
126// The spawn record: written before the spawn, read back with the answer. It holds what the
127// ledger entry has no field for: the branch the spawn named (the adjudicator's verdict events
128// carry it; a fixer that reports another one supersedes the old run), the adjudicator's purpose,
129// and the channel the prompt asked for, with the answer file it named for the script (#561), so
130// a repair message asks again the way the agent was first asked. The prompt is kept for the
131// owner, beside the answer.
132export type SpawnRecord = {
133  key: string
134  attempt: number
135  agent: AgentType
136  purpose: AdjudicatorPurpose | null
137  issue: number | null
138  run: string | null
139  branch: string | null
140  subagentType: string
141  model: string | null
142  channel: AnswerChannel
143  answerFile: string | null
144  prompt: string
145}
146
147export function spawnRecordPath(loopDir: string, key: string): string {
148  return `${loopDir}/spawns/${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.json`
149}
150
151function recordOf(action: AgentAction, input: SpawnInput, attempt: number, script: ScriptPlace | null, channel: AnswerChannel): SpawnRecord {
152  const spawn = action.kind === 'spawn' ? action : null
153  return {
154    key: action.key,
155    attempt,
156    agent: action.kind === 'delegate' ? 'implementer' : action.agent,
157    purpose: spawn?.agent === 'adjudicator' ? spawn.purpose : null,
158    issue: action.issue,
159    run: spawn !== null && spawn.agent !== 'designer' ? spawn.run : null,
160    branch: namedBranchOf(action),
161    subagentType: input.subagentType,
162    model: input.model ?? null,
163    channel,
164    answerFile: script?.file ?? null,
165    prompt: input.prompt,
166  }
167}
168
169// namedBranchOf: the branch a spawn names (an adjudicator's or fixer's), or null.
170function namedBranchOf(action: AgentAction): string | null {
171  const spawn = action.kind === 'spawn' ? action : null
172  return spawn !== null && spawn.agent !== 'designer' && spawn.branch !== '' ? spawn.branch : null
173}
174
175// parseRecord: a saved spawn record's branch, purpose and channel, or null when it does not read.
176// A record from before the channel was kept names none.
177export function parseRecord(text: string | null): (Pick<SpawnRecord, 'branch' | 'purpose'> & { channel: AnswerChannel | null }) | null {
178  if (text === null) return null
179  try {
180    const v: unknown = JSON.parse(text)
181    if (v === null || typeof v !== 'object') return null
182    const r = v as Record<string, unknown>
183    return {
184      branch: typeof r.branch === 'string' && r.branch !== '' ? r.branch : null,
185      purpose: PURPOSES.find(p => p === r.purpose) ?? null,
186      channel: CHANNELS.find(c => c === r.channel) ?? null,
187    }
188  } catch {
189    return null
190  }
191}
192
193// --- reading an answer --------------------------------------------------------------------------
194
195export type AgentEvent = { state: string; fields: EventFields }
196export type AnswerInfo = {
197  repo: string
198  // the branch the spawn named (spawn record, else the run's branch in the state)
199  branch: string | null
200  // the spawn record's purpose, when the key does not say
201  purpose: AdjudicatorPurpose | null
202  // the channel the spawn's prompt asked for (its record), null when not known
203  channel?: AnswerChannel | null
204}
205// problem: what kept an answer from being read (no machine line), for the repair message (#548)
206export type AnswerRead = { exec: Exec; events: AgentEvent[]; problem: string | null }
207// How an answer came: recorded with the answer script, through the tool, or in text.
208export type AnswerVia = 'script' | 'tool' | 'text'
209
210// readAnswer: an agent's answer as an Exec, and the events its outcome logs (SPEC 7.3, 7.4):
211// - a turn that ended in error, aborted or refused is a failed attempt (SPEC 10.4);
212// - the designer's report is done, parsed or { unparsed: true } (rule D3 sends that one to the
213//   model);
214// - the adjudicator's reply is done with its verdict lines, each logged as a `verdict` event,
215//   or a failed attempt when it does not parse (rule P2 retries);
216// - an implementer's or fixer's handoff is done with its fields, or failed when it does not
217//   parse; a fixer that reports a branch other than the one it was sent to supersedes the old
218//   run (I-39).
219// A reply with no machine line says why, in its summary and in problem.
220export function readAnswer(entry: LedgerEntry, answer: AgentAnswer, info: AnswerInfo): AnswerRead {
221  const type = agentOf(entry)
222  const who = `${type ?? entry.kind} #${entry.issue ?? '?'}`
223  const failed = (summary: string, problem: string | null = null, noRetry = false): AnswerRead => ({
224    exec: noRetry ? { status: 'failed', summary: clip(summary), noRetry } : { status: 'failed', summary: clip(summary) },
225    events: [],
226    problem,
227  })
228  if (type === null) return failed(`${entry.key}: no agent entry`, null, true)
229  if (answer.reason !== 'answer') return failed(`${who}: its turn ended: ${answer.reason}`)
230  if (type === 'designer') {
231    const r = readDesigner(answer.answer, info.repo, entry.issue)
232    if (typeof r !== 'string') return doneRead(entry, type, r, info, 'text')
233    const exec: Exec = { status: 'done', outcome: { kind: 'designer', unparsed: true }, summary: clip(`${who}: ${r}; the report is saved for judge(report)`) }
234    return { exec, events: [], problem: r }
235  }
236  if (type === 'adjudicator') {
237    const purpose = purposeOfKey(entry.key) ?? info.purpose
238    if (purpose === null) return failed(`${who}: ${entry.key} names no adjudicator purpose`, null, true)
239    const r = readAdjudicator(answer.answer, purpose)
240    return typeof r === 'string' ? failed(`${who}: the ${purpose} reply does not parse: ${r}`, r) : doneRead(entry, type, r, info, 'text')
241  }
242  const r = readHandoff(answer.answer, type === 'fixer' ? info.branch : null)
243  return typeof r === 'string' ? failed(`${who}: the handoff does not parse: ${r}`, r) : doneRead(entry, type, r, info, 'text')
244}
245
246// jobOf: what checking this entry's answer needs, from what the loop knows itself (the entry, its
247// spawn record and the runtime): the contract of its agent, the review's purpose, the repo and
248// issue a brief must be on, the branch a fixer was sent to. Every structured answer is checked
249// against it (contract.mjs checkAnswer), never against anything the agent wrote; null when the
250// loop cannot tell which review an adjudicator's is.
251export function jobOf(entry: LedgerEntry, type: AgentType, info: AnswerInfo): Job | null {
252  const purpose = type === 'adjudicator' ? (purposeOfKey(entry.key) ?? info.purpose) : null
253  if (type === 'adjudicator' && purpose === null) return null
254  return { contract: CONTRACT_OF[type], purpose, repo: info.repo, issue: entry.issue, branch: type === 'fixer' ? info.branch : null }
255}
256
257// readToolAnswer: an agent's answer through its tool (agent-tools.ts), read as readAnswer reads
258// its text: the same outcome and events, or why the call is refused (another agent's tool, an
259// input that breaks the contract), which the agent reads and corrects. Pure.
260export function readToolAnswer(entry: LedgerEntry, tool: string, input: unknown, info: AnswerInfo): AnswerRead | { refused: string } {
261  const type = agentOf(entry)
262  if (type === null) return { refused: `${entry.key} ran no agent` }
263  if (tool !== TOOL_OF[type]) return { refused: `${tool} is not the answer of a ${type}: call ${TOOL_OF[type]} instead` }
264  const job = jobOf(entry, type, info)
265  if (job === null) return { refused: 'the loop cannot tell which review this is: reply in your output contract instead' }
266  const check = checkAnswer(job, input)
267  return check.ok ? doneRead(entry, type, check.outcome, info, 'tool') : { refused: `${check.problems.join('; ')}. Correct the call and call ${tool} again` }
268}
269
270// readScriptAnswer: the file an agent recorded with the answer script (#561), read and checked
271// again against the job the loop knows (jobOf), never the job file beside it, so it means exactly
272// what the same answer through the tool or in text means: the same outcome and events, or what
273// is wrong with it, for the repair message. Pure.
274export function readScriptAnswer(entry: LedgerEntry, body: string, info: AnswerInfo): AnswerRead | { problem: string } {
275  const type = agentOf(entry)
276  if (type === null) return { problem: `${entry.key} ran no agent` }
277  const job = jobOf(entry, type, info)
278  if (job === null) return { problem: 'the factory loop cannot tell which review this is' }
279  const file = parseAnswer(body, job.contract)
280  const check: Check<AnswerOutcome> = file.ok ? checkAnswer(job, file.outcome) : file
281  return check.ok ? doneRead(entry, type, check.outcome, info, 'script') : { problem: `the answer recorded with the answer script does not pass its checks: ${check.problems.join('; ')}` }
282}
283
284// doneRead: a read outcome as a done Exec and the events it logs, whichever channel gave it; an
285// answer through the tool or the script says so in its summary.
286function doneRead(entry: LedgerEntry, type: AgentType, outcome: AnswerOutcome, info: AnswerInfo, via: AnswerVia): AnswerRead {
287  const who = (inner: string[]) => {
288    const all = via === 'text' ? inner : [...inner, via]
289    return `${type} #${entry.issue ?? '?'}${all.length === 0 ? '' : ` (${all.join(', ')})`}`
290  }
291  const done = (summary: string, events: AgentEvent[] = []): AnswerRead => ({ exec: { status: 'done', outcome, summary: clip(summary) }, events, problem: null })
292  if (outcome.kind === 'designer') {
293    return done(`${who([])}: brief ${outcome.brief ?? 'none'}, created ${outcome.created.join(',') || 'none'}, pending decision ${outcome.pendingDecision ? 'yes' : 'no'}`)
294  }
295  if (outcome.kind === 'adjudicator') {
296    const purpose = purposeOfKey(entry.key) ?? info.purpose ?? 'gate'
297    const events = outcome.verdicts.map(line => ({
298      state: 'verdict',
299      fields: { issue: entry.issue, run: entry.run, branch: info.branch, note: line },
300    }))
301    const report = outcome.report === null ? '' : `, bump ${outcome.report.bump}, flags ${outcome.report.flags.join(',') || 'none'}`
302    const n = outcome.verdicts.length
303    return done(`${who([purpose])}: ACTION ${outcome.action.type}, ${n} verdict line${n === 1 ? '' : 's'}${report}`, events)
304  }
305  const named = type === 'fixer' ? info.branch : null
306  const events: AgentEvent[] = []
307  if (type === 'fixer' && outcome.newBranch !== null && named !== null && entry.run !== null) {
308    events.push({ state: 'superseded', fields: { issue: entry.issue, run: entry.run, note: `by ${outcome.newBranch}` } })
309  }
310  const parts = [`run ${outcome.run ?? 'none'}`, `head ${outcome.head ?? 'none'}`]
311  if (outcome.notGated) parts.push('NOT GATED')
312  if (outcome.newBranch !== null) parts.push(`branch ${outcome.newBranch}`)
313  return done(`${who([])}: ${parts.join(', ')}`, events)
314}
315
316// --- an interim answer (#545) ----------------------------------------------------------------
317
318// One row of the agent list as the interim check reads it.
319export type ListedLoop = { id: string; status: AgentStatus; parentId?: string }
320
321const AT_WORK: readonly AgentStatus[] = ['pending', 'running', 'waiting']
322
323// interim: an answer that ended its turn with no machine line (the designer's RESULT, the
324// adjudicator's reply, a handoff's RESULT or NOT GATED), which an agent still at work may give
325// before its final one. A turn that failed, and an answer read as a loop bug, are never interim.
326export function interim(answer: AgentAnswer, exec: Exec): boolean {
327  if (answer.reason !== 'answer') return false
328  if (exec.status === 'failed') return exec.noRetry !== true
329  return exec.status === 'done' && exec.outcome?.kind === 'designer' && 'unparsed' in exec.outcome
330}
331
332// stillAtWork: the agent list shows the agent holding background work (`waiting`), or a
333// sub-agent it started still at work. Its own `running` proves nothing at its turn.complete: an
334// agent that just ended is listed so until the engine marks it idle or completed.
335export function stillAtWork(agents: readonly ListedLoop[], id: string): boolean {
336  return agents.some(a => (a.id === id && a.status === 'waiting') || (a.parentId === id && AT_WORK.includes(a.status)))
337}
338
339// What goes between an answer and the reply to its repair message in the saved file.
340export const REPAIR_REPLY = "--- the reply to the factory loop's repair message ---"
341// What goes between an agent's final message and the answer it recorded with the answer script.
342export const SCRIPT_RECORD = '--- the answer recorded with the answer script ---'
343
344// scriptPlaceOf: the answer script and the answer file of an entry's attempt (#561): the paths a
345// spawn's prompt names, and the only answer file read for that attempt.
346export function scriptPlaceOf(rt: Pick<Runtime, 'pluginRoot' | 'loopDir'>, entry: Pick<LedgerEntry, 'key' | 'attempts' | 'startedAt'>): ScriptPlace {
347  return { bin: `${rt.pluginRoot}/${ANSWER_SCRIPT}`, file: scriptAnswerPath(rt.loopDir, entry) }
348}
349
350// What an agent's turn.complete gave: the Exec to record, the problem for the repair message,
351// how the answer came (via: the answer script's file, else its text), what kept a recorded file
352// out (scriptProblem, for the decision log), and the channel its spawn was asked on (null: not
353// known, so the setting's).
354export type AnswerTaken = { exec: Exec; problem: string | null; via: 'script' | 'text'; scriptProblem?: string; channel: AnswerChannel | null }
355
356// onAgentAnswer: an agent of the loop finished (its turn.complete). Its text is saved whole
357// first. Then the answer it recorded with the answer script (#561) is read, only at the path
358// issued for this attempt (scriptPlaceOf), and checked again; with none there, or one that fails
359// its checks, its text is read as before. The events the outcome logs go out; the driver records
360// the Exec, or first sends the agent the repair message for the problem (act.ts agentAnswered).
361// An answer from the script follows the text in the saved file, under SCRIPT_RECORD. The reply
362// to a repair goes after the answer it repairs, never over it: a designer's report is what
363// judge(report) reads (rule D3), and the reply is a line at most. A failed save or event is
364// noted in the summary but keeps the reading: re-running an agent costs far more.
365export async function onAgentAnswer(ctx: ExecCtx, entry: LedgerEntry, answer: AgentAnswer): Promise<AnswerTaken> {
366  const notes: string[] = []
367  const path = answerPath(ctx.rt.loopDir, entry.key)
368  let said: string | null = null
369  try {
370    const before = entry.reminded === true ? await ctx.io.readFile(path) : null
371    said = before === null ? answer.answer : `${before.replace(/\n*$/, '')}\n\n${REPAIR_REPLY}\n\n${answer.answer}`
372    await ctx.io.writeFile(path, said)
373  } catch (err) {
374    notes.push(`answer not saved: ${message(err)}`)
375  }
376  const info = await answerInfoOf(ctx, entry)
377  const recorded = await scriptAnswerOf(ctx, entry)
378  const script = recorded === null ? null : readScriptAnswer(entry, recorded.body, info)
379  const channel = info.channel ?? null
380  if (recorded !== null && script !== null && 'exec' in script) {
381    if (said !== null) {
382      try {
383        await ctx.io.writeFile(path, `${said.replace(/\n*$/, '')}\n\n${SCRIPT_RECORD}\n\n${recorded.body.replace(/\n*$/, '')}\n`)
384      } catch (err) {
385        notes.push(`recorded answer not saved: ${message(err)}`)
386      }
387    }
388    notes.push(...(await logEvents(ctx, script.events)))
389    return { exec: noted(script.exec, notes), problem: null, via: 'script', channel }
390  }
391  const read = readAnswer(entry, answer, info)
392  notes.push(...(await logEvents(ctx, read.events)))
393  const scriptProblem = script !== null && !('exec' in script) ? script.problem : undefined
394  const problem = read.problem === null || scriptProblem === undefined ? read.problem : `${scriptProblem}; and the reply: ${read.problem}`
395  const out: AnswerTaken = { exec: noted(read.exec, notes), problem, via: 'text', channel }
396  if (scriptProblem !== undefined) out.scriptProblem = scriptProblem
397  return out
398}
399
400// scriptAnswerOf: the answer file at the path issued for this attempt, or null when there is none
401// (the agent recorded nothing, or was asked on another channel). Nothing at any other path is
402// read: who answered is the path.
403async function scriptAnswerOf(ctx: ExecCtx, entry: LedgerEntry): Promise<{ body: string } | null> {
404  try {
405    const body = await ctx.io.readFile(scriptPlaceOf(ctx.rt, entry).file)
406    return body === null ? null : { body }
407  } catch {
408    return null
409  }
410}
411
412// scriptAnswered: the answer an agent of the loop recorded with the answer script, for an agent
413// the agent list shows ended or gone before its turn.complete was read (act.ts actPass, #561): read
414// and checked as onAgentAnswer reads it, saved under SCRIPT_RECORD (after an answer it repairs),
415// its events logged, and the Exec to finish its entry with. Null when there is none or it fails
416// its checks, and the entry is lost as before.
417export async function scriptAnswered(ctx: ExecCtx, entry: LedgerEntry): Promise<Exec | null> {
418  const recorded = await scriptAnswerOf(ctx, entry)
419  if (recorded === null) return null
420  const script = readScriptAnswer(entry, recorded.body, await answerInfoOf(ctx, entry))
421  if (!('exec' in script)) return null
422  const notes = ['read before its turn.complete']
423  const path = answerPath(ctx.rt.loopDir, entry.key)
424  try {
425    const before = entry.reminded === true ? await ctx.io.readFile(path) : null
426    const record = `${SCRIPT_RECORD}\n\n${recorded.body.replace(/\n*$/, '')}\n`
427    await ctx.io.writeFile(path, before === null ? record : `${before.replace(/\n*$/, '')}\n\n${record}`)
428  } catch (err) {
429    notes.push(`recorded answer not saved: ${message(err)}`)
430  }
431  notes.push(...(await logEvents(ctx, script.events)))
432  return noted(script.exec, notes)
433}
434
435// repairAskOf: the channel a repair message asks on (act.ts agentAnswered), with the answer
436// script's place on the script channel: the channel the spawn's record names, the script only
437// while this attempt's job file stands beside its answer file, since the script refuses an answer
438// file without one. With no channel named (no record, or one from before the channel was kept) or
439// no job file, text, the channel always read.
440export async function repairAskOf(ctx: Pick<ExecCtx, 'io' | 'rt'>, entry: LedgerEntry, channel: AnswerChannel | null): Promise<{ channel: AnswerChannel; script: ScriptPlace | null }> {
441  if (channel !== 'script') return { channel: channel ?? 'text', script: null }
442  const place = scriptPlaceOf(ctx.rt, entry)
443  const job = jobPathOf(place.file)
444  let body: string | null = null
445  try {
446    body = job === null ? null : await ctx.io.readFile(job)
447  } catch {
448    body = null
449  }
450  return body === null ? { channel: 'text', script: null } : { channel: 'script', script: place }
451}
452
453// onToolAnswer: an agent of the loop called its answer tool, read against its spawn record. A
454// call the contract refuses changes nothing. An accepted one has its input saved (as JSON) before
455// the driver records it, so a pass that acts on the answer (judge(handoff) points the model at the
456// saved answer) finds it there; a call then refused for time leaves the file to the text answer,
457// which writes it again. It gives the Exec the driver records at once, as for an answer in text,
458// and `after`, what follows once the call has its answer (#558): its events logged, through the
459// ExecCtx it is handed; notes on what failed, for the decision log.
460export async function onToolAnswer(
461  ctx: ExecCtx,
462  entry: LedgerEntry,
463  tool: string,
464  input: unknown,
465): Promise<{ exec: Exec; after: (later: ExecCtx) => Promise<string[]> } | { refused: string }> {
466  const read = readToolAnswer(entry, tool, input, await answerInfoOf(ctx, entry))
467  if ('refused' in read) return read
468  const saved: string[] = []
469  try {
470    await ctx.io.writeFile(answerPath(ctx.rt.loopDir, entry.key), JSON.stringify({ tool, input }, null, 2))
471  } catch (err) {
472    saved.push(`answer not saved: ${message(err)}`)
473  }
474  const after = async (later: ExecCtx): Promise<string[]> => [...saved, ...(await logEvents(later, read.events))]
475  return { exec: read.exec, after }
476}
477
478// answerInfoOf: what reading an answer needs beside the entry: the branch and purpose its spawn
479// record names. With no record, the run's branch in the state stands in.
480async function answerInfoOf(ctx: ExecCtx, entry: LedgerEntry): Promise<AnswerInfo> {
481  let record: ReturnType<typeof parseRecord> = null
482  try {
483    record = parseRecord(await ctx.io.readFile(spawnRecordPath(ctx.rt.loopDir, entry.key)))
484  } catch {
485    // no record
486  }
487  const branch = record?.branch ?? (entry.run === null ? null : (ctx.state.runs.find(r => r.run === entry.run)?.branch ?? null))
488  return { repo: ctx.rt.repo, branch, purpose: record?.purpose ?? null, channel: record?.channel ?? null }
489}
490
491// logEvents: the events an outcome logs; a note when some could not be.
492async function logEvents(ctx: ExecCtx, events: readonly AgentEvent[]): Promise<string[]> {
493  let missed = 0
494  for (const e of events) {
495    try {
496      await ctx.event(e.state, e.fields)
497    } catch {
498      missed += 1
499    }
500  }
501  return missed > 0 ? [`${missed} of ${events.length} events not logged`] : []
502}
503
504function noted(exec: Exec, notes: readonly string[]): Exec {
505  return notes.length === 0 ? exec : { ...exec, summary: clip(`${exec.summary}; ${notes.join('; ')}`) }
506}
507
508// --- the executors --------------------------------------------------------------------------------
509
510function refused(why: string): Exec {
511  return { status: 'failed', summary: clip(`refused: ${why}`), noRetry: true }
512}
513
514// scriptFor: on the script channel (config answerChannel, the default), the answer script and
515// this attempt's answer file, once the job file the script checks against stands beside it; null
516// when the channel is another, or the paths are not ones a prompt may carry, or the job file
517// cannot be written, and the prompt then asks for text. The script channel is a TEMPORARY
518// WORKAROUND (#561, DECISIONS D-74) for the registered answer tools (#548), which an agent the
519// loop spawns from its timer cannot reach on Claude Code 2.1.296 and 2.1.299 (#560).
520async function scriptFor(ctx: ExecCtx, action: AgentAction): Promise<ScriptPlace | null> {
521  if (ctx.rt.config.answerChannel !== 'script') return null
522  const place = scriptPlaceOf(ctx.rt, ctx.entry)
523  const job = jobPathOf(place.file)
524  if (job === null || !scriptShaped(place)) return null
525  const type: AgentType = action.kind === 'delegate' ? 'implementer' : action.agent
526  const purpose = action.kind === 'spawn' && action.agent === 'adjudicator' ? action.purpose : null
527  try {
528    await ctx.io.writeFile(job, jobText({ contract: CONTRACT_OF[type], purpose, repo: ctx.rt.repo, issue: action.issue, branch: type === 'fixer' ? namedBranchOf(action) : null }))
529  } catch {
530    return null
531  }
532  return place
533}
534
535// start: write the answer script's job file (on the script channel) and the spawn record, then
536// spawn. A deny, a throw or a start with no agent id is a failed attempt; a started agent runs on,
537// and its turn.complete ends the entry.
538async function start(ctx: ExecCtx, action: AgentAction, model: string | null, env: Omit<PromptEnv, 'repo'>): Promise<Exec> {
539  const script = await scriptFor(ctx, action)
540  const channel: AnswerChannel = script !== null ? 'script' : ctx.rt.config.answerChannel === 'tool' ? 'tool' : 'text'
541  const input = spawnInputOf(action, { repo: ctx.rt.repo, attempt: ctx.entry.attempts, model, channel, script, ...env })
542  let saved = true
543  try {
544    const record = recordOf(action, input, ctx.entry.attempts, script, channel)
545    await ctx.io.writeFile(spawnRecordPath(ctx.rt.loopDir, action.key), JSON.stringify(record, null, 2))
546  } catch {
547    saved = false
548  }
549  let r: { agentId?: string; deny?: string }
550  try {
551    r = await ctx.io.spawnAgent(input)
552  } catch (err) {
553    return { status: 'failed', summary: clip(`${input.subagentType}: the spawn threw: ${message(err)}`) }
554  }
555  if (typeof r.deny === 'string') return { status: 'failed', summary: clip(`${input.subagentType} denied: ${r.deny}`) }
556  if (r.agentId === undefined || r.agentId === '') return { status: 'failed', summary: `${input.subagentType}: started with no agent id` }
557  const on = input.model === undefined ? '' : ` (${input.model})`
558  const note = saved ? '' : '; spawn record not saved'
559  return { status: 'running', agentId: r.agentId, summary: clip(`${input.subagentType}${on} started for #${action.issue}: ${r.agentId}${note}`) }
560}
561
562// spawn: the designer, an adjudicator or a fixer, on the issue's model label when it has one.
563const spawn: Executor = async (ctx, action) => {
564  if (action.kind !== 'spawn') return refused(`the spawn executor got a ${action.kind} action`)
565  if (action.issue === null) return refused(`${action.key} names no issue`)
566  const inFlight = action.agent === 'designer' ? inFlightOf(ctx.state, action.issue) : []
567  return start(ctx, action, action.model, { modelOverride: action.model, inFlight, notes: notesOf(ctx.state, action.issue) })
568}
569
570// delegate: `callum-flow-implementer-route <N>`, then `callum-flow-event delegated --issue <N>
571// --note "brief <url>"`, then the implementer on the routed model (SPEC 6.1, I-32). A route
572// that fails, or prints no model, ends it before anything is logged.
573const delegate: Executor = async (ctx, action) => {
574  if (action.kind !== 'delegate') return refused(`the delegate executor got a ${action.kind} action`)
575  const n = action.issue
576  if (n === null) return refused(`${action.key} names no issue`)
577  // a brief travels as its URL only (I-33); anything else here is a loop bug
578  if (!isBriefUrl(action.brief, ctx.rt.repo)) return refused(`${action.key}: the brief is not a comment URL on ${ctx.rt.repo}`)
579  const argv = ['callum-flow-implementer-route', String(n)]
580  let r: RunResult
581  try {
582    r = await ctx.cmd(argv, { timeoutMs: ROUTE_TIMEOUT_MS })
583  } catch (err) {
584    return { status: 'failed', outcome: { kind: 'cli', exit: NO_EXIT, detail: message(err).slice(0, 2000) }, summary: clip(`${argv[0]}: ${message(err)}`) }
585  }
586  if (r.exitCode !== 0) return routeFailure(r)
587  const model = routedModel(r.stdout)
588  if (model === null) {
589    const detail = `printed no model: ${JSON.stringify(r.stdout.trim().slice(0, 200))}`
590    return { status: 'failed', outcome: { kind: 'cli', exit: 0, detail }, summary: clip(`${argv[0]} ${detail}`) }
591  }
592  try {
593    await ctx.event('delegated', { issue: n, note: `brief ${action.brief}` })
594  } catch (err) {
595    return eventFailure('delegated', err)
596  }
597  return start(ctx, action, model, { modelOverride: modelLabelOf(ctx.state, n), inFlight: [], notes: notesOf(ctx.state, n) })
598}
599
600// Exit 2 is the route's usage error, a loop bug never retried (SPEC 10.1).
601function routeFailure(r: RunResult): Exec {
602  const line = lastLine(r.stderr) || lastLine(r.stdout)
603  const exec: Extract<Exec, { status: 'failed' }> = {
604    status: 'failed',
605    outcome: { kind: 'cli', exit: r.exitCode, detail: detailOf(r) },
606    summary: clip(`callum-flow-implementer-route: exit ${r.exitCode}${line === '' ? '' : `: ${line}`}`),
607  }
608  if (r.exitCode === 2) exec.noRetry = true
609  if (rateLimited(r.stderr)) exec.rateLimited = true
610  return exec
611}
612
613// callum-flow-event exits non-zero only on a usage error (exit 2), never retried.
614function eventFailure(state: string, err: unknown): Exec {
615  const code = typeof err === 'object' && err !== null ? (err as { exitCode?: unknown }).exitCode : undefined
616  const exit = typeof code === 'number' ? code : NO_EXIT
617  const exec: Extract<Exec, { status: 'failed' }> = {
618    status: 'failed',
619    outcome: { kind: 'cli', exit, detail: message(err).slice(0, 2000) },
620    summary: clip(`event ${state}: ${message(err)}`),
621  }
622  if (exit === 2) exec.noRetry = true
623  return exec
624}
625
626export const agentExecutors: Partial<Record<ActionKind, Executor>> = { spawn, delegate }
627
628// --- small text helpers ---------------------------------------------------------------------
629
630function lastLine(text: string): string {
631  return (
632    text
633      .split('\n')
634      .map(l => l.trim())
635      .filter(l => l !== '')
636      .pop() ?? ''
637  )
638}
639
640function clip(text: string): string {
641  return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 3)}...` : text
642}
643
644function message(err: unknown): string {
645  return err instanceof Error ? err.message : String(err)
646}
647
hooks/agent-tools.ts 133 lines
1// The answer tools the loop's agents call (#548): the adjudicator's verdict, the designer's
2// result and the implementer's or fixer's handoff, each a tool whose input schema is the
3// contract its text lines carry (replies.ts), and whose errors the model reads and corrects in
4// the same turn. The handler (act.ts agentToolAnswered) ties a call to its ledger entry by the
5// caller's agentId. The registered tools are the intended answer channel; until a timer-spawned
6// agent reaches them (#560), agents answer through the checked script bin/factory-answer instead
7// (#561, config answerChannel). Both check an answer with contract.mjs, which takes nothing on
8// trust from the engine's own schema check (the test kit makes none) and adds what a schema
9// cannot say: the action the purpose allows, the fields that action takes, the finding ids a
10// fix names, a brief on the designed issue. The text lines stay the fallback (SPEC 7.3), and the
11// tools wait behind ToolSearch, as the wake answer tool does, so they cost the main conversation
12// nothing.
13import type { ToolSpec } from 'claude-code'
14
15import { BUMPS, FLAGS, VERDICTS } from './contract.mjs'
16import type { Check } from './contract.mjs'
17import type { AgentType } from './types'
18
19// The checks are the contract's, which the answer script shares (#561).
20export { checkDesign, checkHandoff, checkVerdict } from './contract.mjs'
21
22// The tool each agent answers with, by its full name.
23export const VERDICT = 'mcp__factory-loop__verdict'
24export const DESIGN = 'mcp__factory-loop__design'
25export const HANDOFF = 'mcp__factory-loop__handoff'
26export const TOOL_OF: Record<AgentType, string> = { adjudicator: VERDICT, designer: DESIGN, implementer: HANDOFF, fixer: HANDOFF }
27
28const nullable = (type: string, description: string) => ({ type: [type, 'null'], description })
29
30export const VERDICT_TOOL: ToolSpec = {
31  name: 'verdict',
32  description:
33    "The factory loop's adjudicator answer, in place of verdict and ACTION lines: one call at the end of your judgement. " +
34    'findings: one entry per finding you judged ([] for a clean review). action: exactly one; a gate or test-weakening review ' +
35    'takes approve, fix, skip, fixer or escalate, a merge review merge, hold, fixer or escalate, and a merge review also gives ' +
36    'summary, bump and flags. An error says what is wrong: correct the call and call it again; once it is recorded, end your turn.',
37  inputSchema: {
38    type: 'object',
39    properties: {
40      findings: {
41        type: 'array',
42        items: {
43          type: 'object',
44          properties: {
45            id: {
46              type: 'string',
47              description: "the reviewer's finding id (review-1); a gap the reviewer missed takes an id of your own, missed-1 and on, and goes in action.addFinding, never action.findings",
48            },
49            verdict: { type: 'string', enum: [...VERDICTS] },
50            reason: { type: 'string', description: 'one sentence with the evidence' },
51          },
52          required: ['id', 'verdict', 'reason'],
53          additionalProperties: false,
54        },
55      },
56      action: {
57        type: 'object',
58        properties: {
59          type: { type: 'string', enum: ['approve', 'fix', 'skip', 'fixer', 'escalate', 'merge', 'hold'] },
60          reason: { type: 'string', description: 'approve: the evidence, optional; hold: why the owner must decide' },
61          findings: { type: 'array', items: { type: 'string' }, description: "fix: ids of the reviewer's CORRECT or ENV findings above for the pipeline to fix" },
62          instructions: { type: 'string', description: 'fix: what the pipeline should change; fixer: what the fixer agent should do' },
63          addFinding: { type: 'object', description: 'fix: a finding the reviewer missed, as no-mistakes takes it' },
64          why: { type: 'string', description: 'escalate: what the owner must decide' },
65        },
66        required: ['type'],
67        additionalProperties: false,
68      },
69      summary: { type: 'string', description: 'merge review: what changes for users, one line' },
70      bump: {
71        type: 'object',
72        properties: { level: { type: 'string', enum: [...BUMPS] }, reason: { type: 'string' } },
73        required: ['level', 'reason'],
74        additionalProperties: false,
75        description: 'merge review: the release bump and why',
76      },
77      flags: { type: 'array', items: { type: 'string', enum: [...FLAGS] }, description: 'merge review: [] for none' },
78    },
79    required: ['findings', 'action'],
80    additionalProperties: false,
81  },
82  isDeferred: true,
83}
84
85export const DESIGN_TOOL: ToolSpec = {
86  name: 'design',
87  description:
88    "The factory loop's designer answer, in place of the RESULT line: one call once the brief is posted. brief: the brief " +
89    "comment's URL on the designed issue, or null; created: the issues you created ([] for none); pendingDecision: true when " +
90    'the brief waits on an owner decision. An error says what is wrong: correct the call and call it again; once it is recorded, end your turn.',
91  inputSchema: {
92    type: 'object',
93    properties: {
94      brief: nullable('string', "the brief comment's URL (https://github.com/<owner>/<repo>/issues/<N>#issuecomment-<id>), or null"),
95      created: { type: 'array', items: { type: 'integer', minimum: 1 }, description: 'issue numbers you created; [] for none' },
96      pendingDecision: { type: 'boolean' },
97    },
98    required: ['brief', 'created', 'pendingDecision'],
99    additionalProperties: false,
100  },
101  isDeferred: true,
102}
103
104export const HANDOFF_TOOL: ToolSpec = {
105  name: 'handoff',
106  description:
107    "The factory loop's handoff from an implementer or fixer, in place of the RESULT line: one call after the implement-issue " +
108    'handoff. run and head: the pipeline run you started or drove and its head from `no-mistakes status`; commit: your latest ' +
109    'commit, null when you committed nothing; gated: true only with a run, when its head equals your commit or you committed nothing; branch: ' +
110    'the branch the work is on now; pr: the PR number, or null. An error says what is wrong: correct the call and call it again; ' +
111    'once it is recorded, end your turn.',
112  inputSchema: {
113    type: 'object',
114    properties: {
115      run: nullable('string', 'the pipeline run id, or null'),
116      head: nullable('string', "the run's head SHA, or null"),
117      commit: nullable('string', 'your latest commit SHA, or null'),
118      gated: { type: 'boolean' },
119      branch: nullable('string', 'the branch the work is on now'),
120      pr: nullable('integer', 'the PR number, or null'),
121    },
122    required: ['run', 'head', 'commit', 'gated', 'branch', 'pr'],
123    additionalProperties: false,
124  },
125  isDeferred: true,
126}
127
128export const AGENT_TOOLS: readonly ToolSpec[] = [VERDICT_TOOL, DESIGN_TOOL, HANDOFF_TOOL]
129
130// What a check gives: the outcome the text contract would, or every problem at once, so the
131// model fixes them in one more call.
132export type ToolCheck<O> = Check<O>
133
hooks/config.ts 350 lines
1// Default settings (SPEC 4.2), and the pure loaders for the settings files and the control
2// state. The repo-local .claude/factory-loop.json overrides any setting; the plugin ships no
3// repo-specific values. Types only are imported, so plan.ts and the replay can use this too, and
4// v2's vocabulary (v2/types.ts), which holds constants alone.
5import type { ActionKind, AgentType, AnswerChannel, Config, Control, Ms } from './types'
6import { MODELS, PLANNERS, SHADOWS, V2_ACTION_KINDS } from './v2/types'
7
8const MIN = 60_000
9
10export const DEFAULT_CONFIG: Config = {
11  mode: 'observe',
12  shadowDevice: null,
13  readyLabel: 'ready',
14  // labels are lower case for now and compared without regard to case (owner, 2026-10-13)
15  inDevLabel: 'in development',
16  skipLabels: [],
17  trackerTitle: '^(Epic|Tracker)\\b',
18  usageSlowPct: 85,
19  usageStopNewPct: 90,
20  usageStopAllPct: 95,
21  passEveryMs: 60_000,
22  queueEveryMs: 2 * MIN,
23  githubEveryMs: 5 * MIN,
24  usageEveryMs: 5 * MIN,
25  milestoneEveryMs: 10 * MIN,
26  usageStaleMs: 15 * MIN,
27  heartbeatEveryMs: 20 * MIN,
28  sweepEveryMs: 30 * MIN,
29  checkoutEveryMs: 5 * MIN,
30  slowGapMs: 10 * MIN,
31  maxStartsPerPass: 4,
32  maxInFlightIssues: 5,
33  worktreeReserve: 2,
34  reviewFixCap: 3,
35  guardRecheckMs: 3 * MIN,
36  // a backstop: a repair re-guards at once, and a failure is judged once (#550)
37  guardTtlMs: 5 * MIN,
38  respondSettleMs: 15 * MIN,
39  updateBehindRetryMs: 5 * MIN,
40  updateBehindRetries: 3,
41  ciStallRecheckMs: 15 * MIN,
42  blindNoticeAfterMs: 15 * MIN,
43  agentTimeoutMs: { designer: 60 * MIN, implementer: 120 * MIN, adjudicator: 30 * MIN, fixer: 120 * MIN },
44  wakeTimeoutMs: { judge: 30 * MIN, task: 360 * MIN, tell: 30 * MIN },
45  maxAttempts: {
46    claim: 3,
47    designer: 2,
48    implementer: 2,
49    adjudicator: 2,
50    fixer: 2,
51    respond: 2,
52    merge: 2,
53    'fix-linkage': 2,
54    guard: 3,
55    'fast-forward': 2,
56    // heartbeat, sweep and update-checkout are never used up (they retry at their cadence, SPEC
57    // 6.1): this many failures in a row tells the owner once
58    heartbeat: 3,
59    sweep: 3,
60    'update-checkout': 3,
61    // a restart is tried once; the owner hears if it fails (SPEC 6.1)
62    'restart-self': 1,
63    // SPEC 6.1: a close and reopen once, a stop once, a slot's return twice
64    'pr-reopen': 1,
65    'stop-agent': 1,
66    'release-worktree': 2,
67    // a wake is asked once (with one reminder inside the attempt); then its fallback holds
68    judge: 1,
69    task: 1,
70    tell: 1,
71    // v2 (appendix B): a step key is one attempt, the table retries steps (v2 SPEC 5.1); an
72    // update and a merge are one per key; a CI re-run's call three, with backoff, since one that
73    // failed on a transient error re-ran nothing (8.5); effects keep the default 3, and a slot op
74    // and a CI re-run carry their own caps on their actions
75    'agent-step': 1,
76    'ci-rerun': 3,
77    'queue-update': 1,
78    'queue-merge': 1,
79  },
80  retryBackoffMs: 2 * MIN,
81  rateCaps: { start: 30, spawn: 120, wake: 20, perIssueKind: 6 },
82  allowActOutsideOrchestrator: false,
83  // off: the decision log alone carries each pass's time and trigger (#552)
84  trace: false,
85  // 100 MiB in all (owner, #552)
86  traceMaxBytes: 100 * 1024 * 1024,
87  // script: a TEMPORARY WORKAROUND (#561, D-74). The registered answer tools (`tool`, #548) are
88  // the intended channel, but on Claude Code 2.1.296 and 2.1.299 an agent spawned from the loop's
89  // timer never reaches the loop's tool hook (#560, SPEC 13), while Bash works: agents record
90  // their answer with bin/factory-answer, checked by the same contract. Set it back to `tool`
91  // once the probe shows a timer-spawned agent reaching the tool hook.
92  answerChannel: 'script',
93  // v2 (v2 SPEC appendix A): v1 plans until cutover; the shadow is off
94  planner: 'v1',
95  shadow: 'off',
96  stateTimeoutMs: {
97    design: 45 * MIN,
98    build: 90 * MIN,
99    review: 30 * MIN,
100    fix: 60 * MIN,
101    triage: 15 * MIN,
102    queued: 6 * 60 * MIN,
103    queueWait: 2 * 60 * MIN,
104    parked: 24 * 60 * MIN,
105  },
106  ciStartMs: 10 * MIN,
107  ciRunMs: 30 * MIN,
108  prEveryMs: 30_000,
109  maxTriages: 2,
110  maxLives: 3,
111  triageModel: 'opus',
112  reviewer: 'adjudicator',
113  reviewerModel: 'opus',
114  // the plugin ships no repo's commands: a repo sets its own (dev-system: npm run lint, npm test)
115  gateCommands: [],
116  gateTimeoutMs: 10 * MIN,
117}
118
119// The defaults that differ under planner v2, for a key no settings file sets: K is 3 (v2 SPEC
120// section 15; v1's stays 5).
121export const V2_DEFAULTS: Readonly<Pick<Config, 'maxInFlightIssues'>> = { maxInFlightIssues: 3 }
122
123export const ANSWER_CHANNELS: readonly AnswerChannel[] = ['script', 'tool', 'text']
124
125export const EMPTY_CONTROL: Control = {
126  paused: [],
127  noNewWork: false,
128  skip: [],
129  priority: [],
130  only: [],
131  serial: false,
132  usageOverride: null,
133  updateDue: null,
134  resumed: [],
135  dropped: [],
136  notes: [],
137}
138
139// --- the settings files (SPEC 4.2) -------------------------------------------------------
140
141const UNITS: Record<string, number> = { ms: 1, s: 1000, m: MIN, h: 60 * MIN, d: 24 * 60 * MIN }
142
143// parseDuration: "500ms", "60s", "5m", "2h" or "1d" in milliseconds; null for anything else.
144export function parseDuration(text: string): Ms | null {
145  const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(text)
146  const unit = m?.[2] === undefined ? undefined : UNITS[m[2]]
147  return m?.[1] === undefined || unit === undefined ? null : Math.round(Number(m[1]) * unit)
148}
149
150// Every action kind, for the loaders that read one from text (SPEC 6.1).
151export const ACTION_KINDS: readonly ActionKind[] = [
152  'heartbeat', 'sweep', 'claim', 'spawn', 'delegate', 'respond', 'env-retry', 'guard', 'update-behind', 'fast-forward',
153  'update-checkout', 'fix-linkage', 'merge', 'close-issue', 'release-parent', 'abandon', 'milestone-later', 'milestone-expect',
154  'pr-reopen', 'pr-comment', 'pr-close', 'release-worktree', 'restart-self', 'rollout', 'stop-agent', 'judge', 'task', 'tell', 'title',
155  ...V2_ACTION_KINDS,
156]
157// maxAttempts may name any action kind or agent type.
158const ATTEMPT_KEYS: readonly (ActionKind | AgentType)[] = [...ACTION_KINDS, 'designer', 'implementer', 'adjudicator', 'fixer']
159const TIME_MAPS = ['agentTimeoutMs', 'wakeTimeoutMs', 'stateTimeoutMs'] as const
160const COUNT_MAPS = ['maxAttempts', 'rateCaps'] as const
161
162// v2: the settings v2 runs with, from the same layers: config itself under planner v2; under v1,
163// V2_DEFAULTS on every key no layer set, which the v2 shadow plans with (v2 SPEC 2.3).
164export type LoadedConfig = { config: Config; problems: string[]; v2: Config }
165
166// jsonObject: a settings file's text as an object; {} for no file, or one that does not read.
167export function jsonObject(raw: string | null): Record<string, unknown> {
168  try {
169    const v: unknown = raw === null ? {} : JSON.parse(raw)
170    return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {}
171  } catch {
172    return {}
173  }
174}
175
176// loadConfig: DEFAULT_CONFIG with each raw JSON object layered over it, in order. A time
177// setting takes a duration string under its key with "Ms" dropped (`passEvery: "60s"`), or a
178// number of milliseconds under either spelling; the agentTimeout and wakeTimeout maps likewise,
179// per entry. An unknown key or a bad value is left out and named in `problems`, for the
180// decision log (owner, 2026-10-13). Under planner v2 a key no layer sets takes V2_DEFAULTS'.
181export function loadConfig(...layers: unknown[]): LoadedConfig {
182  const d = DEFAULT_CONFIG
183  const out: Record<string, unknown> = {
184    ...d,
185    skipLabels: [...d.skipLabels],
186    agentTimeoutMs: { ...d.agentTimeoutMs },
187    wakeTimeoutMs: { ...d.wakeTimeoutMs },
188    stateTimeoutMs: { ...d.stateTimeoutMs },
189    maxAttempts: { ...d.maxAttempts },
190    rateCaps: { ...d.rateCaps },
191    gateCommands: [...d.gateCommands],
192  }
193  // the V2_DEFAULTS keys a layer set to a value it takes
194  const set = new Set<string>()
195  const defaults = d as unknown as Record<string, unknown>
196  const problems: string[] = []
197  const bad = (key: string, value: unknown, want: string) => problems.push(`${key}: ${JSON.stringify(value) ?? String(value)} is not ${want}`)
198  const ms = (key: string, value: unknown, short: boolean): number | null => {
199    if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
200    const parsed = short && typeof value === 'string' ? parseDuration(value) : null
201    if (parsed === null) bad(key, value, short ? 'a duration (like 60s, 5m, 2h)' : 'a number of milliseconds')
202    return parsed
203  }
204
205  for (const layer of layers) {
206    if (layer === undefined || layer === null) continue
207    if (typeof layer !== 'object' || Array.isArray(layer)) {
208      problems.push('settings: not a JSON object')
209      continue
210    }
211    for (const [raw, value] of Object.entries(layer)) {
212      const short = !(raw in defaults) && `${raw}Ms` in defaults
213      const key = short ? `${raw}Ms` : raw
214      const base = defaults[key]
215      if (raw === 'answerTools') {
216        // #560's switch, replaced by answerChannel (#561) and not read
217        problems.push('answerTools: replaced by answerChannel (script, tool or text), and ignored')
218      } else if (!(key in defaults)) {
219        problems.push(`${raw}: unknown setting`)
220      } else if ((TIME_MAPS as readonly string[]).includes(key) || (COUNT_MAPS as readonly string[]).includes(key)) {
221        if (value === null || typeof value !== 'object' || Array.isArray(value)) {
222          bad(raw, value, 'an object')
223          continue
224        }
225        const target = out[key] as Record<string, number>
226        const known = key === 'maxAttempts' ? (ATTEMPT_KEYS as readonly string[]) : Object.keys(base as object)
227        for (const [sub, v] of Object.entries(value)) {
228          const name = `${raw}.${sub}`
229          if (!known.includes(sub)) {
230            problems.push(`${name}: unknown${key === 'rateCaps' && sub === 'merge' ? ' (merges are never capped)' : ''}`)
231          } else if ((TIME_MAPS as readonly string[]).includes(key)) {
232            const n = ms(name, v, short)
233            if (n !== null) target[sub] = n
234          } else if (typeof v === 'number' && Number.isInteger(v) && v >= (key === 'maxAttempts' ? 1 : 0)) {
235            target[sub] = v
236          } else {
237            bad(name, v, key === 'maxAttempts' ? 'a whole number of at least 1' : 'a whole number')
238          }
239        }
240      } else if (key.endsWith('Ms') && typeof base === 'number') {
241        const n = ms(raw, value, short)
242        if (n !== null) out[key] = n
243      } else if (key === 'mode') {
244        if (value === 'off' || value === 'observe' || value === 'act') out[key] = value
245        else bad(raw, value, 'off, observe or act')
246      } else if (key === 'shadowDevice') {
247        if (value === null || typeof value === 'string') out[key] = value
248        else bad(raw, value, 'a device name or null')
249      } else if (key === 'maxInFlightIssues') {
250        if (value === null || (typeof value === 'number' && Number.isInteger(value) && value >= 0)) {
251          out[key] = value
252          set.add(key)
253        } else bad(raw, value, 'a whole number or null')
254      } else if (key === 'skipLabels') {
255        if (Array.isArray(value) && value.every(v => typeof v === 'string')) out[key] = [...value]
256        else bad(raw, value, 'a list of labels')
257      } else if (key === 'gateCommands') {
258        if (Array.isArray(value) && value.every(v => typeof v === 'string' && v.trim() !== '')) out[key] = [...value]
259        else bad(raw, value, 'a list of commands')
260      } else if (key === 'planner') {
261        if (PLANNERS.some(p => p === value)) out[key] = value
262        else bad(raw, value, PLANNERS.join(' or '))
263      } else if (key === 'shadow') {
264        if (SHADOWS.some(p => p === value)) out[key] = value
265        else bad(raw, value, SHADOWS.join(' or '))
266      } else if (key.endsWith('Model')) {
267        if (MODELS.some(m => m === value)) out[key] = value
268        else bad(raw, value, `${MODELS.slice(0, -1).join(', ')} or ${MODELS.at(-1)}`)
269      } else if (key === 'maxTriages') {
270        if (typeof value === 'number' && Number.isInteger(value) && value >= 0) out[key] = value
271        else bad(raw, value, 'a whole number')
272      } else if (key === 'maxLives') {
273        if (typeof value === 'number' && Number.isInteger(value) && value >= 1) out[key] = value
274        else bad(raw, value, 'a whole number of at least 1')
275      } else if (key === 'answerChannel') {
276        const channel = ANSWER_CHANNELS.find(c => c === value)
277        if (channel !== undefined) out[key] = channel
278        else bad(raw, value, 'script, tool or text')
279      } else if (key === 'trackerTitle') {
280        if (typeof value === 'string' && compiles(value)) out[key] = value
281        else bad(raw, value, 'a regular expression')
282      } else if (typeof base === 'string') {
283        if (typeof value === 'string' && value !== '') out[key] = value
284        else bad(raw, value, 'a non-empty string')
285      } else if (typeof base === 'boolean') {
286        if (typeof value === 'boolean') out[key] = value
287        else bad(raw, value, 'true or false')
288      } else if (typeof base === 'number') {
289        if (typeof value === 'number' && Number.isFinite(value) && value >= 0) out[key] = value
290        else bad(raw, value, 'a number')
291      }
292    }
293  }
294  const v2: Record<string, unknown> = { ...out }
295  for (const [key, value] of Object.entries(V2_DEFAULTS)) if (!set.has(key)) v2[key] = value
296  const config = (out.planner === 'v2' ? v2 : out) as unknown as Config
297  return { config, problems, v2: v2 as unknown as Config }
298}
299
300function compiles(pattern: string) {
301  try {
302    new RegExp(pattern)
303    return true
304  } catch {
305    return false
306  }
307}
308
309// --- the control state (SPEC 4.3) ----------------------------------------------------------
310
311// controlFrom: control.json's parsed contents as Control, field by field: a missing or
312// malformed field takes its empty value, and a malformed list item is dropped, so one bad
313// directive never voids the others.
314export function controlFrom(raw: unknown): Control {
315  const o = record(raw) ?? {}
316  const issues = (v: unknown) => (Array.isArray(v) ? v.filter(isIssue) : [])
317  const at = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
318  const items = (v: unknown) => (Array.isArray(v) ? v.map(record).filter(r => r !== null) : [])
319  const override = record(o.usageOverride)
320  const update = record(o.updateDue)
321  return {
322    paused: issues(o.paused),
323    noNewWork: o.noNewWork === true,
324    skip: issues(o.skip),
325    priority: issues(o.priority),
326    only: issues(o.only),
327    serial: o.serial === true,
328    usageOverride:
329      override !== null && at(override.untilPct) && at(override.expiresAt) ? { untilPct: override.untilPct, expiresAt: override.expiresAt } : null,
330    updateDue: update !== null && at(update.at) ? { reason: typeof update.reason === 'string' ? update.reason : '', at: update.at } : null,
331    resumed: items(o.resumed).flatMap(r =>
332      typeof r.subject === 'string' && at(r.at) ? [typeof r.note === 'string' ? { subject: r.subject, at: r.at, note: r.note } : { subject: r.subject, at: r.at }] : [],
333    ),
334    dropped: items(o.dropped).flatMap(r => (isIssue(r.issue) && at(r.at) ? [{ issue: r.issue, at: r.at }] : [])),
335    notes: items(o.notes).flatMap(r =>
336      typeof r.text === 'string' && at(r.at) && (r.issue === undefined || r.issue === null || isIssue(r.issue))
337        ? [{ issue: isIssue(r.issue) ? r.issue : null, text: r.text, at: r.at }]
338        : [],
339    ),
340  }
341}
342
343function record(v: unknown): Record<string, unknown> | null {
344  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
345}
346
347function isIssue(v: unknown): v is number {
348  return typeof v === 'number' && Number.isInteger(v) && v > 0
349}
350
hooks/contract.mjs 456 lines
1// The agents' answer contract (SPEC 7.3a): what an adjudicator's verdict, a designer's result and
2// an implementer's or fixer's handoff must hold, checked field by field with every problem named
3// at once, and the files of the answer script (#561). One plain-JS module with one reader of
4// each answer: the loop imports it (the TypeScript hooks the engine runs: replies.ts,
5// agent-tools.ts, act-agents.ts), and so does bin/factory-answer under plain node, so the
6// registered tools, the script and the loop's own re-check of the script's file cannot drift.
7// tests/answer-contract.test.ts (the loop) and tests/factory-loop-answer.test.js at the repo
8// root (the script, run for real) hold both ends to one table of cases (tests/answer-cases.mjs).
9// It uses ES2023 alone, nothing of Node or the engine. Its types are contract.d.mts's.
10//
11// The v2 rule layer's contracts (docs/factory-loop/v2/SPEC.md 5.2, 8.2, 8.7) live in
12// v2/contracts-*.mjs, one module per area; this module imports them, so the answer script and the
13// loop check a v2 answer alike, and a v2 job's contract is one of their names.
14
15import { CONTRACTS as REVIEW } from './v2/contracts-review.mjs'
16import { CONTRACTS as TRIAGE } from './v2/contracts-triage.mjs'
17import { CONTRACTS as WORK } from './v2/contracts-work.mjs'
18
19export const VERDICTS = Object.freeze(['CORRECT', 'WRONG', 'NIT', 'ENV', 'DUP'])
20export const BUMPS = Object.freeze(['patch', 'minor', 'major'])
21export const FLAGS = Object.freeze(['image', 'template', 'restart', 'run-it'])
22
23// The ACTIONs that answer each purpose. A gate or a test-weakening review never merges or
24// holds (types.ts, D-05); a merge review never drives the run's gate. escalate stays open to
25// the merge review: its definition offers it, and plan holds on it as on `hold` (rule M5a).
26export const ACTIONS_FOR = Object.freeze({
27  gate: Object.freeze(['approve', 'fix', 'skip', 'fixer', 'escalate']),
28  weakening: Object.freeze(['approve', 'fix', 'skip', 'fixer', 'escalate']),
29  merge: Object.freeze(['merge', 'hold', 'fixer', 'escalate']),
30})
31
32// A finding id as no-mistakes names them (review-1).
33export const FINDING_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/
34
35// The shape of each handoff id, as a RESULT line or a structured answer gives it.
36export const HANDOFF_SHAPES = Object.freeze({ run: /^[A-Za-z0-9_-]+$/, sha: /^[0-9a-f]{7,40}$/i, branch: /^[A-Za-z0-9._/-]+$/ })
37
38// A free-text value past this is refused: an answer points, it does not carry documents.
39export const TEXT_MAX = 4000
40
41// The contract each agent answers with; each answer tool is named after its contract.
42export const CONTRACT_OF = Object.freeze({ adjudicator: 'verdict', designer: 'design', implementer: 'handoff', fixer: 'handoff' })
43// v2's contracts by name, each { check(input, job), summary(input) }.
44export const V2_CONTRACTS = Object.freeze({ ...WORK, ...REVIEW, ...TRIAGE })
45const CONTRACTS = Object.freeze(['verdict', 'design', 'handoff', ...Object.keys(V2_CONTRACTS)])
46const PURPOSES = Object.freeze(['gate', 'weakening', 'merge'])
47
48// isBriefUrl: a brief comment's URL on an issue of this repo (on that issue, when given).
49export function isBriefUrl(url, repo, issue = null) {
50  const m = url.match(/^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)#issuecomment-\d+$/)
51  if (m === null || (m[1] ?? '').toLowerCase() !== repo.toLowerCase()) return false
52  return issue === null || Number(m[2]) === issue
53}
54
55// gatedWithoutRun: a handoff that says gated but names no run, as an agent that gave up and
56// committed nothing may. Read as done, no rule would act on it (D4b retries only a delegate that
57// failed, D5 only one not gated), so its issue would stall: it is unreadable instead.
58export function gatedWithoutRun(fields) {
59  return fields.gated && fields.run === null
60}
61
62// One SHA may be the other's short form.
63function sameSha(a, b) {
64  return a.startsWith(b) || b.startsWith(a)
65}
66
67// handoffOutcome: what a handoff's fields mean, from any channel: not gated when it says so, or
68// when the run's head is not the agent's commit (implement-issue section 8: never rationalise a
69// mismatch away); newBranch is the branch it reports when that is not the one the spawn named.
70export function handoffOutcome(fields, namedBranch) {
71  const mismatch = fields.head !== null && fields.commit !== null && !sameSha(fields.head, fields.commit)
72  const named = namedBranch === null || namedBranch === '' ? null : namedBranch
73  return {
74    kind: 'handoff',
75    notGated: !fields.gated || mismatch,
76    run: fields.run,
77    head: fields.head,
78    newBranch: fields.branch !== null && fields.branch !== named ? fields.branch : null,
79  }
80}
81
82// --- the checks: an input in, the outcome its text lines would give or every problem out ---------
83
84function record(v) {
85  return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null
86}
87
88function checked(problems, outcome) {
89  return problems.length > 0 ? { ok: false, problems } : { ok: true, outcome: outcome() }
90}
91
92// strays: a field the contract does not have is named, never ignored: it is a misunderstanding.
93function strays(o, allowed, at, problems) {
94  for (const k of Object.keys(o)) if (!allowed.includes(k)) problems.push(`${at}${k} is not a field here`)
95}
96
97// text: a free-text field as one trimmed line (the text contract's lines are one line each);
98// null when absent or null. Not text, empty where required, or over TEXT_MAX is a problem.
99function text(v, name, problems, required) {
100  if (v === undefined || v === null) {
101    if (required) problems.push(`${name} is required`)
102    return null
103  }
104  if (typeof v !== 'string') {
105    problems.push(`${name} must be text`)
106    return null
107  }
108  const t = v.replace(/\s+/g, ' ').trim()
109  if (t === '' && required) problems.push(`${name} must not be empty`)
110  else if (t.length > TEXT_MAX) problems.push(`${name} is over ${TEXT_MAX} characters`)
111  else if (t !== '') return t
112  return null
113}
114
115// clip: a value quoted in a problem as the model wrote it, cut to a readable length.
116function clip(v) {
117  const s = typeof v === 'string' ? JSON.stringify(v.length > 120 ? `${v.slice(0, 117)}...` : v) : (JSON.stringify(v) ?? String(v))
118  return s.length > 124 ? `${s.slice(0, 121)}...` : s
119}
120
121// --- the adjudicator (SPEC 7.3) --------------------------------------------------------------
122
123// The fields each action takes beside its type, as its ACTION line's grammar has them.
124const ACTION_FIELDS = Object.freeze({
125  approve: ['reason'],
126  fix: ['findings', 'instructions', 'addFinding'],
127  skip: [],
128  fixer: ['instructions'],
129  escalate: ['why'],
130  merge: [],
131  hold: ['reason'],
132})
133const REPORT_FIELDS = Object.freeze(['summary', 'bump', 'flags'])
134
135// verdictLine: a finding as the verdict line the text contract gives, naming its id (once, when
136// the reason already opens with it), for the verdict event and the rules that read the lines
137// (P2a reads `ENV:`).
138function verdictLine(verdict, id, reason) {
139  const named = reason.startsWith(id) && !/[\w.-]/.test(reason.charAt(id.length))
140  return named ? `${verdict}: ${reason}` : `${verdict}: ${id}: ${reason}`
141}
142
143// checkVerdict: an adjudicator's answer of this purpose.
144export function checkVerdict(input, purpose) {
145  const problems = []
146  const o = record(input) ?? {}
147  strays(o, ['findings', 'action', ...REPORT_FIELDS], '', problems)
148  const judged = new Map()
149  const verdicts = []
150  const findings = Array.isArray(o.findings) ? o.findings : null
151  if (findings === null) problems.push('findings must be a list of { id, verdict, reason } ([] when there is nothing to report)')
152  for (const [i, raw] of (findings ?? []).entries()) {
153    const at = `findings[${i}]`
154    const f = record(raw)
155    if (f === null) {
156      problems.push(`${at} must be an object { id, verdict, reason }`)
157      continue
158    }
159    strays(f, ['id', 'verdict', 'reason'], `${at}.`, problems)
160    const id = typeof f.id === 'string' ? f.id.trim() : ''
161    const verdict = VERDICTS.find(v => v === f.verdict)
162    const reason = text(f.reason, `${at}.reason`, problems, true)
163    if (verdict === undefined) problems.push(`${at}.verdict must be one of ${VERDICTS.join(', ')}`)
164    if (!FINDING_ID.test(id)) problems.push(`${at}.id ${clip(f.id)} is not a finding id such as review-1`)
165    else if (judged.has(id)) problems.push(`${at}.id ${id} is judged twice`)
166    else if (verdict !== undefined) {
167      judged.set(id, verdict)
168      if (reason !== null) verdicts.push(verdictLine(verdict, id, reason))
169    }
170  }
171  const action = actionOf(o.action, purpose, judged, problems)
172  let report = null
173  if (purpose === 'merge') report = reportOf(o, problems)
174  else {
175    const extra = REPORT_FIELDS.filter(k => o[k] !== undefined)
176    if (extra.length > 0) problems.push(`${extra.join(', ')} answer a merge review only, and this is a ${purpose} review`)
177  }
178  if (action === null || problems.length > 0) return { ok: false, problems: problems.length > 0 ? problems : ['action is required'] }
179  return { ok: true, outcome: { kind: 'adjudicator', verdicts, action, report } }
180}
181
182// actionOf: the one action, of a type the purpose allows, with the fields its type takes (the
183// same as its ACTION line: a fix needs findings, instructions or a finding to add).
184function actionOf(raw, purpose, judged, problems) {
185  const allowed = ACTIONS_FOR[purpose]
186  const a = record(raw)
187  const type = allowed.find(t => t === a?.type)
188  if (a === null || type === undefined) {
189    problems.push(`action must be { type, ... } with type one of ${allowed.join(', ')}: a ${purpose} review takes no other`)
190    return null
191  }
192  const fields = ACTION_FIELDS[type]
193  for (const k of Object.keys(a)) {
194    if (k !== 'type' && !fields.includes(k)) problems.push(`action ${type} takes ${fields.length === 0 ? 'no field but type' : fields.join(', ')}, not ${k}`)
195  }
196  switch (type) {
197    case 'skip':
198    case 'merge':
199      return { type }
200    case 'approve':
201      return { type, reason: text(a.reason, 'action.reason', problems, false) }
202    case 'fixer': {
203      const instructions = text(a.instructions, 'action.instructions', problems, true)
204      return instructions === null ? null : { type, instructions }
205    }
206    case 'escalate': {
207      const why = text(a.why, 'action.why', problems, true)
208      return why === null ? null : { type, why }
209    }
210    case 'hold': {
211      const reason = text(a.reason, 'action.reason', problems, true)
212      return reason === null ? null : { type, reason }
213    }
214    case 'fix': {
215      const findings = fixIds(a.findings, judged, problems)
216      const instructions = text(a.instructions, 'action.instructions', problems, false)
217      let addFinding = null
218      if (a.addFinding !== undefined && a.addFinding !== null) {
219        if (record(a.addFinding) === null) problems.push('action.addFinding must be an object: the finding as no-mistakes takes it')
220        else addFinding = JSON.stringify(a.addFinding)
221      }
222      if (findings === null && instructions === null && addFinding === null) problems.push('action fix needs findings, instructions or addFinding')
223      return { type, findings, instructions, addFinding, ignore: declinedFindings([...judged.keys()], findings) }
224    }
225  }
226  return null
227}
228
229// The id prefix the contract asks for a gap the reviewer missed, which the gate does not know.
230const MISSED_ID = 'missed-'
231
232// declinedFindings: the findings a fix leaves out of its selection, for respond's --ignore.
233// no-mistakes 1.90+ takes declines only when named: every finding the gate shows must be in
234// --findings or --ignore, or the respond is refused and the gate stays parked. So the loop
235// declines what the adjudicator judged and did not select, the complement no-mistakes itself
236// took before 1.90. A gap the reviewer missed (MISSED_ID) is not the gate's, so never declined.
237// null when nothing is left out.
238export function declinedFindings(judgedIds, findings) {
239  const selected = new Set((findings ?? '').split(',').filter(id => id !== ''))
240  const out = [...new Set(judgedIds)].filter(id => FINDING_ID.test(id) && !id.startsWith(MISSED_ID) && !selected.has(id))
241  return out.length > 0 ? out.join(',') : null
242}
243
244// fixIds: a fix's finding ids, each one judged above as CORRECT or ENV: a fix for a WRONG, NIT or
245// DUP finding would make the pipeline rewrite sound code (adjudicator.md). The loop has no view of
246// the gate's own list, so an id the agent made up for a missed gap (MISSED_ID) is the one it can
247// tell is not the gate's: respond would name a finding the gate does not have.
248function fixIds(raw, judged, problems) {
249  if (raw === undefined || raw === null) return null
250  if (!Array.isArray(raw) || raw.length === 0 || !raw.every(id => typeof id === 'string')) {
251    problems.push('action.findings must be a list of the ids of findings above')
252    return null
253  }
254  const ids = [...new Set(raw.map(id => id.trim()))]
255  for (const id of ids) {
256    const verdict = judged.get(id)
257    if (!FINDING_ID.test(id)) problems.push(`action.findings: ${clip(id)} is not a finding id`)
258    else if (id.startsWith(MISSED_ID)) problems.push(`action.findings: ${id} is a gap the reviewer missed, which the gate does not know: put it in action.addFinding`)
259    else if (verdict === undefined) problems.push(`action.findings: ${id} is not one of the findings above`)
260    else if (verdict !== 'CORRECT' && verdict !== 'ENV') problems.push(`action.findings: ${id} is judged ${verdict}, and a fix is for CORRECT or ENV findings`)
261  }
262  return ids.join(',')
263}
264
265// reportOf: the merge review's summary, bump and flags (rule M4).
266function reportOf(o, problems) {
267  const summary = text(o.summary, 'summary', problems, true)
268  const b = record(o.bump)
269  const level = BUMPS.find(x => x === b?.level)
270  let bumpReason = null
271  if (b === null) problems.push('bump is required: { level: patch|minor|major, reason }')
272  else {
273    strays(b, ['level', 'reason'], 'bump.', problems)
274    if (level === undefined) problems.push(`bump.level must be one of ${BUMPS.join(', ')}`)
275    bumpReason = text(b.reason, 'bump.reason', problems, true)
276  }
277  const flags = []
278  if (!Array.isArray(o.flags)) problems.push(`flags is required: a list of ${FLAGS.join(', ')} ([] for none)`)
279  else {
280    for (const raw of o.flags) {
281      const flag = FLAGS.find(f => f === raw)
282      if (flag === undefined) problems.push(`flags: ${clip(raw)} is not one of ${FLAGS.join(', ')}`)
283      else if (!flags.includes(flag)) flags.push(flag)
284    }
285  }
286  return summary === null || level === undefined || bumpReason === null ? null : { summary, bump: level, bumpReason, flags }
287}
288
289// --- the designer (SPEC 7.2) ------------------------------------------------------------------
290
291// checkDesign: a designer's answer for this issue. A result that leaves the loop nothing to do
292// (no brief, no created issues, no decision pending) is a forgotten field.
293export function checkDesign(input, repo, issue) {
294  const problems = []
295  const o = record(input) ?? {}
296  strays(o, ['brief', 'created', 'pendingDecision'], '', problems)
297  const where = `${repo} issue ${issue ?? '<N>'}`
298  let brief = null
299  if (o.brief === undefined) problems.push(`brief is required: the brief comment's URL on ${where}, or null`)
300  else if (o.brief !== null) {
301    const url = typeof o.brief === 'string' ? o.brief.trim() : ''
302    if (isBriefUrl(url, repo, issue)) brief = url
303    else problems.push(`brief ${clip(o.brief)} is not a comment URL on ${where} (https://github.com/${repo}/issues/${issue ?? '<N>'}#issuecomment-<id>)`)
304  }
305  const created = Array.isArray(o.created) && o.created.every(n => Number.isInteger(n) && n > 0) ? [...new Set(o.created)] : null
306  if (created === null) problems.push('created must be a list of the issue numbers you created ([] for none)')
307  if (typeof o.pendingDecision !== 'boolean') problems.push('pendingDecision must be true or false')
308  const pending = o.pendingDecision === true
309  if (problems.length === 0 && brief === null && created?.length === 0 && !pending) {
310    problems.push('with no brief, no created issues and no decision pending, the loop has nothing to hand on: give the brief URL')
311  }
312  return checked(problems, () => ({ kind: 'designer', brief, created: created ?? [], pendingDecision: pending }))
313}
314
315// --- implementer and fixer handoffs ---------------------------------------------------------
316
317// checkHandoff: a handoff, read as its RESULT line is (handoffOutcome): not gated when it says so
318// or its head is not its commit; a branch other than the one the spawn named is a new one; gated
319// with no run is refused (gatedWithoutRun).
320export function checkHandoff(input, namedBranch) {
321  const problems = []
322  const o = record(input) ?? {}
323  strays(o, ['run', 'head', 'commit', 'gated', 'branch', 'pr'], '', problems)
324  const id = (key, shape, what) => {
325    const v = o[key]
326    if (v === undefined) problems.push(`${key} is required (null for none)`)
327    else if (v !== null) {
328      if (typeof v === 'string' && shape.test(v.trim())) return v.trim()
329      problems.push(`${key} ${clip(v)} is not ${what}, or null`)
330    }
331    return null
332  }
333  const run = id('run', HANDOFF_SHAPES.run, 'a pipeline run id')
334  const head = id('head', HANDOFF_SHAPES.sha, 'a commit SHA of 7 to 40 hex digits')
335  const commit = id('commit', HANDOFF_SHAPES.sha, 'a commit SHA of 7 to 40 hex digits')
336  const branch = id('branch', HANDOFF_SHAPES.branch, 'a branch name')
337  if (typeof o.gated !== 'boolean') problems.push('gated must be true or false')
338  else if (o.run === null && gatedWithoutRun({ run, gated: o.gated })) problems.push('gated is true with run null, and with no pipeline run nothing is gated: give the run, or gated false')
339  if (o.pr === undefined) problems.push('pr is required (null for none)')
340  else if (o.pr !== null && !(Number.isInteger(o.pr) && o.pr > 0)) problems.push(`pr ${clip(o.pr)} is not a PR number, or null`)
341  const pr = typeof o.pr === 'number' ? o.pr : null
342  const gated = o.gated === true
343  return checked(problems, () => handoffOutcome({ run, head: head?.toLowerCase() ?? null, commit: commit?.toLowerCase() ?? null, gated, branch, pr }, namedBranch))
344}
345
346// checkAnswer: an answer under its job: the contract, and what checking it needs (the review's
347// purpose, the repo and issue a brief must be on, the branch a fixer was sent to). Every channel
348// checks through this one call: the tool's hook and the loop's re-check with the job the loop
349// knows from its ledger, the script with the job file the loop wrote at the spawn.
350export function checkAnswer(job, input) {
351  const v2 = Object.hasOwn(V2_CONTRACTS, job.contract) ? V2_CONTRACTS[job.contract] : undefined
352  if (v2 !== undefined) return v2.check(input, job)
353  if (job.contract === 'verdict') {
354    if (job.purpose === null) return { ok: false, problems: ['the factory loop cannot tell which review this is'] }
355    return checkVerdict(input, job.purpose)
356  }
357  if (job.contract === 'design') return checkDesign(input, job.repo, job.issue)
358  return checkHandoff(input, job.branch)
359}
360
361// --- the answer script's files (#561) ------------------------------------------------------------
362
363// An agent's answer file, at a path the loop names in its prompt, unique to the ledger entry and
364// attempt, in <loop dir>/answers/; beside it the job file the loop writes before the spawn. The
365// script writes the answer file alone, and only where a job file stands.
366export const ANSWER_SUFFIX = '.answer.json'
367export const JOB_SUFFIX = '.job.json'
368export const ANSWER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*\.answer\.json$/
369// The loop dir's own name: <owner>__<repo> (loopDirOf).
370export const LOOP_DIR_NAME = /^[A-Za-z0-9._-]+__[A-Za-z0-9._-]+$/
371
372// loopBaseOf: the factory loop's directory under a config dir (CLAUDE_CONFIG_DIR, else
373// ~/.claude), a trailing / on it dropped: its config.json, its OFF file and every repo's loop dir.
374export function loopBaseOf(configDir) {
375  return `${configDir.replace(/\/+$/, '')}/factory-loop`
376}
377
378// loopDirOf: a repo's loop dir, <config dir>/factory-loop/<owner>__<repo>: where the loop keeps
379// that repo's files (register.ts runtime), and whose answers/ directory is the only place the
380// answer script writes, for a job of that repo.
381export function loopDirOf(configDir, repo) {
382  return `${loopBaseOf(configDir)}/${repo.replace('/', '__')}`
383}
384const FILE_VERSION = 1
385
386// jobPathOf: the job file beside an answer file, or null for a path that names no answer file.
387export function jobPathOf(answerPath) {
388  const slash = answerPath.lastIndexOf('/')
389  const name = answerPath.slice(slash + 1)
390  return ANSWER_NAME.test(name) ? `${answerPath.slice(0, slash + 1)}${name.slice(0, -ANSWER_SUFFIX.length)}${JOB_SUFFIX}` : null
391}
392
393// jobText: the job file's body. A v2 job's data (what its contract's check needs beside the
394// answer) goes in only when it has some, so a v1 job's body is as it always was.
395export function jobText(job) {
396  const body = { v: FILE_VERSION, contract: job.contract, purpose: job.purpose, repo: job.repo, issue: job.issue, branch: job.branch }
397  if (job.data !== undefined && job.data !== null) body.data = job.data
398  return `${JSON.stringify(body, null, 2)}\n`
399}
400
401// parseJob: a job file's body as a job, or why it is none.
402export function parseJob(body) {
403  let v
404  try {
405    v = JSON.parse(body)
406  } catch {
407    return 'the job file is not JSON'
408  }
409  const o = record(v)
410  if (o === null || o.v !== FILE_VERSION) return `the job file is not version ${FILE_VERSION} of the factory loop's job files`
411  const contract = CONTRACTS.find(c => c === o.contract)
412  const purpose = o.purpose === null ? null : (PURPOSES.find(p => p === o.purpose) ?? undefined)
413  const issue = o.issue === null || (Number.isInteger(o.issue) && o.issue > 0) ? o.issue : undefined
414  const branch = o.branch === null || (typeof o.branch === 'string' && o.branch !== '') ? o.branch : undefined
415  const data = o.data === undefined || o.data === null ? null : (record(o.data) ?? undefined)
416  if (contract === undefined || purpose === undefined || typeof o.repo !== 'string' || issue === undefined || branch === undefined || data === undefined) return 'the job file is not in the shape the factory loop writes'
417  return data === null ? { contract, purpose, repo: o.repo, issue, branch } : { contract, purpose, repo: o.repo, issue, branch, data }
418}
419
420// answerSummary: a few words on an answer the script recorded, so the agent sees the loop will
421// read what it meant.
422export function answerSummary(contract, input) {
423  const v2 = Object.hasOwn(V2_CONTRACTS, contract) ? V2_CONTRACTS[contract] : undefined
424  if (v2 !== undefined) return v2.summary(input)
425  const o = input !== null && typeof input === 'object' ? input : {}
426  if (contract === 'verdict') {
427    const n = Array.isArray(o.findings) ? o.findings.length : 0
428    return `action ${o.action?.type}, ${n} finding${n === 1 ? '' : 's'}`
429  }
430  if (contract === 'design') return `brief ${o.brief ?? 'none'}, created ${(o.created ?? []).join(',') || 'none'}, pending decision ${o.pendingDecision ? 'yes' : 'no'}`
431  return `run ${o.run ?? 'none'}, head ${o.head ?? 'none'}, gated ${o.gated ? 'yes' : 'no'}`
432}
433
434// answerText: the answer file's body for an input its job's contract takes, or the problems
435// that keep it out. The input is kept as the agent gave it: the loop checks it again on reading.
436export function answerText(job, input) {
437  const check = checkAnswer(job, input)
438  if (!check.ok) return check
439  return { ok: true, outcome: `${JSON.stringify({ v: FILE_VERSION, contract: job.contract, answer: input }, null, 2)}\n` }
440}
441
442// parseAnswer: an answer file's body: the answer it carries, for this contract, still to be
443// checked; or why it carries none.
444export function parseAnswer(body, contract) {
445  let v
446  try {
447    v = JSON.parse(body)
448  } catch {
449    return { ok: false, problems: ['the answer file is not JSON'] }
450  }
451  const o = record(v)
452  if (o === null || o.v !== FILE_VERSION || !('answer' in o)) return { ok: false, problems: ['the answer file is not in the shape the answer script writes'] }
453  if (o.contract !== contract) return { ok: false, problems: [`the answer file holds a ${clip(o.contract)} answer, and this agent answers with ${contract}`] }
454  return { ok: true, outcome: o.answer }
455}
456
hooks/control.ts 661 lines
1// The owner's controls (SPEC 4.3; DECISIONS D-15, D-16, D-33, D-35, D-38). `/factory <op>` and
2// the control tool (mcp__factory-loop__control, input { op }) take the same op text: parseOp
3// reads it, applyOp returns the new Control (the old one is never changed) with the effects the
4// driver carries out and the one line the command or tool answers, and runOp makes the round
5// trip over ActIo's readFile and writeFile: `<loop dir>/control.json`, which observe reads each
6// pass, and the workspace config file for `mode`, `trace` and `planner`. Everything but runOp and
7// its two file wrappers is pure, so the tests and the replay harness drive it without the engine.
8// Under planner v2 three ops take v2's meaning (v2 SPEC appendix E), which v2/ops.ts holds:
9// `resume <N...> ["note"]` unparks (P1), `drop` closes the current life's record (E1), and an issue
10// that is not parked is not resumed; the caller says which planner is in force.
11import type { CommandSpec, ToolSpec } from 'claude-code'
12
13import type { PassSummary } from '../types'
14import { EMPTY_CONTROL, controlFrom } from './config'
15import type { ActIo } from './exec'
16import type { Control, Mode, Ms } from './types'
17import { applyDrop, applyUnpark, onlyParked, parseUnpark } from './v2/ops'
18import type { Unpark } from './v2/ops'
19import type { Planner } from './v2/types'
20
21const MIN = 60_000
22const HOUR = 60 * MIN
23// an override longer than the seven-day usage window is a typo, not a plan (I-19)
24const OVERRIDE_MAX_HOURS = 168
25// a reason rides on the restart's ledger entry and the drain-slow tell; a note goes into every
26// matching agent prompt (SPEC 6.3). Both are refused past these, never cut.
27const REASON_MAX = 300
28const NOTE_MAX = 2000
29
30export const OPS = [
31  'status', 'pause', 'unpause', 'skip', 'noskip', 'priority', 'only', 'serial', 'new', 'override', 'resume', 'retry',
32  'update', 'drop', 'note', 'mode', 'trace', 'planner',
33] as const
34export type OpName = (typeof OPS)[number]
35
36// Each op's form, for the usage errors and the tool's description (SPEC 4.3). `priority off`
37// and `override off` are the only way to lift those two, as `only off` and `update off` are.
38export const USAGE: Record<OpName, string> = {
39  status: 'status',
40  pause: 'pause <N>',
41  unpause: 'unpause <N>',
42  skip: 'skip <N>',
43  noskip: 'noskip <N>',
44  priority: 'priority <N...>|off',
45  only: 'only <N...>|off',
46  serial: 'serial on|off',
47  new: 'new on|off',
48  override: 'override <pct> <hours>|off',
49  resume: 'resume <N...> ["note"]|<run id>',
50  retry: 'retry <N>',
51  update: 'update [<reason>]|off',
52  drop: 'drop <N>',
53  note: 'note [<N>] <text>',
54  mode: 'mode off|observe|act',
55  trace: 'trace on|off',
56  planner: 'planner v1|v2',
57}
58
59// What each op does, one line each, for `/factory help` (SPEC 4.3).
60export const MEANING: Record<OpName, string> = {
61  status: "show the loop's last pass and the owner directives in force",
62  pause: 'take no action on issue N (its claim still renews)',
63  unpause: 'undo pause',
64  skip: 'never claim issue N',
65  noskip: 'undo skip',
66  priority: 'claim these first, in this order; off clears it',
67  only: 'claim only these, in this order (a tracker stands for its open sub-issues); off clears it',
68  serial: 'on: one issue in flight at a time',
69  new: 'off: claim and start nothing new (work in flight goes on)',
70  override: 'run past a usage tier up to <pct> for <hours>; off ends it',
71  resume: 'release what waits on you: under planner v2, unpark issues N (a note goes to their next step); under v1, a hold on an issue or a run',
72  retry: "clear issue N's used-up actions, so its rules try again",
73  update: 'an update is due: drain, then restart the workspace; off cancels it',
74  drop: 'abandon issue N: its agents stop and its claim is released (under planner v2, for this life only; ready brings it back)',
75  note: "an instruction for issue N's agents (for every issue without N)",
76  mode: 'off runs nothing, observe plans and logs only, act does the work',
77  trace: "on: each pass's timing goes to trace-YYYY-MM-DD.jsonl beside the decision log",
78  planner: 'the layer that plans and acts, in the workspace config file; v1 may run the v2 shadow beside it',
79}
80
81// helpText: `/factory help`, in the shape of a command's --help, built from the same table the
82// parser checks, so the two cannot drift.
83export function helpText(): string {
84  const rows: [string, string][] = [...OPS.map(name => [USAGE[name], MEANING[name]] as [string, string]), ['help', 'this list']]
85  const width = Math.max(...rows.map(([usage]) => usage.length)) + 2
86  return [
87    'Usage: /factory <op> [arguments]',
88    '',
89    'Owner controls for the factory loop. A change is logged, wakes the loop, and applies on its next pass.',
90    '',
91    'Ops:',
92    ...rows.map(([usage, meaning]) => `  ${usage.padEnd(width)}${meaning}`),
93    '',
94    'N is an issue number, with or without #; a list takes spaces or commas. In chat, the model sets',
95    'the same ops through mcp__factory-loop__control with { "op": "<op>" }.',
96  ].join('\n')
97}
98
99// One op, parsed. An empty priority or only list clears it.
100export type Op =
101  | { op: 'status' }
102  | { op: 'pause' | 'unpause' | 'skip' | 'noskip' | 'retry' | 'drop'; issue: number }
103  | { op: 'priority' | 'only'; issues: number[] }
104  | { op: 'serial' | 'new' | 'trace'; on: boolean }
105  // null: `override off`
106  | { op: 'override'; until: { pct: number; hours: number } | null }
107  // 'issue:<N>' or 'run:<id>', as plan's resumes() matches them
108  | { op: 'resume'; subject: string }
109  // null: `update off`
110  | { op: 'update'; reason: string | null }
111  | { op: 'note'; issue: number | null; text: string }
112  | { op: 'mode'; mode: Mode }
113  | { op: 'planner'; planner: Planner }
114  // v2's resume (v2/ops.ts)
115  | Unpark
116
117export type Parsed = { ok: true; op: Op } | { ok: false; error: string }
118
119// What the driver carries out besides writing control.json: retryIssue through the ledger's
120// retryIssue (D-38), mode, trace and planner into the workspace config file (runOp does those).
121export type Effects = { retryIssue?: number; mode?: Mode; trace?: boolean; planner?: Planner }
122
123export type Applied = { control: Control; effects: Effects; changed: boolean; text: string }
124
125// --- parsing ---------------------------------------------------------------------------------
126
127const ISSUE = /^#?([1-9]\d{0,8})$/
128// a pipeline run id (a ULID such as 01M4RQ0045AAAA); unprefixed, a number alone reads as an issue
129const RUN_CHARS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
130const RUN_ID = /^(?=.*[A-Za-z])[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
131const DECIMAL = /^(\d+(?:\.\d+)?)$/
132
133function issueOf(word: string | undefined): number | null {
134  const m = word === undefined ? null : ISSUE.exec(word)
135  return m?.[1] === undefined ? null : Number(m[1])
136}
137
138function bad(name: OpName, why?: string): Parsed {
139  return { ok: false, error: `${why === undefined ? '' : `${name}: ${why}. `}Usage: ${USAGE[name]}` }
140}
141
142function allUsage(): string {
143  return OPS.map(name => USAGE[name]).join(', ')
144}
145
146// parseOp: one op as the owner types it after `/factory`, or the tool's `op`. The op name and
147// its keywords ignore case, numbers take an optional leading #, and a list may use commas. An
148// empty op is `status`. Anything malformed is an error naming the op and its usage, so a bad
149// op never makes a partial change. Under planner v2, `resume` takes issue numbers and an
150// optional note in double quotes, and no run id.
151export function parseOp(text: string, planner: Planner = 'v1'): Parsed {
152  const words = text.trim().split(/\s+/).filter(w => w !== '')
153  // the model may pass the command as typed
154  if (words[0]?.toLowerCase() === '/factory') words.shift()
155  const head = (words[0] ?? 'status').toLowerCase()
156  const args = words.slice(1)
157  const rest = args.join(' ')
158  const word = args.length === 1 ? args[0]?.toLowerCase() : undefined
159  if (head === 'help' || head === '--help' || head === '-h') return { ok: false, error: helpText() }
160  if (!(OPS as readonly string[]).includes(head)) {
161    return { ok: false, error: `Unknown op "${(words[0] ?? '').slice(0, 40)}". Ops: ${allUsage()}. /factory help explains each.` }
162  }
163  const name = head as OpName
164
165  switch (name) {
166    case 'status':
167      return args.length === 0 ? { ok: true, op: { op: 'status' } } : bad(name)
168    case 'pause':
169    case 'unpause':
170    case 'skip':
171    case 'noskip':
172    case 'retry':
173    case 'drop': {
174      const issue = args.length === 1 ? issueOf(args[0]) : null
175      return issue === null ? bad(name, 'takes one issue number') : { ok: true, op: { op: name, issue } }
176    }
177    case 'priority':
178    case 'only': {
179      if (word === 'off') return { ok: true, op: { op: name, issues: [] } }
180      const items = rest.split(/[\s,]+/).filter(w => w !== '')
181      const issues = items.map(issueOf)
182      if (items.length === 0 || issues.some(n => n === null)) return bad(name, 'takes issue numbers, or off')
183      return { ok: true, op: { op: name, issues: [...new Set(issues as number[])] } }
184    }
185    case 'serial':
186    case 'new':
187    case 'trace':
188      return word === 'on' || word === 'off' ? { ok: true, op: { op: name, on: word === 'on' } } : bad(name)
189    case 'override': {
190      if (word === 'off') return { ok: true, op: { op: 'override', until: null } }
191      const pct = args.length === 2 ? DECIMAL.exec(args[0]?.replace(/%$/, '') ?? '') : null
192      const hours = args.length === 2 ? DECIMAL.exec(args[1]?.replace(/h$/i, '') ?? '') : null
193      if (pct?.[1] === undefined || hours?.[1] === undefined) return bad(name, 'takes a percentage and a number of hours')
194      const p = Number(pct[1])
195      const h = Number(hours[1])
196      if (p <= 0 || p > 100) return bad(name, 'the percentage is above 0 and at most 100')
197      if (h <= 0 || h > OVERRIDE_MAX_HOURS) return bad(name, `the hours are above 0 and at most ${OVERRIDE_MAX_HOURS}`)
198      return { ok: true, op: { op: 'override', until: { pct: p, hours: h } } }
199    }
200    case 'resume': {
201      if (planner === 'v2') {
202        const unpark = parseUnpark(rest, issueOf, NOTE_MAX)
203        return typeof unpark === 'string' ? bad(name, unpark) : { ok: true, op: unpark }
204      }
205      // `issue:<N>` and `run:<id>` (formatOp's spelling) are read too
206      const arg = args.length === 1 ? (args[0] ?? '') : ''
207      const prefixed = /^(issue|run):(.+)$/i.exec(arg)
208      const kind = prefixed?.[1]?.toLowerCase()
209      const value = prefixed?.[2] ?? arg
210      const issue = kind === 'run' ? null : issueOf(value)
211      if (issue !== null) return { ok: true, op: { op: 'resume', subject: `issue:${issue}` } }
212      const run = kind === 'run' ? RUN_CHARS.test(value) : kind === undefined && RUN_ID.test(value)
213      return run ? { ok: true, op: { op: 'resume', subject: `run:${value}` } } : bad(name, 'takes one issue number or run id')
214    }
215    case 'update':
216      if (args.length === 0) return { ok: true, op: { op: 'update', reason: 'owner' } }
217      if (word === 'off') return { ok: true, op: { op: 'update', reason: null } }
218      if (rest.length > REASON_MAX) return bad(name, `the reason is at most ${REASON_MAX} characters`)
219      return { ok: true, op: { op: 'update', reason: rest } }
220    case 'note': {
221      const issue = issueOf(args[0])
222      const note = (issue === null ? args : args.slice(1)).join(' ')
223      if (note === '') return bad(name, 'needs the text')
224      if (note.length > NOTE_MAX) return bad(name, `the text is at most ${NOTE_MAX} characters`)
225      return { ok: true, op: { op: 'note', issue, text: note } }
226    }
227    case 'mode':
228      return word === 'off' || word === 'observe' || word === 'act' ? { ok: true, op: { op: 'mode', mode: word } } : bad(name)
229    case 'planner':
230      return word === 'v1' || word === 'v2' ? { ok: true, op: { op: 'planner', planner: word } } : bad(name)
231  }
232}
233
234// issuesOf: the issues an op names, which its intervention attaches to (v2 SPEC 7.2); none for a
235// fleet-level op.
236export function issuesOf(op: Op): number[] {
237  if ('issue' in op) return op.issue === null ? [] : [op.issue]
238  if ('issues' in op) return [...op.issues]
239  if (op.op === 'resume' && op.subject.startsWith('issue:')) return [Number(op.subject.slice('issue:'.length))]
240  return []
241}
242
243// formatOp: the op as canonical text, for the decision log; parseOp reads it back, but for a
244// note to every issue whose text starts with a number.
245export function formatOp(op: Op): string {
246  switch (op.op) {
247    case 'status':
248      return 'status'
249    case 'pause':
250    case 'unpause':
251    case 'skip':
252    case 'noskip':
253    case 'retry':
254    case 'drop':
255      return `${op.op} ${op.issue}`
256    case 'priority':
257    case 'only':
258      return `${op.op} ${op.issues.length === 0 ? 'off' : op.issues.join(' ')}`
259    case 'serial':
260    case 'new':
261    case 'trace':
262      return `${op.op} ${op.on ? 'on' : 'off'}`
263    case 'override':
264      return op.until === null ? 'override off' : `override ${op.until.pct} ${op.until.hours}`
265    case 'resume':
266      return `resume ${op.subject}`
267    case 'update':
268      return op.reason === null ? 'update off' : `update ${op.reason}`
269    case 'note':
270      return `note ${op.issue === null ? '' : `${op.issue} `}${op.text}`
271    case 'mode':
272      return `mode ${op.mode}`
273    case 'planner':
274      return `planner ${op.planner}`
275    case 'unpark':
276      return `resume ${op.issues.join(' ')}${op.note === null ? '' : ` "${op.note}"`}`
277  }
278}
279
280// --- applying --------------------------------------------------------------------------------
281
282const tags = (issues: readonly number[]) => issues.map(n => `#${n}`).join(' ')
283const same = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((n, i) => n === b[i])
284
285// applyOp: the op against the control state as it stands. The input is never changed: a change
286// returns a new Control with new arrays for what changed. `changed` says whether control.json
287// needs writing; an op that changes nothing (pausing a paused issue) answers so and writes
288// nothing. `last` is the last pass, for status. Under planner v2 a drop applies to the life it
289// closes, so a drop of an issue claimed again since is recorded again (v2 SPEC 4.1).
290export function applyOp(control: Control, op: Op, now: Ms, last: PassSummary | null = null, planner: Planner = 'v1'): Applied {
291  const keep = (text: string, effects: Effects = {}): Applied => ({ control, effects, changed: false, text })
292  const set = (patch: Partial<Control>, text: string): Applied => ({ control: { ...control, ...patch }, effects: {}, changed: true, text })
293
294  switch (op.op) {
295    case 'status':
296      return keep(statusText(last, control, now))
297    case 'pause':
298      return control.paused.includes(op.issue)
299        ? keep(`#${op.issue} is already paused.`)
300        : set({ paused: [...control.paused, op.issue] }, `Paused #${op.issue}: no action on it until unpause; its claim still renews.`)
301    case 'unpause':
302      return control.paused.includes(op.issue)
303        ? set({ paused: control.paused.filter(n => n !== op.issue) }, `Unpaused #${op.issue}.`)
304        : keep(`#${op.issue} is not paused.`)
305    case 'skip':
306      return control.skip.includes(op.issue)
307        ? keep(`#${op.issue} is already skipped.`)
308        : set({ skip: [...control.skip, op.issue] }, `Skipping #${op.issue}: it is never claimed.`)
309    case 'noskip':
310      return control.skip.includes(op.issue)
311        ? set({ skip: control.skip.filter(n => n !== op.issue) }, `#${op.issue} can be claimed again.`)
312        : keep(`#${op.issue} is not skipped.`)
313    case 'priority':
314      if (same(control.priority, op.issues)) return keep(op.issues.length === 0 ? 'No priority is set.' : `Priority is already ${tags(op.issues)}.`)
315      return op.issues.length === 0
316        ? set({ priority: [] }, 'Priority cleared.')
317        : set({ priority: [...op.issues] }, `Priority: ${tags(op.issues)} are claimed first, in this order.`)
318    case 'only':
319      if (same(control.only, op.issues)) return keep(op.issues.length === 0 ? 'Only is already off.' : `Only is already ${tags(op.issues)}.`)
320      return op.issues.length === 0
321        ? set({ only: [] }, 'Only off: any ready issue can be claimed.')
322        : set(
323            { only: [...op.issues] },
324            `Only ${tags(op.issues)}: claims only these, in this order (a tracker stands for its open sub-issues); work in flight goes on.`,
325          )
326    case 'serial':
327      if (control.serial === op.on) return keep(`Serial is already ${op.on ? 'on' : 'off'}.`)
328      return set({ serial: op.on }, op.on ? 'Serial on: one issue in flight at a time.' : 'Serial off.')
329    case 'new':
330      if (control.noNewWork === !op.on) return keep(`New work is already ${op.on ? 'on' : 'off'}.`)
331      return set(
332        { noNewWork: !op.on },
333        op.on ? 'New work on.' : 'New work off: no new claims, designs or implementations; work in flight goes on.',
334      )
335    case 'override': {
336      const live = control.usageOverride !== null && now < control.usageOverride.expiresAt
337      if (op.until === null) {
338        if (control.usageOverride === null) return keep('No usage override is set.')
339        return set({ usageOverride: null }, live ? 'Usage override cleared.' : 'Expired usage override cleared.')
340      }
341      const expiresAt = now + Math.round(op.until.hours * HOUR)
342      return set(
343        { usageOverride: { untilPct: op.until.pct, expiresAt } },
344        `Usage override: run past the usage tiers up to ${op.until.pct}% until ${clock(expiresAt, now)}.`,
345      )
346    }
347    case 'resume': {
348      const what = op.subject.startsWith('issue:') ? `issue #${op.subject.slice('issue:'.length)}` : `run ${op.subject.slice('run:'.length)}`
349      return set({ resumed: [...control.resumed, { subject: op.subject, at: now }] }, `Resumed ${what}: its hold is released.`)
350    }
351    case 'retry':
352      return keep(`Retry #${op.issue}: its exhausted actions are cleared.`, { retryIssue: op.issue })
353    case 'update':
354      if (op.reason === null) {
355        return control.updateDue === null ? keep('No update is due.') : set({ updateDue: null }, 'Update cancelled: the loop stops draining.')
356      }
357      return set(
358        { updateDue: { reason: op.reason, at: now } },
359        `Update due (${op.reason}): the loop drains, then restarts this workspace once nothing is in flight.`,
360      )
361    case 'drop': {
362      if (planner === 'v2') {
363        const v2 = applyDrop(control, op.issue, now)
364        return set(v2.patch, v2.text)
365      }
366      if (control.dropped.some(d => d.issue === op.issue)) return keep(`#${op.issue} is already dropped.`)
367      // a pause holds every action, the abandon too (plan's owner filter)
368      const paused = control.paused.includes(op.issue) ? ` It is paused: unpause it for the abandon to run.` : ''
369      return set(
370        { dropped: [...control.dropped, { issue: op.issue, at: now }] },
371        `Dropped #${op.issue}: the loop abandons it and stops its agents.${paused}`,
372      )
373    }
374    case 'note':
375      return set(
376        { notes: [...control.notes, { issue: op.issue, text: op.text, at: now }] },
377        op.issue === null ? 'Note added for every issue: the next agent prompts carry it.' : `Note added for #${op.issue}: its next agent prompts carry it.`,
378      )
379    case 'mode':
380      return keep(`Mode ${op.mode} set in the workspace config file; it applies from the next pass.`, { mode: op.mode })
381    case 'planner':
382      return keep(`Planner ${op.planner} set in the workspace config file; it plans and acts from the next pass.`, { planner: op.planner })
383    case 'unpark': {
384      const v2 = applyUnpark(control, op, now)
385      return set(v2.patch, v2.text)
386    }
387    case 'trace':
388      return keep(
389        op.on
390          ? "Trace on, in the workspace config file: from the next pass, each pass's timing goes to trace-YYYY-MM-DD.jsonl beside the decision log."
391          : "Trace off, in the workspace config file: from the next pass, the decision log alone carries each pass's time.",
392        { trace: op.on },
393      )
394  }
395}
396
397// retryText: what `retry <N>` answers once the driver ran the ledger's retryIssue (D-38).
398export function retryText(issue: number, cleared: readonly string[]): string {
399  if (cleared.length === 0) return `#${issue} has no exhausted actions: nothing to clear.`
400  const shown = cleared.slice(0, 3).join(', ')
401  const more = cleared.length > 3 ? ` and ${cleared.length - 3} more` : ''
402  const noun = cleared.length === 1 ? 'action' : 'actions'
403  return `Retry #${issue}: cleared ${cleared.length} exhausted ${noun} (${shown}${more}); its rules try again from the next pass.`
404}
405
406// --- status ----------------------------------------------------------------------------------
407
408// clock: a time as HH:MM UTC, with the date when it is not today's.
409function clock(at: Ms, now: Ms): string {
410  const iso = new Date(at).toISOString()
411  const day = iso.slice(0, 10) === new Date(now).toISOString().slice(0, 10) ? '' : `${iso.slice(0, 10)} `
412  return `${day}${iso.slice(11, 16)} UTC`
413}
414
415function ago(ms: Ms): string {
416  if (ms < MIN) return `${Math.max(0, Math.round(ms / 1000))}s`
417  if (ms < HOUR) return `${Math.floor(ms / MIN)}m`
418  return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MIN)}m`
419}
420
421// directives: the control state in force, one short phrase each; an expired override is left out.
422export function directives(control: Control, now: Ms): string[] {
423  const out: string[] = []
424  if (control.noNewWork) out.push('no new work')
425  if (control.updateDue !== null) out.push(`update due since ${clock(control.updateDue.at, now)} (${control.updateDue.reason || 'no reason'})`)
426  if (control.only.length > 0) out.push(`only ${tags(control.only)}`)
427  if (control.priority.length > 0) out.push(`priority ${tags(control.priority)}`)
428  if (control.serial) out.push('serial')
429  if (control.paused.length > 0) out.push(`paused ${tags(control.paused)}`)
430  if (control.skip.length > 0) out.push(`skip ${tags(control.skip)}`)
431  const o = control.usageOverride
432  if (o !== null && now < o.expiresAt) out.push(`usage override to ${o.untilPct}% until ${clock(o.expiresAt, now)}`)
433  const dropped = [...new Set(control.dropped.map(d => d.issue))]
434  if (dropped.length > 0) out.push(`dropped ${tags(dropped)}`)
435  if (control.notes.length > 0) out.push(`${control.notes.length} ${control.notes.length === 1 ? 'note' : 'notes'}`)
436  return out
437}
438
439// statusText: the `/factory status` answer: the last pass (PassSummary), its title, and the
440// owner directives in force.
441export function statusText(last: PassSummary | null, control: Control, now: Ms): string {
442  const lines: string[] = []
443  if (last === null) {
444    lines.push('Factory loop: no pass yet.')
445  } else {
446    const noun = last.actions === 1 ? 'action' : 'actions'
447    const layer = last.planner === undefined ? '' : `, planner ${last.planner}${last.planner === 'v1' && last.shadow === 'v2' ? ' with the v2 shadow' : ''}`
448    lines.push(`Pass ${last.pass}, ${ago(now - last.at)} ago: ${last.mode}${layer}, tier ${last.tier}, ${last.actions} ${noun}, ${last.owner} for the owner.`)
449    lines.push(last.title)
450  }
451  const d = directives(control, now)
452  lines.push(d.length === 0 ? 'Directives: none.' : `Directives: ${d.join('; ')}.`)
453  return lines.join('\n')
454}
455
456// --- the files -------------------------------------------------------------------------------
457
458export function controlPath(loopDir: string): string {
459  return `${loopDir}/control.json`
460}
461
462// The workspace config file (SPEC 4.1), where `mode` lives beside repo, shadowDevice and the rest.
463export function configFilePath(configDir: string): string {
464  return `${configDir}/factory-loop/config.json`
465}
466
467export type LoadedControl = { control: Control; error: string | null }
468
469// loadControl: control.json's text as Control. A missing or empty file is no directive; a
470// missing or malformed field takes its empty value (controlFrom). A file that is not a JSON
471// object is an error, so a write never replaces directives the owner can still recover.
472export function loadControl(text: string | null): LoadedControl {
473  if (text === null || text.trim() === '') return { control: EMPTY_CONTROL, error: null }
474  let raw: unknown
475  try {
476    raw = JSON.parse(text)
477  } catch {
478    return { control: EMPTY_CONTROL, error: 'control.json is not valid JSON' }
479  }
480  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { control: EMPTY_CONTROL, error: 'control.json is not a JSON object' }
481  return { control: controlFrom(raw), error: null }
482}
483
484export function controlText(control: Control): string {
485  return JSON.stringify(control, null, 2) + '\n'
486}
487
488// The workspace config file's keys an op writes.
489export type SettingKey = 'mode' | 'trace' | 'planner'
490
491// withSetting: the workspace config file's text with one key set (`mode`, `trace`, `planner`) and
492// every other key kept, in place. A file that is not a JSON object is left alone: the owner's
493// settings matter more than the op.
494export function withSetting(text: string | null, key: SettingKey, value: Mode | Planner | boolean): { ok: true; text: string } | { ok: false; error: string } {
495  let raw: unknown = {}
496  if (text !== null && text.trim() !== '') {
497    try {
498      raw = JSON.parse(text)
499    } catch {
500      return { ok: false, error: 'the workspace config file is not valid JSON' }
501    }
502  }
503  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'the workspace config file is not a JSON object' }
504  return { ok: true, text: JSON.stringify({ ...(raw as Record<string, unknown>), [key]: value }, null, 2) + '\n' }
505}
506
507export function withMode(text: string | null, mode: Mode): { ok: true; text: string } | { ok: false; error: string } {
508  return withSetting(text, 'mode', mode)
509}
510
511export type FileIo = Pick<ActIo, 'readFile' | 'writeFile'>
512
513export async function readControl(io: FileIo, loopDir: string): Promise<LoadedControl> {
514  return loadControl(await io.readFile(controlPath(loopDir)))
515}
516
517// writeControl: the whole file, every field (SPEC 4.3).
518export async function writeControl(io: FileIo, loopDir: string, control: Control): Promise<void> {
519  await io.writeFile(controlPath(loopDir), controlText(control))
520}
521
522export async function writeSetting(io: FileIo, configDir: string, key: SettingKey, value: Mode | Planner | boolean): Promise<string | null> {
523  const path = configFilePath(configDir)
524  const next = withSetting(await io.readFile(path), key, value)
525  if (!next.ok) return next.error
526  await io.writeFile(path, next.text)
527  return null
528}
529
530// --- the command and the tool ----------------------------------------------------------------
531
532export type ControlPaths = { loopDir: string; configDir: string }
533
534// The caller: `last` for status; agentId when the tool call came from inside an agent's loop; the
535// planner in force (v1 when not said), and under planner v2 the issues parked now, as the last
536// pass's records say (null: not known), so a resume of one that is not parked changes nothing.
537export type OpFrom = { last?: PassSummary | null; agentId?: string; planner?: Planner; parked?: readonly number[] | null }
538
539// What `/factory` or the tool answers. ping: something changed (control.json, the mode, trace, or
540// a retry to run), so the driver pings the loop and logs `logged` (SPEC 4.3). retryIssue: the
541// driver runs the ledger's retryIssue and may answer retryText in place of `text`.
542export type OpReply = {
543  ok: boolean
544  text: string
545  op: Op | null
546  control: Control | null
547  ping: boolean
548  retryIssue: number | null
549  mode: Mode | null
550  logged: string | null
551}
552
553const refused = (text: string, op: Op | null = null): OpReply => ({
554  ok: false,
555  text,
556  op,
557  control: null,
558  ping: false,
559  retryIssue: null,
560  mode: null,
561  logged: null,
562})
563
564// runOp: one op, start to finish: parse, read control.json, apply, write what changed. Only the
565// main conversation changes directives: an agent the loop spawned may read the status, never
566// set an op (SPEC 6.1: the loop's agents act on their own issue only).
567export async function runOp(io: FileIo, paths: ControlPaths, input: string, now: Ms, from: OpFrom = {}): Promise<OpReply> {
568  const planner = from.planner ?? 'v1'
569  const parsed = parseOp(input, planner)
570  if (!parsed.ok) return refused(parsed.error)
571  let op = parsed.op
572  let aside = ''
573  if (op.op === 'unpark') {
574    const only = onlyParked(op, from.parked)
575    if ('refused' in only) return refused(only.refused, op)
576    op = only.op
577    aside = only.aside
578  }
579  if (from.agentId !== undefined && op.op !== 'status') return refused('Not changed: only the main conversation sets owner directives.', op)
580  let loaded: LoadedControl
581  try {
582    loaded = await readControl(io, paths.loopDir)
583  } catch (err) {
584    loaded = { control: EMPTY_CONTROL, error: `control.json could not be read (${String(err).slice(0, 200)})` }
585  }
586  if (op.op === 'status') {
587    const text = statusText(from.last ?? null, loaded.control, now)
588    const warning = loaded.error === null ? '' : `\n${loaded.error}: fix or remove it.`
589    return { ok: true, text: text + warning, op, control: loaded.control, ping: false, retryIssue: null, mode: null, logged: null }
590  }
591  if (loaded.error !== null) return refused(`Not changed: ${loaded.error}; fix or remove it.`, op)
592  const applied = applyOp(loaded.control, op, now, null, planner)
593  try {
594    if (applied.changed) await writeControl(io, paths.loopDir, applied.control)
595    for (const key of ['mode', 'trace', 'planner'] as const) {
596      const value = applied.effects[key]
597      const error = value === undefined ? null : await writeSetting(io, paths.configDir, key, value)
598      if (error !== null) return refused(`Not changed: ${error}.`, op)
599    }
600  } catch (err) {
601    return refused(`Not changed: the write failed (${String(err).slice(0, 200)}).`, op)
602  }
603  const ping = applied.changed || Object.keys(applied.effects).length > 0
604  return {
605    ok: true,
606    text: `${applied.text}${aside}`,
607    op,
608    control: applied.control,
609    ping,
610    retryIssue: applied.effects.retryIssue ?? null,
611    mode: applied.effects.mode ?? null,
612    logged: ping ? formatOp(op) : null,
613  }
614}
615
616// controlTool: the tool's handler: input { op: string }, then runOp.
617export async function controlTool(io: FileIo, paths: ControlPaths, input: unknown, now: Ms, from: OpFrom = {}): Promise<OpReply> {
618  const o = input !== null && typeof input === 'object' && !Array.isArray(input) ? (input as Record<string, unknown>) : null
619  if (o === null || typeof o.op !== 'string') return refused(`Not changed: op is required, as text. Ops: ${allUsage()}`)
620  return runOp(io, paths, o.op, now, from)
621}
622
623// `/factory`: immediate, so a directive typed while a long wake turn runs (a task may take
624// hours) applies at once; the hook only reads and writes files.
625export const FACTORY_COMMAND: CommandSpec = {
626  name: 'factory',
627  description:
628    'Factory loop owner controls: status, pause, unpause, skip, noskip, priority, only, serial, new, override, resume, retry, update, drop, note, mode, trace, planner',
629  argumentHint: '<op>',
630  immediate: true,
631}
632
633// mcp__factory-loop__control: the same ops for the model, when the owner says so in chat (the
634// judge skill's Owner chat section). Behind ToolSearch, as the answer tool is, so it costs the
635// prompt nothing between directives.
636export const CONTROL_TOOL: ToolSpec = {
637  name: 'control',
638  description:
639    "Set the factory loop's owner directives (control.json) when the owner asks, as `/factory <op>` does; " +
640    'one op per call, answered in one line, and a malformed op changes nothing. Ops: ' +
641    'status (what the loop is doing); pause <N> / unpause <N> (no action at all on N; its claim still renews); ' +
642    'skip <N> / noskip <N> (never claim N); priority <N...>|off (claim these first, in order); ' +
643    'only <N...>|off (claim only these; a tracker stands for its open sub-issues); serial on|off (one issue in ' +
644    'flight at a time); new on|off (off: no new claims, designs or implementations); override <pct> <hours>|off ' +
645    '(run past the usage tiers up to pct for hours); resume <N...> ["note"]|<run id> (release what waits on you: ' +
646    'under planner v2 unpark issues, the note going to their next step; under v1 a hold on an issue or a run); ' +
647    "retry <N> (clear N's exhausted actions); update [<reason>]|off (drain, then restart this workspace for an " +
648    'update); drop <N> (abandon N); note [<N>] <text> (an instruction the next agent prompts carry); ' +
649    "mode off|observe|act (only when the owner asks for that mode); trace on|off (each pass's timing to a local " +
650    'trace file, for troubleshooting); planner v1|v2 (the layer that plans and acts, only when the owner asks).',
651  inputSchema: {
652    type: 'object',
653    properties: {
654      op: { type: 'string', description: 'one op as /factory takes it, e.g. "only 522" or "new off"' },
655    },
656    required: ['op'],
657    additionalProperties: false,
658  },
659  isDeferred: true,
660}
661
hooks/exec.ts 156 lines
1// The act half's contract (SPEC 6, 7, 8): what an executor is given and what it reports.
2// register.ts builds ActIo over $ (the only module that holds $); every executor module
3// (act-cli.ts, act-merge.ts, act-agents.ts, wake.ts) takes an ExecCtx and returns an Exec,
4// and the driver records the result in the ledger (ledger.ts). Executors decide nothing:
5// plan already did. They turn one action into commands, an agent spawn or a wake, and say
6// what happened.
7import type { Runtime } from './observe'
8import type { Action, ActionKind, LedgerEntry, Ms, Outcome, State } from './types'
9import type { V2Io } from './v2/types'
10
11export type RunInit = { cwd?: string; stdin?: string; timeoutMs: number }
12export type RunResult = { exitCode: number; stdout: string; stderr: string }
13
14export type SpawnInput = {
15  subagentType: string
16  prompt: string
17  description: string
18  name?: string
19  model?: string
20  // the slot an agent step runs in (v2 SPEC 10.3): its prompt opens with the slot line
21  cwd?: string
22}
23
24// ActIo: the engine calls the act half makes. Commands always go through ExecCtx.cmd, which
25// adds the repo, the mode's environment (SPEC 11.1) and the decision log's command list.
26export type ActIo = {
27  now: () => Promise<Ms>
28  run: (argv: string[], init: RunInit & { cwd: string; env: Record<string, string> }) => Promise<RunResult>
29  // $.agent.spawn: resolves once the agent started; its answer arrives as its turn.complete
30  spawnAgent: (input: SpawnInput) => Promise<{ agentId?: string; deny?: string }>
31  // TaskStop through $.tool.call, for one of the loop's own agents only (SPEC 6.1, 13)
32  stopAgent: (agentId: string) => Promise<{ stopped: boolean; error?: string }>
33  // $.prompt.submit: a turn of its own once the session is idle (SPEC 8.1)
34  submit: (text: string) => Promise<void>
35  // $.command.run rename (SPEC 9.1)
36  rename: (title: string) => Promise<void>
37  // $.session.usage().rateLimits as percentages, for the pre-check before a token-spending
38  // action (SPEC 10.7); null where the session has no reading
39  usage: () => Promise<{ fiveHour: number | null; sevenDay: number | null }>
40  readFile: (path: string) => Promise<string | null>
41  writeFile: (path: string, text: string) => Promise<void>
42  // v2's live records and the driver's ping (v2 SPEC 3.3, 4.6), for the transition executor; absent
43  // where the driver keeps no records (a test's fake, a hook outside act mode)
44  v2?: V2Io
45}
46
47export type ExecCtx = {
48  io: ActIo
49  rt: Runtime
50  // the state this pass planned from
51  state: State
52  // this attempt's ledger entry, already running with its attempt counted
53  entry: LedgerEntry
54  // runs a command from the main checkout (or init.cwd) with the repo and mode environment;
55  // never through a shell unless the argv itself is `sh -c` for a detached launch
56  cmd: (argv: string[], init: RunInit) => Promise<RunResult>
57  // logs one factory event through callum-flow-event (SPEC 7.4)
58  event: (state: string, fields: EventFields) => Promise<void>
59  // a fresh wake id, unique across restarts (SPEC 8.1 `id=`)
60  wakeId: () => string
61}
62
63export type EventFields = {
64  issue?: number | null
65  run?: string | null
66  branch?: string | null
67  pr?: number | null
68  head?: string | null
69  note?: string | null
70}
71
72// An extra done entry an executor records beside its own: a guard FAIL that `merge` printed
73// is a guard outcome for its PR and head, so rule M2 handles it next pass (SPEC 5.5), while the
74// merge itself counts as a failed attempt that plan retries once the guard is clean.
75export type ExtraEntry = {
76  key: string
77  kind: ActionKind
78  issue: number | null
79  run: string | null
80  pr: number | null
81  head: string | null
82  outcome: Outcome
83}
84
85// What an executor reports:
86// - done / failed: finished within the pass, with the outcome plan reads next pass;
87// - running: continues past the pass. An agent ends in its turn.complete (agentId), a wake
88//   with its answer (wakeId), a detached respond by the run moving (rule P3a);
89// - skipped: a pre-check refused it (a usage tier that forbids its class, a rail), so no
90//   attempt is counted and the entry is dropped.
91// noRetry: a usage error (exit 2) is a loop bug, never retried (SPEC 10.1).
92// rateLimited: stderr showed a GitHub rate limit; the driver sets githubBackoffUntil (SPEC 10.2).
93export type Exec =
94  | { status: 'done'; outcome?: Outcome; summary: string; extra?: ExtraEntry[] }
95  | { status: 'failed'; outcome?: Outcome; summary: string; noRetry?: boolean; rateLimited?: boolean; extra?: ExtraEntry[] }
96  | { status: 'running'; agentId?: string; wakeId?: string; outcome?: Outcome; summary: string }
97  | { status: 'skipped'; summary: string }
98
99export type Executor<A extends Action = Action> = (ctx: ExecCtx, action: A) => Promise<Exec>
100
101// One agent's answer, as its turn.complete gives it.
102export type AgentAnswer = { agentId: string; answer: string; reason: 'answer' | 'aborted' | 'refusal' | 'error' }
103
104// GitHub's rate-limit signatures in a command's stderr (SPEC 10.2).
105export function rateLimited(stderr: string): boolean {
106  return /API rate limit exceeded|secondary rate limit|HTTP (403|429)[^\n]*rate.?limit/i.test(stderr)
107}
108
109// The first 2 000 characters of a failed command's stderr, for the outcome (SPEC 10.1).
110export function detailOf(r: RunResult): string {
111  return (r.stderr.trim() || r.stdout.trim()).slice(0, 2000)
112}
113
114// Where an agent's raw answer is kept (act-agents.ts writes it), so a judge(report) or
115// judge(handoff) wake points the model at the file instead of pasting the report (SPEC 7.2).
116export function answerPath(loopDir: string, key: string): string {
117  return `${loopDir}/answers/${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.txt`
118}
119
120// The answer script under the plugin's root (#561): agents record their answer with it while the
121// registered answer tools cannot be reached (config answerChannel `script`, a workaround).
122export const ANSWER_SCRIPT = 'bin/factory-answer'
123
124// Where an agent records its answer with the answer script: a path unique to the ledger entry and
125// its attempt (the attempt's number and start), so the loop reads only what this attempt's agent
126// wrote, never a stale or another agent's file. The job file beside it (contract.mjs jobPathOf)
127// is the loop's; the name has contract.mjs's ANSWER_NAME shape.
128export function scriptAnswerPath(loopDir: string, entry: Pick<LedgerEntry, 'key' | 'attempts' | 'startedAt'>): string {
129  const key = entry.key.replace(/[^A-Za-z0-9._-]+/g, '_')
130  return `${loopDir}/answers/${key}.a${entry.attempts}.${Math.trunc(entry.startedAt)}.answer.json`
131}
132
133// The detached respond's log (act-merge.ts writes it; task(respond-stuck) names it): one per
134// ledger key and attempt, so a retry or a later gate of the same run never overwrites it.
135export function respondLogPath(loopDir: string, key: string, attempt: number): string {
136  return `${loopDir}/respond/${key.replace(/[^A-Za-z0-9._-]+/g, '_')}-${attempt}.log`
137}
138
139// The last line of every wake prompt (skills/judge/SKILL.md answers by it).
140export const WAKE_POINTER = 'Answer with the factory-loop:judge skill.'
141
142// The loop dir's files that only grow (SPEC 11.6): spawn records, saved answers, respond logs,
143// recorded states and the decision logs, kept as long as the ledger keeps entries (7 days).
144export const LOOP_FILES_KEEP_DAYS = 7
145
146// pruneArgvs: the commands that delete those files once older than `days`, then the day
147// directories of states/ left empty. Everything else in the loop dir (the act lock, control.json,
148// the event sink) is never touched.
149export function pruneArgvs(loopDir: string, days: number): string[][] {
150  const under = (dir: string) => ['-path', `${loopDir}/${dir}/*`]
151  return [
152    ['find', loopDir, '-mindepth', '1', '-type', 'f', '(', ...under('spawns'), '-o', ...under('answers'), '-o', ...under('respond'), '-o', ...under('states'), '-o', '-name', 'decisions-*.jsonl', ')', '-mmin', `+${days * 24 * 60}`, '-delete'],
153    ['find', `${loopDir}/states`, '-mindepth', '1', '-type', 'd', '-empty', '-delete'],
154  ]
155}
156
hooks/ledger.ts 489 lines
1// The ledger (SPEC 7): every action the loop started, under its idempotency key, with its
2// status, attempts and outcome, so plan sees what is running or done and re-planning an
3// unchanged state yields nothing new. Pure functions over LedgerEntry[] (no engine calls, no
4// clock: the time comes in as `now`): the driver starts and finishes an entry around each
5// executor, marks what a reload or the agent list proves (SPEC 7.5, 10.4), and keeps the ledger
6// in $.store through the thin StoreIo below. The other half, the ledger rebuilt from the event
7// log after a lost store, is derive.ts's deriveLedger. Every function returns a new array and
8// replaces only the entries it changes, so changedEntries() can tell the driver what moved.
9import { ACTION_KINDS, DEFAULT_CONFIG } from './config'
10import { loopEventArgs } from './derive'
11import type { Exec, ExtraEntry } from './exec'
12import { turnEnded } from './plan'
13import type {
14  Action,
15  ActionClass,
16  ActionKind,
17  AgentStatus,
18  AgentType,
19  Config,
20  LedgerEntry,
21  LedgerStatus,
22  Ms,
23  Outcome,
24} from './types'
25import { AGENT_STEPS } from './v2/types'
26
27const DAY = 86_400_000
28// Entries are kept 7 days after they end; running entries are never pruned (SPEC 7.1).
29export const KEEP_MS = 7 * DAY
30// The store holds 4 MiB of JSON for all of the plugin's keys together. The ledger keeps to three
31// quarters of it, so the driver's few small keys always fit; a busy week needs about half of
32// this, and dev-system's real weeks far less (SPEC 7.1).
33export const LEDGER_MAX_BYTES = 3 * 1024 * 1024
34// An executor's summary is a few words for the decision log; a longer one is cut.
35const SUMMARY_MAX = 300
36
37const WAKES: readonly ActionKind[] = ['judge', 'task', 'tell']
38// Agents and wakes log a `loop` event (owner notices and rate-cap notices are tells); CLI work
39// logs its own events and gets none (D-09, SPEC 7.4). A v2 agent step is an agent entry: its
40// events are for the trail, and a lost store never rebuilds it (derive.ts entryFromLoop).
41const LOOP_KINDS: readonly ActionKind[] = ['spawn', 'delegate', 'judge', 'task', 'tell', 'agent-step']
42const STATUSES: readonly LedgerStatus[] = ['running', 'done', 'failed', 'timed-out', 'lost']
43const CLASSES: readonly ActionClass[] = ['free', 'owner', 'drive', 'develop', 'start']
44const AGENT_TYPES: readonly AgentType[] = ['designer', 'implementer', 'adjudicator', 'fixer']
45
46// --- one attempt: start and finish -------------------------------------------------------
47
48export type Started = { ledger: LedgerEntry[]; entry: LedgerEntry; prev: LedgerEntry | null }
49
50// start: the entry for this attempt (SPEC 5.1: a retry keeps the key and adds an attempt). A
51// new key starts at attempt 1; a key that failed, timed out or was lost runs again as attempt
52// n + 1, its last outcome, agent and wake cleared. A done key started again (only the title,
53// which plan compares rather than keys) starts its count over: success resets it. The subject's
54// fields come from the action. prev is the entry as it stood, so a pre-check that refuses the
55// attempt can put it back (finish with status skipped). A wake records the session submitting it.
56// A v2 transition carries its facts (the row, the state and step it leaves, the event, the via
57// code) from its start, marked not yet stored: a crash after the records store took the seq but
58// before the entry was saved done leaves a lost entry that still names them (v2 SPEC 3.3), and
59// the executor's done outcome replaces them.
60export function start(ledger: readonly LedgerEntry[], action: Action, now: Ms, session = ''): Started {
61  const prev = ledger.find(e => e.key === action.key) ?? null
62  const entry: LedgerEntry = {
63    key: action.key,
64    kind: action.kind,
65    class: action.class,
66    rule: action.rule,
67    issue: action.issue,
68    run: 'run' in action && typeof action.run === 'string' ? action.run : null,
69    pr: 'pr' in action && typeof action.pr === 'number' ? action.pr : null,
70    head: 'head' in action && typeof action.head === 'string' ? action.head : null,
71    status: 'running',
72    attempts: prev === null || prev.status === 'done' ? 1 : prev.attempts + 1,
73    startedAt: now,
74    endedAt: null,
75  }
76  // a delegate spawns the implementer, so its entry is that agent's (plan reads it so)
77  if (action.kind === 'spawn') entry.agent = action.agent
78  else if (action.kind === 'delegate') entry.agent = 'implementer'
79  else if (action.kind === 'agent-step') entry.step = action.step
80  else if (action.kind === 'transition') {
81    entry.outcome = {
82      kind: 'transition',
83      row: action.row,
84      event: action.event,
85      cause: action.cause ?? null,
86      from: action.fromState ?? null,
87      to: action.next.state,
88      step: action.fromStep ?? null,
89      stored: false,
90      via: action.via ?? action.row,
91    }
92  }
93  if (action.maxAttempts !== undefined) entry.maxAttempts = action.maxAttempts
94  if (action.kind === 'judge' || action.kind === 'task' || action.kind === 'tell') {
95    entry.wake = action.wake
96    if (session !== '') entry.session = session
97  }
98  return { ledger: put(ledger, entry), entry, prev }
99}
100
101// An end that arrives outside the pass, or a transition the driver applies itself: an agent's
102// turn.complete, a wake's answer, reminder or timeout, a tell delivered.
103export type Completion = {
104  status: LedgerStatus
105  outcome?: Outcome
106  summary?: string
107  agentId?: string
108  wakeId?: string
109  turnId?: string
110  reminded?: boolean
111  // the count to keep: an orphaned wake gives its attempt back (wake.ts orphanedWakes)
112  attempts?: number
113}
114
115export type FinishOptions = {
116  // the settings in force: a usage error (noRetry) uses up exactly its kind's attempts
117  config?: Config
118  // start's prev: a skipped attempt puts the entry back as it stood (null: it was new)
119  prev?: LedgerEntry | null
120}
121
122// finish: apply an executor's Exec or a completion to the entry under key: its status, outcome,
123// agent or wake id and summary, and endedAt (none while it runs on). A finished attempt keeps
124// the last outcome when the result carries none (a respond's launch record, say).
125// - skipped: no attempt is counted: the entry goes back to prev, or is dropped when it was new.
126// - failed with noRetry (exit 2, a loop bug): the attempts are used up, so plan never retries
127//   it and tells the owner instead (SPEC 10.1).
128// - extra: each ExtraEntry is recorded as a done entry of its own (a guard FAIL that `merge`
129//   printed is a guard outcome, so rule M2 handles it next pass, SPEC 5.5).
130// - a guard, a refused fast-forward, or an update-checkout that found exactly what the one before
131//   it found replaces that one (foldRecheck).
132// - a stop-agent of rule A1 that ended, stopped or not, marks the agent it stopped timed-out
133//   (SPEC A1; SPEC 13: an agent TaskStop could not stop is left running, marked timed-out).
134// It applies whatever the entry's status; a key the ledger no longer holds (pruned, say) is left
135// alone. An agent's answer is matched to its entry by agentId, which a retry clears, so the
136// answer of an agent a retry replaced finds no entry.
137export function finish(
138  ledger: readonly LedgerEntry[],
139  key: string,
140  result: Exec | Completion,
141  now: Ms,
142  opts: FinishOptions = {},
143): LedgerEntry[] {
144  const current = ledger.find(e => e.key === key)
145  if (current === undefined) return [...ledger]
146  if (result.status === 'skipped') return unstart(ledger, current, opts.prev, now, result.summary)
147  const next: LedgerEntry = { ...current, status: result.status, endedAt: result.status === 'running' ? null : now }
148  if (result.outcome !== undefined) next.outcome = result.outcome
149  if (result.summary !== undefined) next.summary = result.summary.slice(0, SUMMARY_MAX)
150  if ('agentId' in result && result.agentId !== undefined) next.agentId = result.agentId
151  if ('wakeId' in result && result.wakeId !== undefined) next.wakeId = result.wakeId
152  if ('turnId' in result && result.turnId !== undefined) next.turnId = result.turnId
153  if ('reminded' in result && result.reminded !== undefined) next.reminded = result.reminded
154  if ('attempts' in result && result.attempts !== undefined) next.attempts = result.attempts
155  if (result.status === 'failed' && 'noRetry' in result && result.noRetry === true) {
156    next.attempts = Math.max(next.attempts, next.maxAttempts ?? maxAttemptsOf(opts.config ?? DEFAULT_CONFIG, attemptsKindOf(next)))
157  }
158  let out = foldRecheck(put(ledger, next), next)
159  for (const extra of 'extra' in result ? (result.extra ?? []) : []) {
160    const x = extraEntry(extra, next, now)
161    out = foldRecheck(put(out, x), x)
162  }
163  if (next.kind === 'stop-agent' && next.rule === 'A1' && next.status !== 'running' && key.startsWith('stop:')) {
164    const agentId = key.slice('stop:'.length)
165    const why = next.status === 'done' ? 'timed out: stopped' : 'timed out: the stop failed, the agent may still run'
166    out = out.map(e => (e.status === 'running' && e.agentId === agentId && !isWake(e) ? ended(e, 'timed-out', now, why) : e))
167  }
168  return out
169}
170
171// unstart: undo start for an attempt a pre-check refused. Without prev (the driver did not keep
172// it) a retried entry keeps its count and waits a backoff: failed, ended now.
173function unstart(ledger: readonly LedgerEntry[], current: LedgerEntry, prev: LedgerEntry | null | undefined, now: Ms, why: string) {
174  if (prev !== undefined && prev !== null) return put(ledger, prev)
175  if (prev === null || current.attempts <= 1) return ledger.filter(e => e.key !== current.key)
176  return put(ledger, ended({ ...current, attempts: current.attempts - 1 }, 'failed', now, `skipped: ${why}`))
177}
178
179// foldRecheck: a done guard whose outcome is exactly that of the guard before it on the same PR
180// and head replaces that entry, and so does a refused fast-forward after a refused one, and an
181// update-checkout after one that said the same. A PR held on one failure is re-checked every
182// guardTtlMs (#550), a lagging dirty worktree is tried again each time (#551), and the main
183// checkout is updated every checkoutEveryMs (#555); kept one entry per re-check, a week fills
184// the ledger toward fit() (SPEC 7.1). Plan numbers the next guard past the highest key
185// (guardSeq), so a dropped number is never reused, and reads only the newest refused
186// fast-forward and the newest update-checkout.
187function foldRecheck(ledger: LedgerEntry[], e: LedgerEntry): LedgerEntry[] {
188  const refused = (o: LedgerEntry) => o.outcome?.kind === 'fast-forward' && o.outcome.result === 'refused'
189  if (e.status !== 'done' || !(e.outcome?.kind === 'guard' || e.outcome?.kind === 'update-checkout' || refused(e))) return ledger
190  let before: LedgerEntry | undefined
191  for (const o of ledger) {
192    if (o.kind !== e.kind || o.key === e.key || o.pr !== e.pr || o.head !== e.head || o.startedAt > e.startedAt) continue
193    if (before === undefined || o.startedAt >= before.startedAt) before = o
194  }
195  if (before?.status !== 'done' || JSON.stringify(before.outcome) !== JSON.stringify(e.outcome)) return ledger
196  return ledger.filter(o => o !== before)
197}
198
199// extraEntry: an ExtraEntry as the done entry it records, under the rule of the action that
200// printed it, started when that action started.
201function extraEntry(x: ExtraEntry, parent: LedgerEntry, now: Ms): LedgerEntry {
202  return {
203    key: x.key,
204    kind: x.kind,
205    class: classOf(x.kind, parent.class),
206    rule: parent.rule,
207    issue: x.issue,
208    run: x.run,
209    pr: x.pr,
210    head: x.head,
211    status: 'done',
212    attempts: 1,
213    startedAt: parent.startedAt,
214    endedAt: now,
215    outcome: x.outcome,
216  }
217}
218
219// classOf: an action kind's class (SPEC 6.1); a spawn's depends on its agent and a v2 agent
220// step's on its step, so each is the caller's.
221function classOf(kind: ActionKind, spawn: ActionClass): ActionClass {
222  if (kind === 'claim') return 'start'
223  if (kind === 'delegate') return 'develop'
224  if (kind === 'respond' || kind === 'env-retry' || kind === 'judge' || kind === 'task') return 'drive'
225  if (kind === 'tell') return 'owner'
226  return kind === 'spawn' || kind === 'agent-step' ? spawn : 'free'
227}
228
229// --- what a reload and the agent list prove (SPEC 3.3, 7.5, 10.4) -------------------------
230
231// onLoad: on module load, every running CLI entry (no agent id, not a wake) is lost: its process
232// died with the old session or module (SPEC 7.5 step 1). Plan then re-derives what to do from
233// the state. Agents wait for the agent list (reconcileAgents); wakes for their answer or timeout.
234export function onLoad(ledger: readonly LedgerEntry[], now: Ms): LedgerEntry[] {
235  return ledger.map(e =>
236    e.status === 'running' && e.agentId === undefined && !isWake(e) ? ended(e, 'lost', now, 'lost: the loop reloaded mid-action') : e,
237  )
238}
239
240// reconcileAgents: a running agent entry against the session's agent list. An id the list
241// lacks is lost (SPEC 3.3, 7.5); one listed failed or killed is failed (SPEC 10.4); one listed
242// completed or idle (its turn ended, plan.ts turnEnded, #560) while its entry still runs ended
243// with no answer, so it is lost. The list must be a successful read: an empty list marks every
244// running agent lost.
245export function reconcileAgents(
246  ledger: readonly LedgerEntry[],
247  agents: readonly { id: string; status: AgentStatus }[],
248  now: Ms,
249): LedgerEntry[] {
250  const status = new Map(agents.map(a => [a.id, a.status]))
251  return ledger.map(e => {
252    if (e.status !== 'running' || e.agentId === undefined || isWake(e)) return e
253    const seen = status.get(e.agentId)
254    if (seen === undefined) return ended(e, 'lost', now, 'lost: gone from the agent list')
255    if (seen === 'failed' || seen === 'killed') return ended(e, 'failed', now, `agent ${seen}`)
256    if (turnEnded(seen)) return ended(e, 'lost', now, `lost: ${seen} with no answer`)
257    return e
258  })
259}
260
261// --- keeping it small -------------------------------------------------------------------
262
263// prune: drop entries that ended more than KEEP_MS ago; a running entry stays whatever its age
264// (SPEC 7.1). An entry with no end time ages from its start.
265export function prune(ledger: readonly LedgerEntry[], now: Ms): LedgerEntry[] {
266  return ledger.filter(e => e.status === 'running' || now - endOf(e) <= KEEP_MS)
267}
268
269// fit: the ledger within maxBytes of JSON, the entries that ended longest ago dropped first,
270// never a running one. A safety valve only: a busy week is well below LEDGER_MAX_BYTES, and a store
271// over its limit would refuse the whole save, losing every change since the last one.
272export function fit(ledger: readonly LedgerEntry[], maxBytes = LEDGER_MAX_BYTES): LedgerEntry[] {
273  const sized = ledger.map(entry => ({ entry, size: bytes(JSON.stringify(entry)) + 1 }))
274  // the envelope: { v, savedAt, entries }
275  let total = sized.reduce((sum, s) => sum + s.size, 64)
276  if (total <= maxBytes) return [...ledger]
277  const drop = new Set<LedgerEntry>()
278  const oldest = sized.filter(s => s.entry.status !== 'running').sort((a, b) => endOf(a.entry) - endOf(b.entry))
279  for (const s of oldest) {
280    if (total <= maxBytes) break
281    drop.add(s.entry)
282    total -= s.size
283  }
284  return ledger.filter(e => !drop.has(e))
285}
286
287// --- the store and the derived ledger (SPEC 7.1, 7.5) -------------------------------------
288
289export type LedgerSource = 'store' | 'events' | 'none'
290export type Chosen = { ledger: LedgerEntry[]; source: LedgerSource }
291
292// choose: the store wins whenever it holds a ledger. A missing or empty store (a new device, a
293// wiped config) falls back to the ledger derive.ts rebuilt from the event log; null there means
294// the log could not be read. The source goes in the decision log.
295export function choose(
296  stored: readonly LedgerEntry[] | null | undefined,
297  derived: readonly LedgerEntry[] | null | undefined,
298): Chosen {
299  if (stored !== null && stored !== undefined && stored.length > 0) return { ledger: [...stored], source: 'store' }
300  if (derived !== null && derived !== undefined && derived.length > 0) return { ledger: [...derived], source: 'events' }
301  return { ledger: [], source: 'none' }
302}
303
304// $.store, or anything shaped like it (a test's fake).
305export type StoreIo = {
306  get: (key: string) => Promise<unknown>
307  set: (key: string, value: unknown) => Promise<void>
308}
309
310export type StoredLedger = { v: 1; savedAt: Ms; entries: LedgerEntry[] }
311
312export function storeKey(repo: string): string {
313  return `ledger:${repo}`
314}
315
316// toStored: what save writes: the ledger pruned and within its size budget.
317export function toStored(ledger: readonly LedgerEntry[], now: Ms): StoredLedger {
318  return { v: 1, savedAt: now, entries: fit(prune(ledger, now)) }
319}
320
321// fromStored: the entries of a stored ledger, or null when the value is none (another version,
322// or not a ledger at all). An entry missing a field plan relies on is dropped, and of two
323// entries under one key the later start wins, as in plan.
324export function fromStored(raw: unknown): LedgerEntry[] | null {
325  const o = record(raw)
326  if (o === null || o.v !== 1 || !Array.isArray(o.entries)) return null
327  const byKey = new Map<string, LedgerEntry>()
328  for (const item of o.entries) {
329    const entry = entryOf(item)
330    if (entry === null) continue
331    const seen = byKey.get(entry.key)
332    if (seen === undefined || entry.startedAt >= seen.startedAt) byKey.set(entry.key, entry)
333  }
334  return [...byKey.values()]
335}
336
337// load: the ledger saved for this repo, or null when the store holds none. A failing store read
338// throws: replacing a ledger the loop could not read would lose its holds.
339export async function load(store: StoreIo, repo: string): Promise<LedgerEntry[] | null> {
340  return fromStored(await store.get(storeKey(repo)))
341}
342
343// save: prune, keep within the budget, write; returns the entries written.
344export async function save(store: StoreIo, repo: string, ledger: readonly LedgerEntry[], now: Ms): Promise<LedgerEntry[]> {
345  const stored = toStored(ledger, now)
346  await store.set(storeKey(repo), stored)
347  return stored.entries
348}
349
350// --- /factory retry (D-38) -------------------------------------------------------------------
351
352export type Retried = { ledger: LedgerEntry[]; cleared: string[]; notices: string[] }
353
354// retryIssue: `/factory retry <N>` clears the attempts of the issue's entries that ran out of
355// them, so their rules try again (D-38, SPEC 7.1). Exhausted is plan's own rule (gate in
356// plan.ts): failed, timed-out or lost with attempts at the kind's maximum. Each such entry stays
357// with its count at 0 rather than going: a rule may need the failed entry to act at all (D4b
358// re-delegates from the last delegate entry), and the next attempt counts from 1. The owner
359// notice each one raised (agent-failed, task-timeout, update-behind) goes, unless it is still
360// open, so a retry that runs out again is told again.
361export function retryIssue(ledger: readonly LedgerEntry[], issue: number, config: Config): Retried {
362  // an env-retry stays spent: the owner's resume, not a retry, allows the next rerun (#537)
363  const spent = ledger.filter(e => e.issue === issue && e.kind !== 'env-retry' && exhausted(e, config))
364  const cleared = spent.map(e => e.key)
365  const raised = new Set(spent.flatMap(noticesOf))
366  const notices = ledger.filter(e => raised.has(e.key) && e.status !== 'running').map(e => e.key)
367  const out = ledger
368    .filter(e => !notices.includes(e.key))
369    .map(e => (cleared.includes(e.key) ? { ...e, attempts: 0, summary: 'attempts cleared by the owner (/factory retry)' } : e))
370  return { ledger: out, cleared, notices }
371}
372
373function noticesOf(entry: LedgerEntry): string[] {
374  const out = [`tell:agent-failed:${entry.key}`, `tell:task-timeout:${entry.key}`]
375  if (entry.kind === 'update-behind') out.push(`tell:${entry.key}`)
376  return out
377}
378
379// exhausted: plan's rule exactly (gate in plan.ts, foundation.ts): a failed, timed-out or lost
380// entry whose attempts reached its key's own cap, else the maximum for its kind or agent.
381// Heartbeat and sweep never are: they retry at their cadence (plan.ts cadenceOf, SPEC 6.1).
382export function exhausted(entry: LedgerEntry, config: Config): boolean {
383  if (entry.status === 'running' || entry.status === 'done') return false
384  if (entry.kind === 'heartbeat' || entry.kind === 'sweep') return false
385  return entry.attempts >= (entry.maxAttempts ?? maxAttemptsOf(config, attemptsKindOf(entry)))
386}
387
388// attemptsKindOf: what plan counts an entry's attempts against: a spawn's agent, the implementer
389// for a delegate, otherwise its kind.
390export function attemptsKindOf(entry: LedgerEntry): ActionKind | AgentType {
391  if (entry.kind === 'spawn') return entry.agent ?? entry.kind
392  if (entry.kind === 'delegate') return 'implementer'
393  return entry.kind
394}
395
396// maxAttemptsOf: plan's maxAttempts: update-behind runs updateBehindRetries + 1 times (rule
397// M2b); anything else its config.maxAttempts entry, 3 when it has none.
398export function maxAttemptsOf(config: Config, kind: ActionKind | AgentType): number {
399  if (kind === 'update-behind') return config.updateBehindRetries + 1
400  // once per owner resume, whatever the config says (#537)
401  if (kind === 'env-retry') return 1
402  return config.maxAttempts[kind] ?? 3
403}
404
405// --- the loop's own events (D-09, SPEC 7.4) -------------------------------------------------
406
407// loopEvent: the callum-flow-event argv for a `loop` event about this entry, for agent and wake
408// entries (owner notices and rate-cap notices are tells) at their start and end; null for CLI
409// kinds, which log their own events.
410export function loopEvent(entry: LedgerEntry): string[] | null {
411  return LOOP_KINDS.includes(entry.kind) ? ['callum-flow-event', ...loopEventArgs(entry)] : null
412}
413
414// changedEntries: the entries `after` holds that `before` did not hold as they are: new or
415// changed by a transition, for the driver's loop events.
416export function changedEntries(before: readonly LedgerEntry[], after: readonly LedgerEntry[]): LedgerEntry[] {
417  const had = new Set(before)
418  return after.filter(e => !had.has(e))
419}
420
421// --- helpers ---------------------------------------------------------------------------------
422
423function isWake(entry: LedgerEntry): boolean {
424  return WAKES.includes(entry.kind) || entry.wakeId !== undefined
425}
426
427function ended(entry: LedgerEntry, status: LedgerStatus, now: Ms, summary: string): LedgerEntry {
428  return { ...entry, status, endedAt: now, summary }
429}
430
431function endOf(entry: LedgerEntry): Ms {
432  return entry.endedAt ?? entry.startedAt
433}
434
435// put: the ledger with entry under its key, in place of the one there, else at the end.
436function put(ledger: readonly LedgerEntry[], entry: LedgerEntry): LedgerEntry[] {
437  const i = ledger.findIndex(e => e.key === entry.key)
438  if (i < 0) return [...ledger, entry]
439  const out = [...ledger]
440  out[i] = entry
441  return out
442}
443
444// bytes: an upper bound of a string's UTF-8 size (a non-ASCII UTF-16 unit counts 3 bytes).
445function bytes(text: string): number {
446  let extra = 0
447  for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) > 0x7f) extra += 2
448  return text.length + extra
449}
450
451function record(v: unknown): Record<string, unknown> | null {
452  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
453}
454
455const isNumOrNull = (v: unknown): v is number | null => v === null || (typeof v === 'number' && Number.isFinite(v))
456const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === 'string'
457
458// entryOf: one stored entry, checked field by field. The ledger is the loop's own writing, so an
459// outcome is taken as written once it names its kind.
460function entryOf(raw: unknown): LedgerEntry | null {
461  const o = record(raw)
462  if (o === null) return null
463  const { key, rule, issue, run, pr, head, attempts, startedAt, endedAt } = o
464  const kind = ACTION_KINDS.find(k => k === o.kind)
465  const klass = CLASSES.find(c => c === o.class)
466  const status = STATUSES.find(s => s === o.status)
467  if (typeof key !== 'string' || key === '' || typeof rule !== 'string') return null
468  if (kind === undefined || klass === undefined || status === undefined) return null
469  if (typeof attempts !== 'number' || !Number.isInteger(attempts) || attempts < 0) return null
470  if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || !isNumOrNull(endedAt)) return null
471  if (!isNumOrNull(issue) || !isNumOrNull(pr) || !isStrOrNull(run) || !isStrOrNull(head)) return null
472  const entry: LedgerEntry = { key, kind, class: klass, rule, issue, run, pr, head, status, attempts, startedAt, endedAt }
473  const agent = AGENT_TYPES.find(a => a === o.agent)
474  if (agent !== undefined) entry.agent = agent
475  if (typeof o.wake === 'string') entry.wake = o.wake
476  if (typeof o.agentId === 'string') entry.agentId = o.agentId
477  if (typeof o.wakeId === 'string') entry.wakeId = o.wakeId
478  if (typeof o.turnId === 'string') entry.turnId = o.turnId
479  if (typeof o.reminded === 'boolean') entry.reminded = o.reminded
480  if (typeof o.session === 'string') entry.session = o.session
481  if (typeof o.summary === 'string') entry.summary = o.summary
482  const step = AGENT_STEPS.find(s => s === o.step)
483  if (step !== undefined) entry.step = step
484  if (typeof o.maxAttempts === 'number' && Number.isInteger(o.maxAttempts) && o.maxAttempts >= 1) entry.maxAttempts = o.maxAttempts
485  const outcome = record(o.outcome)
486  if (outcome !== null && typeof outcome.kind === 'string') entry.outcome = outcome as Outcome
487  return entry
488}
489
hooks/observe.ts 601 lines
1// observe: run the read-only commands of SPEC 3.3 and assemble the State plan takes. Thin on
2// purpose: every output goes straight to a pure parser (parse.ts, derive.ts) and buildState.
3// Cadences (config queueEveryMs, githubEveryMs, usageEveryMs, milestoneEveryMs) keep GitHub
4// reads well under the rate limit; a failed source keeps its last value with its old time, so
5// plan can see how stale it is. Every command runs from the main checkout with the runtime's
6// environment (rt.env), which in observe mode sends its event writes to the sink (SPEC 11.1).
7// With planner v2 or the v2 shadow, v2/observe.ts reads what v2 adds (v2 SPEC 2.4), and planner
8// v2 reads no runs and infers no slot's issue from its reflog.
9import { EMPTY_CONTROL, controlFrom } from './config'
10import { EVENTS_SQL, deriveLedger, eventsFromRows, factsOf } from './derive'
11import type { FactoryEvent } from './derive'
12import { rateLimited } from './exec'
13import { choose } from './ledger'
14import type { LedgerSource } from './ledger'
15import {
16  parseBrief,
17  parseCheckout,
18  parseCsv,
19  parseDirty,
20  parseFactoryState,
21  parseIssueRead,
22  parseMilestone,
23  parsePipelineOnce,
24  parsePool,
25  parseQueue,
26  parseQueueOnce,
27  parseReflog,
28  parseSubIssues,
29  parseUsage,
30} from './parse'
31import type { Factory } from './parse'
32import { ENDED, LIVE, issueFromBranch, listedLive } from './plan'
33import { TRACKER_DEPTH, buildState, runsFromWatch, seenRuns } from './state'
34import type { GithubIssue, SeenRun } from './state'
35import type { Checkout, Config, Control, LedgerEntry, Milestone, Pool, Source, SourceName, State, Usage } from './types'
36import { emptyV2Cache, lastEnded, observeV2, readDue, readOrder } from './v2/observe'
37import type { V2Cache, V2Stored } from './v2/observe'
38import type { HeadSeen } from './v2/types'
39
40const GITHUB_READS_PER_PASS = 12
41const EVENT_DAYS = 3
42// a ledger rebuilt after a lost store reads a week of events: holds older than the facts
43// window (a pending decision, an escalation) must survive it, and entries are kept 7 days
44export const REBUILD_DAYS = 7
45// one poll probes every watched branch's newest run, GitHub included
46const PIPELINE_TIMEOUT_MS = 120_000
47// how long GitHub reads wait after a rate limit (SPEC 10.2)
48export const GITHUB_BACKOFF_MS = 15 * 60_000
49
50// Runtime: what one pass runs as, from the plugin options and both settings files (SPEC 4.1).
51export type Runtime = {
52  mode: 'off' | 'observe' | 'act'
53  repo: string
54  device: string
55  runSource: 'local' | 'events'
56  poolSize: number
57  cwd: string
58  loopDir: string
59  // this session and process, as v2's workspace rail checks the companion's handshake (v2 SPEC 10.9)
60  workspace?: { session: string; process?: string }
61  // the observe-mode event sink (SPEC 11.1), null when acting; env carries it
62  sink: string | null
63  // the base environment of every command the loop runs, observe and act alike:
64  // CALLUM_FLOW_REPO, CALLUM_EVENTS_DIR=<sink> when not acting, and PATH with the workspace
65  // config's binDir in front (a testbed runs the branch's own CLIs before a release)
66  env: Record<string, string>
67  // where pipeline-watch.sh, queue-watch.sh and usage-check.sh live (workspace config toolsDir)
68  toolsDir: string
69  // the plugin's own directory ($.plugin.root), where the answer script lives (#561)
70  pluginRoot: string
71  config: Config
72  // the settings v2 runs with (config.ts LoadedConfig.v2), the v2 shadow's; absent: config's
73  v2Config?: Config
74  // what loadConfig left out of the repo-local settings, for the decision log
75  configProblems: string[]
76  // what the repo-local settings file says, no defaults (settings.ts repoLoaded); null: not read
77  repoSettings?: Record<string, unknown> | null
78}
79
80// Stored: what act mode keeps in $.store across passes and reloads. register.ts reads it
81// (`ledger:<repo>` through ledger.ts load, `backoff:<repo>`, `queue-known:<repo>`) and passes
82// none in observe mode, which keeps nothing of its own.
83export type Stored = {
84  // null: the store holds no ledger (a new device, a wiped config), so it is rebuilt from the log
85  ledger: LedgerEntry[] | null
86  // GitHub reads wait until then after a rate limit (SPEC 10.2)
87  githubBackoffUntil: number | null
88  // the queue's last now= list, handed back to queue-watch.sh as --known (SPEC 3.4)
89  queueKnown: number[] | null
90}
91
92type Timed<T> = { at: number; value: T }
93
94// Cache: what survives between passes in the module (a reload starts it over).
95export type Cache = {
96  // the repo it was filled for: a pass for another repo starts it over (resetFor)
97  repo?: string
98  queue?: Timed<number[]>
99  github: Map<number, Timed<GithubIssue>>
100  // trackers' direct open sub-issues, from their timelines (D-35)
101  timelines: Map<number, Timed<number[]>>
102  usage?: Timed<Usage>
103  milestone?: Timed<Milestone | null>
104  factory?: Timed<Factory>
105  events?: Timed<FactoryEvent[]>
106  pool?: Timed<Pool>
107  // the issue branch each lease of a detached slot was found on (#563), by `<path> <lease id>`:
108  // a lease works one issue, so its reflog is read once it names one
109  leaseBranches: Map<string, string>
110  // runSource local: the last poll's runs, each with when it was first seen as it is
111  runs?: Timed<SeenRun[]>
112  // act mode: the last ledger rebuilt from the event log, and how many entries it found
113  rebuilt?: Timed<number>
114  v2: V2Cache
115}
116
117export function emptyCache(): Cache {
118  return { github: new Map(), timelines: new Map(), leaseBranches: new Map(), v2: emptyV2Cache() }
119}
120
121// resetFor: nothing read for one repo may stand in for another's (a queue, a milestone, issue
122// facts), so a cache filled for another repo, as when the workspace config names a new one
123// without a reload, is emptied in place before the pass reads anything.
124function resetFor(cache: Cache, repo: string) {
125  if (cache.repo === repo) return
126  delete cache.queue
127  delete cache.usage
128  delete cache.milestone
129  delete cache.factory
130  delete cache.events
131  delete cache.pool
132  delete cache.runs
133  delete cache.rebuilt
134  cache.github.clear()
135  cache.timelines.clear()
136  cache.leaseBranches.clear()
137  cache.v2 = emptyV2Cache()
138  cache.repo = repo
139}
140
141export type Observation = {
142  state: State
143  commands: string[]
144  // queue-watch.sh's now= list when it read the queue this pass, for register to save as the
145  // next --known; null when it did not (the cadence, a failure, a backoff, observe mode): the
146  // stored list stands
147  queueKnown: number[] | null
148  // where the ledger came from, for the decision log: the store, the event log, or neither
149  ledgerSource: LedgerSource
150  // a command's stderr showed a GitHub rate limit: register sets the backoff (SPEC 10.2)
151  rateLimited: boolean
152  // the PR heads v2 has seen (v2 SPEC 2.4), for act mode to keep; null when v2 read nothing
153  v2Heads: Record<string, HeadSeen> | null
154}
155
156// Io: the engine calls observe needs, built by register.ts (the only module that holds $).
157export type Io = {
158  now: () => Promise<number>
159  run: (argv: string[], init: { cwd: string; env: Record<string, string>; stdin?: string; timeoutMs: number }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
160  readFile: (path: string) => Promise<string | null>
161  sessionLimits: () => Promise<{ kind: string; percentUsed: number; resetsAt?: string }[]>
162  agents: () => Promise<{ id: string; type: string; status: State['agents'][number]['status'] }[]>
163}
164
165// observe: one pass's reads. `stored` is act mode's store (undefined in observe mode): it adds
166// the ledger, the GitHub backoff and the queue's known list. `v2Stored` is what the store holds
167// for v2, read when v2 plans or shadows.
168export async function observe(
169  io: Io,
170  rt: Runtime,
171  cache: Cache,
172  loadedAt: number,
173  lastTitle: string | null,
174  stored?: Stored,
175  v2Stored?: V2Stored,
176): Promise<Observation> {
177  resetFor(cache, rt.repo)
178  const now = await io.now()
179  const cfg = rt.config
180  const v2Only = cfg.planner === 'v2'
181  const v2On = v2Only || cfg.shadow === 'v2'
182  const commands: string[] = []
183  const sources = {} as Record<SourceName, Source>
184  let limited = false
185  const mark = (name: SourceName, ok: boolean, at: number | null, error?: string) => {
186    sources[name] = error === undefined ? { ok, at } : { ok, at, error }
187  }
188  const run = async (argv: string[], stdin?: string, timeoutMs = 60_000, env = rt.env) => {
189    commands.push(argv.join(' ').slice(0, 160))
190    const r = await io.run(argv, { cwd: rt.cwd, env, stdin, timeoutMs })
191    if (rateLimited(r.stderr)) limited = true
192    return r
193  }
194  const tool = (name: string) => `${rt.toolsDir}/${name}`
195  const fail = (r: { exitCode: number; stderr: string }) => `exit ${r.exitCode}: ${r.stderr.trim().split('\n').pop()?.slice(0, 200) ?? ''}`
196  const eventsSince = (days: number) => ['dev-query', '--csv', '-v', `repo=${rt.repo}`, '-v', `at=${new Date(now).toISOString()}`, '-v', `days=${days}`]
197  // act mode: GitHub reads wait out a rate limit (SPEC 10.2); old values stay, with their age
198  const until = stored?.githubBackoffUntil ?? null
199  const backoffUntil = until !== null && until > now ? until : null
200  const waiting = backoffUntil === null ? '' : `GitHub rate limit: waiting until ${new Date(backoffUntil).toISOString()}`
201
202  // factory: required; a failure makes the pass blind (rule G2)
203  try {
204    const r = await run(['callum-flow-factory-state', '--repo', rt.repo, '--format', 'json', '--github', 'off'], undefined, 90_000)
205    const parsed = r.exitCode === 0 ? parseFactoryState(r.stdout, rt.repo) : null
206    if (parsed !== null) cache.factory = { at: now, value: parsed }
207    mark('factory', parsed !== null, cache.factory?.at ?? null, parsed === null ? fail(r) : undefined)
208  } catch (err) {
209    mark('factory', false, cache.factory?.at ?? null, String(err))
210  }
211
212  // facts: the event log, one query
213  try {
214    const r = await run(eventsSince(EVENT_DAYS), EVENTS_SQL)
215    if (r.exitCode === 0) cache.events = { at: now, value: eventsFromRows(parseCsv(r.stdout)) }
216    mark('facts', r.exitCode === 0, cache.events?.at ?? null, r.exitCode === 0 ? undefined : fail(r))
217  } catch (err) {
218    mark('facts', false, cache.events?.at ?? null, String(err))
219  }
220  const factory = cache.factory?.value ?? null
221  const events = cache.events?.value ?? []
222  const facts = cache.events !== undefined ? factsOf(events) : null
223
224  // runs: the pipeline watcher's one-shot poll (runSource local), or the event log's view of
225  // them (runSource events: the shadow and the replay). A failed poll is a failed source,
226  // never "no runs", and so is a line the parser cannot read.
227  if (rt.runSource === 'events') {
228    mark('runs', sources.facts.ok, sources.facts.at)
229  } else if (v2Only) {
230    mark('runs', false, null, 'not read: planner v2 has no pipeline runs')
231  } else {
232    try {
233      const r = await run([tool('pipeline-watch.sh'), '--once'], undefined, PIPELINE_TIMEOUT_MS)
234      const read = r.exitCode === 0 ? parsePipelineOnce(r.stdout) : null
235      if (read !== null && read.bad.length === 0) {
236        cache.runs = { at: now, value: seenRuns(cache.runs?.value ?? [], read.runs, now) }
237        mark('runs', true, now)
238      } else {
239        const why = read === null ? fail(r) : `unreadable line: ${read.bad[0]?.slice(0, 200)}`
240        mark('runs', false, cache.runs?.at ?? null, why)
241      }
242    } catch (err) {
243      mark('runs', false, cache.runs?.at ?? null, String(err))
244    }
245  }
246
247  // The ledger. Observe mode: what the device's own events prove (SPEC 7.5). Act mode: the
248  // store's whenever it holds one; else it is rebuilt from a `week` of events (the facts window
249  // would lose older holds), where v2 reads its logged facts too (7.2). A rebuild that found nothing
250  // leaves it empty, and the week is not read every pass. Read before the queue and GitHub: a read
251  // older than a label write of the loop's own is due, so it sees what GitHub took.
252  let chosen: { ledger: LedgerEntry[]; source: LedgerSource }, week: FactoryEvent[] | null = null
253  if (stored === undefined) {
254    chosen = choose(null, factory !== null && facts !== null ? deriveLedger(events, factory, facts, rt.device) : null)
255  } else if (stored.ledger !== null && stored.ledger.length > 0) {
256    // the store holds it, so a later loss is rebuilt again
257    cache.rebuilt = undefined
258    chosen = choose(stored.ledger, null)
259  } else if (cache.rebuilt?.value === 0) {
260    chosen = choose(stored.ledger, null)
261  } else {
262    let derived: LedgerEntry[] | null = null
263    let problem = 'the factory source failed'
264    if (factory !== null) {
265      try {
266        const r = await run(eventsSince(REBUILD_DAYS), EVENTS_SQL)
267        if (r.exitCode === 0) {
268          week = eventsFromRows(parseCsv(r.stdout))
269          derived = deriveLedger(week, factory, factsOf(week), rt.device)
270          cache.rebuilt = { at: now, value: derived.length }
271        } else {
272          problem = fail(r)
273        }
274      } catch (err) {
275        problem = String(err)
276      }
277    }
278    chosen = choose(stored.ledger, derived)
279    // a lost ledger the log could not rebuild: the event log is what failed, so say so there,
280    // and plan holds what needs it; the rebuild runs again next pass
281    if (derived === null) mark('facts', false, sources.facts.at, `the store holds no ledger and the event log could not rebuild it: ${problem}`)
282  }
283  const ledger = chosen.ledger
284  const agents = (await io.agents())
285    .filter(a => ledger.some(e => e.agentId === a.id))
286    .map(a => ({ id: a.id, type: a.type, status: a.status }))
287  const lastLabelWrite = (n?: number) => lastEnded(ledger, e => e.kind === 'label' && e.status === 'done' && (n === undefined || e.issue === n))
288
289  // queue: every queueEveryMs, and the pass after a label write. Act mode reads it through
290  // queue-watch.sh --once, which logs a `ready` event for each member the stored list lacks
291  // (SPEC 3.4); observe mode keeps the plain reader, so the shadow logs no ready events of its own.
292  let queueKnown: number[] | null = null
293  if (backoffUntil !== null) {
294    mark('queue', false, cache.queue?.at ?? null, waiting)
295  } else if (readDue(cache.queue?.at, now, cfg.queueEveryMs, lastLabelWrite())) {
296    try {
297      if (stored !== undefined) {
298        const known = stored.queueKnown !== null && stored.queueKnown.length > 0 ? stored.queueKnown.join(',') : 'none'
299        const r = await run([tool('queue-watch.sh'), '--once', '--repo', rt.repo, '--label', cfg.readyLabel, '--known', known])
300        const line = r.exitCode === 0 ? parseQueueOnce(r.stdout) : null
301        if (line !== null) {
302          cache.queue = { at: now, value: line.now }
303          queueKnown = line.now
304        }
305        mark('queue', line !== null, cache.queue?.at ?? null, line === null ? fail(r) : undefined)
306      } else {
307        const r = await run(['callum-flow-issue-read', '--json', '--list', '--label', cfg.readyLabel])
308        const queue = r.exitCode === 0 ? parseQueue(r.stdout) : null
309        if (queue !== null) cache.queue = { at: now, value: queue }
310        mark('queue', queue !== null, cache.queue?.at ?? null, queue === null ? fail(r) : undefined)
311      }
312    } catch (err) {
313      mark('queue', false, cache.queue?.at ?? null, String(err))
314    }
315  } else {
316    mark('queue', true, cache.queue?.at ?? null)
317  }
318
319  // GitHub per issue, oldest reads first, and first of all the issues the loop may act on or
320  // counts as live, so a closed one is known before plan drives it (#556): those of its running
321  // work (an entry running, or an agent at work past its answer), this device's issues and every
322  // issue in a live stage, and those of a merged Run it that has not answered `done` (its closing
323  // ends rule M5's wait). A run is driven only for an issue this device holds, so the watcher's
324  // runs add none. Then the queue, and the issues `only` names with the sub-issues their trackers
325  // stand for (D-35). An issue known closed is not read again unless the queue, which lists open
326  // issues only, names it, or this device holds it (it may be reopened); a failed read keeps the
327  // last value. A read older than a label write of the loop's own or a PR's close is due at once.
328  const control = await readControl(io, rt)
329  const tracker = new RegExp(cfg.trackerTitle, 'i')
330  const isTracker = (n: number) => {
331    const facts = cache.github.get(n)?.value.facts
332    return facts !== null && facts !== undefined && ((facts.subIssues ?? 0) > 0 || tracker.test(facts.title))
333  }
334  // the trackers under `only` to read a timeline for (nested ones TRACKER_DEPTH deep), and every
335  // sub-issue they are known to stand for, from what the cache holds now
336  const walkTrackers = () => {
337    const trackers: number[] = []
338    const subIssues: number[] = []
339    const walk = (n: number, depth: number) => {
340      if (depth >= TRACKER_DEPTH || trackers.includes(n) || !isTracker(n)) return
341      trackers.push(n)
342      for (const sub of cache.timelines.get(n)?.value ?? []) {
343        if (!subIssues.includes(sub)) subIssues.push(sub)
344        walk(sub, depth + 1)
345      }
346    }
347    for (const n of control.only) walk(n, 0)
348    return { trackers, subIssues }
349  }
350  const mine = (factory?.issues ?? []).filter(i => !i.ended && i.holder === rt.device)
351  if (backoffUntil !== null) {
352    const ats = [...cache.github.values(), ...cache.timelines.values()].map(c => c.at)
353    mark('github', false, ats.length > 0 ? Math.max(...ats) : null, waiting)
354  } else {
355    const { subIssues } = walkTrackers()
356    const queue = cache.queue?.value ?? []
357    const working = ledger
358      .filter(e => e.status === 'running' || (e.agentId !== undefined && listedLive(agents, e.agentId)))
359      .flatMap(e => (e.issue === null ? [] : [e.issue]))
360    const live = (factory?.issues ?? []).filter(i => !i.ended && LIVE.includes(i.stage)).map(i => i.number)
361    const runIts = ledger
362      .filter(e => e.key.startsWith('task:run-it:') && !(e.status === 'done' && e.outcome?.kind === 'wake' && e.outcome.decision === 'done'))
363      .flatMap(e => (e.issue === null ? [] : [e.issue]))
364    const held = mine.map(i => i.number)
365    // v2's records go first, in v2's read order (v2 SPEC 2.4); then the oldest reads
366    const first = v2On ? readOrder(v2Stored?.records ?? []) : []
367    const wanted = [...new Set([...first, ...working, ...held, ...live, ...runIts, ...queue, ...control.only, ...subIssues])]
368    const rank = (n: number) => (first.includes(n) ? first.indexOf(n) - first.length : 0)
369    const due = wanted
370      .filter(n => {
371        const c = cache.github.get(n)
372        if (c !== undefined && c.value.facts?.open === false && !queue.includes(n) && !held.includes(n) && !first.includes(n)) return false
373        return readDue(c?.at, now, cfg.githubEveryMs, Math.max(lastLabelWrite(n), cache.v2.changed[n] ?? -Infinity))
374      })
375      .sort((a, b) => rank(a) - rank(b) || (cache.github.get(a)?.at ?? 0) - (cache.github.get(b)?.at ?? 0))
376      .slice(0, GITHUB_READS_PER_PASS)
377    let githubOk = true
378    // a rate limit seen this pass ends the pass's GitHub reads: each would only hit it again
379    for (const n of due) {
380      if (limited) {
381        githubOk = false
382        break
383      }
384      try {
385        const r = await run(['callum-flow-issue-read', '--json', String(n)])
386        if (r.exitCode === 3) {
387          cache.github.set(n, { at: now, value: { facts: null, trusted: false, brief: null } })
388          continue
389        }
390        const facts = r.exitCode === 0 ? parseIssueRead(r.stdout) : null
391        if (facts === null) {
392          githubOk = false
393          continue
394        }
395        let brief = cache.github.get(n)?.value.brief ?? null
396        if (mine.some(i => i.number === n && i.stage === 'designing')) {
397          const c = await run(['callum-flow-issue-read', '--json', String(n), '--comments'])
398          if (c.exitCode === 0) brief = parseBrief(c.stdout)
399        }
400        cache.github.set(n, { at: now, value: { facts, trusted: true, brief } })
401      } catch {
402        githubOk = false
403      }
404    }
405    // tracker timelines, in the reads left this pass, walked again so a tracker read just now counts
406    const timelinesDue = walkTrackers()
407      .trackers.filter(n => {
408        const c = cache.timelines.get(n)
409        return c === undefined || now - c.at >= cfg.githubEveryMs
410      })
411      .slice(0, Math.max(0, GITHUB_READS_PER_PASS - due.length))
412    for (const n of timelinesDue) {
413      if (limited) {
414        githubOk = false
415        break
416      }
417      try {
418        const r = await run(['callum-flow-issue-read', '--json', '--timeline', String(n)])
419        const subs = r.exitCode === 0 ? parseSubIssues(r.stdout, rt.repo) : null
420        if (subs === null) githubOk = false
421        else cache.timelines.set(n, { at: now, value: subs })
422      } catch {
423        githubOk = false
424      }
425    }
426    mark('github', githubOk, now, !githubOk && limited ? 'GitHub rate limit' : undefined)
427  }
428
429  // usage: usage-check.sh every usageEveryMs, and the session's own figures every pass
430  if (cache.usage === undefined || now - cache.usage.at >= cfg.usageEveryMs) {
431    try {
432      const r = await run([tool('usage-check.sh')], undefined, 30_000)
433      cache.usage = { at: now, value: parseUsage(r.stdout.trim()) }
434    } catch {
435      // unavailable: plan fails open as the skill says
436    }
437  }
438  // a reading older than usageStaleMs counts as unavailable (SPEC 3.1)
439  const reading = cache.usage !== undefined && now - cache.usage.at < cfg.usageStaleMs ? cache.usage.value : null
440  const usage = withSession(reading, await io.sessionLimits())
441  mark('usage', usage !== null && !usage.unavailable, cache.usage?.at ?? null)
442
443  // the pool slots rule H3 might return (#563). An implementer detaches its slot once the
444  // pipeline holds the branch, so a leased slot on no branch is matched to its issue by the newest
445  // issue branch its HEAD reflog shows checked out since the lease began (git's own record, per
446  // worktree, whoever leased it and whenever the loop loaded); a slot of a finished issue then has
447  // its tree read for changes, untracked files too, as `treehouse return` counts them. A failed
448  // read leaves the slot unknown, which H3 keeps. Available and busy slots are not read.
449  const finishedIssue = (n: number) => {
450    const f = factory?.issues.find(i => i.number === n)
451    const open = cache.github.get(n)?.value.facts?.open ?? f?.github?.open ?? null
452    return open === false || (f !== undefined && ENDED.includes(f.stage))
453  }
454  const readSlots = async (read: Pool): Promise<Pool> => {
455    const found = new Map<string, string>()
456    const slots: Pool['slots'] = []
457    for (const slot of read.slots) {
458      if (slot.state === 'available' || slot.busy) {
459        slots.push(slot)
460        continue
461      }
462      let leaseBranch = slot.leaseBranch
463      if (leaseBranch === null && slot.lease !== null && slot.leasedAt !== null) {
464        const id = `${slot.path} ${slot.lease}`
465        leaseBranch = cache.leaseBranches.get(id) ?? null
466        if (leaseBranch === null) {
467          try {
468            const r = await run(['git', '-C', slot.path, '--no-optional-locks', 'reflog', 'show', '--date=unix', '--format=%gd%x09%gs', '-n', '500', 'HEAD'], undefined, 10_000)
469            const branches = r.exitCode === 0 ? parseReflog(r.stdout, slot.leasedAt) : null
470            leaseBranch = branches?.find(b => issueFromBranch(b) !== null) ?? null
471          } catch {
472            // unknown: H3 keeps the slot and says so
473          }
474        }
475        if (leaseBranch !== null) found.set(id, leaseBranch)
476      }
477      const n = leaseBranch === null ? null : issueFromBranch(leaseBranch)
478      let dirty: boolean | null = null
479      if (n !== null && finishedIssue(n)) {
480        try {
481          const r = await run(['git', '-C', slot.path, '--no-optional-locks', 'status', '--porcelain=v2', '--untracked-files=normal'], undefined, 30_000)
482          dirty = r.exitCode === 0 ? parseDirty(r.stdout) : null
483        } catch {
484          // not read: H3 keeps the slot and says so
485        }
486      }
487      slots.push({ ...slot, leaseBranch, dirty })
488    }
489    // only the leases still out are remembered
490    cache.leaseBranches = found
491    return { ...read, slots }
492  }
493
494  // pool: the shadow cannot see the shadowed device's pool, so it is assumed from its load
495  let pool: Pool | null = null
496  let poolJson: string | null = null
497  if (rt.runSource === 'events') {
498    const busy = mine.filter(i => ['implementing', 'in pipeline', 'parked', 'ready to merge'].includes(i.stage)).length
499    pool = { free: Math.max(0, rt.poolSize - busy), total: rt.poolSize, slots: [] }
500    mark('pool', true, now)
501  } else {
502    try {
503      const r = await run(['treehouse', 'status', '--json'])
504      pool = r.exitCode === 0 ? parsePool(r.stdout, rt.poolSize) : null
505      if (pool !== null) poolJson = r.stdout
506      if (pool !== null && !v2Only) pool = await readSlots(pool)
507      if (pool !== null) cache.pool = { at: now, value: pool }
508      mark('pool', pool !== null, cache.pool?.at ?? null, pool === null ? fail(r) : undefined)
509    } catch (err) {
510      mark('pool', false, cache.pool?.at ?? null, String(err))
511    }
512  }
513
514  // checkout: the main checkout's branch and tracked changes, every pass (rule G5, #544). Agents
515  // the loop starts begin there and designers read its code, so one an agent moved holds them.
516  // --no-optional-locks: the read never takes the index lock from under an agent's git command.
517  let checkout: Checkout
518  try {
519    const origin = await run(['git', 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], undefined, 10_000)
520    if (origin.exitCode !== 0) {
521      // --quiet: a ref that is not set exits 1 with nothing on stderr
522      const why = origin.stderr.trim() === '' ? `exit ${origin.exitCode}` : fail(origin)
523      checkout = { error: `no origin/HEAD (${why}); git remote set-head origin --auto sets it` }
524    } else {
525      const status = await run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=no'], undefined, 30_000)
526      const read = status.exitCode === 0 ? parseCheckout(origin.stdout, status.stdout) : null
527      checkout = read ?? { error: status.exitCode === 0 ? 'git status did not read' : fail(status) }
528    }
529  } catch (err) {
530    checkout = { error: String(err) }
531  }
532
533  // milestone: every milestoneEveryMs; nothing printed means the repo has not opted in
534  if (cache.milestone === undefined || now - cache.milestone.at >= cfg.milestoneEveryMs) {
535    try {
536      const r = await run(['callum-flow-milestone', 'show', 'next'])
537      if (r.exitCode === 0) cache.milestone = { at: now, value: parseMilestone(r.stdout) }
538    } catch {
539      // leave the last reading
540    }
541  }
542  mark('milestone', cache.milestone !== undefined, cache.milestone?.at ?? null)
543
544  // act mode holds GitHub writes and waits while the backoff lasts, and from the pass that met a
545  // limit: v2's reads below too, so a wait one of them could not read holds with no clock
546  const backoffOf = () => (stored !== undefined && limited ? Math.max(backoffUntil ?? 0, now + GITHUB_BACKOFF_MS) : backoffUntil)
547
548  const state = buildState({
549    now,
550    repo: rt.repo,
551    device: rt.device,
552    loadedAt,
553    sources,
554    factory,
555    facts,
556    runs: rt.runSource === 'local' ? runsFromWatch(cache.runs?.value ?? [], facts) : null,
557    queue: cache.queue?.value ?? null,
558    github: new Map([...cache.github].map(([n, c]) => [n, c.value])),
559    timelines: new Map([...cache.timelines].map(([n, c]) => [n, c.value])),
560    usage,
561    pool,
562    agents,
563    milestone: cache.milestone?.value ?? null,
564    ledger,
565    control,
566    lastTitle,
567    githubBackoffUntil: backoffOf(),
568    trackerTitle: cfg.trackerTitle,
569    checkout,
570  })
571  if (!v2On) return { state, commands, queueKnown, ledgerSource: chosen.source, rateLimited: limited, v2Heads: null }
572  const read = await observeV2({ io, rt, cache: cache.v2, run, state, stored: v2Stored, poolJson, github: cache.github, events: week ?? events, backoff: backoffUntil !== null, limited: () => limited })
573  return { state: { ...state, githubBackoffUntil: backoffOf(), v2: read.v2 }, commands, queueKnown, ledgerSource: chosen.source, rateLimited: limited, v2Heads: read.heads }
574}
575
576// withSession: the higher of usage-check.sh's reading and the session's own, per window.
577function withSession(reading: Usage | null, limits: { kind: string; percentUsed: number; resetsAt?: string }[]): Usage | null {
578  const session = (kind: string) => limits.find(l => l.kind === kind)
579  const five = session('five_hour')
580  const seven = session('seven_day')
581  if (reading === null && five === undefined && seven === undefined) return null
582  const base: Usage = reading ?? { fiveHour: null, sevenDay: null, fiveHourResetsAt: null, sevenDayResetsAt: null, unavailable: false }
583  const max = (a: number | null, b: number | undefined) => (b === undefined ? a : a === null ? b : Math.max(a, b))
584  return {
585    ...base,
586    fiveHour: max(base.fiveHour, five?.percentUsed),
587    sevenDay: max(base.sevenDay, seven?.percentUsed),
588    unavailable: base.unavailable && five === undefined && seven === undefined,
589  }
590}
591
592// readControl: control.json, field by field (controlFrom); missing or unreadable is no directive.
593async function readControl(io: Io, rt: Runtime): Promise<Control> {
594  try {
595    const text = await io.readFile(`${rt.loopDir}/control.json`)
596    return text === null ? EMPTY_CONTROL : controlFrom(JSON.parse(text))
597  } catch {
598    return EMPTY_CONTROL
599  }
600}
601
hooks/plan.ts 2542 lines
1// The factory loop's plan: a pure function from what observe saw to what to do next.
2// It encodes the decision table of docs/factory-loop/SPEC.md section 5, rule by rule.
3// No I/O and no clock (the time is state.now), and nothing imported but types, so the
4// same state and config always give the same actions. Every action already running or
5// done is in state.ledger under its key, so planning an unchanged state again yields
6// nothing new.
7import type {
8  Action,
9  ActionClass,
10  ActionKind,
11  AdjudicatorAction,
12  AgentStatus,
13  AgentType,
14  AgentView,
15  Bump,
16  Config,
17  GuardFail,
18  IssueView,
19  LedgerEntry,
20  MergeFlag,
21  MergeReport,
22  Ms,
23  Outcome,
24  PlanResult,
25  Rollout,
26  RunView,
27  Skip,
28  Slot,
29  Stage,
30  State,
31  Tier,
32  Usage,
33} from './types'
34
35const HOUR = 3_600_000
36const BREAKER_MS = 30 * 60_000
37// A drain this long tells the owner (rule H4, D-15).
38const DRAIN_SLOW_MS = HOUR
39// A failure the guard-fail judge answered `wait` (or `fixer`, its fixer ended) is asked about
40// again after this, if it still stands unchanged (#550): the slow reminder.
41const JUDGE_REMIND_MS = 30 * 60_000
42// The title's M: issues merged in this window (D-23).
43const MERGED_WINDOW_MS = 6 * HOUR
44// An agent still listed at work this long after its answer was recorded did not end its turn:
45// the list marks one that did idle or completed within seconds (answeredLive, turnEnded). Rule
46// A2 then stops it (#587).
47const ANSWERED_GRACE_MS = 60_000
48// A hand merge older than this gets no follow-ups (rule M7). callum-flow-factory-state lists a
49// final stage for 24 hours, and the bound keeps a merge from ever outliving the ledger's 7-day
50// prune of its `rollout:<issue>` entry, so it can never be re-triggered.
51const ROLLOUT_WITHIN_MS = 24 * HOUR
52// the live stages; observe reads the GitHub state of every issue in one (#556)
53export const LIVE: readonly Stage[] = ['designing', 'implementing', 'in pipeline', 'parked', 'ready to merge']
54export const ENDED: readonly Stage[] = ['merged', 'closed without merge']
55const MERGEABLE_STATES = ['merge-ready', 'head-mismatch', 'conflict']
56const WAKES: readonly ActionKind[] = ['judge', 'task', 'tell']
57// Notices where the owner has to act or decide; they go out before any other wake (rule O2).
58const OWNER_TELLS = [
59  'decision',
60  'escalate',
61  'review-cap',
62  'merge-refused',
63  'merge-hold',
64  'untrusted-pr',
65  'rollout',
66  'base',
67  'no-ci',
68  'ci-triggers',
69  'update-behind',
70  'linkage',
71  'checkout',
72]
73// Actions that write to GitHub (or read it heavily); held while GitHub rate-limits us.
74const GITHUB: readonly ActionKind[] = [
75  'claim',
76  'heartbeat',
77  'sweep',
78  'guard',
79  'update-behind',
80  'fix-linkage',
81  'merge',
82  'rollout',
83  'close-issue',
84  'release-parent',
85  'abandon',
86  'milestone-later',
87  'milestone-expect',
88  'pr-reopen',
89  'pr-comment',
90  'pr-close',
91]
92// What waits while the main checkout is off its default branch (rule G5, #544): each starts work
93// in it or reads its code (a claim leads to a designer; agents begin in it; a task is the model
94// working from it). Free commands read only the object store and remote refs, so they go on (a
95// fast-forward moves pool worktrees only, #551, and rule H5 moves this checkout only while it is
96// clean on its default branch, #555).
97const CHECKOUT_HELD: readonly ActionKind[] = ['claim', 'spawn', 'delegate', 'task']
98const ALLOWED: Record<Tier, readonly ActionClass[]> = {
99  normal: ['free', 'owner', 'drive', 'develop', 'start'],
100  slow: ['free', 'owner', 'drive', 'develop', 'start'],
101  'stop-new': ['free', 'owner', 'drive'],
102  'stop-all': ['free', 'owner'],
103}
104// A drain for an update holds every class that starts or drives work (rule H4).
105const DRAINED: readonly ActionClass[] = ['start', 'develop', 'drive']
106const BUMP_RANK: Record<Bump, number> = { patch: 1, minor: 2, major: 3 }
107// Merge report flags that need a release-sequencing call before the merge (rule M5).
108const CALL_FLAGS: readonly MergeFlag[] = ['run-it', 'image', 'template']
109
110type Gate = 'go' | 'running' | 'done' | 'wait' | 'exhausted'
111
112type Ctx = {
113  state: State
114  config: Config
115  tier: Tier
116  out: Action[]
117  skipped: Skip[]
118  owner: Set<string>
119  exhausted: string[]
120  byKey: Map<string, LedgerEntry>
121  issues: Map<number, IssueView>
122  capped: string[]
123  atCap: boolean
124  // the last action each issue wanted that did not go out, and why: what a PR waits for (#550)
125  gated: Map<number, { key: string; gate: Gate; rule: string }>
126  // rule H5's update is due but waits for work reading the main checkout: admit holds new readers
127  // so those drain (#555)
128  checkoutWaits: boolean
129}
130
131// tierOf: the usage tier (SPEC rule G3). The higher of the two windows decides; a missing
132// reading is Normal (the skill fails open); an owner override keeps Normal below its pct.
133export function tierOf(
134  usage: Usage,
135  config: Config,
136  override: State['control']['usageOverride'],
137  now: number,
138): { tier: Tier; overridden: boolean } {
139  if (usage.unavailable || (usage.fiveHour === null && usage.sevenDay === null)) {
140    return { tier: 'normal', overridden: false }
141  }
142  const live = (pct: number | null, resetsAt: number | null) => (resetsAt !== null && now >= resetsAt ? 0 : (pct ?? 0))
143  const binding = Math.max(live(usage.fiveHour, usage.fiveHourResetsAt), live(usage.sevenDay, usage.sevenDayResetsAt))
144  const base: Tier =
145    binding >= config.usageStopAllPct
146      ? 'stop-all'
147      : binding >= config.usageStopNewPct
148        ? 'stop-new'
149        : binding >= config.usageSlowPct
150          ? 'slow'
151          : 'normal'
152  if (base !== 'normal' && override !== null && now < override.expiresAt && binding < override.untilPct) {
153    return { tier: 'normal', overridden: true }
154  }
155  return { tier: base, overridden: false }
156}
157
158// issueFromBranch: the issue number of a branch named by the convention issue-<N>-...
159export function issueFromBranch(branch: string): number | null {
160  const m = branch.match(/(?:^|[/_-])issue-(\d+)(?:-|$)/)
161  return m ? Number(m[1]) : null
162}
163
164// guardSeq: the number a guard key ends with (guard:<pr>:<head>:<n>). The next guard of a PR and
165// head is one past the highest: a re-check that matched the guard before it replaced that entry
166// (ledger.ts foldRecheck), so the count of entries is no key.
167export function guardSeq(key: string): number {
168  const n = Number(key.slice(key.lastIndexOf(':') + 1))
169  return Number.isInteger(n) && n > 0 ? n : 0
170}
171
172// sameLabel: labels are compared without regard to case, so `in development` matches GitHub's
173// `In development` (SPEC 4.2).
174export function sameLabel(a: string, b: string): boolean {
175  return a.toLowerCase() === b.toLowerCase()
176}
177
178export function plan(state: State, config: Config): PlanResult {
179  const { tier, overridden } = tierOf(state.usage, config, state.control.usageOverride, state.now)
180  const byKey = new Map<string, LedgerEntry>()
181  for (const entry of [...state.ledger].sort((a, b) => a.startedAt - b.startedAt)) byKey.set(entry.key, entry)
182  const ctx: Ctx = {
183    state,
184    config,
185    tier,
186    out: [],
187    skipped: [],
188    owner: new Set(),
189    exhausted: [],
190    byKey,
191    issues: new Map(state.issues.map(issue => [issue.number, issue])),
192    capped: [],
193    atCap: false,
194    gated: new Map(),
195    checkoutWaits: false,
196  }
197
198  const blind = !state.sources.factory.ok
199  if (blind) {
200    blindPass(ctx)
201  } else {
202    checkout(ctx)
203    updateCheckout(ctx)
204    housekeeping(ctx)
205    if (state.sources.runs.ok) {
206      const runs = currentRuns(ctx)
207      pipelineGates(ctx, runs)
208      merges(ctx, runs)
209      supersededPrs(ctx)
210    } else {
211      skip(ctx, 'P', null, 'runs source unavailable')
212    }
213    handMerges(ctx)
214    mergeFollowUps(ctx)
215    design(ctx)
216    redelegate(ctx)
217    handoffs(ctx)
218    drops(ctx)
219    claims(ctx)
220    agents(ctx)
221    release(ctx)
222  }
223
224  const actions = admit(ctx)
225  const restart = blind ? null : restartWhenDrained(ctx, actions)
226  const title = titleOf(state, config, tier, { owner: ctx.owner.size, blind, capped: ctx.capped, atCap: ctx.atCap })
227  if (title !== state.lastTitle) {
228    actions.push({ kind: 'title', key: 'title', class: 'free', rule: 'O1', reason: 'status changed', issue: null, text: title })
229  }
230  // the restart ends the session, so it goes after everything else, the title included
231  if (restart !== null) actions.push(restart)
232  return { tier, overridden, actions, skipped: ctx.skipped, title, owner: ctx.owner.size, capped: ctx.capped, atCap: ctx.atCap }
233}
234
235// --- helpers -------------------------------------------------------------------------
236
237function skip(ctx: Ctx, rule: string, issue: number | null, why: string) {
238  ctx.skipped.push({ rule, issue, why })
239}
240
241function mine(ctx: Ctx, issue: IssueView) {
242  return issue.holder !== null && issue.holder === ctx.state.device
243}
244
245// isLive: in a live stage and not closed on GitHub. An issue closed on GitHub is finished
246// (#556), whatever stage the factory still shows: it is not counted, takes no in-flight slot,
247// and no rule starts work on it or its runs, except what follows the loop's own closing of it (a
248// merge's follow-ups, rule M7, and a superseded PR's close, M8). A state not read is not closed.
249function isLive(issue: IssueView | undefined) {
250  return issue !== undefined && issue.open !== false && issue.stage !== null && LIVE.includes(issue.stage)
251}
252
253// finished: merged or closed without merge, or closed on GitHub (#556).
254function finished(issue: IssueView): boolean {
255  return issue.open === false || (issue.stage !== null && ENDED.includes(issue.stage))
256}
257
258// endedWhy: how a finished issue ended: its final stage, else `closed`.
259function endedWhy(issue: IssueView): string {
260  return issue.stage !== null && ENDED.includes(issue.stage) ? issue.stage : 'closed'
261}
262
263function maxAttempts(config: Config, kind: ActionKind | AgentType) {
264  if (kind === 'update-behind') return config.updateBehindRetries + 1
265  // once per owner resume, whatever the config says (#537)
266  if (kind === 'env-retry') return 1
267  return config.maxAttempts[kind] ?? 3
268}
269
270function attemptsKind(action: Action): ActionKind | AgentType {
271  if (action.kind === 'spawn') return action.agent
272  if (action.kind === 'delegate') return 'implementer'
273  return action.kind
274}
275
276// cadenceOf: the interval of a kind that runs on a cadence. Heartbeat, sweep and update-checkout
277// retry at their next due time (SPEC 6.1), so they are never used up: past their attempts they
278// wait at most one interval between tries, and the owner hears once (agent-failed). null: any
279// other kind.
280function cadenceOf(config: Config, kind: ActionKind | AgentType): Ms | null {
281  if (kind === 'heartbeat') return config.heartbeatEveryMs
282  if (kind === 'sweep') return config.sweepEveryMs
283  if (kind === 'update-checkout') return config.checkoutEveryMs
284  return null
285}
286
287// gate: whether the action under this key may run now (SPEC 5.1 "blocked").
288function gate(ctx: Ctx, key: string, kind: ActionKind | AgentType): Gate {
289  const entry = ctx.byKey.get(key)
290  if (entry === undefined) return 'go'
291  if (entry.status === 'running') return 'running'
292  if (entry.status === 'done') return 'done'
293  // an agent that failed, timed out (a stop that did not take, SPEC 13) or was lost is retried
294  // only once the agent list shows it ended, so two never work one key (SPEC A1, 10.4)
295  if (entry.agentId !== undefined && agentLive(ctx, entry.agentId)) return 'running'
296  const cadence = cadenceOf(ctx.config, kind)
297  if (cadence === null && entry.attempts >= maxAttempts(ctx.config, kind)) return 'exhausted'
298  if (entry.status === 'lost') return 'go'
299  const doubled = ctx.config.retryBackoffMs * 2 ** Math.max(0, entry.attempts - 1)
300  const backoff = kind === 'update-behind' ? ctx.config.updateBehindRetryMs : cadence === null ? doubled : Math.min(doubled, cadence)
301  if (entry.endedAt !== null && ctx.state.now - entry.endedAt < backoff) return 'wait'
302  return 'go'
303}
304
305// want: emit the action unless its key is blocked. A notice counts toward the title's owner
306// items whether or not it goes out this pass, so the count stays while the issue stands. An
307// exhausted key is named in the decision log: it is what `/factory retry` clears (D-38). A
308// cadenced kind that failed its attempts in a row tells the owner once, and keeps trying.
309function want(ctx: Ctx, action: Action): Gate {
310  if (action.kind === 'tell' && OWNER_TELLS.includes(action.wake)) ctx.owner.add(action.key)
311  const kind = attemptsKind(action)
312  const g = gate(ctx, action.key, kind)
313  if (g === 'go') ctx.out.push(action)
314  else if (action.issue !== null) ctx.gated.set(action.issue, { key: action.key, gate: g, rule: action.rule })
315  const entry = ctx.byKey.get(action.key)
316  if (g === 'running' && entry !== undefined && entry.status !== 'running') {
317    skip(ctx, action.rule, action.issue, `${action.key}: its agent ${entry.agentId} still runs`)
318  }
319  if (g === 'exhausted') {
320    skip(ctx, action.rule, action.issue, `exhausted: ${action.key}`)
321    if (action.kind === 'task') ctx.exhausted.push(action.key)
322    else if (WAKES.includes(action.kind)) ctx.owner.add(action.key)
323    // env-retry tells the owner itself (envRetry): only a resume reruns it, never a retry
324    else if (action.kind !== 'env-retry') ctx.exhausted.push(action.key)
325  } else if (cadenceOf(ctx.config, kind) !== null) {
326    if (entry !== undefined && entry.status !== 'running' && entry.status !== 'done' && entry.attempts >= maxAttempts(ctx.config, kind)) {
327      ctx.exhausted.push(action.key)
328    }
329  }
330  return g
331}
332
333// agentLive: the session's agent list shows this agent still at work.
334function agentLive(ctx: Ctx, agentId: string): boolean {
335  return listedLive(ctx.state.agents, agentId)
336}
337
338// listedLive: an agent list shows this agent still at work (the stop-agent executor asks too):
339// listed, not failed or killed, and its turn not ended (turnEnded).
340export function listedLive(agents: readonly AgentView[], agentId: string): boolean {
341  const seen = agents.find(a => a.id === agentId)
342  return seen !== undefined && !turnEnded(seen.status) && seen.status !== 'failed' && seen.status !== 'killed'
343}
344
345// turnEnded: an agent of the loop listed past its turn: `completed`, or `idle`, which the engine
346// documents as between turns, until a message wakes it (AgentStatus). On Claude Code 2.1.299 an
347// agent that ended its turn is listed idle for 20 s or more before completed (#560). Nothing but
348// the loop's own repair message wakes one of its agents, and while that is on its way, or its
349// reply awaited, the entry is running and the reconciling read holds it at work (act.ts
350// reconcilable), so idle reads as ended here exactly as completed does.
351export function turnEnded(status: AgentStatus): boolean {
352  return status === 'completed' || status === 'idle'
353}
354
355// answeredLive: an agent entry that is done, while the list still shows its agent at work
356// ANSWERED_GRACE_MS after the answer. An answer through the tool ends the entry before the
357// agent's turn does (SPEC 7.3a.4), and an agent that works on, or waits on background work it
358// left running (a server, a wait loop that never ends), is listed at work up to its timeout, so
359// rule A2 stops it once the grace is out (#587, D-75). The grace leaves alone an agent the list has
360// not yet marked idle or completed (turnEnded). Only a done entry counts: its answer is applied
361// and saved (act.ts agentAnswered), so a stop discards nothing; an entry awaiting an answer, or
362// a repair reply, runs.
363function answeredLive(agents: readonly AgentView[], e: LedgerEntry, now: Ms): boolean {
364  if (e.status !== 'done' || e.agentId === undefined || e.endedAt === null || (e.kind !== 'spawn' && e.kind !== 'delegate')) return false
365  return now - e.endedAt > ANSWERED_GRACE_MS && listedLive(agents, e.agentId)
366}
367
368// heldBy: an agent entry whose agent still holds what it works on (the main checkout for rule
369// H5, its branch for one writer per branch): its entry running, or its agent listed at work. An
370// agent answered past the grace stops holding once its stop (rule A2) has been tried, done or
371// failed, so what it held goes on the next pass, after the stop: what it left running is
372// stopped with it or, if the stop failed, told to the owner, never waited on to its timeout
373// (#587).
374function heldBy(ctx: Ctx, e: LedgerEntry): boolean {
375  if (e.status === 'running') return true
376  if (e.agentId === undefined || !agentLive(ctx, e.agentId)) return false
377  if (!answeredLive(ctx.state.agents, e, ctx.state.now)) return true
378  const stop = ctx.byKey.get(`stop:${e.agentId}`)
379  return stop === undefined || stop.status === 'running'
380}
381
382function done(ctx: Ctx, key: string): LedgerEntry | undefined {
383  const entry = ctx.byKey.get(key)
384  return entry?.status === 'done' ? entry : undefined
385}
386
387function outcome<K extends Outcome['kind']>(entry: LedgerEntry | undefined, kind: K) {
388  const o = entry?.outcome
389  return o !== undefined && o.kind === kind ? (o as Extract<Outcome, { kind: K }>) : undefined
390}
391
392function lastDone(ctx: Ctx, kind: ActionKind): LedgerEntry | undefined {
393  let last: LedgerEntry | undefined
394  for (const entry of ctx.state.ledger) {
395    if (entry.kind !== kind || entry.status !== 'done' || entry.endedAt === null) continue
396    if (last === undefined || entry.endedAt > (last.endedAt ?? 0)) last = entry
397  }
398  return last
399}
400
401function lastClaimAt(ctx: Ctx, n: number): Ms {
402  let at = 0
403  for (const entry of ctx.state.ledger) {
404    if (entry.kind !== 'claim' || entry.issue !== n || entry.status !== 'done' || entry.endedAt === null) continue
405    if (entry.outcome?.kind === 'claim' && entry.outcome.result !== 'claimed') continue
406    at = Math.max(at, entry.endedAt)
407  }
408  return at
409}
410
411function resumes(ctx: Ctx, subject: string, after = Number.NEGATIVE_INFINITY) {
412  return ctx.state.control.resumed.filter(r => r.subject === subject && r.at > after).length
413}
414
415function latestResumeAt(ctx: Ctx, subject: string): Ms {
416  let at = Number.NEGATIVE_INFINITY
417  for (const r of ctx.state.control.resumed) if (r.subject === subject) at = Math.max(at, r.at)
418  return at
419}
420
421// issueNotes: the owner's notes for this issue alone (a note for every issue does not count),
422// recorded after `after` and no later than `until`, oldest first.
423function issueNotes(ctx: Ctx, n: number, after: Ms, until: Ms): string[] {
424  return ctx.state.control.notes
425    .filter(note => note.issue === n && note.at > after && note.at <= until)
426    .sort((a, b) => a.at - b.at)
427    .map(note => note.text)
428}
429
430function modelLabel(issue: IssueView | undefined): string | null {
431  const labels = (issue?.labels ?? [])
432    .map(l => l.toLowerCase())
433    .filter(l => l.startsWith('model:'))
434    .sort()
435  const first = labels[0]
436  return first === undefined ? null : first.slice('model:'.length)
437}
438
439type DesignResult = { brief: string | null; created: number[]; pendingDecision: boolean }
440
441function designResult(ctx: Ctx, entry: LedgerEntry | undefined, reportKey: string): DesignResult | 'unparsed' | null {
442  const o = outcome(entry, 'designer')
443  if (o === undefined) return null
444  if ('unparsed' in o) {
445    const answer = outcome(done(ctx, reportKey), 'wake')
446    return answer?.decision === 'parsed' ? fromFields(answer.fields) : 'unparsed'
447  }
448  return o
449}
450
451function fromFields(fields: Record<string, unknown>): DesignResult {
452  const created = Array.isArray(fields.created) ? fields.created.filter(n => typeof n === 'number') : []
453  return {
454    brief: typeof fields.brief === 'string' ? fields.brief : null,
455    created,
456    pendingDecision: fields.pendingDecision === true,
457  }
458}
459
460// briefOf: the brief URL for an issue (SPEC 5.1): the designer's, else the posted comment's,
461// else the one its delegation logged.
462function briefOf(ctx: Ctx, issue: IssueView | undefined): string | null {
463  if (issue === undefined) return null
464  let best: { at: number; url: string } | undefined
465  for (const entry of ctx.state.ledger) {
466    if (entry.issue !== issue.number || entry.kind !== 'spawn' || entry.agent !== 'designer') continue
467    const result = designResult(ctx, entry.status === 'done' ? entry : undefined, `judge:report:${entry.key}`)
468    if (result === null || result === 'unparsed' || result.brief === null) continue
469    const at = entry.endedAt ?? 0
470    if (best === undefined || at > best.at) best = { at, url: result.brief }
471  }
472  return best?.url ?? issue.brief?.url ?? issue.delegatedBrief
473}
474
475function wake(
476  kind: 'judge' | 'task' | 'tell',
477  name: string,
478  key: string,
479  rule: string,
480  issue: number | null,
481  fields: Record<string, string | number | null>,
482  context: string[] = [],
483  klass: ActionClass = kind === 'tell' ? 'owner' : 'drive',
484): Action {
485  const clean: Record<string, string> = {}
486  for (const [k, v] of Object.entries(fields)) if (v !== null) clean[k] = String(v)
487  return { kind, wake: name, key, class: klass, rule, reason: `${kind} ${name}`, issue, fields: clean, context }
488}
489
490// --- G2: a blind pass ------------------------------------------------------------------
491
492function blindPass(ctx: Ctx) {
493  const { state, config } = ctx
494  const since = state.sources.factory.at
495  skip(ctx, 'G2', null, `factory source unavailable${state.sources.factory.error ? `: ${state.sources.factory.error}` : ''}`)
496  if (since === null || state.now - since >= config.blindNoticeAfterMs) {
497    want(ctx, wake('tell', 'blind', `tell:blind:${since ?? 0}`, 'G2', null, { since: since ?? 'never' }))
498  }
499}
500
501// --- G5: the main checkout (#544) ----------------------------------------------------------
502
503// checkoutMoved: why the main checkout is not where an agent may start in it: detached, on
504// another branch, carrying changes, or unreadable; null when it is on its default branch and
505// clean, or was not read (the replay harness).
506export function checkoutMoved(state: State): string | null {
507  const c = state.checkout
508  if (c === null) return null
509  if ('error' in c) return `unreadable: ${c.error}`
510  if (c.branch === null) return `detached at ${(c.head ?? 'unknown').slice(0, 7)}`
511  if (c.branch !== c.defaultBranch) return `on ${c.branch}, not ${c.defaultBranch}`
512  if (c.dirty.length === 0) return null
513  const more = c.dirty.length > 3 ? ` and ${c.dirty.length - 3} more` : ''
514  return `has changes: ${c.dirty.slice(0, 3).join(', ')}${more}`
515}
516
517// A moved checkout tells the owner once per sight (its branch, commit and whether it carries
518// changes), and admit holds what starts in it until it is back. The loop never puts it back
519// itself: an agent may be using it.
520function checkout(ctx: Ctx) {
521  const why = checkoutMoved(ctx.state)
522  const c = ctx.state.checkout
523  if (why === null || c === null) return
524  if ('error' in c) {
525    want(ctx, wake('tell', 'checkout', 'tell:checkout:unreadable', 'G5', null, {}, [why]))
526    return
527  }
528  const seen = `${c.branch ?? 'detached'}@${c.head ?? 'unknown'}${c.dirty.length > 0 ? ':changed' : ''}`
529  want(ctx, wake('tell', 'checkout', `tell:checkout:${seen}`, 'G5', null, { branch: c.branch ?? 'detached', head: c.head, default: c.defaultBranch }, [why]))
530}
531
532// --- H: housekeeping -------------------------------------------------------------------
533
534function housekeeping(ctx: Ctx) {
535  const { state, config } = ctx
536  const held = state.issues.filter(issue => mine(ctx, issue) && isLive(issue))
537  const beat = lastDone(ctx, 'heartbeat')
538  if (held.length > 0) {
539    // A claim starts its own lease, so each held issue was last renewed by the later of its
540    // claim and the last heartbeat. The stalest of those decides.
541    const beatAt = beat?.endedAt ?? 0
542    const stalest = Math.min(...held.map(issue => Math.max(beatAt, lastClaimAt(ctx, issue.number))))
543    const afterLoad = stalest >= state.loadedAt
544    if (!afterLoad || state.now - stalest >= config.heartbeatEveryMs) {
545      want(ctx, {
546        kind: 'heartbeat',
547        key: `heartbeat:${beatAt}`,
548        class: 'free',
549        rule: 'H1',
550        reason: afterLoad ? 'lease renewal due' : 'first pass after load',
551        issue: null,
552      })
553    }
554  }
555  const sweep = lastDone(ctx, 'sweep')
556  if (sweep === undefined || state.now - (sweep.endedAt ?? 0) >= config.sweepEveryMs) {
557    want(ctx, { kind: 'sweep', key: `sweep:${sweep?.endedAt ?? 0}`, class: 'free', rule: 'H2', reason: 'sweep due', issue: null })
558  }
559  // H4: a drain that has lasted an hour tells the owner once, with what it still waits on.
560  const due = state.control.updateDue
561  const holding = due === null ? [] : drainBlockers(ctx)
562  if (due !== null && holding.length > 0 && state.now - due.at >= DRAIN_SLOW_MS) {
563    want(ctx, wake('tell', 'drain-slow', `tell:drain-slow:${due.at}`, 'H4', null, { since: due.at }, [`update: ${due.reason}`, `waiting on: ${holding.join(', ')}`]))
564  }
565  // a restart that failed is used up (restart-self runs once): the owner hears, since the drain
566  // holds every start, develop and drive action until the update is re-issued or turned off
567  if (due !== null && gate(ctx, `restart:${due.at}`, 'restart-self') === 'exhausted') {
568    skip(ctx, 'H4', null, `exhausted: restart:${due.at}`)
569    ctx.exhausted.push(`restart:${due.at}`)
570  }
571  if (!state.sources.pool.ok || state.pool === null) return
572  for (const slot of [...state.pool.slots].sort((a, b) => a.path.localeCompare(b.path))) {
573    if (slot.state !== 'available') returnSlot(ctx, slot)
574  }
575}
576
577// H3: a leased slot goes back to the pool once the issue its lease worked is finished and no
578// agent of it is at work, its tree clean (no tracked change or untracked file, which `treehouse
579// return` would keep). The issue comes from the lease's branch, which observe reads from the
580// slot's reflog once the agent has detached it (#563). The return is pinned to the lease, so a
581// slot leased again since is left alone, and its keys follow the lease, so a later lease on the
582// same path and branch (a reopened issue) is returned too. Every slot kept says why; a dirty one
583// tells the owner once per lease.
584function returnSlot(ctx: Ctx, slot: Slot) {
585  const kept = (issue: number | null, why: string) => skip(ctx, 'H3', issue, `slot ${slot.path} kept: ${why}`)
586  const branch = slot.leaseBranch
587  const n = branch === null ? null : issueFromBranch(branch)
588  if (slot.busy) return kept(n, 'processes run in it')
589  if (branch === null || n === null) {
590    return kept(null, slot.branch === null ? 'its lease names no issue branch' : `on ${slot.branch}, no issue branch`)
591  }
592  const issue = ctx.issues.get(n)
593  if (issue === undefined) return kept(n, `#${n} is not in the factory state`)
594  if (!finished(issue)) return kept(n, `#${n} is ${issue.stage ?? 'in no stage'}`)
595  // an agent of the issue still at work, its entry running or answered (rule A2 stops it first)
596  const agentRunning = ctx.state.ledger.some(e => e.issue === n && e.agentId !== undefined && (e.status === 'running' || agentLive(ctx, e.agentId)))
597  if (agentRunning) return kept(n, `an agent of #${n} is at work`)
598  const why = endedWhy(issue)
599  const ended = why === 'closed' ? 'closed on GitHub' : why
600  const lease = slot.lease
601  if (lease === null) return kept(n, `#${n} ${ended}, no lease to pin the return to`)
602  if (slot.dirty === null) return kept(n, `#${n} ${ended}, its tree not read`)
603  if (slot.dirty) {
604    kept(n, `#${n} ${ended}, its tree has uncommitted changes`)
605    want(ctx, wake('tell', 'worktree-dirty', `tell:worktree-dirty:${slot.path}:${lease}`, 'H3', n, { path: slot.path, branch }, [`#${n} ${ended}`]))
606    return
607  }
608  want(ctx, {
609    kind: 'release-worktree',
610    key: `release:${slot.path}:${lease}`,
611    class: 'free',
612    rule: 'H3',
613    reason: `${ended}, slot clean`,
614    issue: n,
615    path: slot.path,
616    branch,
617    lease,
618  })
619}
620
621// drainBlockers: what keeps the safe state for a restart from holding (rule H4, D-15): any
622// running ledger entry (an agent, a wake, a command), and any pipeline run still running,
623// pending or ci-stalled (its CI step still polls in the daemon), of any device, since the
624// workspace's daemon serves them all. Empty: safe.
625function drainBlockers(ctx: Ctx): string[] {
626  const { state } = ctx
627  if (!state.sources.runs.ok) return ['the runs source']
628  const entries = state.ledger.filter(e => e.status === 'running').map(e => e.key)
629  const runs = state.runs
630    .filter(r => r.state === 'running' || r.state === 'pending' || r.state === 'ci-stalled')
631    .map(r => `run ${r.run} ${r.state}`)
632  return [...entries.sort(), ...runs.sort()].slice(0, 6)
633}
634
635// H4: once the drain is complete and nothing else is planned, restart for the update.
636function restartWhenDrained(ctx: Ctx, kept: Action[]): Action | null {
637  const due = ctx.state.control.updateDue
638  if (due === null) return null
639  const holding = drainBlockers(ctx)
640  if (holding.length > 0) {
641    skip(ctx, 'H4', null, `update due: draining, waiting on ${holding.join(', ')}`)
642    return null
643  }
644  if (kept.length > 0) {
645    skip(ctx, 'H4', null, 'update due: this pass has other actions to finish first')
646    return null
647  }
648  const key = `restart:${due.at}`
649  // an exhausted restart was named, and told, by housekeeping
650  if (gate(ctx, key, 'restart-self') !== 'go') return null
651  return { kind: 'restart-self', key, class: 'free', rule: 'H4', reason: 'update due, nothing in flight', issue: null, why: due.reason, at: due.at }
652}
653
654// --- H5: the main checkout kept up to date with origin (#555) --------------------------------
655
656// Agents begin in the main checkout and designers read its code, so it follows origin's default
657// branch: on the first pass after a load (a reload can leave it stale), after a merge (the loop's
658// own, or a hand merge's rollout read) and at least every checkoutEveryMs, it is fast-forwarded,
659// only while G5 finds it on its default branch and clean (the executor checks again and refuses
660// commits origin lacks). It waits while work reads the checkout (checkoutReaders), as files
661// changing under it would change mid-read, and meanwhile admit holds new readers, so those drain
662// and the update runs: with several issues in flight there may otherwise never be a gap.
663// Implementers and fixers work in a worktree of their own, so they do not hold it: they run for
664// hours, and would keep it stale all run. A refusal G5 cannot see, such as commits origin lacks,
665// tells the owner once per sight with G5's notice while the checkout is at the refused commit; at
666// another commit it changed since, so the update is tried again at once. Only an acting pass
667// plans it: an observe pass derives its ledger from the event log, where an update leaves no
668// event, so every pass would find one due.
669function updateCheckout(ctx: Ctx) {
670  const { state, config } = ctx
671  const c = state.checkout
672  if (config.mode !== 'act' || c === null || 'error' in c || checkoutMoved(state) !== null) return
673  const last = lastDone(ctx, 'update-checkout')
674  const answer = outcome(last, 'update-checkout')
675  const refused = answer?.result === 'refused' ? answer : undefined
676  // the update records the commit the checkout was at when it was planned
677  const changed = refused !== undefined && last?.head !== c.head
678  if (refused !== undefined && !changed) {
679    const key = `tell:checkout:${c.branch ?? 'detached'}@${c.head ?? 'unknown'}:refused`
680    want(ctx, wake('tell', 'checkout', key, 'H5', null, { branch: c.branch, head: c.head, default: c.defaultBranch }, [refused.line]))
681  }
682  const since = last?.startedAt ?? Number.NEGATIVE_INFINITY
683  const merged = state.ledger.some(
684    e => e.status === 'done' && (e.endedAt ?? 0) > since && (outcome(e, 'merge')?.result === 'merged' || outcome(e, 'rollout') !== undefined),
685  )
686  const reason = merged
687    ? 'a merge since the last update'
688    : last === undefined || last.startedAt < state.loadedAt
689      ? 'none since the load'
690      : changed
691        ? 'the refused checkout changed'
692        : state.now - (last.endedAt ?? 0) >= config.checkoutEveryMs
693          ? 'due'
694          : null
695  if (reason === null) return
696  const key = `update-checkout:${last?.endedAt ?? 0}`
697  const readers = checkoutReaders(ctx)
698  if (readers.length > 0) {
699    skip(ctx, 'H5', null, `update-checkout waits for work reading the main checkout: ${readers.join(', ')}`)
700    ctx.checkoutWaits = gate(ctx, key, 'update-checkout') === 'go'
701    return
702  }
703  want(ctx, { kind: 'update-checkout', key, class: 'free', rule: 'H5', reason, issue: null, branch: c.defaultBranch, head: c.head })
704}
705
706// readsCheckout: work that reads the main checkout's code while it runs (rule H5): a designer or
707// an adjudicator of the loop, or a task, the model working from it (as G5 says).
708function readsCheckout(kind: ActionKind, agent: AgentType | undefined): boolean {
709  return (kind === 'spawn' && (agent === 'designer' || agent === 'adjudicator')) || kind === 'task'
710}
711
712// checkoutReaders: the loop's designers and adjudicators still at work, by the ledger or by the
713// agent list (one whose answer is in may read on, until rule A2 has stopped it: heldBy), and its
714// running tasks.
715function checkoutReaders(ctx: Ctx): string[] {
716  return ctx.state.ledger
717    .filter(e => readsCheckout(e.kind, e.agent))
718    .filter(e => heldBy(ctx, e))
719    .map(e => e.key)
720    .sort()
721}
722
723// --- the runs the P and M rules act on (SPEC 5.4) ------------------------------------------
724
725function currentRuns(ctx: Ctx): RunView[] {
726  const byIssue = new Map<number, RunView[]>()
727  for (const run of ctx.state.runs) {
728    if (run.issue === null || run.superseded) continue
729    const list = byIssue.get(run.issue) ?? []
730    list.push(run)
731    byIssue.set(run.issue, list)
732  }
733  const current: RunView[] = []
734  for (const [n, list] of [...byIssue.entries()].sort((a, b) => a[0] - b[0])) {
735    const issue = ctx.issues.get(n)
736    if (issue === undefined || (issue.stage !== null && ENDED.includes(issue.stage))) continue
737    // Another device's run, or one nobody claimed (the owner's own), is not this loop's to drive.
738    if (!mine(ctx, issue)) continue
739    // closed on GitHub: finished, whatever stage the factory still shows (#556)
740    if (issue.open === false) {
741      skip(ctx, 'P', n, 'closed on GitHub: its runs are not driven')
742      continue
743    }
744    const known = list.filter(r => r.run !== 'unknown').sort((a, b) => (a.run < b.run ? -1 : a.run > b.run ? 1 : 0))
745    const pick = known.length > 0 ? known[known.length - 1] : list.length === 1 ? list[0] : undefined
746    if (pick !== undefined) current.push(pick)
747  }
748  return current
749}
750
751// --- P: pipeline gates ----------------------------------------------------------------
752
753// gateOf: one gate of one run. Without a step (the event log has none), the park's time tells
754// two parks on one head apart, as when a run parks at review and then at test.
755function gateOf(run: RunView): string {
756  const gate = run.step ?? (run.state === 'parked' && run.since !== null ? `park@${run.since}` : null)
757  return `${run.run}:${gate}:${run.head}`
758}
759
760function pipelineGates(ctx: Ctx, runs: RunView[]) {
761  const { state, config } = ctx
762  for (const run of runs) {
763    const n = run.issue as number
764    const issue = ctx.issues.get(n)
765    const brief = briefOf(ctx, issue)
766    const model = modelLabel(issue)
767
768    // A failed run's step is not always known (the event log does not record it); the
769    // adjudicator reads the run and finds it.
770    if (run.state === 'parked' || (run.state === 'failed' && run.run !== 'unknown')) {
771      const resumed = resumes(ctx, `run:${run.run}`)
772      const suffix = resumed > 0 ? `:r${resumed}` : ''
773      const at = gateOf(run)
774      if (run.step === 'review') {
775        const rounds = Math.max(run.reviewFixRounds, ledgerReviewFixes(ctx, run.run))
776        if (rounds >= config.reviewFixCap + resumed) {
777          want(
778            ctx,
779            wake('tell', 'review-cap', `tell:review-cap:${run.run}:${rounds}`, 'P1', n, { issue: n, run: run.run, rounds, brief }, run.verdicts),
780          )
781          continue
782        }
783        if (!state.sources.facts.ok) {
784          skip(ctx, 'P2', n, 'event log unavailable: review rounds cannot be counted')
785          continue
786        }
787      }
788      // P2a (#537) and P2b (#596): a resume of an escalated gate. A resume note, or any verdict
789      // but ENV, sends a fixer (P2b); a resume of an all-ENV escalation with no note is the
790      // owner's word that the environment is fixed, so the same head is rerun (P2a) rather than
791      // a new adjudicator asked about the stale failure. A ledger rebuilt from the event log keeps
792      // an escalation but not its reason or verdicts: only a resume note sends the fixer then.
793      if (resumed > 0 && done(ctx, `adjudicate:${at}${suffix}`) === undefined) {
794        const esc = latestEscalation(ctx, run, at, resumed)
795        if (esc !== undefined) {
796          const notes = issueNotes(ctx, n, esc.endedAt, latestResumeAt(ctx, `run:${run.run}`))
797          const recorded = esc.why !== ''
798          if (recorded && notes.length === 0 && envOnly(esc.verdicts)) {
799            if (run.head !== null) {
800              envRetry(ctx, run, at, suffix, esc.verdicts)
801              continue
802            }
803          } else if (recorded || notes.length > 0) {
804            const instructions = [...notes.map(text => `Owner note: ${text}`), ...(recorded ? [`Adjudicator's reason: ${esc.why}`, ...esc.verdicts] : [])]
805            want(ctx, {
806              kind: 'spawn',
807              agent: 'fixer',
808              purpose: 'resume',
809              key: `fixer:resume:${at}${suffix}`,
810              class: 'drive',
811              rule: 'P2b',
812              reason: 'owner resumed an escalated gate',
813              issue: n,
814              run: run.run,
815              branch: run.branch,
816              head: run.head,
817              pr: run.pr,
818              brief,
819              model,
820              instructions: instructions.join('\n'),
821            })
822            continue
823          }
824        }
825      }
826      const adjKey = `adjudicate:${at}${suffix}`
827      const adj = done(ctx, adjKey)
828      if (adj === undefined) {
829        want(ctx, {
830          kind: 'spawn',
831          agent: 'adjudicator',
832          purpose: 'gate',
833          key: adjKey,
834          class: 'drive',
835          rule: 'P2',
836          reason: `${run.state} at ${run.step}`,
837          issue: n,
838          run: run.run,
839          branch: run.branch,
840          step: run.step,
841          head: run.head,
842          pr: run.pr,
843          brief,
844          model,
845          warns: [],
846          handoffHead: null,
847          verdicts: [],
848          guard: [],
849        })
850        continue
851      }
852      const verdict = outcome(adj, 'adjudicator')
853      if (verdict === undefined) continue
854      const action = verdict.action
855      if (action.type === 'escalate') {
856        want(
857          ctx,
858          wake('tell', 'escalate', `tell:escalate:${at}${suffix}`, 'P3', n, { issue: n, run: run.run, step: run.step, brief }, [
859            action.why,
860            resumeEffect(verdict.verdicts, run.head),
861            ...verdict.verdicts,
862          ]),
863        )
864      } else if (action.type === 'fixer') {
865        want(ctx, {
866          kind: 'spawn',
867          agent: 'fixer',
868          purpose: 'gate',
869          key: `fixer:gate:${at}${suffix}`,
870          class: 'drive',
871          rule: 'P3',
872          reason: 'adjudicator asked for a fixer',
873          issue: n,
874          run: run.run,
875          branch: run.branch,
876          head: run.head,
877          pr: run.pr,
878          brief,
879          model,
880          instructions: action.instructions,
881        })
882      } else if (action.type === 'merge' || action.type === 'hold') {
883        // the merge review's answers; the parser rejects them for a gate, so this is a loop bug
884        skip(ctx, 'P3', n, `adjudicator: ACTION ${action.type} does not answer a gate`)
885      } else {
886        respond(ctx, run, action, `respond:${at}:${action.type}${suffix}`)
887      }
888      continue
889    }
890
891    // A failed or cancelled run while the loop's own fixer or re-drive for the issue is at work
892    // (or just finished) is that work's doing: its handoff, or the new run, comes next.
893    if ((run.state === 'failed' || run.state === 'cancelled') && ownRedrive(ctx, n)) {
894      skip(ctx, 'P4', n, 'a fixer or re-drive of the loop is at work on it')
895      continue
896    }
897
898    if (run.state === 'failed' && run.run === 'unknown') {
899      want(ctx, wake('task', 'redrive', `task:redrive:${run.branch}:${run.head}`, 'P4', n, { issue: n, branch: run.branch, head: run.head, cause: 'infrastructure' }))
900      continue
901    }
902
903    if (run.state === 'cancelled') {
904      const key = `judge:cancelled:${run.run}:${run.head}`
905      const answer = outcome(done(ctx, key), 'wake')
906      if (answer === undefined) {
907        want(ctx, wake('judge', 'cancelled', key, 'P5', n, { issue: n, run: run.run, branch: run.branch, head: run.head, brief }))
908      } else if (answer.decision === 'redrive') {
909        want(ctx, wake('task', 'redrive', `task:redrive:${run.branch}:${run.head}`, 'P5', n, { issue: n, branch: run.branch, head: run.head, cause: 'cancelled' }))
910      } else if (answer.decision === 'drop') {
911        want(ctx, {
912          kind: 'abandon',
913          key: `abandon:${n}:${run.run}`,
914          class: 'free',
915          rule: 'P5',
916          reason: 'cancelled run dropped',
917          issue: n,
918          run: run.run,
919          branch: run.branch,
920          note: answer.reason ?? 'cancelled run dropped',
921        })
922      }
923      continue
924    }
925
926    if (run.state === 'ci-stalled') {
927      if (run.detail?.includes('reason=no-workflow')) {
928        want(ctx, wake('tell', 'no-ci', `tell:no-ci:${run.run}`, 'P6', n, { issue: n, pr: run.pr }))
929      } else if (run.detail?.includes('reason=no-checks') && run.pr !== null) {
930        const key = `reopen:${run.pr}:${run.run}`
931        const reopened = done(ctx, key)
932        if (reopened === undefined) {
933          want(ctx, { kind: 'pr-reopen', key, class: 'free', rule: 'P6a', reason: 'no CI checks registered', issue: n, pr: run.pr })
934        } else if (state.now - (reopened.endedAt ?? 0) >= config.ciStallRecheckMs) {
935          want(ctx, wake('tell', 'ci-triggers', `tell:ci-triggers:${run.run}`, 'P6b', n, { issue: n, pr: run.pr }))
936        }
937      }
938    }
939  }
940}
941
942// latestEscalation: this gate's latest adjudication, when it escalated and the owner resumed the
943// run after it: its reason and verdict lines (both empty when rebuilt from the event log, which
944// carries neither) and its end. undefined otherwise: the latest one asked for something else, the
945// owner has not resumed since, or the gate has none (a resume of the review cap). A round with no
946// adjudication is a fixer's or an env-retry's, so a second resume looks back past it.
947function latestEscalation(ctx: Ctx, run: RunView, at: string, resumed: number): { why: string; verdicts: string[]; endedAt: Ms } | undefined {
948  for (let round = resumed - 1; round >= 0; round--) {
949    const adj = done(ctx, `adjudicate:${at}${round > 0 ? `:r${round}` : ''}`)
950    if (adj === undefined) continue
951    const verdict = outcome(adj, 'adjudicator')
952    if (verdict === undefined || verdict.action.type !== 'escalate') return undefined
953    const endedAt = adj.endedAt ?? Number.POSITIVE_INFINITY
954    if (resumes(ctx, `run:${run.run}`, endedAt) === 0) return undefined
955    return { why: verdict.action.why, verdicts: verdict.verdicts, endedAt }
956  }
957  return undefined
958}
959
960// resumeEffect: the context line of an escalate tell, saying what a plain resume will do.
961function resumeEffect(verdicts: string[], head: string | null): string {
962  if (!envOnly(verdicts)) return "a resume sends a fixer with the adjudicator's reason above, plus any `note <N>` added before it"
963  return head === null
964    ? 'a resume alone asks the adjudicator again, as the run has no known head to run again on; add a `note <N>` before the resume to send a fixer instead'
965    : 'a resume alone runs the pipeline again on the same head; add a `note <N>` before the resume to send a fixer instead'
966}
967
968function envOnly(verdicts: string[]): boolean {
969  return verdicts.length > 0 && verdicts.every(v => /^ENV:/.test(v))
970}
971
972// envRetry, rule P2a: the rerun, once for this resume (one attempt, never retried). A clean launch
973// starts a new run, which the P rules then judge afresh; if this run is still at the gate
974// respondSettleMs later, the owner is told again rather than the loop trying more. A launch
975// that failed or was lost is told the same way, not as agent-failed: `/factory retry` never
976// clears an env-retry, only a fresh resume reruns it.
977function envRetry(ctx: Ctx, run: RunView, at: string, suffix: string, verdicts: string[]) {
978  const n = run.issue as number
979  const key = `env-retry:${at}${suffix}`
980  const fields = { issue: n, run: run.run, step: run.step, brief: briefOf(ctx, ctx.issues.get(n)) }
981  const g = want(ctx, {
982    kind: 'env-retry',
983    key,
984    class: 'drive',
985    rule: 'P2a',
986    reason: 'owner resumed an environment escalation',
987    issue: n,
988    run: run.run,
989    branch: run.branch,
990    step: run.step,
991    head: run.head as string,
992  })
993  if (g === 'exhausted') {
994    const why = ctx.byKey.get(key)?.summary
995    const line = `the pipeline could not be run again on this head after the resume${why === undefined ? '' : ` (${why})`}; resuming the run tries once more`
996    want(ctx, wake('tell', 'escalate', `tell:escalate:${at}${suffix}`, 'P2a', n, fields, [line, resumeEffect(verdicts, run.head), ...verdicts]))
997    return
998  }
999  const ran = g === 'done' ? done(ctx, key) : undefined
1000  if (ran === undefined || ctx.state.now - (ran.endedAt ?? 0) < ctx.config.respondSettleMs) return
1001  want(
1002    ctx,
1003    wake('tell', 'escalate', `tell:escalate:${at}${suffix}`, 'P2a', n, fields, [
1004      'the pipeline was run again on this head after the resume, and the run is still at this gate',
1005      resumeEffect(verdicts, run.head),
1006      ...verdicts,
1007    ]),
1008  )
1009}
1010
1011const REDRIVE_SETTLE_MS = 30 * 60_000
1012
1013function ownRedrive(ctx: Ctx, issue: number) {
1014  return ctx.state.ledger.some(
1015    e =>
1016      e.issue === issue &&
1017      ((e.kind === 'spawn' && e.agent === 'fixer') || (e.kind === 'task' && e.wake === 'redrive')) &&
1018      (e.status === 'running' || (e.endedAt !== null && ctx.state.now - e.endedAt < REDRIVE_SETTLE_MS)),
1019  )
1020}
1021
1022function ledgerReviewFixes(ctx: Ctx, run: string) {
1023  const heads = new Set<string>()
1024  for (const entry of ctx.state.ledger) {
1025    if (entry.kind !== 'respond' || entry.run !== run || entry.status !== 'done') continue
1026    const [, , step, head, action] = entry.key.split(':')
1027    if (step === 'review' && action === 'fix' && head !== undefined) heads.add(head)
1028  }
1029  return heads.size
1030}
1031
1032// respond, then P3a: a respond that left the gate where it was is tried once more, then the
1033// model reads the run's log and daemon.log and re-drives or reports (D-37).
1034function respond(ctx: Ctx, run: RunView, action: AdjAction, key: string) {
1035  const { state, config } = ctx
1036  const n = run.issue as number
1037  const make = (k: string, logReview: boolean): Action => ({
1038    kind: 'respond',
1039    key: k,
1040    class: 'drive',
1041    rule: k.endsWith(':retry') ? 'P3a' : 'P3',
1042    reason: `adjudicator: ${action.type}`,
1043    issue: n,
1044    run: run.run,
1045    branch: run.branch,
1046    step: run.step,
1047    head: run.head as string,
1048    action: action.type,
1049    findings: action.type === 'fix' ? action.findings : null,
1050    instructions: action.type === 'fix' ? action.instructions : null,
1051    addFinding: action.type === 'fix' ? action.addFinding : null,
1052    ignore: action.type === 'fix' ? action.ignore : null,
1053    approveReason: action.type === 'approve' ? action.reason : null,
1054    logReview,
1055  })
1056  const first = done(ctx, key)
1057  if (first === undefined) {
1058    want(ctx, make(key, action.type === 'fix' && run.step === 'review'))
1059    return
1060  }
1061  if (state.now - (first.endedAt ?? 0) < config.respondSettleMs) return
1062  const retryKey = `${key}:retry`
1063  const retry = done(ctx, retryKey)
1064  if (retry === undefined) {
1065    want(ctx, make(retryKey, false))
1066  } else if (state.now - (retry.endedAt ?? 0) >= config.respondSettleMs) {
1067    want(
1068      ctx,
1069      wake('task', 'respond-stuck', `task:respond-stuck:${gateOf(run)}`, 'P3a', n, {
1070        issue: n,
1071        run: run.run,
1072        branch: run.branch,
1073        step: run.step,
1074        head: run.head,
1075      }),
1076    )
1077  }
1078}
1079
1080type AdjAction = Exclude<AdjudicatorAction, { type: 'escalate' } | { type: 'fixer' } | { type: 'merge' } | { type: 'hold' }>
1081
1082// --- M: merge -------------------------------------------------------------------------
1083
1084type FailClass = 'author' | 'rollout' | 'base' | 'gates' | 'state' | 'conflict' | 'behind' | 'linkage' | 'transient' | 'judge'
1085const FAIL_ORDER: readonly FailClass[] = ['author', 'rollout', 'base', 'gates', 'state', 'conflict', 'behind', 'linkage', 'transient', 'judge']
1086
1087// classifyFail: a guard FAIL line by its fixed reason prefixes (SPEC 6.2).
1088export function classifyFail(fail: GuardFail): FailClass {
1089  const reason = fail.reason
1090  switch (fail.name) {
1091    case 'author':
1092    case 'rollout':
1093    case 'base':
1094    case 'gates':
1095    case 'linkage':
1096      return fail.name
1097    case 'mergeable':
1098      if (reason.startsWith('conflicts with')) return 'conflict'
1099      if (reason.startsWith('behind ')) return 'behind'
1100      if (reason.startsWith('GitHub has not computed') || reason.startsWith('branch rules not met')) return 'transient'
1101      if (/^PR is (merged|closed|a draft)/i.test(reason)) return 'state'
1102      if (reason.startsWith('cannot read')) return 'transient'
1103      return 'judge'
1104    case 'checks': {
1105      if (reason === 'no checks reported' || reason.startsWith('cannot ')) return 'transient'
1106      const pairs = reason
1107        .split(',')
1108        .map(p => p.trim())
1109        .filter(p => p.length > 0)
1110      return pairs.length > 0 && pairs.every(p => p.endsWith('=pending')) ? 'transient' : 'judge'
1111    }
1112    default:
1113      return 'judge'
1114  }
1115}
1116
1117type GuardResult = Extract<Outcome, { kind: 'guard' }>
1118
1119function merges(ctx: Ctx, runs: RunView[]) {
1120  const { state, config } = ctx
1121  const outAt = ctx.out.length
1122  const skippedAt = ctx.skipped.length
1123  ctx.gated.clear()
1124  for (const run of runs) {
1125    if (!MERGEABLE_STATES.includes(run.state) || run.pr === null || run.head === null) continue
1126    const n = run.issue as number
1127    const pr = run.pr
1128    const head = run.head
1129    const guards = state.ledger
1130      .filter(e => e.kind === 'guard' && e.pr === pr && e.head === head)
1131      .sort((a, b) => a.startedAt - b.startedAt)
1132    const latest = guards[guards.length - 1]
1133    const next = Math.max(0, ...guards.map(e => guardSeq(e.key))) + 1
1134    const guardAction = (seq: number): Action => ({
1135      kind: 'guard',
1136      key: `guard:${pr}:${head}:${seq}`,
1137      class: 'free',
1138      rule: 'M1',
1139      reason: `${run.state}, no fresh guard`,
1140      issue: n,
1141      pr,
1142      run: run.run,
1143      head,
1144    })
1145    // M1: a fresh guard outcome for this PR and head, or a guard run. A worktree the pipeline's own
1146    // commits left behind fails the guard's `head`, and M2 fast-forwards it (#551).
1147    if (latest === undefined) {
1148      want(ctx, guardAction(1))
1149      continue
1150    }
1151    if (latest.status === 'running') {
1152      skip(ctx, 'M1', n, `PR #${pr} waits for ${latest.key} to finish`)
1153      continue
1154    }
1155    if (latest.status !== 'done') {
1156      want(ctx, { ...guardAction(guards.length), key: latest.key })
1157      continue
1158    }
1159    const result = outcome(latest, 'guard')
1160    if (result === undefined) continue
1161    // a repair on this PR since the guard makes it stale: a fixer that ended done, with a commit
1162    // or without one (#550), a fix-linkage, a fast-forward that moved a worktree or found none
1163    // lagging any more (#551), or an update-behind that pushed a new PR head (the run's head,
1164    // which the watcher prints, stays)
1165    const lastRepair = Math.max(0, ...state.ledger.filter(e => e.pr === pr && repaired(e)).map(e => e.endedAt ?? 0))
1166    const age = state.now - (latest.endedAt ?? 0)
1167    const transientOnly = result.fails.length > 0 && result.fails.every(f => classifyFail(f) === 'transient')
1168    if ((latest.endedAt ?? 0) < lastRepair || age >= config.guardTtlMs || (transientOnly && age >= config.guardRecheckMs)) {
1169      want(ctx, guardAction(next))
1170      continue
1171    }
1172    if (result.fails.length > 0) {
1173      onGuardFails(ctx, run, result.fails, guardSeq(latest.key), latest.startedAt)
1174      continue
1175    }
1176    if (result.warns.length > 0 && done(ctx, `pr-comment:${pr}:${head}`) === undefined) {
1177      weakening(ctx, run, result.warns)
1178      continue
1179    }
1180    mergeReview(ctx, run, result)
1181  }
1182  // a PR no rule acted on this pass says what it waits for (#550)
1183  for (const run of runs) {
1184    if (!MERGEABLE_STATES.includes(run.state) || run.pr === null || run.head === null) continue
1185    const n = run.issue as number
1186    if (ctx.out.slice(outAt).some(a => a.issue === n) || ctx.skipped.slice(skippedAt).some(s => s.issue === n)) continue
1187    const g = ctx.gated.get(n)
1188    skip(ctx, g?.rule ?? 'M1', n, waitsFor(ctx, run, g))
1189  }
1190}
1191
1192// repaired: a done entry that changed a PR or its worktree, after which its guard is stale (M1).
1193// Rule M2's fast-forward that answers CURRENT counts too: the lag its guard saw is gone (the slot
1194// was reused or moved), so a re-guard, not the judge, looks again. An M1 one, which an older
1195// build ran before the first guard (#563 removed it), says nothing about that guard (#551).
1196function repaired(e: LedgerEntry): boolean {
1197  if (e.status !== 'done') return false
1198  if (e.kind === 'fix-linkage' || (e.kind === 'spawn' && e.agent === 'fixer')) return true
1199  const forward = outcome(e, 'fast-forward')?.result
1200  return outcome(e, 'update-behind')?.result === 'updated' || forward === 'forwarded' || (forward === 'current' && e.rule === 'M2')
hooks/rails.ts 559 lines
1// The safety rails (SPEC 2, 10.7, 11.2, 11.4; DECISIONS D-28, D-39): what must hold before the
2// loop acts at all, the usage pre-check before each token-spending action, and the pass
3// scheduler with its ping-storm breaker. Every decision is a pure function over a file's text, a
4// query's rows or a clock reading; the thin wrappers read files and run commands through the io
5// the driver passes, so the tests and the replay drive the decisions without the engine. Every
6// rail fails safe: a read it cannot make keeps the loop in observe, with its own reason.
7import type { ActIo, Exec, ExecCtx } from './exec'
8import { parseCsv } from './parse'
9import { tierOf } from './plan'
10import type { Action, ActionClass, Config, Control, Ms, Tier } from './types'
11
12const SECOND = 1_000
13const MINUTE = 60_000
14const PROBE_TIMEOUT_MS = 10_000
15const QUERY_TIMEOUT_MS = 30_000
16// flock waits 5 s for another session's write of the lock; the whole write is well inside this
17const LOCK_TIMEOUT_MS = 15_000
18
19// What a rail reads and runs: the ledger-free part of ActIo and ExecCtx, since the rails run at
20// the start of a pass, before any action has an entry.
21export type LockIo = Pick<ActIo, 'readFile'> & { cmd: ExecCtx['cmd'] }
22export type RailIo = LockIo
23
24// --- the act lock (SPEC 11.2) ---------------------------------------------------------------
25
26// A lease lasts a few passes and never under fifteen minutes: longer than the longest single step
27// (a merge, 600 s, then its rollout read), so a step can never outlive it and let a second session
28// in. The driver also renews it before every action and on a timer while a pass runs.
29export const LEASE_PASSES = 5
30export const LEASE_MIN_MS = 15 * MINUTE
31
32export type Lease = { session: string; renewedAt: Ms; expiresAt: Ms }
33
34export function actLockPath(loopDir: string): string {
35  return `${loopDir}/act.lock`
36}
37
38export function leaseMsOf(config: Config): Ms {
39  return Math.max(LEASE_PASSES * config.passEveryMs, LEASE_MIN_MS)
40}
41
42// parseLease: the lock file's lease; null for a missing, empty or malformed file, which any
43// session may take (every write is a compare-and-set, so two cannot both take it).
44export function parseLease(text: string | null): Lease | null {
45  if (text === null) return null
46  try {
47    const v: unknown = JSON.parse(text)
48    if (v === null || typeof v !== 'object' || Array.isArray(v)) return null
49    const o = v as Record<string, unknown>
50    const ms = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x)
51    if (typeof o.session !== 'string' || o.session === '' || !ms(o.renewedAt) || !ms(o.expiresAt)) return null
52    return { session: o.session, renewedAt: o.renewedAt, expiresAt: o.expiresAt }
53  } catch {
54    return null
55  }
56}
57
58export function leaseText(lease: Lease): string {
59  return JSON.stringify(lease) + '\n'
60}
61
62// took: the other session's expired lease this one replaced, for the decision log.
63export type LockDecision = { hold: true; lease: Lease; took: Lease | null } | { hold: false; holder: Lease }
64
65// decideLock: renew this session's own lease, take a missing or expired one, and refuse while
66// another session's lease is live (up to the instant it expires).
67export function decideLock(text: string | null, session: string, now: Ms, leaseMs: Ms): LockDecision {
68  const current = parseLease(text)
69  if (current !== null && current.session !== session && now < current.expiresAt) return { hold: false, holder: current }
70  return {
71    hold: true,
72    lease: { session, renewedAt: now, expiresAt: now + leaseMs },
73    took: current !== null && current.session !== session ? current : null,
74  }
75}
76
77// releasedLease: this session's lease, expired now, for session.end; null when the file holds no
78// lease of this session's (another session's lease is never touched).
79export function releasedLease(text: string | null, session: string, now: Ms): Lease | null {
80  const current = parseLease(text)
81  if (current === null || current.session !== session) return null
82  return { ...current, expiresAt: Math.min(current.expiresAt, now) }
83}
84
85export type LockResult = { held: true; lease: Lease; took: Lease | null } | { held: false; reason: string }
86
87function heldBy(lease: Lease): string {
88  return `act lock: session ${lease.session} holds it until ${new Date(lease.expiresAt).toISOString()}`
89}
90
91// The lock file's one writer: a compare-and-set under flock(1), the kernel lock a dying process
92// lets go of. $1 the lock, $2 its text as this session read it (empty when missing), the new
93// lease on stdin. It writes only while the file still holds what was read, and lands by rename,
94// so of two sessions that both read the lock free one writes and the other gets exit 3, and no
95// reader ever sees half a lease. Exit 2: the file cannot be written; 4: flock timed out.
96export const LOCK_CAS_SCRIPT = [
97  'mkdir -p -- "${1%/*}" || exit 2',
98  'exec 9>>"$1.flock" || exit 2',
99  'flock -w 5 9 || exit 4',
100  'cur=$(cat -- "$1" 2>/dev/null)',
101  '[ "$cur" = "$2" ] || exit 3',
102  'cat > "$1.$$" && mv -f -- "$1.$$" "$1" || exit 2',
103].join('\n')
104
105// lockCasArgv: the compare-and-set of one lease; `read` is the file's text as read (null: none).
106// The shell's $(cat) drops trailing newlines, so the expected text is compared without them.
107export function lockCasArgv(path: string, read: string | null): string[] {
108  return ['sh', '-c', LOCK_CAS_SCRIPT, 'act-lock', path, (read ?? '').replace(/\n+$/, '')]
109}
110
111// writeLease: the lease written over the text read, or why not.
112async function writeLease(io: LockIo, path: string, read: string | null, lease: Lease): Promise<'written' | 'changed' | string> {
113  const r = await io.cmd(lockCasArgv(path, read), { stdin: leaseText(lease), timeoutMs: LOCK_TIMEOUT_MS })
114  if (r.exitCode === 0) return 'written'
115  if (r.exitCode === 3) return 'changed'
116  const why = r.stderr.trim().split('\n').pop() ?? ''
117  return `act lock: ${path} not written (exit ${r.exitCode}${why === '' ? '' : `: ${why.slice(0, 200)}`})`
118}
119
120// holdActLock: acquire or renew the lease. The write is a compare-and-set (LOCK_CAS_SCRIPT), so a
121// lease that changed since it was read is never written over: whoever holds it now decides.
122export async function holdActLock(io: LockIo, path: string, session: string, now: Ms, leaseMs: Ms): Promise<LockResult> {
123  if (session === '') return { held: false, reason: 'act lock: this session has no id' }
124  try {
125    const read = await io.readFile(path)
126    const d = decideLock(read, session, now, leaseMs)
127    if (!d.hold) return { held: false, reason: heldBy(d.holder) }
128    const wrote = await writeLease(io, path, read, d.lease)
129    const back = parseLease(await io.readFile(path))
130    if (wrote === 'changed') {
131      // another session took it meanwhile, or this one renewed it from elsewhere (still ours)
132      if (back !== null && back.session === session && now < back.expiresAt) return { held: true, lease: back, took: null }
133      return { held: false, reason: back === null ? `act lock: ${path} changed while it was taken` : heldBy(back) }
134    }
135    if (wrote !== 'written') return { held: false, reason: wrote }
136    if (back === null) return { held: false, reason: `act lock: ${path} did not read back` }
137    if (back.session !== session) return { held: false, reason: heldBy(back) }
138    return { held: true, lease: d.lease, took: d.took }
139  } catch (err) {
140    return { held: false, reason: `act lock: ${String(err).slice(0, 200)}` }
141  }
142}
143
144// releaseActLock: at session.end, expire this session's lease so the next session acts at once.
145export async function releaseActLock(io: LockIo, path: string, session: string, now: Ms): Promise<boolean> {
146  try {
147    const read = await io.readFile(path)
148    const released = releasedLease(read, session, now)
149    if (released === null) return false
150    return (await writeLease(io, path, read, released)) === 'written'
151  } catch {
152    return false
153  }
154}
155
156// --- the role (SPEC 11.2, D-28) ---------------------------------------------------------------
157
158export const ORCHESTRATOR_ROLE = 'orchestrator'
159
160// roleProblem: a desktop session with the plugin installed never acts by accident; the
161// repo-local allowActOutsideOrchestrator lifts it for a testbed.
162export function roleProblem(role: string | undefined, config: Config): string | null {
163  if (role === ORCHESTRATOR_ROLE || config.allowActOutsideOrchestrator) return null
164  return `role: DEV_ROLE is ${role ? role : 'unset'}, act needs ${ORCHESTRATOR_ROLE}`
165}
166
167// --- the old watcher (SPEC 11.2, D-28) ----------------------------------------------------------
168
169// The session environment the rails read: the role, and what orchestrator-watch.sh places its
170// lock by.
171export type RailEnv = {
172  DEV_ROLE?: string
173  ORCHESTRATOR_WATCH_LOCK_DIR?: string
174  XDG_RUNTIME_DIR?: string
175  TMPDIR?: string
176}
177
178// watcherLockPath: the lock orchestrator-watch.sh takes for a repo, computed as it does:
179// ${ORCHESTRATOR_WATCH_LOCK_DIR:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}} (an empty value falls
180// through, as `:-` does), then the repo with every byte outside A-Za-z0-9._- made `_` (GNU tr
181// works on bytes, so a multibyte character gives one `_` per byte).
182export function watcherLockPath(env: RailEnv, repo: string): string {
183  const dir = env.ORCHESTRATOR_WATCH_LOCK_DIR || env.XDG_RUNTIME_DIR || env.TMPDIR || '/tmp'
184  let safe = ''
185  for (const ch of repo) {
186    const cp = ch.codePointAt(0) ?? 0
187    safe += /^[A-Za-z0-9._-]$/.test(ch) ? ch : '_'.repeat(cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4)
188  }
189  return `${dir}/orchestrator-watch-${safe}.lock`
190}
191
192// pidOf: the pid file's text as `$(cat "$lock/pid")` gives it (trailing newlines dropped). The
193// watcher writes its own `$$`, always decimal, so nothing else is probed.
194export function pidOf(text: string | null): string | null {
195  const p = (text ?? '').replace(/\n+$/, '')
196  return /^[0-9]+$/.test(p) ? p : null
197}
198
199// namesWatcher: /proc/<pid>/cmdline as `tr '\0' ' ' | grep -q orchestrator-watch` reads it.
200export function namesWatcher(cmdline: string): boolean {
201  return cmdline.replace(/\0/g, ' ').includes('orchestrator-watch')
202}
203
204// watcherAlive: the watcher's own alive_watcher: `kill -0` reached the pid, and its cmdline is
205// unreadable (no /proc) or names the watcher, so a pid reused by another program is stale.
206export function watcherAlive(signalled: boolean, cmdline: string | null): boolean {
207  return signalled && (cmdline === null || namesWatcher(cmdline))
208}
209
210export type WatcherCheck = { kind: 'none' } | { kind: 'live'; pid: string; path: string } | { kind: 'error'; reason: string }
211
212// findWatcher: a live orchestrator-watch.sh lock for the repo, as given and lower-cased (the
213// skill's REPO and the origin URL may differ in case). The pid reaches each command as an
214// argument, never inside a shell string. A lock directory with no readable decimal pid blocks
215// (fail safe): its owner may be between mkdir and writing its pid, and a stale one is the
216// watcher's own to take over.
217export async function findWatcher(io: RailIo, env: RailEnv, repo: string): Promise<WatcherCheck> {
218  const paths = [...new Set([watcherLockPath(env, repo), watcherLockPath(env, repo.toLowerCase())])]
219  for (const path of paths) {
220    try {
221      const pid = pidOf(await io.readFile(`${path}/pid`))
222      if (pid === null) {
223        const dir = await io.cmd(['test', '-d', path], { timeoutMs: PROBE_TIMEOUT_MS })
224        if (dir.exitCode === 1) continue
225        return { kind: 'error', reason: `old watcher: ${path} ${dir.exitCode === 0 ? 'exists with no readable pid' : `cannot be checked (exit ${dir.exitCode})`}` }
226      }
227      // signal 0 delivers nothing: it only asks whether the pid exists
228      const probe = await io.cmd(['sh', '-c', 'kill -0 "$1" 2>/dev/null', 'sh', pid], { timeoutMs: PROBE_TIMEOUT_MS })
229      if (probe.exitCode === 1) continue
230      if (probe.exitCode !== 0) return { kind: 'error', reason: `old watcher: cannot probe pid ${pid} (exit ${probe.exitCode})` }
231      const proc = await io.cmd(['cat', `/proc/${pid}/cmdline`], { timeoutMs: PROBE_TIMEOUT_MS })
232      if (watcherAlive(true, proc.exitCode === 0 ? proc.stdout : null)) return { kind: 'live', pid, path }
233    } catch (err) {
234      return { kind: 'error', reason: `old watcher: cannot check ${path}: ${String(err).slice(0, 200)}` }
235    }
236  }
237  return { kind: 'none' }
238}
239
240// --- the heartbeat guard (D-28) -----------------------------------------------------------------
241
242// Another orchestrator's heartbeat this recent holds acting, and the same window is the quiet the
243// loop waits for once it stops (heartbeat-loop beats every 60 s, so a live one is never quiet).
244export const HEARTBEAT_QUIET_MS = 120 * SECOND
245
246// Every other device's orchestrator row for the repo, aged by the database's clock through
247// factory.workers_status, as heartbeat-loop asks readers to (it stores the repo lower-cased).
248export const HEARTBEAT_SQL = `select device, extract(epoch from age) as age_s
249from factory.workers_status
250where role = 'orchestrator'
251  and lower(repo) = lower(:'repo')
252  and device <> :'device'
253order by age, device;
254`
255
256export function heartbeatArgv(repo: string, device: string): string[] {
257  return ['dev-query', '--csv', '-v', `repo=${repo}`, '-v', `device=${device}`]
258}
259
260export type Beat = { device: string; ageMs: Ms }
261
262// parseBeats: the query's CSV as beats; null when it is not the query's output. A row with no
263// age (no last_seen) is no heartbeat; a negative age (a clock step) counts as just now.
264export function parseBeats(stdout: string): Beat[] | null {
265  const header = (stdout.split(/\r?\n/, 1)[0] ?? '').trim()
266  if (header !== 'device,age_s') return null
267  return parseCsv(stdout).flatMap(row => {
268    const device = row.device ?? ''
269    const age = row.age_s === undefined || row.age_s.trim() === '' ? Number.NaN : Number(row.age_s)
270    return device !== '' && Number.isFinite(age) ? [{ device, ageMs: Math.max(0, age * SECOND) }] : []
271  })
272}
273
274// A blocking spell: when this loop first saw it, and the device it saw.
275export type Spell = { since: Ms; device: string }
276
277// notice: the spell that started this pass, told once (TELL heartbeat-other).
278export type GuardVerdict = { ok: boolean; reason: string | null; spell: Spell | null; notice: Spell | null }
279
280// heartbeatVerdict: block while any beat is at most HEARTBEAT_QUIET_MS old. A spell starts at
281// the first pass that sees one, with its one notice, and ends at the first pass that sees none.
282// A failed query (beats null) blocks too, fail safe, and leaves the spell as it was.
283export function heartbeatVerdict(beats: Beat[] | null, prev: Spell | null, now: Ms, error = 'no output'): GuardVerdict {
284  if (beats === null) return { ok: false, reason: `heartbeat: query failed (${error})`, spell: prev, notice: null }
285  const recent = beats.filter(b => b.ageMs <= HEARTBEAT_QUIET_MS).sort((a, b) => a.ageMs - b.ageMs)
286  const newest = recent[0]
287  if (newest === undefined) return { ok: true, reason: null, spell: null, notice: null }
288  const seen = recent.map(b => `${b.device} ${Math.round(b.ageMs / SECOND)}s ago`).join(', ')
289  const spell = prev ?? { since: now, device: newest.device }
290  return {
291    ok: false,
292    reason: `heartbeat: another orchestrator on this repo is up (${seen})`,
293    spell,
294    notice: prev === null ? spell : null,
295  }
296}
297
298// heartbeatGuard: one dev-query per pass. A device with no name cannot tell its own beat from
299// another's, so it refuses rather than guess.
300export async function heartbeatGuard(io: RailIo, repo: string, device: string, prev: Spell | null, now: Ms): Promise<GuardVerdict> {
301  if (device === '') {
302    return { ok: false, reason: 'heartbeat: this device has no name (DEV_MACHINE_NAME), so its own beat cannot be told apart', spell: prev, notice: null }
303  }
304  try {
305    const r = await io.cmd(heartbeatArgv(repo, device), { stdin: HEARTBEAT_SQL, timeoutMs: QUERY_TIMEOUT_MS })
306    if (r.exitCode !== 0) {
307      const last = r.stderr.trim().split('\n').pop()?.slice(0, 200) ?? ''
308      return heartbeatVerdict(null, prev, now, `exit ${r.exitCode}${last === '' ? '' : `: ${last}`}`)
309    }
310    return heartbeatVerdict(parseBeats(r.stdout), prev, now, 'unexpected output')
311  } catch (err) {
312    return heartbeatVerdict(null, prev, now, String(err).slice(0, 200))
313  }
314}
315
316// --- the rails together (SPEC 11.2) ------------------------------------------------------------
317
318// The released skill's single-shot watcher exits on every event and is armed again at the end of
319// the model's turn, so its lock comes and goes while that skill drives. The loop acts only after
320// the lock has been gone this long, which outlasts a turn's handling of an event.
321export const WATCHER_QUIET_MS = 10 * MINUTE
322
323// What the rails remember between passes, so each blocking spell is told once. Plain JSON: the
324// driver keeps it beside the ledger in the store, so a reload does not tell a spell again.
325// seenAt: the last pass that saw the watcher's lock, for its quiet window.
326export type WatcherSpell = { since: Ms; pid: string; seenAt: Ms }
327export type RailMemory = { heartbeat: Spell | null; watcher: WatcherSpell | null }
328export const EMPTY_RAIL_MEMORY: RailMemory = { heartbeat: null, watcher: null }
329
330export type RailNotice = { kind: 'heartbeat-other'; device: string; since: Ms } | { kind: 'old-watcher'; pid: string; since: Ms }
331
332export type RailInput = {
333  repo: string
334  device: string
335  // $.session.id(): the act lock's holder
336  session: string
337  loopDir: string
338  config: Config
339  env: RailEnv
340  now: Ms
341  memory: RailMemory
342  // refusals the driver found itself (the checkout's repo), named first; they keep the lease alone
343  refusals?: string[]
344}
345
346// act: every rail passed and this session holds the lease. reasons: each refusal, in the fixed
347// order. lock: the lease outcome, null when another rail refused (the lease is left alone).
348export type RailResult = { act: boolean; reasons: string[]; notices: RailNotice[]; memory: RailMemory; lock: LockResult | null }
349
350// checkRails: run at the start of every act-mode pass, before any action, in a fixed order:
351// the role, the old watcher, the heartbeat guard, then the act lock. The first three always run,
352// so the log names every refusal and each spell is tracked; the lock is taken only when they
353// pass. A session that fails the role check can never act, so it tells nothing.
354export async function checkRails(io: RailIo, input: RailInput): Promise<RailResult> {
355  const { config, env, now, memory } = input
356  const reasons: string[] = [...(input.refusals ?? [])]
357  const notices: RailNotice[] = []
358
359  const role = roleProblem(env.DEV_ROLE, config)
360  if (role !== null) reasons.push(role)
361
362  const watcher = await findWatcher(io, env, input.repo)
363  let watcherSpell = memory.watcher
364  if (watcher.kind === 'live') {
365    reasons.push(`old watcher: orchestrator-watch.sh holds ${watcher.path} (pid ${watcher.pid})`)
366    if (watcherSpell === null) notices.push({ kind: 'old-watcher', pid: watcher.pid, since: now })
367    watcherSpell = { since: watcherSpell?.since ?? now, pid: watcher.pid, seenAt: now }
368  } else if (watcher.kind === 'error') {
369    reasons.push(watcher.reason)
370  } else if (watcherSpell !== null && now - watcherSpell.seenAt < WATCHER_QUIET_MS) {
371    // gone between two of the released skill's turns, perhaps: the spell holds until it is quiet
372    reasons.push(`old watcher: its lock was seen ${new Date(watcherSpell.seenAt).toISOString()}; acting after ${WATCHER_QUIET_MS / MINUTE} minutes without it`)
373  } else {
374    watcherSpell = null
375  }
376
377  const guard = await heartbeatGuard(io, input.repo, input.device, memory.heartbeat, now)
378  if (guard.reason !== null) reasons.push(guard.reason)
379  if (guard.notice !== null) notices.push({ kind: 'heartbeat-other', device: guard.notice.device, since: guard.notice.since })
380
381  let lock: LockResult | null = null
382  if (reasons.length === 0) {
383    lock = await holdActLock(io, actLockPath(input.loopDir), input.session, now, leaseMsOf(config))
384    if (!lock.held) reasons.push(lock.reason)
385  }
386  return {
387    act: reasons.length === 0,
388    reasons,
389    notices: role === null ? notices : [],
390    memory: { heartbeat: guard.spell, watcher: watcherSpell },
391    lock,
392  }
393}
394
395// --- the usage pre-check (SPEC 10.7) -------------------------------------------------------------
396
397// The classes each tier lets run: plan's own matrix (ALLOWED in plan.ts, not exported), mirrored
398// here and held to plan's behaviour by rails.test.ts.
399export const TIER_CLASSES: Record<Tier, readonly ActionClass[]> = {
400  normal: ['free', 'owner', 'drive', 'develop', 'start'],
401  slow: ['free', 'owner', 'drive', 'develop', 'start'],
402  'stop-new': ['free', 'owner', 'drive'],
403  'stop-all': ['free', 'owner'],
404}
405
406// The token-spending classes, read against the session's usage again just before they run.
407export const PRECHECKED: readonly ActionClass[] = ['start', 'develop', 'drive']
408
409export type UsageReading = { fiveHour: number | null; sevenDay: number | null }
410
411const pct = (v: number | null) => (v === null ? 'n/a' : `${v}%`)
412
413// usageVerdict: why this reading forbids the class now, or null. tierOf decides the tier, the
414// owner's override included; a missing reading is Normal, as plan fails open.
415export function usageVerdict(
416  reading: UsageReading,
417  klass: ActionClass,
418  config: Config,
419  override: Control['usageOverride'],
420  now: Ms,
421): string | null {
422  if (!PRECHECKED.includes(klass)) return null
423  const usage = { ...reading, fiveHourResetsAt: null, sevenDayResetsAt: null, unavailable: false }
424  const { tier } = tierOf(usage, config, override, now)
425  if (TIER_CLASSES[tier].includes(klass)) return null
426  return `usage: tier ${tier} forbids ${klass} (5h ${pct(reading.fiveHour)}, 7d ${pct(reading.sevenDay)})`
427}
428
429// preCheck: before a start, develop or drive action, the session's usage read again (it is
430// free); a tier that now forbids the class skips the action, no attempt counted, and the next
431// pass re-plans. A failed read leaves plan's tier standing.
432export async function preCheck(
433  io: Pick<ActIo, 'usage' | 'now'>,
434  action: Action,
435  config: Config,
436  override: Control['usageOverride'],
437): Promise<Extract<Exec, { status: 'skipped' }> | null> {
438  if (!PRECHECKED.includes(action.class)) return null
439  let reading: UsageReading
440  try {
441    reading = await io.usage()
442  } catch {
443    return null
444  }
445  const why = usageVerdict(reading, action.class, config, override, await io.now())
446  return why === null ? null : { status: 'skipped', summary: why }
447}
448
449// --- the pass scheduler and the ping-storm breaker (SPEC 2, 11.4, D-39) --------------------------
450
451export const PASS_GAP_MS = 5 * SECOND
452export const STORM_PASSES = 30
453export const STORM_WINDOW_MS = 10 * MINUTE
454export const STORM_QUIET_MS = 10 * MINUTE
455
456export type PassSource = 'ping' | 'timer'
457
458export type Scheduler = {
459  running: boolean
460  // the one pass wanted next: the timer's when any tick asked for it, else a ping's
461  queued: PassSource | null
462  lastStart: Ms | null
463  // a wait for the end of the gap is set (one timer, however many requests arrive)
464  wakeAt: Ms | null
465  // when each ping-driven pass of the last STORM_WINDOW_MS started
466  pingStarts: Ms[]
467  // the breaker: pings are ignored until then, timer ticks never are
468  pingsIgnoredUntil: Ms | null
469}
470
471export type NextPass = { do: 'start'; source: PassSource } | { do: 'wait'; ms: Ms } | { do: 'idle' }
472
473// The breaker's one notice: TELL breaker kind=ping, plus a loop event.
474export type BreakerNotice = { kind: 'breaker'; since: Ms; until: Ms; passes: number }
475
476export function newScheduler(): Scheduler {
477  return { running: false, queued: null, lastStart: null, wakeAt: null, pingStarts: [], pingsIgnoredUntil: null }
478}
479
480export function pingsIgnored(s: Scheduler, now: Ms): boolean {
481  return s.pingsIgnoredUntil !== null && now < s.pingsIgnoredUntil
482}
483
484// requestPass: a ping or a timer tick arrived, during a pass or not. Requests merge into one
485// queued pass; a ping is dropped while the breaker is open.
486export function requestPass(s: Scheduler, source: PassSource, now: Ms): Scheduler {
487  if (source === 'ping' && pingsIgnored(s, now)) return s
488  return { ...s, queued: s.queued === 'timer' || source === 'timer' ? 'timer' : 'ping' }
489}
490
491// nextPass: what the driver does now; call it after every request, pass end and wait. Passes
492// never overlap and start PASS_GAP_MS apart: `wait` asks for one timer, whose callback calls
493// waitOver and then nextPass again. Starting the ping-driven pass that makes more than
494// STORM_PASSES in STORM_WINDOW_MS opens the breaker for STORM_QUIET_MS, with its one notice.
495export function nextPass(s: Scheduler, now: Ms): { scheduler: Scheduler; next: NextPass; notice: BreakerNotice | null } {
496  if (s.running || s.queued === null || (s.wakeAt !== null && now < s.wakeAt)) return { scheduler: s, next: { do: 'idle' }, notice: null }
497  if (s.lastStart !== null && now < s.lastStart + PASS_GAP_MS) {
498    const wakeAt = s.lastStart + PASS_GAP_MS
499    return { scheduler: { ...s, wakeAt }, next: { do: 'wait', ms: wakeAt - now }, notice: null }
500  }
501  const source = s.queued
502  let pingStarts = s.pingStarts.filter(t => now - t < STORM_WINDOW_MS)
503  let pingsIgnoredUntil = pingsIgnored(s, now) ? s.pingsIgnoredUntil : null
504  let notice: BreakerNotice | null = null
505  if (source === 'ping') {
506    pingStarts = [...pingStarts, now]
507    if (pingStarts.length > STORM_PASSES) {
508      pingsIgnoredUntil = now + STORM_QUIET_MS
509      notice = { kind: 'breaker', since: now, until: pingsIgnoredUntil, passes: pingStarts.length }
510      pingStarts = []
511    }
512  }
513  return {
514    scheduler: { running: true, queued: null, lastStart: now, wakeAt: null, pingStarts, pingsIgnoredUntil },
515    next: { do: 'start', source },
516    notice,
517  }
518}
519
520// waitOver: the wait's timer fired (early or late, nextPass judges the gap again).
521export function waitOver(s: Scheduler): Scheduler {
522  return { ...s, wakeAt: null }
523}
524
525// passEnded: the pass is over; nextPass then starts the one queued meanwhile, if any.
526export function passEnded(s: Scheduler): Scheduler {
527  return { ...s, running: false }
528}
529
530// --- notices as tell wakes (SPEC 9.2) -----------------------------------------------------------
531
532export type Notice = RailNotice | BreakerNotice
533
534// noticeTell: the tell wake a notice becomes, keyed by its spell so it goes out once:
535// `TELL heartbeat-other device=<d>` under tell:heartbeat-other:<since>, `TELL breaker kind=ping`
536// under tell:breaker:pings:<since> (plan's breaker keys name an action kind, never `pings`).
537// SPEC 9.2 has no kind for the old watcher yet (11.2 says the owner is told): old-watcher.
538export function noticeTell(n: Notice): Action {
539  const tell = (wake: string, key: string, fields: Record<string, string>): Action => ({
540    kind: 'tell',
541    wake,
542    key,
543    class: 'owner',
544    rule: 'O2',
545    reason: `tell ${wake}`,
546    issue: null,
547    fields,
548    context: [],
549  })
550  switch (n.kind) {
551    case 'heartbeat-other':
552      return tell('heartbeat-other', `tell:heartbeat-other:${n.since}`, { device: n.device })
553    case 'old-watcher':
554      return tell('old-watcher', `tell:old-watcher:${n.since}`, { pid: n.pid })
555    case 'breaker':
556      return tell('breaker', `tell:breaker:pings:${n.since}`, { kind: 'ping' })
557  }
558}
559