feat: add live executor observability
This commit is contained in:
@@ -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("你好世界");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user