feat(agent-codex): implement mtime-based activity state detection
Use the Codex session file's mtime as a proxy for agent activity. Codex continuously appends to its rollout JSONL while working, so: - recently modified file → active - stale file (past threshold) → idle - process not running → exited This replaces the previous null return with real activity detection, using the same threshold constant (DEFAULT_READY_THRESHOLD_MS = 5min) as the Claude Code plugin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b6eb4d8888
commit
c307ae30e0
|
|
@ -575,39 +575,70 @@ describe("getActivityState", () => {
|
|||
it("returns exited when no runtimeHandle", async () => {
|
||||
const session = makeSession({ runtimeHandle: null });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
expect(result?.state).toBe("exited");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns exited when process is not running", async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
expect(result?.state).toBe("exited");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null (unknown) when process is running", async () => {
|
||||
it("returns null when process is running but no workspacePath", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined });
|
||||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when process is running but no session file found", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns active when session file was recently modified", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
// mtime = now (just modified)
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns idle when session file is stale", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
// mtime = 10 minutes ago (past the 5-minute threshold)
|
||||
const staleTime = Date.now() - 600_000;
|
||||
mockStat.mockResolvedValue({ mtimeMs: staleTime, mtime: new Date(staleTime) });
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("idle");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns exited when process handle has dead PID", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("ESRCH");
|
||||
});
|
||||
const session = makeSession({ runtimeHandle: makeProcessHandle(999) });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
expect(result?.state).toBe("exited");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not include timestamp in exited state", async () => {
|
||||
const session = makeSession({ runtimeHandle: null });
|
||||
const result = await agent.getActivityState(session);
|
||||
// The Codex implementation returns { state: "exited" } without timestamp
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
expect(result?.timestamp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
shellEscape,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
|
|
@ -606,20 +607,38 @@ function createCodexAgent(): Agent {
|
|||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session, _readyThresholdMs?: number): Promise<ActivityDetection | null> {
|
||||
// Check if process is running first
|
||||
if (!session.runtimeHandle) return { state: "exited" };
|
||||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited" };
|
||||
async getActivityState(session: Session, readyThresholdMs?: number): Promise<ActivityDetection | null> {
|
||||
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
||||
|
||||
// NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory
|
||||
// without workspace-specific scoping. When multiple Codex sessions run in
|
||||
// parallel, we cannot reliably determine which rollout file belongs to which
|
||||
// session. Until Codex provides per-workspace session tracking, we return
|
||||
// null (unknown) rather than guessing. See issue #13 for details.
|
||||
//
|
||||
// TODO: Implement proper per-session activity detection when Codex supports it.
|
||||
return null;
|
||||
// Check if process is running first
|
||||
const exitedAt = new Date();
|
||||
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
|
||||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
// Use session file mtime as a proxy for activity. Codex continuously
|
||||
// appends to its rollout JSONL file while working, so a recently
|
||||
// modified file means the agent is active.
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
const sessionFile = await findCodexSessionFile(session.workspacePath);
|
||||
if (!sessionFile) return null;
|
||||
|
||||
try {
|
||||
const s = await stat(sessionFile);
|
||||
const timestamp = s.mtime;
|
||||
const ageMs = Date.now() - s.mtimeMs;
|
||||
|
||||
if (ageMs <= threshold) {
|
||||
// File was recently modified — agent is actively working
|
||||
return { state: "active", timestamp };
|
||||
}
|
||||
|
||||
// File is stale — agent finished or is idle
|
||||
return { state: "idle", timestamp };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue