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,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logger } from "../core/logging.js";
|
|
4
|
+
import { gitTopLevel, runGit } from "../core/git.js";
|
|
5
|
+
import { atomicWriteFile } from "../project/atomic-write.js";
|
|
6
|
+
import { CHECKPOINT_FILE_NAME, writeCheckpoint } from "../runs/checkpoint.js";
|
|
7
|
+
/** Where the bundle lands: `<runDir>/handoff/` (doc §17). */
|
|
8
|
+
export const HANDOFF_DIR_NAME = "handoff";
|
|
9
|
+
/** The one provider v2 knows (H2/D5), hardcoded into the H5 handoff block. */
|
|
10
|
+
const ZAI_PROVIDER = "zai.zcode";
|
|
11
|
+
/** H5: v3 §18's cross-provider handoff targets the orchestrator, by name. */
|
|
12
|
+
const ORCHESTRATOR_PROVIDER = "anthropic.claude-code";
|
|
13
|
+
/**
|
|
14
|
+
* Writes the handoff bundle (doc §17): `checkpoint.json`, `diff.patch`,
|
|
15
|
+
* `handoff.md`, `handoff.json` under `<runDir>/handoff/`.
|
|
16
|
+
*
|
|
17
|
+
* **Never fails the run.** Returns null only when the bundle directory itself
|
|
18
|
+
* cannot be created; every git call and every file write is individually
|
|
19
|
+
* guarded, so one degraded section (no diff, no branch, a lost file) becomes a
|
|
20
|
+
* debug log while the rest of the bundle still reaches disk. The caller prints
|
|
21
|
+
* the bundle path and carries on regardless — the work on disk is the thing
|
|
22
|
+
* being rescued here, and it is already safe.
|
|
23
|
+
*
|
|
24
|
+
* `diff.patch` is plain `git diff` — tracked changes only, never `git add`
|
|
25
|
+
* (this repo's no-automatic-git rule is exactly why untracked files get their
|
|
26
|
+
* own heading in `handoff.md` instead of being staged into the diff). Plain
|
|
27
|
+
* `git diff` rather than `git diff HEAD` deliberately: the router never stages
|
|
28
|
+
* anything, and on an unborn HEAD `git diff` still exits 0 where `HEAD` would
|
|
29
|
+
* fail and needlessly drop the diff section.
|
|
30
|
+
*
|
|
31
|
+
* C3: every task-derived string here (title, completed lines, validation
|
|
32
|
+
* commands, file paths) comes from the checkpoint, whose fields were already
|
|
33
|
+
* redacted and capped upstream. No prompt body, tool result or LLM response is
|
|
34
|
+
* ever read, and nothing from the environment is written.
|
|
35
|
+
*/
|
|
36
|
+
export async function writeHandoffBundle(input) {
|
|
37
|
+
const dir = path.join(input.runDir, HANDOFF_DIR_NAME);
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.debug(`handoff bundle: creating ${dir} failed: ${errorMessage(error)}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const handoffMd = path.join(dir, "handoff.md");
|
|
46
|
+
const handoffJson = path.join(dir, "handoff.json");
|
|
47
|
+
const checkpointJson = path.join(dir, CHECKPOINT_FILE_NAME);
|
|
48
|
+
const diffFile = path.join(dir, "diff.patch");
|
|
49
|
+
const git = input.runGit ?? runGit;
|
|
50
|
+
const workspace = await workspaceInfo(input.cwd, git);
|
|
51
|
+
const diff = workspace.isRepo ? await trackedDiff(input.cwd, git) : null;
|
|
52
|
+
const diffPatch = diff !== null && guardedWrite(diffFile, diff) ? diffFile : null;
|
|
53
|
+
// Reuses the never-throwing writer so the bundle's copy is byte-identical to
|
|
54
|
+
// the one the run directory itself may already hold.
|
|
55
|
+
writeCheckpoint(dir, input.checkpoint);
|
|
56
|
+
guardedWrite(handoffMd, renderMarkdown(input, workspace, diffPatch !== null));
|
|
57
|
+
guardedWrite(handoffJson, JSON.stringify({
|
|
58
|
+
// Doc §18's shape, verbatim in key order...
|
|
59
|
+
status: "handoff_required",
|
|
60
|
+
run_id: input.runId,
|
|
61
|
+
reason: input.reason,
|
|
62
|
+
completed: input.checkpoint.completed,
|
|
63
|
+
pending: input.checkpoint.pending,
|
|
64
|
+
handoff_path: handoffMd,
|
|
65
|
+
// ...then hedge H5: v3 §17's from/to and v4 §20's workspace block, so
|
|
66
|
+
// a v2 bundle stays readable by v3/v4 without a migration pass.
|
|
67
|
+
from: { provider: ZAI_PROVIDER, role: input.role },
|
|
68
|
+
to: { provider: ORCHESTRATOR_PROVIDER, role: "orchestrator" },
|
|
69
|
+
workspace: { repo: workspace.repo, worktree: workspace.worktree, branch: workspace.branch },
|
|
70
|
+
bundle: { checkpoint: checkpointJson, diff: diffPatch, markdown: handoffMd },
|
|
71
|
+
}, null, 2) + "\n");
|
|
72
|
+
return { dir, handoffMd, handoffJson, checkpointJson, diffPatch };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Everything C3 allows a human to read: the title (the checkpoint's first
|
|
76
|
+
* pending entry), the run id and reason, done/remaining/files/validation from
|
|
77
|
+
* the checkpoint, and the git facts. Untracked files get their OWN heading
|
|
78
|
+
* because they are invisible to `diff.patch` — a reader who assumes the patch
|
|
79
|
+
* is the whole story silently loses them.
|
|
80
|
+
*/
|
|
81
|
+
function renderMarkdown(input, workspace, diffWritten) {
|
|
82
|
+
const checkpoint = input.checkpoint;
|
|
83
|
+
const title = checkpoint.pending.length > 0 ? checkpoint.pending[0] : "continue the task";
|
|
84
|
+
const workspaceLine = workspace.isRepo
|
|
85
|
+
? `${workspace.repo ?? "?"} on ${workspace.branch ?? "(detached HEAD)"} — ${workspace.worktree ?? input.cwd}`
|
|
86
|
+
: "not a git repository";
|
|
87
|
+
const lines = [
|
|
88
|
+
`# Handoff: ${title}`,
|
|
89
|
+
"",
|
|
90
|
+
`- Run: ${input.runId}`,
|
|
91
|
+
`- Reason: ${input.reason}`,
|
|
92
|
+
`- Role: ${input.role} (model ${input.model})`,
|
|
93
|
+
`- Workspace: ${workspaceLine}`,
|
|
94
|
+
"",
|
|
95
|
+
"## Done",
|
|
96
|
+
...bullets(checkpoint.completed, "(no completed turns recorded)"),
|
|
97
|
+
"",
|
|
98
|
+
"## Remaining",
|
|
99
|
+
...bullets(checkpoint.pending, "(nothing recorded — see the run's events)"),
|
|
100
|
+
"",
|
|
101
|
+
"## Files changed",
|
|
102
|
+
...bullets(checkpoint.filesChanged, "(none)"),
|
|
103
|
+
];
|
|
104
|
+
if (workspace.isRepo) {
|
|
105
|
+
// The heading is the warning: these files are NOT inside diff.patch.
|
|
106
|
+
lines.push("", "## Untracked files (not in diff.patch)", ...bullets(workspace.untracked, "(none)"));
|
|
107
|
+
}
|
|
108
|
+
lines.push("", "## Validation still owed", ...bullets(checkpoint.validationPending, "(none)"), "", "## Diff");
|
|
109
|
+
if (!workspace.isRepo) {
|
|
110
|
+
lines.push("Not a git repository — no `diff.patch` was written.");
|
|
111
|
+
}
|
|
112
|
+
else if (!diffWritten) {
|
|
113
|
+
lines.push("Tracked changes could not be captured — `diff.patch` was not written. Run `git diff` yourself.");
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push("Tracked changes are in `diff.patch`. Untracked files are the ones listed above, not in the patch.");
|
|
117
|
+
}
|
|
118
|
+
lines.push("", "## Next step", "", `Continue the task in the same worktree (\`${input.cwd}\`) — pick up from "Remaining" above instead of re-running the worker from scratch.`, "");
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
function bullets(items, empty) {
|
|
122
|
+
return items.length > 0 ? items.map((item) => `- ${item}`) : [`- ${empty}`];
|
|
123
|
+
}
|
|
124
|
+
/** One guard per git question, so a broken answer degrades alone. */
|
|
125
|
+
async function workspaceInfo(cwd, git) {
|
|
126
|
+
let topLevel;
|
|
127
|
+
try {
|
|
128
|
+
// gitTopLevel rejects (rather than resolving) when git itself is missing,
|
|
129
|
+
// so even repo detection is inside the guard.
|
|
130
|
+
topLevel = await gitTopLevel(cwd, git);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
logger.debug(`handoff bundle: repo detection in ${cwd} failed: ${errorMessage(error)}`);
|
|
134
|
+
topLevel = undefined;
|
|
135
|
+
}
|
|
136
|
+
if (topLevel === undefined) {
|
|
137
|
+
return { isRepo: false, repo: null, worktree: null, branch: null, untracked: [] };
|
|
138
|
+
}
|
|
139
|
+
const branch = await currentBranch(cwd, git);
|
|
140
|
+
const untracked = await untrackedFiles(cwd, git);
|
|
141
|
+
return { isRepo: true, repo: path.basename(topLevel), worktree: topLevel, branch, untracked };
|
|
142
|
+
}
|
|
143
|
+
async function currentBranch(cwd, git) {
|
|
144
|
+
try {
|
|
145
|
+
const result = await git(["branch", "--show-current"], cwd);
|
|
146
|
+
if (result.code !== 0) {
|
|
147
|
+
logger.debug(`handoff bundle: branch lookup in ${cwd} exited ${result.code}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Empty output is a detached HEAD — truthful as null, not as "".
|
|
151
|
+
const branch = result.stdout.trim();
|
|
152
|
+
return branch.length > 0 ? branch : null;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
logger.debug(`handoff bundle: branch lookup in ${cwd} failed: ${errorMessage(error)}`);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function untrackedFiles(cwd, git) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await git(["status", "--porcelain", "-z"], cwd);
|
|
162
|
+
if (result.code !== 0) {
|
|
163
|
+
logger.debug(`handoff bundle: status in ${cwd} exited ${result.code}`);
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
// -z: NUL-terminated, unquoted — spaces in filenames survive intact.
|
|
167
|
+
return result.stdout
|
|
168
|
+
.split("\0")
|
|
169
|
+
.filter((entry) => entry.startsWith("?? "))
|
|
170
|
+
.map((entry) => entry.slice(3));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
logger.debug(`handoff bundle: status in ${cwd} failed: ${errorMessage(error)}`);
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function trackedDiff(cwd, git) {
|
|
178
|
+
try {
|
|
179
|
+
const result = await git(["diff"], cwd);
|
|
180
|
+
if (result.code !== 0) {
|
|
181
|
+
logger.debug(`handoff bundle: git diff in ${cwd} exited ${result.code}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return result.stdout;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
logger.debug(`handoff bundle: git diff in ${cwd} failed: ${errorMessage(error)}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function guardedWrite(file, content) {
|
|
192
|
+
try {
|
|
193
|
+
atomicWriteFile(file, content);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
logger.debug(`handoff bundle: writing ${file} failed: ${errorMessage(error)}`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function errorMessage(error) {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ExitCode } from "../core/errors.js";
|
|
2
|
+
import { logger } from "../core/logging.js";
|
|
3
|
+
/**
|
|
4
|
+
* Emit a handoff to the parent session and return its exit code (D2).
|
|
5
|
+
*
|
|
6
|
+
* **stdout carries the JSON and nothing else.** Contract C1 reserves stdout
|
|
7
|
+
* for the run's final answer, and a handed-off run has none — it did not
|
|
8
|
+
* finish. Printing a partial answer next to the JSON would give an
|
|
9
|
+
* orchestrator two things to parse and no way to tell which is authoritative,
|
|
10
|
+
* so the JSON *is* the output of a run that ends this way.
|
|
11
|
+
*
|
|
12
|
+
* 41 and 42 differ only in what already happened: 41 is a preflight refusal
|
|
13
|
+
* that spawned nothing, 42 a live run stopped at a safe boundary with its work
|
|
14
|
+
* preserved in a bundle. Neither is a crash, which is exactly what the
|
|
15
|
+
* orchestrator templates now say.
|
|
16
|
+
*/
|
|
17
|
+
export function writeHandoffResult(streams, result, humanSummary, exitCode = ExitCode.HandoffRequired) {
|
|
18
|
+
streams.stdout.write(JSON.stringify(result) + "\n");
|
|
19
|
+
try {
|
|
20
|
+
streams.stderr.write(humanSummary.join("\n") + "\n");
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
// The machine-readable half already landed; losing the prose must not
|
|
24
|
+
// change the exit code the parent reads.
|
|
25
|
+
logger.debug(`parent handoff: writing the human summary failed: ${errorMessage(error)}`);
|
|
26
|
+
}
|
|
27
|
+
return exitCode;
|
|
28
|
+
}
|
|
29
|
+
/** The `[Router]` block a human reads on stderr when a live run hands off (doc §14, §18). */
|
|
30
|
+
export function handoffSummaryLines(input) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`[Router] handing the task back to the parent session (${input.reason}).`,
|
|
33
|
+
`[Router] run ${input.runId} stopped at a safe boundary; its work is on disk.`,
|
|
34
|
+
];
|
|
35
|
+
if (input.completed.length > 0) {
|
|
36
|
+
lines.push("[Router] done so far:", ...input.completed.map((entry) => `[Router] ${entry}`));
|
|
37
|
+
}
|
|
38
|
+
if (input.pending.length > 0) {
|
|
39
|
+
lines.push("[Router] still to do:", ...input.pending.map((entry) => `[Router] ${entry}`));
|
|
40
|
+
}
|
|
41
|
+
lines.push(input.bundlePath === null
|
|
42
|
+
? "[Router] no bundle was written (nothing had changed on disk yet)."
|
|
43
|
+
: `[Router] bundle: ${input.bundlePath}`, "[Router] continue in the SAME worktree; do not re-run the worker until quota resets.");
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
function errorMessage(error) {
|
|
47
|
+
return error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|