@tokenfactory/acc-runner 0.40.27 → 0.40.29

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.
Files changed (64) hide show
  1. package/dist/attachments/allowlist.d.ts +16 -0
  2. package/dist/attachments/allowlist.d.ts.map +1 -0
  3. package/dist/attachments/allowlist.js +34 -0
  4. package/dist/attachments/allowlist.js.map +1 -0
  5. package/dist/attachments/content-block.d.ts +16 -0
  6. package/dist/attachments/content-block.d.ts.map +1 -0
  7. package/dist/attachments/content-block.js +38 -0
  8. package/dist/attachments/content-block.js.map +1 -0
  9. package/dist/attachments/fetch-attachment.d.ts +24 -0
  10. package/dist/attachments/fetch-attachment.d.ts.map +1 -0
  11. package/dist/attachments/fetch-attachment.js +55 -0
  12. package/dist/attachments/fetch-attachment.js.map +1 -0
  13. package/dist/attachments/index.d.ts +21 -0
  14. package/dist/attachments/index.d.ts.map +1 -0
  15. package/dist/attachments/index.js +20 -0
  16. package/dist/attachments/index.js.map +1 -0
  17. package/dist/attachments/types.d.ts +39 -0
  18. package/dist/attachments/types.d.ts.map +1 -0
  19. package/dist/attachments/types.js +9 -0
  20. package/dist/attachments/types.js.map +1 -0
  21. package/dist/chat-cancel/cancel-controller.d.ts +77 -0
  22. package/dist/chat-cancel/cancel-controller.d.ts.map +1 -0
  23. package/dist/chat-cancel/cancel-controller.js +148 -0
  24. package/dist/chat-cancel/cancel-controller.js.map +1 -0
  25. package/dist/chat-cancel/cancel-source.d.ts +37 -0
  26. package/dist/chat-cancel/cancel-source.d.ts.map +1 -0
  27. package/dist/chat-cancel/cancel-source.js +26 -0
  28. package/dist/chat-cancel/cancel-source.js.map +1 -0
  29. package/dist/chat-cancel/child-terminator.d.ts +37 -0
  30. package/dist/chat-cancel/child-terminator.d.ts.map +1 -0
  31. package/dist/chat-cancel/child-terminator.js +81 -0
  32. package/dist/chat-cancel/child-terminator.js.map +1 -0
  33. package/dist/chat-cancel/index.d.ts +21 -0
  34. package/dist/chat-cancel/index.d.ts.map +1 -0
  35. package/dist/chat-cancel/index.js +20 -0
  36. package/dist/chat-cancel/index.js.map +1 -0
  37. package/dist/chat-cancel/types.d.ts +69 -0
  38. package/dist/chat-cancel/types.d.ts.map +1 -0
  39. package/dist/chat-cancel/types.js +7 -0
  40. package/dist/chat-cancel/types.js.map +1 -0
  41. package/dist/claim-deadman/deadman.d.ts +152 -0
  42. package/dist/claim-deadman/deadman.d.ts.map +1 -0
  43. package/dist/claim-deadman/deadman.js +176 -0
  44. package/dist/claim-deadman/deadman.js.map +1 -0
  45. package/dist/claim-deadman/heartbeat-signal.d.ts +48 -0
  46. package/dist/claim-deadman/heartbeat-signal.d.ts.map +1 -0
  47. package/dist/claim-deadman/heartbeat-signal.js +31 -0
  48. package/dist/claim-deadman/heartbeat-signal.js.map +1 -0
  49. package/dist/claim-deadman/index.d.ts +19 -0
  50. package/dist/claim-deadman/index.d.ts.map +1 -0
  51. package/dist/claim-deadman/index.js +17 -0
  52. package/dist/claim-deadman/index.js.map +1 -0
  53. package/dist/clarification.d.ts +48 -0
  54. package/dist/clarification.d.ts.map +1 -0
  55. package/dist/clarification.js +114 -0
  56. package/dist/clarification.js.map +1 -0
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +20 -0
  59. package/dist/cli.js.map +1 -1
  60. package/dist/watch.d.ts +74 -0
  61. package/dist/watch.d.ts.map +1 -1
  62. package/dist/watch.js +289 -1
  63. package/dist/watch.js.map +1 -1
  64. package/package.json +1 -1
package/dist/watch.js CHANGED
@@ -55,6 +55,7 @@ import { getChatEngine } from "./engines/registry.js";
55
55
  // a `watch` runner — the runner serves its bound user's chat turns behind their
56
56
  // companion. Glue lives in watch-chat/wire.ts to keep this file the task loop.
57
57
  import { startFleetChatServing, } from "./watch-chat/wire.js";
58
+ import { ClaimDeadman, readDispatchSignal, DEFAULT_DEADMAN_CHECK_MS, DEFAULT_FALLBACK_POLL_MS, CLAIM_CHANNEL_WEDGED_VERB, CLAIM_CHANNEL_RECOVERED_VERB, CLAIM_CHANNEL_REBUILD_VERB, CLAIM_CHANNEL_FALLBACK_VERB, } from "./claim-deadman/index.js";
58
59
  import { unknownAccountIdentity } from "./engines/account-identity.js";
59
60
  import { checkVersion, compareSemver } from "./version-check.js";
60
61
  import { PACKAGE_VERSION } from "./pkg-version.js";
@@ -120,6 +121,22 @@ const DEFAULT_CLAIM_WATCHDOG_CHECK_MS = 30_000;
120
121
  // regressions of the same shape. Default 60s — prompt recovery without a third
121
122
  // frequent DB poll racing the watchdog. Knob: `claim_backstop_interval_ms`.
122
123
  const DEFAULT_CLAIM_BACKSTOP_INTERVAL_MS = 60_000;
124
+ // CLAIM-DEADMAN: dead-man switch for a WEDGED claim channel
125
+ // that the 90s watchdog resubscribe can't clear. Live incident 2026-07-22: a
126
+ // runner heartbeated fine (hb_age 0min) but its Realtime claim subscription was
127
+ // dead AND the watchdog resubscribe had itself stopped firing — 12 queued / 0
128
+ // running with two online runners, cleared only by a manual restart. The deadman
129
+ // tracks pending-dispatch (work the server is offering) vs the last processed
130
+ // claim; when work is pending and no claim lands for CLAIM_DEADMAN_WEDGE_MS
131
+ // (default 120s, > the 90s watchdog so the cheap resubscribe gets first crack)
132
+ // it (1) TEARS DOWN + FULLY REBUILDS the Realtime client up to
133
+ // CLAIM_DEADMAN_MAX_REBUILDS times, then (2) falls back to degraded DB-poll
134
+ // claiming every CLAIM_DEADMAN_FALLBACK_POLL_MS. CLAIM_DEADMAN_CHECK_MS is the
135
+ // evaluation cadence. Tests override all of these for speed. See
136
+ // packages/acc-runner/src/claim-deadman/deadman.ts + docs/acc/CLAIM_DEADMAN.md.
137
+ const DEFAULT_CLAIM_DEADMAN_WEDGE_MS = 120_000;
138
+ const DEFAULT_CLAIM_DEADMAN_REBUILD_GRACE_MS = 15_000;
139
+ const DEFAULT_CLAIM_DEADMAN_MAX_REBUILDS = 2;
123
140
  // GIT-AUTH-ALERT: how often the runner probes its base clone's git credential
124
141
  // (a masked `ls-remote origin` dry-run). The same 60s cadence detects the first
125
142
  // failure AND polls for recovery — while degraded, task-lane claiming is paused
@@ -208,6 +225,13 @@ function enqueue(state, taskId, factory) {
208
225
  state.seen.add(taskId);
209
226
  state.queue.push(taskId);
210
227
  touchActivity(state);
228
+ // CLAIM-DEADMAN: a claim was processed via SOME path
229
+ // (realtime / poll / scan / watchdog / the deadman's own DB-poll fallback).
230
+ // Reset the deadman's staleness clock so a runner that IS claiming — even
231
+ // slowly, even degraded — is never treated as wedged. Recovery itself is
232
+ // declared by the offered backlog draining (observeDispatchSignal), so this
233
+ // never prematurely clears a wedge episode.
234
+ state.claimDeadman?.observeClaimProcessed();
211
235
  void pump(state, factory);
212
236
  return true;
213
237
  }
@@ -656,6 +680,230 @@ async function claimBackstopScan(state, factory) {
656
680
  state.backstopScanning = false;
657
681
  }
658
682
  }
683
+ /**
684
+ * CLAIM-DEADMAN: a runner may have free capacity but be
685
+ * deliberately not claiming (quarantined, capacity-paused, review-claim-paused,
686
+ * or already at its effective task-claim ceiling). In those states pending work
687
+ * SHOULD sit queued, so it must NOT be read as a wedge. Mirrors the
688
+ * claimBackstopScan guard. Returns true only when the runner both wants to and
689
+ * can claim another task.
690
+ */
691
+ function deadmanCanClaim(state) {
692
+ if (state.quarantined || state.pausedCapacity || state.capacityClaimPaused) {
693
+ return false;
694
+ }
695
+ const limit = computeTaskClaimLimit({
696
+ concurrencyLimit: state.concurrencyLimit,
697
+ reservedReviewSlots: state.reservedReviewSlots,
698
+ capacityProbe: state.capacityProbe,
699
+ claimPaused: state.capacityClaimPaused,
700
+ });
701
+ return state.running.size < limit;
702
+ }
703
+ /**
704
+ * CLAIM-DEADMAN: parse the pending-dispatch fields off a `heartbeat_runner`
705
+ * response. Tolerant of a scalar row, a single-row array, or null (today's
706
+ * void return) — returns null unless a numeric `pending_dispatch` is present, so
707
+ * a pre-extension server leaves the deadman on its assigned-queued read.
708
+ */
709
+ export function parseHeartbeatDeadmanFields(data) {
710
+ const row = Array.isArray(data) ? data[0] : data;
711
+ if (!row || typeof row !== "object")
712
+ return null;
713
+ const r = row;
714
+ if (typeof r.pending_dispatch !== "number")
715
+ return null;
716
+ return {
717
+ pending_dispatch: Math.max(0, Math.floor(r.pending_dispatch)),
718
+ suspect: Boolean(r.suspect),
719
+ };
720
+ }
721
+ /**
722
+ * CLAIM-DEADMAN: emit the audit event + operator-facing alert for a claim-
723
+ * channel state change. `runner.claim_channel_wedged` / `..._recovered` are the
724
+ * UI alert breadcrumbs; the payload NAMES the runner (id + label) so the derived
725
+ * notifications read model can surface "runner X's claim channel is wedged"
726
+ * without a join. Best-effort by contract — a logging failure never disturbs the
727
+ * watch loop. Also mirrors to stderr so a locally-tailed runner shows it.
728
+ */
729
+ function emitDeadmanEvent(state, verb, payload, humanLine) {
730
+ process.stderr.write(`[acc-runner] ${humanLine}\n`);
731
+ void (async () => {
732
+ try {
733
+ await state.supabase.rpc("log_activity", {
734
+ p_verb: verb,
735
+ p_target_id: state.session.runner_id,
736
+ p_payload: {
737
+ // NAMES the runner so the derived notifications read model can surface
738
+ // "runner <id>'s claim channel is wedged" without a join (the UI
739
+ // resolves the display name from acc.runners by this id).
740
+ runner_id: state.session.runner_id,
741
+ version: PACKAGE_VERSION,
742
+ ...payload,
743
+ },
744
+ p_target_type: "runner",
745
+ });
746
+ }
747
+ catch {
748
+ /* best-effort breadcrumb */
749
+ }
750
+ })();
751
+ }
752
+ /**
753
+ * CLAIM-DEADMAN: tear down + FULLY REBUILD the Realtime client — not just a
754
+ * resubscribe. The v0.68 resubscribe / v0.73 watchdog both remove+re-add a
755
+ * channel on the SAME Supabase client, so a wedged underlying socket / Realtime
756
+ * instance survives them (the incident's failure mode). This discards the whole
757
+ * client and builds a fresh one around the same JWT + realtime config (mirrors
758
+ * doRefresh/doFailover's client rebuild, minus the token change), then recovers
759
+ * anything the dead channel missed. Single-flight via `deadmanRebuilding`.
760
+ */
761
+ export async function rebuildRealtimeClient(state, factory, attempt) {
762
+ if (state.stopped || state.deadmanRebuilding)
763
+ return;
764
+ state.deadmanRebuilding = true;
765
+ try {
766
+ // Tear down the old channel + socket as fully as the client allows.
767
+ try {
768
+ await state.supabase.removeChannel(state.channel);
769
+ }
770
+ catch {
771
+ /* channel may already be dead */
772
+ }
773
+ try {
774
+ await state.supabase.removeAllChannels?.();
775
+ }
776
+ catch {
777
+ /* best-effort */
778
+ }
779
+ try {
780
+ // any-allowed: `.realtime` is not on the narrowed RunnerSupabaseClient type
781
+ // but supabase-js exposes a RealtimeClient with disconnect() here — this is
782
+ // the "fully rebuild, not just resubscribe" teardown of the live socket.
783
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
784
+ state.supabase.realtime?.disconnect?.();
785
+ }
786
+ catch {
787
+ /* best-effort */
788
+ }
789
+ // Fresh client around the current (still-valid) credential.
790
+ state.supabase = createRunnerClient(state.cfg, state.session.access_token, state.session.realtime_config);
791
+ subscribeChannel(state, factory);
792
+ // FLEET-SERVE (wire): hot-swap the chat lane onto the fresh client so chat
793
+ // serving survives the rebuild (mirrors doRefresh/doFailover).
794
+ await state.fleetChat?.refreshClient(state.supabase, state.session.access_token);
795
+ emitDeadmanEvent(state, CLAIM_CHANNEL_REBUILD_VERB, { attempt }, `claim-deadman: rebuilding Realtime client (attempt ${attempt}) — ` +
796
+ `claim channel wedged with a live heartbeat`);
797
+ // Recover anything the wedged channel missed. The seen/seenReviews guards
798
+ // dedupe against a late re-delivery on the fresh channel.
799
+ void reassertRunningClaims(state);
800
+ void pollOnce(state, factory);
801
+ void claimScan(state, factory, "periodic");
802
+ void reviewScan(state, "periodic");
803
+ }
804
+ catch (err) {
805
+ process.stderr.write(`[acc-runner] claim-deadman rebuild failed: ${err.message}\n`);
806
+ }
807
+ finally {
808
+ state.deadmanRebuilding = false;
809
+ }
810
+ }
811
+ /**
812
+ * CLAIM-DEADMAN: the degraded DB-poll fallback loop. Once the rebuild budget is
813
+ * spent the runner claims by polling the claim path directly — a full epoch-
814
+ * watermark claim scan plus the `seen`-reconcile backstop — every
815
+ * `deadmanFallbackPollMs`. Degraded (no realtime latency) but FUNCTIONAL: tasks
816
+ * get claimed with zero human action while the WS stays dead. Idempotent —
817
+ * starting an already-running loop is a no-op.
818
+ */
819
+ function startDeadmanFallback(state, factory) {
820
+ if (state.stopped || state.deadmanFallbackTimer)
821
+ return;
822
+ emitDeadmanEvent(state, CLAIM_CHANNEL_FALLBACK_VERB, { poll_ms: state.deadmanFallbackPollMs }, `claim-deadman: Realtime rebuild budget spent — falling back to DB-poll ` +
823
+ `claiming every ${state.deadmanFallbackPollMs}ms (degraded but functional)`);
824
+ const tick = () => {
825
+ void (async () => {
826
+ if (state.stopped)
827
+ return;
828
+ await claimScan(state, factory, "periodic");
829
+ await claimBackstopScan(state, factory);
830
+ })();
831
+ };
832
+ tick(); // claim immediately, don't wait a full interval
833
+ state.deadmanFallbackTimer = setInterval(tick, state.deadmanFallbackPollMs);
834
+ state.deadmanFallbackTimer.unref?.();
835
+ }
836
+ /** CLAIM-DEADMAN: stop the degraded DB-poll fallback loop (on recovery / stop). */
837
+ function stopDeadmanFallback(state) {
838
+ if (state.deadmanFallbackTimer) {
839
+ clearInterval(state.deadmanFallbackTimer);
840
+ state.deadmanFallbackTimer = null;
841
+ }
842
+ }
843
+ /**
844
+ * CLAIM-DEADMAN: the dead-man evaluation. Reads how much
845
+ * work the server is offering this runner (pending-dispatch — from the heartbeat
846
+ * response when the server supplies it, else the assigned-queued read over the
847
+ * same HTTP path that survives a dead WS), feeds it + the last-claim clock to the
848
+ * pure ClaimDeadman, and acts on its decision:
849
+ *
850
+ * - recovered (offered backlog drained after a wedge episode) → stop the
851
+ * fallback loop + emit `runner.claim_channel_recovered`.
852
+ * - rebuild(N) → full Realtime-client rebuild (rebuildRealtimeClient).
853
+ * - fallback → engage/keep the degraded DB-poll claim loop.
854
+ *
855
+ * A heartbeat failure short-circuits to observeHeartbeatFailure (a network/auth
856
+ * outage is a different failure with its own handling — not a wedge). Bounded
857
+ * and side-effect-safe: never throws out of the loop. Single-flight via
858
+ * `claimDeadmanChecking`.
859
+ */
860
+ async function claimDeadmanScan(state, factory) {
861
+ if (state.stopped || state.claimDeadmanChecking)
862
+ return;
863
+ state.claimDeadmanChecking = true;
864
+ try {
865
+ if (state.heartbeatFailures > 0) {
866
+ state.claimDeadman.observeHeartbeatFailure();
867
+ return;
868
+ }
869
+ // Only pending work the runner WOULD claim counts as offered — a runner that
870
+ // is deliberately not claiming (paused/quarantined/at-capacity) is not wedged.
871
+ let signal;
872
+ if (deadmanCanClaim(state)) {
873
+ signal = await readDispatchSignal(state.supabase, state.session.runner_id, state.lastHeartbeatDeadmanFields);
874
+ }
875
+ else {
876
+ signal = { pendingDispatch: 0, suspect: false };
877
+ }
878
+ const { recovered } = state.claimDeadman.observeDispatchSignal(signal);
879
+ if (recovered) {
880
+ stopDeadmanFallback(state);
881
+ emitDeadmanEvent(state, CLAIM_CHANNEL_RECOVERED_VERB, {}, `claim-deadman: claim channel recovered — offered backlog drained, ` +
882
+ `runner is claiming normally again`);
883
+ }
884
+ const decision = state.claimDeadman.tick();
885
+ if (decision.wedged) {
886
+ emitDeadmanEvent(state, CLAIM_CHANNEL_WEDGED_VERB, {
887
+ pending_dispatch: decision.pendingDispatch,
888
+ ms_since_claim: decision.msSinceClaim,
889
+ }, `claim-deadman: claim channel WEDGED — ${decision.pendingDispatch} ` +
890
+ `task(s) offered but none claimed for ${Math.round(decision.msSinceClaim / 1000)}s ` +
891
+ `with a live heartbeat; self-healing`);
892
+ }
893
+ if (decision.action === "rebuild") {
894
+ await rebuildRealtimeClient(state, factory, decision.rebuildAttempt ?? 1);
895
+ }
896
+ else if (decision.action === "fallback") {
897
+ startDeadmanFallback(state, factory);
898
+ }
899
+ }
900
+ catch (err) {
901
+ process.stderr.write(`[acc-runner] claim deadman scan failed: ${err.message}\n`);
902
+ }
903
+ finally {
904
+ state.claimDeadmanChecking = false;
905
+ }
906
+ }
659
907
  /**
660
908
  * v0.65 (T-65-2): full review scan — the review twin of claimScan. Lists EVERY
661
909
  * pending review assigned to this runner with an epoch watermark (`1970-…Z`)
@@ -923,6 +1171,7 @@ export async function watchCommand(options = {}) {
923
1171
  reviewScanOnce: async () => { },
924
1172
  claimWatchdogOnce: async () => { },
925
1173
  claimBackstopOnce: async () => { },
1174
+ claimDeadmanOnce: async () => { },
926
1175
  gitAuthProbeOnce: async () => false,
927
1176
  runningCount: () => 0,
928
1177
  };
@@ -1105,6 +1354,20 @@ export async function watchCommand(options = {}) {
1105
1354
  claimBackstopTimer: undefined,
1106
1355
  claimBackstopIntervalMs: options.claimBackstopIntervalMs ?? DEFAULT_CLAIM_BACKSTOP_INTERVAL_MS,
1107
1356
  backstopScanning: false,
1357
+ // CLAIM-DEADMAN: wedge dead-man switch. The pure state
1358
+ // machine carries its own clock (Date.now); tests inject tiny thresholds.
1359
+ claimDeadman: new ClaimDeadman({
1360
+ wedgeThresholdMs: options.claimDeadmanWedgeMs ?? DEFAULT_CLAIM_DEADMAN_WEDGE_MS,
1361
+ rebuildGraceMs: options.claimDeadmanRebuildGraceMs ?? DEFAULT_CLAIM_DEADMAN_REBUILD_GRACE_MS,
1362
+ maxRebuilds: options.claimDeadmanMaxRebuilds ?? DEFAULT_CLAIM_DEADMAN_MAX_REBUILDS,
1363
+ }),
1364
+ claimDeadmanTimer: undefined,
1365
+ claimDeadmanCheckMs: options.claimDeadmanCheckMs ?? DEFAULT_DEADMAN_CHECK_MS,
1366
+ claimDeadmanChecking: false,
1367
+ deadmanRebuilding: false,
1368
+ deadmanFallbackTimer: null,
1369
+ deadmanFallbackPollMs: options.claimDeadmanFallbackPollMs ?? DEFAULT_FALLBACK_POLL_MS,
1370
+ lastHeartbeatDeadmanFields: null,
1108
1371
  // GIT-AUTH-ALERT: controller wired below once `state` exists (its emit
1109
1372
  // callbacks close over `state`); timer armed in the timer-setup block.
1110
1373
  gitAuthController: null,
@@ -1263,6 +1526,18 @@ export async function watchCommand(options = {}) {
1263
1526
  state.claimBackstopTimer = setInterval(() => {
1264
1527
  void claimBackstopScan(state, taskRunnerFactory);
1265
1528
  }, state.claimBackstopIntervalMs);
1529
+ // CLAIM-DEADMAN: the wedge dead-man switch. Escalates
1530
+ // BEYOND the 90s watchdog resubscribe (which only re-adds a channel on the
1531
+ // same client and, per the incident, can itself stop firing): tracks pending-
1532
+ // dispatch vs the last processed claim, and when work is offered but nothing
1533
+ // is claimed for the wedge threshold it FULLY REBUILDS the Realtime client
1534
+ // (twice) then degrades to DB-poll claiming — self-healing the "heartbeat-
1535
+ // alive but claim-dead" runner with zero human action. Independent timer +
1536
+ // single-flight inside claimDeadmanScan().
1537
+ state.claimDeadmanTimer = setInterval(() => {
1538
+ void claimDeadmanScan(state, taskRunnerFactory);
1539
+ }, state.claimDeadmanCheckMs);
1540
+ state.claimDeadmanTimer.unref?.();
1266
1541
  // GIT-AUTH-ALERT: periodic git-credential health probe. Armed only when the
1267
1542
  // controller is wired (a base clone is resolvable). The same cadence detects
1268
1543
  // the first auth failure AND polls for recovery while degraded. Single-flight
@@ -1368,6 +1643,8 @@ export async function watchCommand(options = {}) {
1368
1643
  clearInterval(state.reviewScanTimer); // v0.65 (T-65-2)
1369
1644
  clearInterval(state.claimWatchdogTimer); // v0.73 (T-73-2)
1370
1645
  clearInterval(state.claimBackstopTimer); // FIX-D
1646
+ clearInterval(state.claimDeadmanTimer); // CLAIM-DEADMAN
1647
+ stopDeadmanFallback(state); // CLAIM-DEADMAN
1371
1648
  if (state.gitAuthTimer)
1372
1649
  clearInterval(state.gitAuthTimer); // GIT-AUTH-ALERT
1373
1650
  if (state.resubscribeTimer)
@@ -1480,6 +1757,7 @@ export async function watchCommand(options = {}) {
1480
1757
  reviewScanOnce: () => reviewScan(state, "periodic"), // v0.65 (T-65-2)
1481
1758
  claimWatchdogOnce: () => claimWatchdogScan(state, taskRunnerFactory), // v0.73 (T-73-2)
1482
1759
  claimBackstopOnce: () => claimBackstopScan(state, taskRunnerFactory), // FIX-D
1760
+ claimDeadmanOnce: () => claimDeadmanScan(state, taskRunnerFactory), // CLAIM-DEADMAN
1483
1761
  gitAuthProbeOnce: () => gitAuthProbeScan(state), // GIT-AUTH-ALERT
1484
1762
  runningCount: () => state.running.size,
1485
1763
  };
@@ -1572,12 +1850,20 @@ function scheduleHeartbeat(state, taskRunnerFactory, baseMs) {
1572
1850
  const delay = nextHeartbeatDelayMs(state.heartbeatFailures, baseMs);
1573
1851
  state.heartbeatTimer = setTimeout(() => {
1574
1852
  void (async () => {
1575
- const { error } = await state.supabase.rpc("heartbeat_runner", {
1853
+ const { data, error } = await state.supabase.rpc("heartbeat_runner", {
1576
1854
  p_id: state.session.runner_id,
1577
1855
  p_version: PACKAGE_VERSION,
1578
1856
  });
1579
1857
  if (!error) {
1580
1858
  state.heartbeatFailures = 0;
1859
+ // CLAIM-DEADMAN: capture the pending-dispatch count
1860
+ // + suspect flag when the heartbeat RESPONSE carries them, so the runner
1861
+ // learns it is being offered work even with a dead WS. Forward-compatible:
1862
+ // today `heartbeat_runner` returns void and this is a no-op (the deadman
1863
+ // reads the assigned-queued count over HTTP instead). Once the RPC is
1864
+ // extended (Track A migration) this is the authoritative signal — the
1865
+ // deadman prefers it in readDispatchSignal.
1866
+ state.lastHeartbeatDeadmanFields = parseHeartbeatDeadmanFields(data);
1581
1867
  // RUNCFG-1: pull the operator's server-set concurrency and apply it to
1582
1868
  // the live ceiling within this heartbeat. Runs only on a healthy
1583
1869
  // heartbeat (a network/auth failure would fail this RPC too). Awaited so
@@ -1611,6 +1897,8 @@ function scheduleHeartbeat(state, taskRunnerFactory, baseMs) {
1611
1897
  clearInterval(state.reviewScanTimer); // v0.65 (T-65-2)
1612
1898
  clearInterval(state.claimWatchdogTimer); // v0.73 (T-73-2)
1613
1899
  clearInterval(state.claimBackstopTimer); // FIX-D
1900
+ clearInterval(state.claimDeadmanTimer); // CLAIM-DEADMAN
1901
+ stopDeadmanFallback(state); // CLAIM-DEADMAN
1614
1902
  if (state.resubscribeTimer)
1615
1903
  clearTimeout(state.resubscribeTimer); // v0.68 (T-68-1)
1616
1904
  process.exit(1);