[macOS App] Completed subagents stay open in thread_spawn_edges and restore stale MCP stacks
What version of the Codex App are you using (From “About Codex” dialog)?
26.707.91948 (build 5440), bundled codex-cli 0.144.5
What subscription do you have?
ChatGPT subscription. The exact tier is not exposed by the local diagnostics used for this report.
What platform is your computer?
Darwin 25.5.0 arm64 arm
What issue are you seeing?
Completed/interrupted subagents remain persisted as open rows in state_5.sqlite.thread_spawn_edges. When Codex Desktop restores the parent task, it starts one complete MCP/plugin helper generation for the root task and for every stale descendant edge—even though the children no longer appear in the live agent listing.
This creates a direct, measurable multiplier:
root task + 4 direct subagents + 1 nested subagent
= 6 restored contexts
= 6 MCP/plugin helper generations
Before the focused reproduction, a single app-server had reached:
generations=11
matched MCP/plugin direct children=99
matched child RSS=3798 MiB
app-server RSS=692 MiB
After a full application relaunch, the same task restored exactly six generations:
generations=6
matched MCP/plugin direct children=48
matched child RSS=3152 MiB
app-server RSS=632 MiB
The five descendant rows were still open, despite the agents being completed/interrupted and absent from the current live-agent listing:
parent child-1 open depth=1
parent child-2 open depth=1
parent child-3 open depth=1
parent child-4 open depth=1
child-3 nested-child open depth=2
There is a second lifecycle/config problem: restored contexts can start helpers from stale tool policy. xcodebuildmcp started four times even though the current plugin MCP override resolved it as disabled. A removed GitHits MCP definition did take effect and did not restart, so persisted tool policy appears inconsistent by source/layer.
What steps can reproduce the bug?
- In Codex Desktop, start one task with several subagents and at least one nested subagent. Use MCP-bearing plugins or configured stdio MCP servers.
- Let the children finish or interrupt them. Confirm they no longer appear as live agents.
- Inspect
thread_spawn_edgesfor the root task:
``sql``
WITH RECURSIVE tree(id, depth) AS (
SELECT '<root-thread-id>', 0
UNION ALL
SELECT e.child_thread_id, t.depth + 1
FROM thread_spawn_edges e
JOIN tree t ON e.parent_thread_id = t.id
)
SELECT e.parent_thread_id, e.child_thread_id, e.status, t.depth + 1 AS depth
FROM thread_spawn_edges e
JOIN tree t ON e.parent_thread_id = t.id;
- Fully quit and reopen Codex Desktop.
- Count repeated direct children under the new
codex app-serverPID (node_repl, pluginserver.mjs/server.cjs, Workshop, Pencil, XcodeBuildMCP, etc.). - Observe that the number of helper generations matches the root task plus the stale
opendescendant edges. - Disable a plugin-provided MCP in the current config, relaunch, and observe that restored contexts may still start that server from stale policy.
Controlled diagnostic
After an online SQLite backup, I changed only these five descendant edge rows from open to closed; PRAGMA integrity_check returned ok.
Restarting only app-server was insufficient: the still-running Electron main process retained cached task state and recreated five helper generations. This suggests there are two sources that must converge:
- persisted
thread_spawn_edgeslifecycle state; - cached restored-task/tool state owned by the long-lived desktop main process.
This database edit is diagnostic evidence, not a recommended user workaround.
What is the expected behavior?
- A completed, interrupted, or stopped subagent must transition its spawn edge to
closedexactly once. - Desktop restore must not treat completed descendants as live task contexts.
- Restored tasks must re-resolve the current MCP/plugin policy before launching helpers.
enabled = falsemust prevent launch from persisted or cached task state.- Finished descendants' MCP process groups must be reaped without requiring a full desktop restart.
- The UI/diagnostics should expose the owning task/subagent for every MCP helper and why it is still alive.
Additional information
Sanitized logs, state-table evidence, and active-config summary:
https://gist.github.com/ardjo-s/aec95bc245439bd447beb179b0bfb049
Related open issues:
- #32942 — restored tasks fan out plugin MCP servers and can ignore current Xcode MCP policy
- #19197 — persistent orphaned subagents and missing lifecycle controls
- #17574 — subagents retain stdio MCP helper trees
- #30408 — per-thread MCP processes are not cleaned up
- #11324 — MCP memory growth while multitasking in the App
- #21984 — eager per-session MCP startup
This report is intentionally focused on the persisted thread_spawn_edges.status = open invariant and the exact 1:1 mapping between stale descendant edges and restored MCP generations.
During diagnostics I intentionally sent SIGTERM to app-server to test teardown. The app displayed its generic crash page; that signal was operator-initiated and is not being reported as a spontaneous crash. The page also displayed an unrelated Semrush remote-plugin name-mismatch warning; it may deserve a separate report but is not presented here as the cause of the MCP/subagent lifecycle defect.
9 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Cross-reference to my related quota report: #34066.
#33700 documents the process-side mechanism that may explain hidden background usage: completed/interrupted descendants remain
openinstate_5.sqlite.thread_spawn_edges, and Desktop restore launches one MCP/plugin helper generation per stale descendant. In the controlled reproduction, 6 restored contexts produced 6 generations, 48 direct child processes, and 3152 MiB RSS; the earlier state reached 11 generations, 99 children, and 3798 MiB RSS.#34066 reports immediate weekly-limit exhaustion and unusually large local token estimates without session-level attribution. The MCP/subagent multiplier is therefore a plausible contributor to background usage, but this does not yet prove that the process leak alone caused the quota exhaustion. The two reports should be investigated together across restored-task state, MCP lifecycle, subagent accounting, and quota attribution.
Windows A/B reproduction on Codex Desktop
26.803.5235.0with bundled Codex CLI0.147.0, using only the official archive operation and read-only aggregate measurements. No database edits, restarts, deletions, or process termination were performed.notLoaded; all evidence remained readable. Helpers stayed at 4 and Node workers at 20 after every archive, so this class produced no process benefit.Interpretation: official archiving can unload a currently loaded idle task, but it is not child-edge finalization and does not repair retained ownership for completed tasks that are already unloaded. This reproduces the archive/open-edge divergence on Windows and distinguishes a transient unload effect from a durable lifecycle fix.
The requested product behavior is an idempotent, bounded terminal transition for completed/failed/interrupted/abandoned child edges; release of helper trees owned only by those terminal children without a desktop restart; parent restore that ignores terminal descendants; and owner-attributed diagnostics plus a supported non-destructive repair path for already-stale edges.
Additional Windows persistence evidence (aggregate-only; complements the existing Windows archive A/B comment):
Codex Desktop for Windows
26.803.5235.0, read-only snapshot at2026-08-09T13:13:09Z:thread_spawn_edges: 692open, 275closed.final_answerat or after the latest user message, and 639 hadtask_completeat or after the latesttask_startedevent.codex_otel.agent_communicationtelemetry contained 263kind=result / state=sendrecords from 197 unique sender threads.openspawn edges; 194 also had terminal final answers after their latest user messages.Method: read the state database in SQLite read-only mode; classify a rollout as terminal only when its latest
final_answeris at or after its latest user message; correlate result-sender UUIDs with open child UUIDs in memory; emit aggregate counts only.No database mutation was performed for this report. The report contains no thread UUIDs, rollout paths, prompts, outputs, task titles, workspace paths, account identifiers, or machine identifiers.
This narrows the persisted-state invariant on Windows: a result can be sent back to the parent and a terminal final answer can be recorded while the corresponding spawn edge remains
openfor days or weeks. Desktop rehydration should not interpret those rows as live descendants.Confirming this on a current ChatGPT/Codex Desktop build:
26.803.41515build6321codex-cli 0.147.0-alpha.6.526.5.1 (25F80)arm64~/.codex/state_5.sqlitecurrently has:thread_spawn_edgesonly has:I derived the ages from the timestamps embedded in the UUIDv7 thread IDs. Codex's current
ThreadIdimplementation says generated thread IDs are UUIDv7 and usesUuid::now_v7().The same long-lived app-server also had a bunch of repeated direct-child generations of:
Several had been alive for more than a day.
Those children were only about 148 MiB RSS combined during this capture, so they were separate from the big app-server memory spike I was looking at.
I haven't mapped all 79 persisted edges 1:1 to the currently live helper generations, but the stale
openrows and repeated helper generations look like the same lifecycle problem described in this issue.Additional macOS evidence from a current local reproduction (read-only; no database mutation).
Environment:
Scope:
019fe3e6-ac7d-7452-b67d-3cfddb7459e7is the parent thread (source=vscode, with no incoming spawn edge). The reported symptom is in its direct child SubAgents; this parent ID is only the root used to enumerate them.Observed:
open, 1closed.openchild rollouts contain a terminalevent_msg.payload.type=task_complete; each timestamp matches that child’sthreads.updated_at_ms.closedchild is a separate interrupted/closed case and has notask_completeevent.task_complete; it is not the affected SubAgent.PRAGMA integrity_checkreturnsok.latestTurnStatus=completedtogether withmarkedStreaming=true; later the stream activity was cleared. Opening/selecting an affected child makes the UI show Done.Interpretation:
The completed child SubAgents are the affected objects. Their terminal rollout state is complete, but the persisted parent→child
thread_spawn_edges.statusremainsopen. The parent reopen/resume is only the trigger that rehydrates the child list; it does not mean the parent was still running.Hypothesis / fix target:
On every child terminal transition (completed, failed, interrupted, or stopped), persist the incoming spawn edge as
closedidempotently. Desktop SubAgent/Agent projections should also reconcile terminal child state instead of continuing to display the child as active or counting elapsed time.I reproduced this on macOS with the Codex desktop build bundled with
codex-cli 0.148.0-alpha.9.The core lifecycle gap is that multi-agent V2 exposes
interrupt_agent, which intentionally keeps the target resumable, but does not expose the existing permanentAgentControl::close_agentpath. An interrupted or completed child can therefore retain an open spawn edge and be restored as open after the app restarts. This is distinct from every stale child actively making new model calls, but it makes the UI state unreliable and can retain live runtime/MCP resources.Verified local impact
For one V2 orchestration tree running from 2026-08-15 21:12 CEST to 2026-08-16 09:52 CEST:
token_countevents.wait_agent1,009 times; 773 returnedtimed_out: true.token_countevents.These are local cumulative runtime counters, not a claim about the exact subscription-weighting formula. The correlation is strong, but only OpenAI can confirm how cached input is weighted against the weekly allowance.
The current database snapshot contains 998
openand 273closedspawn edges. Every linked child is archived. Of the 998 open edges, 966 child rollouts contain atask_completeevent.Current upstream also contains an explicit regression named
multi_agent_v2_spawn_agent_ignores_configured_max_depth; V1 checksagent_max_depth, while the V2 spawn handler does not. The observed tree reached depth 2 despite the default configured depth of 1.Proposed bounded core change
The revised patch exposes V2
close_agent, resolves ids or canonical task paths, and strengthens the durable close path so it marks the selected agent and every persisted descendant edge closed deepest-first before the parent. It still shuts down the live subtree if persistence reports an error, emits the existing close lifecycle event for app-server watch cleanup, and adds regressions proving that a nested grandchild moves from open to closed and does not rehydrate when its parent is resumed.Focused lifecycle and tool-family tests pass. I attempted to open a draft PR from a registered fork, but GitHub returned
FORBIDDEN: socialperks does not have the correct permissions to execute CreatePullRequest. The OAuth token hasreposcope, so I cannot confirm that this is merely a missing token scope. The browser comparison link may still permit manual creation.This commit is intentionally the smallest core termination boundary. It does not auto-close agents on terminal completion, reconcile already-stale archived edges at startup, enforce V2 depth, impose per-tree token/time budgets, or by itself fix the desktop renderer. Those need follow-up changes.
Related: #19197 and #37563.
This is useful for validating the rollout/lifecycle side of
codex-rescue==0.1.0a4. It will not closethread_spawn_edges, reap MCP processes, or alter Desktop state, but a read-onlydoctorpass on one representative stale child rollout can tell us whether its durable transcript is classified as terminal/unfinished and whether any schema/tool-state findings disagree with the persistedopenedge.If you try it, a sanitized summary of status/findings/aggregate counts is sufficient. Please do not post raw rollouts/SQLite, MCP or tool payloads, prompts, repository data, thread IDs, credentials, or private paths.
Confirming similar behavior on macOS with ChatGPT Desktop
26.814.41407(build6720) and bundledcodex-cli 0.148.0-alpha.15.Even with Convex operations serialized, helpers started by the official Convex plugin accumulated instead of being cleaned up: persistent
convex mcp startprocesses grew from 21 to 33, then reached 36 processes using roughly 2.2 GiB RSS combined.This looks consistent with MCP/plugin helpers being retained across task or subagent lifecycles after the work that spawned them has finished.