- commit
- 072e32e
- parent
- 45920f3
- author
- im_wower
- date
- 2026-03-21 23:51:57 +0800 CST
Merge branch 'feat/T-006-checkpointing'
7 files changed,
+693,
-57
+150,
-0
1@@ -0,0 +1,150 @@
2+import {
3+ createCheckpointManager,
4+ renderCheckpointFile,
5+ type CheckpointManager,
6+ type CheckpointRecord
7+} from "@baa-conductor/checkpointing";
8+import type {
9+ LocalRunLogSession,
10+ StreamChunkLogEntry,
11+ WorkerLifecycleLogEntry
12+} from "@baa-conductor/logging";
13+import type { StepCheckpointConfig, StepCheckpointState, StepExecutionRequest } from "./contracts";
14+
15+const DEFAULT_LOG_TAIL_LINES = 20;
16+
17+type OrderedLogEntry =
18+ | {
19+ seq: number;
20+ rendered: string;
21+ }
22+ | {
23+ seq: number;
24+ rendered: string;
25+ };
26+
27+function mapWorkerEntry(entry: WorkerLifecycleLogEntry): OrderedLogEntry {
28+ return {
29+ seq: entry.seq,
30+ rendered: `[worker] ${entry.renderedLine}`
31+ };
32+}
33+
34+function mapStreamEntry(channel: "stdout" | "stderr", entry: StreamChunkLogEntry): OrderedLogEntry {
35+ return {
36+ seq: entry.seq,
37+ rendered: `[${channel}] ${entry.text}`
38+ };
39+}
40+
41+function createCheckpointState(manager: CheckpointManager, mode: StepCheckpointConfig["mode"]): StepCheckpointState {
42+ const { state } = manager;
43+ const latest = state.records.at(-1);
44+
45+ return {
46+ ...state,
47+ mode,
48+ lastCheckpointSeq: state.lastSeq,
49+ nextCheckpointSeq: state.nextSeq,
50+ latest
51+ };
52+}
53+
54+function updateCheckpointState(runState: StepCheckpointState, manager: CheckpointManager): StepCheckpointState {
55+ const nextState = createCheckpointState(manager, runState.mode);
56+
57+ Object.assign(runState, nextState);
58+
59+ return runState;
60+}
61+
62+export function resolveCheckpointConfig(config?: StepCheckpointConfig): StepCheckpointConfig {
63+ return config ?? {
64+ mode: "capture"
65+ };
66+}
67+
68+export function createRunCheckpointManager(
69+ request: StepExecutionRequest,
70+ checkpointsDir: string
71+): CheckpointManager {
72+ const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
73+
74+ return createCheckpointManager({
75+ taskId: request.taskId,
76+ stepId: request.stepId,
77+ runId: request.runId,
78+ directory: checkpointsDir,
79+ resumeFromSeq: checkpointConfig.resumeFromSeq,
80+ gitDiffBaseRef: checkpointConfig.gitDiffBaseRef,
81+ includeGitDiffStat: checkpointConfig.includeGitDiffStat,
82+ supportedTypes: checkpointConfig.mode === "disabled" ? [] : ["summary", "git_diff", "log_tail"],
83+ note:
84+ checkpointConfig.mode === "disabled"
85+ ? "Checkpointing is disabled for this step request."
86+ : "Checkpoint manager tracks summary/log_tail payloads and reserves git diff snapshot commands."
87+ });
88+}
89+
90+export function createPreparedCheckpointState(
91+ manager: CheckpointManager,
92+ config: StepCheckpointConfig
93+): StepCheckpointState {
94+ return createCheckpointState(manager, config.mode);
95+}
96+
97+export function emitPreparationCheckpoint(
98+ manager: CheckpointManager,
99+ checkpoint: StepCheckpointState,
100+ request: StepExecutionRequest
101+): CheckpointRecord | undefined {
102+ if (checkpoint.mode === "disabled") {
103+ return undefined;
104+ }
105+
106+ const summary = request.checkpoint?.summaryHint ?? `Prepared local run layout for ${request.stepId}.`;
107+ const record = manager.createSummaryCheckpoint({
108+ summary,
109+ detail: `Worker runner reserved ${request.workerKind} checkpoint capture for ${request.stepName}.`
110+ });
111+
112+ updateCheckpointState(checkpoint, manager);
113+
114+ return record;
115+}
116+
117+export function emitLogTailCheckpoint(
118+ manager: CheckpointManager,
119+ checkpoint: StepCheckpointState,
120+ session: LocalRunLogSession,
121+ summary: string,
122+ lineLimit: number = DEFAULT_LOG_TAIL_LINES
123+): CheckpointRecord | undefined {
124+ if (checkpoint.mode === "disabled") {
125+ return undefined;
126+ }
127+
128+ const orderedEntries = [
129+ ...session.worker.entries.map(mapWorkerEntry),
130+ ...session.stdout.entries.map((entry) => mapStreamEntry("stdout", entry)),
131+ ...session.stderr.entries.map((entry) => mapStreamEntry("stderr", entry))
132+ ].sort((left, right) => left.seq - right.seq);
133+
134+ const lines = orderedEntries.slice(-lineLimit).map((entry) => entry.rendered);
135+ const record = manager.createLogTailCheckpoint({
136+ summary,
137+ source: "combined",
138+ lines,
139+ truncated: orderedEntries.length > lines.length
140+ });
141+
142+ updateCheckpointState(checkpoint, manager);
143+
144+ return record;
145+}
146+
147+export function describeRenderedCheckpoint(record: CheckpointRecord): string {
148+ const rendered = renderCheckpointFile(record);
149+
150+ return `${record.type}:${rendered.storagePath}`;
151+}
+17,
-5
1@@ -1,3 +1,10 @@
2+import type {
3+ CheckpointManager,
4+ CheckpointManagerState,
5+ CheckpointRecord,
6+ CheckpointType,
7+ GitDiffSnapshotPlan
8+} from "@baa-conductor/checkpointing";
9 import type {
10 LocalRunLogSession,
11 LocalRunLogSummary,
12@@ -10,7 +17,7 @@ import type {
13 export type WorkerKind = "codex" | "shell" | "git";
14 export type StepKind = WorkerKind | "planner" | "review" | "finalize";
15 export type StepExecutionOutcome = "prepared" | "completed" | "failed" | "blocked";
16-export type CheckpointMode = "disabled" | "reserved_for_t006";
17+export type CheckpointMode = "disabled" | "capture";
18 export type StepArtifactKind = "log" | "metadata" | "state" | "directory" | "artifact";
19
20 export interface StepExecutionRuntime {
21@@ -24,6 +31,9 @@ export interface StepCheckpointConfig {
22 mode: CheckpointMode;
23 resumeFromSeq?: number;
24 summaryHint?: string;
25+ gitDiffBaseRef?: string;
26+ includeGitDiffStat?: boolean;
27+ logTailLines?: number;
28 }
29
30 export interface StepExecutionRequest {
31@@ -42,13 +52,14 @@ export interface StepExecutionRequest {
32 checkpoint?: StepCheckpointConfig;
33 }
34
35-export interface StepCheckpointState {
36+export interface StepCheckpointState extends CheckpointManagerState {
37 mode: CheckpointMode;
38 lastCheckpointSeq: number;
39 nextCheckpointSeq: number;
40- resumeFromSeq?: number;
41- deferredFeatures: Array<"git_diff">;
42- note: string;
43+ latest?: CheckpointRecord;
44+ supportedTypes: CheckpointType[];
45+ records: CheckpointRecord[];
46+ gitDiffPlan?: GitDiffSnapshotPlan;
47 }
48
49 export interface StepArtifact {
50@@ -88,6 +99,7 @@ export interface PreparedStepRun {
51 metadata: RunMetadata;
52 state: RunStateSnapshot;
53 checkpoint: StepCheckpointState;
54+ checkpointManager: CheckpointManager;
55 }
56
57 export interface StepExecutionMetrics {
+1,
-0
1@@ -1,2 +1,3 @@
2 export * from "./contracts";
3+export * from "./checkpoints";
4 export * from "./runner";
+71,
-40
1@@ -8,6 +8,7 @@ import {
2 summarizeLocalRunLogSession,
3 updateRunState,
4 type LogLevel,
5+ type StructuredData,
6 type RunStatePatch,
7 type RunStatus,
8 type WorkerLifecycleEventType
9@@ -15,38 +16,19 @@ import {
10 import type {
11 PreparedStepRun,
12 StepArtifact,
13- StepCheckpointConfig,
14- StepCheckpointState,
15 StepExecutionOutcome,
16 StepExecutionRequest,
17 StepExecutionResult,
18 WorkerExecutionOutcome,
19 WorkerExecutor
20 } from "./contracts";
21-
22-const DEFERRED_CHECKPOINT_FEATURES: Array<"git_diff"> = ["git_diff"];
23-
24-function resolveCheckpointConfig(config?: StepCheckpointConfig): StepCheckpointConfig {
25- return config ?? {
26- mode: "reserved_for_t006"
27- };
28-}
29-
30-function createCheckpointState(config: StepCheckpointConfig): StepCheckpointState {
31- const lastCheckpointSeq = config.resumeFromSeq ?? 0;
32-
33- return {
34- mode: config.mode,
35- lastCheckpointSeq,
36- nextCheckpointSeq: lastCheckpointSeq + 1,
37- resumeFromSeq: config.resumeFromSeq,
38- deferredFeatures: [...DEFERRED_CHECKPOINT_FEATURES],
39- note:
40- config.mode === "reserved_for_t006"
41- ? "Checkpoint paths and sequence numbers are reserved for T-006. T-005 does not emit checkpoint content."
42- : "Checkpointing is disabled for this step request."
43- };
44-}
45+import {
46+ createPreparedCheckpointState,
47+ createRunCheckpointManager,
48+ emitLogTailCheckpoint,
49+ emitPreparationCheckpoint,
50+ resolveCheckpointConfig
51+} from "./checkpoints";
52
53 function synchronizeRunState(run: PreparedStepRun, patch: RunStatePatch): void {
54 run.state = updateRunState(run.state, {
55@@ -92,7 +74,7 @@ function createDefaultArtifacts(run: PreparedStepRun): StepArtifact[] {
56 name: "checkpoints",
57 kind: "directory",
58 path: run.logPaths.checkpointsDir,
59- description: "Reserved checkpoint directory for future T-006 persistence."
60+ description: "Checkpoint directory layout for summary/log tail records and future git diff snapshots."
61 },
62 {
63 name: "artifacts",
64@@ -166,13 +148,15 @@ function resolveExitCode(execution: WorkerExecutionOutcome): number {
65 export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
66 const startedAt = request.createdAt ?? new Date().toISOString();
67 const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
68- const checkpoint = createCheckpointState(checkpointConfig);
69 const logPaths = createLocalRunPaths({
70 repoRoot: request.runtime.repoRoot,
71 taskId: request.taskId,
72 runId: request.runId,
73 runsRootDir: request.runtime.runsRootDir
74 });
75+ const checkpointManager = createRunCheckpointManager(request, logPaths.checkpointsDir);
76+ const checkpoint = createPreparedCheckpointState(checkpointManager, checkpointConfig);
77+ const preparationCheckpoint = emitPreparationCheckpoint(checkpointManager, checkpoint, request);
78 const logSession = createLocalRunLogSession(logPaths, {
79 taskId: request.taskId,
80 stepId: request.stepId,
81@@ -200,9 +184,10 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
82 attempt: request.attempt,
83 startedAt,
84 checkpointSeq: checkpoint.lastCheckpointSeq,
85- summary: `Prepared local run layout for ${request.stepId}.`
86+ summary: preparationCheckpoint?.summary ?? `Prepared local run layout for ${request.stepId}.`
87 }),
88- checkpoint
89+ checkpoint,
90+ checkpointManager
91 };
92
93 recordLifecycleEvent(run.logSession, {
94@@ -221,16 +206,54 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
95 }
96 });
97
98- if (checkpoint.mode === "reserved_for_t006") {
99+ if (checkpoint.mode === "capture") {
100+ const checkpointEventData: StructuredData = {
101+ checkpointsDir: run.logPaths.checkpointsDir,
102+ nextCheckpointSeq: checkpoint.nextCheckpointSeq,
103+ supportedTypes: checkpoint.supportedTypes
104+ };
105+
106+ if (preparationCheckpoint !== undefined) {
107+ checkpointEventData.latestCheckpointPath = preparationCheckpoint.file.storagePath;
108+ }
109+
110+ if (checkpoint.gitDiffPlan !== undefined) {
111+ checkpointEventData.gitDiffCommands = {
112+ checkpointType: checkpoint.gitDiffPlan.checkpointType,
113+ cadenceSec: {
114+ min: checkpoint.gitDiffPlan.cadenceSec.min,
115+ max: checkpoint.gitDiffPlan.cadenceSec.max
116+ },
117+ commands: {
118+ statusShort: {
119+ purpose: checkpoint.gitDiffPlan.commands.statusShort.purpose,
120+ args: checkpoint.gitDiffPlan.commands.statusShort.args
121+ },
122+ binaryDiff: {
123+ purpose: checkpoint.gitDiffPlan.commands.binaryDiff.purpose,
124+ args: checkpoint.gitDiffPlan.commands.binaryDiff.args
125+ },
126+ diffStat:
127+ checkpoint.gitDiffPlan.commands.diffStat === undefined
128+ ? null
129+ : {
130+ purpose: checkpoint.gitDiffPlan.commands.diffStat.purpose,
131+ args: checkpoint.gitDiffPlan.commands.diffStat.args
132+ }
133+ },
134+ replay: {
135+ strategy: checkpoint.gitDiffPlan.replay.strategy,
136+ args: checkpoint.gitDiffPlan.replay.args,
137+ baseRef: checkpoint.gitDiffPlan.replay.baseRef ?? null
138+ }
139+ };
140+ }
141+
142 recordLifecycleEvent(run.logSession, {
143 type: "checkpoint_slot_reserved",
144 level: "info",
145- message: `Reserved checkpoint sequence ${checkpoint.nextCheckpointSeq} for future T-006 integration.`,
146- data: {
147- checkpointsDir: run.logPaths.checkpointsDir,
148- nextCheckpointSeq: checkpoint.nextCheckpointSeq,
149- deferredFeatures: checkpoint.deferredFeatures
150- }
151+ message: `Initialized checkpoint manager at sequence ${checkpoint.lastCheckpointSeq}.`,
152+ data: checkpointEventData
153 });
154 }
155
156@@ -304,9 +327,9 @@ export async function runStep(
157 recordLifecycleEvent(run.logSession, {
158 type: "worker_execution_deferred",
159 level: "info",
160- message: `Real ${request.workerKind} execution is intentionally deferred in T-005.`,
161+ message: `Real ${request.workerKind} execution is intentionally deferred while checkpoint capture remains active.`,
162 data: {
163- deferredFeatures: run.checkpoint.deferredFeatures,
164+ supportedTypes: run.checkpoint.supportedTypes,
165 nextCheckpointSeq: run.checkpoint.nextCheckpointSeq
166 }
167 });
168@@ -337,6 +360,14 @@ export async function runStep(
169 }
170 });
171
172+ emitLogTailCheckpoint(
173+ run.checkpointManager,
174+ run.checkpoint,
175+ run.logSession,
176+ `Captured combined log tail after ${execution.outcome} for ${request.stepId}.`,
177+ request.checkpoint?.logTailLines
178+ );
179+
180 synchronizeRunState(run, {
181 status: mapOutcomeToRunStatus(execution.outcome),
182 updatedAt: finishedAt,
183@@ -376,7 +407,7 @@ export async function runStep(
184 lifecycleEventCount: logSummary.lifecycleEventCount,
185 stdoutChunkCount: logSummary.stdoutChunkCount,
186 stderrChunkCount: logSummary.stderrChunkCount,
187- checkpointCount: 0
188+ checkpointCount: run.checkpoint.records.length
189 }
190 };
191 }
+7,
-2
1@@ -5,8 +5,13 @@
2 "outDir": "dist",
3 "baseUrl": "../..",
4 "paths": {
5- "@baa-conductor/logging": ["packages/logging/src/index.ts"]
6+ "@baa-conductor/logging": ["packages/logging/src/index.ts"],
7+ "@baa-conductor/checkpointing": ["packages/checkpointing/src/index.ts"]
8 }
9 },
10- "include": ["src/**/*.ts", "../../packages/logging/src/**/*.ts"]
11+ "include": [
12+ "src/**/*.ts",
13+ "../../packages/logging/src/**/*.ts",
14+ "../../packages/checkpointing/src/**/*.ts"
15+ ]
16 }
1@@ -1,10 +1,10 @@
2 ---
3 task_id: T-006
4 title: Checkpoint 与 Git Diff Snapshots
5-status: todo
6+status: review
7 branch: feat/T-006-checkpointing
8 repo: /Users/george/code/baa-conductor
9-base_ref: main@28829de
10+base_ref: main@56e9a4f
11 depends_on:
12 - T-005
13 write_scope:
14@@ -21,7 +21,7 @@ updated_at: 2026-03-21
15
16 ## 统一开工要求
17
18-- 必须从 `main@28829de` 切出该分支
19+- 必须从 `main@56e9a4f` 切出该分支
20 - 新 worktree 进入后先执行 `npx --yes pnpm install`
21 - 不允许从其他任务分支切分支
22
23@@ -55,24 +55,37 @@ updated_at: 2026-03-21
24
25 ## files_changed
26
27-- 待填写
28+- `packages/checkpointing/src/index.ts`
29+- `apps/worker-runner/src/contracts.ts`
30+- `apps/worker-runner/src/checkpoints.ts`
31+- `apps/worker-runner/src/runner.ts`
32+- `apps/worker-runner/src/index.ts`
33+- `apps/worker-runner/tsconfig.json`
34+- `coordination/tasks/T-006-checkpointing.md`
35
36 ## commands_run
37
38-- 待填写
39+- `npx --yes pnpm install`
40+- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
41+- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
42+- `npx --yes pnpm typecheck`
43
44 ## result
45
46-- 待填写
47+- 已实现 `summary`、`git_diff`、`log_tail` 的 checkpoint 类型、序号管理、文件命名约定与渲染接口。
48+- 已为 `git_diff` 增加快照命令计划与未来 `git apply --binary` 回放提示,但未接入真实 Git 执行。
49+- `worker-runner` 现已默认初始化 checkpoint manager,准备阶段写入 `summary` checkpoint,结束阶段生成 `log_tail` checkpoint,并把状态/计划暴露给后续 executor 与恢复流程。
50
51 ## risks
52
53-- 待填写
54+- 当前实现只落到类型、计划和内存态记录,尚未把 checkpoint 文件真正写入本地目录。
55+- `git_diff` 仍是骨架接口;真实 diff 捕获、patch 持久化与 D1 上报需要后续任务接入。
56
57 ## next_handoff
58
59-- 给 failover 恢复实现提供基础接口
60+- 把 checkpoint manager 接到真实 worker executor、Git 命令执行与本地/D1 持久化链路,为 failover 恢复提供可回放 diff。
61
62 ## notes
63
64 - `2026-03-21`: 创建任务卡
65+- `2026-03-21`: 按最新协调要求将基线修正为 `main@56e9a4f`
+426,
-2
1@@ -1,4 +1,29 @@
2 export type CheckpointType = "summary" | "git_diff" | "log_tail" | "test_output";
3+export type CheckpointFileFormat = "json" | "text" | "patch";
4+export type CheckpointReplayStrategy = "none" | "git_apply_binary";
5+export type CheckpointLogTailSource = "worker" | "stdout" | "stderr" | "combined";
6+
7+export type CheckpointJsonValue =
8+ | string
9+ | number
10+ | boolean
11+ | null
12+ | CheckpointJsonValue[]
13+ | { [key: string]: CheckpointJsonValue };
14+
15+export interface CheckpointFileDescriptor {
16+ fileName: string;
17+ relativePath: string;
18+ storagePath: string;
19+ format: CheckpointFileFormat;
20+ mediaType: string;
21+}
22+
23+export interface CheckpointReplayHint {
24+ strategy: CheckpointReplayStrategy;
25+ args: string[];
26+ baseRef?: string;
27+}
28
29 export interface CheckpointRecord {
30 checkpointId: string;
31@@ -8,13 +33,412 @@ export interface CheckpointRecord {
32 seq: number;
33 type: CheckpointType;
34 summary: string;
35+ createdAt: string;
36+ file: CheckpointFileDescriptor;
37+ contentText?: string;
38+ contentJson?: { [key: string]: CheckpointJsonValue };
39+ replay?: CheckpointReplayHint;
40+}
41+
42+export interface RenderedCheckpointFile extends CheckpointFileDescriptor {
43+ content: string;
44+}
45+
46+export interface SummaryCheckpointInput {
47+ summary: string;
48+ detail?: string;
49+ createdAt?: string;
50+}
51+
52+export interface LogTailCheckpointInput {
53+ summary: string;
54+ source: CheckpointLogTailSource;
55+ lines: string[];
56+ truncated?: boolean;
57+ createdAt?: string;
58+}
59+
60+export interface GitDiffCheckpointInput {
61+ summary: string;
62+ statusShort: string;
63+ diff: string;
64+ diffStat?: string;
65+ baseRef?: string;
66+ createdAt?: string;
67+}
68+
69+export interface GitCommandPlan {
70+ purpose: string;
71+ args: string[];
72+}
73+
74+export interface GitDiffSnapshotPlan {
75+ checkpointType: "git_diff";
76+ commands: {
77+ statusShort: GitCommandPlan;
78+ binaryDiff: GitCommandPlan;
79+ diffStat?: GitCommandPlan;
80+ };
81+ cadenceSec: {
82+ min: number;
83+ max: number;
84+ };
85+ replay: CheckpointReplayHint;
86+}
87+
88+export interface CreateGitDiffSnapshotPlanInput {
89+ baseRef?: string;
90+ includeStat?: boolean;
91+}
92+
93+export interface CreateCheckpointManagerInput {
94+ taskId: string;
95+ stepId: string;
96+ runId: string;
97+ directory: string;
98+ resumeFromSeq?: number;
99+ supportedTypes?: CheckpointType[];
100+ gitDiffBaseRef?: string;
101+ includeGitDiffStat?: boolean;
102+ note?: string;
103+}
104+
105+export interface CheckpointManagerState {
106+ taskId: string;
107+ stepId: string;
108+ runId: string;
109+ directory: string;
110+ lastSeq: number;
111+ nextSeq: number;
112+ resumeFromSeq?: number;
113+ supportedTypes: CheckpointType[];
114+ records: CheckpointRecord[];
115+ gitDiffPlan?: GitDiffSnapshotPlan;
116+ note: string;
117 }
118
119 export interface CheckpointManager {
120- createSummaryCheckpoint(summary: string): CheckpointRecord;
121+ readonly state: CheckpointManagerState;
122+ createSummaryCheckpoint(input: SummaryCheckpointInput): CheckpointRecord;
123+ createLogTailCheckpoint(input: LogTailCheckpointInput): CheckpointRecord;
124+ createGitDiffCheckpoint(input: GitDiffCheckpointInput): CheckpointRecord;
125+}
126+
127+const DEFAULT_SUPPORTED_CHECKPOINT_TYPES: CheckpointType[] = ["summary", "git_diff", "log_tail"];
128+
129+const CHECKPOINT_FILE_LAYOUT: Record<
130+ CheckpointType,
131+ {
132+ suffix: string;
133+ format: CheckpointFileFormat;
134+ mediaType: string;
135+ }
136+> = {
137+ summary: {
138+ suffix: "summary.json",
139+ format: "json",
140+ mediaType: "application/json"
141+ },
142+ git_diff: {
143+ suffix: "git-diff.patch",
144+ format: "patch",
145+ mediaType: "text/x-diff"
146+ },
147+ log_tail: {
148+ suffix: "log-tail.txt",
149+ format: "text",
150+ mediaType: "text/plain"
151+ },
152+ test_output: {
153+ suffix: "test-output.txt",
154+ format: "text",
155+ mediaType: "text/plain"
156+ }
157+};
158+
159+function trimTrailingSlashes(value: string): string {
160+ const trimmed = value.replace(/\/+$/u, "");
161+
162+ return trimmed === "" ? "/" : trimmed;
163+}
164+
165+function joinPath(basePath: string, segment: string): string {
166+ const normalizedBasePath = trimTrailingSlashes(basePath);
167+
168+ if (normalizedBasePath === "/") {
169+ return `/${segment}`;
170+ }
171+
172+ return `${normalizedBasePath}/${segment}`;
173+}
174+
175+function getCheckpointFileLayout(type: CheckpointType) {
176+ return CHECKPOINT_FILE_LAYOUT[type];
177+}
178+
179+function createCheckpointRecordBase(
180+ state: CheckpointManagerState,
181+ type: CheckpointType,
182+ summary: string,
183+ createdAt?: string
184+): CheckpointRecord {
185+ const seq = state.nextSeq;
186+
187+ return {
188+ checkpointId: `${state.runId}:checkpoint:${String(seq).padStart(4, "0")}`,
189+ taskId: state.taskId,
190+ stepId: state.stepId,
191+ runId: state.runId,
192+ seq,
193+ type,
194+ summary,
195+ createdAt: createdAt ?? new Date().toISOString(),
196+ file: createCheckpointFileDescriptor(state.directory, seq, type)
197+ };
198+}
199+
200+function pushRecord(state: CheckpointManagerState, record: CheckpointRecord): CheckpointRecord {
201+ state.records.push(record);
202+ state.lastSeq = record.seq;
203+ state.nextSeq = record.seq + 1;
204+
205+ return record;
206+}
207+
208+function assertSupportedType(state: CheckpointManagerState, type: CheckpointType): void {
209+ if (!state.supportedTypes.includes(type)) {
210+ throw new Error(`Checkpoint type ${type} is not enabled for ${state.runId}.`);
211+ }
212+}
213+
214+function createSummaryPayload(record: CheckpointRecord, detail?: string): {
215+ [key: string]: CheckpointJsonValue;
216+} {
217+ const payload: { [key: string]: CheckpointJsonValue } = {
218+ checkpoint_id: record.checkpointId,
219+ task_id: record.taskId,
220+ step_id: record.stepId,
221+ run_id: record.runId,
222+ seq: record.seq,
223+ checkpoint_type: record.type,
224+ summary: record.summary,
225+ created_at: record.createdAt
226+ };
227+
228+ if (detail !== undefined) {
229+ payload.detail = detail;
230+ }
231+
232+ return payload;
233+}
234+
235+function createLogTailPayload(
236+ source: CheckpointLogTailSource,
237+ lines: string[],
238+ truncated: boolean
239+): { [key: string]: CheckpointJsonValue } {
240+ return {
241+ source,
242+ line_count: lines.length,
243+ truncated
244+ };
245 }
246
247-export function createCheckpointFilename(seq: number, suffix: string): string {
248+function createGitDiffPayload(input: GitDiffCheckpointInput): { [key: string]: CheckpointJsonValue } {
249+ const payload: { [key: string]: CheckpointJsonValue } = {
250+ status_short: input.statusShort
251+ };
252+
253+ if (input.diffStat !== undefined) {
254+ payload.diff_stat = input.diffStat;
255+ }
256+
257+ if (input.baseRef !== undefined) {
258+ payload.base_ref = input.baseRef;
259+ }
260+
261+ return payload;
262+}
263+
264+function renderTextContent(contentText?: string): string {
265+ if (contentText === undefined || contentText === "") {
266+ return "";
267+ }
268+
269+ return contentText.endsWith("\n") ? contentText : `${contentText}\n`;
270+}
271+
272+export function createCheckpointFilename(
273+ seq: number,
274+ checkpointTypeOrSuffix: CheckpointType | string
275+): string {
276+ const suffix =
277+ checkpointTypeOrSuffix in CHECKPOINT_FILE_LAYOUT
278+ ? getCheckpointFileLayout(checkpointTypeOrSuffix as CheckpointType).suffix
279+ : checkpointTypeOrSuffix;
280+
281 return `${String(seq).padStart(4, "0")}-${suffix}`;
282 }
283
284+export function createCheckpointFileDescriptor(
285+ directory: string,
286+ seq: number,
287+ type: CheckpointType
288+): CheckpointFileDescriptor {
289+ const layout = getCheckpointFileLayout(type);
290+ const relativePath = createCheckpointFilename(seq, layout.suffix);
291+
292+ return {
293+ fileName: relativePath,
294+ relativePath,
295+ storagePath: joinPath(directory, relativePath),
296+ format: layout.format,
297+ mediaType: layout.mediaType
298+ };
299+}
300+
301+export function createGitDiffSnapshotPlan(
302+ input: CreateGitDiffSnapshotPlanInput = {}
303+): GitDiffSnapshotPlan {
304+ const replayArgs = ["git", "apply", "--binary", "<checkpoint-file>"];
305+
306+ return {
307+ checkpointType: "git_diff",
308+ commands: {
309+ statusShort: {
310+ purpose: "Capture worktree status before diff snapshot.",
311+ args: ["git", "status", "--short"]
312+ },
313+ binaryDiff: {
314+ purpose: "Capture replayable patch content for checkpoint restore.",
315+ args: ["git", "diff", "--binary"]
316+ },
317+ diffStat:
318+ input.includeStat === false
319+ ? undefined
320+ : {
321+ purpose: "Capture concise diff size summary for conductor UIs and D1 summary fields.",
322+ args: ["git", "diff", "--stat"]
323+ }
324+ },
325+ cadenceSec: {
326+ min: 30,
327+ max: 60
328+ },
329+ replay: {
330+ strategy: "git_apply_binary",
331+ args: replayArgs,
332+ baseRef: input.baseRef
333+ }
334+ };
335+}
336+
337+export function createCheckpointManagerState(
338+ input: CreateCheckpointManagerInput
339+): CheckpointManagerState {
340+ const lastSeq = input.resumeFromSeq ?? 0;
341+ const supportedTypes = input.supportedTypes ?? [...DEFAULT_SUPPORTED_CHECKPOINT_TYPES];
342+ const gitDiffPlan = supportedTypes.includes("git_diff")
343+ ? createGitDiffSnapshotPlan({
344+ baseRef: input.gitDiffBaseRef,
345+ includeStat: input.includeGitDiffStat
346+ })
347+ : undefined;
348+
349+ return {
350+ taskId: input.taskId,
351+ stepId: input.stepId,
352+ runId: input.runId,
353+ directory: trimTrailingSlashes(input.directory),
354+ lastSeq,
355+ nextSeq: lastSeq + 1,
356+ resumeFromSeq: input.resumeFromSeq,
357+ supportedTypes,
358+ records: [],
359+ gitDiffPlan,
360+ note:
361+ input.note ??
362+ "Checkpoint manager tracks summary/log_tail payloads and exposes git diff snapshot commands."
363+ };
364+}
365+
366+export function renderCheckpointFile(record: CheckpointRecord): RenderedCheckpointFile {
367+ let content: string;
368+
369+ switch (record.type) {
370+ case "summary":
371+ content = `${JSON.stringify(record.contentJson ?? {}, null, 2)}\n`;
372+ break;
373+ case "git_diff":
374+ case "log_tail":
375+ case "test_output":
376+ content = renderTextContent(record.contentText);
377+ break;
378+ }
379+
380+ return {
381+ ...record.file,
382+ content
383+ };
384+}
385+
386+export function createCheckpointManager(input: CreateCheckpointManagerInput): CheckpointManager {
387+ const state = createCheckpointManagerState(input);
388+
389+ return {
390+ state,
391+ createSummaryCheckpoint(summaryInput): CheckpointRecord {
392+ assertSupportedType(state, "summary");
393+
394+ const record = createCheckpointRecordBase(
395+ state,
396+ "summary",
397+ summaryInput.summary,
398+ summaryInput.createdAt
399+ );
400+
401+ record.contentJson = createSummaryPayload(record, summaryInput.detail);
402+
403+ return pushRecord(state, record);
404+ },
405+ createLogTailCheckpoint(logTailInput): CheckpointRecord {
406+ assertSupportedType(state, "log_tail");
407+
408+ const record = createCheckpointRecordBase(
409+ state,
410+ "log_tail",
411+ logTailInput.summary,
412+ logTailInput.createdAt
413+ );
414+
415+ record.contentText = logTailInput.lines.join("\n");
416+ record.contentJson = createLogTailPayload(
417+ logTailInput.source,
418+ logTailInput.lines,
419+ logTailInput.truncated ?? false
420+ );
421+
422+ return pushRecord(state, record);
423+ },
424+ createGitDiffCheckpoint(gitDiffInput): CheckpointRecord {
425+ assertSupportedType(state, "git_diff");
426+
427+ const record = createCheckpointRecordBase(
428+ state,
429+ "git_diff",
430+ gitDiffInput.summary,
431+ gitDiffInput.createdAt
432+ );
433+
434+ record.contentText = gitDiffInput.diff;
435+ record.contentJson = createGitDiffPayload(gitDiffInput);
436+ record.replay = {
437+ strategy: "git_apply_binary",
438+ args: ["git", "apply", "--binary", record.file.storagePath],
439+ baseRef: gitDiffInput.baseRef
440+ };
441+
442+ return pushRecord(state, record);
443+ }
444+ };
445+}