beeos-claw 0.5.53 → 0.5.54

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.
@@ -1 +1 @@
1
- import{mkdirSync,mkdtempSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";import{RunProcessOwnershipRegistry}from"./runtime/run-process-ownership-registry.js";import{ProcessSessionTerminator}from"./runtime/process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./runtime/run-process-lifecycle-coordinator.js";import{createImDispatchFixture,createRuntime,deferred}from"./test-fixtures/im-runtime.fixture.js";const SESSION_LOCK_DIAGNOSTIC=["Agent failed before reply: session file locked (timeout 120000ms):","pid=357 /home/node/.openclaw/agents/main/sessions/channel-test.jsonl.lock","gatewayToken=super-secret-token"].join(" "),asOutcome=async e=>{try{return{value:await e}}catch(e){return{error:e}}};describe("dispatchImInboundPrompt Message Service session coordination",()=>{const e=[];afterEach(()=>{for(const t of e.splice(0))rmSync(t,{recursive:!0,force:!0})}),it("releases the cancelled session before a follow-up Message Service turn starts",async()=>{const e=createImDispatchFixture(),t=deferred(),s=deferred(),n=createRuntime(async({abortSignal:e,dispatcher:n})=>(t.resolve(),await new Promise((t,n)=>{e.addEventListener("abort",()=>{s.promise.then(()=>{n(new Error(String(e.reason)))})},{once:!0})}),n.sendFinalReply({text:"unreachable"}),{counts:{tool:0,block:0,final:1}})),o=dispatchImInboundPrompt(e.args(n));await t.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),s.resolve(),await expect(o).rejects.toThrow(/chat_cancel:user_request/),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"});const r=createImDispatchFixture(),a=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"follow-up completed"}),{counts:{tool:0,block:0,final:1}})),i=await dispatchImInboundPrompt(r.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-cancel",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"follow-up"}}}));expect(i).toBe(!0),expect(r.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"follow-up completed"})}),it("redacts raw OpenClaw session-lock diagnostics from the Message Service reply",async()=>{const e=createImDispatchFixture(),t=createRuntime(async()=>{throw new Error(SESSION_LOCK_DIAGNOSTIC)});await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(SESSION_LOCK_DIAGNOSTIC);const s=classifyAgentRuntimeFailure({raw:SESSION_LOCK_DIAGNOSTIC,source:"live_gateway"}).publicMessage,n=e.client.streams[0]?.terminal;expect(n).toMatchObject({state:"failed",stopReason:"agent_lost",body:s}),expect(n?.body).not.toContain(".jsonl.lock"),expect(n?.body).not.toContain("super-secret-token"),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"session_file_locked"})}),it("terminalizes a gateway-injected lock final without ever writing the diagnostic body",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:SESSION_LOCK_DIAGNOSTIC}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(/session file locked/i),expect(e.client.streams[0]?.terminal).toMatchObject({state:"failed",stopReason:"agent_lost",body:"The previous run was interrupted before it completed."}),expect(e.client.streams[0]?.body).not.toContain(".jsonl.lock"),expect(e.client.streams[0]?.body).not.toContain("super-secret-token")}),it("binds onAgentRunStart to the active prompt owner before cancellation",async()=>{const e=createImDispatchFixture(),t=deferred(),s=createRuntime(async({replyOptions:e,abortSignal:s})=>(e.onAgentRunStart?.("openclaw-run-1"),t.resolve(),await new Promise((e,t)=>{s.addEventListener("abort",()=>{t(new Error(String(s.reason)))},{once:!0})}),{counts:{tool:0,block:0,final:0}})),n=asOutcome(dispatchImInboundPrompt(e.args(s)));await t.promise,expect(e.promptRunStore.snapshot()[0]?.[1]).toMatchObject({runtimeRunId:"openclaw-run-1",sessionKey:"agent:main:im:channel-test"}),expect(e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request")).toMatchObject({runtimeRunId:"openclaw-run-1"}),expect(await n).toHaveProperty("error")}),it("waits for exact owned-process termination before committing user cancellation",async()=>{const e=createImDispatchFixture(),t=vi.fn(async e=>{e.args.action;return{ok:!0,toolName:"process",output:{details:{status:"failed",sessionId:"process-session-90"}}}}),s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:t})),n=deferred(),o=createRuntime(async({replyOptions:e,abortSignal:t})=>(e.onAgentRunStart?.("runtime-run-cancel"),await s.afterToolCall({runId:"runtime-run-cancel",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),n.resolve(),await new Promise((e,s)=>{t.addEventListener("abort",()=>{s(new Error(String(t.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),r=asOutcome(dispatchImInboundPrompt(e.args(o,{runProcessLifecycleCoordinator:s})));await n.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await r).toHaveProperty("error"),expect(t.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll"]),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"})}),it("does not report cancellation when owned-process termination is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{details:{status:"failed"}}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-90"}}}},{verificationAttempts:1})),s=deferred(),n=createRuntime(async({replyOptions:e,abortSignal:n})=>(e.onAgentRunStart?.("runtime-run-unverified"),await t.afterToolCall({runId:"runtime-run-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),s.resolve(),await new Promise((e,t)=>{n.addEventListener("abort",()=>{t(new Error(String(n.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),o=asOutcome(dispatchImInboundPrompt(e.args(n,{runProcessLifecycleCoordinator:t})));await s.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await o).toHaveProperty("error"),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"agent_lost",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"process_termination_unverified"})}),it("does not report normal completion when owned-process cleanup is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-yield"}}}},{verificationAttempts:1})),s=createRuntime(async({replyOptions:e,dispatcher:s})=>(e.onAgentRunStart?.("runtime-run-success-unverified"),await t.afterToolCall({runId:"runtime-run-success-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-yield"}}}),s.sendFinalReply({text:"runtime returned"}),{counts:{tool:1,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(s,{runProcessLifecycleCoordinator:t}))).resolves.toBe(!1),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"agent_lost",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("releases the gate immediately when runtime dispatch throws synchronously",async()=>{const e=new SessionWriteGate,t=new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{observationTimeoutMs:1}),s=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),n={channel:{reply:{dispatchReplyFromConfig:()=>{throw new Error("runtime rejected before writer start")}}}};await expect(dispatchImInboundPrompt(s.args(n))).rejects.toThrow("runtime rejected before writer start"),expect(e.snapshot("agent:main:im:channel-test").active).toBeUndefined();const o=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),r=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"accepted"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(o.args(r))).resolves.toBe(!0)}),it("returns a busy reply without invoking the runtime for a concurrent same-session envelope",async()=>{const e=new SessionWriteGate,t={sessionWriteGate:e,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{})},s=new Set,n=deferred(),o=deferred(),r=[],a=createRuntime(async({invocation:e,abortSignal:t})=>{const a=e.ctx.SessionKey;if(r.push(a),s.has(a))throw new Error(SESSION_LOCK_DIAGNOSTIC);return s.add(a),n.resolve(),await new Promise((e,n)=>{t.addEventListener("abort",()=>{o.promise.then(()=>{s.delete(a),n(new Error(String(t.reason)))})},{once:!0})}),{counts:{tool:0,block:0,final:0}}}),i=createImDispatchFixture(t),c=dispatchImInboundPrompt(i.args(a));await n.promise;const l=createImDispatchFixture(t),p=asOutcome(dispatchImInboundPrompt(l.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-concurrent",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"concurrent prompt"}}}))),m=await p;i.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),o.resolve(),await asOutcome(c),expect(m).toEqual({value:!1}),expect(r).toEqual(["agent:main:im:channel-test"]),expect(l.client.streams[0]?.terminal).toMatchObject({state:"failed",body:expect.stringMatching(/still stopping/i)})}),it("keeps a non-contended Message Service stream on the normal completion path",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply?.({text:"normal"}),e.sendFinalReply({text:"normal completion"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).resolves.toBe(!0),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"normal completion"}),expect(e.events.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"])}),it("keeps the shared gate closed until the cancelled JSONL writer releases its lock",async()=>{const t="agent:main:im:locked-conversation",s=mkdtempSync(join(tmpdir(),"beeos-im-lock-"));e.push(s);const n=join(s,"agents","main","sessions");mkdirSync(n,{recursive:!0});const o=join(n,"locked-session.jsonl"),r=`${o}.lock`;writeFileSync(o,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({[t]:{sessionId:"locked-session",sessionFile:o}}));const a=new SessionWriteGate,i=new OpenClawSessionWriteQuiescence({},{stateDir:s,pollIntervalMs:5,quietConfirmationMs:5}),c={sessionWriteGate:a,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(a,i,{},{observationTimeoutMs:100,retryDelayMs:5})},l=deferred();let p=0;const m=createRuntime(async({abortSignal:e,dispatcher:t})=>(p+=1,1===p&&(l.resolve(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{s(new Error(String(e.reason)))},{once:!0})})),t.sendFinalReply({text:"after lock released"}),{counts:{tool:0,block:0,final:1}})),u=createImDispatchFixture(c),d=asOutcome(dispatchImInboundPrompt(u.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-before-cancel",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"start long run"}}})));await l.promise,writeFileSync(r,"331"),u.promptRunStore.abort(promptRunKey("channel-test","message-before-cancel"),"chat_cancel:user_request");const h=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(h.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-while-locked",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"must not enter runtime"}}}))).resolves.toBe(!1),expect(p).toBe(1),unlinkSync(r),expect(await d).toHaveProperty("error");const y=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(y.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-unlock",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"now safe"}}}))).resolves.toBe(!0),expect(p).toBe(2)})});
1
+ import{mkdirSync,mkdtempSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";import{RunProcessOwnershipRegistry}from"./runtime/run-process-ownership-registry.js";import{ProcessSessionTerminator}from"./runtime/process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./runtime/run-process-lifecycle-coordinator.js";import{createImDispatchFixture,createRuntime,deferred}from"./test-fixtures/im-runtime.fixture.js";const SESSION_LOCK_DIAGNOSTIC=["Agent failed before reply: session file locked (timeout 120000ms):","pid=357 /home/node/.openclaw/agents/main/sessions/channel-test.jsonl.lock","gatewayToken=super-secret-token"].join(" "),asOutcome=async e=>{try{return{value:await e}}catch(e){return{error:e}}};describe("dispatchImInboundPrompt Message Service session coordination",()=>{const e=[];afterEach(()=>{for(const t of e.splice(0))rmSync(t,{recursive:!0,force:!0})}),it("releases the cancelled session before a follow-up Message Service turn starts",async()=>{const e=createImDispatchFixture(),t=deferred(),s=deferred(),n=createRuntime(async({abortSignal:e,dispatcher:n})=>(t.resolve(),await new Promise((t,n)=>{e.addEventListener("abort",()=>{s.promise.then(()=>{n(new Error(String(e.reason)))})},{once:!0})}),n.sendFinalReply({text:"unreachable"}),{counts:{tool:0,block:0,final:1}})),o=dispatchImInboundPrompt(e.args(n));await t.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),s.resolve(),await expect(o).rejects.toThrow(/chat_cancel:user_request/),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"});const r=createImDispatchFixture(),a=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"follow-up completed"}),{counts:{tool:0,block:0,final:1}})),i=await dispatchImInboundPrompt(r.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-cancel",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"follow-up"}}}));expect(i).toBe(!0),expect(r.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"follow-up completed"})}),it("redacts raw OpenClaw session-lock diagnostics from the Message Service reply",async()=>{const e=createImDispatchFixture(),t=createRuntime(async()=>{throw new Error(SESSION_LOCK_DIAGNOSTIC)});await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(SESSION_LOCK_DIAGNOSTIC);const s=classifyAgentRuntimeFailure({raw:SESSION_LOCK_DIAGNOSTIC,source:"live_gateway"}).publicMessage,n=e.client.streams[0]?.terminal;expect(n).toMatchObject({state:"failed",stopReason:"error",body:s}),expect(n?.body).not.toContain(".jsonl.lock"),expect(n?.body).not.toContain("super-secret-token"),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"session_file_locked"})}),it("terminalizes a gateway-injected lock final without ever writing the diagnostic body",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:SESSION_LOCK_DIAGNOSTIC}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(/session file locked/i),expect(e.client.streams[0]?.terminal).toMatchObject({state:"failed",stopReason:"error",body:"The previous run was interrupted before it completed."}),expect(e.client.streams[0]?.body).not.toContain(".jsonl.lock"),expect(e.client.streams[0]?.body).not.toContain("super-secret-token")}),it("binds onAgentRunStart to the active prompt owner before cancellation",async()=>{const e=createImDispatchFixture(),t=deferred(),s=createRuntime(async({replyOptions:e,abortSignal:s})=>(e.onAgentRunStart?.("openclaw-run-1"),t.resolve(),await new Promise((e,t)=>{s.addEventListener("abort",()=>{t(new Error(String(s.reason)))},{once:!0})}),{counts:{tool:0,block:0,final:0}})),n=asOutcome(dispatchImInboundPrompt(e.args(s)));await t.promise,expect(e.promptRunStore.snapshot()[0]?.[1]).toMatchObject({runtimeRunId:"openclaw-run-1",sessionKey:"agent:main:im:channel-test"}),expect(e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request")).toMatchObject({runtimeRunId:"openclaw-run-1"}),expect(await n).toHaveProperty("error")}),it("waits for exact owned-process termination before committing user cancellation",async()=>{const e=createImDispatchFixture(),t=vi.fn(async e=>{e.args.action;return{ok:!0,toolName:"process",output:{details:{status:"failed",sessionId:"process-session-90"}}}}),s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:t})),n=deferred(),o=createRuntime(async({replyOptions:e,abortSignal:t})=>(e.onAgentRunStart?.("runtime-run-cancel"),await s.afterToolCall({runId:"runtime-run-cancel",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),n.resolve(),await new Promise((e,s)=>{t.addEventListener("abort",()=>{s(new Error(String(t.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),r=asOutcome(dispatchImInboundPrompt(e.args(o,{runProcessLifecycleCoordinator:s})));await n.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await r).toHaveProperty("error"),expect(t.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll"]),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"})}),it("does not report cancellation when owned-process termination is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{details:{status:"failed"}}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-90"}}}},{verificationAttempts:1})),s=deferred(),n=createRuntime(async({replyOptions:e,abortSignal:n})=>(e.onAgentRunStart?.("runtime-run-unverified"),await t.afterToolCall({runId:"runtime-run-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),s.resolve(),await new Promise((e,t)=>{n.addEventListener("abort",()=>{t(new Error(String(n.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),o=asOutcome(dispatchImInboundPrompt(e.args(n,{runProcessLifecycleCoordinator:t})));await s.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await o).toHaveProperty("error"),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"error",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"process_termination_unverified"})}),it("does not report normal completion when owned-process cleanup is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-yield"}}}},{verificationAttempts:1})),s=createRuntime(async({replyOptions:e,dispatcher:s})=>(e.onAgentRunStart?.("runtime-run-success-unverified"),await t.afterToolCall({runId:"runtime-run-success-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-yield"}}}),s.sendFinalReply({text:"runtime returned"}),{counts:{tool:1,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(s,{runProcessLifecycleCoordinator:t}))).resolves.toBe(!1),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"error",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("completes normally after process polling releases naturally exited exec ownership",async()=>{const e=createImDispatchFixture(),t=vi.fn(async()=>{throw new Error("normal completion must not invoke process termination")}),s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:t})),n=createRuntime(async({replyOptions:e,dispatcher:t})=>{const n="runtime-run-natural-completion",o="agent:main:im:channel-test";return e.onAgentRunStart?.(n),await s.afterToolCall({runId:n,sessionKey:o,toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-natural"}}}),await s.afterToolCall({runId:n,sessionKey:o,toolName:"process",params:{action:"poll",sessionId:"process-session-natural"},result:{details:{status:"completed",sessionId:"process-session-natural",exitCode:0}}}),t.sendFinalReply({text:"completed naturally"}),{counts:{tool:2,block:0,final:1}}});await expect(dispatchImInboundPrompt(e.args(n,{runProcessLifecycleCoordinator:s}))).resolves.toBe(!0),expect(t).not.toHaveBeenCalled(),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"completed naturally"})}),it("releases the gate immediately when runtime dispatch throws synchronously",async()=>{const e=new SessionWriteGate,t=new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{observationTimeoutMs:1}),s=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),n={channel:{reply:{dispatchReplyFromConfig:()=>{throw new Error("runtime rejected before writer start")}}}};await expect(dispatchImInboundPrompt(s.args(n))).rejects.toThrow("runtime rejected before writer start"),expect(e.snapshot("agent:main:im:channel-test").active).toBeUndefined();const o=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),r=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"accepted"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(o.args(r))).resolves.toBe(!0)}),it("returns a busy reply without invoking the runtime for a concurrent same-session envelope",async()=>{const e=new SessionWriteGate,t={sessionWriteGate:e,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{})},s=new Set,n=deferred(),o=deferred(),r=[],a=createRuntime(async({invocation:e,abortSignal:t})=>{const a=e.ctx.SessionKey;if(r.push(a),s.has(a))throw new Error(SESSION_LOCK_DIAGNOSTIC);return s.add(a),n.resolve(),await new Promise((e,n)=>{t.addEventListener("abort",()=>{o.promise.then(()=>{s.delete(a),n(new Error(String(t.reason)))})},{once:!0})}),{counts:{tool:0,block:0,final:0}}}),i=createImDispatchFixture(t),c=dispatchImInboundPrompt(i.args(a));await n.promise;const l=createImDispatchFixture(t),p=asOutcome(dispatchImInboundPrompt(l.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-concurrent",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"concurrent prompt"}}}))),m=await p;i.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),o.resolve(),await asOutcome(c),expect(m).toEqual({value:!1}),expect(r).toEqual(["agent:main:im:channel-test"]),expect(l.client.streams[0]?.terminal).toMatchObject({state:"failed",body:expect.stringMatching(/still stopping/i)})}),it("keeps a non-contended Message Service stream on the normal completion path",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply?.({text:"normal"}),e.sendFinalReply({text:"normal completion"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).resolves.toBe(!0),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"normal completion"}),expect(e.events.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"])}),it("keeps the shared gate closed until the cancelled JSONL writer releases its lock",async()=>{const t="agent:main:im:locked-conversation",s=mkdtempSync(join(tmpdir(),"beeos-im-lock-"));e.push(s);const n=join(s,"agents","main","sessions");mkdirSync(n,{recursive:!0});const o=join(n,"locked-session.jsonl"),r=`${o}.lock`;writeFileSync(o,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({[t]:{sessionId:"locked-session",sessionFile:o}}));const a=new SessionWriteGate,i=new OpenClawSessionWriteQuiescence({},{stateDir:s,pollIntervalMs:5,quietConfirmationMs:5}),c={sessionWriteGate:a,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(a,i,{},{observationTimeoutMs:100,retryDelayMs:5})},l=deferred();let p=0;const m=createRuntime(async({abortSignal:e,dispatcher:t})=>(p+=1,1===p&&(l.resolve(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{s(new Error(String(e.reason)))},{once:!0})})),t.sendFinalReply({text:"after lock released"}),{counts:{tool:0,block:0,final:1}})),u=createImDispatchFixture(c),d=asOutcome(dispatchImInboundPrompt(u.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-before-cancel",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"start long run"}}})));await l.promise,writeFileSync(r,"331"),u.promptRunStore.abort(promptRunKey("channel-test","message-before-cancel"),"chat_cancel:user_request");const h=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(h.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-while-locked",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"must not enter runtime"}}}))).resolves.toBe(!1),expect(p).toBe(1),unlinkSync(r),expect(await d).toHaveProperty("error");const y=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(y.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-unlock",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"now safe"}}}))).resolves.toBe(!0),expect(p).toBe(2)})});
@@ -1 +1 @@
1
- import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.js";const PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE="The task could not be stopped cleanly. Please retry.",normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,r,t)=>`${e}_${createHash("sha256").update(`${n}:${r}:${t}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:t,logger:a,promptRunStore:i,sessionWriteGate:s,sessionWriteReleaseCoordinator:o}=e;if(!hasRuntimeReplyApi(n))return a.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),!1;const d=`im_req_${randomUUID().replace(/-/g,"")}`,l=Date.now(),c=e.origin??"im",u={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:c},m=normalizePromptTimeoutMs(e.promptTimeoutMs),p=promptRunKey(r.channelId,r.inboundMessageId),I=r.sessionKey??"agent:main",h=new AbortController;if(!i.register(p,{requestId:d,channelId:r.channelId,abortController:h,startedAt:l,sessionKey:I}))return a.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${r.inboundMessageId} requestId=${d} channel=${r.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const g=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("asst",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId});let y,f=!1;try{f=!0===(await g.opened()).idempotent}catch(e){a.debug?.(`[im] runtime reply stream open probe failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(f)return a.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),i.unregister(p),!1;const _=new SerializedCallbackQueue;let $;const b=createImReplySink({stream:g,openReasoningStream:()=>(y=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("rsn",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId}),y),abortSignal:h.signal,callbackQueue:_,logger:a,requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:c,onAgentRunStart:e=>{$=e,i.attachRuntimeRunId(p,e)}}),w=interceptAgentRuntimeFailures(b,"im_runtime",e=>a.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),M=w.sink,R=async(e,n)=>{if(!y)return!1;try{return"cancel"===e?await y.cancel({stopReason:"user_stop"}):"fail"===e?await y.fail(n?{stopReason:n}:void 0):await y.finalize({stopReason:"end_turn"}),!1}catch(e){return a.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`),!0}};let E,S,q=!1;const v=async e=>{M.dispatcher.markComplete(e);try{await M.dispatcher.waitForIdle()}catch{}},k=()=>{if(!h.signal.aborted)return;const e=h.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))},C=async()=>{if(!$||!e.runProcessLifecycleCoordinator)return!0;try{const n=(await e.runProcessLifecycleCoordinator.terminateRun($)).terminations.every(e=>e.ok);return n||a.warn?.(`[im] one or more owned processes could not be terminated requestId=${d} runId=${$}`),n}catch{return a.warn?.(`[im] owned process termination failed requestId=${d} runId=${$}`),!1}},P=s.tryStartInteractive({sessionKey:I,label:"im_chat_message"});if(!P.ok){try{await g.fail({body:SESSION_BUSY_PUBLIC_MESSAGE,stopReason:"agent_lost"})}catch(e){a.warn?.(`[im] session busy terminal write failed channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.warn?.(`[im] session write gate busy sessionKey=${I} channel=${r.channelId} in_reply_to=${r.inboundMessageId} active_operation=${P.activeOperationId??"unknown"}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"session_busy",cause:"runtime_error"}),i.unregister(p),!1}const T=P.token;try{a.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${c}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId});const t=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:r.dispatchInput,replyRoute:u,replySinks:{im:M},onDispatchInvoked:()=>{q=!0}}),s=void 0!==m?Promise.race([t,new Promise((e,n)=>{E=setTimeout(()=>{S=new Error(`IM runtime dispatch timed out after ${m}ms`),a.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${m}`),h.abort(S),n(S)},m)})]):t,o=await s.finally(()=>{E&&(clearTimeout(E),E=void 0)}),I=w.detectedFailure();if(I)throw new Error(I.diagnosticMessage);if(k(),await v("success"),k(),!i.claimTerminal(p))throw k(),new Error("terminal outcome already claimed");if(!await C()){const n=await R("fail");let t=!1;try{await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"agent_lost"})}catch(e){t=!0,a.warn?.(`[im] process termination failure terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}return e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"process_termination_unverified",cause:"runtime_error"}),a.warn?.(`[im] runtime dispatch terminalized after unverified process cleanup requestId=${d} channel=${r.channelId} terminalWriteFailed=${t} reasoningTerminalWriteFailed=${n}`),!1}const y=await R("complete");let f=!1;try{await g.finalize({stopReason:"end_turn"})}catch(e){f=!0,a.warn?.(`[im] runtime reply stream finalize failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.info?.(`[im] runtime dispatch completed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} counts=${JSON.stringify(o.counts)} elapsed_ms=${Date.now()-l}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:o.counts,...f?{terminalWriteFailed:!0}:{},...y?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await v("failure");const t=h.signal.aborted,s=t?h.signal.reason:void 0,o=t&&"string"==typeof s&&s.startsWith("chat_cancel"),l=void 0!==S;let c="runtime_error";if(l?c="timeout":t&&(c="abort"),!i.claimTerminal(p))throw a.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const u=w.detectedFailure()??classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw"}),m=!await C();o&&!m?await R("cancel"):l?await R("fail","timeout"):await R("fail");try{o&&!m?await g.cancel({stopReason:"user_stop"}):o?await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"agent_lost"}):await g.fail({body:u.publicMessage,stopReason:messageStopReasonForRuntimeFailure(u)})}catch(e){a.warn?.(`[im] runtime stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}throw a.warn?.(`[im] runtime dispatch failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} cause=${c} dispatchInvoked=${q} error_code=${u.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:m?"process_termination_unverified":u.errorCode,cause:c}),n}finally{i.unregister(p),q?await o.releaseAfterQuiescence(T):o.releaseImmediately(T)}};
1
+ import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.js";const PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE="The task could not be stopped cleanly. Please retry.",normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,r,t)=>`${e}_${createHash("sha256").update(`${n}:${r}:${t}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:t,logger:a,promptRunStore:i,sessionWriteGate:s,sessionWriteReleaseCoordinator:o}=e;if(!hasRuntimeReplyApi(n))return a.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),!1;const d=`im_req_${randomUUID().replace(/-/g,"")}`,c=Date.now(),l=e.origin??"im",u={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:l},m=normalizePromptTimeoutMs(e.promptTimeoutMs),p=promptRunKey(r.channelId,r.inboundMessageId),I=r.sessionKey??"agent:main",h=new AbortController;if(!i.register(p,{requestId:d,channelId:r.channelId,abortController:h,startedAt:c,sessionKey:I}))return a.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${r.inboundMessageId} requestId=${d} channel=${r.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const g=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("asst",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId});let y,f=!1;try{f=!0===(await g.opened()).idempotent}catch(e){a.debug?.(`[im] runtime reply stream open probe failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(f)return a.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),i.unregister(p),!1;const _=new SerializedCallbackQueue;let $;const b=createImReplySink({stream:g,openReasoningStream:()=>(y=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("rsn",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId}),y),abortSignal:h.signal,callbackQueue:_,logger:a,requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:l,onAgentRunStart:e=>{$=e,i.attachRuntimeRunId(p,e)}}),w=interceptAgentRuntimeFailures(b,"im_runtime",e=>a.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),M=w.sink,R=async(e,n)=>{if(!y)return!1;try{return"cancel"===e?await y.cancel({stopReason:"user_stop"}):"fail"===e?await y.fail(n?{stopReason:n}:void 0):await y.finalize({stopReason:"end_turn"}),!1}catch(e){return a.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`),!0}};let E,S,q=!1;const v=async e=>{M.dispatcher.markComplete(e);try{await M.dispatcher.waitForIdle()}catch{}},k=()=>{if(!h.signal.aborted)return;const e=h.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))},C=async()=>{if(!$||!e.runProcessLifecycleCoordinator)return!0;try{const n=(await e.runProcessLifecycleCoordinator.terminateRun($)).terminations.every(e=>e.ok);return n||a.warn?.(`[im] one or more owned processes could not be terminated requestId=${d} runId=${$}`),n}catch{return a.warn?.(`[im] owned process termination failed requestId=${d} runId=${$}`),!1}},P=s.tryStartInteractive({sessionKey:I,label:"im_chat_message"});if(!P.ok){try{await g.fail({body:SESSION_BUSY_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){a.warn?.(`[im] session busy terminal write failed channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.warn?.(`[im] session write gate busy sessionKey=${I} channel=${r.channelId} in_reply_to=${r.inboundMessageId} active_operation=${P.activeOperationId??"unknown"}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"session_busy",cause:"runtime_error"}),i.unregister(p),!1}const T=P.token;try{a.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${l}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId});const t=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:r.dispatchInput,replyRoute:u,replySinks:{im:M},onDispatchInvoked:()=>{q=!0}}),s=void 0!==m?Promise.race([t,new Promise((e,n)=>{E=setTimeout(()=>{S=new Error(`IM runtime dispatch timed out after ${m}ms`),a.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${m}`),h.abort(S),n(S)},m)})]):t,o=await s.finally(()=>{E&&(clearTimeout(E),E=void 0)}),I=w.detectedFailure();if(I)throw new Error(I.diagnosticMessage);if(k(),await v("success"),k(),!i.claimTerminal(p))throw k(),new Error("terminal outcome already claimed");if(!await C()){const n=await R("fail");let t=!1;try{await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){t=!0,a.warn?.(`[im] process termination failure terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}return e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"process_termination_unverified",cause:"runtime_error"}),a.warn?.(`[im] runtime dispatch terminalized after unverified process cleanup requestId=${d} channel=${r.channelId} terminalWriteFailed=${t} reasoningTerminalWriteFailed=${n}`),!1}const y=await R("complete");let f=!1;try{await g.finalize({stopReason:"end_turn"})}catch(e){f=!0,a.warn?.(`[im] runtime reply stream finalize failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.info?.(`[im] runtime dispatch completed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} counts=${JSON.stringify(o.counts)} elapsed_ms=${Date.now()-c}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:o.counts,...f?{terminalWriteFailed:!0}:{},...y?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await v("failure");const t=h.signal.aborted,s=t?h.signal.reason:void 0,o=t&&"string"==typeof s&&s.startsWith("chat_cancel"),c=void 0!==S;let l="runtime_error";if(c?l="timeout":t&&(l="abort"),!i.claimTerminal(p))throw a.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const u=w.detectedFailure()??classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw"}),m=!await C();o&&!m?await R("cancel"):c?await R("fail","timeout"):await R("fail");try{o&&!m?await g.cancel({stopReason:"user_stop"}):o?await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"error"}):await g.fail({body:u.publicMessage,stopReason:messageStopReasonForRuntimeFailure(u)})}catch(e){a.warn?.(`[im] runtime stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}throw a.warn?.(`[im] runtime dispatch failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} cause=${l} dispatchInvoked=${q} error_code=${u.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:m?"process_termination_unverified":u.errorCode,cause:l}),n}finally{i.unregister(p),q?await o.releaseAfterQuiescence(T):o.releaseImmediately(T)}};
@@ -1 +1 @@
1
- import{detectAgentRuntimeFailure}from"./agent-runtime-failure-classifier.js";export const SESSION_BUSY_PUBLIC_MESSAGE="The previous task is still stopping. Please retry in a moment.";export function interceptAgentRuntimeFailures(e,t,n){let r;const i=e=>{if("string"!=typeof e||!e.trim())return!1;const i=detectAgentRuntimeFailure({raw:e,source:t,runtimeKind:"openclaw"});return!!i&&(r||(r=i,n?.(i)),!0)};return{detectedFailure:()=>r,sink:{dispatcher:{...e.dispatcher,sendBlockReply:t=>!!i(t.text)||e.dispatcher.sendBlockReply(t),sendFinalReply:t=>!!i(t.text)||e.dispatcher.sendFinalReply(t)},replyOptions:{...e.replyOptions,onPartialReply:t=>{i(t.text)||e.replyOptions?.onPartialReply?.(t)}}}}}export function messageStopReasonForRuntimeFailure(e){return"timeout"===e.stopReason?"timeout":"agent_lost"===e.stopReason||"interrupted"===e.stopReason?"agent_lost":"error"}
1
+ import{detectAgentRuntimeFailure}from"./agent-runtime-failure-classifier.js";export const SESSION_BUSY_PUBLIC_MESSAGE="The previous task is still stopping. Please retry in a moment.";export function interceptAgentRuntimeFailures(e,t,r){let n;const i=e=>{if("string"!=typeof e||!e.trim())return!1;const i=detectAgentRuntimeFailure({raw:e,source:t,runtimeKind:"openclaw"});return!!i&&(n||(n=i,r?.(i)),!0)};return{detectedFailure:()=>n,sink:{dispatcher:{...e.dispatcher,sendBlockReply:t=>!!i(t.text)||e.dispatcher.sendBlockReply(t),sendFinalReply:t=>!!i(t.text)||e.dispatcher.sendFinalReply(t)},replyOptions:{...e.replyOptions,onPartialReply:t=>{i(t.text)||e.replyOptions?.onPartialReply?.(t)}}}}}export function messageStopReasonForRuntimeFailure(e){return"timeout"===e.stopReason?"timeout":"error"}
@@ -1 +1 @@
1
- import{describe,expect,it,vi}from"vitest";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure}from"./im-runtime-failure-policy.js";const createSink=()=>{const e=vi.fn(()=>!0),t=vi.fn(()=>!0),n=vi.fn();return{sink:{dispatcher:{sendBlockReply:e,sendFinalReply:t,sendToolResult:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:()=>{}},replyOptions:{onPartialReply:n}},sendBlockReply:e,sendFinalReply:t,onPartialReply:n}};describe("IM runtime failure policy",()=>{it("suppresses known lock diagnostics across partial, block, and final callbacks",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="Agent failed before reply: session file locked: /tmp/session.jsonl.lock";t.sink.replyOptions?.onPartialReply?.({text:n}),t.sink.dispatcher.sendBlockReply({text:n}),t.sink.dispatcher.sendFinalReply({text:n}),expect(e.onPartialReply).not.toHaveBeenCalled(),expect(e.sendBlockReply).not.toHaveBeenCalled(),expect(e.sendFinalReply).not.toHaveBeenCalled(),expect(t.detectedFailure()?.errorCode).toBe("session_file_locked")}),it("passes ordinary assistant text through unchanged",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime");t.sink.dispatcher.sendFinalReply({text:"ordinary answer"}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:"ordinary answer"}),expect(t.detectedFailure()).toBeUndefined()}),it("does not classify ordinary prose that discusses timeouts or aborted work",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="A timeout means the operation may have aborted before completion.";t.sink.dispatcher.sendFinalReply({text:n}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:n}),expect(t.detectedFailure()).toBeUndefined()}),it("does not classify an explanatory answer that mentions session file locks",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="A session file locked error may leave a .jsonl.lock file behind.";t.sink.dispatcher.sendFinalReply({text:n}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:n}),expect(t.detectedFailure()).toBeUndefined()}),it("maps runtime-only interrupted semantics to legal Message Service reasons",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime");t.sink.dispatcher.sendFinalReply({text:"Request was aborted."}),expect(messageStopReasonForRuntimeFailure(t.detectedFailure())).toBe("agent_lost")})});
1
+ import{describe,expect,it,vi}from"vitest";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure}from"./im-runtime-failure-policy.js";const createSink=()=>{const e=vi.fn(()=>!0),t=vi.fn(()=>!0),n=vi.fn();return{sink:{dispatcher:{sendBlockReply:e,sendFinalReply:t,sendToolResult:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:()=>{}},replyOptions:{onPartialReply:n}},sendBlockReply:e,sendFinalReply:t,onPartialReply:n}};describe("IM runtime failure policy",()=>{it("suppresses known lock diagnostics across partial, block, and final callbacks",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="Agent failed before reply: session file locked: /tmp/session.jsonl.lock";t.sink.replyOptions?.onPartialReply?.({text:n}),t.sink.dispatcher.sendBlockReply({text:n}),t.sink.dispatcher.sendFinalReply({text:n}),expect(e.onPartialReply).not.toHaveBeenCalled(),expect(e.sendBlockReply).not.toHaveBeenCalled(),expect(e.sendFinalReply).not.toHaveBeenCalled(),expect(t.detectedFailure()?.errorCode).toBe("session_file_locked")}),it("passes ordinary assistant text through unchanged",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime");t.sink.dispatcher.sendFinalReply({text:"ordinary answer"}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:"ordinary answer"}),expect(t.detectedFailure()).toBeUndefined()}),it("does not classify ordinary prose that discusses timeouts or aborted work",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="A timeout means the operation may have aborted before completion.";t.sink.dispatcher.sendFinalReply({text:n}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:n}),expect(t.detectedFailure()).toBeUndefined()}),it("does not classify an explanatory answer that mentions session file locks",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime"),n="A session file locked error may leave a .jsonl.lock file behind.";t.sink.dispatcher.sendFinalReply({text:n}),expect(e.sendFinalReply).toHaveBeenCalledWith({text:n}),expect(t.detectedFailure()).toBeUndefined()}),it("maps runtime-only interrupted semantics to legal Message Service reasons",()=>{const e=createSink(),t=interceptAgentRuntimeFailures(e.sink,"im_runtime");t.sink.dispatcher.sendFinalReply({text:"Request was aborted."}),expect(messageStopReasonForRuntimeFailure(t.detectedFailure())).toBe("error")})});
@@ -1 +1 @@
1
- export class ProcessSessionTerminator{tools;verificationAttempts;verificationDelayMs;sleep;killAcknowledgements=new Map;maxAcknowledgements=4096;constructor(e,s={}){this.tools=e,this.verificationAttempts=s.verificationAttempts??5,this.verificationDelayMs=s.verificationDelayMs??100,this.sleep=s.sleep??(e=>new Promise(s=>setTimeout(s,e)))}async terminate(e){const s=processKey(e);let t=this.killAcknowledgements.get(s);if(!t){const o=this.requireEnvelope(await this.tools.invoke({name:"process",args:{action:"kill",sessionId:e.sessionId},...e.sessionKey?{sessionKey:e.sessionKey}:{}}));if(!this.isKillAcknowledgement(o.output,e.sessionId))throw new Error(`process ${e.sessionId} kill was not acknowledged`);for(t=o,this.killAcknowledgements.delete(s),this.killAcknowledgements.set(s,t);this.killAcknowledgements.size>this.maxAcknowledgements;){const e=this.killAcknowledgements.keys().next().value;if(!e)break;this.killAcknowledgements.delete(e)}}for(let o=0;o<this.verificationAttempts;o+=1){const i=await this.tools.invoke({name:"process",args:{action:"poll",sessionId:e.sessionId,timeout:0},...e.sessionKey?{sessionKey:e.sessionKey}:{}});if(this.isNotFoundEnvelope(i,e.sessionId))return this.killAcknowledgements.delete(s),t;const n=this.requireEnvelope(i),r=this.outputDetails(n.output);if(r?.sessionId===e.sessionId&&("completed"===r.status||"failed"===r.status))return this.killAcknowledgements.delete(s),t;o+1<this.verificationAttempts&&await this.sleep(this.verificationDelayMs)}throw new Error(`process ${e.sessionId} termination could not be verified`)}isKillAcknowledgement(e,s){const t=this.outputDetails(e);if(t?.sessionId===s)return!0;const o=this.outputText(e),i=escapeRegex(s);return new RegExp(`^(?:Termination requested for session|Killed session) ${i}\\.?$`,"i").test(o.trim())}isNotFoundEnvelope(e,s){if(!e||"object"!=typeof e)return!1;const t=e,o=escapeRegex(s);if(!1===t.ok){const e=JSON.stringify(t.error??"");return new RegExp(`(?:not found|no (?:such )?(?:process )?session|unknown session)[^\\n]*${o}`,"i").test(e)}if(!0!==t.ok)return!1;const i=t.output;return new RegExp(`^No session found for ${o}\\.?$`,"i").test(this.outputText(i).trim())}outputText(e){if(!e||"object"!=typeof e)return"";const s=e.content;return Array.isArray(s)?s.map(e=>e&&"object"==typeof e&&"string"==typeof e.text?String(e.text):"").filter(Boolean).join("\n"):""}requireEnvelope(e){if(!e||"object"!=typeof e||"boolean"!=typeof e.ok)throw new Error("invalid tools.invoke response envelope");const s=e;if(!s.ok)throw new Error(`process invocation refused: ${JSON.stringify(s.error??{})}`);return s}outputDetails(e){if(!e||"object"!=typeof e)return;const s=e.details;if(!s||"object"!=typeof s)return;const t=s;return{..."string"==typeof t.status?{status:t.status}:{},..."string"==typeof t.sessionId?{sessionId:t.sessionId}:{}}}}const processKey=e=>JSON.stringify([e.sessionKey??null,e.sessionId]),escapeRegex=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
1
+ export class ProcessSessionTerminator{tools;verificationAttempts;verificationDelayMs;sleep;killAcknowledgements=new Map;maxAcknowledgements=4096;constructor(e,s={}){this.tools=e,this.verificationAttempts=s.verificationAttempts??5,this.verificationDelayMs=s.verificationDelayMs??100,this.sleep=s.sleep??(e=>new Promise(s=>setTimeout(s,e)))}async terminate(e){const s=processKey(e);let t=this.killAcknowledgements.get(s);if(!t){const o=await this.tools.invoke({name:"process",args:{action:"kill",sessionId:e.sessionId},...e.sessionKey?{sessionKey:e.sessionKey}:{}});if(this.isNotFoundEnvelope(o,e.sessionId))return o;const i=this.requireEnvelope(o);if(!this.isKillAcknowledgement(i.output,e.sessionId))throw new Error(`process ${e.sessionId} kill was not acknowledged`);for(t=i,this.killAcknowledgements.delete(s),this.killAcknowledgements.set(s,t);this.killAcknowledgements.size>this.maxAcknowledgements;){const e=this.killAcknowledgements.keys().next().value;if(!e)break;this.killAcknowledgements.delete(e)}}for(let o=0;o<this.verificationAttempts;o+=1){const i=await this.tools.invoke({name:"process",args:{action:"poll",sessionId:e.sessionId,timeout:0},...e.sessionKey?{sessionKey:e.sessionKey}:{}});if(this.isNotFoundEnvelope(i,e.sessionId))return this.killAcknowledgements.delete(s),t;const n=this.requireEnvelope(i),r=this.outputDetails(n.output);if(r?.sessionId===e.sessionId&&("completed"===r.status||"failed"===r.status))return this.killAcknowledgements.delete(s),t;o+1<this.verificationAttempts&&await this.sleep(this.verificationDelayMs)}throw new Error(`process ${e.sessionId} termination could not be verified`)}isKillAcknowledgement(e,s){const t=this.outputDetails(e);if(t?.sessionId===s)return!0;const o=this.outputText(e),i=escapeRegex(s);return new RegExp(`^(?:Termination requested for session|Killed session) ${i}\\.?$`,"i").test(o.trim())}isNotFoundEnvelope(e,s){if(!e||"object"!=typeof e)return!1;const t=e,o=escapeRegex(s);if(!1===t.ok){const e=JSON.stringify(t.error??"");return new RegExp(`(?:not found|no (?:such )?(?:process )?session|unknown session)[^\\n]*${o}`,"i").test(e)}if(!0!==t.ok)return!1;const i=t.output;return new RegExp(`^No session found for ${o}\\.?$`,"i").test(this.outputText(i).trim())}outputText(e){if(!e||"object"!=typeof e)return"";const s=e.content;return Array.isArray(s)?s.map(e=>e&&"object"==typeof e&&"string"==typeof e.text?String(e.text):"").filter(Boolean).join("\n"):""}requireEnvelope(e){if(!e||"object"!=typeof e||"boolean"!=typeof e.ok)throw new Error("invalid tools.invoke response envelope");const s=e;if(!s.ok)throw new Error(`process invocation refused: ${JSON.stringify(s.error??{})}`);return s}outputDetails(e){if(!e||"object"!=typeof e)return;const s=e.details;if(!s||"object"!=typeof s)return;const t=s;return{..."string"==typeof t.status?{status:t.status}:{},..."string"==typeof t.sessionId?{sessionId:t.sessionId}:{}}}}const processKey=e=>JSON.stringify([e.sessionKey??null,e.sessionId]),escapeRegex=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
@@ -1 +1 @@
1
- import{describe,expect,it,vi}from"vitest";import{ProcessSessionTerminator}from"./process-session-terminator.js";describe("ProcessSessionTerminator",()=>{it("uses tools.invoke to precisely kill the scoped process session",async()=>{const e={ok:!0,toolName:"process",output:{details:{status:"failed",sessionId:"proc-1"}}},s=vi.fn(async s=>"kill"===s.args.action?e:{ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:"proc-1"}}}),o=new ProcessSessionTerminator({invoke:s});await expect(o.terminate({sessionId:"proc-1",sessionKey:"scope-1"})).resolves.toEqual(e),expect(s).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-1"},sessionKey:"scope-1"}),expect(s).toHaveBeenCalledWith({name:"process",args:{action:"poll",sessionId:"proc-1",timeout:0},sessionKey:"scope-1"})}),it("supports an unscoped adapter and preserves invoke failures",async()=>{const e=new Error("gateway unavailable"),s={invoke:vi.fn(async()=>{throw e})},o=new ProcessSessionTerminator(s);await expect(o.terminate({sessionId:"proc-2"})).rejects.toBe(e),expect(s.invoke).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-2"}})}),it("accepts the OpenClaw process tool kill and reaped-session envelopes",async()=>{const e=vi.fn(async e=>{const s=e.args;return"kill"===s.action?{ok:!0,output:{content:[{type:"text",text:`Termination requested for session ${s.sessionId}.`}],details:{status:"failed"}}}:{ok:!0,output:{content:[{type:"text",text:`No session found for ${s.sessionId}`}],details:{status:"failed"}}}}),s=new ProcessSessionTerminator({invoke:e});await expect(s.terminate({sessionId:"proc-openclaw",sessionKey:"agent:main:session"})).resolves.toMatchObject({ok:!0}),expect(e).toHaveBeenCalledTimes(2)}),it("turns an OpenClaw policy refusal envelope into a failed termination",async()=>{const e=new ProcessSessionTerminator({invoke:async()=>({ok:!1,error:{code:"policy_denied"}})});await expect(e.terminate({sessionId:"proc-denied"})).rejects.toThrow(/policy_denied/)}),it("fails when verification proves the process is still running",async()=>{const e=new ProcessSessionTerminator({invoke:async e=>({ok:!0,toolName:"process",output:{details:{status:"kill"===e.args.action?"failed":"running",sessionId:"proc-running"}}})},{verificationAttempts:2,verificationDelayMs:0,sleep:async()=>{}});await expect(e.terminate({sessionId:"proc-running"})).rejects.toThrow(/could not be verified/)}),it("does not treat a wrong-scope no-session response as proof of termination",async()=>{const e=new ProcessSessionTerminator({invoke:async()=>({ok:!0,toolName:"process",output:{content:[{type:"text",text:"No session found for proc-scoped"}],details:{status:"failed"}}})},{verificationAttempts:1});await expect(e.terminate({sessionId:"proc-scoped",sessionKey:"agent:wrong:scope"})).rejects.toThrow(/kill was not acknowledged/)}),it("continues verification without re-killing after an acknowledged kill",async()=>{let e=0;const s=vi.fn(async s=>{const o=s.args;return"kill"===o.action?{ok:!0,output:{details:{status:"failed",sessionId:o.sessionId}}}:(e+=1,1===e?{ok:!0,output:{details:{status:"running",sessionId:o.sessionId}}}:{ok:!1,error:{message:`No session found for ${o.sessionId}`}})}),o=new ProcessSessionTerminator({invoke:s},{verificationAttempts:1});await expect(o.terminate({sessionId:"proc-reaped",sessionKey:"scope-1"})).rejects.toThrow(/could not be verified/),await expect(o.terminate({sessionId:"proc-reaped",sessionKey:"scope-1"})).resolves.toMatchObject({ok:!0}),expect(s.mock.calls.filter(([e])=>"kill"===e.args.action)).toHaveLength(1)})});
1
+ import{describe,expect,it,vi}from"vitest";import{ProcessSessionTerminator}from"./process-session-terminator.js";describe("ProcessSessionTerminator",()=>{it("uses tools.invoke to precisely kill the scoped process session",async()=>{const e={ok:!0,toolName:"process",output:{details:{status:"failed",sessionId:"proc-1"}}},s=vi.fn(async s=>"kill"===s.args.action?e:{ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:"proc-1"}}}),o=new ProcessSessionTerminator({invoke:s});await expect(o.terminate({sessionId:"proc-1",sessionKey:"scope-1"})).resolves.toEqual(e),expect(s).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-1"},sessionKey:"scope-1"}),expect(s).toHaveBeenCalledWith({name:"process",args:{action:"poll",sessionId:"proc-1",timeout:0},sessionKey:"scope-1"})}),it("supports an unscoped adapter and preserves invoke failures",async()=>{const e=new Error("gateway unavailable"),s={invoke:vi.fn(async()=>{throw e})},o=new ProcessSessionTerminator(s);await expect(o.terminate({sessionId:"proc-2"})).rejects.toBe(e),expect(s.invoke).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-2"}})}),it("accepts the OpenClaw process tool kill and reaped-session envelopes",async()=>{const e=vi.fn(async e=>{const s=e.args;return"kill"===s.action?{ok:!0,output:{content:[{type:"text",text:`Termination requested for session ${s.sessionId}.`}],details:{status:"failed"}}}:{ok:!0,output:{content:[{type:"text",text:`No session found for ${s.sessionId}`}],details:{status:"failed"}}}}),s=new ProcessSessionTerminator({invoke:e});await expect(s.terminate({sessionId:"proc-openclaw",sessionKey:"agent:main:session"})).resolves.toMatchObject({ok:!0}),expect(e).toHaveBeenCalledTimes(2)}),it("turns an OpenClaw policy refusal envelope into a failed termination",async()=>{const e=new ProcessSessionTerminator({invoke:async()=>({ok:!1,error:{code:"policy_denied"}})});await expect(e.terminate({sessionId:"proc-denied"})).rejects.toThrow(/policy_denied/)}),it("fails when verification proves the process is still running",async()=>{const e=new ProcessSessionTerminator({invoke:async e=>({ok:!0,toolName:"process",output:{details:{status:"kill"===e.args.action?"failed":"running",sessionId:"proc-running"}}})},{verificationAttempts:2,verificationDelayMs:0,sleep:async()=>{}});await expect(e.terminate({sessionId:"proc-running"})).rejects.toThrow(/could not be verified/)}),it("treats an exact scoped already-absent session as idempotent success",async()=>{const e=vi.fn(async()=>({ok:!0,toolName:"process",output:{content:[{type:"text",text:"No session found for proc-scoped"}],details:{status:"failed"}}})),s=new ProcessSessionTerminator({invoke:e},{verificationAttempts:1});await expect(s.terminate({sessionId:"proc-scoped",sessionKey:"agent:main:scope"})).resolves.toMatchObject({ok:!0}),expect(e).toHaveBeenCalledTimes(1)}),it("continues verification without re-killing after an acknowledged kill",async()=>{let e=0;const s=vi.fn(async s=>{const o=s.args;return"kill"===o.action?{ok:!0,output:{details:{status:"failed",sessionId:o.sessionId}}}:(e+=1,1===e?{ok:!0,output:{details:{status:"running",sessionId:o.sessionId}}}:{ok:!1,error:{message:`No session found for ${o.sessionId}`}})}),o=new ProcessSessionTerminator({invoke:s},{verificationAttempts:1});await expect(o.terminate({sessionId:"proc-reaped",sessionKey:"scope-1"})).rejects.toThrow(/could not be verified/),await expect(o.terminate({sessionId:"proc-reaped",sessionKey:"scope-1"})).resolves.toMatchObject({ok:!0}),expect(s.mock.calls.filter(([e])=>"kill"===e.args.action)).toHaveLength(1)})});
@@ -1 +1 @@
1
- export class RunProcessLifecycleCoordinator{registry;terminator;inFlightTerminations=new Map;pendingProcesses=new Map;activeScopes=new Map;activeExecTools=new Map;constructor(e,s){this.registry=e,this.terminator=s}beforeToolCall(e){if("exec"!==e.toolName||!0===e.params.background)return;const s=e.toolCallId?.trim();if(!e.runId.trim()||!s)return;const t=this.activeExecTools.get(e.runId)??new Map;if(t.has(s))return;let r;const n=new Promise(e=>{r=e});t.set(s,{promise:n,resolve:r}),this.activeExecTools.set(e.runId,t)}async afterToolCall(e){const s=this.registry.observeAfterToolCall(e);if("terminate"!==s.kind)return this.completeActiveTool(e,emptyTerminationResult()),{ownership:s};const t=this.terminate(e.runId,s.process).then(t=>(t.ok||this.rememberPending(e.runId,s.process),{processes:[s.process],terminations:[t]}));this.attachTaskToMatchingScopes(e.runId,t);const r=await t;return this.completeActiveTool(e,r),{ownership:s,termination:r.terminations[0]}}terminateRun(e){const s=this.inFlightTerminations.get(e);if(s)return s;const t=new Set(this.registry.runIdsForTermination(e));t.add(e);const r={rootRunId:e,members:t,childTasks:new Set,version:0};this.activeScopes.set(e,r);for(const s of t){for(const e of this.activeExecTools.get(s)?.values()??[])this.attachChildTask(r,s,e.promise);if(s===e)continue;const t=this.inFlightTerminations.get(s);t&&this.attachChildTask(r,s,t)}const n=Promise.resolve().then(()=>this.performTerminateRun(e,r)).finally(()=>{this.inFlightTerminations.delete(e),this.activeScopes.delete(e)});return this.inFlightTerminations.set(e,n),n}async terminateRunWithRetry(e,s={}){const t=Math.max(1,Math.trunc(s.maxAttempts??3)),r=Math.max(0,Math.trunc(s.retryDelayMs??250)),n=s.sleep??(e=>new Promise(s=>{const t=setTimeout(s,e);t.unref?.()})),i=[];for(let s=0;s<t;s+=1){const o=await this.terminateRun(e);if(i.push(o),o.terminations.every(e=>e.ok))break;s+1<t&&await n(r)}return mergeTerminationResults(i)}async performTerminateRun(e,s){const t=deduplicateOwnedProcesses([...this.registry.runIdsForTermination(e).flatMap(e=>Array.from(this.pendingProcesses.get(e)?.values()??[]).map(s=>({ownerRunId:e,process:s}))),...this.registry.terminateRun(e).ownedProcesses]),r=t.map(({process:e})=>e),n=await Promise.all(t.map(({ownerRunId:e,process:s})=>this.terminate(e,s)));for(const e of n)this.forgetPending(e.process),e.ok||this.rememberPending(e.ownerRunId,e.process);return mergeTerminationResults([{processes:r,terminations:n},...await this.drainScope(s)])}async linkChildRun(e,s){const t=this.registry.linkChildRun(e,s);if(!t.terminalized)return{...t,terminations:[]};for(const e of t.ownedProcesses)this.rememberPending(e.ownerRunId,e.process);const r=this.terminateRun(s);for(const t of this.activeScopes.values())t.members.has(e)&&this.attachChildTask(t,s,r);return{terminalized:!0,...await r}}completeActiveTool(e,s){const t=e.toolCallId?.trim();if(!t)return;const r=this.activeExecTools.get(e.runId),n=r?.get(t);n&&(r.delete(t),0===r.size&&this.activeExecTools.delete(e.runId),n.resolve(s))}attachTaskToMatchingScopes(e,s){for(const t of this.activeScopes.values())t.members.has(e)&&this.attachChildTask(t,e,s)}releaseRun(e){for(const s of this.registry.runIdsForTermination(e))this.pendingProcesses.delete(s);return this.registry.releaseRun(e)}rememberPending(e,s){const t=this.pendingProcesses.get(e)??new Map;t.set(processKey(s),s),this.pendingProcesses.set(e,t)}forgetPending(e){const s=processKey(e);for(const e of Array.from(this.pendingProcesses.keys())){const t=this.pendingProcesses.get(e);t?.delete(s),0===t?.size&&this.pendingProcesses.delete(e)}}attachChildTask(e,s,t){e.members.add(s),e.childTasks.has(t)||(e.childTasks.add(t),e.version+=1)}async drainScope(e){for(;;){const s=e.version,t=Array.from(e.childTasks),r=await Promise.all(t);if(await new Promise(e=>setImmediate(e)),e.version===s)return r}}async terminate(e,s){try{return{ownerRunId:e,process:s,ok:!0,result:await this.terminator.terminate(s)}}catch(t){return{ownerRunId:e,process:s,ok:!1,error:t}}}}function processKey(e){return`${e.sessionKey??""}\0${e.sessionId}`}function deduplicateProcesses(e){return Array.from(new Map(e.map(e=>[processKey(e),e])).values())}function deduplicateOwnedProcesses(e){return Array.from(new Map(e.map(e=>[processKey(e.process),e])).values())}function mergeTerminationResults(e){const s=deduplicateProcesses(e.flatMap(e=>e.processes)),t=new Map;for(const s of e)for(const e of s.terminations){const s=processKey(e.process),r=t.get(s);r?.ok&&!e.ok||t.set(s,e)}return{processes:s,terminations:Array.from(t.values())}}function emptyTerminationResult(){return{processes:[],terminations:[]}}
1
+ export class RunProcessLifecycleCoordinator{registry;terminator;inFlightTerminations=new Map;pendingProcesses=new Map;activeScopes=new Map;activeExecTools=new Map;constructor(e,s){this.registry=e,this.terminator=s}beforeToolCall(e){if("exec"!==e.toolName||!0===e.params.background)return;const s=e.toolCallId?.trim();if(!e.runId.trim()||!s)return;const t=this.activeExecTools.get(e.runId)??new Map;if(t.has(s))return;let r;const n=new Promise(e=>{r=e});t.set(s,{promise:n,resolve:r}),this.activeExecTools.set(e.runId,t)}async afterToolCall(e){const s=this.registry.observeAfterToolCall(e);if("released"===s.kind&&this.forgetPending(s.process),"terminate"!==s.kind)return this.completeActiveTool(e,emptyTerminationResult()),{ownership:s};const t=this.terminate(e.runId,s.process).then(t=>(t.ok||this.rememberPending(e.runId,s.process),{processes:[s.process],terminations:[t]}));this.attachTaskToMatchingScopes(e.runId,t);const r=await t;return this.completeActiveTool(e,r),{ownership:s,termination:r.terminations[0]}}terminateRun(e){const s=this.inFlightTerminations.get(e);if(s)return s;const t=new Set(this.registry.runIdsForTermination(e));t.add(e);const r={rootRunId:e,members:t,childTasks:new Set,version:0};this.activeScopes.set(e,r);for(const s of t){for(const e of this.activeExecTools.get(s)?.values()??[])this.attachChildTask(r,s,e.promise);if(s===e)continue;const t=this.inFlightTerminations.get(s);t&&this.attachChildTask(r,s,t)}const n=Promise.resolve().then(()=>this.performTerminateRun(e,r)).finally(()=>{this.inFlightTerminations.delete(e),this.activeScopes.delete(e)});return this.inFlightTerminations.set(e,n),n}async terminateRunWithRetry(e,s={}){const t=Math.max(1,Math.trunc(s.maxAttempts??3)),r=Math.max(0,Math.trunc(s.retryDelayMs??250)),n=s.sleep??(e=>new Promise(s=>{const t=setTimeout(s,e);t.unref?.()})),i=[];for(let s=0;s<t;s+=1){const o=await this.terminateRun(e);if(i.push(o),o.terminations.every(e=>e.ok))break;s+1<t&&await n(r)}return mergeTerminationResults(i)}async performTerminateRun(e,s){const t=deduplicateOwnedProcesses([...this.registry.runIdsForTermination(e).flatMap(e=>Array.from(this.pendingProcesses.get(e)?.values()??[]).map(s=>({ownerRunId:e,process:s}))),...this.registry.terminateRun(e).ownedProcesses]),r=t.map(({process:e})=>e),n=await Promise.all(t.map(({ownerRunId:e,process:s})=>this.terminate(e,s)));for(const e of n)this.forgetPending(e.process),e.ok||this.rememberPending(e.ownerRunId,e.process);return mergeTerminationResults([{processes:r,terminations:n},...await this.drainScope(s)])}async linkChildRun(e,s){const t=this.registry.linkChildRun(e,s);if(!t.terminalized)return{...t,terminations:[]};for(const e of t.ownedProcesses)this.rememberPending(e.ownerRunId,e.process);const r=this.terminateRun(s);for(const t of this.activeScopes.values())t.members.has(e)&&this.attachChildTask(t,s,r);return{terminalized:!0,...await r}}completeActiveTool(e,s){const t=e.toolCallId?.trim();if(!t)return;const r=this.activeExecTools.get(e.runId),n=r?.get(t);n&&(r.delete(t),0===r.size&&this.activeExecTools.delete(e.runId),n.resolve(s))}attachTaskToMatchingScopes(e,s){for(const t of this.activeScopes.values())t.members.has(e)&&this.attachChildTask(t,e,s)}releaseRun(e){for(const s of this.registry.runIdsForTermination(e))this.pendingProcesses.delete(s);return this.registry.releaseRun(e)}rememberPending(e,s){const t=this.pendingProcesses.get(e)??new Map;t.set(processKey(s),s),this.pendingProcesses.set(e,t)}forgetPending(e){const s=processKey(e);for(const e of Array.from(this.pendingProcesses.keys())){const t=this.pendingProcesses.get(e);t?.delete(s),0===t?.size&&this.pendingProcesses.delete(e)}}attachChildTask(e,s,t){e.members.add(s),e.childTasks.has(t)||(e.childTasks.add(t),e.version+=1)}async drainScope(e){for(;;){const s=e.version,t=Array.from(e.childTasks),r=await Promise.all(t);if(await new Promise(e=>setImmediate(e)),e.version===s)return r}}async terminate(e,s){try{return{ownerRunId:e,process:s,ok:!0,result:await this.terminator.terminate(s)}}catch(t){return{ownerRunId:e,process:s,ok:!1,error:t}}}}function processKey(e){return`${e.sessionKey??""}\0${e.sessionId}`}function deduplicateProcesses(e){return Array.from(new Map(e.map(e=>[processKey(e),e])).values())}function deduplicateOwnedProcesses(e){return Array.from(new Map(e.map(e=>[processKey(e.process),e])).values())}function mergeTerminationResults(e){const s=deduplicateProcesses(e.flatMap(e=>e.processes)),t=new Map;for(const s of e)for(const e of s.terminations){const s=processKey(e.process),r=t.get(s);r?.ok&&!e.ok||t.set(s,e)}return{processes:s,terminations:Array.from(t.values())}}function emptyTerminationResult(){return{processes:[],terminations:[]}}
@@ -1 +1 @@
1
- import{describe,expect,it,vi}from"vitest";import{ProcessSessionTerminator}from"./process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./run-process-lifecycle-coordinator.js";import{RunProcessOwnershipRegistry}from"./run-process-ownership-registry.js";const runningExec=(e={})=>({runId:"run-1",sessionKey:"scope-1",toolName:"exec",params:{command:"sleep 60"},result:{details:{status:"running",sessionId:"proc-1"}},...e}),createCoordinator=e=>new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:e})),successfulToolEnvelope=e=>({ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:e?.args?.sessionId}}});describe("RunProcessLifecycleCoordinator",()=>{it("kills an observed parent-owned process when the run is canceled",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec());const t=await n.terminateRun("run-1");expect(t.processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-1"}]),expect(t.terminations[0]).toMatchObject({ok:!0}),expect(e).toHaveBeenCalledTimes(2)}),it("kills a late process when cancel happens before after_tool_call",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("run-1");const t=await n.afterToolCall(runningExec());expect(t.ownership.kind).toBe("terminate"),expect(t.termination).toMatchObject({process:{sessionId:"proc-1",sessionKey:"scope-1"},ok:!0}),expect(e).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-1"},sessionKey:"scope-1"})}),it("does not kill explicit detached background work",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("run-1");const t=await n.afterToolCall(runningExec({params:{command:"worker",background:!0}}));expect(t).toEqual({ownership:{kind:"ignored",reason:"detached"}}),expect(e).not.toHaveBeenCalled()}),it("isolates individual kill failures while attempting every process",async()=>{const e=vi.fn(async e=>{if("proc-1"===e.args.sessionId)throw new Error("first failed");return successfulToolEnvelope(e)}),n=createCoordinator(e);await n.afterToolCall(runningExec()),await n.afterToolCall(runningExec({result:{details:{status:"running",sessionId:"proc-2"}}}));const t=await n.terminateRun("run-1");expect(e).toHaveBeenCalledTimes(3),expect(t.terminations.map(({ok:e})=>e)).toEqual([!1,!0]),expect(t.terminations[0]?.error).toEqual(new Error("first failed"))}),it("releaseRun drops ownership without killing or tombstoning",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec()),expect(n.releaseRun("run-1")).toEqual([{sessionId:"proc-1",sessionKey:"scope-1"}]),expect((await n.terminateRun("run-1")).processes).toEqual([]),expect(e).not.toHaveBeenCalled()}),it("deduplicates concurrent terminal cleanup for the same run",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec());const t=n.terminateRun("run-1"),o=n.terminateRun("run-1");expect(o).toBe(t),await Promise.all([t,o]),expect(e).toHaveBeenCalledTimes(2)}),it("keeps an unverified target pending so a later cleanup can retry it",async()=>{let e=!1;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action||e?successfulToolEnvelope(n):{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.afterToolCall(runningExec()),expect((await t.terminateRun("run-1")).terminations).toEqual([expect.objectContaining({ok:!1})]),e=!0,expect((await t.terminateRun("run-1")).terminations).toEqual([expect.objectContaining({ok:!0})]),expect(n.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll","poll"])}),it("retries pending terminal cleanup within a bounded terminal hook",async()=>{let e=0;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action?successfulToolEnvelope(n):(e+=1,1===e?{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}:successfulToolEnvelope(n))}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.afterToolCall(runningExec());const o=await t.terminateRunWithRetry("run-1",{retryDelayMs:0,sleep:async()=>{}});expect(o.terminations).toEqual([expect.objectContaining({ok:!0})]),expect(n.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll","poll"])}),it("makes parent cleanup wait for a child cleanup already in flight",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);await o.linkChildRun("parent-run","child-run"),await o.afterToolCall(runningExec({runId:"child-run",result:{details:{status:"running",sessionId:"child-process"}}}));const s=o.terminateRun("child-run"),a=o.terminateRun("parent-run");let i=!1;a.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await expect(a).resolves.toMatchObject({terminations:[expect.objectContaining({ok:!0})]}),await s}),it("terminates processes owned by a child linked after its parent ended",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("parent-run"),await n.afterToolCall(runningExec({runId:"late-child",result:{details:{status:"running",sessionId:"late-process"}}}));const t=await n.linkChildRun("parent-run","late-child");expect(t).toMatchObject({terminalized:!0,processes:[{sessionId:"late-process",sessionKey:"scope-1"}],terminations:[expect.objectContaining({ok:!0})]})}),it("keeps the parent barrier open for a child linked during terminalization",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);await o.afterToolCall(runningExec({runId:"late-child",result:{details:{status:"running",sessionId:"late-process"}}}));const s=o.terminateRun("parent-run");await Promise.resolve();const a=o.linkChildRun("parent-run","late-child");let i=!1;s.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await a,await expect(s).resolves.toMatchObject({terminations:[expect.objectContaining({ok:!0})]})}),it("waits for a running exec discovered after same-run termination starts",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);o.beforeToolCall({runId:"run-late",toolCallId:"tool-late",toolName:"exec",params:{command:"sleep 90"}});const s=o.terminateRun("run-late");await Promise.resolve();const a=o.afterToolCall(runningExec({runId:"run-late",toolCallId:"tool-late",result:{details:{status:"running",sessionId:"late-process"}}}));let i=!1;s.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await a,await expect(s).resolves.toMatchObject({processes:[{sessionId:"late-process",sessionKey:"scope-1"}],terminations:[expect.objectContaining({ok:!0})]})}),it("keeps a failed descendant target under its original child owner",async()=>{let e=!1;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action||e?successfulToolEnvelope(n):{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.linkChildRun("parent-run","child-run"),await t.afterToolCall(runningExec({runId:"child-run",result:{details:{status:"running",sessionId:"child-process"}}})),expect((await t.terminateRun("parent-run")).terminations).toEqual([expect.objectContaining({ownerRunId:"child-run",ok:!1})]),e=!0,expect((await t.terminateRun("child-run")).terminations).toEqual([expect.objectContaining({ownerRunId:"child-run",ok:!0})])})});
1
+ import{describe,expect,it,vi}from"vitest";import{ProcessSessionTerminator}from"./process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./run-process-lifecycle-coordinator.js";import{RunProcessOwnershipRegistry}from"./run-process-ownership-registry.js";const runningExec=(e={})=>({runId:"run-1",sessionKey:"scope-1",toolName:"exec",params:{command:"sleep 60"},result:{details:{status:"running",sessionId:"proc-1"}},...e}),createCoordinator=e=>new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:e})),successfulToolEnvelope=e=>({ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:e?.args?.sessionId}}});describe("RunProcessLifecycleCoordinator",()=>{it("kills an observed parent-owned process when the run is canceled",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec());const t=await n.terminateRun("run-1");expect(t.processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-1"}]),expect(t.terminations[0]).toMatchObject({ok:!0}),expect(e).toHaveBeenCalledTimes(2)}),it("kills a late process when cancel happens before after_tool_call",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("run-1");const t=await n.afterToolCall(runningExec());expect(t.ownership.kind).toBe("terminate"),expect(t.termination).toMatchObject({process:{sessionId:"proc-1",sessionKey:"scope-1"},ok:!0}),expect(e).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"proc-1"},sessionKey:"scope-1"})}),it("does not kill explicit detached background work",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("run-1");const t=await n.afterToolCall(runningExec({params:{command:"worker",background:!0}}));expect(t).toEqual({ownership:{kind:"ignored",reason:"detached"}}),expect(e).not.toHaveBeenCalled()}),it("isolates individual kill failures while attempting every process",async()=>{const e=vi.fn(async e=>{if("proc-1"===e.args.sessionId)throw new Error("first failed");return successfulToolEnvelope(e)}),n=createCoordinator(e);await n.afterToolCall(runningExec()),await n.afterToolCall(runningExec({result:{details:{status:"running",sessionId:"proc-2"}}}));const t=await n.terminateRun("run-1");expect(e).toHaveBeenCalledTimes(3),expect(t.terminations.map(({ok:e})=>e)).toEqual([!1,!0]),expect(t.terminations[0]?.error).toEqual(new Error("first failed"))}),it("releaseRun drops ownership without killing or tombstoning",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec()),expect(n.releaseRun("run-1")).toEqual([{sessionId:"proc-1",sessionKey:"scope-1"}]),expect((await n.terminateRun("run-1")).processes).toEqual([]),expect(e).not.toHaveBeenCalled()}),it("deduplicates concurrent terminal cleanup for the same run",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.afterToolCall(runningExec());const t=n.terminateRun("run-1"),o=n.terminateRun("run-1");expect(o).toBe(t),await Promise.all([t,o]),expect(e).toHaveBeenCalledTimes(2)}),it("keeps an unverified target pending so a later cleanup can retry it",async()=>{let e=!1;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action||e?successfulToolEnvelope(n):{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.afterToolCall(runningExec()),expect((await t.terminateRun("run-1")).terminations).toEqual([expect.objectContaining({ok:!1})]),e=!0,expect((await t.terminateRun("run-1")).terminations).toEqual([expect.objectContaining({ok:!0})]),expect(n.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll","poll"])}),it("clears failed pending cleanup when a later poll proves natural completion",async()=>{const e=vi.fn(async e=>{const n=e.args;return"kill"===n.action?successfulToolEnvelope(e):{ok:!0,output:{details:{status:"running",sessionId:n.sessionId}}}}),n=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:e},{verificationAttempts:1}));await n.afterToolCall(runningExec()),expect((await n.terminateRun("run-1")).terminations).toEqual([expect.objectContaining({ok:!1})]),await n.afterToolCall({runId:"run-1",sessionKey:"scope-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{details:{status:"completed",sessionId:"proc-1"}}}),expect(await n.terminateRun("run-1")).toEqual({processes:[],terminations:[]}),expect(e).toHaveBeenCalledTimes(2)}),it("accepts natural completion when terminal polling wins the cancel IO race",async()=>{let e,n;const t=new Promise(e=>{n=e}),o=new Promise(n=>{e=n}),s=vi.fn(async e=>{const t=e.args;if("kill"!==t.action)throw new Error("already-absent kill must not require verification polling");return n(),await o,{ok:!0,output:{content:[{type:"text",text:`No session found for ${t.sessionId}`}]}}}),a=createCoordinator(s);await a.afterToolCall(runningExec());const i=a.terminateRun("run-1");await t,await a.afterToolCall({runId:"run-1",sessionKey:"scope-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{details:{status:"completed",sessionId:"proc-1"}}}),e(),await expect(i).resolves.toMatchObject({terminations:[expect.objectContaining({ok:!0})]}),expect(s).toHaveBeenCalledTimes(1)}),it("retries pending terminal cleanup within a bounded terminal hook",async()=>{let e=0;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action?successfulToolEnvelope(n):(e+=1,1===e?{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}:successfulToolEnvelope(n))}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.afterToolCall(runningExec());const o=await t.terminateRunWithRetry("run-1",{retryDelayMs:0,sleep:async()=>{}});expect(o.terminations).toEqual([expect.objectContaining({ok:!0})]),expect(n.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll","poll"])}),it("makes parent cleanup wait for a child cleanup already in flight",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);await o.linkChildRun("parent-run","child-run"),await o.afterToolCall(runningExec({runId:"child-run",result:{details:{status:"running",sessionId:"child-process"}}}));const s=o.terminateRun("child-run"),a=o.terminateRun("parent-run");let i=!1;a.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await expect(a).resolves.toMatchObject({terminations:[expect.objectContaining({ok:!0})]}),await s}),it("terminates processes owned by a child linked after its parent ended",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),n=createCoordinator(e);await n.terminateRun("parent-run"),await n.afterToolCall(runningExec({runId:"late-child",result:{details:{status:"running",sessionId:"late-process"}}}));const t=await n.linkChildRun("parent-run","late-child");expect(t).toMatchObject({terminalized:!0,processes:[{sessionId:"late-process",sessionKey:"scope-1"}],terminations:[expect.objectContaining({ok:!0})]})}),it("keeps the parent barrier open for a child linked during terminalization",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);await o.afterToolCall(runningExec({runId:"late-child",result:{details:{status:"running",sessionId:"late-process"}}}));const s=o.terminateRun("parent-run");await Promise.resolve();const a=o.linkChildRun("parent-run","late-child");let i=!1;s.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await a,await expect(s).resolves.toMatchObject({terminations:[expect.objectContaining({ok:!0})]})}),it("waits for a running exec discovered after same-run termination starts",async()=>{let e;const n=new Promise(n=>{e=n}),t=vi.fn(async e=>("poll"===e.args.action&&await n,successfulToolEnvelope(e))),o=createCoordinator(t);o.beforeToolCall({runId:"run-late",toolCallId:"tool-late",toolName:"exec",params:{command:"sleep 90"}});const s=o.terminateRun("run-late");await Promise.resolve();const a=o.afterToolCall(runningExec({runId:"run-late",toolCallId:"tool-late",result:{details:{status:"running",sessionId:"late-process"}}}));let i=!1;s.finally(()=>{i=!0}),await Promise.resolve(),expect(i).toBe(!1),e(),await a,await expect(s).resolves.toMatchObject({processes:[{sessionId:"late-process",sessionKey:"scope-1"}],terminations:[expect.objectContaining({ok:!0})]})}),it("keeps a failed descendant target under its original child owner",async()=>{let e=!1;const n=vi.fn(async n=>{const t=n.args;return"kill"===t.action||e?successfulToolEnvelope(n):{ok:!0,output:{details:{status:"running",sessionId:t.sessionId}}}}),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:n},{verificationAttempts:1}));await t.linkChildRun("parent-run","child-run"),await t.afterToolCall(runningExec({runId:"child-run",result:{details:{status:"running",sessionId:"child-process"}}})),expect((await t.terminateRun("parent-run")).terminations).toEqual([expect.objectContaining({ownerRunId:"child-run",ok:!1})]),e=!0,expect((await t.terminateRun("child-run")).terminations).toEqual([expect.objectContaining({ownerRunId:"child-run",ok:!0})])})});
@@ -1 +1 @@
1
- const DEFAULT_OWNERSHIP_TTL_MS=216e5,DEFAULT_MAX_RUNS=1024,DEFAULT_MAX_PROCESSES=4096,DEFAULT_MAX_TOMBSTONES=1024,isRecord=s=>"object"==typeof s&&null!==s&&!Array.isArray(s),nonEmptyString=s=>{if("string"!=typeof s)return;return s.trim()||void 0},positiveInteger=(s,e)=>Number.isInteger(s)&&(s??0)>0?s:e,processKey=s=>JSON.stringify([s.sessionKey??null,s.sessionId]);export const classifyOwnedExecProcess=s=>{if("exec"!==s.toolName)return;if(!0===s.params.background)return;if(!isRecord(s.result))return;const e=s.result.details;if(!isRecord(e)||"running"!==e.status)return;const t=nonEmptyString(e.sessionId);if(!t)return;const n=nonEmptyString(s.sessionKey);return n?{sessionId:t,sessionKey:n}:{sessionId:t}};export class RunProcessOwnershipRegistry{ownershipTtlMs;maxRuns;maxProcesses;maxTombstones;now;runs=new Map;processOwners=new Map;tombstones=new Map;childRuns=new Map;parentRuns=new Map;constructor(s={}){this.ownershipTtlMs=positiveInteger(s.ownershipTtlMs,216e5),this.maxRuns=positiveInteger(s.maxRuns,1024),this.maxProcesses=positiveInteger(s.maxProcesses,4096),this.maxTombstones=positiveInteger(s.maxTombstones,1024),this.now=s.now??Date.now}observeAfterToolCall(s){const e=this.now();this.prune(e);const t=nonEmptyString(s.runId);if(!t)return{kind:"ignored",reason:"invalid_run_id"};if("exec"!==s.toolName)return{kind:"ignored",reason:"not_exec"};if(!0===s.params.background)return{kind:"ignored",reason:"detached"};const n=classifyOwnedExecProcess(s);return n?this.tombstones.has(t)?{kind:"terminate",process:n}:(this.claim(t,n,e),this.enforceOwnershipBounds(),{kind:"owned",process:n}):{kind:"ignored",reason:"not_running"}}terminateRun(s){const e=this.now();this.prune(e);const t=nonEmptyString(s);if(!t)return{processes:[],ownedProcesses:[]};const n=this.runIdsForTermination(t),o=[],r=[];for(const s of n){this.tombstones.delete(s),this.tombstones.set(s,{observedAt:e});const t=this.runs.get(s);if(t)for(const e of t.processes.values())o.push(e),r.push({ownerRunId:s,process:e});this.removeRun(s)}return this.enforceTombstoneBound(),{processes:o,ownedProcesses:r}}runIdsForTermination(s){const e=nonEmptyString(s);return e?this.runAndDescendants(e):[]}linkChildRun(s,e){const t=this.now();this.prune(t);const n=nonEmptyString(s),o=nonEmptyString(e);if(!n||!o||n===o)return{terminalized:!1,processes:[],ownedProcesses:[]};this.parentRuns.set(o,n);const r=this.childRuns.get(n)??new Set;return r.add(o),this.childRuns.set(n,r),this.tombstones.has(n)?{terminalized:!0,...this.terminateRun(o)}:{terminalized:!1,processes:[],ownedProcesses:[]}}releaseRun(s){const e=this.now();this.prune(e);const t=nonEmptyString(s);if(!t)return[];const n=this.runs.get(t),o=n?Array.from(n.processes.values()):[];return this.removeRun(t),this.removeRunRelation(t),this.tombstones.delete(t),o}snapshot(){return this.prune(this.now()),{runCount:this.runs.size,processCount:this.processOwners.size,tombstoneCount:this.tombstones.size}}claim(s,e,t){const n=processKey(e),o=this.processOwners.get(n);if(o&&o.runId!==s){const s=this.runs.get(o.runId);s?.processes.delete(n),0===s?.processes.size&&this.runs.delete(o.runId)}let r=this.runs.get(s);r?(this.runs.delete(s),r.observedAt=t):r={observedAt:t,processes:new Map},r.processes.set(n,e),this.runs.set(s,r),this.processOwners.delete(n),this.processOwners.set(n,{runId:s})}prune(s){for(const[e,t]of this.tombstones)s-t.observedAt>=this.ownershipTtlMs&&(this.tombstones.delete(e),this.removeRunRelation(e));for(const[e,t]of this.runs)s-t.observedAt>=this.ownershipTtlMs&&this.removeRun(e)}enforceOwnershipBounds(){for(;this.runs.size>this.maxRuns;){const s=this.runs.keys().next().value;if(!s)break;this.removeRun(s)}for(;this.processOwners.size>this.maxProcesses;){const s=this.processOwners.keys().next().value;if(!s)break;const e=this.processOwners.get(s);if(this.processOwners.delete(s),!e)continue;const t=this.runs.get(e.runId);t?.processes.delete(s),0===t?.processes.size&&this.runs.delete(e.runId)}}removeRun(s){const e=this.runs.get(s);if(e){for(const t of e.processes.keys()){const e=this.processOwners.get(t);e?.runId===s&&this.processOwners.delete(t)}this.runs.delete(s)}}runAndDescendants(s){const e=[],t=[s],n=new Set;for(;t.length>0;){const s=t.pop();if(!n.has(s)){n.add(s),e.push(s);for(const e of this.childRuns.get(s)??[])t.push(e)}}return e}removeRunRelation(s){const e=this.parentRuns.get(s);if(e){const t=this.childRuns.get(e);t?.delete(s),0===t?.size&&this.childRuns.delete(e),this.parentRuns.delete(s)}const t=this.childRuns.get(s);if(t){for(const s of t)this.parentRuns.delete(s);this.childRuns.delete(s)}}enforceTombstoneBound(){for(;this.tombstones.size>this.maxTombstones;){const s=this.tombstones.keys().next().value;if(!s)break;this.tombstones.delete(s),this.removeRunRelation(s)}}}
1
+ const DEFAULT_OWNERSHIP_TTL_MS=216e5,DEFAULT_MAX_RUNS=1024,DEFAULT_MAX_PROCESSES=4096,DEFAULT_MAX_TOMBSTONES=1024,isRecord=s=>"object"==typeof s&&null!==s&&!Array.isArray(s),nonEmptyString=s=>{if("string"!=typeof s)return;return s.trim()||void 0},positiveInteger=(s,e)=>Number.isInteger(s)&&(s??0)>0?s:e,processKey=s=>JSON.stringify([s.sessionKey??null,s.sessionId]);export const classifyOwnedExecProcess=s=>{if("exec"!==s.toolName)return;if(!0===s.params.background)return;if(!isRecord(s.result))return;const e=s.result.details;if(!isRecord(e)||"running"!==e.status)return;const n=nonEmptyString(e.sessionId);if(!n)return;const t=nonEmptyString(s.sessionKey);return t?{sessionId:n,sessionKey:t}:{sessionId:n}};export const classifyTerminalProcess=s=>{if("process"!==s.toolName)return;if("poll"!==s.params.action)return;if(!isRecord(s.result))return;const e=s.result.details;if(!isRecord(e)||"completed"!==e.status&&"failed"!==e.status)return;const n=nonEmptyString(e.sessionId)??nonEmptyString(s.params.sessionId);if(!n)return;const t=nonEmptyString(s.sessionKey);return t?{sessionId:n,sessionKey:t}:{sessionId:n}};export class RunProcessOwnershipRegistry{ownershipTtlMs;maxRuns;maxProcesses;maxTombstones;now;runs=new Map;processOwners=new Map;tombstones=new Map;childRuns=new Map;parentRuns=new Map;constructor(s={}){this.ownershipTtlMs=positiveInteger(s.ownershipTtlMs,216e5),this.maxRuns=positiveInteger(s.maxRuns,1024),this.maxProcesses=positiveInteger(s.maxProcesses,4096),this.maxTombstones=positiveInteger(s.maxTombstones,1024),this.now=s.now??Date.now}observeAfterToolCall(s){const e=this.now();this.prune(e);const n=nonEmptyString(s.runId);if(!n)return{kind:"ignored",reason:"invalid_run_id"};if("process"===s.toolName){const e=classifyTerminalProcess(s);return e?this.releaseProcess(e):{kind:"ignored",reason:"not_terminal"}}if("exec"!==s.toolName)return{kind:"ignored",reason:"unsupported_tool"};if(!0===s.params.background)return{kind:"ignored",reason:"detached"};const t=classifyOwnedExecProcess(s);return t?this.tombstones.has(n)?{kind:"terminate",process:t}:(this.claim(n,t,e),this.enforceOwnershipBounds(),{kind:"owned",process:t}):{kind:"ignored",reason:"not_running"}}releaseProcess(s){const e=processKey(s),n=this.processOwners.get(e);if(!n)return{kind:"released",process:s,wasOwned:!1};this.processOwners.delete(e);const t=this.runs.get(n.runId);return t?.processes.delete(e),0===t?.processes.size&&this.runs.delete(n.runId),{kind:"released",process:s,wasOwned:!0}}terminateRun(s){const e=this.now();this.prune(e);const n=nonEmptyString(s);if(!n)return{processes:[],ownedProcesses:[]};const t=this.runIdsForTermination(n),r=[],o=[];for(const s of t){this.tombstones.delete(s),this.tombstones.set(s,{observedAt:e});const n=this.runs.get(s);if(n)for(const e of n.processes.values())r.push(e),o.push({ownerRunId:s,process:e});this.removeRun(s)}return this.enforceTombstoneBound(),{processes:r,ownedProcesses:o}}runIdsForTermination(s){const e=nonEmptyString(s);return e?this.runAndDescendants(e):[]}linkChildRun(s,e){const n=this.now();this.prune(n);const t=nonEmptyString(s),r=nonEmptyString(e);if(!t||!r||t===r)return{terminalized:!1,processes:[],ownedProcesses:[]};this.parentRuns.set(r,t);const o=this.childRuns.get(t)??new Set;return o.add(r),this.childRuns.set(t,o),this.tombstones.has(t)?{terminalized:!0,...this.terminateRun(r)}:{terminalized:!1,processes:[],ownedProcesses:[]}}releaseRun(s){const e=this.now();this.prune(e);const n=nonEmptyString(s);if(!n)return[];const t=this.runs.get(n),r=t?Array.from(t.processes.values()):[];return this.removeRun(n),this.removeRunRelation(n),this.tombstones.delete(n),r}snapshot(){return this.prune(this.now()),{runCount:this.runs.size,processCount:this.processOwners.size,tombstoneCount:this.tombstones.size}}claim(s,e,n){const t=processKey(e),r=this.processOwners.get(t);if(r&&r.runId!==s){const s=this.runs.get(r.runId);s?.processes.delete(t),0===s?.processes.size&&this.runs.delete(r.runId)}let o=this.runs.get(s);o?(this.runs.delete(s),o.observedAt=n):o={observedAt:n,processes:new Map},o.processes.set(t,e),this.runs.set(s,o),this.processOwners.delete(t),this.processOwners.set(t,{runId:s})}prune(s){for(const[e,n]of this.tombstones)s-n.observedAt>=this.ownershipTtlMs&&(this.tombstones.delete(e),this.removeRunRelation(e));for(const[e,n]of this.runs)s-n.observedAt>=this.ownershipTtlMs&&this.removeRun(e)}enforceOwnershipBounds(){for(;this.runs.size>this.maxRuns;){const s=this.runs.keys().next().value;if(!s)break;this.removeRun(s)}for(;this.processOwners.size>this.maxProcesses;){const s=this.processOwners.keys().next().value;if(!s)break;const e=this.processOwners.get(s);if(this.processOwners.delete(s),!e)continue;const n=this.runs.get(e.runId);n?.processes.delete(s),0===n?.processes.size&&this.runs.delete(e.runId)}}removeRun(s){const e=this.runs.get(s);if(e){for(const n of e.processes.keys()){const e=this.processOwners.get(n);e?.runId===s&&this.processOwners.delete(n)}this.runs.delete(s)}}runAndDescendants(s){const e=[],n=[s],t=new Set;for(;n.length>0;){const s=n.pop();if(!t.has(s)){t.add(s),e.push(s);for(const e of this.childRuns.get(s)??[])n.push(e)}}return e}removeRunRelation(s){const e=this.parentRuns.get(s);if(e){const n=this.childRuns.get(e);n?.delete(s),0===n?.size&&this.childRuns.delete(e),this.parentRuns.delete(s)}const n=this.childRuns.get(s);if(n){for(const s of n)this.parentRuns.delete(s);this.childRuns.delete(s)}}enforceTombstoneBound(){for(;this.tombstones.size>this.maxTombstones;){const s=this.tombstones.keys().next().value;if(!s)break;this.tombstones.delete(s),this.removeRunRelation(s)}}}
@@ -1 +1 @@
1
- import{describe,expect,it}from"vitest";import{RunProcessOwnershipRegistry,classifyOwnedExecProcess}from"./run-process-ownership-registry.js";const runningExec=(e={})=>({runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"exec",params:{command:"sleep 60"},result:{content:[{type:"text",text:"still running"}],details:{status:"running",sessionId:"proc-1",pid:123}},...e});describe("classifyOwnedExecProcess",()=>{it("owns default auto-yield and explicit yieldMs exec sessions",()=>{expect(classifyOwnedExecProcess(runningExec())).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}),expect(classifyOwnedExecProcess(runningExec({params:{yieldMs:50}}))).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"})}),it("treats explicit background exec as detached",()=>{expect(classifyOwnedExecProcess(runningExec({params:{command:"worker",background:!0}}))).toBeUndefined()}),it("requires a structured running result and never parses display text",()=>{expect(classifyOwnedExecProcess(runningExec({result:{content:[{type:"text",text:"Command still running (session fake)"}]}}))).toBeUndefined(),expect(classifyOwnedExecProcess(runningExec({result:{details:{status:"completed"}}}))).toBeUndefined()})}),describe("RunProcessOwnershipRegistry",()=>{it("returns all parent-owned sessions when cancellation follows after_tool_call",()=>{const e=new RunProcessOwnershipRegistry;expect(e.observeAfterToolCall(runningExec()).kind).toBe("owned"),e.observeAfterToolCall(runningExec({result:{details:{status:"running",sessionId:"proc-2"}}})),expect(e.terminateRun("run-1").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"},{sessionId:"proc-2",sessionKey:"agent:main:conversation-1"}]),expect(e.snapshot()).toEqual({runCount:0,processCount:0,tombstoneCount:1})}),it("returns terminate when cancellation wins the after_tool_call race",()=>{const e=new RunProcessOwnershipRegistry;expect(e.terminateRun("run-1").processes).toEqual([]),expect(e.observeAfterToolCall(runningExec())).toEqual({kind:"terminate",process:{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}}),expect(e.snapshot().processCount).toBe(0)}),it("never terminates detached work, including after parent cancellation",()=>{const e=new RunProcessOwnershipRegistry;e.terminateRun("run-1"),expect(e.observeAfterToolCall(runningExec({params:{command:"worker",background:!0}}))).toEqual({kind:"ignored",reason:"detached"})}),it("retains terminal ownership until the run lifecycle expires",()=>{let e=1e3;const n=new RunProcessOwnershipRegistry({ownershipTtlMs:100,now:()=>e});n.terminateRun("run-1"),e+=101,expect(n.observeAfterToolCall(runningExec()).kind).toBe("owned"),expect(n.snapshot().tombstoneCount).toBe(0)}),it("terminalizes descendant run processes with their parent",()=>{const e=new RunProcessOwnershipRegistry;e.linkChildRun("parent-run","child-run"),e.observeAfterToolCall(runningExec({runId:"child-run",sessionKey:"agent:worker:child"})),expect(e.terminateRun("parent-run").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:worker:child"}]),expect(e.observeAfterToolCall(runningExec({runId:"child-run",sessionKey:"agent:worker:child"})).kind).toBe("terminate")}),it("walks an arbitrary-depth subagent run graph",()=>{const e=new RunProcessOwnershipRegistry;e.linkChildRun("root","child"),e.linkChildRun("child","grandchild"),e.observeAfterToolCall(runningExec({runId:"grandchild",sessionKey:"agent:grandchild:conversation"})),expect(e.terminateRun("root").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:grandchild:conversation"}])}),it("bounds runs, processes, tombstones, and stale ownership",()=>{let e=0;const n=new RunProcessOwnershipRegistry({maxRuns:2,maxProcesses:2,maxTombstones:2,ownershipTtlMs:100,now:()=>e});for(let e=1;e<=3;e+=1)n.observeAfterToolCall(runningExec({runId:`run-${e}`,result:{details:{status:"running",sessionId:`proc-${e}`}}}));expect(n.snapshot()).toEqual({runCount:2,processCount:2,tombstoneCount:0}),n.terminateRun("cancel-1"),n.terminateRun("cancel-2"),n.terminateRun("cancel-3"),expect(n.snapshot().tombstoneCount).toBe(2),e=101,expect(n.snapshot()).toEqual({runCount:0,processCount:0,tombstoneCount:0})}),it("keys process ownership by session scope as well as session id",()=>{const e=new RunProcessOwnershipRegistry;e.observeAfterToolCall(runningExec({sessionKey:"scope-a"})),e.observeAfterToolCall(runningExec({runId:"run-2",sessionKey:"scope-b"})),expect(e.terminateRun("run-1").processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-a"}]),expect(e.terminateRun("run-2").processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-b"}])})});
1
+ import{describe,expect,it}from"vitest";import{RunProcessOwnershipRegistry,classifyOwnedExecProcess,classifyTerminalProcess}from"./run-process-ownership-registry.js";const runningExec=(e={})=>({runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"exec",params:{command:"sleep 60"},result:{content:[{type:"text",text:"still running"}],details:{status:"running",sessionId:"proc-1",pid:123}},...e});describe("classifyOwnedExecProcess",()=>{it("owns default auto-yield and explicit yieldMs exec sessions",()=>{expect(classifyOwnedExecProcess(runningExec())).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}),expect(classifyOwnedExecProcess(runningExec({params:{yieldMs:50}}))).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"})}),it("treats explicit background exec as detached",()=>{expect(classifyOwnedExecProcess(runningExec({params:{command:"worker",background:!0}}))).toBeUndefined()}),it("requires a structured running result and never parses display text",()=>{expect(classifyOwnedExecProcess(runningExec({result:{content:[{type:"text",text:"Command still running (session fake)"}]}}))).toBeUndefined(),expect(classifyOwnedExecProcess(runningExec({result:{details:{status:"completed"}}}))).toBeUndefined()})}),describe("classifyTerminalProcess",()=>{it("recognizes structured completed and failed process results",()=>{const e={runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{details:{status:"completed",sessionId:"proc-1"}}};expect(classifyTerminalProcess(e)).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}),expect(classifyTerminalProcess({...e,result:{details:{status:"failed",sessionId:"proc-1"}}})).toEqual({sessionId:"proc-1",sessionKey:"agent:main:conversation-1"})}),it("ignores running process results and display text",()=>{expect(classifyTerminalProcess({runId:"run-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{content:[{type:"text",text:"Process exited with code 0."}],details:{status:"running",sessionId:"proc-1"}}})).toBeUndefined()})}),describe("RunProcessOwnershipRegistry",()=>{it("releases ownership when process polling proves natural completion",()=>{const e=new RunProcessOwnershipRegistry;e.observeAfterToolCall(runningExec()),expect(e.observeAfterToolCall({runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{details:{status:"completed",sessionId:"proc-1"}}})).toEqual({kind:"released",wasOwned:!0,process:{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}}),expect(e.terminateRun("run-1").processes).toEqual([])}),it("does not release ownership when a non-poll process action fails",()=>{const e=new RunProcessOwnershipRegistry;e.observeAfterToolCall(runningExec()),expect(e.observeAfterToolCall({runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"process",params:{action:"paste",sessionId:"proc-1",text:""},result:{details:{status:"failed",sessionId:"proc-1"}}})).toEqual({kind:"ignored",reason:"not_terminal"}),expect(e.terminateRun("run-1").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}])}),it("surfaces late terminal evidence after cancellation removed ownership",()=>{const e=new RunProcessOwnershipRegistry;e.observeAfterToolCall(runningExec()),e.terminateRun("run-1"),expect(e.observeAfterToolCall({runId:"run-1",sessionKey:"agent:main:conversation-1",toolName:"process",params:{action:"poll",sessionId:"proc-1"},result:{details:{status:"completed",sessionId:"proc-1"}}})).toEqual({kind:"released",wasOwned:!1,process:{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}})}),it("returns all parent-owned sessions when cancellation follows after_tool_call",()=>{const e=new RunProcessOwnershipRegistry;expect(e.observeAfterToolCall(runningExec()).kind).toBe("owned"),e.observeAfterToolCall(runningExec({result:{details:{status:"running",sessionId:"proc-2"}}})),expect(e.terminateRun("run-1").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"},{sessionId:"proc-2",sessionKey:"agent:main:conversation-1"}]),expect(e.snapshot()).toEqual({runCount:0,processCount:0,tombstoneCount:1})}),it("returns terminate when cancellation wins the after_tool_call race",()=>{const e=new RunProcessOwnershipRegistry;expect(e.terminateRun("run-1").processes).toEqual([]),expect(e.observeAfterToolCall(runningExec())).toEqual({kind:"terminate",process:{sessionId:"proc-1",sessionKey:"agent:main:conversation-1"}}),expect(e.snapshot().processCount).toBe(0)}),it("never terminates detached work, including after parent cancellation",()=>{const e=new RunProcessOwnershipRegistry;e.terminateRun("run-1"),expect(e.observeAfterToolCall(runningExec({params:{command:"worker",background:!0}}))).toEqual({kind:"ignored",reason:"detached"})}),it("retains terminal ownership until the run lifecycle expires",()=>{let e=1e3;const s=new RunProcessOwnershipRegistry({ownershipTtlMs:100,now:()=>e});s.terminateRun("run-1"),e+=101,expect(s.observeAfterToolCall(runningExec()).kind).toBe("owned"),expect(s.snapshot().tombstoneCount).toBe(0)}),it("terminalizes descendant run processes with their parent",()=>{const e=new RunProcessOwnershipRegistry;e.linkChildRun("parent-run","child-run"),e.observeAfterToolCall(runningExec({runId:"child-run",sessionKey:"agent:worker:child"})),expect(e.terminateRun("parent-run").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:worker:child"}]),expect(e.observeAfterToolCall(runningExec({runId:"child-run",sessionKey:"agent:worker:child"})).kind).toBe("terminate")}),it("walks an arbitrary-depth subagent run graph",()=>{const e=new RunProcessOwnershipRegistry;e.linkChildRun("root","child"),e.linkChildRun("child","grandchild"),e.observeAfterToolCall(runningExec({runId:"grandchild",sessionKey:"agent:grandchild:conversation"})),expect(e.terminateRun("root").processes).toEqual([{sessionId:"proc-1",sessionKey:"agent:grandchild:conversation"}])}),it("bounds runs, processes, tombstones, and stale ownership",()=>{let e=0;const s=new RunProcessOwnershipRegistry({maxRuns:2,maxProcesses:2,maxTombstones:2,ownershipTtlMs:100,now:()=>e});for(let e=1;e<=3;e+=1)s.observeAfterToolCall(runningExec({runId:`run-${e}`,result:{details:{status:"running",sessionId:`proc-${e}`}}}));expect(s.snapshot()).toEqual({runCount:2,processCount:2,tombstoneCount:0}),s.terminateRun("cancel-1"),s.terminateRun("cancel-2"),s.terminateRun("cancel-3"),expect(s.snapshot().tombstoneCount).toBe(2),e=101,expect(s.snapshot()).toEqual({runCount:0,processCount:0,tombstoneCount:0})}),it("keys process ownership by session scope as well as session id",()=>{const e=new RunProcessOwnershipRegistry;e.observeAfterToolCall(runningExec({sessionKey:"scope-a"})),e.observeAfterToolCall(runningExec({runId:"run-2",sessionKey:"scope-b"})),expect(e.terminateRun("run-1").processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-a"}]),expect(e.terminateRun("run-2").processes).toEqual([{sessionId:"proc-1",sessionKey:"scope-b"}])})});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beeos-claw",
3
- "version": "0.5.53",
3
+ "version": "0.5.54",
4
4
  "description": "OpenClaw plugin that bridges the BeeOS platform with the local OpenClaw Gateway, providing file transfer, ACP bridge, and observability.",
5
5
  "license": "MIT",
6
6
  "author": "BeeOS <dev@beeos.ai>",