@kodrunhq/opencode-autopilot 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +14 -0
  2. package/bin/cli.ts +5 -0
  3. package/bin/inspect.ts +337 -0
  4. package/package.json +1 -1
  5. package/src/agents/autopilot.ts +7 -15
  6. package/src/agents/index.ts +54 -21
  7. package/src/health/checks.ts +108 -4
  8. package/src/health/runner.ts +3 -0
  9. package/src/index.ts +105 -12
  10. package/src/inspect/formatters.ts +225 -0
  11. package/src/inspect/repository.ts +882 -0
  12. package/src/kernel/database.ts +45 -0
  13. package/src/kernel/migrations.ts +62 -0
  14. package/src/kernel/repository.ts +571 -0
  15. package/src/kernel/schema.ts +122 -0
  16. package/src/kernel/types.ts +66 -0
  17. package/src/memory/capture.ts +221 -25
  18. package/src/memory/database.ts +74 -12
  19. package/src/memory/index.ts +17 -1
  20. package/src/memory/project-key.ts +6 -0
  21. package/src/memory/repository.ts +833 -42
  22. package/src/memory/retrieval.ts +83 -169
  23. package/src/memory/schemas.ts +39 -7
  24. package/src/memory/types.ts +4 -0
  25. package/src/observability/event-handlers.ts +28 -17
  26. package/src/observability/event-store.ts +29 -1
  27. package/src/observability/forensic-log.ts +159 -0
  28. package/src/observability/forensic-schemas.ts +69 -0
  29. package/src/observability/forensic-types.ts +10 -0
  30. package/src/observability/index.ts +21 -27
  31. package/src/observability/log-reader.ts +142 -111
  32. package/src/observability/log-writer.ts +41 -83
  33. package/src/observability/retention.ts +2 -2
  34. package/src/observability/session-logger.ts +36 -57
  35. package/src/observability/summary-generator.ts +31 -19
  36. package/src/observability/types.ts +12 -24
  37. package/src/orchestrator/contracts/invariants.ts +14 -0
  38. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  39. package/src/orchestrator/fallback/event-handler.ts +47 -3
  40. package/src/orchestrator/handlers/architect.ts +2 -1
  41. package/src/orchestrator/handlers/build.ts +55 -97
  42. package/src/orchestrator/handlers/retrospective.ts +2 -1
  43. package/src/orchestrator/handlers/types.ts +0 -1
  44. package/src/orchestrator/lesson-memory.ts +29 -9
  45. package/src/orchestrator/orchestration-logger.ts +37 -23
  46. package/src/orchestrator/phase.ts +8 -4
  47. package/src/orchestrator/state.ts +79 -17
  48. package/src/projects/database.ts +47 -0
  49. package/src/projects/repository.ts +264 -0
  50. package/src/projects/resolve.ts +301 -0
  51. package/src/projects/schemas.ts +30 -0
  52. package/src/projects/types.ts +12 -0
  53. package/src/review/memory.ts +29 -9
  54. package/src/tools/doctor.ts +40 -5
  55. package/src/tools/forensics.ts +7 -12
  56. package/src/tools/logs.ts +6 -5
  57. package/src/tools/memory-preferences.ts +157 -0
  58. package/src/tools/memory-status.ts +17 -96
  59. package/src/tools/orchestrate.ts +97 -81
  60. package/src/tools/pipeline-report.ts +3 -2
  61. package/src/tools/quick.ts +2 -2
  62. package/src/tools/review.ts +39 -6
  63. package/src/tools/session-stats.ts +3 -2
  64. package/src/utils/paths.ts +20 -1
@@ -45,6 +45,30 @@ function findInProgressTasks(
45
45
  return tasks.filter((t) => t.status === "IN_PROGRESS");
46
46
  }
47
47
 
48
+ function buildPendingResultError(
49
+ wave: number,
50
+ inProgressTasks: readonly Task[],
51
+ buildProgress: Readonly<BuildProgress>,
52
+ updatedTasks?: readonly Task[],
53
+ ): DispatchResult {
54
+ const taskIds = inProgressTasks.map((task) => task.id);
55
+ return Object.freeze({
56
+ action: "error",
57
+ code: "E_BUILD_RESULT_PENDING",
58
+ phase: "BUILD",
59
+ message: `Wave ${wave} still has in-progress task result(s) pending for taskIds [${taskIds.join(", ")}]. Wait for the typed result envelope and pass it back to oc_orchestrate.`,
60
+ progress: `Wave ${wave} — waiting for typed result(s) for taskIds [${taskIds.join(", ")}]`,
61
+ _stateUpdates: {
62
+ ...(updatedTasks ? { tasks: [...updatedTasks] } : {}),
63
+ buildProgress: {
64
+ ...buildProgress,
65
+ currentWave: wave,
66
+ currentTask: buildProgress.currentTask ?? inProgressTasks[0]?.id ?? null,
67
+ },
68
+ },
69
+ } satisfies DispatchResult);
70
+ }
71
+
48
72
  /**
49
73
  * Mark multiple tasks as IN_PROGRESS immutably.
50
74
  */
@@ -214,6 +238,7 @@ export const handleBuild: PhaseHandler = async (
214
238
  _stateUpdates: {
215
239
  buildProgress: {
216
240
  ...buildProgress,
241
+ reviewPending: false,
217
242
  strikeCount: buildProgress.strikeCount + 1,
218
243
  },
219
244
  },
@@ -232,6 +257,7 @@ export const handleBuild: PhaseHandler = async (
232
257
  _stateUpdates: {
233
258
  buildProgress: {
234
259
  ...buildProgress,
260
+ currentTask: null,
235
261
  reviewPending: false,
236
262
  },
237
263
  },
@@ -239,13 +265,18 @@ export const handleBuild: PhaseHandler = async (
239
265
  }
240
266
 
241
267
  const pendingTasks = findPendingTasks(waveMap, nextWave);
268
+ const inProgressTasks = findInProgressTasks(waveMap, nextWave);
242
269
  const updatedProgress: BuildProgress = {
243
270
  ...buildProgress,
244
271
  reviewPending: false,
245
272
  currentWave: nextWave,
246
273
  };
247
274
 
248
- if (pendingTasks.length === 1) {
275
+ if (pendingTasks.length === 0 && inProgressTasks.length > 0) {
276
+ return buildPendingResultError(nextWave, inProgressTasks, updatedProgress);
277
+ }
278
+
279
+ if (pendingTasks.length > 0) {
249
280
  const task = pendingTasks[0];
250
281
  const prompt = await buildTaskPrompt(task, artifactDir);
251
282
  return Object.freeze({
@@ -257,62 +288,25 @@ export const handleBuild: PhaseHandler = async (
257
288
  taskId: task.id,
258
289
  progress: `Wave ${nextWave} — task ${task.id}`,
259
290
  _stateUpdates: {
291
+ tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
260
292
  buildProgress: { ...updatedProgress, currentTask: task.id },
261
293
  },
262
294
  } satisfies DispatchResult);
263
295
  }
264
296
 
265
- const dispatchedIds = pendingTasks.map((t) => t.id);
266
- const promptsByTaskId = new Map<number, string>();
267
- await Promise.all(
268
- pendingTasks.map(async (task) => {
269
- promptsByTaskId.set(task.id, await buildTaskPrompt(task, artifactDir));
270
- }),
271
- );
272
297
  return Object.freeze({
273
- action: "dispatch_multi",
274
- agents: pendingTasks.map((task) => ({
275
- agent: AGENT_NAMES.BUILD,
276
- prompt: promptsByTaskId.get(task.id) ?? "",
277
- taskId: task.id,
278
- resultKind: "task_completion" as const,
279
- })),
298
+ action: "error",
299
+ code: "E_BUILD_NO_DISPATCHABLE_TASK",
280
300
  phase: "BUILD",
281
- progress: `Wave ${nextWave} ${pendingTasks.length} concurrent tasks`,
282
- _stateUpdates: {
283
- tasks: [...markTasksInProgress(effectiveTasks, dispatchedIds)],
284
- buildProgress: { ...updatedProgress, currentTask: null },
285
- },
301
+ message: `Wave ${nextWave} has no dispatchable pending tasks.`,
286
302
  } satisfies DispatchResult);
287
303
  }
288
304
 
289
305
  // Case 2: Result provided + not review pending -> mark task done
290
- // For dispatch_multi, currentTask may be null — find the first IN_PROGRESS task instead
291
- const hasTypedContext = context !== undefined && !context.legacy;
306
+ const hasTypedContext = context !== undefined;
292
307
  const isTaskCompletion = hasTypedContext && context.envelope.kind === "task_completion";
293
- const isLegacyContext = context !== undefined && context.legacy;
294
- const taskToComplete = isTaskCompletion
295
- ? context.envelope.taskId
296
- : hasTypedContext
297
- ? buildProgress.currentTask
298
- : (buildProgress.currentTask ??
299
- effectiveTasks.find((t) => t.status === "IN_PROGRESS")?.id ??
300
- null);
301
-
302
- if (
303
- resultText &&
304
- !buildProgress.reviewPending &&
305
- isLegacyContext &&
306
- buildProgress.currentTask === null
307
- ) {
308
- return Object.freeze({
309
- action: "error",
310
- code: "E_BUILD_TASK_ID_REQUIRED",
311
- phase: "BUILD",
312
- message:
313
- "Legacy BUILD result cannot be attributed when currentTask is null. Submit typed envelope with taskId.",
314
- } satisfies DispatchResult);
315
- }
308
+ const taskToComplete = isTaskCompletion ? context.envelope.taskId : buildProgress.currentTask;
309
+
316
310
  if (resultText && !buildProgress.reviewPending && taskToComplete === null) {
317
311
  return Object.freeze({
318
312
  action: "error",
@@ -349,7 +343,7 @@ export const handleBuild: PhaseHandler = async (
349
343
  tasks: [...updatedTasks],
350
344
  buildProgress: {
351
345
  ...buildProgress,
352
- currentTask: null,
346
+ currentTask: taskToComplete,
353
347
  reviewPending: true,
354
348
  },
355
349
  },
@@ -370,7 +364,7 @@ export const handleBuild: PhaseHandler = async (
370
364
  taskId: next.id,
371
365
  progress: `Wave ${currentWave} — task ${next.id}`,
372
366
  _stateUpdates: {
373
- tasks: [...updatedTasks],
367
+ tasks: [...markTasksInProgress(updatedTasks, [next.id])],
374
368
  buildProgress: {
375
369
  ...buildProgress,
376
370
  currentTask: next.id,
@@ -382,21 +376,7 @@ export const handleBuild: PhaseHandler = async (
382
376
  // No pending tasks but wave not complete — other tasks are still IN_PROGRESS
383
377
  const inProgressInWave = findInProgressTasks(waveMap, currentWave);
384
378
  if (inProgressInWave.length > 0) {
385
- return Object.freeze({
386
- action: "dispatch",
387
- agent: AGENT_NAMES.BUILD,
388
- prompt: `Wave ${currentWave} has ${inProgressInWave.length} task(s) still in progress. Continue working on remaining tasks.`,
389
- phase: "BUILD",
390
- resultKind: "phase_output",
391
- progress: `Wave ${currentWave} — waiting for ${inProgressInWave.length} in-progress task(s)`,
392
- _stateUpdates: {
393
- tasks: [...updatedTasks],
394
- buildProgress: {
395
- ...buildProgress,
396
- currentTask: null,
397
- },
398
- },
399
- } satisfies DispatchResult);
379
+ return buildPendingResultError(currentWave, inProgressInWave, buildProgress, updatedTasks);
400
380
  }
401
381
  }
402
382
 
@@ -417,23 +397,7 @@ export const handleBuild: PhaseHandler = async (
417
397
  const inProgressTasks = findInProgressTasks(waveMap, currentWave);
418
398
 
419
399
  if (pendingTasks.length === 0 && inProgressTasks.length > 0) {
420
- // Wave has only IN_PROGRESS tasks (e.g., resume after dispatch_multi).
421
- // Return a dispatch instruction so the orchestrator knows work is underway.
422
- return Object.freeze({
423
- action: "dispatch",
424
- agent: AGENT_NAMES.BUILD,
425
- prompt: `Resume: wave ${currentWave} has ${inProgressTasks.length} task(s) still in progress. Wait for agent results and pass them back.`,
426
- phase: "BUILD",
427
- resultKind: "phase_output",
428
- progress: `Wave ${currentWave} — waiting for ${inProgressTasks.length} in-progress task(s)`,
429
- _stateUpdates: {
430
- buildProgress: {
431
- ...buildProgress,
432
- currentWave,
433
- currentTask: null,
434
- },
435
- },
436
- } satisfies DispatchResult);
400
+ return buildPendingResultError(currentWave, inProgressTasks, buildProgress);
437
401
  }
438
402
 
439
403
  if (pendingTasks.length === 0) {
@@ -457,6 +421,7 @@ export const handleBuild: PhaseHandler = async (
457
421
  taskId: task.id,
458
422
  progress: `Wave ${currentWave} — task ${task.id}`,
459
423
  _stateUpdates: {
424
+ tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
460
425
  buildProgress: {
461
426
  ...buildProgress,
462
427
  currentTask: task.id,
@@ -466,29 +431,22 @@ export const handleBuild: PhaseHandler = async (
466
431
  } satisfies DispatchResult);
467
432
  }
468
433
 
469
- // Multiple pending tasks in wave -> dispatch_multi
470
- const dispatchedIds = pendingTasks.map((t) => t.id);
471
- const promptsByTaskId = new Map<number, string>();
472
- await Promise.all(
473
- pendingTasks.map(async (task) => {
474
- promptsByTaskId.set(task.id, await buildTaskPrompt(task, artifactDir));
475
- }),
476
- );
434
+ // Multiple pending tasks in wave -> dispatch only the next task sequentially.
435
+ const task = pendingTasks[0];
436
+ const prompt = await buildTaskPrompt(task, artifactDir);
477
437
  return Object.freeze({
478
- action: "dispatch_multi",
479
- agents: pendingTasks.map((task) => ({
480
- agent: AGENT_NAMES.BUILD,
481
- prompt: promptsByTaskId.get(task.id) ?? "",
482
- taskId: task.id,
483
- resultKind: "task_completion" as const,
484
- })),
438
+ action: "dispatch",
439
+ agent: AGENT_NAMES.BUILD,
440
+ prompt,
485
441
  phase: "BUILD",
486
- progress: `Wave ${currentWave} — ${pendingTasks.length} concurrent tasks`,
442
+ resultKind: "task_completion",
443
+ taskId: task.id,
444
+ progress: `Wave ${currentWave} — task ${task.id}`,
487
445
  _stateUpdates: {
488
- tasks: [...markTasksInProgress(effectiveTasks, dispatchedIds)],
446
+ tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
489
447
  buildProgress: {
490
448
  ...buildProgress,
491
- currentTask: null,
449
+ currentTask: task.id,
492
450
  currentWave,
493
451
  },
494
452
  },
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { getProjectRootFromArtifactDir } from "../../utils/paths";
2
3
  import { getArtifactRef, PHASE_ARTIFACTS } from "../artifacts";
3
4
  import {
4
5
  createEmptyLessonMemory,
@@ -81,7 +82,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
81
82
 
82
83
  // Persist lessons to memory (best-effort: failure should not mark pipeline as FAILED)
83
84
  try {
84
- const projectRoot = join(artifactDir, "..");
85
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
85
86
  const existing = await loadLessonMemory(projectRoot);
86
87
  const memory = existing ?? createEmptyLessonMemory();
87
88
  const merged = [...memory.lessons, ...valid];
@@ -40,7 +40,6 @@ export interface DispatchResult {
40
40
 
41
41
  export interface PhaseHandlerContext {
42
42
  readonly envelope: ResultEnvelope;
43
- readonly legacy: boolean;
44
43
  }
45
44
 
46
45
  export type PhaseHandler = (
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Per-project lesson memory persistence.
3
3
  *
4
- * Stores lessons extracted from pipeline runs at
5
- * {projectRoot}/.opencode-autopilot/lesson-memory.json
4
+ * Stores lessons extracted from pipeline runs in the project kernel,
5
+ * with {projectRoot}/.opencode-autopilot/lesson-memory.json kept as a
6
+ * compatibility mirror/export during the Phase 4 migration window.
6
7
  *
7
8
  * Memory is pruned on load to remove stale entries (>90 days)
8
9
  * and cap at 50 lessons. All writes are atomic (tmp file + rename)
@@ -12,7 +13,9 @@
12
13
  import { randomBytes } from "node:crypto";
13
14
  import { readFile, rename, writeFile } from "node:fs/promises";
14
15
  import { join } from "node:path";
16
+ import { loadLessonMemoryFromKernel, saveLessonMemoryToKernel } from "../kernel/repository";
15
17
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
18
+ import { getProjectArtifactDir } from "../utils/paths";
16
19
  import { lessonMemorySchema } from "./lesson-schemas";
17
20
  import type { LessonMemory } from "./lesson-types";
18
21
 
@@ -21,6 +24,7 @@ export type { LessonMemory };
21
24
  const LESSON_FILE = "lesson-memory.json";
22
25
  const MAX_LESSONS = 50;
23
26
  const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
27
+ let legacyLessonMemoryMirrorWarned = false;
24
28
 
25
29
  /**
26
30
  * Create a valid empty lesson memory object.
@@ -41,6 +45,11 @@ export function createEmptyLessonMemory(): LessonMemory {
41
45
  * Prunes on load to remove stale entries and cap storage.
42
46
  */
43
47
  export async function loadLessonMemory(projectRoot: string): Promise<LessonMemory | null> {
48
+ const kernelMemory = loadLessonMemoryFromKernel(getProjectArtifactDir(projectRoot));
49
+ if (kernelMemory !== null) {
50
+ return pruneLessons(kernelMemory);
51
+ }
52
+
44
53
  const memoryPath = join(projectRoot, ".opencode-autopilot", LESSON_FILE);
45
54
  try {
46
55
  const raw = await readFile(memoryPath, "utf-8");
@@ -51,7 +60,9 @@ export async function loadLessonMemory(projectRoot: string): Promise<LessonMemor
51
60
  ...parsed,
52
61
  lessons: Array.isArray(parsed.lessons) ? parsed.lessons : [],
53
62
  });
54
- return lessonMemorySchema.parse(pruned);
63
+ const validated = lessonMemorySchema.parse(pruned);
64
+ saveLessonMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
65
+ return validated;
55
66
  } catch (error: unknown) {
56
67
  if (isEnoentError(error)) {
57
68
  return null;
@@ -74,12 +85,21 @@ export async function loadLessonMemory(projectRoot: string): Promise<LessonMemor
74
85
  */
75
86
  export async function saveLessonMemory(memory: LessonMemory, projectRoot: string): Promise<void> {
76
87
  const validated = lessonMemorySchema.parse(memory);
77
- const dir = join(projectRoot, ".opencode-autopilot");
78
- await ensureDir(dir);
79
- const memoryPath = join(dir, LESSON_FILE);
80
- const tmpPath = `${memoryPath}.tmp.${randomBytes(8).toString("hex")}`;
81
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
82
- await rename(tmpPath, memoryPath);
88
+ saveLessonMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
89
+
90
+ try {
91
+ const dir = join(projectRoot, ".opencode-autopilot");
92
+ await ensureDir(dir);
93
+ const memoryPath = join(dir, LESSON_FILE);
94
+ const tmpPath = `${memoryPath}.tmp.${randomBytes(8).toString("hex")}`;
95
+ await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
96
+ await rename(tmpPath, memoryPath);
97
+ } catch (error: unknown) {
98
+ if (!legacyLessonMemoryMirrorWarned) {
99
+ legacyLessonMemoryMirrorWarned = true;
100
+ console.warn("[opencode-autopilot] lesson-memory.json mirror write failed:", error);
101
+ }
102
+ }
83
103
  }
84
104
 
85
105
  /**
@@ -1,5 +1,4 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { appendForensicEventForArtifactDir } from "../observability/forensic-log";
3
2
 
4
3
  export interface OrchestrationEvent {
5
4
  readonly timestamp: string;
@@ -9,32 +8,47 @@ export interface OrchestrationEvent {
9
8
  readonly promptLength?: number;
10
9
  readonly attempt?: number;
11
10
  readonly message?: string;
11
+ readonly runId?: string;
12
+ readonly dispatchId?: string;
13
+ readonly taskId?: number | null;
14
+ readonly code?: string;
15
+ readonly sessionId?: string;
16
+ readonly payload?: Record<string, string | number | boolean | null>;
12
17
  }
13
18
 
14
- const LOG_FILE = "orchestration.jsonl";
15
-
16
- /** Rate-limit: warn about log failures at most once per process. */
17
- let logWriteWarned = false;
18
-
19
19
  /**
20
20
  * Append an orchestration event to the project-local JSONL log.
21
21
  * Uses synchronous append to survive crashes. Best-effort — errors are swallowed.
22
22
  */
23
23
  export function logOrchestrationEvent(artifactDir: string, event: OrchestrationEvent): void {
24
- try {
25
- mkdirSync(artifactDir, { recursive: true });
26
- const logPath = join(artifactDir, LOG_FILE);
27
- // Redact filesystem paths from message to avoid leaking sensitive directory info
28
- const safe = event.message
29
- ? { ...event, message: event.message.replace(/[/\\][^\s"']+/g, "[PATH]") }
30
- : event;
31
- const line = `${JSON.stringify(safe)}\n`;
32
- appendFileSync(logPath, line, "utf-8");
33
- } catch (err) {
34
- // Best-effort — never block the pipeline. Warn once so operators know logging is broken.
35
- if (!logWriteWarned) {
36
- logWriteWarned = true;
37
- console.warn("[opencode-autopilot] orchestration log write failed:", err);
38
- }
39
- }
24
+ appendForensicEventForArtifactDir(artifactDir, {
25
+ timestamp: event.timestamp,
26
+ domain: event.action === "error" && event.code?.startsWith("E_") ? "contract" : "orchestrator",
27
+ runId: event.runId ?? null,
28
+ sessionId: event.sessionId ?? null,
29
+ phase: event.phase,
30
+ dispatchId: event.dispatchId ?? null,
31
+ taskId: event.taskId ?? null,
32
+ agent: event.agent ?? null,
33
+ type:
34
+ event.action === "dispatch"
35
+ ? "dispatch"
36
+ : event.action === "dispatch_multi"
37
+ ? "dispatch_multi"
38
+ : event.action === "complete"
39
+ ? "complete"
40
+ : event.action === "loop_detected"
41
+ ? "loop_detected"
42
+ : event.action === "error" && event.code?.startsWith("E_")
43
+ ? "warning"
44
+ : "error",
45
+ code: event.code ?? null,
46
+ message: event.message ?? null,
47
+ payload: {
48
+ action: event.action,
49
+ ...(event.promptLength !== undefined ? { promptLength: event.promptLength } : {}),
50
+ ...(event.attempt !== undefined ? { attempt: event.attempt } : {}),
51
+ ...(event.payload ?? {}),
52
+ },
53
+ });
40
54
  }
@@ -1,3 +1,4 @@
1
+ import { patchState } from "./state";
1
2
  import type { Phase, PhaseStatus, PipelineState } from "./types";
2
3
 
3
4
  /** Maps each phase to its 1-based position for user-facing progress display. */
@@ -71,12 +72,15 @@ export function completePhase(state: Readonly<PipelineState>): PipelineState {
71
72
  return phase;
72
73
  });
73
74
 
74
- return {
75
- ...state,
75
+ return patchState(state, {
76
+ status: nextPhase === null ? "COMPLETED" : state.status,
76
77
  currentPhase: nextPhase,
77
78
  phases: updatedPhases,
78
- lastUpdatedAt: new Date().toISOString(),
79
- };
79
+ pendingDispatches:
80
+ nextPhase === null
81
+ ? []
82
+ : state.pendingDispatches.filter((entry) => entry.phase === nextPhase),
83
+ });
80
84
  }
81
85
 
82
86
  /**
@@ -1,12 +1,15 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { loadLatestPipelineStateFromKernel, savePipelineStateToKernel } from "../kernel/repository";
5
+ import { KERNEL_STATE_CONFLICT_CODE } from "../kernel/types";
4
6
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
5
7
  import { assertStateInvariants } from "./contracts/invariants";
6
8
  import { PHASES, pipelineStateSchema } from "./schemas";
7
9
  import type { PipelineState } from "./types";
8
10
 
9
11
  const STATE_FILE = "state.json";
12
+ let legacyStateMirrorWarned = false;
10
13
 
11
14
  function generateRunId(): string {
12
15
  return `run_${randomBytes(8).toString("hex")}`;
@@ -37,12 +40,14 @@ export function createInitialState(idea: string): PipelineState {
37
40
  });
38
41
  }
39
42
 
40
- export async function loadState(artifactDir: string): Promise<PipelineState | null> {
43
+ async function loadLegacyState(artifactDir: string): Promise<PipelineState | null> {
41
44
  const statePath = join(artifactDir, STATE_FILE);
42
45
  try {
43
46
  const raw = await readFile(statePath, "utf-8");
44
47
  const parsed = JSON.parse(raw);
45
- return pipelineStateSchema.parse(parsed);
48
+ const validated = pipelineStateSchema.parse(parsed);
49
+ assertStateInvariants(validated);
50
+ return validated;
46
51
  } catch (error: unknown) {
47
52
  if (isEnoentError(error)) {
48
53
  return null;
@@ -51,28 +56,85 @@ export async function loadState(artifactDir: string): Promise<PipelineState | nu
51
56
  }
52
57
  }
53
58
 
59
+ async function writeLegacyStateMirror(state: PipelineState, artifactDir: string): Promise<void> {
60
+ await ensureDir(artifactDir);
61
+ const statePath = join(artifactDir, STATE_FILE);
62
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
63
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
64
+ await rename(tmpPath, statePath);
65
+ }
66
+
67
+ async function syncLegacyStateMirror(state: PipelineState, artifactDir: string): Promise<void> {
68
+ try {
69
+ await writeLegacyStateMirror(state, artifactDir);
70
+ } catch (error: unknown) {
71
+ if (!legacyStateMirrorWarned) {
72
+ legacyStateMirrorWarned = true;
73
+ console.warn("[opencode-autopilot] state.json mirror write failed:", error);
74
+ }
75
+ }
76
+ }
77
+
78
+ export async function loadState(artifactDir: string): Promise<PipelineState | null> {
79
+ const kernelState = loadLatestPipelineStateFromKernel(artifactDir);
80
+ if (kernelState !== null) {
81
+ return kernelState;
82
+ }
83
+
84
+ const legacyState = await loadLegacyState(artifactDir);
85
+ if (legacyState === null) {
86
+ return null;
87
+ }
88
+
89
+ savePipelineStateToKernel(artifactDir, legacyState);
90
+ return legacyState;
91
+ }
92
+
54
93
  export async function saveState(
55
94
  state: PipelineState,
56
95
  artifactDir: string,
57
96
  expectedRevision?: number,
58
97
  ): Promise<void> {
59
- if (typeof expectedRevision === "number") {
60
- const current = await loadState(artifactDir);
61
- const currentRevision = current?.stateRevision ?? -1;
62
- if (currentRevision !== expectedRevision) {
63
- throw new Error(
64
- `E_STATE_CONFLICT: expected stateRevision ${expectedRevision}, found ${currentRevision}`,
65
- );
66
- }
67
- }
68
-
69
98
  const validated = pipelineStateSchema.parse(state);
70
99
  assertStateInvariants(validated);
71
- await ensureDir(artifactDir);
72
- const statePath = join(artifactDir, STATE_FILE);
73
- const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
74
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
75
- await rename(tmpPath, statePath);
100
+ savePipelineStateToKernel(artifactDir, validated, expectedRevision);
101
+ await syncLegacyStateMirror(validated, artifactDir);
102
+ }
103
+
104
+ export function isStateConflictError(error: unknown): boolean {
105
+ return error instanceof Error && error.message.startsWith(`${KERNEL_STATE_CONFLICT_CODE}:`);
106
+ }
107
+
108
+ export async function updatePersistedState(
109
+ artifactDir: string,
110
+ state: Readonly<PipelineState>,
111
+ transform: (current: Readonly<PipelineState>) => PipelineState,
112
+ options?: { readonly maxConflicts?: number },
113
+ ): Promise<PipelineState> {
114
+ const maxConflicts = options?.maxConflicts ?? 2;
115
+ let currentState = state;
116
+
117
+ for (let attempt = 0; ; attempt += 1) {
118
+ const nextState = transform(currentState);
119
+ if (nextState === currentState) {
120
+ return currentState;
121
+ }
122
+
123
+ try {
124
+ await saveState(nextState, artifactDir, currentState.stateRevision);
125
+ return nextState;
126
+ } catch (error: unknown) {
127
+ if (!isStateConflictError(error) || attempt >= maxConflicts) {
128
+ throw error;
129
+ }
130
+
131
+ const latestState = await loadState(artifactDir);
132
+ if (latestState === null) {
133
+ throw new Error(`${KERNEL_STATE_CONFLICT_CODE}: state disappeared during update`);
134
+ }
135
+ currentState = latestState;
136
+ }
137
+ }
76
138
  }
77
139
 
78
140
  export function patchState(
@@ -0,0 +1,47 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export const PROJECT_REGISTRY_STATEMENTS: readonly string[] = Object.freeze([
4
+ `CREATE TABLE IF NOT EXISTS projects (
5
+ id TEXT PRIMARY KEY,
6
+ path TEXT NOT NULL UNIQUE,
7
+ name TEXT NOT NULL,
8
+ first_seen_at TEXT NOT NULL,
9
+ last_updated TEXT NOT NULL
10
+ )`,
11
+ `CREATE TABLE IF NOT EXISTS project_paths (
12
+ project_id TEXT NOT NULL,
13
+ path TEXT NOT NULL,
14
+ first_seen_at TEXT NOT NULL,
15
+ last_updated TEXT NOT NULL,
16
+ is_current INTEGER NOT NULL CHECK(is_current IN (0, 1)),
17
+ PRIMARY KEY (project_id, path),
18
+ UNIQUE(path),
19
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
20
+ )`,
21
+ `CREATE INDEX IF NOT EXISTS idx_project_paths_project ON project_paths(project_id, is_current DESC, path)`,
22
+ `CREATE TABLE IF NOT EXISTS project_git_fingerprints (
23
+ project_id TEXT NOT NULL,
24
+ normalized_remote_url TEXT NOT NULL,
25
+ default_branch TEXT,
26
+ first_seen_at TEXT NOT NULL,
27
+ last_updated TEXT NOT NULL,
28
+ PRIMARY KEY (project_id, normalized_remote_url),
29
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
30
+ )`,
31
+ `CREATE INDEX IF NOT EXISTS idx_project_git_remote ON project_git_fingerprints(normalized_remote_url, last_updated DESC)`,
32
+ ]);
33
+
34
+ export function runProjectRegistryMigrations(database: Database): void {
35
+ for (const statement of PROJECT_REGISTRY_STATEMENTS) {
36
+ database.run(statement);
37
+ }
38
+
39
+ const projectsInfo = database.query("PRAGMA table_info(projects)").all() as Array<{
40
+ name?: string;
41
+ }>;
42
+ const hasFirstSeenAt = projectsInfo.some((column) => column.name === "first_seen_at");
43
+ if (!hasFirstSeenAt) {
44
+ database.run("ALTER TABLE projects ADD COLUMN first_seen_at TEXT");
45
+ database.run("UPDATE projects SET first_seen_at = COALESCE(first_seen_at, last_updated)");
46
+ }
47
+ }