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
@@ -0,0 +1,672 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { Errors, ExitCode, formatGlmError, GlmRouterError } from "../core/errors.js";
4
+ import { logger } from "../core/logging.js";
5
+ import { runDir } from "../core/paths.js";
6
+ import { spawnAgentStream } from "../core/process.js";
7
+ import { fetchBudget } from "../budget/manager.js";
8
+ import { classifyTask, estimateCost, isCleanMeasurement, recordSample } from "../budget/estimator.js";
9
+ import { decideRoute } from "../routing/glm-routing.js";
10
+ import { createEventBus } from "../events/bus.js";
11
+ import { createStreamAdapter } from "../events/claude-adapter.js";
12
+ import { attachProgress, resolveProgressMode } from "../tui/progress.js";
13
+ import { buildCheckpoint, writeCheckpoint } from "./checkpoint.js";
14
+ import { startDrainWatch, terminateChild } from "./drain.js";
15
+ import { writeHandoffBundle } from "../handoff/bundle.js";
16
+ import { handoffSummaryLines, writeHandoffResult } from "../handoff/parent-handoff.js";
17
+ import { createRun, finishRun, listActive, pruneHistory, taskHashOf, taskTitleOf, updateRun } from "./registry.js";
18
+ import { startHeartbeat } from "./heartbeat.js";
19
+ import { openRunStore, summarize } from "./store.js";
20
+ import { runId } from "./ulid.js";
21
+ /**
22
+ * `CLAUDECODE=1` is set inside Claude Code subagents; Codex has no single
23
+ * marker variable, so any `CODEX_SESSION` or a value naming codex counts.
24
+ * Everything else is a human at a shell. Kept here, exported, so the v1
25
+ * surfaces (part 3) and the tests share one definition.
26
+ */
27
+ export function detectParent(env) {
28
+ if (env.CLAUDECODE === "1") {
29
+ return "claude";
30
+ }
31
+ if (env.CODEX_SESSION !== undefined) {
32
+ return "codex";
33
+ }
34
+ for (const value of Object.values(env)) {
35
+ if (typeof value === "string" && value.includes("codex")) {
36
+ return "codex";
37
+ }
38
+ }
39
+ return "shell";
40
+ }
41
+ /**
42
+ * The legacy escape hatch (contract C4). False when the caller already passed
43
+ * `--output-format` (`benchmark` does, with `json`) or set
44
+ * `GLM_ROUTER_OBSERVE=off`: those callers must use the exact v1 inherit path
45
+ * instead — no registry, no adapter, byte-identical behavior.
46
+ * `runInstrumented` assumes it is only called when this returns true.
47
+ */
48
+ export function shouldObserve(args, env) {
49
+ if (env.GLM_ROUTER_OBSERVE === "off") {
50
+ return false;
51
+ }
52
+ return !args.includes("--output-format");
53
+ }
54
+ /**
55
+ * One instrumented run. Never throws: a spawn that cannot even start maps to
56
+ * exit code 40 with the diagnostic on stderr, exactly like v1's
57
+ * CHILD_AGENT_FAILED, and everything observability-related degrades quietly.
58
+ */
59
+ export async function runInstrumented(options) {
60
+ const now = options.now ?? (() => new Date());
61
+ const home = options.home ?? os.homedir();
62
+ // The only two places the real process streams are ever referenced: the
63
+ // documented defaults (contract C1 keeps stdout reserved for the final text).
64
+ const stdout = options.stdout ?? process.stdout;
65
+ const stderr = options.stderr ?? process.stderr;
66
+ const startedAt = now();
67
+ const id = runId(() => startedAt.getTime());
68
+ const date = startedAt.toISOString().slice(0, 10);
69
+ const dir = runDir(home, date, id);
70
+ const role = options.role ?? (options.kind === "review" ? "reviewer" : "worker");
71
+ // Retention is opportunistic by design: a pruning failure must never block
72
+ // the work around it (pruneHistory already swallows per-run errors; this
73
+ // catch is for the listing itself).
74
+ try {
75
+ pruneHistory(home, options.config.history);
76
+ }
77
+ catch (error) {
78
+ logger.debug(`worker-run: history pruning failed: ${errorMessage(error)}`);
79
+ }
80
+ // C3: the first prompt line, redacted, and a hash of the full prompt are the
81
+ // only prompt-derived values that ever reach disk.
82
+ const taskTitle = taskTitleOf(options.prompt, options.secrets);
83
+ const taskHash = taskHashOf(options.prompt);
84
+ // Preflight (Phase E). Never throws: any failure degrades to "run on the
85
+ // main model", which is exactly what v1 did, so quota trouble can never be
86
+ // worse than not having quota awareness at all.
87
+ const preflight = await runPreflight(options, home);
88
+ if (preflight.decision?.action === "return_to_parent") {
89
+ // Exit 41. Nothing is spawned and nothing is written — not to the repo and
90
+ // not to the run history, because a run that never started has no events
91
+ // to show. The HandoffResult on stdout IS the record (D2): a parent agent
92
+ // reads it instead of parsing prose. This arm is unreachable with the
93
+ // shipped 2.0.0 config, where refuseOnCritical is false (D3).
94
+ writeRefusal(stdout, stderr, preflight.decision, id, taskTitle);
95
+ return { code: ExitCode.QuotaInsufficient, runId: id, runDir: dir };
96
+ }
97
+ // The model the run ACTUALLY uses: a zone-driven downgrade has to reach the
98
+ // child, and the child reads it from the env, not from our config object.
99
+ const model = preflight.decision?.model ?? options.config.models.main;
100
+ const childEnv = model === options.config.models.main
101
+ ? options.env
102
+ : { ...options.env, ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model };
103
+ // Registry: an unwritable home (full disk, read-only volume) must not stop
104
+ // the user's work — the run continues without persistence.
105
+ let registered = false;
106
+ try {
107
+ createRun({
108
+ id,
109
+ kind: options.kind,
110
+ provider: "zai.zcode",
111
+ role,
112
+ model,
113
+ cwd: options.cwd,
114
+ startedAt: startedAt.toISOString(),
115
+ parent: { type: detectParent(options.env) },
116
+ taskTitle,
117
+ taskHash,
118
+ pid: process.pid,
119
+ date,
120
+ }, { home, now: () => startedAt });
121
+ registered = true;
122
+ }
123
+ catch (error) {
124
+ logger.debug(`worker-run: run registry unavailable, continuing without persistence: ${errorMessage(error)}`);
125
+ }
126
+ const bus = createEventBus(id, { role, now });
127
+ const seen = [];
128
+ bus.subscribe((event) => seen.push(event));
129
+ let store = null;
130
+ if (registered) {
131
+ try {
132
+ store = openRunStore(dir);
133
+ }
134
+ catch (error) {
135
+ logger.debug(`worker-run: run store unavailable, events will not be persisted: ${errorMessage(error)}`);
136
+ }
137
+ }
138
+ if (store !== null) {
139
+ // `target` holds the narrowed handle: `store` is reassigned below, which
140
+ // would invalidate the narrowing inside the closure.
141
+ const target = store;
142
+ bus.subscribe((event) => target.append(event));
143
+ }
144
+ const progressMode = resolveRunProgressMode(options, stderr);
145
+ let detachProgress = () => { };
146
+ try {
147
+ detachProgress = attachProgress(bus, { mode: progressMode, stream: stderr }).detach;
148
+ }
149
+ catch (error) {
150
+ logger.debug(`worker-run: progress renderer unavailable: ${errorMessage(error)}`);
151
+ }
152
+ const adapter = createStreamAdapter(options.cwd);
153
+ // The stdout contract (C1) lives in these three locals. `finalText` is the
154
+ // ONLY copy of the run's final answer, it never touches an event, a log line
155
+ // or the store, and it is written to stdout exactly once at the end.
156
+ let finalText;
157
+ let sawRunCompleted = false;
158
+ let currentTurn = 0;
159
+ let childCode = null;
160
+ let spawnFailure = null;
161
+ let heartbeat = null;
162
+ let filesTouched = 0;
163
+ // How many tools the child has opened and not yet closed. Zero means the run
164
+ // is BETWEEN tools, which is the only moment stopping it is safe.
165
+ let toolsInFlight = 0;
166
+ // The drain state machine (Phase F). `requested` is set by the budget poll;
167
+ // it only becomes `terminating` at a safe boundary, which is the whole
168
+ // point — the router acts on events it has already received, so the boundary
169
+ // is a ToolCompleted or a turn start, never the middle of an Edit.
170
+ // Held in one object rather than two `let`s so the compiler keeps the union
171
+ // wide: every write happens inside a callback, which control-flow analysis
172
+ // cannot see, and a narrowed `drain.state` would make the handoff check below
173
+ // look statically impossible.
174
+ const drain = {
175
+ state: "none",
176
+ reason: "quota_low",
177
+ };
178
+ let child = null;
179
+ const stopAtBoundary = () => {
180
+ if (drain.state !== "requested" || child === null) {
181
+ return;
182
+ }
183
+ drain.state = "terminating";
184
+ try {
185
+ const written = writeCheckpoint(dir, buildCheckpoint(seen));
186
+ if (written !== null) {
187
+ bus.emit({ type: "CheckpointCreated", path: written, phase: buildCheckpoint(seen).phase });
188
+ }
189
+ updateRun(home, id, { state: "CHECKPOINTING" });
190
+ bus.emit({ type: "HandoffStarted", reason: drain.reason });
191
+ }
192
+ catch (error) {
193
+ logger.debug(`worker-run: checkpoint before drain failed: ${errorMessage(error)}`);
194
+ }
195
+ // Fire-and-forget: the spawn promise is what actually tells us the child
196
+ // is gone, and awaiting here would block the event dispatch that feeds it.
197
+ void terminateChild(child).catch((error) => {
198
+ logger.debug(`worker-run: terminating the child failed: ${errorMessage(error)}`);
199
+ });
200
+ };
201
+ const dispatch = (events) => {
202
+ for (const event of events) {
203
+ if (event.type === "TurnStarted") {
204
+ currentTurn = event.turn;
205
+ }
206
+ if (event.type === "RunCompleted") {
207
+ sawRunCompleted = true;
208
+ }
209
+ if (event.type === "ToolStarted") {
210
+ toolsInFlight += 1;
211
+ }
212
+ if (event.type === "ToolCompleted" || event.type === "ToolDenied") {
213
+ toolsInFlight = Math.max(0, toolsInFlight - 1);
214
+ }
215
+ if (event.type === "FileChanged") {
216
+ // Counted here rather than re-derived later: this is the test for
217
+ // "did this run leave work on disk", which decides whether a dead run
218
+ // gets a bundle.
219
+ filesTouched += 1;
220
+ }
221
+ bus.emit(event);
222
+ // Safe boundary, checked AFTER the event is on the bus so the stream
223
+ // that justified stopping is recorded before the child goes away.
224
+ // `ToolDenied` counts: A0 settled that a denial means the tool never
225
+ // ran, so the child is as safely between tools as after a completion —
226
+ // and leaving it out would make a run whose tools keep getting denied
227
+ // undrainable, with no boundary ever arriving.
228
+ if (event.type === "ToolCompleted" ||
229
+ event.type === "ToolDenied" ||
230
+ event.type === "TurnStarted") {
231
+ stopAtBoundary();
232
+ }
233
+ }
234
+ };
235
+ const handleStdoutLine = (line) => {
236
+ try {
237
+ captureResultText(line);
238
+ dispatch(adapter.onStdoutLine(line));
239
+ }
240
+ catch (error) {
241
+ logger.debug(`worker-run: stdout line handling failed: ${errorMessage(error)}`);
242
+ }
243
+ };
244
+ const handleStderrLine = (line) => {
245
+ try {
246
+ dispatch(adapter.onStderrLine(line));
247
+ // Passthrough: real Claude diagnostics (and retry notices) must stay
248
+ // visible to whoever is watching stderr.
249
+ stderr.write(line + "\n");
250
+ }
251
+ catch (error) {
252
+ logger.debug(`worker-run: stderr line handling failed: ${errorMessage(error)}`);
253
+ }
254
+ };
255
+ /** Remembers `result` from the stream's final `{"type":"result",…}` line. */
256
+ const captureResultText = (line) => {
257
+ const trimmed = line.replace(/\r$/, "").trim();
258
+ if (!trimmed.startsWith("{")) {
259
+ return;
260
+ }
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(trimmed);
264
+ }
265
+ catch {
266
+ return; // not JSON — the adapter logs its own debug line for this text
267
+ }
268
+ if (!isRecord(parsed)) {
269
+ return;
270
+ }
271
+ if (parsed.type === "result" && typeof parsed.result === "string") {
272
+ finalText = parsed.result;
273
+ }
274
+ };
275
+ try {
276
+ bus.emit({
277
+ type: "RunStarted",
278
+ kind: options.kind,
279
+ model,
280
+ cwd: options.cwd,
281
+ taskTitle,
282
+ taskHash,
283
+ parent: { type: detectParent(options.env) },
284
+ });
285
+ }
286
+ catch (error) {
287
+ logger.debug(`worker-run: RunStarted emit failed: ${errorMessage(error)}`);
288
+ }
289
+ // D3: with the shipped config a tight quota never refuses and never kills —
290
+ // it warns. This event plus the one stderr line below are the whole of that
291
+ // warning, and `routingAdvice` in summary.json is its durable half.
292
+ if (preflight.decision !== null && preflight.decision.wouldRefuse) {
293
+ try {
294
+ bus.emit({
295
+ type: "BudgetWarning",
296
+ zone: preflight.decision.zone,
297
+ remainingRatio: preflight.remainingRatio,
298
+ usableBudget: preflight.decision.usableBudget,
299
+ estimatedRemaining: preflight.decision.estimatedCost,
300
+ });
301
+ stderr.write(`[Router] ${preflight.decision.zone}: estimated ${round2(preflight.decision.estimatedCost)} credits ` +
302
+ `vs ${round2(preflight.decision.usableBudget)} usable — running anyway on ${model}\n`);
303
+ }
304
+ catch (error) {
305
+ logger.debug(`worker-run: budget warning failed: ${errorMessage(error)}`);
306
+ }
307
+ }
308
+ try {
309
+ heartbeat = startHeartbeat({
310
+ bus,
311
+ home,
312
+ runId: id,
313
+ getState: () => "RUNNING",
314
+ getTurn: () => currentTurn,
315
+ });
316
+ }
317
+ catch (error) {
318
+ logger.debug(`worker-run: heartbeat unavailable: ${errorMessage(error)}`);
319
+ }
320
+ // Watching the budget is observation: it always runs when the router is
321
+ // quota-aware. What it is ALLOWED to do when it fires is the part gated by
322
+ // handoffOnLowQuota (D3) — see onAtRisk below.
323
+ const drainWatch = preflight.decision !== null && options.config.routing.quotaAware
324
+ ? startDrainWatch({
325
+ config: options.config,
326
+ readBudget: () => (options.budgetSource ?? fetchBudget)({
327
+ home,
328
+ key: preflightKey(options),
329
+ refresh: true,
330
+ ttlSec: 0,
331
+ }),
332
+ estimate: () => estimateCost(home, preflight.taskKind, model, options.config.models.fast),
333
+ turnsDone: () => currentTurn,
334
+ maxTurns: maxTurnsOf(options.args),
335
+ onAtRisk: (assessment) => onAtRisk(assessment),
336
+ setIntervalImpl: options.setIntervalImpl,
337
+ clearIntervalImpl: options.clearIntervalImpl,
338
+ })
339
+ : null;
340
+ function onAtRisk(assessment) {
341
+ try {
342
+ bus.emit({
343
+ type: "BudgetWarning",
344
+ zone: assessment.zone,
345
+ remainingRatio: preflight.remainingRatio,
346
+ usableBudget: round2(assessment.usableBudget),
347
+ estimatedRemaining: round2(assessment.projectedCost),
348
+ });
349
+ // The checkpoint ALWAYS happens: it is observation, it costs nothing and
350
+ // it interrupts nobody, so a run that later dies for any reason already
351
+ // has one on disk.
352
+ writeCheckpoint(dir, buildCheckpoint(seen));
353
+ stderr.write(`[Router] ${assessment.zone}: ${round2(assessment.projectedCost)} credits still projected ` +
354
+ `vs ${round2(assessment.usableBudget)} usable — checkpoint written\n`);
355
+ }
356
+ catch (error) {
357
+ logger.debug(`worker-run: drain warning failed: ${errorMessage(error)}`);
358
+ }
359
+ if (!options.config.routing.handoffOnLowQuota) {
360
+ // D3's most invasive switch, off in 2.0.0: observe and warn, never kill
361
+ // a live child. Bundle-on-death below is what protects the worktree.
362
+ return;
363
+ }
364
+ drain.state = "requested";
365
+ drain.reason = `quota_low:${assessment.zone}`;
366
+ updateRun(home, id, { state: "DRAINING" });
367
+ // A run with a tool in flight stops at the NEXT boundary, from dispatch.
368
+ // Only a run already sitting between tools may be stopped here — otherwise
369
+ // this "nudge" would terminate mid-Edit and defeat the entire safe-boundary
370
+ // guarantee, which is the one thing this path exists to provide.
371
+ if (toolsInFlight === 0) {
372
+ stopAtBoundary();
373
+ }
374
+ }
375
+ try {
376
+ // Both flags together: claude 2.1.278 rejects stream-json without
377
+ // --verbose (captured evidence, A0). Callers reach here only when
378
+ // shouldObserve is true, so no --output-format is already present.
379
+ const result = await (options.spawnImpl ?? spawnAgentStream)(options.claudePath, {
380
+ args: [...options.args, "--output-format", "stream-json", "--verbose"],
381
+ cwd: options.cwd,
382
+ env: childEnv,
383
+ onStdoutLine: handleStdoutLine,
384
+ onStderrLine: handleStderrLine,
385
+ onSpawn: (spawned) => {
386
+ child = spawned;
387
+ },
388
+ });
389
+ childCode = result.code;
390
+ }
391
+ catch (error) {
392
+ spawnFailure = error;
393
+ }
394
+ finally {
395
+ // Always: a leaked ticker would keep the process open and keep emitting
396
+ // events into a run that is already over.
397
+ heartbeat?.stop();
398
+ drainWatch?.stop();
399
+ }
400
+ const handedOff = drain.state === "terminating";
401
+ // C1, and the one exception to it: a handed-off run has no final answer —
402
+ // it was stopped — so stdout carries the HandoffResult instead. Printing a
403
+ // partial answer alongside the JSON would give the parent two things to
404
+ // parse and no way to know which is authoritative.
405
+ if (finalText !== undefined && !handedOff) {
406
+ stdout.write(finalText.endsWith("\n") ? finalText : finalText + "\n");
407
+ }
408
+ if (spawnFailure !== null) {
409
+ // v1 maps an unspawnable child to CHILD_AGENT_FAILED (exit 40); keep the
410
+ // formatted diagnostic on stderr so the reason stays visible.
411
+ const text = spawnFailure instanceof GlmRouterError
412
+ ? formatGlmError(spawnFailure)
413
+ : `worker-run: child process failed: ${errorMessage(spawnFailure)}`;
414
+ try {
415
+ stderr.write(text + "\n");
416
+ }
417
+ catch (error) {
418
+ logger.debug(`worker-run: writing spawn failure diagnostic failed: ${errorMessage(error)}`);
419
+ }
420
+ }
421
+ // Close the measurement loop (Phase E). `actualCredits` is what makes the
422
+ // D3 evidence answer its question: a `wouldRefuse: true` run next to the
423
+ // credits it really consumed is how 2.1 learns whether the refusal would
424
+ // have been wrong. Both halves are best-effort and never fail the run.
425
+ const summary = summarize(seen);
426
+ const actualCredits = await closeMeasurement(options, home, preflight, model, role, summary);
427
+ const cleanSuccess = childCode === ExitCode.Success && sawRunCompleted && !handedOff;
428
+ // **Bundle-on-death.** This is what actually satisfies doc §23's "no working
429
+ // tree changes lost", and it holds with EVERY switch off: any ending that is
430
+ // not a clean success, where the run touched at least one file, gets the
431
+ // full bundle. When quota really runs out the child just dies on an API
432
+ // error — at that moment the work is already invisible to the orchestrator
433
+ // unless somebody writes it down.
434
+ let bundlePath = null;
435
+ if (!cleanSuccess && filesTouched > 0 && registered) {
436
+ const bundle = await writeHandoffBundle({
437
+ runDir: dir,
438
+ runId: id,
439
+ cwd: options.cwd,
440
+ reason: handedOff ? drain.reason : "child_error",
441
+ checkpoint: buildCheckpoint(seen),
442
+ role,
443
+ model,
444
+ });
445
+ bundlePath = bundle?.handoffMd ?? null;
446
+ if (bundlePath !== null && !handedOff) {
447
+ // Not a handoff: the exit code stays v1's 40 (C4) and stdout keeps the
448
+ // final text. The bundle is a stderr breadcrumb, not a protocol change.
449
+ try {
450
+ stderr.write(`[Router] work was left on disk; handoff bundle: ${bundlePath}\n`);
451
+ }
452
+ catch (error) {
453
+ logger.debug(`worker-run: announcing the bundle failed: ${errorMessage(error)}`);
454
+ }
455
+ }
456
+ }
457
+ // Before finishRun, which deletes the active file: HANDOFF is a live state,
458
+ // and the only place it can ever be seen is `runs --active` while the run is
459
+ // still registered. Recording it afterwards would write to a file that no
460
+ // longer exists.
461
+ if (handedOff) {
462
+ try {
463
+ bus.emit({ type: "HandoffCompleted", reason: drain.reason, bundlePath: bundlePath ?? "" });
464
+ updateRun(home, id, { state: "HANDOFF" });
465
+ }
466
+ catch (error) {
467
+ logger.debug(`worker-run: recording the handoff failed: ${errorMessage(error)}`);
468
+ }
469
+ }
470
+ try {
471
+ if (registered) {
472
+ finishRun(home, id, {
473
+ ...summary,
474
+ ...(preflight.decision === null
475
+ ? {}
476
+ : {
477
+ routingAdvice: {
478
+ wouldRefuse: preflight.decision.wouldRefuse,
479
+ // Rounded on the way to disk: p90 × safetyFactor produces
480
+ // values like 31.200000000000003, and this file is permanent
481
+ // history that 2.1 reads back.
482
+ estimatedCost: round2(preflight.decision.estimatedCost),
483
+ usableBudget: round2(preflight.decision.usableBudget),
484
+ zone: preflight.decision.zone,
485
+ actualCredits,
486
+ },
487
+ }),
488
+ });
489
+ }
490
+ }
491
+ catch (error) {
492
+ logger.debug(`worker-run: writing summary failed: ${errorMessage(error)}`);
493
+ }
494
+ let code = cleanSuccess ? ExitCode.Success : ExitCode.ChildAgentFailed;
495
+ if (handedOff) {
496
+ const checkpoint = buildCheckpoint(seen);
497
+ code = writeHandoffResult({ stdout, stderr }, {
498
+ status: "handoff_required",
499
+ run_id: id,
500
+ reason: drain.reason,
501
+ completed: checkpoint.completed,
502
+ pending: checkpoint.pending,
503
+ handoff_path: bundlePath,
504
+ }, handoffSummaryLines({
505
+ runId: id,
506
+ reason: drain.reason,
507
+ bundlePath,
508
+ completed: checkpoint.completed,
509
+ pending: checkpoint.pending,
510
+ }));
511
+ }
512
+ detachProgress();
513
+ try {
514
+ store?.close();
515
+ }
516
+ catch (error) {
517
+ logger.debug(`worker-run: closing the run store failed: ${errorMessage(error)}`);
518
+ }
519
+ bus.close();
520
+ return { code, runId: id, runDir: dir };
521
+ }
522
+ /**
523
+ * Reads the quota, estimates the task on BOTH models and asks the router what
524
+ * to do (Phase E). Wrapped whole in a try/catch on purpose: routing is an
525
+ * optimization layered onto a working v1 path, so every failure mode here —
526
+ * no key, endpoint down, unreadable samples — has to degrade to "run the task
527
+ * on the main model" rather than take the run down with it.
528
+ */
529
+ async function runPreflight(options, home) {
530
+ try {
531
+ const source = options.budgetSource ?? fetchBudget;
532
+ const snapshot = await source({
533
+ home,
534
+ key: preflightKey(options),
535
+ refresh: options.refreshQuota,
536
+ ttlSec: options.config.routing.quotaCacheTtlSec,
537
+ });
538
+ const taskKind = classifyTask(options.prompt);
539
+ const fastModel = options.config.models.fast;
540
+ return {
541
+ decision: decideRoute({
542
+ snapshot,
543
+ estimates: {
544
+ main: estimateCost(home, taskKind, options.config.models.main, fastModel),
545
+ fast: estimateCost(home, taskKind, fastModel, fastModel),
546
+ },
547
+ config: options.config,
548
+ requestedModel: options.requestedModel,
549
+ force: options.force,
550
+ }),
551
+ snapshot,
552
+ remainingRatio: Math.min(snapshot.fiveHour.remainingRatio, snapshot.weekly.remainingRatio),
553
+ taskKind,
554
+ };
555
+ }
556
+ catch (error) {
557
+ logger.debug(`worker-run: preflight unavailable, running unrouted: ${errorMessage(error)}`);
558
+ return { decision: null, snapshot: null, remainingRatio: 0, taskKind: "other" };
559
+ }
560
+ }
561
+ /** `secrets` exists for redaction; its first entry is the key every caller passes. */
562
+ function preflightKey(options) {
563
+ return options.zaiKey ?? options.secrets.find((secret) => secret !== undefined);
564
+ }
565
+ /**
566
+ * The enforced preflight refusal (exit 41, decision D2). stdout carries
567
+ * exactly one HandoffResult JSON and nothing else — contract C1 still holds,
568
+ * the "final text" of a run that never ran IS this record — while the human
569
+ * explanation goes to stderr in the standard ERROR format.
570
+ */
571
+ function writeRefusal(stdout, stderr, decision, id, taskTitle) {
572
+ const estimated = round2(decision.estimatedCost);
573
+ const usable = round2(decision.usableBudget);
574
+ // Same emitter as the mid-run handoff: 41 and 42 differ in what already
575
+ // happened, not in what the parent has to parse, and one writer is what
576
+ // keeps that true.
577
+ writeHandoffResult({ stdout, stderr }, {
578
+ status: "handoff_required",
579
+ run_id: id,
580
+ reason: "quota_insufficient",
581
+ completed: [],
582
+ pending: [taskTitle],
583
+ handoff_path: null,
584
+ estimated_cost: estimated,
585
+ usable_quota: usable,
586
+ }, [formatGlmError(Errors.quotaInsufficient(estimated, usable))], ExitCode.QuotaInsufficient);
587
+ }
588
+ /**
589
+ * Second quota reading, and the cost sample it may earn (Phase E, hedge H4).
590
+ * The read bypasses the cache — a cached start snapshot would otherwise be
591
+ * subtracted from itself for every run inside one TTL window, reporting 0
592
+ * credits for work that really cost something.
593
+ *
594
+ * Returns the credits this run consumed, or null when the measurement is not
595
+ * clean (`isCleanMeasurement`): a concurrent run or a window reset makes the
596
+ * delta fiction, and recording fiction would poison the very history the
597
+ * estimator and v4's adaptive routing are meant to learn from.
598
+ */
599
+ async function closeMeasurement(options, home, preflight, model, role, summary) {
600
+ if (preflight.snapshot === null) {
601
+ return null;
602
+ }
603
+ try {
604
+ const source = options.budgetSource ?? fetchBudget;
605
+ const endSnapshot = await source({ home, key: preflightKey(options), refresh: true, ttlSec: 0 });
606
+ const activeRunCount = options.activeRunCount?.() ?? listActive(home).length;
607
+ if (!isCleanMeasurement({ startSnapshot: preflight.snapshot, endSnapshot, activeRunCount })) {
608
+ return null;
609
+ }
610
+ const credits = endSnapshot.fiveHour.used - preflight.snapshot.fiveHour.used;
611
+ recordSample(home, {
612
+ ts: endSnapshot.fetchedAt,
613
+ taskKind: preflight.taskKind,
614
+ model,
615
+ // C3: a basename, never the full path — the repo NAME is what the
616
+ // estimator groups by, and the directory layout is nobody's business.
617
+ repo: path.basename(options.cwd),
618
+ credits,
619
+ turns: summary.turns,
620
+ tokensIn: summary.tokensIn,
621
+ tokensOut: summary.tokensOut,
622
+ provider: "zai.zcode",
623
+ role,
624
+ // "none" is genuinely unknown, not a failure: the run never validated.
625
+ validationOk: summary.validation === "none" ? null : summary.validation === "ok",
626
+ retries: summary.retries,
627
+ costClass: "subscription",
628
+ });
629
+ return credits;
630
+ }
631
+ catch (error) {
632
+ logger.debug(`worker-run: closing the cost measurement failed: ${errorMessage(error)}`);
633
+ return null;
634
+ }
635
+ }
636
+ /**
637
+ * The child's turn ceiling, read back out of the argv we are about to pass it.
638
+ * The drain projection needs it to know how much of the task is still ahead,
639
+ * and argv is the single source of truth — `buildWorkerArgs` already resolved
640
+ * config and profile overlays into this number.
641
+ */
642
+ function maxTurnsOf(args) {
643
+ const index = args.indexOf("--max-turns");
644
+ const value = index >= 0 ? Number(args[index + 1]) : Number.NaN;
645
+ return Number.isFinite(value) && value > 0 ? value : 0;
646
+ }
647
+ /** Credits are reported to two decimals; the endpoint's own numbers are integers. */
648
+ function round2(value) {
649
+ return Math.round(value * 100) / 100;
650
+ }
651
+ /** An explicit mode wins outright; "auto" defers to the Phase C resolution order. */
652
+ function resolveRunProgressMode(options, stderr) {
653
+ if (options.progress !== undefined && options.progress !== "auto") {
654
+ return options.progress;
655
+ }
656
+ // NodeJS.WritableStream does not declare terminal-only members, so narrow
657
+ // through a structural shape (same trick as src/tui/render.ts).
658
+ const terminal = stderr;
659
+ return resolveProgressMode({
660
+ flagOff: options.noProgress,
661
+ quiet: options.quiet,
662
+ configMode: options.config.ui.mode,
663
+ env: options.env,
664
+ isTTY: terminal.isTTY === true,
665
+ });
666
+ }
667
+ function isRecord(value) {
668
+ return typeof value === "object" && value !== null && !Array.isArray(value);
669
+ }
670
+ function errorMessage(error) {
671
+ return error instanceof Error ? error.message : String(error);
672
+ }