@kodrunhq/opencode-autopilot 1.14.1 → 1.15.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.
@@ -1,14 +1,17 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { join } from "node:path";
2
3
  import { tool } from "@opencode-ai/plugin";
4
+ import { parseResultEnvelope } from "../orchestrator/contracts/legacy-result-adapter";
5
+ import type { ResultEnvelope } from "../orchestrator/contracts/result-envelope";
3
6
  import { PHASE_HANDLERS } from "../orchestrator/handlers/index";
4
- import type { DispatchResult } from "../orchestrator/handlers/types";
7
+ import type { DispatchResult, PhaseHandlerContext } from "../orchestrator/handlers/types";
5
8
  import { buildLessonContext } from "../orchestrator/lesson-injection";
6
9
  import { loadLessonMemory } from "../orchestrator/lesson-memory";
7
10
  import { logOrchestrationEvent } from "../orchestrator/orchestration-logger";
8
11
  import { completePhase, getNextPhase, PHASE_INDEX, TOTAL_PHASES } from "../orchestrator/phase";
9
12
  import { loadAdaptiveSkillContext } from "../orchestrator/skill-injection";
10
13
  import { createInitialState, loadState, patchState, saveState } from "../orchestrator/state";
11
- import type { Phase, PipelineState } from "../orchestrator/types";
14
+ import type { PendingDispatch, Phase, PipelineState } from "../orchestrator/types";
12
15
  import { isEnoentError } from "../utils/fs-helpers";
13
16
  import { ensureGitignore } from "../utils/gitignore";
14
17
  import { getGlobalConfigDir, getProjectArtifactDir } from "../utils/paths";
@@ -19,6 +22,180 @@ interface OrchestrateArgs {
19
22
  readonly result?: string;
20
23
  }
21
24
 
25
+ const ORCHESTRATE_ERROR_CODES = Object.freeze({
26
+ INVALID_RESULT: "E_INVALID_RESULT",
27
+ STALE_RESULT: "E_STALE_RESULT",
28
+ PHASE_MISMATCH: "E_PHASE_MISMATCH",
29
+ UNKNOWN_DISPATCH: "E_UNKNOWN_DISPATCH",
30
+ DUPLICATE_RESULT: "E_DUPLICATE_RESULT",
31
+ });
32
+
33
+ function createDispatchId(): string {
34
+ return `dispatch_${randomBytes(6).toString("hex")}`;
35
+ }
36
+
37
+ function findPendingDispatch(
38
+ state: Readonly<PipelineState>,
39
+ dispatchId: string,
40
+ ): PendingDispatch | null {
41
+ return state.pendingDispatches.find((entry) => entry.dispatchId === dispatchId) ?? null;
42
+ }
43
+
44
+ function withPendingDispatch(
45
+ state: Readonly<PipelineState>,
46
+ entry: PendingDispatch,
47
+ ): PipelineState {
48
+ return patchState(state, {
49
+ pendingDispatches: [...state.pendingDispatches, entry],
50
+ });
51
+ }
52
+
53
+ function removePendingDispatch(state: Readonly<PipelineState>, dispatchId: string): PipelineState {
54
+ return patchState(state, {
55
+ pendingDispatches: state.pendingDispatches.filter((entry) => entry.dispatchId !== dispatchId),
56
+ });
57
+ }
58
+
59
+ function markResultProcessed(
60
+ state: Readonly<PipelineState>,
61
+ envelope: ResultEnvelope,
62
+ ): PipelineState {
63
+ if (state.processedResultIds.includes(envelope.resultId)) {
64
+ return state;
65
+ }
66
+ const capped = [...state.processedResultIds, envelope.resultId];
67
+ const nextIds = capped.length > 5000 ? capped.slice(capped.length - 5000) : capped;
68
+ return patchState(state, {
69
+ processedResultIds: nextIds,
70
+ });
71
+ }
72
+
73
+ function asErrorJson(code: string, message: string): string {
74
+ return JSON.stringify({ action: "error", code, message });
75
+ }
76
+
77
+ function logDeterministicError(
78
+ artifactDir: string,
79
+ phase: string,
80
+ code: string,
81
+ message: string,
82
+ ): void {
83
+ logOrchestrationEvent(artifactDir, {
84
+ timestamp: new Date().toISOString(),
85
+ phase,
86
+ action: "error",
87
+ message: `${code}: ${message}`.slice(0, 500),
88
+ });
89
+ }
90
+
91
+ function inferExpectedResultKindForAgent(
92
+ agent?: string,
93
+ ): "phase_output" | "task_completion" | "review_findings" {
94
+ if (agent === "oc-reviewer") {
95
+ return "review_findings";
96
+ }
97
+ if (agent === "oc-implementer") {
98
+ return "task_completion";
99
+ }
100
+ return "phase_output";
101
+ }
102
+
103
+ function ensureDispatchIdentity(
104
+ handlerResult: DispatchResult,
105
+ state: Readonly<PipelineState>,
106
+ ): DispatchResult {
107
+ if (handlerResult.action === "dispatch") {
108
+ const dispatchId = handlerResult.dispatchId ?? createDispatchId();
109
+ return {
110
+ ...handlerResult,
111
+ dispatchId,
112
+ runId: state.runId,
113
+ expectedResultKind:
114
+ handlerResult.expectedResultKind ??
115
+ handlerResult.resultKind ??
116
+ inferExpectedResultKindForAgent(handlerResult.agent),
117
+ };
118
+ }
119
+
120
+ if (handlerResult.action === "dispatch_multi") {
121
+ return {
122
+ ...handlerResult,
123
+ runId: state.runId,
124
+ agents: handlerResult.agents?.map((entry) => ({
125
+ ...entry,
126
+ dispatchId: entry.dispatchId ?? createDispatchId(),
127
+ resultKind: entry.resultKind ?? inferExpectedResultKindForAgent(entry.agent),
128
+ })),
129
+ };
130
+ }
131
+
132
+ return handlerResult;
133
+ }
134
+
135
+ function detectPhaseFromPending(state: Readonly<PipelineState>): Phase | null {
136
+ const last = state.pendingDispatches.at(-1);
137
+ return (last?.phase as Phase | undefined) ?? state.currentPhase;
138
+ }
139
+
140
+ function detectAgentFromPending(state: Readonly<PipelineState>): string | null {
141
+ const last = state.pendingDispatches.at(-1);
142
+ return last?.agent ?? null;
143
+ }
144
+
145
+ function detectDispatchFromPending(state: Readonly<PipelineState>): string {
146
+ const last = state.pendingDispatches.at(-1);
147
+ return last?.dispatchId ?? "legacy-dispatch";
148
+ }
149
+
150
+ function applyResultEnvelope(
151
+ state: Readonly<PipelineState>,
152
+ envelope: ResultEnvelope,
153
+ options?: { readonly allowMissingPending?: boolean },
154
+ ): PipelineState {
155
+ if (state.processedResultIds.includes(envelope.resultId)) {
156
+ throw new Error(
157
+ `${ORCHESTRATE_ERROR_CODES.DUPLICATE_RESULT}: duplicate resultId ${envelope.resultId}`,
158
+ );
159
+ }
160
+
161
+ const pending = findPendingDispatch(state, envelope.dispatchId);
162
+ if (pending === null) {
163
+ if (options?.allowMissingPending) {
164
+ return markResultProcessed(state, envelope);
165
+ }
166
+ throw new Error(
167
+ `${ORCHESTRATE_ERROR_CODES.UNKNOWN_DISPATCH}: unknown dispatchId ${envelope.dispatchId}`,
168
+ );
169
+ }
170
+ if (pending.phase !== envelope.phase) {
171
+ throw new Error(
172
+ `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: result phase ${envelope.phase} != pending ${pending.phase}`,
173
+ );
174
+ }
175
+ if (pending.taskId !== null && envelope.taskId !== pending.taskId) {
176
+ throw new Error(
177
+ `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: taskId ${String(envelope.taskId)} != pending ${pending.taskId}`,
178
+ );
179
+ }
180
+
181
+ const withoutPending = removePendingDispatch(state, envelope.dispatchId);
182
+ return markResultProcessed(withoutPending, envelope);
183
+ }
184
+
185
+ function parseErrorCode(error: unknown): { readonly code: string; readonly message: string } {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ const idx = message.indexOf(":");
188
+ if (idx <= 0) {
189
+ return { code: ORCHESTRATE_ERROR_CODES.INVALID_RESULT, message };
190
+ }
191
+ const maybeCode = message.slice(0, idx);
192
+ const rest = message.slice(idx + 1).trim();
193
+ if (!maybeCode.startsWith("E_")) {
194
+ return { code: ORCHESTRATE_ERROR_CODES.INVALID_RESULT, message };
195
+ }
196
+ return { code: maybeCode, message: rest.length > 0 ? rest : message };
197
+ }
198
+
22
199
  /**
23
200
  * Apply state updates from a DispatchResult if present, then save.
24
201
  * Returns the updated state.
@@ -148,7 +325,11 @@ async function checkCircuitBreaker(
148
325
  currentState: Readonly<PipelineState>,
149
326
  phase: string,
150
327
  artifactDir: string,
151
- ): Promise<{ readonly abortMsg: string | null; readonly newCount: number }> {
328
+ ): Promise<{
329
+ readonly abortMsg: string | null;
330
+ readonly newCount: number;
331
+ readonly nextState: PipelineState;
332
+ }> {
152
333
  const counts = { ...(currentState.phaseDispatchCounts ?? {}) };
153
334
  counts[phase] = (counts[phase] ?? 0) + 1;
154
335
  const newCount = counts[phase];
@@ -162,11 +343,15 @@ async function checkCircuitBreaker(
162
343
  attempt: newCount,
163
344
  message: msg,
164
345
  });
165
- return { abortMsg: JSON.stringify({ action: "error", message: msg }), newCount };
346
+ return {
347
+ abortMsg: JSON.stringify({ action: "error", message: msg }),
348
+ newCount,
349
+ nextState: currentState,
350
+ };
166
351
  }
167
352
  const withCounts = patchState(currentState, { phaseDispatchCounts: counts });
168
353
  await saveState(withCounts, artifactDir);
169
- return { abortMsg: null, newCount };
354
+ return { abortMsg: null, newCount, nextState: withCounts };
170
355
  }
171
356
 
172
357
  /**
@@ -178,48 +363,95 @@ async function processHandlerResult(
178
363
  state: Readonly<PipelineState>,
179
364
  artifactDir: string,
180
365
  ): Promise<string> {
366
+ const normalizedResult = ensureDispatchIdentity(handlerResult, state);
367
+
181
368
  // Apply state updates from handler if present
182
- const currentState = await applyStateUpdates(state, handlerResult, artifactDir);
369
+ let currentState = await applyStateUpdates(state, normalizedResult, artifactDir);
183
370
 
184
- switch (handlerResult.action) {
185
- case "error":
371
+ switch (normalizedResult.action) {
372
+ case "error": {
373
+ const codePrefix = normalizedResult.code ? `${normalizedResult.code}: ` : "";
374
+ const messageBody = normalizedResult.message ?? "Handler returned error";
186
375
  logOrchestrationEvent(artifactDir, {
187
376
  timestamp: new Date().toISOString(),
188
- phase: handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN",
377
+ phase: normalizedResult.phase ?? currentState.currentPhase ?? "UNKNOWN",
189
378
  action: "error",
190
- message: handlerResult.message?.slice(0, 500),
379
+ message: `${codePrefix}${messageBody}`.slice(0, 500),
191
380
  });
192
- return JSON.stringify(handlerResult);
381
+ return JSON.stringify(normalizedResult);
382
+ }
193
383
 
194
384
  case "dispatch": {
195
385
  // Circuit breaker
196
- const phase = handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
197
- const { abortMsg, newCount: attempt } = await checkCircuitBreaker(
198
- currentState,
199
- phase,
200
- artifactDir,
201
- );
386
+ const phase = normalizedResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
387
+ const {
388
+ abortMsg,
389
+ newCount: attempt,
390
+ nextState,
391
+ } = await checkCircuitBreaker(currentState, phase, artifactDir);
202
392
  if (abortMsg) return abortMsg;
393
+ currentState = nextState;
394
+
395
+ const pendingEntry: PendingDispatch = {
396
+ dispatchId: normalizedResult.dispatchId ?? createDispatchId(),
397
+ phase: phase as Phase,
398
+ agent: normalizedResult.agent ?? "unknown",
399
+ issuedAt: new Date().toISOString(),
400
+ resultKind: normalizedResult.expectedResultKind ?? "phase_output",
401
+ taskId: normalizedResult.taskId ?? null,
402
+ };
403
+ const baseRevision = currentState.stateRevision;
404
+ const withPendingState = withPendingDispatch(currentState, pendingEntry);
203
405
 
204
406
  // Log the dispatch event before any inline-review or context injection
205
- const progress = buildUserProgress(phase, handlerResult.progress, attempt);
407
+ const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
206
408
  logOrchestrationEvent(artifactDir, {
207
409
  timestamp: new Date().toISOString(),
208
410
  phase,
209
411
  action: "dispatch",
210
- agent: handlerResult.agent,
211
- promptLength: handlerResult.prompt?.length,
412
+ agent: normalizedResult.agent,
413
+ promptLength: normalizedResult.prompt?.length,
212
414
  attempt,
213
415
  });
214
416
 
215
417
  // Check if this is a review dispatch that should be inlined
216
- const { inlined, reviewResult } = await maybeInlineReview(handlerResult, artifactDir);
418
+ const { inlined, reviewResult } = await maybeInlineReview(normalizedResult, artifactDir);
217
419
  if (inlined && reviewResult) {
218
- const reloadedState = await loadState(artifactDir);
219
- if (reloadedState?.currentPhase) {
220
- const handler = PHASE_HANDLERS[reloadedState.currentPhase];
221
- const nextResult = await handler(reloadedState, artifactDir, reviewResult);
222
- return processHandlerResult(nextResult, reloadedState, artifactDir);
420
+ if (withPendingState.currentPhase) {
421
+ let reviewPayloadText = reviewResult;
422
+ try {
423
+ const parsedReview = JSON.parse(reviewResult) as {
424
+ findingsEnvelope?: unknown;
425
+ };
426
+ if (parsedReview.findingsEnvelope) {
427
+ reviewPayloadText = JSON.stringify(parsedReview.findingsEnvelope);
428
+ }
429
+ } catch {
430
+ // keep raw review payload for legacy parser
431
+ }
432
+
433
+ const inlinedEnvelope: ResultEnvelope = {
434
+ schemaVersion: 1,
435
+ resultId: `inline-${createDispatchId()}`,
436
+ runId: withPendingState.runId,
437
+ phase: withPendingState.currentPhase,
438
+ dispatchId: pendingEntry.dispatchId,
439
+ agent: normalizedResult.agent ?? null,
440
+ kind: "review_findings",
441
+ taskId: normalizedResult.taskId ?? null,
442
+ payload: {
443
+ text: reviewPayloadText,
444
+ },
445
+ };
446
+ const withInlineResult = applyResultEnvelope(withPendingState, inlinedEnvelope);
447
+ await saveState(withInlineResult, artifactDir, baseRevision);
448
+
449
+ const handler = PHASE_HANDLERS[withPendingState.currentPhase];
450
+ const nextResult = await handler(withInlineResult, artifactDir, reviewPayloadText, {
451
+ envelope: inlinedEnvelope,
452
+ legacy: false,
453
+ });
454
+ return processHandlerResult(nextResult, withInlineResult, artifactDir);
223
455
  }
224
456
  // State unavailable or pipeline completed after inline review — return complete
225
457
  return JSON.stringify({
@@ -228,67 +460,105 @@ async function processHandlerResult(
228
460
  _userProgress: progress,
229
461
  });
230
462
  }
463
+
464
+ await saveState(withPendingState, artifactDir, baseRevision);
465
+ currentState = withPendingState;
466
+
231
467
  // Inject lesson + skill context into dispatch prompt (best-effort)
232
- if (handlerResult.prompt && handlerResult.phase) {
468
+ if (normalizedResult.prompt && normalizedResult.phase) {
233
469
  const enrichedPrompt = await injectLessonContext(
234
- handlerResult.prompt,
235
- handlerResult.phase,
470
+ normalizedResult.prompt,
471
+ normalizedResult.phase,
236
472
  artifactDir,
237
473
  );
238
474
  const withSkills = await injectSkillContext(
239
475
  enrichedPrompt,
240
476
  join(artifactDir, ".."),
241
- handlerResult.phase,
477
+ normalizedResult.phase,
242
478
  );
243
- if (withSkills !== handlerResult.prompt) {
244
- return JSON.stringify({ ...handlerResult, prompt: withSkills, _userProgress: progress });
479
+ if (withSkills !== normalizedResult.prompt) {
480
+ return JSON.stringify({
481
+ ...normalizedResult,
482
+ prompt: withSkills,
483
+ dispatchId: pendingEntry.dispatchId,
484
+ runId: currentState.runId,
485
+ _userProgress: progress,
486
+ });
245
487
  }
246
488
  }
247
- return JSON.stringify({ ...handlerResult, _userProgress: progress });
489
+ return JSON.stringify({
490
+ ...normalizedResult,
491
+ dispatchId: pendingEntry.dispatchId,
492
+ runId: currentState.runId,
493
+ _userProgress: progress,
494
+ });
248
495
  }
249
496
 
250
497
  case "dispatch_multi": {
251
498
  // Circuit breaker
252
- const phase = handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
253
- const { abortMsg, newCount: attempt } = await checkCircuitBreaker(
254
- currentState,
255
- phase,
256
- artifactDir,
257
- );
499
+ const phase = normalizedResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
500
+ const {
501
+ abortMsg,
502
+ newCount: attempt,
503
+ nextState,
504
+ } = await checkCircuitBreaker(currentState, phase, artifactDir);
258
505
  if (abortMsg) return abortMsg;
506
+ currentState = nextState;
507
+
508
+ const pendingEntries: readonly PendingDispatch[] =
509
+ normalizedResult.agents?.map((entry) => ({
510
+ dispatchId: entry.dispatchId ?? createDispatchId(),
511
+ phase: phase as Phase,
512
+ agent: entry.agent,
513
+ issuedAt: new Date().toISOString(),
514
+ resultKind: entry.resultKind ?? inferExpectedResultKindForAgent(entry.agent),
515
+ taskId: entry.taskId ?? null,
516
+ })) ?? [];
517
+ const baseRevision = currentState.stateRevision;
518
+ let withPendingState = currentState;
519
+ for (const entry of pendingEntries) {
520
+ withPendingState = withPendingDispatch(withPendingState, entry);
521
+ }
259
522
 
260
- const progress = buildUserProgress(phase, handlerResult.progress, attempt);
523
+ const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
261
524
  logOrchestrationEvent(artifactDir, {
262
525
  timestamp: new Date().toISOString(),
263
526
  phase,
264
527
  action: "dispatch_multi",
265
- agent: `${handlerResult.agents?.length ?? 0} agents`,
528
+ agent: `${normalizedResult.agents?.length ?? 0} agents`,
266
529
  attempt,
267
530
  });
531
+ await saveState(withPendingState, artifactDir, baseRevision);
532
+ currentState = withPendingState;
268
533
 
269
534
  // Inject lesson + skill context into each agent's prompt (best-effort)
270
535
  // Load lesson and skill context once and reuse for all agents in the batch
271
- if (handlerResult.agents && handlerResult.phase) {
272
- const lessonSuffix = await injectLessonContext("", handlerResult.phase, artifactDir);
536
+ if (normalizedResult.agents && normalizedResult.phase) {
537
+ const lessonSuffix = await injectLessonContext("", normalizedResult.phase, artifactDir);
273
538
  const skillSuffix = await injectSkillContext(
274
539
  "",
275
540
  join(artifactDir, ".."),
276
- handlerResult.phase,
541
+ normalizedResult.phase,
277
542
  );
278
543
  const combinedSuffix = lessonSuffix + (skillSuffix || "");
279
544
  if (combinedSuffix) {
280
- const enrichedAgents = handlerResult.agents.map((entry) => ({
545
+ const enrichedAgents = normalizedResult.agents.map((entry) => ({
281
546
  ...entry,
282
547
  prompt: entry.prompt + combinedSuffix,
283
548
  }));
284
549
  return JSON.stringify({
285
- ...handlerResult,
550
+ ...normalizedResult,
286
551
  agents: enrichedAgents,
552
+ runId: currentState.runId,
287
553
  _userProgress: progress,
288
554
  });
289
555
  }
290
556
  }
291
- return JSON.stringify({ ...handlerResult, _userProgress: progress });
557
+ return JSON.stringify({
558
+ ...normalizedResult,
559
+ runId: currentState.runId,
560
+ _userProgress: progress,
561
+ });
292
562
  }
293
563
 
294
564
  case "complete": {
@@ -306,7 +576,6 @@ async function processHandlerResult(
306
576
  });
307
577
  const nextPhase = getNextPhase(currentState.currentPhase);
308
578
  const advanced = completePhase(currentState);
309
- await saveState(advanced, artifactDir);
310
579
 
311
580
  if (nextPhase === null) {
312
581
  // Terminal phase completed
@@ -320,9 +589,11 @@ async function processHandlerResult(
320
589
  });
321
590
  }
322
591
 
592
+ await saveState(advanced, artifactDir);
593
+
323
594
  // Invoke the next phase handler immediately
324
595
  const nextHandler = PHASE_HANDLERS[nextPhase];
325
- const nextResult = await nextHandler(advanced, artifactDir);
596
+ const nextResult = await nextHandler(advanced, artifactDir, undefined, undefined);
326
597
  return processHandlerResult(nextResult, advanced, artifactDir);
327
598
  }
328
599
 
@@ -336,7 +607,7 @@ async function processHandlerResult(
336
607
 
337
608
  export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string): Promise<string> {
338
609
  try {
339
- const state = await loadState(artifactDir);
610
+ let state = await loadState(artifactDir);
340
611
 
341
612
  // No state and no idea -> error
342
613
  if (state === null && !args.idea) {
@@ -360,13 +631,15 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
360
631
  }
361
632
 
362
633
  const handler = PHASE_HANDLERS[newState.currentPhase as Phase];
363
- const handlerResult = await handler(newState, artifactDir);
634
+ const handlerResult = await handler(newState, artifactDir, undefined, undefined);
364
635
  return processHandlerResult(handlerResult, newState, artifactDir);
365
636
  }
366
637
 
367
638
  // State exists
368
639
  if (state !== null) {
369
- // Pipeline already completed
640
+ let phaseHandlerContext: PhaseHandlerContext | undefined;
641
+ let handlerInputResult = args.result;
642
+
370
643
  if (state.currentPhase === null) {
371
644
  return JSON.stringify({
372
645
  action: "complete",
@@ -374,15 +647,106 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
374
647
  });
375
648
  }
376
649
 
650
+ if (typeof args.result === "string") {
651
+ const phaseHint = detectPhaseFromPending(state);
652
+ if (phaseHint === null) {
653
+ const msg = "Received result but no pending dispatch exists.";
654
+ logDeterministicError(
655
+ artifactDir,
656
+ state.currentPhase ?? "UNKNOWN",
657
+ ORCHESTRATE_ERROR_CODES.STALE_RESULT,
658
+ msg,
659
+ );
660
+ return asErrorJson(ORCHESTRATE_ERROR_CODES.STALE_RESULT, msg);
661
+ }
662
+
663
+ try {
664
+ const parsed = parseResultEnvelope(args.result, {
665
+ runId: state.runId,
666
+ phase: phaseHint,
667
+ fallbackDispatchId: detectDispatchFromPending(state),
668
+ fallbackAgent: detectAgentFromPending(state),
669
+ });
670
+
671
+ if (parsed.envelope.runId !== state.runId) {
672
+ const msg = `Result runId ${parsed.envelope.runId} does not match active run ${state.runId}.`;
673
+ logDeterministicError(
674
+ artifactDir,
675
+ state.currentPhase ?? phaseHint,
676
+ ORCHESTRATE_ERROR_CODES.STALE_RESULT,
677
+ msg,
678
+ );
679
+ return asErrorJson(ORCHESTRATE_ERROR_CODES.STALE_RESULT, msg);
680
+ }
681
+
682
+ if (parsed.legacy && state.pendingDispatches.length > 1) {
683
+ const msg =
684
+ "Legacy result payload cannot be attributed with multiple pending dispatches. Provide typed envelope with dispatchId/taskId.";
685
+ logDeterministicError(
686
+ artifactDir,
687
+ state.currentPhase ?? phaseHint,
688
+ ORCHESTRATE_ERROR_CODES.INVALID_RESULT,
689
+ msg,
690
+ );
691
+ return asErrorJson(ORCHESTRATE_ERROR_CODES.INVALID_RESULT, msg);
692
+ }
693
+
694
+ const allowMissingPending = parsed.legacy && state.pendingDispatches.length === 0;
695
+ const nextState = applyResultEnvelope(state, parsed.envelope, {
696
+ allowMissingPending,
697
+ });
698
+ await saveState(nextState, artifactDir);
699
+ state = nextState;
700
+ if (!allowMissingPending && parsed.legacy) {
701
+ const legacyMsg =
702
+ "Legacy result parser path used. Submit typed envelopes for deterministic replay guarantees.";
703
+ logOrchestrationEvent(artifactDir, {
704
+ timestamp: new Date().toISOString(),
705
+ phase: state.currentPhase ?? phaseHint,
706
+ action: "error",
707
+ message: legacyMsg,
708
+ });
709
+ console.warn(`[opencode-autopilot] ${legacyMsg}`);
710
+ }
711
+
712
+ phaseHandlerContext = {
713
+ envelope: parsed.envelope,
714
+ legacy: parsed.legacy,
715
+ };
716
+ handlerInputResult = parsed.envelope.payload.text;
717
+ } catch (error: unknown) {
718
+ const parsedErr = parseErrorCode(error);
719
+ logOrchestrationEvent(artifactDir, {
720
+ timestamp: new Date().toISOString(),
721
+ phase: state.currentPhase ?? "UNKNOWN",
722
+ action: "error",
723
+ message: `${parsedErr.code}: ${parsedErr.message}`,
724
+ });
725
+ return asErrorJson(parsedErr.code, parsedErr.message);
726
+ }
727
+ }
728
+
377
729
  // Delegate to current phase handler
730
+ if (state.currentPhase === null) {
731
+ return JSON.stringify({
732
+ action: "complete",
733
+ summary: `Pipeline already completed. Idea: ${state.idea}`,
734
+ });
735
+ }
378
736
  const handler = PHASE_HANDLERS[state.currentPhase];
379
- const handlerResult = await handler(state, artifactDir, args.result);
737
+ const handlerResult = await handler(
738
+ state,
739
+ artifactDir,
740
+ handlerInputResult,
741
+ phaseHandlerContext,
742
+ );
380
743
  return processHandlerResult(handlerResult, state, artifactDir);
381
744
  }
382
745
 
383
746
  return JSON.stringify({ action: "error", message: "Unexpected state" });
384
747
  } catch (error: unknown) {
385
748
  const message = error instanceof Error ? error.message : String(error);
749
+ const parsedErr = parseErrorCode(error);
386
750
  const safeMessage = message.replace(/[/\\][^\s"']+/g, "[PATH]").slice(0, 4096);
387
751
 
388
752
  // Persist failure metadata for forensics (best-effort)
@@ -407,7 +771,7 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
407
771
  // Swallow save errors -- original error takes priority
408
772
  }
409
773
 
410
- return JSON.stringify({ action: "error", message: safeMessage });
774
+ return JSON.stringify({ action: "error", code: parsedErr.code, message: safeMessage });
411
775
  }
412
776
  }
413
777
 
@@ -49,6 +49,8 @@ export async function quickCore(args: QuickArgs, artifactDir: string): Promise<s
49
49
  const quickState = pipelineStateSchema.parse({
50
50
  schemaVersion: 2,
51
51
  status: "IN_PROGRESS",
52
+ runId: `quick-${Date.now()}`,
53
+ stateRevision: 0,
52
54
  idea: args.idea,
53
55
  currentPhase: "PLAN",
54
56
  startedAt: now,
@@ -71,6 +73,8 @@ export async function quickCore(args: QuickArgs, artifactDir: string): Promise<s
71
73
  tasks: [],
72
74
  arenaConfidence: null,
73
75
  exploreTriggered: false,
76
+ pendingDispatches: [],
77
+ processedResultIds: [],
74
78
  });
75
79
 
76
80
  // 4. Persist quick state to disk