agent-dealer 1.2.4 → 1.2.6
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/adapters/agent-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +9 -1
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -70,6 +70,10 @@ function sessionLooksLive(session) {
|
|
|
70
70
|
*
|
|
71
71
|
* Returns alive if **any** candidate session for this path is live — basename predecessor
|
|
72
72
|
* and every `worktree_path` match (see {@link listWorkerSessionsOwningWorktree}).
|
|
73
|
+
*
|
|
74
|
+
* NOT-280: a dead verdict names the most recently created candidate — the predecessor
|
|
75
|
+
* whose dirt a resume salvage is attributed to. No candidate at all stays a bare dead
|
|
76
|
+
* verdict (unknown owner), which keeps dirt on the fail-closed conflict path.
|
|
73
77
|
*/
|
|
74
78
|
export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
|
|
75
79
|
const candidates = listWorkerSessionsOwningWorktree(worktreePath);
|
|
@@ -78,5 +82,6 @@ export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
|
|
|
78
82
|
return { state: "alive", sessionId: session.id };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
|
-
|
|
85
|
+
const latest = [...candidates].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
|
86
|
+
return latest ? { state: "dead", sessionId: latest.id } : { state: "dead" };
|
|
82
87
|
}
|
|
@@ -137,7 +137,8 @@ test("checkDeveloperWorktreeOwnerLiveness: terminal session with a dead pid is d
|
|
|
137
137
|
patchRunningSession(session.id, { worktreePath: wt });
|
|
138
138
|
recordSessionProcess(session.id, pid, COORDINATOR_PROCESS_OWNER, startTime);
|
|
139
139
|
completeSession(session.id, { status: "failed", errorJson: JSON.stringify({ reason: "gone" }) });
|
|
140
|
-
|
|
140
|
+
// NOT-280: a proven-dead owner is named so a resume salvage can be tied to it.
|
|
141
|
+
assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead", sessionId: session.id });
|
|
141
142
|
});
|
|
142
143
|
test("checkDeveloperWorktreeOwnerLiveness: unknown path with no session is dead", () => {
|
|
143
144
|
assert.deepEqual(checkDeveloperWorktreeOwnerLiveness("/repo/.agent-dealer-worktrees/no-such-session-developer"), { state: "dead" });
|
|
@@ -144,6 +144,19 @@ export function updateOpenHumanAction(id, patch) {
|
|
|
144
144
|
.run(patch.reason ?? null, patch.question ?? null, patch.evidence !== undefined ? JSON.stringify(patch.evidence) : null, id);
|
|
145
145
|
return getHumanAction(id);
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* NOT-280: put a resolved action back to open, unchanged — used when a Resume ran into the
|
|
149
|
+
* exact same blocker again, so the operator keeps one action instead of an identical copy.
|
|
150
|
+
* Only touches resolved rows; returns null otherwise.
|
|
151
|
+
*/
|
|
152
|
+
export function reopenHumanAction(id) {
|
|
153
|
+
const info = getDb()
|
|
154
|
+
.prepare(`UPDATE human_actions SET
|
|
155
|
+
status = 'open', resolution_json = NULL, resolved_by = NULL, resolved_at = NULL
|
|
156
|
+
WHERE id = ? AND status = 'resolved'`)
|
|
157
|
+
.run(id);
|
|
158
|
+
return info.changes === 0 ? null : getHumanAction(id);
|
|
159
|
+
}
|
|
147
160
|
/** Run-scoped equivalent of `findOpenHumanAction` (NOT-95 — outbound-draft delivery
|
|
148
161
|
* parking has no Issue). */
|
|
149
162
|
export function findOpenHumanActionForRun(runId, actionType) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getHumanAction, listOpenHumanActions } from "../repository/human-actions.js";
|
|
2
2
|
import { resolveHumanActionAndAdvanceAsync } from "../coordinator/commands.js";
|
|
3
|
+
import { normalizeResolutionNote } from "../coordinator/human-resolution.js";
|
|
3
4
|
import { triggerIssueReflect, resolveReflectionInteractionAction } from "../coordinator/reflect-trigger.js";
|
|
4
5
|
import { resolveOutboundDeliveryAction } from "../queue/approve-deliver.js";
|
|
5
6
|
export async function registerHumanActionRoutes(app) {
|
|
@@ -14,6 +15,12 @@ export async function registerHumanActionRoutes(app) {
|
|
|
14
15
|
if (!resolvedBy || !choice) {
|
|
15
16
|
return reply.status(400).send({ error: "resolvedBy and choice are required" });
|
|
16
17
|
}
|
|
18
|
+
// NOT-272: optional human note for a product_scope_decision resolution — validated
|
|
19
|
+
// the same way as resolvedBy/choice above (present-but-not-a-string 400s, never 500s).
|
|
20
|
+
const normalizedNote = normalizeResolutionNote(body?.note);
|
|
21
|
+
if (!normalizedNote.ok) {
|
|
22
|
+
return reply.status(400).send({ error: normalizedNote.error });
|
|
23
|
+
}
|
|
17
24
|
// Read before resolving — the issue id this action belongs to, needed for the reflect
|
|
18
25
|
// trigger below and stable regardless of how resolution turns out.
|
|
19
26
|
const action = getHumanAction(id);
|
|
@@ -45,7 +52,9 @@ export async function registerHumanActionRoutes(app) {
|
|
|
45
52
|
}
|
|
46
53
|
// Awaits undraft+merge when final_review:complete (NOT-102) — sync resolve alone would
|
|
47
54
|
// only park at AUTO_MERGE_INTENT and leave the PR draft.
|
|
48
|
-
const result = await resolveHumanActionAndAdvanceAsync(id, resolvedBy, choice
|
|
55
|
+
const result = await resolveHumanActionAndAdvanceAsync(id, resolvedBy, choice, {
|
|
56
|
+
note: normalizedNote.note,
|
|
57
|
+
});
|
|
49
58
|
if (!result.ok)
|
|
50
59
|
return reply.status(result.code).send({ error: result.error });
|
|
51
60
|
// Reflect is a best-effort network call to Agent Deck (health check + a sequential
|
|
@@ -12,7 +12,7 @@ const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await im
|
|
|
12
12
|
const { registerHumanActionRoutes } = await import("./human-actions.js");
|
|
13
13
|
const { startWorkflow, applyCompletion } = await import("../coordinator/commands.js");
|
|
14
14
|
const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
|
|
15
|
-
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
15
|
+
const { claimWorkItem, getWorkItem } = await import("../repository/work-items.js");
|
|
16
16
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
17
17
|
const { createRun, getRun, transitionRun, addArtifact, updateRunFields } = await import("../repository/runs.js");
|
|
18
18
|
const { pendingSendCount, getPendingOutboundDraft } = await import("../repository/outbound-drafts.js");
|
|
@@ -284,3 +284,119 @@ test("POST resolve 400s on a choice not valid for reflection_interaction_require
|
|
|
284
284
|
assert.equal(res.statusCode, 400);
|
|
285
285
|
await app.close();
|
|
286
286
|
});
|
|
287
|
+
// NOT-272: seeds.
|
|
288
|
+
function seedPreStartScopeDecision() {
|
|
289
|
+
const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
290
|
+
const action = createHumanAction({
|
|
291
|
+
issueId: issue.id,
|
|
292
|
+
actionType: "product_scope_decision",
|
|
293
|
+
reason: "Scope unclear before start",
|
|
294
|
+
question: "Is this in scope?",
|
|
295
|
+
responseOptions: [{ choice: "resume", label: "Resume development" }],
|
|
296
|
+
});
|
|
297
|
+
return { issue, action };
|
|
298
|
+
}
|
|
299
|
+
/** Drives a real issue to a mid-workflow product_scope_decision via a reviewer
|
|
300
|
+
* escalate verdict carrying a productScopeQuestion (NOT-150 routing). */
|
|
301
|
+
async function seedMidWorkflowScopeDecision() {
|
|
302
|
+
const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
303
|
+
const start = startWorkflow(issue.id);
|
|
304
|
+
assert.equal(start.ok, true);
|
|
305
|
+
const devItem = claimWorkItem(`scope-note-test-${issue.id}-dev`, { leaseMs: 60_000 });
|
|
306
|
+
await applyCompletion(devItem.id, devItem.leaseToken, {
|
|
307
|
+
kind: "clean_handoff",
|
|
308
|
+
branch: `issue-${issue.id}`,
|
|
309
|
+
headSha: "a".repeat(40),
|
|
310
|
+
baseSha: "b".repeat(40),
|
|
311
|
+
prNumber: 1,
|
|
312
|
+
prUrl: "https://gh/pr/1"
|
|
313
|
+
});
|
|
314
|
+
const reviewItem = claimWorkItem(`scope-note-test-${issue.id}-rev`, { leaseMs: 60_000 });
|
|
315
|
+
await applyCompletion(reviewItem.id, reviewItem.leaseToken, {
|
|
316
|
+
kind: "verdict",
|
|
317
|
+
result: ReviewerResult.parse({
|
|
318
|
+
verdict: "escalated",
|
|
319
|
+
baseSha: "b".repeat(40),
|
|
320
|
+
headSha: "a".repeat(40),
|
|
321
|
+
acceptanceCriteriaAssessment: "unclear scope",
|
|
322
|
+
evidenceAssessment: "fine",
|
|
323
|
+
findings: [],
|
|
324
|
+
risks: [],
|
|
325
|
+
productScopeQuestion: "Is the runner migration in scope?"
|
|
326
|
+
})
|
|
327
|
+
});
|
|
328
|
+
const humanAction = listHumanActionsForIssue(issue.id).find((a) => a.actionType === "product_scope_decision");
|
|
329
|
+
assert.ok(humanAction, "expected a product_scope_decision human action");
|
|
330
|
+
return { issueId: issue.id, actionId: humanAction.id };
|
|
331
|
+
}
|
|
332
|
+
test("NOT-272: POST resolve 400s (not 500) on a non-string note", async () => {
|
|
333
|
+
const app = await buildApp();
|
|
334
|
+
const { action } = seedPreStartScopeDecision();
|
|
335
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: 123 } });
|
|
336
|
+
assert.equal(res.statusCode, 400);
|
|
337
|
+
assert.match(res.json().error, /note must be a string/);
|
|
338
|
+
assert.equal(getHumanAction(action.id).status, "open", "a rejected resolve must leave the action open");
|
|
339
|
+
await app.close();
|
|
340
|
+
});
|
|
341
|
+
test("NOT-272: POST resolve 400s on an over-long note", async () => {
|
|
342
|
+
const app = await buildApp();
|
|
343
|
+
const { action } = seedPreStartScopeDecision();
|
|
344
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: "x".repeat(4001) } });
|
|
345
|
+
assert.equal(res.statusCode, 400);
|
|
346
|
+
assert.match(res.json().error, /at most 4000/);
|
|
347
|
+
assert.equal(getHumanAction(action.id).status, "open");
|
|
348
|
+
await app.close();
|
|
349
|
+
});
|
|
350
|
+
test("NOT-272: pre-start product_scope_decision resolve with a note persists it and carries it on the round-1 payload", async () => {
|
|
351
|
+
const app = await buildApp();
|
|
352
|
+
const { action } = seedPreStartScopeDecision();
|
|
353
|
+
const note = "The runner migration IS in scope — proceed with it.";
|
|
354
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: ` ${note} ` } });
|
|
355
|
+
assert.equal(res.statusCode, 200);
|
|
356
|
+
const body = res.json();
|
|
357
|
+
assert.equal(body.restarted, true);
|
|
358
|
+
assert.ok(body.nextWorkItemId);
|
|
359
|
+
const stored = getHumanAction(action.id);
|
|
360
|
+
assert.equal(stored.status, "resolved");
|
|
361
|
+
assert.deepEqual(JSON.parse(stored.resolutionJson), { choice: "resume", note }, "note round-trips exactly (trimmed)");
|
|
362
|
+
const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
|
|
363
|
+
assert.equal(payload.scopeDecisionNote, note, "the very next round's payload carries the note");
|
|
364
|
+
await app.close();
|
|
365
|
+
});
|
|
366
|
+
test("NOT-272: mid-workflow product_scope_decision resolve with a note persists it and carries it on the next developer payload", async () => {
|
|
367
|
+
const app = await buildApp();
|
|
368
|
+
const { issueId, actionId } = await seedMidWorkflowScopeDecision();
|
|
369
|
+
const note = "Out of scope for this ticket — finish the runner work already here, nothing more.";
|
|
370
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${actionId}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note } });
|
|
371
|
+
assert.equal(res.statusCode, 200);
|
|
372
|
+
const body = res.json();
|
|
373
|
+
assert.equal(body.issueStatus, "developing");
|
|
374
|
+
assert.ok(body.nextWorkItemId);
|
|
375
|
+
const stored = getHumanAction(actionId);
|
|
376
|
+
assert.deepEqual(JSON.parse(stored.resolutionJson), { choice: "resume", note });
|
|
377
|
+
const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
|
|
378
|
+
assert.equal(payload.scopeDecisionNote, note);
|
|
379
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
380
|
+
await app.close();
|
|
381
|
+
});
|
|
382
|
+
test("NOT-272: resolving a product_scope_decision without a note stores exactly today's record and queues a noteless payload", async () => {
|
|
383
|
+
const app = await buildApp();
|
|
384
|
+
const { action } = seedPreStartScopeDecision();
|
|
385
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume" } });
|
|
386
|
+
assert.equal(res.statusCode, 200);
|
|
387
|
+
const body = res.json();
|
|
388
|
+
const stored = getHumanAction(action.id);
|
|
389
|
+
assert.equal(stored.resolutionJson, JSON.stringify({ choice: "resume" }));
|
|
390
|
+
const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
|
|
391
|
+
assert.ok(!("scopeDecisionNote" in payload), "no new payload key without a note");
|
|
392
|
+
await app.close();
|
|
393
|
+
});
|
|
394
|
+
test("NOT-272: a note sent with any other action type is dropped, never stored", async () => {
|
|
395
|
+
const app = await buildApp();
|
|
396
|
+
const { actionId } = await seedRealIssueAwaitingFinalReview();
|
|
397
|
+
const res = await app.inject({ method: "POST", url: `/api/human-actions/${actionId}/resolve`, payload: { resolvedBy: "yusuke", choice: "close", note: "should not persist" } });
|
|
398
|
+
assert.equal(res.statusCode, 200);
|
|
399
|
+
const stored = getHumanAction(actionId);
|
|
400
|
+
assert.equal(stored.resolutionJson, JSON.stringify({ choice: "close" }));
|
|
401
|
+
await app.close();
|
|
402
|
+
});
|
|
@@ -12,6 +12,7 @@ import { refreshMuseCapacityIfStale } from "../capacity/muse-probe.js";
|
|
|
12
12
|
import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
|
|
13
13
|
import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
|
|
14
14
|
import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
|
|
15
|
+
import { registerVersionRoute } from "./version.js";
|
|
15
16
|
async function resolveDeckName(deckId) {
|
|
16
17
|
if (!deckId)
|
|
17
18
|
return null;
|
|
@@ -22,6 +23,7 @@ async function resolveDeckName(deckId) {
|
|
|
22
23
|
}
|
|
23
24
|
export async function registerRoutes(app) {
|
|
24
25
|
app.get("/health", async () => ({ ok: true }));
|
|
26
|
+
registerVersionRoute(app);
|
|
25
27
|
// NOT-159: process-lifetime Linear GraphQL counters (API-key bucket diagnostics).
|
|
26
28
|
app.get("/api/debug/linear-usage", async () => getLinearUsageSnapshot());
|
|
27
29
|
app.get("/api/runtimes/:runtime/models", async (req, reply) => {
|
|
@@ -76,11 +78,16 @@ export async function registerRoutes(app) {
|
|
|
76
78
|
// subprocess.
|
|
77
79
|
// NOT-268: Claude local-first ladder — one background refresh ingests
|
|
78
80
|
// Claude Code's own free cache (plain file read, never a spawn) and then
|
|
79
|
-
// considers the
|
|
80
|
-
//
|
|
81
|
-
// valid 5H/1W observation is
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// considers the free `/usage` refresh without blocking the read: a
|
|
82
|
+
// bounded `claude -p "/usage"` call fires at most once per 14 minutes,
|
|
83
|
+
// only when either valid 5H/1W observation is missing or at least 14
|
|
84
|
+
// minutes old (NOT-281 — checked per window so a fresh sibling never
|
|
85
|
+
// suppresses its stale twin), and only when enabled (default on;
|
|
86
|
+
// `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` disables it). `/usage` is a
|
|
87
|
+
// local slash-command — no model call, $0 cost, ~300ms, verified live
|
|
88
|
+
// 2026-09-27 (the original design spawned a real model turn and was
|
|
89
|
+
// fixed after a live proof showed it never worked; see
|
|
90
|
+
// docs/RUNTIME_CAPACITY.md). Failures never break the read below.
|
|
84
91
|
app.get("/api/runtime-capacity", async () => {
|
|
85
92
|
try {
|
|
86
93
|
void refreshClaudeCapacityIfStale().catch(() => {
|
|
@@ -15,8 +15,9 @@ 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
|
-
// Claude's
|
|
19
|
-
//
|
|
18
|
+
// Claude's `/usage` refresh is free and default-on in production (fixed
|
|
19
|
+
// 2026-09-27 — see docs/RUNTIME_CAPACITY.md); still disabled here so route
|
|
20
|
+
// tests never spawn a real `claude` subprocess.
|
|
20
21
|
process.env.AGENT_DEALER_CLAUDE_CAPACITY_REFRESH = "off";
|
|
21
22
|
// Muse's one-hour fallback is also default-on; route tests cover the free
|
|
22
23
|
// host trigger only and must never run a real model turn.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// packages/server/src/routes/version.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-279: the running backend reports its own version. The CLI's installed version (and
|
|
4
|
+
// the managed `current` symlink) can move ahead of a server that is still serving the old
|
|
5
|
+
// build, so `agent-dealer status` must ask the live process instead of inferring from disk.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
/** Read once at module load: the package this process was started from, not whatever is on
|
|
10
|
+
* disk later (a managed update may replace sibling install dirs while we keep running). */
|
|
11
|
+
function readServerPackageVersion() {
|
|
12
|
+
try {
|
|
13
|
+
// src/routes/version.ts and dist/routes/version.js both sit two levels below package.json.
|
|
14
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
16
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const RUNNING_VERSION = readServerPackageVersion();
|
|
23
|
+
const STARTED_AT = new Date().toISOString();
|
|
24
|
+
export function registerVersionRoute(app) {
|
|
25
|
+
app.get("/api/version", async () => ({
|
|
26
|
+
version: RUNNING_VERSION,
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
startedAt: STARTED_AT,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// packages/server/src/routes/version.test.ts
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Fastify from "fastify";
|
|
8
|
+
import { registerVersionRoute } from "./version.js";
|
|
9
|
+
test("NOT-279: /api/version reports the running server package version and pid", async () => {
|
|
10
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
11
|
+
const expected = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
|
|
12
|
+
const app = Fastify();
|
|
13
|
+
registerVersionRoute(app);
|
|
14
|
+
const res = await app.inject({ method: "GET", url: "/api/version" });
|
|
15
|
+
assert.equal(res.statusCode, 200);
|
|
16
|
+
const body = res.json();
|
|
17
|
+
assert.equal(body.version, expected);
|
|
18
|
+
assert.equal(body.pid, process.pid);
|
|
19
|
+
assert.ok(!Number.isNaN(Date.parse(body.startedAt)));
|
|
20
|
+
await app.close();
|
|
21
|
+
});
|
|
@@ -13,7 +13,11 @@ export const NOT_177_EVIDENCE = Object.freeze({
|
|
|
13
13
|
});
|
|
14
14
|
export const MUSE_MODEL = "muse-spark-1.3-contributor";
|
|
15
15
|
export const AGENT_DECK_SERVER = "agent-deck";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* NOT-278: the read-only allowlist is a reviewer requirement, not a developer requirement.
|
|
18
|
+
* Developers receive the selected deck's full surface, including `call_service_tool`. These
|
|
19
|
+
* lists only ever materialize for a reviewer build that proves allowlist enforcement.
|
|
20
|
+
*/
|
|
17
21
|
export const AGENT_DECK_READ_TOOLS = [
|
|
18
22
|
"get_bound_deck",
|
|
19
23
|
"get_playbook",
|
|
@@ -46,6 +50,10 @@ const FORBIDDEN_FLAGS = [
|
|
|
46
50
|
];
|
|
47
51
|
/** Which ambient env vars reach Muse. Everything else (META_API_KEY, MUSE_*, CODEX_HOME, ...) is dropped. */
|
|
48
52
|
const ENV_ALLOWLIST = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM", "TMPDIR"];
|
|
53
|
+
/** Test-only harness vars for the fake `muse` fixture (fixtures/fake-muse.mjs), which can only be
|
|
54
|
+
* selected via the operator/test `MUSE_CLI` override — and passed through only when that
|
|
55
|
+
* override is set, so production runs of the default binary never carry them. */
|
|
56
|
+
const FAKE_HARNESS_PREFIX = "FAKE_MUSE_";
|
|
49
57
|
const ATTEMPT_PREFIX = "muse-attempt-";
|
|
50
58
|
const SENTINEL = ".dealer-muse-attempt";
|
|
51
59
|
/** Messages name fields and paths, never values that could be credentials. */
|
|
@@ -59,10 +67,17 @@ export class MuseIsolationError extends Error {
|
|
|
59
67
|
this.name = "MuseIsolationError";
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
70
|
+
/**
|
|
71
|
+
* NOT-278: capabilities a role needs that the evidence does not prove. Developers receive the
|
|
72
|
+
* selected deck's full surface (including `call_service_tool`), so no allowlist enforcement is
|
|
73
|
+
* required; `cron_*` stays prohibited by the developer prompt and is detected post-run
|
|
74
|
+
* (`muse_cron_used`), not mechanically disabled. Reviewers still need both controls proven.
|
|
75
|
+
*/
|
|
76
|
+
export function unenforceableRestrictions(role, evidence = NOT_177_EVIDENCE) {
|
|
77
|
+
if (role === "developer")
|
|
78
|
+
return [];
|
|
64
79
|
const missing = [];
|
|
65
|
-
// Outbound mutation (`call_service_tool`) must stay denied for
|
|
80
|
+
// Outbound mutation (`call_service_tool`) must stay denied for reviewers, and Muse only
|
|
66
81
|
// stores enabled_tools/disabled_tools unless this is proven.
|
|
67
82
|
if (!evidence.mcp_tool_allowlist_enforcement)
|
|
68
83
|
missing.push("mcp_tool_allowlist_enforcement");
|
|
@@ -71,15 +86,37 @@ export function unenforceableRestrictions(_role, evidence = NOT_177_EVIDENCE) {
|
|
|
71
86
|
missing.push("cron_tool_disable");
|
|
72
87
|
return missing;
|
|
73
88
|
}
|
|
74
|
-
|
|
89
|
+
/**
|
|
90
|
+
* NOT-278: the deckless worker posture shared by every Muse settings.json — workflows and
|
|
91
|
+
* subagents off, reminder child runs off, plus `cron_*` only when proven mechanically
|
|
92
|
+
* disableable. The capacity serve host (which is shared across decks and never runs a
|
|
93
|
+
* session) uses this directly; per-attempt sessions add the required `agent-deck` server
|
|
94
|
+
* below. Exported only for that host — it is not a session attempt and `assertMuseSettings`
|
|
95
|
+
* rejects it (exactly one required `agent-deck` server is mandatory for a launch).
|
|
96
|
+
*/
|
|
97
|
+
export function buildMuseBaseSettings(role = "developer", evidence = NOT_177_EVIDENCE) {
|
|
75
98
|
const runtime_capabilities = {};
|
|
76
99
|
for (const name of REMINDERS) {
|
|
77
100
|
runtime_capabilities[`plugin:tbh-reminders:reminder:${name}`] = { enabled: false };
|
|
78
101
|
}
|
|
79
|
-
|
|
102
|
+
// NOT-278: `cron_*` can only be claimed disabled for reviewers on a build that proves it.
|
|
103
|
+
// Developers are prohibited by prompt and detected post-run; their settings never claim it.
|
|
104
|
+
if (role === "reviewer" && evidence.cron_tool_disable) {
|
|
80
105
|
for (const tool of CRON_TOOLS)
|
|
81
106
|
runtime_capabilities[`tool:${tool}`] = { enabled: false };
|
|
82
107
|
}
|
|
108
|
+
return {
|
|
109
|
+
schema_version: 1,
|
|
110
|
+
run: { workflow_trigger_mode: "off", subagent_delegation_mode: "off" },
|
|
111
|
+
runtime_capabilities,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* NOT-278: developers always receive the selected deck's full surface unfiltered — their
|
|
116
|
+
* settings never carry `enabled_tools`/`disabled_tools` claims. The read-only allowlist only
|
|
117
|
+
* materializes for reviewers on a build that proves enforcement.
|
|
118
|
+
*/
|
|
119
|
+
export function buildMuseSettings(agentDeck, role = "developer", evidence = NOT_177_EVIDENCE) {
|
|
83
120
|
const server = {
|
|
84
121
|
type: "streamable-http",
|
|
85
122
|
url: agentDeck.url,
|
|
@@ -90,20 +127,18 @@ export function buildMuseSettings(agentDeck, evidence = NOT_177_EVIDENCE) {
|
|
|
90
127
|
// `mode`, not `required`: writing both drops the whole MCP block. Never "optional".
|
|
91
128
|
mode: "required",
|
|
92
129
|
};
|
|
93
|
-
if (evidence.mcp_tool_allowlist_enforcement) {
|
|
130
|
+
if (role === "reviewer" && evidence.mcp_tool_allowlist_enforcement) {
|
|
94
131
|
server.enabled_tools = [...AGENT_DECK_READ_TOOLS];
|
|
95
132
|
server.disabled_tools = [...AGENT_DECK_DENIED_TOOLS];
|
|
96
133
|
}
|
|
97
134
|
return {
|
|
98
|
-
|
|
99
|
-
run: { workflow_trigger_mode: "off", subagent_delegation_mode: "off" },
|
|
100
|
-
runtime_capabilities,
|
|
135
|
+
...buildMuseBaseSettings(role, evidence),
|
|
101
136
|
mcpServers: { [AGENT_DECK_SERVER]: server },
|
|
102
137
|
};
|
|
103
138
|
}
|
|
104
139
|
/** Whitelist check on a settings object (generated or re-read from disk). */
|
|
105
|
-
export function assertMuseSettings(settings, agentDeck, evidence = NOT_177_EVIDENCE) {
|
|
106
|
-
const expected = buildMuseSettings(agentDeck, evidence);
|
|
140
|
+
export function assertMuseSettings(settings, agentDeck, role = "developer", evidence = NOT_177_EVIDENCE) {
|
|
141
|
+
const expected = buildMuseSettings(agentDeck, role, evidence);
|
|
107
142
|
if (JSON.stringify(sortKeys(settings)) !== JSON.stringify(sortKeys(expected))) {
|
|
108
143
|
throw new MuseIsolationError("invalid_settings", "Muse settings differ from the approved role settings (extra, missing, or altered keys)");
|
|
109
144
|
}
|
|
@@ -194,6 +229,17 @@ export function buildMuseEnv(root, ambient = process.env) {
|
|
|
194
229
|
if (key.startsWith("LC_") && value !== undefined)
|
|
195
230
|
env[key] = value;
|
|
196
231
|
}
|
|
232
|
+
// Test-only seam: the fake fixture is driven by FAKE_MUSE_* (scenario, record path, version
|
|
233
|
+
// pins) and the exec lane is exact, so without this the fixture cannot be driven at all.
|
|
234
|
+
// These vars join attempt.env only when the operator/test MUSE_CLI override selects the
|
|
235
|
+
// binary — i.e. only the fixture can ever receive them, never the default real binary —
|
|
236
|
+
// and the pre-spawn verify() snapshot covers them exactly like every other approved var.
|
|
237
|
+
if (ambient.MUSE_CLI?.trim()) {
|
|
238
|
+
for (const [key, value] of Object.entries(ambient)) {
|
|
239
|
+
if (key.startsWith(FAKE_HARNESS_PREFIX) && value !== undefined)
|
|
240
|
+
env[key] = value;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
197
243
|
env.MUSE_NO_AUTO_UPDATE = "1";
|
|
198
244
|
env.XDG_CONFIG_HOME = path.join(root, "config");
|
|
199
245
|
env.XDG_DATA_HOME = path.join(root, "data");
|
|
@@ -337,7 +383,8 @@ function sameJson(a, b) {
|
|
|
337
383
|
* Validates, refuses unenforceable restrictions, then writes the per-attempt dir (0700) with
|
|
338
384
|
* settings.json (0600) and an auth link. Throws before spawn; leaves nothing behind on failure.
|
|
339
385
|
*
|
|
340
|
-
* Always uses the pinned `NOT_177_EVIDENCE`, under which
|
|
386
|
+
* Always uses the pinned `NOT_177_EVIDENCE`, under which developers are admitted unfiltered
|
|
387
|
+
* (NOT-278: the full deck surface, no allowlist claims) and reviewers are still refused. There is
|
|
341
388
|
* deliberately no runtime seam that accepts other evidence: the enforced-path tests load a
|
|
342
389
|
* source-rewritten copy of this file instead (see muse-config.test.ts).
|
|
343
390
|
*/
|
|
@@ -355,8 +402,8 @@ function prepareWithEvidence(rawInput, evidence) {
|
|
|
355
402
|
const { worktree, baseDir } = validateInput(input);
|
|
356
403
|
const agentDeck = input.agentDeck;
|
|
357
404
|
const credentialKind = input.credential.kind;
|
|
358
|
-
const settings = deepFreeze(buildMuseSettings(agentDeck, evidence));
|
|
359
|
-
assertMuseSettings(settings, agentDeck, evidence);
|
|
405
|
+
const settings = deepFreeze(buildMuseSettings(agentDeck, input.role, evidence));
|
|
406
|
+
assertMuseSettings(settings, agentDeck, input.role, evidence);
|
|
360
407
|
const apiKey = input.credential.kind === "api-key" ? input.credential.apiKey : undefined;
|
|
361
408
|
const argv = buildMuseArgv({
|
|
362
409
|
role: input.role,
|
|
@@ -445,7 +492,7 @@ function prepareWithEvidence(rawInput, evidence) {
|
|
|
445
492
|
throw err;
|
|
446
493
|
throw new MuseIsolationError("invalid_settings", "Muse settings.json is missing or unreadable");
|
|
447
494
|
}
|
|
448
|
-
assertMuseSettings(onDisk, agentDeck, evidence);
|
|
495
|
+
assertMuseSettings(onDisk, agentDeck, input.role, evidence);
|
|
449
496
|
const entries = fs.readdirSync(path.dirname(settingsPath)).sort();
|
|
450
497
|
const allowed = credentialKind === "auth-file" ? ["auth.json", "settings.json"] : ["settings.json"];
|
|
451
498
|
if (entries.join() !== allowed.join()) {
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The implementation lives in muse-config-core.ts, which pins `prepareMuseAttempt` to
|
|
4
4
|
// `NOT_177_EVIDENCE`. No module in the build exports a preparer that accepts caller-supplied evidence.
|
|
5
|
-
export { AGENT_DECK_DENIED_TOOLS, AGENT_DECK_READ_TOOLS, AGENT_DECK_SERVER, MUSE_MODEL, MuseIsolationError, NOT_177_EVIDENCE, assertMuseArgv, assertMuseSettings, buildMuseArgv, buildMuseEnv, buildMuseSettings, createRedactor, defaultOperatorAuthPath, prepareMuseAttempt, unenforceableRestrictions, } from "./muse-config-core.js";
|
|
5
|
+
export { AGENT_DECK_DENIED_TOOLS, AGENT_DECK_READ_TOOLS, AGENT_DECK_SERVER, MUSE_MODEL, MuseIsolationError, NOT_177_EVIDENCE, assertMuseArgv, assertMuseSettings, buildMuseArgv, buildMuseBaseSettings, buildMuseEnv, buildMuseSettings, createRedactor, defaultOperatorAuthPath, prepareMuseAttempt, unenforceableRestrictions, } from "./muse-config-core.js";
|