agent-nuvira 3.5.0 → 3.5.2

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 (134) hide show
  1. package/README.md +13 -13
  2. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  3. package/dist/agent-sdk/src/scaffold.js +1 -1
  4. package/dist/cli/chat.d.ts +159 -6
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +583 -35
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/duties.d.ts +11 -0
  9. package/dist/cli/duties.d.ts.map +1 -1
  10. package/dist/cli/duties.js +31 -0
  11. package/dist/cli/duties.js.map +1 -1
  12. package/dist/cli/edit.d.ts.map +1 -1
  13. package/dist/cli/edit.js +3 -2
  14. package/dist/cli/edit.js.map +1 -1
  15. package/dist/cli/execute.d.ts.map +1 -1
  16. package/dist/cli/execute.js +10 -3
  17. package/dist/cli/execute.js.map +1 -1
  18. package/dist/cli/loop-executor.d.ts +16 -0
  19. package/dist/cli/loop-executor.d.ts.map +1 -1
  20. package/dist/cli/loop-executor.js +9 -0
  21. package/dist/cli/loop-executor.js.map +1 -1
  22. package/dist/cli/models.d.ts.map +1 -1
  23. package/dist/cli/models.js +55 -0
  24. package/dist/cli/models.js.map +1 -1
  25. package/dist/cli/plan.d.ts.map +1 -1
  26. package/dist/cli/plan.js +18 -6
  27. package/dist/cli/plan.js.map +1 -1
  28. package/dist/cli/run.d.ts.map +1 -1
  29. package/dist/cli/run.js +23 -8
  30. package/dist/cli/run.js.map +1 -1
  31. package/dist/cli/ui/answer-view.d.ts +38 -0
  32. package/dist/cli/ui/answer-view.d.ts.map +1 -0
  33. package/dist/cli/ui/answer-view.js +72 -0
  34. package/dist/cli/ui/answer-view.js.map +1 -0
  35. package/dist/cli/ui/cards.d.ts +61 -0
  36. package/dist/cli/ui/cards.d.ts.map +1 -0
  37. package/dist/cli/ui/cards.js +141 -0
  38. package/dist/cli/ui/cards.js.map +1 -0
  39. package/dist/cli/ui/highlight.d.ts +32 -0
  40. package/dist/cli/ui/highlight.d.ts.map +1 -0
  41. package/dist/cli/ui/highlight.js +261 -0
  42. package/dist/cli/ui/highlight.js.map +1 -0
  43. package/dist/cli/ui/index.d.ts +21 -0
  44. package/dist/cli/ui/index.d.ts.map +1 -0
  45. package/dist/cli/ui/index.js +14 -0
  46. package/dist/cli/ui/index.js.map +1 -0
  47. package/dist/cli/ui/markdown.d.ts +90 -0
  48. package/dist/cli/ui/markdown.d.ts.map +1 -0
  49. package/dist/cli/ui/markdown.js +292 -0
  50. package/dist/cli/ui/markdown.js.map +1 -0
  51. package/dist/cli/ui/presenter.d.ts +147 -0
  52. package/dist/cli/ui/presenter.d.ts.map +1 -0
  53. package/dist/cli/ui/presenter.js +313 -0
  54. package/dist/cli/ui/presenter.js.map +1 -0
  55. package/dist/cli/ui/render.d.ts +52 -0
  56. package/dist/cli/ui/render.d.ts.map +1 -0
  57. package/dist/cli/ui/render.js +384 -0
  58. package/dist/cli/ui/render.js.map +1 -0
  59. package/dist/cli/ui/theme.d.ts +36 -0
  60. package/dist/cli/ui/theme.d.ts.map +1 -0
  61. package/dist/cli/ui/theme.js +92 -0
  62. package/dist/cli/ui/theme.js.map +1 -0
  63. package/dist/inference/model-entitlement.d.ts +10 -0
  64. package/dist/inference/model-entitlement.d.ts.map +1 -1
  65. package/dist/inference/model-entitlement.js +1 -1
  66. package/dist/inference/model-entitlement.js.map +1 -1
  67. package/dist/learning/auto-router.d.ts +38 -1
  68. package/dist/learning/auto-router.d.ts.map +1 -1
  69. package/dist/learning/auto-router.js +139 -8
  70. package/dist/learning/auto-router.js.map +1 -1
  71. package/dist/learning/duty-lease.d.ts +163 -0
  72. package/dist/learning/duty-lease.d.ts.map +1 -0
  73. package/dist/learning/duty-lease.js +301 -0
  74. package/dist/learning/duty-lease.js.map +1 -0
  75. package/dist/learning/model-first-router.d.ts +14 -0
  76. package/dist/learning/model-first-router.d.ts.map +1 -1
  77. package/dist/learning/model-first-router.js +54 -17
  78. package/dist/learning/model-first-router.js.map +1 -1
  79. package/dist/learning/model-warmup.d.ts +92 -0
  80. package/dist/learning/model-warmup.d.ts.map +1 -1
  81. package/dist/learning/model-warmup.js +137 -2
  82. package/dist/learning/model-warmup.js.map +1 -1
  83. package/dist/learning/reasoning-trace.d.ts +21 -1
  84. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  85. package/dist/learning/reasoning-trace.js.map +1 -1
  86. package/dist/learning/resilient-call.d.ts.map +1 -1
  87. package/dist/learning/resilient-call.js +70 -4
  88. package/dist/learning/resilient-call.js.map +1 -1
  89. package/dist/learning/retry-patience.d.ts +139 -0
  90. package/dist/learning/retry-patience.d.ts.map +1 -0
  91. package/dist/learning/retry-patience.js +169 -0
  92. package/dist/learning/retry-patience.js.map +1 -0
  93. package/dist/tools/process-liveness.d.ts +227 -0
  94. package/dist/tools/process-liveness.d.ts.map +1 -0
  95. package/dist/tools/process-liveness.js +486 -0
  96. package/dist/tools/process-liveness.js.map +1 -0
  97. package/dist/tools/registry.d.ts.map +1 -1
  98. package/dist/tools/registry.js +4 -2
  99. package/dist/tools/registry.js.map +1 -1
  100. package/dist/tools/run-registry.d.ts +282 -0
  101. package/dist/tools/run-registry.d.ts.map +1 -0
  102. package/dist/tools/run-registry.js +544 -0
  103. package/dist/tools/run-registry.js.map +1 -0
  104. package/dist/tools/run-terminal.d.ts +100 -2
  105. package/dist/tools/run-terminal.d.ts.map +1 -1
  106. package/dist/tools/run-terminal.js +841 -67
  107. package/dist/tools/run-terminal.js.map +1 -1
  108. package/dist/tools/tool-loop.d.ts +87 -3
  109. package/dist/tools/tool-loop.d.ts.map +1 -1
  110. package/dist/tools/tool-loop.js +375 -6
  111. package/dist/tools/tool-loop.js.map +1 -1
  112. package/dist/web-dashboard/chat-console.d.ts +25 -0
  113. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  114. package/dist/web-dashboard/chat-console.js +16 -1
  115. package/dist/web-dashboard/chat-console.js.map +1 -1
  116. package/dist/web-dashboard/server.d.ts.map +1 -1
  117. package/dist/web-dashboard/server.js +7 -1
  118. package/dist/web-dashboard/server.js.map +1 -1
  119. package/dist/web-dashboard/src/types.d.ts +32 -1
  120. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  121. package/dist/web-dashboard/task-registry.d.ts +98 -0
  122. package/dist/web-dashboard/task-registry.d.ts.map +1 -0
  123. package/dist/web-dashboard/task-registry.js +159 -0
  124. package/dist/web-dashboard/task-registry.js.map +1 -0
  125. package/dist/web-dashboard/task-runner.d.ts +155 -5
  126. package/dist/web-dashboard/task-runner.d.ts.map +1 -1
  127. package/dist/web-dashboard/task-runner.js +505 -14
  128. package/dist/web-dashboard/task-runner.js.map +1 -1
  129. package/package.json +4 -1
  130. package/src/web-dashboard/public/assets/index-DycGBWwy.js +236 -0
  131. package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +1 -0
  132. package/src/web-dashboard/public/index.html +1 -1
  133. package/src/web-dashboard/public/assets/index-yky0dw5e.js +0 -236
  134. package/src/web-dashboard/public/assets/index-yky0dw5e.js.map +0 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Duty leases — one holder per named background duty, across processes, with no
3
+ * daemon and no supervisor.
4
+ *
5
+ * THE PROBLEM. The background duties (the warmup sweep, the verify-next run,
6
+ * session-start checks) are all "one of these should happen periodically", and
7
+ * every process that starts is willing to do it. So two CLI runs, or a CLI run
8
+ * plus the dashboard, or a long-lived gateway plus a manual `models warm`, each
9
+ * spend the SAME free-tier budget on the SAME candidates — and a spot-check is a
10
+ * real generation, so the waste is measured in a provider's rate limit, not in
11
+ * CPU. In-process guards (`isRunning`) cannot see the other process at all.
12
+ *
13
+ * WHY A LEASE AND NOT A DAEMON. A daemon is the obvious shape and the wrong
14
+ * first step: it needs a supervisor, a way to be started, restarted, discovered
15
+ * and stopped, and it becomes a thing that can be RUNNING AND WRONG. A lease is
16
+ * the smallest primitive that removes the actual waste: exactly one process
17
+ * wins the exclusive create, everyone else steps aside, and the loser has a
18
+ * perfectly good fallback (do nothing — the work is throttled anyway). It is
19
+ * also the piece a daemon would need FIRST, so this is not throwaway work.
20
+ *
21
+ * HOW EXCLUSION WORKS. `open(path, 'wx')` is the atomic primitive: exactly one
22
+ * caller anywhere can win it. That is the whole mechanism — no pid liveness
23
+ * check, no registry service, no lock daemon.
24
+ *
25
+ * HOW CRASH RECOVERY WORKS, AND WHY IT IS THE mtime. A holder that is killed
26
+ * mid-duty leaves its lease file behind, and a lease that can never be taken is
27
+ * worse than no lease at all. So a lease whose mtime is older than `staleMs` is
28
+ * STOLEN: the stealer removes it and takes it, and reports `stole: true` so the
29
+ * event is visible rather than silent. `renewDutyLease` re-writes the file, which
30
+ * bumps that mtime, which is how a legitimately long duty (a warmup cycle can
31
+ * take minutes) keeps its lease while still being stealable if it dies.
32
+ *
33
+ * The pid is written for DIAGNOSTICS ONLY — "who held this" is the first
34
+ * question when a duty mysteriously stops running, and it is a question the
35
+ * user can answer from disk. It is deliberately NOT a correctness input: pid
36
+ * reuse makes "is this pid alive" an unsafe test (a fresh, unrelated process can
37
+ * hold the number), and the mtime is what actually decides.
38
+ *
39
+ * HONEST BOUNDS. Steal-after-stale means a crashed holder blocks its duty for at
40
+ * most `staleMs` — hence the default is minutes, not hours. Clock skew between
41
+ * two machines sharing a config dir shifts that window; it cannot make the
42
+ * exclusion unsafe, only early or late. Nothing here is a distributed lock:
43
+ * the config dir is one machine's, and the dashboard sidecar sharing it is the
44
+ * case this exists for.
45
+ */
46
+ /**
47
+ * How long a lease may go un-renewed before it counts as a DEAD holder's and may
48
+ * be stolen (ms).
49
+ *
50
+ * Minutes, not hours, on purpose: this is the worst-case delay before a duty
51
+ * resumes after a crash, and the alternative — a lease that outlives its holder
52
+ * by hours — silently switches the duty off for the rest of the afternoon.
53
+ */
54
+ export declare const DEFAULT_DUTY_LEASE_STALE_MS: number;
55
+ /**
56
+ * Env override for {@link DEFAULT_DUTY_LEASE_STALE_MS}
57
+ * (`NUVIRA_DUTY_LEASE_STALE_MS`) — the same rule the warmup bounds follow: a
58
+ * test or an operator tunes the window without a source edit, and nonsense is
59
+ * ignored rather than arming a zero-second lease that would be stolen instantly.
60
+ */
61
+ export declare function dutyLeaseStaleMs(env?: NodeJS.ProcessEnv): number;
62
+ /** What a lease file holds. Everything here is either an input (mtime) or diagnostics. */
63
+ export interface DutyLeaseInfo {
64
+ /** The duty name, e.g. `model-warmup`. */
65
+ name: string;
66
+ /** The holder's process id — DIAGNOSTICS ONLY (see the module header). */
67
+ pid: number;
68
+ /** The holder's host, so a shared config dir can say "another machine". */
69
+ host: string;
70
+ /** When the holder first took the lease (ms). */
71
+ startedAt: number;
72
+ /** When the holder last renewed it (ms) — mirrors the file's mtime, for reading. */
73
+ renewedAt: number;
74
+ /** Free-text: what the holder is doing, for the diagnostic readout. */
75
+ purpose?: string;
76
+ }
77
+ export interface DutyLeaseAcquireResult {
78
+ /** True when THIS process holds the lease on return. */
79
+ owned: boolean;
80
+ /** True when a stale lease had to be broken to get it — never silent. */
81
+ stole: boolean;
82
+ /** The lease that was in the way, when it was fresh and therefore respected. */
83
+ holder?: DutyLeaseInfo;
84
+ }
85
+ export interface DutyLeaseOptions {
86
+ /** Override the staleness window (ms). Defaults to {@link dutyLeaseStaleMs}. */
87
+ staleMs?: number;
88
+ /** Recorded in the lease, for the diagnostic readout. */
89
+ purpose?: string;
90
+ /** Clock seam (tests). */
91
+ now?: number;
92
+ }
93
+ /**
94
+ * Where leases live: `<memory dir>/duties/`.
95
+ *
96
+ * THE MEMORY DIR, NOT THE CONFIG DIR, and not by accident:
97
+ *
98
+ * 1. **The lease travels with the state it protects.** The duties this guards
99
+ * (warmup, verify-next, session checks) mutate the model registry and the
100
+ * verified pool, and those live in the memory dir. An isolated profile — a
101
+ * test, a CI run, a sandbox — therefore gets its OWN leases instead of
102
+ * contending with the developer's real CLI over one file in `~/.nuvira`.
103
+ * 2. **It cannot leak into a real profile from a test.**
104
+ * `tests/setup/hermetic-env.ts` gives every test file a throwaway
105
+ * `NUVIRA_MEMORY_DIR` and deliberately does NOT isolate the config dir (a
106
+ * test that needs real credentials must keep working). A lease under the
107
+ * memory dir is isolated by construction; one under the config dir would
108
+ * have let `chat.ts`'s warmup start write `~/.nuvira/duties/*.lease` on
109
+ * every chat test, which is exactly the leak class that harness exists to
110
+ * stop — and worse, a real CLI holding that lease would have made the
111
+ * test's daemon silently skip.
112
+ *
113
+ * `NUVIRA_DUTY_DIR` overrides it for a caller that wants its own namespace.
114
+ */
115
+ export declare function dutyLeaseDir(): string;
116
+ /** `<duty dir>/<name>.lease`. */
117
+ export declare function dutyLeasePath(name: string): string;
118
+ /** Read the lease without taking it (diagnostics/dashboard). Never throws. */
119
+ export declare function readDutyLease(name: string): DutyLeaseInfo | undefined;
120
+ /**
121
+ * Try to take a duty lease. Returns `owned: false` (with the holder, when it
122
+ * could be read) rather than throwing or waiting: every caller's correct
123
+ * response to a busy lease is "someone else has this", and waiting would make a
124
+ * background duty block a foreground command.
125
+ *
126
+ * Re-acquiring a lease THIS process already holds is idempotent and does NOT
127
+ * steal — a duty that runs twice in one process must not look like a crash
128
+ * recovery in the logs.
129
+ */
130
+ export declare function acquireDutyLease(name: string, opts?: DutyLeaseOptions): DutyLeaseAcquireResult;
131
+ /**
132
+ * Push a held lease's staleness window out. In-place write (NOT an atomic
133
+ * rename), so the file never blinks out of existence for a competing stealer,
134
+ * and the mtime bump is what a stealer actually reads.
135
+ *
136
+ * Returns false when this process does not hold the lease — a renewed lease it
137
+ * does not own would be a lie, and a long duty that quietly continued after
138
+ * being stolen would defeat the exclusion.
139
+ */
140
+ export declare function renewDutyLease(name: string, opts?: {
141
+ now?: number;
142
+ }): boolean;
143
+ /**
144
+ * Give the lease up. Best-effort and never throws — a lease left behind expires
145
+ * by `staleMs`, which is exactly why that window exists.
146
+ *
147
+ * Refuses to release a lease this process does NOT hold: otherwise a process
148
+ * whose lease was stolen (because it hung) could finish and delete the new
149
+ * holder's lease, and two processes would then run the duty at once.
150
+ */
151
+ export declare function releaseDutyLease(name: string): void;
152
+ /**
153
+ * Run `fn` while holding `name`, or return `undefined` and run nothing when
154
+ * someone else holds it.
155
+ *
156
+ * The lease is released in a `finally`, so a throwing duty does not block the
157
+ * next one; a duty that CRASHES the process leaves the file behind, which is the
158
+ * case `staleMs` covers.
159
+ */
160
+ export declare function withDutyLease<T>(name: string, fn: () => Promise<T>, opts?: DutyLeaseOptions & {
161
+ onWait?: (holder: DutyLeaseInfo | undefined) => void;
162
+ }): Promise<T | undefined>;
163
+ //# sourceMappingURL=duty-lease.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duty-lease.d.ts","sourceRoot":"","sources":["../../src/learning/duty-lease.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAOH;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,QAAgB,CAAC;AAEzD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAK7E;AAED,0FAA0F;AAC1F,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,wDAAwD;IACxD,KAAK,EAAE,OAAO,CAAC;IACf,yEAAyE;IACzE,KAAK,EAAE,OAAO,CAAC;IACf,gFAAgF;IAChF,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0BAA0B;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,iCAAiC;AACjC,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAiCD,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CASrE;AAWD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,sBAAsB,CA8BlG;AAgCD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAUjF;AAYD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQnD;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,GAAE,gBAAgB,GAAG;IAAE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,KAAK,IAAI,CAAA;CAAO,GACrF,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAexB"}
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Duty leases — one holder per named background duty, across processes, with no
3
+ * daemon and no supervisor.
4
+ *
5
+ * THE PROBLEM. The background duties (the warmup sweep, the verify-next run,
6
+ * session-start checks) are all "one of these should happen periodically", and
7
+ * every process that starts is willing to do it. So two CLI runs, or a CLI run
8
+ * plus the dashboard, or a long-lived gateway plus a manual `models warm`, each
9
+ * spend the SAME free-tier budget on the SAME candidates — and a spot-check is a
10
+ * real generation, so the waste is measured in a provider's rate limit, not in
11
+ * CPU. In-process guards (`isRunning`) cannot see the other process at all.
12
+ *
13
+ * WHY A LEASE AND NOT A DAEMON. A daemon is the obvious shape and the wrong
14
+ * first step: it needs a supervisor, a way to be started, restarted, discovered
15
+ * and stopped, and it becomes a thing that can be RUNNING AND WRONG. A lease is
16
+ * the smallest primitive that removes the actual waste: exactly one process
17
+ * wins the exclusive create, everyone else steps aside, and the loser has a
18
+ * perfectly good fallback (do nothing — the work is throttled anyway). It is
19
+ * also the piece a daemon would need FIRST, so this is not throwaway work.
20
+ *
21
+ * HOW EXCLUSION WORKS. `open(path, 'wx')` is the atomic primitive: exactly one
22
+ * caller anywhere can win it. That is the whole mechanism — no pid liveness
23
+ * check, no registry service, no lock daemon.
24
+ *
25
+ * HOW CRASH RECOVERY WORKS, AND WHY IT IS THE mtime. A holder that is killed
26
+ * mid-duty leaves its lease file behind, and a lease that can never be taken is
27
+ * worse than no lease at all. So a lease whose mtime is older than `staleMs` is
28
+ * STOLEN: the stealer removes it and takes it, and reports `stole: true` so the
29
+ * event is visible rather than silent. `renewDutyLease` re-writes the file, which
30
+ * bumps that mtime, which is how a legitimately long duty (a warmup cycle can
31
+ * take minutes) keeps its lease while still being stealable if it dies.
32
+ *
33
+ * The pid is written for DIAGNOSTICS ONLY — "who held this" is the first
34
+ * question when a duty mysteriously stops running, and it is a question the
35
+ * user can answer from disk. It is deliberately NOT a correctness input: pid
36
+ * reuse makes "is this pid alive" an unsafe test (a fresh, unrelated process can
37
+ * hold the number), and the mtime is what actually decides.
38
+ *
39
+ * HONEST BOUNDS. Steal-after-stale means a crashed holder blocks its duty for at
40
+ * most `staleMs` — hence the default is minutes, not hours. Clock skew between
41
+ * two machines sharing a config dir shifts that window; it cannot make the
42
+ * exclusion unsafe, only early or late. Nothing here is a distributed lock:
43
+ * the config dir is one machine's, and the dashboard sidecar sharing it is the
44
+ * case this exists for.
45
+ */
46
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from 'node:fs';
47
+ import { hostname } from 'node:os';
48
+ import { dirname, join } from 'node:path';
49
+ import { envBuff, resolveNuviraHome } from '../config/paths.js';
50
+ /**
51
+ * How long a lease may go un-renewed before it counts as a DEAD holder's and may
52
+ * be stolen (ms).
53
+ *
54
+ * Minutes, not hours, on purpose: this is the worst-case delay before a duty
55
+ * resumes after a crash, and the alternative — a lease that outlives its holder
56
+ * by hours — silently switches the duty off for the rest of the afternoon.
57
+ */
58
+ export const DEFAULT_DUTY_LEASE_STALE_MS = 5 * 60 * 1000;
59
+ /**
60
+ * Env override for {@link DEFAULT_DUTY_LEASE_STALE_MS}
61
+ * (`NUVIRA_DUTY_LEASE_STALE_MS`) — the same rule the warmup bounds follow: a
62
+ * test or an operator tunes the window without a source edit, and nonsense is
63
+ * ignored rather than arming a zero-second lease that would be stolen instantly.
64
+ */
65
+ export function dutyLeaseStaleMs(env = process.env) {
66
+ const raw = env.NUVIRA_DUTY_LEASE_STALE_MS;
67
+ if (raw === undefined || raw.trim() === '')
68
+ return DEFAULT_DUTY_LEASE_STALE_MS;
69
+ const n = Number(raw);
70
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_DUTY_LEASE_STALE_MS;
71
+ }
72
+ /**
73
+ * Where leases live: `<memory dir>/duties/`.
74
+ *
75
+ * THE MEMORY DIR, NOT THE CONFIG DIR, and not by accident:
76
+ *
77
+ * 1. **The lease travels with the state it protects.** The duties this guards
78
+ * (warmup, verify-next, session checks) mutate the model registry and the
79
+ * verified pool, and those live in the memory dir. An isolated profile — a
80
+ * test, a CI run, a sandbox — therefore gets its OWN leases instead of
81
+ * contending with the developer's real CLI over one file in `~/.nuvira`.
82
+ * 2. **It cannot leak into a real profile from a test.**
83
+ * `tests/setup/hermetic-env.ts` gives every test file a throwaway
84
+ * `NUVIRA_MEMORY_DIR` and deliberately does NOT isolate the config dir (a
85
+ * test that needs real credentials must keep working). A lease under the
86
+ * memory dir is isolated by construction; one under the config dir would
87
+ * have let `chat.ts`'s warmup start write `~/.nuvira/duties/*.lease` on
88
+ * every chat test, which is exactly the leak class that harness exists to
89
+ * stop — and worse, a real CLI holding that lease would have made the
90
+ * test's daemon silently skip.
91
+ *
92
+ * `NUVIRA_DUTY_DIR` overrides it for a caller that wants its own namespace.
93
+ */
94
+ export function dutyLeaseDir() {
95
+ return envBuff('DUTY_DIR') || join(envBuff('MEMORY_DIR') || join(resolveNuviraHome(), 'memory'), 'duties');
96
+ }
97
+ /** `<duty dir>/<name>.lease`. */
98
+ export function dutyLeasePath(name) {
99
+ return join(dutyLeaseDir(), `${safeName(name)}.lease`);
100
+ }
101
+ /**
102
+ * Keep a duty name path-safe. The names are internal constants, but a lease
103
+ * whose path was derived from an arbitrary string could be pointed anywhere.
104
+ */
105
+ function safeName(name) {
106
+ return name.replace(/[^a-zA-Z0-9._-]/g, '_');
107
+ }
108
+ function leaseDir(name) {
109
+ return dirname(dutyLeasePath(name));
110
+ }
111
+ /**
112
+ * This machine's label, defensively.
113
+ *
114
+ * `hostname()` is diagnostics, so it must never be able to fail an acquisition —
115
+ * and it CAN be absent: a test that mocks `node:os` for `homedir()` (the duties
116
+ * suite does exactly that) replaces the whole module, and a bare `hostname()`
117
+ * then throws inside the take and reads as "someone else holds it". The lease
118
+ * would go silently unused, which is the failure mode this module exists to
119
+ * prevent. A missing host therefore degrades to a shared label, and same-process
120
+ * detection still works off the pid.
121
+ */
122
+ function hostLabel() {
123
+ try {
124
+ return hostname() || 'unknown';
125
+ }
126
+ catch {
127
+ return 'unknown';
128
+ }
129
+ }
130
+ /** Read the lease without taking it (diagnostics/dashboard). Never throws. */
131
+ export function readDutyLease(name) {
132
+ try {
133
+ const parsed = JSON.parse(readFileSync(dutyLeasePath(name), 'utf-8'));
134
+ if (!parsed || typeof parsed !== 'object')
135
+ return undefined;
136
+ return parsed;
137
+ }
138
+ catch {
139
+ // Absent or corrupt: from a reader's point of view both mean "no lease".
140
+ return undefined;
141
+ }
142
+ }
143
+ /** Age of a lease file (ms), or `undefined` when there is no readable file. */
144
+ function leaseAgeMs(name, now) {
145
+ try {
146
+ return Math.max(0, now - statSync(dutyLeasePath(name)).mtimeMs);
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
151
+ }
152
+ /**
153
+ * Try to take a duty lease. Returns `owned: false` (with the holder, when it
154
+ * could be read) rather than throwing or waiting: every caller's correct
155
+ * response to a busy lease is "someone else has this", and waiting would make a
156
+ * background duty block a foreground command.
157
+ *
158
+ * Re-acquiring a lease THIS process already holds is idempotent and does NOT
159
+ * steal — a duty that runs twice in one process must not look like a crash
160
+ * recovery in the logs.
161
+ */
162
+ export function acquireDutyLease(name, opts = {}) {
163
+ const now = opts.now ?? Date.now();
164
+ const staleMs = opts.staleMs ?? dutyLeaseStaleMs();
165
+ // 1. The atomic take: exactly one caller can win an exclusive create.
166
+ if (tryCreate(name, opts.purpose, now))
167
+ return { owned: true, stole: false };
168
+ // 2. Someone holds it. Is it the SAME process? Then this is not a contention,
169
+ // it is us asking twice.
170
+ const existing = readDutyLease(name);
171
+ if (existing && existing.host === hostLabel() && existing.pid === process.pid) {
172
+ return { owned: true, stole: false };
173
+ }
174
+ // 3. Is it too old to belong to a live call? Then it is a dead holder's, and
175
+ // a lease nobody can take would switch the duty off forever.
176
+ const age = leaseAgeMs(name, now);
177
+ if (age !== undefined && age > staleMs) {
178
+ try {
179
+ rmSync(dutyLeasePath(name), { force: true });
180
+ }
181
+ catch {
182
+ // Lost the race to break it — fall through to the retry and let the
183
+ // exclusive create decide the winner.
184
+ }
185
+ if (tryCreate(name, opts.purpose, now))
186
+ return { owned: true, stole: true };
187
+ // Another process re-took it between the break and our retry: respect it.
188
+ return { owned: false, stole: false, holder: readDutyLease(name) };
189
+ }
190
+ return { owned: false, stole: false, holder: existing };
191
+ }
192
+ /** The exclusive-create step, in one place so both callers share it. */
193
+ function tryCreate(name, purpose, now) {
194
+ try {
195
+ mkdirSync(leaseDir(name), { recursive: true });
196
+ }
197
+ catch {
198
+ // A failing mkdir surfaces as a failing create below, which is the honest
199
+ // "we do not own the lease" answer.
200
+ }
201
+ try {
202
+ const fd = openSync(dutyLeasePath(name), 'wx');
203
+ try {
204
+ const info = {
205
+ name,
206
+ pid: process.pid,
207
+ host: hostLabel(),
208
+ startedAt: now,
209
+ renewedAt: now,
210
+ ...(purpose ? { purpose } : {}),
211
+ };
212
+ writeSync(fd, `${JSON.stringify(info, null, 2)}\n`);
213
+ }
214
+ finally {
215
+ closeSync(fd);
216
+ }
217
+ return true;
218
+ }
219
+ catch {
220
+ // EEXIST (someone else won it) or an unusable directory — either way, not ours.
221
+ return false;
222
+ }
223
+ }
224
+ /**
225
+ * Push a held lease's staleness window out. In-place write (NOT an atomic
226
+ * rename), so the file never blinks out of existence for a competing stealer,
227
+ * and the mtime bump is what a stealer actually reads.
228
+ *
229
+ * Returns false when this process does not hold the lease — a renewed lease it
230
+ * does not own would be a lie, and a long duty that quietly continued after
231
+ * being stolen would defeat the exclusion.
232
+ */
233
+ export function renewDutyLease(name, opts = {}) {
234
+ const existing = readDutyLease(name);
235
+ if (!existing || existing.host !== hostLabel() || existing.pid !== process.pid)
236
+ return false;
237
+ try {
238
+ const renewed = { ...existing, renewedAt: opts.now ?? Date.now() };
239
+ writeFileInPlace(dutyLeasePath(name), `${JSON.stringify(renewed, null, 2)}\n`);
240
+ return true;
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ }
246
+ /** Truncate-and-write in place, so the path is never absent mid-renewal. */
247
+ function writeFileInPlace(path, content) {
248
+ const fd = openSync(path, 'w');
249
+ try {
250
+ writeSync(fd, content);
251
+ }
252
+ finally {
253
+ closeSync(fd);
254
+ }
255
+ }
256
+ /**
257
+ * Give the lease up. Best-effort and never throws — a lease left behind expires
258
+ * by `staleMs`, which is exactly why that window exists.
259
+ *
260
+ * Refuses to release a lease this process does NOT hold: otherwise a process
261
+ * whose lease was stolen (because it hung) could finish and delete the new
262
+ * holder's lease, and two processes would then run the duty at once.
263
+ */
264
+ export function releaseDutyLease(name) {
265
+ const existing = readDutyLease(name);
266
+ if (existing && !(existing.host === hostLabel() && existing.pid === process.pid))
267
+ return;
268
+ try {
269
+ rmSync(dutyLeasePath(name), { force: true });
270
+ }
271
+ catch {
272
+ /* a lock left behind expires by `staleMs` */
273
+ }
274
+ }
275
+ /**
276
+ * Run `fn` while holding `name`, or return `undefined` and run nothing when
277
+ * someone else holds it.
278
+ *
279
+ * The lease is released in a `finally`, so a throwing duty does not block the
280
+ * next one; a duty that CRASHES the process leaves the file behind, which is the
281
+ * case `staleMs` covers.
282
+ */
283
+ export async function withDutyLease(name, fn, opts = {}) {
284
+ const taken = acquireDutyLease(name, opts);
285
+ if (!taken.owned) {
286
+ try {
287
+ opts.onWait?.(taken.holder);
288
+ }
289
+ catch {
290
+ // Diagnostics must never break the caller.
291
+ }
292
+ return undefined;
293
+ }
294
+ try {
295
+ return await fn();
296
+ }
297
+ finally {
298
+ releaseDutyLease(name);
299
+ }
300
+ }
301
+ //# sourceMappingURL=duty-lease.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duty-lease.js","sourceRoot":"","sources":["../../src/learning/duty-lease.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEhE;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACnE,MAAM,GAAG,GAAG,GAAG,CAAC,0BAA0B,CAAC;IAC3C,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,2BAA2B,CAAC;IAC/E,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACtB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,2BAA2B,CAAC;AACnF,CAAC;AAoCD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,OAAO,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,IAAI,IAAI,CAAC,iBAAiB,EAAE,EAAE,QAAQ,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC7G,CAAC;AAED,iCAAiC;AACjC,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,IAAI,CAAC,YAAY,EAAE,EAAE,GAAG,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,IAAI,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;AAC/C,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;AACtC,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,SAAS;IAChB,IAAI,CAAC;QACH,OAAO,QAAQ,EAAE,IAAI,SAAS,CAAC;IACjC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAkB,CAAC;QACvF,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAC5D,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;QACzE,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,SAAS,UAAU,CAAC,IAAY,EAAE,GAAW;IAC3C,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IAClE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,OAAyB,EAAE;IACxE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;IACnC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,gBAAgB,EAAE,CAAC;IAEnD,sEAAsE;IACtE,IAAI,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAE7E,8EAA8E;IAC9E,4BAA4B;IAC5B,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,QAAQ,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,IAAI,QAAQ,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC;QAC9E,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvC,CAAC;IAED,6EAA6E;IAC7E,gEAAgE;IAChE,MAAM,GAAG,GAAG,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAClC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,GAAG,OAAO,EAAE,CAAC;QACvC,IAAI,CAAC;YACH,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;YACpE,sCAAsC;QACxC,CAAC;QACD,IAAI,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;QAC5E,0EAA0E;QAC1E,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC;IACrE,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;AAC1D,CAAC;AAED,wEAAwE;AACxE,SAAS,SAAS,CAAC,IAAY,EAAE,OAA2B,EAAE,GAAW;IACvE,IAAI,CAAC;QACH,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACjD,CAAC;IAAC,MAAM,CAAC;QACP,0EAA0E;QAC1E,oCAAoC;IACtC,CAAC;IACD,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;QAC/C,IAAI,CAAC;YACH,MAAM,IAAI,GAAkB;gBAC1B,IAAI;gBACJ,GAAG,EAAE,OAAO,CAAC,GAAG;gBAChB,IAAI,EAAE,SAAS,EAAE;gBACjB,SAAS,EAAE,GAAG;gBACd,SAAS,EAAE,GAAG;gBACd,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChC,CAAC;YACF,SAAS,CAAC,EAAE,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;QACtD,CAAC;gBAAS,CAAC;YACT,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,gFAAgF;QAChF,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,OAAyB,EAAE;IACtE,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,IAAI,QAAQ,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG;QAAE,OAAO,KAAK,CAAC;IAC7F,IAAI,CAAC;QACH,MAAM,OAAO,GAAkB,EAAE,GAAG,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;QAClF,gBAAgB,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;QAC/E,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,4EAA4E;AAC5E,SAAS,gBAAgB,CAAC,IAAY,EAAE,OAAe;IACrD,MAAM,EAAE,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC/B,IAAI,CAAC;QACH,SAAS,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;IACzB,CAAC;YAAS,CAAC;QACT,SAAS,CAAC,EAAE,CAAC,CAAC;IAChB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,QAAQ,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,IAAI,QAAQ,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC;QAAE,OAAO;IACzF,IAAI,CAAC;QACH,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,6CAA6C;IAC/C,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAY,EACZ,EAAoB,EACpB,OAAoF,EAAE;IAEtF,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACjB,IAAI,CAAC;YACH,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC9B,CAAC;QAAC,MAAM,CAAC;YACP,2CAA2C;QAC7C,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,EAAE,CAAC;IACpB,CAAC;YAAS,CAAC;QACT,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;AACH,CAAC"}
@@ -125,6 +125,20 @@ export declare function buildFailoverChain(bestCandidate: ModelCandidate, allCan
125
125
  export type CapabilityTier = 'high' | 'medium' | 'low' | 'local';
126
126
  /**
127
127
  * Classify a model into a capability tier.
128
+ *
129
+ * TWO AXES, and they are different questions:
130
+ * 1. WHERE the model runs — a keyless local runner (`local`, `lmstudio`,
131
+ * `vllm`) is the LAST resort whatever its size, so it keeps its own tier.
132
+ * 2. HOW MUCH it can do — `model-capability` bands, name-derived and
133
+ * deterministic, the SAME bands the pinned-handoff repair, the local
134
+ * last-resort pick and the router's capability floors already use.
135
+ *
136
+ * The band used to come from this module's own `capabilityFit` dimension, a
137
+ * task-relative score: a fast small model fits a trivial task well, so it read
138
+ * `high` HERE and `low` to everything else — two taxonomies for one fact, and
139
+ * the reason a 0.5B and a 120B could land in the same tier. `capabilityFit`
140
+ * still decides the candidate ORDER (it is the task fit); it no longer decides
141
+ * the BAND.
128
142
  */
129
143
  export declare function classifyTier(candidate: ModelCandidate): CapabilityTier;
130
144
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"model-first-router.d.ts","sourceRoot":"","sources":["../../src/learning/model-first-router.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"model-first-router.d.ts","sourceRoot":"","sources":["../../src/learning/model-first-router.ts"],"names":[],"mappings":"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"}
@@ -21,6 +21,13 @@ import { getCatalogProvider, CATALOG_PROVIDER_IDS } from '../inference/provider-
21
21
  import { isSmallTalk } from './hybrid-router.js';
22
22
  import { hasCredentials, isPairPlausible } from './model-selection.js';
23
23
  export { isPairPlausible };
24
+ // ONE capability taxonomy. The tier below used to be computed from this
25
+ // module's OWN `capabilityFit` score while every other decision (pinned
26
+ // handoffs, the local last resort, the router's band checks) used
27
+ // `model-capability` bands — two taxonomies, so the same pair could be
28
+ // `medium` on one path and `low` on another, which is how a 0.5B and a 120B
29
+ // ended up as "same tier".
30
+ import { capabilityClassOf, estimateModelCapability } from './model-capability.js';
24
31
  import { isNonChatModel } from '../inference/model-catalog.js';
25
32
  // ─── Task Analysis ──────────────────────────────────────────────────────────
26
33
  /**
@@ -434,16 +441,41 @@ export function buildFailoverChain(bestCandidate, allCandidates) {
434
441
  }
435
442
  /**
436
443
  * Classify a model into a capability tier.
444
+ *
445
+ * TWO AXES, and they are different questions:
446
+ * 1. WHERE the model runs — a keyless local runner (`local`, `lmstudio`,
447
+ * `vllm`) is the LAST resort whatever its size, so it keeps its own tier.
448
+ * 2. HOW MUCH it can do — `model-capability` bands, name-derived and
449
+ * deterministic, the SAME bands the pinned-handoff repair, the local
450
+ * last-resort pick and the router's capability floors already use.
451
+ *
452
+ * The band used to come from this module's own `capabilityFit` dimension, a
453
+ * task-relative score: a fast small model fits a trivial task well, so it read
454
+ * `high` HERE and `low` to everything else — two taxonomies for one fact, and
455
+ * the reason a 0.5B and a 120B could land in the same tier. `capabilityFit`
456
+ * still decides the candidate ORDER (it is the task fit); it no longer decides
457
+ * the BAND.
437
458
  */
438
459
  export function classifyTier(candidate) {
439
460
  if (candidate.provider === 'local' || candidate.provider === 'lmstudio' || candidate.provider === 'vllm') {
440
461
  return 'local';
441
462
  }
442
- if (candidate.dimensions.capabilityFit >= 0.8)
443
- return 'high';
444
- if (candidate.dimensions.capabilityFit >= 0.5)
445
- return 'medium';
446
- return 'low';
463
+ return capabilityClassOf(candidate.model);
464
+ }
465
+ /**
466
+ * Order the candidates of ONE tier strongest-first, stable within equal
467
+ * capability (so the scorer's task fit still decides among equals).
468
+ *
469
+ * WHY, and why here: the tiers were ordered by the scorer, which is a HEALTH-
470
+ * AND-COST ranking, and a small model wins that ranking precisely because it is
471
+ * fast and cheap. Inside a tier that is the wrong question — the tier already
472
+ * said "these are equally suitable", so the strongest must go first. MEASURED
473
+ * (trace-1791879575576-x731yp): the LOCAL tier is where it bit, because a local
474
+ * tier holds every local model from a 0.5B to a 120B and nothing ranked them by
475
+ * size, so the agentic ask was answered by the weakest one on the machine.
476
+ */
477
+ function strongestFirstInTier(candidates) {
478
+ return [...candidates].sort((a, b) => estimateModelCapability(b.model) - estimateModelCapability(a.model));
447
479
  }
448
480
  /**
449
481
  * Check if a model candidate has available quota (not parked, not blocked).
@@ -500,11 +532,12 @@ export function buildTieredFailoverChain(bestCandidate, allCandidates) {
500
532
  });
501
533
  sameModelDifferentProvider.forEach(c => tried.add(`${c.provider}:${c.model}`));
502
534
  }
503
- // Phase 2: Same capability tier, different models (pre-check quota)
504
- const sameTier = allCandidates.filter(c => classifyTier(c) === bestTier
535
+ // Phase 2: Same capability tier, different models (pre-check quota).
536
+ // STRONGEST-FIRST within the tier — see `strongestFirstInTier`.
537
+ const sameTier = strongestFirstInTier(allCandidates.filter(c => classifyTier(c) === bestTier
505
538
  && c.model !== bestCandidate.model
506
539
  && !tried.has(`${c.provider}:${c.model}`)
507
- && isCandidateAvailable(c));
540
+ && isCandidateAvailable(c)));
508
541
  if (sameTier.length > 0) {
509
542
  tiers.push({
510
543
  phase: 'same-tier',
@@ -518,9 +551,9 @@ export function buildTieredFailoverChain(bestCandidate, allCandidates) {
518
551
  : bestTier === 'medium' ? ['high']
519
552
  : [];
520
553
  for (const tier of higherTiers) {
521
- const escalation = allCandidates.filter(c => classifyTier(c) === tier
554
+ const escalation = strongestFirstInTier(allCandidates.filter(c => classifyTier(c) === tier
522
555
  && !tried.has(`${c.provider}:${c.model}`)
523
- && isCandidateAvailable(c));
556
+ && isCandidateAvailable(c)));
524
557
  if (escalation.length > 0) {
525
558
  tiers.push({
526
559
  phase: 'escalate',
@@ -535,9 +568,9 @@ export function buildTieredFailoverChain(bestCandidate, allCandidates) {
535
568
  : bestTier === 'medium' ? ['low']
536
569
  : [];
537
570
  for (const tier of lowerTiers) {
538
- const deescalation = allCandidates.filter(c => classifyTier(c) === tier
571
+ const deescalation = strongestFirstInTier(allCandidates.filter(c => classifyTier(c) === tier
539
572
  && !tried.has(`${c.provider}:${c.model}`)
540
- && isCandidateAvailable(c));
573
+ && isCandidateAvailable(c)));
541
574
  if (deescalation.length > 0) {
542
575
  tiers.push({
543
576
  phase: 'de-escalate',
@@ -547,9 +580,13 @@ export function buildTieredFailoverChain(bestCandidate, allCandidates) {
547
580
  deescalation.forEach(c => tried.add(`${c.provider}:${c.model}`));
548
581
  }
549
582
  }
550
- // Phase 5: Local models (always available, last resort before neural)
551
- const localModels = allCandidates.filter(c => classifyTier(c) === 'local'
552
- && !tried.has(`${c.provider}:${c.model}`));
583
+ // Phase 5: Local models (always available, last resort before neural).
584
+ // STRONGEST-FIRST is the whole point of this phase: the local tier is a
585
+ // single tier by necessity (keyless, no quota, no account), so without this
586
+ // the weakest model on the machine — the one that wins a health ranking for
587
+ // being fast — is what an agentic ask lands on.
588
+ const localModels = strongestFirstInTier(allCandidates.filter(c => classifyTier(c) === 'local'
589
+ && !tried.has(`${c.provider}:${c.model}`)));
553
590
  if (localModels.length > 0) {
554
591
  tiers.push({
555
592
  phase: 'local',
@@ -559,8 +596,8 @@ export function buildTieredFailoverChain(bestCandidate, allCandidates) {
559
596
  localModels.forEach(c => tried.add(`${c.provider}:${c.model}`));
560
597
  }
561
598
  // Phase 6: Any remaining available models (last resort)
562
- const remaining = allCandidates.filter(c => !tried.has(`${c.provider}:${c.model}`)
563
- && isCandidateAvailable(c));
599
+ const remaining = strongestFirstInTier(allCandidates.filter(c => !tried.has(`${c.provider}:${c.model}`)
600
+ && isCandidateAvailable(c)));
564
601
  if (remaining.length > 0) {
565
602
  tiers.push({
566
603
  phase: 'remaining',