diff --git a/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.json b/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.json new file mode 100644 index 000000000..1c616e0eb --- /dev/null +++ b/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.json @@ -0,0 +1,37 @@ +{ + "id": "compact_ridr6adoo0go", + "version": 1, + "type": "compacted", + "compactedAt": "2026-10-15T19:57:17.694Z", + "sourceTrajectories": [ + "traj_fesap23qytsg" + ], + "dateRange": { + "start": "2026-10-15T19:49:55.679Z", + "end": "2026-10-15T19:56:51.350Z" + }, + "summary": { + "totalDecisions": 1, + "totalEvents": 2, + "uniqueAgents": [ + "default" + ] + }, + "decisionGroups": [ + { + "category": "api", + "decisions": [ + { + "question": "Warn once after a dispatched release acknowledgement, before optional identity cleanup", + "chosen": "Warn once after a dispatched release acknowledgement, before optional identity cleanup", + "reasoning": "The release endpoint acknowledges worker dispatch only. Emitting through the existing stderr warning sink preserves the single JSON stdout document and covers delete-agent variants without invoking Cloud/provider deletion.", + "fromTrajectory": "traj_fesap23qytsg" + } + ] + } + ], + "keyLearnings": [], + "keyFindings": [], + "filesAffected": [], + "commits": [] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.md b/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.md new file mode 100644 index 000000000..cffe0d74c --- /dev/null +++ b/.agentworkforce/trajectories/issue-1922/compacted/compact_ridr6adoo0go_2026-10-15.md @@ -0,0 +1,18 @@ +# Trajectory Compaction: Oct 16, 2026 - Oct 16, 2026 + +## Summary +- Sessions: 1 +- Decisions: 1 +- Events: 2 +- Agents: default +- Files: 0 +- Commits: 0 + +## Api +- Warn once after a dispatched release acknowledgement, before optional identity cleanup -> Warn once after a dispatched release acknowledgement, before optional identity cleanup (traj_fesap23qytsg) + +## Key Learnings +- None + +## Key Findings +- None \ No newline at end of file diff --git a/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/compaction.json b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/compaction.json new file mode 100644 index 000000000..1f8971b8f --- /dev/null +++ b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/compaction.json @@ -0,0 +1,5 @@ +{ + "trajectoryId": "traj_fesap23qytsg", + "compactedInto": "compact_ridr6adoo0go", + "compactedAt": "2026-10-15T19:57:17.709Z" +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/summary.md b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/summary.md new file mode 100644 index 000000000..235c1f141 --- /dev/null +++ b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/summary.md @@ -0,0 +1,33 @@ +# Trajectory: Warn when fleet release leaves Cloud sandboxes running + +> **Status:** ✅ Completed +> **Task:** 1922 +> **Confidence:** 90% +> **Started:** October 16, 2026 at 01:19 AM +> **Completed:** October 16, 2026 at 01:26 AM + +--- + +## Summary + +Added the dispatched-release sandbox cost warning without changing JSON stdout or requesting provider teardown. Added seven regressions, one RelayFlow bugfix proof, and a patch changelog entry. Independent review found no actionable issues. Full Windows suite limitations are documented for maintainer validation. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Warn once after a dispatched release acknowledgement, before optional identity cleanup +- **Chose:** Warn once after a dispatched release acknowledgement, before optional identity cleanup +- **Reasoning:** The release endpoint acknowledges worker dispatch only. Emitting through the existing stderr warning sink preserves the single JSON stdout document and covers delete-agent variants without invoking Cloud/provider deletion. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Warn once after a dispatched release acknowledgement, before optional identity cleanup: Warn once after a dispatched release acknowledgement, before optional identity cleanup +- The seven new regressions and all 18 release tests pass. Both proof observations match the expected bug/fixed behavior. Typecheck and formatting pass; full Windows validation has 342 failures and a worker OOM, and the fleet Unix-path mismatch is confirmed on unchanged main. diff --git a/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/trajectory.json b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/trajectory.json new file mode 100644 index 000000000..ea68e3f4e --- /dev/null +++ b/.agentworkforce/trajectories/issue-1922/completed/2026-10/traj_fesap23qytsg/trajectory.json @@ -0,0 +1,69 @@ +{ + "id": "traj_fesap23qytsg", + "version": 1, + "task": { + "title": "Warn when fleet release leaves Cloud sandboxes running", + "source": { + "system": "plain", + "id": "1922" + } + }, + "status": "completed", + "startedAt": "2026-10-15T19:49:55.679Z", + "completedAt": "2026-10-15T19:56:51.350Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-10-15T19:51:26.854Z" + } + ], + "chapters": [ + { + "id": "chap_w5couri5corw", + "title": "Work", + "agentName": "default", + "startedAt": "2026-10-15T19:51:26.854Z", + "endedAt": "2026-10-15T19:56:51.350Z", + "events": [ + { + "ts": 1792093886855, + "type": "decision", + "content": "Warn once after a dispatched release acknowledgement, before optional identity cleanup: Warn once after a dispatched release acknowledgement, before optional identity cleanup", + "raw": { + "question": "Warn once after a dispatched release acknowledgement, before optional identity cleanup", + "chosen": "Warn once after a dispatched release acknowledgement, before optional identity cleanup", + "alternatives": [], + "reasoning": "The release endpoint acknowledges worker dispatch only. Emitting through the existing stderr warning sink preserves the single JSON stdout document and covers delete-agent variants without invoking Cloud/provider deletion." + }, + "significance": "high" + }, + { + "ts": 1792094210071, + "type": "reflection", + "content": "The seven new regressions and all 18 release tests pass. Both proof observations match the expected bug/fixed behavior. Typecheck and formatting pass; full Windows validation has 342 failures and a worker OOM, and the fleet Unix-path mismatch is confirmed on unchanged main.", + "raw": { + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added the dispatched-release sandbox cost warning without changing JSON stdout or requesting provider teardown. Added seven regressions, one RelayFlow bugfix proof, and a patch changelog entry. Independent review found no actionable issues. Full Windows suite limitations are documented for maintainer validation.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "c1ffabbc4a933585ca2ddda729b22a34e3b2a3f2", + "endRef": "c1ffabbc4a933585ca2ddda729b22a34e3b2a3f2" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3e44679..cb5d213fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `fleet release` warns that a dispatched worker release leaves Cloud/provider sandboxes running and may continue incurring cost, while preserving JSON output on stdout. ## [14.0.0] - 2026-10-13 diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 45a90e9e5..a9327c85c 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -5356,6 +5356,85 @@ describe('fleet command support', () => { expect(createAgentRelay).not.toHaveBeenCalled(); }); + describe('fleet release sandbox warning', () => { + function fixture(status = 'dispatched') { + const stdout: string[] = []; + const stderr: string[] = []; + const receipt = { invocationId: 'inv_release', actionName: 'agent.release', status }; + const release = vi.fn(async () => receipt); + const deleteSandbox = vi.fn(); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: (() => ({ + agents: { + get: async () => { + throw { status: 404 }; + }, + list: vi.fn(), + }, + })) as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => stdout.push(String(message)), + error: (message: unknown) => stderr.push(String(message)), + exit: ((code: number) => { + throw new Error(`CLI exit ${code}`); + }) as never, + }, + createFleetWorkspaceClient: (() => ({ agents: { release } })) as never, + deleteCloudFleetSandbox: deleteSandbox as never, + retireOwnedBindings: vi.fn(async () => undefined), + warn: (message: unknown) => stderr.push(String(message)), + }); + return { program, stdout, stderr, receipt, release, deleteSandbox }; + } + + it.each([{ options: [] }, { options: ['--delete-agent'] }, { options: ['--delete-agent', '--wait'] }])( + 'warns once for a dispatched release with options $options and preserves JSON stdout', + async ({ options }) => { + const { program, stdout, stderr, receipt, deleteSandbox } = fixture(); + await program.parseAsync(['fleet', 'release', 'worker', '--workspace-key', 'rk_test', ...options], { + from: 'user', + }); + + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0]!)).toMatchObject(receipt); + expect(stderr).toHaveLength(1); + expect(stderr[0]).toContain('Release was dispatched'); + expect(stderr[0]).toContain('Cloud/provider sandbox may keep running and incurring cost'); + expect(stderr[0]).toContain('`fleet release` does not tear it down'); + expect(deleteSandbox).not.toHaveBeenCalled(); + } + ); + + it.each(['pending', 'completed', 'failed'])( + 'does not claim dispatch for a %s release acknowledgement', + async (status) => { + const { program, stdout, stderr, receipt } = fixture(status); + await program.parseAsync(['fleet', 'release', 'worker', '--workspace-key', 'rk_test'], { + from: 'user', + }); + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0]!)).toEqual(receipt); + expect(stderr).toEqual([]); + } + ); + + it('does not claim dispatch when release fails', async () => { + const { program, stdout, stderr, release } = fixture(); + release.mockRejectedValueOnce(new Error('release unavailable')); + await expect( + program.parseAsync(['fleet', 'release', 'worker', '--workspace-key', 'rk_test'], { + from: 'user', + }) + ).rejects.toThrow('CLI exit 1'); + expect(stdout).toEqual([]); + expect(stderr).toEqual(['release unavailable']); + }); + }); + it('fleet release delegates to the workspace lifecycle API with a default reason and actor', async () => { const release = vi.fn(async () => ({ name: 'api-worker', diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 1d3431905..4327e8853 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -1477,6 +1477,11 @@ export function registerFleetCommands( reason, deleteAgent: false, }); + if (released.status === 'dispatched') { + deps.warn( + 'Release was dispatched. If this worker is sandbox-backed, its Cloud/provider sandbox may keep running and incurring cost; `fleet release` does not tear it down.' + ); + } if (options.unsubscribeBindings === true || deleteAgent) { try { // Helper progress (`Retired ...`, `Unsubscribed ...`) must not land diff --git a/tests/relayflows/cases/1922-fleet-release-sandbox-warning/case.json b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/case.json new file mode 100644 index 000000000..e77091f88 --- /dev/null +++ b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/case.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "id": "1922-fleet-release-sandbox-warning", + "kind": "bugfix", + "title": "Warn that fleet release does not tear down Cloud sandboxes", + "runner": { + "command": ["node", "tests/relayflows/cases/1922-fleet-release-sandbox-warning/run.mjs"] + }, + "requirements": [], + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "bug", + "signature": "fleet_release_missing_sandbox_warning" + }, + "head": { + "outcome": "fixed", + "signature": "fleet_release_warns_sandbox_may_keep_running" + } + } +} diff --git a/tests/relayflows/cases/1922-fleet-release-sandbox-warning/probe.test.mts b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/probe.test.mts new file mode 100644 index 000000000..a4e2de62e --- /dev/null +++ b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/probe.test.mts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; + +import { Command } from 'commander'; +import { expect, test, vi } from 'vitest'; + +// The runner copies this probe into /.relay-pr-proof/. +import { registerFleetCommands } from '../packages/cli/src/cli/commands/fleet.js'; + +test('a dispatched fleet release keeps JSON stdout and reports sandbox costs on stderr', async () => { + const observationPath = process.env.RELAY_PR1922_OBSERVATION_PATH; + if (!observationPath) throw new Error('Missing RELAY_PR1922_OBSERVATION_PATH.'); + + const stdout: string[] = []; + const stderr: string[] = []; + const errors: string[] = []; + const receipt = { + invocationId: 'inv_release_proof', + actionName: 'agent.release', + status: 'dispatched', + }; + const release = vi.fn(async () => receipt); + const deleteSandbox = vi.fn(); + const program = new Command(); + program.exitOverride(); + // Exercise the production default log/warn sinks; only the remote lifecycle API is stubbed. + const log = vi.spyOn(console, 'log').mockImplementation((message) => stdout.push(String(message))); + const warn = vi.spyOn(console, 'warn').mockImplementation((message) => stderr.push(String(message))); + const error = vi.spyOn(console, 'error').mockImplementation((message) => errors.push(String(message))); + + try { + registerFleetCommands(program, { + createFleetWorkspaceClient: (() => ({ agents: { release } })) as never, + deleteCloudFleetSandbox: deleteSandbox as never, + }); + await program.parseAsync(['fleet', 'release', 'proof-worker', '--workspace-key', 'rk_proof'], { + from: 'user', + }); + + expect(errors).toEqual([]); + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0]!)).toEqual(receipt); + expect(release).toHaveBeenCalledExactlyOnceWith({ + name: 'proof-worker', + reason: expect.stringMatching(/^fleet agent released \(actor: .+\)$/), + deleteAgent: false, + }); + expect(deleteSandbox).not.toHaveBeenCalled(); + + const missingWarning = stderr.length === 0; + const hasWarning = + stderr.length === 1 && + stderr[0]!.includes('Release was dispatched') && + stderr[0]!.includes('Cloud/provider sandbox may keep running and incurring cost') && + stderr[0]!.includes('`fleet release` does not tear it down'); + expect(missingWarning || hasWarning, JSON.stringify(stderr)).toBe(true); + fs.writeFileSync(observationPath, JSON.stringify({ missingWarning, hasWarning })); + } finally { + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + } +}); diff --git a/tests/relayflows/cases/1922-fleet-release-sandbox-warning/run.mjs b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/run.mjs new file mode 100644 index 000000000..4a3f8d3d6 --- /dev/null +++ b/tests/relayflows/cases/1922-fleet-release-sandbox-warning/run.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from 'node:child_process'; +import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CASE_ID = '1922-fleet-release-sandbox-warning'; +const arm = required('RELAY_PR_PROOF_ARM'); +if (arm !== 'base' && arm !== 'head') throw new Error('Proof arm must be base or head.'); +const targetDir = path.resolve(required('RELAY_PR_PROOF_TARGET_DIR')); +const resultPath = path.resolve(required('RELAY_PR_PROOF_RESULT_PATH')); +// A failed rerun must not leave a previous successful observation behind. +await rm(resultPath, { force: true }); +const expectedSha = required(arm === 'base' ? 'RELAY_PR_PROOF_BASE_SHA' : 'RELAY_PR_PROOF_HEAD_SHA'); +const actualSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim(); +if (actualSha !== expectedSha) throw new Error(`Target checkout does not match exact ${arm} SHA.`); + +const caseDir = path.dirname(fileURLToPath(import.meta.url)); +const proofDir = path.join(targetDir, '.relay-pr-proof'); +const probePath = path.join(proofDir, `${CASE_ID}.test.mts`); +const configPath = path.join(proofDir, '1922-vitest.config.mts'); +const observationPath = path.join(proofDir, '1922-observation.json'); + +try { + const vitestEntry = path.join(targetDir, 'node_modules', 'vitest', 'vitest.mjs'); + if (!(await exists(vitestEntry))) { + run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund']); + } + await mkdir(proofDir, { recursive: true }); + await rm(observationPath, { force: true }); + await copyFile(path.join(caseDir, 'probe.test.mts'), probePath); + await writeFile( + configPath, + `import { defineConfig } from 'vitest/config';\nimport targetConfig from '../vitest.config.ts';\nexport default defineConfig({ resolve: targetConfig.resolve, test: { environment: 'node', include: ['.relay-pr-proof/${CASE_ID}.test.mts'] } });\n` + ); + run(process.execPath, [vitestEntry, 'run', '--config', configPath, '--reporter=verbose']); + const observed = JSON.parse(await readFile(observationPath, 'utf8')); + if (observed.missingWarning === observed.hasWarning) { + throw new Error('Expected exactly one recognized fleet release observation.'); + } + const fixed = observed.hasWarning === true; + await mkdir(path.dirname(resultPath), { recursive: true }); + await writeFile( + resultPath, + `${JSON.stringify({ + version: 1, + caseId: CASE_ID, + arm, + outcome: fixed ? 'fixed' : 'bug', + signature: fixed + ? 'fleet_release_warns_sandbox_may_keep_running' + : 'fleet_release_missing_sandbox_warning', + details: fixed + ? 'The dispatched release warns on the default stderr sink that sandbox cost may continue, retaining one unchanged JSON stdout document and making no sandbox deletion request.' + : 'The dispatched release returns one JSON stdout document but emits no sandbox teardown or continuing-cost warning on the default stderr sink.', + })}\n` + ); +} finally { + await rm(probePath, { force: true }); + await rm(configPath, { force: true }); + await rm(observationPath, { force: true }); +} + +function required(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required environment variable ${name}.`); + return value; +} + +async function exists(candidate) { + try { + await access(candidate); + return true; + } catch { + return false; + } +} + +function run(command, args) { + const completed = spawnSync(command, args, { + cwd: targetDir, + env: { ...process.env, RELAY_PR1922_OBSERVATION_PATH: observationPath }, + encoding: 'utf8', + stdio: ['ignore', 'inherit', 'inherit'], + timeout: 10 * 60 * 1000, + }); + if (completed.error) throw completed.error; + if (completed.status !== 0) throw new Error(`Proof subprocess failed (${completed.status}).`); +}