From 05408f8dec17a3ea06ffdfe045b2f1148bd06aa9 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 14 Jul 2026 18:55:51 -0500 Subject: [PATCH 1/3] Deliver spawned-task settle outcomes back to the launching run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment-setup workflow's spawned verification task is the end-to- end test of the product pipeline (fresh provisioning, platform-executed setup commands, an agent working in the environment), but consuming its outcome depended on the setup agent's polling discipline: an idle parent never woke when the verification task settled, so the verdict was lost. Make the wait deterministic: - manage_tasks launch gains notifyOnSettle. On run-token standard launches it stamps sourceRunId with the launching run and persists notifySourceRunOnSettle on the spawned task payload. - finishRun now calls notifySourceRunOnSettle: when an opted-in run settles (completed / failed / canceled / idle), the platform injects a "Spawned task update" prompt into the launching run's sandbox — waking it if idle — with the settle status, environmentSetupState, and error. Delivery is at-most-once (guarded via the run result JSON), skips same-task resume chains and exited parents, uses a deployment-service- principal token so the parent's acting user is untouched, and never throws; delivery and failures are recorded as run lifecycle events. - The environment-setup skill launches verification with notifyOnSettle, treats the settle message as the primary completion signal with get_summary polling as fallback, and its verification prompt reads .roomote/setup-status.json before judging. - Re-lands the #340 ground truth reverted in #346: environmentSetupState in the task summary API and the Environment Setup line in get_summary. Co-Authored-By: Claude Opus 4.8 --- .../tasks/__tests__/getTaskSummary.test.ts | 2 + .../tasks/__tests__/launchTask.test.ts | 92 ++++++++ apps/api/src/handlers/tasks/getTaskSummary.ts | 1 + apps/api/src/handlers/tasks/helpers.ts | 2 + apps/api/src/handlers/tasks/launchTask.ts | 13 +- .../__tests__/task-summary.test.ts | 62 ++++++ .../src/mcp/roomote-mcp-server/index.ts | 7 + .../src/mcp/roomote-mcp-server/launch-task.ts | 2 + .../mcp/roomote-mcp-server/task-summary.ts | 24 +++ .../src/mcp/roomote-mcp-server/types.ts | 1 + .../__tests__/environmentSetupSkill.test.ts | 22 +- .../standard/environment-setup/SKILL.md | 9 +- .../task-runs/__tests__/finish-run.test.ts | 6 + .../notify-source-run-on-settle.test.ts | 203 ++++++++++++++++++ .../src/server/lib/task-runs/finish-run.ts | 7 + .../task-runs/notify-source-run-on-settle.ts | 193 +++++++++++++++++ packages/types/src/task-launch-api.ts | 8 + packages/types/src/task-runs.ts | 16 ++ 18 files changed, 663 insertions(+), 7 deletions(-) create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts create mode 100644 packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts diff --git a/apps/api/src/handlers/tasks/__tests__/getTaskSummary.test.ts b/apps/api/src/handlers/tasks/__tests__/getTaskSummary.test.ts index 539a9405..aac3234c 100644 --- a/apps/api/src/handlers/tasks/__tests__/getTaskSummary.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/getTaskSummary.test.ts @@ -114,6 +114,7 @@ describe('getTaskSummary', () => { status: 'failed', taskPhase: null, error: 'Sandbox startup timed out', + environmentSetupState: 'failed', payload: { environmentDefinitionId: 'env-123', }, @@ -135,6 +136,7 @@ describe('getTaskSummary', () => { id: 'task-1', taskRunStatus: 'failed', taskRunError: 'Sandbox startup timed out', + environmentSetupState: 'failed', }); }); diff --git a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts index fc2a9549..442abdfb 100644 --- a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts @@ -154,6 +154,98 @@ describe('launchTask', () => { expect(enqueuedTask.task.payload.sourceControlProvider).toBeUndefined(); }); + it('stamps the launching run and settle opt-in for run-token launches with notifyOnSettle', async () => { + mockEnqueueTask.mockResolvedValue({ id: 102, taskId: 'task-child' }); + + const runAuth = { + runId: 555, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + } as RunTokenContext; + + const app = createApp(runAuth); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Verify the environment', + environmentId: '6f1f3f0a-9f5e-4d2a-8f4e-1a2b3c4d5e6f', + notifyOnSettle: true, + }), + }), + ); + + expect(response.status).toBe(200); + const enqueuedTask = mockEnqueueTask.mock.calls[0]?.[0] as { + task: { + sourceRunId?: number; + payload: { notifySourceRunOnSettle?: boolean }; + }; + }; + expect(enqueuedTask.task.sourceRunId).toBe(555); + expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBe(true); + }); + + it('ignores notifyOnSettle for user-token launches', async () => { + mockEnqueueTask.mockResolvedValue({ id: 103, taskId: 'task-user' }); + + const app = createApp(authContext); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Investigate this', + notifyOnSettle: true, + }), + }), + ); + + expect(response.status).toBe(200); + const enqueuedTask = mockEnqueueTask.mock.calls[0]?.[0] as { + task: { + sourceRunId?: number; + payload: { notifySourceRunOnSettle?: boolean }; + }; + }; + expect(enqueuedTask.task.sourceRunId).toBeUndefined(); + expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBeUndefined(); + }); + + it('does not stamp the launching run for run-token launches without notifyOnSettle', async () => { + mockEnqueueTask.mockResolvedValue({ id: 104, taskId: 'task-plain-child' }); + + const runAuth = { + runId: 556, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + } as RunTokenContext; + + const app = createApp(runAuth); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Investigate this' }), + }), + ); + + expect(response.status).toBe(200); + const enqueuedTask = mockEnqueueTask.mock.calls[0]?.[0] as { + task: { + sourceRunId?: number; + payload: { notifySourceRunOnSettle?: boolean }; + }; + }; + expect(enqueuedTask.task.sourceRunId).toBeUndefined(); + expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBeUndefined(); + }); + it('rejects launches whose selected repositories span multiple providers', async () => { mockRepositoriesFindMany.mockResolvedValue([ { fullName: 'octo/github-repo', installationId: 1 }, diff --git a/apps/api/src/handlers/tasks/getTaskSummary.ts b/apps/api/src/handlers/tasks/getTaskSummary.ts index 835a4c2d..4a97c094 100644 --- a/apps/api/src/handlers/tasks/getTaskSummary.ts +++ b/apps/api/src/handlers/tasks/getTaskSummary.ts @@ -62,6 +62,7 @@ export async function getTaskSummary( taskRunStatus: latestRun?.status ?? null, taskPhase: latestRun?.taskPhase ?? null, taskRunError: latestRun?.error ?? null, + environmentSetupState: latestRun?.environmentSetupState ?? null, linkedEnvironmentId: linkedEnvironmentId ?? null, linkedEnvironmentName: linkedEnvironment?.name ?? null, }); diff --git a/apps/api/src/handlers/tasks/helpers.ts b/apps/api/src/handlers/tasks/helpers.ts index 49760f39..4df04338 100644 --- a/apps/api/src/handlers/tasks/helpers.ts +++ b/apps/api/src/handlers/tasks/helpers.ts @@ -30,6 +30,7 @@ interface LatestTaskRunSummary { status: string; taskPhase: string | null; error: string | null; + environmentSetupState: string | null; firstAssistantOutputAt: Date | null; payload: unknown; } @@ -54,6 +55,7 @@ export async function getLatestTaskRunsByTaskIds( status: taskRuns.status, taskPhase: taskRuns.taskPhase, error: taskRuns.error, + environmentSetupState: taskRuns.environmentSetupState, firstAssistantOutputAt: taskRuns.firstAssistantOutputAt, payload: taskRuns.payload, }) diff --git a/apps/api/src/handlers/tasks/launchTask.ts b/apps/api/src/handlers/tasks/launchTask.ts index 0a2386a3..6bd77438 100644 --- a/apps/api/src/handlers/tasks/launchTask.ts +++ b/apps/api/src/handlers/tasks/launchTask.ts @@ -312,11 +312,19 @@ export async function launchTask( userId: auth.userId, }); + // A settle notification needs a durable pointer back to the launching + // run, so the opt-in only takes effect on run-token launches. + const notifySourceRunOnSettle = + requestedType === 'standard' && + body.notifyOnSettle === true && + 'runId' in auth.authContext; + const taskBase = { harness: harnessSelection.harness ?? body.harness, computeProvider: body.computeProvider, requestedWorkKindDecision, - ...(requestedType === 'environment-definition' && + ...((requestedType === 'environment-definition' || + notifySourceRunOnSettle) && 'runId' in auth.authContext ? { sourceRunId: auth.authContext.runId } : {}), @@ -340,6 +348,9 @@ export async function launchTask( ...basePayload, bootstrap: requestedType === 'standard' ? body.bootstrap : undefined, + ...(notifySourceRunOnSettle + ? { notifySourceRunOnSettle: true } + : {}), }, }; diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-summary.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-summary.test.ts index 05c95fd1..5ab2cb48 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-summary.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-summary.test.ts @@ -24,6 +24,7 @@ describe('handleGetTaskSummary', () => { taskRunStatus: 'running', taskPhase: 'waiting_for_prompt', taskRunError: null, + environmentSetupState: null, linkedEnvironmentId: null, linkedEnvironmentName: null, }); @@ -57,6 +58,7 @@ describe('handleGetTaskSummary', () => { taskRunStatus: 'running', taskPhase: 'executing', taskRunError: null, + environmentSetupState: null, linkedEnvironmentId: 'env-123', linkedEnvironmentName: 'Onboarding Sandbox', }); @@ -81,6 +83,7 @@ describe('handleGetTaskSummary', () => { taskRunStatus: null, taskPhase: null, taskRunError: null, + environmentSetupState: null, linkedEnvironmentId: null, linkedEnvironmentName: null, }); @@ -102,6 +105,7 @@ describe('handleGetTaskSummary', () => { taskRunStatus: 'failed', taskPhase: null, taskRunError: 'Sandbox failed to boot worker process', + environmentSetupState: null, linkedEnvironmentId: null, linkedEnvironmentName: null, }); @@ -114,6 +118,64 @@ describe('handleGetTaskSummary', () => { ); }); + it('omits the environment setup line when no setup state is present', async () => { + vi.mocked(tasksApiClient.getTaskSummary).mockResolvedValueOnce({ + id: 'task-5', + title: 'No env setup', + mode: 'standard', + completed: false, + repositoryName: 'owner/repo', + harness: 'opencode-server', + createdAt: 1700000000, + taskRunStatus: 'running', + taskPhase: 'executing', + taskRunError: null, + environmentSetupState: null, + linkedEnvironmentId: null, + linkedEnvironmentName: null, + }); + + const result = await handleGetTaskSummary({ taskId: 'task-5' }, config); + + expect(result.content[0]?.text).not.toContain('Environment Setup:'); + }); + + it.each([ + ['running', 'Environment Setup: still running in the background'], + ['completed', 'Environment Setup: completed'], + [ + 'completed_with_warnings', + 'Environment Setup: completed with warnings (one or more setup commands failed', + ], + [ + 'failed', + 'Environment Setup: failed (workspace may be missing dependencies or services', + ], + ])( + 'surfaces the %s environment setup state in the summary', + async (state, expectedLine) => { + vi.mocked(tasksApiClient.getTaskSummary).mockResolvedValueOnce({ + id: 'task-6', + title: 'Env-backed task', + mode: 'standard', + completed: false, + repositoryName: 'owner/repo', + harness: 'opencode-server', + createdAt: 1700000000, + taskRunStatus: 'running', + taskPhase: 'executing', + taskRunError: null, + environmentSetupState: state, + linkedEnvironmentId: null, + linkedEnvironmentName: null, + }); + + const result = await handleGetTaskSummary({ taskId: 'task-6' }, config); + + expect(result.content[0]?.text).toContain(expectedLine); + }, + ); + it('returns error on failure', async () => { vi.mocked(tasksApiClient.getTaskSummary).mockRejectedValueOnce( new Error('Not found'), diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index b76a19df..faa68335 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -471,6 +471,12 @@ const manageTasksInputSchema = { 'Call "list_environments" immediately before launching and copy one of the returned environmentId values.', ), branch: z.string().optional().describe('Branch to use (for launch)'), + notifyOnSettle: z + .boolean() + .optional() + .describe( + 'For launch: when true, the platform sends a message into THIS task session when the launched task settles (completes, fails, is canceled, or goes idle), so you can wait for that notification instead of polling get_summary.', + ), } satisfies Record; roomoteMcpServer.registerTool( @@ -556,6 +562,7 @@ roomoteMcpServer.registerTool( prompt: params.prompt, branch: params.branch, environmentId, + notifyOnSettle: params.notifyOnSettle, }, config, ); diff --git a/apps/worker/src/mcp/roomote-mcp-server/launch-task.ts b/apps/worker/src/mcp/roomote-mcp-server/launch-task.ts index eee51984..049d1994 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/launch-task.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/launch-task.ts @@ -9,6 +9,7 @@ export async function handleLaunchTask( prompt: string; branch?: string; environmentId: string; + notifyOnSettle?: boolean; }, config: RoomoteConfig, ): Promise { @@ -22,6 +23,7 @@ export async function handleLaunchTask( ? undefined : params.environmentId, type: 'standard', + ...(params.notifyOnSettle ? { notifyOnSettle: true } : {}), }); if (!result.success) { diff --git a/apps/worker/src/mcp/roomote-mcp-server/task-summary.ts b/apps/worker/src/mcp/roomote-mcp-server/task-summary.ts index df844df9..fd427ac2 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/task-summary.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/task-summary.ts @@ -3,6 +3,29 @@ import { getHarnessLabel, getTaskStatusLabel } from './task-display.js'; import { textResult, catchError } from './tool-result.js'; import type { RoomoteConfig, ToolResult } from './types.js'; +/** + * Lifecycle of the task's environment setup (repository setup commands and + * Docker projects), which can keep running in the background after the agent + * has started. Surfaced so a caller monitoring another task — e.g. the + * environment-setup workflow watching its verification task — can tell + * whether that task's environment actually finished setting up, instead of + * inferring it from the task's prose. + */ +function getEnvironmentSetupLine(state: string | null): string | null { + switch (state) { + case 'running': + return 'Environment Setup: still running in the background (workspace dependencies and services may not be ready yet)'; + case 'completed': + return 'Environment Setup: completed'; + case 'completed_with_warnings': + return 'Environment Setup: completed with warnings (one or more setup commands failed; details in the workspace `.roomote/setup-status.json` and `.roomote/setup-logs/`)'; + case 'failed': + return 'Environment Setup: failed (workspace may be missing dependencies or services; details in the workspace `.roomote/setup-status.json` and `.roomote/setup-logs/`)'; + default: + return null; + } +} + export async function handleGetTaskSummary( params: { taskId: string }, config: RoomoteConfig, @@ -25,6 +48,7 @@ export async function handleGetTaskSummary( ? `Linked Environment ID: ${result.linkedEnvironmentId}` : null, result.taskRunError ? `Error: ${result.taskRunError}` : null, + getEnvironmentSetupLine(result.environmentSetupState ?? null), ].filter(Boolean); return textResult(lines.join('\n')); diff --git a/apps/worker/src/mcp/roomote-mcp-server/types.ts b/apps/worker/src/mcp/roomote-mcp-server/types.ts index c7e983fe..0d12465b 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/types.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/types.ts @@ -46,6 +46,7 @@ export interface TaskSummaryResponse { taskRunStatus: string | null; taskPhase: string | null; taskRunError: string | null; + environmentSetupState: string | null; linkedEnvironmentId: string | null; linkedEnvironmentName: string | null; } diff --git a/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts index 03c18e30..7bfca35e 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts @@ -168,10 +168,28 @@ describe('environment-setup guidance', () => { 'For that follow-up task launch, call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "list_environments"` first so you can confirm the created or updated environment appears as a current launch target and copy the exact returned `environmentId`.', ); expect(skillContent).toContain( - 'Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID', + 'Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID, `notifyOnSettle` set to `true`', ); expect(skillContent).toContain( - 'Immediately begin monitoring that verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId`.', + 'First read .roomote/setup-status.json in the workspace root: while its state is "running", environment setup commands are still executing in the background', + ); + expect(skillContent).toContain( + "If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from its log under .roomote/setup-logs/.", + ); + expect(skillContent).toContain( + 'the platform delivers a `Spawned task update` message into this session when the verification task settles', + ); + expect(skillContent).toContain( + 'Treat that message as the primary completion signal', + ); + expect(skillContent).toContain( + 'While waiting for that settle notification, periodically check the verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal.', + ); + expect(skillContent).toContain( + 'treat `failed` or `completed with warnings` as direct evidence that specific setup commands failed even when the verification task has not described the failure yet', + ); + expect(skillContent).toContain( + 'relaunch the verification task with `notifyOnSettle: true`, and wait for the new settle notification', ); expect(skillContent).toContain( 'Narrate concise, plain-language progress updates while the follow-up check runs', diff --git a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md index ec5d7686..5f8c2004 100644 --- a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md +++ b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md @@ -133,18 +133,19 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r 23. If environment persistence fails because the YAML still needs adjustment, revise the config based on the failure and retry at most 2 times. 24. After environment persistence succeeds, use the Roomote MCP task tools to launch one lightweight follow-up verification task against that environment before finishing this setup task. 25. For that follow-up task launch, call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "list_environments"` first so you can confirm the created or updated environment appears as a current launch target and copy the exact returned `environmentId`. -26. Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID, and a concrete prompt such as `Confirm that this environment is running correctly. Use localhost or the environment's initial URL to verify the expected service responds successfully, and confirm there are no obvious startup failures blocking basic use. Preparing the environment can take 5 minutes or more, so be patient before deciding startup is stuck. Report the exact step that fails plus any visible error messages or logs. If everything works, say that the environment looks ready.`. +26. Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID, `notifyOnSettle` set to `true`, and a concrete prompt such as `Confirm that this environment is running correctly. First read .roomote/setup-status.json in the workspace root: while its state is "running", environment setup commands are still executing in the background — wait for it to reach a terminal state (you will get an in-session notification when it settles) instead of concluding startup is stuck; setup can take 5 minutes or more. Once setup has settled, use localhost or the environment's initial URL to verify the expected service responds successfully and confirm there are no obvious startup failures blocking basic use. If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from its log under .roomote/setup-logs/. Report the exact step that fails plus any visible error messages or logs. If everything works, say that the environment looks ready.`. 27. Keep the returned `taskId` for internal monitoring only. Do not expose the spawned verification task link in the user-facing response. -28. Immediately begin monitoring that verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId`. Use that per-task summary as the source of truth for task state, including any surfaced startup or runtime failure details. +27a. Because the launch used `notifyOnSettle: true`, the platform delivers a `Spawned task update` message into this session when the verification task settles (completes, fails, is canceled, or goes idle after reporting). Treat that message as the primary completion signal: it is safe to finish other pending work or wait between summary checks, because the settle notification arrives even if this task has gone idle. When it arrives, immediately fetch the verification outcome with `action: "get_summary"` and `action: "get_messages"` and continue the workflow from step 31. +28. While waiting for that settle notification, periodically check the verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal. Use that per-task summary as the source of truth for task state, including any surfaced startup or runtime failure details and the `Environment Setup` line, which reports the ground-truth lifecycle of the verification task's environment setup commands: treat `still running in the background` as normal active startup rather than a stall, and treat `failed` or `completed with warnings` as direct evidence that specific setup commands failed even when the verification task has not described the failure yet. 29. Narrate concise, plain-language progress updates while the follow-up check runs, so the user can see that setup is still being checked without being asked to wait. Say what is being checked (for example, `I'm confirming the environment starts cleanly`) rather than mentioning a spawned task, task status, polling, or monitoring. 30. Continue checking the verification task while the summary shows an active startup or running state, or until it clearly reports a startup or runtime blocker through the summary. Preparing the environment can take 5 minutes or more, so do not stop monitoring just because startup is taking a long time. 31. If the monitored summary reaches `Completed` without a surfaced startup or runtime failure, treat that as a successful spawned-task run and report that observed outcome directly instead of asking the user to confirm it manually. 32. If the monitored summary reaches `Ready`, `Idle`, or `Needs input`, do not keep polling that same state indefinitely. Inspect the latest task messages to determine whether the verification task already reported success, surfaced a blocker, or is unexpectedly waiting for follow-up input. 33. If those latest task messages clearly report that the environment looks ready, treat that as a successful spawned-task run and report the observed success directly. 34. If those latest task messages surface a startup or runtime blocker, request unexpected user input, or otherwise fail to give a clear success outcome, treat that as a blocker or verification failure instead of pretending the environment is verified. -35. If the monitored summary reaches `Failed`, `Canceled`, or exposes a startup or runtime error, inspect the exact status and error and decide whether the problem appears fixable within environment-setup scope, such as revising commands, services, environment variables, startup order, readiness checks, or other environment-definition details. +35. If the settle notification or the monitored summary reports `Failed`, `Canceled`, a startup or runtime error, or an `Environment Setup` state of `failed` or `completed with warnings`, inspect the exact status and error — asking the verification task for the failing command names, exit codes, and log excerpts from `.roomote/setup-status.json` and `.roomote/setup-logs/` when they are not already surfaced — and decide whether the problem appears fixable within environment-setup scope, such as revising commands, services, environment variables, startup order, readiness checks, or other environment-definition details. 36. When the spawned verification task reveals a fixable setup or environment-definition error, try to fix it yourself, rerun any affected local validation, recreate or update the environment with the revised YAML, launch a fresh verification task, and repeat the monitoring process instead of stopping after the first failure. -37. Treat each fix attempt as a real retry loop: revise based on the observed error, revalidate the affected setup steps, persist the updated environment again, relaunch the verification task, and monitor the new task summary rather than assuming the old failure is resolved. +37. Treat each fix attempt as a real retry loop: revise based on the observed error, revalidate the affected setup steps, persist the updated environment again, relaunch the verification task with `notifyOnSettle: true`, and wait for the new settle notification rather than assuming the old failure is resolved. 38. Keep this verification-repair loop bounded. Retry at most 2 additional full environment-update-plus-verification attempts after the first spawned verification task unless the task context explicitly justifies a smaller limit. 39. If the observed verification error appears to require product or source-code changes outside environment-setup scope, missing external credentials, unsupported infrastructure, or another user decision you cannot safely make, report that blocker instead of pretending the environment can be repaired automatically. 40. If the verification task remains in an active startup or running state without surfacing a blocker you can act on, keep monitoring instead of handing the waiting back to the user. diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/finish-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/finish-run.test.ts index 1070fd30..d40ab15a 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/finish-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/finish-run.test.ts @@ -19,6 +19,7 @@ const mockRecordTaskRunLifecycleEvent = vi.fn().mockResolvedValue(undefined); const mockCleanupSandboxOidcTargetsForTaskRun = vi .fn() .mockResolvedValue(undefined); +const mockNotifySourceRunOnSettle = vi.fn().mockResolvedValue(undefined); const mockDbTransaction = vi.fn(); /** @@ -239,6 +240,11 @@ vi.mock('../../sandbox-oidc', () => ({ mockCleanupSandboxOidcTargetsForTaskRun(...args), })); +vi.mock('../notify-source-run-on-settle', () => ({ + notifySourceRunOnSettle: (...args: unknown[]) => + mockNotifySourceRunOnSettle(...args), +})); + import { finishRun } from '../finish-run'; import { createTaskRunGitHubToken } from '@roomote/github'; import { enqueueTask } from '@roomote/cloud-agents/server'; diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts new file mode 100644 index 00000000..7b227604 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts @@ -0,0 +1,203 @@ +import { RunStatus } from '@roomote/types'; +import type { TaskRun } from '@roomote/db/server'; + +const mockFindFirstRun = vi.fn(); +const mockUpdateWhere = vi.fn().mockResolvedValue(undefined); +const mockRecordTaskRunLifecycleEvent = vi.fn().mockResolvedValue(undefined); +const mockWithSandboxServerRpcClient = vi.fn().mockResolvedValue({ + success: true, +}); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { + findFirst: (...args: unknown[]) => mockFindFirstRun(...args), + }, + }, + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: (...args: unknown[]) => mockUpdateWhere(...args), + })), + })), + }, + eq: vi.fn((...args: unknown[]) => args), + taskRuns: { id: 'task_runs.id' }, + recordTaskRunLifecycleEvent: (...args: unknown[]) => + mockRecordTaskRunLifecycleEvent(...args), +})); + +vi.mock('../../auth/sandbox-server-rpc', () => ({ + withSandboxServerRpcClient: (...args: unknown[]) => + mockWithSandboxServerRpcClient(...args), +})); + +import { notifySourceRunOnSettle } from '../notify-source-run-on-settle'; + +type SettledRun = TaskRun & { task: { title: string | null } }; + +function makeSettledRun(overrides: Partial = {}): SettledRun { + return { + id: 200, + taskId: 'child-task', + sourceRunId: 100, + payload: { notifySourceRunOnSettle: true }, + environmentSetupState: 'completed', + error: null, + result: null, + task: { title: 'Verify environment' }, + ...overrides, + } as SettledRun; +} + +function mockParentRunLookup(parent: Record | null) { + // First findFirst call re-reads the child row (at-most-once guard); the + // second resolves the parent run. + mockFindFirstRun + .mockResolvedValueOnce({ result: null }) + .mockResolvedValueOnce(parent); +} + +const activeParent = { + id: 100, + taskId: 'parent-task', + status: RunStatus.Idle, + sandboxServerUrl: 'https://sandbox.example.test', +}; + +describe('notifySourceRunOnSettle', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('delivers a settle prompt to the launching run and records delivery', async () => { + mockParentRunLookup(activeParent); + + const sendPromptMutate = vi.fn().mockResolvedValue({ success: true }); + mockWithSandboxServerRpcClient.mockImplementationOnce( + async (options: { + call: (client: unknown) => Promise; + runId: number; + userId: string | null; + }) => { + expect(options.runId).toBe(100); + expect(options.userId).toBeNull(); + return options.call({ + commands: { sendPrompt: { mutate: sendPromptMutate } }, + }); + }, + ); + + await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + + expect(sendPromptMutate).toHaveBeenCalledTimes(1); + const promptArg = sendPromptMutate.mock.calls[0]?.[0] as { + prompt: string; + source: string; + }; + expect(promptArg.source).toBe('task-settled'); + expect(promptArg.prompt).toContain('Spawned task update'); + expect(promptArg.prompt).toContain('child-task'); + expect(promptArg.prompt).toContain('completed'); + expect(promptArg.prompt).toContain('environment setup state is: completed'); + expect(mockUpdateWhere).toHaveBeenCalledTimes(1); + expect(mockRecordTaskRunLifecycleEvent).toHaveBeenCalledTimes(1); + }); + + it('includes the error and setup state for failed runs', async () => { + mockParentRunLookup(activeParent); + + const sendPromptMutate = vi.fn().mockResolvedValue({ success: true }); + mockWithSandboxServerRpcClient.mockImplementationOnce( + async (options: { call: (client: unknown) => Promise }) => + options.call({ + commands: { sendPrompt: { mutate: sendPromptMutate } }, + }), + ); + + await notifySourceRunOnSettle( + makeSettledRun({ + environmentSetupState: 'failed', + error: 'Sandbox startup timed out', + }), + RunStatus.Failed, + ); + + const promptArg = sendPromptMutate.mock.calls[0]?.[0] as { + prompt: string; + }; + expect(promptArg.prompt).toContain('failed'); + expect(promptArg.prompt).toContain('environment setup state is: failed'); + expect(promptArg.prompt).toContain('Sandbox startup timed out'); + }); + + it('does nothing without the payload opt-in', async () => { + await notifySourceRunOnSettle( + makeSettledRun({ payload: {} as never }), + RunStatus.Completed, + ); + + expect(mockFindFirstRun).not.toHaveBeenCalled(); + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + }); + + it('does nothing without a source run pointer', async () => { + await notifySourceRunOnSettle( + makeSettledRun({ sourceRunId: null }), + RunStatus.Completed, + ); + + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + }); + + it('skips delivery when it already notified once', async () => { + mockFindFirstRun.mockResolvedValueOnce({ + result: { sourceRunSettleNotifiedAt: '2026-07-14T00:00:00Z' }, + }); + + await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Idle); + + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + }); + + it('skips same-task resume chains', async () => { + mockParentRunLookup({ ...activeParent, taskId: 'child-task' }); + + await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + }); + + it('skips exited or sandbox-less launching runs', async () => { + mockParentRunLookup({ ...activeParent, status: RunStatus.Completed }); + + await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + + mockParentRunLookup({ ...activeParent, sandboxServerUrl: null }); + + await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + + expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + }); + + it('never throws when delivery fails, and records the failure', async () => { + mockParentRunLookup(activeParent); + mockWithSandboxServerRpcClient.mockRejectedValueOnce( + new Error('sandbox unreachable'), + ); + + await expect( + notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed), + ).resolves.toBeUndefined(); + + expect(mockUpdateWhere).not.toHaveBeenCalled(); + expect(mockRecordTaskRunLifecycleEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + message: expect.stringContaining('Failed to deliver'), + }), + ); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/finish-run.ts b/packages/sdk/src/server/lib/task-runs/finish-run.ts index 98b873fe..65dd2c06 100644 --- a/packages/sdk/src/server/lib/task-runs/finish-run.ts +++ b/packages/sdk/src/server/lib/task-runs/finish-run.ts @@ -57,6 +57,7 @@ import { readConflictResolutionSummary, } from './conflict-resolution-comments'; import { cleanupSandboxOidcTargetsForTaskRun } from '../sandbox-oidc'; +import { notifySourceRunOnSettle } from './notify-source-run-on-settle'; import { refreshTaskTitleOnCompletion } from './record-task-message-envelope'; import { getRedis } from '@roomote/redis'; import { resolveSlackTaskRunRouting } from './slack-task-run-routing'; @@ -226,6 +227,12 @@ export const finishRun = async ({ }); }); + // Deterministic spawned-task feedback: when this run was launched by + // another task's run with notify-on-settle requested, deliver the outcome + // into that launching run's session (waking it if idle) so the parent + // never has to poll for it. Never throws. + await notifySourceRunOnSettle(run, status); + // Anonymous analytics (no-op unless enabled): terminal task outcome with // non-identifying routing facts only. if (status === RunStatus.Completed) { diff --git a/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts new file mode 100644 index 00000000..a42313d4 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts @@ -0,0 +1,193 @@ +import { + RunStatus, + getNotifySourceRunOnSettleFromPayload, + isExitedRunStatus, +} from '@roomote/types'; +import { + type TaskRun, + db, + eq, + recordTaskRunLifecycleEvent, + taskRuns, +} from '@roomote/db/server'; + +import { withSandboxServerRpcClient } from '../auth/sandbox-server-rpc'; + +const NOTIFIED_RESULT_KEY = 'sourceRunSettleNotifiedAt'; + +type SettledStatus = + | RunStatus.Completed + | RunStatus.Failed + | RunStatus.Canceled + | RunStatus.Idle; + +function getSettleStatusLabel(status: SettledStatus): string { + switch (status) { + case RunStatus.Completed: + return 'completed'; + case RunStatus.Failed: + return 'failed'; + case RunStatus.Canceled: + return 'was canceled'; + case RunStatus.Idle: + return 'finished its turn and is now idle'; + } +} + +function buildSettleNotificationPrompt(input: { + taskId: string; + taskTitle: string | null; + status: SettledStatus; + environmentSetupState: string | null; + error: string | null; +}): string { + const lines = [ + `Spawned task update: the task you launched (${input.taskTitle ? `"${input.taskTitle}", ` : ''}ID ${input.taskId}) ${getSettleStatusLabel(input.status)}.`, + ]; + + if (input.environmentSetupState) { + lines.push( + `Its environment setup state is: ${input.environmentSetupState.replace(/_/g, ' ')}.`, + ); + } + + if (input.error) { + lines.push(`Reported error: ${input.error}`); + } + + lines.push( + `Use the Roomote MCP tool \`mcp__roomote__manage_tasks\` with action "get_summary" or "get_messages" and taskId ${input.taskId} for the full outcome, then continue your workflow. This is an automated platform notification, not a user message.`, + ); + + return lines.join('\n'); +} + +/** + * Deliver a spawned task's settle outcome into the session of the run that + * launched it. + * + * Opt-in at launch time (`notifyOnSettle`) persists + * `notifySourceRunOnSettle` on the spawned task's payload and stamps + * `sourceRunId` with the launching run. When a run of the spawned task + * settles, this injects a prompt into the launching run's sandbox — waking + * it if it went idle — so a parent workflow (e.g. environment-setup waiting + * on its verification task) consumes the outcome deterministically instead + * of polling and potentially sleeping through it. + * + * Fire-and-forget: failures are logged and recorded as run events, never + * thrown, and delivery happens at most once per spawned run (guarded via the + * run's `result` JSON). + */ +export async function notifySourceRunOnSettle( + run: TaskRun & { task: { title: string | null } }, + status: SettledStatus, +): Promise { + try { + if ( + !run.sourceRunId || + !getNotifySourceRunOnSettleFromPayload(run.payload) + ) { + return; + } + + // At-most-once guard: re-read the row so a concurrent finalization path + // (e.g. idle followed by a later completed) observes a prior delivery. + const currentRow = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, run.id), + columns: { result: true }, + }); + const currentResult = + currentRow?.result && + typeof currentRow.result === 'object' && + !Array.isArray(currentRow.result) + ? (currentRow.result as Record) + : {}; + + if (currentResult[NOTIFIED_RESULT_KEY]) { + return; + } + + const sourceRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, run.sourceRunId), + columns: { + id: true, + taskId: true, + status: true, + sandboxServerUrl: true, + }, + }); + + // sourceRunId is also used by same-task resume chains; only cross-task + // spawns get a notification. + if ( + !sourceRun || + sourceRun.taskId === run.taskId || + isExitedRunStatus(sourceRun.status) || + !sourceRun.sandboxServerUrl + ) { + return; + } + + const prompt = buildSettleNotificationPrompt({ + taskId: run.taskId, + taskTitle: run.task.title, + status, + environmentSetupState: run.environmentSetupState ?? null, + error: run.error ?? null, + }); + + await withSandboxServerRpcClient({ + runId: sourceRun.id, + // Deployment-service-principal token: this is a platform notification + // with no human actor, so the receiving turn keeps its current actor. + userId: null, + sandboxServerUrl: sourceRun.sandboxServerUrl, + call: (client) => + client.commands.sendPrompt.mutate({ + prompt, + source: 'task-settled', + }), + }); + + await db + .update(taskRuns) + .set({ + result: { ...currentResult, [NOTIFIED_RESULT_KEY]: new Date() }, + }) + .where(eq(taskRuns.id, run.id)); + + await recordTaskRunLifecycleEvent(db, { + runId: run.id, + taskId: run.taskId, + eventType: 'decision', + message: `Delivered settle notification (${status}) to launching run #${sourceRun.id}.`, + details: { + reason: 'source_run_settle_notification', + sourceRunId: sourceRun.id, + status, + }, + }); + } catch (error) { + console.error( + `[notifySourceRunOnSettle] Failed to notify source run for run ${run.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + try { + await recordTaskRunLifecycleEvent(db, { + runId: run.id, + taskId: run.taskId, + eventType: 'decision', + message: `Failed to deliver settle notification to launching run #${run.sourceRunId}.`, + details: { + reason: 'source_run_settle_notification_failed', + sourceRunId: run.sourceRunId, + error: error instanceof Error ? error.message : String(error), + }, + }); + } catch { + // Best-effort observability only. + } + } +} diff --git a/packages/types/src/task-launch-api.ts b/packages/types/src/task-launch-api.ts index 3198fbf7..26c2df80 100644 --- a/packages/types/src/task-launch-api.ts +++ b/packages/types/src/task-launch-api.ts @@ -123,6 +123,14 @@ export const taskLaunchRequestSchema = z.object({ bootstrap: standardTaskBootstrapSchema, trigger: z.enum(['onboarding', 'scheduled']).optional(), notifySlack: z.boolean().optional(), + /** + * When true and the launch is authenticated as a task run, the platform + * delivers a message into that launching run's session when the spawned + * task's run settles (completes, fails, is canceled, or goes idle). Lets a + * parent task consume a spawned task's outcome deterministically instead of + * polling. Standard launches only. + */ + notifyOnSettle: z.boolean().optional(), }); export type TaskLaunchRequest = z.infer; diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index eb457090..078ec52c 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -1239,8 +1239,24 @@ const delegatedTaskPayloadSchema = sharedTaskPayloadSchema.extend({ images: z.array(z.string()).optional(), blank: z.boolean().optional(), bootstrap: standardTaskBootstrapSchema, + /** + * When true, the platform notifies the launching run (`sourceRunId` on this + * task's first run) when a run of this task settles. Set at launch time via + * `notifyOnSettle`; read by the run-finalization path. + */ + notifySourceRunOnSettle: z.boolean().optional(), }); +export function getNotifySourceRunOnSettleFromPayload( + payload: unknown, +): boolean { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return false; + } + + return (payload as Record).notifySourceRunOnSettle === true; +} + export const standardTaskSchema = sharedTaskSchema.extend({ type: z.literal(TaskPayloadKind.StandardTask), payload: delegatedTaskPayloadSchema, From 7b6d35c7c012c244c6613399b35a432ace90c2cf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 14 Jul 2026 19:06:13 -0500 Subject: [PATCH 2/3] Address settle-notification review findings - Persist sourceRunId on the fresh task_runs row: enqueueFreshLaunch dropped the task-level pointer, so every spawned run had a null sourceRunId and the settle notification could never fire. - Splice the finalized error into the run passed to the notifier; the pre-transaction row still carried the previous error. - Claim delivery atomically (conditional JSONB update on the marker) before sending, so concurrent finalizations cannot both notify. Co-Authored-By: Claude Opus 4.8 --- .../src/server/__tests__/enqueue-task.test.ts | 32 ++++++++++ .../cloud-agents/src/server/task-run-queue.ts | 4 ++ .../notify-source-run-on-settle.test.ts | 63 +++++++++++-------- .../src/server/lib/task-runs/finish-run.ts | 8 ++- .../task-runs/notify-source-run-on-settle.ts | 48 +++++++------- 5 files changed, 104 insertions(+), 51 deletions(-) diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index ce24d38f..d2203c24 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -194,6 +194,38 @@ describe('enqueueTask initiator stamping', () => { expect(run.sourceRunId).toBeNull(); }); + it('persists the launching run pointer on the fresh run row', async () => { + const userId = await createUser(); + + const parentRun = await launchFresh({ + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + const childRun = await launchFresh({ + task: standardTaskInput({ + sourceRunId: parentRun.id, + payload: { + repo: 'acme/widgets', + description: 'Verify the environment', + notifySourceRunOnSettle: true, + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(childRun.sourceRunId).toBe(parentRun.id); + expect( + (childRun.payload as { notifySourceRunOnSettle?: boolean }) + .notifySourceRunOnSettle, + ).toBe(true); + }); + it('persists an unlinked external actor with actor context and external commit author', async () => { const run = await launchFresh({ task: standardTaskInput({ diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 5aad996d..3befc9f2 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -1403,6 +1403,10 @@ async function enqueueFreshLaunch( initialPaths, payload: taskWithHarnessOverrides.payload, keepaliveMs, + // Launching-run lineage for platform-spawned tasks. Without this on + // the run row, notify-source-run-on-settle has no pointer back to + // the parent run. + sourceRunId: taskWithHarnessOverrides.sourceRunId ?? null, }) .returning(); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts index 7b227604..b5a7e3d7 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts @@ -2,7 +2,7 @@ import { RunStatus } from '@roomote/types'; import type { TaskRun } from '@roomote/db/server'; const mockFindFirstRun = vi.fn(); -const mockUpdateWhere = vi.fn().mockResolvedValue(undefined); +const mockClaimReturning = vi.fn(); const mockRecordTaskRunLifecycleEvent = vi.fn().mockResolvedValue(undefined); const mockWithSandboxServerRpcClient = vi.fn().mockResolvedValue({ success: true, @@ -17,12 +17,16 @@ vi.mock('@roomote/db/server', () => ({ }, update: vi.fn(() => ({ set: vi.fn(() => ({ - where: (...args: unknown[]) => mockUpdateWhere(...args), + where: vi.fn(() => ({ + returning: (...args: unknown[]) => mockClaimReturning(...args), + })), })), })), }, + and: vi.fn((...args: unknown[]) => args), eq: vi.fn((...args: unknown[]) => args), - taskRuns: { id: 'task_runs.id' }, + sql: vi.fn(), + taskRuns: { id: 'task_runs.id', result: 'task_runs.result' }, recordTaskRunLifecycleEvent: (...args: unknown[]) => mockRecordTaskRunLifecycleEvent(...args), })); @@ -50,14 +54,6 @@ function makeSettledRun(overrides: Partial = {}): SettledRun { } as SettledRun; } -function mockParentRunLookup(parent: Record | null) { - // First findFirst call re-reads the child row (at-most-once guard); the - // second resolves the parent run. - mockFindFirstRun - .mockResolvedValueOnce({ result: null }) - .mockResolvedValueOnce(parent); -} - const activeParent = { id: 100, taskId: 'parent-task', @@ -66,12 +62,17 @@ const activeParent = { }; describe('notifySourceRunOnSettle', () => { + beforeEach(() => { + // Default: claim succeeds (marker was absent). + mockClaimReturning.mockResolvedValue([{ id: 200 }]); + }); + afterEach(() => { vi.clearAllMocks(); }); it('delivers a settle prompt to the launching run and records delivery', async () => { - mockParentRunLookup(activeParent); + mockFindFirstRun.mockResolvedValueOnce(activeParent); const sendPromptMutate = vi.fn().mockResolvedValue({ success: true }); mockWithSandboxServerRpcClient.mockImplementationOnce( @@ -100,12 +101,12 @@ describe('notifySourceRunOnSettle', () => { expect(promptArg.prompt).toContain('child-task'); expect(promptArg.prompt).toContain('completed'); expect(promptArg.prompt).toContain('environment setup state is: completed'); - expect(mockUpdateWhere).toHaveBeenCalledTimes(1); + expect(mockClaimReturning).toHaveBeenCalledTimes(1); expect(mockRecordTaskRunLifecycleEvent).toHaveBeenCalledTimes(1); }); it('includes the error and setup state for failed runs', async () => { - mockParentRunLookup(activeParent); + mockFindFirstRun.mockResolvedValueOnce(activeParent); const sendPromptMutate = vi.fn().mockResolvedValue({ success: true }); mockWithSandboxServerRpcClient.mockImplementationOnce( @@ -150,40 +151,52 @@ describe('notifySourceRunOnSettle', () => { expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); }); - it('skips delivery when it already notified once', async () => { - mockFindFirstRun.mockResolvedValueOnce({ - result: { sourceRunSettleNotifiedAt: '2026-07-14T00:00:00Z' }, - }); + it('skips delivery when another finalization already claimed it', async () => { + mockFindFirstRun.mockResolvedValueOnce(activeParent); + mockClaimReturning.mockResolvedValueOnce([]); await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Idle); expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); + expect(mockRecordTaskRunLifecycleEvent).not.toHaveBeenCalled(); }); - it('skips same-task resume chains', async () => { - mockParentRunLookup({ ...activeParent, taskId: 'child-task' }); + it('skips same-task resume chains without claiming', async () => { + mockFindFirstRun.mockResolvedValueOnce({ + ...activeParent, + taskId: 'child-task', + }); await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + expect(mockClaimReturning).not.toHaveBeenCalled(); expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); }); - it('skips exited or sandbox-less launching runs', async () => { - mockParentRunLookup({ ...activeParent, status: RunStatus.Completed }); + it('skips exited or sandbox-less launching runs without claiming', async () => { + mockFindFirstRun.mockResolvedValueOnce({ + ...activeParent, + status: RunStatus.Completed, + }); await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + expect(mockClaimReturning).not.toHaveBeenCalled(); expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); - mockParentRunLookup({ ...activeParent, sandboxServerUrl: null }); + mockFindFirstRun.mockResolvedValueOnce({ + ...activeParent, + sandboxServerUrl: null, + }); await notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed); + expect(mockClaimReturning).not.toHaveBeenCalled(); expect(mockWithSandboxServerRpcClient).not.toHaveBeenCalled(); }); it('never throws when delivery fails, and records the failure', async () => { - mockParentRunLookup(activeParent); + mockFindFirstRun.mockResolvedValueOnce(activeParent); mockWithSandboxServerRpcClient.mockRejectedValueOnce( new Error('sandbox unreachable'), ); @@ -192,7 +205,7 @@ describe('notifySourceRunOnSettle', () => { notifySourceRunOnSettle(makeSettledRun(), RunStatus.Completed), ).resolves.toBeUndefined(); - expect(mockUpdateWhere).not.toHaveBeenCalled(); + expect(mockClaimReturning).toHaveBeenCalledTimes(1); expect(mockRecordTaskRunLifecycleEvent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/packages/sdk/src/server/lib/task-runs/finish-run.ts b/packages/sdk/src/server/lib/task-runs/finish-run.ts index 65dd2c06..ea30cd66 100644 --- a/packages/sdk/src/server/lib/task-runs/finish-run.ts +++ b/packages/sdk/src/server/lib/task-runs/finish-run.ts @@ -230,8 +230,12 @@ export const finishRun = async ({ // Deterministic spawned-task feedback: when this run was launched by // another task's run with notify-on-settle requested, deliver the outcome // into that launching run's session (waking it if idle) so the parent - // never has to poll for it. Never throws. - await notifySourceRunOnSettle(run, status); + // never has to poll for it. Never throws. `run` was read before the + // transaction, so splice in the error that was just finalized. + await notifySourceRunOnSettle( + { ...run, error: sanitizedError ?? run.error }, + status, + ); // Anonymous analytics (no-op unless enabled): terminal task outcome with // non-identifying routing facts only. diff --git a/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts index a42313d4..d5efd232 100644 --- a/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts +++ b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts @@ -5,9 +5,11 @@ import { } from '@roomote/types'; import { type TaskRun, + and, db, eq, recordTaskRunLifecycleEvent, + sql, taskRuns, } from '@roomote/db/server'; @@ -90,23 +92,6 @@ export async function notifySourceRunOnSettle( return; } - // At-most-once guard: re-read the row so a concurrent finalization path - // (e.g. idle followed by a later completed) observes a prior delivery. - const currentRow = await db.query.taskRuns.findFirst({ - where: eq(taskRuns.id, run.id), - columns: { result: true }, - }); - const currentResult = - currentRow?.result && - typeof currentRow.result === 'object' && - !Array.isArray(currentRow.result) - ? (currentRow.result as Record) - : {}; - - if (currentResult[NOTIFIED_RESULT_KEY]) { - return; - } - const sourceRun = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, run.sourceRunId), columns: { @@ -128,6 +113,28 @@ export async function notifySourceRunOnSettle( return; } + // Atomically claim delivery before sending: a conditional JSONB update + // that only succeeds while the marker is absent, so concurrent + // finalizations (e.g. idle racing a later completed) cannot both send. + // A claim whose send then fails is not retried — at-most-once — and the + // failure is recorded as a run event below. + const claimed = await db + .update(taskRuns) + .set({ + result: sql`coalesce(${taskRuns.result}, '{}'::jsonb) || jsonb_build_object(${NOTIFIED_RESULT_KEY}::text, to_jsonb(now()))`, + }) + .where( + and( + eq(taskRuns.id, run.id), + sql`(${taskRuns.result} -> ${NOTIFIED_RESULT_KEY}) is null`, + ), + ) + .returning({ id: taskRuns.id }); + + if (claimed.length === 0) { + return; + } + const prompt = buildSettleNotificationPrompt({ taskId: run.taskId, taskTitle: run.task.title, @@ -149,13 +156,6 @@ export async function notifySourceRunOnSettle( }), }); - await db - .update(taskRuns) - .set({ - result: { ...currentResult, [NOTIFIED_RESULT_KEY]: new Date() }, - }) - .where(eq(taskRuns.id, run.id)); - await recordTaskRunLifecycleEvent(db, { runId: run.id, taskId: run.taskId, From 4e494fb2c1a48adf946c78e0f46dbacb4d2a9562 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 15 Jul 2026 09:17:24 -0500 Subject: [PATCH 3/3] Hide the settle notification from the user-facing transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose visibleInTranscript on the sandbox sendPrompt procedure (both the follow-up and start-new-task paths already supported it internally) and deliver the spawned-task settle prompt with visibleInTranscript: false — the agent must see it, the user should not. Co-Authored-By: Claude Opus 4.8 --- apps/worker/src/sandbox-server/procedures/sendPrompt.ts | 9 +++++++++ packages/sdk/src/sandbox-router.ts | 2 ++ .../__tests__/notify-source-run-on-settle.test.ts | 2 ++ .../server/lib/task-runs/notify-source-run-on-settle.ts | 2 ++ 4 files changed, 15 insertions(+) diff --git a/apps/worker/src/sandbox-server/procedures/sendPrompt.ts b/apps/worker/src/sandbox-server/procedures/sendPrompt.ts index 1616d5b1..aed4feeb 100644 --- a/apps/worker/src/sandbox-server/procedures/sendPrompt.ts +++ b/apps/worker/src/sandbox-server/procedures/sendPrompt.ts @@ -34,6 +34,13 @@ const sendPromptInputSchema = z * follow-ups (Slack, Teams, Telegram), which always steer. */ autoSteerWhenQueued: z.boolean().optional(), + /** + * Hide this prompt from the user-facing transcript. For platform-injected + * machinery (e.g. spawned-task settle notifications) that the agent must + * see but the user should not. Callers hold a run-scoped token, so this + * is not reachable from arbitrary clients. + */ + visibleInTranscript: z.boolean().optional(), }) .superRefine((data, ctx) => { const hasPrompt = @@ -130,6 +137,7 @@ export const sendPrompt = publicProcedure clientMessageId: input.clientMessageId, userName: input.userName, userImageUrl: input.userImageUrl, + visibleInTranscript: input.visibleInTranscript, }); if (!success) { @@ -189,6 +197,7 @@ export const sendPrompt = publicProcedure userName: input.userName, userImageUrl: input.userImageUrl, clientMessageId: input.clientMessageId, + visibleInTranscript: input.visibleInTranscript, }); if (!success) { diff --git a/packages/sdk/src/sandbox-router.ts b/packages/sdk/src/sandbox-router.ts index 9088059a..8ead473f 100644 --- a/packages/sdk/src/sandbox-router.ts +++ b/packages/sdk/src/sandbox-router.ts @@ -84,6 +84,8 @@ export interface SandboxSendPromptInput { userName?: string; userImageUrl?: string; autoSteerWhenQueued?: boolean; + /** Hide the prompt from the user-facing transcript (platform machinery). */ + visibleInTranscript?: boolean; } export interface SandboxSteerTaskInput { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts index b5a7e3d7..3d048cfd 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/notify-source-run-on-settle.test.ts @@ -95,8 +95,10 @@ describe('notifySourceRunOnSettle', () => { const promptArg = sendPromptMutate.mock.calls[0]?.[0] as { prompt: string; source: string; + visibleInTranscript: boolean; }; expect(promptArg.source).toBe('task-settled'); + expect(promptArg.visibleInTranscript).toBe(false); expect(promptArg.prompt).toContain('Spawned task update'); expect(promptArg.prompt).toContain('child-task'); expect(promptArg.prompt).toContain('completed'); diff --git a/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts index d5efd232..d9659cca 100644 --- a/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts +++ b/packages/sdk/src/server/lib/task-runs/notify-source-run-on-settle.ts @@ -153,6 +153,8 @@ export async function notifySourceRunOnSettle( client.commands.sendPrompt.mutate({ prompt, source: 'task-settled', + // Platform machinery: the agent must see it, the user should not. + visibleInTranscript: false, }), });