chati-dev 4.5.15 → 4.5.27
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/README.md +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +2 -2
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +278 -24
- package/framework/orchestrator/chati.md +250 -68
- package/framework/schemas/session.schema.json +21 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +157 -56
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +91 -23
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/intelligence/timeline.js +3 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1616 -151
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +87 -4
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1249 -52
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +10 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +434 -16
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/utils/schema-validator.js +5 -0
- package/src/wizard/index.js +3 -2
|
@@ -6,36 +6,391 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as yaml from 'js-yaml';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
|
|
11
|
+
openSync, readSync, realpathSync, renameSync, rmSync, rmdirSync,
|
|
12
|
+
unlinkSync, writeFileSync, readFileSync,
|
|
13
|
+
} from 'fs';
|
|
14
|
+
import { basename, join, dirname, resolve } from 'path';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { hostname } from 'node:os';
|
|
12
17
|
|
|
13
18
|
const SESSION_FILE = '.chati/session.yaml';
|
|
19
|
+
const SESSION_MAX_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
const SESSION_LOCK_OWNER_MAX_BYTES = 16 * 1024;
|
|
21
|
+
const SESSION_LOCK_STALE_AFTER_MS = 30_000;
|
|
22
|
+
const SESSION_TEST_HOOKS = Symbol.for('chati.session-manager.security-test-hooks');
|
|
23
|
+
const UNSAFE_SESSION_LOCK_OWNER = Symbol('unsafe-session-lock-owner');
|
|
24
|
+
|
|
25
|
+
function assertSessionWritableSize(value) {
|
|
26
|
+
if (Buffer.byteLength(JSON.stringify(value)) > SESSION_MAX_BYTES) {
|
|
27
|
+
throw new Error('session.yaml exceeds the 4 MiB safety limit');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runSecurityTestHook(name, detail) {
|
|
32
|
+
const hook = globalThis[SESSION_TEST_HOOKS]?.[name];
|
|
33
|
+
if (typeof hook === 'function') hook(detail);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameFileIdentity(left, right) {
|
|
37
|
+
return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameFileSnapshot(left, right) {
|
|
41
|
+
return sameFileIdentity(left, right)
|
|
42
|
+
&& left.mode === right.mode
|
|
43
|
+
&& left.uid === right.uid
|
|
44
|
+
&& left.gid === right.gid
|
|
45
|
+
&& left.nlink === right.nlink
|
|
46
|
+
&& left.size === right.size
|
|
47
|
+
&& left.mtimeMs === right.mtimeMs
|
|
48
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameLockOwnerSnapshot(left, right) {
|
|
52
|
+
return sameFileIdentity(left, right)
|
|
53
|
+
&& left.mode === right.mode
|
|
54
|
+
&& left.uid === right.uid
|
|
55
|
+
&& left.gid === right.gid
|
|
56
|
+
&& left.nlink === right.nlink
|
|
57
|
+
&& left.size === right.size
|
|
58
|
+
&& left.mtimeNs === right.mtimeNs
|
|
59
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openDirectoryNoFollow(directoryPath) {
|
|
63
|
+
const fd = openSync(
|
|
64
|
+
directoryPath,
|
|
65
|
+
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
66
|
+
);
|
|
67
|
+
const stat = fstatSync(fd);
|
|
68
|
+
if (!stat.isDirectory()) {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
throw new Error('The .chati path must be a real directory');
|
|
71
|
+
}
|
|
72
|
+
return { fd, stat };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertDirectoryIdentity(directoryPath, expected) {
|
|
76
|
+
let opened;
|
|
77
|
+
try {
|
|
78
|
+
opened = openDirectoryNoFollow(directoryPath);
|
|
79
|
+
if (!sameFileIdentity(opened.stat, expected)) {
|
|
80
|
+
throw new Error('The .chati directory changed during the session operation');
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
if (opened) closeSync(opened.fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openRegularFileNoFollow(path, { allowMissing = false } = {}) {
|
|
88
|
+
let fd;
|
|
89
|
+
try {
|
|
90
|
+
fd = openSync(
|
|
91
|
+
path,
|
|
92
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (allowMissing && err.code === 'ENOENT') return null;
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const stat = fstatSync(fd);
|
|
100
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('session.yaml must be a regular file');
|
|
101
|
+
if (stat.size > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
|
|
102
|
+
return { fd, stat };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
closeSync(fd);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertPathIdentity(path, expected, { allowMissing = false } = {}) {
|
|
110
|
+
const opened = openRegularFileNoFollow(path, { allowMissing });
|
|
111
|
+
if (!opened) {
|
|
112
|
+
if (expected !== null) throw new Error('session.yaml changed during the session operation');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (expected === null || !sameFileSnapshot(opened.stat, expected)) {
|
|
117
|
+
throw new Error('session.yaml changed during the session operation');
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
closeSync(opened.fd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readRegularFileBounded(opened) {
|
|
125
|
+
const buffer = Buffer.allocUnsafe(SESSION_MAX_BYTES + 1);
|
|
126
|
+
let offset = 0;
|
|
127
|
+
while (offset < buffer.length) {
|
|
128
|
+
const bytesRead = readSync(opened.fd, buffer, offset, buffer.length - offset, null);
|
|
129
|
+
if (bytesRead === 0) break;
|
|
130
|
+
offset += bytesRead;
|
|
131
|
+
}
|
|
132
|
+
if (offset > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
|
|
133
|
+
return buffer.subarray(0, offset).toString('utf8');
|
|
134
|
+
}
|
|
14
135
|
|
|
15
136
|
/**
|
|
16
|
-
* Atomic write helper.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `writeBackSession` to keep both hook and CLI write paths consistent.
|
|
137
|
+
* Atomic write helper. On Linux, all destination operations use the pinned
|
|
138
|
+
* directory descriptor through /proc/self/fd so an ancestor replacement cannot
|
|
139
|
+
* redirect the final rename. Other platforms retain the canonical path plus
|
|
140
|
+
* immediate identity checks before and after the rename.
|
|
21
141
|
*/
|
|
22
|
-
function writeFileAtomic(path, content, encoding = 'utf-8'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
142
|
+
function writeFileAtomic(path, content, encoding = 'utf-8', {
|
|
143
|
+
expectedDestinationIdentity,
|
|
144
|
+
expectedDirectoryIdentity,
|
|
145
|
+
} = {}) {
|
|
146
|
+
if (Buffer.byteLength(String(content), encoding) > SESSION_MAX_BYTES) {
|
|
147
|
+
throw new Error('session.yaml exceeds the 4 MiB safety limit');
|
|
148
|
+
}
|
|
149
|
+
const directoryPath = dirname(path);
|
|
150
|
+
let directory;
|
|
151
|
+
let destination;
|
|
152
|
+
let tmp;
|
|
153
|
+
let tmpFd;
|
|
154
|
+
let tmpIdentity;
|
|
155
|
+
try {
|
|
156
|
+
directory = openDirectoryNoFollow(directoryPath);
|
|
157
|
+
if (expectedDirectoryIdentity && !sameFileIdentity(directory.stat, expectedDirectoryIdentity)) {
|
|
158
|
+
throw new Error('The .chati directory changed during the session operation');
|
|
159
|
+
}
|
|
160
|
+
const directoryReference = process.platform === 'linux'
|
|
161
|
+
? `/proc/self/fd/${directory.fd}`
|
|
162
|
+
: directoryPath;
|
|
163
|
+
destination = join(directoryReference, basename(path));
|
|
164
|
+
tmp = join(directoryReference, `${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
165
|
+
const expectedDestination = expectedDestinationIdentity === undefined
|
|
166
|
+
? (() => {
|
|
167
|
+
const opened = openRegularFileNoFollow(destination, { allowMissing: true });
|
|
168
|
+
if (!opened) return null;
|
|
169
|
+
try { return opened.stat; } finally { closeSync(opened.fd); }
|
|
170
|
+
})()
|
|
171
|
+
: expectedDestinationIdentity;
|
|
172
|
+
tmpFd = openSync(
|
|
173
|
+
tmp,
|
|
174
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
|
|
175
|
+
0o600,
|
|
176
|
+
);
|
|
177
|
+
tmpIdentity = fstatSync(tmpFd);
|
|
178
|
+
if (!tmpIdentity.isFile() || tmpIdentity.nlink !== 1) throw new Error('Session temporary must be a regular file');
|
|
179
|
+
writeFileSync(tmpFd, content, { encoding });
|
|
180
|
+
fsyncSync(tmpFd);
|
|
181
|
+
|
|
182
|
+
runSecurityTestHook('beforeAtomicRename', { path, tmp });
|
|
183
|
+
assertDirectoryIdentity(directoryPath, directory.stat);
|
|
184
|
+
assertPathIdentity(destination, expectedDestination, { allowMissing: true });
|
|
185
|
+
const currentTmp = lstatSync(tmp);
|
|
186
|
+
if (currentTmp.isSymbolicLink() || !currentTmp.isFile() || !sameFileIdentity(currentTmp, tmpIdentity)) {
|
|
187
|
+
throw new Error('Session temporary changed before atomic rename');
|
|
188
|
+
}
|
|
189
|
+
runSecurityTestHook('beforeAtomicRenameCommit', { path, tmp });
|
|
190
|
+
assertDirectoryIdentity(directoryPath, directory.stat);
|
|
191
|
+
assertPathIdentity(destination, expectedDestination, { allowMissing: true });
|
|
192
|
+
renameSync(tmp, destination);
|
|
193
|
+
fsyncSync(directory.fd);
|
|
194
|
+
assertDirectoryIdentity(directoryPath, directory.stat);
|
|
195
|
+
} finally {
|
|
196
|
+
if (tmpFd !== undefined) closeSync(tmpFd);
|
|
197
|
+
try {
|
|
198
|
+
const currentTmp = lstatSync(tmp);
|
|
199
|
+
if (tmpIdentity && currentTmp.isFile() && !currentTmp.isSymbolicLink()
|
|
200
|
+
&& sameFileIdentity(currentTmp, tmpIdentity)) rmSync(tmp, { force: true });
|
|
201
|
+
} catch { /* leave an unverified temporary untouched */ }
|
|
202
|
+
if (directory) closeSync(directory.fd);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readSessionDocumentRecord(sessionPath) {
|
|
207
|
+
const directoryPath = dirname(sessionPath);
|
|
208
|
+
let directory;
|
|
209
|
+
let opened;
|
|
210
|
+
try {
|
|
211
|
+
directory = openDirectoryNoFollow(directoryPath);
|
|
212
|
+
runSecurityTestHook('beforeReadOpen', { sessionPath });
|
|
213
|
+
opened = openRegularFileNoFollow(sessionPath);
|
|
214
|
+
runSecurityTestHook('afterReadOpen', { sessionPath, fd: opened.fd });
|
|
215
|
+
const raw = readRegularFileBounded(opened);
|
|
216
|
+
runSecurityTestHook('afterReadContent', { sessionPath, fd: opened.fd });
|
|
217
|
+
const finalStat = fstatSync(opened.fd);
|
|
218
|
+
if (!sameFileSnapshot(opened.stat, finalStat)) {
|
|
219
|
+
throw new Error('session.yaml changed during the session operation');
|
|
220
|
+
}
|
|
221
|
+
const session = yaml.load(raw);
|
|
222
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) throw new Error('Invalid session document');
|
|
223
|
+
assertDirectoryIdentity(directoryPath, directory.stat);
|
|
224
|
+
assertPathIdentity(sessionPath, opened.stat);
|
|
225
|
+
return { session, identity: opened.stat, directoryIdentity: directory.stat };
|
|
226
|
+
} finally {
|
|
227
|
+
if (opened) closeSync(opened.fd);
|
|
228
|
+
if (directory) closeSync(directory.fd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readSessionDocument(sessionPath) {
|
|
233
|
+
return readSessionDocumentRecord(sessionPath).session;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readRuntimeSessionDocument(sessionPath) {
|
|
237
|
+
let releaseLock = null;
|
|
238
|
+
try {
|
|
239
|
+
try {
|
|
240
|
+
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// A genuinely read-only project cannot create the sibling lock directory
|
|
243
|
+
// and cannot be mutated by this process. Preserve read-only inspection
|
|
244
|
+
// while retaining the descriptor and path-identity checks.
|
|
245
|
+
if (!['EACCES', 'EROFS'].includes(err.code)) throw err;
|
|
246
|
+
}
|
|
247
|
+
return readSessionDocument(sessionPath);
|
|
248
|
+
} finally {
|
|
249
|
+
if (releaseLock) releaseLock();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readSessionDocumentRecordIfPresent(sessionPath) {
|
|
254
|
+
try {
|
|
255
|
+
return readSessionDocumentRecord(sessionPath);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err.code === 'ENOENT') return null;
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sessionLockOwner(lockPath) {
|
|
263
|
+
const ownerPath = join(lockPath, 'owner.json');
|
|
264
|
+
let descriptor;
|
|
265
|
+
try {
|
|
266
|
+
descriptor = openSync(ownerPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
267
|
+
const stat = fstatSync(descriptor, { bigint: true });
|
|
268
|
+
if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(SESSION_LOCK_OWNER_MAX_BYTES)) {
|
|
269
|
+
return UNSAFE_SESSION_LOCK_OWNER;
|
|
270
|
+
}
|
|
271
|
+
runSecurityTestHook('afterSessionLockOwnerStat', { path: ownerPath });
|
|
272
|
+
const bytes = Buffer.allocUnsafe(SESSION_LOCK_OWNER_MAX_BYTES + 1);
|
|
273
|
+
let total = 0;
|
|
274
|
+
while (total < bytes.length) {
|
|
275
|
+
const bytesRead = readSync(descriptor, bytes, total, bytes.length - total, null);
|
|
276
|
+
if (bytesRead === 0) break;
|
|
277
|
+
total += bytesRead;
|
|
278
|
+
}
|
|
279
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
280
|
+
if (total > SESSION_LOCK_OWNER_MAX_BYTES || !sameLockOwnerSnapshot(stat, after)) {
|
|
281
|
+
return UNSAFE_SESSION_LOCK_OWNER;
|
|
282
|
+
}
|
|
283
|
+
let owner;
|
|
284
|
+
try { owner = JSON.parse(bytes.subarray(0, total).toString('utf8')); }
|
|
285
|
+
catch { return null; }
|
|
286
|
+
return owner && typeof owner.token === 'string' ? owner : null;
|
|
287
|
+
} catch (error) { return error?.code === 'ENOENT' ? null : UNSAFE_SESSION_LOCK_OWNER; }
|
|
288
|
+
finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function localProcessAlive(pid) {
|
|
292
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
293
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function localProcessIdentity(pid) {
|
|
297
|
+
if (process.platform !== 'linux' || !Number.isInteger(pid) || pid <= 0) return null;
|
|
298
|
+
try {
|
|
299
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
300
|
+
const commandEnd = stat.lastIndexOf(')');
|
|
301
|
+
if (commandEnd < 0) return null;
|
|
302
|
+
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
303
|
+
const startTime = fields[19]; // field 22; fields[0] is field 3 (state)
|
|
304
|
+
const bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
305
|
+
if (!startTime || !bootId) return null;
|
|
306
|
+
return `linux-proc-v1:${bootId}:${startTime}`;
|
|
307
|
+
} catch { return null; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sessionLockAgeMs(lockStat) {
|
|
311
|
+
return Math.max(0, Date.now() - lockStat.mtimeMs);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function recoverAbandonedSessionLock(lockPath) {
|
|
315
|
+
let initial;
|
|
316
|
+
try { initial = lstatSync(lockPath); } catch (error) { return error?.code === 'ENOENT'; }
|
|
317
|
+
if (initial.isSymbolicLink() || !initial.isDirectory()) return false;
|
|
318
|
+
const owner = sessionLockOwner(lockPath);
|
|
319
|
+
if (owner === UNSAFE_SESSION_LOCK_OWNER) return UNSAFE_SESSION_LOCK_OWNER;
|
|
320
|
+
const staleByAge = sessionLockAgeMs(initial) >= SESSION_LOCK_STALE_AFTER_MS;
|
|
321
|
+
if (owner?.hostname && owner.hostname !== hostname() && !staleByAge) return false;
|
|
322
|
+
if (owner?.hostname === hostname() && localProcessAlive(owner.pid)) {
|
|
323
|
+
const observedIdentity = localProcessIdentity(owner.pid);
|
|
324
|
+
if (owner.process_identity && observedIdentity === owner.process_identity) return false;
|
|
325
|
+
// Legacy owners and platforms without a stable process start identity get
|
|
326
|
+
// a bounded lease instead of trusting a potentially recycled PID forever.
|
|
327
|
+
if ((!owner.process_identity || !observedIdentity) && !staleByAge) return false;
|
|
328
|
+
}
|
|
329
|
+
if (!owner && !staleByAge) return false;
|
|
330
|
+
const electionPath = join(lockPath, 'recovery.json');
|
|
331
|
+
let electionFd;
|
|
332
|
+
try {
|
|
333
|
+
electionFd = openSync(electionPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
334
|
+
writeFileSync(electionFd, JSON.stringify({ pid: process.pid, hostname: hostname(), token: randomUUID() }));
|
|
335
|
+
fsyncSync(electionFd);
|
|
336
|
+
} catch {
|
|
337
|
+
if (electionFd !== undefined) closeSync(electionFd);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
const quarantine = `${lockPath}.stale.${process.pid}.${randomUUID()}`;
|
|
341
|
+
try {
|
|
342
|
+
const current = lstatSync(lockPath);
|
|
343
|
+
const currentOwner = sessionLockOwner(lockPath);
|
|
344
|
+
if (currentOwner === UNSAFE_SESSION_LOCK_OWNER) return UNSAFE_SESSION_LOCK_OWNER;
|
|
345
|
+
if (!sameFileIdentity(initial, current) || currentOwner?.token !== owner?.token) return false;
|
|
346
|
+
renameSync(lockPath, quarantine);
|
|
347
|
+
try { unlinkSync(join(quarantine, 'owner.json')); } catch { /* owner may be absent */ }
|
|
348
|
+
unlinkSync(join(quarantine, 'recovery.json'));
|
|
349
|
+
rmdirSync(quarantine);
|
|
350
|
+
return true;
|
|
351
|
+
} catch { return false; }
|
|
352
|
+
finally { if (electionFd !== undefined) closeSync(electionFd); }
|
|
26
353
|
}
|
|
27
354
|
|
|
28
355
|
function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
|
|
29
356
|
const startedAt = Date.now();
|
|
357
|
+
const lockPath = `${sessionPath}.lock`;
|
|
358
|
+
const parentPath = dirname(lockPath);
|
|
359
|
+
let unsafeLockObserved = false;
|
|
30
360
|
while (true) {
|
|
31
361
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
362
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
363
|
+
const token = randomUUID();
|
|
364
|
+
const ownerPath = join(lockPath, 'owner.json');
|
|
365
|
+
const ownerFd = openSync(ownerPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
366
|
+
try {
|
|
367
|
+
writeFileSync(ownerFd, JSON.stringify({
|
|
368
|
+
schema_version: 2,
|
|
369
|
+
pid: process.pid,
|
|
370
|
+
hostname: hostname(),
|
|
371
|
+
process_identity: localProcessIdentity(process.pid),
|
|
372
|
+
token,
|
|
373
|
+
acquired_at: new Date().toISOString(),
|
|
374
|
+
}));
|
|
375
|
+
fsyncSync(ownerFd);
|
|
376
|
+
} finally { closeSync(ownerFd); }
|
|
377
|
+
const lockDirFd = openSync(lockPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
378
|
+
try { fsyncSync(lockDirFd); } finally { closeSync(lockDirFd); }
|
|
379
|
+
const parentFd = openSync(parentPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
380
|
+
try { fsyncSync(parentFd); } finally { closeSync(parentFd); }
|
|
381
|
+
return () => {
|
|
382
|
+
const current = sessionLockOwner(lockPath);
|
|
383
|
+
if (current?.token !== token) return;
|
|
384
|
+
unlinkSync(ownerPath);
|
|
385
|
+
rmdirSync(lockPath);
|
|
386
|
+
const releaseParentFd = openSync(parentPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
387
|
+
try { fsyncSync(releaseParentFd); } finally { closeSync(releaseParentFd); }
|
|
388
|
+
};
|
|
37
389
|
} catch (err) {
|
|
38
|
-
if (err.code !== '
|
|
390
|
+
if (err.code !== 'EEXIST') throw err;
|
|
391
|
+
const recovery = unsafeLockObserved ? false : recoverAbandonedSessionLock(lockPath);
|
|
392
|
+
if (recovery === UNSAFE_SESSION_LOCK_OWNER) unsafeLockObserved = true;
|
|
393
|
+
else if (recovery) continue;
|
|
39
394
|
if (Date.now() - startedAt >= maxWaitMs) {
|
|
40
395
|
throw new Error(`Timed out waiting for session write lock after ${maxWaitMs}ms`, { cause: err });
|
|
41
396
|
}
|
|
@@ -47,17 +402,161 @@ function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
|
|
|
47
402
|
function findSessionProjectDir(startDir) {
|
|
48
403
|
let current = resolve(startDir);
|
|
49
404
|
while (true) {
|
|
50
|
-
|
|
405
|
+
const chatiDir = join(current, '.chati');
|
|
406
|
+
if (existsSync(chatiDir) && !lstatSync(chatiDir).isSymbolicLink() && lstatSync(chatiDir).isDirectory()
|
|
407
|
+
&& existsSync(join(current, SESSION_FILE))
|
|
408
|
+
&& !lstatSync(join(current, SESSION_FILE)).isSymbolicLink()
|
|
409
|
+
&& lstatSync(join(current, SESSION_FILE)).isFile()) return current;
|
|
51
410
|
const parent = dirname(current);
|
|
52
411
|
if (parent === current) return null;
|
|
53
412
|
current = parent;
|
|
54
413
|
}
|
|
55
414
|
}
|
|
56
415
|
|
|
416
|
+
function validateSessionPath(projectDir, { createDirectory = false, requireFile = false } = {}) {
|
|
417
|
+
const projectRoot = realpathSync(resolve(projectDir));
|
|
418
|
+
const requestedSessionPath = join(projectRoot, SESSION_FILE);
|
|
419
|
+
const sessionDir = dirname(requestedSessionPath);
|
|
420
|
+
let dirStat = null;
|
|
421
|
+
try {
|
|
422
|
+
dirStat = lstatSync(sessionDir);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (err.code !== 'ENOENT') throw err;
|
|
425
|
+
}
|
|
426
|
+
if (dirStat) {
|
|
427
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
428
|
+
throw new Error('The .chati path must be a real directory');
|
|
429
|
+
}
|
|
430
|
+
} else if (createDirectory) {
|
|
431
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
432
|
+
} else {
|
|
433
|
+
throw new Error('Session directory not found');
|
|
434
|
+
}
|
|
435
|
+
const realSessionDir = realpathSync(sessionDir);
|
|
436
|
+
if (dirname(realSessionDir) !== projectRoot || realSessionDir !== join(projectRoot, '.chati')) {
|
|
437
|
+
throw new Error('The .chati directory must remain inside the project');
|
|
438
|
+
}
|
|
439
|
+
const openedDirectory = openDirectoryNoFollow(sessionDir);
|
|
440
|
+
closeSync(openedDirectory.fd);
|
|
441
|
+
const sessionPath = join(realSessionDir, 'session.yaml');
|
|
442
|
+
let fileStat = null;
|
|
443
|
+
try {
|
|
444
|
+
fileStat = lstatSync(sessionPath);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (err.code !== 'ENOENT') throw err;
|
|
447
|
+
}
|
|
448
|
+
if (fileStat) {
|
|
449
|
+
if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
|
|
450
|
+
throw new Error('session.yaml must be a regular file');
|
|
451
|
+
}
|
|
452
|
+
} else if (requireFile) {
|
|
453
|
+
throw new Error('Session file not found');
|
|
454
|
+
}
|
|
455
|
+
return sessionPath;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Read canonical session state without migration or write-back. Authorization
|
|
460
|
+
* and routing consumers use this surface so invalid, symlinked, oversized or
|
|
461
|
+
* non-regular session files fail closed under the same checks as mutations.
|
|
462
|
+
*/
|
|
463
|
+
export function readSessionSafe(projectDir) {
|
|
464
|
+
let sessionPath;
|
|
465
|
+
let releaseLock = null;
|
|
466
|
+
try {
|
|
467
|
+
sessionPath = validateSessionPath(projectDir, { requireFile: true });
|
|
468
|
+
// Security consumers read under the same lock used by atomic writers. This
|
|
469
|
+
// avoids false failures during a legitimate rename without retrying an
|
|
470
|
+
// attacker-controlled replacement after an identity check has failed.
|
|
471
|
+
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
472
|
+
const record = readSessionDocumentRecord(sessionPath);
|
|
473
|
+
releaseLock();
|
|
474
|
+
releaseLock = null;
|
|
475
|
+
return { loaded: true, missing: false, session: record.session, path: sessionPath, error: null };
|
|
476
|
+
} catch (err) {
|
|
477
|
+
const missing = err.code === 'ENOENT'
|
|
478
|
+
|| err.message === 'Session directory not found'
|
|
479
|
+
|| err.message === 'Session file not found';
|
|
480
|
+
return {
|
|
481
|
+
loaded: false,
|
|
482
|
+
missing,
|
|
483
|
+
session: null,
|
|
484
|
+
path: sessionPath || join(resolve(projectDir), SESSION_FILE),
|
|
485
|
+
error: err.message,
|
|
486
|
+
};
|
|
487
|
+
} finally {
|
|
488
|
+
if (releaseLock) {
|
|
489
|
+
try { releaseLock(); } catch { /* the read already fails closed above */ }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Persist the installer-generated initial document through the same lock and
|
|
496
|
+
* atomic writer used by the runtime. Existing session state is always
|
|
497
|
+
* preserved, including an inactive pre-init session created by an earlier
|
|
498
|
+
* installation.
|
|
499
|
+
*/
|
|
500
|
+
export function initializeSessionDocument(projectDir, yamlContent) {
|
|
501
|
+
let sessionPath;
|
|
502
|
+
let releaseLock = null;
|
|
503
|
+
let outcome;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = yaml.load(yamlContent);
|
|
506
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
507
|
+
throw new Error('Invalid initial session document');
|
|
508
|
+
}
|
|
509
|
+
sessionPath = validateSessionPath(projectDir, { createDirectory: true });
|
|
510
|
+
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
511
|
+
const existing = readSessionDocumentRecordIfPresent(sessionPath);
|
|
512
|
+
if (existing) {
|
|
513
|
+
outcome = { created: false, preserved: true, session: existing.session, path: sessionPath };
|
|
514
|
+
} else {
|
|
515
|
+
const directory = openDirectoryNoFollow(dirname(sessionPath));
|
|
516
|
+
try {
|
|
517
|
+
writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
|
|
518
|
+
expectedDestinationIdentity: null,
|
|
519
|
+
expectedDirectoryIdentity: directory.stat,
|
|
520
|
+
});
|
|
521
|
+
} finally {
|
|
522
|
+
closeSync(directory.fd);
|
|
523
|
+
}
|
|
524
|
+
outcome = { created: true, preserved: false, session: parsed, path: sessionPath };
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
outcome = {
|
|
528
|
+
created: false,
|
|
529
|
+
preserved: false,
|
|
530
|
+
session: null,
|
|
531
|
+
path: sessionPath || join(projectDir, SESSION_FILE),
|
|
532
|
+
error: `Failed to initialize session document: ${err.message}`,
|
|
533
|
+
};
|
|
534
|
+
} finally {
|
|
535
|
+
if (releaseLock) {
|
|
536
|
+
try {
|
|
537
|
+
releaseLock();
|
|
538
|
+
} catch (err) {
|
|
539
|
+
outcome = {
|
|
540
|
+
created: false,
|
|
541
|
+
preserved: false,
|
|
542
|
+
session: null,
|
|
543
|
+
path: sessionPath,
|
|
544
|
+
error: `Session initialization lock was compromised: ${err.message}`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return outcome;
|
|
550
|
+
}
|
|
551
|
+
|
|
57
552
|
/**
|
|
58
553
|
* Default session template.
|
|
59
554
|
*/
|
|
60
555
|
const CURRENT_SCHEMA_VERSION = '1.2';
|
|
556
|
+
const V2_SESSION_AGENTS = new Set([
|
|
557
|
+
'greenfield-wu', 'brownfield-wu', 'brief', 'detail', 'architect', 'ux',
|
|
558
|
+
'qa-planning', 'phases', 'tasks',
|
|
559
|
+
]);
|
|
61
560
|
|
|
62
561
|
const DEFAULT_SESSION = {
|
|
63
562
|
schema_version: CURRENT_SCHEMA_VERSION,
|
|
@@ -106,6 +605,12 @@ const DEFAULT_SESSION = {
|
|
|
106
605
|
// context_last_bracket: last computed bracket — telemetry + /chati status.
|
|
107
606
|
active_model: null,
|
|
108
607
|
active_provider: null,
|
|
608
|
+
// The harness is the CLI/UI currently orchestrating the session. It is not
|
|
609
|
+
// the provider selected for the routed task.
|
|
610
|
+
active_harness: null,
|
|
611
|
+
session_status: 'inactive',
|
|
612
|
+
current_execution_binding: null,
|
|
613
|
+
pending_interaction: null,
|
|
109
614
|
context_tokens_used: 0,
|
|
110
615
|
context_window_tokens: null,
|
|
111
616
|
context_last_bracket: null,
|
|
@@ -156,15 +661,22 @@ const DEFAULT_SESSION = {
|
|
|
156
661
|
* @returns {{ created: boolean, session: object, path: string }}
|
|
157
662
|
*/
|
|
158
663
|
export function initSession(projectDir, options = {}) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
664
|
+
let sessionPath;
|
|
665
|
+
try {
|
|
666
|
+
sessionPath = validateSessionPath(projectDir, { createDirectory: true });
|
|
667
|
+
} catch (err) {
|
|
668
|
+
return { created: false, session: null, path: join(projectDir, SESSION_FILE), error: err.message };
|
|
164
669
|
}
|
|
165
670
|
|
|
671
|
+
const sessionAgents = Object.fromEntries(
|
|
672
|
+
Object.entries(DEFAULT_SESSION.agents)
|
|
673
|
+
.filter(([agent]) => options.runtimeVersion !== 2 || V2_SESSION_AGENTS.has(agent))
|
|
674
|
+
.map(([agent, state]) => [agent, { ...state }]),
|
|
675
|
+
);
|
|
166
676
|
const session = {
|
|
167
677
|
...DEFAULT_SESSION,
|
|
678
|
+
runtime_version: options.runtimeVersion === 2 ? 2 : 1,
|
|
679
|
+
agents: sessionAgents,
|
|
168
680
|
mode: options.mode || 'discover',
|
|
169
681
|
project: {
|
|
170
682
|
name: options.projectName || '',
|
|
@@ -179,27 +691,69 @@ export function initSession(projectDir, options = {}) {
|
|
|
179
691
|
providers_enabled: options.providersEnabled || [],
|
|
180
692
|
};
|
|
181
693
|
|
|
694
|
+
let releaseLock = null;
|
|
695
|
+
let outcome;
|
|
182
696
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
697
|
+
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
698
|
+
const existingRecord = readSessionDocumentRecordIfPresent(sessionPath);
|
|
699
|
+
if (existingRecord) {
|
|
700
|
+
const existing = existingRecord.session;
|
|
701
|
+
const hasConcurrentProgress = existing && typeof existing === 'object' && !Array.isArray(existing) && (
|
|
702
|
+
existing.pending_interaction
|
|
703
|
+
|| (Array.isArray(existing.completed_agents) && existing.completed_agents.length > 0)
|
|
704
|
+
|| (existing.mode && existing.mode !== 'discover')
|
|
705
|
+
|| (existing.current_agent && existing.current_agent !== '')
|
|
706
|
+
);
|
|
707
|
+
if (hasConcurrentProgress) {
|
|
708
|
+
outcome = {
|
|
709
|
+
created: true,
|
|
710
|
+
preserved: true,
|
|
711
|
+
session: existing,
|
|
712
|
+
path: sessionPath,
|
|
713
|
+
};
|
|
714
|
+
} else {
|
|
715
|
+
const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
|
|
716
|
+
writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
|
|
717
|
+
expectedDestinationIdentity: existingRecord.identity,
|
|
718
|
+
expectedDirectoryIdentity: existingRecord.directoryIdentity,
|
|
719
|
+
});
|
|
720
|
+
outcome = { created: true, session, path: sessionPath };
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
|
|
724
|
+
const directory = openDirectoryNoFollow(dirname(sessionPath));
|
|
725
|
+
try {
|
|
726
|
+
writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
|
|
727
|
+
expectedDestinationIdentity: null,
|
|
728
|
+
expectedDirectoryIdentity: directory.stat,
|
|
729
|
+
});
|
|
730
|
+
} finally {
|
|
731
|
+
closeSync(directory.fd);
|
|
732
|
+
}
|
|
733
|
+
outcome = { created: true, session, path: sessionPath };
|
|
734
|
+
}
|
|
195
735
|
} catch (err) {
|
|
196
|
-
|
|
736
|
+
outcome = {
|
|
197
737
|
created: false,
|
|
198
738
|
session: null,
|
|
199
739
|
path: sessionPath,
|
|
200
740
|
error: `Failed to create session: ${err.message}`,
|
|
201
741
|
};
|
|
742
|
+
} finally {
|
|
743
|
+
if (releaseLock) {
|
|
744
|
+
try {
|
|
745
|
+
releaseLock();
|
|
746
|
+
} catch (err) {
|
|
747
|
+
outcome = {
|
|
748
|
+
created: false,
|
|
749
|
+
session: null,
|
|
750
|
+
path: sessionPath,
|
|
751
|
+
error: `Session initialization lock was compromised: ${err.message}`,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
202
755
|
}
|
|
756
|
+
return outcome;
|
|
203
757
|
}
|
|
204
758
|
|
|
205
759
|
/**
|
|
@@ -237,8 +791,24 @@ export function migrateSession(session) {
|
|
|
237
791
|
});
|
|
238
792
|
}
|
|
239
793
|
|
|
794
|
+
// Seed additive runtime fields even when the schema version is current.
|
|
795
|
+
// Patch releases can therefore repair installed v1.2 sessions safely.
|
|
796
|
+
let seededRuntimeFields = false;
|
|
797
|
+
const runtimeDefaults = {
|
|
798
|
+
active_harness: null,
|
|
799
|
+
session_status: 'inactive',
|
|
800
|
+
current_execution_binding: null,
|
|
801
|
+
pending_interaction: null,
|
|
802
|
+
};
|
|
803
|
+
for (const [key, value] of Object.entries(runtimeDefaults)) {
|
|
804
|
+
if (!(key in session)) {
|
|
805
|
+
session[key] = value;
|
|
806
|
+
seededRuntimeFields = true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
240
810
|
if (session.schema_version === CURRENT_SCHEMA_VERSION) {
|
|
241
|
-
return { migrated: normalizedLegacySelections, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
|
|
811
|
+
return { migrated: normalizedLegacySelections || seededRuntimeFields, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
|
|
242
812
|
}
|
|
243
813
|
|
|
244
814
|
// v0 (no schema_version) → v1.0 → v1.1 → v1.2
|
|
@@ -356,19 +926,21 @@ function inferLegacyProvider(model) {
|
|
|
356
926
|
* @returns {{ loaded: boolean, session: object|null, error: string|null, migrated?: boolean }}
|
|
357
927
|
*/
|
|
358
928
|
export function loadSession(projectDir) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
929
|
+
let sessionPath;
|
|
930
|
+
try {
|
|
931
|
+
sessionPath = validateSessionPath(projectDir, { requireFile: true });
|
|
932
|
+
} catch (err) {
|
|
362
933
|
return {
|
|
363
934
|
loaded: false,
|
|
364
935
|
session: null,
|
|
365
|
-
error:
|
|
936
|
+
error: err.message,
|
|
366
937
|
};
|
|
367
938
|
}
|
|
368
|
-
|
|
369
939
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
940
|
+
// Canonical runtime reads participate in the same cross-process lock as
|
|
941
|
+
// atomic writers. Retrying unlocked reads can exhaust every attempt while
|
|
942
|
+
// another process replaces session.yaml under scheduler pressure.
|
|
943
|
+
let session = readRuntimeSessionDocument(sessionPath);
|
|
372
944
|
|
|
373
945
|
// Run migration if needed
|
|
374
946
|
const migration = migrateSession(session);
|
|
@@ -436,21 +1008,221 @@ export function updateSession(projectDir, updates) {
|
|
|
436
1008
|
});
|
|
437
1009
|
}
|
|
438
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* Atomically transition conversation ownership and lifecycle state. Returning
|
|
1013
|
+
* the prior values lets callers record timeline evidence without performing a
|
|
1014
|
+
* race-prone read before the canonical mutation.
|
|
1015
|
+
*/
|
|
1016
|
+
export function transitionSessionLifecycle(projectDir, {
|
|
1017
|
+
activeHarness,
|
|
1018
|
+
sessionStatus,
|
|
1019
|
+
} = {}) {
|
|
1020
|
+
let previous = null;
|
|
1021
|
+
const persisted = mutateSession(projectDir, (session) => {
|
|
1022
|
+
previous = {
|
|
1023
|
+
active_harness: session.active_harness || null,
|
|
1024
|
+
session_status: session.session_status || 'inactive',
|
|
1025
|
+
current_agent: session.current_agent || '',
|
|
1026
|
+
mode: session.mode || 'discover',
|
|
1027
|
+
execution_mode: session.execution_mode || 'interactive',
|
|
1028
|
+
language: session.language || 'en',
|
|
1029
|
+
};
|
|
1030
|
+
if (activeHarness !== undefined) session.active_harness = activeHarness;
|
|
1031
|
+
if (sessionStatus !== undefined) session.session_status = sessionStatus;
|
|
1032
|
+
return session;
|
|
1033
|
+
});
|
|
1034
|
+
return { ...persisted, previous };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** Restore a lifecycle transition only while it still owns the canonical state. */
|
|
1038
|
+
export function rollbackSessionLifecycle(projectDir, {
|
|
1039
|
+
expectedActiveHarness,
|
|
1040
|
+
expectedSessionStatus,
|
|
1041
|
+
previous,
|
|
1042
|
+
} = {}) {
|
|
1043
|
+
let outcome = { rolledBack: false };
|
|
1044
|
+
const persisted = mutateSession(projectDir, (session) => {
|
|
1045
|
+
const activeHarness = session.active_harness || null;
|
|
1046
|
+
const sessionStatus = session.session_status || 'inactive';
|
|
1047
|
+
if (activeHarness !== expectedActiveHarness || sessionStatus !== expectedSessionStatus) {
|
|
1048
|
+
outcome = {
|
|
1049
|
+
rolledBack: false,
|
|
1050
|
+
code: 'SESSION_ROLLBACK_CONFLICT',
|
|
1051
|
+
error: 'Canonical lifecycle changed before projection rollback.',
|
|
1052
|
+
};
|
|
1053
|
+
return session;
|
|
1054
|
+
}
|
|
1055
|
+
session.active_harness = previous?.active_harness ?? null;
|
|
1056
|
+
session.session_status = previous?.session_status || 'inactive';
|
|
1057
|
+
outcome = { rolledBack: true };
|
|
1058
|
+
return session;
|
|
1059
|
+
});
|
|
1060
|
+
return { ...persisted, ...outcome };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function cloneSessionValue(value) {
|
|
1064
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function snapshotAgentDispatch(session, agentNames) {
|
|
1068
|
+
return {
|
|
1069
|
+
current_agent: session.current_agent || '',
|
|
1070
|
+
has_current_execution_binding: Object.hasOwn(session, 'current_execution_binding'),
|
|
1071
|
+
current_execution_binding: cloneSessionValue(session.current_execution_binding),
|
|
1072
|
+
agents: Object.fromEntries(agentNames.map((agent) => [agent, {
|
|
1073
|
+
present: Object.hasOwn(session.agents || {}, agent),
|
|
1074
|
+
value: cloneSessionValue(session.agents?.[agent]),
|
|
1075
|
+
}])),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Atomically mark a legacy execution active while retaining exact rollback state. */
|
|
1080
|
+
export function activateAgentExecutionState(projectDir, {
|
|
1081
|
+
agent,
|
|
1082
|
+
binding,
|
|
1083
|
+
markInProgress = true,
|
|
1084
|
+
} = {}) {
|
|
1085
|
+
if (!agent) return { saved: false, activated: false, error: 'Agent activation requires an agent' };
|
|
1086
|
+
let previous = null;
|
|
1087
|
+
const persisted = mutateSession(projectDir, (session) => {
|
|
1088
|
+
session.agents = session.agents || {};
|
|
1089
|
+
previous = snapshotAgentDispatch(session, [agent]);
|
|
1090
|
+
session.current_agent = agent;
|
|
1091
|
+
if (binding) session.current_execution_binding = cloneSessionValue(binding);
|
|
1092
|
+
if (markInProgress) {
|
|
1093
|
+
session.agents[agent] = {
|
|
1094
|
+
...(session.agents[agent] || {}),
|
|
1095
|
+
status: 'in_progress',
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return session;
|
|
1099
|
+
});
|
|
1100
|
+
return { ...persisted, activated: persisted.saved, previous };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Roll back an agent dispatch only if every exact execution identity still
|
|
1105
|
+
* owns the canonical state. This prevents compensation from erasing a newer
|
|
1106
|
+
* CLI's successful mutation.
|
|
1107
|
+
*/
|
|
1108
|
+
export function rollbackAgentDispatch(projectDir, {
|
|
1109
|
+
expectedAgent,
|
|
1110
|
+
claims = null,
|
|
1111
|
+
previous,
|
|
1112
|
+
} = {}) {
|
|
1113
|
+
let outcome = { rolledBack: false };
|
|
1114
|
+
const persisted = mutateSession(projectDir, (session) => {
|
|
1115
|
+
if (!previous || session.current_agent !== expectedAgent) {
|
|
1116
|
+
outcome = {
|
|
1117
|
+
rolledBack: false,
|
|
1118
|
+
code: 'SESSION_ROLLBACK_CONFLICT',
|
|
1119
|
+
error: 'Canonical agent state changed before projection rollback.',
|
|
1120
|
+
};
|
|
1121
|
+
return session;
|
|
1122
|
+
}
|
|
1123
|
+
if (claims) {
|
|
1124
|
+
for (const [agent, claimId] of Object.entries(claims)) {
|
|
1125
|
+
if (!claimId || session.agents?.[agent]?.execution_claim_id !== claimId) {
|
|
1126
|
+
outcome = {
|
|
1127
|
+
rolledBack: false,
|
|
1128
|
+
code: 'SESSION_ROLLBACK_CONFLICT',
|
|
1129
|
+
error: `Execution claim for ${agent} changed before projection rollback.`,
|
|
1130
|
+
};
|
|
1131
|
+
return session;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
} else if (session.agents?.[expectedAgent]?.status !== 'in_progress'
|
|
1135
|
+
&& previous.agents?.[expectedAgent]?.value?.status !== session.agents?.[expectedAgent]?.status) {
|
|
1136
|
+
outcome = {
|
|
1137
|
+
rolledBack: false,
|
|
1138
|
+
code: 'SESSION_ROLLBACK_CONFLICT',
|
|
1139
|
+
error: `Agent ${expectedAgent} changed before projection rollback.`,
|
|
1140
|
+
};
|
|
1141
|
+
return session;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
session.current_agent = previous.current_agent;
|
|
1145
|
+
if (previous.has_current_execution_binding) {
|
|
1146
|
+
session.current_execution_binding = cloneSessionValue(previous.current_execution_binding);
|
|
1147
|
+
} else {
|
|
1148
|
+
delete session.current_execution_binding;
|
|
1149
|
+
}
|
|
1150
|
+
session.agents = session.agents || {};
|
|
1151
|
+
for (const [agent, snapshot] of Object.entries(previous.agents || {})) {
|
|
1152
|
+
if (snapshot.present) session.agents[agent] = cloneSessionValue(snapshot.value);
|
|
1153
|
+
else delete session.agents[agent];
|
|
1154
|
+
}
|
|
1155
|
+
outcome = { rolledBack: true };
|
|
1156
|
+
return session;
|
|
1157
|
+
});
|
|
1158
|
+
return { ...persisted, ...outcome };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export function updateContextUsage(projectDir, {
|
|
1162
|
+
delta = 0,
|
|
1163
|
+
contextWindowTokens,
|
|
1164
|
+
detectedModel = null,
|
|
1165
|
+
routedProvider = null,
|
|
1166
|
+
} = {}) {
|
|
1167
|
+
const normalizedDelta = Math.max(0, Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 0);
|
|
1168
|
+
const normalizedWindow = Math.max(1, Number.isFinite(Number(contextWindowTokens))
|
|
1169
|
+
? Math.trunc(Number(contextWindowTokens))
|
|
1170
|
+
: 200_000);
|
|
1171
|
+
const persisted = mutateSession(projectDir, (session) => {
|
|
1172
|
+
const current = Math.max(0, Number.isFinite(Number(session.context_tokens_used))
|
|
1173
|
+
? Math.trunc(Number(session.context_tokens_used))
|
|
1174
|
+
: 0);
|
|
1175
|
+
const tokensUsed = Math.min(normalizedWindow, current + normalizedDelta);
|
|
1176
|
+
const remainingPercent = Math.max(0, Math.round((1 - tokensUsed / normalizedWindow) * 100));
|
|
1177
|
+
const bracket = remainingPercent < 25
|
|
1178
|
+
? 'CRITICAL'
|
|
1179
|
+
: remainingPercent < 40
|
|
1180
|
+
? 'DEPLETED'
|
|
1181
|
+
: remainingPercent < 60
|
|
1182
|
+
? 'MODERATE'
|
|
1183
|
+
: 'FRESH';
|
|
1184
|
+
session.context_tokens_used = tokensUsed;
|
|
1185
|
+
session.context_window_tokens = normalizedWindow;
|
|
1186
|
+
session.context_last_bracket = bracket;
|
|
1187
|
+
if (detectedModel && (session.current_execution_binding?.model || !session.active_model)) {
|
|
1188
|
+
session.active_model = detectedModel;
|
|
1189
|
+
}
|
|
1190
|
+
if (routedProvider) session.active_provider = routedProvider;
|
|
1191
|
+
if ((detectedModel || session.active_model) && !session.model_at_last_audit) {
|
|
1192
|
+
session.model_at_last_audit = detectedModel || session.active_model;
|
|
1193
|
+
}
|
|
1194
|
+
return session;
|
|
1195
|
+
});
|
|
1196
|
+
if (!persisted.saved) return persisted;
|
|
1197
|
+
return {
|
|
1198
|
+
...persisted,
|
|
1199
|
+
tokensUsed: persisted.session.context_tokens_used,
|
|
1200
|
+
contextWindowTokens: persisted.session.context_window_tokens,
|
|
1201
|
+
bracket: persisted.session.context_last_bracket,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
439
1205
|
function mutateSession(projectDir, mutator) {
|
|
440
|
-
|
|
441
|
-
|
|
1206
|
+
let sessionPath;
|
|
1207
|
+
try {
|
|
1208
|
+
sessionPath = validateSessionPath(projectDir, { requireFile: true });
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
return { saved: false, error: err.message };
|
|
1211
|
+
}
|
|
442
1212
|
let releaseLock = null;
|
|
443
1213
|
let outcome;
|
|
444
1214
|
try {
|
|
445
1215
|
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
throw new Error('Invalid session document');
|
|
449
|
-
}
|
|
1216
|
+
const record = readSessionDocumentRecord(sessionPath);
|
|
1217
|
+
const session = record.session;
|
|
450
1218
|
migrateSession(session);
|
|
451
1219
|
const nextSession = mutator(session) || session;
|
|
1220
|
+
assertSessionWritableSize(nextSession);
|
|
452
1221
|
const yamlContent = yaml.dump(nextSession, { lineWidth: -1, noRefs: true });
|
|
453
|
-
writeFileAtomic(sessionPath, yamlContent
|
|
1222
|
+
writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
|
|
1223
|
+
expectedDestinationIdentity: record.identity,
|
|
1224
|
+
expectedDirectoryIdentity: record.directoryIdentity,
|
|
1225
|
+
});
|
|
454
1226
|
outcome = { saved: true, session: nextSession };
|
|
455
1227
|
} catch (err) {
|
|
456
1228
|
outcome = { saved: false, error: `Failed to update session: ${err.message}` };
|
|
@@ -466,6 +1238,38 @@ function mutateSession(projectDir, mutator) {
|
|
|
466
1238
|
return outcome;
|
|
467
1239
|
}
|
|
468
1240
|
|
|
1241
|
+
/**
|
|
1242
|
+
* Runs a projection against the latest canonical session while holding the
|
|
1243
|
+
* same cross-process lock used by mutations. The callback may update derived
|
|
1244
|
+
* harness files, but cannot replace session.yaml itself.
|
|
1245
|
+
*/
|
|
1246
|
+
export function withSessionProjectionLock(projectDir, projector) {
|
|
1247
|
+
if (typeof projector !== 'function') return { projected: false, error: 'Session projector must be a function' };
|
|
1248
|
+
let sessionPath;
|
|
1249
|
+
try {
|
|
1250
|
+
sessionPath = validateSessionPath(projectDir, { requireFile: true });
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
return { projected: false, error: err.message, ...(err.code ? { code: err.code } : {}) };
|
|
1253
|
+
}
|
|
1254
|
+
let releaseLock = null;
|
|
1255
|
+
let outcome;
|
|
1256
|
+
try {
|
|
1257
|
+
releaseLock = waitForSessionWriteLock(sessionPath);
|
|
1258
|
+
const session = readSessionDocumentRecord(sessionPath).session;
|
|
1259
|
+
migrateSession(session);
|
|
1260
|
+
outcome = { projected: true, session, result: projector(session) };
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
outcome = { projected: false, error: `Failed to project session: ${err.message}`, ...(err.code ? { code: err.code } : {}) };
|
|
1263
|
+
} finally {
|
|
1264
|
+
if (releaseLock) {
|
|
1265
|
+
try { releaseLock(); } catch (err) {
|
|
1266
|
+
outcome = { projected: false, error: `Session projection lock was compromised: ${err.message}` };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return outcome;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
469
1273
|
/**
|
|
470
1274
|
* Append the exact provider/model binding used for an execution.
|
|
471
1275
|
* A short cross-process lock prevents parallel agents from overwriting each
|
|
@@ -492,6 +1296,393 @@ export function recordModelSelection(projectDir, selection) {
|
|
|
492
1296
|
});
|
|
493
1297
|
}
|
|
494
1298
|
|
|
1299
|
+
/** Persist an interactive question so another harness resumes it verbatim. */
|
|
1300
|
+
export function recordPendingInteraction(projectDir, interaction) {
|
|
1301
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1302
|
+
if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
|
|
1303
|
+
if (!interaction?.agent || !interaction?.question) {
|
|
1304
|
+
return { saved: false, error: 'Pending interaction requires agent and question' };
|
|
1305
|
+
}
|
|
1306
|
+
return mutateSession(sessionProjectDir, (session) => {
|
|
1307
|
+
const previous = session.pending_interaction;
|
|
1308
|
+
const taskId = interaction.taskId || `${interaction.agent}-task`;
|
|
1309
|
+
const binding = {
|
|
1310
|
+
provider: interaction.provider || null,
|
|
1311
|
+
provider_id: interaction.providerId || null,
|
|
1312
|
+
model: interaction.model || null,
|
|
1313
|
+
reasoning_configuration: interaction.reasoningConfiguration || null,
|
|
1314
|
+
catalog_snapshot_ref: interaction.catalogSnapshotRef || null,
|
|
1315
|
+
};
|
|
1316
|
+
const sameRelay = previous?.agent === interaction.agent && previous?.task_id === taskId;
|
|
1317
|
+
const relayAdvanced = sameRelay && interaction.advanceRelay === true;
|
|
1318
|
+
const interactionId = (!sameRelay || relayAdvanced || !previous?.interaction_id)
|
|
1319
|
+
? randomUUID()
|
|
1320
|
+
: previous.interaction_id;
|
|
1321
|
+
const now = interaction.timestamp || new Date().toISOString();
|
|
1322
|
+
session.current_agent = interaction.agent;
|
|
1323
|
+
session.current_execution_binding = binding;
|
|
1324
|
+
session.agents = session.agents || {};
|
|
1325
|
+
const existingAgent = session.agents[interaction.agent] || {};
|
|
1326
|
+
session.agents[interaction.agent] = {
|
|
1327
|
+
...existingAgent,
|
|
1328
|
+
status: 'waiting_for_user',
|
|
1329
|
+
interaction_id: interactionId,
|
|
1330
|
+
};
|
|
1331
|
+
delete session.agents[interaction.agent].execution_claim_id;
|
|
1332
|
+
delete session.agents[interaction.agent].execution_claim_state;
|
|
1333
|
+
delete session.agents[interaction.agent].execution_claim_started_at;
|
|
1334
|
+
delete session.agents[interaction.agent].execution_claim_expires_at;
|
|
1335
|
+
session.pending_interaction = {
|
|
1336
|
+
interaction_id: interactionId,
|
|
1337
|
+
status: 'waiting_for_user',
|
|
1338
|
+
agent: interaction.agent,
|
|
1339
|
+
task_id: taskId,
|
|
1340
|
+
language: session.language || 'en',
|
|
1341
|
+
question: interaction.question,
|
|
1342
|
+
relay_cycles: relayAdvanced ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
|
|
1343
|
+
execution_binding: binding,
|
|
1344
|
+
created_at: sameRelay ? previous.created_at : now,
|
|
1345
|
+
updated_at: now,
|
|
1346
|
+
};
|
|
1347
|
+
return session;
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** Atomically reserve one or more agent executions before returning commands. */
|
|
1352
|
+
export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
|
|
1353
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1354
|
+
if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
|
|
1355
|
+
const requested = executions.filter((entry) => entry?.agent);
|
|
1356
|
+
if (requested.length === 0) return { saved: true, claimed: false, claims: {} };
|
|
1357
|
+
let outcome = { claimed: false, claims: {}, previous: null };
|
|
1358
|
+
const now = claim.timestamp ? new Date(claim.timestamp) : new Date();
|
|
1359
|
+
const leaseMs = Math.max(60_000, Number(claim.leaseMs) || 30 * 60 * 1000);
|
|
1360
|
+
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1361
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1362
|
+
session.agents = session.agents || {};
|
|
1363
|
+
for (const entry of requested) {
|
|
1364
|
+
const current = session.agents[entry.agent] || {};
|
|
1365
|
+
if (current.status === 'completed' || session.completed_agents?.includes(entry.agent)) {
|
|
1366
|
+
outcome = {
|
|
1367
|
+
claimed: false,
|
|
1368
|
+
code: 'AGENT_ALREADY_COMPLETED',
|
|
1369
|
+
error: `Agent ${entry.agent} is already recorded as completed.`,
|
|
1370
|
+
agent: entry.agent,
|
|
1371
|
+
};
|
|
1372
|
+
return session;
|
|
1373
|
+
}
|
|
1374
|
+
if (current.status === 'waiting_for_user') {
|
|
1375
|
+
outcome = {
|
|
1376
|
+
claimed: false,
|
|
1377
|
+
code: 'AGENT_WAITING_FOR_USER',
|
|
1378
|
+
error: `Agent ${entry.agent} has a pending user interaction.`,
|
|
1379
|
+
agent: entry.agent,
|
|
1380
|
+
};
|
|
1381
|
+
return session;
|
|
1382
|
+
}
|
|
1383
|
+
if (current.status === 'awaiting_advance' || current.execution_claim_state === 'awaiting_advance') {
|
|
1384
|
+
outcome = {
|
|
1385
|
+
claimed: false,
|
|
1386
|
+
code: 'AGENT_AWAITING_ADVANCE',
|
|
1387
|
+
error: `Agent ${entry.agent} finished its routed process and is waiting for canonical advance.`,
|
|
1388
|
+
agent: entry.agent,
|
|
1389
|
+
};
|
|
1390
|
+
return session;
|
|
1391
|
+
}
|
|
1392
|
+
const expiry = Date.parse(current.execution_claim_expires_at || '');
|
|
1393
|
+
const liveClaim = current.status === 'in_progress'
|
|
1394
|
+
&& current.execution_claim_id
|
|
1395
|
+
&& Number.isFinite(expiry)
|
|
1396
|
+
&& expiry > now.getTime();
|
|
1397
|
+
if (liveClaim) {
|
|
1398
|
+
outcome = {
|
|
1399
|
+
claimed: false,
|
|
1400
|
+
code: 'AGENT_EXECUTION_IN_FLIGHT',
|
|
1401
|
+
error: `Agent ${entry.agent} already has an active execution claim.`,
|
|
1402
|
+
agent: entry.agent,
|
|
1403
|
+
};
|
|
1404
|
+
return session;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
outcome.previous = snapshotAgentDispatch(session, requested.map(({ agent }) => agent));
|
|
1408
|
+
for (const entry of requested) {
|
|
1409
|
+
const claimId = randomUUID();
|
|
1410
|
+
session.agents[entry.agent] = {
|
|
1411
|
+
...(session.agents[entry.agent] || {}),
|
|
1412
|
+
status: 'in_progress',
|
|
1413
|
+
execution_claim_id: claimId,
|
|
1414
|
+
execution_claim_state: 'reserved',
|
|
1415
|
+
execution_claim_started_at: now.toISOString(),
|
|
1416
|
+
execution_claim_expires_at: expiresAt,
|
|
1417
|
+
};
|
|
1418
|
+
outcome.claims[entry.agent] = claimId;
|
|
1419
|
+
}
|
|
1420
|
+
session.current_agent = requested[0].agent;
|
|
1421
|
+
if (requested[0].binding) session.current_execution_binding = requested[0].binding;
|
|
1422
|
+
outcome.claimed = true;
|
|
1423
|
+
return session;
|
|
1424
|
+
});
|
|
1425
|
+
return { ...result, ...outcome };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Atomically consume a reserved execution command exactly once. */
|
|
1429
|
+
export function startAgentExecution(projectDir, match = {}) {
|
|
1430
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1431
|
+
if (!sessionProjectDir) return { saved: false, started: false, error: 'Session file not found in working directory or its parents' };
|
|
1432
|
+
let outcome = { started: false };
|
|
1433
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1434
|
+
const agent = session.agents?.[match.agent];
|
|
1435
|
+
const expired = Date.parse(agent?.execution_claim_expires_at || '') <= Date.now();
|
|
1436
|
+
if (!agent || agent.execution_claim_id !== match.claimId || agent.execution_claim_state !== 'reserved' || expired) {
|
|
1437
|
+
outcome = {
|
|
1438
|
+
started: false,
|
|
1439
|
+
code: 'AGENT_EXECUTION_CLAIM_INVALID',
|
|
1440
|
+
error: `Execution claim for ${match.agent || 'unknown'} is missing, stale, or already consumed.`,
|
|
1441
|
+
};
|
|
1442
|
+
return session;
|
|
1443
|
+
}
|
|
1444
|
+
agent.execution_claim_state = 'running';
|
|
1445
|
+
agent.execution_started_at = match.timestamp || new Date().toISOString();
|
|
1446
|
+
outcome = { started: true };
|
|
1447
|
+
return session;
|
|
1448
|
+
});
|
|
1449
|
+
return { ...result, ...outcome };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** Keep a successful routed result durable until the canonical advance records completion. */
|
|
1453
|
+
export function holdAgentExecutionForAdvance(projectDir, match = {}) {
|
|
1454
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1455
|
+
if (!sessionProjectDir) return { saved: false, held: false, error: 'Session file not found in working directory or its parents' };
|
|
1456
|
+
let outcome = { held: false };
|
|
1457
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1458
|
+
const agent = session.agents?.[match.agent];
|
|
1459
|
+
if (!agent) {
|
|
1460
|
+
outcome = { held: false, code: 'AGENT_EXECUTION_NOT_FOUND', error: `Agent ${match.agent || 'unknown'} is not present in the canonical session.` };
|
|
1461
|
+
return session;
|
|
1462
|
+
}
|
|
1463
|
+
if (match.claimId && agent.execution_claim_id !== match.claimId) {
|
|
1464
|
+
outcome = { held: false, code: 'AGENT_EXECUTION_CLAIM_INVALID', error: `Execution claim for ${match.agent} does not own this result.` };
|
|
1465
|
+
return session;
|
|
1466
|
+
}
|
|
1467
|
+
agent.status = 'awaiting_advance';
|
|
1468
|
+
agent.execution_claim_state = 'awaiting_advance';
|
|
1469
|
+
agent.execution_finished_at = match.timestamp || new Date().toISOString();
|
|
1470
|
+
delete agent.execution_claim_expires_at;
|
|
1471
|
+
delete agent.execution_started_at;
|
|
1472
|
+
outcome = { held: true };
|
|
1473
|
+
return session;
|
|
1474
|
+
});
|
|
1475
|
+
return { ...result, ...outcome };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Release an exact execution claim after completion or controlled failure. */
|
|
1479
|
+
export function releaseAgentExecution(projectDir, match = {}) {
|
|
1480
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1481
|
+
if (!sessionProjectDir) return { saved: false, released: false, error: 'Session file not found in working directory or its parents' };
|
|
1482
|
+
let released = false;
|
|
1483
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1484
|
+
const agent = session.agents?.[match.agent];
|
|
1485
|
+
if (!agent || agent.execution_claim_id !== match.claimId) return session;
|
|
1486
|
+
if (agent.execution_claim_state === 'awaiting_advance' || agent.status === 'awaiting_advance') return session;
|
|
1487
|
+
if (agent.status === 'in_progress') agent.status = 'pending';
|
|
1488
|
+
delete agent.execution_claim_id;
|
|
1489
|
+
delete agent.execution_claim_state;
|
|
1490
|
+
delete agent.execution_claim_started_at;
|
|
1491
|
+
delete agent.execution_claim_expires_at;
|
|
1492
|
+
delete agent.execution_started_at;
|
|
1493
|
+
released = true;
|
|
1494
|
+
return session;
|
|
1495
|
+
});
|
|
1496
|
+
return { ...result, released };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/** Cancel commands that were reserved but never started when a session exits. */
|
|
1500
|
+
export function releaseReservedAgentExecutions(projectDir) {
|
|
1501
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1502
|
+
if (!sessionProjectDir) return { saved: false, released: 0, error: 'Session file not found in working directory or its parents' };
|
|
1503
|
+
let released = 0;
|
|
1504
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1505
|
+
for (const agent of Object.values(session.agents || {})) {
|
|
1506
|
+
if (agent.execution_claim_state !== 'reserved') continue;
|
|
1507
|
+
if (agent.status === 'in_progress') agent.status = 'pending';
|
|
1508
|
+
delete agent.execution_claim_id;
|
|
1509
|
+
delete agent.execution_claim_state;
|
|
1510
|
+
delete agent.execution_claim_started_at;
|
|
1511
|
+
delete agent.execution_claim_expires_at;
|
|
1512
|
+
released += 1;
|
|
1513
|
+
}
|
|
1514
|
+
return session;
|
|
1515
|
+
});
|
|
1516
|
+
return { ...result, released };
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** Add an identity to pending interactions created by older runtime versions. */
|
|
1520
|
+
export function ensurePendingInteractionIdentity(projectDir, fallbackBinding = null) {
|
|
1521
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1522
|
+
if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
|
|
1523
|
+
let interactionId = null;
|
|
1524
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1525
|
+
if (!session.pending_interaction) return session;
|
|
1526
|
+
const existingId = session.pending_interaction.interaction_id;
|
|
1527
|
+
interactionId = isSafeInteractionId(existingId) ? existingId : randomUUID();
|
|
1528
|
+
session.pending_interaction.interaction_id = interactionId;
|
|
1529
|
+
if (!session.pending_interaction.execution_binding && fallbackBinding) {
|
|
1530
|
+
session.pending_interaction.execution_binding = {
|
|
1531
|
+
provider: fallbackBinding.provider || null,
|
|
1532
|
+
provider_id: fallbackBinding.provider_id || null,
|
|
1533
|
+
model: fallbackBinding.model || null,
|
|
1534
|
+
reasoning_configuration: fallbackBinding.reasoning_configuration || null,
|
|
1535
|
+
catalog_snapshot_ref: fallbackBinding.catalog_snapshot_ref || null,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
return session;
|
|
1539
|
+
});
|
|
1540
|
+
return { ...result, interactionId };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Restrict interaction identities to path-inert tokens before file derivation. */
|
|
1544
|
+
export function isSafeInteractionId(value) {
|
|
1545
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Atomically reserve a pending relay before any provider process is started. */
|
|
1549
|
+
export function claimPendingInteraction(projectDir, claim = {}) {
|
|
1550
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1551
|
+
if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
|
|
1552
|
+
let outcome = { allowed: true, claimed: false };
|
|
1553
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1554
|
+
const pending = session.pending_interaction;
|
|
1555
|
+
if (!pending) {
|
|
1556
|
+
if (claim.interactionId || (typeof claim.response === 'string' && claim.response.trim())) {
|
|
1557
|
+
outcome = {
|
|
1558
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_NOT_FOUND',
|
|
1559
|
+
error: 'The pending interaction no longer exists. Refusing a stale relay command.',
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
return session;
|
|
1563
|
+
}
|
|
1564
|
+
if (pending.status !== 'waiting_for_user') {
|
|
1565
|
+
outcome = {
|
|
1566
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_ALREADY_CLAIMED',
|
|
1567
|
+
error: `Pending interaction ${pending.interaction_id || 'unknown'} is already being relayed.`,
|
|
1568
|
+
};
|
|
1569
|
+
return session;
|
|
1570
|
+
}
|
|
1571
|
+
const reasoningEscalation = claim.reasoningEscalation === true;
|
|
1572
|
+
if ((pending.relay_cycles || 1) <= 3 && reasoningEscalation) {
|
|
1573
|
+
outcome = {
|
|
1574
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_ESCALATION_NOT_REQUIRED',
|
|
1575
|
+
error: 'Reasoning escalation is only allowed after three unresolved relay cycles.',
|
|
1576
|
+
};
|
|
1577
|
+
return session;
|
|
1578
|
+
}
|
|
1579
|
+
if ((pending.relay_cycles || 1) > 3 && !reasoningEscalation) {
|
|
1580
|
+
outcome = {
|
|
1581
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_RELAY_LIMIT',
|
|
1582
|
+
error: `Pending interaction reached the maximum of 3 relay cycles and requires reasoning escalation.`,
|
|
1583
|
+
};
|
|
1584
|
+
return session;
|
|
1585
|
+
}
|
|
1586
|
+
const response = typeof claim.response === 'string' ? claim.response.trim() : '';
|
|
1587
|
+
if (!response) {
|
|
1588
|
+
outcome = {
|
|
1589
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_REQUIRES_RESPONSE',
|
|
1590
|
+
error: `Agent ${pending.agent} is waiting for the user's response.`,
|
|
1591
|
+
};
|
|
1592
|
+
return session;
|
|
1593
|
+
}
|
|
1594
|
+
const binding = reasoningEscalation ? {
|
|
1595
|
+
provider: claim.provider || null,
|
|
1596
|
+
provider_id: claim.providerId || null,
|
|
1597
|
+
model: claim.model || null,
|
|
1598
|
+
reasoning_configuration: claim.reasoningConfiguration || null,
|
|
1599
|
+
catalog_snapshot_ref: claim.catalogSnapshotRef || null,
|
|
1600
|
+
} : (pending.execution_binding || {});
|
|
1601
|
+
const mismatches = [
|
|
1602
|
+
['interaction_id', pending.interaction_id, claim.interactionId],
|
|
1603
|
+
['agent', pending.agent, claim.agent],
|
|
1604
|
+
['task_id', pending.task_id, claim.taskId],
|
|
1605
|
+
['provider', binding.provider || null, claim.provider || null],
|
|
1606
|
+
['provider_id', binding.provider_id || null, claim.providerId || null],
|
|
1607
|
+
['model', binding.model || null, claim.model || null],
|
|
1608
|
+
['reasoning_configuration', binding.reasoning_configuration || null, claim.reasoningConfiguration || null],
|
|
1609
|
+
['catalog_snapshot_ref', binding.catalog_snapshot_ref || null, claim.catalogSnapshotRef || null],
|
|
1610
|
+
].filter(([, expected, actual]) => expected !== actual);
|
|
1611
|
+
if (mismatches.length > 0) {
|
|
1612
|
+
outcome = {
|
|
1613
|
+
allowed: false, claimed: false, code: 'PENDING_INTERACTION_BINDING_MISMATCH',
|
|
1614
|
+
error: `Pending interaction binding mismatch: ${mismatches.map(([field]) => field).join(', ')}.`,
|
|
1615
|
+
};
|
|
1616
|
+
return session;
|
|
1617
|
+
}
|
|
1618
|
+
const claimId = randomUUID();
|
|
1619
|
+
session.pending_interaction = {
|
|
1620
|
+
...pending,
|
|
1621
|
+
status: 'relay_in_flight',
|
|
1622
|
+
...(reasoningEscalation ? { escalation_execution_binding: binding } : {}),
|
|
1623
|
+
relay_claim_id: claimId,
|
|
1624
|
+
relay_started_at: claim.timestamp || new Date().toISOString(),
|
|
1625
|
+
relay_lease_expires_at: claim.leaseExpiresAt || null,
|
|
1626
|
+
};
|
|
1627
|
+
outcome = { allowed: true, claimed: true, claimId };
|
|
1628
|
+
return session;
|
|
1629
|
+
});
|
|
1630
|
+
return { ...result, ...outcome };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/** Restore a claimed relay after a controlled provider or contract failure. */
|
|
1634
|
+
export function releasePendingInteraction(projectDir, match = {}) {
|
|
1635
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1636
|
+
if (!sessionProjectDir) return { saved: false, released: false, error: 'Session file not found in working directory or its parents' };
|
|
1637
|
+
let released = false;
|
|
1638
|
+
let settled = false;
|
|
1639
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1640
|
+
const pending = session.pending_interaction;
|
|
1641
|
+
if (!pending) {
|
|
1642
|
+
settled = true;
|
|
1643
|
+
return session;
|
|
1644
|
+
}
|
|
1645
|
+
if (pending.status === 'waiting_for_user' && (!match.interactionId || pending.interaction_id === match.interactionId)) {
|
|
1646
|
+
released = true;
|
|
1647
|
+
settled = true;
|
|
1648
|
+
return session;
|
|
1649
|
+
}
|
|
1650
|
+
if (pending.status !== 'relay_in_flight') return session;
|
|
1651
|
+
if (match.claimId && pending.relay_claim_id !== match.claimId) return session;
|
|
1652
|
+
if (match.interactionId && pending.interaction_id !== match.interactionId) return session;
|
|
1653
|
+
session.pending_interaction = { ...pending, status: 'waiting_for_user' };
|
|
1654
|
+
delete session.pending_interaction.relay_claim_id;
|
|
1655
|
+
delete session.pending_interaction.relay_started_at;
|
|
1656
|
+
delete session.pending_interaction.relay_lease_expires_at;
|
|
1657
|
+
released = true;
|
|
1658
|
+
settled = true;
|
|
1659
|
+
return session;
|
|
1660
|
+
});
|
|
1661
|
+
return { ...result, released, settled };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Clear a pending interaction only when the caller owns that relay. */
|
|
1665
|
+
export function clearPendingInteraction(projectDir, match = {}) {
|
|
1666
|
+
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1667
|
+
if (!sessionProjectDir) return { saved: false, cleared: false, error: 'Session file not found in working directory or its parents' };
|
|
1668
|
+
let cleared = false;
|
|
1669
|
+
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1670
|
+
const pending = session.pending_interaction;
|
|
1671
|
+
if (!pending) return session;
|
|
1672
|
+
if (match.agent && pending.agent !== match.agent) return session;
|
|
1673
|
+
if (match.taskId && pending.task_id !== match.taskId) return session;
|
|
1674
|
+
if (match.interactionId && pending.interaction_id !== match.interactionId) return session;
|
|
1675
|
+
if (match.claimId && pending.relay_claim_id !== match.claimId) return session;
|
|
1676
|
+
session.pending_interaction = null;
|
|
1677
|
+
if (session.agents?.[pending.agent]?.status === 'waiting_for_user') {
|
|
1678
|
+
session.agents[pending.agent].status = 'in_progress';
|
|
1679
|
+
}
|
|
1680
|
+
cleared = true;
|
|
1681
|
+
return session;
|
|
1682
|
+
});
|
|
1683
|
+
return { ...result, cleared };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
495
1686
|
/**
|
|
496
1687
|
* Record a mode transition in session history.
|
|
497
1688
|
* @param {string} projectDir
|
|
@@ -540,6 +1731,12 @@ export function recordAgentCompletion(projectDir, completion) {
|
|
|
540
1731
|
session.agents[agent].status = status;
|
|
541
1732
|
session.agents[agent].score = score || 0;
|
|
542
1733
|
session.agents[agent].completed_at = new Date().toISOString();
|
|
1734
|
+
delete session.agents[agent].execution_claim_id;
|
|
1735
|
+
delete session.agents[agent].execution_claim_state;
|
|
1736
|
+
delete session.agents[agent].execution_claim_started_at;
|
|
1737
|
+
delete session.agents[agent].execution_claim_expires_at;
|
|
1738
|
+
delete session.agents[agent].execution_started_at;
|
|
1739
|
+
delete session.agents[agent].execution_finished_at;
|
|
543
1740
|
|
|
544
1741
|
// Add to completed agents list
|
|
545
1742
|
session.completed_agents = session.completed_agents || [];
|