feat: add live executor observability

This commit is contained in:
liujing
2026-07-17 00:48:52 +08:00
parent 3566a73a03
commit c9d76d8f58
13 changed files with 1546 additions and 28 deletions
+396
View File
@@ -262,3 +262,399 @@ echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}'
.toBe("initial_continuation");
});
});
// ---------------------------------------------------------------------------
// Progress sidecar
// ---------------------------------------------------------------------------
describe("Progress sidecar", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-sidecar-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("writes and reads progress sidecar", async () => {
const { writeProgressSidecar, readProgressSidecar } =
await import("./workflow-state.js");
const sidecar = {
executor: "reasonix" as const,
cycleIndex: 1,
attemptIndex: 0,
phase: "executing",
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
logFilePath: "cycle-1-exec.log",
logBytes: 1024,
activitySummary: "Running build",
updatedAt: new Date().toISOString(),
};
writeProgressSidecar(tmpDir, sidecar);
const read = readProgressSidecar(tmpDir);
expect(read).toBeDefined();
expect(read!.executor).toBe("reasonix");
expect(read!.phase).toBe("executing");
expect(read!.logBytes).toBe(1024);
expect(read!.activitySummary).toBe("Running build");
});
it("returns undefined when sidecar file does not exist", async () => {
const { readProgressSidecar } = await import("./workflow-state.js");
expect(readProgressSidecar(tmpDir)).toBeUndefined();
});
it("reads attempt log path for initial attempt and retry", async () => {
const { attemptLogPath } = await import("./workflow-state.js");
const dir = tmpDir;
expect(path.basename(attemptLogPath(dir, 1, 0))).toBe("cycle-1-exec.log");
expect(path.basename(attemptLogPath(dir, 2, 0))).toBe("cycle-2-exec.log");
expect(path.basename(attemptLogPath(dir, 1, 1))).toBe("cycle-1-exec-retry-1.log");
expect(path.basename(attemptLogPath(dir, 1, 3))).toBe("cycle-1-exec-retry-3.log");
});
it("nextAttemptIndex finds the next free index", async () => {
const { nextAttemptIndex, attemptLogPath } = await import("./workflow-state.js");
fs.writeFileSync(attemptLogPath(tmpDir, 1, 0), "initial");
expect(nextAttemptIndex(tmpDir, 1)).toBe(1);
fs.writeFileSync(attemptLogPath(tmpDir, 1, 1), "retry1");
expect(nextAttemptIndex(tmpDir, 1)).toBe(2);
fs.writeFileSync(attemptLogPath(tmpDir, 1, 2), "retry2");
expect(nextAttemptIndex(tmpDir, 1)).toBe(3);
// Non-existent cycle returns 0
expect(nextAttemptIndex(tmpDir, 9)).toBe(0);
});
it("sidecar preserves Chinese UTF-8 in activitySummary", async () => {
const { writeProgressSidecar, readProgressSidecar } =
await import("./workflow-state.js");
writeProgressSidecar(tmpDir, {
executor: "claude",
cycleIndex: 1,
attemptIndex: 0,
phase: "executing",
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
logFilePath: "cycle-1-exec.log",
logBytes: 42,
activitySummary: "读取文件 src/index.ts",
updatedAt: new Date().toISOString(),
});
const read = readProgressSidecar(tmpDir);
expect(read).toBeDefined();
expect(read!.activitySummary).toBe("读取文件 src/index.ts");
});
});
// ---------------------------------------------------------------------------
// Status live fields and sidecar integration
// ---------------------------------------------------------------------------
describe("Status and sidecar live fields", () => {
let tmpDir: string;
let stateRoot: string;
let repoRoot: string;
let originalAgentDir: string | undefined;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-status-test-"));
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "workflows");
fs.mkdirSync(repoRoot, { recursive: true });
const { execFileSync } = await import("node:child_process");
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot });
fs.writeFileSync(path.join(repoRoot, "test.txt"), "test");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot });
originalAgentDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
});
afterEach(() => {
if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = originalAgentDir;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("statusWorkflow includes live fields from sidecar when executing", async () => {
const { runDir, writeState, initWorkflowState, gitHead, writeProgressSidecar, readState }
= await import("./workflow-state.js");
const runId = "status-live-fields-001";
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() }];
writeState(dir, state);
// Write a sidecar as if an adapter was running
writeProgressSidecar(dir, {
executor: "claude",
cycleIndex: 1,
attemptIndex: 0,
pid: 12345,
phase: "executing",
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
lastActivityAt: new Date().toISOString(),
logFilePath: "cycle-1-exec.log",
logBytes: 4096,
activitySummary: "Reading src/index.ts",
updatedAt: new Date().toISOString(),
});
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "log content");
const { statusWorkflow } = 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; };
try {
statusWorkflow({ runId, json: true });
} finally {
process.stdout.write = origWrite;
}
const output = JSON.parse(chunks.join(""));
expect(output.liveLogFile).toContain("cycle-1-exec.log");
expect(output.executionElapsed).toBeDefined();
expect(output.lastActivity).toBeDefined();
expect(output.logBytes).toBe(4096);
expect(output.executorPid).toBe(12345);
expect(output.activitySummary).toBe("Reading src/index.ts");
});
it("status live fields absent when no sidecar", async () => {
const { runDir, writeState, initWorkflowState, gitHead } = await import("./workflow-state.js");
const runId = "status-no-sidecar-002";
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: "reasonix",
maxCycles: 3,
});
state.status = "completed";
state.currentCycle = 1;
state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }];
writeState(dir, state);
const { statusWorkflow } = 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; };
try {
statusWorkflow({ runId, json: true });
} finally {
process.stdout.write = origWrite;
}
const output = JSON.parse(chunks.join(""));
expect(output.liveLogFile).toBeUndefined();
expect(output.executionElapsed).toBeUndefined();
expect(output.activitySummary).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// logsWorkflow follow mode
// ---------------------------------------------------------------------------
describe("logsWorkflow", () => {
let tmpDir: string;
let stateRoot: string;
let repoRoot: string;
let originalAgentDir: string | undefined;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-logs-test-"));
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "workflows");
fs.mkdirSync(repoRoot, { recursive: true });
const { execFileSync } = await import("node:child_process");
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot });
fs.writeFileSync(path.join(repoRoot, "test.txt"), "test");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot });
originalAgentDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
});
afterEach(() => {
if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = originalAgentDir;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("tail mode prints Chinese UTF-8 lines without duplication", async () => {
const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js");
const runId = "logs-tail-utf8-001";
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);
// Write Chinese UTF-8 content to the log file
const logContent = "line1\n读取文件 src/index.ts\n日本語\n🚀\nlast line";
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), logContent);
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; };
try {
await logsWorkflow({ runId, follow: false, tail: 100 });
} finally {
process.stdout.write = origWrite;
}
const output = chunks.join("");
expect(output).toContain("读取文件 src/index.ts");
expect(output).toContain("日本語");
expect(output).toContain("🚀");
// No duplication
expect(output.split("读取文件 src/index.ts").length - 1).toBe(1);
});
it("follow mode exits when state changes from executing to awaiting_review", async () => {
const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js");
const runId = "logs-follow-exit-002";
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 { 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; };
// Start logsWorkflow in follow mode, change state after a short delay
const logsPromise = logsWorkflow({ runId, follow: true, tail: 5 });
setTimeout(() => {
const curState = readState(dir)!;
curState.status = "awaiting_review";
writeState(dir, curState);
}, 200);
try {
await logsPromise;
} finally {
process.stdout.write = origWrite;
}
const output = chunks.join("");
expect(output).toContain("initial log content");
});
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";
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);
// Create initial log
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial log\n");
// Write a sidecar pointing to the original log
writeProgressSidecar(dir, {
executor: "claude",
cycleIndex: 1,
attemptIndex: 0,
phase: "executing",
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
logFilePath: "cycle-1-exec.log",
logBytes: 12,
activitySummary: "Running",
updatedAt: new Date().toISOString(),
});
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 logsPromise = logsWorkflow({ runId, follow: true, tail: 5 });
setTimeout(() => {
// Create retry log and update cycle record
fs.writeFileSync(path.join(dir, "cycle-1-exec-retry-1.log"), "retry content\n你好世界\n");
const curState = readState(dir)!;
const cycle = curState.cycles[0]!;
cycle.executorLogFile = "cycle-1-exec-retry-1.log";
cycle.executorAttemptLogs = ["cycle-1-exec.log", "cycle-1-exec-retry-1.log"];
writeState(dir, curState);
// Update sidecar to point to retry log
writeProgressSidecar(dir, {
executor: "claude",
cycleIndex: 1,
attemptIndex: 1,
phase: "executing",
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
logFilePath: "cycle-1-exec-retry-1.log",
logBytes: 30,
activitySummary: "Retrying",
updatedAt: new Date().toISOString(),
});
}, 200);
setTimeout(() => {
const curState = readState(dir)!;
curState.status = "awaiting_review";
writeState(dir, curState);
}, 600);
try {
await logsPromise;
} finally {
process.stdout.write = origWrite;
}
const output = chunks.join("");
expect(output).toContain("initial log");
expect(output).toContain("retry content");
expect(output).toContain("你好世界");
});
});