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.
- package/apps/control-plane/src/application/services/intake-service.ts +17 -27
- package/apps/control-plane/src/application/services/plan-service.ts +16 -16
- package/apps/control-plane/src/supervisor/build-wave-executor.ts +28 -0
- package/apps/control-plane/src/supervisor/planning-wave-executor.ts +33 -1
- package/apps/control-plane/src/supervisor/qa-wave-executor.ts +28 -0
- package/apps/control-plane/test/organizer-sidecar-service.spec.ts +306 -0
- package/apps/control-plane/test/plan-service.spec.ts +28 -47
- package/apps/control-plane/test/planning-wave-executor.spec.ts +7 -4
- package/dist/apps/control-plane/application/services/intake-service.js +12 -19
- package/dist/apps/control-plane/application/services/intake-service.js.map +1 -1
- package/dist/apps/control-plane/application/services/plan-service.js +14 -12
- package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
- package/dist/apps/control-plane/supervisor/build-wave-executor.d.ts +1 -0
- package/dist/apps/control-plane/supervisor/build-wave-executor.js +24 -0
- package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
- package/dist/apps/control-plane/supervisor/planning-wave-executor.d.ts +2 -0
- package/dist/apps/control-plane/supervisor/planning-wave-executor.js +28 -0
- package/dist/apps/control-plane/supervisor/planning-wave-executor.js.map +1 -1
- package/dist/apps/control-plane/supervisor/qa-wave-executor.d.ts +1 -0
- package/dist/apps/control-plane/supervisor/qa-wave-executor.js +24 -0
- package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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');
|