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:
Fusion
2026-04-26 12:45:05 -07:00
committed by gsxdsm
parent df7c197ab6
commit bbc872cee9
15 changed files with 231 additions and 231 deletions

View File

@@ -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 () => {

View File

@@ -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");

View File

@@ -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();

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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"),

View File

@@ -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(),
}),

View File

@@ -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");
}
/**

View File

@@ -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");

View File

@@ -19,7 +19,7 @@ vi.mock("../../logger.js", () => {
runtimeLog: mockLogger,
createLogger: () => mockLogger,
schedulerLog: mockLogger,
triageLog: mockLogger,
planLog: mockLogger,
};
});

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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;
}