@kontourai/flow-agents 3.0.0 → 3.1.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/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +143 -0
- package/build/src/cli/assignment-provider.js +106 -49
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +392 -35
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/contracts/assignment-provider-contract.md +9 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +19 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +4 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +4 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +17 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/package.json +4 -2
- package/scripts/README.md +1 -0
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +137 -55
- package/src/cli/workflow-sidecar.ts +430 -33
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
const fs = require('fs');
|
|
50
50
|
const path = require('path');
|
|
51
51
|
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
52
|
+
const { resolveActor } = require('./lib/actor-identity.js');
|
|
53
|
+
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
52
54
|
const crypto = require('crypto');
|
|
53
55
|
|
|
54
56
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -198,8 +200,9 @@ function latestStateDir(flowAgentsDir) {
|
|
|
198
200
|
* to the newest-mtime state.json directory.
|
|
199
201
|
*/
|
|
200
202
|
function resolveArtifactDir(root) {
|
|
203
|
+
const actorKey = resolveActor(process.env).actor;
|
|
201
204
|
for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
|
|
202
|
-
const current =
|
|
205
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
|
|
203
206
|
if (current) {
|
|
204
207
|
const slug = current.artifact_dir || current.active_slug;
|
|
205
208
|
if (typeof slug === 'string' && slug.trim()) {
|
|
@@ -22,6 +22,8 @@ const SANCTIONED_REMEDIES = {
|
|
|
22
22
|
'There is no sanctioned automated writer for this file. Ask a human maintainer to edit it directly. Never disable this hook to make the write.',
|
|
23
23
|
'.kontourai/flow-agents/current.json':
|
|
24
24
|
'Use `npm run workflow:sidecar -- ensure-session` (or `advance-state`), which writes this file for you. Never disable this hook to make the write.',
|
|
25
|
+
'.kontourai/flow-agents/current/<actor>.json':
|
|
26
|
+
'Use `npm run workflow:sidecar -- ensure-session` (or `advance-state`), which writes this file for you. Never disable this hook to make the write.',
|
|
25
27
|
'.kontourai/flow-agents/.goal-fit-block-streak.json':
|
|
26
28
|
'This file is only mutated internally by the goal-fit Stop hook; there is no sanctioned agent writer. Never disable this hook to make the write.',
|
|
27
29
|
'.kontourai/flow-agents/<slug>/state.json':
|
|
@@ -61,6 +63,13 @@ const REMEDY_COMMAND_CANDIDATES = [
|
|
|
61
63
|
{ name: '.kontourai/flow-agents/<slug>/trust.bundle', needles: ['trust.bundle'] },
|
|
62
64
|
{ name: '.kontourai/flow-agents/<slug>/state.json', needles: ['state.json'] },
|
|
63
65
|
{ name: '.kontourai/flow-agents/.goal-fit-block-streak.json', needles: ['.goal-fit-block-streak.json'] },
|
|
66
|
+
// #291: the per-actor projection (current/<actor>.json) is checked BEFORE the legacy
|
|
67
|
+
// current.json needle below — its needle is a directory-scoped substring
|
|
68
|
+
// ('flow-agents/current/') that never overlaps with the bare 'current.json' basename, so
|
|
69
|
+
// ordering between these two entries has no effect on real inputs, but keeping the more
|
|
70
|
+
// specific per-actor entry first mirrors this table's existing "specific before generic"
|
|
71
|
+
// convention.
|
|
72
|
+
{ name: '.kontourai/flow-agents/current/<actor>.json', needles: ['flow-agents/current/', '.flow-agents/current/'] },
|
|
64
73
|
{ name: '.kontourai/flow-agents/current.json', needles: ['current.json'] },
|
|
65
74
|
{ name: '.claude/settings.local.json', needles: ['settings.local.json'] },
|
|
66
75
|
{ name: '.claude/settings.json', needles: ['.claude/settings.json'] },
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* current-pointer.js — shared pure-CJS per-actor "current" pointer reader/writer (#291)
|
|
4
|
+
*
|
|
5
|
+
* Zero external dependencies (only Node core: fs, path). Consumed by:
|
|
6
|
+
* - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire — Wave 2 Task 2.1)
|
|
7
|
+
* - build/src/lib/flow-resolver.js (ESM compiled, via createRequire — Wave 2 Task 2.2)
|
|
8
|
+
* - scripts/hooks/stop-goal-fit.js, evidence-capture.js, lib/liveness-heartbeat.js,
|
|
9
|
+
* scripts/statusline/flow-agents-statusline.js (CJS, direct require — Wave 2 Task 2.3)
|
|
10
|
+
* - scripts/hooks/workflow-steering.js (CJS, direct require — Wave 2 Task 2.4)
|
|
11
|
+
*
|
|
12
|
+
* Purpose (issue #291): today every one of the consumers above hand-rolls its own
|
|
13
|
+
* `fs.readFileSync(path.join(flowAgentsDir, "current.json"))`, so once `ensure-session` starts
|
|
14
|
+
* projecting a per-actor `current/<actor>.json` pointer (Wave 2 Task 2.1's `writeCurrent()` dual
|
|
15
|
+
* write), every reader must gain the SAME actor-aware preference or session A's own "what am I
|
|
16
|
+
* working on" view keeps getting silently overwritten by session B's more-recent legacy write.
|
|
17
|
+
* This module is the single choke point for that preference rule — every reader listed above
|
|
18
|
+
* calls `readCurrentPointer()` instead of hand-rolling its own fallback, so the compat-shim rule
|
|
19
|
+
* (per-actor first, legacy-global fallback) can never drift between call sites.
|
|
20
|
+
*
|
|
21
|
+
* `sanitizeSegment` is required from `./actor-identity.js` (already reviewed: restricts to
|
|
22
|
+
* `[A-Za-z0-9_.-]`, caps 64 chars) — this file deliberately does NOT re-implement a second
|
|
23
|
+
* sanitizer; it is the same charset restriction `assignment-provider.ts`'s `assignmentFilePath()`
|
|
24
|
+
* already applies to its own per-subject filenames, so the naming convention is consistent across
|
|
25
|
+
* both per-key-file stores in this repo (`assignment/<subject>.json` and now
|
|
26
|
+
* `current/<actor>.json`).
|
|
27
|
+
*
|
|
28
|
+
* Exports:
|
|
29
|
+
* perActorCurrentFile(flowAgentsDir, actorKey) → string (path, unsanitized existence not
|
|
30
|
+
* implied — caller must still fs.existsSync)
|
|
31
|
+
* readCurrentPointer(flowAgentsDir, actorKey) → { payload: object|null,
|
|
32
|
+
* source: "per-actor"|"legacy"|"none",
|
|
33
|
+
* file: string|null }
|
|
34
|
+
* writePerActorCurrent(flowAgentsDir, actorKey, payload) → void
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const { sanitizeSegment, isUnresolvedActor } = require('./actor-identity.js');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort tolerant JSON read: missing file or corrupt/unparseable content are BOTH treated
|
|
43
|
+
* as "absent" (returns null), never thrown — this is an advisory read used to decide which
|
|
44
|
+
* pointer file to prefer, not a durable-write persistence path. Mirrors the `readJsonFile`
|
|
45
|
+
* tolerance convention already used by stop-goal-fit.js/evidence-capture.js for this exact file
|
|
46
|
+
* (`current.json`) today.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} file
|
|
49
|
+
* @returns {object|null}
|
|
50
|
+
*/
|
|
51
|
+
function readJsonFileTolerant(file) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Path to the per-actor current pointer file for a given actor key, sanitized via the shared
|
|
61
|
+
* `sanitizeSegment` (reused, not re-implemented) so the filename is path-traversal-safe exactly
|
|
62
|
+
* like `assignment-provider.ts`'s `assignmentFilePath()`.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} flowAgentsDir
|
|
65
|
+
* @param {string} actorKey
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function perActorCurrentFile(flowAgentsDir, actorKey) {
|
|
69
|
+
return path.join(flowAgentsDir, 'current', `${sanitizeSegment(actorKey)}.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The ENTIRE compat-shim fallback rule (#291): when `actorKey` is a non-empty, resolved (not
|
|
74
|
+
* `isUnresolvedActor`) string and its per-actor file exists and parses, prefer it
|
|
75
|
+
* (`source: "per-actor"`). Otherwise — no actorKey, an unresolved actor, or no per-actor file yet
|
|
76
|
+
* — fall back to the legacy global `<flowAgentsDir>/current.json`; if THAT exists and parses,
|
|
77
|
+
* return it (`source: "legacy"`). Otherwise return `{ payload: null, source: "none", file: null }`.
|
|
78
|
+
*
|
|
79
|
+
* A missing/corrupt per-actor file is tolerated as absent (falls through to the legacy branch,
|
|
80
|
+
* never throws) — same best-effort tolerance the existing `readJsonFile`-style readers already
|
|
81
|
+
* apply to `current.json` today. This fallback must be EXACTLY equivalent to today's plain
|
|
82
|
+
* `current.json` read for every caller that has no actorKey (or an unresolved one) — that is
|
|
83
|
+
* what makes this a compat shim rather than a behavior change.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} flowAgentsDir
|
|
86
|
+
* @param {string} [actorKey]
|
|
87
|
+
* @returns {{ payload: object|null, source: "per-actor"|"legacy"|"none", file: string|null }}
|
|
88
|
+
*/
|
|
89
|
+
function readCurrentPointer(flowAgentsDir, actorKey) {
|
|
90
|
+
const key = actorKey == null ? '' : String(actorKey);
|
|
91
|
+
if (key && !isUnresolvedActor(key)) {
|
|
92
|
+
const perActorFile = perActorCurrentFile(flowAgentsDir, key);
|
|
93
|
+
const perActorPayload = readJsonFileTolerant(perActorFile);
|
|
94
|
+
if (perActorPayload !== null) return { payload: perActorPayload, source: 'per-actor', file: perActorFile };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const legacyFile = path.join(flowAgentsDir, 'current.json');
|
|
98
|
+
const legacyPayload = readJsonFileTolerant(legacyFile);
|
|
99
|
+
if (legacyPayload !== null) return { payload: legacyPayload, source: 'legacy', file: legacyFile };
|
|
100
|
+
|
|
101
|
+
return { payload: null, source: 'none', file: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Durable write-side counterpart to `readCurrentPointer`'s per-actor branch — used only by
|
|
106
|
+
* `workflow-sidecar.ts`'s `writeCurrent()` (Wave 2 Task 2.1), which calls this ALONGSIDE
|
|
107
|
+
* (never instead of) its existing unconditional legacy `current.json` write, so the legacy
|
|
108
|
+
* compat-shim fallback always has a value to read for any caller that never resolves an actor.
|
|
109
|
+
* `fs.mkdirSync` the `current/` subdir with `{ recursive: true }` before writing — this is the
|
|
110
|
+
* ONLY writer of that subdir, so no other caller needs to pre-create it.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} flowAgentsDir
|
|
113
|
+
* @param {string} actorKey
|
|
114
|
+
* @param {object} payload
|
|
115
|
+
* @returns {void}
|
|
116
|
+
*/
|
|
117
|
+
function writePerActorCurrent(flowAgentsDir, actorKey, payload) {
|
|
118
|
+
const file = perActorCurrentFile(flowAgentsDir, actorKey);
|
|
119
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { perActorCurrentFile, readCurrentPointer, writePerActorCurrent };
|
|
@@ -18,10 +18,24 @@
|
|
|
18
18
|
* the actor-scoped stream has no shared-write race.
|
|
19
19
|
*
|
|
20
20
|
* Hot-path ordering + bounded read (F3, #288 fix iteration 1, sec-MED +
|
|
21
|
-
* cr-MED): steps run enabled? ->
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* cr-MED; restored by F4, fix-plan iteration 1): steps run enabled? ->
|
|
22
|
+
* mightHaveActiveSession() peek -> resolveActor() -> current.json
|
|
23
|
+
* active_slug present? -> bounded tail read, in that order, so a repo with
|
|
24
|
+
* liveness disabled OR no active session never pays the `resolveActor()`
|
|
25
|
+
* process-ancestry `ps` spawn cost. (#291 initially moved resolveActor()
|
|
26
|
+
* ahead of the active_slug check unconditionally, since the current-pointer
|
|
27
|
+
* read became actor-aware and needs the resolved actor to choose per-actor
|
|
28
|
+
* vs. legacy `current.json` — this silently dropped the "no active session"
|
|
29
|
+
* half of the short-circuit for every liveness-enabled call, not just the
|
|
30
|
+
* "disabled" half. F4 restores it: `mightHaveActiveSession()` is a plain,
|
|
31
|
+
* actor-free `fs.existsSync` peek of the legacy `current.json` file and the
|
|
32
|
+
* `current/` per-actor directory — when NEITHER exists, no actor could
|
|
33
|
+
* possibly have an active session (per-actor files only ever live under
|
|
34
|
+
* `current/`), so resolveActor() is skipped entirely, exactly restoring the
|
|
35
|
+
* pre-#291 short-circuit for the common "enabled, no session yet" case. Only
|
|
36
|
+
* once a current-pointer file might exist does resolveActor() run, still
|
|
37
|
+
* exactly once, reused for both the actor-scoped current-pointer read and the
|
|
38
|
+
* actor-unresolved gate below.)
|
|
25
39
|
*
|
|
26
40
|
* Orphan-heartbeat invariant (F8(ii), #288 fix iteration 2): a heartbeat must
|
|
27
41
|
* never be treated as its own evidence of a claim. The reviewer-reproduced
|
|
@@ -74,12 +88,12 @@
|
|
|
74
88
|
|
|
75
89
|
const fs = require('fs');
|
|
76
90
|
const path = require('path');
|
|
77
|
-
|
|
78
91
|
const { isLivenessEnabled, resolveHeartbeatThrottleSeconds } = require('./liveness-policy');
|
|
79
92
|
const { livenessStreamFile, appendLivenessEvent } = require('./liveness-write');
|
|
80
93
|
const { readLivenessEvents, readLivenessEventsTail, freshHolders } = require('./liveness-read');
|
|
81
94
|
const { resolveActor, isUnresolvedActor, sanitizeSegment } = require('./actor-identity');
|
|
82
95
|
const { flowAgentsArtifactRoot } = require('./local-artifact-paths');
|
|
96
|
+
const { readCurrentPointer } = require('./current-pointer');
|
|
83
97
|
|
|
84
98
|
/**
|
|
85
99
|
* Resolve a caller-supplied `now` (Date, ISO string, or omitted) to epoch ms.
|
|
@@ -101,35 +115,50 @@ function resolveNowMs(now) {
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
* Read
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
118
|
+
* Read the active_slug from the actor-scoped "current" pointer (#291: per-actor
|
|
119
|
+
* `current/<actor>.json` preferred over the legacy global `current.json`, via
|
|
120
|
+
* `readCurrentPointer` — the single choke point for that preference rule), sanitized through the
|
|
121
|
+
* same charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
|
|
122
|
+
* sec-LOW: this is a local file that could be hand-edited or otherwise hostile — `active_slug`
|
|
123
|
+
* must never be trusted verbatim before it is used as a JSONL grouping key / emitted `subjectId`
|
|
124
|
+
* or compared against event data). Tolerates a missing file or malformed JSON (returns "") exactly
|
|
125
|
+
* like the plain-`fs.readFileSync` read this replaces; when `actorKey` is empty/unresolved,
|
|
126
|
+
* `readCurrentPointer` falls straight to the legacy global file, so behavior for that case is
|
|
127
|
+
* unchanged from before #291.
|
|
110
128
|
*
|
|
111
129
|
* @param {string} root
|
|
130
|
+
* @param {string} [actorKey]
|
|
112
131
|
* @returns {string}
|
|
113
132
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
/**
|
|
134
|
+
* F4 fix (fix-plan iteration 1, MED): a cheap, actor-free existence peek restoring the module's
|
|
135
|
+
* documented "no active session never pays resolveActor()" invariant. `readCurrentPointer`'s
|
|
136
|
+
* actor-aware preference genuinely needs a resolved actor to choose per-actor vs. legacy — but
|
|
137
|
+
* that choice is only ever relevant when SOME current-pointer file might exist at all. When
|
|
138
|
+
* neither the legacy global `<root>/current.json` file NOR the `<root>/current/` per-actor
|
|
139
|
+
* directory exists, there is provably no active session for any actor (per-actor files are only
|
|
140
|
+
* ever written into that directory — see current-pointer.js's writePerActorCurrent), so this
|
|
141
|
+
* function returns false without calling resolveActor() at all, exactly restoring the pre-#291
|
|
142
|
+
* short-circuit for the common "liveness enabled, no session yet" case. Two plain fs.existsSync
|
|
143
|
+
* calls — no ps/proc ancestry cost, no JSON parse.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} root
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
function mightHaveActiveSession(root) {
|
|
149
|
+
return fs.existsSync(path.join(root, 'current.json')) || fs.existsSync(path.join(root, 'current'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readActiveSlug(root, actorKey) {
|
|
153
|
+
const { payload: current } = readCurrentPointer(root, actorKey);
|
|
154
|
+
if (!current) return '';
|
|
155
|
+
const activeSlug = (current && current.active_slug) || '';
|
|
156
|
+
if (!activeSlug) return '';
|
|
157
|
+
// sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
|
|
158
|
+
// fallback is not a legitimate slug, so treat it as "no active slug" here rather than
|
|
159
|
+
// matching against a subject that was never really claimed.
|
|
160
|
+
const sanitized = sanitizeSegment(activeSlug);
|
|
161
|
+
return sanitized === 'unknown' ? '' : sanitized;
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
/**
|
|
@@ -221,9 +250,10 @@ function attachConflictField(result, conflict) {
|
|
|
221
250
|
* object, never throws.
|
|
222
251
|
*
|
|
223
252
|
* See the module header for the full F8(ii) throttle/emit split. Summary:
|
|
224
|
-
* - enabled? ->
|
|
225
|
-
*
|
|
226
|
-
*
|
|
253
|
+
* - enabled? -> mightHaveActiveSession() peek -> resolveActor() -> slug
|
|
254
|
+
* present? -> bounded tail read, in that order (F3, restored by F4), so
|
|
255
|
+
* a disabled repo or one with no active session never pays the
|
|
256
|
+
* resolveActor() ancestry `ps` spawn cost.
|
|
227
257
|
* - THROTTLE decision reads only the bounded tail (never a full read):
|
|
228
258
|
* a release found there refuses; a fresh event there throttles.
|
|
229
259
|
* - EMIT decision requires an actual `claim` event for the pair: taken
|
|
@@ -248,12 +278,22 @@ function maybeEmitHeartbeat(opts = {}) {
|
|
|
248
278
|
}
|
|
249
279
|
|
|
250
280
|
const root = flowAgentsArtifactRoot(cwd);
|
|
251
|
-
|
|
252
|
-
|
|
281
|
+
// F4 fix (fix-plan iteration 1, MED): restore the "no active session never pays
|
|
282
|
+
// resolveActor()" invariant. mightHaveActiveSession() is a plain fs.existsSync peek of the
|
|
283
|
+
// LEGACY current.json / current/ dir — no actor needed — so a repo with liveness enabled but
|
|
284
|
+
// no session at all (the common case this invariant exists for) short-circuits here, exactly
|
|
285
|
+
// like pre-#291. Only once a current-pointer file MIGHT exist do we pay for resolveActor(),
|
|
286
|
+
// needed to decide per-actor vs. legacy preference via readActiveSlug/readCurrentPointer.
|
|
287
|
+
if (!mightHaveActiveSession(root)) {
|
|
253
288
|
return { emitted: false, reason: 'no-current' };
|
|
254
289
|
}
|
|
255
290
|
|
|
256
291
|
const { actor } = resolveActor(env);
|
|
292
|
+
const slug = readActiveSlug(root, actor);
|
|
293
|
+
if (!slug) {
|
|
294
|
+
return { emitted: false, reason: 'no-current' };
|
|
295
|
+
}
|
|
296
|
+
|
|
257
297
|
if (isUnresolvedActor(actor)) {
|
|
258
298
|
return { emitted: false, reason: 'actor-unresolved' };
|
|
259
299
|
}
|
|
@@ -42,6 +42,8 @@ const {
|
|
|
42
42
|
flowAgentsArtifactRoot,
|
|
43
43
|
flowAgentsArtifactRootsForRead,
|
|
44
44
|
} = require('./lib/local-artifact-paths');
|
|
45
|
+
const { resolveActor } = require('./lib/actor-identity.js');
|
|
46
|
+
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
45
47
|
|
|
46
48
|
const MAX_STDIN = 1024 * 1024;
|
|
47
49
|
const ACTIVE_STATUSES = new Set([
|
|
@@ -1531,7 +1533,7 @@ async function bundleEnforcement(artifactDir, activeFlowStep) {
|
|
|
1531
1533
|
* to scanning all of .kontourai/flow-agents (newest-mtime).
|
|
1532
1534
|
*/
|
|
1533
1535
|
function preferredArtifactDir(flowAgentsDir) {
|
|
1534
|
-
const current =
|
|
1536
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
|
|
1535
1537
|
if (!current) return null;
|
|
1536
1538
|
const slug = current.artifact_dir || current.active_slug;
|
|
1537
1539
|
if (typeof slug !== 'string' || !slug.trim()) return null;
|
|
@@ -1549,7 +1551,7 @@ function hasSidecarPresence(artifactDir) {
|
|
|
1549
1551
|
// return that slug so analyze() can log the staleness rather than silently falling back to
|
|
1550
1552
|
// a global mtime scan that could resurface an abandoned/never-real session as active.
|
|
1551
1553
|
function staleCurrentSlug(flowAgentsDir) {
|
|
1552
|
-
const current =
|
|
1554
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
|
|
1553
1555
|
if (!current) return null;
|
|
1554
1556
|
const slug = current.artifact_dir || current.active_slug;
|
|
1555
1557
|
if (typeof slug !== 'string' || !slug.trim()) return null;
|
|
@@ -19,6 +19,7 @@ const path = require('path');
|
|
|
19
19
|
const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
|
|
20
20
|
const { resolveActor } = require('./lib/actor-identity');
|
|
21
21
|
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
22
|
+
const { readCurrentPointer } = require('./lib/current-pointer');
|
|
22
23
|
|
|
23
24
|
const STEERING = {
|
|
24
25
|
'tool-planner': [
|
|
@@ -102,7 +103,48 @@ function readJson(file) {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* #291 (Conflict #2, Wave 2 Task 2.4): actor-scoped preference consulted BEFORE the global
|
|
108
|
+
* newest-mtime scan below. For each read-root, resolve the "current" pointer via
|
|
109
|
+
* `readCurrentPointer` (per-actor `current/<actor>.json` preferred, legacy global
|
|
110
|
+
* `current.json` fallback — the same single choke point every other Wave 2 consumer uses) and,
|
|
111
|
+
* if it names a session dir whose `state.json` is still in an ACTIVE_STATE_STATUSES status,
|
|
112
|
+
* return that state immediately — so actor A's own ambient steering hint always prefers A's own
|
|
113
|
+
* current task over a globally-newer-mtime `state.json` some other actor (B) happened to touch
|
|
114
|
+
* more recently. Returns null (never throws) when no root yields an actor-scoped resolution,
|
|
115
|
+
* so the caller falls through to the unchanged global scan below — this is the compat-shim
|
|
116
|
+
* guarantee: when `actorKey` is empty/unresolved and/or no per-actor file exists yet, this
|
|
117
|
+
* degrades to the SAME legacy-`current.json`-or-nothing lookup the compat shim already performs
|
|
118
|
+
* for every other Wave 2 reader, so a single-session/CI/legacy-only fixture cannot fail to
|
|
119
|
+
* resolve here in a way it wouldn't already have resolved via the global scan.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} root
|
|
122
|
+
* @param {string} actorKey
|
|
123
|
+
* @returns {{file: string, payload: object, mtimeMs: number}|null}
|
|
124
|
+
*/
|
|
125
|
+
function actorScopedWorkflowState(root, actorKey) {
|
|
126
|
+
for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
|
|
127
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
|
|
128
|
+
if (!current) continue;
|
|
129
|
+
const slug = current.artifact_dir || current.active_slug;
|
|
130
|
+
if (typeof slug !== 'string' || !slug.trim()) continue;
|
|
131
|
+
const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
|
|
132
|
+
const dir = path.join(flowAgentsDir, safe);
|
|
133
|
+
if (!dir.startsWith(flowAgentsDir + path.sep) || !fs.existsSync(dir)) continue;
|
|
134
|
+
const file = path.join(dir, 'state.json');
|
|
135
|
+
let stat;
|
|
136
|
+
try { stat = fs.statSync(file); } catch { continue; }
|
|
137
|
+
const payload = readJson(file);
|
|
138
|
+
if (!payload || !ACTIVE_STATE_STATUSES.has(payload.status)) continue;
|
|
139
|
+
return { file, payload, mtimeMs: stat.mtimeMs };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
function latestWorkflowState(root) {
|
|
145
|
+
const preferred = actorScopedWorkflowState(root, resolveActor(process.env).actor);
|
|
146
|
+
if (preferred) return preferred;
|
|
147
|
+
|
|
106
148
|
const states = flowAgentsArtifactRootsForRead(root)
|
|
107
149
|
.flatMap(artifactRoot => walkStateFiles(artifactRoot))
|
|
108
150
|
.map(file => {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const { resolveActor } = require('../hooks/lib/actor-identity.js');
|
|
14
|
+
const { readCurrentPointer } = require('../hooks/lib/current-pointer.js');
|
|
13
15
|
|
|
14
16
|
const MAX_SUMMARY = 72;
|
|
15
17
|
|
|
@@ -76,7 +78,7 @@ function stateFiles(workflowRoot) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
function chooseWorkflow(workflowRoot) {
|
|
79
|
-
const current =
|
|
81
|
+
const { payload: current } = readCurrentPointer(workflowRoot, resolveActor(process.env).actor);
|
|
80
82
|
if (current?.active_slug || current?.artifact_dir) {
|
|
81
83
|
const slug = current.active_slug || current.artifact_dir;
|
|
82
84
|
const dir = path.join(workflowRoot, slug);
|