glm-coding-router 1.1.1 → 2.0.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.
- package/README.md +534 -419
- package/dist/bin/glm-review.js +46 -4
- package/dist/bin/glm-worker.js +37 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +38 -0
- package/dist/commands/benchmark.js +4 -0
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/usage.js +1 -40
- package/dist/commands/watch.js +289 -0
- package/dist/core/agent-args.js +20 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/zai-quota.js +46 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +9 -0
- package/dist/templates/claude-block.js +9 -0
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +78 -0
- 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
|
+
}
|
|
@@ -43,4 +43,13 @@ EXPECTED OUTPUT
|
|
|
43
43
|
|
|
44
44
|
Never trust a worker's success report without inspecting the resulting changes.
|
|
45
45
|
|
|
46
|
+
Worker exit codes 41 and 42 are NOT crashes:
|
|
47
|
+
|
|
48
|
+
- stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
|
|
49
|
+
- read \`handoff_path\` — a handoff.md with what was done, what remains, files
|
|
50
|
+
changed, and untracked files that are NOT in diff.patch
|
|
51
|
+
- continue the task yourself in the SAME worktree, starting from "Remaining"
|
|
52
|
+
- do not re-run the worker until quota resets
|
|
53
|
+
- 41 means nothing was spawned; 42 means work was done and is preserved
|
|
54
|
+
|
|
46
55
|
<!-- glm-coding-router:end -->`;
|