fix: deduplicate Codex log followers

This commit is contained in:
liujing
2026-07-20 00:55:33 +08:00
parent 71b8025666
commit 84f1dc589e
5 changed files with 305 additions and 56 deletions
+129
View File
@@ -737,6 +737,135 @@ describe("logsWorkflow", () => {
expect(output).toContain("initial log content");
});
it("rejects a second follower from the same Codex thread", async () => {
const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js");
const runId = "logs-follow-dedupe-004";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] },
executor: "claude",
maxCycles: 3,
});
state.status = "executing";
state.currentCycle = 1;
state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }];
writeState(dir, state);
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial log content\n");
const previousThread = process.env.CODEX_THREAD_ID;
process.env.CODEX_THREAD_ID = "thread-for-follower-dedupe";
const { logsWorkflow } = await import("./workflow-engine.js");
const chunks: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; };
const firstFollower = logsWorkflow({ runId, follow: true, tail: 5 });
try {
await expect(logsWorkflow({ runId, follow: true, tail: 5 }))
.rejects.toThrow(/already active for this Codex thread/);
const curState = readState(dir)!;
curState.status = "awaiting_review";
writeState(dir, curState);
await firstFollower;
expect(chunks.join("")).toContain("initial log content");
} finally {
process.stdout.write = origWrite;
if (previousThread === undefined) delete process.env.CODEX_THREAD_ID;
else process.env.CODEX_THREAD_ID = previousThread;
}
});
it("allows different Codex threads to hold followers for the same run", async () => {
const {
acquireLogsFollowLock,
logsFollowLockPath,
releaseLogsFollowLock,
runDir,
} = await import("./workflow-state.js");
const dir = runDir(repoRoot, "logs-follow-different-threads-005");
fs.mkdirSync(dir, { recursive: true });
const first = acquireLogsFollowLock(dir, "thread-a");
const second = acquireLogsFollowLock(dir, "thread-b");
try {
expect(first).toBe(logsFollowLockPath(dir, "thread-a"));
expect(second).toBe(logsFollowLockPath(dir, "thread-b"));
expect(first).not.toBe(second);
} finally {
releaseLogsFollowLock(first);
releaseLogsFollowLock(second);
}
expect(fs.existsSync(first)).toBe(false);
expect(fs.existsSync(second)).toBe(false);
});
it("reclaims a follower lock whose process is dead", async () => {
const {
acquireLogsFollowLock,
logsFollowLockPath,
releaseLogsFollowLock,
runDir,
} = await import("./workflow-state.js");
const dir = runDir(repoRoot, "logs-follow-stale-006");
fs.mkdirSync(dir, { recursive: true });
const lockPath = logsFollowLockPath(dir, "stale-thread");
fs.writeFileSync(lockPath, JSON.stringify({
pid: 999_999_999,
codexThreadId: "stale-thread",
startedAt: new Date(0).toISOString(),
}));
const acquired = acquireLogsFollowLock(dir, "stale-thread");
expect(acquired).toBe(lockPath);
expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).pid).toBe(process.pid);
releaseLogsFollowLock(acquired);
expect(fs.existsSync(lockPath)).toBe(false);
});
it("keeps manual follow behavior unchanged without CODEX_THREAD_ID", async () => {
const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js");
const runId = "logs-follow-manual-007";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] },
executor: "claude",
maxCycles: 3,
});
state.status = "executing";
state.currentCycle = 1;
state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }];
writeState(dir, state);
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "manual viewer\n");
const previousThread = process.env.CODEX_THREAD_ID;
delete process.env.CODEX_THREAD_ID;
const { logsWorkflow } = await import("./workflow-engine.js");
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = () => true;
const first = logsWorkflow({ runId, follow: true, tail: 5 });
const second = logsWorkflow({ runId, follow: true, tail: 5 });
setTimeout(() => {
const curState = readState(dir)!;
curState.status = "awaiting_review";
writeState(dir, curState);
}, 100);
try {
await Promise.all([first, second]);
expect(fs.readdirSync(dir).some((name) => name.startsWith(".logs-follow-"))).toBe(false);
} finally {
process.stdout.write = origWrite;
if (previousThread === undefined) delete process.env.CODEX_THREAD_ID;
else process.env.CODEX_THREAD_ID = previousThread;
}
});
it("follow mode switches to retry log when sidecar changes and emits new content", async () => {
const { runDir, writeState, initWorkflowState, gitHead, writeProgressSidecar, readState } = await import("./workflow-state.js");
const runId = "logs-rotation-003";