@kontourai/flow-agents 3.4.3 → 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.
@@ -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 { ensureSafeDirectory } from "../lib/fs.js";
12
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
- const existingState = loadJson(path.join(dir, "state.json"));
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;
@@ -1442,6 +1454,15 @@ function sessionDirFor(root: string, slug: string): string {
1442
1454
  return dir;
1443
1455
  }
1444
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
+
1445
1466
  function validateAgentId(agent: string): string {
1446
1467
  if (!agent) die("--agent-id is required");
1447
1468
  if (path.isAbsolute(agent)) die("--agent-id must be a relative slug");
@@ -1481,17 +1502,20 @@ function findRepoRootFromDirStrict(startDir: string): string | null {
1481
1502
  * Find the repository root by walking upward from a starting directory to locate
1482
1503
  * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1483
1504
  * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1484
- * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1485
- * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1486
- * where the caller is always invoked from within a real repo checkout.
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.
1487
1508
  *
1488
- * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1489
- * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1509
+ * Do NOT use this permissive variant for publishDelivery's repo-root resolution —
1510
+ * use findRepoRootFromDirStrict there instead, so a scratch/test
1490
1511
  * session dir with no repo ancestor fails closed (skips publish) rather than
1491
1512
  * silently trusting process.cwd(), which could be an unrelated real repo.
1492
1513
  */
1493
1514
  function findRepoRootFromDir(startDir: string): string {
1494
- return findRepoRootFromDirStrict(startDir) ?? process.cwd();
1515
+ const discovered = findRepoRootFromDirStrict(startDir);
1516
+ if (discovered) return discovered;
1517
+ if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
1518
+ return process.cwd();
1495
1519
  }
1496
1520
 
1497
1521
  /**
@@ -1786,10 +1810,11 @@ function enforceEnsureSessionOwnership(
1786
1810
  slug: string,
1787
1811
  dir: string,
1788
1812
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1789
- ): void {
1813
+ workItemRef?: string,
1814
+ ): { assignmentFile: string; actorKey: string } | null {
1790
1815
  if (p.flags.has("skip-ownership-guard")) {
1791
1816
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1792
- return;
1817
+ return null;
1793
1818
  }
1794
1819
  // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1795
1820
  // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
@@ -1797,7 +1822,7 @@ function enforceEnsureSessionOwnership(
1797
1822
  // than claiming under a synthetic/unstable identity.
1798
1823
  if (resolution.unresolved) {
1799
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");
1800
- return;
1825
+ return null;
1801
1826
  }
1802
1827
 
1803
1828
  // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
@@ -1849,6 +1874,9 @@ function enforceEnsureSessionOwnership(
1849
1874
  // `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
1850
1875
  // afterward would never recognize this guard's own claim as self.
1851
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
+ }
1852
1880
  const events = readLivenessEvents(root);
1853
1881
  const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
1854
1882
  effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
@@ -1859,9 +1887,28 @@ function enforceEnsureSessionOwnership(
1859
1887
  // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1860
1888
  // never a silent hole.
1861
1889
  process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1862
- return;
1890
+ return null;
1863
1891
  }
1864
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
+
1865
1912
  const resolveBranchForClaim = (): string => {
1866
1913
  const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
1867
1914
  return existingBranch || resolveSessionBranch(p, slug);
@@ -1874,17 +1921,17 @@ function enforceEnsureSessionOwnership(
1874
1921
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1875
1922
  // effective_state computation instead of silently using a different identity.
1876
1923
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1877
- if (isSelf) return; // resume own session — no refusal
1924
+ if (isSelf) return selectedWorkEvidence();
1878
1925
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1879
1926
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1880
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.`);
1881
- return;
1928
+ return null;
1882
1929
  }
1883
1930
  case "human-held": {
1884
1931
  const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
1885
1932
  const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
1886
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.`);
1887
- return;
1934
+ return null;
1888
1935
  }
1889
1936
  case "reclaimable": {
1890
1937
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
@@ -1917,11 +1964,12 @@ function enforceEnsureSessionOwnership(
1917
1964
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1918
1965
  // branchActorKey string on the next status/guard check, cross-tool.
1919
1966
  actorKey: resolution.branchActorKey,
1967
+ ...(workItemRef ? { workItemRef } : {}),
1920
1968
  });
1921
1969
  // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1922
1970
  // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1923
1971
  printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1924
- return;
1972
+ return selectedWorkEvidence();
1925
1973
  }
1926
1974
  case "free": {
1927
1975
  performLocalClaim(root, slug, resolution.actorStruct, {
@@ -1931,8 +1979,9 @@ function enforceEnsureSessionOwnership(
1931
1979
  reason: opt(p, "reason", "ensure-session entry"),
1932
1980
  // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1933
1981
  actorKey: resolution.branchActorKey,
1982
+ ...(workItemRef ? { workItemRef } : {}),
1934
1983
  });
1935
- return;
1984
+ return selectedWorkEvidence();
1936
1985
  }
1937
1986
  default:
1938
1987
  die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
@@ -2011,6 +2060,7 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
2011
2060
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2012
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)"));
2013
2062
  const dir = sessionDirFor(root, slug);
2063
+ assertSafeSessionDirectory(root, dir);
2014
2064
  const entry = resolveEnsureSessionEntry(p, dir);
2015
2065
  if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2016
2066
  sessionWorkItem(p, slug, dir);
@@ -2020,6 +2070,7 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2020
2070
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2021
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)"));
2022
2072
  const dir = sessionDirFor(root, slug);
2073
+ assertSafeSessionDirectory(root, dir);
2023
2074
  const entry = resolveEnsureSessionEntry(p, dir);
2024
2075
  if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2025
2076
  const workItem = sessionWorkItem(p, slug, dir);
@@ -2028,8 +2079,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2028
2079
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
2029
2080
  // assignment-claim actor are always the same identity.
2030
2081
  const actorResolution = resolveEnsureSessionActor(p);
2031
- enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
2032
- fs.mkdirSync(dir, { recursive: true });
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);
2033
2087
  const timestamp = opt(p, "timestamp", now());
2034
2088
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2035
2089
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2088,10 +2142,41 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2088
2142
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2089
2143
  if (entry.flowId === "builder.build") {
2090
2144
  try {
2091
- await startBuilderFlowSession({ sessionDir: dir });
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
+ }
2092
2178
  } catch (error) {
2093
- const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2094
- process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
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");
2095
2180
  throw error;
2096
2181
  }
2097
2182
  }
@@ -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. If none is found,
453
- * falls back to `process.cwd()` so the default "run from repo root" case still works.
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
  }