🤖 feat: simplify persistent sub-agent lifecycle - #3825
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
@codex review Please review the persistent sub-agent lifecycle, parent-scoped cleanup, and chat decoration experience. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 140059526d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
1400595 to
56c3bba
Compare
|
Addressed the Codex P1 downgrade concern: config load now migrates both missing and legacy |
|
@codex review Please re-review the downgrade-safe persistence migration and the complete updated diff. |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
d082dc4 to
fab594e
Compare
|
@codex review Please review the one-time retention migration marker, restored opt-out compatibility, workspace-turn ownership check, and complete updated diff at the latest commit. |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
fab594e to
ec23165
Compare
|
@codex review Please review the final parent-facing lifecycle contract in the system prompt, task/list/terminate/lifecycle tool descriptions, and deep-review skill alongside the implementation. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ec231657e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed the sticky lifecycle mismatch: parent/system/tool/schema guidance now states that an unarchived sticky child blocks ancestor archive and must receive its own archive/remove action first. This matches the existing |
|
@codex review Please re-review the corrected sticky lifecycle contract and complete latest diff. |
ec23165 to
dd5c583
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd5c583375
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed the descendant-orphaning P1: |
dd5c583 to
a8726bc
Compare
|
@codex review Please re-review descendant-safe lifecycle removal and the complete latest diff. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8726bcd55
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed both latest lifecycle findings:
Added direct-force, retention-opt-out, service lifecycle, and sequential batch regressions. Focused tests, typecheck, and |
|
Addressed Codex thread |
|
@codex review Please review the latest head, including full-path owner-scoped attention preservation for your latest P2 finding. |
There was a problem hiding this comment.
💡 Codex Review
https://github.com/coder/mux/blob/2678ed3f0670ae1a8f9a50cbafac35fc304c31b9/src/node/services/taskService.ts#L5565
Contain terminal-notification recovery failures
When startup finds a terminal notify_on_terminal workspace turn whose marker is missing, this enqueue writes into the owner’s session directory; a permission or I/O failure rejects recoverTerminalWorkspaceTurnAttentionNotifications() and then TaskService.initialize(), preventing the app from starting. Fresh evidence at the current head is that direct-parent replay is now caught per record, but this subsequent enqueue remains outside that catch. Keep this recovery best-effort as well.
AGENTS.md reference: AGENTS.md:L112-L112
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$1543.70`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=1543.70 -->
|
Addressed Codex thread |
|
@codex review Please review the latest head, including owner-specific foreground waiter wake suppression for your latest P2 finding. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9d964605f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$1550.23`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=1550.23 -->
|
Addressed Codex thread |
|
@codex review Please review the latest head, including outcome-versioned corrected workspace-turn attention for your P1 finding. |
There was a problem hiding this comment.
💡 Codex Review
https://github.com/coder/mux/blob/74dd14f46eba3e3c75d7c41433b6d748591dba78/src/node/services/taskService.ts#L5566-L5570
Recover corrected attention with the outcome version
If the process exits after a self-healed outcome is persisted but before the post-lock corrected notification is enqueued, the stale outcome's delivered/superseded legacy tombstone remains. Startup recovery enqueues only the unversioned workspace_turn:<handleId> ID, so enqueueIfAbsent() finds that tombstone, while recovery still records terminalAttentionNotifiedAt; the corrected outcome then never wakes the idle owner. Use the corrected record's outcome-versioned generation ID in this recovery path as well.
AGENTS.md reference: AGENTS.md:L111-L112
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$1561.58`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=1561.58 -->
|
Addressed the latest Codex P1 ( |
|
@codex review Please review the latest head, including outcome-versioned and failure-contained startup recovery for corrected workspace-turn attention. |
There was a problem hiding this comment.
💡 Codex Review
https://github.com/coder/mux/blob/36c712181f405f20c8e3eb3cac7936bcf1098e71/src/node/services/taskService.ts#L2560
Contain unreadable attention directories during startup
When any session's terminal-attention directory cannot be read, such as from a permission or I/O error, listPendingOwnerWorkspaceIds() propagates the failure because its per-owner listPending() call only handles ENOENT; this unguarded await then rejects TaskService.initialize() and prevents the app from starting. The new per-record workspace-turn catch does not contain this subsequent startup scan, so isolate failures by owner or wrap the scan and continue.
AGENTS.md reference: AGENTS.md:L112-L112
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$1574.17`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=1574.17 -->
|
Addressed Codex thread |
|
@codex review Please review the latest head, including aligned outcome-versioned workspace-turn attention across ordinary settlement and recovery. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 869f0386e1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$1586.01`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=1586.01 -->
|
Addressed Codex thread |
|
@codex review Please review the latest head, including terminal outcome preservation across policy-only notify-on-terminal updates. |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
## Summary Adds deterministic, no-live-LLM validation that persistent sub-agents remain seamless across chat compaction. Mechanical compact turns no longer advance child lifecycle state, reawakened continuation correlation and agent-initiated attribution survive compaction, and the mock AI runtime now mirrors real stream metadata closely enough for a true backend integration test. The PR is also a net maintainability reduction: it removes the unused generic archive/unarchive/delete-worktree TaskService backend left behind after persistent sub-agents adopted the public active → inactive → removed lifecycle. The supported `task_remove` path now uses one direct, parent-scoped persistent-child removal implementation. ## Background The persistent sub-agent foundation from #3825 is merged into `main`. A child compaction stream was visible directly to `TaskService` and could be mistaken for the end of delegated work, moving an active child toward completion recovery. Mock streams also dropped workspace-turn correlation, preventing high-confidence end-to-end coverage for a reawakened persistent child. The old generic lifecycle methods had no production callers; only TaskService tests exercised them. Historical `task_workspace_lifecycle` schemas and transcript rendering remain intact for old chats. ## Implementation - Ignore compact-agent/mode stream ends in `TaskService`; only the post-compaction delegated follow-up may settle the child or continuation handle. - Preserve `agentInitiated` and resolved workspace-turn correlation on persisted compaction follow-ups and their crash-safe dispatch/retry path. - Carry agent identity, thinking level, and mux correlation through mock stream start, partial/final history, and stream-end events. - Replace the unused generic workspace lifecycle resolver/lock/archive/worktree operations with direct persistent-child removal that verifies scope, rejects active/non-leaf children, persists idempotency tombstones, and removes under the task-tree lock. - Delete lifecycle-only tests while retaining focused coverage for scope, deepest-first removal, active-state rejection, reawakening races, and idempotent retries. - Add layered compaction regression coverage from focused unit tests through a ServiceContainer/IPC integration test. ## Validation - `make static-check` - `bun test src/node/services/taskService.test.ts` - `bun test src/node/services/agentSession.autoCompaction.test.ts src/node/services/agentSession.workspaceTurnInheritance.test.ts src/node/services/agentSession.continueMessageAgentId.test.ts src/node/services/mock/mockAiStreamPlayer.test.ts` - `bun test src/node/services/tools/task_remove.test.ts` - `TEST_INTEGRATION=1 bun x jest tests/ipc/tasks/persistentSubagentCompaction.test.ts --runInBand` - `git diff --numstat origin/main...HEAD`: **790 additions, 1,239 deletions, net −449 LoC** No test invokes a live LLM API. ## Risks Low-to-moderate product risk. Compaction changes are narrowly scoped and covered end-to-end. Lifecycle deletion removes backend methods with no production callers; the supported `task_remove` path retains scope, activity, descendant, race, and idempotency protections. Historical transcript rendering remains compatible. --- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh` • Cost: `$88.81`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh costs=88.81 -->
The tool was removed from the executable toolset by #3825 as collateral of the sub-agent lifecycle consolidation, leaving orchestrating agents unable to archive peer workspaces created via task(kind="workspace"). Restore it with only the reversible verbs: - Live input schema (TaskWorkspaceLifecycleToolInputSchema) exposes only archive/unarchive; the historical args schema stays intact so old transcripts still parse. delete_worktree/remove stay non-invocable; task_remove remains the sole irreversible verb. - taskService: restore archiveOwnedWorkspaceTurnWorkspace + helpers from 88580ca^ and add the previously unimplemented unarchiveOwnedWorkspaceTurnWorkspace. Authorization uses durable workspace-turn ownership records (taskHandleStore.isWorkspaceOwnedBy). Unarchive never interrupts active turns, even as defense-in-depth. - Register tool + availability + PTC bridging; remove from explore/plan/ desktop agent allowlists; preserve output in shared transcripts. - Frontend untouched: renderer/result schema survived the removal.
…eer workspaces (coder#3940) ## Summary Restores the `task_workspace_lifecycle` tool — removed from the executable toolset by coder#3825 as collateral of the sub-agent lifecycle consolidation — trimmed to the two reversible verbs `archive` and `unarchive`, scoped strictly to workspace-turn peer workspaces the calling workspace created via `task(kind="workspace")`. ## Background Orchestrating agents can create peer workspaces with `task({ kind: "workspace" })` but had no tool to archive them, so loop skills (e.g. `issue-triage-loop`) that still instruct agents to call `task_workspace_lifecycle` accumulated un-archivable peer workspaces in the sidebar. The Zod result schemas and the transcript renderer (`WorkspaceLifecycleToolCall`) survived the removal, so this is mostly a resurrection from `88580ca7d^` — `unarchive` is the only genuinely new backend surface (the historical tool never implemented it). ## Implementation - **Narrowed live input schema** (`TaskWorkspaceLifecycleToolInputSchema`): only `archive`/`unarchive`, no `force`. The historical 4-action args schema stays untouched so old transcripts still parse; `delete_worktree`/`remove` are not model-invocable — `task_remove` remains the sole irreversible verb. - **Backend** (`taskService.ts`): `archiveOwnedWorkspaceTurnWorkspace` + helpers restored verbatim from `88580ca7d^`; new `unarchiveOwnedWorkspaceTurnWorkspace` mirrors it with interruption hard-disabled (defense-in-depth: an archived workspace should never have active turns; if a race surfaces one, unarchive reports `active` instead of interrupting). - **Authorization** uses durable workspace-turn ownership records (`taskHandleStore.isWorkspaceOwnedBy`) as the sole source of truth; workspace config tags are hints only. - **Lock layering**: a dedicated per-target `workspaceLifecycleLocks` MutexMap wraps the flow; `workspaceService.archive` internally takes the task-tree lifecycle lock for the same key, so an invariant comment forbids calling these helpers while holding `withTaskTreeLifecycleLock` (same-key non-reentrant acquisition would deadlock). - **Registration**: tool factory, `baseTools`, PTC bridging (`BridgeableToolName` + `RESULT_SCHEMAS`), `PRESERVE_OUTPUT_TOOLS` for shared transcripts; removed from explore/plan/desktop agent allowlists (+ regenerated builtin agent/skill/docs artifacts). Zero frontend changes. ## Validation Live dogfooding in a `dev-server-sandbox` instance (screenshots/video in the workspace transcript): | Flow | Result | |---|---| | `task(kind="workspace")` → `archive` | `archived`; peer hidden from sidebar, listed under project-page Archived Workspaces | | Archive a non-owned workspace | `invalid_scope` | | `unarchive` by `wst_` handle | `unarchived`; peer restored to sidebar | | `task(mode:"existing")` follow-up post-unarchive | succeeds (refused while archived) | | Untracked file in worktree → archive | `requires_confirmation` with paths → re-call with `acknowledged_untracked_paths` → `archived` | Targeted suites: tool layer (6), taskService lifecycle (9, incl. concurrent-handle serialization and the archive→refusal→unarchive round-trip), schema gate rejecting `remove`/`delete_worktree`/`force`, renderer UI tests (21). The 3 pre-existing `taskService.test.ts` failures reproduce identically on a clean-HEAD probe worktree. ## Risks Low-to-moderate, contained to agent-driven workspace lifecycle: the archive path re-reads metadata under the per-target lock (idempotency covered by tests), and misuse surfaces as safe statuses (`invalid_scope`/`active`/`requires_confirmation`) rather than destructive actions. The riskiest surface — irreversible removal — is deliberately not restored. --- <details> <summary>📋 Implementation Plan</summary> # Restore `task_workspace_lifecycle`: agent-driven archive/unarchive of workspace-turn peer workspaces ## Problem An orchestrating agent can create peer workspaces with `task({ kind: "workspace" })` (workspace turns), but has **no tool to archive them**. The purpose-built tool `task_workspace_lifecycle` (added in PR coder#3633) was removed from the executable toolset by PR coder#3825 (`88580ca7d`, "simplify persistent sub-agent lifecycle") as collateral of the sub-agent lifecycle consolidation. Loop skills (e.g. `issue-triage-loop`) still instruct agents to call it, and orchestrator workflows accumulate un-archivable peer workspaces in the sidebar. ## Design decision Restore `task_workspace_lifecycle` as an executable tool, **trimmed to `archive` + `unarchive`**, scoped strictly to workspace-turn targets owned by the calling workspace. Do **not** overload `task_remove` (its model-facing contract is "irreversible removal of inactive sub-agents"; archive is reversible — mixing them invites destructive model mistakes and bloats a clean result schema). Key facts making this cheap (verified on HEAD and at `88580ca7d^`): - Ownership tracking **already exists**: `taskHandleStore.isWorkspaceOwnedBy(ownerWorkspaceId, workspaceId)` (HEAD `src/node/services/taskHandleStore.ts:247`) checks `createdWorkspace` flags on durable workspace-turn handle records stored under the **owner's** session dir (never pruned, so ownership cannot silently expire). **Authorization must use these handle records as the sole source of truth.** The `mux.taskOwnerWorkspaceId` / `mux.taskHandleId` / `mux.taskTurnId` tags stamped on created workspaces (`src/constants/workspaceTags.ts`) are correlation/recovery/UI hints only — config metadata is not an authorization surface. - The Zod schemas survive on HEAD (`src/common/utils/tools/toolDefinitions.ts` ~1221: `TaskWorkspaceLifecycle*`), including `unarchive` action + `unarchived`/`already_unarchived` statuses. - The transcript renderer survives on HEAD (`src/browser/features/Tools/WorkspaceLifecycleToolCall.tsx`, registered in `getToolComponent.ts:227`, icon in `ToolPrimitives.tsx:288`) → **zero frontend work**. - The deleted backend (`archiveOwnedWorkspaceTurnWorkspace`, `resolveOwnedWorkspaceLifecycleTarget`, helpers) and tool file (134 lines) + test file (142 lines) are recoverable via `git show '88580ca7d^:<path>'`. - `workspaceService.archive(workspaceId, acknowledgedUntrackedPaths?)` (HEAD `workspaceService.ts:7237`) already handles lifecycle locking, init-abort, active-descendant refusal, and the lossy-snapshot untracked-file confirmation; `workspaceService.unarchive` exists (exposed via ORPC `workspace.unarchive`, `router.ts:4516`). - Historical gap: the pre-removal action enum was only `["archive","delete_worktree","remove"]` — **`unarchive` never had an implementation**; the schema on HEAD is already extended for it. `unarchiveOwnedWorkspaceTurnWorkspace` is the only genuinely new backend code. ### Alternatives considered (rejected) | Approach | Why rejected | Net LoC | |---|---|---| | Extend `task_remove` with an `action`/archive mode | Semantic mismatch (irreversible vs reversible), result-schema bloat (`requires_confirmation`, `already_archived`), re-muddies coder#3825's separation of execution state vs retention | ~+120 | | New `task_archive` tool name | Requires new renderer + registration wiring; breaks `issue-triage-loop` skill compat; historical transcripts diverge from live tool | ~+260 | | Out-of-band `xum api workspace archive` via bash | Requires reachable server + auth token inside worktree workspaces (typically absent); no ownership scoping — any workspace could archive anything; no transcript card | 0 (unsupported) | | **Restore trimmed `task_workspace_lifecycle` (chosen)** | Mostly resurrection; schemas + renderer already on HEAD; skill-compatible | **~+320 product code** (plus ~200 test) | ## Scope In scope: - `archive` and `unarchive` actions for workspace-turn targets (`wst_*` handle IDs or owned peer `workspaceId`s). - Ownership enforcement, active-turn handling (`interrupt_active`), untracked-file confirmation round-trip. - Tool registration (definitions, factory map, availability lists), tests, dogfooding. Out of scope (explicitly not restored): - `remove` / `delete_worktree` actions — `task_remove` stays the only irreversible verb; these can be resurrected later by the same recipe. - Archive of sub-agent (agent-task) children — coder#3825 deliberately replaced that with the active→inactive→removed lifecycle; non-`wst_` task IDs stay `invalid_scope`. - Auto-unarchive-on-send for workspace turns (explicit `unarchive` instead; `createWorkspaceTurn` mode `"existing"` keeps refusing archived targets). - UI changes. ## Implementation Recovery source for all restored code: `git show '88580ca7d^:<path>'`. ### Phase 1 — Schema & tool definition (`src/common/utils/tools/toolDefinitions.ts`) 1. **Add a narrowed live-input schema** next to the existing kept schemas (~line 1249). Do **not** modify `TaskWorkspaceLifecycleActionSchema` (4 actions) or `TaskWorkspaceLifecycleToolArgsSchema` — both are still referenced by the result schema and the renderer's parsing of historical transcripts (`getToolComponent.ts:229`, `src/common/types/tools.ts:309`): ```ts export const TaskWorkspaceLifecycleToolInputSchema = z.object({ action: z.enum(["archive", "unarchive"]).describe(/* reversible archive/unarchive only */), targets: z.array(TaskWorkspaceLifecycleTargetSchema).min(1).describe(/* wst_* taskId or owned workspaceId */), interrupt_active: z.boolean().nullish().describe(/* archive only */), acknowledged_untracked_paths: z.record(z.string(), z.array(z.string())).nullish(), }).strict(); ``` Reuses `TaskWorkspaceLifecycleTargetSchema` (exactly-one-of taskId/workspaceId superRefine). Drops `force` (only applied to the un-restored `remove` action). All optional params `.nullish()` per repo tool-schema convention. Split rationale: the broad historical args schema + `TaskWorkspaceLifecycleToolArgs` type (`src/common/types/tools.ts:309`) stay untouched for historical-transcript parsing and renderer compatibility; the narrowed input schema is what `TOOL_DEFINITIONS.task_workspace_lifecycle.schema` advertises to models. Export a live input type in `types/tools.ts` only if the tool file needs it — do not repoint the existing type. 2. **Add the `TOOL_DEFINITIONS` entry** (~line 2286, adjacent to `task_remove`), description rewritten for the trimmed contract: - reversible archive/unarchive of full workspaces this workspace created via `task(kind="workspace")`; - scoped by durable workspace-turn ownership records — cannot act on arbitrary user workspaces or sub-agent children; - active workspace turns are refused unless `interrupt_active: true`; - archive may return `requires_confirmation` with untracked paths → re-call with `acknowledged_untracked_paths`; - archived targets refuse `task(kind="workspace", mode="existing")` follow-ups until unarchived. 3. **Register availability**: add `"task_workspace_lifecycle"` to the `baseTools` array in `getAvailableTools` (~line 3349, next to `task_remove`). 4. **PTC bridging parity** (2 lines): add `"task_workspace_lifecycle"` to the `BridgeableToolName` union (~3198) and `RESULT_SCHEMAS` (~3228, → `TaskWorkspaceLifecycleToolResultSchema`) so RLM/PTC sandbox sessions get the same task-tool surface as `task_remove`. ### Phase 2 — Backend restoration (`src/node/services/taskService.ts`) No name collisions on HEAD; `coerceNonEmptyString`, `WORKSPACE_TURN_TASK_TAGS`, `isWorkspaceArchived`, `TaskWorkspaceLifecycleToolTargetResultSchema` import, and the `WorkspaceLifecycleResult` type alias (line 213) are already present. Restore verbatim-then-adapt from `88580ca7d^:src/node/services/taskService.ts`: 1. `private readonly workspaceLifecycleLocks = new MutexMap<string>()` (historical line 1200) — a dedicated per-target-workspace lock **outside** `workspaceService.archive`. Do not wrap the call in `withTaskTreeLifecycleLock` externally: `archive` already takes that lock internally for the same key, and a non-reentrant same-key acquisition would deadlock. **Implementation invariant (enforce via code comment on the lock field): no code path may acquire the task-tree lifecycle lock and then call these workspace-lifecycle helpers.** 2. `resolveOwnedWorkspaceLifecycleTarget(ownerWorkspaceId, action, target)` (historical 7401) — resolves `wst_*` handle → workspaceId via `taskHandleStore.getWorkspaceTurn` (HEAD :173), then gates on `taskHandleStore.isWorkspaceOwnedBy` (HEAD :247; records are never pruned, so ownership cannot silently expire); non-`wst_` taskIds and non-owned workspaceIds → `invalid_scope`. 3. `withWorkspaceLifecycleLock` (historical 7388), `lifecycleTargetFields` (historical ~7454), `findWorkspaceLifecycleMetadata` (historical ~7473). 4. `handleActiveWorkspaceLifecycleTurns` (historical 7495) — both callees survive on HEAD: `listWorkspaceTurnTasks` (:8496) and `interruptWorkspaceTurn` (:8512). Filters owner's turns with status `queued|starting|running` targeting the resolved workspace; returns `active` + `activeTaskIds` unless `interruptActive`, in which case it interrupts each turn. 5. `archiveOwnedWorkspaceTurnWorkspace(ownerWorkspaceId, target, options)` (historical 7208) — under the lifecycle lock: `not_found` (metadata absent) / `already_archived` (idempotent) / active-turn handling / `workspaceService.archive(workspaceId, acknowledgedUntrackedPaths)`; maps `kind: "confirm-lossy-untracked-files"` → `requires_confirmation` + `paths`. `workspaceService.archive` errors surface as `status: "error"` (includes the active-descendant-sub-agent refusal, `ACTIVE_DESCENDANT_ARCHIVE_ERROR`). 6. **New** `unarchiveOwnedWorkspaceTurnWorkspace(ownerWorkspaceId, target)` (~30 lines, mirrors archive): resolve + lock → `not_found` / `already_unarchived` (via `isWorkspaceArchived`) → `workspaceService.unarchive(workspaceId)` (HEAD :7514, `Result<void>`) → `unarchived` | `error`. **`interrupt_active` applies to `archive` only.** An archived workspace should never have active turns (archive refuses while active; `createWorkspaceTurn` refuses archived targets), but as defense-in-depth unarchive still runs the active-turn check with interruption hard-disabled: if a race/corruption surfaces an active turn, return `active` — never interrupt on unarchive, even if the caller passed `interrupt_active: true`. Document this in the tool description. ### Phase 3 — Tool file + registration 1. **Restore `src/node/services/tools/task_workspace_lifecycle.ts`** (134 lines historical → ~100 trimmed): keep `normalizeTarget`/`targetKey` dedup and `rejectInvalidWorkspaceTaskId` (non-`wst_` → `invalid_scope`); dispatch only `archive` → `archiveOwnedWorkspaceTurnWorkspace` and `unarchive` → `unarchiveOwnedWorkspaceTurnWorkspace`; keep the `planFileOnly` throw (same pattern as `task_remove.ts:17`); input schema = the new narrowed `TaskWorkspaceLifecycleToolInputSchema`; results parsed against the kept `TaskWorkspaceLifecycleToolResultSchema`. 2. **`src/common/utils/tools/tools.ts`**: import `createTaskWorkspaceLifecycleTool` (~line 42) and register `task_workspace_lifecycle: wrap(createTaskWorkspaceLifecycleTool(config))` (~line 773). 3. **Agent allowlists** (`src/node/builtinAgents/`): add `task_workspace_lifecycle` to `tools.remove` in `explore.md` (line ~30, alongside `task_remove`), `plan.md` (line ~23), and `desktop.md` (line ~43) so read-only/plan/desktop agents can't mutate workspace lifecycle. Regeneration is automatic: the Make rule `$(BUILTIN_AGENTS_GENERATED): src/node/builtinAgents/*.md scripts/generate-builtin-agents.sh` (Makefile:246) rebuilds `builtInAgentContent.generated.ts`, and `make typecheck` (part of `static-check`) lists it as a prerequisite — so a stale generated file cannot pass validation. Commit the regenerated file. No changes to the policy engine — it filters dynamically by name. 4. **`src/common/utils/messages/transcriptShare.ts`** (~line 133): add `"task_workspace_lifecycle"` to `PRESERVE_OUTPUT_TOOLS` so shared transcripts keep target-status rows. 5. **`src/browser/features/Settings/Sections/TasksSection.agents.ts`** (~line 62): add to the agent-template remove arrays where `task_remove` appears, for template parity. 6. **Frontend**: zero changes — `WorkspaceLifecycleToolCall.tsx` already renders all statuses including `unarchived`/`already_unarchived` (exhaustive `Record<TaskWorkspaceLifecycleStatus, StatusMeta>`, lines 101–125) with existing UI tests. ### Phase 4 — Tests (behavioral, no tautologies) Restore + adapt `src/node/services/tools/task_workspace_lifecycle.test.ts` (historical file; all utilities — `TestTempDir`, `createTestToolConfig`, `mockToolCallOptions` — survive on HEAD in `tools/testHelpers.ts`): - Tool layer: forwards owner/target/options to the scoped taskService API; dedupes duplicate targets; non-`wst_` taskId → `invalid_scope` without touching taskService; `planFileOnly` throws; unarchive routes to the unarchive method (replaces the historical delete_worktree/remove routing test); `acknowledged_untracked_paths` forwarding when the target is specified by `taskId` (not `workspaceId`) — the tool must pass the full by-workspaceId map so the backend can apply it after handle→workspaceId resolution. - taskService layer (in `taskService.test.ts`, alongside existing workspace-turn tests): - non-owned workspaceId → `invalid_scope`; owned (createdWorkspace record) → proceeds. - archive idempotency (`already_archived`) and unarchive idempotency (`already_unarchived`). - active turn → `active` + `activeTaskIds`; with `interrupt_active: true` → interrupts then archives. - `workspaceService.archive` returning `confirm-lossy-untracked-files` → `requires_confirmation` with paths; second call with `acknowledged_untracked_paths` → `archived`. - archive → `createWorkspaceTurn(mode: "existing")` refused; after unarchive → succeeds again (the round-trip that motivates unarchive). - unarchive addressed by `wst_*` taskId and by `workspaceId` (both resolution paths). - unarchive never interrupts: with a (synthetic) active turn present, unarchive returns `active` even when `interrupt_active: true`. - Schema tests (`toolDefinitions.test.ts`): live input schema rejects `remove`/`delete_worktree` actions and rejects `force` (behavioral gate: irreversible verbs and their escape hatch must not be model-invocable through this tool). Built-in agent remove-lists are covered by codegen + typecheck staleness (above); add an agent-definition test only if an existing suite already asserts remove-lists for `task_remove` (follow precedent, don't invent a new tautology). ### Phase 5 — Validation & dogfooding gate 1. `make static-check` + targeted suites: `bun test src/node/services/tools/task_workspace_lifecycle.test.ts src/node/services/taskService.test.ts src/common/utils/tools/toolDefinitions.test.ts` and the renderer UI test (`WorkspaceLifecycleToolCall.ui.test.tsx`) to confirm no schema drift. 2. Live dogfooding (see next section) before declaring done. ## Dogfooding Environment: `make dev-server-sandbox` (project skill `dev-server-sandbox`) — the **web** dev server, not the Electron desktop app. Isolated temp `XUM_ROOT`, free backend + Vite ports, seeded `providers.jsonc`/`config.json`. Run as a monitored background bash task (`filter: "ready|listening|localhost|ERROR|EADDRINUSE|failed|Failed"`, `timeout_secs: 1800`). **This environment is already validated in this workspace** (pre-flight run on the unmodified branch): sandbox boots in ~1 min (temp root `/tmp/mux-dev-server-*`, backend `127.0.0.1:<port>`, Vite `127.0.0.1:<port>`), and `agent-browser --session <s> open http://127.0.0.1:<vite-port>/` connects headlessly — no Electron, no Xvfb. The app renders the full sidebar and, on the project page, an **"Archived Workspaces (N)"** section — the exact UI surface that verifies archive state transitions. Seeded `config.json` contains real projects; dogfood against a scratch project (or launch with `DEV_SERVER_SANDBOX_ARGS="--clean-projects"` and add one). agent-browser flow (per `agent-browser` core + `dogfood` skills): `snapshot -i` for element refs → `click`/`fill` by `@eN` ref → re-snapshot after page changes; `screenshot <file>.png` for stills; `record start/stop <file>.webm` for videos (per the agent-browser/dogfood skill workflow); `errors`/`console` for renderer errors after each step. Evidence requirements (per `dogfood` skill: repro-first, evidence per step): **screenshots per step AND a `record start`/`record stop` webm video** of the core archive→unarchive flow, paced human-watchably (`sleep 1` between actions). All evidence attached via `attach_file` for reviewer verification. Script (each step evidenced with an `agent-browser` screenshot, attached via `attach_file`): 1. In the sandbox app, create a parent workspace on a scratch project; send it a prompt instructing: “create a peer workspace via `task({kind:"workspace", ...})`, wait for its turn to settle, then call `task_workspace_lifecycle({action:"archive", targets:[{workspaceId:...}]})`”. 2. Verify: peer workspace appears in the sidebar → after archive it disappears from the active sidebar and shows as archived on the project page. Screenshot both states. 3. Negative check: instruct the agent to archive a workspace it did not create → expect `invalid_scope` in the tool card. Screenshot. 4. Untracked-file confirmation: create the peer workspace, drop an untracked file in its worktree (with snapshot archive behavior enabled), archive → expect `requires_confirmation` card listing paths; re-call with `acknowledged_untracked_paths` → archived. Screenshot the confirmation card. 5. Unarchive: instruct `{action:"unarchive"}` → workspace returns to the sidebar; then a `task(kind="workspace", mode="existing")` follow-up succeeds. Screenshot. 6. Transcript card sanity: confirm the `WorkspaceLifecycleToolCall` card renders archived/unarchived/invalid_scope statuses correctly in the real app (not just Storybook). Deliverables: screenshots per step; if a step can't run headless, fall back to the integration-test equivalent and state exactly which steps were verified live vs. by test. ## Acceptance criteria 1. A parent agent can archive a peer workspace it created via `task(kind="workspace")` using `task_workspace_lifecycle({action:"archive"})`, and unarchive it with `{action:"unarchive"}`. 2. Non-owned workspaces, arbitrary user workspaces, and sub-agent (non-`wst_`) task IDs are refused with `invalid_scope`. 3. Active workspace turns block archive unless `interrupt_active: true` (which interrupts, then archives); unarchive never interrupts regardless of `interrupt_active`. 4. Lossy-snapshot archive requires the `requires_confirmation` → `acknowledged_untracked_paths` round-trip; `force` no longer exists on the input schema. 5. Archived targets refuse `mode:"existing"` follow-ups; unarchive restores them. 6. `remove`/`delete_worktree` are not model-invocable through this tool (schema-rejected); `task_remove` behavior unchanged. 7. Historical transcripts with old `task_workspace_lifecycle` calls still render (renderer untouched, result schema unchanged). 8. Explore/plan/desktop agents do not receive the tool; plan mode throws at execute as defense-in-depth. 9. `make static-check` green; all Phase 4 suites green; dogfooding evidence captured. 10. The `issue-triage-loop` skill's documented call `task_workspace_lifecycle({action:"archive", targets:[{workspaceId}], interrupt_active:false})` works verbatim. ## Risks & mitigations - **Lock layering** (`workspaceLifecycleLocks` wrapping `workspaceService.archive`'s internal task-tree lock): restore the historical ordering exactly; the archive path re-reads metadata after acquiring the lifecycle lock, so stale-metadata races are handled as before. Covered by the idempotency tests. - **Schema drift since removal**: the kept result schema gained `unarchived`/`already_unarchived`; the restored backend emits only statuses in the kept union, and `toolDefinitions.test.ts` already validates the schemas — renderer exhaustiveness (`Record<Status, Meta>`) fails typecheck if a status is added without UI handling. - **Model confusion with `task_remove`**: mitigated by the description contrast (reversible vs irreversible) and by `invalid_scope` for sub-agent IDs; watch Codex review for wording feedback. - **coder#3825 intent**: the removal rationale was sub-agent retention-class cleanup; this restoration keeps sub-agents out of scope entirely, so it does not reintroduce the mixed lifecycle coder#3825 eliminated. ## Estimate ~320 net LoC product code (schema ~25, taskService ~180, tool ~100, registration ~15) + ~250 LoC tests. No migrations; upgrade/downgrade safe (config format untouched; archive fields already exist). </details> --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$53.33`_ <!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh costs=53.33 -->
Summary
Makes sub-agents durable, reusable child workspaces with one consistent public lifecycle:
A report or
task_stopmakes a child inactive without deleting its workspace.task_send_messagesteers active work or reawakens an inactive child under the same stable task ID and workspace context.task_retitleupdates its short, friendly reusable role name.task_removeis the only irreversible cleanup operation.The left sidebar now shows sub-agents only while they are active; stale runtime signals cannot resurrect terminal rows. The chat transcript's sub-agent decoration is the canonical persistent hierarchy and remains available for inactive children. Redundant private-continuation wake prompts are suppressed once the stable child report/failure is in parent history, while remaining machine-authored background-work prompts render as compact expandable events.
Background
The previous task API mixed execution state with workspace retention:
task_terminatedestructively removed active sub-agents;task_workspace_lifecycleexposed archive/unarchive/worktree concepts;stickyandpreserveSubagentsUntilArchivecreated multiple retention classes;The new contract treats every user-owned sub-agent as a persistent specialist with a stable public identity and a parent-managed lifecycle.
Implementation
Uniform child identity and continuation recovery
wst_*continuation handles private.Public task operations
task_stop— cancels active work non-destructively and leaves unfinished children interrupted. Parent guidance now distinguishes cancellation from asking useful work to finalize normally.task_send_message— steers queued/running work or reawakens an inactive child in the same workspace, preserving the child's current model/thinking settings.task_retitle— changes a persistent child’s friendly role label, such asReviewerorSimplicity Auditor, without changing stable identity or filesystem paths.task_remove— irreversibly deletes inactive child workspaces, rejects active children, and removes nested children deepest-first.task_list— keeps private continuation handles hidden and adds event-driven housekeeping guidance when it encounters inactive user-owned children.task_workspace_lifecycleandtask_terminatefrom the advertised/executable toolset while retaining compatibility renderers for historical transcripts.Lifecycle, cleanup, and concurrency
task_await.Naming and UI
task_retitletranscript card, tool icon, schema/bridge registration, Storybook state, and shared-transcript output preservation.task_listcards render inactive-child cleanup guidance.Retention compatibility and simplification
stickyfrom task inputs, listing, and runtime behavior while retaining inert legacy fields for downgrade compatibility.preserveSubagentsUntilArchivefield canonicalized totruefor older builds but removes it from runtime/UI decisions.Validation
make static-checkmake typecheckRisks
task_removeis intentionally irreversible, though parent transcript/report evidence may remain.task_awaitwake; the transcript presents that control prompt compactly.Generated with
mux• Model:openai:gpt-5.6-sol• Thinking:xhigh• Cost:$1586.01