@kontourai/flow-agents 3.6.0 → 3.7.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 +7 -0
- package/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +308 -47
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +67 -13
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +801 -97
- package/build/src/cli/workflow.js +290 -42
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +41 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +212 -63
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +325 -11
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +331 -61
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +62 -13
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +194 -8
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +724 -97
- package/src/cli/workflow.ts +277 -40
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { isDeepStrictEqual } from "node:util";
|
|
5
7
|
import { flowAgentsPackageVersion } from "./lib/package-version.js";
|
|
6
8
|
import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
|
|
7
|
-
import { expectationsForGate, lifecycleRequestMatches, openGates,
|
|
9
|
+
import { expectationsForGate, lifecycleRequestMatches, openGates, } from "@kontourai/flow";
|
|
8
10
|
import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
|
|
9
11
|
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
|
|
10
|
-
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError,
|
|
12
|
+
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderFlowRun, evaluateBuilderFlowRun, loadBuilderFlowRun, pauseBuilderFlowRun, resumeBuilderFlowRun, startBuilderFlowRun, } from "./builder-flow-run-adapter.js";
|
|
11
13
|
export async function startBuilderFlowSession(input) {
|
|
12
14
|
const context = resolveSessionContext(input.sessionDir);
|
|
13
15
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
14
16
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
17
|
+
const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
|
|
15
18
|
let run;
|
|
16
19
|
try {
|
|
17
|
-
run = await
|
|
20
|
+
run = await loadBuilderFlowRun({
|
|
18
21
|
cwd: context.projectRoot,
|
|
19
22
|
runId: context.slug,
|
|
20
23
|
});
|
|
24
|
+
if (run.definitionId !== requestedFlowId) {
|
|
25
|
+
throw new BuilderBuildRunInputError("flowId", `requested ${requestedFlowId} does not match the existing ${run.definitionId} run; start builder.build from a provider Work Item instead of retrying a local shape session`);
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
catch (error) {
|
|
23
29
|
if (!isRunNotFound(error))
|
|
24
30
|
throw error;
|
|
25
|
-
run = await
|
|
31
|
+
run = await startBuilderFlowRun({
|
|
26
32
|
cwd: context.projectRoot,
|
|
27
33
|
runId: context.slug,
|
|
28
34
|
subject,
|
|
35
|
+
flowId: requestedFlowId,
|
|
29
36
|
params: {
|
|
30
37
|
subject,
|
|
31
38
|
},
|
|
@@ -38,7 +45,7 @@ export async function syncBuilderFlowSession(input) {
|
|
|
38
45
|
const context = resolveSessionContext(input.sessionDir);
|
|
39
46
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
40
47
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
41
|
-
const run = await
|
|
48
|
+
const run = await loadBuilderFlowRun({
|
|
42
49
|
cwd: context.projectRoot,
|
|
43
50
|
runId: context.slug,
|
|
44
51
|
});
|
|
@@ -49,7 +56,7 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
49
56
|
const context = resolveSessionContext(input.sessionDir);
|
|
50
57
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
51
58
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
52
|
-
const run = await
|
|
59
|
+
const run = await loadBuilderFlowRun({
|
|
53
60
|
cwd: context.projectRoot,
|
|
54
61
|
runId: context.slug,
|
|
55
62
|
});
|
|
@@ -64,6 +71,20 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
64
71
|
attached: false,
|
|
65
72
|
};
|
|
66
73
|
}
|
|
74
|
+
export async function inspectBuilderFlowSession(input) {
|
|
75
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
76
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
77
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
78
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
79
|
+
assertRunSubjectBinding(run, subject);
|
|
80
|
+
return {
|
|
81
|
+
sessionDir: context.sessionDir,
|
|
82
|
+
projectRoot: context.projectRoot,
|
|
83
|
+
run,
|
|
84
|
+
projection: projectFlowRun(context, run, sidecarSnapshot.state),
|
|
85
|
+
attached: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
67
88
|
export async function pauseBuilderFlowSession(input) {
|
|
68
89
|
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
69
90
|
}
|
|
@@ -75,7 +96,7 @@ export async function cancelBuilderFlowSession(input) {
|
|
|
75
96
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
76
97
|
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
77
98
|
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
78
|
-
const changed = await
|
|
99
|
+
const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
79
100
|
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
80
101
|
actorKey: prepared.authorization.assignment_actor_key,
|
|
81
102
|
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
@@ -91,7 +112,7 @@ export async function releaseBuilderFlowAssignment(input) {
|
|
|
91
112
|
const context = resolveSessionContext(input.sessionDir);
|
|
92
113
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
93
114
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
94
|
-
const run = await
|
|
115
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
95
116
|
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
96
117
|
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
97
118
|
});
|
|
@@ -104,7 +125,7 @@ export async function archiveBuilderFlowSession(input) {
|
|
|
104
125
|
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
105
126
|
if (priorConsumption && !recoveringPreparedArchive)
|
|
106
127
|
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
107
|
-
const run = await
|
|
128
|
+
const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
108
129
|
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
109
130
|
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
110
131
|
}
|
|
@@ -145,7 +166,7 @@ async function changeBuilderFlowSessionLifecycle(input, operation) {
|
|
|
145
166
|
const context = resolveSessionContext(input.sessionDir);
|
|
146
167
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
147
168
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
148
|
-
const change = operation === "pause" ?
|
|
169
|
+
const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
|
|
149
170
|
const at = new Date().toISOString();
|
|
150
171
|
const run = await change({
|
|
151
172
|
cwd: context.projectRoot,
|
|
@@ -171,7 +192,7 @@ async function prepareAuthorizedLifecycleChange(input, operation, context) {
|
|
|
171
192
|
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
172
193
|
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
|
|
173
194
|
: null;
|
|
174
|
-
const canonicalRun = await
|
|
195
|
+
const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
175
196
|
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
176
197
|
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
177
198
|
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
@@ -245,20 +266,6 @@ function clearCurrentPointers(artifactRoot, slug) {
|
|
|
245
266
|
fs.unlinkSync(file);
|
|
246
267
|
}
|
|
247
268
|
}
|
|
248
|
-
export async function syncBuilderFlowSessionIfPresent(sessionDir) {
|
|
249
|
-
let context;
|
|
250
|
-
try {
|
|
251
|
-
context = resolveSessionContext(sessionDir);
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir")
|
|
255
|
-
return null;
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
if (!fs.existsSync(runDir(context.slug, context.projectRoot)))
|
|
259
|
-
return null;
|
|
260
|
-
return syncBuilderFlowSession({ sessionDir });
|
|
261
|
-
}
|
|
262
269
|
async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
263
270
|
let run = initial;
|
|
264
271
|
let attached = false;
|
|
@@ -267,25 +274,35 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
|
267
274
|
throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
|
|
268
275
|
}
|
|
269
276
|
if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
277
|
+
const snapshot = stageTrustBundleSnapshot(context);
|
|
278
|
+
try {
|
|
279
|
+
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
280
|
+
const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state.subject, context.projectRoot);
|
|
281
|
+
if (gateEvidence) {
|
|
282
|
+
const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === snapshot.sha256);
|
|
283
|
+
if (!alreadyAttached) {
|
|
284
|
+
const supersede = manifestEvidence(run.manifest)
|
|
285
|
+
.filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
|
|
286
|
+
.map((entry) => String(entry.id));
|
|
287
|
+
run = await evaluateBuilderFlowRun({
|
|
288
|
+
cwd: context.projectRoot,
|
|
289
|
+
runId: context.slug,
|
|
290
|
+
evidence: {
|
|
291
|
+
gate: gates[0].id,
|
|
292
|
+
file: path.relative(context.projectRoot, snapshot.file),
|
|
293
|
+
expectedSha256: snapshot.sha256,
|
|
294
|
+
...(supersede.length > 0 ? { supersede } : {}),
|
|
295
|
+
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
296
|
+
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
attached = true;
|
|
300
|
+
}
|
|
287
301
|
}
|
|
288
302
|
}
|
|
303
|
+
finally {
|
|
304
|
+
removeTrustBundleSnapshot(snapshot);
|
|
305
|
+
}
|
|
289
306
|
}
|
|
290
307
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
291
308
|
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
@@ -352,10 +369,15 @@ function workflowSubject(state) {
|
|
|
352
369
|
}
|
|
353
370
|
return refs[0];
|
|
354
371
|
}
|
|
372
|
+
function persistedFlowId(state) {
|
|
373
|
+
const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
|
|
374
|
+
const flowId = flowRun?.definition_id;
|
|
375
|
+
return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
|
|
376
|
+
}
|
|
355
377
|
function openGatesForResult(run) {
|
|
356
378
|
return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
|
|
357
379
|
}
|
|
358
|
-
function bundleGateEvidence(bundle, gate, subject) {
|
|
380
|
+
async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
|
|
359
381
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims))
|
|
360
382
|
return null;
|
|
361
383
|
const selectors = expectationsForGate(gate).map((expectation) => expectation.bundle_claim);
|
|
@@ -387,8 +409,247 @@ function bundleGateEvidence(bundle, gate, subject) {
|
|
|
387
409
|
if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
|
|
388
410
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
|
|
389
411
|
}
|
|
412
|
+
if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
413
|
+
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
414
|
+
}
|
|
390
415
|
return { failed, routeReason };
|
|
391
416
|
}
|
|
417
|
+
async function assertVerifiedTestsTrust(claims, projectRoot) {
|
|
418
|
+
const testClaims = claims.filter((claim) => isRecord(claim)
|
|
419
|
+
&& claim.claimType === "builder.verify.tests"
|
|
420
|
+
&& claim.value === "pass"
|
|
421
|
+
&& isRecord(claim.metadata)
|
|
422
|
+
&& isRecord(claim.metadata.gate_claim)
|
|
423
|
+
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
424
|
+
if (testClaims.length === 0)
|
|
425
|
+
throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
426
|
+
const liveCritiques = claims.filter((claim) => isRecord(claim)
|
|
427
|
+
&& isRecord(claim.metadata)
|
|
428
|
+
&& claim.metadata.origin === "critique"
|
|
429
|
+
&& typeof claim.metadata.superseded_by !== "string");
|
|
430
|
+
if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
|
|
431
|
+
throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
|
|
432
|
+
}
|
|
433
|
+
const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor) => typeof actor === "string" && actor.length > 0));
|
|
434
|
+
if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
|
|
435
|
+
throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
|
|
436
|
+
}
|
|
437
|
+
await Promise.all(liveCritiques.flatMap(async (claim) => {
|
|
438
|
+
const artifacts = reviewedArtifacts(claim);
|
|
439
|
+
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
440
|
+
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
441
|
+
}));
|
|
442
|
+
const criteria = claims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
443
|
+
if (criteria.length === 0 || criteria.some((claim) => {
|
|
444
|
+
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
445
|
+
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
446
|
+
})) {
|
|
447
|
+
throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
|
|
448
|
+
}
|
|
449
|
+
for (const testClaim of testClaims)
|
|
450
|
+
assertObservedTestsEvidence(testClaim, criteria);
|
|
451
|
+
}
|
|
452
|
+
function assertObservedTestsEvidence(testClaim, criteria) {
|
|
453
|
+
const observed = testClaim.metadata.observed_commands;
|
|
454
|
+
if (!Array.isArray(observed) || observed.length === 0) {
|
|
455
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
|
|
456
|
+
}
|
|
457
|
+
const commands = new Set();
|
|
458
|
+
for (const entry of observed) {
|
|
459
|
+
if (!isRecord(entry) || typeof entry.command !== "string" || entry.exit_code !== 0 || !Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256) || commands.has(entry.command)) {
|
|
460
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain unique commands with exit_code 0, a positive executed-test count, and SHA-256 output digests");
|
|
461
|
+
}
|
|
462
|
+
commands.add(entry.command);
|
|
463
|
+
}
|
|
464
|
+
const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
|
|
465
|
+
const topCommands = new Set(topRefs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
|
|
466
|
+
if ([...commands].some((command) => !topCommands.has(command))) {
|
|
467
|
+
throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
|
|
468
|
+
}
|
|
469
|
+
const criterionCommands = new Set();
|
|
470
|
+
for (const claim of criteria) {
|
|
471
|
+
const criterion = claim.metadata.criterion;
|
|
472
|
+
const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
|
|
473
|
+
const matched = refs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
|
|
474
|
+
if (matched.length === 0)
|
|
475
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
|
|
476
|
+
matched.forEach((command) => criterionCommands.add(command));
|
|
477
|
+
}
|
|
478
|
+
if ([...commands].some((command) => !criterionCommands.has(command))) {
|
|
479
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function isSubstantivePassingCritique(claim) {
|
|
483
|
+
if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding) => isRecord(finding) && finding.status === "open")))
|
|
484
|
+
return false;
|
|
485
|
+
const lanes = claim.metadata.lanes;
|
|
486
|
+
return Array.isArray(lanes)
|
|
487
|
+
&& lanes.length > 0
|
|
488
|
+
&& lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
|
|
489
|
+
&& reviewedArtifacts(claim).length > 0;
|
|
490
|
+
}
|
|
491
|
+
function reviewedArtifacts(claim) {
|
|
492
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
493
|
+
const artifacts = reviewTarget?.artifacts;
|
|
494
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0)
|
|
495
|
+
return [];
|
|
496
|
+
if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256)))
|
|
497
|
+
return [];
|
|
498
|
+
return artifacts;
|
|
499
|
+
}
|
|
500
|
+
function assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot) {
|
|
501
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
502
|
+
const expected = reviewTarget?.workspace_snapshot;
|
|
503
|
+
if (!isRecord(expected)) {
|
|
504
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
|
|
505
|
+
}
|
|
506
|
+
const current = captureReviewWorkspaceSnapshot(projectRoot, expected.kind === "reviewed-files"
|
|
507
|
+
? reviewedWorkspaceFiles(expected)
|
|
508
|
+
: artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })));
|
|
509
|
+
if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
|
|
510
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
|
|
511
|
+
}
|
|
512
|
+
if (expected.digest !== current.digest) {
|
|
513
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
|
|
514
|
+
}
|
|
515
|
+
if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
|
|
516
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
|
|
517
|
+
}
|
|
518
|
+
if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
|
|
519
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function gitWorktreeSnapshot(projectRoot) {
|
|
523
|
+
const root = fs.realpathSync(projectRoot);
|
|
524
|
+
const hasGitMarker = fs.existsSync(path.join(root, ".git"));
|
|
525
|
+
try {
|
|
526
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
527
|
+
if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
|
|
528
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
|
|
529
|
+
}
|
|
530
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
531
|
+
const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
|
|
532
|
+
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
|
|
533
|
+
.toString("utf8").split("\0").filter(Boolean).sort();
|
|
534
|
+
const hash = createHash("sha256");
|
|
535
|
+
hash.update("flow-agents:git-worktree:v1\0");
|
|
536
|
+
hash.update(headSha).update("\0");
|
|
537
|
+
hash.update(trackedDiff).update("\0");
|
|
538
|
+
for (const file of untracked) {
|
|
539
|
+
const absolute = path.resolve(root, file);
|
|
540
|
+
if (!pathIsWithin(absolute, root))
|
|
541
|
+
throw new Error("untracked file escapes repository root");
|
|
542
|
+
const stat = fs.lstatSync(absolute);
|
|
543
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
544
|
+
throw new Error("untracked entry is not a regular file");
|
|
545
|
+
hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
546
|
+
}
|
|
547
|
+
return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
|
|
551
|
+
if (error instanceof BuilderBuildRunInputError)
|
|
552
|
+
throw error;
|
|
553
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
export function captureReviewWorkspaceSnapshot(projectRoot, reviewedFiles) {
|
|
559
|
+
return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
|
|
560
|
+
}
|
|
561
|
+
function reviewedWorkspaceFiles(snapshot) {
|
|
562
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
|
|
563
|
+
|| !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
|
|
564
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
|
|
565
|
+
}
|
|
566
|
+
const files = snapshot.files.map((file) => ({ file: file.file, sha256: file.sha256 })).sort((left, right) => left.file.localeCompare(right.file));
|
|
567
|
+
if (new Set(files.map((file) => file.file)).size !== files.length) {
|
|
568
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
|
|
569
|
+
}
|
|
570
|
+
return files;
|
|
571
|
+
}
|
|
572
|
+
function reviewedFilesSnapshot(projectRoot, reviewedFiles) {
|
|
573
|
+
const files = reviewedFiles.map((file) => ({ ...file }));
|
|
574
|
+
const hash = createHash("sha256");
|
|
575
|
+
hash.update("flow-agents:reviewed-files:v1\0");
|
|
576
|
+
for (const artifact of files) {
|
|
577
|
+
const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
578
|
+
hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
579
|
+
}
|
|
580
|
+
return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
|
|
581
|
+
}
|
|
582
|
+
async function assertReviewedArtifactDigest(artifact, projectRoot) {
|
|
583
|
+
const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
584
|
+
if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
|
|
585
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function safeReviewedArtifactPath(projectRoot, file) {
|
|
589
|
+
const canonicalRoot = fs.realpathSync(projectRoot);
|
|
590
|
+
const candidate = path.resolve(canonicalRoot, file);
|
|
591
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
592
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
593
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
|
|
594
|
+
}
|
|
595
|
+
let canonicalArtifact;
|
|
596
|
+
try {
|
|
597
|
+
canonicalArtifact = fs.realpathSync(candidate);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
|
|
601
|
+
}
|
|
602
|
+
const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
|
|
603
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
|
|
604
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
|
|
605
|
+
}
|
|
606
|
+
return canonicalArtifact;
|
|
607
|
+
}
|
|
608
|
+
function stageTrustBundleSnapshot(context) {
|
|
609
|
+
assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
|
|
610
|
+
const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
611
|
+
let raw;
|
|
612
|
+
try {
|
|
613
|
+
const stat = fs.fstatSync(source);
|
|
614
|
+
if (!stat.isFile())
|
|
615
|
+
throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
|
|
616
|
+
raw = fs.readFileSync(source);
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
fs.closeSync(source);
|
|
620
|
+
}
|
|
621
|
+
const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
|
|
622
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
623
|
+
assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
|
|
624
|
+
fs.chmodSync(directory, 0o700);
|
|
625
|
+
const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
|
|
626
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
627
|
+
try {
|
|
628
|
+
fs.writeFileSync(descriptor, raw);
|
|
629
|
+
fs.fsyncSync(descriptor);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
fs.closeSync(descriptor);
|
|
633
|
+
}
|
|
634
|
+
fs.chmodSync(file, 0o400);
|
|
635
|
+
return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
|
|
636
|
+
}
|
|
637
|
+
function removeTrustBundleSnapshot(snapshot) {
|
|
638
|
+
try {
|
|
639
|
+
fs.unlinkSync(snapshot.file);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (!isRecord(error) || error.code !== "ENOENT")
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
fs.rmdirSync(path.dirname(snapshot.file));
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY"))
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
392
653
|
function workflowSubjectRef(claim) {
|
|
393
654
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
394
655
|
return metadata && typeof metadata.workflow_subject_ref === "string"
|
|
@@ -404,7 +665,7 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
404
665
|
const complete = run.state.status === "completed";
|
|
405
666
|
const paused = run.state.status === "paused";
|
|
406
667
|
const canceled = run.state.status === "canceled";
|
|
407
|
-
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
|
|
668
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
408
669
|
if (!action) {
|
|
409
670
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
410
671
|
}
|
|
@@ -505,7 +766,7 @@ function prepareProjectionWrites(context, projection, expectedStateRaw, operatio
|
|
|
505
766
|
continue;
|
|
506
767
|
const output = {
|
|
507
768
|
...pointer,
|
|
508
|
-
active_flow_id:
|
|
769
|
+
active_flow_id: projection.flow_run.definition_id,
|
|
509
770
|
active_step_id: projection.flow_run.current_step,
|
|
510
771
|
updated_at: projection.updated_at,
|
|
511
772
|
};
|
|
@@ -602,11 +863,11 @@ function writeExistingFileNoFollow(file, content) {
|
|
|
602
863
|
fs.closeSync(descriptor);
|
|
603
864
|
}
|
|
604
865
|
}
|
|
605
|
-
function stepAction(stepId) {
|
|
866
|
+
function stepAction(flowId, stepId) {
|
|
606
867
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
|
|
607
868
|
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
608
869
|
const action = actions.find((candidate) => isRecord(candidate)
|
|
609
|
-
&& candidate.flow_id ===
|
|
870
|
+
&& candidate.flow_id === flowId
|
|
610
871
|
&& candidate.step_id === stepId);
|
|
611
872
|
if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill) => typeof skill === "string")) {
|
|
612
873
|
return null;
|
|
@@ -66,13 +66,10 @@ export declare function writeLocalRecord(artifactRoot: string, subjectId: string
|
|
|
66
66
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
67
67
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
68
68
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
69
|
-
* races against the built CLI).
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
74
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
75
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
69
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
70
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
71
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
72
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
76
73
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
77
74
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
78
75
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { parseArgs, flagString } from "../lib/args.js";
|
|
@@ -186,18 +187,27 @@ function subjectLockDir(artifactRoot, subjectId) {
|
|
|
186
187
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
187
188
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
188
189
|
}
|
|
190
|
+
// Lock age is adjudicated by the current contender, never by metadata written
|
|
191
|
+
// by the lock owner. The environment is only an operator tuning input; clamp it
|
|
192
|
+
// so a caller cannot turn a transient owner-file write into immediate takeover.
|
|
193
|
+
const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
|
|
194
|
+
const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
|
|
195
|
+
const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
|
|
196
|
+
function trustedSubjectLockStaleMs() {
|
|
197
|
+
const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
|
|
198
|
+
if (!Number.isFinite(configured))
|
|
199
|
+
return SUBJECT_LOCK_STALE_DEFAULT_MS;
|
|
200
|
+
return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
|
|
201
|
+
}
|
|
189
202
|
/**
|
|
190
203
|
* F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
|
|
191
204
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
192
205
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
193
206
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
194
|
-
* races against the built CLI).
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
199
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
200
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
207
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
208
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
209
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
210
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
201
211
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
202
212
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
203
213
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -208,23 +218,33 @@ function subjectLockDir(artifactRoot, subjectId) {
|
|
|
208
218
|
*/
|
|
209
219
|
export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
210
220
|
const lockDir = subjectLockDir(artifactRoot, subjectId);
|
|
211
|
-
const staleMs =
|
|
221
|
+
const staleMs = trustedSubjectLockStaleMs();
|
|
222
|
+
const token = randomBytes(16).toString("hex");
|
|
223
|
+
const ownerFile = path.join(lockDir, "owner.json");
|
|
212
224
|
const deadline = Date.now() + 30000;
|
|
213
225
|
while (true) {
|
|
226
|
+
let createdLockDir = false;
|
|
214
227
|
try {
|
|
215
228
|
fs.mkdirSync(lockDir);
|
|
229
|
+
createdLockDir = true;
|
|
230
|
+
fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
|
|
216
231
|
break;
|
|
217
232
|
}
|
|
218
233
|
catch (error) {
|
|
219
234
|
const lockError = error;
|
|
235
|
+
if (createdLockDir)
|
|
236
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
220
237
|
if (lockError.code !== "EEXIST") {
|
|
221
238
|
throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
|
|
222
239
|
}
|
|
223
240
|
try {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
241
|
+
const owner = readSubjectLockOwner(ownerFile);
|
|
242
|
+
const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
|
|
243
|
+
if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
|
|
244
|
+
throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
|
|
245
|
+
}
|
|
246
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
247
|
+
throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
|
|
228
248
|
}
|
|
229
249
|
}
|
|
230
250
|
catch (statError) {
|
|
@@ -238,7 +258,14 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
|
238
258
|
sleepSync(20);
|
|
239
259
|
}
|
|
240
260
|
}
|
|
241
|
-
|
|
261
|
+
let heartbeat;
|
|
262
|
+
const ownsLock = () => readSubjectLockOwner(ownerFile)?.token === token;
|
|
263
|
+
const release = () => {
|
|
264
|
+
if (heartbeat)
|
|
265
|
+
clearInterval(heartbeat);
|
|
266
|
+
if (ownsLock())
|
|
267
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
268
|
+
};
|
|
242
269
|
let result;
|
|
243
270
|
try {
|
|
244
271
|
result = body();
|
|
@@ -248,11 +275,38 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
|
248
275
|
throw error;
|
|
249
276
|
}
|
|
250
277
|
if (result && typeof result.then === "function") {
|
|
278
|
+
// An async owner can legitimately hold the lock longer than the stale-lock
|
|
279
|
+
// threshold while an authority-bound command is running. Keep its mtime fresh
|
|
280
|
+
// so lifecycle operations and takeovers continue to observe the live lock.
|
|
281
|
+
const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
|
|
282
|
+
heartbeat = setInterval(() => {
|
|
283
|
+
try {
|
|
284
|
+
if (!ownsLock())
|
|
285
|
+
return;
|
|
286
|
+
const timestamp = new Date();
|
|
287
|
+
fs.utimesSync(ownerFile, timestamp, timestamp);
|
|
288
|
+
fs.utimesSync(lockDir, timestamp, timestamp);
|
|
289
|
+
}
|
|
290
|
+
catch { /* release, reclamation, or process teardown owns cleanup */ }
|
|
291
|
+
}, heartbeatMs);
|
|
251
292
|
return Promise.resolve(result).finally(release);
|
|
252
293
|
}
|
|
253
294
|
release();
|
|
254
295
|
return result;
|
|
255
296
|
}
|
|
297
|
+
function readSubjectLockOwner(file) {
|
|
298
|
+
try {
|
|
299
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
300
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
301
|
+
? value
|
|
302
|
+
: null;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
306
|
+
return null;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
256
310
|
/**
|
|
257
311
|
* The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
|
|
258
312
|
* §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
|