|
1 | 1 | import { EventEmitter } from "node:events"; |
2 | 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
3 | | -import type { Socket } from "node:net"; |
| 3 | +import { createServer, type Socket } from "node:net"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import { join } from "node:path"; |
6 | 6 | import type { Api, Model } from "@earendil-works/pi-ai"; |
@@ -31,9 +31,13 @@ import { |
31 | 31 | } from "../src/modes/daemon/daemon-mode.js"; |
32 | 32 | import { |
33 | 33 | createDaemonCommandEnvelope, |
| 34 | + DAEMON_PROTOCOL_INFO, |
| 35 | + DAEMON_SCHEMA_REVISION, |
34 | 36 | type DaemonAttachResult, |
35 | 37 | type DaemonCommand, |
| 38 | + failure, |
36 | 39 | } from "../src/modes/daemon/daemon-protocol.js"; |
| 40 | +import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV } from "../src/modes/daemon/daemon-worker-protocol.js"; |
37 | 41 |
|
38 | 42 | describe("daemon mode helpers", () => { |
39 | 43 | it("preserves envelope client identity while registering prompt admission", () => { |
@@ -1018,6 +1022,64 @@ describe("daemon mode helpers", () => { |
1018 | 1022 | expect(sendRemoteAgentSessionMessage).toHaveBeenCalledWith(source, "deleted-child", "continue", undefined); |
1019 | 1023 | }); |
1020 | 1024 |
|
| 1025 | + it("does not retry permanent ambiguity errors from the supervisor", async () => { |
| 1026 | + const tempDir = mkdtempSync(join(tmpdir(), "pa-ambiguous-")); |
| 1027 | + const socketPath = join(tempDir, "s"); |
| 1028 | + let requestCount = 0; |
| 1029 | + const server = createServer((socket) => { |
| 1030 | + socket.write( |
| 1031 | + `${JSON.stringify({ |
| 1032 | + type: "daemon_hello", |
| 1033 | + socketPath, |
| 1034 | + protocol: DAEMON_PROTOCOL_INFO, |
| 1035 | + schemaRevision: DAEMON_SCHEMA_REVISION, |
| 1036 | + serverCapabilities: [], |
| 1037 | + })}\n`, |
| 1038 | + ); |
| 1039 | + let buffered = ""; |
| 1040 | + socket.on("data", (chunk: Buffer) => { |
| 1041 | + buffered += chunk.toString("utf8"); |
| 1042 | + const newline = buffered.indexOf("\n"); |
| 1043 | + if (newline < 0 || requestCount > 0) return; |
| 1044 | + const command = JSON.parse(buffered.slice(0, newline)) as { id?: string }; |
| 1045 | + requestCount++; |
| 1046 | + socket.write( |
| 1047 | + `${JSON.stringify(failure(command.id, "send_message", new Error('Ambiguous session selector "duplicate"')))}\n`, |
| 1048 | + ); |
| 1049 | + }); |
| 1050 | + }); |
| 1051 | + const previousSocketPath = process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; |
| 1052 | + try { |
| 1053 | + await new Promise<void>((resolve, reject) => { |
| 1054 | + server.once("error", reject); |
| 1055 | + server.listen(socketPath, resolve); |
| 1056 | + }); |
| 1057 | + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = socketPath; |
| 1058 | + const daemon = new AgentDaemon(join(tempDir, "worker.sock"), { |
| 1059 | + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir }, |
| 1060 | + createRuntime: vi.fn(), |
| 1061 | + }); |
| 1062 | + const source = makeState("source"); |
| 1063 | + const internals = daemon as unknown as { |
| 1064 | + sendRemoteAgentSessionMessage( |
| 1065 | + fromState: ActiveSessionState, |
| 1066 | + targetSelector: string, |
| 1067 | + message: string, |
| 1068 | + ): Promise<unknown>; |
| 1069 | + }; |
| 1070 | + |
| 1071 | + await expect(internals.sendRemoteAgentSessionMessage(source, "duplicate", "hello")).rejects.toThrow( |
| 1072 | + 'Ambiguous session selector "duplicate"', |
| 1073 | + ); |
| 1074 | + expect(requestCount).toBe(1); |
| 1075 | + } finally { |
| 1076 | + if (previousSocketPath === undefined) delete process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; |
| 1077 | + else process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = previousSocketPath; |
| 1078 | + await new Promise<void>((resolve) => server.close(() => resolve())); |
| 1079 | + rmSync(tempDir, { recursive: true, force: true }); |
| 1080 | + } |
| 1081 | + }); |
| 1082 | + |
1021 | 1083 | it("reports queued status when a direct accept races into the queue", async () => { |
1022 | 1084 | const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { |
1023 | 1085 | defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, |
@@ -3136,6 +3198,84 @@ describe("daemon mode helpers", () => { |
3136 | 3198 | expect(client.catchupActiveSessionIds).toEqual(new Set()); |
3137 | 3199 | }); |
3138 | 3200 |
|
| 3201 | + it("does not attach a non-chunked client until its snapshot is ready", async () => { |
| 3202 | + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { |
| 3203 | + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, |
| 3204 | + createRuntime: vi.fn(), |
| 3205 | + }); |
| 3206 | + const state = makeState("active"); |
| 3207 | + const client = makeClient("client-1", state.activeSessionId); |
| 3208 | + client.attachedActiveSessionIds.clear(); |
| 3209 | + let releaseSnapshot!: () => void; |
| 3210 | + const snapshotGate = new Promise<void>((resolve) => { |
| 3211 | + releaseSnapshot = resolve; |
| 3212 | + }); |
| 3213 | + const result = { |
| 3214 | + activeSessionId: state.activeSessionId, |
| 3215 | + snapshot: { summary: {}, state: {}, messages: [] }, |
| 3216 | + lastEventSequence: 0, |
| 3217 | + } as unknown as DaemonAttachResult; |
| 3218 | + const internals = daemon as unknown as { |
| 3219 | + sessions: Map<string, ActiveSessionState>; |
| 3220 | + createAttachResult: ReturnType<typeof vi.fn>; |
| 3221 | + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise<unknown>; |
| 3222 | + }; |
| 3223 | + internals.sessions.set(state.activeSessionId, state); |
| 3224 | + internals.createAttachResult = vi.fn(async () => { |
| 3225 | + await snapshotGate; |
| 3226 | + return result; |
| 3227 | + }); |
| 3228 | + |
| 3229 | + const attach = internals.handleCommand(client, { type: "attach", activeSessionId: state.activeSessionId }); |
| 3230 | + await vi.waitFor(() => expect(internals.createAttachResult).toHaveBeenCalledOnce()); |
| 3231 | + expect(state.clients).not.toContain(client); |
| 3232 | + expect(client.attachedActiveSessionIds).not.toContain(state.activeSessionId); |
| 3233 | + releaseSnapshot(); |
| 3234 | + await attach; |
| 3235 | + expect(state.clients).toContain(client); |
| 3236 | + expect(client.attachedActiveSessionIds).toContain(state.activeSessionId); |
| 3237 | + }); |
| 3238 | + |
| 3239 | + it("drops a backpressure catch-up when the client detaches during snapshot creation", async () => { |
| 3240 | + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { |
| 3241 | + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, |
| 3242 | + createRuntime: vi.fn(), |
| 3243 | + }); |
| 3244 | + const state = makeState("active"); |
| 3245 | + const write = vi.fn(() => true); |
| 3246 | + const client = makeClient("client-1", state.activeSessionId); |
| 3247 | + client.socket = { destroyed: false, write } as unknown as Socket; |
| 3248 | + client.catchupActiveSessionIds = new Set([state.activeSessionId]); |
| 3249 | + state.clients.add(client); |
| 3250 | + let releaseSnapshot!: () => void; |
| 3251 | + const snapshotGate = new Promise<void>((resolve) => { |
| 3252 | + releaseSnapshot = resolve; |
| 3253 | + }); |
| 3254 | + const result = { |
| 3255 | + activeSessionId: state.activeSessionId, |
| 3256 | + snapshot: { summary: {}, state: {}, messages: [], lastEventSequence: 0 }, |
| 3257 | + lastEventSequence: 0, |
| 3258 | + } as unknown as DaemonAttachResult; |
| 3259 | + const internals = daemon as unknown as { |
| 3260 | + sessions: Map<string, ActiveSessionState>; |
| 3261 | + createAttachResult: ReturnType<typeof vi.fn>; |
| 3262 | + drainBackpressuredClientCatchups(client: DaemonSocketClient): Promise<void>; |
| 3263 | + }; |
| 3264 | + internals.sessions.set(state.activeSessionId, state); |
| 3265 | + internals.createAttachResult = vi.fn(async () => { |
| 3266 | + await snapshotGate; |
| 3267 | + return result; |
| 3268 | + }); |
| 3269 | + |
| 3270 | + const catchup = internals.drainBackpressuredClientCatchups(client); |
| 3271 | + await vi.waitFor(() => expect(internals.createAttachResult).toHaveBeenCalledOnce()); |
| 3272 | + state.clients.delete(client); |
| 3273 | + client.attachedActiveSessionIds.delete(state.activeSessionId); |
| 3274 | + releaseSnapshot(); |
| 3275 | + await catchup; |
| 3276 | + expect(write).not.toHaveBeenCalled(); |
| 3277 | + }); |
| 3278 | + |
3139 | 3279 | it("marks a chunked attach as snapshotting before deferred streaming", async () => { |
3140 | 3280 | const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-snapshot-order-")); |
3141 | 3281 | try { |
@@ -4034,6 +4174,41 @@ describe("daemon mode helpers", () => { |
4034 | 4174 | } |
4035 | 4175 | }); |
4036 | 4176 |
|
| 4177 | + it("does not passivate a child that starts streaming during the fence snapshot", async () => { |
| 4178 | + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passivation-stream-race-")); |
| 4179 | + try { |
| 4180 | + const fixture = makePersistedRlmDaemonFixture(tempDir); |
| 4181 | + const internals = fixture.daemon as unknown as { |
| 4182 | + createRuntime(command: Extract<DaemonCommand, { type: "create" }>): Promise<ActiveSessionState>; |
| 4183 | + listPassiveRlmSubagents: ReturnType<typeof vi.fn>; |
| 4184 | + passivateIdleChildren(threshold: number, now: number, limit: number): Promise<number>; |
| 4185 | + }; |
| 4186 | + const parentState = await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); |
| 4187 | + const childState = await internals.createRuntime({ type: "create", sessionPath: fixture.childSessionFile }); |
| 4188 | + const childSession = childState.runtime.session as unknown as { |
| 4189 | + isStreaming: boolean; |
| 4190 | + isSessionActive: boolean; |
| 4191 | + abort: ReturnType<typeof vi.fn>; |
| 4192 | + }; |
| 4193 | + let passiveListCalls = 0; |
| 4194 | + internals.listPassiveRlmSubagents = vi.fn(async () => { |
| 4195 | + passiveListCalls++; |
| 4196 | + if (passiveListCalls === 2) { |
| 4197 | + childSession.isStreaming = true; |
| 4198 | + childSession.isSessionActive = true; |
| 4199 | + } |
| 4200 | + return []; |
| 4201 | + }); |
| 4202 | + |
| 4203 | + await expect(internals.passivateIdleChildren(90, Date.parse("2036-08-01T12:00:00Z"), 1)).resolves.toBe(0); |
| 4204 | + expect(passiveListCalls).toBe(2); |
| 4205 | + expect(childSession.abort).not.toHaveBeenCalled(); |
| 4206 | + expect(parentState.runtime.session.releaseFinishedRlmChildSession).not.toHaveBeenCalled(); |
| 4207 | + } finally { |
| 4208 | + rmSync(tempDir, { recursive: true, force: true }); |
| 4209 | + } |
| 4210 | + }); |
| 4211 | + |
4037 | 4212 | it("limits each worker sweep and leaves non-leaf children resident", async () => { |
4038 | 4213 | const daemon = new AgentDaemon("/tmp/prime-agent-passivation-cap.sock", { |
4039 | 4214 | defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, |
|
0 commit comments