diff --git a/.changeset/overseer-watched-lanes.md b/.changeset/overseer-watched-lanes.md new file mode 100644 index 0000000000..71885f0194 --- /dev/null +++ b/.changeset/overseer-watched-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planner oversight now watches tasks on boards with renamed lanes instead of silently watching nothing. +category: fix +dev: `resolveWatchedStage` keyed on the literal `in-progress`/`in-review`, so on a renamed board it returned null for every card — `observeTask` returned early, no observation was recorded, and `PlannerRecoveryController` had nothing to act on. It now takes the task's resolved `columnFlags`, supplied by `project-engine.ts` at both call sites with a per-poll IR cache. diff --git a/packages/engine/src/__tests__/planner-overseer.test.ts b/packages/engine/src/__tests__/planner-overseer.test.ts index 9638ab988f..496775a3f6 100644 --- a/packages/engine/src/__tests__/planner-overseer.test.ts +++ b/packages/engine/src/__tests__/planner-overseer.test.ts @@ -19,6 +19,54 @@ function taskFixture(overrides: Partial = {}): OverseerTaskRef } as OverseerTaskRef; } +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-00:20: + +THE INVARIANT: the watched stage comes from the column's ROLE, not from its id. + +Keyed on the id, `resolveWatchedStage` returned null for every card on a renamed board — and +`observeTask` returns early on a null stage, so no observation was recorded, no +`overseer:intervention` was emitted, and `PlannerRecoveryController` had nothing to steer, retry or +targeted-fix. The whole oversight loop went inert and said nothing about it, which is why this is +worth a parameter rather than a fallback. + +THE REVIEW TEST IS THE THREE-TRAIT UNION. `isReviewColumnRole` checks only `mergeBlocker || +humanReview`, so a board whose review lane carries `merge` (mergeOrchestration) — the default's own +shape — would classify as not-in-review and be skipped. That case is asserted below precisely because +reaching for the obvious helper would have reintroduced the bug this change removes. + +REVERT PROOF, measured: drop the `columnFlags` branch and the three renamed-lane cases fail with +`expected null to be "executor" / "merger"`. +*/ +describe("resolveWatchedStage keys on the column role", () => { + const wip = { countsTowardWip: true } as never; + const mergeLane = { mergeOrchestration: true } as never; + const humanReviewLane = { humanReview: true } as never; + + it("classifies a RENAMED wip lane as the executor stage", () => { + expect(resolveWatchedStage(taskFixture({ column: "building" }), wip)).toBe("executor"); + }); + + it("classifies a review lane that carries only mergeOrchestration", () => { + // The union matters: `isReviewColumnRole` would answer false here and the card would be skipped. + expect(resolveWatchedStage(taskFixture({ column: "signoff" }), mergeLane)).toBe("merger"); + }); + + it("classifies a review lane that carries humanReview", () => { + expect(resolveWatchedStage(taskFixture({ column: "waiting" }), humanReviewLane)).toBe("merger"); + }); + + it("still returns null for a lane carrying neither role", () => { + // The gate must still gate — watching every column would be its own defect. + expect(resolveWatchedStage(taskFixture({ column: "shipped" }), { complete: true } as never)).toBeNull(); + }); + + it("falls back to the legacy ids when no flags are supplied", () => { + expect(resolveWatchedStage(taskFixture({ column: "in-progress" }))).toBe("executor"); + expect(resolveWatchedStage(taskFixture({ column: "todo" }))).toBeNull(); + }); +}); + describe("resolveWatchedStage", () => { it("resolves an active in-progress task to executor", () => { expect(resolveWatchedStage(taskFixture({ column: "in-progress" }))).toBe("executor"); diff --git a/packages/engine/src/planner-overseer.ts b/packages/engine/src/planner-overseer.ts index feefc8aa49..951732f90b 100644 --- a/packages/engine/src/planner-overseer.ts +++ b/packages/engine/src/planner-overseer.ts @@ -14,7 +14,7 @@ * the seam every later planner-oversight subtask reads from. */ -import { DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, type PlannerOversightLevel, type PrInfo, type Task } from "@fusion/core"; +import { DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, type PlannerOversightLevel, type PrInfo, type Task, type TraitFlags } from "@fusion/core"; /** Alias for the `Task.reviewState` shape without requiring a separate core export. */ type OverseerTaskReviewState = NonNullable; @@ -98,7 +98,10 @@ export type OverseerTaskRef = Pick< * * Never throws — missing/partial fields degrade to `null`. */ -export function resolveWatchedStage(task: Partial | null | undefined): OverseerWatchedStage | null { +export function resolveWatchedStage( + task: Partial | null | undefined, + columnFlags?: TraitFlags, +): OverseerWatchedStage | null { try { if (!task) return null; @@ -111,36 +114,40 @@ export function resolveWatchedStage(task: Partial | null | unde } /* - FNXC:WorkflowLifecycleColumns 2026-07-30-21:35 (audited — REAL and HIGH IMPACT, deferred): - On a renamed board this returns `null` for every card, so the planner overseer watches NOTHING. + FNXC:WorkflowLifecycleColumns 2026-07-31-00:20: + Keyed on the ROLE, because keyed on the id this returned null for every card on a renamed board. - Worth stating at full weight because the blast radius is larger than the three literals suggest: - `observeTask` returns early on a null stage, so no observation is recorded, no - `overseer:intervention` entry is emitted, and `PlannerRecoveryController` — which consumes those - observations — has nothing to steer, retry or targeted-fix. The entire oversight loop is inert and - silent about it, exactly like the self-healing sweeps whose queries returned empty arrays. + The blast radius is why this is worth the parameter rather than a fallback: `observeTask` returns + early on a null stage, so no observation is recorded, no `overseer:intervention` entry is emitted, + and `PlannerRecoveryController` — which consumes those observations — has nothing to steer, retry + or targeted-fix. The entire oversight loop went inert and said nothing about it, the same shape as + the self-healing sweeps whose queries returned empty arrays. - NOT MECHANICAL, which is why it is flagged rather than converted. `resolveWatchedStage` is a pure - sync function over a `Partial` with no store and no task id it can resolve from, - so the lane answer has to arrive as a parameter. Its only production caller, `observeTask`, IS - async and the monitor does hold a store — but `observeTask` is called once per task per poll from - `project-engine.ts`, so resolving inside it buys a workflow read per card per poll on a timer. + THE FLAGS ARRIVE AS A PARAMETER because this function is pure and sync with no store and no task + id to resolve from. The cost objection I recorded when first auditing this — "resolving inside + `observeTask` buys a workflow read per card per poll" — turned out to be answered by the caller: + `project-engine.ts`'s poll ALREADY awaits `resolveEffectiveSettings` per task, so it is a per-task + async loop already, and an IR cache keyed by workflow makes the addition (distinct workflows) + resolutions rather than (cards). - The shape that works is the one the board-load enrichment ended up with (#2845): resolve at the - POLL, once, with an IR cache keyed by workflow, and pass the flags down. That is a change to - `project-engine.ts`'s poll as much as to this file, and it is a cost judgement about a periodic - engine loop rather than a rename — the same call `notification-service.ts` documents for its own - two sites. + THE REVIEW TEST IS THE THREE-TRAIT UNION, not `isReviewColumnRole`, which checks only + `mergeBlocker || humanReview`. A board whose review lane carries `merge` (mergeOrchestration) — + the default's own shape — would otherwise be classified as not-in-review and skipped, which is the + bug this change is removing, arriving through the helper meant to fix it. - Left counted with no exemption marker so the census keeps pointing here. `columnFlags` is in the - unwired-lane-parameter vocabulary, so whoever adds the parameter cannot leave it unwired. + `columnFlags` is in the unwired-lane-parameter vocabulary, so the wiring cannot silently rot. */ const column = task.column; - if (column !== "in-progress" && column !== "in-review") { + if (column === undefined) return null; + const isWip = columnFlags ? columnFlags.countsTowardWip === true : column === "in-progress"; + const isReview = columnFlags + ? Boolean(columnFlags.mergeOrchestration || columnFlags.mergeBlocker || columnFlags.humanReview) + : column === "in-review"; + if (!isWip && !isReview) { return null; } - if (column === "in-progress") { + if (isWip) { return "executor"; } @@ -476,14 +483,14 @@ export class PlannerOverseerMonitor { async observeTask( task: OverseerTaskRef, level: PlannerOversightLevel, - options?: { now?: () => number; executorStuckAfterMs?: number }, + options?: { now?: () => number; executorStuckAfterMs?: number; columnFlags?: TraitFlags }, ): Promise { try { if (level === "off") { return null; } - const stage = resolveWatchedStage(task); + const stage = resolveWatchedStage(task, options?.columnFlags); if (!stage) { return null; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 786b52a8d0..de7d25013b 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -20,6 +20,9 @@ import type { import { resolveProjectColumnsForRoles, REVIEW_ROLES, + resolveWorkflowIrForTask, + resolveColumnFlags, + type TraitFlags, allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, emitOverseerConfirmation, @@ -1504,6 +1507,41 @@ export class ProjectEngine { return this.prMonitor; } + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-00:20: + The flags for a task's OWN column, for the planner-overseer stage classification. + + `resolveWatchedStage` is pure and sync with no store, so the answer has to be resolved here and + passed in. Keyed on the id it returned null for every card on a renamed board, and `observeTask` + returns early on a null stage — so no observation, no `overseer:intervention`, and nothing for + `PlannerRecoveryController` to act on. The oversight loop was inert and silent about it. + + The IR cache is what makes this affordable: the poll below already awaits `resolveEffectiveSettings` + per task, so it is a per-task async loop regardless, and caching by workflow means the added work is + (distinct workflows) resolutions rather than (cards). The cache is per-poll on purpose — a longer + lifetime would serve stale lanes after a workflow edit, which is the failure this program exists to + remove wearing a different hat. + + Fail-soft: an unresolvable workflow returns undefined and the callee falls back to the legacy ids, + which is exactly today's behaviour. + */ + private async resolveTaskColumnFlags( + store: TaskStore, + task: Pick, + irCache: Map, + ): Promise { + try { + const ir = await resolveWorkflowIrForTask(store, task.id, irCache); + /* `WorkflowIr` is a union and the v1 arm declares no columns — a v1 graph has no lane + vocabulary to read, so undefined (legacy ids) is the correct answer for it. */ + const columns = (ir as { columns?: Array<{ id: string }> }).columns; + const declared = columns?.find((column) => column.id === task.column); + return declared ? resolveColumnFlags(declared as never) : undefined; + } catch { + return undefined; + } + } + /** Get the records-only PlannerOverseerMonitor (if initialized). See FN-7511. */ getPlannerOverseer(): PlannerOverseerMonitor | undefined { return this.plannerOverseer; @@ -1591,7 +1629,10 @@ export class ProjectEngine { let observation = this.plannerOverseer ? this.plannerOverseer.getObservations(taskId).slice(-1)[0] : undefined; if (!observation && this.plannerOverseer) { - observation = (await this.plannerOverseer.observeTask(task, level)) ?? undefined; + /* FNXC:WorkflowLifecycleColumns 2026-07-31-00:20: the manual nudge classifies the same way the + poll does, or a renamed board answers `no-active-stage` to an operator pressing the button. */ + const columnFlags = await this.resolveTaskColumnFlags(store, task, new Map()); + observation = (await this.plannerOverseer.observeTask(task, level, { columnFlags })) ?? undefined; } if (!observation) { return { applied: false, reason: "no-active-stage", task }; @@ -2971,6 +3012,8 @@ export class ProjectEngine { // FNXC:PlannerOversight 2026-07-14-00:10: keep session-advisor human-control on live settings. this.sessionAdvisor?.setSettings(engineSettings); + /* One IR cache for the whole sweep: (distinct workflows) resolutions, not (cards). */ + const overseerIrCache = new Map(); for (const task of inFlight) { try { const workflowEffective = await resolveEffectiveSettings(store, { id: task.id }).catch(() => ({}) as Record); @@ -3001,7 +3044,11 @@ export class ProjectEngine { // in-progress task reports `signal: "stuck"` instead of always // `progressing` (the FN-7732 symptom). const executorStuckAfterMs = resolveExecutorStuckAfterMs(workflowEffective.plannerOverseerExecutorStuckAfterMs); - await overseer.observeTask(task, level, { executorStuckAfterMs }); + /* FNXC:WorkflowLifecycleColumns 2026-07-31-00:20: without this the stage is resolved from + the legacy ids and every card on a renamed board classifies as null — see + `resolveTaskColumnFlags`. The cache is per-poll so a workflow edit is picked up next tick. */ + const columnFlags = await this.resolveTaskColumnFlags(store, task, overseerIrCache); + await overseer.observeTask(task, level, { executorStuckAfterMs, columnFlags }); // FN-7512: one guarded, autonomous-only bounded recovery tick at the // same passive seam FN-7511 uses for observation. Inert for every diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index b1a285bfa6..ea28b9119b 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -10,7 +10,6 @@ "packages/engine/src/restart-recovery-coordinator.ts": 4, "packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/task-store/task-artifacts-ops.ts": 3, - "packages/engine/src/planner-overseer.ts": 3, "packages/core/src/agent-store.ts": 2, "packages/core/src/task-store/audit-ops.ts": 2, "packages/core/src/task-store/moves.ts": 2, @@ -21,6 +20,7 @@ "packages/dashboard/src/github-tracking-state.ts": 2, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 2, "packages/engine/src/auto-merge-finalization.ts": 2, + "packages/engine/src/planner-overseer.ts": 2, "packages/core/src/eval-signal-collector.ts": 1, "packages/core/src/in-review-stall.ts": 1, "packages/core/src/mission-store.ts": 1,