agent-dealer 1.2.2 → 1.2.3
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/bundle/server/dist/capacity/claude-events.js +27 -10
- package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
- package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +203 -16
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +186 -2
- package/bundle/server/dist/capacity/cursor-individual.js +69 -2
- package/bundle/server/dist/capacity/cursor-individual.test.js +268 -2
- package/bundle/server/dist/capacity/muse-host.js +748 -0
- package/bundle/server/dist/capacity/muse-host.test.js +480 -0
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
- package/bundle/server/dist/capacity/muse.js +307 -60
- package/bundle/server/dist/capacity/muse.test.js +8 -51
- package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
- package/bundle/server/dist/index.js +15 -2
- package/bundle/server/dist/routes/index.js +26 -9
- package/bundle/server/dist/routes/runtime-capacity.test.js +12 -6
- package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
- package/bundle/server/dist/runners/muse-serve-session.js +313 -0
- package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
- package/bundle/server/static-ui/assets/{index-DUYzboWh.css → index-CYqRh_-S.css} +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
- package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.js +206 -1
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +246 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-0GDYfbaM.js +0 -60
|
@@ -25,8 +25,19 @@ import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
|
|
|
25
25
|
import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
|
|
26
26
|
import { buildMuseDeveloperInvocation } from "../runners/muse-code-args.js";
|
|
27
27
|
import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
|
|
28
|
-
import { parseMuseRun } from "../runners/muse-code-jsonl.js";
|
|
28
|
+
import { normalizeMuseRun, parseMuseRun, } from "../runners/muse-code-jsonl.js";
|
|
29
|
+
import { runMuseServeTurn } from "../runners/muse-serve-session.js";
|
|
29
30
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
31
|
+
/**
|
|
32
|
+
* NOT-270: execution lane selection. Default is the server-owned serve host
|
|
33
|
+
* (real turns through it are the NOT-269 precondition for 5H/1W capacity).
|
|
34
|
+
* `AGENT_DEALER_MUSE_RUNNER=exec` forces the legacy `muse exec` subprocess
|
|
35
|
+
* lane — the operator escape hatch if the serve lane ever misbehaves in
|
|
36
|
+
* production (capacity then reads honest N/A until serve runs again).
|
|
37
|
+
*/
|
|
38
|
+
function museServeLaneEnabled() {
|
|
39
|
+
return process.env.AGENT_DEALER_MUSE_RUNNER !== "exec";
|
|
40
|
+
}
|
|
30
41
|
/** Worktree-relative home of every per-attempt Muse config/data dir. */
|
|
31
42
|
export const MUSE_ATTEMPT_ROOT = ".dealer-muse";
|
|
32
43
|
/**
|
|
@@ -103,9 +114,136 @@ function splitSpawnLog(raw) {
|
|
|
103
114
|
? { stdout: raw, stderr: "" }
|
|
104
115
|
: { stdout: raw.slice(0, idx), stderr: raw.slice(idx + STDERR_MARKER.length) };
|
|
105
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* NOT-270: reuse real Dealer Muse work as the observation opportunity. After
|
|
119
|
+
* a genuine Muse session ends, kick one bounded, best-effort refresh
|
|
120
|
+
* through the server-owned serve host — the proven safe point for the final
|
|
121
|
+
* `usage/read`. This deliberately bypasses the GET-route throttle
|
|
122
|
+
* (`maybeRefreshMuseCapacityFromHost` shares it): the final read at the
|
|
123
|
+
* safe point is contractual, not opportunistic — a recent Agents-page poll
|
|
124
|
+
* must never skip it. This creates no model turn of its own (the capacity
|
|
125
|
+
* client only ever sends the read-only handshake/`usage/read` allowlist)
|
|
126
|
+
* and never affects the session result. Serve-lane turns observed the host,
|
|
127
|
+
* so this hook is the final read that populates 5H/1W; after an exec-lane
|
|
128
|
+
* fallback the host stays unobserved by construction (the NOT-269
|
|
129
|
+
* structural precondition) and the read stays honest N/A with last-good
|
|
130
|
+
* rows preserved.
|
|
131
|
+
*/
|
|
132
|
+
function refreshMuseCapacityAfterSession() {
|
|
133
|
+
void (async () => {
|
|
134
|
+
try {
|
|
135
|
+
const [{ configuredCapacityRuntimes }, { refreshMuseCapacityFromHost }] = await Promise.all([import("../capacity/service.js"), import("../capacity/muse-host.js")]);
|
|
136
|
+
if (!configuredCapacityRuntimes().includes("muse_code"))
|
|
137
|
+
return;
|
|
138
|
+
await refreshMuseCapacityFromHost();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Best-effort: capacity must never break or delay a session result.
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* NOT-270: run the real developer turn through the server-owned serve host.
|
|
147
|
+
* Returns null when no turn was admitted (host unavailable, session/start
|
|
148
|
+
* or turn/start rejected) — the caller then runs the legacy `muse exec`
|
|
149
|
+
* lane. Never returns null after admission: an admitted turn is real work
|
|
150
|
+
* and its verdict is reported honestly, never retried on the other lane.
|
|
151
|
+
*
|
|
152
|
+
* The serve lane needs no per-attempt XDG config/data dirs: the host runs
|
|
153
|
+
* under its own server-owned home (`prepareMuseServeHome` in
|
|
154
|
+
* capacity/muse-host.ts — same worker settings as the exec lane's
|
|
155
|
+
* per-attempt settings.json plus a symlink to the ambient login, so runner
|
|
156
|
+
* and capacity can never diverge on identity or posture). `onSpawn` is
|
|
157
|
+
* deliberately not called: there is no per-attempt pid to persist, and
|
|
158
|
+
* recovery must never treat the shared host as an attempt process.
|
|
159
|
+
*/
|
|
160
|
+
async function runMuseServeLane(input, model, onAdmitted) {
|
|
161
|
+
const { getMuseCapacityHost } = await import("../capacity/muse-host.js");
|
|
162
|
+
// Only this call may fall back: it resolves `admitted:false` (or throws)
|
|
163
|
+
// strictly before any model work starts. Everything below runs after
|
|
164
|
+
// admission — the caller's no-double-execution rule (via onAdmitted)
|
|
165
|
+
// keeps those errors loud instead of falling back to exec.
|
|
166
|
+
const turn = await runMuseServeTurn({
|
|
167
|
+
host: getMuseCapacityHost(),
|
|
168
|
+
prompt: input.prompt,
|
|
169
|
+
model,
|
|
170
|
+
cwd: input.cwd,
|
|
171
|
+
timeoutMs: input.timeoutMs,
|
|
172
|
+
signal: input.signal,
|
|
173
|
+
onAdmitted: () => onAdmitted(),
|
|
174
|
+
});
|
|
175
|
+
if (!turn.admitted)
|
|
176
|
+
return null;
|
|
177
|
+
const cronCalls = turn.tools
|
|
178
|
+
.map((t) => t.name)
|
|
179
|
+
.filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
180
|
+
const summary = {
|
|
181
|
+
museSessionId: turn.sessionId ?? randomUUID(),
|
|
182
|
+
confirmedModel: turn.confirmedModel,
|
|
183
|
+
usage: turn.usage,
|
|
184
|
+
failure: turn.failure,
|
|
185
|
+
cronCalls,
|
|
186
|
+
runCount: 1,
|
|
187
|
+
rawLogPath: null,
|
|
188
|
+
};
|
|
189
|
+
// Same normalized evidence both lanes write: the raw folded history, then
|
|
190
|
+
// the Claude/Cursor-shaped stream every existing log reader consumes.
|
|
191
|
+
const rawLogPath = `${input.logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
|
|
192
|
+
try {
|
|
193
|
+
fs.writeFileSync(rawLogPath, `${turn.viewItems.map((i) => JSON.stringify(i)).join("\n")}\n`);
|
|
194
|
+
summary.rawLogPath = rawLogPath;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// evidence copy is best-effort; the normalized log below still carries the outcome
|
|
198
|
+
}
|
|
199
|
+
const events = normalizeMuseRun({
|
|
200
|
+
sessionId: summary.museSessionId,
|
|
201
|
+
confirmedModel: turn.confirmedModel,
|
|
202
|
+
tools: turn.tools,
|
|
203
|
+
finalText: turn.finalText,
|
|
204
|
+
usage: turn.usage,
|
|
205
|
+
failure: turn.failure,
|
|
206
|
+
}).map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: turn.usage } } : e);
|
|
207
|
+
fs.writeFileSync(input.logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n`);
|
|
208
|
+
// The real session above is the observation opportunity: kick the
|
|
209
|
+
// best-effort capacity refresh (no synthetic turn, never throws, never
|
|
210
|
+
// blocks this result).
|
|
211
|
+
refreshMuseCapacityAfterSession();
|
|
212
|
+
// A failed session must not read as success. 124 mirrors spawn-cli's
|
|
213
|
+
// timeout exit so timeout handling downstream sees the same signal.
|
|
214
|
+
const exitCode = turn.timedOut ? 124 : turn.failure ? 1 : turn.terminal === "completed" ? 0 : 1;
|
|
215
|
+
return {
|
|
216
|
+
exitCode,
|
|
217
|
+
transcript: turn.finalText ?? "",
|
|
218
|
+
logPath: input.logPath,
|
|
219
|
+
timedOut: turn.timedOut,
|
|
220
|
+
muse: summary,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
106
223
|
export async function runMuseDeveloperSession(input) {
|
|
107
224
|
const model = input.model ?? MUSE_CODE_CONTRIBUTOR_MODEL;
|
|
108
225
|
const museSessionId = randomUUID();
|
|
226
|
+
// Serve-first: real turns through the owned host observe it (NOT-269
|
|
227
|
+
// precondition for 5H/1W). Pre-admission failure falls back to exec; an
|
|
228
|
+
// admitted turn never falls back — its errors propagate loudly instead of
|
|
229
|
+
// executing the work a second time on the exec lane.
|
|
230
|
+
// `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
|
|
231
|
+
// (operator escape hatch).
|
|
232
|
+
if (museServeLaneEnabled()) {
|
|
233
|
+
let admitted = false;
|
|
234
|
+
try {
|
|
235
|
+
const served = await runMuseServeLane(input, model, () => {
|
|
236
|
+
admitted = true;
|
|
237
|
+
});
|
|
238
|
+
if (served)
|
|
239
|
+
return served;
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (admitted)
|
|
243
|
+
throw err;
|
|
244
|
+
// Pre-admission serve-lane error: fall through to the exec lane below.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
109
247
|
const invocation = buildMuseDeveloperInvocation({
|
|
110
248
|
model,
|
|
111
249
|
maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
|
|
@@ -159,6 +297,10 @@ export async function runMuseDeveloperSession(input) {
|
|
|
159
297
|
}
|
|
160
298
|
const events = run.events.map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: run.usage } } : e);
|
|
161
299
|
fs.writeFileSync(logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n${stderr.trim() ? `${STDERR_MARKER}${stderr}` : ""}`);
|
|
300
|
+
// The real session above is the observation opportunity: kick the
|
|
301
|
+
// best-effort capacity refresh (no synthetic turn, never throws, never
|
|
302
|
+
// blocks this result).
|
|
303
|
+
refreshMuseCapacityAfterSession();
|
|
162
304
|
return {
|
|
163
305
|
// A failed session must not read as success just because Muse exited 0 (wrong model, no terminal event).
|
|
164
306
|
exitCode: run.failure && spawned.exitCode === 0 ? 1 : spawned.exitCode,
|
|
@@ -46,13 +46,26 @@ async function main() {
|
|
|
46
46
|
"Stop it first, or point AGENT_DEALER_HOME at a different directory.");
|
|
47
47
|
process.exit(1);
|
|
48
48
|
}
|
|
49
|
+
// NOT-270: the server-owned Muse capacity host (`muse serve` child) must
|
|
50
|
+
// be released on shutdown — it is spawned with piped stdio, is not
|
|
51
|
+
// detached, and must not be assumed to exit on stdin EOF. Every handler
|
|
52
|
+
// below shuts it down before exiting so no host process leaks.
|
|
53
|
+
const shutdownCapacityHosts = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const { shutdownMuseCapacityHost } = await import("./capacity/muse-host.js");
|
|
56
|
+
await shutdownMuseCapacityHost();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Best-effort: shutdown must never block process exit.
|
|
60
|
+
}
|
|
61
|
+
};
|
|
49
62
|
process.on("SIGINT", () => {
|
|
50
63
|
removeServerPidFile();
|
|
51
|
-
process.exit(0);
|
|
64
|
+
void shutdownCapacityHosts().finally(() => process.exit(0));
|
|
52
65
|
});
|
|
53
66
|
process.on("SIGTERM", () => {
|
|
54
67
|
removeServerPidFile();
|
|
55
|
-
process.exit(0);
|
|
68
|
+
void shutdownCapacityHosts().finally(() => process.exit(0));
|
|
56
69
|
});
|
|
57
70
|
process.on("exit", removeServerPidFile);
|
|
58
71
|
migrate();
|
|
@@ -7,7 +7,8 @@ import { listLinearCandidates, lookupLinearIssue, parseLinearIssueRef, } from ".
|
|
|
7
7
|
import { getLinearUsageSnapshot } from "../adapters/linear-graphql.js";
|
|
8
8
|
import { listRuntimeModels } from "../runners/models.js";
|
|
9
9
|
import { configuredCapacityRuntimes, getRuntimeCapacitySnapshot } from "../capacity/service.js";
|
|
10
|
-
import {
|
|
10
|
+
import { refreshClaudeCapacityIfStale } from "../capacity/claude-local-cache.js";
|
|
11
|
+
import { maybeRefreshMuseCapacityFromHost } from "../capacity/muse-host.js";
|
|
11
12
|
import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
|
|
12
13
|
import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
|
|
13
14
|
import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
|
|
@@ -62,22 +63,38 @@ export async function registerRoutes(app) {
|
|
|
62
63
|
// NOT-245: provider-neutral capacity read model — one entry per configured
|
|
63
64
|
// runtime account with its windows, freshness, and explicit unavailable
|
|
64
65
|
// reasons. Normalized snapshots only; evidence stays server-side.
|
|
65
|
-
// NOT-
|
|
66
|
-
// (default 5 min), bounded, best-effort refresh
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
66
|
+
// NOT-270: the only production trigger for Muse capacity — a throttled
|
|
67
|
+
// (default 5 min), bounded, best-effort refresh through the server-owned
|
|
68
|
+
// long-lived `muse serve` host when `muse_code` is configured. The
|
|
69
|
+
// refresh runs in the background without blocking the read: GET serves
|
|
70
|
+
// the last-known snapshot immediately so a slow or hanging host (bounded
|
|
71
|
+
// by the capacity timeout) can never stall the Agents page. Failures
|
|
72
|
+
// preserve last-good rows as N/A and never fail the read.
|
|
71
73
|
// NOT-246: on-demand Codex refresh — when the stored Codex snapshot is
|
|
72
74
|
// stale the read triggers one bounded, non-billable App Server poll
|
|
73
75
|
// (single-flight, never health rows, never throws); fresh snapshots and
|
|
74
76
|
// runtimes without a configured Codex account serve stored data with no
|
|
75
77
|
// subprocess.
|
|
78
|
+
// NOT-268: Claude local-first ladder — one background refresh ingests
|
|
79
|
+
// Claude Code's own free cache (plain file read, never a spawn) and then
|
|
80
|
+
// considers the paid probe without blocking the read: a bounded Haiku
|
|
81
|
+
// probe fires at most once per hour and only under the explicit
|
|
82
|
+
// `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=paid-after-1h` opt-in when every
|
|
83
|
+
// valid 5H/1W observation is older than 60 minutes. Disabled is a strict
|
|
84
|
+
// no-op (no spawn, no spend). Failures never break the read below.
|
|
76
85
|
app.get("/api/runtime-capacity", async () => {
|
|
86
|
+
try {
|
|
87
|
+
void refreshClaudeCapacityIfStale().catch(() => {
|
|
88
|
+
// Best-effort: failures persist via the probe diagnostic log.
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Best-effort: serve the last-known snapshot below.
|
|
93
|
+
}
|
|
77
94
|
try {
|
|
78
95
|
if (configuredCapacityRuntimes().includes("muse_code")) {
|
|
79
|
-
void
|
|
80
|
-
// Best-effort: failures
|
|
96
|
+
void maybeRefreshMuseCapacityFromHost().catch(() => {
|
|
97
|
+
// Best-effort: failures preserve last-good rows via the ingest path.
|
|
81
98
|
});
|
|
82
99
|
}
|
|
83
100
|
}
|
|
@@ -15,12 +15,17 @@ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
|
|
|
15
15
|
// Never spawn a real provider from route tests: the on-demand Codex refresh is
|
|
16
16
|
// covered against the fake App Server in codex-app-server.test.ts.
|
|
17
17
|
process.env.AGENT_DEALER_CODEX_CAPACITY_REFRESH = "off";
|
|
18
|
+
// NOT-268: the route also ingests the real ~/.claude.json cache on every read. Point it at a
|
|
19
|
+
// path that never exists so a developer's own Claude usage never leaks extra windows into the
|
|
20
|
+
// fixture-controlled assertions below (this passed in CI, which has no such file, but failed
|
|
21
|
+
// deterministically on any machine that actually uses Claude Code).
|
|
22
|
+
process.env.AGENT_DEALER_CLAUDE_CACHE_FILE = path.join(process.env.AGENT_DEALER_HOME, "no-such-claude-cache.json");
|
|
18
23
|
const { migrate } = await import("../db/index.js");
|
|
19
24
|
const { createAgent } = await import("../repository/agents.js");
|
|
20
25
|
const { clearAllCapacitySnapshots, listCapacitySnapshots, recordCapacitySnapshots } = await import("../repository/runtime-capacity.js");
|
|
21
26
|
const { fixtureMultiWindowAdapter, normalizeAdapterWindow } = await import("../capacity/adapter.js");
|
|
22
27
|
const { refreshCapacityFromAdapters } = await import("../capacity/service.js");
|
|
23
|
-
const {
|
|
28
|
+
const { resetMuseCapacityHostForTests } = await import("../capacity/muse-host.js");
|
|
24
29
|
const { refreshCodexCapacityFromAppServer } = await import("../capacity/codex-app-server.js");
|
|
25
30
|
const { registerRoutes } = await import("./index.js");
|
|
26
31
|
const { RuntimeCapacityResponse } = await import("@agent-dealer/shared");
|
|
@@ -58,11 +63,12 @@ test("GET /api/runtime-capacity returns normalized entries without evidence", as
|
|
|
58
63
|
await app.close();
|
|
59
64
|
});
|
|
60
65
|
test("GET triggers the Muse refresh when muse_code is configured", async () => {
|
|
61
|
-
// NOT-
|
|
62
|
-
// No credential here (env key removed, empty login dir),
|
|
63
|
-
// short-circuits to a `missing` sentinel without spawning
|
|
66
|
+
// NOT-270: the route is the only production trigger for Muse capacity
|
|
67
|
+
// (owned host). No credential here (env key removed, empty login dir),
|
|
68
|
+
// so the refresh short-circuits to a `missing` sentinel without spawning
|
|
69
|
+
// anything live.
|
|
64
70
|
clearAllCapacitySnapshots();
|
|
65
|
-
|
|
71
|
+
await resetMuseCapacityHostForTests();
|
|
66
72
|
const prevKey = process.env.META_API_KEY;
|
|
67
73
|
const prevXdg = process.env.XDG_CONFIG_HOME;
|
|
68
74
|
delete process.env.META_API_KEY;
|
|
@@ -103,7 +109,7 @@ test("GET triggers the Muse refresh when muse_code is configured", async () => {
|
|
|
103
109
|
delete process.env.XDG_CONFIG_HOME;
|
|
104
110
|
else
|
|
105
111
|
process.env.XDG_CONFIG_HOME = prevXdg;
|
|
106
|
-
|
|
112
|
+
await resetMuseCapacityHostForTests();
|
|
107
113
|
}
|
|
108
114
|
});
|
|
109
115
|
test("GET serves one logical Codex pair after a dedup refresh of legacy duplicates", async () => {
|
|
@@ -252,7 +252,13 @@ export function parseMuseRun(input) {
|
|
|
252
252
|
events: normalizeMuseRun({ sessionId, confirmedModel, tools: toolList, finalText, usage, failure }),
|
|
253
253
|
};
|
|
254
254
|
}
|
|
255
|
-
|
|
255
|
+
/**
|
|
256
|
+
* NOT-270: shared by the exec parser above and the serve execution lane
|
|
257
|
+
* (runners/muse-serve-session.ts) so both runners write byte-identical
|
|
258
|
+
* normalized log evidence — every downstream log reader works unchanged
|
|
259
|
+
* whichever lane ran the session.
|
|
260
|
+
*/
|
|
261
|
+
export function normalizeMuseRun(r) {
|
|
256
262
|
const out = [];
|
|
257
263
|
if (r.sessionId) {
|
|
258
264
|
out.push({
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// packages/server/src/runners/muse-serve-session.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-270: run a real Dealer Muse developer turn through the server-owned
|
|
4
|
+
// `muse serve` host (the NOT-269 proven lifecycle). Only a host that
|
|
5
|
+
// observes the account's provider traffic can answer `usage/read`, so the
|
|
6
|
+
// observation opportunity IS the real session: `session/start` +
|
|
7
|
+
// `turn/start` on the owned host, then the session-boundary `usage/read`
|
|
8
|
+
// (the existing refresh hook) populates 5H/1W. No synthetic prompt is ever
|
|
9
|
+
// sent — the turn below carries the genuine Dealer developer prompt.
|
|
10
|
+
//
|
|
11
|
+
// Wire contract (stable MSP, Muse 1.4.x):
|
|
12
|
+
// session/start {commandId UUIDv7, workspaceRoot, modelId, approvalMode}
|
|
13
|
+
// turn/start {commandId UUIDv7, sessionId, input:[{type:"text",text}]}
|
|
14
|
+
// await turn/completed {sessionId, terminal, usage, error?}
|
|
15
|
+
// session/read {sessionId, excludeItems:false} for transcript + tools
|
|
16
|
+
// turn/cancel on timeout/abort only
|
|
17
|
+
// Forbidden on this lane (asserted by the host): `session/resume`
|
|
18
|
+
// (state-changing, carries no usage — NOT-269 leg 2), `turn/steer`,
|
|
19
|
+
// approval writes. A capacity read never touches this lane.
|
|
20
|
+
//
|
|
21
|
+
// Posture vs `muse exec` (NOT-270 scope decision, recorded in
|
|
22
|
+
// capacity/muse-host.ts): the decision accepts exactly two gaps —
|
|
23
|
+
// `--disable-web-tools` and `--no-foreign-personal-context` have no wire-
|
|
24
|
+
// or host-level equivalent in this Muse version (1.4.0). Network sandbox
|
|
25
|
+
// parity is exact (the owned host starts with `--sandbox-network
|
|
26
|
+
// restricted`, the same constant every exec invocation passes) and worker
|
|
27
|
+
// posture matches via the host's server-owned config home (no MCP servers,
|
|
28
|
+
// no subagents/workflows/reminders, same as the exec per-attempt settings).
|
|
29
|
+
// Known deltas OUTSIDE the recorded decision (not accepted — need product
|
|
30
|
+
// sign-off or a wire equivalent): approvalMode `denyUnmatched` stands in
|
|
31
|
+
// for `--approval-mode never` (the wire enum has no `never`;
|
|
32
|
+
// never-prompts behavior unverified live), and `--approval-judge off` /
|
|
33
|
+
// `--max-model-steps` have no wire equivalent at all — a runaway loop is
|
|
34
|
+
// bounded only by the attempt wall-clock timeout + `turn/cancel` here.
|
|
35
|
+
// Re-verify all of the above with a free `--provider echo` session/turn;
|
|
36
|
+
// the production smoke check on Muse 1.4.x remains.
|
|
37
|
+
//
|
|
38
|
+
// Admission rule: anything that fails BEFORE the turn is admitted
|
|
39
|
+
// (host unavailable, session/start rejected, turn/start rejected) returns
|
|
40
|
+
// `admitted:false` having started no model work — the caller falls back to
|
|
41
|
+
// the legacy `muse exec` subprocess. After admission there is no fallback:
|
|
42
|
+
// the turn is real, billable work and its verdict is reported honestly.
|
|
43
|
+
import { museUuidv7 } from "../capacity/muse.js";
|
|
44
|
+
export const MUSE_SERVE_APPROVAL_MODE = "denyUnmatched";
|
|
45
|
+
/** Monotonic suffix so concurrent turns never share a JSON-RPC request id. */
|
|
46
|
+
let serveReqSeq = 0;
|
|
47
|
+
function serveReqId(prefix) {
|
|
48
|
+
serveReqSeq += 1;
|
|
49
|
+
return `muse-${prefix}-${Date.now()}-${serveReqSeq}`;
|
|
50
|
+
}
|
|
51
|
+
function asRecord(v) {
|
|
52
|
+
return v && typeof v === "object" && !Array.isArray(v)
|
|
53
|
+
? v
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
56
|
+
function asString(v) {
|
|
57
|
+
return typeof v === "string" ? v : undefined;
|
|
58
|
+
}
|
|
59
|
+
function asFiniteNumber(v) {
|
|
60
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
61
|
+
}
|
|
62
|
+
const AUTH_RE = /login is no longer valid|authentication failed|missing \w+ credentials|api key .*rejected|unauthenticated|unauthorized|unauthori[sz]ed|not (signed|logged) in|sign in|\b401\b|\b403\b|forbidden|authRequired/i;
|
|
63
|
+
const USAGE_CAP_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|\b429\b|rate_limited/i;
|
|
64
|
+
const STEPS_RE = /stepLimit|did not reach a terminal state within \d+ step/i;
|
|
65
|
+
function nullUsage() {
|
|
66
|
+
return {
|
|
67
|
+
inputTokens: null,
|
|
68
|
+
outputTokens: null,
|
|
69
|
+
cacheReadTokens: null,
|
|
70
|
+
cacheWriteTokens: null,
|
|
71
|
+
reasoningTokens: null,
|
|
72
|
+
modelDurationMs: null,
|
|
73
|
+
costUsd: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function usageFromTokenUsage(u, durationMs) {
|
|
77
|
+
const base = nullUsage();
|
|
78
|
+
const r = asRecord(u);
|
|
79
|
+
if (!r)
|
|
80
|
+
return { ...base, modelDurationMs: durationMs };
|
|
81
|
+
return {
|
|
82
|
+
inputTokens: asFiniteNumber(r.inputTokens),
|
|
83
|
+
outputTokens: asFiniteNumber(r.outputTokens),
|
|
84
|
+
cacheReadTokens: asFiniteNumber(r.cacheReadTokens),
|
|
85
|
+
cacheWriteTokens: asFiniteNumber(r.cacheWriteTokens),
|
|
86
|
+
reasoningTokens: asFiniteNumber(r.reasoningTokens),
|
|
87
|
+
modelDurationMs: durationMs,
|
|
88
|
+
costUsd: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function classifyTurnError(kind, message) {
|
|
92
|
+
const k = typeof kind === "string" ? kind : "";
|
|
93
|
+
const m = typeof message === "string" && message ? message : k || "turn failed";
|
|
94
|
+
const text = `${k} ${m}`;
|
|
95
|
+
if (STEPS_RE.test(text))
|
|
96
|
+
return { kind: "max_steps", message: m.slice(0, 500) };
|
|
97
|
+
if (AUTH_RE.test(text))
|
|
98
|
+
return { kind: "auth", message: m.slice(0, 500) };
|
|
99
|
+
if (USAGE_CAP_RE.test(text))
|
|
100
|
+
return { kind: "usage_cap", message: m.slice(0, 500) };
|
|
101
|
+
return { kind: "other", message: m.slice(0, 500) };
|
|
102
|
+
}
|
|
103
|
+
function unadmitted() {
|
|
104
|
+
return {
|
|
105
|
+
admitted: false,
|
|
106
|
+
sessionId: null,
|
|
107
|
+
terminal: null,
|
|
108
|
+
finalText: null,
|
|
109
|
+
confirmedModel: null,
|
|
110
|
+
tools: [],
|
|
111
|
+
usage: nullUsage(),
|
|
112
|
+
failure: null,
|
|
113
|
+
timedOut: false,
|
|
114
|
+
durationMs: null,
|
|
115
|
+
viewItems: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Fold `session/read` history items into transcript/tools/confirmed-model.
|
|
120
|
+
* Unknown kinds are ignored (schema says clients must render them
|
|
121
|
+
* generically; the runner only needs text + tool names). Items from other
|
|
122
|
+
* turns are skipped when `turnId` is present.
|
|
123
|
+
*/
|
|
124
|
+
export function foldServeViewItems(items, turnId) {
|
|
125
|
+
const texts = [];
|
|
126
|
+
const tools = [];
|
|
127
|
+
if (!Array.isArray(items))
|
|
128
|
+
return { finalText: null, tools };
|
|
129
|
+
for (const raw of items) {
|
|
130
|
+
const it = asRecord(raw);
|
|
131
|
+
if (!it)
|
|
132
|
+
continue;
|
|
133
|
+
if (turnId !== null && it.turnId !== undefined && it.turnId !== turnId)
|
|
134
|
+
continue;
|
|
135
|
+
if (typeof it.kind !== "string")
|
|
136
|
+
continue;
|
|
137
|
+
if (it.kind === "agentMessage") {
|
|
138
|
+
const t = asString(it.text);
|
|
139
|
+
if (t)
|
|
140
|
+
texts.push(t);
|
|
141
|
+
}
|
|
142
|
+
else if (it.kind === "toolCall") {
|
|
143
|
+
const name = asString(it.tool);
|
|
144
|
+
const callId = asString(it.callId) ?? `tool-${tools.length}`;
|
|
145
|
+
const failed = it.failureKind !== undefined ||
|
|
146
|
+
it.failureReason !== undefined ||
|
|
147
|
+
it.status === "failed";
|
|
148
|
+
tools.push({
|
|
149
|
+
callId,
|
|
150
|
+
name: name ?? null,
|
|
151
|
+
outcome: it.status === "inProgress" ? null : failed ? "failure" : "success",
|
|
152
|
+
error: asString(it.failureReason) ?? null,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const finalText = texts.length > 0 ? texts.join("\n") : null;
|
|
157
|
+
return { finalText, tools };
|
|
158
|
+
}
|
|
159
|
+
function confirmedModelFromSession(session) {
|
|
160
|
+
const r = asRecord(session);
|
|
161
|
+
if (!r)
|
|
162
|
+
return null;
|
|
163
|
+
return asString(r.modelId) ?? null;
|
|
164
|
+
}
|
|
165
|
+
export async function runMuseServeTurn(input) {
|
|
166
|
+
if (!input.model.trim())
|
|
167
|
+
throw new Error("muse: model must be set explicitly");
|
|
168
|
+
if (!input.prompt.trim())
|
|
169
|
+
throw new Error("muse: prompt must not be empty");
|
|
170
|
+
if (input.prompt.startsWith("-"))
|
|
171
|
+
throw new Error("muse: prompt must not start with '-'");
|
|
172
|
+
const { host } = input;
|
|
173
|
+
const rpcTimeoutMs = Math.min(input.rpcTimeoutMs ?? 30_000, input.timeoutMs);
|
|
174
|
+
const started = await host.ensureStarted();
|
|
175
|
+
if (!started)
|
|
176
|
+
return unadmitted();
|
|
177
|
+
const startReply = await host.execRequest(serveReqId("serve-session"), "session/start", {
|
|
178
|
+
commandId: museUuidv7(),
|
|
179
|
+
workspaceRoot: input.cwd,
|
|
180
|
+
modelId: input.model,
|
|
181
|
+
approvalMode: MUSE_SERVE_APPROVAL_MODE,
|
|
182
|
+
}, rpcTimeoutMs);
|
|
183
|
+
if (!startReply || startReply.error)
|
|
184
|
+
return unadmitted();
|
|
185
|
+
const session = asRecord(startReply.result)?.session;
|
|
186
|
+
const sessionId = asRecord(session)?.sessionId;
|
|
187
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
188
|
+
return unadmitted();
|
|
189
|
+
let confirmedModel = confirmedModelFromSession(session);
|
|
190
|
+
const turnReply = await host.execRequest(serveReqId("serve-turn"), "turn/start", {
|
|
191
|
+
commandId: museUuidv7(),
|
|
192
|
+
sessionId,
|
|
193
|
+
input: [{ type: "text", text: input.prompt }],
|
|
194
|
+
}, rpcTimeoutMs);
|
|
195
|
+
if (!turnReply || turnReply.error)
|
|
196
|
+
return unadmitted();
|
|
197
|
+
// The turn is admitted from here: real, billable work may be running.
|
|
198
|
+
// No exec fallback past this point — every exit below reports honestly.
|
|
199
|
+
try {
|
|
200
|
+
input.onAdmitted?.(sessionId);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// The admission flag is advisory; a throwing listener must not break the turn.
|
|
204
|
+
}
|
|
205
|
+
const turnId = asString(asRecord(turnReply.result)?.turnId) ?? null;
|
|
206
|
+
let aborted = false;
|
|
207
|
+
const onAbort = () => {
|
|
208
|
+
aborted = true;
|
|
209
|
+
};
|
|
210
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
211
|
+
const deadlineMs = Date.now() + input.timeoutMs;
|
|
212
|
+
let completed = null;
|
|
213
|
+
try {
|
|
214
|
+
while (completed === null) {
|
|
215
|
+
if (aborted)
|
|
216
|
+
break;
|
|
217
|
+
// Host death resolves waiters null — re-subscribing on a dead host
|
|
218
|
+
// would spin until the deadline, so stop waiting and settle below.
|
|
219
|
+
if (!host.isConnected())
|
|
220
|
+
break;
|
|
221
|
+
const remaining = deadlineMs - Date.now();
|
|
222
|
+
if (remaining <= 0)
|
|
223
|
+
break;
|
|
224
|
+
completed = await host.waitForHostNotification((m) => m.method === "turn/completed" && asRecord(m.params)?.sessionId === sessionId, Math.min(remaining, 30_000));
|
|
225
|
+
// waitForHostNotification resolves null on chunk timeout too — loop
|
|
226
|
+
// until the turn completes or the deadline passes. (Subscribe happens
|
|
227
|
+
// microseconds after the turn/start ack while a real completion takes
|
|
228
|
+
// seconds, so no completion can slip between admit and subscribe.)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
233
|
+
}
|
|
234
|
+
const timedOut = completed === null && !aborted;
|
|
235
|
+
if (completed === null) {
|
|
236
|
+
// Timeout or abort: cancel the turn, then take whatever terminal the
|
|
237
|
+
// host settles on (usually `cancelled`) as the honest record.
|
|
238
|
+
await host.cancelExecTurn(sessionId, turnId ?? undefined);
|
|
239
|
+
completed = await host.waitForHostNotification((m) => m.method === "turn/completed" && asRecord(m.params)?.sessionId === sessionId, 10_000);
|
|
240
|
+
}
|
|
241
|
+
const params = asRecord(completed?.params);
|
|
242
|
+
const terminalRaw = asString(params?.terminal);
|
|
243
|
+
const terminal = terminalRaw === "completed" || terminalRaw === "failed" || terminalRaw === "cancelled"
|
|
244
|
+
? terminalRaw
|
|
245
|
+
: null;
|
|
246
|
+
const turnError = asRecord(params?.error);
|
|
247
|
+
const durationMs = asFiniteNumber(params?.durationMs);
|
|
248
|
+
const usage = usageFromTokenUsage(params?.usage, durationMs);
|
|
249
|
+
// Folded history for transcript + tool activity (best-effort: a turn whose
|
|
250
|
+
// history cannot be read still reports its terminal + usage honestly).
|
|
251
|
+
let finalText = null;
|
|
252
|
+
let tools = [];
|
|
253
|
+
let viewItems = [];
|
|
254
|
+
try {
|
|
255
|
+
const readReply = await host.execRequest(serveReqId("serve-read"), "session/read", { sessionId, excludeItems: false }, rpcTimeoutMs);
|
|
256
|
+
const readResult = asRecord(readReply?.result);
|
|
257
|
+
const readSession = readResult?.session;
|
|
258
|
+
confirmedModel = confirmedModelFromSession(readSession) ?? confirmedModel;
|
|
259
|
+
const history = asRecord(readResult?.history);
|
|
260
|
+
const items = history?.items;
|
|
261
|
+
if (Array.isArray(items))
|
|
262
|
+
viewItems = items;
|
|
263
|
+
const folded = foldServeViewItems(items, turnId);
|
|
264
|
+
finalText = folded.finalText;
|
|
265
|
+
tools = folded.tools;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Best-effort: keep terminal + usage.
|
|
269
|
+
}
|
|
270
|
+
let failure = null;
|
|
271
|
+
if (aborted && terminal !== "completed") {
|
|
272
|
+
failure = { kind: "other", message: "aborted" };
|
|
273
|
+
}
|
|
274
|
+
else if (terminal === "failed") {
|
|
275
|
+
failure = classifyTurnError(turnError?.kind, turnError?.message);
|
|
276
|
+
}
|
|
277
|
+
else if (terminal === "cancelled") {
|
|
278
|
+
failure = timedOut
|
|
279
|
+
? { kind: "other", message: "turn timed out and was cancelled" }
|
|
280
|
+
: { kind: "other", message: `turn cancelled${asString(params?.reason) ? `: ${asString(params?.reason)}` : ""}`.slice(0, 500) };
|
|
281
|
+
}
|
|
282
|
+
else if (terminal === "completed") {
|
|
283
|
+
const wrong = confirmedModel !== null && confirmedModel !== input.model;
|
|
284
|
+
if (wrong) {
|
|
285
|
+
failure = {
|
|
286
|
+
kind: "model_mismatch",
|
|
287
|
+
message: `configured ${input.model}, server confirmed ${confirmedModel}`.slice(0, 500),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
else if (confirmedModel === null) {
|
|
291
|
+
failure = { kind: "model_mismatch", message: `server did not confirm model ${input.model}` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// No completion observed even after cancel (host died mid-turn).
|
|
296
|
+
failure = timedOut
|
|
297
|
+
? { kind: "other", message: "turn timed out with no completion observed" }
|
|
298
|
+
: { kind: "other", message: "no turn completion observed" };
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
admitted: true,
|
|
302
|
+
sessionId,
|
|
303
|
+
terminal,
|
|
304
|
+
finalText: finalText === "" ? null : finalText,
|
|
305
|
+
confirmedModel,
|
|
306
|
+
tools,
|
|
307
|
+
usage,
|
|
308
|
+
failure,
|
|
309
|
+
timedOut,
|
|
310
|
+
durationMs,
|
|
311
|
+
viewItems,
|
|
312
|
+
};
|
|
313
|
+
}
|