@uchibeke/gallivant 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +70 -0
  12. package/docs/agent-setup.md +214 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +140 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +457 -0
  43. package/lib/commands/init.mjs +34 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +195 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +100 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +631 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +133 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1635 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
package/lib/tick.mjs ADDED
@@ -0,0 +1,1635 @@
1
+ // The tick: the one actor that changes state. Every `poll_seconds` it walks the registry once, in the order PRD 6.8
2
+ // lays out: settle finished runs, fetch, terminal states, events, dispatch, ready, silence.
3
+ //
4
+ // All side effects go through injected dependencies (gh, git, runner, notify, clock), so the whole state machine
5
+ // runs under `node --test` with fakes and no network.
6
+ //
7
+ // Cursors, in one place: `since` is the boundary at or after which events are new, paired with `dispatched`, the
8
+ // event keys already handled at exactly that instant (GitHub stamps to the second, so a strict comparison would
9
+ // bury a second event written in the same second); `fixed_sha` is the head the loop last acted on, both the head when
10
+ // the last run started, so a run that pushed is recognised by the head moving, and the head a ready entry was judged
11
+ // on, so a push after that reopens it; `checks_sha` is the head whose failing checks were already dispatched;
12
+ // `receipt_hash` is the completion receipt when the last run started.
13
+ import { STATES, handleFor, prdIdOf, advanceCursor } from "./state.mjs";
14
+ import { deriveEvents, makeTrust, reviewerVerdict, reviewerClock, isGreen, addedLines, hasOwnComment, eventKey, isFixCommand } from "./events.mjs";
15
+ import { fixPrompt, registerPrompt } from "./prompt.mjs";
16
+ import { parsePrd, verifyCommands, criteriaFromPrd, briefDivergence, divergenceReason, renderDivergence, retakePrdSnapshot } from "./prd.mjs";
17
+ import { gate, denialsPrompt, receiptInstructions, receiptHash, receiptHashes } from "./deliverable.mjs";
18
+ import { normalizePublicBody } from "./block.mjs";
19
+ import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./gh.mjs";
20
+ import { publicPrViolations, publicCommentViolations, publicCorrection, publicReviewHash } from "./public_output.mjs";
21
+ import { hasLegacyStatusLabels } from "./public-format.mjs";
22
+ import { adapterFor, buildRun, capabilitiesFor } from "./harness.mjs";
23
+ import { executionRunner, unsubmittedInitialDispatch } from "./execution.mjs";
24
+ import { reconcileExecutionResult, executionResultCheckpoint, requireVerificationSandbox } from "./execution_result.mjs";
25
+ import { readJson } from "./fs.mjs";
26
+ import { join } from "node:path";
27
+ import { runEnv, prdIdFromBranch } from "./git.mjs";
28
+ import { resolveCredentialFor } from "./credential.mjs";
29
+ import { requireIdentity } from "./identity.mjs";
30
+ import { resolveContextPaths, resolveTimeout } from "./config.mjs";
31
+ import { nowIso, minutes, hours, earlier, later, floorSecond } from "./time.mjs";
32
+ import { writeRepositoryText } from "./repository_files.mjs";
33
+ import { applyControl, interruptControl, cancelControl, recoverControl, controlPending, cancellationStatus } from "./control.mjs";
34
+ import { tickChains } from "./chains.mjs";
35
+ import { taskExecutionConfig, taskProviderAllowance } from "./task_allowance.mjs";
36
+ import { learningInput, learningDispatch, learningDelivered, learningDeliveryPatch, learningDeliveryPending } from "./learning_input.mjs";
37
+ import { hash as learningHash } from "./learning_data.mjs";
38
+ import { boundedCall } from "./execution_io.mjs";
39
+ import { networkFailure, validNetworkRetry, NETWORK_RETRY_DELAYS, NETWORK_RECOVERY_WINDOW } from "./network_recovery.mjs";
40
+ import { completionStopsAutomation } from "./task_completion.mjs";
41
+
42
+ const CONCURRENCY = 4;
43
+
44
+ /**
45
+ * How many ticks in a row one GitHub write may fail before the entry stops with `stuck` and says which write it was.
46
+ *
47
+ * Four steps in the tick decline rather than proceed when a single GitHub write fails, because proceeding would leave a
48
+ * worse state than waiting: the ready-label removal before a dispatch, the same removal before a moved head is judged,
49
+ * the identity block write a start run has to land before it can leave `running`, and the ready-label write that makes
50
+ * a judged PR say a human may merge it. All four are right to wait, and all four used to wait forever, with one log line
51
+ * per tick as the only trace. An entry stuck silently is worse than one that stops with a reason, because the whole
52
+ * point of this daemon is that the operator can go to bed.
53
+ *
54
+ * The fifth caller, the round comment, is on this counter for the same "a permanent refusal must not be silent" reason
55
+ * and for a different second one. It does not decline a step: the round it announces is already counted and the cursor
56
+ * has already moved, because the push it announces already happened and no comment failure can un-spend it. What it
57
+ * declines is the REST of the tick, because the comment is also the re-review request, so an entry that owes one has
58
+ * not asked the reviewer anything and must not be judged on the reviewer's silence or charged a second round.
59
+ *
60
+ * Completion verification uses the same bound: an unavailable evaluator or unreadable diff must surface its cause
61
+ * and stop, without spending a fixer round or waiting on a reviewer who already answered.
62
+ *
63
+ * A fixed number rather than a config key, deliberately. `max_iterations` is the nearest existing knob and it is the
64
+ * wrong one: it counts fix rounds, which cost a harness run and money each, while these cost one API call, so tying
65
+ * the two would either stop a transient failure after three ticks or let a permanent one run for as long as the
66
+ * operator was willing to pay for rounds. A new key would be one more thing in `config.json` with no right answer to
67
+ * tune it to: the only question it answers is "transient or permanent", and five consecutive failures answers it at
68
+ * any `poll_seconds`. At the default 120s that is ten minutes, which outlasts a GitHub rate-limit window and an abuse
69
+ * pause, and is far short of a revoked token or a body-write permission the operator has to fix by hand.
70
+ */
71
+ const MAX_STEP_FAILURES = 5;
72
+
73
+ /**
74
+ * The names `retryFailed` records in `entry.retry.op`, exported because `status` has to tell one of them apart from the
75
+ * others: three of these are steps on the way to a fix round, and `READY_LABEL_WRITE` is reached only after every
76
+ * readiness condition has already passed, so the row it produces says something different. Exported from here rather
77
+ * than duplicated there because this is the file that writes them, and `lib/` does not import from `lib/commands/`.
78
+ * The block write's name carries the PR number, so it is built at the site rather than listed here.
79
+ *
80
+ * `ROUND_COMMENT` is the one whose `retry` sits on an entry whose work is DONE rather than pending, so `status` reads
81
+ * `fixing` for it correctly (a fix round really did happen) and `why` adds the half the row cannot fit: the round is
82
+ * counted, and the PR does not yet say so.
83
+ */
84
+ export const READY_LABEL_REMOVAL = "ready label removal";
85
+ export const READY_LABEL_WRITE = "ready label write";
86
+ export const ROUND_COMMENT = "round comment";
87
+ export const COMPLETION_GATE = "completion gate";
88
+ export const PUBLIC_METADATA = "public metadata write";
89
+ export const REGULAR_PR = "regular PR conversion";
90
+
91
+ /** Two GitHub timestamps for the same instant, however each is spelled. */
92
+ const sameInstant = (a, b) => !!a && !!b && Date.parse(a) === Date.parse(b);
93
+
94
+ /** One second before a cursor: the overlap a server-side `since` filter is asked for. See `tickEntry`. */
95
+ const fetchFrom = iso => (iso ? new Date(Date.parse(floorSecond(iso)) - 1000).toISOString() : iso);
96
+
97
+ export function makeTick(deps) {
98
+ // `writePrd` is here for one caller: the re-snapshot of a brief edited outside its contract. It is the only write to
99
+ // the snapshot the tick makes, and it is not the tick overruling the operator, it is the tick following them.
100
+ const { config, state, gh, git, notify, log, clock = Date, ownLogin, readPrd, writePrd = () => {}, fetch = globalThis.fetch, aportApiKey = null, credential: ctxCredential = null, identity: ctxIdentity = null } = deps;
101
+ const runnerFor = id => executionRunner(deps, id);
102
+ const completed = (id, entry) => {
103
+ if (!completionStopsAutomation(entry)) return false;
104
+ // Acceptance may be durable in the execution ledger before the registry
105
+ // catches up. This is a local saved-state read, never a provider poll.
106
+ try { return completionStopsAutomation(entry, runnerFor(id).current()); }
107
+ catch { return false; } // keep reconciliation available on unreadable state
108
+ };
109
+ const startupReconciliation = new Set(deps.startupReconciliation === false ? [] : state.entries().map(([id]) => id));
110
+ // Embedders may restore the canonical cadence after a bounded wake.
111
+ let nextChainAt = deps.nextChainAt ?? 0;
112
+ const trust = makeTrust(config, ownLogin);
113
+ const learning = deps.learning || deps.learningFor?.(ownLogin);
114
+ const observationQueue = [];
115
+ let observationOffset = 0, observationSelection = null;
116
+ const now = () => nowIso(clock);
117
+
118
+ const save = (id, patch) => state.update(id, { ...patch, updated_at: now() });
119
+
120
+ const recoveryIdentity = (entry, session = entry.session) => ({ prd: entry.prd, harness: entry.harness, session, branch: entry.branch });
121
+
122
+ // Keep this boundary separate from the event cursor: an uncorrected own reply must not disappear after a dispatch.
123
+ const ownPublicComments = (entry, comments) => comments.filter(c => {
124
+ const author = actor(c.user ?? c.author);
125
+ return !author.bot && author.login === normalizeLogin(ownLogin)
126
+ && !isFixCommand(c.body)
127
+ && Date.parse(c.updated_at ?? c.updatedAt ?? c.submitted_at ?? c.created_at ?? c.createdAt) >= Date.parse(entry.public_since);
128
+ });
129
+
130
+ async function currentPublicComments(entry) {
131
+ const [comments, inline, reviews] = await Promise.all([
132
+ gh.issueComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
133
+ gh.inlineComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
134
+ gh.reviews(entry.repo, entry.pr),
135
+ ]);
136
+ return ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
137
+ }
138
+
139
+ /**
140
+ * The one way into needs-human, so every path through it leaves the same two labels. The ready label comes off as
141
+ * the needs-human label goes on: an entry that was ready and then hit a conflict, a red head or a blocking
142
+ * reviewer would otherwise carry both, which tells a person, and any automation reading labels, two opposite
143
+ * things. Removal is unconditional rather than guarded on the entry's own state, because a label left by an
144
+ * earlier daemon outlives the registry; `removeLabel` already ignores a label that is not there.
145
+ */
146
+ async function needsHuman(id, reason, extra = {}) {
147
+ // `timeout` is cleared alongside `gate_skipped` for the same reason: both describe ONE stop, and an entry that
148
+ // stopped for a timeout and later stops for `checks-red` must not still print a kill line from a run ago. The
149
+ // caller that means it passes its own `timeout` in `extra`, which lands after this and wins.
150
+ const e = save(id, { state: STATES.needsHuman, reason, gate_skipped: null, timeout: null, ...extra });
151
+ log.warn(`${id}: needs-human (${reason})`);
152
+ const written = e.pr ? await writeStopLabels(id, e) : true;
153
+ // Unlike the ready label, this one cannot hold the state back: the entry IS stopped, and the registry, `status`,
154
+ // `why` and the message are all signals in their own right. What it must not do is give up: a rate-limited or
155
+ // briefly unreachable GitHub used to leave the PR carrying `ready-for-human` and no `needs-human` while the
156
+ // registry said stopped, with nothing that would ever reconcile it, because every later tick returns early on
157
+ // this state. So the failure is recorded and `tickEntry` retries it before that early return.
158
+ const patch = written ? (e.labels_pending ? { labels_pending: false } : null) : { labels_pending: true };
159
+ const saved = patch ? save(id, patch) : e;
160
+ await notify.needs({ entry: saved, max: config.max_iterations, handle: handleFor(id, saved) });
161
+ return saved;
162
+ }
163
+
164
+ /**
165
+ * The two labels a stopped PR carries, as one write. The ready label comes off as the needs-human label goes on: an
166
+ * entry that was ready and then hit a conflict, a red head or a blocking reviewer would otherwise carry both, which
167
+ * tells a person, and any automation reading labels, two opposite things. Returns whether both landed.
168
+ */
169
+ async function writeStopLabels(id, e) {
170
+ // `removeLabel` throws for everything but a label that was already absent, so a GitHub that is not answering is
171
+ // not mistaken for a label that was already off. It does not decide the retry on its own: whether GitHub is
172
+ // reachable at all is the same question for both writes, and `addLabel` is the one that must land, so that is
173
+ // what the retry tracks. A removal that fails for any other reason is retried with it on the next tick.
174
+ let removed = true;
175
+ try { await gh.removeLabel(e.repo, e.pr, READY_LABEL); }
176
+ catch (err) { removed = false; log.warn(`${id}: ready label not removed (${err.message})`); }
177
+ try { await gh.addLabel(e.repo, e.pr, NEEDS_LABEL); }
178
+ catch (err) { log.warn(`${id}: needs-human label not written (${err.message}); retrying next tick`); return false; }
179
+ if (!removed) log.warn(`${id}: retrying the ready-label removal next tick`);
180
+ return removed;
181
+ }
182
+
183
+ /**
184
+ * The bound on every step that declines a tick because a GitHub write or completion check failed. One mechanism
185
+ * for all of them: this codebase's recurring defect is the same invariant enforced on one path and not its sibling.
186
+ * What makes a caller belong here is the shape: the step declines, the entry stays in a state the next tick
187
+ * re-enters, and nothing else about the entry has changed.
188
+ *
189
+ * `entry.retry` is `{ op, count, error? }`: which operation has been failing and how many ticks in a row. It is a counter of
190
+ * CONSECUTIVE failures, so `retryOk` clears it when the step succeeds and an entry that recovers is never stopped
191
+ * for the ticks it spent failing. A different `op` failing resets the count rather than adding to it, because the
192
+ * count exists to answer "is this one step permanently broken" and two different broken steps are two answers.
193
+ *
194
+ * `retryFailed` returns true when it stopped the entry; the caller returns without doing the work it declined either
195
+ * way. `needsHuman` is the stop, so the labels, the notification and the once-only guarantee are the same as every
196
+ * other stop's; the reason is `stuck` and `last.event` names the write and the last error, which is what the operator
197
+ * reads in `gallivant why`. `onStop` is what a caller wants recorded only once the waiting is over, never while it is
198
+ * still waiting: the start-run case had read the PR number before the block write refused it, and a stopped entry that
199
+ * names its PR is one the operator can click through to, while a retrying one must not claim to watch a PR whose
200
+ * identity block is still missing.
201
+ */
202
+ const retryOk = (id, entry) => (entry.retry ? save(id, { retry: null }) : entry);
203
+
204
+ async function retryFailed(id, entry, op, err, { action, onStop = {} }) {
205
+ const count = (entry.retry?.op === op ? entry.retry.count : 0) + 1;
206
+ if (count < MAX_STEP_FAILURES) {
207
+ log.warn(`${id}: ${op} failed (${err.message}); ${action} (attempt ${count} of ${MAX_STEP_FAILURES})`);
208
+ save(id, { retry: { op, count, ...(op === COMPLETION_GATE ? { error: err.message } : {}) } });
209
+ return false;
210
+ }
211
+ log.error(`${id}: ${op} failed ${count} ticks in a row (${err.message}); stopping`);
212
+ // `onStop.last` is merged into the `last` this builds rather than overwritten by it, and `event` and `at` stay this
213
+ // function's to write either way, because naming the write and the streak is what `gallivant why` reads. The merge
214
+ // was added for the round-comment caller, which had to record the run's exit on the stop and only on the stop
215
+ // because the exit was the registry state the settle re-entered on. It no longer needs it: that caller now records
216
+ // the exit before the comment is attempted at all, so the settle is finished either way. The merge is kept because
217
+ // the ordering it fixes is a real trap for the next caller, and a `last` silently discarded is the harder bug of
218
+ // the two to find; today no caller passes one.
219
+ const { last: stopLast = null, ...rest } = onStop;
220
+ await needsHuman(id, "stuck", {
221
+ ...rest, retry: null,
222
+ last: { ...(entry.last || {}), ...(stopLast || {}), at: now(), event: `${op} failed ${count} ticks in a row: ${err.message}` },
223
+ });
224
+ return true;
225
+ }
226
+
227
+ // The PRD is read from the daemon's own snapshot taken at start, never from the agent-writable checkout, so a
228
+ // session cannot rewrite its own Verify commands or acceptance criteria. Parsed once per tick per entry.
229
+ const prds = new Map();
230
+ const prdFor = id => {
231
+ if (!prds.has(id)) { const text = readPrd(id); prds.set(id, text ? parsePrd(text, `${id}.md`) : null); }
232
+ return prds.get(id);
233
+ };
234
+ const verifyFor = id => { const p = prdFor(id); return p ? verifyCommands(p) : []; };
235
+ const receiptFor = id => { const p = prdFor(id); return p ? receiptInstructions(criteriaFromPrd(p), id) : null; };
236
+
237
+ /**
238
+ * Has the operator edited the brief since this run started, and does it matter?
239
+ *
240
+ * This is the one place the daemon reads the operator's own working tree, and it is worth saying why it reads it at
241
+ * all and why only here.
242
+ *
243
+ * Why the daemon and not only the commands. The tick is the only actor that changes state, and the thing that has to
244
+ * change is an entry stopping before it certifies a stale brief. A comparison that lived only in `status` and `why`
245
+ * would have the daemon finishing the work and opening the PR while the operator's own commands complained about it,
246
+ * which is a warning, not a guard. `status` and `why` compare too, because an operator asking what is going on is
247
+ * exactly when they want to be told, and those run in the operator's own session where reading their tree is
248
+ * unremarkable. Same function, one implementation, three callers.
249
+ *
250
+ * Why only at the gate. `entry.checkout` is a path on the operator's machine, so reading it every `poll_seconds` for
251
+ * every entry is a stat and a parse per entry per two minutes for something that changes a handful of times in a run,
252
+ * and a removable disk or a moved checkout would read as `gone` and stop entries that are perfectly healthy. The
253
+ * moment that matters is the one where the contract is about to be USED to certify something, and that is the
254
+ * readiness judgement: everything before it (a fix round dispatched for a review, a round counted for a push) happens
255
+ * against the brief the session already holds and is not a claim about the brief being satisfied. So the check sits
256
+ * immediately before `gate`, which is also the last point where stopping costs nothing that was not going to be spent
257
+ * anyway.
258
+ *
259
+ * Never throws. A brief the daemon cannot compare is a brief it carries on with, exactly as an entry with no snapshot
260
+ * reaches ready on the checks and the reviewer alone: a failure to read the operator's tree must not be the thing
261
+ * that stops a PR.
262
+ */
263
+ const divergenceFor = (id, entry) => {
264
+ try { return briefDivergence({ id, entry, readPrd, readSource: deps.readSource }); }
265
+ catch (e) { log.warn(`${id}: the brief could not be compared with ${entry.checkout ?? "the operator's checkout"} (${e.message}); carrying on`); return { verdict: "unknown", why: e.message, diff: [] }; }
266
+ };
267
+
268
+ const restoreSources = (id, entry) => {
269
+ for (const rel of entry.sources ?? []) {
270
+ const text = deps.readSource?.(id, rel);
271
+ if (text == null) throw new Error(`${rel}: carried-file snapshot is missing`);
272
+ writeRepositoryText(entry.worktree, rel, text);
273
+ }
274
+ };
275
+
276
+ /** The checkout, recreated from the PR branch when it is gone (a lost disk, a cleaned worktrees folder). */
277
+ const ensureWorktree = async (id, entry) => {
278
+ if (!git.exists(entry.worktree)) {
279
+ await git.fromBranch({ nameWithOwner: entry.repo, prdId: prdIdFromBranch(entry.branch) ?? prdIdOf(id), branch: entry.branch, path: entry.worktree });
280
+ restoreSources(id, entry);
281
+ }
282
+ };
283
+
284
+ /**
285
+ * Bring the base branch into this branch before a session is handed the worktree, so a round of work starts from the
286
+ * current base rather than from wherever the base was when the branch was cut. A branch cut on Monday and fixed on
287
+ * Thursday drifts, and the first sign of the drift today is `mergeable: CONFLICTING`, which stops the entry.
288
+ *
289
+ * It is a MERGE, not a rebase, and `git.mergeBase` carries the reasoning at the implementation. The short of it: a
290
+ * rebase of a pushed branch needs a force-push, which the RULES every session is given forbid; it would strand the
291
+ * reviewer's clearance, which `reviewerVerdict` grants to a named sha; and the tick itself would read the rewritten
292
+ * head as a stranger's push. A merge moves the head forward instead, so none of the three happens.
293
+ *
294
+ * An outstanding review is NOT a reason to skip it, and that is a consequence of choosing merge over rebase rather
295
+ * than an accident. Every commit the reviewer looked at stays an ancestor of the new head, so their inline comments
296
+ * stay anchored to commits that still exist and their clearance still describes something real. A rebase would have
297
+ * had to wait for the review, which is exactly the deadlock the merge avoids: the reviewer is waiting for a fix and
298
+ * the fix is waiting for the reviewer.
299
+ *
300
+ * The merge commit stays LOCAL until the session pushes, which is what keeps the rest of the loop honest. Nothing on
301
+ * GitHub moves here, so `headChanged` does not see a head the loop did not put there, the reviewer clock is not
302
+ * reset, and the round is still counted from the session's own push in `settleRun`. That push carries the merge with
303
+ * it, as one head move rather than two.
304
+ *
305
+ * The result is SAVED here, the moment it happens, rather than carried to the dispatch's own save. A merge is a
306
+ * durable change to a checkout on disk, and four things after this point can return early (the ready-label write, a
307
+ * busy runner, a missing credential, a refused context_paths): a merge recorded only by that later save would have
308
+ * happened with nothing on the entry to say so, which is exactly what an operator debugging the branch needs.
309
+ *
310
+ * A conflict is the caller's to handle: nothing is left half-merged (`git.mergeBase` aborts), and this returns the
311
+ * failure rather than throwing so the caller can stop the entry and name the base it could not merge.
312
+ */
313
+ async function mergeBaseInto(id, entry) {
314
+ // A base is needed to merge one, and an entry can lack one: everything `register` and `reconcile` built before this
315
+ // field existed, and anything they build for a PR whose `baseRefName` could not be read. Nothing to merge is not a
316
+ // failure, it is a round that behaves exactly as every round did before this existed.
317
+ if (!entry.base || !git.mergeBase) return null;
318
+
319
+ let r;
320
+ try { await git.ensureHttpsRemote?.(entry.worktree); r = await git.mergeBase(entry.worktree, entry.base); }
321
+ catch (e) {
322
+ // The fetch or a rev-parse failed, so no merge was attempted and the tree is untouched. Same shape as a conflict
323
+ // to the caller, because the outcome for this round is the same: it does not start, and the operator is told which
324
+ // base could not be brought in.
325
+ return { merged: false, conflict: true, base: entry.base, why: e.message.split("\n").find(l => l.trim()) || e.message };
326
+ }
327
+ if (r?.conflict) return r;
328
+ if (r?.merged) {
329
+ log.info(`${id}: merged origin/${entry.base} into ${entry.branch} before the run (${r.sha ? r.sha.slice(0, 7) : "merge commit"})`);
330
+ save(id, { base_merge: { at: now(), base: r.base, sha: r.sha ?? null, onto: r.head ?? null } });
331
+ }
332
+ return r;
333
+ }
334
+
335
+ /**
336
+ * Post one of the daemon's marked comments unless it is already there. The comment goes to GitHub before the
337
+ * registry is saved, so a crash in between leaves a comment, not a lost one; the check makes the retry harmless.
338
+ */
339
+ async function commentOnce(entry, body, marker, sinceIso) {
340
+ const own = await gh.issueComments(entry.repo, entry.pr, sinceIso);
341
+ if (hasOwnComment(own, ownLogin, marker, sinceIso)) return false;
342
+ await gh.comment(entry.repo, entry.pr, body);
343
+ return true;
344
+ }
345
+
346
+ /**
347
+ * The one way out of ready and back to watching. An entry is ready because a particular head was judged: checks
348
+ * green on it, the reviewer clear on it, its receipt accepted. Anything that makes that judgement no longer about
349
+ * the current state of the PR puts the entry back in pr-open with the label off, so the next tick judges again.
350
+ *
351
+ * Two things do that, and they go through here for the same reason rather than each dropping the label its own
352
+ * way: a new qualifying event (`dispatch` reopens before it spawns anything) and a new head (`headChanged` reopens
353
+ * before the entry is judged). Removal is unconditional, as in `needsHuman`: a label from an earlier daemon
354
+ * outlives the registry, and `removeLabel` already ignores a label that is not there.
355
+ *
356
+ * The write is allowed to throw, and `removeLabel` throws by default, which is the whole reason this works. Both
357
+ * callers have work after it that must not happen while the PR still says a human may merge, so a failure here is
358
+ * the whole step declining and the next tick asking again from an untouched cursor. A transient GitHub is a tick of
359
+ * nothing, not a run nobody recorded. This call used to omit the then-optional `strict` flag that its sibling in
360
+ * `writeStopLabels` passed, so a GitHub that refused the write resolved as if the label had been absent: the
361
+ * dispatch went ahead with `ready-for-human` still on the PR and `retryFailed` never saw the failure at all.
362
+ */
363
+ async function reopen(id, entry) {
364
+ if (entry.state !== STATES.ready) return;
365
+ await gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
366
+ // `gate_skipped` describes the ready state it was recorded with, so it goes as the ready state goes. Left behind,
367
+ // `status` and `why` would keep warning about a gate that did not run for a head nobody is being asked to merge.
368
+ if (entry.gate_skipped) save(id, { gate_skipped: null });
369
+ }
370
+
371
+ /**
372
+ * A head the loop did not put there: a human or a bot pushed while the entry was being watched. One place for it,
373
+ * reached by both watching states, because the bookkeeping it has to reset is the same in both and the previous
374
+ * version of this guard existed only for `ready`. That asymmetry is the shape the audits keep naming, and it cost
375
+ * `pr-open` two things. `fixed_sha` kept describing the commit the loop last acted on, so the NEW head was judged
376
+ * with the OLD head's age: the same tick that noticed the push could stop the entry as `reviewer-silent` on a wait
377
+ * that expired before the commit existed. And `rereview.nudged` stayed true, so the nudge that was spent on the
378
+ * old head was never reissued for this one, and nobody ever asked the reviewer to look at it.
379
+ *
380
+ * What resets, and why each: `fixed_sha` follows the head, as it does on a dispatch, so a later fix run is judged
381
+ * against this commit and not the one before it. The reviewer clock follows the push, because the reviewer has not
382
+ * seen this commit; `reviewerClock` is what keeps that from becoming the opposite bug, since a committer date older
383
+ * than the nudge window is floored to the window rather than taken literally (a commit made on Monday and pushed on
384
+ * Thursday would otherwise stop the entry on arrival). `nudged` goes false so the silence step asks again. Nothing
385
+ * is posted and the iteration is untouched: this was not a round of the loop's own, so the re-review is asked for
386
+ * through the nudge the silence step already owns.
387
+ *
388
+ * A `ready` entry also leaves ready, through `reopen`: its verdict, its green checks and its accepted
389
+ * receipt all describe a commit that is no longer the head, so the label saying a human may merge comes off first.
390
+ * Returns the saved entry when the head changed, `"declined"` when the label write failed, else null. A failed write
391
+ * leaves the whole judgement for the next tick rather than judging a new head with a stale ready label still on the
392
+ * PR, and it goes through `retryFailed` for the same reason the dispatch does: the third caller of the same declining
393
+ * write is the third place that would otherwise wait forever, so it counts on the same counter and stops with the
394
+ * same reason.
395
+ */
396
+ async function headChanged(id, entry, pr) {
397
+ if (entry.state !== STATES.ready && entry.state !== STATES.prOpen) return null;
398
+ if (!entry.cursor.fixed_sha || pr.headRefOid === entry.cursor.fixed_sha) return null;
399
+ const wasReady = entry.state === STATES.ready;
400
+ if (wasReady) {
401
+ try { await reopen(id, entry); }
402
+ catch (e) {
403
+ await retryFailed(id, entry, READY_LABEL_REMOVAL, e, { action: `not judging ${pr.headRefOid.slice(0, 7)} until it is off` });
404
+ return "declined";
405
+ }
406
+ entry = retryOk(id, entry);
407
+ }
408
+ const pushedAt = await headPushedAt(entry, pr.headRefOid, now());
409
+ const saved = save(id, {
410
+ state: STATES.prOpen, gate_skipped: null, retry: null,
411
+ cursor: { ...entry.cursor, fixed_sha: pr.headRefOid },
412
+ rereview: { at: askedAt(entry, pushedAt), nudged: false },
413
+ });
414
+ log.info(`${id}: head moved to ${pr.headRefOid.slice(0, 7)}${wasReady ? " after ready" : ""}; watching again`);
415
+ return saved;
416
+ }
417
+
418
+ function assertProviderHealth(entry) {
419
+ const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
420
+ if (health && !health.ok) throw Object.assign(new Error(`provider health unavailable: ${health.detail}; ${health.fix || "restore this provider account"}`), { code: "PROVIDER_UNAVAILABLE" });
421
+ }
422
+
423
+ /**
424
+ * Spawn a harness run. Resumes when a session exists, otherwise starts a fresh one and returns its id.
425
+ *
426
+ * Async because of the credential. It is resolved here, for this run, rather than once when the daemon booted: an
427
+ * operator who re-authenticates under a long-lived daemon gets the new token on the next run without restarting
428
+ * anything, and a token that expired under a daemon that has been up for a week does not silently poison every run
429
+ * after it. The cost is one `gh auth token` subprocess, and only when neither `github_token_file` nor the daemon's
430
+ * own environment answered first.
431
+ *
432
+ * A run with no credential is refused rather than spawned. It would implement the brief, commit it, and then fail
433
+ * to push, which is money spent for work nobody can reach. The throw is caught by the caller, which stops the entry
434
+ * with `no-credential` and the fix, so the operator is told rather than left reading a dead run's log.
435
+ */
436
+ async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null }) {
437
+ const assertDispatchOpen = () => {
438
+ if (dispatchDeadline != null && clock.now() >= dispatchDeadline) throw Object.assign(new Error("network recovery window expired before dispatch"), { code: "NETWORK_RECOVERY_EXPIRED" });
439
+ const current = state.get(id);
440
+ if (dispatchDeadline != null && controlPending(current?.control)) throw Object.assign(new Error("operator control superseded automatic recovery"), { code: "NETWORK_RECOVERY_SUPERSEDED" });
441
+ const stopped = current?.dispatch_stopped || entry.dispatch_stopped;
442
+ if (stopped) throw Object.assign(new Error(`dispatch stopped: ${stopped}`), { code: "EXECUTION_STOPPED" });
443
+ };
444
+ assertDispatchOpen();
445
+ assertProviderHealth(entry);
446
+ if (capabilitiesFor(entry.harness).transport === "remote") await requireVerificationSandbox();
447
+ await git.ensureHttpsRemote?.(entry.worktree);
448
+ if (entry.brief_changes?.length && name !== "resume") {
449
+ // A prior manual resnapshot may have failed after updating daemon state but before copying into the checkout.
450
+ const brief = readPrd(id);
451
+ if (entry.prd && brief != null) writeRepositoryText(entry.worktree, entry.prd, brief);
452
+ restoreSources(id, entry);
453
+ prompt = `THE BRIEF HAS CHANGED. Reread the brief and its carried files in the worktree.\n${entry.brief_changes.join("\n")}\n\n${prompt}`;
454
+ }
455
+ const execution = runnerFor(id), remote = execution.capabilities?.transport === "remote";
456
+ const mode = entry.session ? "resume" : "start";
457
+ const session = entry.session || entry.learning_continuity?.newSession || (remote ? null : adapterFor(entry.harness).newSession());
458
+ // Extra read-only directories, resolved in one place (lib/config.mjs) from the operator's config list plus the
459
+ // frontmatter of the daemon's OWN snapshot of the brief. The snapshot is what stops a session widening its
460
+ // reads by editing the PRD it was handed.
461
+ const runConfig = taskExecutionConfig(config, entry.task, execution);
462
+ const contextPaths = resolveContextPaths(runConfig, prdFor(id));
463
+ let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
464
+ // The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
465
+ // than sending it a github.com one.
466
+ const credential = await resolveCredentialFor({ config, git, credential: ctxCredential }, { worktree: entry.worktree });
467
+ if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
468
+ // The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
469
+ // long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
470
+ const identity = await requireIdentity({ identity: ctxIdentity });
471
+ // The `commit-msg` hook, on every run rather than only on a clone, and here rather than in `ensureWorktree`
472
+ // because this is the one function both paths into a session pass through: `dispatch` calls it for a fix round and
473
+ // `gallivant resume` calls it directly, skipping `ensureWorktree` altogether. `git.fromBranch` installs the hook
474
+ // itself, so a FRESH checkout was always guarded; one already on disk went through no clone path at all, which is
475
+ // every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
476
+ // because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
477
+ // it was absent. Idempotent and never fatal, so this costs a stat and cannot fail a run.
478
+ const hook = git.installHooks?.(entry.worktree);
479
+ if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); the prompt still forbids attribution but nothing enforces it this run`);
480
+ const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
481
+ const dispatchInput = { operationId: initialDispatch?.operationId ?? `${entry.last?.run || "initial"}:${entry.iteration}:${name}${entry.learning_continuity?.status === "prepared" ? ":fresh" : ""}`, sessionId: entry.session, inputHead: git.head ? await git.head(entry.worktree) : null, workflow: ownership || { state: entry.pr ? STATES.prOpen : STATES.running, reason: null, lastEvent: `recovered ${name} dispatch` } };
482
+ if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
483
+ learning_revocation: null, learning_continuity: { ...entry.learning_continuity, status: "accepted" } };
484
+ const managed = await learningInput({ ...deps, learning }, id, entry, { ...dispatchInput, name, prompt, ...(paths.length ? { paths } : {}) });
485
+ if (!remote && managed.prompt !== prompt) spec = buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt: managed.prompt, cwd: entry.worktree, contextPaths });
486
+ // Checkout/credential/head preparation may outlive the health observation.
487
+ // Recheck the shared scope immediately before provider I/O or local launch.
488
+ assertProviderHealth(entry);
489
+ let job;
490
+ if (remote) {
491
+ const input = deps.executionInputFor
492
+ ? await deps.executionInputFor(id, entry, managed, contextPaths)
493
+ : { ...managed, repo: entry.repo, branch: entry.branch, base: entry.base, allowance: entry.execution?.allowance, mandatoryPolicy: entry.execution?.mandatoryPolicy || [], environment: entry.execution?.environment, model: entry.execution?.model };
494
+ input.allowance = taskProviderAllowance(input.allowance, entry.task);
495
+ await execution.prepare(input);
496
+ assertProviderHealth(entry);
497
+ if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
498
+ try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
499
+ assertDispatchOpen();
500
+ return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
501
+ }); }
502
+ catch (error) {
503
+ if (initialDispatch && error.dispatchNotSent === true) save(id, { initial_dispatch: initialDispatch, reason: "dispatch-refused", last: { at: now(), run: null, exit: null, event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
504
+ throw error;
505
+ }
506
+ } else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
507
+ assertDispatchOpen();
508
+ return execution.start(name, spec.cmd, spec.args, { cwd: entry.worktree, runtimeSettings: spec.runtimeSettings, env: { ...runEnv(config, credential, identity, entry.worktree), ...spec.env }, execution: { ...dispatchInput, ...(entry.learning_continuity?.status === "prepared" ? { recordedSessionId: session } : {}), ...(managed.learning ? { learning: managed.learning, managedInputDigest: managed.managedInputDigest } : {}) } });
509
+ });
510
+ return { job, session: job.execution?.sessionId || session, managed };
511
+ }
512
+
513
+ /** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
514
+ const headPushedAt = (entry, sha, fallback) => gh.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
515
+
516
+ /** The reviewer clock for this entry and this head. The rule itself is `events.mjs`; `reconcile` uses the same one. */
517
+ const askedAt = (entry, pushedAt) => reviewerClock({ config, pushedAt, previousAsk: entry.rereview?.at ?? null, floorAt: now() });
518
+
519
+ /**
520
+ * The round comment: the announcement of a round that has already been counted. Called on the settle tick that
521
+ * counted it, and again by `tickEntry` on every later tick while `entry.round_comment` is still set, which is the
522
+ * `labels_pending` shape: the state has moved and one GitHub write is still owed, so the entry names the debt and a
523
+ * later tick pays it.
524
+ *
525
+ * What separating it from the count buys, precisely. `countRounds` rebuilds `iteration` from these markers after
526
+ * `state.json` is lost, so the comment is still the thing a recovery counts and it must still be posted. What it is
527
+ * no longer allowed to do is decide whether the round happened. A refused comment used to leave the push on GitHub
528
+ * and the counter unmoved; now it leaves the counter moved and the comment owed, and the debt is on the entry where
529
+ * `status`, `why` and a recovery can all see it.
530
+ *
531
+ * Idempotent through `commentOnce`, against the `since` the round was counted at rather than the current cursor: the
532
+ * cursor has already advanced past the push, so asking "is the marker there since the cursor" would answer no for a
533
+ * comment this very function had posted, and the entry would post one per tick. The boundary is therefore stored
534
+ * with the debt, in `round_comment.since`.
535
+ *
536
+ * Still the fifth caller of the bound, and still for the same reason: a token that can push but cannot comment never
537
+ * recovers, and an entry whose rounds are invisible to a recovery must not be left running in silence. What changed
538
+ * is what the stop means. It is no longer "a round happened and was not counted"; it is "the rounds are counted here
539
+ * and the PR cannot be told", which is a state a person has to resolve either way.
540
+ */
541
+ async function postRoundComment(id, entry) {
542
+ const owed = entry.round_comment;
543
+ if (!owed) return false;
544
+ const body = owed.kind === "round" ? roundComment(config) : owed.kind === "public-text" ? `Review text updated.\n\n${ROUND_MARKER}` : receiptRoundComment();
545
+ try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
546
+ catch (err) {
547
+ // The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
548
+ // mint a second time. `action` names the round that IS counted, which is the honest thing to log now: the work
549
+ // is on the branch and in the counter, and only the announcement is outstanding.
550
+ await retryFailed(id, entry, ROUND_COMMENT, err, {
551
+ action: `round ${entry.iteration} is counted; retrying the comment next tick`,
552
+ });
553
+ return false;
554
+ }
555
+ // The ask is now, and only now. `rereview.at` claims the reviewer was asked to look again, and the comment
556
+ // carrying that request is what makes the claim true, so the clock starts when it lands. Through `askedAt`, so a
557
+ // committer date older than the nudge window is floored to the window rather than taken literally.
558
+ const askPatch = owed.kind === "round" ? { rereview: { at: askedAt(entry, owed.pushed_at), nudged: false } } : {};
559
+ save(id, { round_comment: null, retry: null, ...askPatch });
560
+ log.info(`${id}: round ${entry.iteration} ${owed.kind === "round" ? "re-review requested" : "receipt update posted"}`);
561
+ return true;
562
+ }
563
+
564
+ async function stopRun(id, entry, runner, cur) {
565
+ try {
566
+ if (runner.capabilities?.transport === "remote") {
567
+ const answer = await runner.cancel("timeout");
568
+ const status = cancellationStatus(answer);
569
+ await needsHuman(id, status.reason, { last: { ...entry.last, event: `timeout: ${status.message}` } });
570
+ return false;
571
+ }
572
+ if (runner.cancel) await runner.cancel("timeout");
573
+ else runner.kill(cur.id);
574
+ return true;
575
+ }
576
+ catch (error) {
577
+ log.warn(`${id}: cannot verify stopping run ${cur.id}: ${error.message}`);
578
+ if (entry.state !== STATES.needsHuman || entry.reason !== "run-stop-unverified") {
579
+ await needsHuman(id, "run-stop-unverified", {
580
+ last: { ...entry.last, event: `Cannot verify shutdown of run ${cur.id}; it may still be running. Inspect its process tree and restore process inspection before retrying. No replacement was started.` },
581
+ });
582
+ }
583
+ return false;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Step 1: a run that ended since the last tick, or one that has run too long. Only the run this entry dispatched
589
+ * (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
590
+ * entry should wait.
591
+ */
592
+ async function settleRun(id, entry) {
593
+ const runner = runnerFor(id);
594
+ const cur = runner.current();
595
+ if (!cur) return false;
596
+ // Ownership first, before anything is decided about the run. A live run this entry did not dispatch used to
597
+ // short-circuit the whole tick: the entry waited on it for the full `run_timeout_minutes` with its feedback never
598
+ // read and nothing logged, and then the leftover was killed and the entry stopped with `reason: "timeout"` and
599
+ // `last.run` rewritten to a run it never started, so `gallivant why` printed someone else's log and cost. The
600
+ // leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
601
+ // this entry's reason.
602
+ if (entry.last?.run !== cur.id) return false;
603
+ if (cur.execution?.quarantined) {
604
+ save(id, { last: { ...entry.last, exit: cur.exit ?? 1, event: "late provider result quarantined after stop" } });
605
+ return false;
606
+ }
607
+ if (cur.running) {
608
+ if (entry.state === STATES.needsHuman && entry.reason === "run-stop-unverified"
609
+ && entry.labels_pending && entry.pr && await writeStopLabels(id, entry)) {
610
+ entry = save(id, { labels_pending: false });
611
+ }
612
+ if (cur.killed) {
613
+ await stopRun(id, entry, runner, cur);
614
+ return true;
615
+ }
616
+ if (entry.control?.stop_run === cur.id && entry.control.status === "queued") return true;
617
+ // The limit is the repository's when it set one, and the global otherwise (`resolveTimeout`), because how long
618
+ // verification takes is a property of the codebase. A run was killed at 31.2 minutes against a 30 minute global
619
+ // with its work finished and four commits on disk, in a repository whose two Verify commands take 2m30s each.
620
+ const limit = resolveTimeout({ config, repo: entry.repo });
621
+ if (entry.task?.deliveryAllowance) {
622
+ const taskLimit = taskExecutionConfig({ ...config, run_timeout_minutes: limit.minutes }, entry.task, runner, { excludeRunId: cur.id }).run_timeout_minutes;
623
+ if (taskLimit < limit.minutes) { limit.minutes = taskLimit; limit.source = "task"; }
624
+ }
625
+ if (clock.now() - cur.startedAt > minutes(limit.minutes)) {
626
+ const ranMin = Math.round((clock.now() - cur.startedAt) / 60_000);
627
+ if (!await stopRun(id, entry, runner, cur)) return true;
628
+ // What the kill interrupted, read here because SIGKILL is the one stop that records nothing of its own and
629
+ // because both readings stop being true the moment anyone touches the checkout. `needsHuman(id, "timeout")` left
630
+ // `reason: "timeout"` and an exit of 137 and nothing else, so every signal said "it timed out" and none said what
631
+ // it was killed in the middle of.
632
+ //
633
+ // BOTH are read, and the dirty tree is the half that matters. A run was killed having done a whole review round
634
+ // and written its commit message to a temp file, one tool call before `git commit`: nothing was committed, so a
635
+ // commit count alone said zero and the entry read as an ordinary timeout, while the worktree held six modified
636
+ // and four untracked files that no commit, branch or PR contained. Uncommitted work is more fragile than
637
+ // unpushed, not less, because `resume` recreates a missing checkout without asking and a `git clean` takes it.
638
+ // An entry killed with an empty tree and nothing committed is the case the timeout exists for; these two counts
639
+ // are what tell the two apart.
640
+ const stranded = entry.worktree && git.unpushed ? await git.unpushed(entry.worktree, entry.branch, entry.base ?? null).catch(() => []) : [];
641
+ const dirty = entry.worktree && git.dirty ? await git.dirty(entry.worktree).catch(() => ({ modified: 0, untracked: 0 })) : { modified: 0, untracked: 0 };
642
+ // Logged at warn, not info: this is the one line in the daemon's log that says work may be about to be lost.
643
+ if (dirty.modified || dirty.untracked) log.warn(`${id}: killed at ${ranMin}m with UNCOMMITTED work in ${entry.worktree} (${dirty.modified} modified, ${dirty.untracked} untracked). No commit holds it. Do not recreate that checkout.`);
644
+ if (stranded.length) log.info(`${id}: killed at ${ranMin}m with ${stranded.length} unpushed commit${stranded.length === 1 ? "" : "s"} on ${entry.branch}; the work exists and is not on the remote`);
645
+ await needsHuman(id, "timeout", {
646
+ last: { ...(entry.last || {}), run: cur.id, exit: 137, at: now() },
647
+ timeout: { limit: limit.minutes, source: limit.source, ran_minutes: ranMin, unpushed: stranded.length, modified: dirty.modified, untracked: dirty.untracked },
648
+ });
649
+ }
650
+ return true;
651
+ }
652
+ if (entry.last?.exit != null) return false;
653
+
654
+ const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
655
+ const outcome = runner.outcome ? runner.outcome(cur.id) : adapterFor(entry.harness).outcome(lines);
656
+ const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
657
+ const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(cur.id) : adapterFor(entry.harness).sessionFrom(lines));
658
+ if (runner.collect && outcome.finished && !cur.execution?.collected) await runner.collect();
659
+ const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
660
+
661
+ const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
662
+ if (interrupted && entry.state !== STATES.running) {
663
+ // An intentional interruption is not a failed review round or a completion.
664
+ // Account for it once, then let the normal control path resume the saved session.
665
+ save(id, { session, state: STATES.needsHuman, reason: "operator-interrupted", last });
666
+ return false;
667
+ }
668
+
669
+ const networkCode = networkFailure(outcome);
670
+ const recoverableNetwork = !interrupted && networkCode && session && job.exit !== 0 && !job.killed &&
671
+ runner.capabilities?.transport !== "remote" && !entry.dispatch_stopped && !controlPending(entry.control);
672
+ // Persist the observation before GitHub I/O. An outage during reconciliation
673
+ // must not postpone the recovery deadline or authorize an unreconciled retry.
674
+ if (recoverableNetwork) {
675
+ const previous = entry.network_retry;
676
+ if (previous && !validNetworkRetry(previous)) {
677
+ await needsHuman(id, "network-recovery-unverified", { network_retry: null, session, last: { ...last, error: "Invalid saved network retry; inspect state before explicitly resuming" } });
678
+ return true;
679
+ }
680
+ entry = save(id, { session, network_retry: { attempts: previous?.attempts ?? 0,
681
+ first_at: previous?.first_at ?? now(), code: networkCode, run: cur.id,
682
+ phase: "reconciling", next_at: null } });
683
+ }
684
+ // Reconcile published progress before retrying. A terminal network error
685
+ // does not undo a push or let its paid review round escape accounting.
686
+ const scheduleNetworkRecovery = async () => {
687
+ if (!recoverableNetwork) return false;
688
+ const previous = entry.network_retry;
689
+ const attempts = previous?.attempts ?? 0;
690
+ const firstAt = previous?.first_at ?? now();
691
+ const exhausted = attempts >= NETWORK_RETRY_DELAYS.length || clock.now() - Date.parse(firstAt) >= NETWORK_RECOVERY_WINDOW;
692
+ const network_retry = { attempts, first_at: firstAt, code: networkCode, run: cur.id,
693
+ phase: exhausted ? "exhausted" : "waiting",
694
+ next_at: exhausted ? null : new Date(clock.now() + NETWORK_RETRY_DELAYS[attempts]).toISOString() };
695
+ // Failure text may contain a proxy URL or credentials. Keep only the
696
+ // recognized code in operator-facing state; the owned log retains detail.
697
+ const patch = { session, network_retry, last: { ...last, error: `${networkCode}: provider connectivity failed` } };
698
+ if (exhausted) await needsHuman(id, "provider-network-exhausted", patch);
699
+ else {
700
+ save(id, { ...patch, state: STATES.needsHuman, reason: "provider-network", retry: null });
701
+ log.warn(`${id}: ${networkCode}; same-session recovery scheduled at ${network_retry.next_at}`);
702
+ }
703
+ return true;
704
+ };
705
+ // A completed success or a different failure ends this consecutive network
706
+ // streak. Never let old deadlines silently restart a later stopped task.
707
+ if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
708
+
709
+ if (entry.state === STATES.running) {
710
+ const prs = await gh.prListByHead(entry.repo, entry.branch);
711
+ let pr = (prs || []).find(p => !p.isCrossRepository);
712
+ if (!pr) {
713
+ if (await scheduleNetworkRecovery()) return false;
714
+ if (interrupted) {
715
+ save(id, { session, state: STATES.needsHuman, reason: "operator-interrupted", last });
716
+ return false;
717
+ }
718
+ // What the run left behind, on the stop that most often has something to leave. A start run that ends with no
719
+ // pull request has usually done work: this stop was reached by a run that produced a 33-file research package,
720
+ // passed both of its Verify commands, and never committed, and the entry recorded only `no-pr`. An operator
721
+ // reading that has no reason to look in the checkout, and the next `resume` cannot merge the base past the
722
+ // files nobody knows are there.
723
+ //
724
+ // The same reading the timeout kill already does (`git.dirty` plus `git.unpushed`), for the same reason: the
725
+ // counts are the difference between "it did nothing" and "it did everything but the last step".
726
+ const tree = entry.worktree && git.dirty ? await git.dirty(entry.worktree).catch(() => null) : null;
727
+ const left = tree && (tree.modified || tree.untracked)
728
+ ? `; ${[tree.modified && `${tree.modified} modified`, tree.untracked && `${tree.untracked} untracked`].filter(Boolean).join(" and ")} file${tree.modified + tree.untracked === 1 ? "" : "s"} are uncommitted in ${entry.worktree}`
729
+ : "";
730
+ await needsHuman(id, "no-pr", { session, last: { ...last, event: `${last.event}${left}` }, ...(tree ? { tree } : {}) });
731
+ return false;
732
+ }
733
+ // The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
734
+ // rebuilds an entry from once `state.json` is gone (`up.mjs` reads the harness, the session and the PRD path out
735
+ // of it), so an entry that reaches `pr-open` with no block is a PR the advertised recovery will skip: the
736
+ // session it names cannot be resumed and the operator's own `register` is the only way back.
737
+ //
738
+ // `register`'s equivalent throws, because `register` is one interactive command whose operator can fix the cause
739
+ // and rerun it. The tick has no operator and must not abandon an entry, so the failure has to leave a state the
740
+ // NEXT TICK retries from, and `running` with `last.exit` still null is exactly that state: `settleRun` re-enters
741
+ // this branch on the next pass (its early return is `entry.last?.exit != null`, which is registry state and not
742
+ // the job's), re-reads the PR and writes the block again. The session is persisted on the way out so a retry
743
+ // never mints a second one, and `runner.settle` has already recorded the process exit and is idempotent, so
744
+ // nothing spawns and nothing is re-settled. It retries rather than spins because the only work it repeats is
745
+ // two GitHub reads and one write; when the write lands the entry moves on in that same pass, and while it does
746
+ // not, `status` shows the entry still starting and `errors.log` names the cause on every attempt.
747
+ //
748
+ // Retrying is not the same as retrying forever. `retryFailed` counts the consecutive failures of this one write
749
+ // on the entry and stops it with `stuck` once they cross `MAX_STEP_FAILURES`, so a permissions problem that will
750
+ // never resolve (a token that can read a PR but not write its body) rings the operator's phone instead of holding
751
+ // the PR unwatched in silence. The session is saved either way, so neither the retry nor the stop loses it.
752
+ try {
753
+ const identity = recoveryIdentity(entry, session);
754
+ if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
755
+ pr = await gh.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
756
+ }
757
+ }
758
+ catch (err) {
759
+ await retryFailed(id, save(id, { session }), `PR #${pr.number} block write`, err, {
760
+ action: "staying in running and retrying next tick",
761
+ // Only on the stop, never on a retry: a retrying entry must not claim to watch a PR whose block is still
762
+ // missing (the existing invariant), but a stopped one names the PR so the notification and `why` can link it.
763
+ onStop: { pr: pr.number, url: pr.url },
764
+ });
765
+ return false;
766
+ }
767
+ // Events are new from the PR's creation, so a review that landed while the start run was still going is seen.
768
+ // `retry` is cleared here rather than through `retryOk`: leaving `running` IS this write succeeding, and one save
769
+ // for the transition beats two.
770
+ save(id, {
771
+ state: STATES.prOpen, session, pr: pr.number, url: pr.url, last, retry: null, network_retry: null,
772
+ cursor: { since: pr.createdAt, dispatched: [], fixed_sha: pr.headRefOid, checks_sha: null, receipt_hash: receiptHash(pr.body) },
773
+ rereview: { at: pr.createdAt, nudged: false },
774
+ });
775
+ log.info(`${id}: PR #${pr.number} open`);
776
+ return false;
777
+ }
778
+
779
+ // A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
780
+ const pr = await gh.prView(entry.repo, entry.pr);
781
+ const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
782
+ const hash = receiptHash(pr.body);
783
+ const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
784
+ const publicComments = entry.public_fix ? await currentPublicComments(entry) : [];
785
+ const reworded = entry.public_fix && publicReviewHash(pr, publicComments) !== entry.public_fix;
786
+ if (entry.public_fix && !pushed && !rewrote && !reworded) {
787
+ if (await scheduleNetworkRecovery()) return false;
788
+ await needsHuman(id, "public-format", { session, public_fix: null, last: { ...last, event: "public text correction made no change; inspect the PR wording before resuming" } });
789
+ return false;
790
+ }
791
+ if (pushed || rewrote || reworded) {
792
+ // A receipt/wording edit does not acknowledge feedback received while the run was active. Keep its event
793
+ // boundary and dispatched keys so those findings reach the next run. Public-wording runs preserve pending
794
+ // feedback even if they pushed, because they were not assigned that feedback in the first place.
795
+ const preserveFeedback = !pushed || !!entry.public_fix;
796
+ // Normal pushed rounds still move to the push, floored to GitHub's timestamp precision; feedback between
797
+ // the push and settlement stays new. Only a missing push timestamp uses settlement as its fallback.
798
+ const pushedAt = pushed ? await headPushedAt(entry, pr.headRefOid, now()) : null;
799
+ const since = floorSecond(pushedAt);
800
+ // The round is counted HERE, before the comment is attempted, and this ordering is the whole point. What
801
+ // happened is a push: a harness run spent the operator's money and moved the branch on GitHub, and no later
802
+ // failure can un-spend it. The comment is an announcement of that fact, and an announcement that will not send
803
+ // does not make the fact untrue. The comment used to be the precondition of counting, which meant a refused
804
+ // comment left the push on GitHub, the fix in the branch and `iteration` unchanged: the counter and the work
805
+ // disagreed, and since `max_iterations` is the only bound on what one PR may spend, an undercount is a bound
806
+ // that can be exceeded. Driven against the previous tree: an entry one round below the cap pushed its third
807
+ // round, the comment was refused five times, and the entry stopped saying it had done two.
808
+ //
809
+ // So the local write goes first and the GitHub write goes last, which is the rule round seven derived: order a
810
+ // pair so the irreversible half is last. Here the irreversible half already happened before this function was
811
+ // called, so both remaining writes are recoverable and the one that can be replayed exactly (the comment, which
812
+ // `commentOnce` makes idempotent against the marker) is the one left to a later tick.
813
+ // `kind` decides which comment is owed and whether landing it starts the reviewer clock. `pushed_at` goes with a
814
+ // pushed round only, because it is the committer date the clock is dated from and a receipt round has no push to
815
+ // date it from; storing it on both would make the field mean two things.
816
+ // GitHub truncates comment timestamps to whole seconds and marker lookup is strictly after its boundary.
817
+ // Include the run-start second in both the REST fetch and local match, while excluding the prior second.
818
+ const announcementSince = preserveFeedback ? fetchFrom(entry.last.at) : entry.cursor.since;
819
+ const owed = pushed ? { kind: "round", since: announcementSince, pushed_at: pushedAt } : { kind: reworded ? "public-text" : "receipt", since: announcementSince };
820
+ entry = save(id, {
821
+ session, last, public_fix: null, network_retry: null, iteration: entry.iteration + 1,
822
+ // Recorded as unsent, cleared the moment it lands. Same shape as `labels_pending`, which a stopped entry
823
+ // carries for the same reason: the state has moved and one GitHub write is still owed, so the entry names the
824
+ // debt and a later tick pays it. `retry` is NOT cleared here, because nothing has been written to GitHub yet;
825
+ // it is cleared by the write landing, in `postRoundComment`.
826
+ round_comment: owed,
827
+ // `advanceCursor` refuses a candidate before the cursor, so a force-push to an older commit cannot drag the
828
+ // boundary back and make the event that started this round new again.
829
+ // The announcement boundary above is separate from preserved feedback: a marker from before this run
830
+ // cannot pay its new round debt merely because the feedback cursor intentionally stayed behind it.
831
+ cursor: { ...(preserveFeedback ? entry.cursor : advanceCursor(entry.cursor, since)), fixed_sha: pr.headRefOid, receipt_hash: hash },
832
+ // The re-review is asked by the comment, not by this save, so the clock starts only once the comment lands
833
+ // (`postRoundComment` writes it). Until then the entry keeps the ask it already had, which is older, so the
834
+ // silence timer cannot be reset by a request that was never made.
835
+ });
836
+ log.info(`${id}: round ${entry.iteration} ${pushed ? "pushed" : reworded ? "updated the public review text" : "updated the completion receipt"}`);
837
+ // The comment is NOT attempted here. It is attempted by the one step in `tickEntry` that owns it, which runs a
838
+ // few lines later on this same pass, so the usual case is still one tick and there is still exactly one place
839
+ // that posts this comment. Attempting it here as well cost the bound half its budget: two attempts on the tick
840
+ // that counted the round, so five consecutive refusals arrived after three ticks rather than five.
841
+ return false;
842
+ }
843
+ // A run that pushed nothing has not necessarily failed. Resuming an entry whose work was ALREADY pushed gives a
844
+ // session nothing to do, and a session that correctly reports "everything is up to date" is the expected outcome of
845
+ // that resume rather than a stop.
846
+ //
847
+ // Seen on a live run. The reviewer's clean verdict arrived as a comment the old code could not read, so the entry
848
+ // looked stale and was resumed. The session found its work already on the branch at the head the cursor names,
849
+ // changed nothing, and exited 0. `settleRun` then judged it by the only question it asked, "did the head move or the
850
+ // receipt change", and stopped the entry `no-push` on a pull request that was ready to merge. `needs-human` is
851
+ // terminal, so nothing ever asked the readiness question afterwards.
852
+ //
853
+ // The entry goes back to the watched state rather than being declared ready here. Readiness is one specific step's
854
+ // decision, with its own checks, reviewer verdict and completion gate, and duplicating any part of that judgement
855
+ // in the settle path is how two places come to disagree about when a PR is done. This says only what it knows: the
856
+ // run is settled, nothing regressed, and the entry is still worth watching. The next tick judges it.
857
+ if (await scheduleNetworkRecovery()) return false;
858
+ if (outcome.ok) {
859
+ save(id, { state: STATES.prOpen, session, last, retry: null });
860
+ log.info(`${id}: the run pushed nothing and nothing regressed; back to watching so readiness is judged next tick`);
861
+ return false;
862
+ }
863
+ await needsHuman(id, "harness-error", { session, last: { ...last, error: outcome.error } });
864
+ return false;
865
+ }
866
+
867
+ /**
868
+ * Spawn a fix run and move the cursor. `since` is the event boundary that was actually read, never the time the
869
+ * spawn finished: `ensureWorktree` can spend minutes cloning, and anything created in that window would otherwise
870
+ * fall behind the cursor and never be seen again. The caller passes the newest dispatched event's timestamp, or
871
+ * the instant captured before the GitHub reads when there are no events.
872
+ *
873
+ * Because that boundary is inclusive (GitHub timestamps are only accurate to the second), the keys of the events
874
+ * dispatched at exactly that second are recorded alongside it. A second event stamped the same second is read
875
+ * again next tick and dispatched then; the ones already handled are recognised by key and skipped.
876
+ */
877
+ async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
878
+ const { name, events, denials = null, summary, since, publicFix = null } = spec;
879
+ if (entry.dispatch_stopped) return;
880
+ const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
881
+ if (health && !health.ok) {
882
+ const refreshed = deps.providerHealth?.whenRefreshed?.(entry.harness, config.harness[entry.harness]?.account || "operator");
883
+ if (deferredDispatches && refreshed) deferredDispatches.push({ id, entry, pr, spec, refreshed });
884
+ log.info(`${id}: dispatch waiting for its provider/account health`); return;
885
+ }
886
+ if (capabilitiesFor(entry.harness).transport === "remote") {
887
+ try { await requireVerificationSandbox(); }
888
+ catch (error) {
889
+ await needsHuman(id, "verification-sandbox-unavailable", { last: { ...entry.last, event: error.message } });
890
+ return;
891
+ }
892
+ }
893
+ // A checkout that cannot be recreated is not something the next poll will fix: a revoked token, a full disk, a
894
+ // branch deleted on the remote, an unmounted worktrees volume. Left to the per-entry catch in `tick` it logged one
895
+ // line to errors.log every `poll_seconds` forever, with the state, the cursor and the iteration untouched and
896
+ // nothing notified, because `notify.error` is only ever reached from the daemon's probe loop. Same treatment as
897
+ // the context_paths refusal below: stop, name the cause, ring once.
898
+ try { await ensureWorktree(id, entry); }
899
+ catch (e) {
900
+ await needsHuman(id, "no-worktree", { last: { at: now(), event: `cannot recreate ${entry.worktree}: ${e.message}` } });
901
+ return;
902
+ }
903
+ // The base branch, brought in before this round starts. It is here rather than further down for two reasons: the
904
+ // worktree has to exist first, and the prompt below has to be able to tell the session what the merge brought in.
905
+ // Nothing on GitHub moves, so this is safe to do before the label write and before the spawn, and the record of it
906
+ // is written by `mergeBaseInto` itself rather than by the save at the end of this function, which four early
907
+ // returns below can skip.
908
+ //
909
+ // A conflict stops the entry, naming the base. This is a stop rather than a retry because a conflict does not
910
+ // resolve on its own: the next tick would try the same merge against the same two commits and abort again, forever.
911
+ // `git.mergeBase` has already run `git merge --abort`, so the worktree is clean and the session that is not spawned
912
+ // was never handed a tree with conflict markers in it, which is the failure this ordering exists to prevent.
913
+ const merged = await mergeBaseInto(id, entry);
914
+ if (merged?.conflict) {
915
+ log.warn(`${id}: ${entry.branch} could not be merged with origin/${merged.base}: ${merged.why}`);
916
+ await needsHuman(id, "base-conflict", {
917
+ base_merge: { at: now(), base: merged.base, conflict: true, why: merged.why },
918
+ last: { at: now(), event: `cannot merge origin/${merged.base} into ${entry.branch}: ${merged.why}` },
919
+ });
920
+ return;
921
+ }
922
+ // `mergeBaseInto` saved the merge, so the entry in hand is one read behind whenever it merged. Re-read rather than
923
+ // patch it here: every later step in this function reads `entry`, and one of them writing a stale `base_merge` back
924
+ // would undo the record that was just made.
925
+ if (merged?.merged) entry = state.get(id) ?? entry;
926
+ const fix = { repo: entry.repo, pr: entry.pr, headSha: pr.headRefOid, events, receipt: receiptFor(id), denials, merged: merged?.merged ? merged : null };
927
+ const prompt = entry.session
928
+ ? fixPrompt({ ...fix, verify: verifyFor(id) })
929
+ : `${registerPrompt({ repo: entry.repo, pr: entry.pr })}\n\n${fixPrompt({ ...fix, verify: [] })}`;
930
+ // The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
931
+ // above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
932
+ // process running that the registry does not know about is the worst state this function can leave: `settleRun`
933
+ // reads it as an unrelated leftover and ignores it, the cursor never records the feedback that caused it, and
934
+ // when it exits the same feedback spawns a second fixer against a tree the first one is still editing.
935
+ //
936
+ // The ready label is what used to sit in that gap. `reopen` is a GitHub write, so a rate-limited or briefly
937
+ // unreachable GitHub threw after the process was already going and the save below never ran. It now happens
938
+ // before the spawn: a failure here is the same tick doing nothing, which the next tick repeats from an untouched
939
+ // cursor, and the label the removal was for is only wrong for as long as the entry is not yet dispatching.
940
+ //
941
+ // And the wait is bounded. Declining the dispatch is right, but declining it forever is not: a token that cannot
942
+ // write labels at all never recovers, and the entry would sit in `ready` with feedback waiting and a `log.warn` per
943
+ // tick as its only trace. `retryFailed` counts the consecutive failures of this write on the entry and stops it with
944
+ // `stuck` at `MAX_STEP_FAILURES`, through the same mechanism the block write uses.
945
+ try { await reopen(id, entry); }
946
+ catch (e) {
947
+ await retryFailed(id, entry, READY_LABEL_REMOVAL, e, { action: `not dispatching ${summary} until it is off` });
948
+ return;
949
+ }
950
+ // Reaching here means the write is not what is holding this entry back, whether `reopen` wrote anything or was a
951
+ // no-op for a `pr-open` entry, so the count goes. `retryOk` writes nothing when there is nothing to clear.
952
+ entry = retryOk(id, entry);
953
+ // A harness that refuses the configured context_paths will refuse identically on every tick, so retrying is a
954
+ // loop, not a recovery. Stop the entry and name the reason instead: the fix is an operator edit to the config or
955
+ // to the brief's harness, and nothing the daemon can do on its own.
956
+ let started;
957
+ const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
958
+ cursor: { ...advanceCursor(entry.cursor, since, events.filter(e => e.id != null && sameInstant(e.at, since)).map(eventKey)), fixed_sha: pr.headRefOid, receipt_hash: receiptHash(pr.body), ...(events.some(e => e.kind === "check") ? { checks_sha: pr.headRefOid } : {}) } };
959
+ try { started = await startRun(id, entry, { name, prompt, ownership, paths: events.map(e => e.path).filter(Boolean) }); }
960
+ catch (e) {
961
+ if (e.dispatchNotSent === true) {
962
+ await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
963
+ return;
964
+ }
965
+ // One run per entry is the lock, and the runner enforces it. A leftover run this entry never dispatched can be
966
+ // holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
967
+ // dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
968
+ // leftover ends. The cursor is not moved, so the same feedback is dispatched on the tick that finds it free.
969
+ if (e.code === "BUSY") { log.info(`${id}: ${e.message}; dispatching ${summary} once it is free`); return; }
970
+ if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
971
+ // No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
972
+ // also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
973
+ if (e.code === "NO_CREDENTIAL") { await needsHuman(id, "no-credential", { last: { at: now(), event: e.message.split("\n")[0] } }); return; }
974
+ // No git identity is the same shape as no credential and needed saying separately, because it reached here and
975
+ // fell through. `startRun` calls `requireIdentity`, which throws `NO_IDENTITY`; unhandled, it landed in the
976
+ // per-entry catch in `tick`, which only logs. State and cursor untouched means the same feedback is redispatched
977
+ // every `poll_seconds` forever with nothing notified, which is precisely the failure the `no-worktree` stop above
978
+ // was added to end. Stop it, ring once, and name the two commands that fix it: the operator runs them and
979
+ // `gallivant resume` picks the entry back up. `NO_IDENTITY`'s own message is those commands, so the whole of it
980
+ // goes on `last.event` rather than the first line, which on its own would say a thing is wrong and not what to do.
981
+ if (e.code === "NO_IDENTITY") { await needsHuman(id, "no-identity", { last: { at: now(), event: e.message } }); return; }
982
+ if (!/context_paths/.test(e.message)) throw e;
983
+ await needsHuman(id, "context-paths", { last: { at: now(), event: e.message } });
984
+ return;
985
+ }
986
+ // No await between the spawn above and this save: the run is recorded, then anything else may fail.
987
+ const { job, session, managed } = started;
988
+ const dispatchedChecks = events.some(e => e.kind === "check");
989
+ state.update(id, current => ({
990
+ ...learningDeliveryPatch(current, managed, job), updated_at: now(),
991
+ state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
992
+ // The cursor and the keys handled at it move together: everything dispatched in the boundary second is
993
+ // remembered, so an event sharing that second is still read next tick but never sent a second time.
994
+ cursor: { ...advanceCursor(entry.cursor, since, events.filter(e => e.id != null && sameInstant(e.at, since)).map(eventKey)), fixed_sha: pr.headRefOid, receipt_hash: receiptHash(pr.body), ...(dispatchedChecks ? { checks_sha: pr.headRefOid } : {}) },
995
+ last: { at: now(), run: job.id, exit: null, event: summary },
996
+ }));
997
+ await learningDelivered({ ...deps, learning }, id);
998
+ log.info(`${id}: dispatched (${summary})`);
999
+ }
1000
+
1001
+ /** A fix round, or a stop when the round budget is spent. */
1002
+ async function fixOrStop(id, entry, pr, spec, deferredDispatches = null) {
1003
+ if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
1004
+ await dispatch(id, entry, pr, spec, deferredDispatches);
1005
+ }
1006
+
1007
+ /**
1008
+ * "Green" for a head that has no checks yet: GitHub reports an empty rollup for the first seconds after a push,
1009
+ * before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
1010
+ */
1011
+ async function checksSettled(entry, pr) {
1012
+ if ((pr.statusCheckRollup || []).length) return isGreen(pr.statusCheckRollup);
1013
+ const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
1014
+ return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
1015
+ }
1016
+
1017
+ // Integration is an external observation, never merge authority. Reuse the
1018
+ // readiness proof and existing reviewer/check predicates; an early merge or
1019
+ // changed head/receipt/review cannot convert an unqualified delivery to done.
1020
+ async function terminalCompletion(entry, pr) {
1021
+ if (!entry.task?.completion) return {};
1022
+ const terminal = { adapter: "github-pull-request", state: pr.state, repository: entry.repo, pr: entry.pr,
1023
+ head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
1024
+ const evidence = { ...entry.completionEvidence, terminal };
1025
+ if (pr.state === "MERGED") {
1026
+ const delivery = evidence.delivery;
1027
+ const readyAt = Date.parse(delivery?.observedAt), mergedAt = Date.parse(terminal.mergedAt);
1028
+ let qualified = false, reason = "Integration was observed without a matching canonical ready decision.";
1029
+ if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
1030
+ delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
1031
+ Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
1032
+ const [reviews, inline, comments] = await Promise.all([
1033
+ gh.reviews(entry.repo, entry.pr),
1034
+ gh.inlineComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1035
+ gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1036
+ ]);
1037
+ const verdict = reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
1038
+ const pending = deriveEvents({ pr, reviews, inline, comments, entry, trust }).events;
1039
+ const publicIssues = [...publicPrViolations(pr), ...publicCommentViolations(ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]))];
1040
+ qualified = await checksSettled(entry, pr) && ["clear", "none"].includes(verdict.verdict) && !pending.length && !publicIssues.length;
1041
+ reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
1042
+ }
1043
+ evidence.integration = { ...terminal, qualified, reason };
1044
+ }
1045
+ return { completionEvidence: evidence };
1046
+ }
1047
+
1048
+ async function reconcileResults(id, entry, execution, { readOnly = false } = {}) {
1049
+ const run = execution.current();
1050
+ const stopPublication = async () => {
1051
+ const checkpoint = executionResultCheckpoint(execution, run);
1052
+ if (checkpoint?.phase !== "blocked") return false;
1053
+ // Collection may have settled (or another writer advanced) the run after
1054
+ // this tick's snapshot. Persist only the same run's current provider state.
1055
+ const settled = execution.current();
1056
+ if (settled?.id !== run.id) return true;
1057
+ entry = state.get(id) || entry;
1058
+ if (entry.last?.run !== run.id || entry.dispatch_stopped) return true;
1059
+ if (entry.state !== STATES.needsHuman || entry.reason !== "result-verification" || entry.last?.exit == null) {
1060
+ const outcome = execution.outcome(run.id);
1061
+ await needsHuman(id, "result-verification", {
1062
+ execution: settled.execution, session: entry.session || execution.sessionFrom(run.id),
1063
+ last: { ...entry.last, at: now(), exit: settled.exit ?? (outcome.ok ? 0 : 1), cost: outcome.cost, denials: outcome.denials,
1064
+ event: `result publication blocked (${checkpoint.reason}); inspect result-application.json, result.json and any verification.json, then explicitly resume for correction` },
1065
+ });
1066
+ }
1067
+ return true;
1068
+ };
1069
+ if (!run?.execution?.quarantined && await stopPublication()) return true;
1070
+ const savedResult = run && execution.result?.(run.id);
1071
+ const publicationPending = savedResult?.outcome?.ok === true && (savedResult.files || savedResult.pushedHead) && readJson(join(execution.dirOf(run.id), "result-application.json"))?.phase !== "published";
1072
+ if (!run?.execution?.quarantined && (run?.execution?.providerState === "retrieving-results" || publicationPending) && execution.capabilities?.transport === "remote") {
1073
+ if (readOnly) return false;
1074
+ try {
1075
+ const result = await execution.collect();
1076
+ // Transports return artifacts or an exact pushed head. Only this shared
1077
+ // boundary may materialize and publish them into the normal review loop.
1078
+ if (result?.outcome?.ok === true && (result.files || result.pushedHead)) {
1079
+ entry = state.get(id) || entry;
1080
+ const published = await reconcileExecutionResult(deps, { id, entry: { ...entry, execution: run.execution }, result, dir: execution.dirOf(run.id) });
1081
+ save(id, { pr: published.pr.number, url: published.pr.url });
1082
+ }
1083
+ }
1084
+ catch (error) {
1085
+ log.warn(`${id}: results unresolved (${error.code || "collection failed"})`);
1086
+ if (["EXECUTION_RESULT", "EXECUTION_RESULT_BLOCKED"].includes(error.code) && await stopPublication()) return true;
1087
+ return false;
1088
+ }
1089
+ }
1090
+ return true;
1091
+ }
1092
+
1093
+ async function recoverNetwork(id, entry) {
1094
+ const retry = entry.network_retry;
1095
+ if (retry && !validNetworkRetry(retry)) {
1096
+ await needsHuman(id, "network-recovery-unverified", { network_retry: null });
1097
+ return true;
1098
+ }
1099
+ if (retry?.phase === "reconciling") return true; // Settlement must reconcile published work first.
1100
+ if (!retry || retry.phase === "running" || retry.phase === "exhausted" || retry.phase === "blocked") return false;
1101
+ if (entry.dispatch_stopped || entry.state === STATES.done || controlPending(entry.control)) return true;
1102
+ const runner = runnerFor(id), run = runner.current();
1103
+ if (retry.phase === "launching" || !run || run.id !== retry.run || run.running || entry.last?.run !== retry.run || entry.last.exit == null) {
1104
+ await needsHuman(id, "network-recovery-unverified", { network_retry: { ...retry, phase: "blocked" } });
1105
+ return true;
1106
+ }
1107
+ if (clock.now() - Date.parse(retry.first_at) >= NETWORK_RECOVERY_WINDOW) {
1108
+ await needsHuman(id, "provider-network-exhausted", { network_retry: { ...retry, phase: "exhausted", next_at: null } });
1109
+ return true;
1110
+ }
1111
+ if (clock.now() < Date.parse(retry.next_at)) return true;
1112
+ if (entry.iteration >= config.max_iterations) {
1113
+ await needsHuman(id, "iterations", { network_retry: { ...retry, phase: "blocked" } });
1114
+ return true;
1115
+ }
1116
+ const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
1117
+ if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
1118
+ if (entry.pr) {
1119
+ const pr = await gh.prView(entry.repo, entry.pr);
1120
+ if (pr.state !== "OPEN") { save(id, { state: STATES.done, network_retry: null }); return true; }
1121
+ }
1122
+ // Fence before any await that might submit input. A crash in this interval
1123
+ // requires inspection; guessing whether a paid run started is unsafe.
1124
+ save(id, { network_retry: { ...retry, phase: "launching", attempts: retry.attempts + 1 } });
1125
+ try {
1126
+ const { resume } = await import("./commands/resume.mjs");
1127
+ await resume(deps, { handle: id, automaticNetworkRecovery: true,
1128
+ note: `The previous invocation ended with ${retry.code}. Continue the same task from the saved session. Inspect existing files, commits and PR state before acting; do not repeat completed external actions. All original permissions, budgets and verification requirements still apply.` });
1129
+ } catch (error) {
1130
+ // A post-launch bookkeeping failure must not authorize a duplicate run.
1131
+ const current = state.get(id);
1132
+ if (current.dispatch_stopped) return true; // Keep cancellation authoritative.
1133
+ if (current.last?.run !== retry.run) return true;
1134
+ if (error.code === "NETWORK_RECOVERY_SUPERSEDED") {
1135
+ save(id, { network_retry: null }); // Leave the queued operator input for its normal consumer.
1136
+ return true;
1137
+ }
1138
+ if (error.code === "NETWORK_RECOVERY_EXPIRED") {
1139
+ await needsHuman(id, "provider-network-exhausted", { network_retry: { ...current.network_retry, phase: "exhausted", next_at: null } });
1140
+ return true;
1141
+ }
1142
+ log.warn(`${id}: automatic network resume refused; inspect the saved run and resume prerequisites`);
1143
+ await needsHuman(id, "network-recovery-blocked", { network_retry: { ...current.network_retry, phase: "blocked" },
1144
+ last: { ...current.last, event: "network recovery could not pass the normal resume checks; run gallivant doctor and inspect the brief/worktree before explicitly resuming" } });
1145
+ }
1146
+ return true;
1147
+ }
1148
+
1149
+ async function tickEntry(id, entry, { skipReconciliation = false, deferredDispatches = null, settleOnly = false } = {}) {
1150
+ if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
1151
+ if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
1152
+ if (await cancelControl(deps, id)) return;
1153
+ if (!settleOnly && await recoverControl(deps, id)) return;
1154
+ const execution = runnerFor(id);
1155
+ if (execution.reconcile) {
1156
+ if (!skipReconciliation) await execution.reconcile({ force: startupReconciliation.delete(id) });
1157
+ const pending = execution.current();
1158
+ if (entry.reason === "dispatch-intent" && pending?.execution?.acceptance === "accepted" &&
1159
+ pending.execution.operationId === entry.execution?.operationId && !pending.execution.quarantined) {
1160
+ entry = save(id, { state: STATES.running, reason: null, initial_dispatch: null, session: pending.execution.sessionId, execution: pending.execution,
1161
+ ...learningDeliveryPatch(entry, pending.execution, pending),
1162
+ last: { at: now(), run: pending.id, exit: null, event: "start recovered" } });
1163
+ }
1164
+ if (!await reconcileResults(id, entry, execution, { readOnly: skipReconciliation })) return;
1165
+ const run = execution.current();
1166
+ if (run?.execution) {
1167
+ entry = save(id, { execution: run.execution, ...(!entry.session && run.execution.acceptance === "accepted" && run.execution.sessionId ? { session: run.execution.sessionId } : {}) });
1168
+ const terminal = !run.running && run.execution.collected && run.execution.cancellation?.status === "terminal" &&
1169
+ ["succeeded", "failed"].includes(run.execution.providerState);
1170
+ if (entry.reason === "cancellation-requested" && (run.execution.providerState === "cancelled" || terminal) &&
1171
+ run.id === (entry.control?.action === "cancel" ? (entry.control.stop_run ?? entry.last?.run) : entry.last?.run)) {
1172
+ entry = save(id, { reason: "cancelled", ...(entry.control?.action === "cancel" && entry.control.status === "applied" ? {
1173
+ control: { ...entry.control, message: cancellationStatus({ confirmed: !terminal, terminal }).message, finished_at: nowIso(clock) },
1174
+ } : {}) });
1175
+ }
1176
+ }
1177
+ }
1178
+ if (entry.state === STATES.done) { if (!settleOnly) await applyControl(deps, id); return; }
1179
+ if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
1180
+ if (interruptControl(deps, id)) {
1181
+ await needsHuman(id, "run-stop-unverified");
1182
+ return;
1183
+ }
1184
+ entry = state.get(id);
1185
+ if (await settleRun(id, entry)) return;
1186
+ if (completed(id, state.get(id))) return;
1187
+ // Phase-bounded live campaigns reuse settlement/publication, but must not
1188
+ // authorize another paid review or queued resume in the same observation.
1189
+ if (settleOnly) return;
1190
+ if (await applyControl(deps, id)) return;
1191
+ entry = state.get(id);
1192
+ if (entry && await recoverNetwork(id, entry)) return;
1193
+ if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
1194
+ if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
1195
+ if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
1196
+
1197
+ let pr = await gh.prView(entry.repo, entry.pr);
1198
+ if (pr.state === "MERGED" || pr.state === "CLOSED") { save(id, { state: STATES.done, ...await terminalCompletion(entry, pr) }); log.info(`${id}: ${pr.state.toLowerCase()}`); return; }
1199
+ // Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
1200
+ // draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
1201
+ if (entry.state === STATES.needsHuman && entry.reason === "draft") {
1202
+ entry = save(id, { state: STATES.prOpen, reason: null, retry: null });
1203
+ }
1204
+ if (entry.state === STATES.needsHuman) {
1205
+ // The one thing a stopped entry is still asked to do: finish the label write that failed when it stopped, so the
1206
+ // PR stops saying the opposite of the registry. Nothing else about the entry changes, and once it lands the
1207
+ // entry is left alone again.
1208
+ if (entry.labels_pending || hasLegacyStatusLabels(pr.labels)) {
1209
+ const written = await writeStopLabels(id, entry);
1210
+ save(id, { labels_pending: !written });
1211
+ }
1212
+ return;
1213
+ }
1214
+ try {
1215
+ const identity = recoveryIdentity(entry);
1216
+ if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
1217
+ const beforeReceipt = receiptHash(pr.body);
1218
+ const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
1219
+ pr = await gh.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
1220
+ if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
1221
+ }
1222
+ if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
1223
+ } catch (err) {
1224
+ await retryFailed(id, entry, PUBLIC_METADATA, err, { action: "retrying the structured metadata migration next tick" });
1225
+ return;
1226
+ }
1227
+ if (pr.isDraft) {
1228
+ try { await gh.markReady(entry.repo, entry.pr); }
1229
+ catch (err) {
1230
+ await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
1231
+ return;
1232
+ }
1233
+ entry = retryOk(id, entry);
1234
+ // Observe the write next pass. Give automatic review its normal window after ready_for_review, instead of
1235
+ // judging silence that accrued while the PR was still a draft. No acceptance criterion has been waived.
1236
+ save(id, { rereview: { at: floorSecond(now()), nudged: false } });
1237
+ log.info(`${id}: marked PR #${entry.pr} ready for review; completion checks remain required`);
1238
+ return;
1239
+ }
1240
+ if (pr.mergeable === "CONFLICTING") { await needsHuman(id, "conflict"); return; }
1241
+
1242
+ // The base this pull request actually targets, from the PR rather than from anything remembered. Two cases need it.
1243
+ // An entry written before `base` existed has none, and would never merge anything; and a PR someone retargeted (a
1244
+ // reviewer moving it from `main` to `staging`) would otherwise have the daemon merging the branch it was opened
1245
+ // against forever. GitHub is the only authority on where a PR is aimed, and `prView` already reads `baseRefName`,
1246
+ // so this costs nothing beyond the write.
1247
+ if (typeof pr.baseRefName === "string" && pr.baseRefName && pr.baseRefName !== entry.base) {
1248
+ log.info(`${id}: base branch is ${pr.baseRefName}${entry.base ? `, was ${entry.base}` : " (not recorded before)"}`);
1249
+ entry = save(id, { base: pr.baseRefName });
1250
+ }
1251
+
1252
+ // The round comment owed by a round that was already counted. Before every judgement below, and the whole tick
1253
+ // declines while it is owed, for two reasons that are both about the comment being the re-review request. The
1254
+ // reviewer has not been asked yet, so `rereview.at` still points at the previous ask: judging this head on the
1255
+ // reviewer's silence would measure a wait for a question nobody put. And a new fix round dispatched from here
1256
+ // would be a second round charged before the first was announced, which is the undercount in the other
1257
+ // direction. So the entry waits, bounded: five refusals and it stops with `stuck`, naming the write.
1258
+ //
1259
+ // `asked` is whether the re-review went out on THIS pass, and it is the fix for a duplicate seen on a live PR: two
1260
+ // comments five seconds apart, the same text, one tagged `round` and one tagged `nudge`. Both are asks, so the
1261
+ // reviewer was asked twice for one push. See the silence step at the bottom of this function for what it gates and
1262
+ // why the nudge could not gate itself on the clock alone.
1263
+ let asked = false;
1264
+ if (entry.round_comment) {
1265
+ if (!await postRoundComment(id, entry)) return;
1266
+ asked = true;
1267
+ entry = state.get(id);
1268
+ if (!entry || entry.state !== STATES.prOpen) return;
1269
+ }
1270
+
1271
+ // Inline comments are fetched from the earlier of the two windows: events use `since`, the reviewer's verdict
1272
+ // uses `rereview.at`, and a review that landed before `since` still counts for the verdict.
1273
+ const inlineSince = earlier(earlier(entry.cursor.since, entry.rereview.at), entry.public_since);
1274
+
1275
+ // The fallback boundary for a dispatch with no events to bound it. Captured before the reads, so a cursor set
1276
+ // from it cannot skip what arrived during them, and floored to the second, so an item stamped in this very
1277
+ // second is still at or after it. `dispatched` is empty at a boundary no event was dispatched at.
1278
+ const readAt = floorSecond(now());
1279
+
1280
+ // Both server-side `since` filters are asked for from a second earlier than the cursor. GitHub filters on
1281
+ // `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
1282
+ // `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
1283
+ // second time costs nothing, never reading it again is the bug.
1284
+ const [reviews, inline, comments] = await Promise.all([
1285
+ gh.reviews(entry.repo, entry.pr),
1286
+ gh.inlineComments(entry.repo, entry.pr, fetchFrom(inlineSince)),
1287
+ gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.cursor.since, entry.public_since))),
1288
+ ]);
1289
+ if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
1290
+ try {
1291
+ const repo = learning.identityFor(entry.repo);
1292
+ if (repo && learning.mode(repo) !== "off") observationQueue.push({ repo, snapshot: { pr: { ...pr, repo: entry.repo }, reviews, inline, comments } });
1293
+ } catch (error) { log.warn(`learning observation configuration unavailable (${error.code || "storage error"}); coding feedback remains independent`); }
1294
+ }
1295
+ const ownComments = ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
1296
+ const publicViolations = [...publicPrViolations(pr), ...publicCommentViolations(ownComments)];
1297
+ if (publicViolations.length) {
1298
+ await fixOrStop(id, entry, pr, { name: "public-text", events: [], denials: publicCorrection(publicViolations), summary: `public text correction: ${publicViolations.join("; ")}`, since: entry.cursor.since, publicFix: publicReviewHash(pr, ownComments) }, deferredDispatches);
1299
+ return;
1300
+ }
1301
+ const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
1302
+
1303
+ if (events.length) {
1304
+ const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
1305
+ // The newest event dispatched is the boundary: everything up to it is handled, everything after it is still new.
1306
+ await fixOrStop(id, entry, pr, { name: "fix", events, summary, since: later(newest, entry.cursor.since) }, deferredDispatches);
1307
+ return;
1308
+ }
1309
+
1310
+ // A head nobody told the loop about, in one place for both watching states. After the events step, because a batch
1311
+ // of events dispatches against the head it just read and sets `fixed_sha` itself; before every judgement below,
1312
+ // because each of them measures the reviewer, the checks and the receipt against a head, and it has to be this one.
1313
+ const changed = await headChanged(id, entry, pr);
1314
+ if (changed === "declined") return;
1315
+ if (changed) entry = changed;
1316
+ const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
1317
+ const verdict = judge ? reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched }) : null;
1318
+ const green = judge && await checksSettled(entry, pr);
1319
+ if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
1320
+ const delivery = entry.completionEvidence?.delivery;
1321
+ const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
1322
+ if (bindingChanged || !green || !["clear", "none"].includes(verdict.verdict)) {
1323
+ // Revoke the claim before a possibly failing label write. Saved evidence
1324
+ // remains auditable; neither pending checks nor lost clearance launches
1325
+ // a provider. The ordinary review/gate path decides on later ticks.
1326
+ entry = save(id, { completionEvidence: { ...entry.completionEvidence, delivery: { ...delivery, invalidatedAt: now(), invalidationReason: "Delivery binding, checks or reviewer clearance changed." } } });
1327
+ try { await reopen(id, entry); }
1328
+ catch (error) { await retryFailed(id, entry, READY_LABEL_REMOVAL, error, { action: "retrying removal of stale ready label" }); return; }
1329
+ save(id, { state: STATES.prOpen, gate_skipped: null, retry: null,
1330
+ ...(delivery?.base !== pr.baseRefName ? { rereview: { at: now(), nudged: false } } : {}),
1331
+ });
1332
+ return;
1333
+ }
1334
+ }
1335
+ if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
1336
+ try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1337
+ catch (err) {
1338
+ await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
1339
+ return;
1340
+ }
1341
+ entry = retryOk(id, entry);
1342
+ }
1343
+ if (entry.state !== STATES.prOpen) return;
1344
+ if (!pr.isDraft && green && (verdict.verdict === "clear" || verdict.verdict === "none")) {
1345
+ // Before the gate, and before anything is certified: is the brief the gate is about to judge still the brief the
1346
+ // operator is asking for?
1347
+ //
1348
+ // What the gate does while a divergence is unresolved: nothing. It is not run on the old criteria and it is not
1349
+ // run on the new ones, because either would be a false certification. Judging the snapshot certifies a brief the
1350
+ // operator has abandoned, which is the silent divergence this exists to end. Judging the operator's new copy
1351
+ // certifies criteria the session was never given and never attested, so the receipt on the PR would be answering
1352
+ // a different question from the one asked. There is no third brief to judge, so the honest answer is that no
1353
+ // decision is made until a person resolves which brief is the brief.
1354
+ //
1355
+ // `gate_skipped` is deliberately NOT set. That field describes a PR that reached READY with the gate unrun, so a
1356
+ // person deciding whether to merge is told the receipt was never checked. This entry is not reaching ready at
1357
+ // all: it is stopping, with a reason and a notification, and `needsHuman` clears the field for exactly that
1358
+ // reason.
1359
+ const div = divergenceFor(id, entry);
1360
+ const divReason = divergenceReason(div);
1361
+ if (divReason) {
1362
+ const detail = renderDivergence(div);
1363
+ log.warn(`${id}: ${div.why}; not judging either brief:\n${detail}`);
1364
+ await needsHuman(id, divReason, { prd_divergence: { at: now(), why: div.why, diff: div.diff }, last: { at: now(), event: div.why } });
1365
+ return;
1366
+ }
1367
+ // A brief edited outside the contract: the criteria, the Verify commands, the harness and `context_paths` all
1368
+ // still match, so the work in flight is still the work that was asked for and stopping would throw it away for a
1369
+ // reworded Problem section. The snapshot follows the edit anyway, so a later fix round's prompt carries the
1370
+ // operator's current prose rather than a version they have moved on from, and the fingerprint follows with it so
1371
+ // the next tick does not re-derive the same answer. Nobody is notified.
1372
+ if (div.verdict === "same" && div.repoText !== readPrd(id)) {
1373
+ // `retakePrdSnapshot` writes the file and the fingerprint through `state.update`, so the local `entry` is now one
1374
+ // version behind and everything below judges this head with it. `state.get` rather than an empty `save`, which
1375
+ // would read as a no-op to the next person and only happens to re-read as a side effect of touching
1376
+ // `updated_at`. The cached parse is replaced too, so `verifyFor` and `receiptFor` on this same pass carry the
1377
+ // brief that is now on disk rather than the one that was.
1378
+ retakePrdSnapshot({ log, writePrd, state }, { id, divergence: div });
1379
+ prds.set(id, div.repo);
1380
+ entry = state.get(id);
1381
+ log.info(`${id}: the brief was edited outside its contract; snapshot re-taken from ${div.path} and nothing restarted`);
1382
+ } else if (entry.prd_divergence) {
1383
+ entry = save(id, { prd_divergence: null });
1384
+ }
1385
+ // The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
1386
+ const prd = config.deliverable ? prdFor(id) : null;
1387
+ // An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
1388
+ // was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
1389
+ // `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
1390
+ // carried on the entry, where the three of them can say it.
1391
+ const gateSkipped = config.deliverable && !prd ? "no PRD snapshot" : null;
1392
+ let completionGate = null;
1393
+ if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
1394
+ if (prd) {
1395
+ const criteria = criteriaFromPrd(prd);
1396
+ // The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
1397
+ // is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
1398
+ // would let a PR through the very check it is meant to face. A diff too large to hold is the same fail-open
1399
+ // one step further on, and `gh.prDiff` throws for it rather than returning a prefix that looks whole. No
1400
+ // decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
1401
+ let diff = null;
1402
+ if (config.deliverable.scan_output) {
1403
+ let raw;
1404
+ try { raw = await gh.prDiff(entry.repo, entry.pr); }
1405
+ catch (e) { raw = e; }
1406
+ // Anything that is not diff text is unreadable, a thrown error and an absent diff alike, so no future
1407
+ // caller can reintroduce the fail-open by handing the scan a value that merely looks empty.
1408
+ if (typeof raw !== "string") {
1409
+ await retryFailed(id, entry, COMPLETION_GATE, new Error(`diff unreadable: ${raw instanceof Error ? raw.message : `gh pr diff returned ${raw}`}`), { action: "reviewer clear; retrying the output scan next tick" });
1410
+ return;
1411
+ }
1412
+ diff = addedLines(raw);
1413
+ }
1414
+ // `framework` is the harness that did the work, carried as self-reported provenance in the decision's
1415
+ // context so a machine running two harnesses can tell their decisions apart afterwards.
1416
+ const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
1417
+ if (g.pending) {
1418
+ await retryFailed(id, entry, COMPLETION_GATE, new Error(g.denials?.map(d => d.message).join("; ") || g.source), { action: "reviewer clear; retrying the evaluator next tick" });
1419
+ return;
1420
+ }
1421
+ if (!g.allow) {
1422
+ const codes = g.denials.map(d => d.code).join(", ");
1423
+ log.info(`${id}: completion gate (${g.source}) denied: ${codes}`);
1424
+ await fixOrStop(id, entry, pr, { name: "receipt", events: [], denials: `${denialsPrompt(g.denials)}\n\n${receiptInstructions(criteria, id)}`, summary: `receipt denied: ${codes}`, since: readAt }, deferredDispatches);
1425
+ return;
1426
+ }
1427
+ log.info(`${id}: completion gate passed (${g.source})`);
1428
+ completionGate = { allow: true, source: g.source, decisionId: g.decisionId || null };
1429
+ }
1430
+ // The label goes on before the registry says ready, because the registry moving is what makes this branch stop
1431
+ // running: a later tick returns early on the state, so a label write swallowed here would leave the PR with no
1432
+ // label and nobody to add one. A failure therefore leaves the entry in pr-open, which is the retryable state:
1433
+ // the next tick judges the same head again and asks again. Nothing else has happened yet, so the retry is the
1434
+ // same work, not a second round. The operator is not notified either, since being told a PR is ready while the
1435
+ // PR itself says nothing is the contradiction this ordering exists to avoid.
1436
+ //
1437
+ // The fourth caller of the bound. Declining is right and declining forever is not: a token that cannot write
1438
+ // labels at all never recovers, and this path had no counter, so the entry sat in `pr-open` repeating the gate
1439
+ // and the label attempt on every tick, for as long as the daemon ran, with one `log.warn` each time. Worse than
1440
+ // the other three, because every readiness condition had already passed: `status` said the entry was waiting on
1441
+ // the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
1442
+ // hosted evaluator, re-paid for) on every poll for a decision that was already made.
1443
+ try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1444
+ catch (err) {
1445
+ await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
1446
+ return;
1447
+ }
1448
+ // Cleared before the state moves, not after, so `retry` is never set on a `ready` entry: nothing retries a ready
1449
+ // entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
1450
+ // operator is being asked to act on.
1451
+ entry = retryOk(id, entry);
1452
+ const e = save(id, { state: STATES.ready, gate_skipped: gateSkipped, retry: null,
1453
+ ...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
1454
+ adapter: "github-pull-request", repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
1455
+ base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
1456
+ gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
1457
+ } } } : {}),
1458
+ });
1459
+ await notify.ready({ entry: e, max: config.max_iterations, checks: (pr.statusCheckRollup || []).length, reviewerNote: verdict.note });
1460
+ log.info(`${id}: ready for a human`);
1461
+ return;
1462
+ }
1463
+ if (verdict.verdict === "blocking") {
1464
+ // The reviewer blocked this head and nothing new arrived to dispatch: its review predates the event cursor
1465
+ // (a recovery, a reset). A person decides, once per head.
1466
+ const event = `reviewer blocking on ${pr.headRefOid.slice(0, 7)}`;
1467
+ if (entry.last?.event !== event) await needsHuman(id, "reviewer-blocking", { last: { at: now(), event } });
1468
+ return;
1469
+ }
1470
+ if (!green && verdict.verdict !== "waiting") {
1471
+ // Red checks on a head whose failures were already dispatched: nothing more for the loop to do until the
1472
+ // next event, but not silently. Once per head.
1473
+ const event = `red checks on ${pr.headRefOid.slice(0, 7)}`;
1474
+ if (entry.cursor.checks_sha === pr.headRefOid && entry.last?.event !== event) await needsHuman(id, "checks-red", { last: { at: now(), event } });
1475
+ return;
1476
+ }
1477
+ // The silence step: has the reviewer answered the ask, and if not, for how long. Both branches measure the same
1478
+ // clock, `rereview.at`, and both are skipped on a pass that made the ask, which is the duplicate-comment fix.
1479
+ //
1480
+ // What the duplicate was, and why the clock could not catch it on its own. The round comment and the nudge post
1481
+ // the SAME text, `reviewer.rereview_comment`, and are told apart only by their marker; a live PR carried both
1482
+ // five seconds apart for one push. `postRoundComment` dates the ask through `reviewerClock`, which will not
1483
+ // believe a committer date older than the nudge window and floors it at exactly `now - rereview_minutes`. A
1484
+ // committer date older than the window is the ordinary case, not the odd one: the commit was made before the run
1485
+ // that pushed it, or the head is a merge tip carrying the merged commit's date. `floorSecond` then drops the
1486
+ // milliseconds off that floor, so the ask lands a few hundred milliseconds the wrong side of the boundary
1487
+ // `waited > minutes(rereview_minutes)` tests, and the nudge fired on the round's own tick. Worse, with
1488
+ // `stall_hours` low enough the branch above it fires first and the entry stops `reviewer-silent` on the very
1489
+ // tick it asked, for a question that was microseconds old.
1490
+ //
1491
+ // Widening the floor would only move the boundary, and the next tick to straddle it would ask twice again. The
1492
+ // durable statement is about the asks, not the arithmetic: the round comment IS the re-review request, the nudge
1493
+ // is a second copy of that same request, and one push earns one asking of it. So neither branch runs on a pass
1494
+ // that has just asked. The two markers stay separate because they answer different questions on different
1495
+ // passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
1496
+ // false here, so a reviewer that really does go quiet is still chased on the next tick.
1497
+ if (verdict.verdict === "waiting" && entry.rereview.at && !asked) {
1498
+ const waited = clock.now() - Date.parse(entry.rereview.at);
1499
+ if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
1500
+ if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
1501
+ const posted = await commentOnce(entry, nudgeComment(config), NUDGE_MARKER, entry.rereview.at);
1502
+ save(id, { rereview: { ...entry.rereview, nudged: true } });
1503
+ log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * One pass over every entry, a few at a time. Errors are per entry, so one bad PR never stalls the rest. Entries
1510
+ * are independent: each has its own runner root, and the registry serialises writes with a lock.
1511
+ */
1512
+ async function tick() {
1513
+ prds.clear();
1514
+ const entries = state.entries().filter(([id, entry]) => learningDeliveryPending(entry) || !completed(id, entry)), queue = [...entries], progressQueue = [...entries], deferredDispatches = [];
1515
+ if (learning) {
1516
+ // Choose the next bounded window before asynchronous reads. Fast entries
1517
+ // cannot monopolize observation slots, and coding dispatch order is unchanged.
1518
+ const watched = entries.filter(([id, entry]) => !completed(id, entry) && entry.pr && [STATES.prOpen, STATES.ready].includes(entry.state)
1519
+ && (!deps.executionFor || !entry.review_next_at || clock.now() >= entry.review_next_at || controlPending(entry.control)));
1520
+ const count = Math.min(100, watched.length);
1521
+ observationSelection = new Set(Array.from({ length: count }, (_, i) => watched[(observationOffset + i) % watched.length][0]));
1522
+ if (watched.length) observationOffset = (observationOffset + count) % watched.length;
1523
+ }
1524
+ const results = [];
1525
+ // Bind legacy queued ambiguous resumes before the parallel progress lane
1526
+ // can adopt their roots. This pass only observes already-sent input.
1527
+ const recoveryQueue = [...entries], recovered = new Set();
1528
+ const recoverWorker = async () => {
1529
+ for (let next = recoveryQueue.shift(); next; next = recoveryQueue.shift()) {
1530
+ try { if (await recoverControl(deps, next[0])) recovered.add(next[0]); }
1531
+ catch (error) { recovered.add(next[0]); log.warn(`${next[0]}: recovery control refused (${error.code || "recovery failed"})`); }
1532
+ }
1533
+ };
1534
+ await Promise.all(Array.from({ length: Math.min(config.execution?.concurrency || CONCURRENCY, entries.length) }, recoverWorker));
1535
+ const worker = async () => {
1536
+ for (let next = queue.shift(); next; next = queue.shift()) {
1537
+ const [id, entry] = next;
1538
+ if (recovered.has(id)) { results.push({ id, ok: true }); continue; }
1539
+ try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
1540
+ catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
1541
+ }
1542
+ };
1543
+ const progressWorker = async () => {
1544
+ for (let next = progressQueue.shift(); next; next = progressQueue.shift()) {
1545
+ const [id] = next;
1546
+ try {
1547
+ // The entry worker drains settled handoff audits. Do not duplicate
1548
+ // that delivery here or resume provider observation for the handoff.
1549
+ if (completed(id, state.get(id))) continue;
1550
+ const runner = runnerFor(id);
1551
+ await runner.reconcile?.({ force: startupReconciliation.delete(id) });
1552
+ const current = runner.current();
1553
+ if (current?.execution) state.update(id, entry => {
1554
+ const ex = current.execution;
1555
+ const owned = ex.acceptance === "accepted" && ex.operationId?.startsWith(`${entry.last?.run || "initial"}:${entry.iteration}:`) && current.id !== entry.last?.run;
1556
+ const { lastEvent, ...workflow } = ex.workflow || {};
1557
+ return { execution: ex, ...((owned || entry.last?.run === current.id) && entry.learning?.runId !== current.id ? learningDeliveryPatch(entry, ex, current) : {}), ...(owned ? { ...workflow, session: ex.sessionId || entry.session, last: { at: now(), run: current.id, exit: null, event: lastEvent || "recovered accepted execution" } } : {}) };
1558
+ });
1559
+ await learningDelivered({ ...deps, learning }, id);
1560
+ if (runner.reconcile) await reconcileResults(id, state.get(id), runner);
1561
+ }
1562
+ catch (error) { log.warn(`${id}: progress reconciliation failed (${error.code || "transport error"})`); }
1563
+ }
1564
+ };
1565
+ const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
1566
+ // Independent bounded lanes: provider I/O cannot occupy every GitHub worker.
1567
+ await Promise.all([
1568
+ ...Array.from({ length: concurrency }, worker),
1569
+ ...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
1570
+ ]);
1571
+ // Pending health cannot occupy review workers. Retry these dispatches once
1572
+ // in this tick after their existing scoped probe has saved its observation.
1573
+ const dispatchWorker = async () => {
1574
+ for (let next = deferredDispatches.shift(); next; next = deferredDispatches.shift()) {
1575
+ const { id, entry, pr, spec, refreshed } = next;
1576
+ try {
1577
+ await refreshed;
1578
+ const current = state.get(id);
1579
+ if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
1580
+ current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor)) continue;
1581
+ await fixOrStop(id, current, pr, spec);
1582
+ } catch (e) {
1583
+ log.error(`${id}: ${e.message}`);
1584
+ Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });
1585
+ }
1586
+ }
1587
+ };
1588
+ await Promise.all(Array.from({ length: Math.min(concurrency, deferredDispatches.length) }, dispatchWorker));
1589
+ // Chain merge/ancestry reads retain the review cadence, even when remote
1590
+ // progress makes the coordinator wake more often. Boot reconciles once.
1591
+ if (clock.now() >= nextChainAt) {
1592
+ nextChainAt = clock.now() + config.poll_seconds * 1000;
1593
+ results.push(...await tickChains(deps));
1594
+ }
1595
+ // Observation is a bounded, unpaid tail lane after fixes and chain dispatch.
1596
+ // Failure cannot acknowledge events, spend rounds, or fail a coding result.
1597
+ const identityDeadline = Date.now() + 5000, identities = new Map();
1598
+ for (const item of observationQueue.splice(0, 100)) {
1599
+ try {
1600
+ if (learning.mode(item.repo) === "off") continue;
1601
+ const name = item.snapshot.pr.repo.toLowerCase();
1602
+ if (!identities.has(name)) {
1603
+ const remaining = identityDeadline - Date.now();
1604
+ if (remaining <= 0) { log.warn("learning identity budget exhausted; remaining observations skipped"); break; }
1605
+ identities.set(name, boundedCall(signal => learning.resolveRepo(name, { signal }), { deadlineMs: remaining }));
1606
+ }
1607
+ // The queued snapshot is only a proposal until its mutable name has
1608
+ // been checked against the immutable learning scope. Never re-enroll it.
1609
+ if (learningHash(await identities.get(name)) !== learningHash(item.repo)) { log.warn("learning repository identity changed; observation skipped"); continue; }
1610
+ await learning.observe(item.repo, item.snapshot);
1611
+ }
1612
+ catch (error) { log.warn(`learning collection unavailable (${error.code || "storage error"}); coding/review state preserved`); }
1613
+ }
1614
+ return results;
1615
+ }
1616
+
1617
+ return { tick, tickEntry, startRun, needsHuman, mergeBaseInto, reopen, get nextChainAt() { return nextChainAt; } };
1618
+ }
1619
+
1620
+ /**
1621
+ * The words the configured reviewer is asked with, from `reviewer.rereview_comment` and from nowhere else. The
1622
+ * default is `@codex review` because that is the reviewer Gallivant was first driven against, but nothing in the
1623
+ * loop knows what the string says: whatever mention, slash command or plain English sentence a review tool answers
1624
+ * to goes in that one field, and a human reviewer's "please take another look" works the same way.
1625
+ *
1626
+ * `reviewer: null` is "no automated reviewer": nothing is asked for a re-review and readiness rests on the checks
1627
+ * alone. A round still happened and the PR still says so, so the fallback announces the push without asking anyone
1628
+ * for anything. An unguarded read here used to be the difference between the two comment builders, which is the
1629
+ * asymmetry this codebase keeps producing: the round comment had the fallback and the nudge did not.
1630
+ */
1631
+ const reviewerAsk = config => config.reviewer?.rereview_comment ?? "Pushed a fix.";
1632
+
1633
+ export const roundComment = config => `${reviewerAsk(config)}\n\n${ROUND_MARKER}`;
1634
+ export const receiptRoundComment = () => `Completion receipt updated.\n\n${ROUND_MARKER}`;
1635
+ export const nudgeComment = config => `${reviewerAsk(config)}\n\n${NUDGE_MARKER}`;