@kontourai/flow-agents 3.4.2 → 3.5.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 +14 -0
- package/agents/dev.json +0 -5
- package/build/src/builder-flow-runtime.js +18 -12
- package/build/src/cli/assignment-provider.d.ts +3 -0
- package/build/src/cli/assignment-provider.js +59 -10
- package/build/src/cli/workflow-sidecar.js +186 -26
- package/build/src/lib/flow-resolver.js +7 -2
- package/build/src/tools/build-universal-bundles.js +0 -2
- package/context/contracts/assignment-provider-contract.md +5 -2
- package/context/scripts/hooks/workflow-steering.js +2 -40
- package/docs/spec/builder-flow-runtime.md +19 -7
- package/docs/spec/runtime-hook-surface.md +4 -4
- package/evals/integration/test_assignment_provider_local_file.sh +54 -0
- package/evals/integration/test_builder_entry_enforcement.sh +369 -22
- package/evals/integration/test_builder_step_producers.sh +1 -1
- package/evals/integration/test_bundle_install.sh +46 -3
- package/evals/integration/test_current_json_per_actor.sh +3 -2
- package/evals/integration/test_dual_emit_flow_step.sh +43 -9
- package/evals/integration/test_flowdef_session_activation.sh +15 -6
- package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
- package/evals/integration/test_install_merge.sh +24 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +14 -10
- package/evals/integration/test_resolvefirststep_security.sh +1 -1
- package/evals/integration/test_sidecar_field_preservation.sh +4 -2
- package/evals/integration/test_takeover_protocol.sh +1 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +18 -6
- package/evals/integration/test_workflow_steering_hook.sh +0 -72
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +1 -0
- package/scripts/hooks/workflow-steering.js +2 -40
- package/scripts/install-merge.js +2 -0
- package/src/builder-flow-runtime.ts +23 -12
- package/src/cli/assignment-provider.ts +55 -11
- package/src/cli/builder-flow-runtime.test.mjs +32 -0
- package/src/cli/workflow-sidecar.ts +183 -28
- package/src/lib/flow-resolver.ts +6 -2
- package/src/tools/build-universal-bundles.ts +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.5.0](https://github.com/kontourai/flow-agents/compare/v3.4.3...v3.5.0) (2026-07-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **builder:** derive selected-work evidence from acquisition ([#543](https://github.com/kontourai/flow-agents/issues/543)) ([bfe1681](https://github.com/kontourai/flow-agents/commit/bfe1681211174cb6219b0dd94d765aa9c99734bd))
|
|
9
|
+
|
|
10
|
+
## [3.4.3](https://github.com/kontourai/flow-agents/compare/v3.4.2...v3.4.3) (2026-07-10)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixes
|
|
14
|
+
|
|
15
|
+
* start Builder Flow during session creation ([#539](https://github.com/kontourai/flow-agents/issues/539)) ([10214b4](https://github.com/kontourai/flow-agents/commit/10214b478530e522f0dc50c24175d1516c113431))
|
|
16
|
+
|
|
3
17
|
## [3.4.2](https://github.com/kontourai/flow-agents/compare/v3.4.1...v3.4.2) (2026-07-10)
|
|
4
18
|
|
|
5
19
|
|
package/agents/dev.json
CHANGED
|
@@ -53,11 +53,6 @@
|
|
|
53
53
|
"matcher": "*",
|
|
54
54
|
"timeout_ms": 3000
|
|
55
55
|
},
|
|
56
|
-
{
|
|
57
|
-
"command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:workflow-entry workflow-steering.js standard,strict",
|
|
58
|
-
"matcher": "*",
|
|
59
|
-
"timeout_ms": 5000
|
|
60
|
-
},
|
|
61
56
|
{
|
|
62
57
|
"command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:config-protection config-protection.js standard,strict",
|
|
63
58
|
"matcher": "fs_write",
|
|
@@ -26,15 +26,19 @@ export async function startBuilderFlowSession(input) {
|
|
|
26
26
|
},
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
assertRunSubjectBinding(run, subject);
|
|
30
|
+
return syncAndProject(context, run, sidecarSnapshot);
|
|
30
31
|
}
|
|
31
32
|
export async function syncBuilderFlowSession(input) {
|
|
32
33
|
const context = resolveSessionContext(input.sessionDir);
|
|
34
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
35
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
33
36
|
const run = await loadBuilderBuildRun({
|
|
34
37
|
cwd: context.projectRoot,
|
|
35
38
|
runId: context.slug,
|
|
36
39
|
});
|
|
37
|
-
|
|
40
|
+
assertRunSubjectBinding(run, subject);
|
|
41
|
+
return syncAndProject(context, run, sidecarSnapshot);
|
|
38
42
|
}
|
|
39
43
|
export async function recoverBuilderFlowSession(input) {
|
|
40
44
|
const context = resolveSessionContext(input.sessionDir);
|
|
@@ -44,14 +48,7 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
44
48
|
cwd: context.projectRoot,
|
|
45
49
|
runId: context.slug,
|
|
46
50
|
});
|
|
47
|
-
|
|
48
|
-
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
49
|
-
}
|
|
50
|
-
if (isRecord(run.state.params)
|
|
51
|
-
&& Object.prototype.hasOwnProperty.call(run.state.params, "subject")
|
|
52
|
-
&& run.state.params.subject !== subject) {
|
|
53
|
-
throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
|
|
54
|
-
}
|
|
51
|
+
assertRunSubjectBinding(run, subject);
|
|
55
52
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
56
53
|
writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
|
|
57
54
|
return {
|
|
@@ -76,7 +73,7 @@ export async function syncBuilderFlowSessionIfPresent(sessionDir) {
|
|
|
76
73
|
return null;
|
|
77
74
|
return syncBuilderFlowSession({ sessionDir });
|
|
78
75
|
}
|
|
79
|
-
async function syncAndProject(context, initial) {
|
|
76
|
+
async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
80
77
|
let run = initial;
|
|
81
78
|
let attached = false;
|
|
82
79
|
const gates = openGatesForResult(run);
|
|
@@ -104,7 +101,6 @@ async function syncAndProject(context, initial) {
|
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
|
-
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
108
104
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
109
105
|
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
110
106
|
return {
|
|
@@ -115,6 +111,16 @@ async function syncAndProject(context, initial) {
|
|
|
115
111
|
attached,
|
|
116
112
|
};
|
|
117
113
|
}
|
|
114
|
+
function assertRunSubjectBinding(run, subject) {
|
|
115
|
+
if (run.state.subject !== subject) {
|
|
116
|
+
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
117
|
+
}
|
|
118
|
+
if (isRecord(run.state.params)
|
|
119
|
+
&& Object.prototype.hasOwnProperty.call(run.state.params, "subject")
|
|
120
|
+
&& run.state.params.subject !== subject) {
|
|
121
|
+
throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
118
124
|
function resolveSessionContext(sessionDirInput) {
|
|
119
125
|
const sessionDir = path.resolve(sessionDirInput);
|
|
120
126
|
const artifactRoot = path.dirname(sessionDir);
|
|
@@ -31,6 +31,7 @@ export type AssignmentClaimRecord = {
|
|
|
31
31
|
subject_id: string;
|
|
32
32
|
actor: ActorStruct;
|
|
33
33
|
actor_key?: string;
|
|
34
|
+
work_item_ref?: string;
|
|
34
35
|
claimed_at: string;
|
|
35
36
|
ttl_seconds: number;
|
|
36
37
|
branch: string;
|
|
@@ -116,6 +117,7 @@ export declare function performLocalClaim(artifactRoot: string, subjectId: strin
|
|
|
116
117
|
artifactDir: string;
|
|
117
118
|
reason?: string;
|
|
118
119
|
actorKey?: string;
|
|
120
|
+
workItemRef?: string;
|
|
119
121
|
}): AssignmentClaimRecord;
|
|
120
122
|
/**
|
|
121
123
|
* Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
|
|
@@ -177,6 +179,7 @@ export declare function performLocalSupersede(artifactRoot: string, subjectId: s
|
|
|
177
179
|
artifactDir?: string;
|
|
178
180
|
reason?: string;
|
|
179
181
|
actorKey?: string;
|
|
182
|
+
workItemRef?: string;
|
|
180
183
|
}): AssignmentClaimRecord;
|
|
181
184
|
/**
|
|
182
185
|
* Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
|
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs, flagString } from "../lib/args.js";
|
|
7
|
-
import {
|
|
7
|
+
import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
|
|
8
8
|
const DEFAULT_LABEL_NAME = "agent:claimed";
|
|
9
9
|
const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
|
|
10
10
|
/**
|
|
@@ -95,19 +95,59 @@ export function assignmentFilePath(artifactRoot, subjectId) {
|
|
|
95
95
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
96
96
|
return path.join(artifactRoot, "assignment", `${sanitized}.json`);
|
|
97
97
|
}
|
|
98
|
+
function localAssignmentDir(artifactRoot, create) {
|
|
99
|
+
const dir = path.join(artifactRoot, "assignment");
|
|
100
|
+
if (create)
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = fs.lstatSync(dir);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!create && error.code === "ENOENT")
|
|
108
|
+
return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
112
|
+
throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
|
|
113
|
+
}
|
|
114
|
+
const realRoot = fs.realpathSync(artifactRoot);
|
|
115
|
+
if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
|
|
116
|
+
throw new Error(`assignment directory escapes the artifact root: ${dir}`);
|
|
117
|
+
}
|
|
118
|
+
return dir;
|
|
119
|
+
}
|
|
98
120
|
export function readLocalRecord(artifactRoot, subjectId) {
|
|
99
|
-
|
|
100
|
-
if (!fs.existsSync(file))
|
|
121
|
+
if (!localAssignmentDir(artifactRoot, false))
|
|
101
122
|
return null;
|
|
123
|
+
const file = assignmentFilePath(artifactRoot, subjectId);
|
|
124
|
+
let stat;
|
|
125
|
+
try {
|
|
126
|
+
stat = fs.lstatSync(file);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code === "ENOENT")
|
|
130
|
+
return null;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
134
|
+
throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
|
|
135
|
+
}
|
|
102
136
|
let data;
|
|
137
|
+
let descriptor = null;
|
|
103
138
|
try {
|
|
104
|
-
|
|
139
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
140
|
+
data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
105
141
|
}
|
|
106
142
|
catch (error) {
|
|
107
143
|
// Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
|
|
108
144
|
// would be a fail-open path that could let a second claim silently overwrite a real one.
|
|
109
145
|
throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${error.message}`);
|
|
110
146
|
}
|
|
147
|
+
finally {
|
|
148
|
+
if (descriptor !== null)
|
|
149
|
+
fs.closeSync(descriptor);
|
|
150
|
+
}
|
|
111
151
|
if (typeof data !== "object" || data === null)
|
|
112
152
|
throw new Error(`assignment record is not an object: ${file}`);
|
|
113
153
|
const record = data;
|
|
@@ -119,7 +159,7 @@ export function writeLocalRecord(artifactRoot, subjectId, record) {
|
|
|
119
159
|
// writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
|
|
120
160
|
// propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
|
|
121
161
|
// never fail open (artifact-contract.md).
|
|
122
|
-
|
|
162
|
+
atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
|
|
123
163
|
}
|
|
124
164
|
/**
|
|
125
165
|
* Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
|
|
@@ -133,8 +173,7 @@ function sleepSync(ms) {
|
|
|
133
173
|
Atomics.wait(ia, 0, 0, ms);
|
|
134
174
|
}
|
|
135
175
|
function subjectLockDir(artifactRoot, subjectId) {
|
|
136
|
-
const assignmentDir =
|
|
137
|
-
fs.mkdirSync(assignmentDir, { recursive: true });
|
|
176
|
+
const assignmentDir = localAssignmentDir(artifactRoot, true);
|
|
138
177
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
139
178
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
140
179
|
}
|
|
@@ -190,12 +229,20 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
|
190
229
|
sleepSync(20);
|
|
191
230
|
}
|
|
192
231
|
}
|
|
232
|
+
const release = () => fs.rmSync(lockDir, { recursive: true, force: true });
|
|
233
|
+
let result;
|
|
193
234
|
try {
|
|
194
|
-
|
|
235
|
+
result = body();
|
|
195
236
|
}
|
|
196
|
-
|
|
197
|
-
|
|
237
|
+
catch (error) {
|
|
238
|
+
release();
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
if (result && typeof result.then === "function") {
|
|
242
|
+
return Promise.resolve(result).finally(release);
|
|
198
243
|
}
|
|
244
|
+
release();
|
|
245
|
+
return result;
|
|
199
246
|
}
|
|
200
247
|
/**
|
|
201
248
|
* The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
|
|
@@ -403,6 +450,7 @@ export function performLocalClaim(artifactRoot, subjectId, actor, opts) {
|
|
|
403
450
|
subject_id: subjectId,
|
|
404
451
|
actor,
|
|
405
452
|
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
453
|
+
...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
|
|
406
454
|
claimed_at: isoNow(),
|
|
407
455
|
ttl_seconds: opts.ttlSeconds,
|
|
408
456
|
branch: opts.branch,
|
|
@@ -574,6 +622,7 @@ export function performLocalSupersede(artifactRoot, subjectId, fromActor, toActo
|
|
|
574
622
|
subject_id: subjectId,
|
|
575
623
|
actor: toActor,
|
|
576
624
|
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
625
|
+
...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
|
|
577
626
|
claimed_at: isoNow(),
|
|
578
627
|
ttl_seconds: ttlSeconds,
|
|
579
628
|
branch: opts.branch ?? existing.branch,
|
|
@@ -9,12 +9,13 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
10
10
|
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
|
|
11
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
|
-
import {
|
|
12
|
+
import { ensureSafeDirectory } from "../lib/fs.js";
|
|
13
|
+
import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
|
|
13
14
|
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
14
15
|
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
15
16
|
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
16
17
|
// import — same idiom already used above for ../lib/flow-resolver.js).
|
|
17
|
-
import { computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus } from "./assignment-provider.js";
|
|
18
|
+
import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, withSubjectLock } from "./assignment-provider.js";
|
|
18
19
|
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
19
20
|
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
20
21
|
export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
@@ -55,14 +56,27 @@ function workItemSlug(ref) {
|
|
|
55
56
|
const [owner, repo] = parts;
|
|
56
57
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
57
58
|
}
|
|
59
|
+
function assignmentSubjectMatchesWorkItem(slug, ref) {
|
|
60
|
+
if (ref === `local:${slug}`)
|
|
61
|
+
return true;
|
|
62
|
+
if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref))
|
|
63
|
+
return false;
|
|
64
|
+
return workItemSlug(ref) === slug;
|
|
65
|
+
}
|
|
58
66
|
function sessionWorkItem(p, slug, dir) {
|
|
59
67
|
const providerRef = opt(p, "work-item");
|
|
68
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
69
|
+
const existingRef = Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string"
|
|
70
|
+
? existingState.work_item_refs[0]
|
|
71
|
+
: "";
|
|
60
72
|
if (providerRef) {
|
|
73
|
+
if (existingRef && existingRef !== providerRef) {
|
|
74
|
+
die(`ensure-session refused: session ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(existingRef)}, not ${JSON.stringify(providerRef)}`);
|
|
75
|
+
}
|
|
61
76
|
return { ref: providerRef };
|
|
62
77
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return { ref: existingState.work_item_refs[0] };
|
|
78
|
+
if (existingRef) {
|
|
79
|
+
return { ref: existingRef };
|
|
66
80
|
}
|
|
67
81
|
const title = opt(p, "title", slug).trim() || slug;
|
|
68
82
|
const body = opt(p, "summary").trim();
|
|
@@ -1292,9 +1306,13 @@ async function withLock(dir, create, command, body) {
|
|
|
1292
1306
|
const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
|
|
1293
1307
|
const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
1294
1308
|
const deadline = Date.now() + 30000;
|
|
1309
|
+
let acquiredRoot = null;
|
|
1310
|
+
let acquiredLock = null;
|
|
1295
1311
|
while (true) {
|
|
1296
1312
|
try {
|
|
1297
1313
|
fs.mkdirSync(lockDir);
|
|
1314
|
+
acquiredRoot = fs.lstatSync(dir);
|
|
1315
|
+
acquiredLock = fs.lstatSync(lockDir);
|
|
1298
1316
|
break;
|
|
1299
1317
|
}
|
|
1300
1318
|
catch (error) {
|
|
@@ -1326,7 +1344,29 @@ async function withLock(dir, create, command, body) {
|
|
|
1326
1344
|
return await body();
|
|
1327
1345
|
}
|
|
1328
1346
|
finally {
|
|
1329
|
-
|
|
1347
|
+
try {
|
|
1348
|
+
const currentRoot = fs.lstatSync(dir);
|
|
1349
|
+
const currentLock = fs.lstatSync(lockDir);
|
|
1350
|
+
const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
1351
|
+
if (acquiredRoot
|
|
1352
|
+
&& acquiredLock
|
|
1353
|
+
&& !currentRoot.isSymbolicLink()
|
|
1354
|
+
&& currentRoot.isDirectory()
|
|
1355
|
+
&& !currentLock.isSymbolicLink()
|
|
1356
|
+
&& currentLock.isDirectory()
|
|
1357
|
+
&& sameIdentity(currentRoot, acquiredRoot)
|
|
1358
|
+
&& sameIdentity(currentLock, acquiredLock)) {
|
|
1359
|
+
fs.rmdirSync(lockDir);
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
catch (error) {
|
|
1366
|
+
if (error.code !== "ENOENT") {
|
|
1367
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1330
1370
|
}
|
|
1331
1371
|
}
|
|
1332
1372
|
function section(text, heading) {
|
|
@@ -1411,6 +1451,15 @@ function sessionDirFor(root, slug) {
|
|
|
1411
1451
|
die("session directory must stay under artifact root");
|
|
1412
1452
|
return dir;
|
|
1413
1453
|
}
|
|
1454
|
+
function assertSafeSessionDirectory(root, dir) {
|
|
1455
|
+
if (!fs.existsSync(dir))
|
|
1456
|
+
return;
|
|
1457
|
+
const stat = fs.lstatSync(dir);
|
|
1458
|
+
const expected = path.join(fs.realpathSync(root), path.basename(dir));
|
|
1459
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(dir) !== expected) {
|
|
1460
|
+
die(`session directory must be a real directory under the artifact root: ${dir}`);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1414
1463
|
function validateAgentId(agent) {
|
|
1415
1464
|
if (!agent)
|
|
1416
1465
|
die("--agent-id is required");
|
|
@@ -1455,17 +1504,22 @@ function findRepoRootFromDirStrict(startDir) {
|
|
|
1455
1504
|
* Find the repository root by walking upward from a starting directory to locate
|
|
1456
1505
|
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1457
1506
|
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1458
|
-
* internal helper.
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1507
|
+
* internal helper. A canonical `.kontourai` path falls back to its owning project,
|
|
1508
|
+
* allowing installed package assets to resolve independently of ambient cwd. Other
|
|
1509
|
+
* layouts retain the cwd fallback used by standalone primitive sessions.
|
|
1461
1510
|
*
|
|
1462
|
-
* Do NOT use this
|
|
1463
|
-
*
|
|
1511
|
+
* Do NOT use this permissive variant for publishDelivery's repo-root resolution —
|
|
1512
|
+
* use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1464
1513
|
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1465
1514
|
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1466
1515
|
*/
|
|
1467
1516
|
function findRepoRootFromDir(startDir) {
|
|
1468
|
-
|
|
1517
|
+
const discovered = findRepoRootFromDirStrict(startDir);
|
|
1518
|
+
if (discovered)
|
|
1519
|
+
return discovered;
|
|
1520
|
+
if (path.basename(startDir) === ".kontourai")
|
|
1521
|
+
return path.dirname(startDir);
|
|
1522
|
+
return process.cwd();
|
|
1469
1523
|
}
|
|
1470
1524
|
/**
|
|
1471
1525
|
* Resolve the first step id from a FlowDefinition's steps[] list.
|
|
@@ -1744,10 +1798,10 @@ function loadJsonInputFile(file) {
|
|
|
1744
1798
|
* mitigation class: a hostile liveness event or a hand-crafted --effective-state-json fixture must
|
|
1745
1799
|
* never be able to smuggle raw control/ANSI bytes into this process's stderr/thrown message).
|
|
1746
1800
|
*/
|
|
1747
|
-
function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
1801
|
+
function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemRef) {
|
|
1748
1802
|
if (p.flags.has("skip-ownership-guard")) {
|
|
1749
1803
|
process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
|
|
1750
|
-
return;
|
|
1804
|
+
return null;
|
|
1751
1805
|
}
|
|
1752
1806
|
// Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
|
|
1753
1807
|
// hard-fails session creation. Without a resolvable actor there is no safe identity to claim
|
|
@@ -1755,7 +1809,7 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1755
1809
|
// than claiming under a synthetic/unstable identity.
|
|
1756
1810
|
if (resolution.unresolved) {
|
|
1757
1811
|
process.stderr.write("[ensure-session] ownership guard not evaluated: actor is unresolved (set --actor or FLOW_AGENTS_ACTOR, or run inside a supported runtime) — proceeding without a durable claim, exactly as ensure-session behaved before #291\n");
|
|
1758
|
-
return;
|
|
1812
|
+
return null;
|
|
1759
1813
|
}
|
|
1760
1814
|
// F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
|
|
1761
1815
|
// two-tier convention (64 for id-like fields, 240 for free text) rather than asserting one
|
|
@@ -1804,6 +1858,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1804
1858
|
// `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
|
|
1805
1859
|
// afterward would never recognize this guard's own claim as self.
|
|
1806
1860
|
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1861
|
+
if (workItemRef && assignment.record?.work_item_ref && assignment.record.work_item_ref !== workItemRef) {
|
|
1862
|
+
die(`ensure-session refused: assignment ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(assignment.record.work_item_ref)}, not ${JSON.stringify(workItemRef)}`);
|
|
1863
|
+
}
|
|
1807
1864
|
const events = readLivenessEvents(root);
|
|
1808
1865
|
const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
|
|
1809
1866
|
effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
|
|
@@ -1815,8 +1872,27 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1815
1872
|
// neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
|
|
1816
1873
|
// never a silent hole.
|
|
1817
1874
|
process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
|
|
1818
|
-
return;
|
|
1875
|
+
return null;
|
|
1819
1876
|
}
|
|
1877
|
+
const selectedWorkEvidence = () => {
|
|
1878
|
+
// Only a claim read from the local provider is durable evidence. Precomputed provider state
|
|
1879
|
+
// can authorize entry, but it cannot prove that this process acquired the Work Item.
|
|
1880
|
+
if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file")
|
|
1881
|
+
return null;
|
|
1882
|
+
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1883
|
+
const record = assignment.record;
|
|
1884
|
+
if (!record
|
|
1885
|
+
|| record.status !== "claimed"
|
|
1886
|
+
|| record.subject_id !== slug
|
|
1887
|
+
|| record.actor_key !== resolution.branchActorKey
|
|
1888
|
+
|| record.work_item_ref !== workItemRef) {
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
return {
|
|
1892
|
+
assignmentFile: assignmentFilePath(root, slug),
|
|
1893
|
+
actorKey: resolution.branchActorKey,
|
|
1894
|
+
};
|
|
1895
|
+
};
|
|
1820
1896
|
const resolveBranchForClaim = () => {
|
|
1821
1897
|
const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? loadJson(path.join(dir, "state.json")).branch : undefined;
|
|
1822
1898
|
return existingBranch || resolveSessionBranch(p, slug);
|
|
@@ -1829,17 +1905,17 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1829
1905
|
// effective_state computation instead of silently using a different identity.
|
|
1830
1906
|
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1831
1907
|
if (isSelf)
|
|
1832
|
-
return;
|
|
1908
|
+
return selectedWorkEvidence();
|
|
1833
1909
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1834
1910
|
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1835
1911
|
die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
|
|
1836
|
-
return;
|
|
1912
|
+
return null;
|
|
1837
1913
|
}
|
|
1838
1914
|
case "human-held": {
|
|
1839
1915
|
const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
|
|
1840
1916
|
const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
|
|
1841
1917
|
die(`ensure-session refused: subject ${sanitize(slug)} is assigned to ${assignee}${idleSuffix}. This guard never auto-reclaims a human assignment — confirm with the user before proceeding.`);
|
|
1842
|
-
return;
|
|
1918
|
+
return null;
|
|
1843
1919
|
}
|
|
1844
1920
|
case "reclaimable": {
|
|
1845
1921
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
@@ -1873,11 +1949,12 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1873
1949
|
// computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
|
|
1874
1950
|
// branchActorKey string on the next status/guard check, cross-tool.
|
|
1875
1951
|
actorKey: resolution.branchActorKey,
|
|
1952
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
1876
1953
|
});
|
|
1877
1954
|
// Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
|
|
1878
1955
|
// branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
|
|
1879
1956
|
printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
|
|
1880
|
-
return;
|
|
1957
|
+
return selectedWorkEvidence();
|
|
1881
1958
|
}
|
|
1882
1959
|
case "free": {
|
|
1883
1960
|
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
@@ -1887,8 +1964,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1887
1964
|
reason: opt(p, "reason", "ensure-session entry"),
|
|
1888
1965
|
// F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
|
|
1889
1966
|
actorKey: resolution.branchActorKey,
|
|
1967
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
1890
1968
|
});
|
|
1891
|
-
return;
|
|
1969
|
+
return selectedWorkEvidence();
|
|
1892
1970
|
}
|
|
1893
1971
|
default:
|
|
1894
1972
|
die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
|
|
@@ -1930,26 +2008,68 @@ function resolveEnsureSessionEntry(p, dir) {
|
|
|
1930
2008
|
}
|
|
1931
2009
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
1932
2010
|
}
|
|
2011
|
+
function assertCanonicalBuilderArtifactRoot(root) {
|
|
2012
|
+
const kontouraiRoot = path.dirname(root);
|
|
2013
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
2014
|
+
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
2015
|
+
die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
|
|
2016
|
+
}
|
|
2017
|
+
const statIfPresent = (candidate) => {
|
|
2018
|
+
try {
|
|
2019
|
+
return fs.lstatSync(candidate);
|
|
2020
|
+
}
|
|
2021
|
+
catch (error) {
|
|
2022
|
+
if (error.code === "ENOENT")
|
|
2023
|
+
return null;
|
|
2024
|
+
throw error;
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
const projectStat = statIfPresent(projectRoot);
|
|
2028
|
+
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
2029
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
|
|
2030
|
+
}
|
|
2031
|
+
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
2032
|
+
for (const [candidate, expected, label] of [
|
|
2033
|
+
[kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
|
|
2034
|
+
[root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
|
|
2035
|
+
]) {
|
|
2036
|
+
const stat = statIfPresent(candidate);
|
|
2037
|
+
if (!stat)
|
|
2038
|
+
continue;
|
|
2039
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2040
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
1933
2044
|
function preflightEnsureSession(p) {
|
|
1934
2045
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1935
2046
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
1936
2047
|
const dir = sessionDirFor(root, slug);
|
|
1937
|
-
|
|
2048
|
+
assertSafeSessionDirectory(root, dir);
|
|
2049
|
+
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2050
|
+
if (entry.flowId === "builder.build")
|
|
2051
|
+
assertCanonicalBuilderArtifactRoot(root);
|
|
1938
2052
|
sessionWorkItem(p, slug, dir);
|
|
1939
2053
|
}
|
|
1940
|
-
function ensureSession(p) {
|
|
2054
|
+
async function ensureSession(p) {
|
|
1941
2055
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1942
2056
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
1943
2057
|
const dir = sessionDirFor(root, slug);
|
|
2058
|
+
assertSafeSessionDirectory(root, dir);
|
|
1944
2059
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2060
|
+
if (entry.flowId === "builder.build")
|
|
2061
|
+
assertCanonicalBuilderArtifactRoot(root);
|
|
1945
2062
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
1946
2063
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
1947
2064
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
1948
2065
|
// below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
|
|
1949
2066
|
// assignment-claim actor are always the same identity.
|
|
1950
2067
|
const actorResolution = resolveEnsureSessionActor(p);
|
|
1951
|
-
|
|
1952
|
-
|
|
2068
|
+
const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
|
|
2069
|
+
? workItem.ref
|
|
2070
|
+
: undefined;
|
|
2071
|
+
const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
|
|
2072
|
+
ensureSafeDirectory(root, dir);
|
|
1953
2073
|
const timestamp = opt(p, "timestamp", now());
|
|
1954
2074
|
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
1955
2075
|
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
@@ -1976,7 +2096,6 @@ function ensureSession(p) {
|
|
|
1976
2096
|
summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
|
|
1977
2097
|
skills: ["pull-work"],
|
|
1978
2098
|
command: startCommand,
|
|
1979
|
-
enforcement: "before_tool_use",
|
|
1980
2099
|
}
|
|
1981
2100
|
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
1982
2101
|
: opt(p, "next-action", "Continue.");
|
|
@@ -1997,6 +2116,47 @@ function ensureSession(p) {
|
|
|
1997
2116
|
? persistedCurrent.active_step_id
|
|
1998
2117
|
: entry.stepId;
|
|
1999
2118
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2119
|
+
if (entry.flowId === "builder.build") {
|
|
2120
|
+
try {
|
|
2121
|
+
const started = await startBuilderFlowSession({ sessionDir: dir });
|
|
2122
|
+
if (started.run.state.current_step === "pull-work"
|
|
2123
|
+
&& selectedWorkEvidence
|
|
2124
|
+
&& assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
|
|
2125
|
+
await withSubjectLock(root, slug, async () => {
|
|
2126
|
+
const assignment = readLocalAssignmentStatus(root, slug).record;
|
|
2127
|
+
if (!assignment
|
|
2128
|
+
|| assignment.status !== "claimed"
|
|
2129
|
+
|| assignment.subject_id !== slug
|
|
2130
|
+
|| assignment.actor_key !== selectedWorkEvidence.actorKey
|
|
2131
|
+
|| assignment.work_item_ref !== workItem.ref) {
|
|
2132
|
+
die(`ensure-session refused to emit selected-work evidence: assignment provenance changed before gate evaluation for ${JSON.stringify(workItem.ref)}`);
|
|
2133
|
+
}
|
|
2134
|
+
const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
|
|
2135
|
+
const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
|
|
2136
|
+
const projectRoot = path.dirname(path.dirname(root));
|
|
2137
|
+
const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
|
|
2138
|
+
await recordGateClaim(parseArgs([
|
|
2139
|
+
"record-gate-claim",
|
|
2140
|
+
dir,
|
|
2141
|
+
"--actor", selectedWorkEvidence.actorKey,
|
|
2142
|
+
"--expectation", "selected-work",
|
|
2143
|
+
"--status", "pass",
|
|
2144
|
+
"--summary", `Work Item ${workItem.ref} is durably assigned to the active workflow actor (assignment sha256:${assignmentDigest}).`,
|
|
2145
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2146
|
+
kind: "artifact",
|
|
2147
|
+
file: evidenceFile,
|
|
2148
|
+
summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
|
|
2149
|
+
}),
|
|
2150
|
+
"--timestamp", timestamp,
|
|
2151
|
+
]));
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
catch (error) {
|
|
2156
|
+
process.stderr.write("[ensure-session] canonical Builder Flow entry failed; the acquired Work Item provenance remains retryable. Re-run the same ensure-session command.\n");
|
|
2157
|
+
throw error;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2000
2160
|
console.log(dir);
|
|
2001
2161
|
return 0;
|
|
2002
2162
|
}
|
|
@@ -392,8 +392,8 @@ export function resolvePhaseMap(flowId, repoRoot) {
|
|
|
392
392
|
}
|
|
393
393
|
/**
|
|
394
394
|
* Find the repository root from a starting directory by walking upward to locate
|
|
395
|
-
* the nearest ancestor that contains a `kits/` subdirectory.
|
|
396
|
-
* falls back to
|
|
395
|
+
* the nearest ancestor that contains a `kits/` subdirectory. A canonical `.kontourai`
|
|
396
|
+
* artifact path falls back to its owning project; other layouts fall back to cwd.
|
|
397
397
|
*
|
|
398
398
|
* This is required because the runtime artifact directory can live anywhere (temp dirs,
|
|
399
399
|
* subprojects, CI workspaces) while the kits/ directory is always at the repo root.
|
|
@@ -409,6 +409,11 @@ function findRepoRoot(startDir) {
|
|
|
409
409
|
break; // reached filesystem root
|
|
410
410
|
dir = parent;
|
|
411
411
|
}
|
|
412
|
+
// A canonical product artifact root identifies its owning project even when that
|
|
413
|
+
// consumer has no source-tree kits/. resolveFlowFilePath can then use the installed
|
|
414
|
+
// package fallback without consulting an unrelated ambient cwd.
|
|
415
|
+
if (path.basename(startDir) === ".kontourai")
|
|
416
|
+
return path.dirname(startDir);
|
|
412
417
|
// Fallback: process.cwd() covers the common "run from repo root" case
|
|
413
418
|
return process.cwd();
|
|
414
419
|
}
|