Archiving a thread does not archive spawned child threads

Resolved 💬 2 comments Opened Apr 15, 2026 by Vadevious Closed Apr 16, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of the Codex App are you using (From “About Codex” dialog)?

26.409.20454

What subscription do you have?

Pro 20x(10x)

What platform is your computer?

Microsoft Windows NT 10.0.19045.0 x64

What issue are you seeing?

Archiving a main thread does not archive the spawned child/sub-agent threads associated with it.

I archived parent thread 019d6f6f-c048-7283-8871-ef0a87e778ea. The parent thread is marked archived, but its spawned descendants remain live. In local Codex state, that parent has 177 child threads in thread_spawn_edges, and all 177 children still have threads.archived = 0.

Many of those child threads inherit the same original generated title, which leaves a large amount of clutter in the thread list and makes the archive feel incomplete.

What steps can reproduce the bug?

  1. Start a thread that uses spawned child/sub-agent threads.
  2. Let the child threads complete so they are closed but still present in local state.
  3. Archive the parent thread from the Codex app.
  4. Inspect the archive/live state afterward.

Observed result:

  • the parent thread is archived
  • the child threads remain unarchived/live
  • descendant rollout/session state is still left behind

What is the expected behavior?

Archiving a parent thread should either:

  1. recursively archive its spawned descendants by default, or
  2. prompt the user to also archive the spawned child threads.

At minimum, the UI should make it clear that descendant threads remain live after archiving the parent.

Additional information

Example parent thread:

  • 019d6f6f-c048-7283-8871-ef0a87e778ea

Local evidence:

  • 177 descendants recorded in thread_spawn_edges
  • all 177 descendants still had threads.archived = 0
  • many descendant threads inherited the same original generated title as the parent

I can provide descendant thread IDs if useful.

View original on GitHub ↗

2 Comments

github-actions[bot] contributor · 4 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16860

Powered by Codex Action

Vadevious · 4 months ago

Here's a Codex-made script that I use as a workaround currently, to help clean up the bloat.

It does three things:

  • finds descendant threads via thread_spawn_edges
  • moves their rollout files from sessions to archived_sessions
  • updates threads.archived, threads.archived_at, threads.updated_at, and threads.rollout_path

It supports:

  • -DryRun while Codex is open
  • real mutation only when Codex is closed
param(
    [string]$StateDb = "$HOME\\.codex\\state_5.sqlite",
    [string]$ArchivedRoot = "$HOME\\.codex\\archived_sessions",
    [string]$ThreadId,
    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'

function Get-CodexProcesses {
    Get-Process -ErrorAction SilentlyContinue |
        Where-Object { $_.ProcessName -in @('Codex', 'codex', 'CodexAccountDashboard') }
}

function Invoke-SqlCsv {
    param([string]$Sql)
    sqlite3 -header -csv $StateDb $Sql | ConvertFrom-Csv
}

function Normalize-Path {
    param([string]$Path)
    if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
    try {
        return [System.IO.Path]::GetFullPath($Path)
    } catch {
        return $Path
    }
}

$codexProcesses = Get-CodexProcesses
if ($codexProcesses -and -not $DryRun) {
    $processList = ($codexProcesses | Sort-Object ProcessName, Id | ForEach-Object { '{0}:{1}' -f $_.ProcessName, $_.Id }) -join ', '
    throw "Refusing to archive thread trees while Codex processes are running: $processList"
}

if (-not (Test-Path -LiteralPath $StateDb)) {
    throw "Missing Codex state DB: $StateDb"
}

if (-not (Test-Path -LiteralPath $ArchivedRoot)) {
    New-Item -ItemType Directory -Force -Path $ArchivedRoot | Out-Null
}

$threads = Invoke-SqlCsv @"
SELECT
  id,
  archived,
  archived_at,
  updated_at,
  rollout_path,
  REPLACE(REPLACE(title, char(13), ' '), char(10), ' ') AS title
FROM threads;
"@

$edges = Invoke-SqlCsv @"
SELECT parent_thread_id, child_thread_id, status
FROM thread_spawn_edges;
"@

$threadMap = @{}
foreach ($thread in $threads) {
    $thread.rollout_path = Normalize-Path $thread.rollout_path
    $threadMap[$thread.id] = $thread
}

$childrenByParent = @{}
foreach ($edge in $edges) {
    if (-not $childrenByParent.ContainsKey($edge.parent_thread_id)) {
        $childrenByParent[$edge.parent_thread_id] = [System.Collections.Generic.List[string]]::new()
    }
    $childrenByParent[$edge.parent_thread_id].Add($edge.child_thread_id)
}

if ($ThreadId) {
    if (-not $threadMap.ContainsKey($ThreadId)) {
        throw "Thread not found in DB: $ThreadId"
    }
    $rootIds = @($ThreadId)
} else {
    $rootIds = @()
    foreach ($parentId in $childrenByParent.Keys) {
        if (-not $threadMap.ContainsKey($parentId)) { continue }
        $parent = $threadMap[$parentId]
        if ([int]$parent.archived -ne 1) { continue }

        $hasUnarchivedChild = $false
        foreach ($childId in $childrenByParent[$parentId]) {
            if ($threadMap.ContainsKey($childId) -and ([int]$threadMap[$childId].archived -ne 1)) {
                $hasUnarchivedChild = $true
                break
            }
        }

        if ($hasUnarchivedChild) {
            $rootIds += $parentId
        }
    }
    $rootIds = $rootIds | Sort-Object -Unique
}

if (-not $rootIds) {
    Write-Output 'No archived parent threads with unarchived descendants were found.'
    exit 0
}

$visited = [System.Collections.Generic.HashSet[string]]::new()
$targets = [System.Collections.Generic.List[object]]::new()

foreach ($rootId in $rootIds) {
    if (-not $childrenByParent.ContainsKey($rootId)) { continue }

    $queue = [System.Collections.Generic.Queue[string]]::new()
    foreach ($childId in $childrenByParent[$rootId]) {
        $queue.Enqueue($childId)
    }

    while ($queue.Count -gt 0) {
        $childId = $queue.Dequeue()
        if (-not $visited.Add($childId)) { continue }
        if (-not $threadMap.ContainsKey($childId)) { continue }

        $thread = $threadMap[$childId]
        $targets.Add([pscustomobject]@{
            root_id = $rootId
            thread_id = $childId
            title = $thread.title
            archived = [int]$thread.archived
            rollout_path = $thread.rollout_path
        })

        if ($childrenByParent.ContainsKey($childId)) {
            foreach ($nextId in $childrenByParent[$childId]) {
                $queue.Enqueue($nextId)
            }
        }
    }
}

if (-not $targets) {
    Write-Output 'No descendant threads were found for the selected roots.'
    exit 0
}

$now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
$results = [System.Collections.Generic.List[object]]::new()

foreach ($target in $targets) {
    if ($target.archived -eq 1) {
        $results.Add([pscustomobject]@{
            root_id = $target.root_id
            thread_id = $target.thread_id
            action = 'already_archived'
            from_path = $target.rollout_path
            to_path = $target.rollout_path
            note = 'DB already marked archived'
        })
        continue
    }

    $sourcePath = $target.rollout_path
    $destPath = if ($sourcePath) { Join-Path $ArchivedRoot ([System.IO.Path]::GetFileName($sourcePath)) } else { $null }
    $sourceExists = $sourcePath -and (Test-Path -LiteralPath $sourcePath)
    $destExists = $destPath -and (Test-Path -LiteralPath $destPath)

    if (-not $sourceExists -and -not $destExists) {
        $results.Add([pscustomobject]@{
            root_id = $target.root_id
            thread_id = $target.thread_id
            action = 'skipped_missing_rollout'
            from_path = $sourcePath
            to_path = $destPath
            note = 'Neither source nor archive rollout file exists'
        })
        continue
    }

    if (-not $DryRun) {
        if ($sourceExists -and -not $destExists) {
            Move-Item -LiteralPath $sourcePath -Destination $destPath -Force
        }

        $dbPath = if ($destExists -or (Test-Path -LiteralPath $destPath)) { $destPath } else { $sourcePath }
        $safePath = if ($dbPath) { $dbPath.Replace("'", "''") } else { '' }

        sqlite3 $StateDb @"
UPDATE threads
SET archived = 1,
    archived_at = $now,
    updated_at = $now,
    rollout_path = '$safePath'
WHERE id = '$($target.thread_id)';
"@ | Out-Null
    }

    $results.Add([pscustomobject]@{
        root_id = $target.root_id
        thread_id = $target.thread_id
        action = if ($DryRun) { 'would_archive' } else { 'archived' }
        from_path = $sourcePath
        to_path = $destPath
        note = if ($sourceExists -and -not $destExists) { 'Moved rollout and updated DB' } elseif ($destExists) { 'Archive rollout already existed; updated DB only' } else { 'Updated DB only' }
    })
}

$summary = [pscustomobject]@{
    root_count = ($rootIds | Measure-Object).Count
    descendant_count = $targets.Count
    archived_count = ($results | Where-Object action -eq 'archived').Count
    would_archive_count = ($results | Where-Object action -eq 'would_archive').Count
    already_archived_count = ($results | Where-Object action -eq 'already_archived').Count
    skipped_missing_rollout_count = ($results | Where-Object action -eq 'skipped_missing_rollout').Count
}

$summary
$results | Sort-Object root_id, thread_id