@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
|
@@ -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, type ParsedArgs } from "../lib/args.js";
|
|
7
|
-
import {
|
|
7
|
+
import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
|
|
8
8
|
|
|
9
9
|
// ─── AssignmentProvider CLI (#290) ──────────────────────────────────────────
|
|
10
10
|
// context/contracts/assignment-provider-contract.md is the governing vocabulary doc for this
|
|
@@ -60,6 +60,7 @@ export type AssignmentClaimRecord = {
|
|
|
60
60
|
subject_id: string;
|
|
61
61
|
actor: ActorStruct;
|
|
62
62
|
actor_key?: string;
|
|
63
|
+
work_item_ref?: string;
|
|
63
64
|
claimed_at: string;
|
|
64
65
|
ttl_seconds: number;
|
|
65
66
|
branch: string;
|
|
@@ -218,16 +219,50 @@ export function assignmentFilePath(artifactRoot: string, subjectId: string): str
|
|
|
218
219
|
return path.join(artifactRoot, "assignment", `${sanitized}.json`);
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
function localAssignmentDir(artifactRoot: string, create: boolean): string | null {
|
|
223
|
+
const dir = path.join(artifactRoot, "assignment");
|
|
224
|
+
if (create) fs.mkdirSync(dir, { recursive: true });
|
|
225
|
+
let stat: fs.Stats;
|
|
226
|
+
try {
|
|
227
|
+
stat = fs.lstatSync(dir);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
233
|
+
throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
|
|
234
|
+
}
|
|
235
|
+
const realRoot = fs.realpathSync(artifactRoot);
|
|
236
|
+
if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
|
|
237
|
+
throw new Error(`assignment directory escapes the artifact root: ${dir}`);
|
|
238
|
+
}
|
|
239
|
+
return dir;
|
|
240
|
+
}
|
|
241
|
+
|
|
221
242
|
export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
|
|
243
|
+
if (!localAssignmentDir(artifactRoot, false)) return null;
|
|
222
244
|
const file = assignmentFilePath(artifactRoot, subjectId);
|
|
223
|
-
|
|
245
|
+
let stat: fs.Stats;
|
|
246
|
+
try {
|
|
247
|
+
stat = fs.lstatSync(file);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
253
|
+
throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
|
|
254
|
+
}
|
|
224
255
|
let data: unknown;
|
|
256
|
+
let descriptor: number | null = null;
|
|
225
257
|
try {
|
|
226
|
-
|
|
258
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
259
|
+
data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
227
260
|
} catch (error) {
|
|
228
261
|
// Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
|
|
229
262
|
// would be a fail-open path that could let a second claim silently overwrite a real one.
|
|
230
263
|
throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${(error as Error).message}`);
|
|
264
|
+
} finally {
|
|
265
|
+
if (descriptor !== null) fs.closeSync(descriptor);
|
|
231
266
|
}
|
|
232
267
|
if (typeof data !== "object" || data === null) throw new Error(`assignment record is not an object: ${file}`);
|
|
233
268
|
const record = data as AssignmentClaimRecord;
|
|
@@ -239,7 +274,7 @@ export function writeLocalRecord(artifactRoot: string, subjectId: string, record
|
|
|
239
274
|
// writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
|
|
240
275
|
// propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
|
|
241
276
|
// never fail open (artifact-contract.md).
|
|
242
|
-
|
|
277
|
+
atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
|
|
243
278
|
}
|
|
244
279
|
|
|
245
280
|
/**
|
|
@@ -255,8 +290,7 @@ function sleepSync(ms: number): void {
|
|
|
255
290
|
}
|
|
256
291
|
|
|
257
292
|
function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
258
|
-
const assignmentDir =
|
|
259
|
-
fs.mkdirSync(assignmentDir, { recursive: true });
|
|
293
|
+
const assignmentDir = localAssignmentDir(artifactRoot, true)!;
|
|
260
294
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
261
295
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
262
296
|
}
|
|
@@ -310,11 +344,19 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
|
|
|
310
344
|
sleepSync(20);
|
|
311
345
|
}
|
|
312
346
|
}
|
|
347
|
+
const release = (): void => fs.rmSync(lockDir, { recursive: true, force: true });
|
|
348
|
+
let result: T;
|
|
313
349
|
try {
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
350
|
+
result = body();
|
|
351
|
+
} catch (error) {
|
|
352
|
+
release();
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
if (result && typeof (result as { then?: unknown }).then === "function") {
|
|
356
|
+
return Promise.resolve(result).finally(release) as T;
|
|
317
357
|
}
|
|
358
|
+
release();
|
|
359
|
+
return result;
|
|
318
360
|
}
|
|
319
361
|
|
|
320
362
|
/**
|
|
@@ -512,7 +554,7 @@ export function performLocalClaim(
|
|
|
512
554
|
artifactRoot: string,
|
|
513
555
|
subjectId: string,
|
|
514
556
|
actor: ActorStruct,
|
|
515
|
-
opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string },
|
|
557
|
+
opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string; workItemRef?: string },
|
|
516
558
|
): AssignmentClaimRecord {
|
|
517
559
|
const helper = loadActorIdentityHelper();
|
|
518
560
|
const reason = opts.reason ?? "claim";
|
|
@@ -538,6 +580,7 @@ export function performLocalClaim(
|
|
|
538
580
|
subject_id: subjectId,
|
|
539
581
|
actor,
|
|
540
582
|
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
583
|
+
...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
|
|
541
584
|
claimed_at: isoNow(),
|
|
542
585
|
ttl_seconds: opts.ttlSeconds,
|
|
543
586
|
branch: opts.branch,
|
|
@@ -707,7 +750,7 @@ export function performLocalSupersede(
|
|
|
707
750
|
subjectId: string,
|
|
708
751
|
fromActor: ActorStruct,
|
|
709
752
|
toActor: ActorStruct,
|
|
710
|
-
opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
|
|
753
|
+
opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string; workItemRef?: string } = {},
|
|
711
754
|
): AssignmentClaimRecord {
|
|
712
755
|
const helper = loadActorIdentityHelper();
|
|
713
756
|
const reason = opts.reason ?? "supersede";
|
|
@@ -732,6 +775,7 @@ export function performLocalSupersede(
|
|
|
732
775
|
subject_id: subjectId,
|
|
733
776
|
actor: toActor,
|
|
734
777
|
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
778
|
+
...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
|
|
735
779
|
claimed_at: isoNow(),
|
|
736
780
|
ttl_seconds: ttlSeconds,
|
|
737
781
|
branch: opts.branch ?? existing.branch,
|
|
@@ -172,6 +172,38 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
172
172
|
assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
|
|
176
|
+
const session = makeSession("start-subject-mismatch");
|
|
177
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
178
|
+
const sidecar = readJson(path.join(session.sessionDir, "state.json"));
|
|
179
|
+
sidecar.work_item_refs = ["local:work-item/other"];
|
|
180
|
+
writeJson(path.join(session.sessionDir, "state.json"), sidecar);
|
|
181
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
182
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
183
|
+
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => startBuilderFlowSession({ sessionDir: session.sessionDir }),
|
|
186
|
+
/flow_run\.state\.subject.*selected Work Item/,
|
|
187
|
+
);
|
|
188
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
189
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("automatic start refuses a sidecar changed after its immutable subject snapshot", async () => {
|
|
193
|
+
const session = makeSession("start-sidecar-race");
|
|
194
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
195
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
196
|
+
const startup = startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
197
|
+
const changed = readJson(path.join(session.sessionDir, "state.json"));
|
|
198
|
+
changed.next_action = { status: "continue", summary: "concurrent change" };
|
|
199
|
+
writeJson(path.join(session.sessionDir, "state.json"), changed);
|
|
200
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
201
|
+
|
|
202
|
+
await assert.rejects(() => startup, /state\.json.*changed during projection/);
|
|
203
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
204
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
205
|
+
});
|
|
206
|
+
|
|
175
207
|
test("wrong workflow subject is rejected before canonical Flow mutation", async () => {
|
|
176
208
|
const session = makeSession("wrong-subject");
|
|
177
209
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -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, type ActiveFlowStep } 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, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
|
|
18
|
+
import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, withSubjectLock, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
|
|
18
19
|
|
|
19
20
|
type AnyObj = Record<string, any>;
|
|
20
21
|
|
|
@@ -57,6 +58,12 @@ function workItemSlug(ref: string): string {
|
|
|
57
58
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
|
|
62
|
+
if (ref === `local:${slug}`) return true;
|
|
63
|
+
if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref)) return false;
|
|
64
|
+
return workItemSlug(ref) === slug;
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
type SessionWorkItem = {
|
|
61
68
|
ref: string;
|
|
62
69
|
localRecord?: AnyObj;
|
|
@@ -64,13 +71,18 @@ type SessionWorkItem = {
|
|
|
64
71
|
|
|
65
72
|
function sessionWorkItem(p: ReturnType<typeof parseArgs>, slug: string, dir: string): SessionWorkItem {
|
|
66
73
|
const providerRef = opt(p, "work-item");
|
|
74
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
75
|
+
const existingRef = Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string"
|
|
76
|
+
? existingState.work_item_refs[0]
|
|
77
|
+
: "";
|
|
67
78
|
if (providerRef) {
|
|
79
|
+
if (existingRef && existingRef !== providerRef) {
|
|
80
|
+
die(`ensure-session refused: session ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(existingRef)}, not ${JSON.stringify(providerRef)}`);
|
|
81
|
+
}
|
|
68
82
|
return { ref: providerRef };
|
|
69
83
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
|
|
73
|
-
return { ref: existingState.work_item_refs[0] };
|
|
84
|
+
if (existingRef) {
|
|
85
|
+
return { ref: existingRef };
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
const title = opt(p, "title", slug).trim() || slug;
|
|
@@ -1312,8 +1324,15 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
|
|
|
1312
1324
|
const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
|
|
1313
1325
|
const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
1314
1326
|
const deadline = Date.now() + 30000;
|
|
1327
|
+
let acquiredRoot: fs.Stats | null = null;
|
|
1328
|
+
let acquiredLock: fs.Stats | null = null;
|
|
1315
1329
|
while (true) {
|
|
1316
|
-
try {
|
|
1330
|
+
try {
|
|
1331
|
+
fs.mkdirSync(lockDir);
|
|
1332
|
+
acquiredRoot = fs.lstatSync(dir);
|
|
1333
|
+
acquiredLock = fs.lstatSync(lockDir);
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1317
1336
|
catch (error) {
|
|
1318
1337
|
const lockError = error as NodeJS.ErrnoException;
|
|
1319
1338
|
if (lockError.code !== "EEXIST") {
|
|
@@ -1338,7 +1357,28 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
|
|
|
1338
1357
|
if (delay) await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
|
|
1339
1358
|
return await body();
|
|
1340
1359
|
} finally {
|
|
1341
|
-
|
|
1360
|
+
try {
|
|
1361
|
+
const currentRoot = fs.lstatSync(dir);
|
|
1362
|
+
const currentLock = fs.lstatSync(lockDir);
|
|
1363
|
+
const sameIdentity = (left: fs.Stats, right: fs.Stats): boolean =>
|
|
1364
|
+
left.dev === right.dev && left.ino === right.ino;
|
|
1365
|
+
if (acquiredRoot
|
|
1366
|
+
&& acquiredLock
|
|
1367
|
+
&& !currentRoot.isSymbolicLink()
|
|
1368
|
+
&& currentRoot.isDirectory()
|
|
1369
|
+
&& !currentLock.isSymbolicLink()
|
|
1370
|
+
&& currentLock.isDirectory()
|
|
1371
|
+
&& sameIdentity(currentRoot, acquiredRoot)
|
|
1372
|
+
&& sameIdentity(currentLock, acquiredLock)) {
|
|
1373
|
+
fs.rmdirSync(lockDir);
|
|
1374
|
+
} else {
|
|
1375
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
|
|
1376
|
+
}
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1379
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1342
1382
|
}
|
|
1343
1383
|
}
|
|
1344
1384
|
|
|
@@ -1414,6 +1454,15 @@ function sessionDirFor(root: string, slug: string): string {
|
|
|
1414
1454
|
return dir;
|
|
1415
1455
|
}
|
|
1416
1456
|
|
|
1457
|
+
function assertSafeSessionDirectory(root: string, dir: string): void {
|
|
1458
|
+
if (!fs.existsSync(dir)) return;
|
|
1459
|
+
const stat = fs.lstatSync(dir);
|
|
1460
|
+
const expected = path.join(fs.realpathSync(root), path.basename(dir));
|
|
1461
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(dir) !== expected) {
|
|
1462
|
+
die(`session directory must be a real directory under the artifact root: ${dir}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1417
1466
|
function validateAgentId(agent: string): string {
|
|
1418
1467
|
if (!agent) die("--agent-id is required");
|
|
1419
1468
|
if (path.isAbsolute(agent)) die("--agent-id must be a relative slug");
|
|
@@ -1453,17 +1502,20 @@ function findRepoRootFromDirStrict(startDir: string): string | null {
|
|
|
1453
1502
|
* Find the repository root by walking upward from a starting directory to locate
|
|
1454
1503
|
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1455
1504
|
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1456
|
-
* internal helper.
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1505
|
+
* internal helper. A canonical `.kontourai` path falls back to its owning project,
|
|
1506
|
+
* allowing installed package assets to resolve independently of ambient cwd. Other
|
|
1507
|
+
* layouts retain the cwd fallback used by standalone primitive sessions.
|
|
1459
1508
|
*
|
|
1460
|
-
* Do NOT use this
|
|
1461
|
-
*
|
|
1509
|
+
* Do NOT use this permissive variant for publishDelivery's repo-root resolution —
|
|
1510
|
+
* use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1462
1511
|
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1463
1512
|
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1464
1513
|
*/
|
|
1465
1514
|
function findRepoRootFromDir(startDir: string): string {
|
|
1466
|
-
|
|
1515
|
+
const discovered = findRepoRootFromDirStrict(startDir);
|
|
1516
|
+
if (discovered) return discovered;
|
|
1517
|
+
if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
|
|
1518
|
+
return process.cwd();
|
|
1467
1519
|
}
|
|
1468
1520
|
|
|
1469
1521
|
/**
|
|
@@ -1758,10 +1810,11 @@ function enforceEnsureSessionOwnership(
|
|
|
1758
1810
|
slug: string,
|
|
1759
1811
|
dir: string,
|
|
1760
1812
|
resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
|
|
1761
|
-
|
|
1813
|
+
workItemRef?: string,
|
|
1814
|
+
): { assignmentFile: string; actorKey: string } | null {
|
|
1762
1815
|
if (p.flags.has("skip-ownership-guard")) {
|
|
1763
1816
|
process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
|
|
1764
|
-
return;
|
|
1817
|
+
return null;
|
|
1765
1818
|
}
|
|
1766
1819
|
// Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
|
|
1767
1820
|
// hard-fails session creation. Without a resolvable actor there is no safe identity to claim
|
|
@@ -1769,7 +1822,7 @@ function enforceEnsureSessionOwnership(
|
|
|
1769
1822
|
// than claiming under a synthetic/unstable identity.
|
|
1770
1823
|
if (resolution.unresolved) {
|
|
1771
1824
|
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");
|
|
1772
|
-
return;
|
|
1825
|
+
return null;
|
|
1773
1826
|
}
|
|
1774
1827
|
|
|
1775
1828
|
// F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
|
|
@@ -1821,6 +1874,9 @@ function enforceEnsureSessionOwnership(
|
|
|
1821
1874
|
// `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
|
|
1822
1875
|
// afterward would never recognize this guard's own claim as self.
|
|
1823
1876
|
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1877
|
+
if (workItemRef && assignment.record?.work_item_ref && assignment.record.work_item_ref !== workItemRef) {
|
|
1878
|
+
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)}`);
|
|
1879
|
+
}
|
|
1824
1880
|
const events = readLivenessEvents(root);
|
|
1825
1881
|
const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
|
|
1826
1882
|
effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
|
|
@@ -1831,9 +1887,28 @@ function enforceEnsureSessionOwnership(
|
|
|
1831
1887
|
// neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
|
|
1832
1888
|
// never a silent hole.
|
|
1833
1889
|
process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
|
|
1834
|
-
return;
|
|
1890
|
+
return null;
|
|
1835
1891
|
}
|
|
1836
1892
|
|
|
1893
|
+
const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
|
|
1894
|
+
// Only a claim read from the local provider is durable evidence. Precomputed provider state
|
|
1895
|
+
// can authorize entry, but it cannot prove that this process acquired the Work Item.
|
|
1896
|
+
if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
|
|
1897
|
+
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1898
|
+
const record = assignment.record;
|
|
1899
|
+
if (!record
|
|
1900
|
+
|| record.status !== "claimed"
|
|
1901
|
+
|| record.subject_id !== slug
|
|
1902
|
+
|| record.actor_key !== resolution.branchActorKey
|
|
1903
|
+
|| record.work_item_ref !== workItemRef) {
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
return {
|
|
1907
|
+
assignmentFile: assignmentFilePath(root, slug),
|
|
1908
|
+
actorKey: resolution.branchActorKey,
|
|
1909
|
+
};
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1837
1912
|
const resolveBranchForClaim = (): string => {
|
|
1838
1913
|
const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
|
|
1839
1914
|
return existingBranch || resolveSessionBranch(p, slug);
|
|
@@ -1846,17 +1921,17 @@ function enforceEnsureSessionOwnership(
|
|
|
1846
1921
|
// ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
|
|
1847
1922
|
// effective_state computation instead of silently using a different identity.
|
|
1848
1923
|
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1849
|
-
if (isSelf) return;
|
|
1924
|
+
if (isSelf) return selectedWorkEvidence();
|
|
1850
1925
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1851
1926
|
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1852
1927
|
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.`);
|
|
1853
|
-
return;
|
|
1928
|
+
return null;
|
|
1854
1929
|
}
|
|
1855
1930
|
case "human-held": {
|
|
1856
1931
|
const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
|
|
1857
1932
|
const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
|
|
1858
1933
|
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.`);
|
|
1859
|
-
return;
|
|
1934
|
+
return null;
|
|
1860
1935
|
}
|
|
1861
1936
|
case "reclaimable": {
|
|
1862
1937
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
@@ -1889,11 +1964,12 @@ function enforceEnsureSessionOwnership(
|
|
|
1889
1964
|
// computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
|
|
1890
1965
|
// branchActorKey string on the next status/guard check, cross-tool.
|
|
1891
1966
|
actorKey: resolution.branchActorKey,
|
|
1967
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
1892
1968
|
});
|
|
1893
1969
|
// Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
|
|
1894
1970
|
// branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
|
|
1895
1971
|
printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
|
|
1896
|
-
return;
|
|
1972
|
+
return selectedWorkEvidence();
|
|
1897
1973
|
}
|
|
1898
1974
|
case "free": {
|
|
1899
1975
|
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
@@ -1903,8 +1979,9 @@ function enforceEnsureSessionOwnership(
|
|
|
1903
1979
|
reason: opt(p, "reason", "ensure-session entry"),
|
|
1904
1980
|
// F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
|
|
1905
1981
|
actorKey: resolution.branchActorKey,
|
|
1982
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
1906
1983
|
});
|
|
1907
|
-
return;
|
|
1984
|
+
return selectedWorkEvidence();
|
|
1908
1985
|
}
|
|
1909
1986
|
default:
|
|
1910
1987
|
die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
|
|
@@ -1947,27 +2024,66 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
|
|
|
1947
2024
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
1948
2025
|
}
|
|
1949
2026
|
|
|
2027
|
+
function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
2028
|
+
const kontouraiRoot = path.dirname(root);
|
|
2029
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
2030
|
+
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
2031
|
+
die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const statIfPresent = (candidate: string): fs.Stats | null => {
|
|
2035
|
+
try {
|
|
2036
|
+
return fs.lstatSync(candidate);
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
2039
|
+
throw error;
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
const projectStat = statIfPresent(projectRoot);
|
|
2043
|
+
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
2044
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
|
|
2045
|
+
}
|
|
2046
|
+
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
2047
|
+
for (const [candidate, expected, label] of [
|
|
2048
|
+
[kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
|
|
2049
|
+
[root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
|
|
2050
|
+
] as const) {
|
|
2051
|
+
const stat = statIfPresent(candidate);
|
|
2052
|
+
if (!stat) continue;
|
|
2053
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2054
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1950
2059
|
function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
|
|
1951
2060
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1952
2061
|
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)"));
|
|
1953
2062
|
const dir = sessionDirFor(root, slug);
|
|
1954
|
-
|
|
2063
|
+
assertSafeSessionDirectory(root, dir);
|
|
2064
|
+
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2065
|
+
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
1955
2066
|
sessionWorkItem(p, slug, dir);
|
|
1956
2067
|
}
|
|
1957
2068
|
|
|
1958
|
-
function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
2069
|
+
async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1959
2070
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1960
2071
|
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)"));
|
|
1961
2072
|
const dir = sessionDirFor(root, slug);
|
|
2073
|
+
assertSafeSessionDirectory(root, dir);
|
|
1962
2074
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2075
|
+
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
1963
2076
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
1964
2077
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
1965
2078
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
1966
2079
|
// below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
|
|
1967
2080
|
// assignment-claim actor are always the same identity.
|
|
1968
2081
|
const actorResolution = resolveEnsureSessionActor(p);
|
|
1969
|
-
|
|
1970
|
-
|
|
2082
|
+
const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
|
|
2083
|
+
? workItem.ref
|
|
2084
|
+
: undefined;
|
|
2085
|
+
const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
|
|
2086
|
+
ensureSafeDirectory(root, dir);
|
|
1971
2087
|
const timestamp = opt(p, "timestamp", now());
|
|
1972
2088
|
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
1973
2089
|
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
@@ -1994,7 +2110,6 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1994
2110
|
summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
|
|
1995
2111
|
skills: ["pull-work"],
|
|
1996
2112
|
command: startCommand,
|
|
1997
|
-
enforcement: "before_tool_use",
|
|
1998
2113
|
}
|
|
1999
2114
|
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
2000
2115
|
: opt(p, "next-action", "Continue.");
|
|
@@ -2025,6 +2140,46 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
2025
2140
|
? persistedCurrent.active_step_id
|
|
2026
2141
|
: entry.stepId;
|
|
2027
2142
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2143
|
+
if (entry.flowId === "builder.build") {
|
|
2144
|
+
try {
|
|
2145
|
+
const started = await startBuilderFlowSession({ sessionDir: dir });
|
|
2146
|
+
if (started.run.state.current_step === "pull-work"
|
|
2147
|
+
&& selectedWorkEvidence
|
|
2148
|
+
&& assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
|
|
2149
|
+
await withSubjectLock(root, slug, async () => {
|
|
2150
|
+
const assignment = readLocalAssignmentStatus(root, slug).record;
|
|
2151
|
+
if (!assignment
|
|
2152
|
+
|| assignment.status !== "claimed"
|
|
2153
|
+
|| assignment.subject_id !== slug
|
|
2154
|
+
|| assignment.actor_key !== selectedWorkEvidence.actorKey
|
|
2155
|
+
|| assignment.work_item_ref !== workItem.ref) {
|
|
2156
|
+
die(`ensure-session refused to emit selected-work evidence: assignment provenance changed before gate evaluation for ${JSON.stringify(workItem.ref)}`);
|
|
2157
|
+
}
|
|
2158
|
+
const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
|
|
2159
|
+
const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
|
|
2160
|
+
const projectRoot = path.dirname(path.dirname(root));
|
|
2161
|
+
const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
|
|
2162
|
+
await recordGateClaim(parseArgs([
|
|
2163
|
+
"record-gate-claim",
|
|
2164
|
+
dir,
|
|
2165
|
+
"--actor", selectedWorkEvidence.actorKey,
|
|
2166
|
+
"--expectation", "selected-work",
|
|
2167
|
+
"--status", "pass",
|
|
2168
|
+
"--summary", `Work Item ${workItem.ref} is durably assigned to the active workflow actor (assignment sha256:${assignmentDigest}).`,
|
|
2169
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2170
|
+
kind: "artifact",
|
|
2171
|
+
file: evidenceFile,
|
|
2172
|
+
summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
|
|
2173
|
+
}),
|
|
2174
|
+
"--timestamp", timestamp,
|
|
2175
|
+
]));
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
} catch (error) {
|
|
2179
|
+
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");
|
|
2180
|
+
throw error;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2028
2183
|
console.log(dir);
|
|
2029
2184
|
return 0;
|
|
2030
2185
|
}
|
package/src/lib/flow-resolver.ts
CHANGED
|
@@ -449,8 +449,8 @@ export function resolvePhaseMap(flowId: string, repoRoot: string): Record<string
|
|
|
449
449
|
|
|
450
450
|
/**
|
|
451
451
|
* Find the repository root from a starting directory by walking upward to locate
|
|
452
|
-
* the nearest ancestor that contains a `kits/` subdirectory.
|
|
453
|
-
* falls back to
|
|
452
|
+
* the nearest ancestor that contains a `kits/` subdirectory. A canonical `.kontourai`
|
|
453
|
+
* artifact path falls back to its owning project; other layouts fall back to cwd.
|
|
454
454
|
*
|
|
455
455
|
* This is required because the runtime artifact directory can live anywhere (temp dirs,
|
|
456
456
|
* subprojects, CI workspaces) while the kits/ directory is always at the repo root.
|
|
@@ -464,6 +464,10 @@ function findRepoRoot(startDir: string): string {
|
|
|
464
464
|
if (parent === dir) break; // reached filesystem root
|
|
465
465
|
dir = parent;
|
|
466
466
|
}
|
|
467
|
+
// A canonical product artifact root identifies its owning project even when that
|
|
468
|
+
// consumer has no source-tree kits/. resolveFlowFilePath can then use the installed
|
|
469
|
+
// package fallback without consulting an unrelated ambient cwd.
|
|
470
|
+
if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
|
|
467
471
|
// Fallback: process.cwd() covers the common "run from repo root" case
|
|
468
472
|
return process.cwd();
|
|
469
473
|
}
|
|
@@ -337,7 +337,6 @@ function exportClaudeSettings(): string {
|
|
|
337
337
|
hooks.UserPromptSubmit.push({ hooks: [shellHook(claudePolicy("UserPromptSubmit", "workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
338
338
|
hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "quality-gate.js"), 30, "Running Flow Agents hook policy")] });
|
|
339
339
|
hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
|
|
340
|
-
hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
|
|
341
340
|
hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
|
|
342
341
|
return `${JSON.stringify({
|
|
343
342
|
statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
|
|
@@ -355,7 +354,6 @@ function exportCodexHooks(): string {
|
|
|
355
354
|
hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
356
355
|
hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
357
356
|
hooks.PostToolUse.push({ hooks: [shellHook(codexPolicy("evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
|
|
358
|
-
hooks.PreToolUse.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
|
|
359
357
|
return `${JSON.stringify({ hooks }, null, 2)}\n`;
|
|
360
358
|
}
|
|
361
359
|
|