Skip to content

Guardian threads retain their parent in rollouts but are absent from thread_spawn_edges #33670

Open

Description

@deinspanjer

Verified against: upstream/main commit
71448a29e7343b9613eaea620fcdbd196aed2af0 from 2026-07-16.

Related but not duplicate:

  • #25341 covers ordinary
    subagent visibility and stale open edges; it assumes child edges exist.
  • #32035 shows Guardian
    parent_thread_id metadata while reporting resume and UI failures; it does
    not report the missing thread_spawn_edges row.

The examples below are from a local profile, with thread IDs replaced by
consistent synthetic UUIDs so the SQLite and rollout records can still be
correlated safely.

What issue are you seeing?

Persisted Guardian review threads have an exact parent_thread_id in their
rollout session_meta, but no corresponding row is written to
thread_spawn_edges in ~/.codex/state_5.sqlite.

This makes the canonical SQLite thread graph incomplete. Consumers using
thread_spawn_edges cannot associate Guardian cost, activity, or lifecycle
with the parent thread even though Codex had and persisted the exact parent ID.

Local read-only counts:

Guardian rows in threads:       466
All thread_spawn_edges rows:   1137
Guardian child edge rows:         0

Two example threads records:

id                                    source                                      thread_source  cli_version
11111111-1111-4111-8111-111111111111  {"subagent":{"other":"guardian"}}          subagent       0.144.5
22222222-2222-4222-8222-222222222222  {"subagent":{"other":"guardian"}}          subagent       0.144.5

For both children, this query returns zero rows:

SELECT parent_thread_id, child_thread_id, status
FROM thread_spawn_edges
WHERE child_thread_id IN (
  '11111111-1111-4111-8111-111111111111',
  '22222222-2222-4222-8222-222222222222'
);

Their rollout files contain the missing relationship in the first record:

{"type":"session_meta","payload":{"id":"11111111-1111-4111-8111-111111111111","parent_thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","source":{"subagent":{"other":"guardian"}},"thread_source":"subagent"}}
{"type":"session_meta","payload":{"id":"22222222-2222-4222-8222-222222222222","parent_thread_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","source":{"subagent":{"other":"guardian"}},"thread_source":"subagent"}}

Each prompt independently records the same parent:

Reviewed Codex session id: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
Reviewed Codex session id: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb

Both referenced parent IDs exist in threads.

What steps can reproduce the bug?

  1. Run Codex with Guardian review enabled and trigger an action that creates a
    persisted Guardian review thread.
  2. Read the Guardian rollout's first session_meta record and note
    payload.id and payload.parent_thread_id.
  3. Query thread_spawn_edges for child_thread_id = payload.id.
  4. Observe that the rollout has an exact parent while the edge query returns no
    row.

Aggregate verification query:

SELECT count(*) AS guardian_edges
FROM thread_spawn_edges AS edge
JOIN threads AS child ON child.id = edge.child_thread_id
WHERE child.source = '{"subagent":{"other":"guardian"}}';

Observed result: 0.

What is the expected behavior?

A persisted Guardian thread with session_meta.parent_thread_id should have a
canonical parent relation in SQLite. Preferably, thread_spawn_edges should
contain the same parent/child pair so existing relation filters and graph
consumers work consistently for all persisted subagent threads.

If Guardians are intentionally excluded from thread_spawn_edges, the state
database should expose another canonical, queryable parent relation rather than
requiring consumers to reopen rollout JSONL files.

Existing Guardian rollouts should be backfillable from
session_meta.parent_thread_id.

Additional information

The current source still has the complete failure path:

  1. Guardian spawning receives the reviewed parent and uses
    SubAgentSource::Other("guardian"):
    review_session.rs#L656-L681.
  2. The delegate supplies parent_thread_id: Some(parent_session.thread_id) to
    the spawned session:
    codex_delegate.rs#L102-L114.
  3. SubAgentSource::parent_thread_id() returns a parent only for
    ThreadSpawn; Other(_) returns None:
    protocol.rs#L2973-L2982.
  4. The edge writer is called from the regular agent spawn and resume paths, but
    the Guardian spawn path above does not call it:
    spawn.rs#L516-L526 and
    spawn.rs#L898-L916.
  5. Even if source-based edge persistence is reached, the edge writer exits when
    SessionSource::parent_thread_id() returns None:
    control.rs#L672-L680.
  6. The state backfill also derives the parent only from serialized source, so
    it cannot recover Guardian edges from session_meta.parent_thread_id:
    threads.rs#L318-L326 and
    threads.rs#L1252-L1255.

The prompt builder also deliberately emits the reviewed session ID:
prompt.rs#L184-L191.

No write or manual repair was performed while collecting this evidence.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    subagentIssues involving subagents or multi-agent features
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on Jul 16, 2026
  2. starriet9 commented on Aug 1, 2026

    @starriet9

    I reproduced an additional user-visible consequence of this missing persisted relationship on current main (ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff).

    After a restart, thread/delete discovers persisted descendants through the spawned-agent graph. Because Guardian threads are absent from that graph, deleting a parent thread can leave the Guardian rollout and SQLite thread row behind.

    This implements the alternative already suggested in this issue: PR #26662 confirms that Guardian threads are intentionally outside the spawned-agent lifecycle, so the prototype persists a separate ownership relation instead of adding Guardians to thread_spawn_edges. Adding them directly to that graph would also change unrelated lifecycle and thread/list parentThreadId semantics.

    The prototype:

    • persists session_meta.parent_thread_id as a separate ownership relation in thread metadata;
    • distinguishes an inspected thread with no parent from an older thread whose relation has not been indexed yet;
    • lazily repairs existing unresolved subagent rows from rollout session_meta;
    • includes owned descendants when computing the subtree for deletion;
    • preserves the existing spawned-agent graph behavior.

    The integration regression test creates an existing Guardian rollout whose parent is known only from session_meta, deletes the parent through thread/delete, and verifies that both rollout files and both SQLite rows are removed.

    Verification completed locally:

    • 166 codex-state tests;
    • 272 codex-rollout and codex-thread-store tests;
    • all 5 thread_delete integration tests;
    • scoped Clippy for the affected crates.

    Prototype branch:
    https://github.com/starriet9/codex/tree/fix/guardian-parent-spawn-edges

    Commit:
    starriet9@6514af2

    I have not opened a pull request because external code contributions are by invitation only. If this approach matches the intended ownership model, I would be happy to submit it as an invited PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIbugSomething isn't workingsessionIssues involving session (thread) management, resuming, forking, naming, archivingsubagentIssues involving subagents or multi-agent features

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions