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…

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.
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).
| Piece | File | State |
|---|---|---|
| The decision table | hooks/plan.ts | done, pure |
| Parsers, event derivation, state assembly | hooks/parse.ts, hooks/derive.ts, hooks/state.ts | done, pure |
| Observe adapters | hooks/observe.ts | done; running as a shadow in spike-reconcile-mod |
| The pass driver: the store, the scheduler, the decision log, the hooks | hooks/register.ts | done |
| The act step and the ledger | hooks/act.ts, hooks/ledger.ts, hooks/exec.ts | done |
| Executors: commands, guard and merge, respond, agent spawns, prompts and reply parsers | hooks/act-cli.ts, hooks/act-merge.ts, hooks/act-agents.ts, hooks/prompts.ts, hooks/replies.ts | done; not yet run against real agents |
| Wakes and the answer tool | hooks/wake.ts | done |
| The agents' answer tools and the repair message | hooks/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.mjs | done; not yet run by real agents |
| Safety rails | hooks/rails.ts | done |
/factory and the control tool | hooks/control.ts | done |
| The judge skill | skills/judge/SKILL.md | drafted; not yet tried on a real wake |
--once watcher modes, the loop event state | features/src/callum-tools/, images/base/callum-flow-event (repo root) | done; ship in the next base image release |
| Tests | tests/ | 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 harness | scripts/factory-loop-replay.js (repo root) | done; results in docs/factory-loop/REPLAY-REPORT.md |
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.
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:
origin is repo;DEV_ROLE=orchestrator, or "allowActOutsideOrchestrator": true in the repo's .claude/factory-loop.json (for a testbed);orchestrator-watch.sh is armed for the repo, and none has been for 10 minutes;DEV_MACHINE_NAME;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.
/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.
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.
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.
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:
npm test: claude plugin validate and claude plugin test check this plugin against the new build's engine.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/./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.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).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.
hooks/register.ts 1688 lines1// 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 lines1// 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}
746hooks/act-agents.ts 647 lines1// 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}
647hooks/agent-tools.ts 133 lines1// 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>
133hooks/config.ts 350 lines1// 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}
350hooks/contract.mjs 456 lines1// 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}
456hooks/control.ts 661 lines1// 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}
661hooks/exec.ts 156 lines1// 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}
156hooks/ledger.ts 489 lines1// 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}
489hooks/observe.ts 601 lines1// 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}
601hooks/plan.ts 2542 lines1// 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 lines1// 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