Batched conversion of every lifecycle-column guard I hold, plus the three the census could not see. **Six files to zero, repo-wide 60 → 49 by a comment-stripped unanchored sweep.** Each conversion has an isolated revert proof and a paired negative case, and the one code move is a separate commit from the behavior changes. ## Per-file before → after Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']` sweep over `packages/*/src` + `plugins/*/src`, excluding tests. | file | before | after | note | |---|---:|---:|---| | `core/default-workflow-hooks.ts` | 4 | **0** | | | `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror converted; the flag-OFF inline block is the parity reference and stays | | `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** — see below | | `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer deliberately unchanged | | `engine/replan-target.ts` | 2 | **0** | both were comment prose, not guards | | `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column guards | | `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons | | `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column guard | | `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE | | `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE | | `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** | ROLE | | `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE | | `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias table, marked `DELIBERATE-LITERAL` with its reason | ## The census errs in BOTH directions This is the finding I would most like carried into the remaining work. - It **flagged 10 sites that were never column guards.** `role === "triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The planner *lane* is named `triage` and keeps that name — U11 removed the *column*. Worse than noise: the obvious "finish the migration" edit is to rename the role, and that silently empties the planner's prompt template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names it, so the two vocabularies are distinguishable by grep and a rename fails loudly (revert proof: 4 tests, two of them pre-existing). - It **missed 3 real guards in `executor.ts`**, because the pattern matches `column`/`toColumn`/`fromColumn` and those locals are named `from` and `originColumn`. A census keyed on variable names will keep missing guards wherever a local was named for its role in the function. ## Two real defects, not tidying **1. A renamed board could merge with its re-review never run.** `default-workflow-hooks.ts` is named for the default workflow, but the store runs it on the flag-ON path for *every* workflow — the trait registry resolves hooks by trait id, not by workflow. Its reopen predicates listed the default lineage's column names, so on a renamed board **no reopen effect fired at all**. One of them clears `workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced out of review carried its old `passed` result back in, and that satisfies the merge gate. Same regression the graph-owned-crossing carve-out exists to prevent, arriving through the other door. (Two smaller ones rode along: failure state never cleared on a renamed reopen, and an operator dragging a card back to the queue never parked it, so the scheduler re-dispatched what they had just pulled back.) **I forgot the carve-out on my first pass, and that was worse than not converting.** A role-resolved clear plus a *name*-matched exemption means a renamed board takes the clear and never the exemption, destroying the remediation input the graph had just written. My own paired negative test caught it. **2. The last-resort recovery for completed-but-stranded work did not exist off the default lineage.** In `recoverCompletedTask`, `promotedFromPlannerColumn` was false on a renamed board, so finished work resting in the planning lane was never promoted — the code fell through to `handoffTaskToReview` straight from the planning column, and role adjacency has no planning → review edge, so the handoff was rejected and the card stayed stuck with its work complete. I converted the promotion **target** too: resolving the lane and then moving to a literal `in-progress` is the half-conversion I have already been burned by twice this program, where the guard starts admitting cards and the move then sends them to a column the board does not declare. ## E2E evidence `renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and a real `moveTask` on a workflow whose columns carry the standard traits under non-default names. The unit tests cannot show this: if `moves.ts` passed `undefined`, every unit case still passes via the no-basis fallback while the real board keeps the old behavior. **Proof it is load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of 3.** The executor suite covers both the split-role and the MERGED post-U11 shape. ## Revert proofs, isolated per site | change reverted | result | |---|---| | reopen predicate → literal names | 4 of 10 fail | | reopen field clears → literal names | 2 of 10 fail | | `userPaused` hold lane → literal `todo` | 1 of 10 fail | | graph carve-out → literal names | 1 of 10 fail | | store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) | | `promotedFromPlannerColumn` → literals | 3 of 7 fail | | two-hop condition → `=== "triage"` | 1 of 7 fails | | promotion target → `"in-progress"` | 3 of 7 fail | | `isPlannerColumnFor` → literals | 1 of 7 fails | | live-agent-count: one arm dropped | 2 of 11 fail | | DocumentsView: trait branch removed | 3 of 7 fail | | planner role renamed to `"planner"` | 4 fail (2 pre-existing) | Every conversion is paired with a negative case (a forward move, a not-a-planner-lane card, a default-lineage card, a renamed column with no traits), so neither "always fire" nor "never fire" can pass for "resolve the role". ## Deliberately NOT converted, with reasons - **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy path, kept verbatim so the two can be parity-checked. Converting it erases the reference implementation. - **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is nothing to resolve from — `enrich…FromFlags` exists for callers with board flags rather than an IR, so a column missing from that map is the renamed case. "Not intake" is as much a guess as "todo is intake", and Running/Waiting are complements, so a card matching neither arm is reported as neither and the footer's queued total under-reports it. The real fix is at the caller; four new cases pin that flags override the legacy answer **in both directions**. What did change is the duplication: two hand-written copies of one rule now call one named function. - **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column *names* where `triage` sits beside `signal` and `backlog`. Command Center aggregates across projects, so there is no single workflow to resolve traits from — the honest conversion is a data change, not a predicate change. - **`DocumentsView` with no traits.** Same no-basis rule; the documents list is full of historical columns absent from the current board. A case asserts a renamed column with no traits still reads as "working", documenting the gap rather than hiding it. ## Fixture findings Each cost a red run that looked like the code under test: - a `merge-blocker` column needs a reachable merge-class node, or `parseWorkflowIr` rejects the workflow; - a back-edge must be `kind: "rework"`, and a rework edge is legal only **into** a node with `config.reworkRegion: true`; - a workflow gets role-level transitions only when it declares wip + review + complete + **archived** plus a planning lane — without the archived column, adjacency falls back to order-derived neighbours and `checking -> queued` is not a legal move at all; - `recoverCompletedTask` only *reaches* the promotion seam when nothing is left to gate; without passed `plan-review`/`code-review` rows it re-enters the workflow graph and returns first, so a naive fixture silently tests the wrong branch and every assertion reads "no moves happened" for an unrelated reason. ## Verification - `pnpm test:gate` **71/71** - new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4 planner-role-is-not-a-column - neighbours: 132 + 10 + 482 (gate shards), 350/351 engine planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector, 11/11 live-agent-count, 11/11 dashboard hook/log suites - the single engine failure (`executor-fast-mode-workflows.test.ts` › "raw fast mode still invokes non-executable review seam nodes") **reproduces with my changes stashed** — pre-existing on `origin/main` - typechecks clean for core, engine, and dashboard-app (`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`); `pnpm lint` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
|
|
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
|
|
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
|
|
import { PLANNER_AGENT_ROLE, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
|
import { ACTIVE_STATUSES } from "../utils/taskActivity";
|
|
|
|
export type ModelSelection = ResolvedModelSelection;
|
|
export { ACTIVE_STATUSES };
|
|
|
|
const STRING_OBJECT_TAG = "[object String]";
|
|
|
|
function isStringValue(value: unknown): value is string {
|
|
return Object.prototype.toString.call(value) === STRING_OBJECT_TAG;
|
|
}
|
|
|
|
/*
|
|
FNXC:ModelResolution 2026-06-25-00:00:
|
|
FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces.
|
|
|
|
FNXC:TaskLogModelThinking 2026-07-01-00:00:
|
|
Runtime "using model" markers may append parenthesized diagnostics such as thinking effort, workflow-step overrides, or fallback reasons. Dashboard model resolution strips those suffix annotations while preserving legacy exact markers so provider icons and effective-model headers continue to resolve from the same row operators read in Activity and Raw Logs.
|
|
|
|
FNXC:PlanningModelMarker 2026-07-21-12:00:
|
|
New planning sessions identify the operator-facing lane as Planning, while historical rows retain Triage. Treat both prefixes as one planning lane so stored logs continue to resolve provider icons and effective-model headers.
|
|
*/
|
|
const MODEL_MARKER_PATTERN = /^(Planning|Triage|Executor|Reviewer) using model: ([^/\s]+)\/(.+?)(?:\s+\([^)]*\))*$/;
|
|
|
|
/*
|
|
FNXC:TaskLogModelThinking 2026-07-15-11:20:
|
|
Engine lanes now write standalone messages (including the "using model" markers) as `status` rather than `text`, so complete messages are never glued together like streamed deltas. Model resolution must accept BOTH: `status` for markers written after that change, `text` for the rows already persisted in every existing task's log. Dropping `text` here would silently blank the provider icons and effective-model headers on historical tasks.
|
|
*/
|
|
function isEngineMarkerEntryType(type: AgentLogEntry["type"]): boolean {
|
|
return type === "status" || type === "text";
|
|
}
|
|
|
|
export function parseRuntimeModelMarker(text: string, role: "Planning" | "Triage" | "Executor" | "Reviewer"): { provider: string; modelId: string } | null {
|
|
const match = text.match(MODEL_MARKER_PATTERN);
|
|
const isPlanningRole = role === "Planning" || role === "Triage";
|
|
const matchesRole = isPlanningRole
|
|
? match?.[1] === "Planning" || match?.[1] === "Triage"
|
|
: match?.[1] === role;
|
|
if (!match || !matchesRole) return null;
|
|
return { provider: match[2], modelId: match[3] };
|
|
}
|
|
|
|
export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== "executor" || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Executor");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== "reviewer" || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Reviewer");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
|
|
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
|
|
const model = isStringValue(runtimeConfig?.model) ? runtimeConfig.model.trim() : "";
|
|
if (model) {
|
|
const slashIdx = model.indexOf("/");
|
|
if (slashIdx > 0 && slashIdx < model.length - 1) {
|
|
return {
|
|
provider: model.slice(0, slashIdx),
|
|
modelId: model.slice(slashIdx + 1),
|
|
};
|
|
}
|
|
}
|
|
|
|
const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : "";
|
|
const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : "";
|
|
return {
|
|
provider: provider || undefined,
|
|
modelId: modelId || undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective executor model following the dashboard display resolution order:
|
|
* 1. Runtime executor model from agent log marker
|
|
* 2. Assigned agent runtime model (active runs only)
|
|
* 3. Per-task modelProvider/modelId override
|
|
* 4. Project/global execution lane fallback
|
|
*/
|
|
export function resolveEffectiveExecutor(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
assignedAgent: Agent | null,
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
const fromLog = extractExecutorModelFromLog(logEntries);
|
|
if (fromLog) return fromLog;
|
|
|
|
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
|
|
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
|
|
if (assignedModel.provider && assignedModel.modelId) {
|
|
return assignedModel;
|
|
}
|
|
}
|
|
|
|
return resolveTaskExecutionModel(task, settings);
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective validator model following the dashboard display resolution order.
|
|
* Merger display intentionally reuses this reviewer/validator lane in TaskDetailModal.
|
|
*/
|
|
export function resolveEffectiveValidator(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
assignedAgent: Agent | null,
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
const fromLog = extractReviewerModelFromLog(logEntries);
|
|
if (fromLog) return fromLog;
|
|
|
|
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
|
|
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
|
|
if (assignedModel.provider && assignedModel.modelId) {
|
|
return assignedModel;
|
|
}
|
|
}
|
|
|
|
return resolveTaskValidatorModel(task, settings);
|
|
}
|
|
|
|
/**
|
|
* Extract planning model from agent log entries.
|
|
* Looks for status or text entries with agent role "triage" matching either pattern:
|
|
* "Planning using model: <provider>/<modelId>"
|
|
* "Triage using model: <provider>/<modelId>" (legacy)
|
|
* Returns the latest match, or null if none found.
|
|
*/
|
|
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== PLANNER_AGENT_ROLE || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Planning");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective planning model following the preserved dashboard order:
|
|
* 1. Per-task planningModelProvider/planningModelId override
|
|
* 2. Runtime triage model from agent log marker
|
|
* 3. Project/global planning lane fallback
|
|
*/
|
|
export function resolveEffectivePlanning(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
if (task.planningModelProvider && task.planningModelId) {
|
|
return { provider: task.planningModelProvider, modelId: task.planningModelId };
|
|
}
|
|
const fromLog = extractPlanningModelFromLog(logEntries);
|
|
if (fromLog) {
|
|
return fromLog;
|
|
}
|
|
return resolveTaskPlanningModel(task, settings);
|
|
}
|