feat(FN-4967): merge fusion/fn-4967

This commit is contained in:
gsxdsm
2026-05-18 15:50:48 -07:00
parent fabcb5387a
commit aad1219037
34 changed files with 1128 additions and 549 deletions

View File

@@ -717,7 +717,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
db.close();
});
@@ -767,7 +767,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
db.close();
});
@@ -796,7 +796,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
db.close();
});
@@ -831,7 +831,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
db.close();
});

View File

@@ -291,7 +291,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -319,7 +319,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1384,7 +1384,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1409,11 +1409,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
db.close();
});
@@ -1448,7 +1448,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1489,7 +1489,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1561,7 +1561,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1801,7 +1801,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1875,7 +1875,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1899,7 +1899,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2003,7 +2003,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2222,7 +2222,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(85);
expect(localDb.getSchemaVersion()).toBe(86);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2533,7 +2533,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2687,7 +2687,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(85);
expect(migrated.getSchemaVersion()).toBe(86);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2733,7 +2733,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(85);
expect(migrated.getSchemaVersion()).toBe(86);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2760,7 +2760,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(85);
expect(fresh.getSchemaVersion()).toBe(86);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(85);
expect(db1.getSchemaVersion()).toBe(86);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(85);
expect(db3.getSchemaVersion()).toBe(86);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(85);
expect(db1.getSchemaVersion()).toBe(86);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(85);
expect(db2.getSchemaVersion()).toBe(86);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(85);
expect(db1.getSchemaVersion()).toBe(86);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2801,7 +2801,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
});
});
});

View File

@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("85");
expect(version.value).toBe("86");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("85");
expect(version.value).toBe("86");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(projectVersion.value).toBe("85");
expect(projectVersion.value).toBe("86");
expect(centralVersion.value).toBe("13");
} finally {
projectDb.close();

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import type { PrInfo } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore prInfos", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
const pr = (number: number, patch: Partial<PrInfo> = {}): PrInfo => ({
url: `https://github.com/acme/repo/pull/${number}`,
number,
status: "open",
title: `PR ${number}`,
headBranch: `feature/${number}`,
baseBranch: "main",
commentCount: 0,
...patch,
});
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("round-trips prInfos through sqlite row + rehydrate", async () => {
const task = await harness.createTestTask();
await store.addPrInfo(task.id, pr(11));
await store.addPrInfo(task.id, pr(22));
const db = (store as any).db;
const row = db.prepare("SELECT prInfos FROM tasks WHERE id = ?").get(task.id) as { prInfos: string | null };
expect(row.prInfos).toContain('"number":22');
expect(row.prInfos).toContain('"number":11');
const reopened = await store.getTask(task.id);
expect(reopened.prInfos?.map((entry) => entry.number)).toEqual([22, 11]);
expect(reopened.prInfo?.number).toBe(22);
});
it("materializes legacy prInfo into prInfos without writing back on read", async () => {
const task = await harness.createTestTask();
await store.updatePrInfo(task.id, pr(33));
const db = (store as any).db;
db.prepare("UPDATE tasks SET prInfos = NULL WHERE id = ?").run(task.id);
const migrated = await store.getTask(task.id);
expect(migrated.prInfos?.map((entry) => entry.number)).toEqual([33]);
const row = db.prepare("SELECT prInfos FROM tasks WHERE id = ?").get(task.id) as { prInfos: string | null };
expect(row.prInfos).toBeNull();
});
it("supports add/update/remove by PR number", async () => {
const task = await harness.createTestTask();
await store.addPrInfo(task.id, pr(1));
await store.addPrInfo(task.id, pr(2));
await store.updatePrInfoByNumber(task.id, 1, { status: "merged" });
const updated = await store.removePrInfoByNumber(task.id, 2);
expect(updated?.prInfos).toHaveLength(1);
expect(updated?.prInfos?.[0].number).toBe(1);
expect(updated?.prInfos?.[0].status).toBe("merged");
});
it("keeps primary mirror on most recently checked open PR", async () => {
const task = await harness.createTestTask();
await store.addPrInfo(task.id, pr(1, { lastCheckedAt: "2026-05-17T10:00:00.000Z" }));
await store.addPrInfo(task.id, pr(2, { lastCheckedAt: "2026-05-17T11:00:00.000Z" }));
await store.updatePrInfoByNumber(task.id, 1, { lastCheckedAt: "2026-05-17T12:00:00.000Z" });
const current = await store.getTask(task.id);
expect(current.prInfo?.number).toBe(1);
});
it("legacy updatePrInfo(null) clears prInfo and prInfos", async () => {
const task = await harness.createTestTask();
await store.addPrInfo(task.id, pr(1));
await store.addPrInfo(task.id, pr(2));
const cleared = await store.updatePrInfo(task.id, null);
expect(cleared.prInfo).toBeUndefined();
expect(cleared.prInfos).toBeUndefined();
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(85);
expect(db.getSchemaVersion()).toBe(86);
const index = db
.prepare(

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { getPrimaryPrInfo } from "../task-helpers.js";
describe("getPrimaryPrInfo", () => {
it("returns prInfo when only legacy field is set", () => {
const prInfo = { number: 1 } as any;
expect(getPrimaryPrInfo({ prInfo })).toBe(prInfo);
});
it("returns first prInfos entry when only prInfos is set", () => {
const first = { number: 2 } as any;
const second = { number: 3 } as any;
expect(getPrimaryPrInfo({ prInfos: [first, second] })).toBe(first);
});
it("prefers prInfos[0] when both fields are set", () => {
const prInfo = { number: 1 } as any;
const first = { number: 2 } as any;
expect(getPrimaryPrInfo({ prInfo, prInfos: [first] })).toBe(first);
});
it("returns undefined when neither field is set", () => {
expect(getPrimaryPrInfo({})).toBeUndefined();
});
});

View File

@@ -120,7 +120,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 85;
const SCHEMA_VERSION = 86;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -251,6 +251,7 @@ CREATE TABLE IF NOT EXISTS tasks (
reviewState TEXT,
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
prInfos TEXT,
issueInfo TEXT,
githubTracking TEXT,
sourceIssueProvider TEXT,
@@ -3407,6 +3408,12 @@ export class Database {
});
}
if (version < 86) {
this.applyMigration(86, () => {
this.addColumnIfMissing("tasks", "prInfos", "TEXT");
});
}
}
/**

View File

@@ -166,6 +166,7 @@ export {
hasTitleIdDrift,
normalizeTitleForTaskId,
} from "./task-title-id-drift.js";
export { getPrimaryPrInfo } from "./task-helpers.js";
export type {
TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind,

View File

@@ -129,6 +129,7 @@ interface TaskRow {
reviewState: string | null;
workflowStepResults: string | null;
prInfo: string | null;
prInfos: string | null;
issueInfo: string | null;
githubTracking: string | null;
sourceIssueProvider: string | null;
@@ -1215,6 +1216,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewState: normalizeTaskReviewState(fromJson<import("./types.js").TaskReviewState>(row.reviewState) ?? undefined),
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
prInfos: (() => {
const multi = fromJson<import("./types.js").PrInfo[]>(row.prInfos);
if (multi && multi.length > 0) return multi;
const single = fromJson<import("./types.js").PrInfo>(row.prInfo);
return single ? [single] : undefined;
})(),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
sourceIssue: (() => {
@@ -1285,6 +1292,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
size: entry.size,
reviewLevel: entry.reviewLevel,
prInfo: slim ? undefined : entry.prInfo,
prInfos: slim ? undefined : entry.prInfos,
issueInfo: slim ? undefined : entry.issueInfo,
githubTracking: entry.githubTracking,
sourceIssue: slim ? undefined : entry.sourceIssue,
@@ -1414,6 +1422,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
size: task.size,
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
prInfos: task.prInfos,
issueInfo: task.issueInfo,
githubTracking: task.githubTracking,
sourceIssue: task.sourceIssue,
@@ -1503,7 +1512,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1552,7 +1561,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1655,6 +1664,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJsonNullable(task.reviewState),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJson(task.prInfos || []),
toJsonNullable(task.issueInfo),
toJsonNullable(task.githubTracking),
task.sourceIssue?.provider ?? null,
@@ -1710,7 +1720,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
) VALUES (${placeholders})
@@ -1737,7 +1747,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
) VALUES (${placeholders})
@@ -1813,6 +1823,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewState = excluded.reviewState,
workflowStepResults = excluded.workflowStepResults,
prInfo = excluded.prInfo,
prInfos = excluded.prInfos,
issueInfo = excluded.issueInfo,
githubTracking = excluded.githubTracking,
sourceIssueProvider = excluded.sourceIssueProvider,
@@ -7315,13 +7326,39 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("task:updated", task);
}
private getTaskPrInfos(task: Task): import("./types.js").PrInfo[] {
return [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : []))];
}
private resolvePrimaryPrInfo(prInfos: import("./types.js").PrInfo[]): import("./types.js").PrInfo | undefined {
// Primary selection rule: prefer the most-recently-updated open PR; if none are open,
// fall back to the first linked PR for stable back-compat rendering.
const openPrs = prInfos.filter((entry) => entry.status === "open");
if (openPrs.length === 0) return prInfos[0];
const sorted = [...openPrs].sort((a, b) => {
const aTs = Date.parse(a.lastCheckedAt ?? a.lastCommentAt ?? "");
const bTs = Date.parse(b.lastCheckedAt ?? b.lastCommentAt ?? "");
if (Number.isFinite(aTs) && Number.isFinite(bTs)) return bTs - aTs;
if (Number.isFinite(aTs)) return -1;
if (Number.isFinite(bTs)) return 1;
return 0;
});
return sorted[0] ?? prInfos[0];
}
private upsertPrInfoByNumber(prInfos: import("./types.js").PrInfo[], prInfo: import("./types.js").PrInfo): import("./types.js").PrInfo[] {
const idx = prInfos.findIndex((entry) => entry.number === prInfo.number);
if (idx >= 0) {
const next = [...prInfos];
next[idx] = { ...next[idx], ...prInfo };
return next;
}
return [prInfo, ...prInfos];
}
/**
* Update or clear PR information for a task.
* Updates task.json atomically and emits `task:updated` event.
*
* @param id - The task ID
* @param prInfo - The PR info to set, or null to clear
* @returns The updated task
*/
async updatePrInfo(
id: string,
@@ -7343,41 +7380,89 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
previous?.lastCommentAt !== prInfo?.lastCommentAt;
const linkChanged = previous?.number !== prInfo?.number || previous?.url !== prInfo?.url;
let prInfos = this.getTaskPrInfos(task);
if (prInfo) {
task.prInfo = prInfo;
prInfos = this.upsertPrInfoByNumber(prInfos, prInfo);
if (!previous || linkChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR linked",
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
});
task.log.push({ timestamp: new Date().toISOString(), action: "PR linked", outcome: `PR #${prInfo.number}: ${prInfo.url}` });
} else if (badgeChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR updated",
outcome: `PR #${prInfo.number} badge metadata refreshed`,
});
task.log.push({ timestamp: new Date().toISOString(), action: "PR updated", outcome: `PR #${prInfo.number} badge metadata refreshed` });
}
} else {
task.prInfo = undefined;
if (previous?.number) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR unlinked",
outcome: `PR #${previous.number} removed`,
});
if (previous?.number !== undefined) {
task.log.push({ timestamp: new Date().toISOString(), action: "PR unlinked", outcome: `PR #${previous.number} removed` });
}
prInfos = [];
}
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
if (badgeChanged || linkChanged || !prInfo) this.emit("task:updated", task);
return task;
});
}
if (badgeChanged) {
this.emit("task:updated", task);
async addPrInfo(id: string, prInfo: import("./types.js").PrInfo): Promise<Task | undefined> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
let prInfos = this.getTaskPrInfos(task);
const existingIndex = prInfos.findIndex((entry) => entry.number === prInfo.number);
if (existingIndex >= 0) {
prInfos[existingIndex] = { ...prInfos[existingIndex], ...prInfo };
} else {
prInfos = [prInfo, ...prInfos];
}
task.prInfos = prInfos;
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async updatePrInfoByNumber(id: string, number: number, patch: Partial<import("./types.js").PrInfo>): Promise<Task | undefined> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const prInfos = this.getTaskPrInfos(task);
const index = prInfos.findIndex((entry) => entry.number === number);
if (index < 0) {
storeLog.warn(`[store] updatePrInfoByNumber: PR #${number} not found for ${id}`);
return task;
}
prInfos[index] = { ...prInfos[index], ...patch };
task.prInfos = prInfos;
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async removePrInfoByNumber(id: string, number: number): Promise<Task | undefined> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const prInfos = this.getTaskPrInfos(task).filter((entry) => entry.number !== number);
if ((task.prInfos ?? []).length === prInfos.length && task.prInfo?.number !== number) {
storeLog.warn(`[store] removePrInfoByNumber: PR #${number} not found for ${id}`);
return task;
}
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}

View File

@@ -0,0 +1,5 @@
import type { PrInfo, Task } from "./types.js";
export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
return task.prInfos?.[0] ?? task.prInfo;
}

View File

@@ -831,6 +831,7 @@ export interface BatchStatusRequest {
export interface BatchStatusEntry {
issueInfo?: IssueInfo;
prInfo?: PrInfo;
prInfos?: PrInfo[];
stale: boolean;
error?: string;
}
@@ -1543,6 +1544,8 @@ export interface Task {
reviewState?: TaskReviewState;
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
/** Canonical list of linked PRs; prInfo mirrors the primary PR for back-compat. */
prInfos?: PrInfo[];
mergeDetails?: MergeDetails;
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
@@ -3499,6 +3502,7 @@ export interface ArchivedTaskEntry {
* - "fast": Expedited execution with minimal overhead for simple tasks */
executionMode?: ExecutionMode;
prInfo?: PrInfo;
prInfos?: PrInfo[];
issueInfo?: IssueInfo;
githubTracking?: TaskGithubTracking;
/** Durable source provenance for the originating external issue. */