Commit 899a637
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
- hooks
- src
- common/utils/tools
- node
- builtinAgents
- services
- agentDefinitions
- agentSkills
- tools
- tests/ui/storybook
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
| 367 | + | |
| 368 | + | |
367 | 369 | | |
368 | 370 | | |
369 | 371 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
716 | 716 | | |
717 | 717 | | |
718 | 718 | | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
719 | 735 | | |
720 | 736 | | |
721 | 737 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
151 | 152 | | |
152 | 153 | | |
153 | 154 | | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
154 | 195 | | |
155 | 196 | | |
156 | 197 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1022 | 1022 | | |
1023 | 1023 | | |
1024 | 1024 | | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
| 1039 | + | |
| 1040 | + | |
| 1041 | + | |
| 1042 | + | |
| 1043 | + | |
| 1044 | + | |
| 1045 | + | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
| 1058 | + | |
| 1059 | + | |
| 1060 | + | |
| 1061 | + | |
| 1062 | + | |
| 1063 | + | |
| 1064 | + | |
| 1065 | + | |
| 1066 | + | |
| 1067 | + | |
| 1068 | + | |
| 1069 | + | |
| 1070 | + | |
| 1071 | + | |
| 1072 | + | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
| 1081 | + | |
| 1082 | + | |
| 1083 | + | |
| 1084 | + | |
| 1085 | + | |
| 1086 | + | |
| 1087 | + | |
| 1088 | + | |
| 1089 | + | |
| 1090 | + | |
| 1091 | + | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
| 1104 | + | |
| 1105 | + | |
| 1106 | + | |
| 1107 | + | |
| 1108 | + | |
| 1109 | + | |
| 1110 | + | |
| 1111 | + | |
| 1112 | + | |
| 1113 | + | |
| 1114 | + | |
| 1115 | + | |
1025 | 1116 | | |
1026 | 1117 | | |
1027 | 1118 | | |
| |||
1920 | 2011 | | |
1921 | 2012 | | |
1922 | 2013 | | |
| 2014 | + | |
| 2015 | + | |
| 2016 | + | |
| 2017 | + | |
| 2018 | + | |
| 2019 | + | |
| 2020 | + | |
| 2021 | + | |
1923 | 2022 | | |
1924 | 2023 | | |
1925 | 2024 | | |
| |||
2702 | 2801 | | |
2703 | 2802 | | |
2704 | 2803 | | |
| 2804 | + | |
2705 | 2805 | | |
2706 | 2806 | | |
2707 | 2807 | | |
| |||
2728 | 2828 | | |
2729 | 2829 | | |
2730 | 2830 | | |
| 2831 | + | |
2731 | 2832 | | |
2732 | 2833 | | |
2733 | 2834 | | |
| |||
2834 | 2935 | | |
2835 | 2936 | | |
2836 | 2937 | | |
| 2938 | + | |
2837 | 2939 | | |
2838 | 2940 | | |
2839 | 2941 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
538 | 539 | | |
539 | 540 | | |
540 | 541 | | |
| 542 | + | |
541 | 543 | | |
542 | 544 | | |
543 | 545 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
22 | 24 | | |
23 | 25 | | |
24 | 26 | | |
| |||
0 commit comments