@tokenfactory/acc-runner 0.44.0 → 0.44.2

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 (197) hide show
  1. package/README.md +43 -1
  2. package/dist/bin-resolve.d.ts +39 -3
  3. package/dist/bin-resolve.d.ts.map +1 -1
  4. package/dist/bin-resolve.js +176 -12
  5. package/dist/bin-resolve.js.map +1 -1
  6. package/dist/build-fingerprint.d.ts +66 -0
  7. package/dist/build-fingerprint.d.ts.map +1 -0
  8. package/dist/build-fingerprint.js +70 -0
  9. package/dist/build-fingerprint.js.map +1 -0
  10. package/dist/build-info.generated.d.ts +3 -0
  11. package/dist/build-info.generated.d.ts.map +1 -0
  12. package/dist/build-info.generated.js +3 -0
  13. package/dist/build-info.generated.js.map +1 -0
  14. package/dist/capacity/resume-controller.d.ts +5 -3
  15. package/dist/capacity/resume-controller.d.ts.map +1 -1
  16. package/dist/capacity/resume-controller.js +9 -0
  17. package/dist/capacity/resume-controller.js.map +1 -1
  18. package/dist/chat-delegation-guard.d.ts +23 -0
  19. package/dist/chat-delegation-guard.d.ts.map +1 -0
  20. package/dist/chat-delegation-guard.js +62 -0
  21. package/dist/chat-delegation-guard.js.map +1 -0
  22. package/dist/chat-lane.d.ts +17 -6
  23. package/dist/chat-lane.d.ts.map +1 -1
  24. package/dist/chat-lane.js +29 -8
  25. package/dist/chat-lane.js.map +1 -1
  26. package/dist/companion-identity.d.ts +37 -0
  27. package/dist/companion-identity.d.ts.map +1 -1
  28. package/dist/companion-identity.js +98 -29
  29. package/dist/companion-identity.js.map +1 -1
  30. package/dist/companion-run.d.ts +1 -50
  31. package/dist/companion-run.d.ts.map +1 -1
  32. package/dist/companion-run.js +48 -105
  33. package/dist/companion-run.js.map +1 -1
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +14 -3
  37. package/dist/config.js.map +1 -1
  38. package/dist/conformance.d.ts +49 -0
  39. package/dist/conformance.d.ts.map +1 -0
  40. package/dist/conformance.js +121 -0
  41. package/dist/conformance.js.map +1 -0
  42. package/dist/cost-pricing.d.ts +91 -0
  43. package/dist/cost-pricing.d.ts.map +1 -1
  44. package/dist/cost-pricing.js +115 -1
  45. package/dist/cost-pricing.js.map +1 -1
  46. package/dist/credential-identity.d.ts +70 -0
  47. package/dist/credential-identity.d.ts.map +1 -0
  48. package/dist/credential-identity.js +128 -0
  49. package/dist/credential-identity.js.map +1 -0
  50. package/dist/doctor.d.ts +37 -1
  51. package/dist/doctor.d.ts.map +1 -1
  52. package/dist/doctor.js +300 -41
  53. package/dist/doctor.js.map +1 -1
  54. package/dist/engines/chat-stream.d.ts +71 -1
  55. package/dist/engines/chat-stream.d.ts.map +1 -1
  56. package/dist/engines/chat-stream.js +126 -8
  57. package/dist/engines/chat-stream.js.map +1 -1
  58. package/dist/engines/claude-code-chat.d.ts +11 -1
  59. package/dist/engines/claude-code-chat.d.ts.map +1 -1
  60. package/dist/engines/claude-code-chat.js +69 -5
  61. package/dist/engines/claude-code-chat.js.map +1 -1
  62. package/dist/engines/claude-code.d.ts.map +1 -1
  63. package/dist/engines/claude-code.js +30 -6
  64. package/dist/engines/claude-code.js.map +1 -1
  65. package/dist/engines/codex.d.ts.map +1 -1
  66. package/dist/engines/codex.js +18 -5
  67. package/dist/engines/codex.js.map +1 -1
  68. package/dist/engines/registry.d.ts +26 -0
  69. package/dist/engines/registry.d.ts.map +1 -1
  70. package/dist/engines/registry.js +37 -0
  71. package/dist/engines/registry.js.map +1 -1
  72. package/dist/engines/types.d.ts +5 -0
  73. package/dist/engines/types.d.ts.map +1 -1
  74. package/dist/failure-classifier.d.ts +1 -1
  75. package/dist/failure-classifier.d.ts.map +1 -1
  76. package/dist/failure-classifier.js +65 -12
  77. package/dist/failure-classifier.js.map +1 -1
  78. package/dist/gh.d.ts +15 -0
  79. package/dist/gh.d.ts.map +1 -1
  80. package/dist/gh.js +18 -0
  81. package/dist/gh.js.map +1 -1
  82. package/dist/git.d.ts +6 -0
  83. package/dist/git.d.ts.map +1 -1
  84. package/dist/git.js +27 -1
  85. package/dist/git.js.map +1 -1
  86. package/dist/instance.d.ts +30 -0
  87. package/dist/instance.d.ts.map +1 -0
  88. package/dist/instance.js +51 -0
  89. package/dist/instance.js.map +1 -0
  90. package/dist/keychain.d.ts +20 -7
  91. package/dist/keychain.d.ts.map +1 -1
  92. package/dist/keychain.js +40 -4
  93. package/dist/keychain.js.map +1 -1
  94. package/dist/loc-advisory.d.ts +70 -0
  95. package/dist/loc-advisory.d.ts.map +1 -0
  96. package/dist/loc-advisory.js +196 -0
  97. package/dist/loc-advisory.js.map +1 -0
  98. package/dist/locks/claim-lock-policy.d.ts +67 -0
  99. package/dist/locks/claim-lock-policy.d.ts.map +1 -0
  100. package/dist/locks/claim-lock-policy.js +132 -0
  101. package/dist/locks/claim-lock-policy.js.map +1 -0
  102. package/dist/login.d.ts +63 -2
  103. package/dist/login.d.ts.map +1 -1
  104. package/dist/login.js +93 -8
  105. package/dist/login.js.map +1 -1
  106. package/dist/mcp-spawn.d.ts +54 -0
  107. package/dist/mcp-spawn.d.ts.map +1 -1
  108. package/dist/mcp-spawn.js +88 -4
  109. package/dist/mcp-spawn.js.map +1 -1
  110. package/dist/memory-context.d.ts +106 -0
  111. package/dist/memory-context.d.ts.map +1 -0
  112. package/dist/memory-context.js +220 -0
  113. package/dist/memory-context.js.map +1 -0
  114. package/dist/memory-curation.d.ts +172 -0
  115. package/dist/memory-curation.d.ts.map +1 -0
  116. package/dist/memory-curation.js +277 -0
  117. package/dist/memory-curation.js.map +1 -0
  118. package/dist/migration-seq-guard.d.ts +28 -1
  119. package/dist/migration-seq-guard.d.ts.map +1 -1
  120. package/dist/migration-seq-guard.js +111 -6
  121. package/dist/migration-seq-guard.js.map +1 -1
  122. package/dist/output-diagnostics.d.ts +15 -0
  123. package/dist/output-diagnostics.d.ts.map +1 -0
  124. package/dist/output-diagnostics.js +95 -0
  125. package/dist/output-diagnostics.js.map +1 -0
  126. package/dist/pr-open-guard.d.ts +67 -0
  127. package/dist/pr-open-guard.d.ts.map +1 -0
  128. package/dist/pr-open-guard.js +79 -0
  129. package/dist/pr-open-guard.js.map +1 -0
  130. package/dist/prepr-gate.d.ts +229 -1
  131. package/dist/prepr-gate.d.ts.map +1 -1
  132. package/dist/prepr-gate.js +406 -3
  133. package/dist/prepr-gate.js.map +1 -1
  134. package/dist/program.d.ts.map +1 -1
  135. package/dist/program.js +43 -5
  136. package/dist/program.js.map +1 -1
  137. package/dist/provider-auth.d.ts +27 -0
  138. package/dist/provider-auth.d.ts.map +1 -1
  139. package/dist/provider-auth.js +33 -0
  140. package/dist/provider-auth.js.map +1 -1
  141. package/dist/runtime/cache-dir.d.ts.map +1 -1
  142. package/dist/runtime/cache-dir.js +8 -3
  143. package/dist/runtime/cache-dir.js.map +1 -1
  144. package/dist/runtime/decision-provenance.d.ts +63 -0
  145. package/dist/runtime/decision-provenance.d.ts.map +1 -0
  146. package/dist/runtime/decision-provenance.js +107 -0
  147. package/dist/runtime/decision-provenance.js.map +1 -0
  148. package/dist/runtime/prompt-substitute.d.ts +100 -0
  149. package/dist/runtime/prompt-substitute.d.ts.map +1 -0
  150. package/dist/runtime/prompt-substitute.js +124 -0
  151. package/dist/runtime/prompt-substitute.js.map +1 -0
  152. package/dist/runtime/quarantine.d.ts +11 -0
  153. package/dist/runtime/quarantine.d.ts.map +1 -1
  154. package/dist/runtime/quarantine.js +16 -1
  155. package/dist/runtime/quarantine.js.map +1 -1
  156. package/dist/runtime/review-cost-cap.d.ts +167 -0
  157. package/dist/runtime/review-cost-cap.d.ts.map +1 -0
  158. package/dist/runtime/review-cost-cap.js +182 -0
  159. package/dist/runtime/review-cost-cap.js.map +1 -0
  160. package/dist/runtime/reviewer-abstain.d.ts +101 -0
  161. package/dist/runtime/reviewer-abstain.d.ts.map +1 -0
  162. package/dist/runtime/reviewer-abstain.js +138 -0
  163. package/dist/runtime/reviewer-abstain.js.map +1 -0
  164. package/dist/runtime/reviewer.d.ts +165 -2
  165. package/dist/runtime/reviewer.d.ts.map +1 -1
  166. package/dist/runtime/reviewer.js +752 -111
  167. package/dist/runtime/reviewer.js.map +1 -1
  168. package/dist/runtime/running-build.d.ts +24 -0
  169. package/dist/runtime/running-build.d.ts.map +1 -0
  170. package/dist/runtime/running-build.js +62 -0
  171. package/dist/runtime/running-build.js.map +1 -0
  172. package/dist/runtime/singleton.d.ts.map +1 -1
  173. package/dist/runtime/singleton.js +9 -1
  174. package/dist/runtime/singleton.js.map +1 -1
  175. package/dist/runtime/worktree.d.ts +45 -0
  176. package/dist/runtime/worktree.d.ts.map +1 -1
  177. package/dist/runtime/worktree.js +174 -5
  178. package/dist/runtime/worktree.js.map +1 -1
  179. package/dist/serving-auth/index.d.ts +79 -0
  180. package/dist/serving-auth/index.d.ts.map +1 -0
  181. package/dist/serving-auth/index.js +169 -0
  182. package/dist/serving-auth/index.js.map +1 -0
  183. package/dist/task-runner.d.ts +134 -5
  184. package/dist/task-runner.d.ts.map +1 -1
  185. package/dist/task-runner.js +1255 -129
  186. package/dist/task-runner.js.map +1 -1
  187. package/dist/types.d.ts +8 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/watch-chat/wire.d.ts +80 -0
  190. package/dist/watch-chat/wire.d.ts.map +1 -1
  191. package/dist/watch-chat/wire.js +203 -43
  192. package/dist/watch-chat/wire.js.map +1 -1
  193. package/dist/watch.d.ts +70 -16
  194. package/dist/watch.d.ts.map +1 -1
  195. package/dist/watch.js +548 -108
  196. package/dist/watch.js.map +1 -1
  197. package/package.json +4 -3
@@ -15,24 +15,31 @@
15
15
  */
16
16
  import fs from "node:fs";
17
17
  import { existsSync, unlinkSync } from "node:fs";
18
+ import { createHash } from "node:crypto";
18
19
  import os from "node:os";
19
20
  import path from "node:path";
20
21
  import { join } from "node:path";
21
22
  import { fileURLToPath, pathToFileURL } from "node:url";
22
23
  import { execa } from "execa";
23
24
  import { canServeRepo, loadProfile as defaultLoadProfile, localRepoPathFor, resolveBaseBranch } from "./config.js";
24
- import { normalizeUsage, priceUsdCents, toCliAlias, } from "./cost-pricing.js";
25
- import { extractResetTime, isCapacityExhausted, isGitProvisionContention, } from "./failure-classifier.js";
26
- import { getEngine } from "./engines/registry.js";
25
+ import { UNRESOLVED_MODEL, engineResultModel, isPlaceholderModel, normalizeUsage, priceUsdCents, resolveSpawnModel, shouldLogPinRefusal, } from "./cost-pricing.js";
26
+ import { extractResetTime, isGitProvisionContention, } from "./failure-classifier.js";
27
+ import { getEngine, hasEngine } from "./engines/registry.js";
28
+ import { safeText } from "./machine-capabilities/redact.js";
29
+ import { openaiApiKey, openaiApiKeyFromKeychain, codexApiKeySpawnEnv } from "./provider-auth.js";
27
30
  import { startTaskGithubBudget, endTaskGithubBudget, isRetryableGithubError, } from "./github-client.js";
28
31
  import { git as defaultGit } from "./git.js";
29
32
  import { gh as defaultGh } from "./gh.js";
33
+ import { guardPrOpen, prOpenSkippedPayload, PR_OPEN_SKIPPED_EVENT_KIND, } from "./pr-open-guard.js";
30
34
  import { composePrBody } from "./pr-body.js";
31
- import { writeMcpConfig as defaultWriteMcpConfig, } from "./mcp-spawn.js";
35
+ import { evaluateLocAdvisory, locAdvisoryEventPayload, prependLocAdvisory, LOC_ADVISORY_EVENT_KIND } from "./loc-advisory.js";
36
+ import { conformanceEnforceRequested, evaluateConformance, formatConformanceFindings, CONFORMANCE_BLOCK_REASON, CONFORMANCE_EVENT_KIND, } from "./conformance.js";
37
+ import { writeMcpConfig as defaultWriteMcpConfig, readMcpConfig as defaultReadMcpConfig, } from "./mcp-spawn.js";
32
38
  import { postRunnerStateMessage as defaultPostRunnerStateMessage, } from "./messaging.js";
33
39
  import { branchForTask, prTitleForTask, renderTaskPrompt, } from "./prompt.js";
40
+ import { buildMemoryContext } from "./memory-context.js";
34
41
  import { acquireTaskLock as defaultAcquireTaskLock, TaskLockHeldError, } from "./runtime/locks.js";
35
- import { prepareTaskWorktree as defaultPrepareTaskWorktree, } from "./runtime/worktree.js";
42
+ import { prepareTaskWorktree as defaultPrepareTaskWorktree, WorktreeBranchTakenError, } from "./runtime/worktree.js";
36
43
  // v0.21 T-66-1: serialize the per-task worktree-provisioning critical section
37
44
  // against the shared clone so concurrent tasks can't collide on git's
38
45
  // repo-global locks (the T-65-2 race).
@@ -45,10 +52,14 @@ import { clonePathForRepo, ensureRepoClone, CloneFailedError, } from "./runtime/
45
52
  import { parseConflictMeta, resolveConflict, MAX_CONFLICT_ATTEMPTS, } from "./runtime/conflict-resolver.js";
46
53
  import { parseReworkMeta, renderReworkPrompt, reconcileReworkBranch as defaultReconcileReworkBranch, fetchPrHeadRef as defaultFetchPrHeadRef, } from "./runtime/rework.js";
47
54
  import { assertResolutionIntegrity, isConflictResolutionTask, summariseFailures, } from "./runtime/resolution-integrity.js";
48
- import { runPreprGate as defaultRunPreprGate, } from "./prepr-gate.js";
55
+ import { depsMissingGateResult, depsStaleGateResult, gateFailureBlockReason, gateRunnerCrashResult, runPreprGate as defaultRunPreprGate, DEPS_MISSING_CLASSIFICATION, DEPS_STALE_CLASSIFICATION, GATE_TOOL_CRASHED_CLASSIFICATION, PREPR_GATE_FAILED_CLASSIFICATION, PREPR_GATES, } from "./prepr-gate.js";
49
56
  import { revalidateMigrationSeq as defaultRevalidateMigrationSeq, } from "./migration-seq-guard.js";
50
57
  import { commitGuardSkipRequested, gitConfigHooksEnv, installCommitGuardSync, } from "./commit-guard.js";
51
58
  import { readLiveAllowedGlobs } from "./locks/index.js";
59
+ import { declaredLockPaths, planClaimLockLoss, } from "./locks/claim-lock-policy.js";
60
+ import { resolveCredentialIdentity } from "./credential-identity.js";
61
+ import { failureDiagnostics } from "./output-diagnostics.js";
62
+ export const PREPR_GATE_MISSING_EVENT = "prepr_gate_missing";
52
63
  const LOG_BATCH_BYTES = 4 * 1024;
53
64
  // M2 (ENGINE-EXTRACT): the coding engine this runner drives. Wave 1 resolved
54
65
  // this to a single module-level `claude-code` constant — spawn, argv,
@@ -64,7 +75,28 @@ const LOG_BATCH_BYTES = 4 * 1024;
64
75
  // v0.6.0 REG-296: argv builder for `claude --print`, re-exported from the
65
76
  // claude-code engine so the historical import surface is preserved.
66
77
  export { buildClaudeArgs } from "./engines/claude-code.js";
67
- function defaultSpawnClaude(cwd, modelId, engineId) {
78
+ /**
79
+ * PREPR-GATE-TIMEOUT-VISIBILITY-1: the structured, queryable fields a red gate
80
+ * contributes to its `error` / `rework_halt` record so a consumer never has to
81
+ * grep `stderr_tail`. For the `test` gate these name the offending test and,
82
+ * crucially, whether it TIMED OUT (`failure_kind`) vs asserted — the timeout
83
+ * vs assertion split the whole gate-triage rework depends on. A non-test gate
84
+ * (or a test gate whose output was unparseable) contributes nothing, so the
85
+ * record shape is unchanged for those.
86
+ */
87
+ function gateFailureRecordFields(fail) {
88
+ if (fail.gate !== "test" || !fail.test)
89
+ return {};
90
+ return {
91
+ failure_kind: fail.test.timedOut ? "timeout" : "assertion",
92
+ timed_out: fail.test.timedOut,
93
+ failing_test: fail.test.primaryId,
94
+ failing_file: fail.test.file,
95
+ test_name: fail.test.name,
96
+ failing_tests: fail.test.failing,
97
+ };
98
+ }
99
+ function defaultSpawnClaude(cwd, modelId, engineId, codexApiKey = null) {
68
100
  // RR-4: install the commit-time mechanical guard into this
69
101
  // worktree and point the agent's git at it via an ephemeral core.hooksPath env
70
102
  // (never a config-file write). The agent then hits an actionable in-session
@@ -83,8 +115,51 @@ function defaultSpawnClaude(cwd, modelId, engineId) {
83
115
  baseEnv = undefined;
84
116
  }
85
117
  }
118
+ if (codexApiKey) {
119
+ // Forward the RESOLVED key explicitly (env OR keychain): the codex engine's
120
+ // withOpenAiAuth reads OPENAI_API_KEY off baseEnv, so a keychain-only key
121
+ // that never sits in process.env would otherwise not reach the child.
122
+ baseEnv = codexApiKeySpawnEnv(codexApiKey, { ...process.env, ...baseEnv });
123
+ }
86
124
  return getEngine(engineId).spawn({ cwd, modelId, baseEnv });
87
125
  }
126
+ /**
127
+ * Read the legacy org fallback knobs without making dispatch depend on them.
128
+ *
129
+ * The read runs from the RUNNER process against `org_settings`. If the runner's
130
+ * Supabase client is not authorised to see org-wide settings (RLS), the query
131
+ * returns an `error` and fallback would resolve to OFF. That is a legitimate
132
+ * "no auto-fallback for this runner" outcome, but it must never be SILENT — an
133
+ * operator who configured `auto_fallback_on_rate_limit` needs to know their
134
+ * policy could not be read here rather than assume it is in force. So a read
135
+ * error (or a thrown client) emits an operator-visible stderr warning before
136
+ * returning disabled. A clean read with no row (policy genuinely unset) is the
137
+ * quiet, expected default and is NOT warned.
138
+ */
139
+ export async function loadEngineFallbackPolicy(supabase) {
140
+ try {
141
+ const client = supabase;
142
+ const { data, error } = await client.from("org_settings")
143
+ .select("auto_fallback_on_rate_limit, default_fallback_model")
144
+ .maybeSingle();
145
+ if (error) {
146
+ const message = error?.message ?? String(error);
147
+ process.stderr.write(`[acc-runner] engine fallback policy read failed (org_settings): ${message}; ` +
148
+ `auto-fallback treated as OFF for this task — check that the runner's ` +
149
+ `Supabase role can read org_settings if you expect a fallback\n`);
150
+ return { enabled: false, engine: null };
151
+ }
152
+ const engine = typeof data?.default_fallback_model === "string"
153
+ ? data.default_fallback_model.trim().toLowerCase()
154
+ : "";
155
+ return { enabled: data?.auto_fallback_on_rate_limit === true, engine: engine || null };
156
+ }
157
+ catch (err) {
158
+ process.stderr.write(`[acc-runner] engine fallback policy read threw (org_settings): ` +
159
+ `${err.message}; auto-fallback treated as OFF for this task\n`);
160
+ return { enabled: false, engine: null };
161
+ }
162
+ }
88
163
  async function defaultCheckoutBase(repoPath, baseBranch) {
89
164
  // Plain `git checkout <branch>` (not `-B`) so the integration
90
165
  // branch's existing ref is honored. With -B we'd reset the
@@ -106,6 +181,22 @@ export function expandHomePath(p) {
106
181
  }
107
182
  /** Opt-in the cold-parent per-worktree install fallback (default OFF). */
108
183
  export const WORKTREE_INSTALL_ENV = "ACC_RUNNER_WORKTREE_INSTALL";
184
+ /**
185
+ * RUNNER-GATE-VISIBILITY-2: does this checkout actually NEED node_modules? Only
186
+ * a repo declaring a gate script (typecheck/lint/build/test) can be broken by a
187
+ * missing one — for a Go/Python clone every gate is `absent` and the pre-PR gate
188
+ * passes as a no-op, so without this guard the deps short-circuit would HALT
189
+ * repos over a dependency tree they never had.
190
+ */
191
+ export async function repoNeedsNodeDeps(dir) {
192
+ try {
193
+ const pkg = JSON.parse(await fs.promises.readFile(path.join(dir, "package.json"), "utf8"));
194
+ return PREPR_GATES.some((g) => typeof pkg.scripts?.[g] === "string");
195
+ }
196
+ catch {
197
+ return false; // absent/unparseable package.json — nothing here needs deps
198
+ }
199
+ }
109
200
  async function worktreePathExists(p) {
110
201
  try {
111
202
  await fs.promises.access(p);
@@ -194,7 +285,10 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
194
285
  // A repo_path_hint that points AT the shared clone (no isolated worktree)
195
286
  // already has its deps — nothing to seed.
196
287
  if (path.resolve(workdir) === path.resolve(repoPath)) {
197
- return { linked: [], installed: false, detail: "workdir==repoPath; deps already present" };
288
+ return {
289
+ linked: [], installed: false, depsMissing: false,
290
+ detail: "workdir==repoPath; deps already present",
291
+ };
198
292
  }
199
293
  const rootSrc = path.resolve(repoPath, "node_modules");
200
294
  const rootDst = path.join(workdir, "node_modules");
@@ -202,7 +296,10 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
202
296
  if (!parentHasRoot) {
203
297
  // Cold parent (e.g. clone-on-demand) — nothing warm to seed from.
204
298
  if (await worktreePathExists(rootDst)) {
205
- return { linked: [], installed: false, detail: "worktree already has node_modules" };
299
+ return {
300
+ linked: [], installed: false, depsMissing: false,
301
+ detail: "worktree already has node_modules",
302
+ };
206
303
  }
207
304
  const installEnabled = (() => {
208
305
  const v = env[WORKTREE_INSTALL_ENV]?.trim().toLowerCase();
@@ -213,16 +310,27 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
213
310
  `${WORKTREE_INSTALL_ENV} is off — cannot seed deps; the pre-PR gate may fail ` +
214
311
  `on missing binaries. Install deps in the parent clone (recommended) or set ` +
215
312
  `${WORKTREE_INSTALL_ENV}=1 for a per-worktree install.`);
216
- return { linked: [], installed: false, detail: "cold parent; install fallback disabled" };
313
+ return {
314
+ linked: [], installed: false,
315
+ depsMissing: await repoNeedsNodeDeps(workdir),
316
+ detail: "cold parent; install fallback disabled",
317
+ };
217
318
  }
218
319
  const install = opts.install ?? defaultWorktreeInstall;
219
320
  try {
220
321
  await install(workdir);
221
- return { linked: [], installed: true, detail: "pnpm install (cold parent — no warm cache)" };
322
+ return {
323
+ linked: [], installed: true, depsMissing: false,
324
+ detail: "pnpm install (cold parent — no warm cache)",
325
+ };
222
326
  }
223
327
  catch (err) {
224
328
  opts.onLog?.(`[acc-runner] worktree pnpm install failed: ${err.message} — gate may fail`);
225
- return { linked: [], installed: false, detail: `install failed: ${err.message}` };
329
+ return {
330
+ linked: [], installed: false,
331
+ depsMissing: await repoNeedsNodeDeps(workdir),
332
+ detail: `install failed: ${err.message}`,
333
+ };
226
334
  }
227
335
  }
228
336
  const dirs = await resolveWorkspaceDirs(repoPath);
@@ -249,8 +357,71 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
249
357
  detail: linked.length
250
358
  ? `seeded ${linked.length} node_modules symlink(s) from warm clone`
251
359
  : "worktree already provisioned; nothing to seed",
360
+ depsMissing: false,
252
361
  };
253
362
  }
363
+ // WORKTREE-STALE-DEPS-1: seeding above assumes byte-identity with the parent
364
+ // clone, but PA-1's rework merge runs AFTER the seed and can advance the
365
+ // worktree's CODE past what the parent installed while the SYMLINKED
366
+ // node_modules stay put. Re-verify post-merge, install in-place on drift.
367
+ /** sha256 hex of a dir's pnpm-lock.yaml, or null when absent (never a false mismatch). */
368
+ export async function lockfileIdentity(dir) {
369
+ try {
370
+ const buf = await fs.promises.readFile(path.join(dir, "pnpm-lock.yaml"));
371
+ return createHash("sha256").update(buf).digest("hex");
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ }
377
+ /**
378
+ * Re-verify (and, if needed, repair) a worktree's seeded deps against its
379
+ * CURRENT lockfile — call AFTER any merge that could move it past what the
380
+ * parent last installed (today: PA-1's rework reconcile). Unlike the
381
+ * cold-parent fallback in `ensureWorktreeNodeModules`, this install is NOT
382
+ * gated behind `ACC_RUNNER_WORKTREE_INSTALL` — deps already exist here, so
383
+ * `--prefer-offline` is normally near-instant. Best-effort: never throws.
384
+ */
385
+ export async function verifyWorktreeDepsFreshness(workdir, repoPath, opts = {}) {
386
+ if (path.resolve(workdir) === path.resolve(repoPath)) {
387
+ const hash = await lockfileIdentity(workdir);
388
+ return {
389
+ fresh: true, lockfileHash: hash, parentLockfileHash: hash,
390
+ installed: false, depsMissing: false,
391
+ detail: "workdir==repoPath; deps already present",
392
+ };
393
+ }
394
+ const [lockfileHash, parentLockfileHash] = await Promise.all([
395
+ lockfileIdentity(workdir),
396
+ lockfileIdentity(repoPath),
397
+ ]);
398
+ if (lockfileHash === parentLockfileHash) {
399
+ return {
400
+ fresh: true, lockfileHash, parentLockfileHash, installed: false, depsMissing: false,
401
+ detail: "lockfile identity unchanged since seed; symlinked deps still satisfy it",
402
+ };
403
+ }
404
+ const drift = `worktree pnpm-lock.yaml (${lockfileHash?.slice(0, 12) ?? "absent"}) no longer matches ` +
405
+ `the parent clone's (${parentLockfileHash?.slice(0, 12) ?? "absent"}) the seeded ` +
406
+ `node_modules were symlinked from`;
407
+ const install = opts.install ?? defaultWorktreeInstall;
408
+ try {
409
+ await install(workdir);
410
+ return {
411
+ fresh: true, lockfileHash, parentLockfileHash, installed: true, depsMissing: false,
412
+ detail: `${drift} — reinstalled in worktree`,
413
+ };
414
+ }
415
+ catch (err) {
416
+ const detail = `${drift} — reinstall failed: ${err.message}`;
417
+ opts.onLog?.(`[acc-runner] worktree deps freshness: ${detail}`);
418
+ return {
419
+ fresh: false, lockfileHash, parentLockfileHash, installed: false,
420
+ depsMissing: await repoNeedsNodeDeps(workdir),
421
+ detail,
422
+ };
423
+ }
424
+ }
254
425
  /**
255
426
  * v0.63 T-63-1: default claude health probe. A silent instant-empty exit is
256
427
  * ambiguous — it can be a broken host OR a momentarily-unavailable claude.
@@ -269,6 +440,14 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
269
440
  * window is left strictly alone; the runner just keeps waiting.
270
441
  */
271
442
  const STALE_LOCK_RECLAIM_MS = 15 * 60_000;
443
+ /**
444
+ * CLAIM-LOCK-GLOB: process-local fallback for
445
+ * RunTaskDeps.claimLossState when the caller (watch.ts in production) supplies
446
+ * none — a single standalone runTask then still gets correct single-attempt
447
+ * back-off semantics. watch.ts owns the real per-runner map so attempts persist
448
+ * across re-dispatch and get cleared when the task leaves the queue.
449
+ */
450
+ const moduleClaimLossState = new Map();
272
451
  /**
273
452
  * v0.41.4 default self-heal for a claim conflict: read the conflicting holder
274
453
  * tasks' liveness and release the locks of holders that are dead/stale — a
@@ -395,6 +574,47 @@ async function appendEvent(supabase, taskId, kind, payload) {
395
574
  process.stderr.write(`[acc-runner] append_task_event failed: ${error.message}\n`);
396
575
  }
397
576
  }
577
+ function formatBranchReadinessError(files) {
578
+ const shown = files.length > 0 ? files.slice(0, 12).join(", ") : "(none)";
579
+ const suffix = files.length > 12 ? `, +${files.length - 12} more` : "";
580
+ return `no commits on branch, ${files.length} uncommitted changes: ${shown}${suffix}`;
581
+ }
582
+ async function diagnoseNoCommitBranch(git, workdir, baseRef, branch) {
583
+ const ahead = await git.aheadCount?.(workdir, baseRef, branch);
584
+ if (ahead == null || ahead > 0)
585
+ return null;
586
+ const files = await git.changedFiles?.(workdir) ?? [];
587
+ return { ahead, files };
588
+ }
589
+ async function ensureBranchReadyForPr(git, workdir, baseRef, branch, taskId) {
590
+ let files = await git.changedFiles?.(workdir) ?? [];
591
+ if (files.length > 0 && git.commitAll) {
592
+ try {
593
+ await git.commitAll(workdir, `chore(acc): commit runner work for ${taskId}`);
594
+ }
595
+ catch (err) {
596
+ return {
597
+ ok: false,
598
+ reason: `could not commit ${files.length} uncommitted changes: ${files.join(", ")}; ` +
599
+ `${err.message}`,
600
+ files,
601
+ };
602
+ }
603
+ files = await git.changedFiles?.(workdir) ?? [];
604
+ }
605
+ const ahead = await git.aheadCount?.(workdir, baseRef, branch);
606
+ if (ahead != null && ahead < 1) {
607
+ return { ok: false, reason: formatBranchReadinessError(files), files };
608
+ }
609
+ if (files.length > 0) {
610
+ return {
611
+ ok: false,
612
+ reason: `branch has ${files.length} uncommitted changes: ${files.join(", ")}`,
613
+ files,
614
+ };
615
+ }
616
+ return { ok: true };
617
+ }
398
618
  async function streamToEvents(stream, supabase, taskId, streamName) {
399
619
  let captured = "";
400
620
  let pending = "";
@@ -477,6 +697,15 @@ async function setTaskPr(supabase, taskId, prUrl) {
477
697
  }
478
698
  return true;
479
699
  }
700
+ async function recordPreprGateMissing(supabase, taskId, payload) {
701
+ await appendEvent(supabase, taskId, "log", {
702
+ phase: "prepr_gate",
703
+ stream: "stderr",
704
+ event: PREPR_GATE_MISSING_EVENT,
705
+ gate_verdict: "missing",
706
+ ...payload,
707
+ });
708
+ }
480
709
  /**
481
710
  * v0.53 T-53-4: emit a `task.first_commit` activity event the first time a
482
711
  * task's worktree produces a commit on its branch (the SLO funnel signal
@@ -491,7 +720,10 @@ async function setTaskPr(supabase, taskId, prUrl) {
491
720
  */
492
721
  async function emitFirstCommit(supabase, git, taskId, workdir, baseRef, branch) {
493
722
  try {
494
- const first = await git.firstCommit?.(workdir, baseRef, branch);
723
+ // The worktree is on a DETACHED HEAD (no local `branch` ref exists), so
724
+ // range against HEAD; the shared branch name is still recorded in the
725
+ // payload below.
726
+ const first = await git.firstCommit?.(workdir, baseRef, "HEAD");
495
727
  if (!first)
496
728
  return;
497
729
  const { error } = await supabase.rpc("log_activity", {
@@ -521,10 +753,25 @@ async function emitFirstCommit(supabase, git, taskId, workdir, baseRef, branch)
521
753
  * NS MM-2: `engineId` is optional and appended last for the same reason as
522
754
  * `extractReportFromOutput` — every pre-existing caller keeps resolving
523
755
  * `claude-code` unchanged.
756
+ *
757
+ * COST-ATTR-1: the model is resolved from what the ENGINE actually reported
758
+ * (`model`, else the winning `modelUsage` key — see engineResultModel) before
759
+ * falling back to the task's model pin. It lands on UNRESOLVED_MODEL only when
760
+ * both are empty, and says so on stderr rather than silently writing a token
761
+ * that reads like a real attribution. `acc.record_cost_event` then completes
762
+ * the DEC-P9 chain server-side (task pin -> project -> org), which the runner
763
+ * cannot do: the fetch payload carries the pinned model row and nothing else.
524
764
  */
525
765
  export function buildCostEvent(taskId, stdout, fallbackModel, runnerId, engineId) {
526
766
  const parsed = getEngine(engineId).parseResult(stdout);
527
- const model = parsed?.model ?? fallbackModel ?? "unknown";
767
+ const reported = engineResultModel(parsed);
768
+ const pinned = isPlaceholderModel(fallbackModel) ? "" : (fallbackModel ?? "").trim();
769
+ const model = reported ?? (pinned || UNRESOLVED_MODEL);
770
+ if (model === UNRESOLVED_MODEL) {
771
+ process.stderr.write(`[acc-runner] cost-event model unresolved for ${taskId}: engine ` +
772
+ `${engineId ?? "claude-code"} reported no model and the task carries no ` +
773
+ `model pin — posting '${UNRESOLVED_MODEL}' for server-side resolution\n`);
774
+ }
528
775
  const usage = normalizeUsage(parsed?.usage);
529
776
  return {
530
777
  task_id: taskId,
@@ -739,14 +986,33 @@ export function runTask(taskId, deps) {
739
986
  const prepareWorktree = deps.prepareWorktree ?? defaultPrepareTaskWorktree;
740
987
  const provisionRepoClone = deps.provisionRepoClone ?? ensureRepoClone;
741
988
  const postCostEvent = deps.postCostEvent ?? ((event) => defaultPostCostEvent(deps.supabase, event));
989
+ const buildCostEventForRun = deps.buildCostEvent ?? buildCostEvent;
742
990
  const postState = deps.postRunnerStateMessage ??
743
991
  ((args) => defaultPostRunnerStateMessage(deps.supabase, args));
744
992
  const loadProfile = deps.loadProfile ?? defaultLoadProfile;
745
993
  const loadPromptPreludeFn = deps.loadPromptPrelude ?? loadPromptPrelude;
746
994
  // NS MM-2: same reason as `spawnClaude` above — `healthProbe`'s default
747
995
  // binds below, after the task (and its `engine`) is fetched.
748
- const runPreprGate = deps.runPreprGate ??
996
+ const runPreprGateRaw = deps.runPreprGate ??
749
997
  ((workdir, baseRef) => defaultRunPreprGate(workdir, { baseRef }));
998
+ /**
999
+ * RUNNER-GATE-VISIBILITY-2: set to the parent clone path by the deps seeding
1000
+ * below when the worktree was left without node_modules. The wrapper under it
1001
+ * then fails the gate as `deps` — a distinct, actionable class — instead of
1002
+ * letting `pnpm typecheck` die "tsc: command not found" and charging the task
1003
+ * with a typecheck failure it did not cause. Short-circuits BEFORE any gate
1004
+ * runs; running them would only burn minutes to reach the same conclusion.
1005
+ */
1006
+ let worktreeDepsMissing = null;
1007
+ // WORKTREE-STALE-DEPS-1: set when a rework merge left the worktree's seeded
1008
+ // deps stale and the remediation install couldn't fix it. Distinct from
1009
+ // `worktreeDepsMissing` (nothing was ever seeded) so triage names the right fault.
1010
+ let worktreeDepsStale = null;
1011
+ const runPreprGate = async (workdir, baseRef) => worktreeDepsMissing !== null
1012
+ ? depsMissingGateResult(worktreeDepsMissing)
1013
+ : worktreeDepsStale !== null
1014
+ ? depsStaleGateResult(worktreeDepsStale.repoPath, worktreeDepsStale.detail)
1015
+ : runPreprGateRaw(workdir, baseRef);
750
1016
  const reconcileRework = deps.reconcileReworkBranch ?? defaultReconcileReworkBranch;
751
1017
  const revalidateSeq = deps.revalidateMigrationSeq ??
752
1018
  ((workdir, opts) => defaultRevalidateMigrationSeq(workdir, opts));
@@ -756,6 +1022,12 @@ export function runTask(taskId, deps) {
756
1022
  ((wd, repo) => ensureWorktreeNodeModules(wd, repo, {
757
1023
  onLog: (line) => process.stderr.write(`${line}\n`),
758
1024
  }));
1025
+ // WORKTREE-STALE-DEPS-1: default re-verifies seeded deps against the
1026
+ // worktree's CURRENT lockfile; logs via stderr so a doctor can see it.
1027
+ const verifyDepsFreshness = deps.verifyWorktreeDepsFreshness ??
1028
+ ((wd, repo) => verifyWorktreeDepsFreshness(wd, repo, {
1029
+ onLog: (line) => process.stderr.write(`${line}\n`),
1030
+ }));
759
1031
  // Silence the unused-binding lint for `checkoutBase` — v0.11-F supersedes
760
1032
  // the v0.6.0 REG-301 pre-spawn `git checkout <baseBranch>` with the
761
1033
  // worktree's `add -B <branch> <path> <baseBranch>` start-point semantics,
@@ -777,6 +1049,45 @@ export function runTask(taskId, deps) {
777
1049
  payload,
778
1050
  });
779
1051
  };
1052
+ /**
1053
+ * RUNNER-GATE-VISIBILITY-2: the ONE way this runner parks a task at `blocked`
1054
+ * (the transient capacity-pause hop excepted — it is `blocked`→`queued` inside
1055
+ * two awaits and already carries its own `capacity_paused:` reason).
1056
+ *
1057
+ * Live 2026-08-27: reworks blocked four times with `block_reason = null`, the
1058
+ * only trace of WHY living in the runner's local log — invisible on the board.
1059
+ * `acc.transition_task` has always stamped `acc.tasks.block_reason` and the
1060
+ * `task.status_changed` activity payload from `p_reason`; the runner just
1061
+ * never passed it. The `task.blocked` event next to it is the SECOND record:
1062
+ * it survives even when the RPC REJECTS the edge (invalid transition, task
1063
+ * already terminal) — exactly the case that previously left no trace at all.
1064
+ *
1065
+ * `reason` is a machine-readable token from the prepr-gate vocabulary
1066
+ * (`deps-missing`, `prepr_gate_failed:<gate>`, `gate_tool_crashed:<gate>`) or
1067
+ * a bare class like `rework_integration_conflict`; the class is everything
1068
+ * before the first colon. Never throws — a HALT is already the bad path, and
1069
+ * losing the audit line must not also lose the HALT.
1070
+ */
1071
+ const transitionBlocked = async (reason, context = {}) => {
1072
+ const { error } = await deps.supabase.rpc("transition_task", {
1073
+ p_task_id: taskId,
1074
+ p_new_status: "blocked",
1075
+ p_reason: reason,
1076
+ });
1077
+ if (error) {
1078
+ process.stderr.write(`[acc-runner] transition_task(blocked, ${reason}) failed for ${taskId}: ` +
1079
+ `${error.message}\n`);
1080
+ }
1081
+ await appendEvent(deps.supabase, taskId, "log", {
1082
+ stream: "stderr",
1083
+ event: "task.blocked",
1084
+ error_class: reason.split(":")[0],
1085
+ block_reason: reason,
1086
+ transition_ok: !error,
1087
+ runner_id: deps.session.runnerId,
1088
+ ...context,
1089
+ });
1090
+ };
780
1091
  // v0.6.1 (v0.11-D): release lock rows on every terminal exit path
781
1092
  // below the claim. Best-effort — a release failure logs to stderr
782
1093
  // but does not change the outcome the runner reports to the caller.
@@ -792,6 +1103,42 @@ export function runTask(taskId, deps) {
792
1103
  process.stderr.write(`[acc-runner] release_task_locks(${taskId}) failed: ${error.message}\n`);
793
1104
  }
794
1105
  };
1106
+ // RUNNER-CURRENT-TASK-DEAD-1: keep acc.runners.current_task_id tracking
1107
+ // reality. The column is READ by 13 sites (idle-first reviewer routing,
1108
+ // review assignment, lifecycle-reconcile's ownership gate, fleet-health "what
1109
+ // is this runner doing", the ops-board join) but was WRITTEN by nothing — the
1110
+ // only writer was runner-reconcile.ts, which CLEARS it — so every runner read
1111
+ // as idle while executing. We SET it to this task on a successful claim and
1112
+ // CLEAR it (guarded) on every post-claim exit via the outer finally, making
1113
+ // runner-reconcile.ts the backstop it was meant to be rather than the only
1114
+ // writer. This is a direct self-update, NOT an RPC: the runner's JWT `sub` is
1115
+ // its bound_user_id (api/runner/mint-token.ts), so the `runners_heartbeat_update`
1116
+ // RLS policy (bound_user_id = auth.uid()) authorises writing its own row;
1117
+ // adding an RPC would need a migration this task forbids. Best-effort: a
1118
+ // failed pointer write is logged but NEVER aborts or fails the task (parity
1119
+ // with releaseLocks / updateSignal above).
1120
+ const writeRunnerCurrentTask = async (value) => {
1121
+ try {
1122
+ let q = deps.supabase
1123
+ .from("runners")
1124
+ .update({ current_task_id: value })
1125
+ .eq("id", deps.session.runnerId);
1126
+ // Guard the CLEAR so a sibling task that claimed this runner after us
1127
+ // (concurrency > 1) is not clobbered — its own finally clears its claim.
1128
+ // The SET is unconditional: the newest claim wins the single-value column.
1129
+ if (value === null)
1130
+ q = q.eq("current_task_id", taskId);
1131
+ const { error } = await q;
1132
+ if (error) {
1133
+ process.stderr.write(`[acc-runner] runners.current_task_id write (${value ?? "null"}) ` +
1134
+ `failed for ${taskId}: ${error.message}\n`);
1135
+ }
1136
+ }
1137
+ catch (err) {
1138
+ process.stderr.write(`[acc-runner] runners.current_task_id write (${value ?? "null"}) ` +
1139
+ `threw for ${taskId}: ${err.message}\n`);
1140
+ }
1141
+ };
795
1142
  // v0.12-RESUME — periodic signal loop. After the claim succeeds we
796
1143
  // bump acc.tasks.last_runner_signal_at every signalIntervalMs ms so
797
1144
  // the v0.12 /5m sweep distinguishes "runner alive, work in flight"
@@ -869,6 +1216,33 @@ export function runTask(taskId, deps) {
869
1216
  // against other running tasks' locks, INSERTs a lock row +
870
1217
  // transitions to running in one transaction. On overlap or
871
1218
  // same-task race the task stays queued for another runner.
1219
+ //
1220
+ // CLAIM-LOCK-GLOB: resolve this task's FILE-granular lock
1221
+ // scope up front (AC1). declaredLockPaths keeps only concrete files from the
1222
+ // live allowed[] — a broad directory glob is authorization scope, not lock
1223
+ // scope, so it acquires no directory-wide lock. Migration 0353 mirrors this
1224
+ // in SQL (acc._declared_lock_paths) so the DB overlap key is file-granular
1225
+ // too; this read is a best-effort breadcrumb + input to the conflict
1226
+ // back-off below and never fails the claim.
1227
+ let claimLockScope = [];
1228
+ try {
1229
+ claimLockScope = declaredLockPaths(await readLiveAllowedGlobs(deps.supabase, taskId));
1230
+ }
1231
+ catch (err) {
1232
+ process.stderr.write(`[acc-runner] claim-lock scope read for ${taskId} failed (continuing): ${err.message}\n`);
1233
+ }
1234
+ // AC2: an empty declared set (no concrete files — an authorization-scope-only
1235
+ // glob task, or an empty allowed[]) holds NO file lock, so it never contends
1236
+ // on files and never serializes behind a directory-sharing sibling.
1237
+ await appendEvent(deps.supabase, taskId, "log", {
1238
+ phase: "claim_locks",
1239
+ stream: "stderr",
1240
+ lock_paths: claimLockScope,
1241
+ message: claimLockScope.length === 0
1242
+ ? "no concrete file locks (authorization-scope-only globs); claim does not contend on files"
1243
+ : `file-granular claim lock scope: ${claimLockScope.join(", ")}`,
1244
+ runner_id: deps.session.runnerId,
1245
+ });
872
1246
  const claim = await deps.supabase.rpc("claim_task_with_locks", {
873
1247
  p_task_id: taskId,
874
1248
  p_runner_id: deps.session.runnerId,
@@ -927,22 +1301,88 @@ export function runTask(taskId, deps) {
927
1301
  }
928
1302
  if (claimResult.ok !== true) {
929
1303
  conflicts = Array.isArray(claimResult.conflicts) ? claimResult.conflicts : conflicts;
930
- await appendEvent(deps.supabase, taskId, "log", {
931
- phase: "claim_locks",
932
- stream: "stderr",
933
- conflicts,
934
- message: `file-lock conflict other tasks hold overlapping paths: ${conflicts.join(", ")}`,
935
- runner_id: deps.session.runnerId,
1304
+ // Conflict set exactly [self] = the `same_task_different_runner`
1305
+ // refusal (returns array[p_task_id]); file-overlap can never contain
1306
+ // self. Evidence: docs/acc/RUNNER_CACHE_STATE.md.
1307
+ const otherConflicts = conflicts.filter((c) => c && c !== taskId);
1308
+ const selfConflictOnly = conflicts.includes(taskId) && otherConflicts.length === 0;
1309
+ if (selfConflictOnly) {
1310
+ await appendEvent(deps.supabase, taskId, "log", {
1311
+ phase: "claim_locks",
1312
+ stream: "stderr",
1313
+ conflicts,
1314
+ // Says what actually happens: this attempt ends; the task is left
1315
+ // to the lock holder. This branch requeues nothing.
1316
+ message: `file-lock claim race - ${taskId} is already locked by another runner; abandoning this attempt and leaving the task to the lock holder`,
1317
+ runner_id: deps.session.runnerId,
1318
+ });
1319
+ // Greppable breadcrumb for the shape that has twice sent an
1320
+ // investigation to the wrong subsystem. Best-effort only.
1321
+ // `benign`/`released_own_locks` are the diagnostic trail this
1322
+ // documents: this is a same-task claim race (not a fault), and
1323
+ // the loser leaves without touching the holder's lock row.
1324
+ try {
1325
+ await deps.supabase.rpc("log_activity", {
1326
+ p_verb: "runner.claim_self_conflict_race",
1327
+ p_target_id: deps.session.runnerId,
1328
+ p_payload: {
1329
+ task_id: taskId,
1330
+ conflicts,
1331
+ cause: "same_task_different_runner",
1332
+ benign: true,
1333
+ released_own_locks: false,
1334
+ },
1335
+ p_target_type: "runner",
1336
+ });
1337
+ }
1338
+ catch { /* breadcrumb only */ }
1339
+ return {
1340
+ taskId,
1341
+ status: "failed",
1342
+ phase: "claim_locks",
1343
+ error: `file-lock claim race: ${taskId} already locked by another runner`,
1344
+ };
1345
+ }
1346
+ // CLAIM-LOCK-GLOB: a real file-overlap CONTENTION — a
1347
+ // live task holds one of our concrete files. Advance the per-task
1348
+ // claim-loss state so the retry loop backs off after
1349
+ // CLAIM_LOCK_MAX_FAST_ATTEMPTS (AC3) and records exactly ONE conflict
1350
+ // event per contention episode (AC4) instead of one per re-dispatch.
1351
+ const lossState = deps.claimLossState ?? moduleClaimLossState;
1352
+ const nowMs = (deps.now ?? Date.now)();
1353
+ const decision = planClaimLockLoss(lossState.get(taskId), otherConflicts, nowMs);
1354
+ lossState.set(taskId, {
1355
+ attempts: decision.attempts,
1356
+ eventRecorded: decision.eventRecorded,
1357
+ firstConflictAtMs: decision.firstConflictAtMs,
936
1358
  });
937
- const reason = `file-lock conflict with: ${conflicts.join(", ") || "(unknown)"}`;
1359
+ if (decision.event) {
1360
+ await appendEvent(deps.supabase, taskId, "log", {
1361
+ phase: decision.event.phase,
1362
+ stream: "stderr",
1363
+ // Raw conflict set preserved verbatim for the audit trail; the
1364
+ // human message below names only the OTHER holders.
1365
+ conflicts,
1366
+ lock_paths: claimLockScope,
1367
+ message: `file-lock conflict - other tasks hold overlapping paths: ${otherConflicts.join(", ")}`,
1368
+ runner_id: deps.session.runnerId,
1369
+ });
1370
+ }
938
1371
  return {
939
1372
  taskId,
940
1373
  status: "failed",
941
1374
  phase: "claim_locks",
942
- error: reason,
1375
+ error: decision.event?.message
1376
+ ?? `file-lock conflict with: ${otherConflicts.join(", ") || "(unknown)"}`,
1377
+ claim_lock_attempts: decision.attempts,
1378
+ claim_lock_yield_until_ms: decision.yieldUntilMs,
943
1379
  };
944
1380
  }
945
1381
  }
1382
+ // CLAIM-LOCK-GLOB: the claim landed — this task is no
1383
+ // longer contending, so drop any accumulated claim-loss back-off state so a
1384
+ // future re-dispatch starts fresh from attempt 1.
1385
+ (deps.claimLossState ?? moduleClaimLossState).delete(taskId);
946
1386
  // v0.6.1 (v0.11-D): every exit path below the successful claim
947
1387
  // must release the lock row so the same paths free up for the
948
1388
  // next runner. try/finally captures returns AND uncaught throws
@@ -999,6 +1439,11 @@ export function runTask(taskId, deps) {
999
1439
  // RPC still runs the finally (stop loop, release locks).
1000
1440
  await updateSignal();
1001
1441
  scheduleSignal();
1442
+ // RUNNER-CURRENT-TASK-DEAD-1: now that the claim landed (tasks.runner_id is
1443
+ // this runner and the task is 'running'), stamp the runner's own pointer so
1444
+ // fleet health / reviewer routing see it as busy. Cleared unconditionally in
1445
+ // the outer finally on every exit below.
1446
+ await writeRunnerCurrentTask(taskId);
1002
1447
  // v0.14-MESSAGING-RUNTIME-WIRE: first bus event — runner has the
1003
1448
  // claim, will now fetch + spawn. planner subscribes via /messages.
1004
1449
  await postState_("planning", "info", { runner_id: deps.session.runnerId });
@@ -1030,12 +1475,41 @@ export function runTask(taskId, deps) {
1030
1475
  // pre-MM-1 row) resolves the same default Wave 1 always used, so this is
1031
1476
  // byte-identical for the all-claude-code fleet. Every spawn/parse/classify
1032
1477
  // call below drives THIS engine instead of a fixed module-level default.
1033
- const taskEngineId = task.engine ?? null;
1034
- const taskEngine = getEngine(taskEngineId);
1478
+ let taskEngineId = task.engine ?? null;
1479
+ // CAPS-ENGINES-STALE-KEYCHAIN-1 (AC12): guard the resolution — an engine the
1480
+ // router pinned but this live process cannot resolve is a caps self-
1481
+ // inconsistency. Drop it from caps (onUnknownAdvertisedEngine), emit the
1482
+ // distinct signal, and requeue — never surface a repeatable reviewer_error.
1483
+ let taskEngine;
1484
+ try {
1485
+ taskEngine = getEngine(taskEngineId);
1486
+ }
1487
+ catch (err) {
1488
+ // AC12: an engine the router pinned but this process cannot resolve is a
1489
+ // caps self-inconsistency. Drop it from caps (onUnknownAdvertisedEngine)
1490
+ // and emit the distinct signal so a repeat routing is prevented — THEN
1491
+ // re-throw, preserving upstream's unknown-engine guard (the task hard-fails
1492
+ // and never reaches a CLI; see task-runner-engine-dispatch.test.ts).
1493
+ // AC6: the event payload is redacted via safeText (CREDENTIAL_SHAPES).
1494
+ const safeMsg = safeText(err.message, 300) ?? "unresolved engine";
1495
+ const safeEngine = taskEngineId ? (safeText(taskEngineId, 80) ?? "<redacted>") : null;
1496
+ if (taskEngineId) {
1497
+ await deps.onUnknownAdvertisedEngine?.(taskEngineId, err);
1498
+ await appendEvent(deps.supabase, taskId, "runner.engine_caps_self_inconsistent", {
1499
+ engine: safeEngine,
1500
+ error: safeMsg,
1501
+ runner_id: deps.session.runnerId,
1502
+ });
1503
+ }
1504
+ throw err;
1505
+ }
1506
+ // Upstream (CAPACITY-EXIT-ENGINE-BLIND / engine fallback): kept intact.
1507
+ const fallbackPolicy = await loadEngineFallbackPolicy(deps.supabase);
1508
+ let codexApiKey = null;
1035
1509
  // `spawnClaude`/`healthProbe` bind here (not in the sync prologue above)
1036
1510
  // because their defaults need `taskEngineId`, which isn't known until now.
1037
1511
  const spawnClaude = deps.spawnClaude ??
1038
- ((cwd, modelId) => defaultSpawnClaude(cwd, modelId, taskEngineId));
1512
+ ((cwd, modelId) => defaultSpawnClaude(cwd, modelId, taskEngineId, codexApiKey));
1039
1513
  const healthProbe = deps.healthProbe ?? (() => defaultHealthProbe(taskEngineId));
1040
1514
  // LOCK-LIFECYCLE-WIRE: re-read the task's CURRENT file
1041
1515
  // scope from the live row so an operator who narrowed a QUEUED task between
@@ -1239,7 +1713,25 @@ export function runTask(taskId, deps) {
1239
1713
  });
1240
1714
  }
1241
1715
  }
1242
- const prompt = prelude ? `${prelude}\n\n${renderedPrompt}` : renderedPrompt;
1716
+ const preluded = prelude ? `${prelude}\n\n${renderedPrompt}` : renderedPrompt;
1717
+ // MEMORY-1: the task lane's memory pack. This is the point where task/agent
1718
+ // context is assembled for a run — the counterpart to
1719
+ // api/_lib/memory-pack/build.ts on the chat lane, which had no task-lane
1720
+ // equivalent before now. buildMemoryContext returns "" for a task with no
1721
+ // agent, an agent with no memory, or ANY error, so with an empty store
1722
+ // `prompt` is byte-identical to the pre-MEMORY-1 value. Only
1723
+ // operational-class rows are ever returned, and the section is bounded by a
1724
+ // TOKEN budget (<= 500 tokens, <= 5 rows) — see memory-context.ts.
1725
+ //
1726
+ // MEMORY-2: `taskText` drives the relevance gate. It is the same three
1727
+ // fields the prompt itself is built from — title, description, acceptance
1728
+ // criteria — so "relevant to this task" is judged against exactly what the
1729
+ // agent has been asked to do, and a task whose memory store holds nothing
1730
+ // topical gets no section at all.
1731
+ const memorySection = await buildMemoryContext(deps.supabase, result.agent?.id, {
1732
+ taskText: [task.title, task.description ?? "", ...task.acceptance].join(" \n "),
1733
+ });
1734
+ const prompt = memorySection ? `${preluded}\n\n${memorySection}` : preluded;
1243
1735
  // v0.11-F: acquire the per-task PID lock before any worktree
1244
1736
  // side-effect. Same-machine parallel runners that picked up the same
1245
1737
  // task_id (e.g. two `acc-runner watch` processes seeing the same
@@ -1369,6 +1861,164 @@ export function runTask(taskId, deps) {
1369
1861
  error: `conflict-resolution integrity guardrail rejected resolution (${summary})`,
1370
1862
  };
1371
1863
  };
1864
+ /**
1865
+ * PREPR-GATE-CRASH-CLASS-1: route a gate whose TOOL CRASHED (see
1866
+ * prepr-gate.ts `GATE_TOOL_CRASHED_CLASSIFICATION` for the 2026-08-28
1867
+ * incident) as INFRA, never as a verdict on the diff. A crashed tool judged
1868
+ * nothing, so: the task is NEVER transitioned to `failed`; an already-open
1869
+ * PR is NEVER discarded (the task stays where the PR flow expects it —
1870
+ * needs-review — and CI is the arbiter); otherwise it parks at `blocked`
1871
+ * with block_reason `gate_tool_crashed:<gate>`, which is still fail-CLOSED
1872
+ * because no PR is opened off a check that never ran.
1873
+ */
1874
+ const handleGateToolCrash = async (result, crash, rework) => {
1875
+ // Advisory probe: a GitHub hiccup must never be what decides this, so an
1876
+ // unreadable probe degrades to "no PR" — and that route is `blocked`,
1877
+ // never `failed`, anyway.
1878
+ let openPrUrl = null;
1879
+ try {
1880
+ openPrUrl = await gh.findOpenPR(workdir, branch);
1881
+ }
1882
+ catch {
1883
+ openPrUrl = null;
1884
+ }
1885
+ const prNumber = reworkMeta?.pr_number ?? task.pr_number ?? null;
1886
+ const summary = `pre-PR ${crash.gate} gate TOOL CRASHED (${crash.signature}, ` +
1887
+ `${crash.attempts} attempt(s)) — infra, not a verdict`;
1888
+ await appendEvent(deps.supabase, taskId, "error", {
1889
+ phase: "prepr_gate",
1890
+ error_class: GATE_TOOL_CRASHED_CLASSIFICATION,
1891
+ failed_gate: crash.gate,
1892
+ exit_code: crash.exitCode,
1893
+ signal: crash.signal,
1894
+ crash_signature: crash.signature,
1895
+ attempts: crash.attempts,
1896
+ block_reason: crash.blockReason,
1897
+ gates_ran: result.ran,
1898
+ pr_url: openPrUrl,
1899
+ pr_number: prNumber,
1900
+ rework,
1901
+ reviewer_notice: crash.reviewerNotice,
1902
+ stderr_tail: crash.outputTail,
1903
+ runner_id: deps.session.runnerId,
1904
+ });
1905
+ // A fresh task whose PR is ALREADY open (an earlier attempt pushed it):
1906
+ // CI is judging that exact head and a crashed local tool cannot overrule
1907
+ // it. Re-stamp the PR (running → needs-review; a no-op refresh when the
1908
+ // task is already needs-review) and hand it back to the PR flow. Rework
1909
+ // is excluded on purpose: its fixes were NOT pushed, so flipping it to
1910
+ // needs-review would re-review the identical head.
1911
+ if (openPrUrl && !rework) {
1912
+ await setTaskPr(deps.supabase, taskId, openPrUrl);
1913
+ await postState_("done", "info", {
1914
+ runner_id: deps.session.runnerId,
1915
+ pr_url: openPrUrl,
1916
+ error_class: GATE_TOOL_CRASHED_CLASSIFICATION,
1917
+ failed_gate: crash.gate,
1918
+ skipped_reason: "gate_tool_crashed_pr_open",
1919
+ });
1920
+ return { taskId, status: "ok", prUrl: openPrUrl, exitCode: 0 };
1921
+ }
1922
+ await postState_("blocked", "error_context", {
1923
+ task_id: taskId,
1924
+ phase: "review",
1925
+ error_class: GATE_TOOL_CRASHED_CLASSIFICATION,
1926
+ failed_gate: crash.gate,
1927
+ block_reason: crash.blockReason,
1928
+ pr_number: prNumber,
1929
+ stderr_tail: crash.reviewerNotice,
1930
+ });
1931
+ // A park that cannot land leaves the task exactly as it is — still never
1932
+ // `failed` — for the stale-running sweep / operator to pick up. RUNNER-
1933
+ // GATE-VISIBILITY-2 routes it through the shared parker so the crash's
1934
+ // OWN block_reason (built by prepr-gate.ts, never re-derived here) also
1935
+ // lands on a `task.blocked` event.
1936
+ await transitionBlocked(crash.blockReason, {
1937
+ phase: "prepr_gate",
1938
+ failed_gate: crash.gate,
1939
+ rework,
1940
+ });
1941
+ return {
1942
+ taskId,
1943
+ status: "failed",
1944
+ phase: "prepr_gate",
1945
+ exitCode: crash.exitCode,
1946
+ error: summary,
1947
+ };
1948
+ };
1949
+ /**
1950
+ * PREPR-CONFORMANCE-GATE-1: the mechanical pre-PR conformance pass, fired
1951
+ * only when a finding was COMPUTED. DEFAULT is ADVISORY (record + OPEN the
1952
+ * PR): a HARD-blocking check must be corpus-validated first, so until an
1953
+ * operator opts in a false positive can never block a real PR. ENFORCE
1954
+ * (opt-in) HARD-fails: fresh → `failed` with a REAL `error` task_event
1955
+ * carrying the findings, so failed-task-triage re-dispatches them
1956
+ * (classifyFixableFailure) under the bounded rework budget; rework → HALT
1957
+ * `blocked` (board + bus agree). ACC_RUNNER_SKIP_CONFORMANCE=1 bypasses it.
1958
+ */
1959
+ const runConformanceGate = async (rework, capturedStdout) => {
1960
+ const skip = (process.env.ACC_RUNNER_SKIP_CONFORMANCE ?? "").trim().toLowerCase();
1961
+ if (["1", "true", "yes", "on"].includes(skip))
1962
+ return null;
1963
+ // Same diff the PR-open path uses; one we cannot compute fails OPEN.
1964
+ const diff = await git
1965
+ .diff?.(workdir, `origin/${integrationBranch}`, branch)
1966
+ .catch(() => undefined);
1967
+ if (diff == null)
1968
+ return null;
1969
+ const report = extractReportFromOutput(capturedStdout, taskEngineId);
1970
+ let prBody = report;
1971
+ try {
1972
+ prBody = composePrBody(report, diff, { acceptance: task.acceptance });
1973
+ }
1974
+ catch { /* raw report */ }
1975
+ const result = evaluateConformance({
1976
+ diff, acceptance: task.acceptance, forbidden: task.forbidden, prBody,
1977
+ });
1978
+ if (result.ok)
1979
+ return null;
1980
+ const feedback = formatConformanceFindings(result.findings);
1981
+ const kinds = result.findings.map((f) => f.kind);
1982
+ // ADVISORY (default): record + OPEN THE PR. No fail, no block.
1983
+ if (!conformanceEnforceRequested(process.env)) {
1984
+ await appendEvent(deps.supabase, taskId, CONFORMANCE_EVENT_KIND, {
1985
+ phase: "conformance_gate", mode: "advisory",
1986
+ finding_kinds: kinds, findings: result.findings, rework, feedback,
1987
+ runner_id: deps.session.runnerId,
1988
+ });
1989
+ return null;
1990
+ }
1991
+ // ENFORCE: a REAL `error` task_event carries the findings (AC5) — its
1992
+ // error_class/phase route failed-task-triage to a bounded auto-rework that
1993
+ // feeds `stderr_tail` back as the retry (never a blind runner_drop retry).
1994
+ await appendEvent(deps.supabase, taskId, "error", {
1995
+ phase: "conformance_gate",
1996
+ error_class: CONFORMANCE_BLOCK_REASON,
1997
+ block_reason: CONFORMANCE_BLOCK_REASON,
1998
+ finding_kinds: kinds, findings: result.findings, rework,
1999
+ stderr_tail: feedback,
2000
+ runner_id: deps.session.runnerId,
2001
+ });
2002
+ if (rework) {
2003
+ // HALT for an operator: board `blocked` AND bus `blocked` — they AGREE.
2004
+ await postState_("blocked", "error_context", {
2005
+ task_id: taskId, phase: "review",
2006
+ error_class: CONFORMANCE_BLOCK_REASON, block_reason: CONFORMANCE_BLOCK_REASON,
2007
+ stderr_tail: feedback,
2008
+ });
2009
+ await transitionBlocked(CONFORMANCE_BLOCK_REASON, { phase: "conformance_gate", rework: true });
2010
+ }
2011
+ else {
2012
+ // Fresh → board `failed` (triage owns it). No contradictory `blocked` bus
2013
+ // post — a task failing into triage is not parked-for-operator.
2014
+ await deps.supabase.rpc("transition_task", { p_task_id: taskId, p_new_status: "failed" });
2015
+ }
2016
+ return {
2017
+ taskId, status: "failed", phase: "conformance_gate", exitCode: 1,
2018
+ error: `pre-PR conformance failed (${CONFORMANCE_BLOCK_REASON}: ${kinds.join(", ")}) — ` +
2019
+ (rework ? "re-push withheld for operator review" : "PR not opened"),
2020
+ };
2021
+ };
1372
2022
  /**
1373
2023
  * MIG-SEQ-GUARD: last thing before `git push`, re-validate this branch's
1374
2024
  * NEW migration seq against a FRESHLY-FETCHED integration head. The seq was
@@ -1419,6 +2069,13 @@ export function runTask(taskId, deps) {
1419
2069
  file: result.move.file,
1420
2070
  new_file: result.move.newFile,
1421
2071
  remote_max_seq: result.remoteMaxSeq ?? null,
2072
+ // AC4: the classification that decided this WAS the branch's own collision
2073
+ // to renumber (not an inherited migration — those are excluded, #2132).
2074
+ upstream_seq_collided: result.move.upstreamSeq,
2075
+ identical_modulo_seq: result.move.identicalModuloSeq,
2076
+ reachable_from_target: result.move.reachableFromTarget,
2077
+ // AC5: files rewritten OUTSIDE supabase/migrations/ (never silent).
2078
+ refs_rewritten_outside_migrations: result.move.refsRewrittenOutside,
1422
2079
  message: result.detail,
1423
2080
  renumber_output: result.move.output,
1424
2081
  runner_id: deps.session.runnerId,
@@ -1466,9 +2123,9 @@ export function runTask(taskId, deps) {
1466
2123
  // 3. Repo prep. v0.11-F: fetch on the shared clone (worktree
1467
2124
  // shares the object store) then provision an isolated worktree
1468
2125
  // at ~/.cache/acc-runner/work/<task_id>/ forked from the
1469
- // integration branch. The redundant `git.checkout -B <branch>`
1470
- // is a no-op inside the new worktree — kept so the v0.6.0
1471
- // checkout seam stays observable in unit tests.
2126
+ // integration branch. WORKTREE-BRANCH-COLLISION: the worktree is
2127
+ // checked out DETACHED (never on the task branch), so it reserves
2128
+ // no branch and cannot collide with a sibling instance's clone.
1472
2129
  try {
1473
2130
  // v0.19 T-64-1: cut a FRESH task branch from the LATEST
1474
2131
  // integration tip, not a stale LOCAL ref. `git.fetch` runs
@@ -1560,29 +2217,29 @@ export function runTask(taskId, deps) {
1560
2217
  taskId,
1561
2218
  branch,
1562
2219
  baseBranch: worktreeBaseBranch,
2220
+ owner: {
2221
+ runnerId: deps.session.runnerId,
2222
+ pid: process.pid,
2223
+ startedAt: new Date().toISOString(),
2224
+ },
1563
2225
  });
1564
2226
  });
1565
2227
  workdir = worktree.path;
1566
- // v0.12-RESUME: log resume-vs-fresh so an operator can audit
1567
- // how often the resume path actually fires. `resumed: true`
1568
- // means a prior runner crashed mid-task, the v0.12 sweep
1569
- // returned the task to queued, and this runner picked it
1570
- // back up with the prior worktree intact. Claude reads the
1571
- // partially-committed state and continues; the spawn is the
1572
- // same prompt either way (Claude is idempotent enough that
1573
- // re-running on a partially-edited worktree converges on
1574
- // the right final state).
1575
- if (worktree.resumed) {
1576
- await appendEvent(deps.supabase, taskId, "log", {
1577
- phase: "git",
1578
- stream: "stdout",
1579
- event: "worktree.resumed",
1580
- worktree_path: workdir,
1581
- branch,
1582
- runner_id: deps.session.runnerId,
1583
- });
1584
- }
1585
- await git.checkout(workdir, branch);
2228
+ // Log resume-vs-fresh, and record worktree_path on SUCCESS too (not
2229
+ // only on resume/failure) so a later-abandoned worktree is
2230
+ // discoverable for salvage by path.
2231
+ await appendEvent(deps.supabase, taskId, "log", {
2232
+ phase: "git",
2233
+ event: worktree.resumed ? "worktree.resumed" : "worktree.ready",
2234
+ worktree_path: workdir,
2235
+ branch,
2236
+ runner_id: deps.session.runnerId,
2237
+ });
2238
+ // WORKTREE-BRANCH-COLLISION: the worktree stays on a DETACHED HEAD for
2239
+ // its whole life so it never reserves the task branch in the shared
2240
+ // clone (checking the branch out here would re-introduce the exit-128
2241
+ // collision and re-poison the branch on abandonment). The branch is
2242
+ // materialized only at push time (git.push -> HEAD:refs/heads/<branch>).
1586
2243
  // FU-9 (CHAT-RT): seed node_modules into the fresh worktree NOW — before
1587
2244
  // Claude spawns AND before the pre-PR gate — so `pnpm typecheck` finds
1588
2245
  // tsc/eslint/vite instead of dying "sh: tsc: command not found" (a fresh
@@ -1592,6 +2249,8 @@ export function runTask(taskId, deps) {
1592
2249
  try {
1593
2250
  const dep = await ensureWorktreeDeps(workdir, repoPath);
1594
2251
  if (dep.linked.length > 0 || dep.installed) {
2252
+ // WORKTREE-STALE-DEPS-1 (AC3): the parent's lockfile identity — what the symlinked deps satisfy.
2253
+ const lockfileHash = await lockfileIdentity(repoPath);
1595
2254
  await appendEvent(deps.supabase, taskId, "log", {
1596
2255
  phase: "git",
1597
2256
  stream: "stdout",
@@ -1599,6 +2258,25 @@ export function runTask(taskId, deps) {
1599
2258
  linked: dep.linked,
1600
2259
  installed: dep.installed,
1601
2260
  detail: dep.detail,
2261
+ lockfile_hash: lockfileHash,
2262
+ runner_id: deps.session.runnerId,
2263
+ });
2264
+ }
2265
+ else if (dep.depsMissing) {
2266
+ // RUNNER-GATE-VISIBILITY-2: arm the `deps` short-circuit NOW, while
2267
+ // we still know WHICH parent clone is cold, and record it here so
2268
+ // the operator sees the environment fault the moment it happens —
2269
+ // not only when the gate later HALTs, and never disguised as a
2270
+ // typecheck failure.
2271
+ worktreeDepsMissing = repoPath;
2272
+ await appendEvent(deps.supabase, taskId, "log", {
2273
+ phase: "git",
2274
+ stream: "stderr",
2275
+ event: "worktree.deps_missing",
2276
+ error_class: DEPS_MISSING_CLASSIFICATION,
2277
+ block_reason: DEPS_MISSING_CLASSIFICATION,
2278
+ repo_path: repoPath,
2279
+ detail: dep.detail,
1602
2280
  runner_id: deps.session.runnerId,
1603
2281
  });
1604
2282
  }
@@ -1609,6 +2287,26 @@ export function runTask(taskId, deps) {
1609
2287
  }
1610
2288
  catch (err) {
1611
2289
  const msg = err.message;
2290
+ // WORKTREE-BRANCH-COLLISION-ACROSS-INSTANCES-1: the task branch is held
2291
+ // by a worktree in ANOTHER instance (or an abandoned worktree). TRANSIENT
2292
+ // infra, not a task failure: requeue (never dead-letter / rework-cycle /
2293
+ // retry-cap burn), mirroring the git_provision_contention exemption below.
2294
+ // The payload names the holder path + instance and our worktree path.
2295
+ if (err instanceof WorktreeBranchTakenError) {
2296
+ await appendEvent(deps.supabase, taskId, "log", {
2297
+ phase: "git",
2298
+ event: err.event,
2299
+ error_class: err.errorClass,
2300
+ error: msg,
2301
+ branch,
2302
+ holder_path: err.holderPath,
2303
+ holder_instance: err.holderInstance,
2304
+ worktree_path: err.worktreePath,
2305
+ runner_id: deps.session.runnerId,
2306
+ });
2307
+ await deps.supabase.rpc("transition_task", { p_task_id: taskId, p_new_status: "queued" });
2308
+ return { taskId, status: "requeued", phase: "git", error: `${err.errorClass}: ${msg}` };
2309
+ }
1612
2310
  // v0.21 T-66-1: a git REPO-GLOBAL LOCK collision during provisioning
1613
2311
  // (an external git process touched the clone while we held the
1614
2312
  // provision mutex) is TRANSIENT — requeue the task so it re-provisions
@@ -1679,6 +2377,7 @@ export function runTask(taskId, deps) {
1679
2377
  };
1680
2378
  }
1681
2379
  if (reconcile.status === "halt") {
2380
+ const blockReason = "rework_integration_conflict";
1682
2381
  await appendEvent(deps.supabase, taskId, "rework_halt", {
1683
2382
  phase: "reconcile",
1684
2383
  pr_number: reworkMeta.pr_number,
@@ -1686,6 +2385,7 @@ export function runTask(taskId, deps) {
1686
2385
  branch,
1687
2386
  integration_ref: reconcile.integrationRef,
1688
2387
  conflicted: reconcile.conflicted,
2388
+ block_reason: blockReason,
1689
2389
  runner_id: deps.session.runnerId,
1690
2390
  });
1691
2391
  // HALT, NOT dead-letter: blocked → operator review, no re-review
@@ -1693,13 +2393,15 @@ export function runTask(taskId, deps) {
1693
2393
  await postState_("blocked", "error_context", {
1694
2394
  task_id: taskId,
1695
2395
  phase: "plan",
1696
- error_class: "rework_integration_conflict",
2396
+ error_class: blockReason,
2397
+ block_reason: blockReason,
1697
2398
  stderr_tail: `rework branch conflicts with ${reconcile.integrationRef} — ` +
1698
2399
  `needs human merge: ${reconcile.conflicted.join(", ")}`,
1699
2400
  });
1700
- await deps.supabase.rpc("transition_task", {
1701
- p_task_id: taskId,
1702
- p_new_status: "blocked",
2401
+ await transitionBlocked(blockReason, {
2402
+ phase: "reconcile",
2403
+ integration_ref: reconcile.integrationRef,
2404
+ conflicted: reconcile.conflicted,
1703
2405
  });
1704
2406
  return {
1705
2407
  taskId,
@@ -1717,6 +2419,39 @@ export function runTask(taskId, deps) {
1717
2419
  auto_resolved: reconcile.autoResolved,
1718
2420
  runner_id: deps.session.runnerId,
1719
2421
  });
2422
+ // WORKTREE-STALE-DEPS-1: the merge above may have moved this branch's
2423
+ // lockfile past what the parent clone (and so the SYMLINKED deps
2424
+ // already seeded into this worktree) last installed. Re-verify and
2425
+ // repair before Claude/the gate see the worktree. Skipped on a no-op
2426
+ // merge ("already up to date") — nothing could have drifted.
2427
+ if (reconcile.merged) {
2428
+ try {
2429
+ const freshness = await verifyDepsFreshness(workdir, repoPath);
2430
+ // Distinct classification from `worktreeDepsMissing` — deps were
2431
+ // seeded here, they're just stale relative to the merged lockfile.
2432
+ if (!freshness.fresh)
2433
+ worktreeDepsStale = { repoPath, detail: freshness.detail };
2434
+ await appendEvent(deps.supabase, taskId, "log", {
2435
+ phase: "reconcile",
2436
+ stream: freshness.fresh ? "stdout" : "stderr",
2437
+ event: freshness.fresh ? "worktree.deps_ready" : "worktree.deps_stale",
2438
+ reverified: true,
2439
+ lockfile_hash: freshness.lockfileHash,
2440
+ parent_lockfile_hash: freshness.parentLockfileHash,
2441
+ installed: freshness.installed,
2442
+ detail: freshness.detail,
2443
+ ...(freshness.fresh ? {} : {
2444
+ error_class: DEPS_STALE_CLASSIFICATION,
2445
+ block_reason: DEPS_STALE_CLASSIFICATION,
2446
+ repo_path: repoPath,
2447
+ }),
2448
+ runner_id: deps.session.runnerId,
2449
+ });
2450
+ }
2451
+ catch (freshErr) {
2452
+ process.stderr.write(`[acc-runner] verifyDepsFreshness(${taskId}) failed: ${freshErr.message}\n`);
2453
+ }
2454
+ }
1720
2455
  }
1721
2456
  // 4. Spawn Claude.
1722
2457
  if (cancelled) {
@@ -1741,6 +2476,7 @@ export function runTask(taskId, deps) {
1741
2476
  throw probeErr;
1742
2477
  }
1743
2478
  if (existingPrUrl) {
2479
+ const existingPrNumber = parsePrNumber(existingPrUrl);
1744
2480
  await appendEvent(deps.supabase, taskId, "log", {
1745
2481
  phase: "coding",
1746
2482
  stream: "stdout",
@@ -1749,6 +2485,13 @@ export function runTask(taskId, deps) {
1749
2485
  pr_url: existingPrUrl,
1750
2486
  message: "open PR already exists for branch — skipping Claude Code spawn",
1751
2487
  });
2488
+ await recordPreprGateMissing(deps.supabase, taskId, {
2489
+ branch,
2490
+ pr_number: existingPrNumber,
2491
+ pr_url: existingPrUrl,
2492
+ reason: "retry_pr_exists_before_spawn",
2493
+ runner_id: deps.session.runnerId,
2494
+ });
1752
2495
  // v0.35-B: stamp pr_number + transition running→needs-review on
1753
2496
  // the recovered task. On any failure the webhook-driven flow
1754
2497
  // still moves running→done at merge time (matrix 0156).
@@ -1774,8 +2517,11 @@ export function runTask(taskId, deps) {
1774
2517
  // is a context source, not a critical dependency for v0.5-C1.
1775
2518
  let mcpCleanup = null;
1776
2519
  if (deps.session) {
2520
+ const readMcpCfg = deps.readMcpConfig ?? defaultReadMcpConfig;
1777
2521
  const writer = deps.writeMcpConfig ?? defaultWriteMcpConfig;
1778
2522
  try {
2523
+ // Read through the seam, then hand the snapshot to the writer (single read).
2524
+ const existing = await readMcpCfg(workdir);
1779
2525
  mcpCleanup = await writer({
1780
2526
  cwd: workdir,
1781
2527
  taskId,
@@ -1784,7 +2530,7 @@ export function runTask(taskId, deps) {
1784
2530
  publicUrl: deps.publicUrl ?? deps.cfg.publicUrl,
1785
2531
  supabaseUrl: deps.cfg.supabaseUrl,
1786
2532
  supabaseAnonKey: deps.cfg.supabaseAnonKey,
1787
- });
2533
+ }, { existing });
1788
2534
  }
1789
2535
  catch (err) {
1790
2536
  // Don't echo the token even on failure.
@@ -1797,12 +2543,32 @@ export function runTask(taskId, deps) {
1797
2543
  // through verbatim so a future model not yet in the embedded
1798
2544
  // table still spawns.
1799
2545
  //
2546
+ // SEED-MODEL-REGISTRY-1 AC2 (b): resolveSpawnModel gates the translation. The whole
2547
+ // acc.models row is in fetch_task_for_runner's to_jsonb(m) — FetchTaskResult
2548
+ // under-declares it — so read cli_alias/enabled via a local widening (no migration).
2549
+ const pinnedModel = result.model;
2550
+ const spawnModel = resolveSpawnModel(pinnedModel);
2551
+ //
1800
2552
  // v0.53 T-53-4: one claude attempt — spawn, stream both pipes to the
1801
2553
  // History tab, post a cost event (one row per attempt), and time the
1802
2554
  // run so the classifier's fast-exit env_broken heuristic can fire.
2555
+ let useRoutedModel = true;
2556
+ let pinRefusalLogged = false;
1803
2557
  const runClaudeOnce = async () => {
1804
2558
  const spawnedAt = Date.now();
1805
- child = spawnClaude(workdir, toCliAlias(result.model?.id));
2559
+ child = spawnClaude(workdir, useRoutedModel ? spawnModel.model : undefined);
2560
+ // Log the refused pin once, only on the routed path (shouldLogPinRefusal).
2561
+ if (shouldLogPinRefusal(useRoutedModel, spawnModel) && !pinRefusalLogged) {
2562
+ pinRefusalLogged = true;
2563
+ await appendEvent(deps.supabase, taskId, "log", {
2564
+ phase: "coding",
2565
+ stream: "stderr",
2566
+ event: "model.pin_refused",
2567
+ model_id: pinnedModel?.id ?? null,
2568
+ reason: spawnModel.refusedReason,
2569
+ runner_id: deps.session.runnerId,
2570
+ });
2571
+ }
1806
2572
  if (child.stdin) {
1807
2573
  child.stdin.write(prompt);
1808
2574
  child.stdin.end();
@@ -1822,7 +2588,7 @@ export function runTask(taskId, deps) {
1822
2588
  // cancellation, retry, and non-zero exit alike. Best-effort: a
1823
2589
  // failed POST logs to stderr but never bubbles past the runner.
1824
2590
  {
1825
- const event = buildCostEvent(taskId, capturedStdout, result.model?.id, result.runner?.id, taskEngineId);
2591
+ const event = buildCostEventForRun(taskId, capturedStdout, result.model?.id, result.runner?.id, taskEngineId);
1826
2592
  try {
1827
2593
  await postCostEvent(event);
1828
2594
  }
@@ -1863,11 +2629,13 @@ export function runTask(taskId, deps) {
1863
2629
  if (a.exitCode !== 0) {
1864
2630
  return taskEngine.classifyFailure(a.exitCode, a.stderr, a.stdout, a.durationMs);
1865
2631
  }
1866
- if (isCapacityExhausted(a.stdout, a.stderr)) {
2632
+ if (taskEngine.classifyZeroExit)
2633
+ return taskEngine.classifyZeroExit(a.stderr, a.stdout, a.durationMs);
2634
+ if (taskEngine.id !== "claude-code" && a.stderr.trim().length === 0 && a.stdout.trim().length === 0) {
1867
2635
  return {
1868
- exitCode: a.exitCode,
1869
- class: "capacity_exhausted",
1870
- detail: "Claude session/usage capacity reached (429) on a 0-exit envelope runner will pause until reset",
2636
+ exitCode: 0,
2637
+ class: "unknown_fault",
2638
+ detail: `${taskEngine.id} exited 0 with no stdout/stderr and no result envelope`,
1871
2639
  };
1872
2640
  }
1873
2641
  return null;
@@ -1889,6 +2657,81 @@ export function runTask(taskId, deps) {
1889
2657
  instantEmptyStreak = 2;
1890
2658
  }
1891
2659
  }
2660
+ // Codex subscription-first: retry the same task immediately on the
2661
+ // metered API-key channel before pausing, quarantining, or burning a
2662
+ // task retry. The engine env shaper strips the key on attempt one and
2663
+ // forwards it only after this explicit preference flip.
2664
+ if (classified?.class === "subscription_exhausted" &&
2665
+ taskEngine.id === "codex" &&
2666
+ !cancelled) {
2667
+ // Resolve the key from env OR the OS keychain (the runner's documented
2668
+ // credential store). The env-only gate silently skipped the spill for a
2669
+ // keychain-authed runner. Keychain read is awaited only here, after a
2670
+ // real codex cap-hit, never on the hot path.
2671
+ const spillKey = openaiApiKey() || (await openaiApiKeyFromKeychain());
2672
+ if (spillKey.length > 0) {
2673
+ await appendEvent(deps.supabase, taskId, "log", {
2674
+ phase: "codex_auth_spill",
2675
+ from: "subscription",
2676
+ to: "api-key",
2677
+ detail: classified.detail,
2678
+ });
2679
+ codexApiKey = spillKey;
2680
+ attempt = await runClaudeOnce();
2681
+ classified = classifyIfFailed(attempt);
2682
+ }
2683
+ }
2684
+ // The legacy Settings fields name the target a "model", but deployments
2685
+ // store an engine id (notably "codex"). Honor that promise on a hard
2686
+ // usage wall. `wantsFallback` is the POLICY decision (org opted in, a
2687
+ // distinct target is configured, and we actually hit a usage wall);
2688
+ // whether THIS runner can serve it is a separate check below, so a
2689
+ // fleet-level gap surfaces loudly instead of collapsing into the policy
2690
+ // gate and vanishing.
2691
+ const fallbackId = fallbackPolicy.engine;
2692
+ const wantsFallback = classified?.class === "usage_limit" &&
2693
+ fallbackPolicy.enabled &&
2694
+ !!fallbackId &&
2695
+ fallbackId !== taskEngine.id &&
2696
+ !cancelled;
2697
+ if (wantsFallback && fallbackId && hasEngine(fallbackId)) {
2698
+ const from = taskEngine.id;
2699
+ taskEngineId = fallbackId;
2700
+ taskEngine = getEngine(fallbackId);
2701
+ codexApiKey = null;
2702
+ useRoutedModel = false;
2703
+ await appendEvent(deps.supabase, taskId, "log", {
2704
+ phase: "engine_fallback",
2705
+ from,
2706
+ to: fallbackId,
2707
+ reason: "usage_limit",
2708
+ });
2709
+ attempt = await runClaudeOnce();
2710
+ classified = classifyIfFailed(attempt);
2711
+ }
2712
+ else if (wantsFallback && fallbackId) {
2713
+ // The org configured a usage_limit fallback to an engine THIS runner
2714
+ // is not registered to serve (`hasEngine(fallbackId)` is false). A
2715
+ // single serving runner cannot see the whole fleet's capability, so it
2716
+ // cannot know whether some OTHER runner could serve `fallbackId` — that
2717
+ // cross-runner reroute is a scheduler concern (follow-up FLEET-FALLBACK
2718
+ // -REROUTE). What it MUST NOT do is drop the configured fallback in
2719
+ // silence: that is exactly how fleet-level unavailability hides. So we
2720
+ // surface it as a per-task warning on the timeline AND in the runner
2721
+ // log. Per-task emission means there is no global-by-verb dedup that a
2722
+ // later change to a different fallback engine or org could be
2723
+ // suppressed by — every task that hits an unservable fallback records
2724
+ // its own event, tagged with the specific target.
2725
+ await appendEvent(deps.supabase, taskId, "log", {
2726
+ phase: "engine_fallback_unservable",
2727
+ from: taskEngine.id,
2728
+ fallback: fallbackId,
2729
+ reason: "not_registered_on_runner",
2730
+ });
2731
+ process.stderr.write(`[acc-runner] engine fallback to "${fallbackId}" is configured but not ` +
2732
+ `registered on this runner (task ${taskId}); the ${taskEngine.id} ` +
2733
+ `usage_limit outcome stands — no other runner is auto-tried from here\n`);
2734
+ }
1892
2735
  // Restore .mcp.json as soon as Claude is done — its MCP subprocess
1893
2736
  // tree comes down with it, so leaving our token-bearing config on
1894
2737
  // disk a moment longer is pure exposure surface.
@@ -1910,7 +2753,7 @@ export function runTask(taskId, deps) {
1910
2753
  // on an exit-0 capacity envelope (see classifyIfFailed) — the latter must
1911
2754
  // NOT reach the success path. The capacity_exhausted branch below always
1912
2755
  // returns, so no other exit-0 handling is affected.
1913
- if (attempt.exitCode !== 0 || classified?.class === "capacity_exhausted") {
2756
+ if (attempt.exitCode !== 0 || classified !== null) {
1914
2757
  // v0.48: classify the failure so the quarantine cause is consistent
1915
2758
  // across the event, the bus message, and the RunTaskOutcome. `let`
1916
2759
  // because v0.63's health probe can upgrade a claude_unavailable exit
@@ -1926,7 +2769,13 @@ export function runTask(taskId, deps) {
1926
2769
  // cold-started re-run hits the SAME cap, and it loops — burning a
1927
2770
  // little capacity per cold start and never committing (the EG-B
1928
2771
  // reseed signature: runner online + current_task_id=null while the
1929
- // task says running; 2 zombie recoveries, 0 commits).
2772
+ // task says running; 2 zombie recoveries, 0 commits). NB (RUNNER-
2773
+ // CURRENT-TASK-DEAD-1): that "current_task_id=null" is a HISTORICAL
2774
+ // symptom description, not a live signal read — nothing here reads the
2775
+ // column, and now that the outer finally clears it on this very path a
2776
+ // paused runner also stops advertising a stale claim. This is unrelated
2777
+ // to ci.sibling_reseed_intervention (a post-merge vis-reg baseline
2778
+ // reseed in sibling-reseed-sweep.ts that never touches current_task_id).
1930
2779
  //
1931
2780
  // Fix: CLEANLY REQUEUE NOW instead of waiting for the sweep. Get the
1932
2781
  // task back to 'queued' via the runner-callable matrix route
@@ -1959,33 +2808,85 @@ export function runTask(taskId, deps) {
1959
2808
  // reason for the audit trail. Errors are surfaced but never abort the
1960
2809
  // pause: if the requeue can't land, the existing stale-running sweep
1961
2810
  // is still the backstop (same as the v0.56 behaviour this replaces).
1962
- const blockReason = `capacity_paused: session/usage cap reached mid-task; requeued${resumeAt ? ` until ${resumeAt}` : ""}`;
1963
- const toBlocked = await deps.supabase.rpc("transition_task", {
1964
- p_task_id: taskId,
1965
- p_new_status: "blocked",
1966
- p_reason: blockReason,
1967
- });
1968
- if (toBlocked.error) {
1969
- process.stderr.write(`[acc-runner] FH-1a capacity requeue: running→blocked failed for ${taskId}: ` +
1970
- `${toBlocked.error.message} falling back to stale-running sweep\n`);
2811
+ const prLinkedTask = task;
2812
+ let linkedPrUrl = typeof prLinkedTask.pr_html_url === "string" && prLinkedTask.pr_html_url.trim().length > 0
2813
+ ? prLinkedTask.pr_html_url.trim()
2814
+ : null;
2815
+ let linkedPrNumber = typeof task.pr_number === "number" && Number.isFinite(task.pr_number)
2816
+ ? task.pr_number
2817
+ : parsePrNumber(linkedPrUrl);
2818
+ // The task snapshot was fetched at claim time, BEFORE the Claude
2819
+ // session ran. When the session opens a PR and THEN hits the capacity
2820
+ // cap, `task.pr_number`/`task.pr_html_url` are still the stale null from
2821
+ // that snapshot — the session-exits-after-PR-open shape the snapshot-only
2822
+ // check missed. So if the snapshot shows no linked PR, ask GitHub for an
2823
+ // open PR on the branch (the same probe the idempotency guard uses) to
2824
+ // catch a PR the session opened mid-flight. Best-effort: a probe failure
2825
+ // degrades to the snapshot-only view (the requeue path) and never aborts
2826
+ // the pause.
2827
+ if (linkedPrNumber === null) {
2828
+ try {
2829
+ const probedPrUrl = await gh.findOpenPR(workdir, branch);
2830
+ if (probedPrUrl) {
2831
+ linkedPrUrl = probedPrUrl;
2832
+ linkedPrNumber = parsePrNumber(probedPrUrl);
2833
+ }
2834
+ }
2835
+ catch (probeErr) {
2836
+ process.stderr.write(`[acc-runner] FH-1a capacity requeue: open-PR probe failed for ${taskId}: ` +
2837
+ `${probeErr.message} — using task snapshot only\n`);
2838
+ }
2839
+ }
2840
+ let requeued = false;
2841
+ if (linkedPrNumber !== null) {
2842
+ await recordPreprGateMissing(deps.supabase, taskId, {
2843
+ branch,
2844
+ pr_number: linkedPrNumber,
2845
+ pr_url: linkedPrUrl,
2846
+ reason: "capacity_exit_after_pr_open",
2847
+ runner_id: deps.session.runnerId,
2848
+ });
2849
+ if (linkedPrUrl)
2850
+ await setTaskPr(deps.supabase, taskId, linkedPrUrl);
1971
2851
  }
1972
2852
  else {
1973
- const toQueued = await deps.supabase.rpc("transition_task", {
2853
+ const blockReason = `capacity_paused: session/usage cap reached mid-task; requeued${resumeAt ? ` until ${resumeAt}` : ""}`;
2854
+ const toBlocked = await deps.supabase.rpc("transition_task", {
1974
2855
  p_task_id: taskId,
1975
- p_new_status: "queued",
2856
+ p_new_status: "blocked",
2857
+ p_reason: blockReason,
1976
2858
  });
1977
- if (toQueued.error) {
1978
- process.stderr.write(`[acc-runner] FH-1a capacity requeue: blocked→queued failed for ${taskId}: ` +
1979
- `${toQueued.error.message} — task left 'blocked', sweep/operator will recover\n`);
2859
+ if (toBlocked.error) {
2860
+ process.stderr.write(`[acc-runner] FH-1a capacity requeue: running→blocked failed for ${taskId}: ` +
2861
+ `${toBlocked.error.message} — falling back to stale-running sweep\n`);
2862
+ }
2863
+ else {
2864
+ const toQueued = await deps.supabase.rpc("transition_task", {
2865
+ p_task_id: taskId,
2866
+ p_new_status: "queued",
2867
+ });
2868
+ if (toQueued.error) {
2869
+ process.stderr.write(`[acc-runner] FH-1a capacity requeue: blocked→queued failed for ${taskId}: ` +
2870
+ `${toQueued.error.message} — task left 'blocked', sweep/operator will recover\n`);
2871
+ }
2872
+ else {
2873
+ requeued = true;
2874
+ }
1980
2875
  }
1981
2876
  }
1982
2877
  await appendEvent(deps.supabase, taskId, "capacity_paused", {
1983
2878
  phase: "claude_exit",
2879
+ engine: taskEngine.id,
1984
2880
  exit_code: attempt.exitCode,
1985
2881
  exit_class: cls.class,
1986
2882
  resume_at: resumeAt,
1987
- requeued: true,
2883
+ requeued,
2884
+ pr_number: linkedPrNumber,
2885
+ pr_url: linkedPrUrl,
2886
+ gate_verdict: linkedPrNumber !== null ? "missing" : undefined,
1988
2887
  detail: cls.detail,
2888
+ stdout_tail: attempt.stdout.slice(-2000),
2889
+ stderr_tail: attempt.stderr.slice(-2000),
1989
2890
  runner_id: deps.session.runnerId,
1990
2891
  });
1991
2892
  // FH-1a: task-scoped activity event carrying pausedUntil so board /
@@ -2000,8 +2901,14 @@ export function runTask(taskId, deps) {
2000
2901
  p_payload: {
2001
2902
  task_id: taskId,
2002
2903
  paused_until: resumeAt,
2003
- requeued: true,
2904
+ requeued,
2905
+ pr_number: linkedPrNumber,
2906
+ pr_url: linkedPrUrl,
2907
+ gate_verdict: linkedPrNumber !== null ? "missing" : undefined,
2004
2908
  detail: cls.detail,
2909
+ engine: taskEngine.id,
2910
+ stdout_tail: attempt.stdout.slice(-4000),
2911
+ stderr_tail: attempt.stderr.slice(-4000),
2005
2912
  runner_id: deps.session.runnerId,
2006
2913
  },
2007
2914
  });
@@ -2014,6 +2921,7 @@ export function runTask(taskId, deps) {
2014
2921
  exitCode: attempt.exitCode,
2015
2922
  error: cls.detail,
2016
2923
  capacity_exhausted: true,
2924
+ engine: taskEngine.id,
2017
2925
  resume_at: resumeAt,
2018
2926
  };
2019
2927
  }
@@ -2123,24 +3031,60 @@ export function runTask(taskId, deps) {
2123
3031
  // (heuristic-confirmed).
2124
3032
  const quarantineCause = cls.class !== "task_error" ? cls.class : undefined;
2125
3033
  const quarantineConsecutive = quarantineCause === "env_broken" ? Math.max(instantEmptyStreak, 1) : undefined;
3034
+ // USAGE-LIMIT-INVISIBLE-1: a usage_limit quarantine still deserves a
3035
+ // fleet-visible capacity signal (watch.ts folds it into the account
3036
+ // window / fleet_pause below) — extract whatever reset hint the
3037
+ // provider gave us with the SAME parser capacity_exhausted uses, so a
3038
+ // resettable subscription window (the live 2026-08-30 incident: four
3039
+ // usage_limit exits with no capacity_paused signal anywhere) is not
3040
+ // silently discarded. `null` means no reset was found — an INDEFINITE
3041
+ // cap, never a guessed backoff (this class is not assumed recoverable
3042
+ // soon; see the class doc at the top of failure-classifier.ts).
3043
+ const usageLimitResumeMs = cls.class === "usage_limit"
3044
+ ? extractResetTime(`${attempt.stderr}\n${attempt.stdout}`)
3045
+ : null;
3046
+ const usageLimitResumeAt = usageLimitResumeMs !== null ? new Date(usageLimitResumeMs).toISOString() : null;
3047
+ const usageLimitIdentity = cls.class === "usage_limit"
3048
+ ? await (deps.credentialIdentity?.() ?? resolveCredentialIdentity())
3049
+ : null;
3050
+ const diagnostics = failureDiagnostics({
3051
+ exitCode: attempt.exitCode,
3052
+ stdout: attempt.stdout,
3053
+ stderr: attempt.stderr,
3054
+ detail: cls.detail,
3055
+ label: taskEngine.id,
3056
+ });
2126
3057
  await appendEvent(deps.supabase, taskId, "error", {
2127
3058
  phase: "claude_exit",
3059
+ engine: taskEngine.id,
2128
3060
  exit_code: attempt.exitCode,
2129
3061
  exit_class: cls.class,
2130
3062
  exit_class_heuristic: cls.heuristic === true,
2131
3063
  consecutive_instant_empty: instantEmptyStreak,
2132
3064
  quarantine: quarantineCause !== undefined,
2133
- stderr_tail: attempt.stderr.slice(-2000),
3065
+ reasons: diagnostics.reasons,
3066
+ stdout_tail: diagnostics.stdoutTail,
3067
+ stderr_tail: diagnostics.stderrTail,
3068
+ ...(cls.class === "usage_limit"
3069
+ ? {
3070
+ resume_at: usageLimitResumeAt,
3071
+ capacity_indefinite: usageLimitResumeAt === null,
3072
+ ...usageLimitIdentity,
3073
+ }
3074
+ : {}),
2134
3075
  });
2135
3076
  await postState_("blocked", "error_context", {
2136
3077
  task_id: taskId,
2137
3078
  phase: "code",
3079
+ engine: taskEngine.id,
2138
3080
  error_class: `claude_exit_${attempt.exitCode ?? "unknown"}`,
2139
3081
  // v0.48: include the classified failure class so the planner can
2140
3082
  // distinguish machine-level failures from task-level failures.
2141
3083
  exit_class: cls.class,
2142
- // Recommended cap from MESSAGING_PROTOCOLS.md: last ~4 KB.
2143
- stderr_tail: attempt.stderr.slice(-4000),
3084
+ reasons: diagnostics.reasons,
3085
+ stdout_tail: diagnostics.stdoutTail,
3086
+ stderr_tail: diagnostics.stderrTail,
3087
+ ...(usageLimitIdentity ?? {}),
2144
3088
  });
2145
3089
  await deps.supabase.rpc("transition_task", {
2146
3090
  p_task_id: taskId,
@@ -2151,9 +3095,15 @@ export function runTask(taskId, deps) {
2151
3095
  status: "failed",
2152
3096
  phase: "claude_exit",
2153
3097
  exitCode: attempt.exitCode,
2154
- error: attempt.stderr.slice(-200).trim() || cls.detail,
3098
+ error: diagnostics.reasons[0] ?? cls.detail,
2155
3099
  quarantine_cause: quarantineCause,
2156
3100
  quarantine_consecutive: quarantineConsecutive,
3101
+ engine: taskEngine.id,
3102
+ // USAGE-LIMIT-INVISIBLE-1: carries the provider's stated reset (or
3103
+ // null for an indefinite cap) so watch.ts can fold a fleet-visible
3104
+ // capacity signal on top of the quarantine, instead of the account
3105
+ // exhaustion going unrecorded anywhere but this one task's log.
3106
+ ...(cls.class === "usage_limit" ? { resume_at: usageLimitResumeAt } : {}),
2157
3107
  };
2158
3108
  }
2159
3109
  // Success: expose the final attempt's stdout under the name the
@@ -2188,28 +3138,41 @@ export function runTask(taskId, deps) {
2188
3138
  // drift the agent couldn't fix re-fail at review/CI and burn the
2189
3139
  // cycle). On failure HALT (blocked → operator review) instead of
2190
3140
  // pushing — no review re-requested, no cycle consumed.
2191
- const reworkGate = await runPreprGate(workdir, resolvedBaseRef).catch((err) => ({
2192
- ok: false,
2193
- skipped: false,
2194
- ran: [],
2195
- absent: [],
2196
- failure: {
2197
- gate: "typecheck",
2198
- exitCode: null,
2199
- outputTail: `pre-PR gate runner crashed: ${err.message?.slice(0, 400)}`,
2200
- },
2201
- }));
3141
+ // PREPR-GATE-CRASH-CLASS-1: a gate RUNNER that threw is the same infra
3142
+ // class as a gate TOOL that crashed — it judged nothing, so it parks
3143
+ // the task instead of charging the crash to the diff (still no push).
3144
+ const reworkGate = await runPreprGate(workdir, resolvedBaseRef).catch((err) => gateRunnerCrashResult(err));
3145
+ if (!reworkGate.skipped && reworkGate.crash) {
3146
+ return handleGateToolCrash(reworkGate, reworkGate.crash, true);
3147
+ }
2202
3148
  if (!reworkGate.skipped && !reworkGate.ok && reworkGate.failure) {
2203
3149
  const fail = reworkGate.failure;
3150
+ // RUNNER-GATE-VISIBILITY-2: `deps-missing` vs
3151
+ // `prepr_gate_failed:typecheck` is the whole point — the first is a
3152
+ // cold parent clone on THIS host, the second is the task's own code.
3153
+ // WORKTREE-STALE-DEPS-1: `deps-stale` is the sibling environment
3154
+ // fault (deps WERE seeded, the merge above left them stale) — kept
3155
+ // out of the generic `rework_gate_failed` bucket for the same reason.
3156
+ const blockReason = gateFailureBlockReason(fail.gate);
3157
+ const errorClass = fail.gate === "deps"
3158
+ ? DEPS_MISSING_CLASSIFICATION
3159
+ : fail.gate === "deps-stale"
3160
+ ? DEPS_STALE_CLASSIFICATION
3161
+ : "rework_gate_failed";
2204
3162
  await appendEvent(deps.supabase, taskId, "rework_halt", {
2205
3163
  phase: "prepr_gate",
2206
3164
  pr_number: reworkMeta.pr_number,
2207
3165
  repo: reworkMeta.repo,
2208
3166
  branch,
2209
- error_class: "rework_gate_failed",
3167
+ error_class: errorClass,
3168
+ block_reason: blockReason,
2210
3169
  failed_gate: fail.gate,
2211
3170
  exit_code: fail.exitCode,
2212
3171
  gates_ran: reworkGate.ran,
3172
+ // PREPR-GATE-TIMEOUT-VISIBILITY-1: name the offending test + whether
3173
+ // it TIMED OUT, so a timeout halt is distinguishable from a genuine
3174
+ // assertion halt without hand-querying stderr_tail.
3175
+ ...gateFailureRecordFields(fail),
2213
3176
  stderr_tail: fail.outputTail,
2214
3177
  runner_id: deps.session.runnerId,
2215
3178
  });
@@ -2219,22 +3182,31 @@ export function runTask(taskId, deps) {
2219
3182
  await postState_("blocked", "error_context", {
2220
3183
  task_id: taskId,
2221
3184
  phase: "review",
2222
- error_class: "rework_gate_failed",
3185
+ error_class: errorClass,
3186
+ block_reason: blockReason,
2223
3187
  failed_gate: fail.gate,
2224
3188
  stderr_tail: fail.outputTail,
2225
3189
  });
2226
- await deps.supabase.rpc("transition_task", {
2227
- p_task_id: taskId,
2228
- p_new_status: "blocked",
3190
+ await transitionBlocked(blockReason, {
3191
+ phase: "prepr_gate",
3192
+ failed_gate: fail.gate,
3193
+ rework: true,
2229
3194
  });
2230
3195
  return {
2231
3196
  taskId,
2232
3197
  status: "failed",
2233
3198
  phase: "prepr_gate",
2234
3199
  exitCode: fail.exitCode,
2235
- error: `rework gate HALT: ${fail.gate} failed — re-push withheld for operator review`,
3200
+ error: `rework gate HALT: ${fail.gate} failed (${blockReason}) ` +
3201
+ `re-push withheld for operator review`,
2236
3202
  };
2237
3203
  }
3204
+ // PREPR-CONFORMANCE-GATE-1: a rework re-requests a paid review, so the
3205
+ // pass runs here too — a rework that STILL blows the cap / touches a
3206
+ // forbidden path (the #2162 case) HALTs instead of spending a cycle.
3207
+ const reworkConformance = await runConformanceGate(true, capturedStdout);
3208
+ if (reworkConformance)
3209
+ return reworkConformance;
2238
3210
  // RVU-5: rework completion requires an ADVANCED head. Compare the local
2239
3211
  // HEAD to the reviewed head (origin/<branch> — what the reviewer last
2240
3212
  // saw). When they match, the agent + reconcile produced NO new commit:
@@ -2373,19 +3345,13 @@ export function runTask(taskId, deps) {
2373
3345
  // the runner: no PR is opened, the task fails with
2374
3346
  // `error_class: prepr_gate_failed`, and the failing gate + output tail
2375
3347
  // land on the History tab. Skippable via ACC_RUNNER_SKIP_PREPR=1.
2376
- const prepr = await runPreprGate(workdir, resolvedBaseRef).catch((err) => ({
2377
- // A gate-runner that itself crashed must fail closed: do not wave a
2378
- // PR through because the check errored.
2379
- ok: false,
2380
- skipped: false,
2381
- ran: [],
2382
- absent: [],
2383
- failure: {
2384
- gate: "typecheck",
2385
- exitCode: null,
2386
- outputTail: `pre-PR gate runner crashed: ${err.message?.slice(0, 400)}`,
2387
- },
2388
- }));
3348
+ // PREPR-GATE-CRASH-CLASS-1: a gate-runner that itself threw still fails
3349
+ // CLOSED (`ok: false` no PR is opened off a check that never ran), but
3350
+ // it is reported as infra (`crash`), not as a verdict on the diff.
3351
+ const prepr = await runPreprGate(workdir, resolvedBaseRef).catch((err) => gateRunnerCrashResult(err));
3352
+ if (!prepr.skipped && prepr.crash) {
3353
+ return handleGateToolCrash(prepr, prepr.crash, false);
3354
+ }
2389
3355
  if (prepr.skipped) {
2390
3356
  await appendEvent(deps.supabase, taskId, "log", {
2391
3357
  phase: "prepr_gate",
@@ -2397,28 +3363,65 @@ export function runTask(taskId, deps) {
2397
3363
  }
2398
3364
  else if (!prepr.ok && prepr.failure) {
2399
3365
  const fail = prepr.failure;
2400
- const summary = `pre-PR ${fail.gate} gate failed (exit ${fail.exitCode ?? "signal"}) — PR not opened`;
3366
+ // RUNNER-GATE-VISIBILITY-2: a deps-less worktree is an ENVIRONMENT fault
3367
+ // on this host, not a verdict on the diff — `tsc` was never there to
3368
+ // run. It keeps its own class end-to-end so failed-task triage can never
3369
+ // auto-rework a task over a missing binary.
3370
+ const depsMissing = fail.gate === "deps";
3371
+ // WORKTREE-STALE-DEPS-1: sibling environment fault — deps WERE seeded,
3372
+ // a rework merge just left them stale — kept as its own class end-to-end
3373
+ // so triage never conflates "no environment" with "stale environment".
3374
+ const depsStale = fail.gate === "deps-stale";
3375
+ const envFault = depsMissing || depsStale;
3376
+ const blockReason = gateFailureBlockReason(fail.gate);
3377
+ const errorClass = depsMissing
3378
+ ? DEPS_MISSING_CLASSIFICATION
3379
+ : depsStale
3380
+ ? DEPS_STALE_CLASSIFICATION
3381
+ : PREPR_GATE_FAILED_CLASSIFICATION;
3382
+ const summary = depsMissing
3383
+ ? `pre-PR gate could not run: worktree has no node_modules (${blockReason}) — PR not opened`
3384
+ : depsStale
3385
+ ? `pre-PR gate could not run: worktree deps are stale vs the merged lockfile (${blockReason}) — PR not opened`
3386
+ : `pre-PR ${fail.gate} gate failed (${blockReason}, exit ${fail.exitCode ?? "signal"}) — PR not opened`;
2401
3387
  await appendEvent(deps.supabase, taskId, "error", {
2402
3388
  phase: "prepr_gate",
2403
- error_class: "prepr_gate_failed",
3389
+ error_class: errorClass,
3390
+ block_reason: blockReason,
2404
3391
  failed_gate: fail.gate,
2405
3392
  exit_code: fail.exitCode,
2406
3393
  gates_ran: prepr.ran,
2407
3394
  gates_absent: prepr.absent,
3395
+ // PREPR-GATE-TIMEOUT-VISIBILITY-1: same structured timeout/assertion
3396
+ // fingerprint as the rework_halt path above.
3397
+ ...gateFailureRecordFields(fail),
2408
3398
  stderr_tail: fail.outputTail,
2409
3399
  runner_id: deps.session.runnerId,
2410
3400
  });
2411
3401
  await postState_("blocked", "error_context", {
2412
3402
  task_id: taskId,
2413
3403
  phase: "review",
2414
- error_class: "prepr_gate_failed",
3404
+ error_class: errorClass,
3405
+ block_reason: blockReason,
2415
3406
  failed_gate: fail.gate,
2416
3407
  stderr_tail: fail.outputTail,
2417
3408
  });
2418
- await deps.supabase.rpc("transition_task", {
2419
- p_task_id: taskId,
2420
- p_new_status: "failed",
2421
- });
3409
+ if (envFault) {
3410
+ // Park, never fail — same reasoning as a crashed gate tool
3411
+ // (PREPR-GATE-CRASH-CLASS-1): a check that never ran judged nothing.
3412
+ // Still fail-CLOSED: no push, no PR opened.
3413
+ await transitionBlocked(blockReason, {
3414
+ phase: "prepr_gate",
3415
+ failed_gate: fail.gate,
3416
+ rework: false,
3417
+ });
3418
+ }
3419
+ else {
3420
+ await deps.supabase.rpc("transition_task", {
3421
+ p_task_id: taskId,
3422
+ p_new_status: "failed",
3423
+ });
3424
+ }
2422
3425
  return {
2423
3426
  taskId,
2424
3427
  status: "failed",
@@ -2437,11 +3440,44 @@ export function runTask(taskId, deps) {
2437
3440
  runner_id: deps.session.runnerId,
2438
3441
  });
2439
3442
  }
3443
+ // PREPR-CONFORMANCE-GATE-1: withhold the PR when a MECHANICAL card check
3444
+ // fails, so a paid review is never spent on what a script already decided.
3445
+ const conformance = await runConformanceGate(false, capturedStdout);
3446
+ if (conformance)
3447
+ return conformance;
2440
3448
  // MIG-SEQ-GUARD: the LAST thing before the push. It deliberately runs
2441
3449
  // after the pre-PR gate rather than inside it — the gate's own MIGFIX-01
2442
3450
  // renumber reads the fork-time worktree snapshot, and only a fresh fetch
2443
3451
  // here can see a sibling that merged while the gate was running.
2444
3452
  await runMigrationSeqGuard(false);
3453
+ const ready = await ensureBranchReadyForPr(git, workdir, resolvedBaseRef, branch, taskId);
3454
+ if (!ready.ok) {
3455
+ const readinessClass = /no commits on branch/.test(ready.reason)
3456
+ ? "pr_open_no_commits"
3457
+ : "pr_open_branch_not_ready";
3458
+ await appendEvent(deps.supabase, taskId, "error", {
3459
+ phase: "pr_open",
3460
+ error_class: readinessClass,
3461
+ error: ready.reason,
3462
+ branch,
3463
+ base_ref: resolvedBaseRef,
3464
+ uncommitted_files: ready.files,
3465
+ worktree_path: workdir,
3466
+ runner_id: deps.session.runnerId,
3467
+ });
3468
+ await postState_("blocked", "error_context", {
3469
+ task_id: taskId,
3470
+ phase: "review",
3471
+ error_class: readinessClass,
3472
+ stderr_tail: ready.reason,
3473
+ worktree_path: workdir,
3474
+ });
3475
+ await deps.supabase.rpc("transition_task", {
3476
+ p_task_id: taskId,
3477
+ p_new_status: "failed",
3478
+ });
3479
+ return { taskId, status: "failed", phase: "pr_open", error: ready.reason };
3480
+ }
2445
3481
  // 5. Push + open PR. Both run from the worktree so the operator's
2446
3482
  // shared clone never has the task branch checked out.
2447
3483
  let prUrl = "";
@@ -2496,6 +3532,31 @@ export function runTask(taskId, deps) {
2496
3532
  runner_id: deps.session.runnerId,
2497
3533
  branch,
2498
3534
  });
3535
+ // ORPHAN-PR-GUARD-1: probe HERE — after the push, immediately before the
3536
+ // open — so a merge that landed mid-push is still seen. Own try/catch so a
3537
+ // rate limit requeues the task instead of being read as "no PR exists".
3538
+ let prOpenSkip = null;
3539
+ try {
3540
+ const g = await guardPrOpen({
3541
+ supabase: deps.supabase, gh, repoPath: workdir, taskId, branch,
3542
+ });
3543
+ if (!g.proceed)
3544
+ prOpenSkip = g;
3545
+ }
3546
+ catch (guardErr) {
3547
+ const requeued = await requeueOnGithubRateLimit(guardErr, "pr_open_guard");
3548
+ if (requeued)
3549
+ return requeued;
3550
+ }
3551
+ if (prOpenSkip) {
3552
+ await appendEvent(deps.supabase, taskId, PR_OPEN_SKIPPED_EVENT_KIND, prOpenSkippedPayload(prOpenSkip, taskId, branch));
3553
+ // An OPEN twin is adoptable — link the task to it. A merged / terminal
3554
+ // skip leaves prUrl null: the task is already settled (or the
3555
+ // merged-task reconcile sweep settles it).
3556
+ if (prOpenSkip.adoptable && prOpenSkip.existingPrUrl) {
3557
+ prUrl = prOpenSkip.existingPrUrl;
3558
+ }
3559
+ }
2499
3560
  try {
2500
3561
  const report = extractReportFromOutput(capturedStdout, taskEngineId);
2501
3562
  // V96 (R4): generate the PR-body "Files changed" table FROM the actual
@@ -2505,24 +3566,50 @@ export function runTask(taskId, deps) {
2505
3566
  // without git.diff), fall back to the raw report body rather than
2506
3567
  // blocking the PR open.
2507
3568
  let body = report;
3569
+ // LOC-ADVISORY-2B: measured off the SAME diff as the table, so the two
3570
+ // can never disagree. Null unless a ceiling is actually exceeded.
3571
+ let locAdvisory = null;
2508
3572
  try {
2509
- const diff = await git.diff?.(workdir, `origin/${integrationBranch}`, branch);
3573
+ // Detached worktree: diff HEAD (no local `branch` ref exists).
3574
+ const diff = await git.diff?.(workdir, `origin/${integrationBranch}`, "HEAD");
2510
3575
  // RR-1: pass the task's acceptance criteria so composePrBody emits the
2511
3576
  // machine-readable `Covers:` scope manifest (whole-task by default; the
2512
3577
  // agent may declare a partial scope in its report).
2513
- if (diff != null)
3578
+ if (diff != null) {
2514
3579
  body = composePrBody(report, diff, { acceptance: task.acceptance });
3580
+ // LOC-ADVISORY-2B: split impl-vs-tests against the task's declared
3581
+ // `<= N LOC` ceilings (else the 300-impl house guideline) so an
3582
+ // overage is read at the TOP of the body, not discovered
3583
+ // mid-review. Informational — it never blocks the PR.
3584
+ locAdvisory = evaluateLocAdvisory(diff, task.acceptance);
3585
+ if (locAdvisory)
3586
+ body = prependLocAdvisory(body, locAdvisory);
3587
+ }
2515
3588
  }
2516
3589
  catch (composeErr) {
2517
3590
  process.stderr.write(`[acc-runner] pr-body compose failed (using raw report): ` +
2518
3591
  `${composeErr.message?.slice(0, 200)}\n`);
2519
3592
  }
2520
- const pr = await gh.openPR(workdir, {
2521
- title: prTitleForTask(task.id, task.title),
2522
- body,
2523
- base: integrationBranch,
2524
- });
2525
- prUrl = pr.url;
3593
+ // Own try: a failed event RPC must cost neither the body section above
3594
+ // nor the PR itself.
3595
+ if (locAdvisory && !prOpenSkip) {
3596
+ try {
3597
+ await appendEvent(deps.supabase, taskId, LOC_ADVISORY_EVENT_KIND, locAdvisoryEventPayload(locAdvisory));
3598
+ }
3599
+ catch (evErr) {
3600
+ process.stderr.write(`[acc-runner] loc_advisory event failed: ` +
3601
+ `${evErr.message?.slice(0, 200)}\n`);
3602
+ }
3603
+ }
3604
+ // ORPHAN-PR-GUARD-1: the ONE gate on the open call.
3605
+ if (!prOpenSkip) {
3606
+ const pr = await gh.openPR(workdir, {
3607
+ title: prTitleForTask(task.id, task.title),
3608
+ body,
3609
+ base: integrationBranch,
3610
+ });
3611
+ prUrl = pr.url;
3612
+ }
2526
3613
  }
2527
3614
  catch (err) {
2528
3615
  // V109: a GitHub rate limit / spent budget while opening the PR is
@@ -2560,12 +3647,45 @@ export function runTask(taskId, deps) {
2560
3647
  // and the user can open a PR manually (webhook back-write picks
2561
3648
  // it up). Wrapped so a logging RPC failure can't block the
2562
3649
  // terminal status write below.
3650
+ let primary = msg;
3651
+ if (/no commits between/i.test(msg)) {
3652
+ try {
3653
+ const diagnosed = await diagnoseNoCommitBranch(git, workdir, resolvedBaseRef, branch);
3654
+ if (diagnosed?.ahead === 0)
3655
+ primary = formatBranchReadinessError(diagnosed.files);
3656
+ }
3657
+ catch { /* keep gh's message */ }
3658
+ }
2563
3659
  try {
2564
- await appendEvent(deps.supabase, taskId, "error", { phase: "pr_open", error: msg });
3660
+ await appendEvent(deps.supabase, taskId, "error", {
3661
+ phase: "pr_open",
3662
+ error_class: /no commits on branch/.test(primary)
3663
+ ? "pr_open_no_commits"
3664
+ : "pr_open_failed",
3665
+ error: primary,
3666
+ gh_error: msg,
3667
+ branch,
3668
+ worktree_path: workdir,
3669
+ runner_id: deps.session.runnerId,
3670
+ });
2565
3671
  }
2566
3672
  catch (logErr) {
2567
3673
  process.stderr.write(`[acc-runner] pr_open error log failed: ${logErr.message}\n`);
2568
3674
  }
3675
+ if (/no commits on branch/.test(primary)) {
3676
+ await postState_("blocked", "error_context", {
3677
+ task_id: taskId,
3678
+ phase: "review",
3679
+ error_class: "pr_open_no_commits",
3680
+ stderr_tail: primary,
3681
+ worktree_path: workdir,
3682
+ });
3683
+ await deps.supabase.rpc("transition_task", {
3684
+ p_task_id: taskId,
3685
+ p_new_status: "failed",
3686
+ });
3687
+ return { taskId, status: "failed", phase: "pr_open", error: primary };
3688
+ }
2569
3689
  }
2570
3690
  }
2571
3691
  // v0.35-B: write pr_number + transition running→needs-review via
@@ -2642,6 +3762,12 @@ export function runTask(taskId, deps) {
2642
3762
  // made outside a task run is unbudgeted.
2643
3763
  endTaskGithubBudget();
2644
3764
  await releaseLocks();
3765
+ // RUNNER-CURRENT-TASK-DEAD-1: release the runner's task pointer on EVERY
3766
+ // post-claim exit — success, failure, github requeue, the FH-1a capacity
3767
+ // requeue (running→blocked→queued), cancellation, and uncaught throws all
3768
+ // funnel through this finally. Guarded on the current value so a sibling
3769
+ // task's newer claim on the same runner is preserved.
3770
+ await writeRunnerCurrentTask(null);
2645
3771
  }
2646
3772
  })();
2647
3773
  return {