Uh oh!
There was an error while loading. Please reload this page.
🤖 feat: restore task_workspace_lifecycle with archive/unarchive for peer workspaces - #3940
🤖 feat: restore task_workspace_lifecycle with archive/unarchive for peer workspaces#3940ThomasK33 wants to merge 22 commits into
Conversation
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:035ad4d751
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit:035ad4d751
Only the user who started this review can view the report in Codex.
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Uh oh!
There was an error while loading. Please reload this page.
ThomasK33
commented
Aug 24, 2026
Addressed all four review findings in 743b93b:
|
ThomasK33
commented
Aug 24, 2026
@codex review |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:743b93b203
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
ThomasK33
commented
Aug 24, 2026
Addressed all five round-2 findings in ef03f32:
|
ThomasK33
commented
Aug 24, 2026
@codex review |
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.
…-policy guard - Serialize workspace-turn handle persistence with owned-workspace archive via the shared workspaceLifecycleLocks (archived re-check at persist time), so a follow-up can no longer slip between archive's active-turn check and its stream stop. Lock order mutex -> lifecycle lock is acyclic: the archive path never acquires the task mutex. - Archive/unarchive active-turn checks now also cover turns OWNED BY the target workspace (nested delegation); interrupt_active settles those too. - When interrupt_active is set without acknowledged paths, preflightArchive runs BEFORE any interruption so a lossy-snapshot confirmation cannot leave work terminated but the workspace unarchived. - Model-facing archive fails closed under the 'Delete checkout' worktree archive behavior (would delete the checkout without user confirmation).
…flight, sink-enforced delete guard, settled-turn tolerance - createWorkspaceTurn persists handles under sorted lifecycle locks for BOTH owner and target, so archiving a peer serializes against that peer starting nested turns; a nested turn racing its owner's archive is refused. - Archive refuses when the target has live non-turn activity (user stream, terminal PTYs, desktop session) — interrupt_active covers delegated turns only. New workspaceService.listLiveWorkspaceActivity + terminalService.hasWorkspaceSessions + DesktopSessionManager.has. - preflightArchive now runs before interruption on every interrupt_active archive (not only unacknowledged ones), surfacing blockers like active descendant sub-agents and re-confirming when acknowledged paths no longer cover the fresh untracked set. - Delete-checkout policy enforced at the sink: workspaceService.archive gains forbidWorktreeCheckoutDeletion, checked against the same behavior read that drives snapshot/deletion; that read is now passed to the afterArchive worktree hook so a keep->delete settings flip mid-archive can no longer delete a checkout that was never snapshotted. - Lifecycle interruption skips turns that settled after collection instead of aborting the archive mid-set.
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:81ef051d21
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This comment has been minimized.
This comment has been minimized.
… clean up created workspace on owner_archived refusal
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:daa3e8d0dd
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…sensitive interrupt_active, guard archived-workspace activity admission, suppress disposable cleanup on archive, reject blank acknowledged paths
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit:96ed7d8d2c
Only the user who started this review can view the report in Codex.
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:96ed7d8d2c
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…p/queue admission with archive, pin archive policy through interruption, gate on active workflow runs, defer nested disposable cleanup, serialize unarchive under tree lock
ThomasK33
commented
Aug 24, 2026
@codex review |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:6d5c00ba3b
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:62db201e4f
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…efused open reservations P1: an unreadable/missing meta.json could hide a surviving crash orphan. Now: - RuntimeBackgroundHandle.writeMeta propagates persistence failures and spawn aborts (terminating the process, whose exit trap self-heals the directory) when the initial record cannot be written — a process the crash-orphan gate cannot see must not run. - The orphan probe trusts only the exit marker for records it cannot read or parse, failing closed otherwise. - Failed spawns remove their output directory so recordless directories from spawn errors cannot permanently over-refuse archives. P2: openNative/recordExternalEditorOpen added the sticky in-memory reservation before their refusal checks, so a rejected open (archiving/archived/unknown workspace/marker failure) permanently gated model-driven archives until restart. Newly added reservations now roll back on refusal — but only until the durable marker persists, after which the Set is just a cache of it.
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:8aee92da6a
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit:8aee92da6a
Only the user who started this review can view the report in Codex.
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Uh oh!
There was an error while loading. Please reload this page.
…s, executeBash archive pairing - Restart-unique process directories (local runtimes): spawn skips display names whose durable directory may still belong to a live process from a previous session, so a surviving crash orphan's meta.json/exit_code is never shared with (and settled by) a newer same-name process. - The crash-orphan probe fails closed when the spawn-record directory itself is unreadable (only ENOENT/ENOTDIR mean no records). - Workflow activity scans used by archive gates are now strict: an unreadable run store or run record refuses archive (caller) / reads as active (sink) instead of silently reporting no runs, so a crash-recovered run cannot resume into an archived workspace. Heuristic callers keep the lenient scan. - executeBash pairs with archive admission like sends/terminals/workflows: a synchronous preflight count held for the command's duration, checked by the refuseLiveUserActivity gate, so an admitted command cannot resume against a captured/removed checkout or re-wake a stopped Coder workspace.
ThomasK33
commented
Aug 24, 2026
@codex review |
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit:d79137a33a
Only the user who started this review can view the report in Codex.
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:d79137a33a
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…hive pairing - P1: concurrent same-name spawns could both pass the in-memory allocator and the async disk checks before either registered, sharing one output directory whose meta.json/exit_code the first exit would settle under the other still- running process. Process IDs are now reserved synchronously when a candidate is chosen (reservedProcessIds), kept in sync through the disk-dedup loop, and released on registration or failure. - stageAttachment pairs with archive admission (sync archivingWorkspaces guard, archived-state refusal, preflight counter held for the upload): staging writes into the checkout a snapshot archive would capture/remove. - getFileCompletions pairs likewise (degrading to empty results): its refresh runs git through the target runtime, which could re-wake a Coder workspace the archive hook just stopped. The refresh closure holds its own admission because it can outlive the calling request. Remaining round-19 findings are tracked as follow-ups: MCP server lifecycle pairing (#3946) and staged-attachment snapshot placement (#3947).
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:a995722cc9
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Migrated background processes record pid 0 (exec streams expose no PID) and their exit marker is written by the in-process handle, not a detached trap — so after an unclean shutdown a surviving migrated child left a markerless running record the probe deliberately skipped, blinding the archive gate. The probe now fails closed on such records instead of skipping them, and skips tracked processes by ID (directory name = process ID) so live migrated processes remain owned by the in-memory gates. Clean shutdowns and natural exits still settle records via updateMetaFile / the exit marker, so only genuine unclean-exit survivors trip the gate (routing to user-mediated archive).
ThomasK33
commented
Aug 24, 2026
@codex review |
…ch, redact shared lifecycle paths - recordExternalEditorOpen requires a real config workspace entry before any filesystem work: the marker path joins the raw ID beneath the sessions directory, so unknown (possibly traversal-crafted, e.g. ../../.ssh) IDs must never reach it. Rejected IDs roll their reservation back. - The renderer records editor opens immediately before each deep-link launch, after every deterministic compatibility check (custom-in-browser, Zed/custom vs Docker/devcontainer, missing deep link), so a refused open can no longer persist a sticky durable marker that permanently gates snapshot archives. Custom-editor opens remain recorded by the backend route. - Shared transcripts with includeToolOutput=false keep lifecycle results (the card renders from statuses) but redact paths / error / note, which can name local files the exporter chose not to share.
ThomasK33
commented
Aug 24, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit:196f2d1536
Only the user who started this review can view the report in Codex.
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:196f2d1536
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
ThomasK33
commented
Aug 24, 2026
@codex review Round 22: the lossy-archive acknowledgement finding is acknowledged as a real product gap and tracked in #3950 (user-origin approval / non-lossy snapshot design, covering |
Codex Review: Didn't find any major issues. Delightful! 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". |
This comment has been minimized.
This comment has been minimized.
…, preserve browser popup activation - TerminalService.openNative and recordExternalEditorOpen now delete a durable marker they just created when the launch deterministically fails (all launchers throw only before their detached spawn), so a single failed launch can no longer permanently refuse future model-driven snapshot/Coder-stop archives. Pre-existing markers and concurrent opens' launch evidence are preserved; marker writes and rollbacks are serialized per workspace. - The custom-editor route records via recordExternalEditorOpenForLaunch and rolls the marker back when EditorService validation fails without spawning. - Browser-mode openInEditor opens a blank placeholder synchronously during the click's transient user activation and navigates it after admission, closing it on refusal; popup-blocked placeholders fall back to the legacy direct open.
ThomasK33
commented
Aug 24, 2026
@codex review Round 23 fixes pushed in 85118cf: failed native-terminal/custom-editor launches now roll back a durable marker they just created (pre-existing markers and concurrent opens' launch evidence preserved; writes/rollbacks serialized per workspace), and browser-mode editor opens consume the click's transient user activation via a synchronously opened placeholder that is navigated after admission and closed on refusal. |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:85118cf4bf
ℹ️ 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".
| // No placeholder was needed (Electron) or it was popup-blocked: fall back to a direct | ||
| // open, which shares the blocked popup's fate but never regresses it. | ||
| openUrl(deepLink); |
There was a problem hiding this comment.
Refuse browser opens when the placeholder is blocked
When browser popup blocking makes the synchronous window.open("about:blank") return null, this fallback runs only after the awaited recording RPC has consumed transient user activation, so it is also blocked. Because openUrl does not report that failure, the function returns success after persisting an editor-open marker even though no editor opened, and future model-driven Snapshot/Coder-stop archives remain refused across restarts. Refuse before recording when the placeholder is blocked, or use rollback-capable admission when the fallback cannot launch.
Useful? React with 👍 / 👎.
| this.nativeTerminalOpenTokens.set(workspaceId, tokens); | ||
| } | ||
| tokens.add(token); | ||
| return { token, markerPreexisted: preexisting !== "absent" }; |
There was a problem hiding this comment.
Track marker ancestry across concurrent failed launches
When two first-time openNative() calls reach marker admission concurrently and both launch attempts fail, the second serialized admission sees the marker created by the first call and records it as pre-existing. The first rollback then preserves the marker because the second token remains, and the second rollback preserves it because markerPreexisted is true, leaving durable and in-memory false evidence that permanently refuses later Snapshot/Coder-stop archives even though neither terminal opened. Fresh evidence after 85118cf is that the new serialization conflates an earlier in-flight marker with evidence from a prior launch; track ownership for the whole concurrent batch so its last failed token can remove the marker. recordExternalEditorOpenForLaunch() mirrors the same defect for concurrent failed custom-editor opens.
Useful? React with 👍 / 👎.
| if (options?.refuseLiveUserActivity === true) { | ||
| const liveActivity = this.listLiveWorkspaceActivity(workspaceId); |
There was a problem hiding this comment.
Wait for workspace initialization before model-driven archive
When a newly created owned workspace's delegated turn has settled but its background initialization is still running, this gate reports no live activity because initialization exists only in initStateManager/initAbortControllers. archiveUnlocked() later aborts and immediately clears that state without retaining or awaiting the runBackgroundInit() promise started fire-and-forget in create(), so Snapshot capture and checkout deletion—or a Coder stop—can proceed while the init hook is still writing or reconnecting. Count running initialization as live activity, or retain its settlement promise and await it after abort before continuing archive.
AGENTS.md reference: AGENTS.md:L150-L150
Useful? React with 👍 / 👎.
Summary
Restores the
task_workspace_lifecycletool — removed from the executable toolset by #3825 as collateral of the sub-agent lifecycle consolidation — trimmed to the two reversible verbsarchiveandunarchive, scoped strictly to workspace-turn peer workspaces the calling workspace created viatask(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 calltask_workspace_lifecycleaccumulated 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 from88580ca7d^—unarchiveis the only genuinely new backend surface (the historical tool never implemented it).Implementation
TaskWorkspaceLifecycleToolInputSchema): onlyarchive/unarchive, noforce. The historical 4-action args schema stays untouched so old transcripts still parse;delete_worktree/removeare not model-invocable —task_removeremains the sole irreversible verb.taskService.ts):archiveOwnedWorkspaceTurnWorkspace+ helpers restored verbatim from88580ca7d^; newunarchiveOwnedWorkspaceTurnWorkspacemirrors it with interruption hard-disabled (defense-in-depth: an archived workspace should never have active turns; if a race surfaces one, unarchive reportsactiveinstead of interrupting).taskHandleStore.isWorkspaceOwnedBy) as the sole source of truth; workspace config tags are hints only.workspaceLifecycleLocksMutexMap wraps the flow;workspaceService.archiveinternally takes the task-tree lifecycle lock for the same key, so an invariant comment forbids calling these helpers while holdingwithTaskTreeLifecycleLock(same-key non-reentrant acquisition would deadlock).baseTools, PTC bridging (BridgeableToolName+RESULT_SCHEMAS),PRESERVE_OUTPUT_TOOLSfor 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-sandboxinstance (screenshots/video in the workspace transcript):task(kind="workspace")→archivearchived; peer hidden from sidebar, listed under project-page Archived Workspacesinvalid_scopeunarchivebywst_handleunarchived; peer restored to sidebartask(mode:"existing")follow-up post-unarchiverequires_confirmationwith paths → re-call withacknowledged_untracked_paths→archivedTargeted 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-existingtaskService.test.tsfailures 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.📋 Implementation Plan
Restore
task_workspace_lifecycle: agent-driven archive/unarchive of workspace-turn peer workspacesProblem
An orchestrating agent can create peer workspaces with
task({ kind: "workspace" })(workspace turns), but has no tool to archive them. The purpose-built tooltask_workspace_lifecycle(added in PR #3633) was removed from the executable toolset by PR #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_lifecycleas an executable tool, trimmed toarchive+unarchive, scoped strictly to workspace-turn targets owned by the calling workspace. Do not overloadtask_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^):taskHandleStore.isWorkspaceOwnedBy(ownerWorkspaceId, workspaceId)(HEADsrc/node/services/taskHandleStore.ts:247) checkscreatedWorkspaceflags 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. Themux.taskOwnerWorkspaceId/mux.taskHandleId/mux.taskTurnIdtags stamped on created workspaces (src/constants/workspaceTags.ts) are correlation/recovery/UI hints only — config metadata is not an authorization surface.src/common/utils/tools/toolDefinitions.ts~1221:TaskWorkspaceLifecycle*), includingunarchiveaction +unarchived/already_unarchivedstatuses.src/browser/features/Tools/WorkspaceLifecycleToolCall.tsx, registered ingetToolComponent.ts:227, icon inToolPrimitives.tsx:288) → zero frontend work.archiveOwnedWorkspaceTurnWorkspace,resolveOwnedWorkspaceLifecycleTarget, helpers) and tool file (134 lines) + test file (142 lines) are recoverable viagit show '88580ca7d^:<path>'.workspaceService.archive(workspaceId, acknowledgedUntrackedPaths?)(HEADworkspaceService.ts:7237) already handles lifecycle locking, init-abort, active-descendant refusal, and the lossy-snapshot untracked-file confirmation;workspaceService.unarchiveexists (exposed via ORPCworkspace.unarchive,router.ts:4516).["archive","delete_worktree","remove"]—unarchivenever had an implementation; the schema on HEAD is already extended for it.unarchiveOwnedWorkspaceTurnWorkspaceis the only genuinely new backend code.Alternatives considered (rejected)
task_removewith anaction/archive moderequires_confirmation,already_archived), re-muddies #3825's separation of execution state vs retentiontask_archivetool nameissue-triage-loopskill compat; historical transcripts diverge from live toolxum api workspace archivevia bashtask_workspace_lifecycle(chosen)Scope
In scope:
archiveandunarchiveactions for workspace-turn targets (wst_*handle IDs or owned peerworkspaceIds).interrupt_active), untracked-file confirmation round-trip.Out of scope (explicitly not restored):
remove/delete_worktreeactions —task_removestays the only irreversible verb; these can be resurrected later by the same recipe.wst_task IDs stayinvalid_scope.unarchiveinstead;createWorkspaceTurnmode"existing"keeps refusing archived targets).Implementation
Recovery source for all restored code:
git show '88580ca7d^:<path>'.Phase 1 — Schema & tool definition (
src/common/utils/tools/toolDefinitions.ts)Add a narrowed live-input schema next to the existing kept schemas (~line 1249). Do not modify
TaskWorkspaceLifecycleActionSchema(4 actions) orTaskWorkspaceLifecycleToolArgsSchema— 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):Reuses
TaskWorkspaceLifecycleTargetSchema(exactly-one-of taskId/workspaceId superRefine). Dropsforce(only applied to the un-restoredremoveaction). All optional params.nullish()per repo tool-schema convention.Split rationale: the broad historical args schema +
TaskWorkspaceLifecycleToolArgstype (src/common/types/tools.ts:309) stay untouched for historical-transcript parsing and renderer compatibility; the narrowed input schema is whatTOOL_DEFINITIONS.task_workspace_lifecycle.schemaadvertises to models. Export a live input type intypes/tools.tsonly if the tool file needs it — do not repoint the existing type.Add the
TOOL_DEFINITIONSentry (~line 2286, adjacent totask_remove), description rewritten for the trimmed contract:task(kind="workspace");interrupt_active: true;requires_confirmationwith untracked paths → re-call withacknowledged_untracked_paths;task(kind="workspace", mode="existing")follow-ups until unarchived.Register availability: add
"task_workspace_lifecycle"to thebaseToolsarray ingetAvailableTools(~line 3349, next totask_remove).PTC bridging parity (2 lines): add
"task_workspace_lifecycle"to theBridgeableToolNameunion (~3198) andRESULT_SCHEMAS(~3228, →TaskWorkspaceLifecycleToolResultSchema) so RLM/PTC sandbox sessions get the same task-tool surface astask_remove.Phase 2 — Backend restoration (
src/node/services/taskService.ts)No name collisions on HEAD;
coerceNonEmptyString,WORKSPACE_TURN_TASK_TAGS,isWorkspaceArchived,TaskWorkspaceLifecycleToolTargetResultSchemaimport, and theWorkspaceLifecycleResulttype alias (line 213) are already present.Restore verbatim-then-adapt from
88580ca7d^:src/node/services/taskService.ts:private readonly workspaceLifecycleLocks = new MutexMap<string>()(historical line 1200) — a dedicated per-target-workspace lock outsideworkspaceService.archive. Do not wrap the call inwithTaskTreeLifecycleLockexternally:archivealready 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.resolveOwnedWorkspaceLifecycleTarget(ownerWorkspaceId, action, target)(historical 7401) — resolveswst_*handle → workspaceId viataskHandleStore.getWorkspaceTurn(HEAD :173), then gates ontaskHandleStore.isWorkspaceOwnedBy(HEAD :247; records are never pruned, so ownership cannot silently expire); non-wst_taskIds and non-owned workspaceIds →invalid_scope.withWorkspaceLifecycleLock(historical 7388),lifecycleTargetFields(historical ~7454),findWorkspaceLifecycleMetadata(historical ~7473).handleActiveWorkspaceLifecycleTurns(historical 7495) — both callees survive on HEAD:listWorkspaceTurnTasks(:8496) andinterruptWorkspaceTurn(:8512). Filters owner's turns with statusqueued|starting|runningtargeting the resolved workspace; returnsactive+activeTaskIdsunlessinterruptActive, in which case it interrupts each turn.archiveOwnedWorkspaceTurnWorkspace(ownerWorkspaceId, target, options)(historical 7208) — under the lifecycle lock:not_found(metadata absent) /already_archived(idempotent) / active-turn handling /workspaceService.archive(workspaceId, acknowledgedUntrackedPaths); mapskind: "confirm-lossy-untracked-files"→requires_confirmation+paths.workspaceService.archiveerrors surface asstatus: "error"(includes the active-descendant-sub-agent refusal,ACTIVE_DESCENDANT_ARCHIVE_ERROR).unarchiveOwnedWorkspaceTurnWorkspace(ownerWorkspaceId, target)(~30 lines, mirrors archive): resolve + lock →not_found/already_unarchived(viaisWorkspaceArchived) →workspaceService.unarchive(workspaceId)(HEAD :7514,Result<void>) →unarchived|error.interrupt_activeapplies toarchiveonly. An archived workspace should never have active turns (archive refuses while active;createWorkspaceTurnrefuses 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, returnactive— never interrupt on unarchive, even if the caller passedinterrupt_active: true. Document this in the tool description.Phase 3 — Tool file + registration
src/node/services/tools/task_workspace_lifecycle.ts(134 lines historical → ~100 trimmed): keepnormalizeTarget/targetKeydedup andrejectInvalidWorkspaceTaskId(non-wst_→invalid_scope); dispatch onlyarchive→archiveOwnedWorkspaceTurnWorkspaceandunarchive→unarchiveOwnedWorkspaceTurnWorkspace; keep theplanFileOnlythrow (same pattern astask_remove.ts:17); input schema = the new narrowedTaskWorkspaceLifecycleToolInputSchema; results parsed against the keptTaskWorkspaceLifecycleToolResultSchema.src/common/utils/tools/tools.ts: importcreateTaskWorkspaceLifecycleTool(~line 42) and registertask_workspace_lifecycle: wrap(createTaskWorkspaceLifecycleTool(config))(~line 773).src/node/builtinAgents/): addtask_workspace_lifecycletotools.removeinexplore.md(line ~30, alongsidetask_remove),plan.md(line ~23), anddesktop.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) rebuildsbuiltInAgentContent.generated.ts, andmake typecheck(part ofstatic-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.src/common/utils/messages/transcriptShare.ts(~line 133): add"task_workspace_lifecycle"toPRESERVE_OUTPUT_TOOLSso shared transcripts keep target-status rows.src/browser/features/Settings/Sections/TasksSection.agents.ts(~line 62): add to the agent-template remove arrays wheretask_removeappears, for template parity.WorkspaceLifecycleToolCall.tsxalready renders all statuses includingunarchived/already_unarchived(exhaustiveRecord<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 intools/testHelpers.ts):wst_taskId →invalid_scopewithout touching taskService;planFileOnlythrows; unarchive routes to the unarchive method (replaces the historical delete_worktree/remove routing test);acknowledged_untracked_pathsforwarding when the target is specified bytaskId(notworkspaceId) — the tool must pass the full by-workspaceId map so the backend can apply it after handle→workspaceId resolution.taskService.test.ts, alongside existing workspace-turn tests):invalid_scope; owned (createdWorkspace record) → proceeds.already_archived) and unarchive idempotency (already_unarchived).active+activeTaskIds; withinterrupt_active: true→ interrupts then archives.workspaceService.archivereturningconfirm-lossy-untracked-files→requires_confirmationwith paths; second call withacknowledged_untracked_paths→archived.createWorkspaceTurn(mode: "existing")refused; after unarchive → succeeds again (the round-trip that motivates unarchive).wst_*taskId and byworkspaceId(both resolution paths).activeeven wheninterrupt_active: true.toolDefinitions.test.ts): live input schema rejectsremove/delete_worktreeactions and rejectsforce(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 fortask_remove(follow precedent, don't invent a new tautology).Phase 5 — Validation & dogfooding gate
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.tsand the renderer UI test (WorkspaceLifecycleToolCall.ui.test.tsx) to confirm no schema drift.Dogfooding
Environment:
make dev-server-sandbox(project skilldev-server-sandbox) — the web dev server, not the Electron desktop app. Isolated tempXUM_ROOT, free backend + Vite ports, seededproviders.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-*, backend127.0.0.1:<port>, Vite127.0.0.1:<port>), andagent-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. Seededconfig.jsoncontains real projects; dogfood against a scratch project (or launch withDEV_SERVER_SANDBOX_ARGS="--clean-projects"and add one).agent-browser flow (per
agent-browsercore +dogfoodskills):snapshot -ifor element refs →click/fillby@eNref → re-snapshot after page changes;screenshot <file>.pngfor stills;record start/stop <file>.webmfor videos (per the agent-browser/dogfood skill workflow);errors/consolefor renderer errors after each step.Evidence requirements (per
dogfoodskill: repro-first, evidence per step): screenshots per step AND arecord start/record stopwebm video of the core archive→unarchive flow, paced human-watchably (sleep 1between actions). All evidence attached viaattach_filefor reviewer verification.Script (each step evidenced with an
agent-browserscreenshot, attached viaattach_file):task({kind:"workspace", ...}), wait for its turn to settle, then calltask_workspace_lifecycle({action:"archive", targets:[{workspaceId:...}]})”.invalid_scopein the tool card. Screenshot.requires_confirmationcard listing paths; re-call withacknowledged_untracked_paths→ archived. Screenshot the confirmation card.{action:"unarchive"}→ workspace returns to the sidebar; then atask(kind="workspace", mode="existing")follow-up succeeds. Screenshot.WorkspaceLifecycleToolCallcard 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
task(kind="workspace")usingtask_workspace_lifecycle({action:"archive"}), and unarchive it with{action:"unarchive"}.wst_) task IDs are refused withinvalid_scope.interrupt_active: true(which interrupts, then archives); unarchive never interrupts regardless ofinterrupt_active.requires_confirmation→acknowledged_untracked_pathsround-trip;forceno longer exists on the input schema.mode:"existing"follow-ups; unarchive restores them.remove/delete_worktreeare not model-invocable through this tool (schema-rejected);task_removebehavior unchanged.task_workspace_lifecyclecalls still render (renderer untouched, result schema unchanged).make static-checkgreen; all Phase 4 suites green; dogfooding evidence captured.issue-triage-loopskill's documented calltask_workspace_lifecycle({action:"archive", targets:[{workspaceId}], interrupt_active:false})works verbatim.Risks & mitigations
workspaceLifecycleLockswrappingworkspaceService.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.unarchived/already_unarchived; the restored backend emits only statuses in the kept union, andtoolDefinitions.test.tsalready validates the schemas — renderer exhaustiveness (Record<Status, Meta>) fails typecheck if a status is added without UI handling.task_remove: mitigated by the description contrast (reversible vs irreversible) and byinvalid_scopefor sub-agent IDs; watch Codex review for wording feedback.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).
Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$53.33