@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.
@@ -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 { commitGuardSkipRequested, gitConfigHooksEnv, installCommitGuardSync, } from "./commit-guard.js";
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 — the defect that got PR #265 rejected four times. Best-effort: a guard
128
- // install failure must not block the task (the pre-PR gate in prepr-gate.ts is
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
- if (!commitGuardSkipRequested(process.env)) {
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
- baseEnv = undefined;
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) => worktreeDepsMissing !== null
1067
- ? depsMissingGateResult(worktreeDepsMissing)
1068
- : worktreeDepsStale !== null
1069
- ? depsStaleGateResult(worktreeDepsStale.repoPath, worktreeDepsStale.detail)
1070
- : runPreprGateRaw(workdir, baseRef);
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 updateSignal();
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 (worktree) {
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,