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