@tokenfactory/acc-runner 0.47.17 → 0.47.18
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/dist/build-info.generated.d.ts +1 -1
- package/dist/build-info.generated.js +2 -2
- package/dist/commit-guard.d.ts +3 -3
- package/dist/commit-guard.d.ts.map +1 -1
- package/dist/commit-guard.js +9 -4
- package/dist/commit-guard.js.map +1 -1
- package/dist/gh.d.ts +4 -2
- package/dist/gh.d.ts.map +1 -1
- package/dist/gh.js +16 -4
- package/dist/gh.js.map +1 -1
- package/dist/orphan-work.d.ts +32 -0
- package/dist/orphan-work.d.ts.map +1 -0
- package/dist/orphan-work.js +69 -0
- package/dist/orphan-work.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +14 -1
- package/dist/program.js.map +1 -1
- package/dist/task-liveness.d.ts +16 -0
- package/dist/task-liveness.d.ts.map +1 -0
- package/dist/task-liveness.js +31 -0
- package/dist/task-liveness.js.map +1 -0
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +142 -22
- package/dist/task-runner.js.map +1 -1
- package/dist/task-write-hook.d.ts +11 -0
- package/dist/task-write-hook.d.ts.map +1 -0
- package/dist/task-write-hook.js +22 -0
- package/dist/task-write-hook.js.map +1 -0
- package/package.json +1 -1
- package/scripts/audit-terminal-claims-core.d.mts +66 -0
- package/scripts/audit-terminal-claims-core.mjs +164 -0
- package/scripts/audit-terminal-claims.mjs +98 -0
package/dist/task-runner.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { writeTaskHookContext } from "./task-write-hook.js";
|
|
2
|
+
import { readTaskStatus, assertTaskWritable, TERMINAL_STATUSES, TaskWriteClosedError } from "./task-liveness.js";
|
|
3
|
+
import { cancellationTokenSpend, retainOrphanWork, terminatePane } from "./orphan-work.js";
|
|
4
|
+
import { processTreeRegistry } from "./process-tree.js";
|
|
1
5
|
/**
|
|
2
6
|
* Task execution path. Called by watch.ts when a task_assigned broadcast
|
|
3
7
|
* lands on the runner channel.
|
|
@@ -61,7 +65,7 @@ import { assertResolutionIntegrity, isConflictResolutionTask, summariseFailures,
|
|
|
61
65
|
import { depsMissingGateResult, depsStaleGateResult, gateFailureBlockReason, gateRunnerCrashResult, runPreprGate as defaultRunPreprGate, classifyDanglingParentDeps, DEPS_MISSING_CLASSIFICATION, DEPS_STALE_CLASSIFICATION, GATE_TOOL_CRASHED_CLASSIFICATION, PREPR_GATE_FAILED_CLASSIFICATION, PREPR_GATES, } from "./prepr-gate.js";
|
|
62
66
|
import { revalidateMigrationSeq as defaultRevalidateMigrationSeq, } from "./migration-seq-guard.js";
|
|
63
67
|
import { readAppliedMigrationSeqs } from "./applied-seq-roster.js";
|
|
64
|
-
import {
|
|
68
|
+
import { gitConfigHooksEnv, installCommitGuardSync, } from "./commit-guard.js";
|
|
65
69
|
import { readLiveAllowedGlobs } from "./locks/index.js";
|
|
66
70
|
import { declaredLockPaths, planClaimLockLoss, } from "./locks/claim-lock-policy.js";
|
|
67
71
|
import { resolveCredentialIdentity } from "./credential-identity.js";
|
|
@@ -124,18 +128,16 @@ function defaultSpawnClaude(cwd, modelId, engineId, codexApiKey = null) {
|
|
|
124
128
|
// worktree and point the agent's git at it via an ephemeral core.hooksPath env
|
|
125
129
|
// (never a config-file write). The agent then hits an actionable in-session
|
|
126
130
|
// block if it tries to commit a NUL-byte / invalid-UTF-8 / binary-blob text
|
|
127
|
-
// file
|
|
128
|
-
//
|
|
129
|
-
// the backstop), and unit tests inject their own spawnClaude so this never
|
|
130
|
-
// runs there.
|
|
131
|
+
// file. Installation is mandatory because these hooks also fence terminal
|
|
132
|
+
// tasks and closed PRs. Unit tests can inject spawnClaude.
|
|
131
133
|
let baseEnv;
|
|
132
|
-
|
|
134
|
+
{
|
|
133
135
|
try {
|
|
134
|
-
const hooksDir = installCommitGuardSync(cwd);
|
|
136
|
+
const hooksDir = installCommitGuardSync(cwd, undefined, true);
|
|
135
137
|
baseEnv = gitConfigHooksEnv(hooksDir);
|
|
136
138
|
}
|
|
137
|
-
catch {
|
|
138
|
-
|
|
139
|
+
catch (err) {
|
|
140
|
+
throw new Error(`Cannot install task write fence: ${err.message}`);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
if (codexApiKey) {
|
|
@@ -641,7 +643,7 @@ async function ensureBranchReadyForPr(git, workdir, baseRef, branch, taskId) {
|
|
|
641
643
|
}
|
|
642
644
|
return { ok: true };
|
|
643
645
|
}
|
|
644
|
-
async function streamToEvents(stream, supabase, taskId, streamName) {
|
|
646
|
+
async function streamToEvents(stream, supabase, taskId, streamName, onChunk) {
|
|
645
647
|
let captured = "";
|
|
646
648
|
let pending = "";
|
|
647
649
|
const flush = async () => {
|
|
@@ -654,6 +656,7 @@ async function streamToEvents(stream, supabase, taskId, streamName) {
|
|
|
654
656
|
};
|
|
655
657
|
for await (const raw of stream) {
|
|
656
658
|
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
659
|
+
onChunk?.(text);
|
|
657
660
|
pending += text;
|
|
658
661
|
if (pending.length >= LOG_BATCH_BYTES)
|
|
659
662
|
await flush();
|
|
@@ -1063,11 +1066,20 @@ export function runTask(taskId, deps) {
|
|
|
1063
1066
|
// deps stale and the remediation install couldn't fix it. Distinct from
|
|
1064
1067
|
// `worktreeDepsMissing` (nothing was ever seeded) so triage names the right fault.
|
|
1065
1068
|
let worktreeDepsStale = null;
|
|
1066
|
-
const runPreprGate = async (workdir, baseRef) =>
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1069
|
+
const runPreprGate = async (workdir, baseRef) => {
|
|
1070
|
+
await checkTerminal();
|
|
1071
|
+
if (orphanReason)
|
|
1072
|
+
throw new Error(orphanReason);
|
|
1073
|
+
const result = worktreeDepsMissing !== null
|
|
1074
|
+
? depsMissingGateResult(worktreeDepsMissing)
|
|
1075
|
+
: worktreeDepsStale !== null
|
|
1076
|
+
? depsStaleGateResult(worktreeDepsStale.repoPath, worktreeDepsStale.detail)
|
|
1077
|
+
: await runPreprGateRaw(workdir, baseRef);
|
|
1078
|
+
await checkTerminal();
|
|
1079
|
+
if (orphanReason)
|
|
1080
|
+
throw new Error(orphanReason);
|
|
1081
|
+
return result;
|
|
1082
|
+
};
|
|
1071
1083
|
const emitGateTimeoutBreadcrumb = async (fail) => {
|
|
1072
1084
|
if (!fail.wallClockTimeout)
|
|
1073
1085
|
return;
|
|
@@ -1125,7 +1137,37 @@ export function runTask(taskId, deps) {
|
|
|
1125
1137
|
// inject a mock. Drop in v0.7.
|
|
1126
1138
|
void checkoutBase;
|
|
1127
1139
|
let child = null;
|
|
1140
|
+
let paneActive = false;
|
|
1141
|
+
let cancellationVerb = "runner.terminal_cancelled";
|
|
1128
1142
|
let cancelled = false;
|
|
1143
|
+
let orphanReason = null;
|
|
1144
|
+
let cancelledAt = null;
|
|
1145
|
+
let cancellationOutput = "";
|
|
1146
|
+
let observedOutput = "";
|
|
1147
|
+
let paneEngine = null;
|
|
1148
|
+
let panePid = null;
|
|
1149
|
+
let originTaskId;
|
|
1150
|
+
const startedAt = Date.now();
|
|
1151
|
+
const cancelOrphan = (reason, terminalSignal = true) => {
|
|
1152
|
+
if (orphanReason)
|
|
1153
|
+
return;
|
|
1154
|
+
orphanReason = reason;
|
|
1155
|
+
cancellationVerb = terminalSignal ? "runner.terminal_cancelled" : "runner.write_refused";
|
|
1156
|
+
cancelledAt = Date.now();
|
|
1157
|
+
cancellationOutput = observedOutput;
|
|
1158
|
+
cancelled = true;
|
|
1159
|
+
if (child && paneActive)
|
|
1160
|
+
terminatePane(child);
|
|
1161
|
+
// Gate processes are detached groups tracked by the production gate runner.
|
|
1162
|
+
processTreeRegistry.reapTask(taskId);
|
|
1163
|
+
};
|
|
1164
|
+
const checkTerminal = async () => {
|
|
1165
|
+
for (const id of new Set([taskId, originTaskId].filter((v) => !!v))) {
|
|
1166
|
+
const status = await readTaskStatus(deps.supabase, id);
|
|
1167
|
+
if (TERMINAL_STATUSES.has(status))
|
|
1168
|
+
cancelOrphan(`${id}:${status}`);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1129
1171
|
// v0.14-MESSAGING-RUNTIME-WIRE: convenience wrapper that scopes every
|
|
1130
1172
|
// bus message to this task + this runner. Best-effort: an underlying
|
|
1131
1173
|
// RPC failure is logged inside defaultPostRunnerStateMessage and never
|
|
@@ -1275,15 +1317,19 @@ export function runTask(taskId, deps) {
|
|
|
1275
1317
|
}
|
|
1276
1318
|
};
|
|
1277
1319
|
const scheduleSignal = () => {
|
|
1278
|
-
if (signalStopped)
|
|
1320
|
+
if (signalStopped || cancelled)
|
|
1279
1321
|
return;
|
|
1280
1322
|
signalTimer = setTimeout(async () => {
|
|
1281
|
-
if (signalStopped)
|
|
1323
|
+
if (signalStopped || cancelled)
|
|
1282
1324
|
return;
|
|
1283
1325
|
try {
|
|
1284
|
-
await
|
|
1326
|
+
await checkTerminal();
|
|
1327
|
+
if (!cancelled)
|
|
1328
|
+
await updateSignal();
|
|
1329
|
+
}
|
|
1330
|
+
catch (err) {
|
|
1331
|
+
process.stderr.write(`[acc-runner] terminal poll failed: ${err.message}\n`);
|
|
1285
1332
|
}
|
|
1286
|
-
catch { /* logged inside */ }
|
|
1287
1333
|
scheduleSignal();
|
|
1288
1334
|
}, signalIntervalMs);
|
|
1289
1335
|
// Detach so an in-flight signal timer doesn't keep the process
|
|
@@ -1314,6 +1360,15 @@ export function runTask(taskId, deps) {
|
|
|
1314
1360
|
// in SQL (acc._declared_lock_paths) so the DB overlap key is file-granular
|
|
1315
1361
|
// too; this read is a best-effort breadcrumb + input to the conflict
|
|
1316
1362
|
// back-off below and never fails the claim.
|
|
1363
|
+
// Claim-time read refuses terminal tasks before the claim RPC can revive them.
|
|
1364
|
+
try {
|
|
1365
|
+
await checkTerminal();
|
|
1366
|
+
if (cancelled)
|
|
1367
|
+
return { taskId, status: "cancelled" };
|
|
1368
|
+
}
|
|
1369
|
+
catch (err) {
|
|
1370
|
+
return { taskId, status: "failed", phase: "claim_locks", error: err.message };
|
|
1371
|
+
}
|
|
1317
1372
|
let claimLockScope = [];
|
|
1318
1373
|
try {
|
|
1319
1374
|
claimLockScope = declaredLockPaths(await readLiveAllowedGlobs(deps.supabase, taskId));
|
|
@@ -1581,6 +1636,11 @@ export function runTask(taskId, deps) {
|
|
|
1581
1636
|
}
|
|
1582
1637
|
const result = fetched.data;
|
|
1583
1638
|
const task = result.task;
|
|
1639
|
+
if (TERMINAL_STATUSES.has(task.status)) {
|
|
1640
|
+
cancelOrphan(`${taskId}:${task.status}`);
|
|
1641
|
+
return { taskId, status: "cancelled" };
|
|
1642
|
+
}
|
|
1643
|
+
paneEngine = task.engine ?? null;
|
|
1584
1644
|
// NS MM-2 (BATCH-ENGINE-DISPATCH): resolve this task's build engine ONCE,
|
|
1585
1645
|
// right after the task itself is known — the router (NS MM-1) stamps
|
|
1586
1646
|
// `engine` alongside `model_id` on every dispatch path, defaulting to
|
|
@@ -1719,6 +1779,7 @@ export function runTask(taskId, deps) {
|
|
|
1719
1779
|
repo: task.repo,
|
|
1720
1780
|
branch: task.branch,
|
|
1721
1781
|
});
|
|
1782
|
+
originTaskId = reworkMeta?.origin_task_id || undefined;
|
|
1722
1783
|
if (!reworkMeta) {
|
|
1723
1784
|
const msg = "rework task missing PR metadata (acc-rework sentinel or pr_number + repo on task row)";
|
|
1724
1785
|
await appendEvent(deps.supabase, taskId, "error", { phase: "rework", error: msg });
|
|
@@ -1873,6 +1934,17 @@ export function runTask(taskId, deps) {
|
|
|
1873
1934
|
// return that follows.
|
|
1874
1935
|
let worktree = null;
|
|
1875
1936
|
let workdir = repoPath;
|
|
1937
|
+
const fenceWrites = async () => {
|
|
1938
|
+
if (orphanReason)
|
|
1939
|
+
throw new Error(orphanReason);
|
|
1940
|
+
try {
|
|
1941
|
+
await assertTaskWritable({ db: deps.supabase, taskId, originTaskId, gh, workdir, branch });
|
|
1942
|
+
}
|
|
1943
|
+
catch (err) {
|
|
1944
|
+
cancelOrphan(err.message, err instanceof TaskWriteClosedError);
|
|
1945
|
+
throw err;
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1876
1948
|
// v0.19 T-64-1: the ref the task branch is actually forked from, set
|
|
1877
1949
|
// once the base is resolved below. Used as the `base..branch` range for
|
|
1878
1950
|
// the first-commit SLO so the signal stays accurate now that FRESH
|
|
@@ -2702,8 +2774,18 @@ export function runTask(taskId, deps) {
|
|
|
2702
2774
|
let useRoutedModel = true;
|
|
2703
2775
|
let pinRefusalLogged = false;
|
|
2704
2776
|
const runClaudeOnce = async () => {
|
|
2777
|
+
await checkTerminal();
|
|
2778
|
+
if (cancelled)
|
|
2779
|
+
return { exitCode: null, stdout: "", stderr: "", durationMs: 0 };
|
|
2705
2780
|
const spawnedAt = Date.now();
|
|
2781
|
+
// Persist only public hook context before spawn. Hook installation is mandatory
|
|
2782
|
+
// for the production engine path, including when the byte-scan escape hatch is set.
|
|
2783
|
+
if (!deps.spawnClaude) {
|
|
2784
|
+
writeTaskHookContext(workdir, { cfg: deps.cfg, taskId, branch, originTaskId });
|
|
2785
|
+
}
|
|
2706
2786
|
child = spawnClaude(workdir, useRoutedModel ? spawnModel.model : undefined);
|
|
2787
|
+
paneActive = true;
|
|
2788
|
+
panePid = child.pid ?? null;
|
|
2707
2789
|
// Log the refused pin once, only on the routed path (shouldLogPinRefusal).
|
|
2708
2790
|
if (shouldLogPinRefusal(useRoutedModel, spawnModel) && !pinRefusalLogged) {
|
|
2709
2791
|
pinRefusalLogged = true;
|
|
@@ -2721,7 +2803,7 @@ export function runTask(taskId, deps) {
|
|
|
2721
2803
|
child.stdin.end();
|
|
2722
2804
|
}
|
|
2723
2805
|
const stdoutPromise = child.stdout
|
|
2724
|
-
? streamToEvents(child.stdout, deps.supabase, taskId, "stdout")
|
|
2806
|
+
? streamToEvents(child.stdout, deps.supabase, taskId, "stdout", text => { observedOutput += text; })
|
|
2725
2807
|
: Promise.resolve("");
|
|
2726
2808
|
const stderrPromise = child.stderr
|
|
2727
2809
|
? streamToEvents(child.stderr, deps.supabase, taskId, "stderr")
|
|
@@ -2731,6 +2813,7 @@ export function runTask(taskId, deps) {
|
|
|
2731
2813
|
stdoutPromise,
|
|
2732
2814
|
stderrPromise,
|
|
2733
2815
|
]);
|
|
2816
|
+
paneActive = false;
|
|
2734
2817
|
// One cost_events row per attempt so cap tracking captures success,
|
|
2735
2818
|
// cancellation, retry, and non-zero exit alike. Best-effort: a
|
|
2736
2819
|
// failed POST logs to stderr but never bubbles past the runner.
|
|
@@ -3427,6 +3510,8 @@ export function runTask(taskId, deps) {
|
|
|
3427
3510
|
// class as a gate TOOL that crashed — it judged nothing, so it parks
|
|
3428
3511
|
// the task instead of charging the crash to the diff (still no push).
|
|
3429
3512
|
const reworkGate = await runPreprGate(workdir, resolvedBaseRef).catch((err) => gateRunnerCrashResult(err));
|
|
3513
|
+
if (orphanReason)
|
|
3514
|
+
return { taskId, status: "cancelled" };
|
|
3430
3515
|
if (!reworkGate.skipped && reworkGate.crash) {
|
|
3431
3516
|
return handleGateToolCrash(reworkGate, reworkGate.crash, true);
|
|
3432
3517
|
}
|
|
@@ -3499,6 +3584,7 @@ export function runTask(taskId, deps) {
|
|
|
3499
3584
|
const reworkConformance = await runConformanceGate(true, capturedStdout);
|
|
3500
3585
|
if (reworkConformance)
|
|
3501
3586
|
return reworkConformance;
|
|
3587
|
+
await fenceWrites();
|
|
3502
3588
|
try {
|
|
3503
3589
|
await git.push(workdir, branch);
|
|
3504
3590
|
}
|
|
@@ -3598,6 +3684,8 @@ export function runTask(taskId, deps) {
|
|
|
3598
3684
|
// CLOSED (`ok: false` — no PR is opened off a check that never ran), but
|
|
3599
3685
|
// it is reported as infra (`crash`), not as a verdict on the diff.
|
|
3600
3686
|
const prepr = await runPreprGate(workdir, resolvedBaseRef).catch((err) => gateRunnerCrashResult(err));
|
|
3687
|
+
if (orphanReason)
|
|
3688
|
+
return { taskId, status: "cancelled" };
|
|
3601
3689
|
if (!prepr.skipped && prepr.crash) {
|
|
3602
3690
|
return handleGateToolCrash(prepr, prepr.crash, false);
|
|
3603
3691
|
}
|
|
@@ -3714,6 +3802,7 @@ export function runTask(taskId, deps) {
|
|
|
3714
3802
|
// renumber reads the fork-time worktree snapshot, and only a fresh fetch
|
|
3715
3803
|
// here can see a sibling that merged while the gate was running.
|
|
3716
3804
|
await runMigrationSeqGuard(false);
|
|
3805
|
+
await fenceWrites();
|
|
3717
3806
|
const ready = await ensureBranchReadyForPr(git, workdir, resolvedBaseRef, branch, taskId);
|
|
3718
3807
|
if (!ready.ok) {
|
|
3719
3808
|
const readinessClass = /no commits on branch/.test(ready.reason)
|
|
@@ -3748,6 +3837,7 @@ export function runTask(taskId, deps) {
|
|
|
3748
3837
|
// The §7 report the PR-open path extracts, kept so the
|
|
3749
3838
|
// session-end hook below can file it on the board.
|
|
3750
3839
|
let sessionReport = null;
|
|
3840
|
+
await fenceWrites();
|
|
3751
3841
|
try {
|
|
3752
3842
|
await git.push(workdir, branch);
|
|
3753
3843
|
}
|
|
@@ -4010,7 +4100,33 @@ export function runTask(taskId, deps) {
|
|
|
4010
4100
|
// lock on every completion path (success, failure, cancellation,
|
|
4011
4101
|
// unexpected throw). Both legs are best-effort — leaking either
|
|
4012
4102
|
// resource is preferable to masking the original return value.
|
|
4013
|
-
if (
|
|
4103
|
+
if (orphanReason) {
|
|
4104
|
+
let artifact = null;
|
|
4105
|
+
try {
|
|
4106
|
+
artifact = await retainOrphanWork(workdir, taskId, orphanReason);
|
|
4107
|
+
}
|
|
4108
|
+
catch (err) {
|
|
4109
|
+
process.stderr.write(`[acc-runner] orphan manifest failed; worktree retained at ${workdir}: ${err.message}\n`);
|
|
4110
|
+
}
|
|
4111
|
+
const spend = cancellationTokenSpend(getEngine(paneEngine).parseResult(cancellationOutput)?.usage);
|
|
4112
|
+
const payload = {
|
|
4113
|
+
task_id: taskId, pane_id: panePid, runner_id: deps.session.runnerId,
|
|
4114
|
+
cancelled_at: new Date(cancelledAt ?? Date.now()).toISOString(),
|
|
4115
|
+
elapsed_ms: (cancelledAt ?? Date.now()) - startedAt, reason: orphanReason,
|
|
4116
|
+
...spend,
|
|
4117
|
+
artifact, worktree: workdir, summary: `Retained orphaned work for ${taskId} at ${workdir}; see git status and git diff HEAD`,
|
|
4118
|
+
};
|
|
4119
|
+
process.stderr.write(`[acc-runner] orphan retained: ${JSON.stringify(payload)}\n`);
|
|
4120
|
+
try {
|
|
4121
|
+
const logged = await deps.supabase.rpc("log_activity", { p_verb: cancellationVerb, p_target_id: taskId, p_target_type: "task", p_payload: payload });
|
|
4122
|
+
if (logged.error)
|
|
4123
|
+
process.stderr.write(`[acc-runner] cancellation activity failed: ${logged.error.message}\n`);
|
|
4124
|
+
}
|
|
4125
|
+
catch (err) {
|
|
4126
|
+
process.stderr.write(`[acc-runner] cancellation activity failed: ${err.message}\n`);
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
if (worktree && !orphanReason) {
|
|
4014
4130
|
// v0.37-A: clear any stale .git/HEAD.lock or index.lock so the
|
|
4015
4131
|
// `git worktree remove` + `prune` inside cleanup() don't trip on
|
|
4016
4132
|
// a leftover lock from the prior worktree add or claude run.
|
|
@@ -4064,7 +4180,11 @@ export function runTask(taskId, deps) {
|
|
|
4064
4180
|
// task's newer claim on the same runner is preserved.
|
|
4065
4181
|
await writeRunnerCurrentTask(null);
|
|
4066
4182
|
}
|
|
4067
|
-
})()
|
|
4183
|
+
})().catch((err) => {
|
|
4184
|
+
if (orphanReason)
|
|
4185
|
+
return { taskId, status: "cancelled", error: orphanReason };
|
|
4186
|
+
throw err;
|
|
4187
|
+
});
|
|
4068
4188
|
return {
|
|
4069
4189
|
taskId,
|
|
4070
4190
|
promise,
|