Skip to content

Commit f45596b

Browse files
committed
Fix Codex resume agent discovery
1 parent 86465f0 commit f45596b

3 files changed

Lines changed: 74 additions & 19 deletions

File tree

packages/server-core/src/activity/agentJournal.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ interface WatchedTerminal {
3333
provider: AgentProvider | null;
3434
discovery?: ReturnType<typeof setInterval>;
3535
discoveryAttempts?: number;
36+
discoveryBusy?: boolean;
37+
discoveryPersistent?: boolean;
3638
tail?: JournalTail;
3739
}
3840

3941
interface JournalTail { path: string; offset: number; partial: string; timer: ReturnType<typeof setInterval>; busy: boolean }
4042

4143
export interface NodeAgentJournalSourceOptions {
4244
readonly codexHome?: string;
45+
readonly discoveryAttemptLimit?: number;
4346
readonly platform?: NodeJS.Platform;
4447
readonly pollMs?: number;
4548
}
@@ -54,13 +57,15 @@ export class NodeAgentJournalSource implements AgentJournalSource {
5457
private readonly root: string;
5558
private readonly platform: NodeJS.Platform;
5659
private readonly pollMs: number;
60+
private readonly discoveryAttemptLimit: number;
5761
private listener: AgentJournalListener | undefined;
5862
private enabled = true;
5963

6064
constructor(options: NodeAgentJournalSourceOptions = {}) {
6165
this.root = resolve(options.codexHome ?? (process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")));
6266
this.platform = options.platform ?? process.platform;
6367
this.pollMs = Math.max(50, options.pollMs ?? POLL_MS);
68+
this.discoveryAttemptLimit = Math.max(1, Math.floor(options.discoveryAttemptLimit ?? 80));
6469
}
6570

6671
async start(listener: AgentJournalListener): Promise<void> { this.listener = listener; }
@@ -89,8 +94,9 @@ export class NodeAgentJournalSource implements AgentJournalSource {
8994
const terminal = this.exactTerminal(identity);
9095
if (!terminal) return;
9196
terminal.provider = provider;
92-
if (provider === "codex") this.startDiscovery(terminal);
97+
if (provider === "codex") this.startDiscovery(terminal, true);
9398
else if (shellForeground) this.stopWatching(terminal);
99+
else this.startDiscovery(terminal);
94100
}
95101

96102
unregisterTerminal(identity: ActivitySessionIdentity): void {
@@ -113,26 +119,32 @@ export class NodeAgentJournalSource implements AgentJournalSource {
113119
return terminal?.identity.serverId === identity.serverId && terminal.identity.projectId === identity.projectId ? terminal : undefined;
114120
}
115121

116-
private startDiscovery(terminal: WatchedTerminal): void {
122+
private startDiscovery(terminal: WatchedTerminal, persistent = false): void {
123+
if (persistent) terminal.discoveryPersistent = true;
117124
if (!this.enabled || terminal.shellPid === undefined || terminal.tail !== undefined || terminal.discovery !== undefined) return;
118125
terminal.discoveryAttempts = 0;
119126
const discover = async () => {
120-
if (terminal.tail !== undefined || terminal.shellPid === undefined) return;
121-
terminal.discoveryAttempts = (terminal.discoveryAttempts ?? 0) + 1;
122-
const path = await findProcessBoundCodexRollout(terminal.shellPid, join(this.root, "sessions"), this.platform).catch(() => undefined);
123-
if (!path) {
124-
if ((terminal.discoveryAttempts ?? 0) >= 80 && terminal.discovery !== undefined) {
125-
clearInterval(terminal.discovery); terminal.discovery = undefined;
127+
if (terminal.tail !== undefined || terminal.shellPid === undefined || terminal.discoveryBusy) return;
128+
terminal.discoveryBusy = true;
129+
try {
130+
terminal.discoveryAttempts = (terminal.discoveryAttempts ?? 0) + 1;
131+
const path = await findProcessBoundCodexRollout(terminal.shellPid, join(this.root, "sessions"), this.platform).catch(() => undefined);
132+
if (!path) {
133+
if (!terminal.discoveryPersistent && (terminal.discoveryAttempts ?? 0) >= this.discoveryAttemptLimit && terminal.discovery !== undefined) {
134+
clearInterval(terminal.discovery); terminal.discovery = undefined;
135+
}
136+
return;
126137
}
127-
return;
138+
if (terminal.discovery !== undefined) clearInterval(terminal.discovery);
139+
terminal.discovery = undefined;
140+
await this.startTail(terminal, path).catch(() => undefined);
141+
} finally {
142+
terminal.discoveryBusy = false;
128143
}
129-
if (terminal.discovery !== undefined) clearInterval(terminal.discovery);
130-
terminal.discovery = undefined;
131-
await this.startTail(terminal, path).catch(() => undefined);
132144
};
133-
void discover();
134145
terminal.discovery = setInterval(() => void discover(), this.pollMs);
135146
terminal.discovery.unref?.();
147+
void discover();
136148
}
137149

138150
private async startTail(terminal: WatchedTerminal, path: string): Promise<void> {
@@ -205,6 +217,8 @@ export class NodeAgentJournalSource implements AgentJournalSource {
205217
if (terminal.tail !== undefined) clearInterval(terminal.tail.timer);
206218
terminal.discovery = undefined;
207219
terminal.discoveryAttempts = undefined;
220+
terminal.discoveryBusy = undefined;
221+
terminal.discoveryPersistent = undefined;
208222
terminal.tail = undefined;
209223
}
210224
}

packages/server-core/test/agent-journal.test.mjs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import test from "node:test";
7-
import { findProcessBoundCodexRollout } from "../dist/activity/agentJournal.js";
7+
import { findProcessBoundCodexRollout, NodeAgentJournalSource } from "../dist/activity/agentJournal.js";
88

99
test("rollout discovery requires an open writer below the exact process tree", { skip: !["darwin", "linux"].includes(process.platform) }, async () => {
1010
const root = await mkdtemp(join(tmpdir(), "terminay-agent-journal-"));
@@ -26,3 +26,33 @@ test("rollout discovery requires an open writer below the exact process tree", {
2626
await rm(root, { recursive: true, force: true });
2727
}
2828
});
29+
30+
test("a generic foreground wrapper restarts discovery for Codex resume after the startup scan expires", { skip: !["darwin", "linux"].includes(process.platform) }, async () => {
31+
const root = await mkdtemp(join(tmpdir(), "terminay-agent-resume-"));
32+
const sessions = join(root, "sessions", "2026", "08", "05");
33+
const path = join(sessions, "rollout-resumed.jsonl");
34+
await mkdir(sessions, { recursive: true });
35+
const record = JSON.stringify({ type: "session_meta", payload: { id: "resumed-session", cli_version: "0.146.1" } });
36+
const identity = Object.freeze({ serverId: "server-1", projectId: "project-1", sessionId: "terminal-1" });
37+
const source = new NodeAgentJournalSource({ codexHome: root, discoveryAttemptLimit: 2, pollMs: 50 });
38+
const observations = [];
39+
let child;
40+
try {
41+
await source.start((observation) => observations.push(observation));
42+
source.registerTerminal(identity);
43+
source.terminalStarted(identity, process.pid);
44+
await new Promise((resolve) => setTimeout(resolve, 150));
45+
child = spawn(process.execPath, ["-e", "const fs=require('fs');const fd=fs.openSync(process.argv[1],'a');fs.writeSync(fd,process.argv[2]+'\\n');setInterval(()=>{},1000)", path, record], { stdio: "ignore" });
46+
source.foregroundProcessChanged(identity, null, false);
47+
for (let attempt = 0; attempt < 30 && observations.length === 0; attempt += 1) {
48+
await new Promise((resolve) => setTimeout(resolve, 25));
49+
}
50+
assert.equal(observations[0]?.record?.payload?.id, "resumed-session");
51+
assert.deepEqual(observations[0]?.identity, identity);
52+
} finally {
53+
await source.stop();
54+
child?.kill();
55+
if (child?.exitCode === null) await new Promise((resolve) => child.once("exit", resolve));
56+
await rm(root, { recursive: true, force: true });
57+
}
58+
});

specs/features/agent-status-and-sidebar.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ provider session may reopen the same journal in another terminal; the new
7272
writer creates a new binding incarnation and activation terminal without
7373
allowing stale events from the previous incarnation to mutate it.
7474

75+
The Codex launcher may expose a generic wrapper such as `node` as the PTY
76+
foreground process. Every transition away from the shell therefore starts a
77+
new bounded journal-discovery window even when the foreground name is not a
78+
recognized provider. This lets a resumed session launched long after terminal
79+
startup bind its reopened journal without treating the wrapper itself as an
80+
agent. The journal is still admitted only after a writable handle is proven
81+
beneath the exact PTY process tree.
82+
7583
CWD, filename timestamps, terminal title, active tab, and “closest match” logic
7684
must not establish an authoritative binding. A host that cannot prove the
7785
writer relationship uses terminal fallback instead.
@@ -88,11 +96,14 @@ A provider journal source:
8896
6. emits raw records only to the selected privileged driver;
8997
7. stops all file/process observation when the terminal, integration, or server stops.
9098

91-
Discovery is retried briefly because foreground observation can arrive before
92-
the journal is opened. Expensive open-file/process inspection is used for
93-
initial binding, not for every appended record. Symlinks, non-regular files,
94-
paths outside the canonical sessions root, oversized records, invalid JSON,
95-
and unbounded growth are handled defensively.
99+
Discovery is retried briefly after terminal startup and whenever the shell
100+
loses foreground because either transition can arrive before the journal is
101+
opened. Once a supported provider is known to be foreground, discovery remains
102+
armed until that incarnation is bound or leaves the foreground. Expensive
103+
open-file/process inspection is used for initial binding, not for every
104+
appended record. Symlinks, non-regular files, paths outside the canonical
105+
sessions root, oversized records, invalid JSON, and unbounded growth are
106+
handled defensively.
96107

97108
## Versioned driver contract
98109

0 commit comments

Comments
 (0)