beeos-claw 0.5.52 → 0.5.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-gateway/bridge-core-history-page.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/history-replay.test.js +1 -1
- package/dist/acp-gateway/history-turn-completion-policy.js +1 -1
- package/dist/acp-gateway/local-session-history.js +1 -1
- package/dist/acp-gateway/local-session-history.test.js +1 -1
- package/dist/acp-gateway/session-write-gate.js +1 -1
- package/dist/acp-gateway/session-write-gate.test.js +1 -1
- package/dist/acp-gateway/stale-session-lock-sweeper.js +1 -1
- package/dist/acp-server.js +1 -1
- package/dist/im-inbound-handler.js +1 -1
- package/dist/im-inbound-handler.test.js +1 -1
- package/dist/im-runtime-dispatch-session-guard.test.js +1 -0
- package/dist/im-runtime-dispatch.js +1 -1
- package/dist/im-runtime-dispatch.test.js +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/agent-runtime-failure-classifier.js +1 -0
- package/dist/{acp-gateway → runtime}/agent-runtime-failure-classifier.test.js +1 -1
- package/dist/runtime/im-reply-sink.js +1 -1
- package/dist/runtime/im-runtime-failure-policy.js +1 -0
- package/dist/runtime/im-runtime-failure-policy.test.js +1 -0
- package/dist/runtime/openclaw-process-ownership-hook-registrar.js +1 -0
- package/dist/runtime/openclaw-process-ownership-hook-registrar.test.js +1 -0
- package/dist/runtime/openclaw-process-ownership-hook.js +1 -0
- package/dist/runtime/openclaw-process-ownership-hook.test.js +1 -0
- package/dist/runtime/openclaw-session-write-quiescence.js +1 -0
- package/dist/runtime/openclaw-session-write-quiescence.test.js +1 -0
- package/dist/runtime/openclaw-state-dir.js +1 -0
- package/dist/runtime/process-session-terminator.js +1 -0
- package/dist/runtime/process-session-terminator.test.js +1 -0
- package/dist/runtime/prompt-run-store.js +1 -1
- package/dist/runtime/prompt-run-store.test.js +1 -1
- package/dist/runtime/reply-router.js +1 -1
- package/dist/runtime/reply-router.test.js +1 -1
- package/dist/runtime/run-process-lifecycle-coordinator.js +1 -0
- package/dist/runtime/run-process-lifecycle-coordinator.test.js +1 -0
- package/dist/runtime/run-process-ownership-registry.js +1 -0
- package/dist/runtime/run-process-ownership-registry.test.js +1 -0
- package/dist/runtime/session-write-gate-release-coordinator.js +1 -0
- package/dist/runtime/session-write-gate-release-coordinator.test.js +1 -0
- package/dist/test-fixtures/im-runtime.fixture.js +1 -0
- package/package.json +1 -1
- package/dist/acp-gateway/agent-runtime-failure-classifier.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,a,s="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:s})}appendToolResult(e,t,a=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>({runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,origin:"im",onLifecycleEvent:e=>s.push(e),...n});describe("dispatchImInboundPrompt",()=>{let e,t,a;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,a=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(a.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const s=await dispatchImInboundPrompt(baseArgs({},e,t,a));expect(s).toBe(!1),expect(e.streams).toHaveLength(0),expect(a).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let s=!1;const n=makeRuntime(async()=>(s=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,a));expect(o).toBe(!1),expect(s).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(a).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const s=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=a.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("runtime error path: stream is failed with the runtime's error message",async()=>{const s=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("LLM exploded");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",abortController:new AbortController,startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
|
1
|
+
import{describe,it,expect,beforeEach}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,s,a="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:s,state:a})}appendToolResult(e,t,s=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...s?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions?.abortSignal;if(!s)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:s})}}}}),stubLogger=()=>{const e=[],t=t=>s=>e.push(`[${t}] ${s}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,s,a,n={})=>{const o=n.sessionWriteGate??new SessionWriteGate,r=n.sessionWriteReleaseCoordinator??new SessionWriteGateReleaseCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:s,sessionWriteGate:o,sessionWriteReleaseCoordinator:r,origin:"im",onLifecycleEvent:e=>a.push(e),...n}};describe("dispatchImInboundPrompt",()=>{let e,t,s;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,s=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(s.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const a=await dispatchImInboundPrompt(baseArgs({},e,t,s));expect(a).toBe(!1),expect(e.streams).toHaveLength(0),expect(s).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let a=!1;const n=makeRuntime(async()=>(a=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,s));expect(o).toBe(!1),expect(a).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(s).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const a=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({abortSignal:e})=>(a(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{const t=e.reason;s(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=s.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({abortSignal:e})=>(a(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(s.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(s.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("runtime error path: stream fails with a redacted public message",async()=>{const a=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("The agent could not complete the request. Please retry.");const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const a=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let a=-1;const n=makeRuntime(async()=>(a=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,s)),expect(a).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",abortController:new AbortController,startedAt:Date.now()});const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(a,e,t,s)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(a,e,t,s,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const a=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),a(),await new Promise((e,s)=>{t.addEventListener("abort",()=>{const e=t.reason;s(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n,t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const a=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const a=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const s=n(t);return 2===e.streams.length&&(s.failNextTerminal=!0),s};const o=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=s.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(s.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const a=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const s=n(t);return 1===e.streams.length&&(s.failNextTerminal=!0),s};const o=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(o).toBe(!0);const r=s.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(s.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const n=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:n,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,t,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),a({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeListFiles(o,t,agentKeys,n);return a({component:"file-list-tool",domain:"tool",name:s.ok?"list_files.success":"list_files.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),s}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeFetchFile(o,t,agentKeys,{signal:n,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:s.ok?"fetch_file.success":"fetch_file.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,path:s.path,size:s.size,error:s.error}}),s}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,a,s,i,n));const l=sharedAcpServer;o.current=l;const c=new PromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await l.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await l.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:g,url:d,principalId:p,a2aEnabled:u}=t.messageBus;let f;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);f=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const m=g&&(f||t.messageBus.serviceUrl),y=m?l.getMessageClient():null;if(m&&y){y.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),y.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=p||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:d,tokenProvider:f,logger:e.logger},n=new Map;let s,i=!1;const g=async()=>{if(r||!f)return r;try{const o=await f("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},u=()=>{if(r)return[{localAgentId:l.getLocalAgentId(),principalId:r}];const e=l.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=l.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:l.getLocalAgentId(),principalId:o}]:[]},m=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{l.ensureImInboundSession(e,r,o)}}),_=async(r,s)=>{if(n.has(r.principalId))return;const i=s?y:new MessageClient(o);s&&l.setMessageClient(o,r.principalId);const g=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:m(r),promptRunStore:c,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),d=new MessageServiceRecoveryScanner({client:i,imInbound:g,logger:e.logger});i.on("connect",()=>{d.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,f?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await g.onChatMessage(r):"chat_cancel"===r.type&&g.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),n.set(r.principalId,{client:i,scanner:d,primary:s});try{await i.connect({identityId:r.principalId}),d.start(r.principalId)}catch(e){throw n.delete(r.principalId),d.stop(),s||await i.disconnect().catch(()=>{}),e}},b=async r=>{if(i)if(s)await s;else{s=(async()=>{const o=u();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,s]of n)t.has(o)||(s.primary&&(a=!0),s.scanner.stop(),await s.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&n.size>0)for(const[o,t]of n)t.scanner.stop(),await t.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let s=[...n.values()].some(e=>e.primary);for(const e of o){const r=!s;await _(e,r),r&&(s=!0)}})();try{await s}finally{s=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,l.onAgentMappingUpdated(()=>{b("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await g(),!r)try{await l.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await b("startup")},async stop(){i=!1;const r=[...n.values()];n.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else g?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",g);if(!1!==u&&t.agentGatewayUrl){const r=e=>l.getGatewaySessionKeyForToolCall(e),o=e=>l.getMentionsForToolCall(e),a=(e,r)=>{l.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,a,()=>l?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const n=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>l.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(a.id),l.wireRelayActionHandler(),l.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await l.waitForAgentMapping();const r=l.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),l.onceAgentMappingReady(()=>{const r=l.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&l.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>l.getSessionIdForToolCall(e),getPlatformAgentId:e=>l.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>l.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>l.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:t.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const c=new ResourceDescriptorRegistry;c.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(l.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),l.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:c});const d=l.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=l.getResourceRenderer();if(p){generateActionTools(c,e,async(e,r)=>l.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return l.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&l.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?l.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const _=new TerminalManager,b=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:_,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){b.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){b.stop(),_.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
1
|
+
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.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{OpenClawProcessOwnershipHookRegistrar}from"./runtime/openclaw-process-ownership-hook-registrar.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null;const sharedSessionWriteGate=new SessionWriteGate,sharedPromptRunStore=new PromptRunStore,sharedRunProcessOwnershipRegistry=new RunProcessOwnershipRegistry;let sharedRunProcessLifecycleCoordinator,sharedSessionWriteReleaseCoordinator;const processOwnershipHookRegistrar=new OpenClawProcessOwnershipHookRegistrar;let _initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const s=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};s({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),s({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)s({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:s,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,s){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const n=await executeUpload(o,t,agentKeys,s);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${n.ok}`),a({component:"upload-tool",domain:"tool",name:n.ok?"upload.success":"upload.failure",severity:n.ok?"info":"warn",payload:{toolCallId:r,ok:n.ok,fileCount:n.files?.length??0,error:n.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(n)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,s){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const n=await executeListFiles(o,t,agentKeys,s);return a({component:"file-list-tool",domain:"tool",name:n.ok?"list_files.success":"list_files.failure",severity:n.ok?"info":"warn",payload:{toolCallId:r,ok:n.ok,fileCount:n.files?.length??0,error:n.error}}),n}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,s){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const n=await executeFetchFile(o,t,agentKeys,{signal:s,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:n.ok?"fetch_file.success":"fetch_file.failure",severity:n.ok?"info":"warn",payload:{toolCallId:r,ok:n.ok,path:n.path,size:n.size,error:n.error}}),n}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:s}=r,n=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedSessionWriteReleaseCoordinator??=new SessionWriteGateReleaseCoordinator(sharedSessionWriteGate,new OpenClawSessionWriteQuiescence(e.logger,{agentId:t.gateway.agentId||"main"}),e.logger);const l=sharedSessionWriteReleaseCoordinator;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,a,n,i,s,sharedSessionWriteGate,l));const c=sharedAcpServer;o.current=c;const g=sharedPromptRunStore;sharedRunProcessLifecycleCoordinator??=new RunProcessLifecycleCoordinator(sharedRunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:e=>c.gatewayRequest("tools.invoke",e)}));const d=sharedRunProcessLifecycleCoordinator;if("function"==typeof e.on)try{processOwnershipHookRegistrar.register(e,d,e.logger,e=>g.runtimeRunIdForSessionKey(e))}catch(r){e.logger.debug?.("[beeos-claw] after_tool_call hook unavailable: %s",r instanceof Error?r.message:String(r))}e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&n){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,n);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await c.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await c.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:p,url:u,principalId:f,a2aEnabled:m}=t.messageBus;let y;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);y=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const w=p&&(y||t.messageBus.serviceUrl),b=w?c.getMessageClient():null;if(w&&b){b.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),b.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=f||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:u,tokenProvider:y,logger:e.logger},s=new Map;let n,i=!1;const p=async()=>{if(r||!y)return r;try{const o=await y("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},m=()=>{if(r)return[{localAgentId:c.getLocalAgentId(),principalId:r}];const e=c.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=c.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:c.getLocalAgentId(),principalId:o}]:[]},w=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{c.ensureImInboundSession(e,r,o)}}),_=async(r,n)=>{if(s.has(r.principalId))return;const i=n?b:new MessageClient(o);n&&c.setMessageClient(o,r.principalId);const p=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:w(r),promptRunStore:g,sessionWriteGate:sharedSessionWriteGate,sessionWriteReleaseCoordinator:l,runProcessLifecycleCoordinator:d,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),u=new MessageServiceRecoveryScanner({client:i,imInbound:p,logger:e.logger});i.on("connect",()=>{u.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,y?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await p.onChatMessage(r):"chat_cancel"===r.type&&p.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),s.set(r.principalId,{client:i,scanner:u,primary:n});try{await i.connect({identityId:r.principalId}),u.start(r.principalId)}catch(e){throw s.delete(r.principalId),u.stop(),n||await i.disconnect().catch(()=>{}),e}},h=async r=>{if(i)if(n)await n;else{n=(async()=>{const o=m();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,n]of s)t.has(o)||(n.primary&&(a=!0),n.scanner.stop(),await n.client.disconnect(),s.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&s.size>0)for(const[o,t]of s)t.scanner.stop(),await t.client.disconnect(),s.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let n=[...s.values()].some(e=>e.primary);for(const e of o){const r=!n;await _(e,r),r&&(n=!0)}})();try{await n}finally{n=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,c.onAgentMappingUpdated(()=>{h("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await p(),!r)try{await c.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await h("startup")},async stop(){i=!1;const r=[...s.values()];s.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else p?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",p);if(!1!==m&&t.agentGatewayUrl){const r=e=>c.getGatewaySessionKeyForToolCall(e),o=e=>c.getMentionsForToolCall(e),a=(e,r)=>{c.broadcastNotification(e,r)},s=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},s,r,o,a,()=>c?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const s=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>c.onRelayReconnected(),logger:e.logger});s.connect(),r.setRelayClient(s),r.setAgentId(a.id),c.wireRelayActionHandler(),c.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await c.waitForAgentMapping();const r=c.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),c.onceAgentMappingReady(()=>{const r=c.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&c.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>c.getSessionIdForToolCall(e),getPlatformAgentId:e=>c.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>c.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>c.waitForAgentMapping()},n=new FormSchemaRegistry,i=buildCronSchemas(s,{capMs:t.cronMaxDurationCapMs});n.register(i.create),n.register(i.update);const l=new ResourceDescriptorRegistry;l.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(c.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),c.setCanvasState(r,{schemaRegistry:n,descriptorRegistry:l});const d=c.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(n),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:n,builder:d,logger:e.logger}))});const p=c.getResourceRenderer();if(p){generateActionTools(l,e,async(e,r)=>c.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(s,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return c.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&c.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(s,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?c.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const _=new TerminalManager,h=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:_,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){h.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){h.stop(),_.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";export const TIMEOUT_PUBLIC_MESSAGE="The agent task timed out. Please retry.";export const GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE="The agent could not complete the request. Please retry.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/^\s*(?:⚠️\s*)?agent failed before reply:\s*session file locked\b/i,/^\s*sessionwritelocktimeouterror\b/i,/^\s*OPENCLAW_SESSION_WRITE_LOCK\b/i],ABORT_PATTERNS=[/^\s*request was aborted\.?\s*$/i,/^\s*this operation was aborted\.?\s*$/i,/^\s*aborterror\b/i,/^\s*agent failed before reply:\s*.*\baborted\b/i],AGENT_LOST_PATTERNS=[/^\s*(?:agent failed before reply:\s*)?gateway disconnected\b/i,/^\s*agent lost\b/i,/^\s*no live owner\b/i,/^\s*stale live stream\b/i,/^\s*bridge_not_ready\b/i,/^\s*gateway not connected\b/i],TIMEOUT_PATTERNS=[/^\s*prompt timed out\b/i,/^\s*im runtime dispatch timed out\b/i,/^\s*gateway\b.*\btimed out\b/i,/^\s*deadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){return detectAgentRuntimeFailure(e)??classifyProviderRuntimeFailure(e)}export function detectAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim();return matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)}:matchesAny(t,ABORT_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,AGENT_LOST_PATTERNS)?{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,TIMEOUT_PATTERNS)?{kind:"interrupted",stopReason:"timeout",publicMessage:TIMEOUT_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r}:void 0}function classifyProviderRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase(),s=formatFriendlyError(t);return{kind:"error",stopReason:"error",publicMessage:s&&s!==t?s:GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE,errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)})});
|
|
1
|
+
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)}),it("never exposes an unknown diagnostic or credential as public text",()=>{const e="unexpected gateway detail token=super-secret /home/node/private",t=classifyAgentRuntimeFailure({raw:e,source:"im_runtime"});expect(t.publicMessage).toBe("The agent could not complete the request. Please retry."),expect(t.publicMessage).not.toContain("super-secret"),expect(t.diagnosticMessage).toBe(e)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=new Map,i=new Map,a=[],l=[];let d=0,u=0;const m=(t,n,r)=>{const o=u;return e.stream.appendToolUse(t,n,r,"streaming"),u+=1,o},g=()=>(d+=1,`tool_${e.requestId}_${d}`),c=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},p=e=>{const t=e?a.filter(t=>s.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},h=e=>{const t=l.map((e,t)=>({hint:e,index:t})).filter(({hint:t})=>!e||t.name===e);if(1!==t.length)return;const[{hint:n,index:r}]=t;return l.splice(r,1),n},f=(e,t)=>{i.set(e,t)},y=e=>i.get(e)??e,I=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),v=(t,n,r,o)=>{const i=s.get(t);if(i){const s="tool"===i.name&&"tool"!==n?n:i.name,l=!I(i.arguments)&&I(r)?r:i.arguments;return s===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:s,arguments:l,state:i.state}),i.name=s,i.arguments=l),o&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:m(t,n,r),state:"streaming",resultSignatures:new Set,itemEventObserved:!1,toolStartObserved:!1};return s.set(t,l),o&&a.push(t),l},$=t=>{"done"!==t.state&&(e.stream.replacePart(t.partIndex,{type:"tool_use",id:t.id,name:t.name,arguments:t.arguments,state:"done"}),t.state="done")},x=(t,n,r)=>{const o=`${r?"1":"0"}:${"string"==typeof n?n:JSON.stringify(n)}`;t.resultSignatures.has(o)||(((t,n,r)=>{e.stream.appendToolResult(t,n,r),u+=1})(t.id,n,r),t.resultSignatures.add(o))},b=(e,t,n)=>{const r=i.get(e);if(r)return v(r,t??"tool",n??{},!1);if(s.has(e))return v(e,t??"tool",n??{},!1);const o=p(t);return o?(f(e,o),v(o,t??"tool",n??{},!1)):v(e,t??"tool",n??{},!1)},
|
|
1
|
+
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=new Map,i=new Map,a=[],l=[];let d=0,u=0;const m=(t,n,r)=>{const o=u;return e.stream.appendToolUse(t,n,r,"streaming"),u+=1,o},g=()=>(d+=1,`tool_${e.requestId}_${d}`),c=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},p=e=>{const t=e?a.filter(t=>s.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},h=e=>{const t=l.map((e,t)=>({hint:e,index:t})).filter(({hint:t})=>!e||t.name===e);if(1!==t.length)return;const[{hint:n,index:r}]=t;return l.splice(r,1),n},f=(e,t)=>{i.set(e,t)},y=e=>i.get(e)??e,I=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),v=(t,n,r,o)=>{const i=s.get(t);if(i){const s="tool"===i.name&&"tool"!==n?n:i.name,l=!I(i.arguments)&&I(r)?r:i.arguments;return s===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:s,arguments:l,state:i.state}),i.name=s,i.arguments=l),o&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:m(t,n,r),state:"streaming",resultSignatures:new Set,itemEventObserved:!1,toolStartObserved:!1};return s.set(t,l),o&&a.push(t),l},$=t=>{"done"!==t.state&&(e.stream.replacePart(t.partIndex,{type:"tool_use",id:t.id,name:t.name,arguments:t.arguments,state:"done"}),t.state="done")},x=(t,n,r)=>{const o=`${r?"1":"0"}:${"string"==typeof n?n:JSON.stringify(n)}`;t.resultSignatures.has(o)||(((t,n,r)=>{e.stream.appendToolResult(t,n,r),u+=1})(t.id,n,r),t.resultSignatures.add(o))},b=(e,t,n)=>{const r=i.get(e);if(r)return v(r,t??"tool",n??{},!1);if(s.has(e))return v(e,t??"tool",n??{},!1);const o=p(t);return o?(f(e,o),v(o,t??"tool",n??{},!1)):v(e,t??"tool",n??{},!1)},S=(e,t)=>{const n=i.get(e);if(n)return v(n,t,{},!1);if(s.has(e))return v(e,t,{},!1);const r=(e=>{const t=a.filter(t=>{const n=s.get(t);return!(void 0===n||e&&n.name!==e||n.itemEventObserved)});return 1===t.length?t[0]:void 0})(t);if(r)return f(e,r),v(r,t,{},!1);const o=h(t);return v(e,t,o?.arguments??{},!1)},k={tool:0,block:0,final:0};let _=!1;const R=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!_||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(R("tool_result",()=>{const n=resolveRuntimeToolCallId(t),r=resolveRuntimeToolName(t),o=n?void 0:r?p(r):p(),i=n||o?void 0:h(r);if(!n&&!o&&!i)return void e.logger.debug?.(`[im-sink] ambiguous id-less tool result ignored requestId=${e.requestId} channel=${e.channelId} tool=${r??"unknown"} pending=${a.length}`);const l=n?b(n,r,resolveRuntimeToolArguments(t)):v(o??g(),r??s.get(o??"")?.name??i.name,I(resolveRuntimeToolArguments(t))?resolveRuntimeToolArguments(t):i?.arguments??{},!1);k.tool+=1,$(l),x(l,(e=>{const t=[e.mediaUrl,...e.mediaUrls??[]].filter(e=>isNonEmptyString(e));return isNonEmptyString(e.text)&&t.length>0?{text:e.text,media_urls:t}:isNonEmptyString(e.text)?e.text:1===t.length?t[0]:t.length>1?{media_urls:t}:!0===e.isError?"Tool execution failed.":""})(t),!0===t.isError),c(l.id)}),!0),sendBlockReply:n=>(R("block_reply",()=>{k.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(R("final_reply",()=>{k.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...k}),markComplete:t=>{if(1===l.length){const e=l.shift();v(g(),e.name,e.arguments,!1)}for(const n of s.values())try{"success"===t&&$(n),c(n.id)}catch(t){const r=t instanceof Error?t.message:String(t);e.logger.warn?.(`[im-sink] tool finalization failed requestId=${e.requestId} toolUseId=${n.id} error=${r}`)}l.length=0,_=!0}},replyOptions:{abortSignal:e.abortSignal,onAgentRunStart:t=>{e.onAgentRunStart?.(t),e.logger.info?.(`[im-sink] run_start requestId=${e.requestId} runId=${t} channel=${e.channelId} in_reply_to=${e.inboundMessageId} origin=${e.origin??"im"}`)},onPartialReply:n=>{R("partial",()=>{const r=extractBodyUpdate(t,n.text);switch(r.kind){case"append":e.stream.appendBody(r.text);break;case"replace":e.stream.setBody(r.text)}})},onReasoningStream:t=>{R("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{R("reasoning_end",()=>{var t;""!==o&&(t=o,e.stream.appendThinking(t,"done"),u+=1,r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{R("tool_start",()=>{const n="string"==typeof t.phase?t.phase:"start",r=resolveRuntimeToolName(t)??"tool",o=resolveRuntimeToolCallId(t);if("update"===n){if(o){const e=y(o);s.has(e)&&v(e,r,resolveRuntimeToolArguments(t),!0)}return}const i=resolveRuntimeToolArguments(t);if(o){const e=b(o,r,i);return e.toolStartObserved=!0,void(a.includes(e.id)||"streaming"!==e.state||a.push(e.id))}const d=(u=r,a.map(e=>s.get(e)).filter(e=>void 0!==e&&(!u||e.name===u)&&e.itemEventObserved));var u;if(1===d.length){return void(v(d[0].id,r,i,!0).toolStartObserved=!0)}d.length>1?e.logger.debug?.(`[im-sink] ambiguous id-less tool start ignored requestId=${e.requestId} channel=${e.channelId} tool=${r} candidates=${d.length}`):l.push({name:r,arguments:i})})},onItemEvent:e=>{R("item_event",()=>{const t="string"==typeof e.kind?e.kind.toLowerCase():"";if(t&&"tool"!==t)return;const n="string"==typeof e.phase?e.phase.toLowerCase():"update",r=resolveRuntimeToolCallId(e)??("string"==typeof e.itemId&&e.itemId.trim()?e.itemId.trim():void 0),o=resolveRuntimeToolName(e)??("string"==typeof e.title&&e.title.trim()?e.title.trim():void 0);if(!r||!o)return;if("update"===n){const e=y(r),t=s.get(e);return void(t&&(v(t.id,o,{},!0).itemEventObserved=!0))}const i=S(r,o);if(i.itemEventObserved=!0,a.includes(i.id)||"streaming"!==i.state||a.push(i.id),"end"!==n)return;const l="string"==typeof e.status?e.status.toLowerCase():"",d="failed"===l||"error"===l||"cancelled"===l,u=isNonEmptyString(e.summary)?e.summary:isNonEmptyString(e.meta)?e.meta:"";$(i),u&&x(i,u,d),c(i.id)})}}}}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +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")})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{coordinateOpenClawAfterToolCall}from"./openclaw-process-ownership-hook.js";export class OpenClawProcessOwnershipHookRegistrar{registered=new WeakSet;runIdBySessionKey=new Map;maxTrackedSubagentSessions=4096;register(e,s,n,r){this.registered.has(e)||(e.on("before_tool_call",(...e)=>{const n=e[0],r=e[1],o=n?.runId??r?.runId,t=n?.toolName;o&&t&&s.beforeToolCall({runId:o,toolCallId:n?.toolCallId??r?.toolCallId,toolName:t,params:n?.params??{}})}),e.on("after_tool_call",(...e)=>{const r=e[0],o=e[1];if(!r)return;const t=r.runId??o?.runId??"";return coordinateOpenClawAfterToolCall(r,o??{},s).then(e=>e.termination&&!e.termination.ok?s.terminateRunWithRetry(t).then(s=>(s.terminations.some(e=>!e.ok)&&n.warn?.("[beeos-claw] late owned process termination unverified runId=%s sessionId=%s",t,e.termination.process.sessionId),e)):e).catch(e=>{n.warn?.("[beeos-claw] process ownership hook failed runId=%s error=%s",t,e instanceof Error?e.message:String(e))})}),e.on("subagent_spawned",(...e)=>{const o=e[0],t=e[1],i=o?.runId,a=t?.requesterSessionKey,l=a?this.runIdBySessionKey.get(a)??r?.(a):void 0;if(l&&i){if(o?.childSessionKey){if(this.runIdBySessionKey.size>=this.maxTrackedSubagentSessions&&!this.runIdBySessionKey.has(o.childSessionKey)){const e=this.runIdBySessionKey.keys().next().value;e&&this.runIdBySessionKey.delete(e)}this.runIdBySessionKey.set(o.childSessionKey,i)}return s.linkChildRun(l,i).then(async e=>(e.terminations.some(e=>!e.ok)&&await s.terminateRunWithRetry(i),e)).catch(e=>{n.warn?.("[beeos-claw] subagent ownership link failed parentRunId=%s childRunId=%s error=%s",l,i,e instanceof Error?e.message:String(e))})}}),e.on("subagent_ended",(...e)=>{const r=e[0];if(r?.runId)return r.targetSessionKey&&this.runIdBySessionKey.delete(r.targetSessionKey),s.terminateRunWithRetry(r.runId).then(e=>{const s=e.terminations.filter(e=>!e.ok);s.length>0&&n.warn?.("[beeos-claw] subagent terminal process cleanup unverified runId=%s failureCount=%s",r.runId,s.length)}).catch(e=>{n.warn?.("[beeos-claw] subagent terminal process cleanup failed runId=%s error=%s",r.runId,e instanceof Error?e.message:String(e))})}),this.registered.add(e),n.debug?.("[beeos-claw] Process ownership hooks registered: before_tool_call, after_tool_call"))}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{OpenClawProcessOwnershipHookRegistrar}from"./openclaw-process-ownership-hook-registrar.js";import{ProcessSessionTerminator}from"./process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./run-process-lifecycle-coordinator.js";import{RunProcessOwnershipRegistry}from"./run-process-ownership-registry.js";describe("OpenClawProcessOwnershipHookRegistrar",()=>{it("registers independently for discovery and full runtime registries",()=>{const e=new OpenClawProcessOwnershipHookRegistrar,s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async()=>({ok:!1})})),r={on:vi.fn()},n={on:vi.fn()};e.register(r,s,{}),e.register(n,s,{}),e.register(n,s,{}),expect(r.on.mock.calls.map(([e])=>e)).toEqual(["before_tool_call","after_tool_call","subagent_spawned","subagent_ended"]),expect(n.on.mock.calls.map(([e])=>e)).toEqual(["before_tool_call","after_tool_call","subagent_spawned","subagent_ended"])}),it("links spawned child runs so parent termination reaches child processes",async()=>{const e=new Map,s={on:vi.fn((s,r)=>{e.set(s,r)})},r=vi.fn(async e=>({ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:e.args.sessionId}}})),n=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:r}));(new OpenClawProcessOwnershipHookRegistrar).register(s,n,{},e=>"agent:main:parent"===e?"parent-run":void 0),e.get("subagent_spawned")?.({runId:"child-run",childSessionKey:"agent:worker:child"},{requesterSessionKey:"agent:main:parent"}),e.get("subagent_spawned")?.({runId:"grandchild-run",childSessionKey:"agent:worker:grandchild"},{requesterSessionKey:"agent:worker:child"}),await Promise.resolve(),await(e.get("after_tool_call")?.({toolName:"exec",params:{command:"sleep 90"},runId:"grandchild-run",result:{details:{status:"running",sessionId:"child-process"}}},{runId:"grandchild-run",sessionKey:"agent:worker:grandchild"})),await Promise.resolve(),await n.terminateRun("parent-run"),expect(r).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"child-process"},sessionKey:"agent:worker:grandchild"})}),it("reports a subagent cleanup whose termination could not be verified",async()=>{const e=new Map,s={on:vi.fn((s,r)=>{e.set(s,r)})},r=vi.fn(),n=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{}}:{ok:!0,output:{details:{status:"running",sessionId:"proc-child"}}}},{verificationAttempts:1}));(new OpenClawProcessOwnershipHookRegistrar).register(s,n,{warn:r}),await n.afterToolCall({runId:"child-run",sessionKey:"agent:worker:child",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"proc-child"}}}),e.get("subagent_ended")?.({runId:"child-run"}),await vi.waitFor(()=>expect(r).toHaveBeenCalledWith("[beeos-claw] subagent terminal process cleanup unverified runId=%s failureCount=%s","child-run",1))})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function coordinateOpenClawAfterToolCall(o,l,a){return a.afterToolCall({runId:o.runId??l.runId??"",toolCallId:o.toolCallId??l.toolCallId,sessionKey:l.sessionKey,toolName:o.toolName??l.toolName??"",params:o.params??{},result:o.result})}
|
|
@@ -0,0 +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";import{coordinateOpenClawAfterToolCall}from"./openclaw-process-ownership-hook.js";const runningResult={details:{status:"running",sessionId:"process-hook"}},successfulToolEnvelope=e=>({ok:!0,toolName:"process",output:{details:{status:"completed",sessionId:e?.args?.sessionId}}});describe("coordinateOpenClawAfterToolCall",()=>{it("uses the event run id and kills a late process after cancellation",async()=>{const e=vi.fn(async e=>successfulToolEnvelope(e)),o=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:e}));await o.terminateRun("event-run");const s=await coordinateOpenClawAfterToolCall({toolName:"exec",params:{command:"sleep 90"},runId:"event-run",result:runningResult},{runId:"context-run",sessionKey:"agent:main:conversation:one"},o);expect(s.ownership.kind).toBe("terminate"),expect(e).toHaveBeenCalledWith({name:"process",args:{action:"kill",sessionId:"process-hook"},sessionKey:"agent:main:conversation:one"})}),it("falls back to the hook context run id",async()=>{const e=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>successfulToolEnvelope(e)}));await e.terminateRun("context-run");const o=await coordinateOpenClawAfterToolCall({toolName:"exec",params:{},result:runningResult},{runId:"context-run",sessionKey:"agent:worker:conversation:two"},e);expect(o.ownership.kind).toBe("terminate")})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import{isAbsolute,join}from"node:path";import{resolveOpenClawStateDir}from"./openclaw-state-dir.js";const DEFAULT_POLL_INTERVAL_MS=250,DEFAULT_QUIET_CONFIRMATION_MS=750;export class OpenClawSessionWriteQuiescence{logger;stateDir;defaultAgentId;pollIntervalMs;quietConfirmationMs;now;sleep;constructor(e,s={}){this.logger=e,this.stateDir=s.stateDir??resolveOpenClawStateDir(),this.defaultAgentId=s.agentId??"main",this.pollIntervalMs=s.pollIntervalMs??250,this.quietConfirmationMs=s.quietConfirmationMs??750,this.now=s.now??(()=>Date.now()),this.sleep=s.sleep??(e=>new Promise(s=>{const t=setTimeout(s,e);t.unref?.()}))}async waitForQuiescence(e,s){const t=this.now();let i,o,n;for(;this.now()-t<s;){const s=n??this.resolveLockPath(e);if(s){if(o=s,n=s,existsSync(s))i=void 0;else if(i??=this.now(),this.now()-i>=this.quietConfirmationMs)return{quiescent:!0,reason:"unlocked",lockPath:s,elapsedMs:this.now()-t};await this.sleep(this.pollIntervalMs)}else await this.sleep(this.pollIntervalMs)}return this.logger.warn?.(`[im] session writer did not become quiescent sessionKey=${e} timeout_ms=${s}`),{quiescent:!1,reason:o?"timeout":"session_unresolved",...o?{lockPath:o}:{},elapsedMs:this.now()-t}}resolveLockPath(e){const s=/^agent:([^:]+):/.exec(e)?.[1]??this.defaultAgentId,t=join(this.stateDir,"agents",s,"sessions"),i=join(t,"sessions.json");if(!existsSync(i))return;let o;try{const s=JSON.parse(readFileSync(i,"utf8"));if(!s||"object"!=typeof s)return;const t=s[e];t&&"object"==typeof t&&(o=t)}catch(s){return void this.logger.debug?.(`[im] session index read deferred sessionKey=${e} error=${s instanceof Error?s.message:String(s)}`)}if(!o)return;const n="string"==typeof o.sessionFile?o.sessionFile.trim():"",r="string"==typeof o.sessionId?o.sessionId.trim():"",l=n?isAbsolute(n)?n:join(t,n):r?join(t,`${r}.jsonl`):"";return l&&existsSync(l)?`${l}.lock`:void 0}}
|
|
@@ -0,0 +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}from"vitest";import{OpenClawSessionWriteQuiescence}from"./openclaw-session-write-quiescence.js";describe("OpenClawSessionWriteQuiescence",()=>{const e=[],s=process.env.OPENCLAW_STATE_DIR,n=process.env.OPENCLAW_HOME;afterEach(()=>{for(const s of e.splice(0))rmSync(s,{recursive:!0,force:!0});void 0===s?delete process.env.OPENCLAW_STATE_DIR:process.env.OPENCLAW_STATE_DIR=s,void 0===n?delete process.env.OPENCLAW_HOME:process.env.OPENCLAW_HOME=n});const i=()=>{const s=mkdtempSync(join(tmpdir(),"beeos-quiescence-"));e.push(s);const n=join(s,"agents","main","sessions");mkdirSync(n,{recursive:!0});const i=join(n,"session-1.jsonl");return writeFileSync(i,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({"agent:main:im:conversation":{sessionId:"session-1",sessionFile:i}})),{home:s,lockPath:`${i}.lock`}};it("waits for a real JSONL lock to disappear before declaring quiescence",async()=>{const{home:e,lockPath:s}=i();writeFileSync(s,"331");let n=0;const o=new OpenClawSessionWriteQuiescence({},{stateDir:e,pollIntervalMs:10,quietConfirmationMs:20,now:()=>n,sleep:async e=>{n+=e,20===n&&unlinkSync(s)}});await expect(o.waitForQuiescence("agent:main:im:conversation",100)).resolves.toMatchObject({quiescent:!0,reason:"unlocked",lockPath:s})}),it("fails closed while a resolved lock remains owned",async()=>{const{home:e,lockPath:s}=i();writeFileSync(s,"331");let n=0;const o=new OpenClawSessionWriteQuiescence({},{stateDir:e,pollIntervalMs:10,now:()=>n,sleep:async e=>{n+=e}});await expect(o.waitForQuiescence("agent:main:im:conversation",30)).resolves.toMatchObject({quiescent:!1,reason:"timeout",lockPath:s})}),it("fails closed when the session index cannot be resolved",async()=>{const s=mkdtempSync(join(tmpdir(),"beeos-unresolved-state-"));e.push(s);let n=0;const i=new OpenClawSessionWriteQuiescence({},{stateDir:s,pollIntervalMs:10,now:()=>n,sleep:async e=>{n+=e}});await expect(i.waitForQuiescence("agent:main:missing",30)).resolves.toMatchObject({quiescent:!1,reason:"session_unresolved"})}),it("resolves the local agent directory from a multi-agent session key",async()=>{const s=mkdtempSync(join(tmpdir(),"beeos-quiescence-agent-"));e.push(s);const n=join(s,"agents","worker","sessions");mkdirSync(n,{recursive:!0});const i=join(n,"worker-session.jsonl");writeFileSync(i,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({"agent:worker:beeos-conversation:conv-1":{sessionId:"worker-session",sessionFile:i}}));const o=new OpenClawSessionWriteQuiescence({},{stateDir:s,agentId:"main",quietConfirmationMs:0});await expect(o.waitForQuiescence("agent:worker:beeos-conversation:conv-1",100)).resolves.toMatchObject({quiescent:!0,reason:"unlocked",lockPath:`${i}.lock`})}),it("prefers OpenClaw's authoritative OPENCLAW_STATE_DIR over OPENCLAW_HOME",async()=>{const s=mkdtempSync(join(tmpdir(),"beeos-state-dir-")),n=mkdtempSync(join(tmpdir(),"beeos-home-dir-"));e.push(s,n),process.env.OPENCLAW_STATE_DIR=s,process.env.OPENCLAW_HOME=n;const i=join(s,"agents","main","sessions");mkdirSync(i,{recursive:!0});const o=join(i,"state-session.jsonl");writeFileSync(o,""),writeFileSync(join(i,"sessions.json"),JSON.stringify({"agent:main:state":{sessionFile:o,sessionId:"state-session"}}));const t=new OpenClawSessionWriteQuiescence({},{quietConfirmationMs:0});await expect(t.waitForQuiescence("agent:main:state",100)).resolves.toMatchObject({quiescent:!0,lockPath:`${o}.lock`})})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";export function resolveOpenClawStateDir(o=process.env,e=homedir){const t=o.OPENCLAW_STATE_DIR?.trim();if(t)return resolveHomePath(t,e);const r=o.OPENCLAW_HOME?.trim()?resolveHomePath(o.OPENCLAW_HOME.trim(),e):e(),n=join(r,".openclaw"),i=join(r,".clawdbot");return existsSync(n)||!existsSync(i)?n:i}function resolveHomePath(o,e){return"~"===o?e():o.startsWith("~/")?join(e(),o.slice(2)):o}
|
|
@@ -0,0 +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,"\\$&");
|
|
@@ -0,0 +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 +1 @@
|
|
|
1
|
-
const RUN_KEY_SEPARATOR="\0";export const promptRunKey=(e,
|
|
1
|
+
const RUN_KEY_SEPARATOR="\0";export const promptRunKey=(e,n)=>`${e}\0${n}`;export class PromptRunStore{byRunKey=new Map;size(){return this.byRunKey.size}register(e,n){if(this.byRunKey.has(e)){try{n.abortController.abort("duplicate_inbound_message_id")}catch{}return!1}return n.phase="running",this.byRunKey.set(e,n),!0}attachRuntimeRunId(e,n){const t=this.byRunKey.get(e);return!(!t||!n)&&(t.runtimeRunId=n,!0)}runtimeRunIdForSessionKey(e){const n=Array.from(this.byRunKey.values()).filter(n=>n.sessionKey===e&&n.runtimeRunId).map(e=>e.runtimeRunId);return 1===n.length?n[0]:void 0}claimTerminal(e){const n=this.byRunKey.get(e);return!(!n||"running"!==n.phase)&&(n.phase="terminal_committing",!0)}abort(e,n){const t=this.byRunKey.get(e);if(!t)return{ok:!1,reason:"not_found"};if("terminal_committing"===t.phase)return{ok:!1,reason:"terminal_committing",requestId:t.requestId,channelId:t.channelId,...t.runtimeRunId?{runtimeRunId:t.runtimeRunId}:{},...t.sessionKey?{sessionKey:t.sessionKey}:{}};if(t.abortController.signal.aborted)return{ok:!1,reason:"already_aborted",requestId:t.requestId,channelId:t.channelId,...t.runtimeRunId?{runtimeRunId:t.runtimeRunId}:{},...t.sessionKey?{sessionKey:t.sessionKey}:{}};try{t.abortController.abort(n)}catch{}return{ok:!0,requestId:t.requestId,channelId:t.channelId,...t.runtimeRunId?{runtimeRunId:t.runtimeRunId}:{},...t.sessionKey?{sessionKey:t.sessionKey}:{}}}abortByInboundMessageId(e,n){if(!e)return{ok:!1,reason:"not_found"};const t=`\0${e}`,r=Array.from(this.byRunKey.keys()).filter(n=>n===e||n.endsWith(t));return 0===r.length?{ok:!1,reason:"not_found"}:r.length>1?{ok:!1,reason:"ambiguous"}:this.abort(r[0],n)}unregister(e){this.byRunKey.delete(e)}snapshot(){return Array.from(this.byRunKey.entries(),([e,n])=>[e,{...n}])}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach}from"vitest";import{PromptRunStore,promptRunKey}from"./prompt-run-store.js";const makeEntry=(e={})=>({requestId:"im_req_test_001",channelId:"ch_test_001",abortController:new AbortController,startedAt:Date.now(),...e});describe("PromptRunStore",()=>{let e;beforeEach(()=>{e=new PromptRunStore}),it("starts empty",()=>{expect(e.size()).toBe(0),expect(e.snapshot()).toEqual([])}),it("register adds an entry and reports size correctly",()=>{const t=makeEntry();expect(e.register("msg_001",t)).toBe(!0),expect(e.size()).toBe(1),expect(e.snapshot()).toEqual([["msg_001",t]])}),it("register rejects a duplicate inboundMessageId and aborts the duplicate's controller",()=>{const t=makeEntry({requestId:"req_1"}),r=makeEntry({requestId:"req_2"});expect(e.register("msg_dup",t)).toBe(!0),expect(e.register("msg_dup",r)).toBe(!1),expect(e.snapshot()).toEqual([["msg_dup",t]]),expect(r.abortController.signal.aborted).toBe(!0),expect(r.abortController.signal.reason).toBe("duplicate_inbound_message_id"),expect(t.abortController.signal.aborted).toBe(!1)}),it("abort triggers the AbortSignal and returns ok=true with metadata",()=>{const t=makeEntry({requestId:"req_abort_ok",channelId:"ch_abort_ok"});e.register("msg_abort_ok",t);const r=e.abort("msg_abort_ok","chat_cancel:user_request");expect(r).toEqual({ok:!0,requestId:"req_abort_ok",channelId:"ch_abort_ok"}),expect(t.abortController.signal.aborted).toBe(!0),expect(t.abortController.signal.reason).toBe("chat_cancel:user_request")}),it("abort on unknown inboundMessageId returns not_found and is a no-op",()=>{const t=e.abort("msg_unknown","whatever");expect(t).toEqual({ok:!1,reason:"not_found"})}),it("abortByInboundMessageId aborts the only matching composite run key",()=>{const t=makeEntry({requestId:"req_fallback",channelId:"ch_fallback"});e.register(promptRunKey("ch_fallback","msg_fallback"),t);const r=e.abortByInboundMessageId("msg_fallback","chat_cancel:legacy");expect(r).toEqual({ok:!0,requestId:"req_fallback",channelId:"ch_fallback"}),expect(t.abortController.signal.aborted).toBe(!0),expect(t.abortController.signal.reason).toBe("chat_cancel:legacy")}),it("abortByInboundMessageId refuses ambiguous matches",()=>{const t=makeEntry({requestId:"req_a",channelId:"ch_a"}),r=makeEntry({requestId:"req_b",channelId:"ch_b"});e.register(promptRunKey("ch_a","msg_same"),t),e.register(promptRunKey("ch_b","msg_same"),r);const o=e.abortByInboundMessageId("msg_same","chat_cancel:legacy");expect(o).toEqual({ok:!1,reason:"ambiguous"}),expect(t.abortController.signal.aborted).toBe(!1),expect(r.abortController.signal.aborted).toBe(!1)}),it("abort on an already-aborted entry returns already_aborted",()=>{const t=makeEntry({requestId:"req_2x"});t.abortController.abort("first_call"),e.register("msg_2x",t);const r=e.abort("msg_2x","second_call");expect(r).toEqual({ok:!1,reason:"already_aborted",requestId:"req_2x",channelId:t.channelId}),expect(t.abortController.signal.reason).toBe("first_call")}),it("unregister removes the entry and is safe to call twice",()=>{const t=makeEntry();e.register("msg_drop",t),expect(e.size()).toBe(1),e.unregister("msg_drop"),expect(e.size()).toBe(0),e.unregister("msg_drop"),expect(e.size()).toBe(0)}),it("unregister of unknown id is a silent no-op",()=>{expect(()=>e.unregister("msg_ghost")).not.toThrow(),expect(e.size()).toBe(0)}),it("supports multiple concurrent entries with independent abort",()=>{const t=makeEntry({requestId:"req_a",channelId:"ch_a"}),r=makeEntry({requestId:"req_b",channelId:"ch_b"}),o=makeEntry({requestId:"req_c",channelId:"ch_c"});e.register("msg_a",t),e.register("msg_b",r),e.register("msg_c",o),expect(e.size()).toBe(3),expect(e.abort("msg_b","cancel_b").ok).toBe(!0),expect(t.abortController.signal.aborted).toBe(!1),expect(r.abortController.signal.aborted).toBe(!0),expect(o.abortController.signal.aborted).toBe(!1),e.unregister("msg_b"),expect(e.size()).toBe(2),expect(e.snapshot().map(([e])=>e).sort()).toEqual(["msg_a","msg_c"])}),it("snapshot returns a defensive array copy (mutation does not affect store)",()=>{e.register("msg_snap",makeEntry());e.snapshot().length=0,expect(e.size()).toBe(1)})});
|
|
1
|
+
import{describe,it,expect,beforeEach}from"vitest";import{PromptRunStore,promptRunKey}from"./prompt-run-store.js";const makeEntry=(e={})=>({requestId:"im_req_test_001",channelId:"ch_test_001",abortController:new AbortController,startedAt:Date.now(),...e});describe("PromptRunStore",()=>{let e;beforeEach(()=>{e=new PromptRunStore}),it("starts empty",()=>{expect(e.size()).toBe(0),expect(e.snapshot()).toEqual([])}),it("register adds an entry and reports size correctly",()=>{const t=makeEntry();expect(e.register("msg_001",t)).toBe(!0),expect(e.size()).toBe(1),expect(e.snapshot()).toEqual([["msg_001",t]])}),it("register rejects a duplicate inboundMessageId and aborts the duplicate's controller",()=>{const t=makeEntry({requestId:"req_1"}),r=makeEntry({requestId:"req_2"});expect(e.register("msg_dup",t)).toBe(!0),expect(e.register("msg_dup",r)).toBe(!1),expect(e.snapshot()).toEqual([["msg_dup",t]]),expect(r.abortController.signal.aborted).toBe(!0),expect(r.abortController.signal.reason).toBe("duplicate_inbound_message_id"),expect(t.abortController.signal.aborted).toBe(!1)}),it("abort triggers the AbortSignal and returns ok=true with metadata",()=>{const t=makeEntry({requestId:"req_abort_ok",channelId:"ch_abort_ok"});e.register("msg_abort_ok",t);const r=e.abort("msg_abort_ok","chat_cancel:user_request");expect(r).toEqual({ok:!0,requestId:"req_abort_ok",channelId:"ch_abort_ok"}),expect(t.abortController.signal.aborted).toBe(!0),expect(t.abortController.signal.reason).toBe("chat_cancel:user_request")}),it("rejects cancellation after the terminal writer wins the CAS",()=>{const t=makeEntry({requestId:"req_terminal"});e.register("msg_terminal",t),expect(e.claimTerminal("msg_terminal")).toBe(!0),expect(e.claimTerminal("msg_terminal")).toBe(!1),expect(e.abort("msg_terminal","chat_cancel:too_late")).toMatchObject({ok:!1,reason:"terminal_committing",requestId:"req_terminal"}),expect(t.abortController.signal.aborted).toBe(!1)}),it("binds the OpenClaw run id so cancellation can terminate owned processes",()=>{const t=makeEntry({sessionKey:"agent:worker:conversation:one"});e.register("msg_runtime",t),expect(e.attachRuntimeRunId("msg_runtime","run_runtime_1")).toBe(!0),expect(e.abort("msg_runtime","chat_cancel:user_request")).toMatchObject({ok:!0,runtimeRunId:"run_runtime_1",sessionKey:"agent:worker:conversation:one"}),expect(e.attachRuntimeRunId("missing","run_runtime_2")).toBe(!1)}),it("abort on unknown inboundMessageId returns not_found and is a no-op",()=>{const t=e.abort("msg_unknown","whatever");expect(t).toEqual({ok:!1,reason:"not_found"})}),it("abortByInboundMessageId aborts the only matching composite run key",()=>{const t=makeEntry({requestId:"req_fallback",channelId:"ch_fallback"});e.register(promptRunKey("ch_fallback","msg_fallback"),t);const r=e.abortByInboundMessageId("msg_fallback","chat_cancel:legacy");expect(r).toEqual({ok:!0,requestId:"req_fallback",channelId:"ch_fallback"}),expect(t.abortController.signal.aborted).toBe(!0),expect(t.abortController.signal.reason).toBe("chat_cancel:legacy")}),it("abortByInboundMessageId refuses ambiguous matches",()=>{const t=makeEntry({requestId:"req_a",channelId:"ch_a"}),r=makeEntry({requestId:"req_b",channelId:"ch_b"});e.register(promptRunKey("ch_a","msg_same"),t),e.register(promptRunKey("ch_b","msg_same"),r);const o=e.abortByInboundMessageId("msg_same","chat_cancel:legacy");expect(o).toEqual({ok:!1,reason:"ambiguous"}),expect(t.abortController.signal.aborted).toBe(!1),expect(r.abortController.signal.aborted).toBe(!1)}),it("abort on an already-aborted entry returns already_aborted",()=>{const t=makeEntry({requestId:"req_2x"});t.abortController.abort("first_call"),e.register("msg_2x",t);const r=e.abort("msg_2x","second_call");expect(r).toEqual({ok:!1,reason:"already_aborted",requestId:"req_2x",channelId:t.channelId}),expect(t.abortController.signal.reason).toBe("first_call")}),it("unregister removes the entry and is safe to call twice",()=>{const t=makeEntry();e.register("msg_drop",t),expect(e.size()).toBe(1),e.unregister("msg_drop"),expect(e.size()).toBe(0),e.unregister("msg_drop"),expect(e.size()).toBe(0)}),it("unregister of unknown id is a silent no-op",()=>{expect(()=>e.unregister("msg_ghost")).not.toThrow(),expect(e.size()).toBe(0)}),it("supports multiple concurrent entries with independent abort",()=>{const t=makeEntry({requestId:"req_a",channelId:"ch_a"}),r=makeEntry({requestId:"req_b",channelId:"ch_b"}),o=makeEntry({requestId:"req_c",channelId:"ch_c"});e.register("msg_a",t),e.register("msg_b",r),e.register("msg_c",o),expect(e.size()).toBe(3),expect(e.abort("msg_b","cancel_b").ok).toBe(!0),expect(t.abortController.signal.aborted).toBe(!1),expect(r.abortController.signal.aborted).toBe(!0),expect(o.abortController.signal.aborted).toBe(!1),e.unregister("msg_b"),expect(e.size()).toBe(2),expect(e.snapshot().map(([e])=>e).sort()).toEqual(["msg_a","msg_c"])}),it("snapshot returns a defensive array copy (mutation does not affect store)",()=>{e.register("msg_snap",makeEntry());const t=e.snapshot();t[0][1].phase="terminal_committing",t.length=0,expect(e.size()).toBe(1),expect(e.abort("msg_snap","still_running").ok).toBe(!0)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const DEFAULT_ORIGINATING_CHANNEL="beeos-claw",DEFAULT_ORIGINATING_TARGET="main",DEFAULT_ORIGINATING_ACCOUNT_ID="main",isRecord=o=>"object"==typeof o&&null!==o&&!Array.isArray(o),assertKnownReplyProtocol=o=>{if("acp"!==o&&"im"!==o)throw new Error(`unsupported reply route protocol=${o}`)};export const hasRuntimeReplyApi=o=>"function"==typeof o?.channel?.reply?.dispatchReplyFromConfig;export const buildFinalizedMsgContext=(o,e)=>{assertKnownReplyProtocol(e.protocol);const r=e.originatingChannel??"beeos-claw",t=e.originatingTo??"main",n=e.accountId??"main";return{Body:o.message,BodyForAgent:o.agentMessage,From:`${r}:${t}`,To:t,SessionKey:e.sessionId,Surface:r,Provider:r,OriginatingChannel:r,OriginatingTo:t,AccountId:n,CommandAuthorized:!0}};export const resolveReplySinkForRoute=(o,e)=>{assertKnownReplyProtocol(o.protocol);const r=e[o.protocol];if(!r)throw new Error(`runtime reply sink unavailable protocol=${o.protocol} requestId=${o.requestId}`);return r};const resolveRuntimeDispatchConfig=(o,e)=>{try{const e=o.config?.loadConfig?.();if(isRecord(e))return e}catch{}return e};export const dispatchRuntimeReplyWithRoute=async o=>{const e=o.runtime.channel?.reply?.dispatchReplyFromConfig;if("function"!=typeof e)throw new Error(`runtime reply API unavailable protocol=${o.replyRoute.protocol} requestId=${o.replyRoute.requestId}`);const r=resolveReplySinkForRoute(o.replyRoute,o.replySinks)
|
|
1
|
+
const DEFAULT_ORIGINATING_CHANNEL="beeos-claw",DEFAULT_ORIGINATING_TARGET="main",DEFAULT_ORIGINATING_ACCOUNT_ID="main",isRecord=o=>"object"==typeof o&&null!==o&&!Array.isArray(o),assertKnownReplyProtocol=o=>{if("acp"!==o&&"im"!==o)throw new Error(`unsupported reply route protocol=${o}`)};export const hasRuntimeReplyApi=o=>"function"==typeof o?.channel?.reply?.dispatchReplyFromConfig;export const buildFinalizedMsgContext=(o,e)=>{assertKnownReplyProtocol(e.protocol);const r=e.originatingChannel??"beeos-claw",t=e.originatingTo??"main",n=e.accountId??"main";return{Body:o.message,BodyForAgent:o.agentMessage,From:`${r}:${t}`,To:t,SessionKey:e.sessionId,Surface:r,Provider:r,OriginatingChannel:r,OriginatingTo:t,AccountId:n,CommandAuthorized:!0}};export const resolveReplySinkForRoute=(o,e)=>{assertKnownReplyProtocol(o.protocol);const r=e[o.protocol];if(!r)throw new Error(`runtime reply sink unavailable protocol=${o.protocol} requestId=${o.requestId}`);return r};const resolveRuntimeDispatchConfig=(o,e)=>{try{const e=o.config?.loadConfig?.();if(isRecord(e))return e}catch{}return e};export const dispatchRuntimeReplyWithRoute=async o=>{const e=o.runtime.channel?.reply?.dispatchReplyFromConfig;if("function"!=typeof e)throw new Error(`runtime reply API unavailable protocol=${o.replyRoute.protocol} requestId=${o.replyRoute.requestId}`);const r=resolveReplySinkForRoute(o.replyRoute,o.replySinks),t=e({ctx:buildFinalizedMsgContext(o.preparedPrompt,o.replyRoute),cfg:resolveRuntimeDispatchConfig(o.runtime,o.pluginConfig),dispatcher:r.dispatcher,replyOptions:r.replyOptions});return o.onDispatchInvoked?.(),t};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{hasRuntimeReplyApi,buildFinalizedMsgContext,resolveReplySinkForRoute,dispatchRuntimeReplyWithRoute}from"./reply-router.js";const noopSink=()=>({dispatcher:{sendToolResult:()=>!0,sendBlockReply:()=>!0,sendFinalReply:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:e=>{}},replyOptions:void 0}),okResult={counts:{tool:0,block:0,final:1}},baseRoute=(e={})=>({protocol:"im",requestId:"req_test_001",sessionId:"agent:main:test-session",...e}),basePrompt=(e={})=>({message:"hello world",agentMessage:"hello world",...e});describe("hasRuntimeReplyApi",()=>{it("returns true when runtime exposes a callable dispatchReplyFromConfig",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}})).toBe(!0)}),it("returns false when runtime is undefined",()=>{expect(hasRuntimeReplyApi(void 0)).toBe(!1)}),it("returns false when channel.reply is missing",()=>{expect(hasRuntimeReplyApi({})).toBe(!1),expect(hasRuntimeReplyApi({channel:{}})).toBe(!1),expect(hasRuntimeReplyApi({channel:{reply:{}}})).toBe(!1)}),it("returns false when dispatchReplyFromConfig is present but not a function",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:"not-a-function"}}})).toBe(!1)})}),describe("buildFinalizedMsgContext",()=>{it("uses beeos-claw defaults when route doesn't override",()=>{const e=buildFinalizedMsgContext(basePrompt(),baseRoute());expect(e).toEqual({Body:"hello world",BodyForAgent:"hello world",From:"beeos-claw:main",To:"main",SessionKey:"agent:main:test-session",Surface:"beeos-claw",Provider:"beeos-claw",OriginatingChannel:"beeos-claw",OriginatingTo:"main",AccountId:"main",CommandAuthorized:!0})}),it("honors caller-supplied originatingChannel / originatingTo / accountId",()=>{const e=buildFinalizedMsgContext(basePrompt({message:"hi",agentMessage:"agent-hi"}),baseRoute({originatingChannel:"custom-channel",originatingTo:"thread-42",accountId:"user-7"}));expect(e.From).toBe("custom-channel:thread-42"),expect(e.To).toBe("thread-42"),expect(e.OriginatingChannel).toBe("custom-channel"),expect(e.OriginatingTo).toBe("thread-42"),expect(e.AccountId).toBe("user-7"),expect(e.BodyForAgent).toBe("agent-hi")}),it("accepts both 'acp' and 'im' protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"acp"}))).not.toThrow(),expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"im"}))).not.toThrow()}),it("rejects unsupported protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"websocket"}))).toThrow(/unsupported reply route protocol=websocket/)})}),describe("resolveReplySinkForRoute",()=>{it("returns the matching protocol sink",()=>{const e=noopSink(),t=noopSink(),o={acp:e,im:t};expect(resolveReplySinkForRoute(baseRoute({protocol:"acp"}),o)).toBe(e),expect(resolveReplySinkForRoute(baseRoute({protocol:"im"}),o)).toBe(t)}),it("throws when the matching sink is missing",()=>{const e={acp:noopSink()};expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"im",requestId:"req_missing"}),e)).toThrow(/runtime reply sink unavailable protocol=im requestId=req_missing/)}),it("rejects unsupported protocols",()=>{expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"xyz"}),{im:noopSink()})).toThrow(/unsupported reply route protocol=xyz/)})}),describe("dispatchRuntimeReplyWithRoute",()=>{it("dispatches via the matching sink and returns the runtime result",async()=>{const e=noopSink(),t=[],o={channel:{reply:{dispatchReplyFromConfig:async e=>(t.push(e),okResult)}}},n=await dispatchRuntimeReplyWithRoute({runtime:o,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt({message:"test prompt"}),replyRoute:baseRoute({protocol:"im",requestId:"req_ok"}),replySinks:{im:e}});expect(n).toBe(okResult),expect(t).toHaveLength(1);const i=t[0];expect(i.ctx.Body).toBe("test prompt"),expect(i.ctx.OriginatingChannel).toBe("beeos-claw"),expect(i.dispatcher).toBe(e.dispatcher),expect(i.cfg).toEqual({fromPluginConfig:!0})}),it("prefers runtime.config.loadConfig() over pluginConfig",async()=>{const e=[],t={config:{loadConfig:()=>({fromRuntime:!0})},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromRuntime:!0})}),it("falls back to pluginConfig when runtime.config.loadConfig throws",async()=>{const e=[],t={config:{loadConfig:()=>{throw new Error("load failed")}},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromPluginConfig:!0})}),it("calls onDispatchInvoked exactly once
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{hasRuntimeReplyApi,buildFinalizedMsgContext,resolveReplySinkForRoute,dispatchRuntimeReplyWithRoute}from"./reply-router.js";const noopSink=()=>({dispatcher:{sendToolResult:()=>!0,sendBlockReply:()=>!0,sendFinalReply:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:e=>{}},replyOptions:void 0}),okResult={counts:{tool:0,block:0,final:1}},baseRoute=(e={})=>({protocol:"im",requestId:"req_test_001",sessionId:"agent:main:test-session",...e}),basePrompt=(e={})=>({message:"hello world",agentMessage:"hello world",...e});describe("hasRuntimeReplyApi",()=>{it("returns true when runtime exposes a callable dispatchReplyFromConfig",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}})).toBe(!0)}),it("returns false when runtime is undefined",()=>{expect(hasRuntimeReplyApi(void 0)).toBe(!1)}),it("returns false when channel.reply is missing",()=>{expect(hasRuntimeReplyApi({})).toBe(!1),expect(hasRuntimeReplyApi({channel:{}})).toBe(!1),expect(hasRuntimeReplyApi({channel:{reply:{}}})).toBe(!1)}),it("returns false when dispatchReplyFromConfig is present but not a function",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:"not-a-function"}}})).toBe(!1)})}),describe("buildFinalizedMsgContext",()=>{it("uses beeos-claw defaults when route doesn't override",()=>{const e=buildFinalizedMsgContext(basePrompt(),baseRoute());expect(e).toEqual({Body:"hello world",BodyForAgent:"hello world",From:"beeos-claw:main",To:"main",SessionKey:"agent:main:test-session",Surface:"beeos-claw",Provider:"beeos-claw",OriginatingChannel:"beeos-claw",OriginatingTo:"main",AccountId:"main",CommandAuthorized:!0})}),it("honors caller-supplied originatingChannel / originatingTo / accountId",()=>{const e=buildFinalizedMsgContext(basePrompt({message:"hi",agentMessage:"agent-hi"}),baseRoute({originatingChannel:"custom-channel",originatingTo:"thread-42",accountId:"user-7"}));expect(e.From).toBe("custom-channel:thread-42"),expect(e.To).toBe("thread-42"),expect(e.OriginatingChannel).toBe("custom-channel"),expect(e.OriginatingTo).toBe("thread-42"),expect(e.AccountId).toBe("user-7"),expect(e.BodyForAgent).toBe("agent-hi")}),it("accepts both 'acp' and 'im' protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"acp"}))).not.toThrow(),expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"im"}))).not.toThrow()}),it("rejects unsupported protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"websocket"}))).toThrow(/unsupported reply route protocol=websocket/)})}),describe("resolveReplySinkForRoute",()=>{it("returns the matching protocol sink",()=>{const e=noopSink(),t=noopSink(),o={acp:e,im:t};expect(resolveReplySinkForRoute(baseRoute({protocol:"acp"}),o)).toBe(e),expect(resolveReplySinkForRoute(baseRoute({protocol:"im"}),o)).toBe(t)}),it("throws when the matching sink is missing",()=>{const e={acp:noopSink()};expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"im",requestId:"req_missing"}),e)).toThrow(/runtime reply sink unavailable protocol=im requestId=req_missing/)}),it("rejects unsupported protocols",()=>{expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"xyz"}),{im:noopSink()})).toThrow(/unsupported reply route protocol=xyz/)})}),describe("dispatchRuntimeReplyWithRoute",()=>{it("dispatches via the matching sink and returns the runtime result",async()=>{const e=noopSink(),t=[],o={channel:{reply:{dispatchReplyFromConfig:async e=>(t.push(e),okResult)}}},n=await dispatchRuntimeReplyWithRoute({runtime:o,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt({message:"test prompt"}),replyRoute:baseRoute({protocol:"im",requestId:"req_ok"}),replySinks:{im:e}});expect(n).toBe(okResult),expect(t).toHaveLength(1);const i=t[0];expect(i.ctx.Body).toBe("test prompt"),expect(i.ctx.OriginatingChannel).toBe("beeos-claw"),expect(i.dispatcher).toBe(e.dispatcher),expect(i.cfg).toEqual({fromPluginConfig:!0})}),it("prefers runtime.config.loadConfig() over pluginConfig",async()=>{const e=[],t={config:{loadConfig:()=>({fromRuntime:!0})},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromRuntime:!0})}),it("falls back to pluginConfig when runtime.config.loadConfig throws",async()=>{const e=[],t={config:{loadConfig:()=>{throw new Error("load failed")}},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromPluginConfig:!0})}),it("calls onDispatchInvoked exactly once after runtime accepts the invocation",async()=>{const e=vi.fn();let t=!1;const o={channel:{reply:{dispatchReplyFromConfig:vi.fn(async()=>(expect(e).not.toHaveBeenCalled(),t=!0,okResult))}}};await dispatchRuntimeReplyWithRoute({runtime:o,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()},onDispatchInvoked:e}),expect(e).toHaveBeenCalledTimes(1),expect(t).toBe(!0)}),it("does not report dispatch invocation when the runtime throws synchronously",async()=>{const e=vi.fn(),t={channel:{reply:{dispatchReplyFromConfig:()=>{throw new Error("runtime rejected before start")}}}};await expect(dispatchRuntimeReplyWithRoute({runtime:t,preparedPrompt:basePrompt(),replyRoute:baseRoute({requestId:"req_sync_failure"}),replySinks:{im:noopSink()},onDispatchInvoked:e})).rejects.toThrow("runtime rejected before start"),expect(e).not.toHaveBeenCalled()}),it("throws when runtime reply API is missing",async()=>{await expect(dispatchRuntimeReplyWithRoute({runtime:{},preparedPrompt:basePrompt(),replyRoute:baseRoute({requestId:"req_no_api"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply API unavailable protocol=im requestId=req_no_api/)}),it("propagates runtime dispatch errors verbatim",async()=>{const e=new Error("LLM exploded"),t={channel:{reply:{dispatchReplyFromConfig:async()=>{throw e}}}};await expect(dispatchRuntimeReplyWithRoute({runtime:t,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}})).rejects.toBe(e)}),it("throws when route.protocol has no matching sink",async()=>{const e={channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}};await expect(dispatchRuntimeReplyWithRoute({runtime:e,preparedPrompt:basePrompt(),replyRoute:baseRoute({protocol:"acp",requestId:"req_no_sink"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply sink unavailable protocol=acp requestId=req_no_sink/)})});
|
|
@@ -0,0 +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:[]}}
|
|
@@ -0,0 +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})])})});
|
|
@@ -0,0 +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)}}}
|
|
@@ -0,0 +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"}])})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class SessionWriteGateReleaseCoordinator{gate;quiescence;logger;inFlight=new Map;retainedTokens=new Map;stopped=!1;observationTimeoutMs;retryDelayMs;sleep;constructor(e,s,t,i={}){this.gate=e,this.quiescence=s,this.logger=t,this.observationTimeoutMs=i.observationTimeoutMs??13e4,this.retryDelayMs=i.retryDelayMs??1e3,this.sleep=i.sleep??(e=>new Promise(s=>{const t=setTimeout(s,e);t.unref?.()}))}start(){this.stopped=!1}stop(){this.stopped=!0;for(const e of this.retainedTokens.values())this.gate.release(e);this.retainedTokens.clear()}releaseImmediately(e){this.gate.release(e)}releaseAfterQuiescence(e){const s=this.inFlight.get(e.operationId);if(s)return s;this.retainedTokens.set(e.operationId,e);const t=this.reconcile(e).finally(()=>{this.inFlight.delete(e.operationId),this.retainedTokens.delete(e.operationId)});return this.inFlight.set(e.operationId,t),t}async reconcile(e){for(;;){if(this.stopped)return void this.gate.release(e);const s=await this.quiescence.waitForQuiescence(e.sessionKey,this.observationTimeoutMs);if(s.quiescent)return this.gate.release(e),void this.logger.debug?.(`[session-write-gate] released sessionKey=${e.sessionKey} reason=${s.reason} elapsed_ms=${s.elapsedMs}`);if(this.stopped)return void this.gate.release(e);this.logger.error?.(`[session-write-gate] release deferred sessionKey=${e.sessionKey} reason=${s.reason} elapsed_ms=${s.elapsedMs}`),await this.sleep(this.retryDelayMs)}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it}from"vitest";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./session-write-gate-release-coordinator.js";describe("SessionWriteGateReleaseCoordinator",()=>{it("keeps the gate closed after a failed proof and retries until quiescent",async()=>{const e=new SessionWriteGate,t=e.tryStartInteractive({sessionKey:"agent:main:one"});if(!t.ok)throw new Error("expected lease");let s=0;const o=new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>(s+=1,1===s?{quiescent:!1,reason:"timeout",elapsedMs:10}:{quiescent:!0,reason:"unlocked",elapsedMs:1})},{},{retryDelayMs:0,sleep:async()=>{}}).releaseAfterQuiescence(t.token);await Promise.resolve(),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),await o,expect(s).toBe(2),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!0)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{PromptRunStore}from"../runtime/prompt-run-store.js";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"../runtime/session-write-gate-release-coordinator.js";export const deferred=()=>{let e,t;return{promise:new Promise((s,r)=>{e=s,t=r}),resolve:e,reject:t}};export class TestMessageStream{body="";isTerminated=!1;terminal;async opened(){return{id:"test-envelope",state:"streaming"}}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(){}replacePart(){}appendThinking(){}appendToolUse(){}appendToolResult(){}async finalize(e){return this.terminal={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"completed"}}async fail(e){return this.terminal={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"failed"}}async cancel(e){return this.terminal={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"test-envelope",state:"cancelled"}}}export class TestMessageClient{streams=[];startStreamCalls=[];messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,replyTo:e.replyTo,type:e.type});const t=new TestMessageStream;return this.streams.push(t),t}};asMessageClient(){return this}}export const createRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions;if(!s?.abortSignal)throw new Error("test runtime expected an abort signal");return e({invocation:t,dispatcher:t.dispatcher,replyOptions:s,abortSignal:s.abortSignal})}}}});const createLogger=()=>({debug:()=>{},info:()=>{},warn:()=>{},error:()=>{}});export const createImDispatchFixture=e=>{const t=new TestMessageClient,s=[],r=new PromptRunStore,n=e?.sessionWriteGate??new SessionWriteGate,o=e?.sessionWriteReleaseCoordinator??new SessionWriteGateReleaseCoordinator(n,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{client:t,events:s,promptRunStore:r,args:(e,a={})=>({runtime:e,client:t.asMessageClient(),logger:createLogger(),promptRunStore:r,sessionWriteGate:n,sessionWriteReleaseCoordinator:o,origin:"im",onLifecycleEvent:e=>s.push(e),prompt:{channelId:"channel-test",inboundMessageId:"message-test",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"test prompt"}},...a})}};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.53",
|
|
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>",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/session file locked/i,/\.jsonl\.lock\b/i,/OPENCLAW_SESSION_WRITE_LOCK/i],ABORT_PATTERNS=[/\brequest was aborted\b/i,/\bthis operation was aborted\b/i,/\baborterror\b/i,/\baborted\b/i],AGENT_LOST_PATTERNS=[/\bgateway disconnected\b/i,/\bagent lost\b/i,/\bno live owner\b/i,/\bstale live stream\b/i,/\bbridge_not_ready\b/i,/\bgateway not connected\b/i],TIMEOUT_PATTERNS=[/\bprompt timed out\b/i,/\btimeout\b/i,/\btimed out\b/i,/\bdeadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase();if(matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS))return{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)};if(matchesAny(t,ABORT_PATTERNS))return{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r};if(matchesAny(t,AGENT_LOST_PATTERNS))return{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r};if(matchesAny(t,TIMEOUT_PATTERNS))return{kind:"interrupted",stopReason:"timeout",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r};return{kind:"error",stopReason:"error",publicMessage:formatFriendlyError(t)||"The agent reported an error.",errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|