@tokenfactory/acc-runner 0.14.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/failure-classifier.d.ts +71 -13
- package/dist/failure-classifier.d.ts.map +1 -1
- package/dist/failure-classifier.js +203 -15
- package/dist/failure-classifier.js.map +1 -1
- package/dist/runtime/resolution-integrity.d.ts +86 -0
- package/dist/runtime/resolution-integrity.d.ts.map +1 -0
- package/dist/runtime/resolution-integrity.js +248 -0
- package/dist/runtime/resolution-integrity.js.map +1 -0
- package/dist/runtime/reviewer.d.ts +8 -1
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +18 -0
- package/dist/runtime/reviewer.js.map +1 -1
- package/dist/task-runner.d.ts +99 -1
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +345 -21
- package/dist/task-runner.js.map +1 -1
- package/dist/watch.d.ts +25 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +295 -32
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
package/dist/task-runner.js
CHANGED
|
@@ -22,7 +22,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
22
22
|
import { execa } from "execa";
|
|
23
23
|
import { loadProfile as defaultLoadProfile } from "./config.js";
|
|
24
24
|
import { normalizeUsage, parseClaudeJson, priceUsdCents, toCliAlias, } from "./cost-pricing.js";
|
|
25
|
-
import { classifyClaudeExit } from "./failure-classifier.js";
|
|
25
|
+
import { classifyClaudeExit, extractResetTime, } from "./failure-classifier.js";
|
|
26
26
|
import { startTaskGithubBudget, endTaskGithubBudget, } from "./github-client.js";
|
|
27
27
|
import { git as defaultGit } from "./git.js";
|
|
28
28
|
import { gh as defaultGh } from "./gh.js";
|
|
@@ -33,6 +33,7 @@ import { acquireTaskLock as defaultAcquireTaskLock, TaskLockHeldError, } from ".
|
|
|
33
33
|
import { prepareTaskWorktree as defaultPrepareTaskWorktree, } from "./runtime/worktree.js";
|
|
34
34
|
import { parseConflictMeta, resolveConflict, MAX_CONFLICT_ATTEMPTS, } from "./runtime/conflict-resolver.js";
|
|
35
35
|
import { parseReworkMeta, renderReworkPrompt, fetchPrHeadRef as defaultFetchPrHeadRef, } from "./runtime/rework.js";
|
|
36
|
+
import { assertResolutionIntegrity, isConflictResolutionTask, summariseFailures, } from "./runtime/resolution-integrity.js";
|
|
36
37
|
const LOG_BATCH_BYTES = 4 * 1024;
|
|
37
38
|
/**
|
|
38
39
|
* v0.6.0 REG-296: build the argv for `claude --print`. Splitting this
|
|
@@ -85,6 +86,85 @@ export function expandHomePath(p) {
|
|
|
85
86
|
return path.join(os.homedir(), p.slice(2));
|
|
86
87
|
return p;
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* v0.63 T-63-1: default claude health probe. A silent instant-empty exit is
|
|
91
|
+
* ambiguous — it can be a broken host OR a momentarily-unavailable claude.
|
|
92
|
+
* `claude --version` is a cheap, deterministic answer to "is the binary/host
|
|
93
|
+
* actually broken?": if it returns 0 the host is fine (the exit was
|
|
94
|
+
* transient → claude_unavailable, retry), and any spawn failure / non-zero
|
|
95
|
+
* exit / timeout means the binary or its dynamic deps are genuinely broken
|
|
96
|
+
* (→ env_broken, quarantine). Short timeout so a probe never wedges the run.
|
|
97
|
+
*/
|
|
98
|
+
async function defaultHealthProbe() {
|
|
99
|
+
try {
|
|
100
|
+
const res = await execa("claude", ["--version"], {
|
|
101
|
+
reject: false,
|
|
102
|
+
timeout: 5_000,
|
|
103
|
+
env: process.env,
|
|
104
|
+
});
|
|
105
|
+
if (res.exitCode === 0) {
|
|
106
|
+
return { ok: true, detail: (res.stdout || "").trim().slice(0, 200) || "claude --version ok" };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
detail: `claude --version exited ${res.exitCode ?? "signal"}: ${(res.stderr || "").trim().slice(0, 200)}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { ok: false, detail: `claude --version spawn failed: ${err.message}` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const DEFAULT_CLAUDE_UNAVAILABLE_KNOBS = {
|
|
118
|
+
windowMs: 15 * 60_000,
|
|
119
|
+
alertThreshold: 5,
|
|
120
|
+
backoffBaseMs: 30_000,
|
|
121
|
+
backoffMaxMs: 15 * 60_000,
|
|
122
|
+
};
|
|
123
|
+
// Process-level state. runTask() is a fresh closure per task, but the module
|
|
124
|
+
// is loaded once, so consecutive task runs in the same `acc-runner watch`
|
|
125
|
+
// process share this — the only place a cross-task streak can live without
|
|
126
|
+
// touching watch.ts (out of this track's file scope). A clean (exit-0) claude
|
|
127
|
+
// run resets it: claude has recovered.
|
|
128
|
+
let claudeUnavailableKnobs = { ...DEFAULT_CLAUDE_UNAVAILABLE_KNOBS };
|
|
129
|
+
let claudeUnavailableHits = [];
|
|
130
|
+
let claudeUnavailableStreak = 0;
|
|
131
|
+
/** v0.63 T-63-1: override the silent-unavailability knobs (used by tests). */
|
|
132
|
+
export function configureClaudeUnavailable(partial) {
|
|
133
|
+
claudeUnavailableKnobs = { ...claudeUnavailableKnobs, ...partial };
|
|
134
|
+
}
|
|
135
|
+
/** v0.63 T-63-1: reset both the streak and the knobs (used by tests). */
|
|
136
|
+
export function resetClaudeUnavailableState() {
|
|
137
|
+
claudeUnavailableHits = [];
|
|
138
|
+
claudeUnavailableStreak = 0;
|
|
139
|
+
claudeUnavailableKnobs = { ...DEFAULT_CLAUDE_UNAVAILABLE_KNOBS };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* v0.63 T-63-1: record one silent instant-empty exit and compute the response
|
|
143
|
+
* (exponential backoff + whether the sustained-streak INFRA alert trips).
|
|
144
|
+
* Exported so the watch process and the unit suite share one definition.
|
|
145
|
+
*/
|
|
146
|
+
export function recordClaudeUnavailable(now) {
|
|
147
|
+
claudeUnavailableStreak += 1;
|
|
148
|
+
claudeUnavailableHits.push(now);
|
|
149
|
+
const cutoff = now - claudeUnavailableKnobs.windowMs;
|
|
150
|
+
claudeUnavailableHits = claudeUnavailableHits.filter((t) => t >= cutoff);
|
|
151
|
+
const windowCount = claudeUnavailableHits.length;
|
|
152
|
+
const alert = windowCount >= claudeUnavailableKnobs.alertThreshold;
|
|
153
|
+
const expo = claudeUnavailableKnobs.backoffBaseMs * 2 ** (claudeUnavailableStreak - 1);
|
|
154
|
+
const backoffMs = Math.min(expo, claudeUnavailableKnobs.backoffMaxMs);
|
|
155
|
+
return {
|
|
156
|
+
streak: claudeUnavailableStreak,
|
|
157
|
+
windowCount,
|
|
158
|
+
alert,
|
|
159
|
+
backoffMs,
|
|
160
|
+
resumeAt: new Date(now + backoffMs).toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** v0.63 T-63-1: a clean claude run proves the binary recovered — reset. */
|
|
164
|
+
function clearClaudeUnavailableStreak() {
|
|
165
|
+
claudeUnavailableStreak = 0;
|
|
166
|
+
claudeUnavailableHits = [];
|
|
167
|
+
}
|
|
88
168
|
// v0.37-A HEAD-LOCK-GUARD: git worktree add/remove/prune can leave stale
|
|
89
169
|
// .git/HEAD.lock or .git/index.lock behind. The virtiofs boundary keeps
|
|
90
170
|
// the sandbox from clearing them, but the host runner process can. Call
|
|
@@ -447,6 +527,7 @@ export function runTask(taskId, deps) {
|
|
|
447
527
|
((args) => defaultPostRunnerStateMessage(deps.supabase, args));
|
|
448
528
|
const loadProfile = deps.loadProfile ?? defaultLoadProfile;
|
|
449
529
|
const loadPromptPreludeFn = deps.loadPromptPrelude ?? loadPromptPrelude;
|
|
530
|
+
const healthProbe = deps.healthProbe ?? defaultHealthProbe;
|
|
450
531
|
// Silence the unused-binding lint for `checkoutBase` — v0.11-F supersedes
|
|
451
532
|
// the v0.6.0 REG-301 pre-spawn `git checkout <baseBranch>` with the
|
|
452
533
|
// worktree's `add -B <branch> <path> <baseBranch>` start-point semantics,
|
|
@@ -746,6 +827,102 @@ export function runTask(taskId, deps) {
|
|
|
746
827
|
// return that follows.
|
|
747
828
|
let worktree = null;
|
|
748
829
|
let workdir = repoPath;
|
|
830
|
+
// v0.57 T-57-2: conflict-resolution integrity guardrail. For tasks whose
|
|
831
|
+
// job is to resolve a merge conflict (the Claude/worktree path that the
|
|
832
|
+
// mechanical resolver hands off to when it bails), re-verify the
|
|
833
|
+
// resolution from the runner's own vantage point before pushing —
|
|
834
|
+
// zero conflict markers, typecheck green, and no silently-dropped /
|
|
835
|
+
// regressed version bump. On failure the resolution is REJECTED (no
|
|
836
|
+
// push, not marked resolved) and the task fails with a diagnostic that
|
|
837
|
+
// names the offending assertion, so a broken merge is reworked rather
|
|
838
|
+
// than shipped. A no-op for every non-conflict task.
|
|
839
|
+
const runResolutionIntegrityGate = async () => {
|
|
840
|
+
if (!isConflictResolutionTask(task))
|
|
841
|
+
return null;
|
|
842
|
+
const assertIntegrity = deps.assertResolutionIntegrity ?? assertResolutionIntegrity;
|
|
843
|
+
let result;
|
|
844
|
+
try {
|
|
845
|
+
result = await assertIntegrity(workdir, {
|
|
846
|
+
baseRef: integrationBranch,
|
|
847
|
+
typecheckCmd: task.typecheck_cmd ?? undefined,
|
|
848
|
+
// The #496 regression lived in the acc-runner package; also guard
|
|
849
|
+
// the repo root. Each pair is skipped silently when its version is
|
|
850
|
+
// unchanged or unreadable, so this never false-fails.
|
|
851
|
+
versionTargets: [
|
|
852
|
+
{ packageJsonPath: "package.json", changelogPath: "CHANGELOG.md" },
|
|
853
|
+
{
|
|
854
|
+
packageJsonPath: "packages/acc-runner/package.json",
|
|
855
|
+
changelogPath: "packages/acc-runner/CHANGELOG.md",
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
// A guardrail that itself crashed must fail closed: reject, never
|
|
862
|
+
// wave a resolution through because the check errored.
|
|
863
|
+
result = {
|
|
864
|
+
ok: false,
|
|
865
|
+
checked: [],
|
|
866
|
+
failures: [
|
|
867
|
+
{
|
|
868
|
+
assertion: "typecheck",
|
|
869
|
+
detail: `integrity check crashed: ${err.message?.slice(0, 200)}`,
|
|
870
|
+
},
|
|
871
|
+
],
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
if (result.ok) {
|
|
875
|
+
await appendEvent(deps.supabase, taskId, "resolution_integrity_passed", {
|
|
876
|
+
pr_number: task.pr_number ?? null,
|
|
877
|
+
checked: result.checked,
|
|
878
|
+
});
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
const summary = summariseFailures(result.failures);
|
|
882
|
+
const detail = result.failures
|
|
883
|
+
.map((f) => `${f.assertion}: ${f.detail}`)
|
|
884
|
+
.join("\n");
|
|
885
|
+
await appendEvent(deps.supabase, taskId, "resolution_integrity_failed", {
|
|
886
|
+
pr_number: task.pr_number ?? null,
|
|
887
|
+
failed_assertions: result.failures.map((f) => f.assertion),
|
|
888
|
+
files: result.failures.flatMap((f) => f.files ?? []),
|
|
889
|
+
detail: detail.slice(-4000),
|
|
890
|
+
});
|
|
891
|
+
// Best-effort operator-facing activity event naming the failed assertion.
|
|
892
|
+
try {
|
|
893
|
+
await deps.supabase.rpc("log_activity", {
|
|
894
|
+
p_verb: "conflict.integrity_rejected",
|
|
895
|
+
p_target_id: String(task.pr_number ?? taskId),
|
|
896
|
+
p_payload: {
|
|
897
|
+
task_id: taskId,
|
|
898
|
+
repo: task.repo ?? null,
|
|
899
|
+
failed_assertions: result.failures.map((f) => f.assertion),
|
|
900
|
+
},
|
|
901
|
+
p_target_type: task.pr_number ? "pr" : "task",
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
/* best-effort */
|
|
906
|
+
}
|
|
907
|
+
await postState_("blocked", "error_context", {
|
|
908
|
+
task_id: taskId,
|
|
909
|
+
phase: "review",
|
|
910
|
+
error_class: "resolution_integrity_failed",
|
|
911
|
+
stderr_tail: detail.slice(-4000),
|
|
912
|
+
});
|
|
913
|
+
// Reject: do NOT push, do NOT mark resolved. Fail the task so the
|
|
914
|
+
// resolution is reworked instead of merged broken.
|
|
915
|
+
await deps.supabase.rpc("transition_task", {
|
|
916
|
+
p_task_id: taskId,
|
|
917
|
+
p_new_status: "failed",
|
|
918
|
+
});
|
|
919
|
+
return {
|
|
920
|
+
taskId,
|
|
921
|
+
status: "failed",
|
|
922
|
+
phase: "push",
|
|
923
|
+
error: `conflict-resolution integrity guardrail rejected resolution (${summary})`,
|
|
924
|
+
};
|
|
925
|
+
};
|
|
749
926
|
try {
|
|
750
927
|
// 3. Repo prep. v0.11-F: fetch on the shared clone (worktree
|
|
751
928
|
// shares the object store) then provision an isolated worktree
|
|
@@ -926,32 +1103,35 @@ export function runTask(taskId, deps) {
|
|
|
926
1103
|
durationMs: Date.now() - spawnedAt,
|
|
927
1104
|
};
|
|
928
1105
|
};
|
|
929
|
-
// v0.53 T-53-4:
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
1106
|
+
// v0.53 T-53-4 / v0.63 T-63-1: instant-empty tune. A lone instant-empty
|
|
1107
|
+
// exit (the pd36 fast-exit fingerprint, now classified
|
|
1108
|
+
// `claude_unavailable`) is too weak a signal to act on — v0.52 incident
|
|
1109
|
+
// (d) quarantined a working machine on ONE 249ms empty exit. So when the
|
|
1110
|
+
// FIRST exit is a *heuristic* claude_unavailable we log it and retry the
|
|
1111
|
+
// spawn once; a single blip that the retry recovers stays silent. Only a
|
|
1112
|
+
// CONFIRMED streak (two consecutive instant-empties) is acted on — and
|
|
1113
|
+
// even then v0.63 routes it to pause + backoff + retry (NOT quarantine),
|
|
1114
|
+
// gated by the health probe below. Definitive failures (dyld/OOM/exit
|
|
1115
|
+
// 127, usage/auth patterns — classified.heuristic falsey) skip the retry
|
|
1116
|
+
// and quarantine immediately, unchanged from v0.48.
|
|
937
1117
|
let attempt = await runClaudeOnce();
|
|
938
1118
|
const classifyIfFailed = (a) => a.exitCode === 0
|
|
939
1119
|
? null
|
|
940
1120
|
: classifyClaudeExit(a.exitCode, a.stderr, a.stdout, a.durationMs);
|
|
941
1121
|
let classified = classifyIfFailed(attempt);
|
|
942
|
-
let instantEmptyStreak = classified?.class === "
|
|
1122
|
+
let instantEmptyStreak = classified?.class === "claude_unavailable" && classified.heuristic ? 1 : 0;
|
|
943
1123
|
if (instantEmptyStreak === 1 && !cancelled) {
|
|
944
1124
|
await appendEvent(deps.supabase, taskId, "log", {
|
|
945
1125
|
phase: "claude_exit",
|
|
946
1126
|
stream: "stderr",
|
|
947
|
-
event: "
|
|
1127
|
+
event: "claude_unavailable_blip",
|
|
948
1128
|
attempt: 1,
|
|
949
1129
|
duration_ms: attempt.durationMs,
|
|
950
|
-
message: "instant empty claude exit — single blip, retrying task once before
|
|
1130
|
+
message: "instant empty claude exit — single blip, retrying task once before pausing",
|
|
951
1131
|
});
|
|
952
1132
|
attempt = await runClaudeOnce();
|
|
953
1133
|
classified = classifyIfFailed(attempt);
|
|
954
|
-
if (classified?.class === "
|
|
1134
|
+
if (classified?.class === "claude_unavailable" && classified.heuristic) {
|
|
955
1135
|
instantEmptyStreak = 2;
|
|
956
1136
|
}
|
|
957
1137
|
}
|
|
@@ -974,15 +1154,146 @@ export function runTask(taskId, deps) {
|
|
|
974
1154
|
}
|
|
975
1155
|
if (attempt.exitCode !== 0) {
|
|
976
1156
|
// v0.48: classify the failure so the quarantine cause is consistent
|
|
977
|
-
// across the event, the bus message, and the RunTaskOutcome.
|
|
978
|
-
|
|
1157
|
+
// across the event, the bus message, and the RunTaskOutcome. `let`
|
|
1158
|
+
// because v0.63's health probe can upgrade a claude_unavailable exit
|
|
1159
|
+
// to env_broken when the probe proves the host is genuinely broken.
|
|
1160
|
+
let cls = classified ??
|
|
979
1161
|
classifyClaudeExit(attempt.exitCode, attempt.stderr, attempt.stdout, attempt.durationMs);
|
|
980
|
-
// v0.
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
|
|
985
|
-
|
|
1162
|
+
// v0.56 (T-56-1): capacity exhaustion is NOT a task failure. Do not
|
|
1163
|
+
// transition the task to 'failed' (that would dead-letter it and burn
|
|
1164
|
+
// the reviewer/automerge retry cap). Leave it 'running' with its
|
|
1165
|
+
// signal loop stopped (the outer finally does this) so the
|
|
1166
|
+
// stale-running sweep returns it to 'queued' with runner_id cleared —
|
|
1167
|
+
// a lossless requeue for any runner that still has capacity. Surface
|
|
1168
|
+
// the parsed reset time so watch.ts can pause until the window
|
|
1169
|
+
// reopens. The locks are released by the outer finally exactly as on
|
|
1170
|
+
// any other exit path.
|
|
1171
|
+
if (cls.class === "capacity_exhausted") {
|
|
1172
|
+
const resumeMs = extractResetTime(`${attempt.stderr}\n${attempt.stdout}`);
|
|
1173
|
+
const resumeAt = resumeMs !== null ? new Date(resumeMs).toISOString() : null;
|
|
1174
|
+
await appendEvent(deps.supabase, taskId, "capacity_paused", {
|
|
1175
|
+
phase: "claude_exit",
|
|
1176
|
+
exit_code: attempt.exitCode,
|
|
1177
|
+
exit_class: cls.class,
|
|
1178
|
+
resume_at: resumeAt,
|
|
1179
|
+
detail: cls.detail,
|
|
1180
|
+
runner_id: deps.session.runnerId,
|
|
1181
|
+
});
|
|
1182
|
+
return {
|
|
1183
|
+
taskId,
|
|
1184
|
+
status: "capacity_paused",
|
|
1185
|
+
phase: "claude_exit",
|
|
1186
|
+
exitCode: attempt.exitCode,
|
|
1187
|
+
error: cls.detail,
|
|
1188
|
+
capacity_exhausted: true,
|
|
1189
|
+
resume_at: resumeAt,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
// v0.63 (T-63-1): a bare silent instant-empty exit. Silence alone
|
|
1193
|
+
// cannot tell "broken host" from "claude momentarily unavailable", so
|
|
1194
|
+
// run an authoritative `claude --version` health probe as the
|
|
1195
|
+
// tiebreaker. Probe OK → the host is fine → treat exactly like
|
|
1196
|
+
// capacity_exhausted (pause + EXPONENTIAL backoff + retry; leave the
|
|
1197
|
+
// task 'running' for the stale-running sweep; NEVER quarantine, NEVER
|
|
1198
|
+
// burn the task/review retry caps). Probe FAILS → the binary/host is
|
|
1199
|
+
// genuinely broken → upgrade to env_broken and fall through to the
|
|
1200
|
+
// quarantine path (the pd36 contract, now gated by a real signal
|
|
1201
|
+
// rather than silence). A human INFRA alert fires only after a
|
|
1202
|
+
// SUSTAINED streak inside a rolling window, never on 2.
|
|
1203
|
+
if (cls.class === "claude_unavailable") {
|
|
1204
|
+
const probe = await healthProbe();
|
|
1205
|
+
await appendEvent(deps.supabase, taskId, "log", {
|
|
1206
|
+
phase: "claude_exit",
|
|
1207
|
+
stream: "stderr",
|
|
1208
|
+
event: "claude_health_probe",
|
|
1209
|
+
ok: probe.ok,
|
|
1210
|
+
detail: probe.detail,
|
|
1211
|
+
consecutive_instant_empty: instantEmptyStreak,
|
|
1212
|
+
});
|
|
1213
|
+
if (probe.ok) {
|
|
1214
|
+
const decision = recordClaudeUnavailable(Date.now());
|
|
1215
|
+
await appendEvent(deps.supabase, taskId, "claude_unavailable", {
|
|
1216
|
+
phase: "claude_exit",
|
|
1217
|
+
exit_code: attempt.exitCode,
|
|
1218
|
+
exit_class: cls.class,
|
|
1219
|
+
consecutive_instant_empty: instantEmptyStreak,
|
|
1220
|
+
duration_ms: attempt.durationMs,
|
|
1221
|
+
resume_at: decision.resumeAt,
|
|
1222
|
+
streak: decision.streak,
|
|
1223
|
+
window_count: decision.windowCount,
|
|
1224
|
+
detail: cls.detail,
|
|
1225
|
+
runner_id: deps.session.runnerId,
|
|
1226
|
+
});
|
|
1227
|
+
// Best-effort audit trail on the runner timeline.
|
|
1228
|
+
try {
|
|
1229
|
+
await deps.supabase.rpc("log_activity", {
|
|
1230
|
+
p_verb: "runner.claude_unavailable",
|
|
1231
|
+
p_target_id: deps.session.runnerId,
|
|
1232
|
+
p_target_type: "runner",
|
|
1233
|
+
p_payload: {
|
|
1234
|
+
task_id: taskId,
|
|
1235
|
+
streak: decision.streak,
|
|
1236
|
+
window_count: decision.windowCount,
|
|
1237
|
+
backoff_ms: decision.backoffMs,
|
|
1238
|
+
resume_at: decision.resumeAt,
|
|
1239
|
+
detail: cls.detail,
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
catch { /* best-effort */ }
|
|
1244
|
+
// Escalate to a human only on a SUSTAINED streak — not a single
|
|
1245
|
+
// pause, not 2. Still NOT a quarantine: the runner stays online,
|
|
1246
|
+
// paused + retrying, while an operator investigates infra.
|
|
1247
|
+
if (decision.alert) {
|
|
1248
|
+
try {
|
|
1249
|
+
await deps.supabase.rpc("log_activity", {
|
|
1250
|
+
p_verb: "runner.claude_unavailable_infra",
|
|
1251
|
+
p_target_id: deps.session.runnerId,
|
|
1252
|
+
p_target_type: "runner",
|
|
1253
|
+
p_payload: {
|
|
1254
|
+
task_id: taskId,
|
|
1255
|
+
window_count: decision.windowCount,
|
|
1256
|
+
streak: decision.streak,
|
|
1257
|
+
detail: cls.detail,
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
catch { /* best-effort */ }
|
|
1262
|
+
await postState_("blocked", "error_context", {
|
|
1263
|
+
task_id: taskId,
|
|
1264
|
+
phase: "code",
|
|
1265
|
+
error_class: "claude_unavailable_sustained",
|
|
1266
|
+
infra_alert: true,
|
|
1267
|
+
window_count: decision.windowCount,
|
|
1268
|
+
stderr_tail: cls.detail,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
return {
|
|
1272
|
+
taskId,
|
|
1273
|
+
status: "capacity_paused",
|
|
1274
|
+
phase: "claude_exit",
|
|
1275
|
+
exitCode: attempt.exitCode,
|
|
1276
|
+
error: `claude silently unavailable (instant-empty exit) — pausing ` +
|
|
1277
|
+
`${Math.round(decision.backoffMs / 1000)}s then retrying, not quarantining`,
|
|
1278
|
+
claude_unavailable: true,
|
|
1279
|
+
resume_at: decision.resumeAt,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
// Probe failed — the host/binary really is broken. This IS an
|
|
1283
|
+
// env_broken; fall through to the quarantine path below.
|
|
1284
|
+
cls = {
|
|
1285
|
+
exitCode: attempt.exitCode,
|
|
1286
|
+
class: "env_broken",
|
|
1287
|
+
detail: `${cls.detail}; claude health probe failed: ${probe.detail}`,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
// v0.48 / v0.63: by this point claude_unavailable has either returned
|
|
1291
|
+
// (probe OK) or been rewritten to env_broken (probe failed), so any
|
|
1292
|
+
// non-task_error class is a genuine quarantine cause. consecutive
|
|
1293
|
+
// mirrors the instant-empty streak for an env_broken upgraded from the
|
|
1294
|
+
// heuristic (≥1), so quarantine.json still records 1 (definitive) vs 2
|
|
1295
|
+
// (heuristic-confirmed).
|
|
1296
|
+
const quarantineCause = cls.class !== "task_error" ? cls.class : undefined;
|
|
986
1297
|
const quarantineConsecutive = quarantineCause === "env_broken" ? Math.max(instantEmptyStreak, 1) : undefined;
|
|
987
1298
|
await appendEvent(deps.supabase, taskId, "error", {
|
|
988
1299
|
phase: "claude_exit",
|
|
@@ -1020,6 +1331,10 @@ export function runTask(taskId, deps) {
|
|
|
1020
1331
|
// Success: expose the final attempt's stdout under the name the
|
|
1021
1332
|
// downstream report/PR path expects.
|
|
1022
1333
|
const capturedStdout = attempt.stdout;
|
|
1334
|
+
// v0.63 (T-63-1): a clean claude run proves the binary recovered — reset
|
|
1335
|
+
// the silent-unavailability streak so the next blip restarts the backoff
|
|
1336
|
+
// ladder from the base and the rolling INFRA-alert window starts fresh.
|
|
1337
|
+
clearClaudeUnavailableStreak();
|
|
1023
1338
|
// v0.14-MESSAGING-RUNTIME-WIRE: Claude finished successfully.
|
|
1024
1339
|
// testing covers the post-exit window where the runner inspects
|
|
1025
1340
|
// the captured stdout (parses the report, builds the cost event)
|
|
@@ -1036,6 +1351,10 @@ export function runTask(taskId, deps) {
|
|
|
1036
1351
|
// push failure here: a rework push that didn't land means the
|
|
1037
1352
|
// reviewer feedback was NOT addressed, so the task must fail.
|
|
1038
1353
|
if (reworkMeta) {
|
|
1354
|
+
// v0.57 T-57-2: gate conflict-resolution reworks before the push.
|
|
1355
|
+
const reworkIntegrity = await runResolutionIntegrityGate();
|
|
1356
|
+
if (reworkIntegrity)
|
|
1357
|
+
return reworkIntegrity;
|
|
1039
1358
|
try {
|
|
1040
1359
|
await git.push(workdir, branch);
|
|
1041
1360
|
}
|
|
@@ -1117,6 +1436,11 @@ export function runTask(taskId, deps) {
|
|
|
1117
1436
|
// Emit the first-commit SLO signal once, before push, so the funnel
|
|
1118
1437
|
// sees "coding started" independent of whether the push/PR succeeds.
|
|
1119
1438
|
await emitFirstCommit(deps.supabase, git, taskId, workdir, integrationBranch, branch);
|
|
1439
|
+
// v0.57 T-57-2: gate conflict-resolution tasks before the push so a
|
|
1440
|
+
// broken merge never reaches a PR. No-op for every non-conflict task.
|
|
1441
|
+
const integrityGate = await runResolutionIntegrityGate();
|
|
1442
|
+
if (integrityGate)
|
|
1443
|
+
return integrityGate;
|
|
1120
1444
|
// 5. Push + open PR. Both run from the worktree so the operator's
|
|
1121
1445
|
// shared clone never has the task branch checked out.
|
|
1122
1446
|
let prUrl = "";
|