mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(cloud-workers): accept worker live events for dispatch-owned run contexts
A worker-routed turn stalls with "worker live-event acknowledgement did not advance" because the gateway rejects the worker's first live event. The gateway claims a run context for the turn (session identity plus `isControlUiVisible`) before handing the turn to the remote worker. When the worker's live events arrive, live-events `claimRun` adopts that pre-existing context but its fresh-claim gate hardcoded `controlUiVisible = false` and rejected any existing context whose `isControlUiVisible` differed. A visible turn registers `isControlUiVisible: true`, so seq=1 was rejected as `invalid-event` and the stream never advanced. Visibility is a presentation preference the dispatch owns, not a run-identity attribute. Inherit the existing context's `isControlUiVisible` when adopting it and drop it from the identity gate. Session/agent/lifecycle identity must still match, so foreign runs are still rejected and local turns (no pre-existing turn context, or a matching one) are unaffected.
This commit is contained in:
@@ -476,6 +476,30 @@ describe("worker live events", () => {
|
||||
expect(deltas()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("adopts a visible dispatch-owned run context so worker live events stay visible", () => {
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
// A worker-routed turn keeps its dispatch-owned Control UI visibility. The
|
||||
// gateway claims the run context (isControlUiVisible: true for a visible
|
||||
// turn) before handing the turn to the remote worker; adopting live events
|
||||
// must inherit that visibility instead of forcing the run hidden.
|
||||
claimAgentRunContext(RUN, {
|
||||
...LOCAL,
|
||||
isControlUiVisible: true,
|
||||
lifecycleGeneration,
|
||||
});
|
||||
|
||||
ack(msg(1, "worker"));
|
||||
|
||||
expect(getAgentRunContext(RUN)).toMatchObject({
|
||||
...LOCAL,
|
||||
isControlUiVisible: true,
|
||||
lifecycleGeneration,
|
||||
projectSessionActive: true,
|
||||
});
|
||||
expect(deltas()).toEqual(["worker"]);
|
||||
expect(events[0]?.controlUiVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects pre-registered gateway run contexts with mismatched identity", () => {
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const mismatches: Array<{
|
||||
@@ -486,7 +510,6 @@ describe("worker live events", () => {
|
||||
{ name: "session-key", context: { ...LOCAL, sessionKey: `${KEY}-other` } },
|
||||
{ name: "agent-id", context: { ...LOCAL, agentId: "other" } },
|
||||
{ name: "lifecycle", context: { ...LOCAL, lifecycleGeneration: "other-lifecycle" } },
|
||||
{ name: "visibility", context: { ...LOCAL, isControlUiVisible: true } },
|
||||
];
|
||||
|
||||
for (const mismatch of mismatches) {
|
||||
@@ -502,14 +525,10 @@ describe("worker live events", () => {
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps run ids exclusive", () => {
|
||||
const local = "run-local-first";
|
||||
claimAgentRunContext(local, LOCAL);
|
||||
fail(msg(1, "blocked", 0, local), "invalid-event");
|
||||
clearAgentRunContext(local);
|
||||
|
||||
it("keeps a claimed run id exclusive against a later untracked local claim", () => {
|
||||
const worker = "run-worker-first";
|
||||
ack(msg(1, "worker", 0, worker));
|
||||
// A same-identity untracked claim cannot hijack a run live events already own.
|
||||
claimAgentRunContext(worker, LOCAL);
|
||||
clearAgentRunContext(worker);
|
||||
emitAgentEvent({
|
||||
|
||||
@@ -569,16 +569,19 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp
|
||||
}
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const existingContext = getAgentRunContext(runId);
|
||||
// Turn placement owns wider visibility; otherwise scope to this session.
|
||||
const controlUiVisible = false;
|
||||
// A dispatch-owned turn context (e.g. a worker-routed turn) owns the run's
|
||||
// Control UI visibility; adopt it so worker live events keep reaching the
|
||||
// visible clients that started the turn. Identity still has to match, so a
|
||||
// foreign run is rejected; only the visibility preference is inherited. With
|
||||
// no pre-existing turn context we scope live events to this session.
|
||||
const controlUiVisible = existingContext?.isControlUiVisible ?? false;
|
||||
const adoptExistingUnowned = existingContext !== undefined;
|
||||
if (
|
||||
existingContext &&
|
||||
(existingContext.sessionId !== window.sessionId ||
|
||||
existingContext.sessionKey !== window.target.sessionKey ||
|
||||
existingContext.agentId !== window.target.agentId ||
|
||||
existingContext.lifecycleGeneration !== lifecycleGeneration ||
|
||||
existingContext.isControlUiVisible !== controlUiVisible)
|
||||
existingContext.lifecycleGeneration !== lifecycleGeneration)
|
||||
) {
|
||||
return invalidEvent();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ import {
|
||||
} from "../gateway/worker-environments/store.js";
|
||||
import { createWorkerTranscriptCommitStore } from "../gateway/worker-environments/transcript-commit-store.js";
|
||||
import { createWorkerTranscriptCommitter } from "../gateway/worker-environments/transcript-commit.js";
|
||||
import { onAgentRuntimeEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
claimAgentRunContext,
|
||||
clearAgentRunContext,
|
||||
getAgentEventLifecycleGeneration,
|
||||
getAgentRunContext,
|
||||
onAgentRuntimeEvent,
|
||||
} from "../infra/agent-events.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import type { WorkerProvider, WorkerSshEndpoint } from "../plugins/types.js";
|
||||
import {
|
||||
@@ -920,6 +926,43 @@ describe("cloud worker milestone 2 fault injection", () => {
|
||||
expect(SessionManager.open(harness.sessionFile).getEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("advances a worker live stream whose run context is dispatch-owned and visible", async () => {
|
||||
const current = harness.createClients();
|
||||
clients.push(current);
|
||||
// A visible turn's run context is claimed by the gateway dispatch before the
|
||||
// turn hands off to the worker. The worker's first live event must adopt that
|
||||
// dispatch-owned context (seq advances from 1) and keep the run visible.
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
claimAgentRunContext(RUN_ID, {
|
||||
agentId: "main",
|
||||
sessionId: SESSION_ID,
|
||||
sessionKey: SESSION_KEY,
|
||||
isControlUiVisible: true,
|
||||
lifecycleGeneration,
|
||||
});
|
||||
try {
|
||||
await current.connection.start();
|
||||
|
||||
await expect(
|
||||
Promise.all(
|
||||
["one", "two"].map((delta) =>
|
||||
current.live.emit(RUN_ID, { kind: "assistant", payload: { text: delta, delta } }),
|
||||
),
|
||||
),
|
||||
).resolves.toHaveLength(2);
|
||||
|
||||
expect(harness.liveDeltas).toEqual(["one", "two"]);
|
||||
expect(
|
||||
harness
|
||||
.requestParams("worker.live-event")
|
||||
.map((request) => (request as WorkerLiveEventParams).seq),
|
||||
).toEqual([1, 2]);
|
||||
expect(getAgentRunContext(RUN_ID)?.isControlUiVisible).toBe(true);
|
||||
} finally {
|
||||
clearAgentRunContext(RUN_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles stop during an in-flight commit without retrying or spinning", async () => {
|
||||
const current = harness.createClients();
|
||||
clients.push(current);
|
||||
|
||||
Reference in New Issue
Block a user