Skip to content

Commit 05ab5ae

Browse files
committed
fix(hold): Sage review round 3 — keep persisted task metadata as a fallback for every resume checkpoint (#627 stage 1)
The synthetic pre-wave discovery was replaced by fresh discovery as soon as it existed, and fresh discovery lists only tasks still to run — so a hold checkpoint after that point wrote taskFolder '' (and dropped partial-progress / diagnostic fields) for every completed task. Fresh discovery is now merged OVER the persisted fallback for both checkpoint contexts; fresh entries win, persisted entries fill the gaps. Test: real hold checkpoint through persistRuntimeStateStrict with one held task and one completed task absent from fresh discovery; reload asserts folders, partialProgress* and exitDiagnostic survive. 4017 pass.
1 parent 34c0206 commit 05ab5ae

2 files changed

Lines changed: 191 additions & 2 deletions

File tree

extensions/taskplane/resume.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2006,6 +2006,21 @@ export async function resumeOrchBatch(
20062006
),
20072007
completed: new Map(),
20082008
} as unknown as import("./types.ts").DiscoveryResult;
2009+
/**
2010+
* Merge fresh discovery WITH the persisted fallback (Sage review round 3):
2011+
* discovery.pending only lists tasks still to run, so a checkpoint that
2012+
* relied on it alone wrote taskFolder "" for every completed/archived task
2013+
* — durable metadata loss on crash, and merge artifact staging skips tasks
2014+
* with no folder. Fresh entries win; persisted entries fill the gaps.
2015+
*/
2016+
const withPersistedFallback = (
2017+
fresh: import("./types.ts").DiscoveryResult | null,
2018+
): import("./types.ts").DiscoveryResult => {
2019+
if (!fresh) return preWaveDiscovery;
2020+
const pending = new Map(preWaveDiscovery.pending);
2021+
for (const [id, task] of fresh.pending) pending.set(id, task);
2022+
return { ...fresh, pending } as import("./types.ts").DiscoveryResult;
2023+
};
20092024
const preWaveLanes = reconstructAllocatedLanes(persistedState.lanes, persistedState.tasks);
20102025
const holdPersistCtx: {
20112026
wavePlan: () => string[][];
@@ -2081,7 +2096,7 @@ export async function resumeOrchBatch(
20812096
useDependencyCache: orchConfig.dependencies.cache,
20822097
workspaceConfig: workspaceConfig ?? null,
20832098
});
2084-
holdPersistCtx.discovery = () => discovery;
2099+
holdPersistCtx.discovery = () => withPersistedFallback(discovery);
20852100

20862101
// Build dependency graph for skip-dependents policy
20872102
const depGraph = buildDependencyGraph(discovery.pending, discovery.completed);
@@ -2741,7 +2756,7 @@ export async function resumeOrchBatch(
27412756
holdPersistCtx.wavePlan = () => wavePlan;
27422757
holdPersistCtx.lanes = () => latestAllocatedLanes;
27432758
holdPersistCtx.outcomes = () => allTaskOutcomes;
2744-
holdPersistCtx.discovery = () => discovery ?? null;
2759+
holdPersistCtx.discovery = () => withPersistedFallback(discovery ?? null);
27452760

27462761
// Build outcomes from reconciled tasks
27472762
for (const task of reconciledTasks) {

extensions/tests/held-state-recovery.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
import { reconstructHoldsFromMailbox } from "../taskplane/hold-state.ts";
2323
import { quarantineUnauthorizedDoneMarkers } from "../taskplane/resume.ts";
2424
import { readOutboxStrict } from "../taskplane/mailbox.ts";
25+
import { loadBatchState, persistRuntimeStateStrict } from "../taskplane/persistence.ts";
26+
import { createHoldStore } from "../taskplane/hold-state.ts";
27+
import { defaultBatchDiagnostics, defaultResilienceState } from "../taskplane/types.ts";
2528
import { mkdirSync, writeFileSync } from "node:fs";
2629
import { drainAgentOutbox, writeOutboxMessage, sessionOutboxDir } from "../taskplane/mailbox.ts";
2730
import { applyRuling, createHoldRecord, type HoldRecord } from "../taskplane/hold-state.ts";
@@ -695,3 +698,174 @@ describe("#627 — Sage round 2 regressions", () => {
695698
);
696699
});
697700
});
701+
702+
describe("#627 — Sage round 3: strict checkpoint keeps every task's folder and recovery metadata", () => {
703+
let root: string;
704+
beforeEach(() => {
705+
root = mkdtempSync(join(tmpdir(), "tp627-r3-"));
706+
mkdirSync(join(root, ".pi"), { recursive: true });
707+
});
708+
afterEach(() => {
709+
rmSync(root, { recursive: true, force: true });
710+
});
711+
712+
it("a hold checkpoint during resume (fresh discovery lists only the held task) preserves the completed task's folder, partial-progress and diagnostic fields", () => {
713+
// Persisted table: TP-D completed (absent from fresh discovery), TP-H held.
714+
const tasks = [
715+
{
716+
taskId: "TP-D",
717+
laneNumber: 1,
718+
sessionName: "orch-op-lane-1",
719+
status: "succeeded",
720+
taskFolder: join(root, "tasks", "TP-D"),
721+
startedAt: 1,
722+
endedAt: 2,
723+
doneFileFound: true,
724+
exitReason: "done",
725+
partialProgressCommits: 3,
726+
partialProgressBranch: "pp/TP-D",
727+
exitDiagnostic: {
728+
classification: "clean_exit",
729+
exitCode: 0,
730+
errorMessage: null,
731+
tokensUsed: 1,
732+
contextPct: 1,
733+
partialProgressCommits: 3,
734+
partialProgressBranch: "pp/TP-D",
735+
durationSec: 1,
736+
lastKnownStep: null,
737+
lastKnownCheckbox: null,
738+
repoId: "default",
739+
},
740+
},
741+
{
742+
taskId: "TP-H",
743+
laneNumber: 2,
744+
sessionName: "orch-op-lane-2",
745+
status: "held",
746+
taskFolder: join(root, "tasks", "TP-H"),
747+
startedAt: 3,
748+
endedAt: null,
749+
doneFileFound: false,
750+
exitReason: "",
751+
},
752+
];
753+
const lanes = [
754+
{
755+
laneNumber: 1,
756+
laneId: "lane-1",
757+
laneSessionId: "orch-op-lane-1",
758+
worktreePath: join(root, "wt1"),
759+
branch: "b1",
760+
taskIds: ["TP-D"],
761+
},
762+
{
763+
laneNumber: 2,
764+
laneId: "lane-2",
765+
laneSessionId: "orch-op-lane-2",
766+
worktreePath: join(root, "wt2"),
767+
branch: "b2",
768+
taskIds: ["TP-H"],
769+
},
770+
];
771+
const batchState: any = {
772+
phase: "paused",
773+
batchId: "b",
774+
baseBranch: "main",
775+
orchBranch: "orch/x",
776+
mode: "repo",
777+
pauseSignal: { paused: false },
778+
waveResults: [],
779+
currentWaveIndex: 0,
780+
totalWaves: 1,
781+
blockedTaskIds: new Set(),
782+
startedAt: 1,
783+
endedAt: null,
784+
totalTasks: 2,
785+
succeededTasks: 1,
786+
failedTasks: 0,
787+
skippedTasks: 0,
788+
blockedTasks: 0,
789+
errors: [],
790+
currentLanes: [],
791+
dependencyGraph: null,
792+
mergeResults: [],
793+
segments: [],
794+
holds: [],
795+
resilience: defaultResilienceState(),
796+
diagnostics: defaultBatchDiagnostics(),
797+
};
798+
// Exactly what resume builds: pre-wave outcomes from the persisted table + synthetic discovery
799+
// merged under fresh discovery (which only knows the held task).
800+
const preWaveOutcomes = tasks.map((t: any) => ({
801+
taskId: t.taskId,
802+
status: t.status,
803+
segmentId: null,
804+
startTime: t.startedAt,
805+
endTime: t.endedAt,
806+
exitReason: t.exitReason,
807+
sessionName: t.sessionName,
808+
doneFileFound: t.doneFileFound,
809+
laneNumber: t.laneNumber,
810+
...(t.partialProgressCommits !== undefined
811+
? { partialProgressCommits: t.partialProgressCommits }
812+
: {}),
813+
...(t.partialProgressBranch !== undefined
814+
? { partialProgressBranch: t.partialProgressBranch }
815+
: {}),
816+
...(t.exitDiagnostic !== undefined ? { exitDiagnostic: t.exitDiagnostic } : {}),
817+
}));
818+
const synthetic = {
819+
pending: new Map(tasks.map((t) => [t.taskId, { taskId: t.taskId, taskFolder: t.taskFolder }])),
820+
completed: new Map(),
821+
};
822+
const fresh = {
823+
pending: new Map([["TP-H", { taskId: "TP-H", taskFolder: join(root, "tasks", "TP-H") }]]),
824+
completed: new Map(),
825+
};
826+
const merged = { ...fresh, pending: new Map([...synthetic.pending, ...fresh.pending]) };
827+
const allocated = lanes.map((l) => ({
828+
...l,
829+
tasks: l.taskIds.map((id) => ({
830+
taskId: id,
831+
order: 0,
832+
task: { taskId: id },
833+
estimatedMinutes: 0,
834+
})),
835+
strategy: "round-robin",
836+
estimatedLoad: 0,
837+
estimatedMinutes: 0,
838+
}));
839+
const store = createHoldStore(batchState, (reason) =>
840+
persistRuntimeStateStrict(
841+
reason,
842+
batchState,
843+
[["TP-D", "TP-H"]],
844+
allocated as never,
845+
preWaveOutcomes as never,
846+
merged as never,
847+
root,
848+
),
849+
);
850+
store.open(hold({ taskId: "TP-H", laneNumber: 2, agentId: "orch-op-lane-2-worker" }));
851+
852+
const reloaded = loadBatchState(root)!;
853+
const d = reloaded.tasks.find((t) => t.taskId === "TP-D")!;
854+
const h = reloaded.tasks.find((t) => t.taskId === "TP-H")!;
855+
expect(d.taskFolder).toBe(join(root, "tasks", "TP-D"));
856+
expect(d.partialProgressCommits).toBe(3);
857+
expect(d.partialProgressBranch).toBe("pp/TP-D");
858+
expect(d.exitDiagnostic?.classification).toBe("clean_exit");
859+
expect(d.status).toBe("succeeded");
860+
expect(h.taskFolder).toBe(join(root, "tasks", "TP-H"));
861+
expect(h.status).toBe("held");
862+
expect(reloaded.holds.length).toBe(1);
863+
864+
// and the resume source keeps the fallback for EVERY checkpoint, not just pre-wave
865+
const src = readSrc("resume.ts");
866+
expect(
867+
(src.match(/holdPersistCtx\.discovery = \(\) => withPersistedFallback\(/g) ?? []).length,
868+
).toBe(2);
869+
expect(src).toContain("discovery: () => preWaveDiscovery,");
870+
});
871+
});

0 commit comments

Comments
 (0)