agentic-orchestrator 0.2.7 → 0.2.10

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 (22) hide show
  1. package/apps/control-plane/src/application/services/intake-service.ts +17 -27
  2. package/apps/control-plane/src/application/services/plan-service.ts +16 -16
  3. package/apps/control-plane/src/supervisor/build-wave-executor.ts +28 -0
  4. package/apps/control-plane/src/supervisor/planning-wave-executor.ts +33 -1
  5. package/apps/control-plane/src/supervisor/qa-wave-executor.ts +28 -0
  6. package/apps/control-plane/test/organizer-sidecar-service.spec.ts +306 -0
  7. package/apps/control-plane/test/plan-service.spec.ts +28 -47
  8. package/apps/control-plane/test/planning-wave-executor.spec.ts +7 -4
  9. package/dist/apps/control-plane/application/services/intake-service.js +12 -19
  10. package/dist/apps/control-plane/application/services/intake-service.js.map +1 -1
  11. package/dist/apps/control-plane/application/services/plan-service.js +14 -12
  12. package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
  13. package/dist/apps/control-plane/supervisor/build-wave-executor.d.ts +1 -0
  14. package/dist/apps/control-plane/supervisor/build-wave-executor.js +24 -0
  15. package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
  16. package/dist/apps/control-plane/supervisor/planning-wave-executor.d.ts +2 -0
  17. package/dist/apps/control-plane/supervisor/planning-wave-executor.js +28 -0
  18. package/dist/apps/control-plane/supervisor/planning-wave-executor.js.map +1 -1
  19. package/dist/apps/control-plane/supervisor/qa-wave-executor.d.ts +1 -0
  20. package/dist/apps/control-plane/supervisor/qa-wave-executor.js +24 -0
  21. package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
  22. package/package.json +1 -1
@@ -604,6 +604,14 @@ function isOpenIntakeQuestion(question: QuestionEvidenceRecord): boolean {
604
604
  return question.status === 'open' && isIntakeQuestion(question);
605
605
  }
606
606
 
607
+ function resolveQuestionAmbiguityIds(details: Record<string, unknown>): string[] {
608
+ const fromPlural = normalizeAmbiguityIds(details.ambiguity_ids);
609
+ if (fromPlural.length > 0) {
610
+ return fromPlural;
611
+ }
612
+ return normalizeAmbiguityIds(details.ambiguity_id);
613
+ }
614
+
607
615
  function isAnsweredIntakeClarification(question: QuestionEvidenceRecord): boolean {
608
616
  return (
609
617
  question.status === 'answered' &&
@@ -613,7 +621,7 @@ function isAnsweredIntakeClarification(question: QuestionEvidenceRecord): boolea
613
621
  question.answered_at !== null &&
614
622
  question.answered_by !== null &&
615
623
  isIntakeQuestion(question) &&
616
- normalizeAmbiguityIds(question.details.ambiguity_ids).length > 0
624
+ resolveQuestionAmbiguityIds(question.details).length > 0
617
625
  );
618
626
  }
619
627
 
@@ -625,7 +633,7 @@ function buildClarificationAnswers(
625
633
  return questions
626
634
  .filter((question) => isAnsweredIntakeClarification(question))
627
635
  .map((question) => {
628
- const ambiguityIds = normalizeAmbiguityIds(question.details.ambiguity_ids);
636
+ const ambiguityIds = resolveQuestionAmbiguityIds(question.details);
629
637
  const obligationIds = normalizeObligationIds(question.details.obligation_ids);
630
638
  const resolutionStatus = ambiguityIds.every(
631
639
  (ambiguityId) => ambiguityStatus.get(ambiguityId) === 'resolved',
@@ -795,18 +803,9 @@ export class IntakeService {
795
803
  return false;
796
804
  });
797
805
  if (duplicateObligationIds.length > 0) {
798
- throw {
799
- normalizedResponse: fail(
800
- ERROR_CODES.INTAKE_SUBMISSION_INVALID,
801
- 'verified manifest contains duplicate obligation ids after normalization',
802
- {
803
- feature_id: featureId,
804
- duplicate_obligation_ids: normalizeUniqueStrings(duplicateObligationIds),
805
- retryable: false,
806
- requires_human: true,
807
- },
808
- ),
809
- };
806
+ console.warn(
807
+ `[intake] ${featureId}: deduplicating ${duplicateObligationIds.length} duplicate obligation ids (${normalizeUniqueStrings(duplicateObligationIds).join(', ')})`,
808
+ );
810
809
  }
811
810
 
812
811
  return {
@@ -1347,19 +1346,10 @@ export class IntakeService {
1347
1346
  verifiedManifest.source_bootstrap_version !== null &&
1348
1347
  verifiedManifest.source_bootstrap_version !== bootstrapManifest.manifest_version
1349
1348
  ) {
1350
- throw {
1351
- normalizedResponse: fail(
1352
- ERROR_CODES.INTAKE_SUBMISSION_INVALID,
1353
- 'verified manifest must reference the current bootstrap manifest version',
1354
- {
1355
- feature_id: featureId,
1356
- expected_source_bootstrap_version: bootstrapManifest.manifest_version,
1357
- received_source_bootstrap_version: verifiedManifest.source_bootstrap_version,
1358
- retryable: false,
1359
- requires_human: true,
1360
- },
1361
- ),
1362
- };
1349
+ console.warn(
1350
+ `[intake] ${featureId}: auto-correcting source_bootstrap_version from ${verifiedManifest.source_bootstrap_version} to ${bootstrapManifest.manifest_version}`,
1351
+ );
1352
+ verifiedManifest.source_bootstrap_version = bootstrapManifest.manifest_version;
1363
1353
  }
1364
1354
  await this.validateVerificationBasisEvidence(
1365
1355
  featureId,
@@ -1021,22 +1021,22 @@ export class PlanService {
1021
1021
  );
1022
1022
 
1023
1023
  if (!traceValidation.valid) {
1024
- throw {
1025
- normalizedResponse: fail(
1026
- ERROR_CODES.PLAN_TRACE_INVALID,
1027
- 'plan trace does not match the verified manifest contract',
1028
- {
1029
- feature_id: featureId,
1030
- missing_obligation_ids: traceValidation.missing_obligation_ids,
1031
- unknown_obligation_ids: traceValidation.unknown_obligation_ids,
1032
- duplicate_obligation_ids: traceValidation.duplicate_obligation_ids,
1033
- invalid_mappings: traceValidation.invalid_mappings,
1034
- invalid_mapping_details: traceValidation.invalid_mapping_details,
1035
- retryable: false,
1036
- requires_human: true,
1037
- },
1038
- ),
1039
- };
1024
+ const issues: string[] = [];
1025
+ if (traceValidation.missing_obligation_ids.length > 0) {
1026
+ issues.push(`missing: ${traceValidation.missing_obligation_ids.join(', ')}`);
1027
+ }
1028
+ if (traceValidation.unknown_obligation_ids.length > 0) {
1029
+ issues.push(`unknown: ${traceValidation.unknown_obligation_ids.join(', ')}`);
1030
+ }
1031
+ if (traceValidation.duplicate_obligation_ids.length > 0) {
1032
+ issues.push(`duplicates: ${traceValidation.duplicate_obligation_ids.join(', ')}`);
1033
+ }
1034
+ if (traceValidation.invalid_mappings.length > 0) {
1035
+ issues.push(`invalid mappings: ${traceValidation.invalid_mappings.join(', ')}`);
1036
+ }
1037
+ console.warn(
1038
+ `[plan-trace] ${featureId}: trace mismatch accepted with warnings — ${issues.join('; ')}`,
1039
+ );
1040
1040
  }
1041
1041
  }
1042
1042
  }
@@ -483,6 +483,15 @@ export class BuildWaveExecutor {
483
483
  await this.reactionsService.notifyEscalation(featureId, escalateCtx);
484
484
  }
485
485
  }
486
+ } catch (error) {
487
+ if ((error as Record<string, unknown>)?._failRun === true) {
488
+ throw error;
489
+ }
490
+ const msg = error instanceof Error ? error.message : String(error);
491
+ console.warn(
492
+ `[build-wave] feature ${featureId} failed, continuing with remaining features: ${msg}`,
493
+ );
494
+ await this.blockFeatureOnUnhandledError(featureId, error);
486
495
  } finally {
487
496
  if (this.safeCheckpointHook) {
488
497
  await this.safeCheckpointHook();
@@ -601,6 +610,7 @@ export class BuildWaveExecutor {
601
610
  message,
602
611
  });
603
612
  if (policyAction === 'fail_run') {
613
+ (error as Record<string, unknown>)._failRun = true;
604
614
  throw error;
605
615
  }
606
616
  await this.applyWorkerPolicyAction(featureId, policyAction, code, message, phase);
@@ -721,6 +731,7 @@ export class BuildWaveExecutor {
721
731
  const error = new Error(message) as Error & {
722
732
  code?: string;
723
733
  details?: Record<string, unknown>;
734
+ _failRun?: boolean;
724
735
  };
725
736
  error.code = errorCode;
726
737
  error.details = {
@@ -728,6 +739,7 @@ export class BuildWaveExecutor {
728
739
  retryable: false,
729
740
  requires_human: true,
730
741
  };
742
+ error._failRun = true;
731
743
  throw error;
732
744
  }
733
745
  await this.applyWorkerPolicyAction(featureId, action, errorCode, message, phase);
@@ -926,6 +938,20 @@ export class BuildWaveExecutor {
926
938
  }
927
939
  }
928
940
 
941
+ private async blockFeatureOnUnhandledError(featureId: string, error: unknown): Promise<void> {
942
+ const typed = error as { code?: string; message?: string };
943
+ const code =
944
+ typeof typed.code === 'string' && typed.code.length > 0
945
+ ? typed.code
946
+ : 'unhandled_build_error';
947
+ const message = typed.message ?? String(error);
948
+ try {
949
+ await this.applyWorkerPolicyAction(featureId, 'block_feature', code, message, 'build');
950
+ } catch {
951
+ // Best-effort
952
+ }
953
+ }
954
+
929
955
  private async applyWorkerPolicyAction(
930
956
  featureId: string,
931
957
  action:
@@ -941,6 +967,7 @@ export class BuildWaveExecutor {
941
967
  const error = new Error(message) as Error & {
942
968
  code?: string;
943
969
  details?: Record<string, unknown>;
970
+ _failRun?: boolean;
944
971
  };
945
972
  error.code = errorCode;
946
973
  error.details = {
@@ -948,6 +975,7 @@ export class BuildWaveExecutor {
948
975
  retryable: false,
949
976
  requires_human: true,
950
977
  };
978
+ error._failRun = true;
951
979
  throw error;
952
980
  }
953
981
 
@@ -466,6 +466,15 @@ export class PlanningWaveExecutor {
466
466
  plan_json: plan,
467
467
  });
468
468
  }
469
+ } catch (error) {
470
+ if (this.isFailRunPolicyError(error)) {
471
+ throw error;
472
+ }
473
+ const msg = error instanceof Error ? error.message : String(error);
474
+ console.warn(
475
+ `[planning-wave] feature ${featureId} failed, continuing with remaining features: ${msg}`,
476
+ );
477
+ await this.blockFeatureOnUnhandledError(featureId, error);
469
478
  } finally {
470
479
  if (this.safeCheckpointHook) {
471
480
  await this.safeCheckpointHook();
@@ -1128,6 +1137,7 @@ export class PlanningWaveExecutor {
1128
1137
  message,
1129
1138
  });
1130
1139
  if (policyAction === 'fail_run') {
1140
+ (error as Record<string, unknown>)._failRun = true;
1131
1141
  throw error;
1132
1142
  }
1133
1143
  await this.applyWorkerPolicyAction(featureId, policyAction, code, message, 'planning');
@@ -1184,13 +1194,14 @@ export class PlanningWaveExecutor {
1184
1194
  if (this.outputLoopStallAction === 'fail_run') {
1185
1195
  const error = new Error(
1186
1196
  `Planner output loop detected after ${consecutiveIterations} consecutive identical iterations`,
1187
- ) as Error & { code?: string; details?: Record<string, unknown> };
1197
+ ) as Error & { code?: string; details?: Record<string, unknown>; _failRun?: boolean };
1188
1198
  error.code = ERROR_CODES.PROVIDER_OUTPUT_LOOP;
1189
1199
  error.details = {
1190
1200
  feature_id: featureId,
1191
1201
  retryable: false,
1192
1202
  requires_human: true,
1193
1203
  };
1204
+ error._failRun = true;
1194
1205
  throw error;
1195
1206
  }
1196
1207
 
@@ -1530,6 +1541,25 @@ export class PlanningWaveExecutor {
1530
1541
  };
1531
1542
  }
1532
1543
 
1544
+ private isFailRunPolicyError(error: unknown): boolean {
1545
+ const typed = error as { _failRun?: boolean };
1546
+ return typed?._failRun === true;
1547
+ }
1548
+
1549
+ private async blockFeatureOnUnhandledError(featureId: string, error: unknown): Promise<void> {
1550
+ const typed = error as { code?: string; message?: string };
1551
+ const code =
1552
+ typeof typed.code === 'string' && typed.code.length > 0
1553
+ ? typed.code
1554
+ : 'unhandled_planning_error';
1555
+ const message = typed.message ?? String(error);
1556
+ try {
1557
+ await this.applyWorkerPolicyAction(featureId, 'block_feature', code, message, 'planning');
1558
+ } catch {
1559
+ // Best-effort — if we can't block the feature, at least we logged above
1560
+ }
1561
+ }
1562
+
1533
1563
  private async applyWorkerPolicyAction(
1534
1564
  featureId: string,
1535
1565
  action:
@@ -1545,6 +1575,7 @@ export class PlanningWaveExecutor {
1545
1575
  const error = new Error(message) as Error & {
1546
1576
  code?: string;
1547
1577
  details?: Record<string, unknown>;
1578
+ _failRun?: boolean;
1548
1579
  };
1549
1580
  error.code = errorCode;
1550
1581
  error.details = {
@@ -1552,6 +1583,7 @@ export class PlanningWaveExecutor {
1552
1583
  retryable: false,
1553
1584
  requires_human: true,
1554
1585
  };
1586
+ error._failRun = true;
1555
1587
  throw error;
1556
1588
  }
1557
1589
 
@@ -462,6 +462,15 @@ export class QaWaveExecutor {
462
462
  orchestrator_session_id: this.state.orchestratorSessionId ?? 'unknown',
463
463
  qa_session_id: newQa.session_id,
464
464
  });
465
+ } catch (error) {
466
+ if ((error as Record<string, unknown>)?._failRun === true) {
467
+ throw error;
468
+ }
469
+ const msg = error instanceof Error ? error.message : String(error);
470
+ console.warn(
471
+ `[qa-wave] feature ${featureId} failed, continuing with remaining features: ${msg}`,
472
+ );
473
+ await this.blockFeatureOnUnhandledError(featureId, error);
465
474
  } finally {
466
475
  if (this.safeCheckpointHook) {
467
476
  await this.safeCheckpointHook();
@@ -470,6 +479,18 @@ export class QaWaveExecutor {
470
479
  }
471
480
  }
472
481
 
482
+ private async blockFeatureOnUnhandledError(featureId: string, error: unknown): Promise<void> {
483
+ const typed = error as { code?: string; message?: string };
484
+ const code =
485
+ typeof typed.code === 'string' && typed.code.length > 0 ? typed.code : 'unhandled_qa_error';
486
+ const message = typed.message ?? String(error);
487
+ try {
488
+ await this.applyWorkerPolicyAction(featureId, 'block_feature', code, message, 'qa');
489
+ } catch {
490
+ // Best-effort
491
+ }
492
+ }
493
+
473
494
  private asRecord(value: unknown): Record<string, unknown> {
474
495
  return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
475
496
  }
@@ -568,6 +589,8 @@ export class QaWaveExecutor {
568
589
  policyAction: 'block_feature',
569
590
  message: `${message} (auto-retry once at same phase)`,
570
591
  });
592
+ // Tag for propagation — the coordinator retries the feature on the next iteration
593
+ (error as Record<string, unknown>)._failRun = true;
571
594
  return false;
572
595
  }
573
596
 
@@ -579,6 +602,7 @@ export class QaWaveExecutor {
579
602
  message,
580
603
  });
581
604
  if (policyAction === 'fail_run') {
605
+ (error as Record<string, unknown>)._failRun = true;
582
606
  throw error;
583
607
  }
584
608
  await this.applyWorkerPolicyAction(featureId, policyAction, code, message, phase);
@@ -699,6 +723,7 @@ export class QaWaveExecutor {
699
723
  const error = new Error(message) as Error & {
700
724
  code?: string;
701
725
  details?: Record<string, unknown>;
726
+ _failRun?: boolean;
702
727
  };
703
728
  error.code = errorCode;
704
729
  error.details = {
@@ -706,6 +731,7 @@ export class QaWaveExecutor {
706
731
  retryable: false,
707
732
  requires_human: true,
708
733
  };
734
+ error._failRun = true;
709
735
  throw error;
710
736
  }
711
737
  await this.applyWorkerPolicyAction(featureId, action, errorCode, message, phase);
@@ -876,6 +902,7 @@ export class QaWaveExecutor {
876
902
  const error = new Error(message) as Error & {
877
903
  code?: string;
878
904
  details?: Record<string, unknown>;
905
+ _failRun?: boolean;
879
906
  };
880
907
  error.code = errorCode;
881
908
  error.details = {
@@ -883,6 +910,7 @@ export class QaWaveExecutor {
883
910
  retryable: false,
884
911
  requires_human: true,
885
912
  };
913
+ error._failRun = true;
886
914
  throw error;
887
915
  }
888
916
 
@@ -48,6 +48,84 @@ function makeMalformedResponse(): WorkerRunOutput {
48
48
  return { content: '{ not valid json' };
49
49
  }
50
50
 
51
+ function makeEmbeddedJsonResponse(): WorkerRunOutput {
52
+ const ordering = {
53
+ type: 'ORDERING',
54
+ ordering: {
55
+ dependency_graph: {
56
+ feat_a: { depends_on: [], classification: 'no_dependency_found' },
57
+ },
58
+ ready_set: ['feat_a'],
59
+ deferred_set: [],
60
+ blocked_set: [],
61
+ },
62
+ };
63
+ return { content: `Here is the ordering:\n${JSON.stringify(ordering)}\nDone.` };
64
+ }
65
+
66
+ function makeTextFieldResponse(): WorkerRunOutput {
67
+ const ordering = {
68
+ type: 'ORDERING',
69
+ ordering: {
70
+ dependency_graph: {
71
+ feat_a: { depends_on: [], classification: 'no_dependency_found' },
72
+ },
73
+ ready_set: ['feat_a'],
74
+ deferred_set: [],
75
+ blocked_set: [],
76
+ },
77
+ };
78
+ return { text: JSON.stringify(ordering) } as unknown as WorkerRunOutput;
79
+ }
80
+
81
+ function makeResponseFieldOutput(): WorkerRunOutput {
82
+ const ordering = {
83
+ type: 'ORDERING',
84
+ ordering: {
85
+ dependency_graph: {
86
+ feat_a: { depends_on: [], classification: 'no_dependency_found' },
87
+ },
88
+ ready_set: ['feat_a'],
89
+ deferred_set: [],
90
+ blocked_set: [],
91
+ },
92
+ };
93
+ return { response: JSON.stringify(ordering) } as unknown as WorkerRunOutput;
94
+ }
95
+
96
+ function makeStringifyFallbackResponse(): WorkerRunOutput {
97
+ const ordering = {
98
+ type: 'ORDERING',
99
+ ordering: {
100
+ dependency_graph: {
101
+ feat_a: { depends_on: [], classification: 'no_dependency_found' },
102
+ },
103
+ ready_set: ['feat_a'],
104
+ deferred_set: [],
105
+ blocked_set: [],
106
+ },
107
+ };
108
+ // No content/text/response/outputs — forces stringify fallback
109
+ return { data: ordering } as unknown as WorkerRunOutput;
110
+ }
111
+
112
+ function makeOutputsArrayResponse(): WorkerRunOutput {
113
+ const ordering = {
114
+ type: 'ORDERING',
115
+ ordering: {
116
+ dependency_graph: {
117
+ feat_a: { depends_on: [], classification: 'no_dependency_found' },
118
+ },
119
+ ready_set: ['feat_a'],
120
+ deferred_set: [],
121
+ blocked_set: [],
122
+ },
123
+ };
124
+ return {
125
+ outputs: [{ content: JSON.stringify(ordering) }],
126
+ } as unknown as WorkerRunOutput;
127
+ }
128
+
51
129
  function makeInvalidClassificationResponse(): WorkerRunOutput {
52
130
  const ordering = {
53
131
  type: 'ORDERING',
@@ -408,6 +486,234 @@ describe('OrganizerSidecarService', () => {
408
486
  await expect(service.stop()).resolves.toBeUndefined();
409
487
  });
410
488
 
489
+ it('GIVEN_relative_spec_path_WHEN_collecting_specs_THEN_resolves_against_repoRoot', async () => {
490
+ const specPath = join(specDir, 'feat_a.md');
491
+ await writeFile(specPath, '# Spec A\nRelative path test', 'utf8');
492
+
493
+ // Use a relative path from the temp dir acting as repoRoot
494
+ const relativeSpecPath = specPath.replace(tempDir + '/', '');
495
+ const kernel = {
496
+ getRepoRoot: () => tempDir,
497
+ drainPendingExecutionRequests: vi.fn().mockResolvedValue({
498
+ data: {
499
+ items: [
500
+ {
501
+ request_id: 'req-feat_a',
502
+ request_type: 'add_features' as const,
503
+ status: 'pending' as const,
504
+ requested_at: new Date().toISOString(),
505
+ processed_at: null,
506
+ requested_by: 'cli:add',
507
+ operation_id: 'op-feat_a',
508
+ features: [{ feature_id: 'feat_a', spec_path: relativeSpecPath }],
509
+ progress: null,
510
+ outcome: null,
511
+ },
512
+ ],
513
+ },
514
+ }),
515
+ readState: vi.fn().mockResolvedValue({
516
+ frontMatter: { spec_path: null, status: 'active' },
517
+ body: '',
518
+ }),
519
+ };
520
+ const provider = makeProvider([makeValidOrderingResponse()]);
521
+ const state = makeState();
522
+
523
+ const service = new OrganizerSidecarService({
524
+ artifactPath,
525
+ kernel: kernel as never,
526
+ provider,
527
+ state,
528
+ organizerSessionId: () => state.organizerSessionId ?? null,
529
+ schemaValidator: validator,
530
+ });
531
+
532
+ await (service as any).performReconcile();
533
+
534
+ expect(existsSync(artifactPath)).toBe(true);
535
+ expect(provider.runWorker).toHaveBeenCalledTimes(1);
536
+ const call = (provider.runWorker as ReturnType<typeof vi.fn>).mock.calls[0][0];
537
+ expect(call.instructions).toContain('Relative path test');
538
+ });
539
+
540
+ it('GIVEN_spec_missing_WHEN_collecting_specs_THEN_logs_warning_once_per_feature', async () => {
541
+ const kernel = makeKernel({ feat_missing: join(specDir, 'nonexistent.md') });
542
+ const provider = makeProvider([makeValidOrderingResponse()]);
543
+ const state = makeState();
544
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
545
+
546
+ try {
547
+ const service = new OrganizerSidecarService({
548
+ artifactPath,
549
+ kernel: kernel as never,
550
+ provider,
551
+ state,
552
+ organizerSessionId: () => state.organizerSessionId ?? null,
553
+ schemaValidator: validator,
554
+ });
555
+
556
+ // First reconcile — should log the warning
557
+ await (service as any).performReconcile();
558
+ const firstCallCount = warnSpy.mock.calls.filter((c) =>
559
+ String(c[0]).includes('spec file missing'),
560
+ ).length;
561
+ expect(firstCallCount).toBe(1);
562
+
563
+ // Second reconcile — should NOT log again for the same feature
564
+ await (service as any).performReconcile();
565
+ const secondCallCount = warnSpy.mock.calls.filter((c) =>
566
+ String(c[0]).includes('spec file missing'),
567
+ ).length;
568
+ expect(secondCallCount).toBe(1);
569
+ } finally {
570
+ warnSpy.mockRestore();
571
+ }
572
+ });
573
+
574
+ it('GIVEN_active_session_feature_with_spec_path_WHEN_collecting_specs_THEN_reads_from_state', async () => {
575
+ const specPath = join(specDir, 'feat_session.md');
576
+ await writeFile(specPath, '# Session Spec', 'utf8');
577
+
578
+ const kernel = {
579
+ getRepoRoot: () => '/repo',
580
+ drainPendingExecutionRequests: vi.fn().mockResolvedValue({ data: { items: [] } }),
581
+ readState: vi.fn().mockResolvedValue({
582
+ frontMatter: { spec_path: specPath, status: 'planning' },
583
+ body: '',
584
+ }),
585
+ };
586
+ const provider = makeProvider([makeValidOrderingResponse()]);
587
+ const state = makeState();
588
+ state.sessionsByFeature.set('feat_session', {
589
+ planner: 'session-001',
590
+ builder: 'unassigned',
591
+ qa: 'unassigned',
592
+ });
593
+
594
+ const service = new OrganizerSidecarService({
595
+ artifactPath,
596
+ kernel: kernel as never,
597
+ provider,
598
+ state,
599
+ organizerSessionId: () => state.organizerSessionId ?? null,
600
+ schemaValidator: validator,
601
+ });
602
+
603
+ await (service as any).performReconcile();
604
+
605
+ expect(kernel.readState).toHaveBeenCalledWith('feat_session');
606
+ expect(provider.runWorker).toHaveBeenCalledTimes(1);
607
+ const call = (provider.runWorker as ReturnType<typeof vi.fn>).mock.calls[0][0];
608
+ expect(call.instructions).toContain('Session Spec');
609
+ });
610
+
611
+ it('GIVEN_json_embedded_in_text_WHEN_parsing_response_THEN_extracts_valid_artifact', async () => {
612
+ const specPath = join(specDir, 'feat_a.md');
613
+ await writeFile(specPath, '# Spec A', 'utf8');
614
+
615
+ const kernel = makeKernel({ feat_a: specPath });
616
+ const provider = makeProvider([makeEmbeddedJsonResponse()]);
617
+ const state = makeState();
618
+
619
+ const service = new OrganizerSidecarService({
620
+ artifactPath,
621
+ kernel: kernel as never,
622
+ provider,
623
+ state,
624
+ organizerSessionId: () => state.organizerSessionId ?? null,
625
+ schemaValidator: validator,
626
+ });
627
+
628
+ await (service as any).performReconcile();
629
+ expect(existsSync(artifactPath)).toBe(true);
630
+ });
631
+
632
+ it('GIVEN_text_field_response_WHEN_extracting_text_THEN_reads_from_text_property', async () => {
633
+ const specPath = join(specDir, 'feat_a.md');
634
+ await writeFile(specPath, '# Spec A', 'utf8');
635
+
636
+ const kernel = makeKernel({ feat_a: specPath });
637
+ const provider = makeProvider([makeTextFieldResponse()]);
638
+ const state = makeState();
639
+
640
+ const service = new OrganizerSidecarService({
641
+ artifactPath,
642
+ kernel: kernel as never,
643
+ provider,
644
+ state,
645
+ organizerSessionId: () => state.organizerSessionId ?? null,
646
+ schemaValidator: validator,
647
+ });
648
+
649
+ await (service as any).performReconcile();
650
+ expect(existsSync(artifactPath)).toBe(true);
651
+ });
652
+
653
+ it('GIVEN_response_field_WHEN_extracting_text_THEN_reads_from_response_property', async () => {
654
+ const specPath = join(specDir, 'feat_a.md');
655
+ await writeFile(specPath, '# Spec A', 'utf8');
656
+
657
+ const kernel = makeKernel({ feat_a: specPath });
658
+ const provider = makeProvider([makeResponseFieldOutput()]);
659
+ const state = makeState();
660
+
661
+ const service = new OrganizerSidecarService({
662
+ artifactPath,
663
+ kernel: kernel as never,
664
+ provider,
665
+ state,
666
+ organizerSessionId: () => state.organizerSessionId ?? null,
667
+ schemaValidator: validator,
668
+ });
669
+
670
+ await (service as any).performReconcile();
671
+ expect(existsSync(artifactPath)).toBe(true);
672
+ });
673
+
674
+ it('GIVEN_no_standard_text_fields_WHEN_extracting_text_THEN_falls_back_to_stringify', async () => {
675
+ const specPath = join(specDir, 'feat_a.md');
676
+ await writeFile(specPath, '# Spec A', 'utf8');
677
+
678
+ const kernel = makeKernel({ feat_a: specPath });
679
+ const provider = makeProvider([makeStringifyFallbackResponse()]);
680
+ const state = makeState();
681
+
682
+ const service = new OrganizerSidecarService({
683
+ artifactPath,
684
+ kernel: kernel as never,
685
+ provider,
686
+ state,
687
+ organizerSessionId: () => state.organizerSessionId ?? null,
688
+ schemaValidator: validator,
689
+ });
690
+
691
+ await (service as any).performReconcile();
692
+ // Stringify of the whole response includes the valid ordering JSON — should parse
693
+ expect(existsSync(artifactPath)).toBe(true);
694
+ });
695
+
696
+ it('GIVEN_outputs_array_response_WHEN_extracting_text_THEN_reads_from_outputs_content', async () => {
697
+ const specPath = join(specDir, 'feat_a.md');
698
+ await writeFile(specPath, '# Spec A', 'utf8');
699
+
700
+ const kernel = makeKernel({ feat_a: specPath });
701
+ const provider = makeProvider([makeOutputsArrayResponse()]);
702
+ const state = makeState();
703
+
704
+ const service = new OrganizerSidecarService({
705
+ artifactPath,
706
+ kernel: kernel as never,
707
+ provider,
708
+ state,
709
+ organizerSessionId: () => state.organizerSessionId ?? null,
710
+ schemaValidator: validator,
711
+ });
712
+
713
+ await (service as any).performReconcile();
714
+ expect(existsSync(artifactPath)).toBe(true);
715
+ });
716
+
411
717
  it('GIVEN_valid_artifact_WHEN_schema_validation_runs_THEN_passes', async () => {
412
718
  const specPath = join(specDir, 'feat_a.md');
413
719
  await writeFile(specPath, '# Spec A', 'utf8');