@tokenfactory/acc-runner 0.40.4 → 0.40.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CC-3E FAILOVER-E2E — companion contention resolution: most-recent-heartbeat
3
+ * wins.
4
+ *
5
+ * When an operator has TWO companions registered for the same conversation
6
+ * (e.g. a laptop that went to sleep and a desktop that just came online, or a
7
+ * duplicate launch), only one should serve. Per-turn contention is already safe
8
+ * — `acc.claim_chat_turn` takes a per-conversation advisory lock, so exactly one
9
+ * companion wins each individual turn (see companion.ts §advisory lock). But the
10
+ * lock's winner is arbitrary; a stale companion whose host is asleep can still
11
+ * grab the lock first and then requeue on lease-expiry, adding a wasted hop per
12
+ * turn. This module is the PURE tiebreak the pump consults before racing for the
13
+ * lock: prefer the companion whose heartbeat is freshest (and drop ones whose
14
+ * heartbeat has gone stale entirely).
15
+ *
16
+ * The heartbeat-recency comparator mirrors the reviewer-routing "freshest wins"
17
+ * rule (api/_lib/reviewer/routing.ts) but for chat companions, which had no such
18
+ * tiebreak. Pure — the clock is passed in — so every ordering + staleness branch
19
+ * is unit-testable without real timers.
20
+ */
21
+ import type { RunnerCapacityClass } from "./engines/types.js";
22
+ /** A companion's last-known presence, as gathered from `identity:<user>` beats. */
23
+ export interface CompanionPresence {
24
+ runnerId: string;
25
+ /** Epoch ms of this companion's most recent heartbeat. */
26
+ lastHeartbeatMs: number;
27
+ /** Last-reported host capacity. */
28
+ capacity: RunnerCapacityClass;
29
+ /** Engines still claimable on this companion at report time. */
30
+ availableEngineIds: string[];
31
+ }
32
+ /**
33
+ * Default staleness window: a companion whose last heartbeat is older than this
34
+ * is considered gone. Generous relative to the 4 s default beat cadence so a
35
+ * single missed beat (GC pause, brief network blip) does not evict a live host,
36
+ * but tight enough that a slept/crashed host stops winning within seconds.
37
+ */
38
+ export declare const DEFAULT_HEARTBEAT_STALE_MS = 15000;
39
+ export interface CompanionSelectOptions {
40
+ /** Current time (epoch ms). */
41
+ now: number;
42
+ /** Heartbeats older than this are dropped as gone. Default 15s. */
43
+ staleMs?: number;
44
+ /**
45
+ * Require the winner to still advertise claimable engines. Default true — a
46
+ * companion reporting an empty engine set has nothing to serve and should not
47
+ * win even if its heartbeat is the freshest.
48
+ */
49
+ requireEngines?: boolean;
50
+ }
51
+ /** True when a heartbeat is within the staleness window (i.e. the host is live). */
52
+ export declare function isHeartbeatLive(presence: CompanionPresence, now: number, staleMs?: number): boolean;
53
+ /** The eligible companions (live + non-empty engines when required), unranked. */
54
+ export declare function eligibleCompanions(companions: readonly CompanionPresence[], opts: CompanionSelectOptions): CompanionPresence[];
55
+ /**
56
+ * Pick the companion that should serve: the live one with the FRESHEST heartbeat.
57
+ * Ties (identical heartbeat ms — e.g. a replayed beat) break on `runnerId`
58
+ * lexicographically so the choice is deterministic. Returns null when no
59
+ * companion is eligible (all stale / no engines).
60
+ */
61
+ export declare function pickFreshestCompanion(companions: readonly CompanionPresence[], opts: CompanionSelectOptions): CompanionPresence | null;
62
+ /**
63
+ * Should THIS companion defer (yield) to a fresher peer? True when a different,
64
+ * eligible companion has a strictly newer heartbeat. Used by the pump to avoid
65
+ * racing for the claim lock when a fresher peer is present — the stale host backs
66
+ * off instead of winning the lock and then requeueing on lease-expiry.
67
+ */
68
+ export declare function shouldDeferToPeer(self: CompanionPresence, peers: readonly CompanionPresence[], opts: CompanionSelectOptions): boolean;
69
+ /** Parse an ISO-8601 heartbeat timestamp to epoch ms (NaN-safe → 0). */
70
+ export declare function heartbeatMsFromIso(ts: string | null | undefined): number;
71
+ //# sourceMappingURL=companion-select.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"companion-select.d.ts","sourceRoot":"","sources":["../src/companion-select.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,mFAAmF;AACnF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,eAAe,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,gEAAgE;IAChE,kBAAkB,EAAE,MAAM,EAAE,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,QAAS,CAAC;AAEjD,MAAM,WAAW,sBAAsB;IACrC,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,oFAAoF;AACpF,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,iBAAiB,EAC3B,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,MAAmC,GAC3C,OAAO,CAET;AAED,kFAAkF;AAClF,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,SAAS,iBAAiB,EAAE,EACxC,IAAI,EAAE,sBAAsB,GAC3B,iBAAiB,EAAE,CAQrB;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,SAAS,iBAAiB,EAAE,EACxC,IAAI,EAAE,sBAAsB,GAC3B,iBAAiB,GAAG,IAAI,CAS1B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,SAAS,iBAAiB,EAAE,EACnC,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAGT;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAIxE"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default staleness window: a companion whose last heartbeat is older than this
3
+ * is considered gone. Generous relative to the 4 s default beat cadence so a
4
+ * single missed beat (GC pause, brief network blip) does not evict a live host,
5
+ * but tight enough that a slept/crashed host stops winning within seconds.
6
+ */
7
+ export const DEFAULT_HEARTBEAT_STALE_MS = 15_000;
8
+ /** True when a heartbeat is within the staleness window (i.e. the host is live). */
9
+ export function isHeartbeatLive(presence, now, staleMs = DEFAULT_HEARTBEAT_STALE_MS) {
10
+ return now - presence.lastHeartbeatMs <= Math.max(0, staleMs);
11
+ }
12
+ /** The eligible companions (live + non-empty engines when required), unranked. */
13
+ export function eligibleCompanions(companions, opts) {
14
+ const staleMs = opts.staleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
15
+ const requireEngines = opts.requireEngines ?? true;
16
+ return companions.filter((c) => {
17
+ if (!isHeartbeatLive(c, opts.now, staleMs))
18
+ return false;
19
+ if (requireEngines && c.availableEngineIds.length === 0)
20
+ return false;
21
+ return true;
22
+ });
23
+ }
24
+ /**
25
+ * Pick the companion that should serve: the live one with the FRESHEST heartbeat.
26
+ * Ties (identical heartbeat ms — e.g. a replayed beat) break on `runnerId`
27
+ * lexicographically so the choice is deterministic. Returns null when no
28
+ * companion is eligible (all stale / no engines).
29
+ */
30
+ export function pickFreshestCompanion(companions, opts) {
31
+ const eligible = eligibleCompanions(companions, opts);
32
+ if (eligible.length === 0)
33
+ return null;
34
+ return eligible.reduce((best, c) => {
35
+ if (c.lastHeartbeatMs !== best.lastHeartbeatMs) {
36
+ return c.lastHeartbeatMs > best.lastHeartbeatMs ? c : best;
37
+ }
38
+ return c.runnerId < best.runnerId ? c : best;
39
+ });
40
+ }
41
+ /**
42
+ * Should THIS companion defer (yield) to a fresher peer? True when a different,
43
+ * eligible companion has a strictly newer heartbeat. Used by the pump to avoid
44
+ * racing for the claim lock when a fresher peer is present — the stale host backs
45
+ * off instead of winning the lock and then requeueing on lease-expiry.
46
+ */
47
+ export function shouldDeferToPeer(self, peers, opts) {
48
+ const winner = pickFreshestCompanion([self, ...peers], opts);
49
+ return !!winner && winner.runnerId !== self.runnerId;
50
+ }
51
+ /** Parse an ISO-8601 heartbeat timestamp to epoch ms (NaN-safe → 0). */
52
+ export function heartbeatMsFromIso(ts) {
53
+ if (!ts)
54
+ return 0;
55
+ const n = Date.parse(ts);
56
+ return Number.isFinite(n) ? n : 0;
57
+ }
58
+ //# sourceMappingURL=companion-select.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"companion-select.js","sourceRoot":"","sources":["../src/companion-select.ts"],"names":[],"mappings":"AAiCA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,MAAM,CAAC;AAejD,oFAAoF;AACpF,MAAM,UAAU,eAAe,CAC7B,QAA2B,EAC3B,GAAW,EACX,UAAkB,0BAA0B;IAE5C,OAAO,GAAG,GAAG,QAAQ,CAAC,eAAe,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;AAChE,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,kBAAkB,CAChC,UAAwC,EACxC,IAA4B;IAE5B,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,0BAA0B,CAAC;IAC3D,MAAM,cAAc,GAAG,IAAI,CAAC,cAAc,IAAI,IAAI,CAAC;IACnD,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE;QAC7B,IAAI,CAAC,eAAe,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QACzD,IAAI,cAAc,IAAI,CAAC,CAAC,kBAAkB,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACtE,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAwC,EACxC,IAA4B;IAE5B,MAAM,QAAQ,GAAG,kBAAkB,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;IACtD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,OAAO,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;QACjC,IAAI,CAAC,CAAC,eAAe,KAAK,IAAI,CAAC,eAAe,EAAE,CAAC;YAC/C,OAAO,CAAC,CAAC,eAAe,GAAG,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC7D,CAAC;QACD,OAAO,CAAC,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAC/B,IAAuB,EACvB,KAAmC,EACnC,IAA4B;IAE5B,MAAM,MAAM,GAAG,qBAAqB,CAAC,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,EAAE,IAAI,CAAC,CAAC;IAC7D,OAAO,CAAC,CAAC,MAAM,IAAI,MAAM,CAAC,QAAQ,KAAK,IAAI,CAAC,QAAQ,CAAC;AACvD,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,kBAAkB,CAAC,EAA6B;IAC9D,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,CAAC;IAClB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IACzB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACpC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenfactory/acc-runner",
3
- "version": "0.40.4",
3
+ "version": "0.40.5",
4
4
  "description": "Agent Control Center local runner. Spawns Claude Code sessions assigned via ACC.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,