@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/bin-resolve.d.ts +9 -10
- package/dist/bin-resolve.d.ts.map +1 -1
- package/dist/bin-resolve.js +25 -13
- package/dist/bin-resolve.js.map +1 -1
- package/dist/engines/claude-code.d.ts.map +1 -1
- package/dist/engines/claude-code.js +10 -3
- package/dist/engines/claude-code.js.map +1 -1
- package/dist/runtime/reviewer.d.ts +80 -0
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +241 -6
- package/dist/runtime/reviewer.js.map +1 -1
- package/dist/runtime/singleton.d.ts +16 -12
- package/dist/runtime/singleton.d.ts.map +1 -1
- package/dist/runtime/singleton.js +51 -28
- package/dist/runtime/singleton.js.map +1 -1
- package/dist/task-runner.d.ts +21 -0
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +111 -16
- package/dist/task-runner.js.map +1 -1
- package/dist/watch.d.ts +54 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +143 -15
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
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(`
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
"
|
|
1177
|
-
"
|
|
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
|