Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to this project are documented here.

## Unreleased

### Fixed

- Agent Status no longer lists Codex Guardian / Guardian2 reviewer
sub-sessions, which could fill the recent-task slots and fail with
"cannot resume a live Guardian reviewer". Internal threads are excluded
in SQL from structured metadata (`thread_source`, JSON `source.subagent`,
`agent_path`, and spawn-edge children) before the 12-row limit. A user
chat titled "Guardian" still appears. New Chat targeting uses the same
user-facing filter.

## 0.2.4 — 2026-09-05

### Fixed
Expand Down
29 changes: 27 additions & 2 deletions native/Fixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2228,9 +2228,9 @@ func runFixtureAction(_ action: String, arguments: [String]) {
emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite fixture query failed"), exitCode: 1)
}
let valid = ids.contains("aaaaaaaaaaaaaaaa")
&& ids.contains("bbbbbbbbbbbbbbbb")
&& !ids.contains("bbbbbbbbbbbbbbbb")
&& sqliteThreadCwdValue(databasePath, "aaaaaaaaaaaaaaaa") == "/tmp/active"
&& sqliteThreadCwdValue(databasePath, "bbbbbbbbbbbbbbbb") == "/tmp/archived"
&& sqliteThreadCwdValue(databasePath, "bbbbbbbbbbbbbbbb") == nil
emit(
ControlResult(
ok: valid,
Expand All @@ -2243,4 +2243,29 @@ func runFixtureAction(_ action: String, arguments: [String]) {
exitCode: valid ? 0 : 1
)
}

if action == "--sqlite-guardian-fixture" {
let databasePath = arguments.dropFirst().first ?? ""
guard isRegularCanonicalSQLiteDatabase(databasePath) else {
emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite guardian fixture prevalidation failed"), exitCode: 1)
}
guard let ids = sqliteThreadIds(databasePath) else {
emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite guardian fixture query failed"), exitCode: 1)
}
let valid = ids.contains("aaaaaaaaaaaaaaaa")
&& !ids.contains("cccccccccccccccc")
&& sqliteThreadCwdValue(databasePath, "aaaaaaaaaaaaaaaa") == "/tmp/parent"
&& sqliteThreadCwdValue(databasePath, "cccccccccccccccc") == nil
emit(
ControlResult(
ok: valid,
action: action,
requested: nil,
model: nil,
effort: nil,
message: valid ? "SQLite guardian fixture accepted" : "SQLite guardian fixture rejected"
),
exitCode: valid ? 0 : 1
)
}
}
83 changes: 80 additions & 3 deletions native/Targeting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,20 +361,97 @@ func sqliteLines(_ databasePath: String, _ sql: String) -> SQLiteQueryResult {
return .success(text.split(separator: "\n").map(String.init))
}

func sqliteThreadIds(_ databasePath: String) -> Set<String>? {
guard case .success(let lines) = sqliteLines(databasePath, "SELECT id FROM threads;") else {
func sqliteColumnNames(_ databasePath: String, _ table: String) -> Set<String>? {
guard table.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
return nil
}
guard case .success(let lines) = sqliteLines(
databasePath,
"PRAGMA table_info(\(table));"
) else { return nil }
return Set(lines.compactMap { line in
let parts = line.split(separator: "|", omittingEmptySubsequences: false)
guard parts.count > 1 else { return nil }
let name = String(parts[1])
return name.isEmpty ? nil : name
})
}

func sqliteTableExists(_ databasePath: String, _ table: String) -> Bool {
guard table.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
return false
}
guard case .success(let lines) = sqliteLines(
databasePath,
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '\(table)' LIMIT 1;"
) else { return false }
return lines.contains("1")
}

/// User-facing chats only. Never matches titles; archived and Guardian
/// reviewer sub-sessions are excluded when those columns/tables exist.
func sqliteUserFacingPredicate(_ databasePath: String) -> String? {
guard let columns = sqliteColumnNames(databasePath, "threads") else { return nil }
var clauses: [String] = []
if columns.contains("archived") { clauses.append("t.archived = 0") }
if columns.contains("thread_source") {
clauses.append("t.thread_source IS NOT 'subagent'")
}
if columns.contains("source") {
clauses.append(
"""
CASE
WHEN json_valid(t.source)
THEN json_extract(t.source, '$.subagent')
END IS NULL
"""
)
}
if columns.contains("agent_path") {
clauses.append("(t.agent_path IS NULL OR t.agent_path = '' OR t.agent_path = '/root')")
}
if sqliteTableExists(databasePath, "thread_spawn_edges"),
let spawnColumns = sqliteColumnNames(databasePath, "thread_spawn_edges"),
spawnColumns.contains("child_thread_id")
{
clauses.append(
"""
NOT EXISTS (
SELECT 1 FROM thread_spawn_edges child
WHERE child.child_thread_id = t.id
)
"""
)
}
guard !clauses.isEmpty else { return "" }
return clauses.map { "AND \($0)" }.joined(separator: "\n")
}

func sqliteThreadIds(_ databasePath: String) -> Set<String>? {
guard let predicate = sqliteUserFacingPredicate(databasePath) else { return nil }
guard case .success(let lines) = sqliteLines(
databasePath,
"""
SELECT DISTINCT t.id FROM threads t
WHERE 1 = 1
\(predicate);
"""
) else { return nil }
return Set(lines)
}

func sqliteThreadCwd(_ databasePath: String, _ threadId: String) -> SQLiteRowResult {
guard threadId.range(of: "^[0-9A-Fa-f-]{16,64}$", options: .regularExpression) != nil else {
return .failure
}
guard let predicate = sqliteUserFacingPredicate(databasePath) else { return .failure }
guard case .success(let lines) = sqliteLines(
databasePath,
"SELECT cwd FROM threads WHERE id = '\(threadId)';"
"""
SELECT t.cwd FROM threads t
WHERE t.id = '\(threadId)'
\(predicate);
"""
) else { return .failure }
return .success(lines.first)
}
Expand Down
146 changes: 125 additions & 21 deletions src/lib/codex-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,121 @@ interface ThreadRow {
spawn_status: string | null;
}

interface ThreadSchema {
columns: Set<string>;
spawnColumns: Set<string>;
}

interface SqliteNamedRow {
name: string;
}

/** Root user chats use `/root` or leave this unset; nested paths are sub-sessions. */
const ROOT_AGENT_PATH = "/root";

function tableColumns(database: DatabaseSync, table: string): Set<string> {
return new Set(
(
database
.prepare(`PRAGMA table_info(${table})`)
.all() as unknown as SqliteNamedRow[]
)
.map((column) => column.name)
.filter(Boolean),
);
}

function inspectThreadSchema(database: DatabaseSync): ThreadSchema {
const columns = tableColumns(database, "threads");
const spawnTable = database
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'thread_spawn_edges'`,
)
.get() as SqliteNamedRow | undefined;
return {
columns,
spawnColumns:
spawnTable?.name === "thread_spawn_edges"
? tableColumns(database, "thread_spawn_edges")
: new Set(),
};
}

/**
* Exclude Codex-internal sub-sessions using structured metadata only.
* Titles are never consulted: a user chat named "Guardian" must remain visible.
*
* Signals, each optional and used only when present in this database:
* - threads.archived = 1
* - threads.thread_source = 'subagent'
* - threads.source JSON with a `subagent` property
* - threads.agent_path set to a non-root path
* - a row in thread_spawn_edges whose child is this thread
*/
function userFacingThreadPredicate(schema: ThreadSchema): string {
const clauses: string[] = [];
if (schema.columns.has("archived")) {
clauses.push(`t.archived = 0`);
}
if (schema.columns.has("thread_source")) {
clauses.push(`t.thread_source IS NOT 'subagent'`);
}
if (schema.columns.has("source")) {
clauses.push(
`CASE
WHEN json_valid(t.source)
THEN json_extract(t.source, '$.subagent')
END IS NULL`,
);
}
if (schema.columns.has("agent_path")) {
clauses.push(
`(t.agent_path IS NULL OR t.agent_path = '' OR t.agent_path = '${ROOT_AGENT_PATH}')`,
);
}
if (schema.spawnColumns.has("child_thread_id")) {
clauses.push(
`NOT EXISTS (
SELECT 1 FROM thread_spawn_edges child
WHERE child.child_thread_id = t.id
)`,
);
}
return clauses.length === 0 ? "" : `AND ${clauses.join("\n AND ")}`;
}

function threadSelectSql(
schema: ThreadSchema,
kind: "recent" | "focused",
): string {
const spawnStatus =
schema.spawnColumns.has("status") &&
schema.spawnColumns.has("child_thread_id")
? `(SELECT MAX(child.status) FROM thread_spawn_edges child
WHERE child.child_thread_id = t.id) AS spawn_status`
: "NULL AS spawn_status";
const visibility = userFacingThreadPredicate(schema);
const recencyFilter =
kind === "recent"
? `AND t.preview <> ''
AND t.recency_at_ms >= ?`
: "AND t.id = ?";
const orderLimit =
kind === "recent"
? `ORDER BY t.recency_at_ms DESC
LIMIT 12`
: "LIMIT 1";
return `SELECT DISTINCT
t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms,
t.reasoning_effort, t.model, ${spawnStatus}
FROM threads t
WHERE 1 = 1
${recencyFilter}
${visibility}
${orderLimit}`;
}

function expandHome(path: string): string {
return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
}
Expand Down Expand Up @@ -251,6 +366,7 @@ export class CodexStore {
#recentRolloutPaths = new Set<string>();
#focusedRolloutPath: string | undefined;
#modelCache: { identity: FileIdentity; parsed: unknown } | undefined;
#threadSchema: ThreadSchema | undefined;

constructor(
options: {
Expand Down Expand Up @@ -285,6 +401,7 @@ export class CodexStore {
close(): void {
this.#database?.close();
this.#database = undefined;
this.#threadSchema = undefined;
}

acknowledge(threadId: string, at = Date.now()): void {
Expand Down Expand Up @@ -377,18 +494,7 @@ export class CodexStore {
const database = this.#open();
const cutoff = now - maxAgeMs;
const rows = database
.prepare(
`SELECT
t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms,
t.reasoning_effort, t.model, e.status AS spawn_status
FROM threads t
LEFT JOIN thread_spawn_edges e ON e.child_thread_id = t.id
WHERE t.archived = 0
AND t.preview <> ''
AND t.recency_at_ms >= ?
ORDER BY t.recency_at_ms DESC
LIMIT 12`,
)
.prepare(threadSelectSql(this.#schema(), "recent"))
.all(cutoff) as unknown as ThreadRow[];

const persistedSnapshots = rows.map((row) =>
Expand Down Expand Up @@ -424,15 +530,7 @@ export class CodexStore {
return this.#focusedProjectionCache.snapshot;
}
const row = this.#open()
.prepare(
`SELECT
t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms,
t.reasoning_effort, t.model, e.status AS spawn_status
FROM threads t
LEFT JOIN thread_spawn_edges e ON e.child_thread_id = t.id
WHERE t.archived = 0 AND t.id = ?
LIMIT 1`,
)
.prepare(threadSelectSql(this.#schema(), "focused"))
.get(activeId) as unknown as ThreadRow | undefined;
const persisted = row ? this.#projectThreadRow(row, now) : undefined;
const snapshot = persisted
Expand Down Expand Up @@ -824,6 +922,11 @@ export class CodexStore {
);
}

#schema(): ThreadSchema {
this.#open();
return this.#threadSchema!;
}

#open(): DatabaseSync {
if (this.#database) return this.#database;
const database = new DatabaseSync(this.databasePath, {
Expand All @@ -832,6 +935,7 @@ export class CodexStore {
});
database.exec("PRAGMA query_only = ON");
this.#database = database;
this.#threadSchema = inspectThreadSchema(database);
return database;
}
}
Expand Down
Loading
Loading