@tokenfactory/acc-runner 0.40.14 → 0.40.16

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
@@ -43,6 +43,14 @@ import { acquireSingletonLock, singletonRunnerId, SingletonLockHeldError, } from
43
43
  import { getClaudeVersion, recordTaskClaudeVersion, } from "./runtime/version-drift.js";
44
44
  import { autoUpgradeDisabled, maybeSelfUpgrade, maybeSelfUpdateOn426, } from "./runtime/self-upgrade.js";
45
45
  import { postRunnerStateMessage } from "./messaging.js";
46
+ // AS-3 (RESUME-WIRE): drive the live capacity pause/resume through the
47
+ // ResumeController (pause-until reset OR account-change OR ping) instead of a
48
+ // fixed pause-until-reset setTimeout, and off the AS-2 account-probe cadence.
49
+ import { ResumeController } from "./capacity/resume-controller.js";
50
+ import { RUNNER_CAPACITY_PAUSED_VERB, RUNNER_CAPACITY_RESUME_VERB, } from "./capacity/fleet-pause-verbs.js";
51
+ import { AccountProbeScheduler } from "./engines/account-probe.js";
52
+ import { getChatEngine } from "./engines/registry.js";
53
+ import { unknownAccountIdentity } from "./engines/account-identity.js";
46
54
  import { checkVersion, compareSemver } from "./version-check.js";
47
55
  import { PACKAGE_VERSION } from "./pkg-version.js";
48
56
  import { resolveAuthMode, describeAuthMode, authPrecedenceHint } from "./provider-auth.js";
@@ -1119,12 +1127,24 @@ export async function watchCommand(options = {}) {
1119
1127
  pausedCapacity: false,
1120
1128
  pausedUntil: null,
1121
1129
  capacityTriggerTaskId: null,
1122
- pauseTimer: null,
1130
+ capacityPauseDetail: "",
1131
+ // AS-3 (RESUME-WIRE): assigned immediately below once `state` exists (the
1132
+ // controller's seams close over `state`). `undefined as unknown as` mirrors
1133
+ // the channel/timer bootstrap fields above.
1134
+ resumeController: undefined,
1135
+ accountProbe: null,
1136
+ resumeAccountKey: null,
1123
1137
  capacityProbe: false,
1124
1138
  capacityBackoffMs: options.capacityBackoffMs ?? DEFAULT_CAPACITY_BACKOFF_MS,
1125
1139
  resumeJitterMs: options.resumeJitterMs ?? DEFAULT_RESUME_JITTER_MS,
1126
1140
  singletonLock,
1127
1141
  };
1142
+ // AS-3 (RESUME-WIRE): build the capacity resume governor + account-probe
1143
+ // cadence NOW, before subscribeChannel / the first pump, so a capacity signal
1144
+ // on the very first claim routes through the controller. Awaited so the
1145
+ // starting account is seeded before any claim can 429. Both timers unref so
1146
+ // the probe/poll cadence never holds the process open on its own.
1147
+ await wireCapacityResume(state, taskRunnerFactory, options);
1128
1148
  subscribeChannel(state, taskRunnerFactory);
1129
1149
  // v0.34-B / v0.62 (T-62-1): one-shot startup claim scan. The 5-minute
1130
1150
  // pollSince watermark (v0.30-B) can miss a task dispatched (runner_id set)
@@ -1279,8 +1299,10 @@ export async function watchCommand(options = {}) {
1279
1299
  clearInterval(state.idleTimer);
1280
1300
  if (state.selfUpgradeTimer)
1281
1301
  clearInterval(state.selfUpgradeTimer); // FIX-K
1282
- if (state.pauseTimer)
1283
- clearTimeout(state.pauseTimer); // v0.56 (T-56-1)
1302
+ // AS-3 (RESUME-WIRE): cancel the resume poll + account-probe cadence
1303
+ // (replaces the v0.56 fixed pause-until-reset timer).
1304
+ state.resumeController.stop();
1305
+ state.accountProbe?.stop();
1284
1306
  // v0.10 T-49-2: cancel every in-flight task (replaces v0.9 single cancel).
1285
1307
  for (const ctrl of state.running.values()) {
1286
1308
  ctrl.cancel();
@@ -1763,142 +1785,234 @@ consecutive) {
1763
1785
  })();
1764
1786
  }
1765
1787
  /**
1766
- * v0.56 (T-56-1): enter a capacity pause.
1788
+ * AS-3 (RESUME-WIRE): enter a capacity pause.
1767
1789
  *
1768
- * Triggered when a task OR a review reports capacity_exhausted (Claude
1769
- * account out of session/usage capacity). The runner:
1770
- * - stops claiming tasks AND reviews (pump/pumpReviews bail on the flag);
1771
- * - schedules an automatic resume at the stated reset time (or a default
1772
- * backoff when none was parseable) plus a small jitter;
1773
- * - keeps heartbeating so the board shows online-but-paused, and emits a
1774
- * `runner.paused_capacity` activity event + a runner-level bus message
1775
- * carrying `resume_at` so health/board surfaces can show WHY the fleet
1776
- * is idle (heartbeat_runner's RPC shape is frozen, so the detail is
1777
- * carried additively through these existing channels).
1790
+ * Triggered when a task OR a review reports capacity_exhausted (Claude account
1791
+ * out of session/usage capacity). Since AS-3 the actual pause is owned by the
1792
+ * ResumeController: this helper resolves the reset instant (stated reset, else
1793
+ * the default backoff, plus the v0.56 spread jitter), stashes the trigger task +
1794
+ * cause for the pause seams, and hands the cap to `reportCapacity`. The
1795
+ * controller folds it onto the current account's window (extend-only), ENTERS
1796
+ * the pause through the stopClaims/setServerPause seams (which stop claiming and
1797
+ * emit the canonical `runner.capacity_paused` event + bus message), and owns the
1798
+ * resume timing — reset elapsed OR a logged-in-account change — REPLACING the
1799
+ * fixed pause-until-reset `setTimeout`.
1778
1800
  *
1779
- * Idempotent: a second capacity hit while already paused only ever pushes
1780
- * the resume later, never earlier, so a straggler task that 429s a moment
1781
- * after the first one can't shorten the wait.
1801
+ * Idempotent: a straggler cap while already paused only ever pushes the reset
1802
+ * later (the window fold's extend-only rule), never shortens the wait.
1782
1803
  */
1783
1804
  function enterCapacityPause(state, triggerTaskId, resumeAtIso, detail) {
1784
1805
  const now = Date.now();
1785
1806
  const parsed = resumeAtIso ? Date.parse(resumeAtIso) : NaN;
1786
- const resumeMs = Number.isFinite(parsed) && parsed > now ? parsed : now + state.capacityBackoffMs;
1787
- const source = Number.isFinite(parsed) && parsed > now ? "reset_time" : "default_backoff";
1788
- // Already paused: only extend the window, never shorten it.
1789
- if (state.pausedCapacity && state.pausedUntil !== null && resumeMs <= state.pausedUntil) {
1790
- return;
1791
- }
1792
- state.pausedCapacity = true;
1793
- state.pausedUntil = resumeMs;
1794
- // FH-1a: remember which task drove the pause so resumeFromCapacity can emit
1795
- // a task-scoped `task.capacity_resumed` for it once the window reopens.
1796
- state.capacityTriggerTaskId = triggerTaskId;
1797
- if (state.pauseTimer) {
1798
- clearTimeout(state.pauseTimer);
1799
- state.pauseTimer = null;
1800
- }
1807
+ const baseResumeMs = Number.isFinite(parsed) && parsed > now ? parsed : now + state.capacityBackoffMs;
1808
+ // Preserve the v0.56 resume jitter (spreads a fleet's simultaneous resume)
1809
+ // by folding it into the reset instant the controller waits on.
1801
1810
  const jitter = state.resumeJitterMs > 0 ? Math.floor(Math.random() * state.resumeJitterMs) : 0;
1802
- const delay = Math.max(0, resumeMs - now) + jitter;
1803
- const resumeAtFinal = new Date(now + delay).toISOString();
1804
- process.stderr.write(chalk.yellow(`[acc-runner] PAUSED (capacity): ${detail}. Not claiming tasks or reviews; ` +
1805
- `resuming at ${resumeAtFinal} (${source}).\n`));
1806
- void (async () => {
1807
- try {
1808
- await state.supabase.rpc("log_activity", {
1809
- p_verb: "runner.paused_capacity",
1810
- p_target_id: state.session.runner_id,
1811
- p_payload: {
1812
- resume_at: resumeAtFinal,
1813
- source,
1814
- detail,
1815
- version: PACKAGE_VERSION,
1816
- },
1817
- p_target_type: "runner",
1818
- });
1819
- }
1820
- catch { /* best-effort */ }
1821
- })();
1822
- // Runner-level bus message so operators see the paused status + reason on
1823
- // /messages even though heartbeat_runner itself can't carry a detail field.
1824
- // Anchored to the triggering task so the RPC can resolve the org scope
1825
- // (post_agent_message looks up org_id from acc.tasks for a non-null id).
1826
- void postRunnerStateMessage(state.supabase, {
1827
- task_id: triggerTaskId,
1828
- sender_id: state.session.runner_id,
1829
- state: "blocked",
1830
- protocol: "info",
1831
- payload: {
1832
- capacity_paused: true,
1833
- runner_id: state.session.runner_id,
1834
- resume_at: resumeAtFinal,
1835
- source,
1836
- detail,
1837
- },
1838
- kind: "blocked",
1839
- }).catch((err) => {
1840
- process.stderr.write(`[acc-runner] capacity pause bus post failed: ${err.message}\n`);
1811
+ const resumeMs = baseResumeMs + jitter;
1812
+ // FH-1a: remember which task drove the pause so the resume path can emit a
1813
+ // task-scoped `task.capacity_resumed` for it once the window reopens; stash
1814
+ // the non-secret cause so the setServerPause seam can carry it into the
1815
+ // canonical pause payload + bus message (the seam only sees {account, reset}).
1816
+ state.capacityTriggerTaskId = triggerTaskId;
1817
+ state.capacityPauseDetail = detail;
1818
+ // Stop claiming SYNCHRONOUSLY (the controller's stopClaims seam runs on the
1819
+ // serialized action tail a microtask later; pump()/pumpReviews() read this
1820
+ // flag between their own awaits, so setting it here closes the gap and keeps
1821
+ // the v0.56 "no new claim the instant a 429 lands" guarantee byte-identical).
1822
+ state.pausedCapacity = true;
1823
+ state.resumeController.reportCapacity({
1824
+ accountKey: state.resumeAccountKey ?? state.session.runner_id,
1825
+ resetAtMs: resumeMs,
1826
+ detail,
1841
1827
  });
1842
- state.pauseTimer = setTimeout(() => {
1843
- void resumeFromCapacity(state);
1844
- }, delay);
1845
- state.pauseTimer.unref?.();
1846
1828
  }
1847
1829
  /**
1848
- * v0.56 (T-56-1): resume from a capacity pause.
1830
+ * AS-3 (RESUME-WIRE): the ResumeController side-effect seams, backed by the REAL
1831
+ * runner path (log_activity RPCs + pollOnce/pump). Exported so the account-switch
1832
+ * rig / real-path tests drive the controller through the SAME seams the live
1833
+ * runner uses, not hand-written fakes.
1834
+ *
1835
+ * - setServerPause → emit the canonical `runner.capacity_paused` activity
1836
+ * event (the verb api/_lib/fleet-pause.ts actually reads,
1837
+ * fixing the drift) + the operator bus message; mark the
1838
+ * account probe paused so it re-probes at the pause cadence.
1839
+ * - stopClaims → set pausedCapacity so pump()/pumpReviews() stop claiming.
1840
+ * - clearServerPause → emit the canonical `runner.capacity_resume` (clears the
1841
+ * derived fleet_pause) + the FH-1a task-scoped
1842
+ * `task.capacity_resumed`; unpause the probe.
1843
+ * - restartClaims → arm the single-probe clamp and re-drive pollOnce + pump
1844
+ * (the clamp bounds the first claim to one task; a
1845
+ * non-capacity completion releases the held reviews).
1849
1846
  *
1850
- * Clears the pause and arms the single-probe clamp: the runner claims at
1851
- * most ONE task to confirm the window actually reopened before resuming
1852
- * full task concurrency and reviews. If the probe itself 429s, the
1853
- * completion handler re-enters the pause; if it succeeds, the probe clamp
1854
- * clears and normal claiming resumes.
1847
+ * In-flight sessions are NEVER touched — the seams only gate CLAIMING, so a task
1848
+ * already running finishes normally across a pause / resume / account swap.
1855
1849
  */
1856
- async function resumeFromCapacity(state) {
1857
- if (state.stopped)
1858
- return;
1859
- state.pausedCapacity = false;
1860
- state.pausedUntil = null;
1861
- // RVU-2 (FU-RVU2R): a full pause supersedes the soft claim-pause; clear it on
1862
- // resume so the probe window (which pins the task limit to 1) isn't zeroed out.
1863
- state.capacityClaimPaused = false;
1864
- if (state.pauseTimer) {
1865
- clearTimeout(state.pauseTimer);
1866
- state.pauseTimer = null;
1867
- }
1868
- state.capacityProbe = true;
1869
- console.log(chalk.gray(`[acc-runner] capacity window reopened — resuming with a single probe task.`));
1870
- try {
1871
- await state.supabase.rpc("log_activity", {
1872
- p_verb: "runner.resume_capacity",
1873
- p_target_id: state.session.runner_id,
1874
- p_payload: { probe: true, version: PACKAGE_VERSION },
1875
- p_target_type: "runner",
1876
- });
1877
- }
1878
- catch { /* best-effort */ }
1879
- // FH-1a: task-scoped resume audit for the task that drove the pause. The
1880
- // mid-task 429 cleanly requeued it (running→queued) and emitted
1881
- // task.capacity_paused; this closes the pair so a task timeline shows the
1882
- // window reopening. Best-effort + cleared after so a later review-triggered
1883
- // pause doesn't re-emit a stale task id.
1884
- const resumedTaskId = state.capacityTriggerTaskId;
1885
- state.capacityTriggerTaskId = null;
1886
- if (resumedTaskId) {
1887
- try {
1888
- await state.supabase.rpc("log_activity", {
1889
- p_verb: "task.capacity_resumed",
1890
- p_target_id: resumedTaskId,
1891
- p_payload: { task_id: resumedTaskId, probe: true, version: PACKAGE_VERSION },
1892
- p_target_type: "task",
1850
+ export function buildResumeControllerDeps(state, factory) {
1851
+ return {
1852
+ setServerPause: async ({ account, resetAtMs }) => {
1853
+ state.pausedUntil = resetAtMs;
1854
+ const resumeAtIso = resetAtMs !== null ? new Date(resetAtMs).toISOString() : null;
1855
+ const detail = state.capacityPauseDetail;
1856
+ const triggerTaskId = state.capacityTriggerTaskId;
1857
+ state.accountProbe?.setPaused(true);
1858
+ process.stderr.write(chalk.yellow(`[acc-runner] PAUSED (capacity) on ${account}: ${detail}. Not claiming ` +
1859
+ `tasks or reviews; resuming at ${resumeAtIso ?? "reset / account change"}.\n`));
1860
+ try {
1861
+ await state.supabase.rpc("log_activity", {
1862
+ p_verb: RUNNER_CAPACITY_PAUSED_VERB,
1863
+ p_target_id: state.session.runner_id,
1864
+ p_payload: {
1865
+ runner_id: state.session.runner_id,
1866
+ resume_at: resumeAtIso,
1867
+ reason: detail,
1868
+ account,
1869
+ version: PACKAGE_VERSION,
1870
+ },
1871
+ p_target_type: "runner",
1872
+ });
1873
+ }
1874
+ catch { /* best-effort */ }
1875
+ // Runner-level bus message so operators see the paused status + reason on
1876
+ // /messages. Anchored to the triggering task so the RPC can resolve the
1877
+ // org scope (post_agent_message looks up org_id from acc.tasks for a
1878
+ // non-null id); skipped for a review/probe pause with no task anchor.
1879
+ if (triggerTaskId) {
1880
+ await postRunnerStateMessage(state.supabase, {
1881
+ task_id: triggerTaskId,
1882
+ sender_id: state.session.runner_id,
1883
+ state: "blocked",
1884
+ protocol: "info",
1885
+ payload: {
1886
+ capacity_paused: true,
1887
+ runner_id: state.session.runner_id,
1888
+ resume_at: resumeAtIso,
1889
+ detail,
1890
+ },
1891
+ kind: "blocked",
1892
+ }).catch((err) => {
1893
+ process.stderr.write(`[acc-runner] capacity pause bus post failed: ${err.message}\n`);
1894
+ });
1895
+ }
1896
+ },
1897
+ stopClaims: () => {
1898
+ state.pausedCapacity = true;
1899
+ },
1900
+ clearServerPause: async () => {
1901
+ try {
1902
+ await state.supabase.rpc("log_activity", {
1903
+ p_verb: RUNNER_CAPACITY_RESUME_VERB,
1904
+ p_target_id: state.session.runner_id,
1905
+ p_payload: { runner_id: state.session.runner_id, version: PACKAGE_VERSION },
1906
+ p_target_type: "runner",
1907
+ });
1908
+ }
1909
+ catch { /* best-effort */ }
1910
+ // FH-1a: task-scoped resume audit for the task that drove the pause, then
1911
+ // clear it so a later review-triggered pause doesn't re-emit a stale id.
1912
+ const resumedTaskId = state.capacityTriggerTaskId;
1913
+ state.capacityTriggerTaskId = null;
1914
+ if (resumedTaskId) {
1915
+ try {
1916
+ await state.supabase.rpc("log_activity", {
1917
+ p_verb: "task.capacity_resumed",
1918
+ p_target_id: resumedTaskId,
1919
+ p_payload: { task_id: resumedTaskId, probe: true, version: PACKAGE_VERSION },
1920
+ p_target_type: "task",
1921
+ });
1922
+ }
1923
+ catch { /* best-effort */ }
1924
+ }
1925
+ state.accountProbe?.setPaused(false);
1926
+ },
1927
+ restartClaims: async () => {
1928
+ if (state.stopped)
1929
+ return;
1930
+ state.pausedCapacity = false;
1931
+ state.pausedUntil = null;
1932
+ // RVU-2 (FU-RVU2R): a full pause supersedes the soft claim-pause; clear it
1933
+ // so the probe window (task limit pinned to 1) isn't zeroed out.
1934
+ state.capacityClaimPaused = false;
1935
+ // Single-probe clamp — claim exactly ONE task to confirm the window
1936
+ // reopened before unleashing full concurrency + reviews.
1937
+ state.capacityProbe = true;
1938
+ console.log(chalk.gray(`[acc-runner] capacity window reopened — resuming with a single probe task.`));
1939
+ // Poll first (a released task may have been re-queued elsewhere), then
1940
+ // pump — the probe clamp bounds it to one task.
1941
+ await pollOnce(state, factory);
1942
+ void pump(state, factory);
1943
+ },
1944
+ onLog: (line) => {
1945
+ process.stderr.write(`${line}\n`);
1946
+ },
1947
+ };
1948
+ }
1949
+ /**
1950
+ * AS-3 (RESUME-WIRE): construct + start the ResumeController and the AS-1/AS-2
1951
+ * account-probe cadence, and stitch them together — each account-probe broadcast
1952
+ * feeds `observeAccount()` so a keychain login swap converges within one probe
1953
+ * cycle instead of waiting on the stale account's reset. Both use an UNREF'd
1954
+ * timer so the poll / probe cadence never holds the process open by itself.
1955
+ */
1956
+ async function wireCapacityResume(state, factory, options) {
1957
+ const unrefTimer = {
1958
+ setTimer: (fn, ms) => {
1959
+ const h = setTimeout(fn, ms);
1960
+ h.unref?.();
1961
+ return h;
1962
+ },
1963
+ clearTimer: (h) => clearTimeout(h),
1964
+ };
1965
+ // The account-identity probe: injected in tests; the resolved chat engine's
1966
+ // accountIdentity() otherwise (an honest per-host synthetic account when no
1967
+ // real login introspects). Never throws — a failed probe degrades to unknown
1968
+ // here, and to a logged no-op on the scheduler's later cadence probes.
1969
+ const chatEngine = getChatEngine();
1970
+ const probe = options.accountProbe ??
1971
+ (async () => {
1972
+ if (chatEngine.accountIdentity)
1973
+ return chatEngine.accountIdentity();
1974
+ return unknownAccountIdentity({
1975
+ engineId: chatEngine.id,
1976
+ channel: chatEngine.authMode,
1977
+ detail: "no account-identity probe on the resolved chat engine",
1893
1978
  });
1894
- }
1895
- catch { /* best-effort */ }
1896
- }
1897
- // Poll first (the stale-running sweep may have re-queued the released task
1898
- // to another runner; this picks up whatever is still assigned here), then
1899
- // pump — the probe clamp bounds it to one task.
1900
- await pollOnce(state, state.taskRunnerFactory);
1901
- void pump(state, state.taskRunnerFactory);
1979
+ });
1980
+ // Seed the controller's starting account BEFORE it starts, so a cap that
1981
+ // lands before the scheduler's first cadence probe still folds onto — and
1982
+ // pauses — the right account (reportCapacity only pauses the observed account).
1983
+ const initial = await probe().catch(() => null);
1984
+ state.resumeAccountKey = initial?.synthetic_account ?? null;
1985
+ state.resumeController = new ResumeController({
1986
+ timer: unrefTimer,
1987
+ pollIntervalMs: options.capacityPollMs ?? 60_000,
1988
+ ...(options.capacityAdoptionDebounceMs !== undefined
1989
+ ? { adoptionDebounceMs: options.capacityAdoptionDebounceMs }
1990
+ : {}),
1991
+ // A login swap resolves via the account-change branch (no request burned);
1992
+ // reset-elapsed drives the same-account resume. No probe ping wired.
1993
+ ...buildResumeControllerDeps(state, factory),
1994
+ });
1995
+ state.resumeController.start(state.resumeAccountKey);
1996
+ state.accountProbe = new AccountProbeScheduler({
1997
+ probe,
1998
+ timer: unrefTimer,
1999
+ pauseTickMs: options.accountProbeTickMs ?? 60_000,
2000
+ ...(options.accountProbeHeartbeatMs !== undefined
2001
+ ? { heartbeatMs: options.accountProbeHeartbeatMs }
2002
+ : {}),
2003
+ emit: (broadcast) => {
2004
+ state.resumeAccountKey = broadcast.identity.synthetic_account;
2005
+ // Fast path for a login swap: an immediate re-evaluation so a swap
2006
+ // converges within one probe cycle rather than the stale account's reset.
2007
+ state.resumeController.observeAccount(broadcast.identity.synthetic_account);
2008
+ },
2009
+ onLog: (line) => {
2010
+ process.stderr.write(`${line}\n`);
2011
+ },
2012
+ });
2013
+ // Start the probe cadence (fires the startup probe immediately, then the
2014
+ // pause-tick / heartbeat cadence per its four-trigger schedule).
2015
+ state.accountProbe.start();
1902
2016
  }
1903
2017
  /**
1904
2018
  * v0.10 T-49-2: concurrent task pump.