@kontourai/flow-agents 3.7.0 → 3.8.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/CHANGELOG.md +12 -0
- package/build/src/builder-flow-runtime.js +102 -7
- package/build/src/cli/assignment-provider.js +13 -1
- package/build/src/cli/workflow-sidecar.js +29 -11
- package/build/src/cli/workflow.js +20 -10
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/docs/getting-started.md +9 -1
- package/docs/spec/builder-flow-runtime.md +36 -2
- package/evals/integration/test_builder_step_producers.sh +37 -4
- package/evals/integration/test_bundle_install.sh +108 -4
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_public_workflow_cli.sh +11 -5
- package/evals/static/test_universal_bundles.sh +44 -1
- package/package.json +1 -1
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-runtime.ts +99 -7
- package/src/cli/assignment-provider.ts +13 -1
- package/src/cli/builder-flow-runtime.test.mjs +250 -18
- package/src/cli/workflow-sidecar.ts +27 -11
- package/src/cli/workflow.ts +20 -10
- package/src/tools/build-universal-bundles.ts +51 -3
|
@@ -367,10 +367,21 @@ async function syncAndProject(
|
|
|
367
367
|
const snapshot = stageTrustBundleSnapshot(context);
|
|
368
368
|
try {
|
|
369
369
|
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
370
|
-
const gateEvidence = await bundleGateEvidence(
|
|
370
|
+
const gateEvidence = await bundleGateEvidence(
|
|
371
|
+
rawBundle,
|
|
372
|
+
gates[0]!,
|
|
373
|
+
run.state,
|
|
374
|
+
run.state.subject,
|
|
375
|
+
context.projectRoot,
|
|
376
|
+
manifestEvidence(run.manifest),
|
|
377
|
+
);
|
|
371
378
|
if (gateEvidence) {
|
|
372
379
|
const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
|
|
373
|
-
entry.gate_id === gates[0]!.id
|
|
380
|
+
entry.gate_id === gates[0]!.id
|
|
381
|
+
&& entry.sha256 === snapshot.sha256
|
|
382
|
+
&& typeof entry.superseded_by !== "string"
|
|
383
|
+
&& timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
|
|
384
|
+
&& gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId))
|
|
374
385
|
);
|
|
375
386
|
if (!alreadyAttached) {
|
|
376
387
|
const supersede = manifestEvidence(run.manifest)
|
|
@@ -386,6 +397,7 @@ async function syncAndProject(
|
|
|
386
397
|
...(supersede.length > 0 ? { supersede } : {}),
|
|
387
398
|
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
388
399
|
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
400
|
+
expectationIds: gateEvidence.expectationIds,
|
|
389
401
|
},
|
|
390
402
|
});
|
|
391
403
|
attached = true;
|
|
@@ -478,21 +490,72 @@ function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: s
|
|
|
478
490
|
) as Array<FlowGate & { id: string }>;
|
|
479
491
|
}
|
|
480
492
|
|
|
481
|
-
async function bundleGateEvidence(
|
|
493
|
+
async function bundleGateEvidence(
|
|
494
|
+
bundle: unknown,
|
|
495
|
+
gate: FlowGate,
|
|
496
|
+
state: FlowRunState,
|
|
497
|
+
subject: string,
|
|
498
|
+
projectRoot: string,
|
|
499
|
+
manifest: AnyRecord[],
|
|
500
|
+
): Promise<{ failed: boolean; routeReason: string | null; expectationIds: string[]; visitEnteredAt: number } | null> {
|
|
482
501
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
|
|
483
|
-
const
|
|
502
|
+
const expectations = expectationsForGate(gate) as FlowExpectation[];
|
|
503
|
+
const visit = currentGateVisit(state, String((gate as AnyRecord).step));
|
|
504
|
+
const enteredAt = visit.enteredAt;
|
|
505
|
+
const synchronizedAt = Date.now();
|
|
506
|
+
const maxClockSkewMs = 30_000;
|
|
507
|
+
const priorVisitClaimIds = new Set<string>();
|
|
508
|
+
const priorVisitEvidenceIds = new Set<string>();
|
|
509
|
+
for (const entry of manifest) {
|
|
510
|
+
if (entry.gate_id !== String((gate as AnyRecord).id)) continue;
|
|
511
|
+
const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
|
|
512
|
+
for (const historical of claims) {
|
|
513
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitClaimIds.add(historical.id);
|
|
514
|
+
}
|
|
515
|
+
const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
|
|
516
|
+
for (const historical of evidence) {
|
|
517
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitEvidenceIds.add(historical.id);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const claimIsCurrent = (claim: AnyRecord): boolean => {
|
|
521
|
+
if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id)) return false;
|
|
522
|
+
const timestamps: number[] = [];
|
|
523
|
+
const createdAt = parseTimestamp(claim.createdAt);
|
|
524
|
+
if (createdAt !== null) timestamps.push(createdAt);
|
|
525
|
+
if (Array.isArray((bundle as AnyRecord).evidence)) for (const evidence of (bundle as AnyRecord).evidence) {
|
|
526
|
+
if (!isRecord(evidence) || evidence.claimId !== claim.id) continue;
|
|
527
|
+
if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id)) return false;
|
|
528
|
+
const observedAt = parseTimestamp(evidence.observedAt);
|
|
529
|
+
if (observedAt !== null) timestamps.push(observedAt);
|
|
530
|
+
}
|
|
531
|
+
const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
|
|
532
|
+
return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
|
|
533
|
+
&& timestamp <= synchronizedAt + maxClockSkewMs);
|
|
534
|
+
};
|
|
484
535
|
const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
|
|
485
536
|
if (!isRecord(claim)) return false;
|
|
486
|
-
|
|
537
|
+
if (claim.producerStatus === "superseded") return false;
|
|
538
|
+
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
539
|
+
if (metadata && typeof metadata.superseded_by === "string") return false;
|
|
540
|
+
return expectations.some((expectation) => {
|
|
541
|
+
const candidate = expectation.bundle_claim;
|
|
542
|
+
return candidate
|
|
543
|
+
&& claimIsCurrent(claim)
|
|
544
|
+
&&
|
|
487
545
|
candidate.claimType === claim.claimType
|
|
488
546
|
&& (!candidate.subjectType || candidate.subjectType === claim.subjectType)
|
|
489
|
-
);
|
|
547
|
+
});
|
|
490
548
|
});
|
|
491
549
|
if (relevant.length === 0) return null;
|
|
492
550
|
if (relevant.some((claim) => workflowSubjectRef(claim) !== subject)) {
|
|
493
551
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
|
|
494
552
|
}
|
|
495
553
|
const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
|
|
554
|
+
const expectationIds = expectations.filter((expectation) => relevant.some((claim: AnyRecord) => {
|
|
555
|
+
const selector = expectation.bundle_claim;
|
|
556
|
+
return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
|
|
557
|
+
})).map((expectation) => expectation.id);
|
|
558
|
+
const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
|
|
496
559
|
const routeReasons = [...new Set(relevant.flatMap((claim) => {
|
|
497
560
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
498
561
|
const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
|
|
@@ -502,6 +565,11 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
|
|
|
502
565
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
|
|
503
566
|
}
|
|
504
567
|
const routeReason = routeReasons[0] ?? null;
|
|
568
|
+
if (failed && !routeReason) return null;
|
|
569
|
+
// Passing evidence waits for the complete expectation set. A failing
|
|
570
|
+
// snapshot is complete only when a gate producer explicitly declares its
|
|
571
|
+
// route reason; report-only disputed critique state remains pending.
|
|
572
|
+
if (!failed && missingRequired.length > 0) return null;
|
|
505
573
|
if (routeReason && !failed) {
|
|
506
574
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
|
|
507
575
|
}
|
|
@@ -512,7 +580,31 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
|
|
|
512
580
|
if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
513
581
|
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
514
582
|
}
|
|
515
|
-
return { failed, routeReason };
|
|
583
|
+
return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function currentGateVisit(state: FlowRunState, step: string): { enteredAt: number; initial: boolean } {
|
|
587
|
+
let enteredAt: number | null = null;
|
|
588
|
+
for (const transition of state.transitions ?? []) {
|
|
589
|
+
if (transition.to_step !== step) continue;
|
|
590
|
+
const parsed = parseTimestamp(transition.at);
|
|
591
|
+
if (parsed !== null) enteredAt = parsed;
|
|
592
|
+
}
|
|
593
|
+
const initial = parseTimestamp(state.updated_at);
|
|
594
|
+
if (enteredAt !== null) return { enteredAt, initial: false };
|
|
595
|
+
if (initial !== null) return { enteredAt: initial, initial: true };
|
|
596
|
+
throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseTimestamp(value: unknown): number | null {
|
|
600
|
+
if (typeof value !== "string") return null;
|
|
601
|
+
const parsed = Date.parse(value);
|
|
602
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function timestampAtOrAfter(value: unknown, boundary: number): boolean {
|
|
606
|
+
const parsed = parseTimestamp(value);
|
|
607
|
+
return parsed !== null && parsed >= boundary;
|
|
516
608
|
}
|
|
517
609
|
|
|
518
610
|
async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
|
|
@@ -775,7 +775,19 @@ export function performLocalReleaseUnderLock(
|
|
|
775
775
|
// seam, relocated to this write path).
|
|
776
776
|
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
777
777
|
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
778
|
-
|
|
778
|
+
// Pre-3.7 lifecycle events could persist the derived ancestry actor before
|
|
779
|
+
// sanitizeSegment removed ':' separators. Modern explicit/env release paths
|
|
780
|
+
// always use the sanitized form. Accept only that one-way legacy migration;
|
|
781
|
+
// never normalize two modern keys or relax ownership to a prefix match.
|
|
782
|
+
const sameActorStruct = existing.actor.runtime === releasedBy.runtime
|
|
783
|
+
&& existing.actor.session_id === releasedBy.session_id
|
|
784
|
+
&& existing.actor.host === releasedBy.host
|
|
785
|
+
&& (existing.actor.human ?? null) === (releasedBy.human ?? null);
|
|
786
|
+
const legacyActorKeyMatches = holderActorKey.includes(":")
|
|
787
|
+
&& holderActorKey === helper.serializeActor(existing.actor)
|
|
788
|
+
&& helper.sanitizeSegment(holderActorKey) === releasedByActorKey
|
|
789
|
+
&& sameActorStruct;
|
|
790
|
+
if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
|
|
779
791
|
if (tolerateNoActiveClaim) return null;
|
|
780
792
|
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
781
793
|
}
|
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
} from "../../build/src/builder-flow-runtime.js";
|
|
20
20
|
import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
|
|
21
21
|
import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
|
|
22
|
-
import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
|
|
22
|
+
import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
|
|
23
23
|
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
24
|
-
import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
|
|
24
|
+
import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
|
|
25
25
|
|
|
26
26
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
27
27
|
const NOW = "2026-07-09T20:00:00.000Z";
|
|
@@ -214,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
|
|
|
214
214
|
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp =
|
|
217
|
+
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = new Date().toISOString() }) {
|
|
218
218
|
const claimId = `claim.${expectation}`;
|
|
219
219
|
return {
|
|
220
220
|
claim: {
|
|
@@ -264,7 +264,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
264
264
|
};
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function verifiedTestsPrerequisites(session, timestamp =
|
|
267
|
+
function verifiedTestsPrerequisites(session, timestamp = new Date().toISOString()) {
|
|
268
268
|
const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
|
|
269
269
|
const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
|
|
270
270
|
const implementationFile = path.relative(session.projectRoot, implementation);
|
|
@@ -312,6 +312,24 @@ function writeBundle(sessionDir, entries) {
|
|
|
312
312
|
});
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
function historicalProducerSuperseded(entry, suffix = "historical") {
|
|
316
|
+
const copy = withIdentitySuffix(entry, suffix);
|
|
317
|
+
copy.claim.producerStatus = "superseded";
|
|
318
|
+
return copy;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function withIdentitySuffix(entry, suffix) {
|
|
322
|
+
const copy = structuredClone(entry);
|
|
323
|
+
const priorId = copy.claim.id;
|
|
324
|
+
copy.claim.id = `${priorId}.${suffix}`;
|
|
325
|
+
copy.evidence.id = `${copy.evidence.id}.${suffix}`;
|
|
326
|
+
copy.evidence.claimId = copy.claim.id;
|
|
327
|
+
copy.event.id = `${copy.event.id}.${suffix}`;
|
|
328
|
+
copy.event.claimId = copy.claim.id;
|
|
329
|
+
copy.event.evidenceIds = [copy.evidence.id];
|
|
330
|
+
return copy;
|
|
331
|
+
}
|
|
332
|
+
|
|
315
333
|
async function writeAndSync(session, entries) {
|
|
316
334
|
writeBundle(session.sessionDir, entries);
|
|
317
335
|
return syncBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -736,13 +754,15 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
|
|
|
736
754
|
const verify = await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
737
755
|
assert.equal(verify.run.state.current_step, "verify");
|
|
738
756
|
|
|
757
|
+
const failureTimestamp = new Date().toISOString();
|
|
739
758
|
const routed = await writeAndSync(session, [bundleClaim({
|
|
740
759
|
expectation: "tests-evidence",
|
|
741
760
|
claimType: "builder.verify.tests",
|
|
742
761
|
subjectType: "flow-step",
|
|
743
762
|
status: "fail",
|
|
744
763
|
routeReason: "implementation_defect",
|
|
745
|
-
|
|
764
|
+
timestamp: failureTimestamp,
|
|
765
|
+
}), ...verifiedTestsPrerequisites(session, failureTimestamp)]);
|
|
746
766
|
|
|
747
767
|
assert.equal(routed.run.state.current_step, "execute");
|
|
748
768
|
assert.equal(routed.projection.flow_run.route_back_attempt, 1);
|
|
@@ -750,17 +770,28 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
|
|
|
750
770
|
assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
|
|
751
771
|
assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
|
|
752
772
|
|
|
753
|
-
const reentered = await writeAndSync(session, [bundleClaim({
|
|
773
|
+
const reentered = await writeAndSync(session, [withIdentitySuffix(bundleClaim({
|
|
754
774
|
expectation: "implementation-scope",
|
|
755
775
|
claimType: "builder.execute.scope",
|
|
756
776
|
subjectType: "change",
|
|
757
777
|
timestamp: new Date().toISOString(),
|
|
758
|
-
})]);
|
|
778
|
+
}), "reentry")]);
|
|
759
779
|
assert.equal(reentered.run.state.current_step, "verify");
|
|
780
|
+
const staleRetry = await writeAndSync(session, [bundleClaim({
|
|
781
|
+
expectation: "tests-evidence",
|
|
782
|
+
claimType: "builder.verify.tests",
|
|
783
|
+
subjectType: "flow-step",
|
|
784
|
+
status: "fail",
|
|
785
|
+
routeReason: "implementation_defect",
|
|
786
|
+
timestamp: NOW,
|
|
787
|
+
})]);
|
|
788
|
+
assert.equal(staleRetry.attached, false);
|
|
789
|
+
assert.equal(staleRetry.run.state.current_step, "verify");
|
|
790
|
+
assert.equal(staleRetry.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
|
|
760
791
|
const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
|
|
761
792
|
const corrected = await writeAndSync(session, [
|
|
762
|
-
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
|
|
763
|
-
...verifiedTestsPrerequisites(session, correctedAt),
|
|
793
|
+
withIdentitySuffix(bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }), "corrected"),
|
|
794
|
+
...verifiedTestsPrerequisites(session, correctedAt).map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`)),
|
|
764
795
|
]);
|
|
765
796
|
assert.equal(corrected.run.state.current_step, "merge-ready");
|
|
766
797
|
const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
|
|
@@ -768,6 +799,190 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
|
|
|
768
799
|
assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
|
|
769
800
|
});
|
|
770
801
|
|
|
802
|
+
test("producer-superseded FAIL is audit history and live PASS drives verify", async () => {
|
|
803
|
+
const session = makeSession("producer-superseded-verify");
|
|
804
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
805
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
806
|
+
await writeAndSync(session, [
|
|
807
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
808
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
809
|
+
]);
|
|
810
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
811
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
812
|
+
|
|
813
|
+
const livePass = bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" });
|
|
814
|
+
const historicalFail = historicalProducerSuperseded(bundleClaim({
|
|
815
|
+
expectation: "tests-evidence",
|
|
816
|
+
claimType: "builder.verify.tests",
|
|
817
|
+
subjectType: "flow-step",
|
|
818
|
+
status: "fail",
|
|
819
|
+
routeReason: "implementation_defect",
|
|
820
|
+
}));
|
|
821
|
+
const result = await writeAndSync(session, [historicalFail, livePass, ...verifiedTestsPrerequisites(session)]);
|
|
822
|
+
|
|
823
|
+
assert.equal(result.run.state.current_step, "merge-ready");
|
|
824
|
+
const attached = result.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate" && !entry.superseded_by);
|
|
825
|
+
assert.equal(attached.length, 1);
|
|
826
|
+
assert.deepEqual(attached[0].expectation_ids.sort(), ["acceptance-criteria", "clean-critique", "tests-evidence"]);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("partial passing review snapshot waits for the complete verify expectation set", async () => {
|
|
830
|
+
const session = makeSession("atomic-review-sync");
|
|
831
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
832
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
833
|
+
await writeAndSync(session, [
|
|
834
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
835
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
836
|
+
]);
|
|
837
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
838
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
839
|
+
|
|
840
|
+
const disputedCritique = verifiedTestsPrerequisites(session)[0];
|
|
841
|
+
disputedCritique.claim.value = "fail";
|
|
842
|
+
disputedCritique.claim.status = "disputed";
|
|
843
|
+
disputedCritique.event.status = "disputed";
|
|
844
|
+
const disputedPartial = await writeAndSync(session, [disputedCritique]);
|
|
845
|
+
assert.equal(disputedPartial.attached, false);
|
|
846
|
+
assert.equal(disputedPartial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
|
|
847
|
+
|
|
848
|
+
const partial = await writeAndSync(session, verifiedTestsPrerequisites(session).slice(0, 1));
|
|
849
|
+
assert.equal(partial.attached, false);
|
|
850
|
+
assert.equal(partial.run.state.current_step, "verify");
|
|
851
|
+
assert.equal(partial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
|
|
852
|
+
|
|
853
|
+
const complete = await writeAndSync(session, [
|
|
854
|
+
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }),
|
|
855
|
+
...verifiedTestsPrerequisites(session),
|
|
856
|
+
]);
|
|
857
|
+
assert.equal(complete.run.state.current_step, "merge-ready");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("initial gate boundary rejects pre-run and far-future claims", async () => {
|
|
861
|
+
for (const [name, timestamp] of [
|
|
862
|
+
["pre-run", NOW],
|
|
863
|
+
["far-future", new Date(Date.now() + 60 * 60_000).toISOString()],
|
|
864
|
+
]) {
|
|
865
|
+
const session = makeSession(`freshness-${name}`);
|
|
866
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
867
|
+
const result = await writeAndSync(session, [bundleClaim({
|
|
868
|
+
expectation: "selected-work",
|
|
869
|
+
claimType: "builder.pull-work.selected",
|
|
870
|
+
subjectType: "work-item",
|
|
871
|
+
timestamp,
|
|
872
|
+
})]);
|
|
873
|
+
assert.equal(result.attached, false, name);
|
|
874
|
+
assert.equal(result.run.state.current_step, "pull-work", name);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("prior claim identity is rejected after re-entry and a new identity can recover", async () => {
|
|
879
|
+
const session = makeSession("same-digest-reentry");
|
|
880
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
881
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
882
|
+
await writeAndSync(session, [
|
|
883
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
884
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
885
|
+
]);
|
|
886
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
887
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
888
|
+
const future = new Date(Date.now() + 10_000).toISOString();
|
|
889
|
+
const failure = [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", status: "fail", routeReason: "implementation_defect", timestamp: future }), ...verifiedTestsPrerequisites(session, future)];
|
|
890
|
+
const first = await writeAndSync(session, failure);
|
|
891
|
+
assert.equal(first.run.state.current_step, "execute");
|
|
892
|
+
await writeAndSync(session, [withIdentitySuffix(bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change", timestamp: future }), "reentry")]);
|
|
893
|
+
const replay = await writeAndSync(session, failure);
|
|
894
|
+
assert.equal(replay.attached, false);
|
|
895
|
+
assert.equal(replay.run.state.current_step, "verify");
|
|
896
|
+
assert.equal(replay.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
|
|
897
|
+
const newIdentity = failure.map((entry, index) => withIdentitySuffix(entry, `visit-2-${index}`));
|
|
898
|
+
const second = await writeAndSync(session, newIdentity);
|
|
899
|
+
assert.equal(second.run.state.current_step, "execute");
|
|
900
|
+
const verifyEvidence = second.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
|
|
901
|
+
assert.equal(verifyEvidence.length, 2);
|
|
902
|
+
assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("legacy colon-bearing assignment actor releases only through its normalized equivalent", () => {
|
|
906
|
+
const session = makeSession("legacy-actor-release");
|
|
907
|
+
const legacyActor = { runtime: "unknown", session_id: "legacy-session", host: "Kontour", human: null };
|
|
908
|
+
performLocalClaim(session.artifactRoot, session.slug, legacyActor, {
|
|
909
|
+
ttlSeconds: 1800,
|
|
910
|
+
actorKey: "unknown:legacy-session:Kontour",
|
|
911
|
+
branch: `agent/${session.slug}`,
|
|
912
|
+
artifactDir: session.sessionDir,
|
|
913
|
+
workItemRef: SUBJECT,
|
|
914
|
+
reason: "legacy fixture",
|
|
915
|
+
});
|
|
916
|
+
assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
|
|
917
|
+
actorKey: "different-actor",
|
|
918
|
+
}), /refusing to release/);
|
|
919
|
+
assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, { ...legacyActor, session_id: "different" }, {
|
|
920
|
+
actorKey: "unknownlegacy-sessionKontour",
|
|
921
|
+
}), /refusing to release/);
|
|
922
|
+
const released = performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
|
|
923
|
+
actorKey: "unknownlegacy-sessionKontour",
|
|
924
|
+
});
|
|
925
|
+
assert.equal(released.status, "released");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("string-only legacy liveness identity cannot be released by a colliding modern actor", async () => {
|
|
929
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-legacy-liveness-"));
|
|
930
|
+
const eventsFile = path.join(root, "liveness", "events.jsonl");
|
|
931
|
+
fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
|
|
932
|
+
fs.writeFileSync(eventsFile, `${JSON.stringify({
|
|
933
|
+
type: "claim",
|
|
934
|
+
subjectId: "legacy-subject",
|
|
935
|
+
actor: "unknown:anc-123456789abc:Kontour",
|
|
936
|
+
at: NOW,
|
|
937
|
+
ttlSeconds: 1800,
|
|
938
|
+
})}\n`);
|
|
939
|
+
|
|
940
|
+
await assert.rejects(() => workflowSidecarMain([
|
|
941
|
+
"liveness", "release", "legacy-subject",
|
|
942
|
+
"--actor", "unknownanc-123456789abcKontour",
|
|
943
|
+
"--artifact-root", root,
|
|
944
|
+
"--at", "2026-07-09T20:01:00.000Z",
|
|
945
|
+
]), /prior claim/);
|
|
946
|
+
const events = fs.readFileSync(eventsFile, "utf8").trim().split("\n").map(JSON.parse);
|
|
947
|
+
assert.equal(events.length, 1);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("lossy modern liveness key collision cannot release a non-reversible legacy actor", async () => {
|
|
951
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lossy-liveness-"));
|
|
952
|
+
const eventsFile = path.join(root, "liveness", "events.jsonl");
|
|
953
|
+
fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
|
|
954
|
+
fs.writeFileSync(eventsFile, `${JSON.stringify({ type: "claim", subjectId: "collision", actor: "a:b", at: NOW, ttlSeconds: 1800 })}\n`);
|
|
955
|
+
await assert.rejects(() => workflowSidecarMain([
|
|
956
|
+
"liveness", "release", "collision", "--actor", "ab", "--artifact-root", root,
|
|
957
|
+
]), /prior claim/);
|
|
958
|
+
assert.equal(fs.readFileSync(eventsFile, "utf8").trim().split("\n").length, 1);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("upgrade rebuild preserves legacy critique identity until a versioned new review", async () => {
|
|
962
|
+
const legacy = {
|
|
963
|
+
id: "upgrade-review",
|
|
964
|
+
reviewer: "reviewer",
|
|
965
|
+
reviewed_at: "2026-07-01T00:00:00Z",
|
|
966
|
+
verdict: "pass",
|
|
967
|
+
summary: "Legacy clean review",
|
|
968
|
+
findings: [],
|
|
969
|
+
lanes: [{ id: "code", status: "pass" }],
|
|
970
|
+
review_target: { artifacts: [] },
|
|
971
|
+
artifact_refs: [],
|
|
972
|
+
};
|
|
973
|
+
const beforeUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-01T00:00:00Z", [], [], [legacy]);
|
|
974
|
+
const rebuiltAfterUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:00:00Z", [], [], [legacy]);
|
|
975
|
+
const newReview = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:01:00Z", [], [], [{
|
|
976
|
+
...legacy,
|
|
977
|
+
reviewed_at: "2026-07-12T00:01:00Z",
|
|
978
|
+
identity_version: 2,
|
|
979
|
+
}]);
|
|
980
|
+
assert.equal(beforeUpgrade.claims[0].id, rebuiltAfterUpgrade.claims[0].id);
|
|
981
|
+
assert.notEqual(newReview.claims[0].id, beforeUpgrade.claims[0].id);
|
|
982
|
+
assert.equal(rebuiltAfterUpgrade.claims[0].metadata.identity_version, undefined);
|
|
983
|
+
assert.equal(newReview.claims[0].metadata.identity_version, 2);
|
|
984
|
+
});
|
|
985
|
+
|
|
771
986
|
test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
|
|
772
987
|
const session = makeSession("stale-critique-artifact");
|
|
773
988
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -837,25 +1052,30 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
837
1052
|
claimSessionAssignment(session);
|
|
838
1053
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
839
1054
|
const steps = [
|
|
840
|
-
[bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
|
|
841
|
-
[
|
|
1055
|
+
() => [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
|
|
1056
|
+
() => [
|
|
842
1057
|
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
843
1058
|
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
844
1059
|
],
|
|
845
|
-
[bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
|
|
846
|
-
[bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
|
|
847
|
-
[bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
|
|
848
|
-
[bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
|
|
1060
|
+
() => [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
|
|
1061
|
+
() => [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
|
|
1062
|
+
() => [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
|
|
1063
|
+
() => [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
|
|
849
1064
|
];
|
|
850
|
-
for (const entries of steps) await writeAndSync(session, entries);
|
|
1065
|
+
for (const entries of steps) await writeAndSync(session, entries());
|
|
851
1066
|
|
|
852
1067
|
const prOpen = readJson(path.join(session.sessionDir, "state.json"));
|
|
853
1068
|
assert.equal(prOpen.flow_run.current_step, "pr-open");
|
|
1069
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "pr-open");
|
|
854
1070
|
assert.deepEqual(prOpen.next_action.skills, []);
|
|
855
1071
|
assert.deepEqual(prOpen.next_action.operations, ["publish-change"]);
|
|
856
1072
|
|
|
857
|
-
await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
|
|
858
|
-
|
|
1073
|
+
const mergeReadyCi = await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
|
|
1074
|
+
assert.equal(mergeReadyCi.run.state.current_step, "merge-ready-ci");
|
|
1075
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "merge-ready-ci");
|
|
1076
|
+
const learn = await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
|
|
1077
|
+
assert.equal(learn.run.state.current_step, "learn");
|
|
1078
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "learn");
|
|
859
1079
|
const completed = await writeAndSync(session, [
|
|
860
1080
|
bundleClaim({ expectation: "decision-evidence", claimType: "builder.learn.decisions", subjectType: "decision" }),
|
|
861
1081
|
bundleClaim({ expectation: "learning-evidence", claimType: "builder.learn.evidence", subjectType: "release" }),
|
|
@@ -865,6 +1085,18 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
865
1085
|
assert.equal(completed.run.state.status, "completed");
|
|
866
1086
|
assert.equal(completed.projection.status, "delivered");
|
|
867
1087
|
assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
1088
|
+
const liveEvidence = completed.run.manifest.evidence.filter((entry) => !entry.superseded_by);
|
|
1089
|
+
assert.deepEqual(liveEvidence.map((entry) => entry.expectation_ids), [
|
|
1090
|
+
["selected-work"],
|
|
1091
|
+
["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"],
|
|
1092
|
+
["implementation-plan"],
|
|
1093
|
+
["implementation-scope"],
|
|
1094
|
+
["clean-critique", "acceptance-criteria", "tests-evidence"],
|
|
1095
|
+
["merge-readiness"],
|
|
1096
|
+
["pull-request-opened"],
|
|
1097
|
+
["ci-merge-readiness"],
|
|
1098
|
+
["decision-evidence", "learning-evidence"],
|
|
1099
|
+
]);
|
|
868
1100
|
|
|
869
1101
|
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
870
1102
|
const beforeFlow = snapshotTree(flowDirectory);
|