@selesai/code 0.13.38 → 0.13.40
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/CHANGELOG.md +7 -0
- package/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-graft/cli.test.ts +5 -5
- package/dist/extensions/pi-graft/cli.ts +10 -5
- package/dist/extensions/pi-graft/commands.test.ts +5 -5
- package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
- package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
- package/dist/extensions/pi-graft/prompt.test.ts +1 -1
- package/dist/extensions/pi-graft/state.test.ts +9 -9
- package/dist/extensions/pi-graft/test-support.ts +1 -1
- package/dist/extensions/pi-graft/tools.test.ts +1 -1
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +142 -3
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -42,6 +42,8 @@ export interface FakeChildResponse {
|
|
|
42
42
|
createError?: string;
|
|
43
43
|
/** Keeps the run open until the parent aborts it. */
|
|
44
44
|
hangUntilAbort?: boolean;
|
|
45
|
+
/** Rejects the first N `steer()` calls (a child that cannot take steering yet), then accepts them. */
|
|
46
|
+
failSteers?: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface FakeChildSessionRecord {
|
|
@@ -262,7 +264,9 @@ export function createFakeChildSessions(queueDir: () => string): FakeChildSessio
|
|
|
262
264
|
await sleep(20, abortedPromise);
|
|
263
265
|
}
|
|
264
266
|
};
|
|
267
|
+
let failingSteers = 0;
|
|
265
268
|
const runScript = async (response: FakeChildResponse, task: string): Promise<void> => {
|
|
269
|
+
failingSteers = typeof response.failSteers === "number" ? response.failSteers : 0;
|
|
266
270
|
if (typeof response.delay === "number" && response.delay > 0) await sleep(response.delay, abortedPromise);
|
|
267
271
|
await waitForReleasePath(response.waitForPath);
|
|
268
272
|
if (record.aborted) return;
|
|
@@ -376,6 +380,10 @@ export function createFakeChildSessions(queueDir: () => string): FakeChildSessio
|
|
|
376
380
|
}
|
|
377
381
|
},
|
|
378
382
|
async steer(text) {
|
|
383
|
+
if (failingSteers > 0) {
|
|
384
|
+
failingSteers--;
|
|
385
|
+
throw new Error("mock child cannot accept steering");
|
|
386
|
+
}
|
|
379
387
|
record.steers.push({ text, mode: "steer" });
|
|
380
388
|
recordSteer(text, "steer");
|
|
381
389
|
},
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createEventBus } from "../../../../core/event-bus.ts";
|
|
3
|
+
import { SUBAGENT_OBSERVE_EVENT, isSubagentRpcProjection, type SubagentObserveRequest, type SubagentRpcEvent, type SubagentRpcProjection } from "../../../../core/subagent-rpc.ts";
|
|
4
|
+
import { RpcSubagentBridge } from "../../../../modes/rpc/subagent-bridge.ts";
|
|
5
|
+
import { registerSubagentObservability } from "../../src/extension/observability.ts";
|
|
6
|
+
import { buildFleetStatus } from "../../src/extension/rpc.ts";
|
|
7
|
+
import { createAsyncJobTracker } from "../../src/runs/background/async-job-tracker.ts";
|
|
8
|
+
import type { SubagentState } from "../../src/shared/types.ts";
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { retainLiveForegroundNestedRoute } from "../../src/integrations/pi-web-session-liveness.ts";
|
|
14
|
+
import { createRetainedNestedRouteTracker } from "../../src/runs/background/retained-nested-route-tracker.ts";
|
|
15
|
+
import { createNestedRoute, writeNestedEvent } from "../../src/runs/shared/nested-events.ts";
|
|
16
|
+
|
|
17
|
+
function native() {
|
|
18
|
+
const bus = createEventBus();
|
|
19
|
+
const state = { currentSessionId: "/managed/session", supervisorOwnerSessionId: "host", foregroundControls: new Map(), foregroundRuns: new Map(),
|
|
20
|
+
asyncJobs: new Map(), fleetJobs: new Map(), cleanupTimers: new Map(), resultFileCoalescer: new Map(), lastUiContext: null,
|
|
21
|
+
activeAsyncCapacity: { used: 1, limit: 3 }, poller: null } as unknown as SubagentState;
|
|
22
|
+
const waiting: Array<{ runId: string; childIndex: number; expectsReply: boolean }> = [];
|
|
23
|
+
const fleetKeys = { sessionId: null, next: 0, keys: new Map() };
|
|
24
|
+
const adapter = registerSubagentObservability({ events: bus, state, getHostSessionId: () => state.supervisorOwnerSessionId!,
|
|
25
|
+
getWaitingChildren: () => waiting, getFleet: () => buildFleetStatus(state, fleetKeys, state.currentSessionId) });
|
|
26
|
+
const events: SubagentRpcEvent[] = [];
|
|
27
|
+
const bridge = new RpcSubagentBridge(bus, event => events.push(event), { version: 1, sessionId: "host", epoch: "bind-1" });
|
|
28
|
+
bridge.initialize();
|
|
29
|
+
const status = () => {
|
|
30
|
+
const reply = bridge.request({ type: "subagent_status", version: 1 });
|
|
31
|
+
if (!reply.success || reply.command !== "subagent_status") throw new Error(JSON.stringify(reply));
|
|
32
|
+
return reply.data;
|
|
33
|
+
};
|
|
34
|
+
return { bus, state, waiting, adapter, bridge, events, status, dispose: () => { bridge.dispose(); adapter.dispose(); } };
|
|
35
|
+
}
|
|
36
|
+
afterEach(() => vi.useRealTimers());
|
|
37
|
+
|
|
38
|
+
describe("RpcSubagentBridge / native observability", () => {
|
|
39
|
+
it("explicitly reports missing backend and rejects requested versions", () => {
|
|
40
|
+
const output = vi.fn();
|
|
41
|
+
const bridge = new RpcSubagentBridge(undefined, output, { version: 1, sessionId: "host", epoch: "epoch" });
|
|
42
|
+
bridge.initialize();
|
|
43
|
+
expect(bridge.request({ id: "s", type: "subagent_status", version: 1 })).toMatchObject({ id: "s", success: true, data: { available: false, capabilities: { snapshot: true, events: false } } });
|
|
44
|
+
expect(bridge.request({ type: "subagent_status", version: 2 as 1 })).toMatchObject({ success: false, error: expect.stringContaining("version") });
|
|
45
|
+
expect(bridge.request({ type: "subagent_status" } as never)).toMatchObject({ success: false });
|
|
46
|
+
expect(bridge.request({ type: "subagent_status", version: 1, id: "x".repeat(129) })).toMatchObject({ success: false });
|
|
47
|
+
expect(bridge.request({ type: "subagent_status", version: 1, method: "spawn" } as never)).toMatchObject({ success: false });
|
|
48
|
+
expect(output).toHaveBeenCalledOnce();
|
|
49
|
+
bridge.dispose();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("observes direct native status after parent agent_end, coalesces progress and clears actual supervisor waits", async () => {
|
|
53
|
+
vi.useFakeTimers();
|
|
54
|
+
const n = native();
|
|
55
|
+
try {
|
|
56
|
+
n.state.asyncJobs.set("run-a", { asyncId: "run-a", asyncDir: "/managed/run", sessionId: n.state.currentSessionId!, status: "running", startedAt: 10,
|
|
57
|
+
steps: [{ agent: "worker", status: "running", index: 0, tokens: { input: 10, output: 5, total: 15 }, currentTool: "read" }] });
|
|
58
|
+
n.bus.emit("agent_end", { messages: [] });
|
|
59
|
+
const snapshot = n.status();
|
|
60
|
+
expect(snapshot).toMatchObject({ available: true, sessionId: "host", epoch: "bind-1", fleet: { totalActive: 1 }, runs: [{ runId: "run-a", status: "running" }] });
|
|
61
|
+
expect(snapshot.runs[0].children[0].outcome).toBeUndefined();
|
|
62
|
+
expect(snapshot.runs[0].processTerminal).toBeUndefined();
|
|
63
|
+
const revision = snapshot.revision;
|
|
64
|
+
n.waiting.push({ runId: "run-a", childIndex: 0, expectsReply: true });
|
|
65
|
+
const child = n.state.asyncJobs.get("run-a")!.steps![0];
|
|
66
|
+
child.activityState = "needs_attention";
|
|
67
|
+
child.currentTool = "contact_supervisor";
|
|
68
|
+
child.recentOutput = ["first", "latest"];
|
|
69
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
70
|
+
expect(n.events.at(-1)!.snapshot.runs[0].children[0]).toMatchObject({ activity: "waiting_supervisor", progress: "latest" });
|
|
71
|
+
expect(n.events.at(-1)!.revision).toBe(revision + 1);
|
|
72
|
+
n.waiting.length = 0;
|
|
73
|
+
expect(n.status().runs[0].children[0].activity).toBe("needs_attention");
|
|
74
|
+
child.activityState = undefined;
|
|
75
|
+
child.currentTool = "bash";
|
|
76
|
+
expect(n.status().runs[0].children[0].activity).toBeUndefined();
|
|
77
|
+
// Run level follows the same projection: once the runner clears `activityState` (D12) no attention is reported.
|
|
78
|
+
const job = n.state.asyncJobs.get("run-a")!;
|
|
79
|
+
job.activityState = "needs_attention";
|
|
80
|
+
expect(n.status().runs[0].activity).toBe("needs_attention");
|
|
81
|
+
job.activityState = undefined;
|
|
82
|
+
expect(n.status().runs[0].activity).toBeUndefined();
|
|
83
|
+
const eventCount = n.events.length;
|
|
84
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
85
|
+
expect(n.events).toHaveLength(eventCount);
|
|
86
|
+
} finally { n.dispose(); }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps explicit task outcome separate from lifecycle and process terminal evidence", () => {
|
|
90
|
+
const n = native();
|
|
91
|
+
try {
|
|
92
|
+
n.state.asyncJobs.set("a", { asyncId: "a", asyncDir: "/private/path", sessionId: n.state.currentSessionId!, status: "complete", endedAt: 30,
|
|
93
|
+
processTerminal: { version: 1, state: "unknown", reason: "observer-unavailable", runId: "a", runnerProcessInstanceId: "instance" },
|
|
94
|
+
steps: [{ agent: "worker", status: "completed", execution: { status: "failed", success: false, exitCode: 1 }, error: "acceptance rejected" }] });
|
|
95
|
+
const node = n.status().runs[0];
|
|
96
|
+
expect(node.status).toBe("complete");
|
|
97
|
+
expect(node.outcome).toBeUndefined();
|
|
98
|
+
expect(node.processTerminal).toEqual({ state: "unknown", reason: "observer-unavailable" });
|
|
99
|
+
expect(node.children[0].outcome).toEqual({ status: "failed", success: false, exitCode: 1 });
|
|
100
|
+
n.state.asyncJobs.get("a")!.steps![0].execution = { status: "completed", success: true, exitCode: 0 };
|
|
101
|
+
expect(n.status().runs[0].children[0].outcome?.success).toBe(true);
|
|
102
|
+
expect(n.status().runs[0].processTerminal?.state).toBe("unknown");
|
|
103
|
+
expect(JSON.stringify(n.status())).not.toContain("/private/path");
|
|
104
|
+
} finally { n.dispose(); }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("bounds runs, child trees, malformed fields and path identity with explicit omissions", () => {
|
|
108
|
+
const n = native();
|
|
109
|
+
try {
|
|
110
|
+
for (let i = 0; i < 50; i++) n.state.asyncJobs.set(`r${i}`, { asyncId: `r${i}`, asyncDir: "/private", status: "running", sessionId: n.state.currentSessionId!, startedAt: 1,
|
|
111
|
+
steps: Array.from({ length: 30 }, (_, index) => ({ agent: "worker", index, status: "running", runId: "/not-an-id", recentOutput: ["x".repeat(4000)] })) });
|
|
112
|
+
const snapshot = n.status();
|
|
113
|
+
expect(isSubagentRpcProjection({ fleet: snapshot.fleet, runs: snapshot.runs, omitted: snapshot.omitted })).toBe(true);
|
|
114
|
+
expect(snapshot.runs.length).toBeLessThanOrEqual(32);
|
|
115
|
+
expect(snapshot.runs[0].children).toHaveLength(16);
|
|
116
|
+
expect(snapshot.omitted.runs).toBeGreaterThan(0);
|
|
117
|
+
expect(snapshot.omitted.children).toBeGreaterThan(0);
|
|
118
|
+
expect(snapshot.fleet.totalActive).toBe(1500);
|
|
119
|
+
expect(snapshot.fleet.omitted).toBe(1484);
|
|
120
|
+
expect(snapshot.runs[0].children[0].runId).toBeUndefined();
|
|
121
|
+
expect(snapshot.runs[0].children[0].progress!.length).toBeLessThanOrEqual(256);
|
|
122
|
+
expect(Buffer.byteLength(JSON.stringify(snapshot))).toBeLessThan(65536);
|
|
123
|
+
expect(isSubagentRpcProjection({ fleet: snapshot.fleet, omitted: snapshot.omitted, runs: [{ ...snapshot.runs[0], status: "fake" }] })).toBe(false);
|
|
124
|
+
} finally { n.dispose(); }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects malformed backend projections and stale generation callbacks", () => {
|
|
128
|
+
const bus = createEventBus();
|
|
129
|
+
const projection: SubagentRpcProjection = { fleet: { version: 1, entries: [], totalActive: 0, omitted: 0, topLevelAsyncCapacity: { used: 0, limit: 0 } }, runs: [], omitted: { runs: 0, children: 0 } };
|
|
130
|
+
let callback: ((p: SubagentRpcProjection | null) => void) | undefined;
|
|
131
|
+
const dispose = vi.fn();
|
|
132
|
+
const off = bus.on(SUBAGENT_OBSERVE_EVENT, raw => (raw as SubagentObserveRequest).accept({ snapshot: () => projection, subscribe: listener => { callback = listener; return dispose; } }));
|
|
133
|
+
const output = vi.fn();
|
|
134
|
+
const old = new RpcSubagentBridge(bus, output, { version: 1, sessionId: "host", epoch: "old" });
|
|
135
|
+
old.initialize();
|
|
136
|
+
const stale = callback!;
|
|
137
|
+
old.dispose();
|
|
138
|
+
const next = new RpcSubagentBridge(bus, output, { version: 1, sessionId: "new", epoch: "new" });
|
|
139
|
+
next.initialize();
|
|
140
|
+
stale({ ...projection, omitted: { runs: 1, children: 0 } });
|
|
141
|
+
expect(output).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(dispose).toHaveBeenCalledOnce();
|
|
143
|
+
callback!({ ...projection, runs: [null] } as never);
|
|
144
|
+
expect(next.request({ type: "subagent_status", version: 1 })).toMatchObject({ success: false, error: expect.stringContaining("Malformed") });
|
|
145
|
+
next.dispose(); off();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps tracker-cached descendants visible after foreground settlement, fences ownership and clears terminal routes", async () => {
|
|
149
|
+
const n = native();
|
|
150
|
+
const route = createNestedRoute(`observe-${randomUUID()}`);
|
|
151
|
+
const write = (state: "running" | "complete", ts: number, id = "nested-observer-child") => writeNestedEvent(route, {
|
|
152
|
+
type: state === "running" ? "subagent.nested.updated" : "subagent.nested.completed", ts, parentRunId: route.rootRunId,
|
|
153
|
+
child: { id, parentRunId: route.rootRunId, depth: 1, path: [{ runId: route.rootRunId }], state, agent: "worker", lastUpdate: ts },
|
|
154
|
+
});
|
|
155
|
+
const tracker = createRetainedNestedRouteTracker(n.state, { platform: "win32", pollIntervalMs: 10 });
|
|
156
|
+
try {
|
|
157
|
+
n.state.foregroundControls.set(route.rootRunId, { runId: route.rootRunId, sessionId: n.state.currentSessionId!, parentWorkflowRunId: "workflow-owner", mode: "single", startedAt: 10, updatedAt: 10 });
|
|
158
|
+
write("running", 100);
|
|
159
|
+
expect(retainLiveForegroundNestedRoute(n.state, route)).toBe(true);
|
|
160
|
+
n.state.foregroundControls.delete(route.rootRunId); // scheduling owner settled; descendants remain live
|
|
161
|
+
expect(n.status().runs).toMatchObject([{ runId: "nested-observer-child", workflowId: "workflow-owner", status: "running" }]);
|
|
162
|
+
expect(n.status().runs[0].outcome).toBeUndefined();
|
|
163
|
+
tracker.track(route.rootRunId);
|
|
164
|
+
n.state.asyncJobs.set("nested-observer-child", { asyncId: "nested-observer-child", asyncDir: "/native", sessionId: n.state.currentSessionId!, status: "running" });
|
|
165
|
+
expect(n.status().runs.filter(r => r.runId === "nested-observer-child")).toHaveLength(1);
|
|
166
|
+
n.state.asyncJobs.delete("nested-observer-child");
|
|
167
|
+
write("complete", 200);
|
|
168
|
+
await vi.waitFor(() => expect(n.state.retainedForegroundNestedChildren?.size).toBe(0));
|
|
169
|
+
expect(n.status().runs).toEqual([]);
|
|
170
|
+
write("running", 300, "nested-new-child");
|
|
171
|
+
expect(retainLiveForegroundNestedRoute(n.state, route)).toBe(true);
|
|
172
|
+
tracker.track(route.rootRunId);
|
|
173
|
+
n.state.currentSessionId = "/new/session";
|
|
174
|
+
expect(n.status().runs).toEqual([]);
|
|
175
|
+
await vi.waitFor(() => expect(n.state.retainedForegroundNestedRoutes?.size).toBe(0));
|
|
176
|
+
tracker.clear();
|
|
177
|
+
expect(n.state.retainedForegroundNestedChildren?.size).toBe(0);
|
|
178
|
+
} finally { tracker.clear(); n.dispose(); rmSync(dirname(route.eventSink), { recursive: true, force: true }); }
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("receives background progress from the existing artifact tracker without observer I/O", async () => {
|
|
182
|
+
const root = mkdtempSync(join(tmpdir(), "subagent-observe-"));
|
|
183
|
+
const n = native();
|
|
184
|
+
const runDir = join(root, "run-a"); mkdirSync(runDir);
|
|
185
|
+
const status = { runId: "run-a", sessionId: n.state.currentSessionId, mode: "single", state: "running", startedAt: Date.now(), pid: process.pid,
|
|
186
|
+
steps: [{ agent: "worker", status: "running", currentTool: "read", tokens: { input: 12, output: 3, total: 15 } }] };
|
|
187
|
+
writeFileSync(join(runDir, "status.json"), JSON.stringify(status));
|
|
188
|
+
const tracker = createAsyncJobTracker({ events: n.bus }, n.state, root, { pollIntervalMs: 20, platform: "win32", resultsDir: join(root, "results"), widgetEnabled: false, kill: () => true });
|
|
189
|
+
try {
|
|
190
|
+
tracker.handleStarted({ id: "run-a", asyncDir: runDir, sessionId: n.state.currentSessionId, agent: "worker", pid: process.pid });
|
|
191
|
+
await vi.waitFor(() => expect(n.status().runs[0].children[0].currentTool).toBe("read"));
|
|
192
|
+
n.bus.emit("agent_end", { messages: [] });
|
|
193
|
+
writeFileSync(join(runDir, "status.json"), JSON.stringify({ ...status, steps: [{ ...status.steps[0], currentTool: "bash" }] }));
|
|
194
|
+
await vi.waitFor(() => expect(n.status().runs[0]).toMatchObject({ status: "running", children: [{ currentTool: "bash" }] }));
|
|
195
|
+
expect(n.status().fleet.totalActive).toBe(1);
|
|
196
|
+
} finally { tracker.dispose(); for (const timer of n.state.cleanupTimers.values()) clearTimeout(timer); n.dispose(); rmSync(root, { recursive: true, force: true }); }
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { computeProcessStartKey, processLiveness, processStartKey, psProcessStartKey, startKeyScheme } from "../../src/shared/process-identity.ts";
|
|
4
|
+
|
|
5
|
+
function resolvers() {
|
|
6
|
+
const calls: string[] = [];
|
|
7
|
+
return {
|
|
8
|
+
calls,
|
|
9
|
+
linux: (pid: number) => { calls.push(`linux:${pid}`); return undefined as string | undefined; },
|
|
10
|
+
ps: (pid: number) => { calls.push(`ps:${pid}`); return `ps:started-${pid}` as string | undefined; },
|
|
11
|
+
windows: (pid: number) => { calls.push(`win:${pid}`); return `win:started-${pid}` as string | undefined; },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("process start key", () => {
|
|
16
|
+
it("uses ps on darwin and other non-linux, non-windows unixes", () => {
|
|
17
|
+
for (const platform of ["darwin", "freebsd", "openbsd", "sunos"] as const) {
|
|
18
|
+
const fakes = resolvers();
|
|
19
|
+
assert.equal(computeProcessStartKey(42, platform, fakes), "ps:started-42", platform);
|
|
20
|
+
assert.deepEqual(fakes.calls, ["ps:42"], platform);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps Linux on /proc with a ps fallback and Windows on CIM", () => {
|
|
25
|
+
const fakes = resolvers();
|
|
26
|
+
assert.equal(computeProcessStartKey(7, "linux", { ...fakes, linux: () => "linux:123" }), "linux:123");
|
|
27
|
+
assert.equal(computeProcessStartKey(7, "linux", fakes), "ps:started-7");
|
|
28
|
+
assert.deepEqual(fakes.calls, ["linux:7", "ps:7"]);
|
|
29
|
+
const win = resolvers();
|
|
30
|
+
assert.equal(computeProcessStartKey(9, "win32", win), "win:started-9");
|
|
31
|
+
assert.deepEqual(win.calls, ["win:9"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns undefined when the identity cannot be established", () => {
|
|
35
|
+
assert.equal(computeProcessStartKey(1, "darwin", { ps: () => undefined }), undefined);
|
|
36
|
+
assert.equal(computeProcessStartKey(1, "win32", { windows: () => undefined }), undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("caches only this process's own key", () => {
|
|
40
|
+
const own = processStartKey(process.pid);
|
|
41
|
+
assert.equal(processStartKey(process.pid), own);
|
|
42
|
+
if (process.platform === "darwin") assert.match(own ?? "", /^ps:\S/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads a real ps start key on darwin", { skip: process.platform !== "darwin" }, () => {
|
|
46
|
+
const key = psProcessStartKey(process.pid);
|
|
47
|
+
assert.match(key ?? "", /^ps:\S/);
|
|
48
|
+
assert.equal(startKeyScheme(key!), "ps");
|
|
49
|
+
assert.equal(psProcessStartKey(2 ** 22 + 12345), undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("names key schemes so unlike keys are never compared", () => {
|
|
53
|
+
assert.equal(startKeyScheme("linux:123"), "linux");
|
|
54
|
+
assert.equal(startKeyScheme("ps:Mon Oct 4 10:00:00 2026"), "ps");
|
|
55
|
+
assert.equal(startKeyScheme("win:2026"), "win");
|
|
56
|
+
assert.equal(startKeyScheme("opaque"), "");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("process liveness", () => {
|
|
61
|
+
const killing = (code: string | undefined) => () => { throw Object.assign(new Error(code ?? "boom"), code ? { code } : {}); };
|
|
62
|
+
|
|
63
|
+
it("maps kill(pid, 0) outcomes to alive, dead, or unknown", () => {
|
|
64
|
+
assert.equal(processLiveness(10, () => true), "alive");
|
|
65
|
+
assert.equal(processLiveness(10, killing("ESRCH")), "dead");
|
|
66
|
+
assert.equal(processLiveness(10, killing("EPERM")), "alive");
|
|
67
|
+
assert.equal(processLiveness(10, killing("EINVAL")), "unknown");
|
|
68
|
+
assert.equal(processLiveness(10, killing(undefined)), "unknown");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("never probes non-positive or fractional pids (kill(0) would signal the process group)", () => {
|
|
72
|
+
const probe = () => { throw new Error("must not be called"); };
|
|
73
|
+
for (const pid of [0, -1, 1.5, Number.NaN]) assert.equal(processLiveness(pid, probe), "unknown");
|
|
74
|
+
});
|
|
75
|
+
});
|