agent-dealer 1.2.6 → 1.2.8
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 +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +4 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +39 -3
- package/bundle/server/dist/coordinator/commands.js +90 -5
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +79 -1
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +17 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +78 -2
- package/bundle/server/dist/coordinator/prompts.test.js +83 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/index.js +5 -0
- package/bundle/server/dist/db/schema.sql +3 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +29 -4
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-B6SVCzMR.js +64 -0
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/action-presentation.d.ts +51 -0
- package/bundle/shared/dist/action-presentation.js +280 -0
- package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
- package/bundle/shared/dist/action-presentation.test.js +180 -0
- package/bundle/shared/dist/attempt-waste.d.ts +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +11 -11
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +14 -14
- package/bundle/shared/dist/human-actions.js +9 -0
- package/bundle/shared/dist/index.d.ts +126 -124
- package/bundle/shared/dist/index.js +4 -0
- package/bundle/shared/dist/issues.d.ts +158 -28
- package/bundle/shared/dist/issues.js +8 -0
- package/bundle/shared/dist/issues.test.js +1 -0
- package/bundle/shared/dist/outbound-draft.d.ts +18 -18
- package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
- package/bundle/shared/dist/usage-events.d.ts +2 -2
- package/bundle/shared/dist/worker-sessions.d.ts +16 -6
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +6 -6
- package/bundle/shared/dist/workflow.js +10 -0
- package/bundle/shared/dist/workflow.test.js +2 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +0 -60
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// NOT-310: conflict-failure classifier + the conflict-repair episode bound.
|
|
2
|
+
import { test, before, beforeEach } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310-"));
|
|
8
|
+
process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH = "1";
|
|
9
|
+
process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
|
|
10
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
11
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
12
|
+
const { createIssue } = await import("../repository/issues.js");
|
|
13
|
+
const { startWorkflow } = await import("./commands.js");
|
|
14
|
+
const { appendWorkflowEvent, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
15
|
+
const { conflictRepairSpent, isMergeConflictFailure } = await import("./merge-conflict-sync.js");
|
|
16
|
+
before(() => {
|
|
17
|
+
migrate();
|
|
18
|
+
});
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
getDb().exec(`
|
|
21
|
+
DELETE FROM work_items;
|
|
22
|
+
DELETE FROM human_actions;
|
|
23
|
+
DELETE FROM workflow_events;
|
|
24
|
+
DELETE FROM findings;
|
|
25
|
+
DELETE FROM review_publications;
|
|
26
|
+
DELETE FROM worker_sessions;
|
|
27
|
+
DELETE FROM artifacts;
|
|
28
|
+
DELETE FROM usage_events;
|
|
29
|
+
DELETE FROM workflow_instances;
|
|
30
|
+
DELETE FROM issues;
|
|
31
|
+
`);
|
|
32
|
+
});
|
|
33
|
+
test("isMergeConflictFailure classifies gh conflict text, not infra failures", () => {
|
|
34
|
+
assert.equal(isMergeConflictFailure("Pull request #42 is not mergeable: the merge commit cannot be cleanly created"), true);
|
|
35
|
+
assert.equal(isMergeConflictFailure("merge conflict: CONFLICTING"), true);
|
|
36
|
+
assert.equal(isMergeConflictFailure("CONFLICTING"), true);
|
|
37
|
+
// Deliberately broad: a policy "not mergeable" still enters the sync, which
|
|
38
|
+
// then escalates directly once the branch proves up to date (integration:
|
|
39
|
+
// "up-to-date branch that stays not mergeable").
|
|
40
|
+
assert.equal(isMergeConflictFailure("Pull request #42 is not mergeable: base branch policy prohibits the merge"), true);
|
|
41
|
+
assert.equal(isMergeConflictFailure("required status checks failed"), false);
|
|
42
|
+
assert.equal(isMergeConflictFailure("gh timed out after 20000ms"), false);
|
|
43
|
+
assert.equal(isMergeConflictFailure("protected branch"), false);
|
|
44
|
+
assert.equal(isMergeConflictFailure("Managed clone missing for github.com/a/b (/x)"), false);
|
|
45
|
+
assert.equal(isMergeConflictFailure("gh not on PATH — install GitHub CLI (`gh`) and ensure the daemon can see it"), false);
|
|
46
|
+
assert.equal(isMergeConflictFailure("invalid merge cwd (/x) — path does not exist (portable issue.repo must not be used as cwd)"), false);
|
|
47
|
+
});
|
|
48
|
+
function newIssueWithWorkflow() {
|
|
49
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310bound-repo-"));
|
|
50
|
+
const issueId = createIssue({
|
|
51
|
+
title: "Bound me",
|
|
52
|
+
description: "d",
|
|
53
|
+
acceptanceCriteria: "It works",
|
|
54
|
+
repo,
|
|
55
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
56
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
57
|
+
baseBranch: "main",
|
|
58
|
+
maxReviewRounds: 3,
|
|
59
|
+
maxInfraAttempts: 3,
|
|
60
|
+
source: "manual",
|
|
61
|
+
autoMerge: true,
|
|
62
|
+
}).id;
|
|
63
|
+
startWorkflow(issueId);
|
|
64
|
+
return { issueId, instanceId: getActiveWorkflowInstance(issueId).id };
|
|
65
|
+
}
|
|
66
|
+
function emitRepairQueued(issueId, instanceId, files) {
|
|
67
|
+
appendWorkflowEvent({
|
|
68
|
+
issueId,
|
|
69
|
+
workflowInstanceId: instanceId,
|
|
70
|
+
workerSessionId: null,
|
|
71
|
+
type: "auto_merge.conflict_repair_queued",
|
|
72
|
+
actorType: "system",
|
|
73
|
+
stage: "repairing",
|
|
74
|
+
round: 2,
|
|
75
|
+
payload: { baseBranch: "main", branch: "issue-1", files, round: 2, workItemId: "w" },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function emitResolved(issueId, instanceId) {
|
|
79
|
+
appendWorkflowEvent({
|
|
80
|
+
issueId,
|
|
81
|
+
workflowInstanceId: instanceId,
|
|
82
|
+
workerSessionId: null,
|
|
83
|
+
type: "human_action.resolved",
|
|
84
|
+
actorType: "human",
|
|
85
|
+
stage: "needs_human",
|
|
86
|
+
round: 2,
|
|
87
|
+
payload: { actionType: "policy_escalation", choice: "retry_merge" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
test("conflictRepairSpent is false with no repair event", () => {
|
|
91
|
+
const { issueId, instanceId } = newIssueWithWorkflow();
|
|
92
|
+
assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
|
|
93
|
+
});
|
|
94
|
+
test("conflictRepairSpent is true after a repair round queued, carrying its files", () => {
|
|
95
|
+
const { issueId, instanceId } = newIssueWithWorkflow();
|
|
96
|
+
emitRepairQueued(issueId, instanceId, ["shared.txt"]);
|
|
97
|
+
assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: true, files: ["shared.txt"] });
|
|
98
|
+
});
|
|
99
|
+
test("conflictRepairSpent resets on a later human resolution (fresh episode)", () => {
|
|
100
|
+
const { issueId, instanceId } = newIssueWithWorkflow();
|
|
101
|
+
emitRepairQueued(issueId, instanceId, ["shared.txt"]);
|
|
102
|
+
emitResolved(issueId, instanceId);
|
|
103
|
+
assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
|
|
104
|
+
});
|
|
105
|
+
test("conflictRepairSpent stays spent when the repair queued after the last resolution", () => {
|
|
106
|
+
const { issueId, instanceId } = newIssueWithWorkflow();
|
|
107
|
+
emitResolved(issueId, instanceId);
|
|
108
|
+
emitRepairQueued(issueId, instanceId, []);
|
|
109
|
+
assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: true, files: [] });
|
|
110
|
+
});
|
|
111
|
+
test("conflictRepairSpent ignores other issues/instances and malformed file lists", () => {
|
|
112
|
+
const { issueId, instanceId } = newIssueWithWorkflow();
|
|
113
|
+
const other = newIssueWithWorkflow();
|
|
114
|
+
emitRepairQueued(other.issueId, other.instanceId, ["other.txt"]);
|
|
115
|
+
assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
|
|
116
|
+
assert.deepEqual(conflictRepairSpent(issueId, other.instanceId), { spent: false, files: [] });
|
|
117
|
+
emitRepairQueued(issueId, instanceId, "not-a-list");
|
|
118
|
+
const spent = conflictRepairSpent(issueId, instanceId);
|
|
119
|
+
assert.equal(spent.spent, true);
|
|
120
|
+
assert.deepEqual(spent.files, []);
|
|
121
|
+
});
|
|
@@ -203,7 +203,7 @@ function recorded() {
|
|
|
203
203
|
return JSON.parse(fs.readFileSync(RECORD, "utf8"));
|
|
204
204
|
}
|
|
205
205
|
/** The captured settings file: exactly one required agent-deck server, accepted by the guard. */
|
|
206
|
-
function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
|
|
206
|
+
function expectDeckSettings(rec, issueId, deckId = TEST_DECK_ID) {
|
|
207
207
|
const settings = JSON.parse(rec.settings);
|
|
208
208
|
assert.deepEqual(Object.keys(settings).sort(), ["mcpServers", "run", "runtime_capabilities", "schema_version"]);
|
|
209
209
|
assert.deepEqual(Object.keys(settings.mcpServers), ["agent-deck"]);
|
|
@@ -211,15 +211,20 @@ function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
|
|
|
211
211
|
assert.equal(server.type, "streamable-http");
|
|
212
212
|
assert.equal(server.url, expectedMcpUrl());
|
|
213
213
|
assert.equal(server.mode, "required");
|
|
214
|
+
// NOT-305: every worker session carries an opaque Deck correlation UUID, passed here
|
|
215
|
+
// as the exact x-agent-deck-correlation-id observability header.
|
|
216
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
217
|
+
assert.ok(dev.deckCorrelationId, "the developer session persists a Deck correlation ID");
|
|
214
218
|
// cwdReal was resolved by the fixture at spawn time: the coordinator removes the
|
|
215
219
|
// worktree after the turn, so resolving rec.cwd here would race cleanup.
|
|
216
220
|
assert.deepEqual(server.headers, {
|
|
217
221
|
"x-agent-deck-deck-id": deckId,
|
|
218
222
|
"x-agent-deck-workspace": rec.cwdReal,
|
|
223
|
+
"x-agent-deck-correlation-id": dev.deckCorrelationId,
|
|
219
224
|
});
|
|
220
225
|
assert.equal("enabled_tools" in server, false, "developers are unfiltered: no allowlist claim");
|
|
221
226
|
assert.equal("disabled_tools" in server, false, "developers are unfiltered: no denylist claim");
|
|
222
|
-
assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal }, "developer");
|
|
227
|
+
assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal, correlationId: dev.deckCorrelationId }, "developer");
|
|
223
228
|
return settings;
|
|
224
229
|
}
|
|
225
230
|
test("fake-Muse developer session goes admission → verified branch → draft PR, with a frozen muse_code snapshot", async () => {
|
|
@@ -272,7 +277,7 @@ test("the turn runs the isolated exec lane (never serve) with the required agent
|
|
|
272
277
|
assert.ok(!rec.xdgConfigHome.startsWith(rec.cwd), "config is not under the worktree");
|
|
273
278
|
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after success");
|
|
274
279
|
assert.equal(fs.existsSync(rec.xdgDataHome), false, "per-attempt data removed after success");
|
|
275
|
-
expectDeckSettings(rec);
|
|
280
|
+
expectDeckSettings(rec, issueId);
|
|
276
281
|
// Exact environment: ambient secrets and sentinels never reached the child.
|
|
277
282
|
assert.deepEqual(rec.envLeak, {
|
|
278
283
|
META_API_KEY: false,
|
|
@@ -308,7 +313,7 @@ test("saved-login auth is symlinked, never copied, and carries no stdin key", as
|
|
|
308
313
|
assert.equal(rec.stdinBytes, 0, "no key on stdin for saved-login auth");
|
|
309
314
|
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config (and link) removed");
|
|
310
315
|
assert.equal(fs.readFileSync(operatorAuth, "utf8"), '{"token":"operator-owned"}', "operator credentials untouched");
|
|
311
|
-
expectDeckSettings(rec);
|
|
316
|
+
expectDeckSettings(rec, issueId);
|
|
312
317
|
});
|
|
313
318
|
test("usage events record muse_code, the confirmed model, duration and tokens; cost stays null", async () => {
|
|
314
319
|
const issueId = makeMuseIssue();
|
|
@@ -24,6 +24,7 @@ import { getWorkerMcpConfigDir } from "../paths.js";
|
|
|
24
24
|
import { prepareMuseAttempt, } from "../runners/muse-config.js";
|
|
25
25
|
import { parseMuseRun, } from "../runners/muse-code-jsonl.js";
|
|
26
26
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
27
|
+
import { museIdleTimeoutMs } from "./session-timeouts.js";
|
|
27
28
|
/**
|
|
28
29
|
* Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
|
|
29
30
|
* wall-clock timeout is the real limit, the cap only stops a runaway loop.
|
|
@@ -32,38 +33,94 @@ export const DEFAULT_MUSE_MAX_MODEL_STEPS = 300;
|
|
|
32
33
|
/** Muse cannot disable these; any call in a session's tool activity fails it as `muse_cron_used`. */
|
|
33
34
|
const CRON_TOOL_RE = /^cron_(create|list|delete)$/;
|
|
34
35
|
const STDERR_MARKER = "\n--- stderr ---\n";
|
|
36
|
+
export function museStallMetadata(summary) {
|
|
37
|
+
return {
|
|
38
|
+
lastActivityAt: summary.lastActivityAt,
|
|
39
|
+
toolCallCount: summary.toolCallCount,
|
|
40
|
+
lastToolName: summary.lastToolName,
|
|
41
|
+
firstOutputMs: summary.firstOutputMs,
|
|
42
|
+
idleTimedOut: summary.idleTimedOut,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
35
45
|
/** `sessions/YYYY/MM/DD/<session-id>/session.jsonl` under the attempt's data dir, if present. */
|
|
36
46
|
function readSessionLog(dataDir, museSessionId) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
const found = findSessionLogPath(path.join(dataDir, "muse", "sessions"), museSessionId, 0);
|
|
48
|
+
if (!found)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
return fs.readFileSync(found, "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* NOT-307: mtime (epoch ms) of the live `session.jsonl` for one attempt's session id,
|
|
59
|
+
* or null when it cannot be found or statted. The idle watchdog polls this as its
|
|
60
|
+
* second progress source: Muse appends model-stream rows to the session log as work
|
|
61
|
+
* proceeds, so growth here counts as progress even across a long stdout silence
|
|
62
|
+
* (e.g. a big tool call whose output arrives only at the end). The found path is
|
|
63
|
+
* resolved on every call — the file appears only after the child starts — and any
|
|
64
|
+
* filesystem error reads as "unknown", never as progress and never as a throw.
|
|
65
|
+
*/
|
|
66
|
+
export function sessionLogActivityMs(dataDir, museSessionId) {
|
|
67
|
+
try {
|
|
68
|
+
const found = findSessionLogPath(path.join(dataDir, "muse", "sessions"), museSessionId, 0);
|
|
69
|
+
if (!found)
|
|
70
|
+
return null;
|
|
71
|
+
return fs.statSync(found).mtimeMs;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function findSessionLogPath(dir, museSessionId, depth) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
if (!e.isDirectory())
|
|
87
|
+
continue;
|
|
88
|
+
const child = path.join(dir, e.name);
|
|
89
|
+
if (e.name === museSessionId) {
|
|
90
|
+
const candidate = path.join(child, "session.jsonl");
|
|
91
|
+
try {
|
|
92
|
+
if (fs.statSync(candidate).isFile())
|
|
93
|
+
return candidate;
|
|
57
94
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (found !== undefined)
|
|
61
|
-
return found;
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
62
97
|
}
|
|
98
|
+
return null;
|
|
63
99
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
100
|
+
if (depth < 4) {
|
|
101
|
+
const found = findSessionLogPath(child, museSessionId, depth + 1);
|
|
102
|
+
if (found)
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* NOT-307: whole minutes of silence ending now, for the idle-kill reason text.
|
|
110
|
+
* Prefers the measured last-activity time; falls back to the configured bound when
|
|
111
|
+
* no activity was ever observed (a child silent since spawn is killed after exactly
|
|
112
|
+
* the bound). Null when the watchdog is disabled — there is no idle kill to explain.
|
|
113
|
+
*/
|
|
114
|
+
export function museIdleMinutes(opts) {
|
|
115
|
+
const now = opts.nowMs ?? Date.now();
|
|
116
|
+
if (opts.lastActivityAt) {
|
|
117
|
+
const at = Date.parse(opts.lastActivityAt);
|
|
118
|
+
if (Number.isFinite(at))
|
|
119
|
+
return Math.max(1, Math.round((now - at) / 60_000));
|
|
120
|
+
}
|
|
121
|
+
if (opts.idleTimeoutMs !== undefined)
|
|
122
|
+
return Math.max(1, Math.round(opts.idleTimeoutMs / 60_000));
|
|
123
|
+
return null;
|
|
67
124
|
}
|
|
68
125
|
/**
|
|
69
126
|
* NOT-278: the attempt's credential. A saved login is linked, never read or copied; otherwise
|
|
@@ -90,6 +147,15 @@ function splitSpawnLog(raw) {
|
|
|
90
147
|
? { stdout: raw, stderr: "" }
|
|
91
148
|
: { stdout: raw.slice(0, idx), stderr: raw.slice(idx + STDERR_MARKER.length) };
|
|
92
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* NOT-307: writes the normalized session log every reader knows — one JSON event
|
|
152
|
+
* per line (each carrying its ISO `ts`; `tool_call` events their `durationMs`
|
|
153
|
+
* when a matching result was timed), plus the stderr trailer when non-blank.
|
|
154
|
+
* Extracted pure so tests assert the exact bytes on disk.
|
|
155
|
+
*/
|
|
156
|
+
export function writeMuseNormalizedLog(logPath, events, stderr) {
|
|
157
|
+
fs.writeFileSync(logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n${stderr.trim() ? `${STDERR_MARKER}${stderr}` : ""}`);
|
|
158
|
+
}
|
|
93
159
|
/**
|
|
94
160
|
* NOT-270: reuse real Dealer Muse work as the observation opportunity. After
|
|
95
161
|
* a genuine Muse session ends, kick one bounded, best-effort refresh
|
|
@@ -148,7 +214,8 @@ export async function runMuseDeveloperSession(input) {
|
|
|
148
214
|
role: "developer",
|
|
149
215
|
worktreePath: input.cwd,
|
|
150
216
|
baseDir: getWorkerMcpConfigDir(),
|
|
151
|
-
|
|
217
|
+
// NOT-305: opaque per-session correlation UUID — observability metadata, not authority.
|
|
218
|
+
agentDeck: { url: mcpUrl, deckId, workspace: input.cwd, correlationId: input.deckCorrelationId ?? null },
|
|
152
219
|
credential: resolveMuseCredential(),
|
|
153
220
|
sessionId: input.sessionId,
|
|
154
221
|
prompt: input.prompt,
|
|
@@ -157,6 +224,13 @@ export async function runMuseDeveloperSession(input) {
|
|
|
157
224
|
const { logPath } = input;
|
|
158
225
|
let spawned;
|
|
159
226
|
let sessionLog;
|
|
227
|
+
// NOT-307: Dealer-observed arrival epoch ms parallel to the transcript's lines —
|
|
228
|
+
// the stream's own `recorded_at` is batch-stamped, so per-event `ts` is stamped
|
|
229
|
+
// here instead. Only the Muse lane collects these; other runtimes pass nothing.
|
|
230
|
+
const lineTs = [];
|
|
231
|
+
// NOT-307: silence bound for this session. Undefined disables the watchdog
|
|
232
|
+
// (`MUSE_IDLE_TIMEOUT_MS=0`); only the Muse lane opts in.
|
|
233
|
+
const idleTimeoutMs = museIdleTimeoutMs();
|
|
160
234
|
try {
|
|
161
235
|
// The exact launch about to happen — a tampered settings file or a differing
|
|
162
236
|
// cwd/argv/env/stdin fails here, before any process exists.
|
|
@@ -169,6 +243,13 @@ export async function runMuseDeveloperSession(input) {
|
|
|
169
243
|
stdin: attempt.stdin,
|
|
170
244
|
signal: input.signal,
|
|
171
245
|
onSpawn: input.onSpawn,
|
|
246
|
+
idleTimeoutMs,
|
|
247
|
+
// The session log appears only after the child starts; a missing file reads as
|
|
248
|
+
// unknown and never postpones the kill on its own — stdout bytes still count.
|
|
249
|
+
progressSource: () => sessionLogActivityMs(attempt.env.XDG_DATA_HOME, input.sessionId),
|
|
250
|
+
onStdoutLine: (_line, atMs) => {
|
|
251
|
+
lineTs.push(atMs);
|
|
252
|
+
},
|
|
172
253
|
});
|
|
173
254
|
sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
|
|
174
255
|
}
|
|
@@ -183,6 +264,10 @@ export async function runMuseDeveloperSession(input) {
|
|
|
183
264
|
exitCode: spawned.timedOut ? null : spawned.exitCode,
|
|
184
265
|
sessionLog,
|
|
185
266
|
expectedModel: model,
|
|
267
|
+
lineTs,
|
|
268
|
+
// Log-write time: every normalized event below carries a `ts`, stream-derived
|
|
269
|
+
// where the arrival was observed, this fallback everywhere else.
|
|
270
|
+
now: new Date().toISOString(),
|
|
186
271
|
});
|
|
187
272
|
const cronCalls = run.tools.map((t) => t.name).filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
188
273
|
const summary = {
|
|
@@ -193,6 +278,11 @@ export async function runMuseDeveloperSession(input) {
|
|
|
193
278
|
cronCalls,
|
|
194
279
|
runCount: run.runCount,
|
|
195
280
|
rawLogPath: null,
|
|
281
|
+
firstOutputMs: spawned.firstOutputMs,
|
|
282
|
+
lastActivityAt: spawned.lastActivityAt,
|
|
283
|
+
idleTimedOut: spawned.idleTimedOut,
|
|
284
|
+
toolCallCount: run.tools.length,
|
|
285
|
+
lastToolName: run.tools.length > 0 ? (run.tools[run.tools.length - 1].name ?? null) : null,
|
|
196
286
|
};
|
|
197
287
|
// The raw envelopes stay as evidence; the log path every reader knows becomes the normalized stream.
|
|
198
288
|
const rawLogPath = `${logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
|
|
@@ -204,7 +294,7 @@ export async function runMuseDeveloperSession(input) {
|
|
|
204
294
|
// evidence copy is best-effort; the normalized log below still carries the outcome
|
|
205
295
|
}
|
|
206
296
|
const events = run.events.map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: run.usage } } : e);
|
|
207
|
-
|
|
297
|
+
writeMuseNormalizedLog(logPath, events, stderr);
|
|
208
298
|
// The real session above is the observation opportunity: kick the
|
|
209
299
|
// best-effort capacity refresh (no synthetic turn, never throws, never
|
|
210
300
|
// blocks this result).
|
|
@@ -215,6 +305,9 @@ export async function runMuseDeveloperSession(input) {
|
|
|
215
305
|
transcript: run.finalText ?? "",
|
|
216
306
|
logPath,
|
|
217
307
|
timedOut: spawned.timedOut,
|
|
308
|
+
// NOT-307: an idle kill reads as a timeout downstream (same salvage, same
|
|
309
|
+
// infra-retry budget) with the idle flag distinguishing the cause.
|
|
310
|
+
idleTimedOut: spawned.idleTimedOut,
|
|
218
311
|
muse: summary,
|
|
219
312
|
};
|
|
220
313
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// NOT-307: Muse idle-wiring units — env parsing, session-log progress source,
|
|
2
|
+
// arrival-stamped normalized events, and the five-field stall metadata object.
|
|
3
|
+
// The full `runMuseDeveloperSession` needs a Muse binary + credentials, so these
|
|
4
|
+
// test the pure pieces it wires together (plus the parser contract it relies on).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { parseMuseRun } from "../runners/muse-code-jsonl.js";
|
|
11
|
+
import { museIdleTimeoutMs, DEFAULT_MUSE_IDLE_TIMEOUT_MS } from "./session-timeouts.js";
|
|
12
|
+
import { museIdleMinutes, museStallMetadata, sessionLogActivityMs, writeMuseNormalizedLog, } from "./muse-spawn.js";
|
|
13
|
+
const MODEL = "muse-spark-1.3-contributor";
|
|
14
|
+
// ── MUSE_IDLE_TIMEOUT_MS parsing ─────────────────────────────────────────────
|
|
15
|
+
function withIdleEnv(raw, fn) {
|
|
16
|
+
const prev = process.env.MUSE_IDLE_TIMEOUT_MS;
|
|
17
|
+
try {
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
delete process.env.MUSE_IDLE_TIMEOUT_MS;
|
|
20
|
+
else
|
|
21
|
+
process.env.MUSE_IDLE_TIMEOUT_MS = raw;
|
|
22
|
+
fn();
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (prev === undefined)
|
|
26
|
+
delete process.env.MUSE_IDLE_TIMEOUT_MS;
|
|
27
|
+
else
|
|
28
|
+
process.env.MUSE_IDLE_TIMEOUT_MS = prev;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
test("museIdleTimeoutMs defaults to 30 minutes when unset or blank", () => {
|
|
32
|
+
withIdleEnv(undefined, () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
33
|
+
withIdleEnv("", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
34
|
+
withIdleEnv(" ", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
35
|
+
assert.equal(DEFAULT_MUSE_IDLE_TIMEOUT_MS, 30 * 60_000);
|
|
36
|
+
});
|
|
37
|
+
test("museIdleTimeoutMs accepts an explicit bound", () => {
|
|
38
|
+
withIdleEnv("600000", () => assert.equal(museIdleTimeoutMs(), 600_000));
|
|
39
|
+
});
|
|
40
|
+
test("MUSE_IDLE_TIMEOUT_MS=0 disables the watchdog", () => {
|
|
41
|
+
withIdleEnv("0", () => assert.equal(museIdleTimeoutMs(), undefined));
|
|
42
|
+
});
|
|
43
|
+
test("non-numeric or negative MUSE_IDLE_TIMEOUT_MS falls back to the default", () => {
|
|
44
|
+
withIdleEnv("soon", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
45
|
+
withIdleEnv("NaN", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
46
|
+
withIdleEnv("-1", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
47
|
+
withIdleEnv("Infinity", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
|
|
48
|
+
});
|
|
49
|
+
test("invalid MUSE_IDLE_TIMEOUT_MS logs a warning naming the value", () => {
|
|
50
|
+
const prev = process.env.MUSE_IDLE_TIMEOUT_MS;
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const orig = console.warn;
|
|
53
|
+
console.warn = (...args) => {
|
|
54
|
+
warnings.push(args);
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
process.env.MUSE_IDLE_TIMEOUT_MS = "soon";
|
|
58
|
+
assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
console.warn = orig;
|
|
62
|
+
if (prev === undefined)
|
|
63
|
+
delete process.env.MUSE_IDLE_TIMEOUT_MS;
|
|
64
|
+
else
|
|
65
|
+
process.env.MUSE_IDLE_TIMEOUT_MS = prev;
|
|
66
|
+
}
|
|
67
|
+
assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`);
|
|
68
|
+
assert.match(String(warnings[0][0]), /MUSE_IDLE_TIMEOUT_MS/);
|
|
69
|
+
});
|
|
70
|
+
// ── session-log progress source ──────────────────────────────────────────────
|
|
71
|
+
function writeSessionLog(dataDir, sessionId) {
|
|
72
|
+
const dir = path.join(dataDir, "muse", "sessions", "2026", "10", "01", sessionId);
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
const file = path.join(dir, "session.jsonl");
|
|
75
|
+
fs.writeFileSync(file, "{}\n");
|
|
76
|
+
return file;
|
|
77
|
+
}
|
|
78
|
+
test("sessionLogActivityMs returns the session.jsonl mtime once it exists", () => {
|
|
79
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-progress-"));
|
|
80
|
+
const sid = "11111111-1111-4111-8111-111111111111";
|
|
81
|
+
assert.equal(sessionLogActivityMs(dataDir, sid), null, "absent before the child starts");
|
|
82
|
+
const file = writeSessionLog(dataDir, sid);
|
|
83
|
+
const got = sessionLogActivityMs(dataDir, sid);
|
|
84
|
+
assert.ok(typeof got === "number", `expected mtime, got ${got}`);
|
|
85
|
+
assert.ok(Math.abs(got - fs.statSync(file).mtimeMs) < 1, "reads the live mtime");
|
|
86
|
+
});
|
|
87
|
+
test("sessionLogActivityMs is null for a missing tree and never throws", () => {
|
|
88
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-progress-"));
|
|
89
|
+
assert.equal(sessionLogActivityMs(dataDir, "no-such-session"), null);
|
|
90
|
+
assert.equal(sessionLogActivityMs(path.join(dataDir, "missing"), "x"), null);
|
|
91
|
+
});
|
|
92
|
+
// ── idle minutes ─────────────────────────────────────────────────────────────
|
|
93
|
+
test("museIdleMinutes measures silence from last activity, else the bound", () => {
|
|
94
|
+
const last = new Date(Date.now() - 20 * 60_000).toISOString();
|
|
95
|
+
assert.equal(museIdleMinutes({ lastActivityAt: last, idleTimeoutMs: 1_200_000 }), 20);
|
|
96
|
+
assert.equal(museIdleMinutes({ lastActivityAt: null, idleTimeoutMs: 1_200_000 }), 20, "silent since spawn was killed after exactly the bound");
|
|
97
|
+
assert.equal(museIdleMinutes({ lastActivityAt: null, idleTimeoutMs: undefined }), null, "disabled watchdog has no idle kill to explain");
|
|
98
|
+
});
|
|
99
|
+
// ── stall metadata object ────────────────────────────────────────────────────
|
|
100
|
+
test("museStallMetadata carries exactly the five worker_sessions fields", () => {
|
|
101
|
+
assert.deepEqual(museStallMetadata({
|
|
102
|
+
lastActivityAt: "2026-10-01T19:00:00.000Z",
|
|
103
|
+
toolCallCount: 24,
|
|
104
|
+
lastToolName: "npm_test",
|
|
105
|
+
firstOutputMs: 1234,
|
|
106
|
+
idleTimedOut: true,
|
|
107
|
+
}), {
|
|
108
|
+
lastActivityAt: "2026-10-01T19:00:00.000Z",
|
|
109
|
+
toolCallCount: 24,
|
|
110
|
+
lastToolName: "npm_test",
|
|
111
|
+
firstOutputMs: 1234,
|
|
112
|
+
idleTimedOut: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// ── arrival-stamped normalized events ────────────────────────────────────────
|
|
116
|
+
function env(payloadType, payload) {
|
|
117
|
+
return JSON.stringify({ payload_type: payloadType, payload, stream: { kind: "session", id: "s1" } });
|
|
118
|
+
}
|
|
119
|
+
function timedStdout() {
|
|
120
|
+
return [
|
|
121
|
+
env("run.model.configured", { run_stream: { kind: "run", id: "r1" } }),
|
|
122
|
+
env("task.lifecycle.side_effect_intent", {
|
|
123
|
+
run_stream: { kind: "run", id: "r1" },
|
|
124
|
+
event: { operation: "tool:read_file", idempotency_key: "tool:c1", task_id: "t1" },
|
|
125
|
+
}),
|
|
126
|
+
env("tool.result", { call_id: "c1", correlation_facts: { tool_name: "read_file", outcome: "success" } }),
|
|
127
|
+
env("task.lifecycle.side_effect_intent", {
|
|
128
|
+
run_stream: { kind: "run", id: "r1" },
|
|
129
|
+
event: { operation: "tool:npm_test", idempotency_key: "tool:c2", task_id: "t2" },
|
|
130
|
+
}),
|
|
131
|
+
env("run.terminal.completed", { run_stream: { kind: "run", id: "r1" }, text: "done", reason: "" }),
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
const baseInput = {
|
|
135
|
+
stdout: timedStdout(),
|
|
136
|
+
stderr: "",
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
expectedModel: MODEL,
|
|
139
|
+
};
|
|
140
|
+
test("arrival times stamp every event; tool_call carries durationMs for the completed call", () => {
|
|
141
|
+
const t0 = Date.UTC(2026, 9, 1, 19, 0, 0);
|
|
142
|
+
// lineTs is parallel to stdout lines: intent c1 at +1s, result at +61s, intent c2 at +62s.
|
|
143
|
+
const lineTs = [t0, t0 + 1_000, t0 + 61_000, t0 + 62_000, t0 + 63_000];
|
|
144
|
+
const r = parseMuseRun({ ...baseInput, lineTs, now: new Date(t0 + 64_000).toISOString() });
|
|
145
|
+
const tools = r.events.filter((e) => e.type === "tool_call");
|
|
146
|
+
assert.equal(tools.length, 2);
|
|
147
|
+
assert.equal(tools[0].name, "read_file");
|
|
148
|
+
assert.equal(tools[0].ts, new Date(t0 + 1_000).toISOString());
|
|
149
|
+
assert.equal(tools[0].durationMs, 60_000, "result arrival minus intent arrival");
|
|
150
|
+
assert.equal(tools[1].name, "npm_test");
|
|
151
|
+
assert.equal(tools[1].ts, new Date(t0 + 62_000).toISOString());
|
|
152
|
+
assert.equal("durationMs" in tools[1], false, "a bare intent (no result) carries no duration");
|
|
153
|
+
for (const e of r.events) {
|
|
154
|
+
assert.ok(typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts)), `every event has ts: ${JSON.stringify(e).slice(0, 120)}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
test("without arrival times or now the parser output stays byte-stable (no ts)", () => {
|
|
158
|
+
const r = parseMuseRun({ ...baseInput });
|
|
159
|
+
const tools = r.events.filter((e) => e.type === "tool_call");
|
|
160
|
+
assert.deepEqual(tools.map((t) => ({ type: t.type, name: t.name })), [
|
|
161
|
+
{ type: "tool_call", name: "read_file" },
|
|
162
|
+
{ type: "tool_call", name: "npm_test" },
|
|
163
|
+
]);
|
|
164
|
+
for (const e of r.events)
|
|
165
|
+
assert.equal("ts" in e, false);
|
|
166
|
+
});
|
|
167
|
+
test("now alone stamps every event when the stream gave no usable time", () => {
|
|
168
|
+
const now = "2026-10-01T19:05:00.000Z";
|
|
169
|
+
const r = parseMuseRun({ ...baseInput, now });
|
|
170
|
+
assert.ok(r.events.length > 0);
|
|
171
|
+
for (const e of r.events)
|
|
172
|
+
assert.equal(e.ts, now);
|
|
173
|
+
const tools = r.events.filter((e) => e.type === "tool_call");
|
|
174
|
+
for (const t of tools)
|
|
175
|
+
assert.equal("durationMs" in t, false);
|
|
176
|
+
});
|
|
177
|
+
test("the written ndjson carries ts on every event and durationMs on completed tool calls", () => {
|
|
178
|
+
const t0 = Date.UTC(2026, 9, 1, 19, 0, 0);
|
|
179
|
+
const lineTs = [t0, t0 + 1_000, t0 + 61_000, t0 + 62_000, t0 + 63_000];
|
|
180
|
+
const r = parseMuseRun({ ...baseInput, lineTs, now: new Date(t0 + 64_000).toISOString() });
|
|
181
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-ndjson-"));
|
|
182
|
+
const logPath = path.join(dir, "session.ndjson");
|
|
183
|
+
// What runMuseDeveloperSession writes: the result event embeds the session summary.
|
|
184
|
+
const summary = {
|
|
185
|
+
firstOutputMs: 900,
|
|
186
|
+
lastActivityAt: new Date(t0 + 63_000).toISOString(),
|
|
187
|
+
idleTimedOut: false,
|
|
188
|
+
toolCallCount: r.tools.length,
|
|
189
|
+
lastToolName: "npm_test",
|
|
190
|
+
};
|
|
191
|
+
const events = r.events.map((e) => e.type === "result" ? { ...e, muse: { ...summary } } : e);
|
|
192
|
+
writeMuseNormalizedLog(logPath, events, "");
|
|
193
|
+
const lines = fs.readFileSync(logPath, "utf8").trim().split("\n");
|
|
194
|
+
assert.equal(lines.length, events.length);
|
|
195
|
+
const read = lines.map((l) => JSON.parse(l));
|
|
196
|
+
for (const e of read) {
|
|
197
|
+
assert.ok(typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts)), "ts on every written event");
|
|
198
|
+
}
|
|
199
|
+
const tools = read.filter((e) => e.type === "tool_call");
|
|
200
|
+
assert.equal(tools.length, 2);
|
|
201
|
+
assert.equal(tools[0].durationMs, 60_000);
|
|
202
|
+
assert.equal("durationMs" in tools[1], false);
|
|
203
|
+
const result = read.find((e) => e.type === "result");
|
|
204
|
+
const muse = result.muse;
|
|
205
|
+
assert.equal(muse.firstOutputMs, 900);
|
|
206
|
+
assert.equal(muse.toolCallCount, 2);
|
|
207
|
+
assert.equal(muse.lastToolName, "npm_test");
|
|
208
|
+
assert.equal(muse.idleTimedOut, false);
|
|
209
|
+
assert.equal(muse.lastActivityAt, new Date(t0 + 63_000).toISOString());
|
|
210
|
+
});
|