import { CancellationScope, ChildWorkflowCancellationType, condition, continueAsNew, defineSignal, executeChild, isCancellation, patched, proxyActivities, setHandler, uuid4, workflowInfo } from "@temporalio/workflow"; import { buildCodexAppServerTurnWorkflowId, DOT_GOAL_SIGNAL, ROOM_AGENT_ENQUEUE_SIGNAL, ROOM_AGENT_STOP_SIGNAL, isSafeErrorCode, roomAgentSupervisorInputSchema, roomAgentSupervisorQueueItemSchema, roomAgentSupervisorStopSignalSchema, type DummyTurnInput, type DummyTurnResult, type RoomAgentSupervisorInput, type RoomAgentSupervisorQueueItem, type TurnWorkflowResult } from "@space/contracts"; import type * as activities from "./activities.js"; import { ROOM_AGENT_TURN_ACTIVITY_TIMEOUT, ROOM_AGENT_TURN_ACTIVITY_MAX_ATTEMPTS, ROOM_AGENT_TURN_HEARTBEAT_TIMEOUT, enqueueRoomAgentMission, roomAgentMissionHasContinuation, roomAgentMissionCompletion, stopRoomAgentMissions } from "./room-supervisor-state.js"; import { isCliChatTurnProviderId, isNativeChatTurn, NATIVE_CHAT_TURN_ACTIVITY_TIMEOUT } from "./turn-runtime-policy.js"; import { runDotGoalLoop } from "./dot-goal-loop.js"; const { recordDummyTurnStarted, recordDummyTurnCompleted } = proxyActivities({ startToCloseTimeout: "1 minute", retry: { maximumAttempts: 3 } }); const roomAgentTurnActivities = proxyActivities({ startToCloseTimeout: ROOM_AGENT_TURN_ACTIVITY_TIMEOUT, heartbeatTimeout: ROOM_AGENT_TURN_HEARTBEAT_TIMEOUT, retry: { maximumAttempts: ROOM_AGENT_TURN_ACTIVITY_MAX_ATTEMPTS } }); const nativeChatTurnActivities = proxyActivities({ startToCloseTimeout: NATIVE_CHAT_TURN_ACTIVITY_TIMEOUT, heartbeatTimeout: ROOM_AGENT_TURN_HEARTBEAT_TIMEOUT, retry: { maximumAttempts: ROOM_AGENT_TURN_ACTIVITY_MAX_ATTEMPTS } }); const openCodeAgentTurnActivities = proxyActivities({ startToCloseTimeout: "40 minutes", heartbeatTimeout: ROOM_AGENT_TURN_HEARTBEAT_TIMEOUT, retry: { maximumAttempts: 1 } }); const cliChatTurnActivities = proxyActivities({ startToCloseTimeout: "30 minutes", heartbeatTimeout: ROOM_AGENT_TURN_HEARTBEAT_TIMEOUT, retry: { maximumAttempts: 1 } }); const { markRoomAgentMissionStarted, markRoomAgentMissionFinished, markRoomAgentMissionContinued } = proxyActivities({ startToCloseTimeout: "1 minute", retry: { maximumAttempts: 5 } }); const dotGoalActivities = proxyActivities({ startToCloseTimeout: "2 minutes", retry: { maximumAttempts: 20, initialInterval: "5 seconds", backoffCoefficient: 2, maximumInterval: "2 minutes" } }); export const dotGoalWakeSignal = defineSignal<[]>(DOT_GOAL_SIGNAL); /** * Space Dots Goal: start one Chat step at a time and wait for it, until the * API reports the Goal finished. State lives in PostgreSQL; this workflow only * sequences steps, so it survives worker restarts and closed browsers. */ export async function dotGoalWorkflow(input: { goalId: string }): Promise<{ goalStatus: string }> { let woken = false; setHandler(dotGoalWakeSignal, () => { woken = true; }); const result = await runDotGoalLoop(input.goalId, { step: (goalId) => dotGoalActivities.dotGoalStep(goalId), observe: (goalId, runId) => dotGoalActivities.dotGoalObserve(goalId, runId), // A wake-up that arrives during an activity is kept and ends the next wait at once. wait: async (timeout) => { await condition(() => woken, timeout); woken = false; }, shouldContinueAsNew: () => workflowInfo().continueAsNewSuggested || workflowInfo().historyLength > 10_000 }, 300); // A long Goal continues as a new run to bound workflow history. if (result.goalStatus === null) await continueAsNew({ goalId: input.goalId }); return { goalStatus: result.goalStatus ?? "UNKNOWN" }; } export const enqueueRoomAgentMissionSignal = defineSignal<[RoomAgentSupervisorQueueItem]>(ROOM_AGENT_ENQUEUE_SIGNAL); export const stopRoomAgentMissionSignal = defineSignal<[ { missionId: string | null; reason: string } ]>(ROOM_AGENT_STOP_SIGNAL); export async function dummyTurnWorkflow(input: DummyTurnInput): Promise { await recordDummyTurnStarted(input); return recordDummyTurnCompleted(input); } export async function codexAppServerTurnWorkflow(input: DummyTurnInput): Promise { if (input.providerId === "opencode") { return openCodeAgentTurnActivities.runOpenCodeAgentTurn(input); } if (isCliChatTurnProviderId(input.providerId)) { return cliChatTurnActivities.runCliAgentTurn(input); } return isNativeChatTurn(input) ? nativeChatTurnActivities.runCodexAppServerTurn(input) : roomAgentTurnActivities.runCodexAppServerTurn(input); } export async function roomAgentSupervisorWorkflow(rawInput: RoomAgentSupervisorInput): Promise { // Keep the old activity/boolean contract for histories that predate this patch. if (!patched("room-background-continuation-v1")) return legacyRoomAgentSupervisorWorkflow(rawInput); return continuingRoomAgentSupervisorWorkflow(rawInput); } async function legacyRoomAgentSupervisorWorkflow(rawInput: RoomAgentSupervisorInput): Promise { const input = roomAgentSupervisorInputSchema.parse(rawInput); let pending = [...input.pending]; let interrupted: Array<{ item: RoomAgentSupervisorQueueItem; reason: string }> = []; let activeMissionId: string | null = null; let activeScope: CancellationScope | null = null; let activeStopReason = "Stopped by operator."; let processedCount = input.processedCount; const monitoring = new Set(input.monitoringMissionIds); let monitoringPolls = 0; setHandler(enqueueRoomAgentMissionSignal, (rawItem) => { const parsed = roomAgentSupervisorQueueItemSchema.safeParse(rawItem); if (!parsed.success || parsed.data.turn.roomId !== input.roomId) return; pending = enqueueRoomAgentMission(pending, activeMissionId, parsed.data); }); setHandler(stopRoomAgentMissionSignal, (rawSignal) => { const parsed = roomAgentSupervisorStopSignalSchema.safeParse(rawSignal); if (!parsed.success) return; const stopped = stopRoomAgentMissions(pending, activeMissionId, parsed.data); pending = stopped.pending; interrupted = [ ...interrupted, ...stopped.interrupted.map((item) => ({ item, reason: parsed.data.reason })) ]; if (stopped.shouldCancelActive) { activeStopReason = parsed.data.reason; activeScope?.cancel(); } }); for (;;) { if (monitoring.size && !pending.length && !interrupted.length) { await condition(() => pending.length > 0 || interrupted.length > 0, "1 second"); if (!pending.length && !interrupted.length) { for (const missionId of monitoring) { const finished = await roomAgentTurnActivities.settleRoomAgentBackgroundMission({ roomId: input.roomId, missionId }); if (finished) monitoring.delete(missionId); } if (++monitoringPolls >= 250) return continueAsNew({ ...input, pending, processedCount, monitoringMissionIds: [...monitoring] }); continue; } } else await condition(() => interrupted.length > 0 || pending.length > 0); const stoppedBeforeStart = interrupted.shift(); if (stoppedBeforeStart) { await markRoomAgentMissionFinished({ missionId: stoppedBeforeStart.item.missionId, roomId: stoppedBeforeStart.item.turn.roomId, status: "INTERRUPTED", statusReason: stoppedBeforeStart.reason }); processedCount += 1; } else { const item = pending.shift()!; activeMissionId = item.missionId; activeStopReason = "Stopped by operator."; activeScope = new CancellationScope({ cancellable: true }); await markRoomAgentMissionStarted({ missionId: item.missionId, roomId: item.turn.roomId, paneId: item.turn.paneId }); try { const result = await activeScope.run(() => executeChild(codexAppServerTurnWorkflow, { args: [item.turn], workflowId: buildCodexAppServerTurnWorkflowId(item.turn), cancellationType: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED }) ); const completion = roomAgentMissionCompletion(result); if (result.roomAgentOutcome?.status === "VERIFIED" && result.roomAgentOutcome.pendingActionIds?.length) { monitoring.add(item.missionId); await markRoomAgentMissionContinued({ missionId: item.missionId, roomId: item.turn.roomId, statusReason: "Pane work is running independently. Room Agent is ready for further commands." }); } else if (monitoring.has(item.missionId) || (completion.status === "COMPLETED" && roomAgentMissionHasContinuation(pending, item.missionId))) { await markRoomAgentMissionContinued({ missionId: item.missionId, roomId: item.turn.roomId, statusReason: "The current turn completed; continuing the same goal with the next operator follow-up." }); } else { await markRoomAgentMissionFinished({ missionId: item.missionId, roomId: item.turn.roomId, status: completion.status, statusReason: completion.statusReason }); } } catch (error) { await CancellationScope.nonCancellable(() => markRoomAgentMissionFinished({ missionId: item.missionId, roomId: item.turn.roomId, status: isCancellation(error) ? "INTERRUPTED" : "FAILED", statusReason: isCancellation(error) ? activeStopReason : "Room agent child workflow failed.", ...(isCancellation(error) ? {} : { errorCode: supervisorFailureCode(error) }) }) ); } finally { activeMissionId = null; activeScope = null; processedCount += 1; } } if (processedCount >= 50) { await continueAsNew({ roomId: input.roomId, pending, monitoringMissionIds: [...monitoring], interrupted: [], processedCount: 0 }); } } } const supervisorActivities = proxyActivities({ startToCloseTimeout: "1 minute", retry: { maximumAttempts: 5 } }); /** Upper bound of each list in roomAgentSupervisorInputSchema. */ const ROOM_AGENT_SUPERVISOR_CARRY_LIMIT = 100; async function continuingRoomAgentSupervisorWorkflow(rawInput: RoomAgentSupervisorInput): Promise { const input = roomAgentSupervisorInputSchema.parse(rawInput); let pending = [...input.pending]; let interrupted = [...input.interrupted]; const monitoring = new Set(input.monitoringMissionIds); let active: RoomAgentSupervisorQueueItem | null = null; let scope: CancellationScope | null = null; let stopReason = "Stopped by operator."; let signalRevision = 0; let processedCount = input.processedCount; let polls = 0; setHandler(enqueueRoomAgentMissionSignal, (rawItem) => { const parsed = roomAgentSupervisorQueueItemSchema.safeParse(rawItem); if (!parsed.success || parsed.data.turn.roomId !== input.roomId) return; const item = parsed.data; if (active?.turn.agentRunId === item.turn.agentRunId || pending.some((p) => p.turn.agentRunId === item.turn.agentRunId)) return; signalRevision++; // An operator follow-up wins over speculative automatic work. if (!item.turn.roomAgentContinuation) { const stale = pending.filter((p) => p.missionId === item.missionId && p.turn.roomAgentContinuation); interrupted.push(...stale.map((item) => ({ item, reason: "Superseded by a newer operator request." }))); pending = pending.filter((p) => !stale.includes(p)); if (active?.missionId === item.missionId && active.turn.roomAgentContinuation) { stopReason = "Superseded by a newer operator request."; scope?.cancel(); } } pending = enqueueRoomAgentMission(pending, active?.missionId ?? null, item); }); setHandler(stopRoomAgentMissionSignal, (rawSignal) => { const parsed = roomAgentSupervisorStopSignalSchema.safeParse(rawSignal); if (!parsed.success) return; signalRevision++; const stopped = stopRoomAgentMissions(pending, active?.missionId ?? null, parsed.data); pending = stopped.pending; interrupted.push(...stopped.interrupted.map((item) => ({ item, reason: parsed.data.reason }))); for (const missionId of monitoring) if (!parsed.data.missionId || parsed.data.missionId === missionId) monitoring.delete(missionId); if (stopped.shouldCancelActive) { stopReason = parsed.data.reason; scope?.cancel(); } }); for (;;) { if (interrupted.length) { const stopped = interrupted.shift()!; await supervisorActivities.discardRoomAgentSupervisorTurn(stopped); // A discarded automatic turn must never terminate its newer operator turn. await supervisorActivities.finishRoomAgentSupervisorTurn({ item: stopped.item, status: "INTERRUPTED", statusReason: stopped.reason }); processedCount++; } else if (pending.length) { const item = pending.shift()!; active = item; scope = new CancellationScope({ cancellable: true }); stopReason = "Stopped by operator."; let paused = false; try { await scope.run(async () => { const state = await supervisorActivities.checkRoomAgentSupervisorTurn(item); if (state === "PAUSED") { paused = true; return; } if (state === "SKIP") { await supervisorActivities.discardRoomAgentSupervisorTurn({ item, reason: "Turn is no longer active or was superseded." }); return; } const result = await executeChild(codexAppServerTurnWorkflow, { args: [item.turn], workflowId: buildCodexAppServerTurnWorkflowId(item.turn), cancellationType: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED }); const completion = roomAgentMissionCompletion(result); if (result.roomAgentOutcome?.status === "VERIFIED" && result.roomAgentOutcome.pendingActionIds?.length) { monitoring.add(item.missionId); } else if (completion.status === "COMPLETED" && monitoring.has(item.missionId) && !item.turn.roomAgentContinuation) { // A FIND/layout follow-up can finish while earlier pane tasks continue. } else if (!roomAgentMissionHasContinuation(pending, item.missionId)) { monitoring.delete(item.missionId); await supervisorActivities.finishRoomAgentSupervisorTurn({ item, ...completion }); } }); } catch (error) { await CancellationScope.nonCancellable(async () => { const reason = isCancellation(error) ? stopReason : "Room Agent supervisor turn failed."; const errorCode = isCancellation(error) ? undefined : supervisorFailureCode(error); await supervisorActivities.discardRoomAgentSupervisorTurn({ item, reason, errorCode }); if (!roomAgentMissionHasContinuation(pending, item.missionId)) { monitoring.delete(item.missionId); await supervisorActivities.finishRoomAgentSupervisorTurn({ item, status: errorCode ? "FAILED" : "INTERRUPTED", statusReason: reason, errorCode }); } }); } finally { active = null; scope = null; processedCount++; } if (paused) { pending.push(item); const revision = signalRevision; await condition(() => interrupted.length > 0 || signalRevision !== revision, "1 second"); } } else if (monitoring.size) { await condition(() => pending.length > 0 || interrupted.length > 0, "1 second"); if (!pending.length && !interrupted.length) for (const missionId of monitoring) { const revision = signalRevision; const result = await supervisorActivities.prepareRoomAgentBackgroundContinuation({ roomId: input.roomId, missionId, continuationId: uuid4() }); if (result.state === "DONE") monitoring.delete(missionId); if (result.state === "READY") { if (revision !== signalRevision || !monitoring.has(missionId)) { await supervisorActivities.discardRoomAgentSupervisorTurn({ item: result.item, reason: "Superseded while preparing the automatic continuation." }); } else pending = enqueueRoomAgentMission(pending, null, result.item); } if (pending.length || interrupted.length) break; } polls++; } else await condition(() => pending.length > 0 || interrupted.length > 0); // The next run parses its input with roomAgentSupervisorInputSchema, which // bounds each carried list. Keep draining until the state fits instead of // starting a run that would fail validation. Closed histories never replay, // so deferring here cannot change an already issued continueAsNew. const carriedStateFits = pending.length <= ROOM_AGENT_SUPERVISOR_CARRY_LIMIT && interrupted.length <= ROOM_AGENT_SUPERVISOR_CARRY_LIMIT && monitoring.size <= ROOM_AGENT_SUPERVISOR_CARRY_LIMIT; if ((processedCount >= 50 || polls >= 250) && carriedStateFits) return continueAsNew({ roomId: input.roomId, pending, interrupted, monitoringMissionIds: [...monitoring], processedCount: 0 }); } } export async function roomAgentActionWorkflow(input: Parameters[0]) { try { return await roomAgentTurnActivities.runRoomAgentBackgroundAction(input); } catch (error) { if (patched("room-background-action-failure-v1")) { await CancellationScope.nonCancellable(() => supervisorActivities.failRoomAgentBackgroundAction({ roomId: input.bridge.roomId, missionId: input.bridge.missionId, actionId: input.actionId ?? workflowInfo().workflowId.replace(/^space-room-action:/, ""), cancelled: isCancellation(error) })); } throw error; } } // The cause of a failed supervisor turn as a safe machine code, kept on the run and the mission: the deepest // ApplicationFailure type that is one, a Temporal timeout, or a generic code (external review 2026-10-16). function supervisorFailureCode(error: unknown): string { let code = "ROOM_AGENT_SUPERVISOR_FAILED"; let failure = error as { name?: unknown; type?: unknown; cause?: unknown } | undefined; for (let depth = 0; failure && depth < 8; depth++, failure = failure.cause as typeof failure) { if (failure.name === "TimeoutFailure") code = "ROOM_AGENT_SUPERVISOR_TIMEOUT"; else if (isSafeErrorCode(failure.type)) code = failure.type; } return code; }