@kodrunhq/opencode-autopilot 1.15.2 → 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.
- package/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- package/src/health/checks.ts +29 -4
- package/src/index.ts +103 -11
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/types.ts +66 -0
- package/src/memory/capture.ts +221 -25
- package/src/memory/database.ts +74 -12
- package/src/memory/index.ts +17 -1
- package/src/memory/project-key.ts +6 -0
- package/src/memory/repository.ts +833 -42
- package/src/memory/retrieval.ts +83 -169
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/event-handlers.ts +28 -17
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +159 -0
- package/src/observability/forensic-schemas.ts +69 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +142 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build.ts +55 -97
- package/src/orchestrator/handlers/retrospective.ts +2 -1
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +29 -9
- package/src/orchestrator/orchestration-logger.ts +37 -23
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +29 -9
- package/src/tools/doctor.ts +26 -2
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +6 -5
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +97 -81
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/review.ts +39 -6
- package/src/tools/session-stats.ts +3 -2
- 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 ===
|
|
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: "
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
const hasTypedContext = context !== undefined && !context.legacy;
|
|
306
|
+
const hasTypedContext = context !== undefined;
|
|
292
307
|
const isTaskCompletion = hasTypedContext && context.envelope.kind === "task_completion";
|
|
293
|
-
const
|
|
294
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 ->
|
|
470
|
-
const
|
|
471
|
-
const
|
|
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: "
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
442
|
+
resultKind: "task_completion",
|
|
443
|
+
taskId: task.id,
|
|
444
|
+
progress: `Wave ${currentWave} — task ${task.id}`,
|
|
487
445
|
_stateUpdates: {
|
|
488
|
-
tasks: [...markTasksInProgress(effectiveTasks,
|
|
446
|
+
tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
|
|
489
447
|
buildProgress: {
|
|
490
448
|
...buildProgress,
|
|
491
|
-
currentTask:
|
|
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 =
|
|
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];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-project lesson memory persistence.
|
|
3
3
|
*
|
|
4
|
-
* Stores lessons extracted from pipeline runs
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
75
|
+
return patchState(state, {
|
|
76
|
+
status: nextPhase === null ? "COMPLETED" : state.status,
|
|
76
77
|
currentPhase: nextPhase,
|
|
77
78
|
phases: updatedPhases,
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|