@tokenfactory/acc-runner 0.44.1 → 0.44.3

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 (159) hide show
  1. package/README.md +20 -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 +46 -0
  43. package/dist/cost-pricing.d.ts.map +1 -1
  44. package/dist/cost-pricing.js +58 -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 +33 -1
  51. package/dist/doctor.d.ts.map +1 -1
  52. package/dist/doctor.js +284 -44
  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/keychain.d.ts +17 -0
  87. package/dist/keychain.d.ts.map +1 -1
  88. package/dist/keychain.js +30 -0
  89. package/dist/keychain.js.map +1 -1
  90. package/dist/loc-advisory.d.ts +70 -0
  91. package/dist/loc-advisory.d.ts.map +1 -0
  92. package/dist/loc-advisory.js +196 -0
  93. package/dist/loc-advisory.js.map +1 -0
  94. package/dist/locks/claim-lock-policy.d.ts +67 -0
  95. package/dist/locks/claim-lock-policy.d.ts.map +1 -0
  96. package/dist/locks/claim-lock-policy.js +132 -0
  97. package/dist/locks/claim-lock-policy.js.map +1 -0
  98. package/dist/login.d.ts +59 -2
  99. package/dist/login.d.ts.map +1 -1
  100. package/dist/login.js +87 -7
  101. package/dist/login.js.map +1 -1
  102. package/dist/mcp-spawn.d.ts +54 -0
  103. package/dist/mcp-spawn.d.ts.map +1 -1
  104. package/dist/mcp-spawn.js +88 -4
  105. package/dist/mcp-spawn.js.map +1 -1
  106. package/dist/migration-seq-guard.d.ts +28 -1
  107. package/dist/migration-seq-guard.d.ts.map +1 -1
  108. package/dist/migration-seq-guard.js +111 -6
  109. package/dist/migration-seq-guard.js.map +1 -1
  110. package/dist/output-diagnostics.d.ts +15 -0
  111. package/dist/output-diagnostics.d.ts.map +1 -0
  112. package/dist/output-diagnostics.js +95 -0
  113. package/dist/output-diagnostics.js.map +1 -0
  114. package/dist/pr-open-guard.d.ts +67 -0
  115. package/dist/pr-open-guard.d.ts.map +1 -0
  116. package/dist/pr-open-guard.js +79 -0
  117. package/dist/pr-open-guard.js.map +1 -0
  118. package/dist/prepr-gate.d.ts +229 -1
  119. package/dist/prepr-gate.d.ts.map +1 -1
  120. package/dist/prepr-gate.js +406 -3
  121. package/dist/prepr-gate.js.map +1 -1
  122. package/dist/program.d.ts.map +1 -1
  123. package/dist/program.js +30 -5
  124. package/dist/program.js.map +1 -1
  125. package/dist/provider-auth.d.ts +8 -0
  126. package/dist/provider-auth.d.ts.map +1 -1
  127. package/dist/provider-auth.js +10 -0
  128. package/dist/provider-auth.js.map +1 -1
  129. package/dist/runtime/quarantine.d.ts +11 -0
  130. package/dist/runtime/quarantine.d.ts.map +1 -1
  131. package/dist/runtime/quarantine.js +16 -1
  132. package/dist/runtime/quarantine.js.map +1 -1
  133. package/dist/runtime/reviewer.d.ts +90 -2
  134. package/dist/runtime/reviewer.d.ts.map +1 -1
  135. package/dist/runtime/reviewer.js +547 -46
  136. package/dist/runtime/reviewer.js.map +1 -1
  137. package/dist/runtime/running-build.d.ts +24 -0
  138. package/dist/runtime/running-build.d.ts.map +1 -0
  139. package/dist/runtime/running-build.js +62 -0
  140. package/dist/runtime/running-build.js.map +1 -0
  141. package/dist/runtime/worktree.d.ts +45 -0
  142. package/dist/runtime/worktree.d.ts.map +1 -1
  143. package/dist/runtime/worktree.js +174 -5
  144. package/dist/runtime/worktree.js.map +1 -1
  145. package/dist/task-runner.d.ts +126 -5
  146. package/dist/task-runner.d.ts.map +1 -1
  147. package/dist/task-runner.js +1219 -127
  148. package/dist/task-runner.js.map +1 -1
  149. package/dist/types.d.ts +8 -0
  150. package/dist/types.d.ts.map +1 -1
  151. package/dist/watch-chat/wire.d.ts +80 -0
  152. package/dist/watch-chat/wire.d.ts.map +1 -1
  153. package/dist/watch-chat/wire.js +203 -43
  154. package/dist/watch-chat/wire.js.map +1 -1
  155. package/dist/watch.d.ts +70 -16
  156. package/dist/watch.d.ts.map +1 -1
  157. package/dist/watch.js +501 -89
  158. package/dist/watch.js.map +1 -1
  159. package/package.json +2 -2
@@ -15,25 +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 { UNRESOLVED_MODEL, engineResultModel, isPlaceholderModel, 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";
34
40
  import { buildMemoryContext } from "./memory-context.js";
35
41
  import { acquireTaskLock as defaultAcquireTaskLock, TaskLockHeldError, } from "./runtime/locks.js";
36
- import { prepareTaskWorktree as defaultPrepareTaskWorktree, } from "./runtime/worktree.js";
42
+ import { prepareTaskWorktree as defaultPrepareTaskWorktree, WorktreeBranchTakenError, } from "./runtime/worktree.js";
37
43
  // v0.21 T-66-1: serialize the per-task worktree-provisioning critical section
38
44
  // against the shared clone so concurrent tasks can't collide on git's
39
45
  // repo-global locks (the T-65-2 race).
@@ -46,10 +52,14 @@ import { clonePathForRepo, ensureRepoClone, CloneFailedError, } from "./runtime/
46
52
  import { parseConflictMeta, resolveConflict, MAX_CONFLICT_ATTEMPTS, } from "./runtime/conflict-resolver.js";
47
53
  import { parseReworkMeta, renderReworkPrompt, reconcileReworkBranch as defaultReconcileReworkBranch, fetchPrHeadRef as defaultFetchPrHeadRef, } from "./runtime/rework.js";
48
54
  import { assertResolutionIntegrity, isConflictResolutionTask, summariseFailures, } from "./runtime/resolution-integrity.js";
49
- 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";
50
56
  import { revalidateMigrationSeq as defaultRevalidateMigrationSeq, } from "./migration-seq-guard.js";
51
57
  import { commitGuardSkipRequested, gitConfigHooksEnv, installCommitGuardSync, } from "./commit-guard.js";
52
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";
53
63
  const LOG_BATCH_BYTES = 4 * 1024;
54
64
  // M2 (ENGINE-EXTRACT): the coding engine this runner drives. Wave 1 resolved
55
65
  // this to a single module-level `claude-code` constant — spawn, argv,
@@ -65,7 +75,28 @@ const LOG_BATCH_BYTES = 4 * 1024;
65
75
  // v0.6.0 REG-296: argv builder for `claude --print`, re-exported from the
66
76
  // claude-code engine so the historical import surface is preserved.
67
77
  export { buildClaudeArgs } from "./engines/claude-code.js";
68
- 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) {
69
100
  // RR-4: install the commit-time mechanical guard into this
70
101
  // worktree and point the agent's git at it via an ephemeral core.hooksPath env
71
102
  // (never a config-file write). The agent then hits an actionable in-session
@@ -84,8 +115,51 @@ function defaultSpawnClaude(cwd, modelId, engineId) {
84
115
  baseEnv = undefined;
85
116
  }
86
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
+ }
87
124
  return getEngine(engineId).spawn({ cwd, modelId, baseEnv });
88
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
+ }
89
163
  async function defaultCheckoutBase(repoPath, baseBranch) {
90
164
  // Plain `git checkout <branch>` (not `-B`) so the integration
91
165
  // branch's existing ref is honored. With -B we'd reset the
@@ -107,6 +181,22 @@ export function expandHomePath(p) {
107
181
  }
108
182
  /** Opt-in the cold-parent per-worktree install fallback (default OFF). */
109
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
+ }
110
200
  async function worktreePathExists(p) {
111
201
  try {
112
202
  await fs.promises.access(p);
@@ -195,7 +285,10 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
195
285
  // A repo_path_hint that points AT the shared clone (no isolated worktree)
196
286
  // already has its deps — nothing to seed.
197
287
  if (path.resolve(workdir) === path.resolve(repoPath)) {
198
- 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
+ };
199
292
  }
200
293
  const rootSrc = path.resolve(repoPath, "node_modules");
201
294
  const rootDst = path.join(workdir, "node_modules");
@@ -203,7 +296,10 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
203
296
  if (!parentHasRoot) {
204
297
  // Cold parent (e.g. clone-on-demand) — nothing warm to seed from.
205
298
  if (await worktreePathExists(rootDst)) {
206
- 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
+ };
207
303
  }
208
304
  const installEnabled = (() => {
209
305
  const v = env[WORKTREE_INSTALL_ENV]?.trim().toLowerCase();
@@ -214,16 +310,27 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
214
310
  `${WORKTREE_INSTALL_ENV} is off — cannot seed deps; the pre-PR gate may fail ` +
215
311
  `on missing binaries. Install deps in the parent clone (recommended) or set ` +
216
312
  `${WORKTREE_INSTALL_ENV}=1 for a per-worktree install.`);
217
- 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
+ };
218
318
  }
219
319
  const install = opts.install ?? defaultWorktreeInstall;
220
320
  try {
221
321
  await install(workdir);
222
- 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
+ };
223
326
  }
224
327
  catch (err) {
225
328
  opts.onLog?.(`[acc-runner] worktree pnpm install failed: ${err.message} — gate may fail`);
226
- 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
+ };
227
334
  }
228
335
  }
229
336
  const dirs = await resolveWorkspaceDirs(repoPath);
@@ -250,8 +357,71 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
250
357
  detail: linked.length
251
358
  ? `seeded ${linked.length} node_modules symlink(s) from warm clone`
252
359
  : "worktree already provisioned; nothing to seed",
360
+ depsMissing: false,
253
361
  };
254
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
+ }
255
425
  /**
256
426
  * v0.63 T-63-1: default claude health probe. A silent instant-empty exit is
257
427
  * ambiguous — it can be a broken host OR a momentarily-unavailable claude.
@@ -270,6 +440,14 @@ export async function ensureWorktreeNodeModules(workdir, repoPath, opts = {}) {
270
440
  * window is left strictly alone; the runner just keeps waiting.
271
441
  */
272
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();
273
451
  /**
274
452
  * v0.41.4 default self-heal for a claim conflict: read the conflicting holder
275
453
  * tasks' liveness and release the locks of holders that are dead/stale — a
@@ -396,6 +574,47 @@ async function appendEvent(supabase, taskId, kind, payload) {
396
574
  process.stderr.write(`[acc-runner] append_task_event failed: ${error.message}\n`);
397
575
  }
398
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
+ }
399
618
  async function streamToEvents(stream, supabase, taskId, streamName) {
400
619
  let captured = "";
401
620
  let pending = "";
@@ -478,6 +697,15 @@ async function setTaskPr(supabase, taskId, prUrl) {
478
697
  }
479
698
  return true;
480
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
+ }
481
709
  /**
482
710
  * v0.53 T-53-4: emit a `task.first_commit` activity event the first time a
483
711
  * task's worktree produces a commit on its branch (the SLO funnel signal
@@ -492,7 +720,10 @@ async function setTaskPr(supabase, taskId, prUrl) {
492
720
  */
493
721
  async function emitFirstCommit(supabase, git, taskId, workdir, baseRef, branch) {
494
722
  try {
495
- 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");
496
727
  if (!first)
497
728
  return;
498
729
  const { error } = await supabase.rpc("log_activity", {
@@ -755,14 +986,33 @@ export function runTask(taskId, deps) {
755
986
  const prepareWorktree = deps.prepareWorktree ?? defaultPrepareTaskWorktree;
756
987
  const provisionRepoClone = deps.provisionRepoClone ?? ensureRepoClone;
757
988
  const postCostEvent = deps.postCostEvent ?? ((event) => defaultPostCostEvent(deps.supabase, event));
989
+ const buildCostEventForRun = deps.buildCostEvent ?? buildCostEvent;
758
990
  const postState = deps.postRunnerStateMessage ??
759
991
  ((args) => defaultPostRunnerStateMessage(deps.supabase, args));
760
992
  const loadProfile = deps.loadProfile ?? defaultLoadProfile;
761
993
  const loadPromptPreludeFn = deps.loadPromptPrelude ?? loadPromptPrelude;
762
994
  // NS MM-2: same reason as `spawnClaude` above — `healthProbe`'s default
763
995
  // binds below, after the task (and its `engine`) is fetched.
764
- const runPreprGate = deps.runPreprGate ??
996
+ const runPreprGateRaw = deps.runPreprGate ??
765
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);
766
1016
  const reconcileRework = deps.reconcileReworkBranch ?? defaultReconcileReworkBranch;
767
1017
  const revalidateSeq = deps.revalidateMigrationSeq ??
768
1018
  ((workdir, opts) => defaultRevalidateMigrationSeq(workdir, opts));
@@ -772,6 +1022,12 @@ export function runTask(taskId, deps) {
772
1022
  ((wd, repo) => ensureWorktreeNodeModules(wd, repo, {
773
1023
  onLog: (line) => process.stderr.write(`${line}\n`),
774
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
+ }));
775
1031
  // Silence the unused-binding lint for `checkoutBase` — v0.11-F supersedes
776
1032
  // the v0.6.0 REG-301 pre-spawn `git checkout <baseBranch>` with the
777
1033
  // worktree's `add -B <branch> <path> <baseBranch>` start-point semantics,
@@ -793,6 +1049,45 @@ export function runTask(taskId, deps) {
793
1049
  payload,
794
1050
  });
795
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
+ };
796
1091
  // v0.6.1 (v0.11-D): release lock rows on every terminal exit path
797
1092
  // below the claim. Best-effort — a release failure logs to stderr
798
1093
  // but does not change the outcome the runner reports to the caller.
@@ -808,6 +1103,42 @@ export function runTask(taskId, deps) {
808
1103
  process.stderr.write(`[acc-runner] release_task_locks(${taskId}) failed: ${error.message}\n`);
809
1104
  }
810
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
+ };
811
1142
  // v0.12-RESUME — periodic signal loop. After the claim succeeds we
812
1143
  // bump acc.tasks.last_runner_signal_at every signalIntervalMs ms so
813
1144
  // the v0.12 /5m sweep distinguishes "runner alive, work in flight"
@@ -885,6 +1216,33 @@ export function runTask(taskId, deps) {
885
1216
  // against other running tasks' locks, INSERTs a lock row +
886
1217
  // transitions to running in one transaction. On overlap or
887
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
+ });
888
1246
  const claim = await deps.supabase.rpc("claim_task_with_locks", {
889
1247
  p_task_id: taskId,
890
1248
  p_runner_id: deps.session.runnerId,
@@ -943,22 +1301,88 @@ export function runTask(taskId, deps) {
943
1301
  }
944
1302
  if (claimResult.ok !== true) {
945
1303
  conflicts = Array.isArray(claimResult.conflicts) ? claimResult.conflicts : conflicts;
946
- await appendEvent(deps.supabase, taskId, "log", {
947
- phase: "claim_locks",
948
- stream: "stderr",
949
- conflicts,
950
- message: `file-lock conflict other tasks hold overlapping paths: ${conflicts.join(", ")}`,
951
- 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,
952
1358
  });
953
- 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
+ }
954
1371
  return {
955
1372
  taskId,
956
1373
  status: "failed",
957
1374
  phase: "claim_locks",
958
- 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,
959
1379
  };
960
1380
  }
961
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);
962
1386
  // v0.6.1 (v0.11-D): every exit path below the successful claim
963
1387
  // must release the lock row so the same paths free up for the
964
1388
  // next runner. try/finally captures returns AND uncaught throws
@@ -1015,6 +1439,11 @@ export function runTask(taskId, deps) {
1015
1439
  // RPC still runs the finally (stop loop, release locks).
1016
1440
  await updateSignal();
1017
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);
1018
1447
  // v0.14-MESSAGING-RUNTIME-WIRE: first bus event — runner has the
1019
1448
  // claim, will now fetch + spawn. planner subscribes via /messages.
1020
1449
  await postState_("planning", "info", { runner_id: deps.session.runnerId });
@@ -1046,12 +1475,41 @@ export function runTask(taskId, deps) {
1046
1475
  // pre-MM-1 row) resolves the same default Wave 1 always used, so this is
1047
1476
  // byte-identical for the all-claude-code fleet. Every spawn/parse/classify
1048
1477
  // call below drives THIS engine instead of a fixed module-level default.
1049
- const taskEngineId = task.engine ?? null;
1050
- 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;
1051
1509
  // `spawnClaude`/`healthProbe` bind here (not in the sync prologue above)
1052
1510
  // because their defaults need `taskEngineId`, which isn't known until now.
1053
1511
  const spawnClaude = deps.spawnClaude ??
1054
- ((cwd, modelId) => defaultSpawnClaude(cwd, modelId, taskEngineId));
1512
+ ((cwd, modelId) => defaultSpawnClaude(cwd, modelId, taskEngineId, codexApiKey));
1055
1513
  const healthProbe = deps.healthProbe ?? (() => defaultHealthProbe(taskEngineId));
1056
1514
  // LOCK-LIFECYCLE-WIRE: re-read the task's CURRENT file
1057
1515
  // scope from the live row so an operator who narrowed a QUEUED task between
@@ -1403,6 +1861,164 @@ export function runTask(taskId, deps) {
1403
1861
  error: `conflict-resolution integrity guardrail rejected resolution (${summary})`,
1404
1862
  };
1405
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
+ };
1406
2022
  /**
1407
2023
  * MIG-SEQ-GUARD: last thing before `git push`, re-validate this branch's
1408
2024
  * NEW migration seq against a FRESHLY-FETCHED integration head. The seq was
@@ -1453,6 +2069,13 @@ export function runTask(taskId, deps) {
1453
2069
  file: result.move.file,
1454
2070
  new_file: result.move.newFile,
1455
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,
1456
2079
  message: result.detail,
1457
2080
  renumber_output: result.move.output,
1458
2081
  runner_id: deps.session.runnerId,
@@ -1500,9 +2123,9 @@ export function runTask(taskId, deps) {
1500
2123
  // 3. Repo prep. v0.11-F: fetch on the shared clone (worktree
1501
2124
  // shares the object store) then provision an isolated worktree
1502
2125
  // at ~/.cache/acc-runner/work/<task_id>/ forked from the
1503
- // integration branch. The redundant `git.checkout -B <branch>`
1504
- // is a no-op inside the new worktree — kept so the v0.6.0
1505
- // 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.
1506
2129
  try {
1507
2130
  // v0.19 T-64-1: cut a FRESH task branch from the LATEST
1508
2131
  // integration tip, not a stale LOCAL ref. `git.fetch` runs
@@ -1594,29 +2217,29 @@ export function runTask(taskId, deps) {
1594
2217
  taskId,
1595
2218
  branch,
1596
2219
  baseBranch: worktreeBaseBranch,
2220
+ owner: {
2221
+ runnerId: deps.session.runnerId,
2222
+ pid: process.pid,
2223
+ startedAt: new Date().toISOString(),
2224
+ },
1597
2225
  });
1598
2226
  });
1599
2227
  workdir = worktree.path;
1600
- // v0.12-RESUME: log resume-vs-fresh so an operator can audit
1601
- // how often the resume path actually fires. `resumed: true`
1602
- // means a prior runner crashed mid-task, the v0.12 sweep
1603
- // returned the task to queued, and this runner picked it
1604
- // back up with the prior worktree intact. Claude reads the
1605
- // partially-committed state and continues; the spawn is the
1606
- // same prompt either way (Claude is idempotent enough that
1607
- // re-running on a partially-edited worktree converges on
1608
- // the right final state).
1609
- if (worktree.resumed) {
1610
- await appendEvent(deps.supabase, taskId, "log", {
1611
- phase: "git",
1612
- stream: "stdout",
1613
- event: "worktree.resumed",
1614
- worktree_path: workdir,
1615
- branch,
1616
- runner_id: deps.session.runnerId,
1617
- });
1618
- }
1619
- 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>).
1620
2243
  // FU-9 (CHAT-RT): seed node_modules into the fresh worktree NOW — before
1621
2244
  // Claude spawns AND before the pre-PR gate — so `pnpm typecheck` finds
1622
2245
  // tsc/eslint/vite instead of dying "sh: tsc: command not found" (a fresh
@@ -1626,6 +2249,8 @@ export function runTask(taskId, deps) {
1626
2249
  try {
1627
2250
  const dep = await ensureWorktreeDeps(workdir, repoPath);
1628
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);
1629
2254
  await appendEvent(deps.supabase, taskId, "log", {
1630
2255
  phase: "git",
1631
2256
  stream: "stdout",
@@ -1633,6 +2258,25 @@ export function runTask(taskId, deps) {
1633
2258
  linked: dep.linked,
1634
2259
  installed: dep.installed,
1635
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,
1636
2280
  runner_id: deps.session.runnerId,
1637
2281
  });
1638
2282
  }
@@ -1643,6 +2287,26 @@ export function runTask(taskId, deps) {
1643
2287
  }
1644
2288
  catch (err) {
1645
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
+ }
1646
2310
  // v0.21 T-66-1: a git REPO-GLOBAL LOCK collision during provisioning
1647
2311
  // (an external git process touched the clone while we held the
1648
2312
  // provision mutex) is TRANSIENT — requeue the task so it re-provisions
@@ -1713,6 +2377,7 @@ export function runTask(taskId, deps) {
1713
2377
  };
1714
2378
  }
1715
2379
  if (reconcile.status === "halt") {
2380
+ const blockReason = "rework_integration_conflict";
1716
2381
  await appendEvent(deps.supabase, taskId, "rework_halt", {
1717
2382
  phase: "reconcile",
1718
2383
  pr_number: reworkMeta.pr_number,
@@ -1720,6 +2385,7 @@ export function runTask(taskId, deps) {
1720
2385
  branch,
1721
2386
  integration_ref: reconcile.integrationRef,
1722
2387
  conflicted: reconcile.conflicted,
2388
+ block_reason: blockReason,
1723
2389
  runner_id: deps.session.runnerId,
1724
2390
  });
1725
2391
  // HALT, NOT dead-letter: blocked → operator review, no re-review
@@ -1727,13 +2393,15 @@ export function runTask(taskId, deps) {
1727
2393
  await postState_("blocked", "error_context", {
1728
2394
  task_id: taskId,
1729
2395
  phase: "plan",
1730
- error_class: "rework_integration_conflict",
2396
+ error_class: blockReason,
2397
+ block_reason: blockReason,
1731
2398
  stderr_tail: `rework branch conflicts with ${reconcile.integrationRef} — ` +
1732
2399
  `needs human merge: ${reconcile.conflicted.join(", ")}`,
1733
2400
  });
1734
- await deps.supabase.rpc("transition_task", {
1735
- p_task_id: taskId,
1736
- p_new_status: "blocked",
2401
+ await transitionBlocked(blockReason, {
2402
+ phase: "reconcile",
2403
+ integration_ref: reconcile.integrationRef,
2404
+ conflicted: reconcile.conflicted,
1737
2405
  });
1738
2406
  return {
1739
2407
  taskId,
@@ -1751,6 +2419,39 @@ export function runTask(taskId, deps) {
1751
2419
  auto_resolved: reconcile.autoResolved,
1752
2420
  runner_id: deps.session.runnerId,
1753
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
+ }
1754
2455
  }
1755
2456
  // 4. Spawn Claude.
1756
2457
  if (cancelled) {
@@ -1775,6 +2476,7 @@ export function runTask(taskId, deps) {
1775
2476
  throw probeErr;
1776
2477
  }
1777
2478
  if (existingPrUrl) {
2479
+ const existingPrNumber = parsePrNumber(existingPrUrl);
1778
2480
  await appendEvent(deps.supabase, taskId, "log", {
1779
2481
  phase: "coding",
1780
2482
  stream: "stdout",
@@ -1783,6 +2485,13 @@ export function runTask(taskId, deps) {
1783
2485
  pr_url: existingPrUrl,
1784
2486
  message: "open PR already exists for branch — skipping Claude Code spawn",
1785
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
+ });
1786
2495
  // v0.35-B: stamp pr_number + transition running→needs-review on
1787
2496
  // the recovered task. On any failure the webhook-driven flow
1788
2497
  // still moves running→done at merge time (matrix 0156).
@@ -1808,8 +2517,11 @@ export function runTask(taskId, deps) {
1808
2517
  // is a context source, not a critical dependency for v0.5-C1.
1809
2518
  let mcpCleanup = null;
1810
2519
  if (deps.session) {
2520
+ const readMcpCfg = deps.readMcpConfig ?? defaultReadMcpConfig;
1811
2521
  const writer = deps.writeMcpConfig ?? defaultWriteMcpConfig;
1812
2522
  try {
2523
+ // Read through the seam, then hand the snapshot to the writer (single read).
2524
+ const existing = await readMcpCfg(workdir);
1813
2525
  mcpCleanup = await writer({
1814
2526
  cwd: workdir,
1815
2527
  taskId,
@@ -1818,7 +2530,7 @@ export function runTask(taskId, deps) {
1818
2530
  publicUrl: deps.publicUrl ?? deps.cfg.publicUrl,
1819
2531
  supabaseUrl: deps.cfg.supabaseUrl,
1820
2532
  supabaseAnonKey: deps.cfg.supabaseAnonKey,
1821
- });
2533
+ }, { existing });
1822
2534
  }
1823
2535
  catch (err) {
1824
2536
  // Don't echo the token even on failure.
@@ -1831,12 +2543,32 @@ export function runTask(taskId, deps) {
1831
2543
  // through verbatim so a future model not yet in the embedded
1832
2544
  // table still spawns.
1833
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
+ //
1834
2552
  // v0.53 T-53-4: one claude attempt — spawn, stream both pipes to the
1835
2553
  // History tab, post a cost event (one row per attempt), and time the
1836
2554
  // run so the classifier's fast-exit env_broken heuristic can fire.
2555
+ let useRoutedModel = true;
2556
+ let pinRefusalLogged = false;
1837
2557
  const runClaudeOnce = async () => {
1838
2558
  const spawnedAt = Date.now();
1839
- 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
+ }
1840
2572
  if (child.stdin) {
1841
2573
  child.stdin.write(prompt);
1842
2574
  child.stdin.end();
@@ -1856,7 +2588,7 @@ export function runTask(taskId, deps) {
1856
2588
  // cancellation, retry, and non-zero exit alike. Best-effort: a
1857
2589
  // failed POST logs to stderr but never bubbles past the runner.
1858
2590
  {
1859
- 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);
1860
2592
  try {
1861
2593
  await postCostEvent(event);
1862
2594
  }
@@ -1897,11 +2629,13 @@ export function runTask(taskId, deps) {
1897
2629
  if (a.exitCode !== 0) {
1898
2630
  return taskEngine.classifyFailure(a.exitCode, a.stderr, a.stdout, a.durationMs);
1899
2631
  }
1900
- 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) {
1901
2635
  return {
1902
- exitCode: a.exitCode,
1903
- class: "capacity_exhausted",
1904
- 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`,
1905
2639
  };
1906
2640
  }
1907
2641
  return null;
@@ -1923,6 +2657,81 @@ export function runTask(taskId, deps) {
1923
2657
  instantEmptyStreak = 2;
1924
2658
  }
1925
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
+ }
1926
2735
  // Restore .mcp.json as soon as Claude is done — its MCP subprocess
1927
2736
  // tree comes down with it, so leaving our token-bearing config on
1928
2737
  // disk a moment longer is pure exposure surface.
@@ -1944,7 +2753,7 @@ export function runTask(taskId, deps) {
1944
2753
  // on an exit-0 capacity envelope (see classifyIfFailed) — the latter must
1945
2754
  // NOT reach the success path. The capacity_exhausted branch below always
1946
2755
  // returns, so no other exit-0 handling is affected.
1947
- if (attempt.exitCode !== 0 || classified?.class === "capacity_exhausted") {
2756
+ if (attempt.exitCode !== 0 || classified !== null) {
1948
2757
  // v0.48: classify the failure so the quarantine cause is consistent
1949
2758
  // across the event, the bus message, and the RunTaskOutcome. `let`
1950
2759
  // because v0.63's health probe can upgrade a claude_unavailable exit
@@ -1960,7 +2769,13 @@ export function runTask(taskId, deps) {
1960
2769
  // cold-started re-run hits the SAME cap, and it loops — burning a
1961
2770
  // little capacity per cold start and never committing (the EG-B
1962
2771
  // reseed signature: runner online + current_task_id=null while the
1963
- // 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).
1964
2779
  //
1965
2780
  // Fix: CLEANLY REQUEUE NOW instead of waiting for the sweep. Get the
1966
2781
  // task back to 'queued' via the runner-callable matrix route
@@ -1993,33 +2808,85 @@ export function runTask(taskId, deps) {
1993
2808
  // reason for the audit trail. Errors are surfaced but never abort the
1994
2809
  // pause: if the requeue can't land, the existing stale-running sweep
1995
2810
  // is still the backstop (same as the v0.56 behaviour this replaces).
1996
- const blockReason = `capacity_paused: session/usage cap reached mid-task; requeued${resumeAt ? ` until ${resumeAt}` : ""}`;
1997
- const toBlocked = await deps.supabase.rpc("transition_task", {
1998
- p_task_id: taskId,
1999
- p_new_status: "blocked",
2000
- p_reason: blockReason,
2001
- });
2002
- if (toBlocked.error) {
2003
- process.stderr.write(`[acc-runner] FH-1a capacity requeue: running→blocked failed for ${taskId}: ` +
2004
- `${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);
2005
2851
  }
2006
2852
  else {
2007
- 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", {
2008
2855
  p_task_id: taskId,
2009
- p_new_status: "queued",
2856
+ p_new_status: "blocked",
2857
+ p_reason: blockReason,
2010
2858
  });
2011
- if (toQueued.error) {
2012
- process.stderr.write(`[acc-runner] FH-1a capacity requeue: blocked→queued failed for ${taskId}: ` +
2013
- `${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
+ }
2014
2875
  }
2015
2876
  }
2016
2877
  await appendEvent(deps.supabase, taskId, "capacity_paused", {
2017
2878
  phase: "claude_exit",
2879
+ engine: taskEngine.id,
2018
2880
  exit_code: attempt.exitCode,
2019
2881
  exit_class: cls.class,
2020
2882
  resume_at: resumeAt,
2021
- requeued: true,
2883
+ requeued,
2884
+ pr_number: linkedPrNumber,
2885
+ pr_url: linkedPrUrl,
2886
+ gate_verdict: linkedPrNumber !== null ? "missing" : undefined,
2022
2887
  detail: cls.detail,
2888
+ stdout_tail: attempt.stdout.slice(-2000),
2889
+ stderr_tail: attempt.stderr.slice(-2000),
2023
2890
  runner_id: deps.session.runnerId,
2024
2891
  });
2025
2892
  // FH-1a: task-scoped activity event carrying pausedUntil so board /
@@ -2034,8 +2901,14 @@ export function runTask(taskId, deps) {
2034
2901
  p_payload: {
2035
2902
  task_id: taskId,
2036
2903
  paused_until: resumeAt,
2037
- requeued: true,
2904
+ requeued,
2905
+ pr_number: linkedPrNumber,
2906
+ pr_url: linkedPrUrl,
2907
+ gate_verdict: linkedPrNumber !== null ? "missing" : undefined,
2038
2908
  detail: cls.detail,
2909
+ engine: taskEngine.id,
2910
+ stdout_tail: attempt.stdout.slice(-4000),
2911
+ stderr_tail: attempt.stderr.slice(-4000),
2039
2912
  runner_id: deps.session.runnerId,
2040
2913
  },
2041
2914
  });
@@ -2048,6 +2921,7 @@ export function runTask(taskId, deps) {
2048
2921
  exitCode: attempt.exitCode,
2049
2922
  error: cls.detail,
2050
2923
  capacity_exhausted: true,
2924
+ engine: taskEngine.id,
2051
2925
  resume_at: resumeAt,
2052
2926
  };
2053
2927
  }
@@ -2157,24 +3031,60 @@ export function runTask(taskId, deps) {
2157
3031
  // (heuristic-confirmed).
2158
3032
  const quarantineCause = cls.class !== "task_error" ? cls.class : undefined;
2159
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
+ });
2160
3057
  await appendEvent(deps.supabase, taskId, "error", {
2161
3058
  phase: "claude_exit",
3059
+ engine: taskEngine.id,
2162
3060
  exit_code: attempt.exitCode,
2163
3061
  exit_class: cls.class,
2164
3062
  exit_class_heuristic: cls.heuristic === true,
2165
3063
  consecutive_instant_empty: instantEmptyStreak,
2166
3064
  quarantine: quarantineCause !== undefined,
2167
- 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
+ : {}),
2168
3075
  });
2169
3076
  await postState_("blocked", "error_context", {
2170
3077
  task_id: taskId,
2171
3078
  phase: "code",
3079
+ engine: taskEngine.id,
2172
3080
  error_class: `claude_exit_${attempt.exitCode ?? "unknown"}`,
2173
3081
  // v0.48: include the classified failure class so the planner can
2174
3082
  // distinguish machine-level failures from task-level failures.
2175
3083
  exit_class: cls.class,
2176
- // Recommended cap from MESSAGING_PROTOCOLS.md: last ~4 KB.
2177
- stderr_tail: attempt.stderr.slice(-4000),
3084
+ reasons: diagnostics.reasons,
3085
+ stdout_tail: diagnostics.stdoutTail,
3086
+ stderr_tail: diagnostics.stderrTail,
3087
+ ...(usageLimitIdentity ?? {}),
2178
3088
  });
2179
3089
  await deps.supabase.rpc("transition_task", {
2180
3090
  p_task_id: taskId,
@@ -2185,9 +3095,15 @@ export function runTask(taskId, deps) {
2185
3095
  status: "failed",
2186
3096
  phase: "claude_exit",
2187
3097
  exitCode: attempt.exitCode,
2188
- error: attempt.stderr.slice(-200).trim() || cls.detail,
3098
+ error: diagnostics.reasons[0] ?? cls.detail,
2189
3099
  quarantine_cause: quarantineCause,
2190
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 } : {}),
2191
3107
  };
2192
3108
  }
2193
3109
  // Success: expose the final attempt's stdout under the name the
@@ -2222,28 +3138,41 @@ export function runTask(taskId, deps) {
2222
3138
  // drift the agent couldn't fix re-fail at review/CI and burn the
2223
3139
  // cycle). On failure HALT (blocked → operator review) instead of
2224
3140
  // pushing — no review re-requested, no cycle consumed.
2225
- const reworkGate = await runPreprGate(workdir, resolvedBaseRef).catch((err) => ({
2226
- ok: false,
2227
- skipped: false,
2228
- ran: [],
2229
- absent: [],
2230
- failure: {
2231
- gate: "typecheck",
2232
- exitCode: null,
2233
- outputTail: `pre-PR gate runner crashed: ${err.message?.slice(0, 400)}`,
2234
- },
2235
- }));
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
+ }
2236
3148
  if (!reworkGate.skipped && !reworkGate.ok && reworkGate.failure) {
2237
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";
2238
3162
  await appendEvent(deps.supabase, taskId, "rework_halt", {
2239
3163
  phase: "prepr_gate",
2240
3164
  pr_number: reworkMeta.pr_number,
2241
3165
  repo: reworkMeta.repo,
2242
3166
  branch,
2243
- error_class: "rework_gate_failed",
3167
+ error_class: errorClass,
3168
+ block_reason: blockReason,
2244
3169
  failed_gate: fail.gate,
2245
3170
  exit_code: fail.exitCode,
2246
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),
2247
3176
  stderr_tail: fail.outputTail,
2248
3177
  runner_id: deps.session.runnerId,
2249
3178
  });
@@ -2253,22 +3182,31 @@ export function runTask(taskId, deps) {
2253
3182
  await postState_("blocked", "error_context", {
2254
3183
  task_id: taskId,
2255
3184
  phase: "review",
2256
- error_class: "rework_gate_failed",
3185
+ error_class: errorClass,
3186
+ block_reason: blockReason,
2257
3187
  failed_gate: fail.gate,
2258
3188
  stderr_tail: fail.outputTail,
2259
3189
  });
2260
- await deps.supabase.rpc("transition_task", {
2261
- p_task_id: taskId,
2262
- p_new_status: "blocked",
3190
+ await transitionBlocked(blockReason, {
3191
+ phase: "prepr_gate",
3192
+ failed_gate: fail.gate,
3193
+ rework: true,
2263
3194
  });
2264
3195
  return {
2265
3196
  taskId,
2266
3197
  status: "failed",
2267
3198
  phase: "prepr_gate",
2268
3199
  exitCode: fail.exitCode,
2269
- 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`,
2270
3202
  };
2271
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;
2272
3210
  // RVU-5: rework completion requires an ADVANCED head. Compare the local
2273
3211
  // HEAD to the reviewed head (origin/<branch> — what the reviewer last
2274
3212
  // saw). When they match, the agent + reconcile produced NO new commit:
@@ -2407,19 +3345,13 @@ export function runTask(taskId, deps) {
2407
3345
  // the runner: no PR is opened, the task fails with
2408
3346
  // `error_class: prepr_gate_failed`, and the failing gate + output tail
2409
3347
  // land on the History tab. Skippable via ACC_RUNNER_SKIP_PREPR=1.
2410
- const prepr = await runPreprGate(workdir, resolvedBaseRef).catch((err) => ({
2411
- // A gate-runner that itself crashed must fail closed: do not wave a
2412
- // PR through because the check errored.
2413
- ok: false,
2414
- skipped: false,
2415
- ran: [],
2416
- absent: [],
2417
- failure: {
2418
- gate: "typecheck",
2419
- exitCode: null,
2420
- outputTail: `pre-PR gate runner crashed: ${err.message?.slice(0, 400)}`,
2421
- },
2422
- }));
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
+ }
2423
3355
  if (prepr.skipped) {
2424
3356
  await appendEvent(deps.supabase, taskId, "log", {
2425
3357
  phase: "prepr_gate",
@@ -2431,28 +3363,65 @@ export function runTask(taskId, deps) {
2431
3363
  }
2432
3364
  else if (!prepr.ok && prepr.failure) {
2433
3365
  const fail = prepr.failure;
2434
- 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`;
2435
3387
  await appendEvent(deps.supabase, taskId, "error", {
2436
3388
  phase: "prepr_gate",
2437
- error_class: "prepr_gate_failed",
3389
+ error_class: errorClass,
3390
+ block_reason: blockReason,
2438
3391
  failed_gate: fail.gate,
2439
3392
  exit_code: fail.exitCode,
2440
3393
  gates_ran: prepr.ran,
2441
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),
2442
3398
  stderr_tail: fail.outputTail,
2443
3399
  runner_id: deps.session.runnerId,
2444
3400
  });
2445
3401
  await postState_("blocked", "error_context", {
2446
3402
  task_id: taskId,
2447
3403
  phase: "review",
2448
- error_class: "prepr_gate_failed",
3404
+ error_class: errorClass,
3405
+ block_reason: blockReason,
2449
3406
  failed_gate: fail.gate,
2450
3407
  stderr_tail: fail.outputTail,
2451
3408
  });
2452
- await deps.supabase.rpc("transition_task", {
2453
- p_task_id: taskId,
2454
- p_new_status: "failed",
2455
- });
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
+ }
2456
3425
  return {
2457
3426
  taskId,
2458
3427
  status: "failed",
@@ -2471,11 +3440,44 @@ export function runTask(taskId, deps) {
2471
3440
  runner_id: deps.session.runnerId,
2472
3441
  });
2473
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;
2474
3448
  // MIG-SEQ-GUARD: the LAST thing before the push. It deliberately runs
2475
3449
  // after the pre-PR gate rather than inside it — the gate's own MIGFIX-01
2476
3450
  // renumber reads the fork-time worktree snapshot, and only a fresh fetch
2477
3451
  // here can see a sibling that merged while the gate was running.
2478
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
+ }
2479
3481
  // 5. Push + open PR. Both run from the worktree so the operator's
2480
3482
  // shared clone never has the task branch checked out.
2481
3483
  let prUrl = "";
@@ -2530,6 +3532,31 @@ export function runTask(taskId, deps) {
2530
3532
  runner_id: deps.session.runnerId,
2531
3533
  branch,
2532
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
+ }
2533
3560
  try {
2534
3561
  const report = extractReportFromOutput(capturedStdout, taskEngineId);
2535
3562
  // V96 (R4): generate the PR-body "Files changed" table FROM the actual
@@ -2539,24 +3566,50 @@ export function runTask(taskId, deps) {
2539
3566
  // without git.diff), fall back to the raw report body rather than
2540
3567
  // blocking the PR open.
2541
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;
2542
3572
  try {
2543
- 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");
2544
3575
  // RR-1: pass the task's acceptance criteria so composePrBody emits the
2545
3576
  // machine-readable `Covers:` scope manifest (whole-task by default; the
2546
3577
  // agent may declare a partial scope in its report).
2547
- if (diff != null)
3578
+ if (diff != null) {
2548
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
+ }
2549
3588
  }
2550
3589
  catch (composeErr) {
2551
3590
  process.stderr.write(`[acc-runner] pr-body compose failed (using raw report): ` +
2552
3591
  `${composeErr.message?.slice(0, 200)}\n`);
2553
3592
  }
2554
- const pr = await gh.openPR(workdir, {
2555
- title: prTitleForTask(task.id, task.title),
2556
- body,
2557
- base: integrationBranch,
2558
- });
2559
- 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
+ }
2560
3613
  }
2561
3614
  catch (err) {
2562
3615
  // V109: a GitHub rate limit / spent budget while opening the PR is
@@ -2594,12 +3647,45 @@ export function runTask(taskId, deps) {
2594
3647
  // and the user can open a PR manually (webhook back-write picks
2595
3648
  // it up). Wrapped so a logging RPC failure can't block the
2596
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
+ }
2597
3659
  try {
2598
- 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
+ });
2599
3671
  }
2600
3672
  catch (logErr) {
2601
3673
  process.stderr.write(`[acc-runner] pr_open error log failed: ${logErr.message}\n`);
2602
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
+ }
2603
3689
  }
2604
3690
  }
2605
3691
  // v0.35-B: write pr_number + transition running→needs-review via
@@ -2676,6 +3762,12 @@ export function runTask(taskId, deps) {
2676
3762
  // made outside a task run is unbudgeted.
2677
3763
  endTaskGithubBudget();
2678
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);
2679
3771
  }
2680
3772
  })();
2681
3773
  return {