glm-coding-router 1.1.2 → 2.1.0

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 (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +542 -426
  3. package/dist/bin/glm-review.js +28 -3
  4. package/dist/bin/glm-worker.js +30 -4
  5. package/dist/budget/estimator.js +218 -0
  6. package/dist/budget/manager.js +223 -0
  7. package/dist/cli.js +46 -3
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/doctor-auth.js +107 -0
  10. package/dist/commands/doctor-command.js +171 -41
  11. package/dist/commands/landing.js +47 -0
  12. package/dist/commands/runs.js +568 -0
  13. package/dist/commands/status.js +28 -15
  14. package/dist/commands/usage.js +34 -58
  15. package/dist/commands/watch.js +289 -0
  16. package/dist/core/config.js +61 -0
  17. package/dist/core/errors.js +24 -0
  18. package/dist/core/key-inspector.js +45 -0
  19. package/dist/core/paths.js +32 -0
  20. package/dist/core/process.js +83 -0
  21. package/dist/core/prompt.js +18 -5
  22. package/dist/core/routing-flags.js +59 -0
  23. package/dist/core/user-env.js +17 -7
  24. package/dist/core/zai-quota.js +148 -0
  25. package/dist/events/bus.js +64 -0
  26. package/dist/events/claude-adapter.js +416 -0
  27. package/dist/events/types.js +9 -0
  28. package/dist/handoff/bundle.js +203 -0
  29. package/dist/handoff/parent-handoff.js +48 -0
  30. package/dist/mcp/server.js +45 -1
  31. package/dist/routing/glm-routing.js +131 -0
  32. package/dist/runs/checkpoint.js +204 -0
  33. package/dist/runs/drain.js +165 -0
  34. package/dist/runs/heartbeat.js +45 -0
  35. package/dist/runs/registry.js +350 -0
  36. package/dist/runs/store.js +186 -0
  37. package/dist/runs/ulid.js +112 -0
  38. package/dist/runs/worker-run.js +672 -0
  39. package/dist/templates/agents-block.js +53 -44
  40. package/dist/templates/claude-block.js +56 -47
  41. package/dist/templates/glm-delegation-skill.js +76 -65
  42. package/dist/tui/command-ui.js +158 -0
  43. package/dist/tui/progress.js +338 -0
  44. package/dist/tui/render.js +144 -0
  45. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import os from "node:os";
2
+ import { Writable } from "node:stream";
2
3
  import { buildWorkerArgs } from "../bin/glm-worker.js";
3
4
  import { buildReviewArgs } from "../bin/glm-review.js";
4
5
  import { loadConfig } from "../core/config.js";
@@ -11,7 +12,9 @@ import { applyProfile } from "../core/profile.js";
11
12
  import { version } from "../core/version.js";
12
13
  import { createDelegateWorktree, delegateBranch, removeDelegateWorktree, rollbackDelegateBranch, validateDelegateName, } from "../core/worktree.js";
13
14
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
- import { aggregateLocalUsage, describeWindow, fetchZaiQuota } from "../commands/usage.js";
15
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
16
+ import { aggregateLocalUsage } from "../commands/usage.js";
17
+ import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
15
18
  const PROMPT_PROPERTY = { type: "string", description: "The task prompt for the GLM agent." };
16
19
  export const MCP_TOOLS = [
17
20
  {
@@ -80,6 +83,47 @@ async function runAgent(prompt, profile, kind, deps) {
80
83
  }
81
84
  const claudePath = locateClaude(config, env);
82
85
  const args = kind === "worker" ? buildWorkerArgs(prompt, config) : buildReviewArgs(prompt, config);
86
+ // An injected `spawn` pins the v1 capture path: that caller owns child
87
+ // execution and expects the captured stdout/stderr (C4). Production passes
88
+ // none and observes by default.
89
+ if (deps.spawn === undefined && shouldObserve(args, env)) {
90
+ // C2 (v2 spec Phase D): MCP's stdout is the JSON-RPC channel, so the run is
91
+ // instrumented with the renderer off and both streams captured — the final
92
+ // text comes back as the tool result string, and the run still lands in the
93
+ // registry/history. is-error stays derived from the exit code, exactly like
94
+ // the capture path below.
95
+ let stdoutText = "";
96
+ let stderrText = "";
97
+ const stderr = new Writable({
98
+ write: (chunk, _encoding, callback) => {
99
+ stderrText += String(chunk);
100
+ callback();
101
+ },
102
+ });
103
+ const result = await runInstrumented({
104
+ kind,
105
+ prompt,
106
+ args,
107
+ claudePath,
108
+ config,
109
+ secrets: [resolved.key],
110
+ cwd: deps.cwd ?? process.cwd(),
111
+ env: createGlmEnv(config, resolved.key, env),
112
+ home,
113
+ progress: "off",
114
+ stdout: {
115
+ write: (text) => {
116
+ stdoutText += text;
117
+ },
118
+ },
119
+ stderr,
120
+ });
121
+ const text = stdoutText.trim() || "(no output)";
122
+ if (result.code !== 0) {
123
+ return { text: `${text}\n[worker exited ${result.code}]${stderrText ? `\n${tail(stderrText.trim())}` : ""}`, isError: true };
124
+ }
125
+ return { text, isError: false };
126
+ }
83
127
  const spawn = deps.spawn ?? spawnAgentCapture;
84
128
  const captured = await spawn(claudePath, {
85
129
  args,
@@ -0,0 +1,131 @@
1
+ import { zoneFor } from "../budget/manager.js";
2
+ /**
3
+ * DEVIATION FROM THE SPEC, deliberate: the spec's signature says `estimate`
4
+ * (singular), but its own refusal rule needs both models' p90 — wouldRefuse
5
+ * is true only when the cost does not fit for main AND fast (doc §14: always
6
+ * try Flash before giving up). One estimate cannot express that, and deriving
7
+ * the fast estimate by scaling the main one would bake the estimator's
8
+ * baseline ratio (FAST_MODEL_RATIO, 0.4) into the router, so this takes both
9
+ * and the caller computes each with estimateCost.
10
+ *
11
+ * Pure: every input arrives as an argument — no files, no network, no clock,
12
+ * no logging, and the process is never exited from here. The rules, in order:
13
+ *
14
+ * 1. `quotaAware: false` or `confidence: "unknown"` fails OPEN — a monitoring
15
+ * outage must never block work: action "run", the requested model (or
16
+ * main), wouldRefuse false, zone as computed.
17
+ * 2. zone = zoneFor(snapshot, config.routing).
18
+ * 3. The BINDING window is whichever of fiveHour/weekly has the lower
19
+ * remainingRatio — the same window zoneFor's min() picks, so the budget
20
+ * arithmetic and the zone can never disagree. reserve = reserveRatio ×
21
+ * binding.limit; usableBudget = max(0, binding.remaining − reserve).
22
+ * 4. Zone preference (doc §12): HEALTHY → main; CONSERVE, HANDOFF_READY and
23
+ * CRITICAL → fast. CRITICAL is in the fast arm because with the shipped
24
+ * default it still runs, and a nearly-empty quota should run cheap.
25
+ * 5. `requestedModel` PINS the model, overriding the zone preference. It does
26
+ * NOT bypass an enforced refusal — only `force` does.
27
+ * 6. wouldRefuse when the zone is CRITICAL, or when NEITHER model's
28
+ * p90 × safetyFactor fits usableBudget.
29
+ * 7. wouldRefuse becomes `return_to_parent` only when `refuseOnCritical` is
30
+ * true and `force` is absent.
31
+ * 8. "downgrade" when the fast model was chosen without a pin (the route
32
+ * changed underneath the caller); otherwise "run".
33
+ *
34
+ * `refuseOnCritical` ships FALSE in 2.0.0 (decision D3), and this function is
35
+ * built to run with it off: the estimator's baseline table has never been
36
+ * measured on this stack, so a wrongly-high row would refuse runs the quota
37
+ * could have afforded, and the user would only find --force after being
38
+ * blocked. With the switch off, wouldRefuse is simply reported and the caller
39
+ * logs a BudgetWarning and runs anyway. Do not flip the default here; flip it
40
+ * in config once the routingAdvice evidence exists.
41
+ *
42
+ * The affordability fallback is one-way (main → fast), mirroring doc §14's
43
+ * "always try Flash before giving up": when the unpinned choice is main and
44
+ * only fast fits, the route downgrades to fast. There is no fast → main
45
+ * upgrade — when a zone below HEALTHY asks for the fast model, the zone (not
46
+ * the estimate) is what protects the remaining quota.
47
+ */
48
+ export function decideRoute(input) {
49
+ const { snapshot, estimates, config, requestedModel, force } = input;
50
+ const routing = config.routing;
51
+ const zone = zoneFor(snapshot, routing);
52
+ const usableBudget = usableBudgetOf(snapshot, routing.reserveRatio);
53
+ const costOf = (estimate) => estimate.p90 * routing.safetyFactor;
54
+ const fits = (estimate) => costOf(estimate) <= usableBudget;
55
+ // Rule 1 — fail-open. usableBudget/estimatedCost are still reported (an
56
+ // unknown snapshot honestly yields 0: we know no budget), but they gate
57
+ // nothing here.
58
+ if (!routing.quotaAware || snapshot.confidence === "unknown") {
59
+ const slot = requestedModel ?? "main";
60
+ return {
61
+ action: "run",
62
+ model: config.models[slot],
63
+ zone,
64
+ reason: routing.quotaAware ? "quota confidence unknown, failing open" : "quota-aware routing is disabled",
65
+ usableBudget,
66
+ estimatedCost: costOf(estimates[slot]),
67
+ wouldRefuse: false,
68
+ };
69
+ }
70
+ const preferred = zone === "HEALTHY" ? "main" : "fast";
71
+ let slot = requestedModel ?? preferred;
72
+ let fellBackToFit = false;
73
+ if (requestedModel === undefined && slot === "main" && !fits(estimates.main) && fits(estimates.fast)) {
74
+ slot = "fast";
75
+ fellBackToFit = true;
76
+ }
77
+ const wouldRefuse = zone === "CRITICAL" || (!fits(estimates.main) && !fits(estimates.fast));
78
+ // Rule 7 — D3: with the shipped refuseOnCritical: false this stays false and
79
+ // wouldRefuse is only reported. force bypasses the enforced form only.
80
+ const enforced = wouldRefuse && routing.refuseOnCritical && force !== true;
81
+ return {
82
+ action: enforced
83
+ ? "return_to_parent"
84
+ : slot === "fast" && requestedModel === undefined
85
+ ? "downgrade"
86
+ : "run",
87
+ model: config.models[slot],
88
+ zone,
89
+ reason: enforced
90
+ ? zone === "CRITICAL"
91
+ ? "zone CRITICAL, refusal enforced"
92
+ : "neither model's estimated cost fits the usable budget"
93
+ : requestedModel !== undefined
94
+ ? `--model ${requestedModel} pinned`
95
+ : fellBackToFit
96
+ ? "estimated main cost exceeds usable budget, trying the fast model"
97
+ : `zone ${zone} prefers the ${preferred} model`,
98
+ usableBudget,
99
+ estimatedCost: costOf(estimates[slot]),
100
+ wouldRefuse,
101
+ };
102
+ }
103
+ /**
104
+ * The window the budget arithmetic must respect: the one with the lower
105
+ * remainingRatio, i.e. the same window zoneFor's min() already picked — so
106
+ * the credits and the zone are computed against one window, never two that
107
+ * could disagree. On an exact ratio tie min() is indifferent, so the smaller
108
+ * limit binds: it leaves less absolute headroom, which is the conservative
109
+ * reading. (confidence "unknown" never reaches here with real numbers —
110
+ * decideRoute fails open first — and its zero windows tie harmlessly.)
111
+ */
112
+ /**
113
+ * Credits this run may actually spend: the binding window's remaining, less
114
+ * the untouchable reserve, floored at 0.
115
+ *
116
+ * Exported because the Phase F drain controller re-asks the same question
117
+ * every poll, and two implementations of one piece of arithmetic would drift
118
+ * — preflight and mid-run draining have to agree on what "affordable" means
119
+ * or the router contradicts itself halfway through a run.
120
+ */
121
+ export function usableBudgetOf(snapshot, reserveRatio) {
122
+ const binding = bindingWindow(snapshot);
123
+ return Math.max(0, binding.remaining - reserveRatio * binding.limit);
124
+ }
125
+ function bindingWindow(snapshot) {
126
+ const { fiveHour, weekly } = snapshot;
127
+ if (fiveHour.remainingRatio !== weekly.remainingRatio) {
128
+ return fiveHour.remainingRatio < weekly.remainingRatio ? fiveHour : weekly;
129
+ }
130
+ return fiveHour.limit <= weekly.limit ? fiveHour : weekly;
131
+ }
@@ -0,0 +1,204 @@
1
+ import path from "node:path";
2
+ import { logger } from "../core/logging.js";
3
+ import { atomicWriteFile } from "../project/atomic-write.js";
4
+ /** Doc §16's shape, rebuilt from events only (doc §17). */
5
+ export const CHECKPOINT_FILE_NAME = "checkpoint.json";
6
+ /** Tools whose appearance in a turn marks that turn as implementation work. */
7
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
8
+ /** C3: the widest any human-readable checkpoint line may ever be. */
9
+ const LINE_MAX_CHARS = 120;
10
+ /**
11
+ * Rebuilds a checkpoint from an event stream — the pure half of Phase F. Never
12
+ * throws and never reads a prompt: every string it returns is either a
13
+ * redacted tool summary (the adapter already enforced C3) or the redacted,
14
+ * 120-char `taskTitle` the `RunStarted` event already persisted.
15
+ *
16
+ * `pending` is the C3-safe reading of a spec that contradicted itself: Phase F
17
+ * first says it is "derived from the prompt's checklist lines if present", then
18
+ * that the checkpoint is "rebuilt from events only". Both cannot hold, and the
19
+ * first would put prompt text into `checkpoint.json` under `<configDir>/runs/`,
20
+ * which the C3 sweep walks. Events win:
21
+ *
22
+ * - with a `RunStarted`: the run's `taskTitle`, plus one entry per validation
23
+ * still owed (`validationPending`);
24
+ * - without one: the single entry `"continue the task"`.
25
+ *
26
+ * Do not "restore" prompt parsing here — if a richer pending list is ever
27
+ * wanted, the event model is where it must arrive (e.g. a task-spec event), not
28
+ * the prompt body.
29
+ */
30
+ export function buildCheckpoint(events) {
31
+ let taskTitle;
32
+ let lastTurnStarted = 0;
33
+ let lastToolTurn = 0;
34
+ let terminalSeen = false;
35
+ const turns = new Map();
36
+ const filesChanged = [];
37
+ const seenFiles = new Set();
38
+ for (const event of events) {
39
+ switch (event.type) {
40
+ case "RunStarted":
41
+ // First wins: a multi-segment run (error_max_turns + continue) replays
42
+ // its opener, and the original title is the one the registry recorded.
43
+ if (taskTitle === undefined) {
44
+ taskTitle = event.taskTitle;
45
+ }
46
+ break;
47
+ case "TurnStarted":
48
+ lastTurnStarted = Math.max(lastTurnStarted, event.turn);
49
+ break;
50
+ case "ToolStarted": {
51
+ const state = turnState(turns, event.turn);
52
+ state.toolLines.push(`${event.tool} ${event.summary}`);
53
+ if (EDIT_TOOLS.has(event.tool)) {
54
+ state.hasEditTool = true;
55
+ }
56
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
57
+ break;
58
+ }
59
+ case "ToolCompleted": {
60
+ const state = turnState(turns, event.turn);
61
+ // The completion alone is enough evidence of an edit: a stream whose
62
+ // `ToolStarted` line was lost to a crash still classifies truthfully.
63
+ if (EDIT_TOOLS.has(event.tool)) {
64
+ state.hasEditTool = true;
65
+ }
66
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
67
+ break;
68
+ }
69
+ case "FileChanged":
70
+ turnState(turns, event.turn).hasEditTool = true;
71
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
72
+ if (!seenFiles.has(event.path)) {
73
+ seenFiles.add(event.path);
74
+ filesChanged.push(event.path);
75
+ }
76
+ break;
77
+ case "ValidationStarted":
78
+ turnState(turns, event.turn).validationStarts.push(event.command);
79
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
80
+ break;
81
+ case "ValidationCompleted": {
82
+ const state = turnState(turns, event.turn);
83
+ (event.ok ? state.validationOk : state.validationFailed).push(event.command);
84
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
85
+ break;
86
+ }
87
+ case "ToolDenied":
88
+ // A denied tool ran nothing, so it proves neither edits nor validation;
89
+ // its ToolStarted summary is already in toolLines (A0: reportable).
90
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
91
+ break;
92
+ case "RunCompleted":
93
+ case "RunFailed":
94
+ case "RunCancelled":
95
+ terminalSeen = true;
96
+ break;
97
+ default:
98
+ break;
99
+ }
100
+ }
101
+ const ordered = [...turns.entries()].sort((a, b) => a[0] - b[0]);
102
+ // Phase comes from the LAST turn with tool activity — a TurnStarted alone is
103
+ // a counter, not activity, so a stream truncated right after one keeps the
104
+ // phase of the turn that was actually doing something.
105
+ const lastActive = lastToolTurn > 0 ? turns.get(lastToolTurn) : undefined;
106
+ const phase = lastActive !== undefined && lastActive.validationStarts.length > 0
107
+ ? "validation"
108
+ : lastActive !== undefined && lastActive.hasEditTool
109
+ ? "implementation"
110
+ : "exploration";
111
+ const completed = [];
112
+ for (const [turn, state] of ordered) {
113
+ // Finished = a later turn started, or the run reached a terminal event.
114
+ // The final turn of a stream with no terminal event is the one in progress
115
+ // when the handoff happened, so it stays out of `completed`.
116
+ if (state.toolLines.length > 0 && (turn < lastTurnStarted || terminalSeen)) {
117
+ completed.push(`turn ${turn}: ${state.toolLines.join(", ")}`.slice(0, LINE_MAX_CHARS));
118
+ }
119
+ }
120
+ const validationPending = owedValidations(ordered);
121
+ const pending = taskTitle === undefined
122
+ ? // No RunStarted at all: the stream lost its opener (or is empty), so
123
+ // there is no title to carry — one honest entry, nothing fabricated.
124
+ ["continue the task"]
125
+ : [taskTitle.trim().length > 0 ? taskTitle : "continue the task", ...validationPending];
126
+ return {
127
+ // Mirrors summarize(): the first event's runId is the run the stream
128
+ // belongs to, and "" is the only answer an empty stream has.
129
+ runId: events.length > 0 ? events[0].runId : "",
130
+ phase,
131
+ completed,
132
+ pending,
133
+ filesChanged,
134
+ validationPending,
135
+ };
136
+ }
137
+ /**
138
+ * Writes `checkpoint.json` into a run directory and returns its path, or null
139
+ * on any failure — logged at debug, never thrown. A checkpoint is an aid: the
140
+ * run it describes has already happened, and losing the aid must not cost the
141
+ * run its exit path.
142
+ */
143
+ export function writeCheckpoint(runDirPath, checkpoint) {
144
+ const file = path.join(runDirPath, CHECKPOINT_FILE_NAME);
145
+ try {
146
+ atomicWriteFile(file, JSON.stringify(checkpoint, null, 2) + "\n");
147
+ return file;
148
+ }
149
+ catch (error) {
150
+ logger.debug(`writeCheckpoint: writing ${file} failed: ${errorMessage(error)}`);
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Validations still owed: every started command with no ok completion in the
156
+ * SAME turn (the adapter ties a completion to the turn that started it), plus
157
+ * every completion that reported `ok: false`. A denied validation falls under
158
+ * the first clause — the denial never produces a completion, and A0 settled
159
+ * that a denied validation is a real, reportable outcome rather than an error,
160
+ * so it must resurface here instead of vanishing. Deduplicated by command,
161
+ * first-owed order: the reader wants which commands to run, not how many times
162
+ * the run failed to run them.
163
+ */
164
+ function owedValidations(orderedTurns) {
165
+ const owed = [];
166
+ const seen = new Set();
167
+ const add = (command) => {
168
+ if (!seen.has(command)) {
169
+ seen.add(command);
170
+ owed.push(command);
171
+ }
172
+ };
173
+ for (const [, state] of orderedTurns) {
174
+ for (const command of new Set(state.validationStarts)) {
175
+ if (occurrences(state.validationOk, command) < occurrences(state.validationStarts, command)) {
176
+ add(command);
177
+ }
178
+ }
179
+ for (const command of state.validationFailed) {
180
+ add(command);
181
+ }
182
+ }
183
+ return owed;
184
+ }
185
+ function turnState(turns, turn) {
186
+ let state = turns.get(turn);
187
+ if (state === undefined) {
188
+ state = { toolLines: [], validationStarts: [], validationOk: [], validationFailed: [], hasEditTool: false };
189
+ turns.set(turn, state);
190
+ }
191
+ return state;
192
+ }
193
+ function occurrences(commands, command) {
194
+ let count = 0;
195
+ for (const entry of commands) {
196
+ if (entry === command) {
197
+ count += 1;
198
+ }
199
+ }
200
+ return count;
201
+ }
202
+ function errorMessage(error) {
203
+ return error instanceof Error ? error.message : String(error);
204
+ }
@@ -0,0 +1,165 @@
1
+ import { execFile } from "node:child_process";
2
+ import { logger } from "../core/logging.js";
3
+ import { zoneFor } from "../budget/manager.js";
4
+ import { usableBudgetOf } from "../routing/glm-routing.js";
5
+ /**
6
+ * Grace between SIGINT and SIGTERM. Five seconds is enough for `claude` to
7
+ * finish flushing the tool result it is holding and exit on its own — the
8
+ * point of the ladder is to let it close cleanly, not to win a race.
9
+ */
10
+ export const TERMINATE_GRACE_MS = 5_000;
11
+ /** Second grace, before Windows' last resort. Short: by now it has ignored two signals. */
12
+ export const TASKKILL_GRACE_MS = 2_000;
13
+ /** Worst-to-best, so "at or below HANDOFF_READY" is one comparison. */
14
+ const ZONE_SEVERITY = {
15
+ CRITICAL: 3,
16
+ HANDOFF_READY: 2,
17
+ CONSERVE: 1,
18
+ HEALTHY: 0,
19
+ };
20
+ /**
21
+ * Should this run be worried yet? (specs/v2-architecture.md Phase F.)
22
+ *
23
+ * Pure: the caller does the polling and owns every side effect. Note what this
24
+ * projects — the cost of what is LEFT, not of the whole task. A run three
25
+ * quarters done needs a quarter of the estimate, and treating it as if it were
26
+ * starting over would raise the alarm on every long run that is nearly
27
+ * finished, which is precisely when interrupting costs the most.
28
+ *
29
+ * `confidence: "unknown"` can never reach `atRisk`, because `zoneFor` fails
30
+ * open to HEALTHY — a monitoring outage must not drain a live child.
31
+ */
32
+ export function assessDrain(input) {
33
+ const routing = input.config.routing;
34
+ const zone = zoneFor(input.snapshot, routing);
35
+ const usableBudget = usableBudgetOf(input.snapshot, routing.reserveRatio);
36
+ // A run that has already used every turn it was given has nothing left to
37
+ // project; an unknown/zero ceiling means "assume it all still lies ahead".
38
+ const remainingTurnRatio = input.maxTurns > 0
39
+ ? Math.min(1, Math.max(0, (input.maxTurns - input.turnsDone) / input.maxTurns))
40
+ : 1;
41
+ const projectedCost = input.estimate.p90 * routing.safetyFactor * remainingTurnRatio;
42
+ return {
43
+ zone,
44
+ usableBudget,
45
+ projectedCost,
46
+ atRisk: ZONE_SEVERITY[zone] >= ZONE_SEVERITY.HANDOFF_READY && projectedCost > usableBudget,
47
+ };
48
+ }
49
+ /**
50
+ * Polls the budget while the child runs and calls back the first time the run
51
+ * becomes at-risk, then never again: the warning and the checkpoint are worth
52
+ * writing once, and a per-poll repeat would turn a tight quota into a wall of
53
+ * identical stderr lines.
54
+ *
55
+ * Every timer is injectable because a test must not wait a real minute, and
56
+ * the interval is unref'd so a poll in flight can never hold the process open
57
+ * past the run it was watching.
58
+ */
59
+ export function startDrainWatch(input) {
60
+ const setIntervalFn = input.setIntervalImpl ?? ((tick, ms) => setInterval(tick, ms));
61
+ const clearIntervalFn = input.clearIntervalImpl ?? ((handle) => clearInterval(handle));
62
+ let fired = false;
63
+ let stopped = false;
64
+ const tick = () => {
65
+ if (fired || stopped) {
66
+ return;
67
+ }
68
+ // Fire-and-forget: a poll that rejects must not become an unhandled
69
+ // rejection, and a slow endpoint must not delay the next tick.
70
+ void (async () => {
71
+ try {
72
+ const assessment = assessDrain({
73
+ snapshot: await input.readBudget(),
74
+ estimate: input.estimate(),
75
+ config: input.config,
76
+ turnsDone: input.turnsDone(),
77
+ maxTurns: input.maxTurns,
78
+ });
79
+ if (assessment.atRisk && !fired && !stopped) {
80
+ fired = true;
81
+ input.onAtRisk(assessment);
82
+ }
83
+ }
84
+ catch (error) {
85
+ logger.debug(`drain watch: poll failed, continuing: ${errorMessage(error)}`);
86
+ }
87
+ })();
88
+ };
89
+ const handle = setIntervalFn(tick, Math.max(1, input.config.routing.pollIntervalSec) * 1000);
90
+ handle.unref?.();
91
+ return {
92
+ stop() {
93
+ stopped = true;
94
+ clearIntervalFn(handle);
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Stop the child at a safe boundary: SIGINT, grace, SIGTERM, and on Windows
100
+ * `taskkill /pid <pid> /t` as the last resort — the same ladder and the same
101
+ * no-shell rule the spawn helpers already follow.
102
+ *
103
+ * Windows has no real POSIX signals: Node emulates `kill` by terminating the
104
+ * process, and a `claude` that spawned its own children leaves them behind,
105
+ * which is what `/t` (kill the tree) is for. `/f` is deliberately NOT used
106
+ * before the tree walk — a forced kill is the thing this whole ladder exists
107
+ * to avoid, because it is what loses the work on disk.
108
+ *
109
+ * Resolves when the child is gone or the ladder is exhausted; never throws.
110
+ */
111
+ export async function terminateChild(child, options = {}) {
112
+ const platform = options.platform ?? process.platform;
113
+ const wait = options.waitImpl ?? defaultWait;
114
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
115
+ send(child, "SIGINT");
116
+ if (exited()) {
117
+ return;
118
+ }
119
+ await wait(options.graceMs ?? TERMINATE_GRACE_MS);
120
+ if (exited()) {
121
+ return;
122
+ }
123
+ send(child, "SIGTERM");
124
+ if (platform !== "win32") {
125
+ return;
126
+ }
127
+ await wait(options.taskkillGraceMs ?? TASKKILL_GRACE_MS);
128
+ if (exited() || typeof child.pid !== "number") {
129
+ return;
130
+ }
131
+ const taskkill = options.runTaskkill ?? defaultTaskkill;
132
+ try {
133
+ taskkill(child.pid);
134
+ }
135
+ catch (error) {
136
+ logger.debug(`terminateChild: taskkill failed: ${errorMessage(error)}`);
137
+ }
138
+ }
139
+ /** A kill on an already-dead child throws ESRCH; that is success, not an error. */
140
+ function send(child, signal) {
141
+ try {
142
+ child.kill(signal);
143
+ }
144
+ catch (error) {
145
+ logger.debug(`terminateChild: ${signal} failed: ${errorMessage(error)}`);
146
+ }
147
+ }
148
+ /** argv array, no shell — the same rule every spawn in this package follows. */
149
+ function defaultTaskkill(pid) {
150
+ execFile("taskkill", ["/pid", String(pid), "/t"], { windowsHide: true }, (error) => {
151
+ if (error) {
152
+ logger.debug(`taskkill /pid ${pid} /t failed: ${error.message}`);
153
+ }
154
+ });
155
+ }
156
+ function defaultWait(ms) {
157
+ return new Promise((resolve) => {
158
+ // unref: a pending grace period must never keep the process alive.
159
+ const timer = setTimeout(resolve, ms);
160
+ timer.unref?.();
161
+ });
162
+ }
163
+ function errorMessage(error) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
@@ -0,0 +1,45 @@
1
+ import { logger } from "../core/logging.js";
2
+ import { updateRun } from "./registry.js";
3
+ /**
4
+ * Doc §6 fixes the tick at 5 s and keeps it out of config on purpose: the
5
+ * 30 s orphan threshold is six missed ticks of THIS interval. A config knob
6
+ * here would let anyone silently break liveness detection.
7
+ */
8
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
9
+ /**
10
+ * Emits a `Heartbeat` event and refreshes `heartbeatAt` in the active file on
11
+ * every tick — the pair that `watch` and orphan detection read. A file
12
+ * failure is caught and logged at debug: the heartbeat is liveness plumbing,
13
+ * and plumbing must never kill the run it is monitoring. The bus's own
14
+ * subscriber guard covers the emit side.
15
+ */
16
+ export function startHeartbeat(deps) {
17
+ const intervalMs = deps.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
18
+ const tick = () => {
19
+ deps.bus.emit({ type: "Heartbeat", state: deps.getState(), turn: deps.getTurn() });
20
+ try {
21
+ updateRun(deps.home, deps.runId, { heartbeatAt: new Date().toISOString() });
22
+ }
23
+ catch (error) {
24
+ logger.debug(`heartbeat: could not refresh ${deps.runId}: ${errorMessage(error)}`);
25
+ }
26
+ };
27
+ const setIntervalImpl = deps.setIntervalImpl ?? ((fn, ms) => setInterval(fn, ms));
28
+ const clearIntervalImpl = deps.clearIntervalImpl ?? ((timer) => clearInterval(timer));
29
+ let timer = setIntervalImpl(tick, intervalMs);
30
+ // Unref when the runtime supports it: a leaked heartbeat timer must never
31
+ // be the reason the process stays open after the run finished.
32
+ timer.unref?.();
33
+ return {
34
+ stop() {
35
+ if (timer === null) {
36
+ return;
37
+ }
38
+ clearIntervalImpl(timer);
39
+ timer = null;
40
+ },
41
+ };
42
+ }
43
+ function errorMessage(error) {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }