feat(FN-2603): merge fusion/fn-2603 (auto-resolved)
- test(FN-2603): update downstream tests for planning label expectations - test(FN-2603): complete Step 6 — update engine tests for planning terminology - feat(FN-2603): complete Step 5 — rename self-healing planning APIs - feat(FN-2603): complete Step 4 — rename needs-respecify status to needs-replan - feat(FN-2603): complete Step 3 — rename specifying and re-specification text - feat(FN-2603): complete Step 2 — rename triageLog usages to planLog - feat(FN-2603): complete Step 1 — rename triage logger to plan logger - feat(FN-2602): complete Step 7 — add release changeset - test(FN-2602): complete Step 6 — align migration and schema tests - test(FN-2602): complete Step 5 — update store status assertions - feat(FN-2602): complete Step 4 — rename triage prompt labels - feat(FN-2602): complete Step 3 — add status rename migration - feat(FN-2602): complete Step 2 — rename respecify status literals - feat(FN-2602): complete Step 1 — rename triage display labels
This commit is contained in:
@@ -34,7 +34,7 @@ vi.mock("../logger.js", () => {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
schedulerLog: createMockLogger(),
|
||||
executorLog: createMockLogger(),
|
||||
triageLog: createMockLogger(),
|
||||
planLog: createMockLogger(),
|
||||
mergerLog: createMockLogger(),
|
||||
worktreePoolLog: createMockLogger(),
|
||||
reviewerLog: createMockLogger(),
|
||||
@@ -5456,7 +5456,7 @@ describe("fn_task_add_dep tool", () => {
|
||||
|
||||
await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-planning");
|
||||
});
|
||||
|
||||
it("appends to existing dependencies without overwriting when confirm=true", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createLogger,
|
||||
schedulerLog,
|
||||
executorLog,
|
||||
triageLog,
|
||||
planLog,
|
||||
mergerLog,
|
||||
worktreePoolLog,
|
||||
reviewerLog,
|
||||
@@ -70,8 +70,8 @@ describe("createLogger", () => {
|
||||
executorLog.log("run");
|
||||
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=info\u0000[executor] run");
|
||||
|
||||
triageLog.log("spec");
|
||||
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=info\u0000[triage] spec");
|
||||
planLog.log("spec");
|
||||
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=info\u0000[plan] spec");
|
||||
|
||||
mergerLog.log("merge");
|
||||
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=info\u0000[merger] merge");
|
||||
|
||||
@@ -830,8 +830,8 @@ describe("Triage re-pick after restart", () => {
|
||||
triage.stop();
|
||||
|
||||
// Both triage tasks should have been picked up for specification
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-060", { status: "specifying" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-061", { status: "specifying" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-060", { status: "planning" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-061", { status: "planning" });
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -960,7 +960,7 @@ describe("Scheduler after restart", () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
triage.stop();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-080", { status: "specifying" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-080", { status: "planning" });
|
||||
|
||||
// 2. Scheduler moves todo → in-progress
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1966,21 +1966,21 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
|
||||
describe("recoverApprovedTriageTasks", () => {
|
||||
it("recovers approved specifying triage tasks that are not actively processing", async () => {
|
||||
it("recovers approved planning triage tasks that are not actively processing", async () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverApprovedTriageTask: recoverFn,
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
{ action: "Spec review requested" },
|
||||
@@ -2004,19 +2004,19 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
it("skips tasks that are still actively being specified", async () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-101"]));
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set(["FN-101"]));
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverApprovedTriageTask: recoverFn,
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-101",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2033,21 +2033,21 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips specifying triage tasks whose latest review is not APPROVE", async () => {
|
||||
it("skips planning triage tasks whose latest review is not APPROVE", async () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverApprovedTriageTask: recoverFn,
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-102",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
{ action: "Spec review: APPROVE" },
|
||||
@@ -2069,20 +2069,20 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverOrphanedSpecifyingTasks", () => {
|
||||
it("clears status for orphaned specifying tasks without approval", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
describe("recoverOrphanedPlanningTasks", () => {
|
||||
it("clears status for orphaned planning tasks without approval", async () => {
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-200",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2091,31 +2091,31 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-200",
|
||||
"Auto-recovered orphaned specifying task — agent session lost, cleared for re-specification",
|
||||
"Auto-recovered orphaned planning task — agent session lost, cleared for re-planning",
|
||||
);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips tasks that are still actively being specified", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-201"]));
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set(["FN-201"]));
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-201",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2124,7 +2124,7 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
@@ -2133,18 +2133,18 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
|
||||
it("skips tasks that have an approved spec (handled by recoverApprovedTriageTasks)", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-202",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
{ action: "Spec review requested" },
|
||||
@@ -2156,7 +2156,7 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
@@ -2165,18 +2165,18 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
|
||||
it("skips paused tasks", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-203",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: true,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2185,7 +2185,7 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
@@ -2194,18 +2194,18 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
|
||||
it("skips tasks within the grace period", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-204",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2215,7 +2215,7 @@ describe("SelfHealingManager", () => {
|
||||
// Only 30s later — within the 60s grace period
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
@@ -2239,12 +2239,12 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
const store = createMockStore();
|
||||
const evictFn = vi.fn().mockReturnValue(new Set<string>());
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-100"]));
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set(["FN-100"]));
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverApprovedTriageTask: recoverFn,
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
evictStaleTriageProcessing: evictFn,
|
||||
});
|
||||
|
||||
@@ -2252,7 +2252,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2261,7 +2261,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
// FN-100 is in specifyingIds — would normally be skipped.
|
||||
// FN-100 is in planningIds — would normally be skipped.
|
||||
// But evictStaleTriageProcessing was called first (even though it evicted nothing here).
|
||||
await manager.recoverApprovedTriageTasks();
|
||||
|
||||
@@ -2271,21 +2271,21 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("recovers approved task after eviction removes it from specifyingIds", async () => {
|
||||
it("recovers approved task after eviction removes it from planningIds", async () => {
|
||||
const store = createMockStore();
|
||||
let specifyingIds = new Set(["FN-100"]);
|
||||
let planningIds = new Set(["FN-100"]);
|
||||
const evictFn = vi.fn().mockImplementation(() => {
|
||||
// Simulate eviction removing FN-100 from the processing set
|
||||
specifyingIds = new Set<string>();
|
||||
planningIds = new Set<string>();
|
||||
return new Set(["FN-100"]);
|
||||
});
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getSpecifying = vi.fn().mockImplementation(() => specifyingIds);
|
||||
const getPlanning = vi.fn().mockImplementation(() => planningIds);
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverApprovedTriageTask: recoverFn,
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
evictStaleTriageProcessing: evictFn,
|
||||
});
|
||||
|
||||
@@ -2293,7 +2293,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2304,7 +2304,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
const result = await manager.recoverApprovedTriageTasks();
|
||||
|
||||
// After eviction cleared the specifying set, the task was recovered
|
||||
// After eviction cleared the planning set, the task was recovered
|
||||
expect(result).toBe(1);
|
||||
expect(recoverFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "FN-100" }),
|
||||
@@ -2313,14 +2313,14 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("calls evictStaleTriageProcessing before recoverOrphanedSpecifyingTasks", async () => {
|
||||
it("calls evictStaleTriageProcessing before recoverOrphanedPlanningTasks", async () => {
|
||||
const store = createMockStore();
|
||||
const evictFn = vi.fn().mockReturnValue(new Set<string>());
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-101"]));
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set(["FN-101"]));
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
getPlanningTaskIds: getPlanning,
|
||||
evictStaleTriageProcessing: evictFn,
|
||||
});
|
||||
|
||||
@@ -2328,7 +2328,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
{
|
||||
id: "FN-101",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: REVISE" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -2337,7 +2337,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
await manager.recoverOrphanedSpecifyingTasks();
|
||||
await manager.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(evictFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -2424,7 +2424,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
(vi.spyOn(manager as any, "recoverPartialProgressNoTaskDoneFailures").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverOrphanedExecutions").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverApprovedTriageTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverOrphanedSpecifyingTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverOrphanedPlanningTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "archiveStaleDoneTasks").mockResolvedValue(0) as any);
|
||||
|
||||
await (manager as any).runMaintenance();
|
||||
@@ -2491,14 +2491,14 @@ describe("maintenance cycle concurrency", () => {
|
||||
makeSlow("recoverPartialProgressNoTaskDoneFailures");
|
||||
makeSlow("recoverOrphanedExecutions");
|
||||
makeSlow("recoverApprovedTriageTasks");
|
||||
makeSlow("recoverOrphanedSpecifyingTasks");
|
||||
makeSlow("recoverOrphanedPlanningTasks");
|
||||
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
// Operations run sequentially (one at a time), not in parallel.
|
||||
expect(maxConcurrent).toBe(1);
|
||||
// All operations should have run (including last one)
|
||||
expect(executionOrder[executionOrder.length - 1]).toBe("recoverOrphanedSpecifyingTasks");
|
||||
expect(executionOrder[executionOrder.length - 1]).toBe("recoverOrphanedPlanningTasks");
|
||||
});
|
||||
|
||||
it("one failing batch 2 operation does not abort the batch", async () => {
|
||||
@@ -2513,7 +2513,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
"recoverPartialProgressNoTaskDoneFailures",
|
||||
"recoverOrphanedExecutions",
|
||||
"recoverApprovedTriageTasks",
|
||||
"recoverOrphanedSpecifyingTasks",
|
||||
"recoverOrphanedPlanningTasks",
|
||||
] as const;
|
||||
|
||||
// Make one operation fail
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("evaluateSpecStaleness", () => {
|
||||
expect(result.reason).toContain("Specification stale");
|
||||
expect(result.reason).toContain(`age=${defaultMaxAgeMs + 1000}ms`);
|
||||
expect(result.reason).toContain(`max=${defaultMaxAgeMs}ms`);
|
||||
expect(result.reason).toContain("moved to triage for re-specification");
|
||||
expect(result.reason).toContain("moved to triage for re-planning");
|
||||
});
|
||||
|
||||
it("uses custom specStalenessMaxAgeMs when set and valid", async () => {
|
||||
|
||||
@@ -566,7 +566,7 @@ vi.mock("../logger.js", () => {
|
||||
createLogger: vi.fn((prefix: string) => getLogger(prefix)),
|
||||
schedulerLog: getLogger("scheduler"),
|
||||
executorLog: getLogger("executor"),
|
||||
triageLog: getLogger("triage"),
|
||||
planLog: getLogger("plan"),
|
||||
mergerLog: getLogger("merger"),
|
||||
worktreePoolLog: getLogger("worktree-pool"),
|
||||
reviewerLog: getLogger("reviewer"),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { join } from "node:path";
|
||||
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { triageLog } from "../logger.js";
|
||||
import { planLog } from "../logger.js";
|
||||
|
||||
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
mockReviewStep: vi.fn(),
|
||||
@@ -182,7 +182,7 @@ describe("buildSpecificationPrompt", () => {
|
||||
expect(prompt).toContain("revising an existing task specification");
|
||||
});
|
||||
|
||||
it("generates fresh re-specification prompt when only feedback is provided", () => {
|
||||
it("generates fresh re-planning prompt when only feedback is provided", () => {
|
||||
const feedback = "Start fresh and avoid the stale bootstrap assumption";
|
||||
|
||||
const prompt = buildSpecificationPrompt(
|
||||
@@ -1053,13 +1053,13 @@ describe("Re-specification flow", () => {
|
||||
outcome: "Please add more details about error handling",
|
||||
},
|
||||
],
|
||||
status: "needs-respecify",
|
||||
status: "needs-replan",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("detects needs-respecify status", () => {
|
||||
expect(taskWithRevisionRequest.status).toBe("needs-respecify");
|
||||
it("detects needs-replan status", () => {
|
||||
expect(taskWithRevisionRequest.status).toBe("needs-replan");
|
||||
});
|
||||
|
||||
it("extracts feedback from log entry", () => {
|
||||
@@ -1155,7 +1155,7 @@ describe("requirePlanApproval setting", () => {
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
@@ -1218,7 +1218,7 @@ describe("approved triage recovery", () => {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
});
|
||||
|
||||
it("moves approved specifying task to todo during recovery", async () => {
|
||||
it("moves approved planning task to todo during recovery", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
@@ -1236,7 +1236,7 @@ describe("approved triage recovery", () => {
|
||||
id: "FN-001",
|
||||
description: "Recovered triage task",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
@@ -1259,7 +1259,7 @@ describe("approved triage recovery", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Auto-recovered approved specification stuck in specifying — moved to todo",
|
||||
"Auto-recovered approved specification stuck in planning — moved to todo",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1280,7 +1280,7 @@ describe("approved triage recovery", () => {
|
||||
id: "FN-001",
|
||||
description: "Recovered triage task",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
@@ -1299,7 +1299,7 @@ describe("approved triage recovery", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("moves approved specifying task to awaiting-approval when manual approval is required", async () => {
|
||||
it("moves approved planning task to awaiting-approval when manual approval is required", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
@@ -1316,7 +1316,7 @@ describe("approved triage recovery", () => {
|
||||
id: "FN-001",
|
||||
description: "Recovered triage task",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
@@ -1334,7 +1334,7 @@ describe("approved triage recovery", () => {
|
||||
});
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Auto-recovered approved specification stuck in specifying — awaiting manual approval",
|
||||
"Auto-recovered approved specification stuck in planning — awaiting manual approval",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2493,7 +2493,7 @@ describe("stale approval detection", () => {
|
||||
});
|
||||
|
||||
describe("pause-abort status clearing (bug fix)", () => {
|
||||
it("clears specifying status to null on global pause (not a no-op)", async () => {
|
||||
it("clears planning status to null on global pause (not a no-op)", async () => {
|
||||
const settingsListeners: Array<(e: any) => void> = [];
|
||||
|
||||
const store = {
|
||||
@@ -2547,7 +2547,7 @@ describe("pause-abort status clearing (bug fix)", () => {
|
||||
});
|
||||
|
||||
describe("stuck task detector integration", () => {
|
||||
it("markStuckAborted clears specifying status to null for retry", async () => {
|
||||
it("markStuckAborted clears planning status to null for retry", async () => {
|
||||
const store = {
|
||||
on: vi.fn(),
|
||||
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
|
||||
@@ -2632,7 +2632,7 @@ describe("stuck task detector integration", () => {
|
||||
|
||||
describe("specifyTask — status restore failure diagnostics", () => {
|
||||
it("logs warning when status restore fails during pause abort", async () => {
|
||||
const warnSpy = vi.spyOn(triageLog, "warn");
|
||||
const warnSpy = vi.spyOn(planLog, "warn");
|
||||
const settingsListeners: Array<(e: any) => void> = [];
|
||||
|
||||
const store = {
|
||||
@@ -2686,7 +2686,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
|
||||
});
|
||||
|
||||
it("logs warning when status restore fails during stuck-detector abort", async () => {
|
||||
const warnSpy = vi.spyOn(triageLog, "warn");
|
||||
const warnSpy = vi.spyOn(planLog, "warn");
|
||||
|
||||
const store = {
|
||||
on: vi.fn(),
|
||||
@@ -2738,7 +2738,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
|
||||
|
||||
it("logs warning when logEntry fails during rate-limit retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(triageLog, "warn");
|
||||
const warnSpy = vi.spyOn(planLog, "warn");
|
||||
|
||||
try {
|
||||
const task: Task = {
|
||||
@@ -2795,7 +2795,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
|
||||
});
|
||||
|
||||
it("logs warning when transient-error retry status update fails", async () => {
|
||||
const warnSpy = vi.spyOn(triageLog, "warn");
|
||||
const warnSpy = vi.spyOn(planLog, "warn");
|
||||
const task: Task = {
|
||||
id: "FN-208",
|
||||
description: "Transient retry test",
|
||||
@@ -3269,7 +3269,7 @@ describe("TriageProcessor delegation tools", () => {
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
status: "specifying",
|
||||
status: "planning",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
|
||||
@@ -271,7 +271,7 @@ When creating multiple related tasks, declare dependencies between them:
|
||||
\`fn_task_create(description="load door sounds", dependencies=[])\` → returns KB-050
|
||||
\`fn_task_create(description="play sound on door open/close", dependencies=["KB-050"])\`
|
||||
|
||||
**Discovered a dependency:** \`fn_task_add_dep(task_id="KB-XXX")\` — use when you discover mid-execution that another task must be completed first. This will return a warning first — you must call again with \`confirm=true\` to proceed. Adding a dependency stops execution, discards current work, and moves the task to triage for re-specification.
|
||||
**Discovered a dependency:** \`fn_task_add_dep(task_id="KB-XXX")\` — use when you discover mid-execution that another task must be completed first. This will return a warning first — you must call again with \`confirm=true\` to proceed. Adding a dependency stops execution, discards current work, and moves the task to triage for re-planning.
|
||||
|
||||
## Cross-model review via fn_review_step tool
|
||||
|
||||
@@ -1267,13 +1267,13 @@ export class TaskExecutor {
|
||||
const audit = createRunAuditor(this.store, engineRunContext);
|
||||
|
||||
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
|
||||
// When enabled, stale tasks are moved back to triage with status "needs-respecify"
|
||||
// When enabled, stale tasks are moved back to triage with status "needs-replan"
|
||||
// so they receive fresh specification before execution. This guard runs early in
|
||||
// execute() to prevent stale tasks from entering worktree creation or agent sessions.
|
||||
// If timestamp evaluation is skipped (missing/unreadable file), continue with execution
|
||||
// so existing filesystem validation paths remain authoritative.
|
||||
// Skip for tasks that are already in-progress, in-review, merging, or done —
|
||||
// these should not be interrupted and sent back to triage for respecification.
|
||||
// these should not be interrupted and sent back to triage for re-planning.
|
||||
const activeColumns = new Set(["in-progress", "in-review", "done"]);
|
||||
const activeMergeStatuses = new Set(["merging", "merging-pr"]);
|
||||
const isActiveTask = activeColumns.has(task.column) || activeMergeStatuses.has(task.status ?? "");
|
||||
@@ -1283,9 +1283,9 @@ export class TaskExecutor {
|
||||
const staleness = await evaluateSpecStaleness({ settings, promptPath });
|
||||
if (staleness.isStale) {
|
||||
executorLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
|
||||
// Move to triage first, then set status so the task enters triage with needs-respecify
|
||||
// Move to triage first, then set status so the task enters triage with needs-replan
|
||||
await this.store.moveTask(task.id, "triage");
|
||||
await this.store.updateTask(task.id, { status: "needs-respecify" });
|
||||
await this.store.updateTask(task.id, { status: "needs-replan" });
|
||||
await this.store.logEntry(task.id, staleness.reason, undefined, this.currentRunContext);
|
||||
return;
|
||||
}
|
||||
@@ -2749,7 +2749,7 @@ export class TaskExecutor {
|
||||
|
||||
// Add the dependency
|
||||
await store.updateTask(taskId, { dependencies: [...existing, targetId] });
|
||||
await store.logEntry(taskId, `Added dependency on ${targetId} — stopping execution for re-specification`);
|
||||
await store.logEntry(taskId, `Added dependency on ${targetId} — stopping execution for re-planning`);
|
||||
|
||||
// Trigger abort flow (same pattern as pausedAborted)
|
||||
this.depAborted.add(taskId);
|
||||
@@ -2767,7 +2767,7 @@ export class TaskExecutor {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Added dependency on ${targetId}. Stopping execution — task will move to triage for re-specification.`,
|
||||
text: `Added dependency on ${targetId}. Stopping execution — task will move to triage for re-planning.`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
@@ -3011,7 +3011,7 @@ export class TaskExecutor {
|
||||
* Shared between the try-block (graceful return) and catch-block (error) paths.
|
||||
*/
|
||||
private async handleDepAbortCleanup(taskId: string, worktreePath: string): Promise<void> {
|
||||
executorLog.log(`${taskId} dependency added — work discarded, moved to triage for re-specification`);
|
||||
executorLog.log(`${taskId} dependency added — work discarded, moved to triage for re-planning`);
|
||||
|
||||
// Remove worktree
|
||||
try {
|
||||
@@ -3043,7 +3043,7 @@ export class TaskExecutor {
|
||||
// Update task: clear worktree and status, move to triage
|
||||
await this.store.updateTask(taskId, { worktree: null, status: null });
|
||||
await this.store.moveTask(taskId, "triage");
|
||||
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-specification");
|
||||
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-planning");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,8 +62,8 @@ export const schedulerLog = createLogger("scheduler");
|
||||
/** Logger for the task executor subsystem. */
|
||||
export const executorLog = createLogger("executor");
|
||||
|
||||
/** Logger for the triage processor subsystem. */
|
||||
export const triageLog = createLogger("triage");
|
||||
/** Logger for the plan processor subsystem. */
|
||||
export const planLog = createLogger("plan");
|
||||
|
||||
/** Logger for the pi agent session subsystem. */
|
||||
export const piLog = createLogger("pi");
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("../../logger.js", () => {
|
||||
runtimeLog: mockLogger,
|
||||
createLogger: () => mockLogger,
|
||||
schedulerLog: mockLogger,
|
||||
triageLog: mockLogger,
|
||||
planLog: mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ export class InProcessRuntime
|
||||
recoverFailedPreMergeStep: (task) => this.executor.recoverFailedPreMergeWorkflowStep(task),
|
||||
getExecutingTaskIds: () => this.executor.getExecutingTaskIds(),
|
||||
recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false),
|
||||
getSpecifyingTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||
});
|
||||
this.selfHealingManager.start();
|
||||
|
||||
@@ -683,7 +683,7 @@ export class Scheduler {
|
||||
}
|
||||
|
||||
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
|
||||
// When enabled, stale tasks are moved back to triage with status "needs-respecify"
|
||||
// When enabled, stale tasks are moved back to triage with status "needs-replan"
|
||||
// so they receive fresh specification before execution. This guard runs after
|
||||
// filesystem validation so missing/unreadable files skip staleness checks entirely.
|
||||
const promptPath = getPromptPath(this.store.getTasksDir(), task.id);
|
||||
@@ -691,7 +691,7 @@ export class Scheduler {
|
||||
if (staleness.isStale) {
|
||||
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
|
||||
await this.store.moveTask(task.id, "triage");
|
||||
await this.store.updateTask(task.id, { status: "needs-respecify" });
|
||||
await this.store.updateTask(task.id, { status: "needs-replan" });
|
||||
await this.store.logEntry(task.id, staleness.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -44,14 +44,14 @@ export interface SelfHealingOptions {
|
||||
getExecutingTaskIds?: () => Set<string>;
|
||||
/**
|
||||
* Recover a triage task whose spec was approved but whose final transition
|
||||
* out of `status: "specifying"` never completed.
|
||||
* out of `status: "planning"` never completed.
|
||||
*/
|
||||
recoverApprovedTriageTask?: (task: Task) => Promise<boolean>;
|
||||
/**
|
||||
* Returns the set of task IDs currently being specified by triage.
|
||||
* Used to avoid recovering active triage sessions.
|
||||
*/
|
||||
getSpecifyingTaskIds?: () => Set<string>;
|
||||
getPlanningTaskIds?: () => Set<string>;
|
||||
/**
|
||||
* Evict tasks from the triage processor's `processing` set that have been
|
||||
* there longer than the staleness threshold (hung promises from stuck kills).
|
||||
@@ -151,7 +151,7 @@ export class SelfHealingManager {
|
||||
* has had a chance to resume orphaned sessions.
|
||||
*
|
||||
* This avoids waiting for the periodic maintenance interval before fixing
|
||||
* stale in-progress/specifying tasks that no longer have a live worker.
|
||||
* stale in-progress/planning tasks that no longer have a live worker.
|
||||
*/
|
||||
async runStartupRecovery(): Promise<void> {
|
||||
// Each recovery step is isolated — one failure doesn't prevent subsequent steps.
|
||||
@@ -165,7 +165,7 @@ export class SelfHealingManager {
|
||||
{ name: "partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures().then(() => undefined) },
|
||||
{ name: "orphaned-executions", fn: () => this.recoverOrphanedExecutions().then(() => undefined) },
|
||||
{ name: "approved-triage", fn: () => this.recoverApprovedTriageTasks().then(() => undefined) },
|
||||
{ name: "orphaned-specifying", fn: () => this.recoverOrphanedSpecifyingTasks().then(() => undefined) },
|
||||
{ name: "orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks().then(() => undefined) },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
@@ -552,7 +552,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
|
||||
{ name: "recover-orphaned-executions", fn: () => this.recoverOrphanedExecutions() },
|
||||
{ name: "recover-approved-triage", fn: () => this.recoverApprovedTriageTasks() },
|
||||
{ name: "recover-orphaned-specifying", fn: () => this.recoverOrphanedSpecifyingTasks() },
|
||||
{ name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
|
||||
];
|
||||
for (const fn of batch2Fns) {
|
||||
try {
|
||||
@@ -1339,7 +1339,7 @@ export class SelfHealingManager {
|
||||
|
||||
/**
|
||||
* Recover triage tasks that already have an approved specification but were
|
||||
* left stuck in `status: "specifying"` without an active triage session.
|
||||
* left stuck in `status: "planning"` without an active triage session.
|
||||
*
|
||||
* This catches the mirror-image of executor recovery: the review completed,
|
||||
* but the final transition to `todo` / `awaiting-approval` never happened.
|
||||
@@ -1355,21 +1355,21 @@ export class SelfHealingManager {
|
||||
this.options.evictStaleTriageProcessing?.();
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const orphanedApproved = tasks.filter((t) =>
|
||||
t.column === "triage" &&
|
||||
t.status === "specifying" &&
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!specifyingIds.has(t.id) &&
|
||||
!planningIds.has(t.id) &&
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS &&
|
||||
hasLatestSpecReviewApproval(t),
|
||||
);
|
||||
|
||||
if (orphanedApproved.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${orphanedApproved.length} approved triage task(s) stuck in specifying`);
|
||||
log.warn(`Found ${orphanedApproved.length} approved triage task(s) stuck in planning`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of orphanedApproved) {
|
||||
@@ -1379,7 +1379,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} approved triage task(s) out of specifying`);
|
||||
log.log(`Recovered ${recovered} approved triage task(s) out of planning`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -1389,7 +1389,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover triage tasks stuck in `status: "specifying"` whose agent session
|
||||
* Recover triage tasks stuck in `status: "planning"` whose agent session
|
||||
* died before producing an approved spec.
|
||||
*
|
||||
* These tasks fall through two cracks:
|
||||
@@ -1398,9 +1398,9 @@ export class SelfHealingManager {
|
||||
* - `recoverApprovedTriageTasks` only handles tasks with an approved spec.
|
||||
*
|
||||
* Recovery clears the status back to `null` so the next triage poll picks
|
||||
* them up for a fresh specification attempt.
|
||||
* them up for a fresh planning attempt.
|
||||
*/
|
||||
async recoverOrphanedSpecifyingTasks(): Promise<number> {
|
||||
async recoverOrphanedPlanningTasks(): Promise<number> {
|
||||
try {
|
||||
// Evict stale entries from the triage processor's in-memory set before
|
||||
// checking — tasks with hung promises (from stuck kills) would otherwise
|
||||
@@ -1408,43 +1408,43 @@ export class SelfHealingManager {
|
||||
this.options.evictStaleTriageProcessing?.();
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const orphaned = tasks.filter((t) =>
|
||||
t.column === "triage" &&
|
||||
t.status === "specifying" &&
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!specifyingIds.has(t.id) &&
|
||||
!planningIds.has(t.id) &&
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS &&
|
||||
!hasLatestSpecReviewApproval(t),
|
||||
);
|
||||
|
||||
if (orphaned.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${orphaned.length} orphaned specifying triage task(s) without approval`);
|
||||
log.warn(`Found ${orphaned.length} orphaned planning triage task(s) without approval`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of orphaned) {
|
||||
try {
|
||||
log.log(`Recovering orphaned specifying task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
|
||||
log.log(`Recovering orphaned planning task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
|
||||
await this.store.updateTask(task.id, { status: null });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Auto-recovered orphaned specifying task — agent session lost, cleared for re-specification",
|
||||
"Auto-recovered orphaned planning task — agent session lost, cleared for re-planning",
|
||||
);
|
||||
recovered++;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to recover orphaned specifying task ${task.id}: ${errorMessage}`);
|
||||
log.error(`Failed to recover orphaned planning task ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} orphaned specifying task(s) — cleared for re-specification`);
|
||||
log.log(`Recovered ${recovered} orphaned planning task(s) — cleared for re-planning`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Orphaned specifying task recovery failed: ${errorMessage}`);
|
||||
log.error(`Orphaned planning task recovery failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Evaluates whether a task's PROMPT.md has become stale based on file modification time.
|
||||
* When spec staleness enforcement is enabled, tasks whose specification age exceeds
|
||||
* the configured threshold must be re-triaged before execution.
|
||||
* the configured threshold must be re-planned before execution.
|
||||
*/
|
||||
|
||||
import { stat } from "node:fs/promises";
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_SPEC_STALENESS_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||
* validation logic without throwing.
|
||||
*/
|
||||
export interface SpecStalenessResult {
|
||||
/** Whether the specification is considered stale and requires re-triaging. */
|
||||
/** Whether the specification is considered stale and requires re-planning. */
|
||||
isStale: boolean;
|
||||
/** Age of the PROMPT.md in milliseconds at evaluation time. Undefined when skipped. */
|
||||
ageMs: number | undefined;
|
||||
@@ -131,7 +131,7 @@ export async function evaluateSpecStaleness(
|
||||
const isStale = ageMs > maxAgeMs;
|
||||
|
||||
const reason = isStale
|
||||
? `Specification stale (age=${ageMs}ms, max=${maxAgeMs}ms) — moved to triage for re-specification`
|
||||
? `Specification stale (age=${ageMs}ms, max=${maxAgeMs}ms) — moved to triage for re-planning`
|
||||
: "";
|
||||
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { triageLog, reviewerLog, formatError } from "./logger.js";
|
||||
import { planLog, reviewerLog, formatError } from "./logger.js";
|
||||
import {
|
||||
isUsageLimitError,
|
||||
checkSessionError,
|
||||
@@ -489,7 +489,7 @@ export class TriageProcessor {
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (settings.globalPause && !previous.globalPause) {
|
||||
for (const [taskId, session] of this.activeSessions) {
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`Global pause — terminating triage session for ${taskId}`,
|
||||
);
|
||||
this.pauseAborted.add(taskId);
|
||||
@@ -531,33 +531,33 @@ export class TriageProcessor {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
// Clear stale "specifying" statuses left by a prior crash/restart.
|
||||
// Clear stale "planning" statuses left by a prior crash/restart.
|
||||
// No triage agent is actually running at startup, so any task still
|
||||
// marked as "specifying" is a leftover from a previous engine lifecycle.
|
||||
// marked as "planning" is a leftover from a previous engine lifecycle.
|
||||
// Without this, stale statuses consume concurrency slots and block
|
||||
// new triage work indefinitely.
|
||||
this.clearStaleSpecifyingStatuses().catch((err) => {
|
||||
triageLog.error("Failed to clear stale specifying statuses:", err);
|
||||
planLog.error("Failed to clear stale planning statuses:", err);
|
||||
});
|
||||
|
||||
const interval = this.options.pollIntervalMs ?? 10_000;
|
||||
this.activePollMs = interval;
|
||||
this.pollInterval = setInterval(() => this.poll(), interval);
|
||||
this.poll();
|
||||
triageLog.log("Processor started");
|
||||
planLog.log("Processor started");
|
||||
}
|
||||
|
||||
private async clearStaleSpecifyingStatuses(): Promise<void> {
|
||||
const tasks = await this.store.listTasks({ column: "triage", slim: true });
|
||||
const stale = tasks.filter(
|
||||
(t) => t.status === "specifying" && !this.processing.has(t.id),
|
||||
(t) => t.status === "planning" && !this.processing.has(t.id),
|
||||
);
|
||||
for (const t of stale) {
|
||||
triageLog.log(`Startup sweep: clearing stale 'specifying' status on ${t.id}`);
|
||||
planLog.log(`Startup sweep: clearing stale 'planning' status on ${t.id}`);
|
||||
await this.store.updateTask(t.id, { status: null });
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
triageLog.log(`Startup sweep: cleared ${stale.length} stale specifying task(s)`);
|
||||
planLog.log(`Startup sweep: cleared ${stale.length} stale planning task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ export class TriageProcessor {
|
||||
this.pollInterval = null;
|
||||
this.activePollMs = null;
|
||||
}
|
||||
triageLog.log("Processor stopped");
|
||||
planLog.log("Processor stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,7 +614,7 @@ export class TriageProcessor {
|
||||
|
||||
for (const [taskId, since] of this.processingSince) {
|
||||
if (now - since >= threshold) {
|
||||
triageLog.warn(
|
||||
planLog.warn(
|
||||
`${taskId} has been in processing for ${Math.round((now - since) / 60_000)}min ` +
|
||||
`(threshold: ${Math.round(threshold / 60_000)}min) — evicting (likely hung promise)`,
|
||||
);
|
||||
@@ -631,10 +631,10 @@ export class TriageProcessor {
|
||||
|
||||
/**
|
||||
* Recover a triage task whose spec was already approved but the final
|
||||
* handoff out of `status: "specifying"` never completed.
|
||||
* handoff out of `status: "planning"` never completed.
|
||||
*/
|
||||
async recoverApprovedTask(task: Task): Promise<boolean> {
|
||||
if (task.column !== "triage" || task.status !== "specifying") {
|
||||
if (task.column !== "triage" || task.status !== "planning") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -646,19 +646,19 @@ export class TriageProcessor {
|
||||
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
const written = await readFile(promptPath, "utf-8").catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to read PROMPT.md during approved-spec recovery (${promptPath}): ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to read PROMPT.md during approved-spec recovery (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
if (!written.trim()) {
|
||||
triageLog.warn(`${task.id} approved-spec recovery skipped — PROMPT.md missing or empty`);
|
||||
planLog.warn(`${task.id} approved-spec recovery skipped — PROMPT.md missing or empty`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.finalizeApprovedTask(task, written, settings, {
|
||||
recoveryLogAction: settings.requirePlanApproval
|
||||
? "Auto-recovered approved specification stuck in specifying — awaiting manual approval"
|
||||
: "Auto-recovered approved specification stuck in specifying — moved to todo",
|
||||
? "Auto-recovered approved specification stuck in planning — awaiting manual approval"
|
||||
: "Auto-recovered approved specification stuck in planning — moved to todo",
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -677,7 +677,7 @@ export class TriageProcessor {
|
||||
}
|
||||
this.activePollMs = newIntervalMs;
|
||||
this.pollInterval = setInterval(() => this.poll(), newIntervalMs);
|
||||
triageLog.log(`Poll interval updated to ${newIntervalMs}ms`);
|
||||
planLog.log(`Poll interval updated to ${newIntervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -702,7 +702,7 @@ export class TriageProcessor {
|
||||
// Global pause (hard stop): halt all triage activity
|
||||
if (settings.globalPause) {
|
||||
if (!this.wasGlobalPaused) {
|
||||
triageLog.log("Global pause active — triage halted");
|
||||
planLog.log("Global pause active — triage halted");
|
||||
this.wasGlobalPaused = true;
|
||||
}
|
||||
return;
|
||||
@@ -712,7 +712,7 @@ export class TriageProcessor {
|
||||
// Engine paused (soft pause): halt new triage work, but let agents finish
|
||||
if (settings.enginePaused) {
|
||||
if (!this.wasEnginePaused) {
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
"Engine paused — triage halted (in-flight agents continue)",
|
||||
);
|
||||
this.wasEnginePaused = true;
|
||||
@@ -737,12 +737,12 @@ export class TriageProcessor {
|
||||
const triageTasks = sortTasksByPriorityThenAgeAndId(eligibleTriageTasks);
|
||||
|
||||
// Respect both per-project maxTriageConcurrent and the global semaphore.
|
||||
// Only specifying tasks count against the triage limit; execution is governed by maxConcurrent.
|
||||
// Only planning tasks count against the triage limit; execution is governed by maxConcurrent.
|
||||
const maxTriageConcurrent = settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2;
|
||||
const specifying = allTasks.filter(
|
||||
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
|
||||
const planning = allTasks.filter(
|
||||
(t) => t.column === "triage" && t.status === "planning" && !t.paused,
|
||||
).length;
|
||||
const activeAgents = specifying;
|
||||
const activeAgents = planning;
|
||||
|
||||
const perProjectAvailable = Math.max(0, maxTriageConcurrent - activeAgents);
|
||||
const semaphoreAvailable = this.options.semaphore
|
||||
@@ -751,8 +751,8 @@ export class TriageProcessor {
|
||||
const maxToStart = Math.min(perProjectAvailable, semaphoreAvailable);
|
||||
|
||||
if (maxToStart <= 0 && triageTasks.length > 0) {
|
||||
triageLog.log(
|
||||
`Triage throttled: ${activeAgents} specifying agents, limit ${maxTriageConcurrent}`,
|
||||
planLog.log(
|
||||
`Plan throttled: ${activeAgents} planning agents, limit ${maxTriageConcurrent}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ export class TriageProcessor {
|
||||
void this.specifyTask(triageTasks[i]);
|
||||
}
|
||||
} catch (err) {
|
||||
triageLog.error("Poll error:", err);
|
||||
planLog.error("Poll error:", err);
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
@@ -776,7 +776,7 @@ export class TriageProcessor {
|
||||
* - **REVISE**: the agent revises the spec and calls `fn_review_spec()` again.
|
||||
* If the agent finishes without getting APPROVE, the task is NOT moved to
|
||||
* `todo` — a post-session gate requires an explicit APPROVE verdict.
|
||||
* - **RETHINK**: the conversation rewinds to a pre-specification checkpoint
|
||||
* - **RETHINK**: the conversation rewinds to a pre-planning checkpoint
|
||||
* and the agent starts over with a fundamentally different approach.
|
||||
*/
|
||||
async specifyTask(task: Task): Promise<void> {
|
||||
@@ -784,7 +784,7 @@ export class TriageProcessor {
|
||||
this.processing.add(task.id);
|
||||
this.processingSince.set(task.id, Date.now());
|
||||
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`Specifying ${task.id}: ${task.title || task.description.slice(0, 60)}`,
|
||||
);
|
||||
this.options.onSpecifyStart?.(task);
|
||||
@@ -797,8 +797,8 @@ export class TriageProcessor {
|
||||
|
||||
const agentWork = async () => {
|
||||
// Set status only after the semaphore slot has been acquired, so
|
||||
// tasks waiting in the queue don't appear as "specifying".
|
||||
await this.store.updateTask(task.id, { status: "specifying" });
|
||||
// tasks waiting in the queue don't appear as "planning".
|
||||
await this.store.updateTask(task.id, { status: "planning" });
|
||||
|
||||
const stuckDetector = this.options.stuckTaskDetector;
|
||||
|
||||
@@ -871,10 +871,10 @@ export class TriageProcessor {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to resolve triage agent instructions, continuing with defaults: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to resolve triage agent instructions, continuing with defaults: ${msg}`);
|
||||
}
|
||||
}
|
||||
triageLog.log(`${task.id}: specifying in ${isFast ? "fast" : "standard"} mode`);
|
||||
planLog.log(`${task.id}: planning in ${isFast ? "fast" : "standard"} mode`);
|
||||
const triageSystemPrompt = buildSystemPromptWithInstructions(
|
||||
resolveAgentPrompt("triage", settings.agentPrompts)
|
||||
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT),
|
||||
@@ -931,7 +931,7 @@ export class TriageProcessor {
|
||||
});
|
||||
|
||||
const modelDesc = describeModel(session);
|
||||
triageLog.log(`${task.id}: using model ${modelDesc}`);
|
||||
planLog.log(`${task.id}: using model ${modelDesc}`);
|
||||
await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`);
|
||||
await this.store.appendAgentLog(
|
||||
task.id,
|
||||
@@ -960,20 +960,20 @@ export class TriageProcessor {
|
||||
detail.attachments,
|
||||
);
|
||||
|
||||
// Check if this is a re-specification request
|
||||
const isRespecify = task.status === "needs-respecify";
|
||||
// Check if this is a re-planning request
|
||||
const isReplan = task.status === "needs-replan";
|
||||
let existingPrompt: string | undefined;
|
||||
let feedback: string | undefined;
|
||||
|
||||
if (isRespecify) {
|
||||
if (isReplan) {
|
||||
// Extract feedback from the most recent "AI spec revision requested" log entry
|
||||
const revisionLogEntry = [...task.log]
|
||||
.reverse()
|
||||
.find((entry) => entry.action === "AI spec revision requested");
|
||||
feedback = revisionLogEntry?.outcome;
|
||||
|
||||
triageLog.log(
|
||||
`${task.id} re-specifying with feedback: ${feedback?.slice(0, 100)}...`,
|
||||
planLog.log(
|
||||
`${task.id} re-planning with feedback: ${feedback?.slice(0, 100)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -996,22 +996,22 @@ export class TriageProcessor {
|
||||
|
||||
if (this.pauseAborted.has(task.id)) {
|
||||
this.pauseAborted.delete(task.id);
|
||||
triageLog.log(`${task.id} aborted by pause — clearing status`);
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
planLog.log(`${task.id} aborted by pause — clearing status`);
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort cleanup: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort cleanup: ${msg}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stuckAborted.has(task.id)) {
|
||||
this.stuckAborted.delete(task.id);
|
||||
triageLog.log(`${task.id} killed by stuck detector — clearing status for retry`);
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
planLog.log(`${task.id} killed by stuck detector — clearing status for retry`);
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector abort cleanup: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector abort cleanup: ${msg}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1024,14 +1024,14 @@ export class TriageProcessor {
|
||||
);
|
||||
try {
|
||||
await this.store.deleteTask(task.id);
|
||||
triageLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
planLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
} catch (err: unknown) {
|
||||
// deleteTask refuses when live tasks still depend on this id.
|
||||
// If fn_task_create's validation worked correctly this branch is
|
||||
// unreachable, but we keep it as defense-in-depth: leaving the
|
||||
// parent alive is always safer than stranding dependents.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.error(
|
||||
planLog.error(
|
||||
`${task.id}: cannot close parent after split (${msg}). ` +
|
||||
`Parent kept alive to avoid orphaning dependents; subtasks were still created.`,
|
||||
);
|
||||
@@ -1062,7 +1062,7 @@ export class TriageProcessor {
|
||||
specReviewVerdictRef.current === null
|
||||
? "fn_review_spec was never called"
|
||||
: `verdict was ${specReviewVerdictRef.current}`;
|
||||
triageLog.warn(
|
||||
planLog.warn(
|
||||
`${task.id} primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
@@ -1095,7 +1095,7 @@ export class TriageProcessor {
|
||||
? "fn_review_spec was never called"
|
||||
: `verdict was ${specReviewVerdictRef.current}`;
|
||||
const fallbackDesc = `${planningFallbackProvider}/${planningFallbackModelId}`;
|
||||
triageLog.warn(
|
||||
planLog.warn(
|
||||
`${task.id} primary planning model produced no approved spec (${verdictDesc}) — retrying with fallback ${fallbackDesc}`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
@@ -1128,7 +1128,7 @@ export class TriageProcessor {
|
||||
|
||||
session = fallbackResult.session;
|
||||
const fallbackModelDesc = describeModel(session);
|
||||
triageLog.log(`${task.id}: using fallback model ${fallbackModelDesc}`);
|
||||
planLog.log(`${task.id}: using fallback model ${fallbackModelDesc}`);
|
||||
await this.store.logEntry(task.id, `Triage using fallback model: ${fallbackModelDesc}`);
|
||||
await this.store.appendAgentLog(
|
||||
task.id,
|
||||
@@ -1157,7 +1157,7 @@ export class TriageProcessor {
|
||||
`Converted into subtasks: ${childTaskIds}`,
|
||||
);
|
||||
await this.store.deleteTask(task.id);
|
||||
triageLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
planLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1181,9 +1181,9 @@ export class TriageProcessor {
|
||||
const delay = formatDelay(decision.delayMs);
|
||||
const retryMessage =
|
||||
`Spec review not approved (${verdictDesc}) — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}.`;
|
||||
triageLog.warn(`${task.id} ${retryMessage}`);
|
||||
planLog.warn(`${task.id} ${retryMessage}`);
|
||||
await this.store.logEntry(task.id, retryMessage);
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, {
|
||||
status: restoreStatus,
|
||||
error: null,
|
||||
@@ -1196,7 +1196,7 @@ export class TriageProcessor {
|
||||
const failureMessage =
|
||||
`Specification failed after ${MAX_RECOVERY_RETRIES} unapproved spec reviews (${verdictDesc}). ` +
|
||||
"Retry after adjusting the task prompt or model.";
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`${task.id} spec review not approved (${verdictDesc}) — retry budget exhausted`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
@@ -1215,18 +1215,18 @@ export class TriageProcessor {
|
||||
// Stale-approval detection: re-read the task to check if new user
|
||||
// comments arrived after the spec was approved. If the comment
|
||||
// fingerprint changed, the approval is stale and the task needs
|
||||
// re-specification.
|
||||
// re-planning.
|
||||
const latestTask = await this.store.getTask(task.id);
|
||||
const currentFingerprint = computeUserCommentFingerprint(latestTask.comments);
|
||||
if (currentFingerprint !== approvedCommentFingerprintRef.current) {
|
||||
triageLog.log(
|
||||
`${task.id} stale approval detected — user comments changed after approval, triggering re-specification`,
|
||||
planLog.log(
|
||||
`${task.id} stale approval detected — user comments changed after approval, triggering re-planning`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Spec approval invalidated — new user comments arrived after approval. Task needs re-specification.",
|
||||
"Spec approval invalidated — new user comments arrived after approval. Task needs re-planning.",
|
||||
);
|
||||
await this.store.updateTask(task.id, { status: "needs-respecify" });
|
||||
await this.store.updateTask(task.id, { status: "needs-replan" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1235,12 +1235,12 @@ export class TriageProcessor {
|
||||
"utf-8",
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
await this.finalizeApprovedTask(task, written, settings, {
|
||||
isRespecify,
|
||||
isReplan,
|
||||
feedback,
|
||||
});
|
||||
this.options.onSpecifyComplete?.(task);
|
||||
@@ -1255,10 +1255,10 @@ export class TriageProcessor {
|
||||
const retryableWork = () => withRateLimitRetry(agentWork, {
|
||||
onRetry: (attempt, delayMs, error) => {
|
||||
const delaySec = Math.round(delayMs / 1000);
|
||||
triageLog.warn(`⏳ ${task.id} rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||||
planLog.warn(`⏳ ${task.id} rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||||
this.store.logEntry(task.id, `Rate limited — retry ${attempt} in ${delaySec}s`).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to log rate-limit retry entry: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to log rate-limit retry entry: ${msg}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1273,27 +1273,27 @@ export class TriageProcessor {
|
||||
// Race condition: task was deleted (e.g. as a duplicate) between listTasks()
|
||||
// and specifyTask(). The file is gone, so just log and skip — no point retrying.
|
||||
if ((err as Record<string, unknown>).code === "ENOENT") {
|
||||
triageLog.log(`${task.id} no longer exists — skipping`);
|
||||
planLog.log(`${task.id} no longer exists — skipping`);
|
||||
} else if (this.pauseAborted.has(task.id)) {
|
||||
// Pause (global or engine) — clear specifying status without reporting an error
|
||||
// Pause (global or engine) — clear planning status without reporting an error
|
||||
this.pauseAborted.delete(task.id);
|
||||
triageLog.log(`${task.id} aborted by pause — clearing status`);
|
||||
// For re-specification, restore needs-respecify status; otherwise clear to null
|
||||
planLog.log(`${task.id} aborted by pause — clearing status`);
|
||||
// For re-planning, restore needs-replan status; otherwise clear to null
|
||||
// so the next poll can re-pick this task up.
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort error cleanup: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort error cleanup: ${msg}`);
|
||||
});
|
||||
} else if (this.stuckAborted.has(task.id)) {
|
||||
// Stuck task detector killed this session — clear specifying status so the
|
||||
// Stuck task detector killed this session — clear planning status so the
|
||||
// next poll retries the task from scratch without reporting an error.
|
||||
this.stuckAborted.delete(task.id);
|
||||
triageLog.log(`${task.id} killed by stuck detector — clearing status for retry`);
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
planLog.log(`${task.id} killed by stuck detector — clearing status for retry`);
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector error cleanup: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector error cleanup: ${msg}`);
|
||||
});
|
||||
} else {
|
||||
// Check if the error is a usage-limit error and trigger global pause
|
||||
@@ -1315,29 +1315,29 @@ export class TriageProcessor {
|
||||
const delay = formatDelay(decision.delayMs);
|
||||
// Silent transient errors (e.g., "request was aborted") are noisy — skip logging
|
||||
if (!isSilentTransientError(errorMessage)) {
|
||||
triageLog.warn(`⚡ ${task.id} transient error during triage — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${errorMessage}`);
|
||||
planLog.warn(`⚡ ${task.id} transient error during triage — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${errorMessage}`);
|
||||
await this.store.logEntry(task.id, `Transient error during specification (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${errorMessage}`).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to log transient-error retry entry: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to log transient-error retry entry: ${msg}`);
|
||||
});
|
||||
}
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, {
|
||||
status: restoreStatus,
|
||||
recoveryRetryCount: decision.nextState.recoveryRetryCount,
|
||||
nextRecoveryAt: decision.nextState.nextRecoveryAt,
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during transient-error retry scheduling: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during transient-error retry scheduling: ${msg}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Recovery budget exhausted — freeze in triage with error for manual intervention
|
||||
triageLog.error(`✗ ${task.id} transient error retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`);
|
||||
planLog.error(`✗ ${task.id} transient error retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`);
|
||||
await this.store.logEntry(task.id, `Specification failed after ${MAX_RECOVERY_RETRIES} transient errors: ${errorMessage}`).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to log transient-error retries-exhausted entry: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to log transient-error retries-exhausted entry: ${msg}`);
|
||||
});
|
||||
await this.store.updateTask(task.id, {
|
||||
error: `Specification failed after ${MAX_RECOVERY_RETRIES} transient errors: ${errorMessage}`,
|
||||
@@ -1345,23 +1345,23 @@ export class TriageProcessor {
|
||||
nextRecoveryAt: null,
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${task.id}: failed to persist transient-error retries-exhausted state: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to persist transient-error retries-exhausted state: ${msg}`);
|
||||
});
|
||||
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
// For re-specification, restore needs-respecify status so it can be retried;
|
||||
// For re-planning, restore needs-replan status so it can be retried;
|
||||
// otherwise clear to null so the next poll can re-pick the task up.
|
||||
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus }).catch((restoreErr: unknown) => {
|
||||
const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
|
||||
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after specification error: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after planning error: ${msg}`);
|
||||
});
|
||||
triageLog.error(`✗ ${task.id} specification failed:`, errorDetail);
|
||||
planLog.error(`✗ ${task.id} planning failed:`, errorDetail);
|
||||
if (errorStack) {
|
||||
await this.store.logEntry(task.id, `Specification failed: ${errorMessage}`, errorStack).catch((logErr: unknown) => {
|
||||
const msg = logErr instanceof Error ? logErr.message : String(logErr);
|
||||
triageLog.warn(`${task.id}: failed to persist specification-failure stack trace: ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to persist specification-failure stack trace: ${msg}`);
|
||||
});
|
||||
}
|
||||
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
|
||||
@@ -1395,7 +1395,7 @@ export class TriageProcessor {
|
||||
label: "List Tasks",
|
||||
description:
|
||||
"List all tasks that aren't done. Returns ID, description, column, " +
|
||||
"and dependencies for each. Use to check for duplicates before specifying.",
|
||||
"and dependencies for each. Use to check for duplicates before planning.",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
@@ -1450,7 +1450,7 @@ export class TriageProcessor {
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${options.parentTaskId}: fn_task_get lookup failed for ${params.id}: ${msg}`);
|
||||
planLog.warn(`${options.parentTaskId}: fn_task_get lookup failed for ${params.id}: ${msg}`);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Task ${params.id} not found.` },
|
||||
@@ -1538,7 +1538,7 @@ export class TriageProcessor {
|
||||
parentTask = await store.getTask(options.parentTaskId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${options.parentTaskId}: failed to load parent task for fn_task_create inheritance: ${msg}`);
|
||||
planLog.warn(`${options.parentTaskId}: failed to load parent task for fn_task_create inheritance: ${msg}`);
|
||||
// Parent task not found or error - proceed without inheritance
|
||||
parentTask = undefined;
|
||||
}
|
||||
@@ -1593,7 +1593,7 @@ export class TriageProcessor {
|
||||
* - **REVISE**: returns the review feedback. The triage agent must fix the
|
||||
* PROMPT.md and call `fn_review_spec` again. A post-session gate in
|
||||
* `specifyTask()` prevents moving to `todo` if the last verdict is REVISE.
|
||||
* - **RETHINK**: rewinds the conversation to a pre-specification checkpoint
|
||||
* - **RETHINK**: rewinds the conversation to a pre-planning checkpoint
|
||||
* using `session.navigateTree()`. Returns a re-prompt instructing the agent
|
||||
* to take a fundamentally different approach.
|
||||
*/
|
||||
@@ -1645,7 +1645,7 @@ export class TriageProcessor {
|
||||
"utf-8",
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${taskId}: failed to read PROMPT.md for fn_review_spec (${promptPath}): ${msg}`);
|
||||
planLog.warn(`${taskId}: failed to read PROMPT.md for fn_review_spec (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
@@ -1672,7 +1672,7 @@ export class TriageProcessor {
|
||||
approvedCommentFingerprintRef.current = currentUserComments.length > 0
|
||||
? computeUserCommentFingerprint(currentUserComments)
|
||||
: "";
|
||||
triageLog.log(`${taskId}: spec review auto-approved (fast mode)`);
|
||||
planLog.log(`${taskId}: spec review auto-approved (fast mode)`);
|
||||
await store.logEntry(taskId, "Spec review: APPROVE (auto, fast mode)");
|
||||
return { content: [{ type: "text" as const, text: "APPROVE" }], details: {} };
|
||||
}
|
||||
@@ -1734,37 +1734,37 @@ export class TriageProcessor {
|
||||
text = `REVISE — fix the issues below, rewrite the PROMPT.md, and call fn_review_spec() again.\n\n${result.review}`;
|
||||
break;
|
||||
case "RETHINK": {
|
||||
// Rewind conversation to pre-specification checkpoint
|
||||
// Rewind conversation to pre-planning checkpoint
|
||||
const checkpointId = checkpointRef.current;
|
||||
if (checkpointId && sessionRef.current) {
|
||||
try {
|
||||
await sessionRef.current.navigateTree(checkpointId, {
|
||||
summarize: false,
|
||||
});
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — session rewound to checkpoint ${checkpointId}`,
|
||||
);
|
||||
} catch (rewindErr: unknown) {
|
||||
const msg = rewindErr instanceof Error ? rewindErr.message : String(rewindErr);
|
||||
triageLog.warn(`${taskId}: RETHINK navigateTree rewind failed, falling back to branchWithSummary: ${msg}`);
|
||||
planLog.warn(`${taskId}: RETHINK navigateTree rewind failed, falling back to branchWithSummary: ${msg}`);
|
||||
// Fallback to branchWithSummary
|
||||
try {
|
||||
sessionRef.current.sessionManager.branchWithSummary(
|
||||
checkpointId,
|
||||
`RETHINK: ${result.summary || "Approach rejected by reviewer"}`,
|
||||
);
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — branched from checkpoint ${checkpointId}`,
|
||||
);
|
||||
} catch (branchErr: unknown) {
|
||||
const branchErrMessage = branchErr instanceof Error ? branchErr.message : String(branchErr);
|
||||
triageLog.error(
|
||||
planLog.error(
|
||||
`${taskId}: RETHINK session rewind failed: ${branchErrMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
triageLog.log(
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — no session checkpoint, skipping rewind`,
|
||||
);
|
||||
}
|
||||
@@ -1804,7 +1804,7 @@ export class TriageProcessor {
|
||||
written: string,
|
||||
settings: Settings,
|
||||
options: {
|
||||
isRespecify?: boolean;
|
||||
isReplan?: boolean;
|
||||
feedback?: string;
|
||||
recoveryLogAction?: string;
|
||||
} = {},
|
||||
@@ -1813,7 +1813,7 @@ export class TriageProcessor {
|
||||
|
||||
if (dupMatch) {
|
||||
const dupId = dupMatch[1];
|
||||
triageLog.log(`${task.id} is a duplicate of ${dupId} — closing`);
|
||||
planLog.log(`${task.id} is a duplicate of ${dupId} — closing`);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Duplicate of ${dupId} — closed`,
|
||||
@@ -1828,7 +1828,7 @@ export class TriageProcessor {
|
||||
|
||||
if (parsedDeps.length > 0) {
|
||||
taskUpdates.dependencies = parsedDeps;
|
||||
triageLog.log(`${task.id} dependencies: ${parsedDeps.join(", ")}`);
|
||||
planLog.log(`${task.id} dependencies: ${parsedDeps.join(", ")}`);
|
||||
}
|
||||
|
||||
const parsedSteps = await this.store.parseStepsFromPrompt(task.id);
|
||||
@@ -1854,7 +1854,7 @@ export class TriageProcessor {
|
||||
task.id,
|
||||
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
|
||||
);
|
||||
triageLog.log(`✓ ${task.id} specified and awaiting manual approval`);
|
||||
planLog.log(`✓ ${task.id} specified and awaiting manual approval`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1862,15 +1862,15 @@ export class TriageProcessor {
|
||||
|
||||
if (options.recoveryLogAction) {
|
||||
await this.store.logEntry(task.id, options.recoveryLogAction);
|
||||
triageLog.log(`✓ ${task.id} recovered and moved to todo`);
|
||||
planLog.log(`✓ ${task.id} recovered and moved to todo`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.isRespecify) {
|
||||
if (options.isReplan) {
|
||||
await this.store.logEntry(task.id, "Spec revised by AI", options.feedback);
|
||||
triageLog.log(`✓ ${task.id} re-specified and moved to todo`);
|
||||
planLog.log(`✓ ${task.id} re-planned and moved to todo`);
|
||||
} else {
|
||||
triageLog.log(`✓ ${task.id} specified and moved to todo`);
|
||||
planLog.log(`✓ ${task.id} specified and moved to todo`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1960,7 +1960,7 @@ export async function readAttachmentContents(
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
triageLog.warn(`${taskId}: failed to read attachment '${att.filename}', skipping: ${msg}`);
|
||||
planLog.warn(`${taskId}: failed to read attachment '${att.filename}', skipping: ${msg}`);
|
||||
// Skip unreadable attachments
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user