Skip to content

Commit 899a637

Browse files
authored
🤖 feat: add parent-owned workspace lifecycle tool (#3633)
## Summary Adds a parent-scoped `task_workspace_lifecycle` tool so orchestrator workspaces can archive, delete managed worktrees for, or remove full workspaces they created via `task(kind="workspace")` without exposing raw workspace lifecycle power over arbitrary user workspaces. ## Background Parent/orchestrator agents need to reconcile child full workspaces after external work completes, such as after a child PR merges. Existing `task_terminate` only interrupts active workspace turns and intentionally preserves full workspaces, while raw workspace archive/remove routes are not model-facing and would be too broad if exposed directly. ## Implementation - Adds `task_workspace_lifecycle` schemas, result types, tool registration, and generated hook docs. - Adds `TaskService` lifecycle methods that resolve either `wst_...` task IDs or workspace IDs, verify ownership through durable workspace-turn records, and delegate to `WorkspaceService` only after scope/state checks pass. - Keeps `archive` idempotent and safe by default, gates `delete_worktree`/`remove` on archived state, and requires `interrupt_active: true` before acting on active workspace turns. - Supports archive confirmations keyed by resolved workspace ID, including task-ID targets. - Serializes lifecycle operations per resolved workspace so batch targets that refer to the same child cannot race archive/delete/remove side effects. - Refreshes the stale Storybook snapshot budget constants to the exact current baseline so `make test` remains a no-growth guardrail for the current tree. ## Validation - `bun test ./tests/ui/storybook/budget.test.ts` - `bun test ./tests/ui/storybook/` - `bun test src/node/services/taskService.test.ts --test-name-pattern "workspace lifecycle"` - `bun test src/common/utils/tools/toolDefinitions.test.ts src/node/services/tools/task_workspace_lifecycle.test.ts` - `bun test src/node/services/tools/task_list.test.ts` - `bun test src/node/services/tools/task_await.test.ts` - `make typecheck` - `make test` - `MUX_ESLINT_CONCURRENCY=1 make static-check` ## Dogfood Captured terminal-level dogfood evidence showing the lifecycle tool archives a parent-owned workspace turn, hides it from default `task_list`, preserves it with `includeArchived: true`, rejects an unowned workspace, blocks active `delete_worktree` without `interrupt_active`, and succeeds after explicit interruption. Screenshot/video artifacts were produced during development; GitHub image upload from this environment was blocked by the repo upload token/SSO flow. ## Risks Medium surface-area backend change touching task orchestration. Risk is mitigated by keeping lifecycle authority in `TaskService`, checking durable ownership records before every operation, preserving `task_terminate` semantics, and adding focused tests for ownership, active-turn gating, archived-state precedence, archive confirmations, idempotency, and tool routing. ## Pains The repo's Storybook snapshot budget test had stale exact-baseline constants after unrelated story growth. The constants are updated to the current baseline with no headroom so future story growth still fails the guardrail. --- <details> <summary>📋 Implementation Plan</summary> # Implementation Plan: Parent-Owned Workspace Lifecycle Tooling ## Goal Enable an orchestrator/parent workspace to clean up full workspaces it created with `task(kind: "workspace")` after external reconciliation shows the work is finished, for example after a child workspace's PR is merged and its GitHub issue is closed. The new capability must let agents perform safe cleanup without granting broad authority over arbitrary user workspaces. ## Verified context and constraints - `task(kind: "workspace")` is implemented in `src/node/services/tools/task.ts` and calls `TaskService.createWorkspaceTurn(...)`. - Workspace-turn task handles use the `wst_...` prefix, defined by `WORKSPACE_TURN_TASK_ID_PREFIX` / `isWorkspaceTurnTaskId(...)` in `src/node/services/taskHandleStore.ts`. - Workspace turns are persisted as `WorkspaceTurnTaskHandleRecord` with `ownerWorkspaceId`, `workspaceId`, `createdWorkspace`, and `disposableWorkspace` in `src/node/services/taskHandleStore.ts`. - `TaskHandleStore.isWorkspaceOwnedBy(ownerWorkspaceId, workspaceId)` already exists and checks that an owner has a workspace-turn record with `createdWorkspace === true` for the target workspace. - `task_terminate` is implemented in `src/node/services/tools/task_terminate.ts`; for `wst_...` handles it calls `TaskService.interruptWorkspaceTurn(...)` and returns `status: "interrupted"` with a note that the full workspace is preserved. - `WorkspaceService.archive(workspaceId, acknowledgedUntrackedPaths?)`, `WorkspaceService.deleteWorktree(workspaceId)`, `WorkspaceService.remove(workspaceId, force?)`, and `WorkspaceService.archiveMergedInProject(projectPath)` already exist in `src/node/services/workspaceService.ts`. - `workspace.archive`, `workspace.deleteWorktree`, `workspace.remove`, and `workspace.archiveMergedInProject` already exist as ORPC routes in `src/node/orpc/router.ts` with schemas in `src/common/orpc/schemas/api.ts`, but these are not agent-facing tools. - `task_list` already returns workspace-turn rows with `handleKind: "workspace_turn"` and `workspaceId` from `src/node/services/tools/task_list.ts`. - `task_list` hides archived non-actionable workspace turns by default via archive filtering; `includeArchived: true` reveals them. - Plan mode/plan agents may only spawn `explore` tasks; implementation must happen in exec mode. <details> <summary>Design principle: keep cancellation and lifecycle cleanup separate</summary> Do not overload `task_terminate` to delete/archive full workspaces. `task_terminate` should remain the verb for stopping active work. Workspace cleanup should be a distinct lifecycle operation with its own ownership checks and clearer destructive semantics. </details> ## Recommended approach Add a new scoped tool, **`task_workspace_lifecycle`**, backed by new `TaskService` methods that verify ownership through `TaskHandleStore` before delegating to `WorkspaceService`. Recommended product-code net LoC estimate: **~550–750 LoC** for a complete first version with archive + delete-worktree + guarded remove, tool schema/result definitions, service methods, and registration. Tests are not included in this product-code LoC estimate. ### Why this approach - It gives orchestrator agents exactly the lifecycle primitive they need. - It avoids granting raw `workspace.archive/remove` authority to arbitrary workspace IDs. - It reuses the existing durable ownership record for `task(kind: "workspace")` children. - It keeps `archive` as the safe default and treats `deleteWorktree` / `remove` as more explicit destructive phases. - It works well with heartbeat reconciliation because repeated calls can be idempotent. ## Non-recommended alternatives | Approach | Product-code net LoC estimate | Recommendation | Reason | |---|---:|---|---| | Add `workspace.disposable: true` usage guidance only | ~0 LoC | Not enough | Cleanup has to be decided at creation time; PR/issue workflows decide after merge/closure. | | Extend `task_terminate` with archive/delete flags | ~150–250 LoC | Avoid | Conflates interruption with destructive lifecycle cleanup and changes current mental model. | | Expose raw `workspace.archive/remove` as agent tools | ~250–400 LoC | Avoid | Too broad; scope checks would be easy to bypass or duplicate incorrectly. | | Add project-wide `archiveMergedInProject` as an agent tool | ~150–250 LoC | Maybe later | Useful admin convenience, but too coarse for parent-owned orchestrator reconciliation. | ## Tool interface ### Tool name `task_workspace_lifecycle` This keeps the tool grouped with existing task orchestration tools (`task`, `task_list`, `task_await`, `task_terminate`) while making clear that it acts on task-created full workspaces. ### Input schema Add a schema in `src/common/utils/tools/toolDefinitions.ts`: ```ts export const TaskWorkspaceLifecycleToolArgsSchema = z .object({ action: z.enum(["archive", "delete_worktree", "remove"]), targets: z.array( z .object({ taskId: z.string().min(1).nullish(), workspaceId: z.string().min(1).nullish(), }) .strict() .superRefine((target, ctx) => { const hasTaskId = target.taskId != null && target.taskId.trim().length > 0; const hasWorkspaceId = target.workspaceId != null && target.workspaceId.trim().length > 0; if (hasTaskId === hasWorkspaceId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Provide exactly one of taskId or workspaceId", path: ["taskId"], }); } }) ).min(1), interrupt_active: z.boolean().nullish(), force: z.boolean().nullish(), acknowledged_untracked_paths: z.record(z.string(), z.array(z.string())).nullish(), }) .strict(); ``` Notes: - Use `.nullish()` for optional tool input fields, following existing tool-schema conventions. - `taskId` targets should accept only `wst_...` workspace-turn IDs for this tool. - `workspaceId` targets are allowed only when the current parent owns the workspace through a `createdWorkspace` workspace-turn record. - `interrupt_active` is explicit and only affects active workspace turns. - `force` should only apply to `remove`; it must not silently bypass archive confirmation. - `acknowledged_untracked_paths` is keyed by resolved `workspaceId` so batched targets can acknowledge archive confirmations independently. ### Output schema Add a per-target result union in `src/common/utils/tools/toolDefinitions.ts`: ```ts type TaskWorkspaceLifecycleStatus = | "archived" | "already_archived" | "deleted_worktree" | "already_transcript_only" | "removed" | "already_removed" | "requires_archive" | "requires_confirmation" | "active" | "not_found" | "invalid_scope" | "error"; ``` Suggested structured output: ```ts { results: Array<{ status: TaskWorkspaceLifecycleStatus; action: "archive" | "delete_worktree" | "remove"; taskId?: string; workspaceId?: string; paths?: string[]; activeTaskIds?: string[]; note?: string; error?: string; }>; } ``` Semantics: - `requires_confirmation` surfaces `ArchiveLossyUntrackedFilesConfirmation` paths from `WorkspaceService.archive(...)`. - `active` means queued/starting/running workspace turns target the workspace and `interrupt_active !== true`. - `already_archived`, `already_transcript_only`, and `already_removed` make heartbeat reconciliation idempotent for repeated cleanup attempts. - `requires_archive` means the target is valid and owned, but the requested destructive action requires archiving first. - `invalid_scope` means the caller does not own that workspace, even if the workspace exists. ## Backend implementation plan ### Phase 1 — Add service-level scoped lifecycle seam Files: - `src/node/services/taskService.ts` - `src/node/services/taskHandleStore.ts` only if additional lookup helpers are needed; prefer using the existing `isWorkspaceOwnedBy(...)` and `listWorkspaceTurns(...)` methods. Add public methods to `TaskService`, not directly to the tool handler: ```ts async archiveOwnedWorkspaceTurnWorkspace( ownerWorkspaceId: string, target: { taskId?: string; workspaceId?: string }, options?: { interruptActive?: boolean; acknowledgedUntrackedPaths?: string[]; } ): Promise<Result<TaskWorkspaceLifecycleArchiveResult, string>> async deleteOwnedWorkspaceTurnWorktree( ownerWorkspaceId: string, target: { taskId?: string; workspaceId?: string }, options?: { interruptActive?: boolean } ): Promise<Result<TaskWorkspaceLifecycleDeleteWorktreeResult, string>> async removeOwnedWorkspaceTurnWorkspace( ownerWorkspaceId: string, target: { taskId?: string; workspaceId?: string }, options?: { interruptActive?: boolean; force?: boolean } ): Promise<Result<TaskWorkspaceLifecycleRemoveResult, string>> ``` Implementation details: 1. Resolve the target: - If `taskId` is supplied, require `isWorkspaceTurnTaskId(taskId)`. - Look up the workspace turn with `taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, taskId)`. - Extract `record.workspaceId`. - Then call `taskHandleStore.isWorkspaceOwnedBy(ownerWorkspaceId, record.workspaceId)`. - Do not require the supplied handle's own `record.createdWorkspace === true`; an `existing` follow-up turn targeting a workspace originally created by this parent should be lifecycle-manageable. - If `workspaceId` is supplied, call `taskHandleStore.isWorkspaceOwnedBy(ownerWorkspaceId, workspaceId)`. 2. Load metadata/existence before checking active turns: - If the target is owned but missing and the action is `remove`, return `already_removed`. - If the target is owned but missing and the action is `archive` or `delete_worktree`, return `not_found` with a note that the workspace is already absent. 3. Compute archived/transcript-only state from metadata. 4. Apply action gating before active-turn checks: - For `archive`, return `already_archived` before calling `WorkspaceService.archive(...)` so hooks/snapshot work are not re-run. - For `delete_worktree`, return `requires_archive` if the workspace is not archived. - For `remove`, return `requires_archive` if the workspace is not archived, even when `force === true`. 5. Only after the action is eligible to proceed, find active workspace turns for the resolved `workspaceId` using `listWorkspaceTurnTasks(ownerWorkspaceId, { statuses: ["queued", "starting", "running"] })` and filter by `turn.workspaceId`. 6. If active turns exist and `interruptActive !== true`, return an `active` result with `activeTaskIds`. 7. If active turns exist and `interruptActive === true`, call `interruptWorkspaceTurn(ownerWorkspaceId, handleId)` for each active turn before lifecycle action. 8. For `archive`, call `workspaceService.archive(workspaceId, acknowledgedUntrackedPaths)` only after the already-archived check and active-turn handling. 9. For `delete_worktree`, require archived state, then call `workspaceService.deleteWorktree(workspaceId)` after active-turn handling. 10. For `remove`, require archived state always, then call `workspaceService.remove(workspaceId, force)` after active-turn handling. `force` is only forwarded to the existing removal preflight; it must not bypass scope, active-turn, archived-state, or archive-confirmation rules. 11. Keep all ownership, state precedence, and active-turn checks inside `TaskService` so tool tests and future callers share the same safety rules. Important guardrails: - Do not use `metadata.parentWorkspaceId` for full workspace-turn ownership; full workspace turns are normal workspaces, so ownership should come from `TaskHandleStore` records. - Existing-mode workspace turns are lifecycle-manageable only when their target workspace was originally created by this same parent through any `createdWorkspace: true` workspace-turn record. - Do not make archive confirmation or archived-state requirements bypassable through `force`. - Use `assert` for impossible internal assumptions: non-empty owner workspace ID, exactly-one target resolution after schema validation, stable resolved workspace ID. ### Phase 2 — Add the model-facing tool Files: - New: `src/node/services/tools/task_workspace_lifecycle.ts` - New: `src/node/services/tools/task_workspace_lifecycle.test.ts` - Existing: `src/common/utils/tools/toolDefinitions.ts` - Existing: `src/common/utils/tools/tools.ts` - Existing: `src/common/types/tools.ts` if generated/shared tool-result aliases need extension. - Existing: any renderer tool-card mapping if unknown tool names are not already handled generically. Implementation details: 1. Add args/result schemas and `TOOL_DEFINITIONS.task_workspace_lifecycle` in `toolDefinitions.ts`. 2. Add the new tool name to the common tool-name/result-schema unions/mappings in `toolDefinitions.ts`. 3. Implement `createTaskWorkspaceLifecycleTool(config)` using existing helpers: - `requireWorkspaceId(config, "task_workspace_lifecycle")` - `requireTaskService(config, "task_workspace_lifecycle")` - `parseToolResult(...)` 4. For each target, call the corresponding new `TaskService` method. 5. Return per-target results; do not throw for one failed target in a batch unless the entire input is malformed. 6. Register in `src/common/utils/tools/tools.ts` next to the other task tools: - import `createTaskWorkspaceLifecycleTool` - add `task_workspace_lifecycle: wrap(createTaskWorkspaceLifecycleTool(config))` 7. Confirm plan agents cannot use this tool. Existing plan-mode policy appears to gate only `task(kind="workspace")` and editing tools; if generic tool availability does not gate destructive lifecycle tools for plan agents, add a guard in the tool implementation: - if `config.planFileOnly === true`, throw the same style of plan-agent restriction error. ### Phase 3 — Make lifecycle states ergonomic for orchestrators Files: - `src/node/services/tools/task_list.ts` - `src/node/services/tools/task_await.ts` only if lifecycle action removes data that affects awaited workspace-turn reports. - `src/common/utils/tools/toolDefinitions.ts` descriptions. Implementation details: 1. Update `task_list` documentation to mention that archived workspace-turn workspaces are hidden by default and visible with `includeArchived: true`. 2. Confirm `task_list` rows for workspace turns include `workspaceId`, `handleKind`, `title`, `status`, and `createdAt` after lifecycle changes. 3. Confirm `task_await` can still return completed workspace-turn reports after archive. 4. If full `remove` deletes child session data needed by `task_await`, document this clearly in the `remove` action result note and prefer `archive` / `delete_worktree` for normal orchestrator cleanup. ### Phase 4 — Optional convenience action after the safe primitive exists Only after `task_workspace_lifecycle` is working, consider an additional higher-level convenience action/tool for reconciliation: ```ts task_workspace_archive_merged({ projectPath?: string, owner_only: true }) ``` This could build on `archiveMergedInProject(projectPath)` or a new parent-owned variant, but it should not be the first primitive because it is coarser and harder to scope safely. ## Safety semantics by action ### `archive` Default orchestrator cleanup action. - Allowed only for parent-owned, created workspaces. - Refuses active workspace turns unless `interrupt_active: true`. - Calls `WorkspaceService.archive(...)`. - Preserves metadata/history and allows unarchive. - Returns `requires_confirmation` if untracked-file snapshot confirmation is needed. - Idempotent if already archived. ### `delete_worktree` Disk reclamation while preserving transcript/metadata. - Allowed only for parent-owned, created workspaces. - Requires archived state; otherwise returns `requires_archive`. - Calls `WorkspaceService.deleteWorktree(...)`. - Only works for archived managed worktree runtimes; return `error`/typed failure for unsupported runtimes. - Idempotent if already transcript-only, if the existing metadata exposes a reliable transcript-only marker; otherwise treat `WorkspaceService.deleteWorktree(...)` result as source of truth. ### `remove` Hard destructive cleanup. - Allowed only for parent-owned, created workspaces. - Requires archived state always; otherwise returns `requires_archive` even when `force` is true. - Calls `WorkspaceService.remove(workspaceId, force)` only after scope, active-turn, and archived-state checks pass. - If the workspace is owned but already absent, returns `already_removed` for idempotent heartbeat cleanup. - Deletes config/session state; should be presented as irreversible. - Should not be the default recommendation in the tool description. - If implemented in the first PR, keep the description conservative and make archive the primary path. ## Testing plan ### Unit tests: tool definitions File: - `src/common/utils/tools/toolDefinitions.test.ts` Cases: - Accepts exactly one of `taskId` or `workspaceId` per target. - Rejects neither target and both targets. - Accepts optional fields with `null` because tool schemas use `.nullish()`. - Accepts all action enum values. - Rejects invalid action values. ### Unit tests: service ownership and lifecycle File: - `src/node/services/taskService.test.ts` Cases: - Parent can archive a workspace with a `createdWorkspace: true` workspace-turn record. - Parent cannot archive unrelated workspace IDs. - Parent can lifecycle-manage an owned workspace when targeting a later `existing` follow-up `wst_...` handle, as long as some owner record has `createdWorkspace: true` for that workspace. - Parent cannot lifecycle-manage an unowned workspace referenced only by an `existing` workspace turn when no owner record has `createdWorkspace: true` for that workspace. - Target resolution works by `taskId` and by `workspaceId`. - Active workspace turns return `active` when `interruptActive` is false. - Active workspace turns are interrupted before archive/delete/remove when `interruptActive` is true. - Archive confirmation from `WorkspaceService.archive(...)` is returned to caller without bypassing. - Already archived targets return `already_archived`. - `delete_worktree` requires archived workspace state. - `delete_worktree` and `remove` return `requires_archive` for owned but unarchived workspaces. - `force` on `remove` does not bypass archived-state requirements. - Owned-but-missing remove targets return `already_removed`; owned-but-missing archive/delete-worktree targets return the documented `not_found` result. - Already archived targets with stale active workspace-turn records return `already_archived`, not `active`. - Unarchived active targets for `delete_worktree` and `remove` return `requires_archive`, not `active`. - `force: true` still returns `requires_archive` for unarchived `remove` targets. ### Unit tests: tool handler File: - New `src/node/services/tools/task_workspace_lifecycle.test.ts` Cases: - Calls the correct `TaskService` method for each action. - Converts service `not_found`, `invalid_scope`, `active`, confirmation, and generic error outcomes into schema-valid tool results. - Handles multiple targets independently. - Deduplicates exact duplicate targets or documents that duplicates are processed sequentially; prefer dedupe for idempotency. - Rejects plan-agent usage when `config.planFileOnly === true` if the tool is otherwise available in plan-like contexts. ### Integration/regression tests Potential files: - `src/node/services/tools/task.test.ts` for task-result compatibility if needed. - `src/node/services/tools/task_list.test.ts` for archived filtering behavior. - `src/node/services/tools/task_await.test.ts` for report retrieval after archive. - `src/node/services/workspaceService.test.ts` only if new lifecycle behavior requires changes to existing archive/delete/remove semantics. Cases: - After archiving a child workspace, default `task_list` hides the completed workspace turn; `includeArchived: true` shows it. - `task_await` on a completed `wst_...` still returns report data after archive. - For `delete_worktree`, archived workspace becomes transcript-only while still visible in archived/project views. ## Validation commands Run targeted tests first, then broader checks: ```sh bun test src/common/utils/tools/toolDefinitions.test.ts bun test src/node/services/tools/task_workspace_lifecycle.test.ts bun test src/node/services/taskService.test.ts bun test src/node/services/tools/task_list.test.ts bun test src/node/services/tools/task_await.test.ts make typecheck MUX_ESLINT_CONCURRENCY=1 make lint make test ``` If full `make test` is too large or flaky, run the targeted tests plus `make typecheck` and `MUX_ESLINT_CONCURRENCY=1 make lint`, then document the blocker before stopping. ## Dogfooding plan Dogfooding must include screenshots and video recordings for reviewer verification. ### CLI/tool-level dogfood 1. Start a sandboxed Mux dev environment suitable for manual tool calls. 2. From a parent workspace, spawn a child: ```json { "kind": "workspace", "prompt": "Create a small harmless change, report when done.", "title": "Lifecycle cleanup dogfood child", "run_in_background": true } ``` 3. Use `task_list` to capture the child `taskId`, `handleKind: "workspace_turn"`, and `workspaceId`. 4. Wait for completion with `task_await`. 5. Call `task_workspace_lifecycle({ action: "archive", targets: [{ workspaceId }] })`. 6. Verify: - default `task_list` no longer shows the archived completed child, - `task_list({ includeArchived: true })` shows it, - repeated archive returns `already_archived`, - trying an unrelated workspace ID returns `invalid_scope`. 7. If using a worktree runtime, call `delete_worktree` after archive and verify the workspace becomes transcript-only while history remains inspectable. 8. Record a terminal video of the tool calls and results. ### UI dogfood 1. Run the app locally using the repo-standard dev command (`make dev`, or the project-specific sandbox skill if using an isolated app instance). 2. Use `agent-browser` to open the Mux UI. 3. Capture a screenshot/video showing the child workspace before archive in the sidebar/project view. 4. Trigger the archive via the parent agent/tool call. 5. Capture a screenshot/video showing the child is hidden from the main sidebar. 6. Navigate to the archived/project workspace view and capture a screenshot/video showing the child is still available for audit. 7. If `delete_worktree` is dogfooded, capture a screenshot/video showing transcript-only state after deleting the managed worktree. 8. Attach screenshots/video artifacts for review. ### Negative dogfood 1. Try archiving a sibling or arbitrary workspace ID from the parent and verify `invalid_scope`. 2. Spawn a long-running child turn, then try archive without `interrupt_active`; verify `active` and `activeTaskIds`. 3. Retry with `interrupt_active: true`; verify the active turn is interrupted and then archived. 4. If archive returns untracked-file confirmation, retry with the returned `paths` in `acknowledged_untracked_paths[workspaceId]` and verify no silent bypass happens. ## Rollout plan 1. Implement archive action first and keep it the recommended/default path in the tool description. 2. Add `delete_worktree` once archive path and archived-state detection are tested. 3. Add `remove` only if needed in the same PR; otherwise defer to a follow-up PR because it is destructive and has session/history implications. 4. Update tool descriptions to teach agents the orchestrator pattern: - archive when work is complete, - delete worktree later to reclaim disk, - remove only for irreversible cleanup. ## Acceptance criteria - Parent workspaces can archive child full workspaces they created via `task(kind: "workspace")`. - Parent workspaces cannot archive/delete/remove arbitrary user workspaces, sibling workspaces, or workspace IDs not represented by a `createdWorkspace` workspace-turn record owned by that parent. - `task_terminate` behavior remains unchanged for `wst_...` handles: it interrupts and preserves non-disposable workspaces. - Active child workspace turns are refused by default and can only be interrupted with explicit `interrupt_active: true`. - Archive confirmation for untracked files is surfaced as a tool result and is not bypassed by `force`. - Repeated heartbeat reconciliation is idempotent for already archived, already transcript-only, and owned-but-already-removed `remove` targets. - Archived workspace-turn workspaces are hidden from default `task_list` but visible with `includeArchived: true`. - Targeted tests, typecheck, lint, and the feasible broader test suite pass before claiming completion. - Dogfooding produces screenshot and video evidence for the archive path, negative scope checks, and active-turn behavior. ## Risks and mitigations | Risk | Mitigation | |---|---| | Agent deletes/archives arbitrary workspace | Enforce ownership in `TaskService` using `TaskHandleStore.isWorkspaceOwnedBy(...)` / workspace-turn records, not model-provided metadata. | | Running workspace turn is hidden or corrupted | Detect active workspace turns and require explicit `interrupt_active: true`; use `interruptWorkspaceTurn(...)` before lifecycle action. | | Archive loses untracked files silently | Surface `requires_confirmation` and require exact `acknowledged_untracked_paths`. Do not let `force` bypass this. | | Hard remove breaks later synthesis | Prefer archive/delete-worktree; clearly document that remove deletes session/config state and may limit later `task_await`/audit use. | | Tool is accidentally available to plan agents | Add a `config.planFileOnly` guard in the new tool if tool availability does not already exclude it. | | Scope is based on wrong relationship | Use workspace-turn ownership records (`ownerWorkspaceId` + `createdWorkspace`), not `parentWorkspaceId`. | ## Advisor review status Approved by advisor after three review rounds. Required advisor changes incorporated: workspace-level ownership for existing-mode follow-up handles; archived-state requirement even with `force`; `already_removed` and `requires_archive` statuses; archive idempotency before calling `WorkspaceService.archive(...)`; state-gating precedence before active-turn checks; tests for the revised precedence. </details> --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$67.46`_ <!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=67.46 -->
1 parent c24a8c9 commit 899a637

14 files changed

Lines changed: 1253 additions & 7 deletions

File tree

docs/agents/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ tools:
364364
- image_.*
365365
# Plan should not apply sub-agent patches.
366366
- task_apply_git_patch
367+
# Plan should not perform destructive workspace cleanup.
368+
- task_workspace_lifecycle
367369
# Global config and catalog tools stay out of general-purpose agents
368370
- mux_agents_.*
369371
- agent_skill_write

docs/hooks/tools.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,22 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
716716

717717
</details>
718718

719+
<details>
720+
<summary>task_workspace_lifecycle (8)</summary>
721+
722+
| Env var | JSON path | Type | Description |
723+
| ----------------------------------------------------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
724+
| `MUX_TOOL_INPUT_ACKNOWLEDGED_UNTRACKED_PATHS_<KEY>_<INDEX>` | `acknowledged_untracked_paths[<KEY>][<INDEX>]` | string | Archive-only confirmations keyed by resolved workspaceId. Use only paths returned by a previous requires_confirmation result. |
725+
| `MUX_TOOL_INPUT_ACKNOWLEDGED_UNTRACKED_PATHS_<KEY>_COUNT` | `acknowledged_untracked_paths[<KEY>].length` | number | Number of elements in acknowledged_untracked_paths[&lt;KEY&gt;] (Archive-only confirmations keyed by resolved workspaceId. Use only paths returned by a previous requires_confirmation result.) |
726+
| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Lifecycle action to perform: "archive" is the safe default, "delete_worktree" reclaims disk after archive, and "remove" irreversibly deletes archived workspace metadata/session state. |
727+
| `MUX_TOOL_INPUT_FORCE` | `force` | boolean | Only applies to remove. Does not bypass ownership, active-turn, archive, or archive-confirmation safety checks. |
728+
| `MUX_TOOL_INPUT_INTERRUPT_ACTIVE` | `interrupt_active` | boolean | When true, interrupt active workspace turns for the target before performing an otherwise-eligible lifecycle action. Defaults to false. |
729+
| `MUX_TOOL_INPUT_TARGETS_<INDEX>_TASK_ID` | `targets[<INDEX>].taskId` | string ||
730+
| `MUX_TOOL_INPUT_TARGETS_<INDEX>_WORKSPACE_ID` | `targets[<INDEX>].workspaceId` | string ||
731+
| `MUX_TOOL_INPUT_TARGETS_COUNT` | `targets.length` | number | Number of elements in targets (Parent-owned workspace-turn targets. Provide exactly one of taskId (wst\_...) or workspaceId for each target.) |
732+
733+
</details>
734+
719735
<details>
720736
<summary>todo_write (3)</summary>
721737

src/common/utils/tools/toolDefinitions.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getAvailableTools,
66
supportsGoogleNativeToolsWithFunctionTools,
77
TaskToolArgsSchema,
8+
TaskWorkspaceLifecycleToolArgsSchema,
89
TOOL_DEFINITIONS,
910
WorkflowRunToolArgsSchema,
1011
} from "./toolDefinitions";
@@ -151,6 +152,46 @@ describe("TOOL_DEFINITIONS", () => {
151152
).toBe(false);
152153
});
153154

155+
it("validates task workspace lifecycle targets and optional nulls", () => {
156+
expect(
157+
TaskWorkspaceLifecycleToolArgsSchema.safeParse({
158+
action: "archive",
159+
targets: [{ taskId: "wst_child" }],
160+
interrupt_active: null,
161+
force: null,
162+
acknowledged_untracked_paths: null,
163+
}).success
164+
).toBe(true);
165+
166+
expect(
167+
TaskWorkspaceLifecycleToolArgsSchema.safeParse({
168+
action: "delete_worktree",
169+
targets: [{ workspaceId: "child-workspace" }],
170+
}).success
171+
).toBe(true);
172+
173+
expect(
174+
TaskWorkspaceLifecycleToolArgsSchema.safeParse({
175+
action: "remove",
176+
targets: [{ taskId: "wst_child", workspaceId: "child-workspace" }],
177+
}).success
178+
).toBe(false);
179+
180+
expect(
181+
TaskWorkspaceLifecycleToolArgsSchema.safeParse({
182+
action: "archive",
183+
targets: [{}],
184+
}).success
185+
).toBe(false);
186+
187+
expect(
188+
TaskWorkspaceLifecycleToolArgsSchema.safeParse({
189+
action: "destroy",
190+
targets: [{ workspaceId: "child-workspace" }],
191+
}).success
192+
).toBe(false);
193+
});
194+
154195
it("requires workspaceId for existing workspace task targets", () => {
155196
expect(
156197
TaskToolArgsSchema.safeParse({

src/common/utils/tools/toolDefinitions.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,97 @@ export const TaskTerminateToolResultSchema = z
10221022
})
10231023
.strict();
10241024

1025+
// -----------------------------------------------------------------------------
1026+
// task_workspace_lifecycle (parent-owned workspace cleanup)
1027+
// -----------------------------------------------------------------------------
1028+
1029+
export const TaskWorkspaceLifecycleActionSchema = z.enum(["archive", "delete_worktree", "remove"]);
1030+
1031+
export const TaskWorkspaceLifecycleTargetSchema = z
1032+
.object({
1033+
taskId: z.string().min(1).nullish(),
1034+
workspaceId: z.string().min(1).nullish(),
1035+
})
1036+
.strict()
1037+
.superRefine((target, ctx) => {
1038+
const hasTaskId = target.taskId != null && target.taskId.trim().length > 0;
1039+
const hasWorkspaceId = target.workspaceId != null && target.workspaceId.trim().length > 0;
1040+
if (hasTaskId === hasWorkspaceId) {
1041+
ctx.addIssue({
1042+
code: z.ZodIssueCode.custom,
1043+
message: "Provide exactly one of taskId or workspaceId",
1044+
path: ["taskId"],
1045+
});
1046+
}
1047+
});
1048+
1049+
export const TaskWorkspaceLifecycleToolArgsSchema = z
1050+
.object({
1051+
action: TaskWorkspaceLifecycleActionSchema.describe(
1052+
'Lifecycle action to perform: "archive" is the safe default, "delete_worktree" reclaims disk after archive, and "remove" irreversibly deletes archived workspace metadata/session state.'
1053+
),
1054+
targets: z
1055+
.array(TaskWorkspaceLifecycleTargetSchema)
1056+
.min(1)
1057+
.describe(
1058+
"Parent-owned workspace-turn targets. Provide exactly one of taskId (wst_...) or workspaceId for each target."
1059+
),
1060+
interrupt_active: z
1061+
.boolean()
1062+
.nullish()
1063+
.describe(
1064+
"When true, interrupt active workspace turns for the target before performing an otherwise-eligible lifecycle action. Defaults to false."
1065+
),
1066+
force: z
1067+
.boolean()
1068+
.nullish()
1069+
.describe(
1070+
"Only applies to remove. Does not bypass ownership, active-turn, archive, or archive-confirmation safety checks."
1071+
),
1072+
acknowledged_untracked_paths: z
1073+
.record(z.string(), z.array(z.string()))
1074+
.nullish()
1075+
.describe(
1076+
"Archive-only confirmations keyed by resolved workspaceId. Use only paths returned by a previous requires_confirmation result."
1077+
),
1078+
})
1079+
.strict();
1080+
1081+
const TaskWorkspaceLifecycleBaseResultSchema = z.object({
1082+
action: TaskWorkspaceLifecycleActionSchema,
1083+
taskId: z.string().optional(),
1084+
workspaceId: z.string().optional(),
1085+
paths: z.array(z.string()).optional(),
1086+
activeTaskIds: z.array(z.string()).optional(),
1087+
note: z.string().optional(),
1088+
error: z.string().optional(),
1089+
});
1090+
1091+
export const TaskWorkspaceLifecycleToolTargetResultSchema = z.discriminatedUnion("status", [
1092+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("archived") }).strict(),
1093+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("already_archived") }).strict(),
1094+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("deleted_worktree") }).strict(),
1095+
TaskWorkspaceLifecycleBaseResultSchema.extend({
1096+
status: z.literal("already_transcript_only"),
1097+
}).strict(),
1098+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("removed") }).strict(),
1099+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("already_removed") }).strict(),
1100+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("requires_archive") }).strict(),
1101+
TaskWorkspaceLifecycleBaseResultSchema.extend({
1102+
status: z.literal("requires_confirmation"),
1103+
}).strict(),
1104+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("active") }).strict(),
1105+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("not_found") }).strict(),
1106+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("invalid_scope") }).strict(),
1107+
TaskWorkspaceLifecycleBaseResultSchema.extend({ status: z.literal("error") }).strict(),
1108+
]);
1109+
1110+
export const TaskWorkspaceLifecycleToolResultSchema = z
1111+
.object({
1112+
results: z.array(TaskWorkspaceLifecycleToolTargetResultSchema),
1113+
})
1114+
.strict();
1115+
10251116
// -----------------------------------------------------------------------------
10261117
// task_list (list descendant sub-agent tasks)
10271118
// -----------------------------------------------------------------------------
@@ -1920,6 +2011,14 @@ export const TOOL_DEFINITIONS = {
19202011
"For workflow runs (wfr_... IDs), this interrupts the run instead: durable state is preserved and the run can be resumed later with workflow_resume.",
19212012
schema: TaskTerminateToolArgsSchema,
19222013
},
2014+
task_workspace_lifecycle: {
2015+
description:
2016+
'Archive, delete the managed worktree for, or remove full workspaces that the current workspace created via task(kind="workspace"). ' +
2017+
"This tool is scoped by durable workspace-turn ownership records; it cannot act on arbitrary user workspaces. " +
2018+
'Use action="archive" as the safe default when child work is complete. Use delete_worktree only after archive to reclaim disk while preserving transcript metadata. ' +
2019+
"Use remove only for irreversible cleanup of already archived owned workspaces. Active workspace turns are refused unless interrupt_active is true, and force never bypasses ownership, archive, or confirmation checks.",
2020+
schema: TaskWorkspaceLifecycleToolArgsSchema,
2021+
},
19232022
task_list: {
19242023
description:
19252024
"List descendant tasks for the current workspace, including status + metadata. " +
@@ -2702,6 +2801,7 @@ export type BridgeableToolName =
27022801
| "task_apply_git_patch"
27032802
| "task_list"
27042803
| "task_terminate"
2804+
| "task_workspace_lifecycle"
27052805
| "heartbeat"
27062806
| "memory";
27072807

@@ -2728,6 +2828,7 @@ export const RESULT_SCHEMAS: Record<BridgeableToolName, z.ZodType> = {
27282828
task_apply_git_patch: TaskApplyGitPatchToolResultSchema,
27292829
task_list: TaskListToolResultSchema,
27302830
task_terminate: TaskTerminateToolResultSchema,
2831+
task_workspace_lifecycle: TaskWorkspaceLifecycleToolResultSchema,
27312832
heartbeat: HeartbeatToolResultSchema,
27322833
memory: MemoryToolResultSchema,
27332834
};
@@ -2834,6 +2935,7 @@ export function getAvailableTools(
28342935
"task_await",
28352936
"task_apply_git_patch",
28362937
"task_terminate",
2938+
"task_workspace_lifecycle",
28372939
"task_list",
28382940
...(enableDynamicWorkflows ? ["workflow_run", "workflow_resume"] : []),
28392941
...(enableAgentReport ? ["agent_report"] : []),

src/common/utils/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { createTaskTool } from "@/node/services/tools/task";
3030
import { createTaskApplyGitPatchTool } from "@/node/services/tools/task_apply_git_patch";
3131
import { createTaskAwaitTool } from "@/node/services/tools/task_await";
3232
import { createTaskTerminateTool } from "@/node/services/tools/task_terminate";
33+
import { createTaskWorkspaceLifecycleTool } from "@/node/services/tools/task_workspace_lifecycle";
3334
import { createTaskListTool } from "@/node/services/tools/task_list";
3435
import { createAgentSkillReadTool } from "@/node/services/tools/agent_skill_read";
3536
import { createAgentSkillReadFileTool } from "@/node/services/tools/agent_skill_read_file";
@@ -538,6 +539,7 @@ export async function getToolsForModel(
538539
task_await: wrap(createTaskAwaitTool(config)),
539540
task_apply_git_patch: wrap(createTaskApplyGitPatchTool(config)),
540541
task_terminate: wrap(createTaskTerminateTool(config)),
542+
task_workspace_lifecycle: wrap(createTaskWorkspaceLifecycleTool(config)),
541543
task_list: wrap(createTaskListTool(config)),
542544

543545
// Bash execution (foreground/background). Manage background output via task_await/task_list/task_terminate.

src/node/builtinAgents/plan.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ tools:
1919
- image_.*
2020
# Plan should not apply sub-agent patches.
2121
- task_apply_git_patch
22+
# Plan should not perform destructive workspace cleanup.
23+
- task_workspace_lifecycle
2224
# Global config and catalog tools stay out of general-purpose agents
2325
- mux_agents_.*
2426
- agent_skill_write

0 commit comments

Comments
 (0)