**Consolidated per the queue freeze.** Three single-fix PRs of mine folded into this one branch; #2930 and #2936 are closed as superseded. Net effect on the queue: **3 → 1**. All three are the same root cause — a lifecycle lane compared against a legacy id — and all three carry a measured revert proof. Verified scoped (not full suite) on the folded branch: `tsc --noEmit` clean, `pnpm lint` clean, SQL-literal gate green, census `--strict` green, and 61 tests across five suites plus the guard at 9/9. --- ### 1. `getLiveTaskColumn` produced the archived sentinel from a literal (was #2925) `getLiveTaskColumn` **manufactures** the string `"archived"` that a dozen comparisons across five files trust — and it tested `row.column === "archived"`. A live row in a renamed archived lane read as **live**, so the gates hiding an archived card's artifacts and document listings never closed. Fixing those twelve comparisons individually would have been wrong twice over: **they are sentinels, and the defect was in the producer.** One line, once, and all twelve become correct. `resolveArchivedLanes` moved to `project-lane-vocabulary.ts` — three private copies of one fact is how the "write guard says yes, publication guard says no" disagreement happens at scale. *Revert proof (real PostgreSQL):* restore the literal → `expected [ { …(14) } ] to deeply equal []`. **Caught myself shipping the unwired shape here:** I added the parameter to seven functions and wired none of their impl callers — the exact inert-conversion defect this program exists to remove. The failing test is the only reason I noticed. ### 2. Mission delivery repair refused a completed card (was #2930) `getTerminalTaskEvidence` tested only `column === "done"`, so a completed card on a renamed board classified as `nonterminal` and `reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL: … not shipped`. Valid operator work refused — with the message naming the real column while the check couldn't see it. The **type** blocked the fix from the far end: `TerminalTaskEvidence` pinned `column: "done"` / `"archived"`, so the resolver couldn't report the real column without a compile error. `kind` already carries the role, so `column` is free to carry the truth. *Revert proof (real PostgreSQL):* restore the literal → `TerminalTaskReconciliationError: … not shipped`. I had deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store". It holds an **optional `taskStore`**, and the single production construction site supplies it. ### 3. The unwired-lane guard reported two FALSE entries (was #2936) `unwired-lane-parameter-guard.test.ts` has been **red on main** since #2875, flagging two `InReviewDurationLanes` properties as unwired when the impl demonstrably supplies both. Cause: my own owner-scoping rule requires a mention from a file naming the declaring symbol — correct for a function, structurally impossible for an interface passed as an inferred object literal. Fixed at the caller (name the type) after trying the tool three ways: relaxing type-owned properties hid **12** genuine entries; resolving owners to consuming functions hid **6**. Each refinement traded the false positive for false negatives — the sign a co-occurrence heuristic has hit its limit. Recording two *wired* parameters in `KNOWN_UNWIRED` was rejected: that puts non-debt in the debt list, which is how a ratchet starts lying. Guard back to **9/9**, baseline unchanged at 17. **This un-reds main.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/live-task-column-lanes.md
Normal file
7
.changeset/live-task-column-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: A card in a renamed archived lane is now recognised as archived everywhere, not just on two paths.
|
||||
category: fix
|
||||
dev: `getLiveTaskColumn` manufactures the sentinel `"archived"` that a dozen comparisons across five files trust, and it tested `row.column === "archived"` — so a live row in a renamed archived lane read as live and every downstream gate opened. It now takes a resolved archived-lane set, threaded from the store-level impls; the shared `resolveArchivedLanes` moved to `project-lane-vocabulary.ts` so there is one answer rather than three copies.
|
||||
7
.changeset/terminal-evidence-lanes.md
Normal file
7
.changeset/terminal-evidence-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Mission delivery repair now accepts a completed card on boards that rename the done lane.
|
||||
category: fix
|
||||
dev: `getTerminalTaskEvidence` tested only `column === "done"`, so a completed card on a renamed board classified as `nonterminal` and `reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL`. It now takes resolved complete/archived lane sets, supplied by `AsyncMissionStore` from its `taskStore`. The `TerminalTaskEvidence` type's `column` field was widened from the pinned literals to `string`.
|
||||
@@ -104,6 +104,48 @@ pgDescribe("archived-document guards resolve the board's archived lane", () => {
|
||||
).rejects.toThrow(/read-only/);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-04:10:
|
||||
THE SENTINEL ITSELF, not just the two guards that read a row directly.
|
||||
|
||||
#2886 fixed `upsertTaskDocument` and `publishArchivedTaskDocumentAddition`, which read `task.column`
|
||||
from their own `select`. Everything ELSE in this area routes through `getLiveTaskColumn`, which
|
||||
MANUFACTURES the string "archived" — a dozen comparisons across five files trust it. Keyed on the
|
||||
literal, that function reported a live row in a renamed archived lane as LIVE, so the gates that
|
||||
hide an archived card's artifacts and document LISTINGS never closed.
|
||||
|
||||
Fixing those dozen comparisons individually would have been wrong twice over: they are sentinels,
|
||||
and the defect was in the producer.
|
||||
|
||||
ARTIFACTS, not `getTaskDocument`. My first version asserted that reading a document returns
|
||||
undefined for an archived card, and it failed — `getTaskDocument` gates on `column === null`, i.e.
|
||||
existence only. Reading an archived card's document is ALLOWED by design; the read-only contract is
|
||||
about writes. The premise was wrong, not the code, which is the fourth time this session that
|
||||
suspecting my own fixture first would have been quicker. `getArtifacts` is one of the five sites
|
||||
that genuinely consumes the sentinel (`column === null || column === "archived"`).
|
||||
|
||||
REVERT PROOF, measured: restore `row.column === "archived"` in `getLiveTaskColumn` and this fails —
|
||||
the artifact list is returned for a card the board shows as archived.
|
||||
*/
|
||||
it("hides artifacts behind the SENTINEL for a card in a renamed archived lane", async () => {
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ description: "Archived into a renamed lane, then listed" });
|
||||
await store.registerArtifact({
|
||||
type: "document",
|
||||
title: "spec",
|
||||
description: "inline body",
|
||||
content: "body",
|
||||
authorId: "agent-1",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
});
|
||||
expect(await store.getArtifacts(task.id)).toHaveLength(1);
|
||||
|
||||
await parkInRenamedArchivedLane(task.id);
|
||||
|
||||
expect(await store.getArtifacts(task.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT treat a live card as archived just because a lane is named vault", async () => {
|
||||
// The guard must still let real work through — rejecting everything would be its own bug.
|
||||
const store = h.store();
|
||||
|
||||
@@ -397,6 +397,54 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
||||
FNXC:MissionReconciliation 2026-07-20-08:34:
|
||||
Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-05:05:
|
||||
THE INVARIANT: terminal evidence is the card's ROLE, not the id `done`.
|
||||
|
||||
`getTerminalTaskEvidence` tested only `column === "done"`, so a genuinely completed card on a
|
||||
renamed board fell through every branch to `nonterminal` and this method threw
|
||||
`TASK_NOT_TERMINAL: ... must be in done or supported archived state, not shipped`. Mission
|
||||
shipped-delivery repair refused valid work — and the message named the real column while the check
|
||||
could not see it, which is the tell that the classifier and the reporter disagreed.
|
||||
|
||||
I deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store". It
|
||||
holds an OPTIONAL `taskStore`, and the single production construction site supplies it. That is the
|
||||
third deferral of mine this session to dissolve on inspection, which is the argument for checking a
|
||||
premise before recording it as a blocker.
|
||||
|
||||
REVERT PROOF, measured: restore `column === "done"` and this fails with a
|
||||
`TASK_NOT_TERMINAL` rejection naming `shipped`.
|
||||
*/
|
||||
it("accepts a completed card whose board calls the lane something else", async () => {
|
||||
const m = missions();
|
||||
const store = h.store();
|
||||
await store.createWorkflowDefinition({
|
||||
name: "Renamed complete",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Renamed complete",
|
||||
columns: [
|
||||
{ id: "todo", name: "Todo", traits: [{ trait: "intake" }, { trait: "hold" }] },
|
||||
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{ id: "end", kind: "end", column: "shipped" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end", condition: "success" }],
|
||||
} as never,
|
||||
});
|
||||
const mission = await m.createMission({ title: "Renamed-lane repair" });
|
||||
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = await m.addFeature(slice.id, { title: "Delivered" });
|
||||
const task = await store.createTask({ description: "shipped elsewhere", column: "shipped" as never });
|
||||
|
||||
const reconciled = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
|
||||
|
||||
expect(reconciled).toMatchObject({ taskId: task.id, status: "done" });
|
||||
});
|
||||
|
||||
it("atomically reconciles live done evidence and remains idempotent", async () => {
|
||||
const m = missions();
|
||||
const mission = await m.createMission({ title: "Parked repair" });
|
||||
|
||||
@@ -2173,9 +2173,19 @@ export async function listLiveLinkedTaskIds(handle: QueryHandle, taskIds: string
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-05:05:
|
||||
`column` is the lane the card ACTUALLY reached, not the built-in name for its role.
|
||||
|
||||
These two arms pinned `"done"` and `"archived"` in the TYPE, which is the census-invisible shape that
|
||||
blocks a conversion from the other end: the resolver below could not report the real column without a
|
||||
compile error, so the type would have forced the literal back in even after the predicate was fixed.
|
||||
`kind` already carries the role — that is what a consumer switches on — so the column field is free to
|
||||
carry the truth.
|
||||
*/
|
||||
export type TerminalTaskEvidence =
|
||||
| { kind: "done"; id: string; column: "done" }
|
||||
| { kind: "archived"; id: string; column: "archived" }
|
||||
| { kind: "done"; id: string; column: string }
|
||||
| { kind: "archived"; id: string; column: string }
|
||||
| { kind: "nonterminal"; id: string; column: string }
|
||||
| { kind: "invalid-deleted"; id: string; column?: string }
|
||||
| { kind: "missing" };
|
||||
@@ -2184,7 +2194,11 @@ export type TerminalTaskEvidence =
|
||||
* FNXC:MissionReconciliation 2026-07-20-08:34:
|
||||
* Terminal evidence repair must distinguish a supported archive (the retained archived task tombstone plus its project-scoped cold snapshot) from an arbitrary soft/hard deletion. Read both representations on the caller's transaction handle so validation and feature linkage share one snapshot.
|
||||
*/
|
||||
export async function getTerminalTaskEvidence(handle: QueryHandle, taskId: string): Promise<TerminalTaskEvidence> {
|
||||
export async function getTerminalTaskEvidence(
|
||||
handle: QueryHandle,
|
||||
taskId: string,
|
||||
terminalColumns?: { complete?: ReadonlySet<string>; archived?: ReadonlySet<string> },
|
||||
): Promise<TerminalTaskEvidence> {
|
||||
const taskRows = await handle
|
||||
.select({
|
||||
id: schema.project.tasks.id,
|
||||
@@ -2218,12 +2232,27 @@ export async function getTerminalTaskEvidence(handle: QueryHandle, taskId: strin
|
||||
set threaded in by the caller — the same shape `getLiveTaskColumn` needs, and it should land with
|
||||
it so the two cannot disagree about what "finished" means.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-05:05:
|
||||
The lane sets arrive from the caller; omitted, the legacy ids answer exactly as before.
|
||||
|
||||
I deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store" and so
|
||||
could not resolve anything. It holds an OPTIONAL `taskStore`, and the single production construction
|
||||
site supplies it (`workflow-definitions.ts`: `new AsyncMissionStore(layer, store)`). That is the
|
||||
third deferral of mine to dissolve on inspection, which is why the premise is now recorded next to
|
||||
the fix rather than in a note claiming it cannot be done.
|
||||
*/
|
||||
const isComplete = (column: string) =>
|
||||
terminalColumns?.complete ? terminalColumns.complete.has(column) : column === "done";
|
||||
const isArchived = (column: string) =>
|
||||
terminalColumns?.archived ? terminalColumns.archived.has(column) : column === "archived";
|
||||
|
||||
if (!task) return hasArchiveSnapshot ? { kind: "invalid-deleted", id: taskId } : { kind: "missing" };
|
||||
if (task.deletedAt === null && task.column === "done") return { kind: "done", id: task.id, column: "done" };
|
||||
if (task.deletedAt !== null && task.column === "archived" && hasArchiveSnapshot) {
|
||||
return { kind: "archived", id: task.id, column: "archived" };
|
||||
if (task.deletedAt === null && isComplete(task.column)) return { kind: "done", id: task.id, column: task.column };
|
||||
if (task.deletedAt !== null && isArchived(task.column) && hasArchiveSnapshot) {
|
||||
return { kind: "archived", id: task.id, column: task.column };
|
||||
}
|
||||
if (task.deletedAt !== null || task.column === "archived") {
|
||||
if (task.deletedAt !== null || isArchived(task.column)) {
|
||||
return { kind: "invalid-deleted", id: task.id, column: task.column };
|
||||
}
|
||||
return { kind: "nonterminal", id: task.id, column: task.column };
|
||||
|
||||
@@ -52,6 +52,7 @@ import type { Goal } from "./goal-types.js";
|
||||
import {
|
||||
deriveMilestoneAcceptanceCriteriaFromFeatures,
|
||||
} from "./mission-store.js";
|
||||
import { resolveProjectColumnsForRoles } from "./project-lane-vocabulary.js";
|
||||
import type {
|
||||
MissionSummary,
|
||||
MissionAssertionBackfillReport,
|
||||
@@ -1158,7 +1159,26 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
);
|
||||
}
|
||||
|
||||
const evidence = await getTerminalTaskEvidence(tx, taskId);
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-05:05:
|
||||
Resolve the board's terminal lanes and hand them down; without this the predicate is inert.
|
||||
|
||||
Keyed on the literals, a genuinely completed card on a renamed board fell through every branch
|
||||
to `nonterminal`, and this method then threw `TASK_NOT_TERMINAL: ... must be in done or
|
||||
supported archived state, not shipped`. Mission shipped-delivery repair refused valid work, and
|
||||
the message named the real column while the check could not see it.
|
||||
|
||||
`this.taskStore` is optional on the class but the single production construction site supplies
|
||||
it (`workflow-definitions.ts`). Absent, the resolver is skipped and the legacy ids answer —
|
||||
which is what every test that constructs the store without one already relies on.
|
||||
*/
|
||||
const terminalColumns = this.taskStore
|
||||
? {
|
||||
complete: await resolveProjectColumnsForRoles(this.taskStore, ["complete"]).catch(() => undefined),
|
||||
archived: await resolveProjectColumnsForRoles(this.taskStore, ["archived"]).catch(() => undefined),
|
||||
}
|
||||
: undefined;
|
||||
const evidence = await getTerminalTaskEvidence(tx, taskId, terminalColumns);
|
||||
if (evidence.kind === "missing") {
|
||||
throw new TerminalTaskReconciliationError("TASK_NOT_FOUND", `Delivery task ${taskId} not found`);
|
||||
}
|
||||
|
||||
@@ -2265,7 +2265,7 @@ file (`scripts/build-engine-core-gate-bundle.mjs`), so an export added only to `
|
||||
an import error. That cost 88 red tests in `project-engine.test.ts`, all with the same misleading
|
||||
"columns is not iterable" a hundred lines from the actual cause.
|
||||
*/
|
||||
export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||
export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||
export type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
|
||||
export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js";
|
||||
export { LEGACY_STATUS_ADOPTION, resolveLegacyStatusAdoption, resolveReviewLevelBackfill, planLegacyAdoption, resolveOrphanedPendingStepResults, type LegacyAdoptionPlan, type LegacyAdoptionCandidate, type LegacyAdoptionAction, type LegacyAdoptionKind } from "./legacy-adoption.js";
|
||||
|
||||
@@ -469,7 +469,7 @@ export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_
|
||||
export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js";
|
||||
export { columnHasFlag, columnsWithFlag, declaresAnyLifecycleTrait, resolveArchiveTargetForTask, resolveCompleteColumn, resolveLifecycleColumns, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveReboundTargetForTask, resolveReviewColumns, resolveTaskLifecycleColumns, resolveTerminalColumns, resolveWipTargetForTask } from "./workflow-lifecycle-traits.js";
|
||||
export type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
|
||||
export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||
export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||
export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js";
|
||||
export {
|
||||
LEGACY_STATUS_ADOPTION,
|
||||
|
||||
@@ -130,3 +130,29 @@ export const REVIEW_ROLES = ["mergeOrchestration", "mergeBlocker", "humanReview"
|
||||
|
||||
/** "Finished either way" — the pair `resolveTerminalColumns` answers for a single task. */
|
||||
export const TERMINAL_ROLES = ["complete", "archived"] as const;
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-04:10:
|
||||
ONE archived-lane answer, shared, because the alternative is callers disagreeing.
|
||||
|
||||
`getLiveTaskColumn` manufactures the string "archived" for an archived-or-soft-deleted parent, and a
|
||||
dozen call sites across five files compare against that sentinel. The sentinel is only correct if the
|
||||
function that PRODUCES it recognises the board's archived lane — keyed on the literal it does not, so
|
||||
on a renamed board a live row in `vault` is reported as live and every downstream gate opens.
|
||||
|
||||
Fixing that means every caller supplies the same lane set. `comments-ops.ts` grew a private copy of
|
||||
this helper with #2886; a second copy is how two readers of one fact start to disagree, which is the
|
||||
mistake `resolveProjectColumnsForRoles`' own header warns about. Promoted here so there is one.
|
||||
|
||||
Best-effort by contract: an unresolvable workflow returns undefined and every caller falls back to
|
||||
the legacy id, which is exactly the behaviour before any of this.
|
||||
*/
|
||||
export async function resolveArchivedLanes(
|
||||
store: ProjectLaneVocabularyStore,
|
||||
): Promise<ReadonlySet<string> | undefined> {
|
||||
try {
|
||||
return await resolveProjectColumnsForRoles(store, ["archived"]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +120,26 @@ function rowToArtifact(row: ArtifactRow): Artifact {
|
||||
* or soft-deleted tasks. Returns the task's column if live, or `null` if the
|
||||
* task is absent, archived, or soft-deleted.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-04:10:
|
||||
The sentinel is only as correct as the lane test that PRODUCES it.
|
||||
|
||||
A dozen call sites across five files compare this function's result against the string "archived",
|
||||
and every one of those comparisons is right precisely because this function manufactures it. Keyed on
|
||||
the literal, a live row in a renamed archived lane (`vault`, not soft-deleted) was reported as LIVE —
|
||||
so documents stayed readable and writable, artifacts listed, and log writes were accepted on a card
|
||||
the board shows as archived. Fixing the twelve comparisons individually would have been wrong twice
|
||||
over: they are sentinels, and the defect is here.
|
||||
|
||||
`archivedColumns` is threaded from the store-level impls, which are the only layer that can resolve
|
||||
it — this function holds a `db` handle. Omitted, the legacy id answers, which is the behaviour every
|
||||
caller had before.
|
||||
*/
|
||||
export async function getLiveTaskColumn(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
archivedColumns?: ReadonlySet<string>,
|
||||
): Promise<string | null> {
|
||||
/*
|
||||
FNXC:PostgresArchiveSafety 2026-07-14-21:48:
|
||||
@@ -139,7 +155,8 @@ export async function getLiveTaskColumn(
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.column === "archived" || row.deletedAt != null) return "archived";
|
||||
const isArchivedLane = archivedColumns ? archivedColumns.has(row.column) : row.column === "archived";
|
||||
if (isArchivedLane || row.deletedAt != null) return "archived";
|
||||
return row.column;
|
||||
}
|
||||
|
||||
@@ -183,8 +200,9 @@ export async function getTaskDocument(
|
||||
taskId: string,
|
||||
key: string,
|
||||
projectId?: string,
|
||||
): Promise<TaskDocument | null> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocument | null> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
|
||||
if (column === null) return null;
|
||||
|
||||
const rows = await db
|
||||
@@ -463,8 +481,9 @@ export async function listTaskDocuments(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
): Promise<TaskDocument[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocument[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
|
||||
if (column === null || column === "archived") return [];
|
||||
|
||||
const rows = await db
|
||||
@@ -486,8 +505,9 @@ export async function getTaskDocumentRevisions(
|
||||
taskId: string,
|
||||
key: string,
|
||||
projectId?: string,
|
||||
): Promise<TaskDocumentRevisionRow[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocumentRevisionRow[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
|
||||
if (column === null) return [];
|
||||
|
||||
const rows = await db
|
||||
@@ -522,9 +542,10 @@ export async function deleteTaskDocument(
|
||||
layer: AsyncDataLayer,
|
||||
taskId: string,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<void> {
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const state = await getLiveTaskColumn(tx, taskId, layer.projectId);
|
||||
const state = await getLiveTaskColumn(tx, taskId, layer.projectId, archivedColumns);
|
||||
if (state === "archived") throw new Error(`Task ${taskId} is archived — documents are read-only`);
|
||||
if (state === null) throw new Error(`Task ${taskId} not found`);
|
||||
const existing = await tx
|
||||
@@ -580,11 +601,12 @@ export async function insertArtifactRow(
|
||||
layer: AsyncDataLayer,
|
||||
input: ArtifactCreateInput,
|
||||
stored: { uri?: string; sizeBytes?: number },
|
||||
): Promise<Artifact> {
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<Artifact> {
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
// Gate: if taskId is set, the parent must be live.
|
||||
if (input.taskId) {
|
||||
const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId);
|
||||
const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId, archivedColumns);
|
||||
if (column === "archived") {
|
||||
throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`);
|
||||
}
|
||||
@@ -634,14 +656,15 @@ export async function updateArtifactRow(
|
||||
layer: AsyncDataLayer,
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; content?: string },
|
||||
): Promise<Artifact> {
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<Artifact> {
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const existing = await getArtifact(tx, id);
|
||||
if (!existing) {
|
||||
throw new Error(`Artifact ${id} not found`);
|
||||
}
|
||||
if (existing.taskId) {
|
||||
const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId);
|
||||
const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId, archivedColumns);
|
||||
if (column === "archived") {
|
||||
throw new Error(`Task ${existing.taskId} is archived — artifacts are read-only`);
|
||||
}
|
||||
@@ -698,8 +721,9 @@ export async function getArtifacts(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
): Promise<Artifact[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<Artifact[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
|
||||
if (column === null || column === "archived") return [];
|
||||
|
||||
const rows = await db
|
||||
|
||||
@@ -17,6 +17,7 @@ import "../builtin-traits.js";
|
||||
import {__setTaskActivityLogLimitsForTesting, truncateTaskLogOutcome, getTaskActivityLogEntryLimit} from "../task-store/comments.js";
|
||||
import {readTaskRow, updateTaskColumns} from "../task-store/async-persistence.js";
|
||||
import { getLiveTaskColumn } from "./async-comments-attachments.js";
|
||||
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
|
||||
|
||||
export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskId: string, workflowIr: WorkflowIr, fromColumn: string, toColumn: string,): Promise<void> {
|
||||
const registry = getTraitRegistry();
|
||||
@@ -123,7 +124,7 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string,
|
||||
if (runContext) {
|
||||
{
|
||||
const layer = store.asyncLayer!;
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId);
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-30-21:20 (audited — SENTINEL, do NOT convert):
|
||||
`getLiveTaskColumn` MANUFACTURES the string "archived" for an archived-or-soft-deleted
|
||||
|
||||
@@ -21,7 +21,7 @@ import "../builtin-traits.js";
|
||||
import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
|
||||
import {resolveLifecycleColumns} from "../workflow-lifecycle-traits.js";
|
||||
import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub} from "../task-store/comments.js";
|
||||
import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js";
|
||||
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
|
||||
import {getLiveTaskColumn, publishArchivedTaskDocumentAddition as publishArchivedTaskDocumentAdditionAsync, upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js";
|
||||
|
||||
/*
|
||||
@@ -61,7 +61,7 @@ export function resolvePostCommentRetriageDecision(input: {
|
||||
export async function addCommentImpl(store: TaskStore, id: string, text: string, author: string = "user", options?: { skipRefinement?: boolean; source?: "user" | "agent" | "github-review" | "github-review-comment"; externalId?: string; reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"; }, runContext?: RunMutationContext,): Promise<Task> {
|
||||
{
|
||||
const layer = store.asyncLayer!;
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId);
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
|
||||
if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`);
|
||||
if (state === null) throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
@@ -326,7 +326,12 @@ FNXC:WorkflowLifecycleColumns 2026-07-30-23:40:
|
||||
Shared by both document paths so the "is this card archived?" answer cannot differ between the write
|
||||
guard and the publication guard — one saying yes while the other says no is how a card ends up both
|
||||
read-only and un-publishable.
|
||||
*/
|
||||
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-04:10:
|
||||
The helper that used to live here now lives in `project-lane-vocabulary.ts` and is imported. It grew a
|
||||
THIRD caller (`getLiveTaskColumn`, whose sentinel a dozen sites compare against), and three private
|
||||
copies of one fact is how the disagreement above happens at scale rather than between two functions.
|
||||
The analysis below is unchanged and still governs the shape.
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-03:35 (#2886 review — greptile P1, "project-wide lanes
|
||||
misclassify tasks"): THE FINDING IS RIGHT AND THE OBVIOUS FIX IS A WORSE TRADE. Measured, not argued.
|
||||
@@ -355,13 +360,6 @@ distinguished from "selection resolved to the default", i.e. the provenance form
|
||||
Sized here rather than faked, because swapping one defect for a larger one would have looked like
|
||||
progress and dropped a guard count.
|
||||
*/
|
||||
async function resolveArchivedLanes(store: TaskStore): Promise<ReadonlySet<string> | undefined> {
|
||||
try {
|
||||
return await resolveProjectColumnsForRoles(store, ["archived"]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishArchivedTaskDocumentAdditionImpl(
|
||||
store: TaskStore,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { storeLog } from "../store.js";
|
||||
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
|
||||
|
||||
export function listWorkflowWorkItemsForTaskSyncImpl(store: TaskStore, taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): WorkflowWorkItem[] {
|
||||
const conditions = ["taskId = ?"];
|
||||
@@ -654,7 +655,7 @@ export async function addSteeringCommentImpl(store: TaskStore, id: string, text:
|
||||
export async function updateTaskCommentImpl(store: TaskStore, id: string, commentId: string, text: string): Promise<Task> {
|
||||
{
|
||||
const layer = store.asyncLayer!;
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId);
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
|
||||
if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`);
|
||||
if (state === null) throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
@@ -688,7 +689,7 @@ export async function updateTaskCommentImpl(store: TaskStore, id: string, commen
|
||||
export async function deleteTaskCommentImpl(store: TaskStore, id: string, commentId: string): Promise<Task> {
|
||||
{
|
||||
const layer = store.asyncLayer!;
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId);
|
||||
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
|
||||
if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`);
|
||||
if (state === null) throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
@@ -782,24 +783,24 @@ export async function getArtifactImpl(store: TaskStore, id: string): Promise<Art
|
||||
*/
|
||||
export async function updateArtifactImpl(store: TaskStore, id: string, updates: { title?: string; description?: string; content?: string }): Promise<Artifact> {
|
||||
const layer = store.asyncLayer!;
|
||||
const updated = await updateArtifactRowAsync(layer, id, updates);
|
||||
const updated = await updateArtifactRowAsync(layer, id, updates, await resolveArchivedLanes(store));
|
||||
store.emit("artifact:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getArtifactsImpl(store: TaskStore, taskId: string): Promise<Artifact[]> {
|
||||
const layer = store.asyncLayer!;
|
||||
return getArtifactsAsync(layer.db, taskId, layer.projectId);
|
||||
return getArtifactsAsync(layer.db, taskId, layer.projectId, await resolveArchivedLanes(store));
|
||||
}
|
||||
|
||||
export async function getTaskDocumentsImpl(store: TaskStore, taskId: string): Promise<TaskDocument[]> {
|
||||
const layer = store.asyncLayer!;
|
||||
return listTaskDocumentsAsync(layer.db, taskId, layer.projectId);
|
||||
return listTaskDocumentsAsync(layer.db, taskId, layer.projectId, await resolveArchivedLanes(store));
|
||||
}
|
||||
|
||||
export async function getTaskDocumentImpl(store: TaskStore, taskId: string, key: string): Promise<TaskDocument | null> {
|
||||
const layer = store.asyncLayer!;
|
||||
return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId);
|
||||
return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId, await resolveArchivedLanes(store));
|
||||
}
|
||||
|
||||
export async function getTaskDocumentRevisionsImpl(store: TaskStore,
|
||||
@@ -815,7 +816,7 @@ export async function getTaskDocumentRevisionsImpl(store: TaskStore,
|
||||
to preserve that ordering exactly, then apply the optional LIMIT.
|
||||
*/
|
||||
const layer = store.asyncLayer!;
|
||||
const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key, layer.projectId);
|
||||
const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key, layer.projectId, await resolveArchivedLanes(store));
|
||||
const sorted = [...rows].sort((a, b) => b.revision - a.revision);
|
||||
const mapped = sorted.map((row) => store.rowToTaskDocumentRevision(row));
|
||||
return options?.limit !== undefined ? mapped.slice(0, Math.max(0, options.limit)) : mapped;
|
||||
@@ -831,7 +832,7 @@ export async function deleteTaskDocumentImpl(store: TaskStore, taskId: string, k
|
||||
only live tasks so a present task implies deletedAt == null.
|
||||
*/
|
||||
const layer = store.asyncLayer!;
|
||||
await deleteTaskDocumentAsync(layer, taskId, key);
|
||||
await deleteTaskDocumentAsync(layer, taskId, key, await resolveArchivedLanes(store));
|
||||
const task = await store.getTask(taskId);
|
||||
if (task) {
|
||||
store.emit("task:updated", task);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DependencyCycleError, TaskDeletedError, TombstonedTaskResurrectionError, coreLog, detectDependencyCycle, storeLog } from "../store.js";
|
||||
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
|
||||
|
||||
export function trackDeferredTaskCreatedWorkImpl(store: TaskStore, work: () => Promise<void>): Promise<void> {
|
||||
if (store.closing) return Promise.resolve();
|
||||
@@ -451,7 +452,7 @@ getLiveTaskColumn, plus cold archive.archived_tasks presence.
|
||||
export async function isTaskArchivedAsyncImpl(store: TaskStore, id: string): Promise<boolean> {
|
||||
|
||||
const layer = store.asyncLayer!;
|
||||
const live = await getLiveTaskColumn(layer.db, id, layer.projectId);
|
||||
const live = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
|
||||
// getLiveTaskColumn returns "archived" for archived OR soft-deleted rows.
|
||||
if (live === "archived") return true;
|
||||
if (live !== null) return false;
|
||||
|
||||
@@ -42,6 +42,7 @@ import {appendConfigurationRevision, createConfigurationRevision, getConfigurati
|
||||
import {readProjectConfig, writeProjectConfig} from "./async-settings.js";
|
||||
import {publishSettingsUpdated} from "./settings-ops.js";
|
||||
import type {ConfigChangedBy, ConfigurationRevision} from "../types.js";
|
||||
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
|
||||
|
||||
export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, limit: number): string {
|
||||
const columns = [
|
||||
@@ -955,7 +956,7 @@ export async function registerArtifactImpl(store: TaskStore, input: ArtifactCrea
|
||||
FNXC:SqliteDualPathCleanup 2026-07-26-14:07:
|
||||
Artifact row insert is PostgreSQL-only via insertArtifactRowAsync.
|
||||
*/
|
||||
return insertArtifactRowAsync(store.asyncLayer!, input, stored);
|
||||
return insertArtifactRowAsync(store.asyncLayer!, input, stored, await resolveArchivedLanes(store));
|
||||
} catch (error) {
|
||||
if (stored.absolutePath) {
|
||||
await unlink(stored.absolutePath).catch(() => undefined);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { resolveSwitchReconciliation } from "../workflow-reconciliation.js";
|
||||
import { WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX } from "../store.js";
|
||||
import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js";
|
||||
import { resolveProjectColumnsForRoles, REVIEW_ROLES } from "../project-lane-vocabulary.js";
|
||||
import type { InReviewDurationLanes } from "./async-audit.js";
|
||||
|
||||
export async function getAgentLogsByTimeRangeImpl(store: TaskStore,
|
||||
taskId: string,
|
||||
@@ -1008,7 +1009,22 @@ failed resolve leaves the query on its documented legacy lanes rather than faili
|
||||
*/
|
||||
export async function getInReviewDurationEventsImpl(store: TaskStore, options: { since: string; until: string }): Promise<ActivityLogEntry[]> {
|
||||
const layer = store.asyncLayer!;
|
||||
let lanes: { reviewColumns?: string[]; completeColumns?: string[] } | undefined;
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-06:20:
|
||||
Named type, not an inferred literal, and the reason is a tool contract rather than style.
|
||||
|
||||
`scripts/lib/unwired-lane-parameter.mjs` decides a lane parameter is WIRED when some other file
|
||||
mentions both the parameter name and its declaring symbol. For an interface passed as an inferred
|
||||
object literal there is no mention of the type anywhere, so this call site — which does supply
|
||||
both lanes — was reported as unwired, and #2875 landed two false entries into a guard written to
|
||||
catch the opposite mistake.
|
||||
|
||||
Annotating the local is the smallest honest fix. I tried three variations of the heuristic first;
|
||||
each traded the false positive for false NEGATIVES (the widest hid twelve genuine entries), which
|
||||
is the usual sign that a co-occurrence check has reached its limit. Naming the type costs one
|
||||
word, makes the wiring visible to both the reader and the tool, and leaves the guard's rule alone.
|
||||
*/
|
||||
let lanes: InReviewDurationLanes | undefined;
|
||||
try {
|
||||
const [review, complete] = await Promise.all([
|
||||
resolveProjectColumnsForRoles(store, REVIEW_ROLES),
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"packages/engine/src/notification/notification-service.ts": 5,
|
||||
"packages/engine/src/replan-target.ts": 4,
|
||||
"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/core/src/agent-store.ts": 2,
|
||||
"packages/core/src/async-mission-store-queries.ts": 2,
|
||||
"packages/core/src/task-store/audit-ops.ts": 2,
|
||||
"packages/core/src/task-store/moves.ts": 2,
|
||||
"packages/core/src/task-store/project-store-ops.ts": 2,
|
||||
|
||||
Reference in New Issue
Block a user