@tokenfactory/acc-runner 0.41.3 → 0.41.5

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/watch.js CHANGED
@@ -42,7 +42,7 @@ import { TASK_PAUSED_EVENT, captureWorktreeCheckpoint, parseTaskPausedBroadcast,
42
42
  import { worktreePath } from "./runtime/worktree.js";
43
43
  import { runTask } from "./task-runner.js";
44
44
  import { runReview, } from "./runtime/reviewer.js";
45
- import { getQuarantine, setQuarantine, } from "./runtime/quarantine.js";
45
+ import { getQuarantine, setQuarantine, clearQuarantine, } from "./runtime/quarantine.js";
46
46
  import { acquireSingletonLock, singletonRunnerId, SingletonLockHeldError, } from "./runtime/singleton.js";
47
47
  import { getClaudeVersion, recordTaskClaudeVersion, } from "./runtime/version-drift.js";
48
48
  import { autoUpgradeDisabled, maybeSelfUpgrade, maybeSelfUpdateOn426, } from "./runtime/self-upgrade.js";
@@ -54,7 +54,7 @@ import { GitAuthController, makeGitAuthProbe, RUNNER_GIT_AUTH_FAILED_VERB, RUNNE
54
54
  import { ResumeController } from "./capacity/resume-controller.js";
55
55
  import { RUNNER_CAPACITY_PAUSED_VERB, RUNNER_CAPACITY_RESUME_VERB, } from "./capacity/fleet-pause-verbs.js";
56
56
  import { AccountProbeScheduler } from "./engines/account-probe.js";
57
- import { getChatEngine } from "./engines/registry.js";
57
+ import { getChatEngine, getEngine } from "./engines/registry.js";
58
58
  // FLEET-SERVE (wire): fleet-fallback chat serving runs ALONGSIDE the task pump on
59
59
  // a `watch` runner — the runner serves its bound user's chat turns behind their
60
60
  // companion. Glue lives in watch-chat/wire.ts to keep this file the task loop.
@@ -127,6 +127,10 @@ const DEFAULT_CLAIM_WATCHDOG_CHECK_MS = 30_000;
127
127
  // regressions of the same shape. Default 60s — prompt recovery without a third
128
128
  // frequent DB poll racing the watchdog. Knob: `claim_backstop_interval_ms`.
129
129
  const DEFAULT_CLAIM_BACKSTOP_INTERVAL_MS = 60_000;
130
+ // v0.41.4: cadence for the env_broken quarantine-recovery re-probe. 60s gives a
131
+ // prompt auto-recovery once claude is reachable again (e.g. after a self-update
132
+ // window) without hammering the health probe.
133
+ const DEFAULT_QUARANTINE_RECOVERY_INTERVAL_MS = 60_000;
130
134
  // CLAIM-DEADMAN: dead-man switch for a WEDGED claim channel
131
135
  // that the 90s watchdog resubscribe can't clear. Live incident 2026-07-22: a
132
136
  // runner heartbeated fine (hb_age 0min) but its Realtime claim subscription was
@@ -581,6 +585,16 @@ async function claimWatchdogScan(state, factory) {
581
585
  state.claimWatchdogSince.clear();
582
586
  return;
583
587
  }
588
+ // v0.41.4: never answer a DELIBERATE not-claiming state with a transport
589
+ // remedy. While quarantined or capacity-paused the runner is SUPPOSED to
590
+ // leave its tasks queued; a resubscribe changes nothing and produced the
591
+ // resubscribe storm interleaved with QUARANTINED in the claim-livelock
592
+ // incident. Mirror the backstop's guard and drop the stale timers so an
593
+ // age never spans the pause and falsely trips the instant it lifts.
594
+ if (state.quarantined || state.pausedCapacity) {
595
+ state.claimWatchdogSince.clear();
596
+ return;
597
+ }
584
598
  const { data, error } = await state.supabase.rpc("list_assigned_queued_tasks", {
585
599
  p_runner_id: state.session.runner_id,
586
600
  p_since: "1970-01-01T00:00:00.000Z",
@@ -601,20 +615,52 @@ async function claimWatchdogScan(state, factory) {
601
615
  if (!currentIds.has(id))
602
616
  state.claimWatchdogSince.delete(id);
603
617
  }
618
+ // v0.41.4: same reset for the contention tracker — a task that left the
619
+ // queue (claimed or unassigned) is no longer contended.
620
+ for (const id of [...state.claimContentionSince.keys()]) {
621
+ if (!currentIds.has(id))
622
+ state.claimContentionSince.delete(id);
623
+ }
604
624
  // Observe: stamp the first-seen instant for newly assigned-unclaimed tasks;
605
- // collect any that have now been stale past the threshold.
625
+ // collect any that have now been stale past the threshold, splitting off
626
+ // those that are merely waiting on file-lock contention (v0.41.4).
606
627
  const stale = [];
628
+ const contended = [];
607
629
  for (const id of currentIds) {
608
630
  const since = state.claimWatchdogSince.get(id);
609
631
  if (since === undefined) {
610
632
  state.claimWatchdogSince.set(id, now);
633
+ continue;
634
+ }
635
+ if (now - since < state.claimWatchdogStaleMs)
636
+ continue;
637
+ // Stale past threshold. A recorded claim_locks conflict means the claim
638
+ // IS reaching the broker and being refused on a lock a live task holds —
639
+ // contention, not a wedged transport. Resubscribing cannot help; the fix
640
+ // is the holder releasing (or the orphan-lock self-heal / server sweep).
641
+ // The stamp is cleared the moment the task leaves the assigned+queued set
642
+ // (claimed or unassigned, in the reset loop above), so its presence means
643
+ // "conflicted and STILL queued" — no time window needed. The periodic
644
+ // backstop keeps re-driving a genuinely-claimable task, so suppressing
645
+ // the resubscribe here never strands it.
646
+ if (state.claimContentionSince.has(id)) {
647
+ contended.push(id);
611
648
  }
612
- else if (now - since >= state.claimWatchdogStaleMs) {
649
+ else {
613
650
  stale.push(id);
614
651
  }
615
652
  }
616
- if (stale.length === 0)
653
+ if (stale.length === 0) {
654
+ if (contended.length > 0) {
655
+ // Quiet, throttled breadcrumb (re-arm the timers so it fires at most
656
+ // once per threshold window instead of every check).
657
+ for (const id of contended)
658
+ state.claimWatchdogSince.set(id, now);
659
+ process.stderr.write(`[acc-runner] claim watchdog: task(s) ${contended.join(", ")} waiting on ` +
660
+ `file-lock contention (holder still active) — not resubscribing.\n`);
661
+ }
617
662
  return;
663
+ }
618
664
  // Single-flight: never stack on a resubscribe already in flight or
619
665
  // scheduled (the existing T-68-1 guard/backoff owns the recovery).
620
666
  if (state.resubscribing || state.resubscribeTimer)
@@ -746,6 +792,59 @@ async function claimBackstopScan(state, factory) {
746
792
  state.backstopScanning = false;
747
793
  }
748
794
  }
795
+ /**
796
+ * v0.41.4 (claim-livelock hardening): env_broken quarantine auto-recovery.
797
+ *
798
+ * A quarantine is a sticky sentinel — it persists until an operator runs
799
+ * `acc-runner quarantine clear`. That is correct for a genuinely broken host,
800
+ * but `env_broken` also fires on TRANSIENT conditions (a claude self-update
801
+ * window, a momentary "claude --version exited signal") that resolve on their
802
+ * own — leaving the runner permanently dark long after claude recovered (the
803
+ * incident). This pass re-probes claude while quarantined under `env_broken`
804
+ * and, when the probe passes, clears the quarantine and resumes claiming.
805
+ *
806
+ * Scoped to `env_broken` ONLY: usage_limit clears via the capacity resume path
807
+ * and auth_expired via a token refresh — a claude re-probe must not paper over
808
+ * those. Single-flight; never throws out of the loop.
809
+ */
810
+ async function quarantineRecoveryScan(state, factory) {
811
+ if (state.stopped || state.quarantineRecovering)
812
+ return;
813
+ if (!state.quarantined || state.quarantineCause !== "env_broken")
814
+ return;
815
+ state.quarantineRecovering = true;
816
+ try {
817
+ const probe = await state.healthProbe();
818
+ if (!probe.ok)
819
+ return; // still broken — stay quarantined, try again next tick.
820
+ // claude is reachable again — lift the quarantine and resume.
821
+ await clearQuarantine().catch(() => { });
822
+ state.quarantined = false;
823
+ state.quarantineCause = null;
824
+ process.stderr.write(chalk.green(`[acc-runner] quarantine auto-cleared: claude health probe recovered ` +
825
+ `(${probe.detail}). Resuming task claims.\n`));
826
+ void (async () => {
827
+ try {
828
+ await state.supabase.rpc("log_activity", {
829
+ p_verb: "runner.quarantine_auto_cleared",
830
+ p_target_id: state.session.runner_id,
831
+ p_payload: { cause: "env_broken", detail: probe.detail, version: PACKAGE_VERSION },
832
+ p_target_type: "runner",
833
+ });
834
+ }
835
+ catch { /* best-effort breadcrumb */ }
836
+ })();
837
+ // Recover any work that stranded while quarantined: re-list + pump.
838
+ void pollOnce(state, factory);
839
+ void pump(state, factory);
840
+ }
841
+ catch (err) {
842
+ process.stderr.write(`[acc-runner] quarantine recovery scan failed: ${err.message}\n`);
843
+ }
844
+ finally {
845
+ state.quarantineRecovering = false;
846
+ }
847
+ }
749
848
  /**
750
849
  * CLAIM-DEADMAN: a runner may have free capacity but be
751
850
  * deliberately not claiming (quarantined, capacity-paused, review-claim-paused,
@@ -1158,10 +1257,12 @@ export async function watchCommand(options = {}) {
1158
1257
  const enforceSingleton = options.enforceSingleton ?? !options.taskRunnerFactory;
1159
1258
  let singletonLock = null;
1160
1259
  if (enforceSingleton) {
1161
- // v1.02-A: the lock is keyed by (repo path + ACC_RUNNER_ID). A second
1162
- // runner with a DISTINCT ACC_RUNNER_ID (and, by default, its own
1163
- // namespaced cache dir) acquires its own lock and coexists; only a true
1164
- // duplicate same repo, same identity is rejected here.
1260
+ // v1.02-A / v0.41.4: the lock is keyed by the runner IDENTITY, not the
1261
+ // repo path task assignment is per identity, so two processes sharing an
1262
+ // identity contend regardless of checkout. A second runner with a DISTINCT
1263
+ // ACC_RUNNER_ID (and, by default, its own namespaced cache dir) acquires
1264
+ // its own lock and coexists; a duplicate of the same identity (including a
1265
+ // second runner with no ACC_RUNNER_ID) is rejected here.
1165
1266
  const runnerId = singletonRunnerId();
1166
1267
  try {
1167
1268
  singletonLock = await acquireSingletonLock(cfg.repoPath, process.pid, runnerId);
@@ -1169,12 +1270,14 @@ export async function watchCommand(options = {}) {
1169
1270
  catch (err) {
1170
1271
  if (err instanceof SingletonLockHeldError) {
1171
1272
  process.stderr.write(chalk.red.bold("\n[acc-runner] ANOTHER RUNNER IS ALREADY ACTIVE.\n"));
1172
- process.stderr.write(chalk.red(`Repo path ${err.holder.repoPath} is served by pid ${err.holder.pid}` +
1173
- (err.holder.runnerId ? ` as ${err.holder.runnerId}` : "") +
1174
- ` (acc-runner v${err.holder.version}, since ${err.holder.acquiredAt}).\n` +
1175
- "Refusing to start a second runner with the same identity for the " +
1176
- "same repo concurrent same-identity runners corrupt worktrees and " +
1177
- "race git refs.\n"));
1273
+ process.stderr.write(chalk.red(`Identity ${err.holder.runnerId || "(default / no ACC_RUNNER_ID)"} is ` +
1274
+ `already served by pid ${err.holder.pid} on repo path ` +
1275
+ `${err.holder.repoPath} (acc-runner v${err.holder.version}, since ` +
1276
+ `${err.holder.acquiredAt}).\n` +
1277
+ "Refusing to start a second runner with the same identity they " +
1278
+ "claim the same tasks and fight over the same file-locks (the claim " +
1279
+ "livelock), and concurrent same-identity runners corrupt worktrees " +
1280
+ "and race git refs.\n"));
1178
1281
  process.stderr.write(chalk.gray(`To stop the other runner: kill ${err.holder.pid}\n` +
1179
1282
  `If it is already dead, remove ${err.lockFile} (or just retry — ` +
1180
1283
  "stale locks self-reclaim).\n" +
@@ -1239,6 +1342,7 @@ export async function watchCommand(options = {}) {
1239
1342
  claimBackstopOnce: async () => { },
1240
1343
  claimDeadmanOnce: async () => { },
1241
1344
  gitAuthProbeOnce: async () => false,
1345
+ quarantineRecoveryOnce: async () => { },
1242
1346
  runningCount: () => 0,
1243
1347
  };
1244
1348
  }
@@ -1416,10 +1520,18 @@ export async function watchCommand(options = {}) {
1416
1520
  claimWatchdogStaleMs: options.claimWatchdogStaleMs ?? DEFAULT_CLAIM_WATCHDOG_STALE_MS,
1417
1521
  claimWatchdogSince: new Map(),
1418
1522
  claimWatchdogChecking: false,
1523
+ // v0.41.4: file-lock contention tracker (see WatchState doc).
1524
+ claimContentionSince: new Map(),
1419
1525
  // FIX-D: periodic DB-poll claim backstop.
1420
1526
  claimBackstopTimer: undefined,
1421
1527
  claimBackstopIntervalMs: options.claimBackstopIntervalMs ?? DEFAULT_CLAIM_BACKSTOP_INTERVAL_MS,
1422
1528
  backstopScanning: false,
1529
+ // v0.41.4: env_broken quarantine recovery.
1530
+ quarantineCause: startupQuarantine?.cause ?? null,
1531
+ quarantineRecoveryTimer: undefined,
1532
+ quarantineRecoveryIntervalMs: options.quarantineRecoveryIntervalMs ?? DEFAULT_QUARANTINE_RECOVERY_INTERVAL_MS,
1533
+ healthProbe: options.healthProbe ?? (() => getEngine().healthProbe()),
1534
+ quarantineRecovering: false,
1423
1535
  // CLAIM-DEADMAN: wedge dead-man switch. The pure state
1424
1536
  // machine carries its own clock (Date.now); tests inject tiny thresholds.
1425
1537
  claimDeadman: new ClaimDeadman({
@@ -1593,6 +1705,10 @@ export async function watchCommand(options = {}) {
1593
1705
  state.claimBackstopTimer = setInterval(() => {
1594
1706
  void claimBackstopScan(state, taskRunnerFactory);
1595
1707
  }, state.claimBackstopIntervalMs);
1708
+ // v0.41.4: env_broken quarantine auto-recovery re-probe.
1709
+ state.quarantineRecoveryTimer = setInterval(() => {
1710
+ void quarantineRecoveryScan(state, taskRunnerFactory);
1711
+ }, state.quarantineRecoveryIntervalMs);
1596
1712
  // CLAIM-DEADMAN: the wedge dead-man switch. Escalates
1597
1713
  // BEYOND the 90s watchdog resubscribe (which only re-adds a channel on the
1598
1714
  // same client and, per the incident, can itself stop firing): tracks pending-
@@ -1710,6 +1826,7 @@ export async function watchCommand(options = {}) {
1710
1826
  clearInterval(state.reviewScanTimer); // v0.65 (T-65-2)
1711
1827
  clearInterval(state.claimWatchdogTimer); // v0.73 (T-73-2)
1712
1828
  clearInterval(state.claimBackstopTimer); // FIX-D
1829
+ clearInterval(state.quarantineRecoveryTimer); // v0.41.4
1713
1830
  clearInterval(state.claimDeadmanTimer); // CLAIM-DEADMAN
1714
1831
  stopDeadmanFallback(state); // CLAIM-DEADMAN
1715
1832
  if (state.gitAuthTimer)
@@ -1826,6 +1943,7 @@ export async function watchCommand(options = {}) {
1826
1943
  claimBackstopOnce: () => claimBackstopScan(state, taskRunnerFactory), // FIX-D
1827
1944
  claimDeadmanOnce: () => claimDeadmanScan(state, taskRunnerFactory), // CLAIM-DEADMAN
1828
1945
  gitAuthProbeOnce: () => gitAuthProbeScan(state), // GIT-AUTH-ALERT
1946
+ quarantineRecoveryOnce: () => quarantineRecoveryScan(state, taskRunnerFactory), // v0.41.4
1829
1947
  runningCount: () => state.running.size,
1830
1948
  };
1831
1949
  }
@@ -2012,6 +2130,7 @@ function scheduleHeartbeat(state, taskRunnerFactory, baseMs, lastRoundMs = 0) {
2012
2130
  clearInterval(state.reviewScanTimer); // v0.65 (T-65-2)
2013
2131
  clearInterval(state.claimWatchdogTimer); // v0.73 (T-73-2)
2014
2132
  clearInterval(state.claimBackstopTimer); // FIX-D
2133
+ clearInterval(state.quarantineRecoveryTimer); // v0.41.4
2015
2134
  clearInterval(state.claimDeadmanTimer); // CLAIM-DEADMAN
2016
2135
  stopDeadmanFallback(state); // CLAIM-DEADMAN
2017
2136
  if (state.resubscribeTimer)
@@ -2330,6 +2449,7 @@ consecutive) {
2330
2449
  if (state.quarantined)
2331
2450
  return;
2332
2451
  state.quarantined = true;
2452
+ state.quarantineCause = cause; // v0.41.4: gates env_broken auto-recovery.
2333
2453
  const qState = {
2334
2454
  cause,
2335
2455
  classifiedAt: new Date().toISOString(),
@@ -2726,6 +2846,14 @@ async function pump(state, factory) {
2726
2846
  const reason = outcome.error?.trim() ||
2727
2847
  (outcome.exitCode != null ? `exit ${outcome.exitCode}` : "unknown");
2728
2848
  process.stderr.write(`[acc-runner] task ${next} phase=${outcome.phase ?? "unknown"} failed: ${reason}\n`);
2849
+ // v0.41.4: a claim_locks failure is file-lock CONTENTION — the
2850
+ // claim reached the broker and was refused because a live task
2851
+ // holds an overlapping path. Stamp it so the claim watchdog treats
2852
+ // this task as legitimately waiting, not transport-wedged, and
2853
+ // does not answer it with a resubscribe storm.
2854
+ if (outcome.phase === "claim_locks") {
2855
+ state.claimContentionSince.set(next, Date.now());
2856
+ }
2729
2857
  // v0.48: machine-level failure → quarantine. Guard with
2730
2858
  // !state.quarantined so concurrent tasks don't fire duplicate
2731
2859
  // quarantine events when two machine-level failures land