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
package/src/orchestrator/cli.js
CHANGED
|
@@ -17,9 +17,14 @@ import { isFeatureEnabled } from '../utils/feature-flags.js';
|
|
|
17
17
|
import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
-
loadSession, initSession, updateSession,
|
|
20
|
+
loadSession, readSessionSafe, initSession, updateSession, transitionSessionLifecycle, rollbackSessionLifecycle,
|
|
21
|
+
activateAgentExecutionState, rollbackAgentDispatch, recordAgentCompletion,
|
|
21
22
|
recordModeTransition, getSessionSummary, validateSession,
|
|
22
23
|
releaseSession, parseHandoffDecisionTrail, parseHandoffScaffoldSignals,
|
|
24
|
+
ensurePendingInteractionIdentity, releasePendingInteraction,
|
|
25
|
+
claimAgentExecutions,
|
|
26
|
+
releaseReservedAgentExecutions,
|
|
27
|
+
withSessionProjectionLock,
|
|
23
28
|
} from './index.js';
|
|
24
29
|
import {
|
|
25
30
|
resolveScaffoldSource, loadScaffoldManifest, applyScaffold,
|
|
@@ -42,9 +47,14 @@ import {
|
|
|
42
47
|
import { analyzeDeviationImpact, applyDeviation } from './index.js';
|
|
43
48
|
import { detectQuickFlow, detectStandardFlow } from './index.js';
|
|
44
49
|
import { getAgentFile } from '../terminal/prompt-builder.js';
|
|
45
|
-
import {
|
|
46
|
-
|
|
50
|
+
import {
|
|
51
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
|
|
52
|
+
lstatSync, realpathSync, renameSync, rmSync, openSync, closeSync, constants,
|
|
53
|
+
fstatSync, fchmodSync, chmodSync, readSync, linkSync, unlinkSync, fsyncSync, writeSync,
|
|
54
|
+
} from 'fs';
|
|
55
|
+
import { basename, join, parse, resolve, dirname, relative, isAbsolute, sep } from 'path';
|
|
47
56
|
import { fileURLToPath } from 'url';
|
|
57
|
+
import { createHash, randomUUID } from 'crypto';
|
|
48
58
|
import { execSync } from 'child_process';
|
|
49
59
|
import * as yaml from 'js-yaml';
|
|
50
60
|
import {
|
|
@@ -56,10 +66,14 @@ import {
|
|
|
56
66
|
import { updateProjectContext } from '../memory/magic-docs.js';
|
|
57
67
|
import {
|
|
58
68
|
loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
|
|
59
|
-
describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
|
|
69
|
+
resolveRuntimeAdjudicationV2, describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
|
|
70
|
+
assertV2PlanningPredecessors,
|
|
60
71
|
} from './runtime-installation-v2.js';
|
|
61
72
|
import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
|
|
62
73
|
import { persistRailHandoff } from './rail-runtime.js';
|
|
74
|
+
import {
|
|
75
|
+
installationAuthorityProjectScope, signHostAuthorityPayload, verifyHostAuthorityPayload,
|
|
76
|
+
} from '../installer-v2/installation-authority.js';
|
|
63
77
|
|
|
64
78
|
// Spawn commands must reference the terminal runners with an absolute,
|
|
65
79
|
// layout-independent path: the old monorepo-relative
|
|
@@ -68,7 +82,676 @@ import { persistRailHandoff } from './rail-runtime.js';
|
|
|
68
82
|
// node_modules). Anchoring to this file's own location works in every layout
|
|
69
83
|
// because the src/ tree shape is preserved verbatim by the CLI bundling.
|
|
70
84
|
const _THISDIR = dirname(fileURLToPath(import.meta.url));
|
|
71
|
-
const
|
|
85
|
+
const NATIVE_LOCK_MAX_BYTES = 256 * 1024;
|
|
86
|
+
const NATIVE_LOCK_MAX_BYTES_BIGINT = BigInt(NATIVE_LOCK_MAX_BYTES);
|
|
87
|
+
const RUNNER = (name) => join(_THISDIR, '..', 'terminal', name);
|
|
88
|
+
const RUNTIME_JSON_MAX_BYTES = 256 * 1024;
|
|
89
|
+
const RUNTIME_SECURITY_HOOKS = Symbol.for('chati.cli.runtime-security-test-hooks');
|
|
90
|
+
const SCRIPT_EXECUTION_RECEIPT_DOMAIN = 'chati.script-execution-receipt.v1';
|
|
91
|
+
|
|
92
|
+
function runRuntimeSecurityTestHook(name, detail) {
|
|
93
|
+
const hook = globalThis[RUNTIME_SECURITY_HOOKS]?.[name];
|
|
94
|
+
if (typeof hook === 'function') hook(detail);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameRuntimeIdentity(left, right) {
|
|
98
|
+
return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameRuntimeFileSnapshot(left, right) {
|
|
102
|
+
return sameRuntimeIdentity(left, right)
|
|
103
|
+
&& left.size === right.size
|
|
104
|
+
&& left.mode === right.mode
|
|
105
|
+
&& left.mtimeNs === right.mtimeNs
|
|
106
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A rename is allowed to update ctime on the source inode. For the commit
|
|
110
|
+
// check, retain identity, permissions, size and mtime, which are sufficient to
|
|
111
|
+
// reject a replacement or content mutation without treating a valid rename as
|
|
112
|
+
// a race.
|
|
113
|
+
function samePublishedRuntimeFile(left, right) {
|
|
114
|
+
return sameRuntimeIdentity(left, right)
|
|
115
|
+
&& left.size === right.size
|
|
116
|
+
&& left.mode === right.mode
|
|
117
|
+
&& left.mtimeNs === right.mtimeNs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function openRuntimeDirectoryNoFollow(runtimePath) {
|
|
121
|
+
if (process.platform !== 'linux') {
|
|
122
|
+
throw Object.assign(new Error('Runtime request authority requires Linux descriptor-relative filesystem operations'), { code: 'UNSUPPORTED_RUNTIME_REQUEST_PLATFORM' });
|
|
123
|
+
}
|
|
124
|
+
const absolute = resolve(runtimePath);
|
|
125
|
+
const root = parse(absolute).root;
|
|
126
|
+
const anchor = openWorkingDirectoryAnchor(absolute);
|
|
127
|
+
const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
|
|
128
|
+
let fd;
|
|
129
|
+
try {
|
|
130
|
+
fd = anchor
|
|
131
|
+
? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
|
|
132
|
+
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
133
|
+
} finally {
|
|
134
|
+
if (anchor) closeSync(anchor.descriptor);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
const child = openSync(
|
|
139
|
+
join(`/proc/self/fd/${fd}`, segment),
|
|
140
|
+
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
141
|
+
);
|
|
142
|
+
const stat = fstatSync(child);
|
|
143
|
+
if (!stat.isDirectory()) {
|
|
144
|
+
closeSync(child);
|
|
145
|
+
throw Object.assign(new Error('The .chati/runtime chain must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
146
|
+
}
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
fd = child;
|
|
149
|
+
}
|
|
150
|
+
return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
154
|
+
throw Object.assign(new Error('The .chati/runtime chain must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH', cause: error });
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertRuntimeDirectoryRecord(record) {
|
|
161
|
+
const pinned = fstatSync(record.fd);
|
|
162
|
+
if (!pinned.isDirectory() || !sameRuntimeIdentity(pinned, record.runtimeIdentity)) {
|
|
163
|
+
throw Object.assign(new Error('The pinned runtime directory changed during request processing'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
164
|
+
}
|
|
165
|
+
let opened;
|
|
166
|
+
try {
|
|
167
|
+
opened = openRuntimeDirectoryNoFollow(record.runtimePath);
|
|
168
|
+
if (!sameRuntimeIdentity(opened.stat, record.runtimeIdentity)) {
|
|
169
|
+
throw Object.assign(new Error('The runtime directory changed during request processing'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
if (opened) closeSync(opened.fd);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } = {}) {
|
|
177
|
+
if (process.platform !== 'linux') {
|
|
178
|
+
throw Object.assign(new Error('Script handoff persistence requires Linux descriptor-relative filesystem operations'), { code: 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM' });
|
|
179
|
+
}
|
|
180
|
+
const absolute = resolve(directoryPath);
|
|
181
|
+
const root = parse(absolute).root;
|
|
182
|
+
const anchor = openWorkingDirectoryAnchor(absolute);
|
|
183
|
+
const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
|
|
184
|
+
let fd;
|
|
185
|
+
try {
|
|
186
|
+
fd = anchor
|
|
187
|
+
? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
|
|
188
|
+
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
189
|
+
} finally {
|
|
190
|
+
if (anchor) closeSync(anchor.descriptor);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
for (const segment of segments) {
|
|
194
|
+
const entry = join(`/proc/self/fd/${fd}`, segment);
|
|
195
|
+
let child;
|
|
196
|
+
try {
|
|
197
|
+
child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error?.code !== 'ENOENT' || !create) throw error;
|
|
200
|
+
try { mkdirSync(entry, { mode: 0o700 }); }
|
|
201
|
+
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
202
|
+
child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
203
|
+
}
|
|
204
|
+
const stat = fstatSync(child);
|
|
205
|
+
if (!stat.isDirectory()) {
|
|
206
|
+
closeSync(child);
|
|
207
|
+
throw Object.assign(new Error('The script handoff directory chain must contain only real directories'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
208
|
+
}
|
|
209
|
+
closeSync(fd);
|
|
210
|
+
fd = child;
|
|
211
|
+
}
|
|
212
|
+
return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
closeSync(fd);
|
|
215
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
216
|
+
throw Object.assign(new Error('The script handoff directory chain must contain only real directories'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH', cause: error });
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assertScriptHandoffDirectoryRecord(record) {
|
|
223
|
+
const pinned = fstatSync(record.fd);
|
|
224
|
+
if (!pinned.isDirectory() || !sameRuntimeIdentity(pinned, record.stat)) {
|
|
225
|
+
throw Object.assign(new Error('The pinned script handoff directory changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
226
|
+
}
|
|
227
|
+
let canonical;
|
|
228
|
+
try {
|
|
229
|
+
canonical = openScriptHandoffDirectoryNoFollow(record.absolute);
|
|
230
|
+
if (!sameRuntimeIdentity(canonical.stat, record.stat)) {
|
|
231
|
+
throw Object.assign(new Error('The script handoff directory chain changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error?.code === 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM') throw error;
|
|
235
|
+
throw Object.assign(new Error('The script handoff directory chain changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH', cause: error });
|
|
236
|
+
} finally {
|
|
237
|
+
if (canonical) closeSync(canonical.fd);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function shellQuote(value) {
|
|
242
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ensureRuntimeResponsePath(projectDir, filename) {
|
|
246
|
+
if (!/^[A-Za-z0-9_-]+\.json$/.test(filename)) {
|
|
247
|
+
throw Object.assign(new Error('Runtime response filename is not path-safe'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
248
|
+
}
|
|
249
|
+
const projectPath = resolve(projectDir);
|
|
250
|
+
const chatiPath = join(projectPath, '.chati');
|
|
251
|
+
if (!existsSync(chatiPath) || lstatSync(chatiPath).isSymbolicLink() || !lstatSync(chatiPath).isDirectory()) {
|
|
252
|
+
throw Object.assign(new Error('The .chati directory must be a real directory'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
253
|
+
}
|
|
254
|
+
const realProject = realpathSync(projectPath);
|
|
255
|
+
const realChati = realpathSync(chatiPath);
|
|
256
|
+
const chatiRelative = relative(realProject, realChati);
|
|
257
|
+
if (!chatiRelative || chatiRelative.startsWith('..') || isAbsolute(chatiRelative)) {
|
|
258
|
+
throw Object.assign(new Error('The .chati directory must remain inside the project'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
259
|
+
}
|
|
260
|
+
const runtimePath = join(chatiPath, 'runtime');
|
|
261
|
+
if (!existsSync(runtimePath)) mkdirSync(runtimePath, { mode: 0o700 });
|
|
262
|
+
const runtimeStat = lstatSync(runtimePath);
|
|
263
|
+
if (runtimeStat.isSymbolicLink() || !runtimeStat.isDirectory()) {
|
|
264
|
+
throw Object.assign(new Error('The .chati/runtime directory must be a real directory'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
265
|
+
}
|
|
266
|
+
if (typeof process.getuid !== 'function' || runtimeStat.uid === process.getuid()) chmodSync(runtimePath, 0o700);
|
|
267
|
+
const realRuntime = realpathSync(runtimePath);
|
|
268
|
+
const runtimeRelative = relative(realChati, realRuntime);
|
|
269
|
+
if (!runtimeRelative || runtimeRelative.startsWith('..') || isAbsolute(runtimeRelative)) {
|
|
270
|
+
throw Object.assign(new Error('The runtime directory must remain inside .chati'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
271
|
+
}
|
|
272
|
+
const target = join(realRuntime, filename);
|
|
273
|
+
if (existsSync(target)) {
|
|
274
|
+
const targetStat = lstatSync(target);
|
|
275
|
+
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
276
|
+
throw Object.assign(new Error('The runtime response target must be a regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return target;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function captureRuntimePath(projectDir, filename) {
|
|
283
|
+
const target = ensureRuntimeResponsePath(projectDir, filename);
|
|
284
|
+
const runtimePath = dirname(target);
|
|
285
|
+
const opened = openRuntimeDirectoryNoFollow(runtimePath);
|
|
286
|
+
return {
|
|
287
|
+
projectDir: resolve(projectDir),
|
|
288
|
+
target,
|
|
289
|
+
targetEntry: join(opened.reference, basename(target)),
|
|
290
|
+
runtimePath,
|
|
291
|
+
runtimeIdentity: opened.stat,
|
|
292
|
+
fd: opened.fd,
|
|
293
|
+
reference: opened.reference,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function closeRuntimeRecord(record) {
|
|
298
|
+
if (record?.fd === undefined || record.fd === null) return;
|
|
299
|
+
closeSync(record.fd);
|
|
300
|
+
record.fd = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runtimeProcessAlive(pid) {
|
|
304
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
305
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry, expectedIdentity) {
|
|
309
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
310
|
+
const claimStat = lstatSync(claimEntry, { bigint: true });
|
|
311
|
+
const canonicalStat = lstatSync(canonicalEntry, { bigint: true });
|
|
312
|
+
if (claimStat.isSymbolicLink() || !claimStat.isFile()
|
|
313
|
+
|| canonicalStat.isSymbolicLink() || !canonicalStat.isFile()
|
|
314
|
+
|| !sameRuntimeIdentity(claimStat, expectedIdentity)
|
|
315
|
+
|| !sameRuntimeIdentity(canonicalStat, expectedIdentity)
|
|
316
|
+
|| !sameRuntimeIdentity(claimStat, canonicalStat)
|
|
317
|
+
|| claimStat.nlink !== 2n || canonicalStat.nlink !== 2n) {
|
|
318
|
+
throw Object.assign(new Error('Runtime request restoration links do not identify one safe file'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
319
|
+
}
|
|
320
|
+
unlinkSync(claimEntry);
|
|
321
|
+
fsyncSync(runtimeRecord.fd);
|
|
322
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
323
|
+
const restored = lstatSync(canonicalEntry, { bigint: true });
|
|
324
|
+
if (restored.isSymbolicLink() || !restored.isFile()
|
|
325
|
+
|| !sameRuntimeIdentity(restored, expectedIdentity) || restored.nlink !== 1n) {
|
|
326
|
+
throw Object.assign(new Error('Restored runtime request did not become one canonical link'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function recoverInterruptedRuntimeClaim(runtimeRecord) {
|
|
332
|
+
const claimPattern = /^\.claimed-(\d+)-[A-Fa-f0-9-]{36}\.json$/;
|
|
333
|
+
const claims = readdirSync(runtimeRecord.reference)
|
|
334
|
+
.map((name) => ({ name, match: name.match(claimPattern) }))
|
|
335
|
+
.filter((entry) => entry.match);
|
|
336
|
+
if (claims.length === 0) return false;
|
|
337
|
+
if (claims.length > 1) {
|
|
338
|
+
throw Object.assign(new Error('Multiple interrupted runtime request claims require manual reconciliation'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
339
|
+
}
|
|
340
|
+
const [{ name, match }] = claims;
|
|
341
|
+
const claimEntry = join(runtimeRecord.reference, name);
|
|
342
|
+
const claimStat = lstatSync(claimEntry, { bigint: true });
|
|
343
|
+
if (claimStat.isSymbolicLink() || !claimStat.isFile()
|
|
344
|
+
|| ![1n, 2n].includes(claimStat.nlink)
|
|
345
|
+
|| claimStat.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
|
|
346
|
+
throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
347
|
+
}
|
|
348
|
+
let canonical;
|
|
349
|
+
try { canonical = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
|
|
350
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
351
|
+
if (canonical) {
|
|
352
|
+
if (sameRuntimeIdentity(canonical, claimStat)) {
|
|
353
|
+
return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (runtimeProcessAlive(Number(match[1]))) {
|
|
357
|
+
throw Object.assign(new Error('The runtime request is already claimed by a live process'), { code: 'RUNTIME_REQUEST_IN_PROGRESS' });
|
|
358
|
+
}
|
|
359
|
+
if (canonical) {
|
|
360
|
+
throw Object.assign(new Error('A canonical request and interrupted claim coexist'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
361
|
+
}
|
|
362
|
+
if (claimStat.nlink !== 1n) {
|
|
363
|
+
throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
364
|
+
}
|
|
365
|
+
linkSync(claimEntry, runtimeRecord.targetEntry);
|
|
366
|
+
runRuntimeSecurityTestHook('afterRuntimeClaimRestoreLink', { claimPath: claimEntry, canonicalPath: runtimeRecord.targetEntry });
|
|
367
|
+
return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = false } = {}) {
|
|
371
|
+
if (runtimeRecord?.fd === undefined || runtimeRecord?.fd === null
|
|
372
|
+
|| !runtimeRecord?.targetEntry || !runtimeRecord?.reference) {
|
|
373
|
+
throw Object.assign(new Error('Runtime response persistence requires a pinned runtime directory'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
374
|
+
}
|
|
375
|
+
const temporary = join(runtimeRecord.reference, `.pending-${process.pid}-${randomUUID()}.tmp`);
|
|
376
|
+
let descriptor;
|
|
377
|
+
let written;
|
|
378
|
+
let publicationAttempted = false;
|
|
379
|
+
let published = false;
|
|
380
|
+
try {
|
|
381
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
382
|
+
runRuntimeSecurityTestHook('beforeRuntimeResponseWrite', {
|
|
383
|
+
targetPath: runtimeRecord.target,
|
|
384
|
+
targetEntry: runtimeRecord.targetEntry,
|
|
385
|
+
runtimePath: runtimeRecord.runtimePath,
|
|
386
|
+
});
|
|
387
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
388
|
+
|
|
389
|
+
let existing;
|
|
390
|
+
try { existing = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
|
|
391
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
392
|
+
if (existing) {
|
|
393
|
+
if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n) {
|
|
394
|
+
throw Object.assign(new Error('The runtime response target must be a regular single-link file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
395
|
+
}
|
|
396
|
+
if (preserveExisting) return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
descriptor = openSync(
|
|
400
|
+
temporary,
|
|
401
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
|
|
402
|
+
0o600,
|
|
403
|
+
);
|
|
404
|
+
const bytes = Buffer.from(JSON.stringify(payload));
|
|
405
|
+
let offset = 0;
|
|
406
|
+
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
407
|
+
fsyncSync(descriptor);
|
|
408
|
+
written = fstatSync(descriptor, { bigint: true });
|
|
409
|
+
if (!written.isFile() || written.nlink !== 1n
|
|
410
|
+
|| (written.mode & 0o777n) !== 0o600n || written.size !== BigInt(bytes.length)) {
|
|
411
|
+
throw Object.assign(new Error('The temporary runtime response is not a private regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
412
|
+
}
|
|
413
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
414
|
+
runRuntimeSecurityTestHook('beforeRuntimeResponseCommit', {
|
|
415
|
+
targetPath: runtimeRecord.target,
|
|
416
|
+
targetEntry: runtimeRecord.targetEntry,
|
|
417
|
+
temporary,
|
|
418
|
+
runtimePath: runtimeRecord.runtimePath,
|
|
419
|
+
});
|
|
420
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
421
|
+
if (!sameRuntimeFileSnapshot(fstatSync(descriptor, { bigint: true }), written)) {
|
|
422
|
+
throw Object.assign(new Error('The temporary runtime response changed before publication'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
423
|
+
}
|
|
424
|
+
publicationAttempted = true;
|
|
425
|
+
renameSync(temporary, runtimeRecord.targetEntry);
|
|
426
|
+
const committed = lstatSync(runtimeRecord.targetEntry, { bigint: true });
|
|
427
|
+
if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
|
|
428
|
+
|| !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
|
|
429
|
+
throw Object.assign(new Error('The published runtime response differs from its pinned temporary file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
430
|
+
}
|
|
431
|
+
fsyncSync(runtimeRecord.fd);
|
|
432
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
433
|
+
published = true;
|
|
434
|
+
} finally {
|
|
435
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
436
|
+
try { unlinkSync(temporary); } catch { /* preserve primary response result */ }
|
|
437
|
+
if (publicationAttempted && !published && written) {
|
|
438
|
+
try {
|
|
439
|
+
const current = lstatSync(runtimeRecord.targetEntry, { bigint: true });
|
|
440
|
+
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(runtimeRecord.targetEntry);
|
|
441
|
+
} catch { /* preserve primary response result */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function readClaimedRuntimeJson(claim, maxBytes = RUNTIME_JSON_MAX_BYTES) {
|
|
447
|
+
runRuntimeSecurityTestHook('beforeRuntimeRequestRead', { claimPath: claim.claimPath });
|
|
448
|
+
const chunks = [];
|
|
449
|
+
let total = 0;
|
|
450
|
+
while (true) {
|
|
451
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
452
|
+
const bytesRead = readSync(claim.fd, buffer, 0, buffer.length, null);
|
|
453
|
+
if (bytesRead === 0) break;
|
|
454
|
+
total += bytesRead;
|
|
455
|
+
if (total > maxBytes) {
|
|
456
|
+
throw Object.assign(new Error('Runtime JSON exceeds the size limit'), { code: 'INVALID_RUNTIME_JSON' });
|
|
457
|
+
}
|
|
458
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
459
|
+
}
|
|
460
|
+
const after = fstatSync(claim.fd, { bigint: true });
|
|
461
|
+
if (!sameRuntimeIdentity(after, claim.identity)
|
|
462
|
+
|| after.size !== claim.identity.size
|
|
463
|
+
|| after.mtimeNs !== claim.identity.mtimeNs
|
|
464
|
+
|| after.ctimeNs !== claim.identity.ctimeNs) {
|
|
465
|
+
throw Object.assign(new Error('Runtime JSON changed while it was being read'), { code: 'INVALID_RUNTIME_JSON' });
|
|
466
|
+
}
|
|
467
|
+
return JSON.parse(Buffer.concat(chunks, total).toString('utf-8'));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function claimRuntimeRequest(runtimeRecord) {
|
|
471
|
+
const canonicalPath = runtimeRecord.target;
|
|
472
|
+
const claimName = `.claimed-${process.pid}-${randomUUID()}.json`;
|
|
473
|
+
const claim = {
|
|
474
|
+
canonicalPath,
|
|
475
|
+
canonicalEntry: runtimeRecord.targetEntry,
|
|
476
|
+
claimPath: join(runtimeRecord.reference, claimName),
|
|
477
|
+
claimName,
|
|
478
|
+
runtimeRecord,
|
|
479
|
+
fd: null,
|
|
480
|
+
identity: null,
|
|
481
|
+
};
|
|
482
|
+
try {
|
|
483
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
484
|
+
recoverInterruptedRuntimeClaim(runtimeRecord);
|
|
485
|
+
runRuntimeSecurityTestHook('beforeRuntimeRequestOpen', { canonicalPath, directory: runtimeRecord.runtimePath });
|
|
486
|
+
claim.fd = openSync(
|
|
487
|
+
claim.canonicalEntry,
|
|
488
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
|
|
489
|
+
);
|
|
490
|
+
claim.identity = fstatSync(claim.fd, { bigint: true });
|
|
491
|
+
if (!claim.identity.isFile() || claim.identity.nlink !== 1n
|
|
492
|
+
|| claim.identity.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
|
|
493
|
+
throw Object.assign(new Error('Runtime JSON must be a regular file within the size limit'), { code: 'INVALID_RUNTIME_JSON' });
|
|
494
|
+
}
|
|
495
|
+
runRuntimeSecurityTestHook('beforeRuntimeClaimRename', { canonicalPath, claimPath: claim.claimPath });
|
|
496
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
497
|
+
renameSync(claim.canonicalEntry, claim.claimPath);
|
|
498
|
+
fsyncSync(runtimeRecord.fd);
|
|
499
|
+
const claimedPathStat = lstatSync(claim.claimPath, { bigint: true });
|
|
500
|
+
if (claimedPathStat.isSymbolicLink() || !claimedPathStat.isFile()
|
|
501
|
+
|| !sameRuntimeIdentity(claimedPathStat, claim.identity)) {
|
|
502
|
+
throw Object.assign(new Error('Runtime JSON changed while it was being claimed'), { code: 'INVALID_RUNTIME_JSON' });
|
|
503
|
+
}
|
|
504
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
505
|
+
fchmodSync(claim.fd, 0o600);
|
|
506
|
+
claim.identity = fstatSync(claim.fd, { bigint: true });
|
|
507
|
+
if ((claim.identity.mode & 0o777n) !== 0o600n) {
|
|
508
|
+
throw Object.assign(new Error('Runtime JSON permissions could not be restricted'), { code: 'INVALID_RUNTIME_JSON' });
|
|
509
|
+
}
|
|
510
|
+
return claim;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if (claim.fd !== null) closeSync(claim.fd);
|
|
513
|
+
err.runtimeClaim = claim;
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function restoreRuntimeRequest(claim) {
|
|
519
|
+
if (!claim?.claimPath || !claim.identity) return false;
|
|
520
|
+
let current;
|
|
521
|
+
try { current = lstatSync(claim.claimPath, { bigint: true }); }
|
|
522
|
+
catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
|
|
523
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
|
|
524
|
+
try {
|
|
525
|
+
linkSync(claim.claimPath, claim.canonicalEntry);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
528
|
+
const canonical = lstatSync(claim.canonicalEntry, { bigint: true });
|
|
529
|
+
if (!sameRuntimeIdentity(canonical, current)) return false;
|
|
530
|
+
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
|
|
531
|
+
}
|
|
532
|
+
runRuntimeSecurityTestHook('afterRuntimeRequestRestoreLink', { claimPath: claim.claimPath, canonicalPath: claim.canonicalEntry });
|
|
533
|
+
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function consumeRuntimeRequest(claim) {
|
|
537
|
+
if (!claim?.claimPath || !claim.identity) return true;
|
|
538
|
+
const current = lstatSync(claim.claimPath, { bigint: true });
|
|
539
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
|
|
540
|
+
unlinkSync(claim.claimPath);
|
|
541
|
+
fsyncSync(claim.runtimeRecord.fd);
|
|
542
|
+
assertRuntimeDirectoryRecord(claim.runtimeRecord);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function scriptExecutionReceiptDigest(receipt) {
|
|
547
|
+
return createHash('sha256').update(JSON.stringify({
|
|
548
|
+
schema_version: receipt.schema_version,
|
|
549
|
+
state: receipt.state,
|
|
550
|
+
request: receipt.request,
|
|
551
|
+
result: receipt.result,
|
|
552
|
+
executed_at: receipt.executed_at,
|
|
553
|
+
execution_id: receipt.execution_id,
|
|
554
|
+
project_scope: receipt.project_scope,
|
|
555
|
+
})).digest('hex');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, executedAt) {
|
|
559
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
560
|
+
const current = lstatSync(claim.claimPath, { bigint: true });
|
|
561
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) {
|
|
562
|
+
throw Object.assign(new Error('The claimed request changed before its execution receipt was persisted'), { code: 'INVALID_RUNTIME_JSON' });
|
|
563
|
+
}
|
|
564
|
+
const material = {
|
|
565
|
+
schema_version: 1,
|
|
566
|
+
state: 'executed_pending_handoff',
|
|
567
|
+
request: {
|
|
568
|
+
kind: 'script', ref: request.ref, id: request.id || request.task || '', title: request.title || '',
|
|
569
|
+
},
|
|
570
|
+
result,
|
|
571
|
+
executed_at: executedAt,
|
|
572
|
+
execution_id: randomUUID(),
|
|
573
|
+
project_scope: installationAuthorityProjectScope(runtimeRecord.projectDir),
|
|
574
|
+
};
|
|
575
|
+
const proof = signHostAuthorityPayload(material, { domain: SCRIPT_EXECUTION_RECEIPT_DOMAIN });
|
|
576
|
+
const receipt = {
|
|
577
|
+
...material,
|
|
578
|
+
receipt_digest: scriptExecutionReceiptDigest(material),
|
|
579
|
+
authority_id: proof.authority_id,
|
|
580
|
+
signature: proof.signature,
|
|
581
|
+
};
|
|
582
|
+
const temporary = join(runtimeRecord.reference, `.executed-${process.pid}-${randomUUID()}.tmp`);
|
|
583
|
+
const claimBackup = join(runtimeRecord.reference, `.receipt-backup-${process.pid}-${randomUUID()}.tmp`);
|
|
584
|
+
const originalClaimIdentity = claim.identity;
|
|
585
|
+
let temporaryDescriptor;
|
|
586
|
+
let written;
|
|
587
|
+
let publicationAttempted = false;
|
|
588
|
+
let published = false;
|
|
589
|
+
let backupCreated = false;
|
|
590
|
+
try {
|
|
591
|
+
const bytes = Buffer.from(JSON.stringify(receipt));
|
|
592
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
593
|
+
let offset = 0;
|
|
594
|
+
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
595
|
+
fsyncSync(temporaryDescriptor);
|
|
596
|
+
written = fstatSync(temporaryDescriptor, { bigint: true });
|
|
597
|
+
if (!written.isFile() || written.nlink !== 1n || (written.mode & 0o777n) !== 0o600n
|
|
598
|
+
|| written.size !== BigInt(bytes.length)) {
|
|
599
|
+
throw Object.assign(new Error('The temporary execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
600
|
+
}
|
|
601
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
602
|
+
const beforeRename = lstatSync(claim.claimPath, { bigint: true });
|
|
603
|
+
if (beforeRename.isSymbolicLink() || !beforeRename.isFile() || !sameRuntimeIdentity(beforeRename, claim.identity)) {
|
|
604
|
+
throw Object.assign(new Error('The claimed request changed before its execution receipt was committed'), { code: 'INVALID_RUNTIME_JSON' });
|
|
605
|
+
}
|
|
606
|
+
runRuntimeSecurityTestHook('beforeRuntimeReceiptCommit', { claimPath: claim.claimPath, temporary, runtimePath: runtimeRecord.runtimePath });
|
|
607
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
608
|
+
if (!sameRuntimeFileSnapshot(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
|
|
609
|
+
throw Object.assign(new Error('The temporary execution receipt changed before publication'), { code: 'INVALID_RUNTIME_JSON' });
|
|
610
|
+
}
|
|
611
|
+
linkSync(claim.claimPath, claimBackup);
|
|
612
|
+
backupCreated = true;
|
|
613
|
+
const backup = lstatSync(claimBackup, { bigint: true });
|
|
614
|
+
if (backup.isSymbolicLink() || !backup.isFile() || !sameRuntimeIdentity(backup, originalClaimIdentity)
|
|
615
|
+
|| backup.nlink !== 2n) {
|
|
616
|
+
throw Object.assign(new Error('The original request could not be protected during receipt publication'), { code: 'INVALID_RUNTIME_JSON' });
|
|
617
|
+
}
|
|
618
|
+
publicationAttempted = true;
|
|
619
|
+
renameSync(temporary, claim.claimPath);
|
|
620
|
+
fsyncSync(runtimeRecord.fd);
|
|
621
|
+
claim.identity = lstatSync(claim.claimPath, { bigint: true });
|
|
622
|
+
if (!claim.identity.isFile() || claim.identity.isSymbolicLink?.() || !samePublishedRuntimeFile(claim.identity, written)
|
|
623
|
+
|| !samePublishedRuntimeFile(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
|
|
624
|
+
throw Object.assign(new Error('The execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
625
|
+
}
|
|
626
|
+
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
627
|
+
unlinkSync(claimBackup);
|
|
628
|
+
backupCreated = false;
|
|
629
|
+
fsyncSync(runtimeRecord.fd);
|
|
630
|
+
published = true;
|
|
631
|
+
return receipt;
|
|
632
|
+
} finally {
|
|
633
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
634
|
+
try { unlinkSync(temporary); } catch { /* preserve primary receipt result */ }
|
|
635
|
+
if (publicationAttempted && !published && backupCreated) {
|
|
636
|
+
try {
|
|
637
|
+
const backup = lstatSync(claimBackup, { bigint: true });
|
|
638
|
+
if (!backup.isSymbolicLink() && backup.isFile() && sameRuntimeIdentity(backup, originalClaimIdentity)) {
|
|
639
|
+
renameSync(claimBackup, claim.claimPath);
|
|
640
|
+
claim.identity = lstatSync(claim.claimPath, { bigint: true });
|
|
641
|
+
fsyncSync(runtimeRecord.fd);
|
|
642
|
+
backupCreated = false;
|
|
643
|
+
}
|
|
644
|
+
} catch { /* preserve primary receipt result */ }
|
|
645
|
+
}
|
|
646
|
+
if (backupCreated) {
|
|
647
|
+
try { unlinkSync(claimBackup); } catch { /* preserve primary receipt result */ }
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function writeDurableScriptHandoff(path, content) {
|
|
653
|
+
const directoryPath = dirname(path);
|
|
654
|
+
const directory = openScriptHandoffDirectoryNoFollow(directoryPath, { create: true });
|
|
655
|
+
const name = basename(path);
|
|
656
|
+
if (resolve(path) !== join(directory.absolute, name)) {
|
|
657
|
+
closeSync(directory.fd);
|
|
658
|
+
throw Object.assign(new Error('The script handoff target is not canonical'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
659
|
+
}
|
|
660
|
+
const temporary = join(directory.reference, `.${name}.${process.pid}.${randomUUID()}.tmp`);
|
|
661
|
+
const destination = join(directory.reference, name);
|
|
662
|
+
let descriptor;
|
|
663
|
+
let written;
|
|
664
|
+
let publicationAttempted = false;
|
|
665
|
+
let published = false;
|
|
666
|
+
try {
|
|
667
|
+
assertScriptHandoffDirectoryRecord(directory);
|
|
668
|
+
runRuntimeSecurityTestHook('beforeScriptHandoffTempOpen', { handoffPath: path, temporary, directory: directory.absolute });
|
|
669
|
+
assertScriptHandoffDirectoryRecord(directory);
|
|
670
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
671
|
+
const bytes = Buffer.from(content);
|
|
672
|
+
let offset = 0;
|
|
673
|
+
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
674
|
+
fsyncSync(descriptor);
|
|
675
|
+
written = fstatSync(descriptor, { bigint: true });
|
|
676
|
+
if (!written.isFile() || written.nlink !== 1n || (written.mode & 0o777n) !== 0o600n || written.size !== BigInt(bytes.length)) {
|
|
677
|
+
throw Object.assign(new Error('The temporary script handoff is not a private regular file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
678
|
+
}
|
|
679
|
+
assertScriptHandoffDirectoryRecord(directory);
|
|
680
|
+
runRuntimeSecurityTestHook('beforeScriptHandoffCommit', { handoffPath: path, temporary, directory: directory.absolute });
|
|
681
|
+
assertScriptHandoffDirectoryRecord(directory);
|
|
682
|
+
let existing;
|
|
683
|
+
try { existing = lstatSync(destination, { bigint: true }); }
|
|
684
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
685
|
+
if (existing && (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n)) {
|
|
686
|
+
throw Object.assign(new Error('The script handoff destination must be a regular single-link file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
687
|
+
}
|
|
688
|
+
if (!sameRuntimeFileSnapshot(fstatSync(descriptor, { bigint: true }), written)) {
|
|
689
|
+
throw Object.assign(new Error('The temporary script handoff changed before publication'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
690
|
+
}
|
|
691
|
+
publicationAttempted = true;
|
|
692
|
+
renameSync(temporary, destination);
|
|
693
|
+
const committed = lstatSync(destination, { bigint: true });
|
|
694
|
+
if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
|
|
695
|
+
|| !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
|
|
696
|
+
throw Object.assign(new Error('The published script handoff differs from its pinned temporary file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
697
|
+
}
|
|
698
|
+
fsyncSync(directory.fd);
|
|
699
|
+
assertScriptHandoffDirectoryRecord(directory);
|
|
700
|
+
published = true;
|
|
701
|
+
} finally {
|
|
702
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
703
|
+
try { unlinkSync(temporary); } catch { /* preserve primary handoff result */ }
|
|
704
|
+
if (publicationAttempted && !published && written) {
|
|
705
|
+
try {
|
|
706
|
+
const current = lstatSync(destination, { bigint: true });
|
|
707
|
+
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(destination);
|
|
708
|
+
} catch { /* preserve primary handoff result */ }
|
|
709
|
+
}
|
|
710
|
+
closeSync(directory.fd);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isExecutedScriptReceipt(projectDir, value) {
|
|
715
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
716
|
+
|| value.schema_version !== 1 || value.state !== 'executed_pending_handoff'
|
|
717
|
+
|| typeof value.executed_at !== 'string' || typeof value.receipt_digest !== 'string'
|
|
718
|
+
|| typeof value.execution_id !== 'string' || !/^[a-f0-9-]{36}$/i.test(value.execution_id)
|
|
719
|
+
|| typeof value.authority_id !== 'string' || typeof value.signature !== 'string'
|
|
720
|
+
|| !value.result || typeof value.result !== 'object' || Array.isArray(value.result)) return false;
|
|
721
|
+
if (!validateScriptRequestSchema(value.request)) return false;
|
|
722
|
+
try {
|
|
723
|
+
const expectedScope = installationAuthorityProjectScope(projectDir);
|
|
724
|
+
if (JSON.stringify(value.project_scope) !== JSON.stringify(expectedScope)
|
|
725
|
+
|| value.receipt_digest !== scriptExecutionReceiptDigest(value)) return false;
|
|
726
|
+
return verifyHostAuthorityPayload({
|
|
727
|
+
schema_version: value.schema_version,
|
|
728
|
+
state: value.state,
|
|
729
|
+
request: value.request,
|
|
730
|
+
result: value.result,
|
|
731
|
+
executed_at: value.executed_at,
|
|
732
|
+
execution_id: value.execution_id,
|
|
733
|
+
project_scope: value.project_scope,
|
|
734
|
+
}, {
|
|
735
|
+
domain: SCRIPT_EXECUTION_RECEIPT_DOMAIN,
|
|
736
|
+
authorityId: value.authority_id,
|
|
737
|
+
signature: value.signature,
|
|
738
|
+
});
|
|
739
|
+
} catch {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function validateScriptRequestSchema(request) {
|
|
745
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) return false;
|
|
746
|
+
if (typeof request.kind !== 'string' || typeof request.ref !== 'string') return false;
|
|
747
|
+
if (request.kind === 'script'
|
|
748
|
+
&& (typeof request.id !== 'string' || typeof request.title !== 'string')) return false;
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function scriptRequestError(code, error) {
|
|
753
|
+
return { ran: false, status: 'error', code, error, reason: error };
|
|
754
|
+
}
|
|
72
755
|
|
|
73
756
|
// ---------------------------------------------------------------------------
|
|
74
757
|
// Constants
|
|
@@ -87,7 +770,7 @@ const RESUME_MESSAGES = {
|
|
|
87
770
|
en: 'Session saved. Type /chati anytime to resume.',
|
|
88
771
|
pt: 'Sessão salva. Digite /chati para retomar.',
|
|
89
772
|
es: 'Sesión guardada. Escribe /chati para reanudar.',
|
|
90
|
-
fr: 'Session
|
|
773
|
+
fr: 'Session sauvée. Tapez /chati pour reprendre.',
|
|
91
774
|
};
|
|
92
775
|
|
|
93
776
|
function getRuntimeAgentPipeline(projectDir, session = null) {
|
|
@@ -573,17 +1256,22 @@ function sessionToPipelineState(session, projectDir) {
|
|
|
573
1256
|
*/
|
|
574
1257
|
export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false, routing = {}) {
|
|
575
1258
|
if (!provider) throw new Error('A routed provider is required to build an agent spawn command');
|
|
1259
|
+
const taskId = routing.task_id || `${agent}-task`;
|
|
576
1260
|
const parts = [
|
|
577
|
-
'node', RUNNER('run-agent.js'),
|
|
578
|
-
'--agent', agent,
|
|
579
|
-
'--task-id',
|
|
580
|
-
'--project-dir',
|
|
581
|
-
'--previous-agent', previousAgent || 'none',
|
|
582
|
-
'--provider', provider,
|
|
583
|
-
...(model ? ['--model', model] : []),
|
|
584
|
-
...(routing.provider_id ? ['--provider-id', routing.provider_id] : []),
|
|
585
|
-
...(routing.reasoning_configuration ? ['--reasoning-configuration', routing.reasoning_configuration] : []),
|
|
586
|
-
...(routing.catalog_snapshot_ref ? ['--catalog-snapshot-ref', routing.catalog_snapshot_ref] : []),
|
|
1261
|
+
'node', shellQuote(RUNNER('run-agent.js')),
|
|
1262
|
+
'--agent', shellQuote(agent),
|
|
1263
|
+
'--task-id', shellQuote(taskId),
|
|
1264
|
+
'--project-dir', shellQuote(resolve(projectDir)),
|
|
1265
|
+
'--previous-agent', shellQuote(previousAgent || 'none'),
|
|
1266
|
+
'--provider', shellQuote(provider),
|
|
1267
|
+
...(model ? ['--model', shellQuote(model)] : []),
|
|
1268
|
+
...(routing.provider_id ? ['--provider-id', shellQuote(routing.provider_id)] : []),
|
|
1269
|
+
...(routing.reasoning_configuration ? ['--reasoning-configuration', shellQuote(routing.reasoning_configuration)] : []),
|
|
1270
|
+
...(routing.catalog_snapshot_ref ? ['--catalog-snapshot-ref', shellQuote(routing.catalog_snapshot_ref)] : []),
|
|
1271
|
+
...(routing.additional_context_file ? ['--additional-context-file', shellQuote(routing.additional_context_file)] : []),
|
|
1272
|
+
...(routing.interaction_id ? ['--interaction-id', shellQuote(routing.interaction_id)] : []),
|
|
1273
|
+
...(routing.reasoning_escalation ? ['--reasoning-escalation'] : []),
|
|
1274
|
+
...(routing.execution_claim_id ? ['--execution-claim-id', shellQuote(routing.execution_claim_id)] : []),
|
|
587
1275
|
...(strictProvider ? ['--strict-provider'] : []),
|
|
588
1276
|
'--timeout', String(timeout || 600000),
|
|
589
1277
|
];
|
|
@@ -598,13 +1286,13 @@ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, pro
|
|
|
598
1286
|
if (!provider) throw new Error('A routed provider is required to build a parallel spawn command');
|
|
599
1287
|
const taskIds = agents.map(a => `${a}-task`).join(',');
|
|
600
1288
|
const parts = [
|
|
601
|
-
'node', RUNNER('run-parallel.js'),
|
|
602
|
-
'--agents', agents.join(','),
|
|
603
|
-
'--task-ids', taskIds,
|
|
604
|
-
'--project-dir',
|
|
605
|
-
'--previous-agent', previousAgent || 'none',
|
|
606
|
-
'--provider', provider,
|
|
607
|
-
...(model ? ['--model', model] : []),
|
|
1289
|
+
'node', shellQuote(RUNNER('run-parallel.js')),
|
|
1290
|
+
'--agents', shellQuote(agents.join(',')),
|
|
1291
|
+
'--task-ids', shellQuote(taskIds),
|
|
1292
|
+
'--project-dir', shellQuote(resolve(projectDir)),
|
|
1293
|
+
'--previous-agent', shellQuote(previousAgent || 'none'),
|
|
1294
|
+
'--provider', shellQuote(provider),
|
|
1295
|
+
...(model ? ['--model', shellQuote(model)] : []),
|
|
608
1296
|
...(strictProvider ? ['--strict-provider'] : []),
|
|
609
1297
|
'--timeout', String(timeout || 900000),
|
|
610
1298
|
];
|
|
@@ -681,6 +1369,26 @@ export function resolveAgentModel(agent, projectDir) {
|
|
|
681
1369
|
return defaults;
|
|
682
1370
|
}
|
|
683
1371
|
|
|
1372
|
+
function resolveAdjudicationModel(projectDir, fallbackBinding = {}) {
|
|
1373
|
+
const artifact = loadRuntimeInstallationV2(projectDir);
|
|
1374
|
+
if (artifact) {
|
|
1375
|
+
const invocation = resolveRuntimeAdjudicationV2({ artifact });
|
|
1376
|
+
return {
|
|
1377
|
+
provider: invocation.harness_id,
|
|
1378
|
+
provider_id: invocation.provider_id,
|
|
1379
|
+
model: invocation.model_pin.model_id,
|
|
1380
|
+
reasoning_configuration: invocation.model_pin.reasoning_configuration,
|
|
1381
|
+
catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref,
|
|
1382
|
+
source: 'installation-v2-adjudication',
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
...fallbackBinding,
|
|
1387
|
+
reasoning_configuration: fallbackBinding.reasoning_configuration || 'high',
|
|
1388
|
+
source: 'legacy-explicit-adjudication',
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
684
1392
|
/**
|
|
685
1393
|
* Estimate context bracket from pipeline progress.
|
|
686
1394
|
*/
|
|
@@ -719,7 +1427,7 @@ function sessionLockTargets(projectDir) {
|
|
|
719
1427
|
orchestratorPath: existsSync(join(projectDir, frameworkDir, '.adapted', target.provider, 'orchestrator', 'chati.md'))
|
|
720
1428
|
? `${frameworkDir}/.adapted/${target.provider}/orchestrator/chati.md`
|
|
721
1429
|
: `${frameworkDir}/orchestrator/chati.md`,
|
|
722
|
-
})).filter(({ provider
|
|
1430
|
+
})).filter(({ provider }) => enabled.has(provider));
|
|
723
1431
|
}
|
|
724
1432
|
|
|
725
1433
|
function removeLegacyPauseSection(content) {
|
|
@@ -732,64 +1440,291 @@ function removeLegacyPauseSection(content) {
|
|
|
732
1440
|
return clean;
|
|
733
1441
|
}
|
|
734
1442
|
|
|
735
|
-
function
|
|
1443
|
+
function nativeLockFailure(code, message) {
|
|
1444
|
+
throw Object.assign(new Error(message), { code });
|
|
1445
|
+
}
|
|
736
1446
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1447
|
+
function nativeLockSnapshot(stat) {
|
|
1448
|
+
return Object.freeze({
|
|
1449
|
+
dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function sameNativeLockSnapshot(left, right) {
|
|
1454
|
+
return Boolean(left && right && Object.keys(left).every((key) => left[key] === right[key]));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function closeNativeLockStorage(storage) {
|
|
1458
|
+
for (const descriptor of [...(storage?.descriptors ?? [])].reverse()) {
|
|
1459
|
+
try { closeSync(descriptor); } catch { /* preserve primary projection result */ }
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function openWorkingDirectoryAnchor(absolute) {
|
|
1464
|
+
if (process.platform !== 'linux') return null;
|
|
1465
|
+
let descriptor;
|
|
743
1466
|
try {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1467
|
+
descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
1468
|
+
const workingDirectory = realpathSync(`/proc/self/fd/${descriptor}`);
|
|
1469
|
+
const targetRelative = relative(workingDirectory, absolute);
|
|
1470
|
+
if (targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
|
|
1471
|
+
closeSync(descriptor);
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
return Object.freeze({ descriptor, segments: targetRelative.split(sep).filter(Boolean) });
|
|
1475
|
+
} catch {
|
|
1476
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
747
1480
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1481
|
+
function openNativeLockStorage(projectDir, targetPath) {
|
|
1482
|
+
if (process.platform !== 'linux') {
|
|
1483
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock projection requires Linux descriptor-relative storage');
|
|
1484
|
+
}
|
|
1485
|
+
const project = resolve(projectDir);
|
|
1486
|
+
const target = resolve(targetPath);
|
|
1487
|
+
const targetRelative = relative(project, target);
|
|
1488
|
+
if (!targetRelative || targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
|
|
1489
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock target must remain inside the project');
|
|
1490
|
+
}
|
|
1491
|
+
const parent = dirname(target);
|
|
1492
|
+
const root = parse(parent).root;
|
|
1493
|
+
const workingDirectoryAnchor = openWorkingDirectoryAnchor(project);
|
|
1494
|
+
const parentRelative = relative(project, parent);
|
|
1495
|
+
const anchor = workingDirectoryAnchor;
|
|
1496
|
+
const segments = anchor
|
|
1497
|
+
? [...anchor.segments, ...parentRelative.split(sep).filter(Boolean)]
|
|
1498
|
+
: parent.slice(root.length).split(sep).filter(Boolean);
|
|
1499
|
+
const descriptors = [];
|
|
1500
|
+
const snapshots = [];
|
|
1501
|
+
try {
|
|
1502
|
+
let descriptor;
|
|
1503
|
+
try {
|
|
1504
|
+
descriptor = anchor
|
|
1505
|
+
? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
|
|
1506
|
+
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
1507
|
+
} finally {
|
|
1508
|
+
if (anchor) closeSync(anchor.descriptor);
|
|
1509
|
+
}
|
|
1510
|
+
descriptors.push(descriptor);
|
|
1511
|
+
snapshots.push(nativeLockSnapshot(fstatSync(descriptor)));
|
|
1512
|
+
for (const segment of segments) {
|
|
1513
|
+
descriptor = openSync(
|
|
1514
|
+
join(`/proc/self/fd/${descriptor}`, segment),
|
|
1515
|
+
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
1516
|
+
);
|
|
1517
|
+
const stat = fstatSync(descriptor);
|
|
1518
|
+
if (!stat.isDirectory()) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock directory chain must contain only real directories');
|
|
1519
|
+
descriptors.push(descriptor);
|
|
1520
|
+
snapshots.push(nativeLockSnapshot(stat));
|
|
1521
|
+
}
|
|
1522
|
+
return Object.freeze({
|
|
1523
|
+
project, target, name: basename(target), parent,
|
|
1524
|
+
descriptor: descriptors.at(-1), descriptors: Object.freeze(descriptors), snapshots: Object.freeze(snapshots),
|
|
1525
|
+
});
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
closeNativeLockStorage({ descriptors });
|
|
1528
|
+
if (error?.code === 'UNSAFE_NATIVE_LOCK_PATH') throw error;
|
|
1529
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock directory cannot be pinned: ${error.message}`);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
753
1532
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1533
|
+
function assertNativeLockStorage(storage) {
|
|
1534
|
+
let current;
|
|
1535
|
+
try {
|
|
1536
|
+
current = openNativeLockStorage(storage.project, storage.target);
|
|
1537
|
+
if (current.snapshots.length !== storage.snapshots.length
|
|
1538
|
+
|| current.snapshots.some((snapshot, index) => !sameNativeLockSnapshot(snapshot, storage.snapshots[index]))) {
|
|
1539
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock directory chain changed during projection');
|
|
1540
|
+
}
|
|
1541
|
+
for (const [index, descriptor] of storage.descriptors.entries()) {
|
|
1542
|
+
if (!sameNativeLockSnapshot(nativeLockSnapshot(fstatSync(descriptor)), storage.snapshots[index])) {
|
|
1543
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'pinned native lock directory changed during projection');
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
} finally { closeNativeLockStorage(current); }
|
|
1547
|
+
return undefined;
|
|
1548
|
+
}
|
|
757
1549
|
|
|
758
|
-
|
|
1550
|
+
function readHarnessProjection(storage) {
|
|
1551
|
+
const reference = join(`/proc/self/fd/${storage.descriptor}`, storage.name);
|
|
1552
|
+
let descriptor;
|
|
1553
|
+
try {
|
|
1554
|
+
const pathStat = lstatSync(reference, { bigint: true });
|
|
1555
|
+
if (pathStat.isSymbolicLink()) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must not be a symlink');
|
|
1556
|
+
descriptor = openSync(reference, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
1557
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
1558
|
+
if (!before.isFile() || before.nlink !== 1n) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must be a single-link regular file');
|
|
1559
|
+
if (before.size > NATIVE_LOCK_MAX_BYTES_BIGINT) return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'native lock exceeds the 256 KiB safety limit');
|
|
1560
|
+
const bytes = Buffer.alloc(Number(before.size));
|
|
1561
|
+
let offset = 0;
|
|
1562
|
+
while (offset < bytes.length) {
|
|
1563
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
1564
|
+
if (count === 0) break;
|
|
1565
|
+
offset += count;
|
|
1566
|
+
}
|
|
1567
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
1568
|
+
const finalPath = lstatSync(reference, { bigint: true });
|
|
1569
|
+
const snapshot = (stat) => ({
|
|
1570
|
+
dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
|
|
1571
|
+
nlink: stat.nlink, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
|
|
1572
|
+
});
|
|
1573
|
+
const initial = snapshot(before);
|
|
1574
|
+
if (offset !== bytes.length || !sameNativeLockSnapshot(initial, snapshot(after))
|
|
1575
|
+
|| !sameNativeLockSnapshot(initial, snapshot(finalPath))) {
|
|
1576
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed while being read');
|
|
1577
|
+
}
|
|
1578
|
+
return Object.freeze({ content: bytes.toString('utf8'), snapshot: initial });
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
if (['UNSAFE_NATIVE_LOCK_PATH', 'NATIVE_LOCK_SIZE_LIMIT'].includes(error?.code)) throw error;
|
|
1581
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock cannot be read safely: ${error.message}`);
|
|
1582
|
+
} finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
1583
|
+
}
|
|
759
1584
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1585
|
+
function writeHarnessProjectionAtomic(storage, content, expected) {
|
|
1586
|
+
const reference = `/proc/self/fd/${storage.descriptor}`;
|
|
1587
|
+
const temporaryName = `.${storage.name}.${process.pid}.${randomUUID()}.tmp`;
|
|
1588
|
+
const temporary = join(reference, temporaryName);
|
|
1589
|
+
const destination = join(reference, storage.name);
|
|
1590
|
+
let descriptor;
|
|
1591
|
+
let replaced = false;
|
|
1592
|
+
try {
|
|
1593
|
+
if (Buffer.byteLength(content, 'utf8') > NATIVE_LOCK_MAX_BYTES) {
|
|
1594
|
+
return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'projected native lock exceeds the 256 KiB safety limit');
|
|
1595
|
+
}
|
|
1596
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
1597
|
+
const bytes = Buffer.from(content, 'utf8');
|
|
1598
|
+
let offset = 0;
|
|
1599
|
+
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
1600
|
+
fsyncSync(descriptor);
|
|
1601
|
+
closeSync(descriptor);
|
|
1602
|
+
descriptor = undefined;
|
|
1603
|
+
assertNativeLockStorage(storage);
|
|
1604
|
+
const current = lstatSync(destination, { bigint: true });
|
|
1605
|
+
const currentSnapshot = {
|
|
1606
|
+
dev: current.dev, ino: current.ino, mode: current.mode, uid: current.uid, gid: current.gid,
|
|
1607
|
+
nlink: current.nlink, size: current.size, mtimeNs: current.mtimeNs, ctimeNs: current.ctimeNs,
|
|
1608
|
+
};
|
|
1609
|
+
if (current.isSymbolicLink() || !sameNativeLockSnapshot(currentSnapshot, expected)) {
|
|
1610
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed before atomic replacement');
|
|
1611
|
+
}
|
|
1612
|
+
renameSync(temporary, destination);
|
|
1613
|
+
replaced = true;
|
|
1614
|
+
fsyncSync(storage.descriptor);
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
if (replaced) error.nativeLockCommitted = true;
|
|
1617
|
+
throw error;
|
|
1618
|
+
} finally {
|
|
1619
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
1620
|
+
rmSync(temporary, { force: true });
|
|
767
1621
|
}
|
|
1622
|
+
return undefined;
|
|
768
1623
|
}
|
|
769
1624
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1625
|
+
function projectSessionLocks(projectDir, { resumeMsg = null } = {}) {
|
|
1626
|
+
return withSessionProjectionLock(projectDir, (session) => {
|
|
1627
|
+
const active = session.session_status !== 'paused' && Boolean(session.current_agent);
|
|
1628
|
+
const pipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
1629
|
+
const completed = Array.isArray(session.completed_agents) ? session.completed_agents.length : 0;
|
|
1630
|
+
const progress = pipeline.length > 0 ? Math.round((completed / pipeline.length) * 100) : 0;
|
|
1631
|
+
const stateInner = active
|
|
1632
|
+
? `## Current State
|
|
1633
|
+
- **Agent**: ${session.current_agent}
|
|
1634
|
+
- **Phase**: ${session.mode || 'discover'}
|
|
1635
|
+
- **Pipeline**: ${completed}/${pipeline.length || '?'} (${progress}%)
|
|
1636
|
+
- **Mode**: ${session.execution_mode || 'interactive'}`
|
|
1637
|
+
: `## Current State
|
|
775
1638
|
- **Agent**: None (ready to start)
|
|
776
1639
|
- **Pipeline**: Pre-start
|
|
777
1640
|
- **Mode**: interactive`;
|
|
778
1641
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1642
|
+
const prepared = [];
|
|
1643
|
+
try {
|
|
1644
|
+
// Validate and render every enabled harness before replacing any file.
|
|
1645
|
+
// A missing or unsafe target therefore cannot leave a partial projection.
|
|
1646
|
+
for (const target of sessionLockTargets(projectDir)) {
|
|
1647
|
+
const storage = openNativeLockStorage(projectDir, target.path);
|
|
1648
|
+
prepared.push({ target, storage, committed: false });
|
|
1649
|
+
const source = readHarnessProjection(storage);
|
|
1650
|
+
let content = removeLegacyPauseSection(source.content);
|
|
1651
|
+
const lockInner = active
|
|
1652
|
+
? `## Session Lock: ACTIVE
|
|
1653
|
+
|
|
1654
|
+
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
1655
|
+
|
|
1656
|
+
1. Read \`${target.orchestratorPath}\` and follow its routing logic
|
|
1657
|
+
2. Route ALL user messages through the current agent: \`${session.current_agent}\`
|
|
1658
|
+
3. NEVER respond outside of the Chati.dev system
|
|
1659
|
+
4. The ONLY way to exit is via \`${target.invocation} exit\`, \`${target.invocation} stop\`, or \`${target.invocation} quit\``
|
|
1660
|
+
: `## Session Lock
|
|
782
1661
|
**Status: INACTIVE**: Type \`${target.invocation}\` to activate.`;
|
|
783
|
-
|
|
784
|
-
|
|
1662
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
1663
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
1664
|
+
if (!active && resumeMsg) {
|
|
1665
|
+
const providerMessage = resumeMsg.replaceAll('/chati', target.invocation);
|
|
1666
|
+
const pauseBlock = `${PAUSE_START}\n## Session Paused\n\n${providerMessage}\n${PAUSE_END}`;
|
|
1667
|
+
content = `${content.trimEnd()}\n\n${pauseBlock}\n`;
|
|
1668
|
+
}
|
|
1669
|
+
prepared.at(-1).source = source;
|
|
1670
|
+
prepared.at(-1).content = `${content.trimEnd()}\n`;
|
|
1671
|
+
}
|
|
785
1672
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1673
|
+
for (const entry of prepared) {
|
|
1674
|
+
runRuntimeSecurityTestHook('beforeNativeLockReplacement', { target: entry.target.path });
|
|
1675
|
+
try {
|
|
1676
|
+
writeHarnessProjectionAtomic(entry.storage, entry.content, entry.source.snapshot);
|
|
1677
|
+
entry.committed = true;
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
if (error.nativeLockCommitted) entry.committed = true;
|
|
1680
|
+
throw error;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return Object.freeze({
|
|
1684
|
+
active,
|
|
1685
|
+
projected: Object.freeze(prepared.map(({ target }) => target.path)),
|
|
1686
|
+
});
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
const rollbackErrors = [];
|
|
1689
|
+
for (const entry of [...prepared].reverse()) {
|
|
1690
|
+
if (!entry.committed || !entry.source) continue;
|
|
1691
|
+
try {
|
|
1692
|
+
const current = readHarnessProjection(entry.storage);
|
|
1693
|
+
if (current.content !== entry.content) {
|
|
1694
|
+
throw Object.assign(
|
|
1695
|
+
new Error('native lock changed after this projection committed'),
|
|
1696
|
+
{ code: 'NATIVE_LOCK_ROLLBACK_CONFLICT' },
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
writeHarnessProjectionAtomic(entry.storage, entry.source.content, current.snapshot);
|
|
1700
|
+
} catch (rollbackError) {
|
|
1701
|
+
rollbackErrors.push(`${entry.target.path}: ${rollbackError.message}`);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (rollbackErrors.length > 0) {
|
|
1705
|
+
throw Object.assign(
|
|
1706
|
+
new Error(`Native lock projection failed (${error.message}) and rollback was incomplete: ${rollbackErrors.join('; ')}`),
|
|
1707
|
+
{ code: 'NATIVE_LOCK_ROLLBACK_FAILED', cause: error },
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
throw error;
|
|
1711
|
+
} finally {
|
|
1712
|
+
for (const entry of prepared) closeNativeLockStorage(entry.storage);
|
|
790
1713
|
}
|
|
791
|
-
|
|
792
|
-
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
1718
|
+
void currentAgent;
|
|
1719
|
+
void stateInfo;
|
|
1720
|
+
return projectSessionLocks(projectDir);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Remove session lock blocks from every enabled harness's native local file.
|
|
1725
|
+
*/
|
|
1726
|
+
function removeSessionLock(projectDir, resumeMsg) {
|
|
1727
|
+
return projectSessionLocks(projectDir, { resumeMsg });
|
|
793
1728
|
}
|
|
794
1729
|
|
|
795
1730
|
// ---------------------------------------------------------------------------
|
|
@@ -798,50 +1733,148 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
798
1733
|
|
|
799
1734
|
async function handleNext(projectDir) {
|
|
800
1735
|
const result = await _handleNextInner(projectDir);
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
// The
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1736
|
+
let dispatchMutation = null;
|
|
1737
|
+
|
|
1738
|
+
// The canonical session owns the active task. Native harness lock files are
|
|
1739
|
+
// projections only, so a CLI switch cannot create a second execution.
|
|
1740
|
+
// Persist only actions that actually dispatch an agent. Surface holds and
|
|
1741
|
+
// RAIL handoffs are control-plane responses, not running pipeline agents.
|
|
1742
|
+
const dispatchActions = new Set([
|
|
1743
|
+
'spawn_routed_interactive',
|
|
1744
|
+
'spawn_autonomous',
|
|
1745
|
+
'spawn_parallel',
|
|
1746
|
+
'spawn_team',
|
|
1747
|
+
]);
|
|
1748
|
+
if (result?.agent && dispatchActions.has(result.action)) {
|
|
1749
|
+
const binding = result.model_info ? {
|
|
1750
|
+
provider: result.model_info.provider || null,
|
|
1751
|
+
provider_id: result.model_info.provider_id || null,
|
|
1752
|
+
model: result.model_info.model || null,
|
|
1753
|
+
reasoning_configuration: result.model_info.reasoning_configuration || null,
|
|
1754
|
+
catalog_snapshot_ref: result.model_info.catalog_snapshot_ref || null,
|
|
1755
|
+
} : null;
|
|
1756
|
+
if (result.model_info?.source === 'installation-v2') {
|
|
1757
|
+
const dispatchedAgents = Array.isArray(result.parallel_spawn_commands) && result.parallel_spawn_commands.length > 0
|
|
1758
|
+
? result.parallel_spawn_commands.map((entry) => ({ agent: entry.agent, binding: entry.agent === result.agent ? binding : null }))
|
|
1759
|
+
: [{ agent: result.agent, binding }];
|
|
1760
|
+
const claimed = claimAgentExecutions(projectDir, dispatchedAgents);
|
|
1761
|
+
if (!claimed.saved) {
|
|
1762
|
+
return errorResult(`Could not persist canonical agent state: ${claimed.error}`, 'SESSION_WRITE_FAILED');
|
|
811
1763
|
}
|
|
812
|
-
|
|
1764
|
+
if (!claimed.claimed) {
|
|
1765
|
+
return {
|
|
1766
|
+
action: 'agent_execution_in_flight',
|
|
1767
|
+
agent: claimed.agent || result.agent,
|
|
1768
|
+
code: claimed.code || 'AGENT_EXECUTION_IN_FLIGHT',
|
|
1769
|
+
status_summary: claimed.error || `Agent ${result.agent} is already executing in another CLI.`,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
dispatchMutation = {
|
|
1773
|
+
expectedAgent: result.agent,
|
|
1774
|
+
claims: claimed.claims,
|
|
1775
|
+
previous: claimed.previous,
|
|
1776
|
+
};
|
|
1777
|
+
if (result.spawn_command && claimed.claims[result.agent]) {
|
|
1778
|
+
result.spawn_command += ` --execution-claim-id ${shellQuote(claimed.claims[result.agent])}`;
|
|
1779
|
+
}
|
|
1780
|
+
if (Array.isArray(result.parallel_spawn_commands)) {
|
|
1781
|
+
result.parallel_spawn_commands = result.parallel_spawn_commands.map((entry) => ({
|
|
1782
|
+
...entry,
|
|
1783
|
+
command: `${entry.command} --execution-claim-id ${shellQuote(claimed.claims[entry.agent])}`,
|
|
1784
|
+
}));
|
|
1785
|
+
}
|
|
1786
|
+
result.execution_claim_id = claimed.claims[result.agent] || null;
|
|
1787
|
+
} else {
|
|
1788
|
+
const persisted = activateAgentExecutionState(projectDir, {
|
|
1789
|
+
agent: result.agent,
|
|
1790
|
+
binding,
|
|
1791
|
+
});
|
|
1792
|
+
if (!persisted.saved) {
|
|
1793
|
+
return errorResult(`Could not persist canonical agent state: ${persisted.error}`, 'SESSION_WRITE_FAILED');
|
|
1794
|
+
}
|
|
1795
|
+
dispatchMutation = {
|
|
1796
|
+
expectedAgent: result.agent,
|
|
1797
|
+
previous: persisted.previous,
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
813
1800
|
}
|
|
814
1801
|
|
|
815
1802
|
// Centralized session lock update: applies to ALL return paths.
|
|
816
1803
|
// Activates lock when an agent is returned, deactivates on completion.
|
|
817
1804
|
if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
|
|
818
|
-
const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
|
|
1805
|
+
const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel', 'resume_pending_interaction'];
|
|
819
1806
|
if (activating.includes(result.action)) {
|
|
1807
|
+
let projection;
|
|
820
1808
|
try {
|
|
821
1809
|
const runtimePipeline = getRuntimeAgentPipeline(projectDir, result.session);
|
|
822
|
-
writeSessionLock(projectDir, result.agent, {
|
|
1810
|
+
projection = writeSessionLock(projectDir, result.agent, {
|
|
823
1811
|
phase: result.phase || 'discover',
|
|
824
1812
|
mode: result.session?.execution_mode || 'interactive',
|
|
825
1813
|
position: result.pipeline_progress?.completedAgents?.length ?? 0,
|
|
826
1814
|
total: runtimePipeline.length,
|
|
827
1815
|
progress: result.pipeline_progress?.progress ?? 0,
|
|
828
1816
|
});
|
|
829
|
-
} catch {
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
projection = { projected: false, error: error.message, code: error.code };
|
|
1819
|
+
}
|
|
1820
|
+
if (!projection.projected) {
|
|
1821
|
+
if (dispatchMutation) {
|
|
1822
|
+
const rollback = rollbackAgentDispatch(projectDir, dispatchMutation);
|
|
1823
|
+
if (!rollback.saved || !rollback.rolledBack) {
|
|
1824
|
+
return errorResult(
|
|
1825
|
+
`Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
|
|
1826
|
+
rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return errorResult(
|
|
1831
|
+
`Native session lock projection failed: ${projection.error}`,
|
|
1832
|
+
projection.code || 'SESSION_PROJECTION_FAILED',
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
830
1835
|
}
|
|
831
1836
|
} else if (result && result.action === 'complete') {
|
|
1837
|
+
let projection;
|
|
832
1838
|
try {
|
|
833
1839
|
const { session } = loadSession(projectDir);
|
|
834
1840
|
const lang = session?.language || 'en';
|
|
835
|
-
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
836
|
-
} catch {
|
|
1841
|
+
projection = removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
projection = { projected: false, error: error.message, code: error.code };
|
|
1844
|
+
}
|
|
1845
|
+
if (!projection.projected) {
|
|
1846
|
+
return errorResult(
|
|
1847
|
+
`Native session lock projection failed: ${projection.error}`,
|
|
1848
|
+
projection.code || 'SESSION_PROJECTION_FAILED',
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (dispatchMutation) {
|
|
1854
|
+
recordEvent(projectDir, EventType.AGENT_ACTIVATED, result.agent, { action: result.action, phase: result.phase });
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Fase 8: attach state-at-a-glance card after canonical state and every
|
|
1858
|
+
// enabled native projection have committed successfully.
|
|
1859
|
+
if (result && result.action !== 'setup' && result.action !== 'error') {
|
|
1860
|
+
try {
|
|
1861
|
+
const { session } = loadSession(projectDir);
|
|
1862
|
+
if (session) result.state_card = buildStateCard({ session, projectDir });
|
|
1863
|
+
} catch { /* non-fatal — card is UX sugar */ }
|
|
837
1864
|
}
|
|
838
1865
|
|
|
839
1866
|
return result;
|
|
840
1867
|
}
|
|
841
1868
|
|
|
842
1869
|
async function _handleNextInner(projectDir) {
|
|
843
|
-
const { loaded, session } = loadSession(projectDir);
|
|
1870
|
+
const { loaded, session, error } = loadSession(projectDir);
|
|
844
1871
|
if (!loaded || !session) {
|
|
1872
|
+
if (!['Session file not found', 'Session directory not found'].includes(error)) {
|
|
1873
|
+
const code = error?.includes('Timed out waiting for session write lock')
|
|
1874
|
+
? 'SESSION_WRITE_FAILED'
|
|
1875
|
+
: 'INVALID_SESSION_PATH';
|
|
1876
|
+
return errorResult(`Cannot load session safely: ${error}`, code);
|
|
1877
|
+
}
|
|
845
1878
|
return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
|
|
846
1879
|
}
|
|
847
1880
|
|
|
@@ -852,6 +1885,146 @@ async function _handleNextInner(projectDir) {
|
|
|
852
1885
|
|
|
853
1886
|
const installationV2 = loadRuntimeInstallationV2(projectDir);
|
|
854
1887
|
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
1888
|
+
|
|
1889
|
+
const pending = session.pending_interaction;
|
|
1890
|
+
if (pending?.status === 'relay_in_flight') {
|
|
1891
|
+
const relayStartedAt = Date.parse(pending.relay_started_at || '');
|
|
1892
|
+
const explicitLeaseExpiry = Date.parse(pending.relay_lease_expires_at || '');
|
|
1893
|
+
const relayLeaseExpired = Number.isFinite(explicitLeaseExpiry)
|
|
1894
|
+
? Date.now() >= explicitLeaseExpiry
|
|
1895
|
+
: Number.isFinite(relayStartedAt) && Date.now() - relayStartedAt > 25 * 60 * 1000;
|
|
1896
|
+
if (relayLeaseExpired) {
|
|
1897
|
+
const recovered = releasePendingInteraction(projectDir, {
|
|
1898
|
+
interactionId: pending.interaction_id || null,
|
|
1899
|
+
claimId: pending.relay_claim_id || null,
|
|
1900
|
+
});
|
|
1901
|
+
if (!recovered.saved || (!recovered.released && !recovered.settled)) {
|
|
1902
|
+
return errorResult(`Could not recover stale pending relay: ${recovered.error || 'ownership mismatch'}`, 'SESSION_WRITE_FAILED');
|
|
1903
|
+
}
|
|
1904
|
+
return _handleNextInner(projectDir);
|
|
1905
|
+
}
|
|
1906
|
+
return {
|
|
1907
|
+
action: 'pending_interaction_in_flight',
|
|
1908
|
+
agent: pending.agent,
|
|
1909
|
+
interaction_id: pending.interaction_id || null,
|
|
1910
|
+
relay_cycles: pending.relay_cycles || 1,
|
|
1911
|
+
wait_for_user: false,
|
|
1912
|
+
spawn_command: null,
|
|
1913
|
+
deferred_spawn_command: null,
|
|
1914
|
+
session: { language: session.language, active_harness: session.active_harness },
|
|
1915
|
+
status_summary: `The response for ${pending.agent} is already being relayed by another process.`,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
if (pending?.status === 'waiting_for_user' && pending.agent && pending.question) {
|
|
1919
|
+
if ((pending.relay_cycles || 1) > 3) {
|
|
1920
|
+
const originalBinding = pending.execution_binding || session.current_execution_binding || resolveAgentModel(pending.agent, projectDir);
|
|
1921
|
+
const identity = ensurePendingInteractionIdentity(projectDir, originalBinding);
|
|
1922
|
+
if (!identity.saved || !identity.interactionId) {
|
|
1923
|
+
return errorResult(`Could not identify pending interaction: ${identity.error || 'unknown error'}`, 'SESSION_WRITE_FAILED');
|
|
1924
|
+
}
|
|
1925
|
+
const adjudicationBinding = resolveAdjudicationModel(projectDir, originalBinding);
|
|
1926
|
+
let contextFile;
|
|
1927
|
+
let contextRuntime;
|
|
1928
|
+
try {
|
|
1929
|
+
contextRuntime = captureRuntimePath(projectDir, `pending-adjudication-${identity.interactionId}.json`);
|
|
1930
|
+
writePrivateRuntimeJson(contextRuntime, {
|
|
1931
|
+
response: `Reasoning escalation after three unresolved relays. Review the pending interaction and its project context. Resolve the issue technically when safe. Ask the user only if authority, product intent, or unavailable information is strictly required. Pending question: ${pending.question}`,
|
|
1932
|
+
});
|
|
1933
|
+
contextFile = contextRuntime.target;
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
return errorResult(error.message, error.code || 'UNSAFE_RUNTIME_PATH');
|
|
1936
|
+
} finally {
|
|
1937
|
+
closeRuntimeRecord(contextRuntime);
|
|
1938
|
+
}
|
|
1939
|
+
const spawnCommand = buildSpawnCommand(
|
|
1940
|
+
pending.agent,
|
|
1941
|
+
projectDir,
|
|
1942
|
+
session.last_handoff || 'none',
|
|
1943
|
+
adjudicationBinding.provider,
|
|
1944
|
+
600000,
|
|
1945
|
+
adjudicationBinding.model,
|
|
1946
|
+
true,
|
|
1947
|
+
{
|
|
1948
|
+
...adjudicationBinding,
|
|
1949
|
+
task_id: pending.task_id,
|
|
1950
|
+
interaction_id: identity.interactionId,
|
|
1951
|
+
additional_context_file: contextFile,
|
|
1952
|
+
reasoning_escalation: true,
|
|
1953
|
+
},
|
|
1954
|
+
);
|
|
1955
|
+
return {
|
|
1956
|
+
action: 'pending_interaction_escalation_required',
|
|
1957
|
+
agent: pending.agent,
|
|
1958
|
+
question: pending.question,
|
|
1959
|
+
interaction_id: identity.interactionId,
|
|
1960
|
+
relay_cycles: pending.relay_cycles || 3,
|
|
1961
|
+
wait_for_user: false,
|
|
1962
|
+
spawn_command: spawnCommand,
|
|
1963
|
+
deferred_spawn_command: null,
|
|
1964
|
+
model_info: adjudicationBinding,
|
|
1965
|
+
escalation_required: true,
|
|
1966
|
+
session: { language: session.language, active_harness: session.active_harness },
|
|
1967
|
+
status_summary: 'Interactive relay limit reached. Apply the configured reasoning escalation.',
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
const binding = pending.execution_binding || session.current_execution_binding || resolveAgentModel(pending.agent, projectDir);
|
|
1971
|
+
const identity = ensurePendingInteractionIdentity(projectDir, binding);
|
|
1972
|
+
if (!identity.saved || !identity.interactionId) {
|
|
1973
|
+
return errorResult(`Could not identify pending interaction: ${identity.error || 'unknown error'}`, 'SESSION_WRITE_FAILED');
|
|
1974
|
+
}
|
|
1975
|
+
let responseFile;
|
|
1976
|
+
let responseRuntime;
|
|
1977
|
+
try {
|
|
1978
|
+
responseRuntime = captureRuntimePath(projectDir, `pending-response-${identity.interactionId}.json`);
|
|
1979
|
+
writePrivateRuntimeJson(responseRuntime, { response: '' }, { preserveExisting: true });
|
|
1980
|
+
responseFile = responseRuntime.target;
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
return errorResult(error.message, error.code || 'UNSAFE_RUNTIME_PATH');
|
|
1983
|
+
} finally {
|
|
1984
|
+
closeRuntimeRecord(responseRuntime);
|
|
1985
|
+
}
|
|
1986
|
+
const agentDef = getAgentDefinition(pending.agent);
|
|
1987
|
+
const deferredSpawnCommand = buildSpawnCommand(
|
|
1988
|
+
pending.agent,
|
|
1989
|
+
projectDir,
|
|
1990
|
+
session.last_handoff || 'none',
|
|
1991
|
+
binding.provider,
|
|
1992
|
+
600000,
|
|
1993
|
+
binding.model,
|
|
1994
|
+
true,
|
|
1995
|
+
{
|
|
1996
|
+
...binding,
|
|
1997
|
+
task_id: pending.task_id,
|
|
1998
|
+
interaction_id: identity.interactionId,
|
|
1999
|
+
additional_context_file: responseFile,
|
|
2000
|
+
},
|
|
2001
|
+
);
|
|
2002
|
+
return {
|
|
2003
|
+
action: 'resume_pending_interaction',
|
|
2004
|
+
agent: pending.agent,
|
|
2005
|
+
agent_file: getAgentFile(pending.agent, projectDir) || null,
|
|
2006
|
+
phase: agentDef?.phase || session.mode,
|
|
2007
|
+
question: pending.question,
|
|
2008
|
+
relay_cycles: pending.relay_cycles || 1,
|
|
2009
|
+
wait_for_user: true,
|
|
2010
|
+
requires_user_response: true,
|
|
2011
|
+
interaction_id: identity.interactionId,
|
|
2012
|
+
response_file: responseFile,
|
|
2013
|
+
response_file_format: { response: 'exact user response as a JSON string' },
|
|
2014
|
+
spawn_command: null,
|
|
2015
|
+
deferred_spawn_command: deferredSpawnCommand,
|
|
2016
|
+
model_info: binding,
|
|
2017
|
+
redispatched: false,
|
|
2018
|
+
pipeline_progress: getPipelineProgress(sessionToPipelineState(session, projectDir)),
|
|
2019
|
+
session: {
|
|
2020
|
+
language: session.language,
|
|
2021
|
+
project_type: session.project_type || session.project?.type,
|
|
2022
|
+
execution_mode: session.execution_mode,
|
|
2023
|
+
active_harness: session.active_harness,
|
|
2024
|
+
},
|
|
2025
|
+
status_summary: `Waiting for the user's answer to ${pending.agent}.`,
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
855
2028
|
if (installationV2 && session.rail_handoff_id) {
|
|
856
2029
|
const completedPlanning = runtimePipeline.length;
|
|
857
2030
|
return {
|
|
@@ -899,6 +2072,16 @@ async function _handleNextInner(projectDir) {
|
|
|
899
2072
|
if (currentAgent && !completedAgents.includes(currentAgent)) {
|
|
900
2073
|
const agent = currentAgent;
|
|
901
2074
|
const agentStatus = session.agents?.[agent]?.status;
|
|
2075
|
+
if (agentStatus === 'awaiting_advance') {
|
|
2076
|
+
return {
|
|
2077
|
+
action: 'awaiting_agent_advance',
|
|
2078
|
+
agent,
|
|
2079
|
+
code: 'AGENT_AWAITING_ADVANCE',
|
|
2080
|
+
pipeline_progress: getPipelineProgress(pipelineState),
|
|
2081
|
+
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
|
|
2082
|
+
status_summary: `${agent} finished its routed process and is waiting for canonical advance.`,
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
902
2085
|
if (agentStatus === 'in_progress') {
|
|
903
2086
|
const agentFile = getAgentFile(agent, projectDir) || null;
|
|
904
2087
|
const agentDef = getAgentDefinition(agent);
|
|
@@ -1006,7 +2189,56 @@ async function _handleNextInner(projectDir) {
|
|
|
1006
2189
|
};
|
|
1007
2190
|
}
|
|
1008
2191
|
|
|
1009
|
-
|
|
2192
|
+
let nextAgent = nextInfo.next;
|
|
2193
|
+
|
|
2194
|
+
// Parallel groups can be resumed after only a subset has completed. Never
|
|
2195
|
+
// send completed members back through the claim layer, and do not let a live
|
|
2196
|
+
// claim for one member prevent an unclaimed sibling from being dispatched.
|
|
2197
|
+
// A successful member waiting for canonical advance still takes precedence,
|
|
2198
|
+
// because its result must be committed before the group can progress.
|
|
2199
|
+
if (installationV2 && nextInfo.isParallel && nextInfo.group.length > 1) {
|
|
2200
|
+
const incompleteGroup = nextInfo.group.filter((agent) => (
|
|
2201
|
+
!completedAgents.includes(agent) && session.agents?.[agent]?.status !== 'completed'
|
|
2202
|
+
));
|
|
2203
|
+
const awaitingAdvance = incompleteGroup.find((agent) => (
|
|
2204
|
+
session.agents?.[agent]?.status === 'awaiting_advance'
|
|
2205
|
+
|| session.agents?.[agent]?.execution_claim_state === 'awaiting_advance'
|
|
2206
|
+
));
|
|
2207
|
+
if (awaitingAdvance) {
|
|
2208
|
+
return {
|
|
2209
|
+
action: 'awaiting_agent_advance',
|
|
2210
|
+
agent: awaitingAdvance,
|
|
2211
|
+
code: 'AGENT_AWAITING_ADVANCE',
|
|
2212
|
+
pipeline_progress: getPipelineProgress(pipelineState),
|
|
2213
|
+
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
|
|
2214
|
+
status_summary: `${awaitingAdvance} finished its routed process and is waiting for canonical advance.`,
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
const now = Date.now();
|
|
2218
|
+
const liveAgents = incompleteGroup.filter((agent) => {
|
|
2219
|
+
const state = session.agents?.[agent];
|
|
2220
|
+
const expiry = Date.parse(state?.execution_claim_expires_at || '');
|
|
2221
|
+
return state?.status === 'in_progress'
|
|
2222
|
+
&& Boolean(state.execution_claim_id)
|
|
2223
|
+
&& Number.isFinite(expiry)
|
|
2224
|
+
&& expiry > now;
|
|
2225
|
+
});
|
|
2226
|
+
const dispatchableGroup = incompleteGroup.filter((agent) => !liveAgents.includes(agent));
|
|
2227
|
+
if (dispatchableGroup.length === 0 && liveAgents.length > 0) {
|
|
2228
|
+
return {
|
|
2229
|
+
action: 'agent_execution_in_flight',
|
|
2230
|
+
agent: liveAgents[0],
|
|
2231
|
+
code: 'AGENT_EXECUTION_IN_FLIGHT',
|
|
2232
|
+
pipeline_progress: getPipelineProgress(pipelineState),
|
|
2233
|
+
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
|
|
2234
|
+
status_summary: `The remaining planning agents are already executing: ${liveAgents.join(', ')}.`,
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
if (dispatchableGroup.length > 0) {
|
|
2238
|
+
nextAgent = dispatchableGroup[0];
|
|
2239
|
+
nextInfo = { ...nextInfo, next: nextAgent, group: dispatchableGroup };
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
1010
2242
|
const agentFile = getAgentFile(nextAgent, projectDir) || null;
|
|
1011
2243
|
const agentDef = getAgentDefinition(nextAgent);
|
|
1012
2244
|
const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
|
|
@@ -1402,6 +2634,50 @@ async function handleAdvance(projectDir, args) {
|
|
|
1402
2634
|
const { loaded, session } = loadSession(projectDir);
|
|
1403
2635
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
1404
2636
|
|
|
2637
|
+
let runtimeV2;
|
|
2638
|
+
try { runtimeV2 = loadRuntimeInstallationV2(projectDir); } catch (error) {
|
|
2639
|
+
return errorResult(error.message, error.code || 'INVALID_INSTALLATION_ARTIFACT');
|
|
2640
|
+
}
|
|
2641
|
+
if (runtimeV2) {
|
|
2642
|
+
if (!V2_PLANNING_AGENTS.includes(agent)) {
|
|
2643
|
+
return errorResult(
|
|
2644
|
+
`Agent ${agent} is outside the v2 planning runtime. Implementation and release must advance through a sealed RAIL handoff.`,
|
|
2645
|
+
'V2_RAIL_EXECUTION_REQUIRED',
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
if (!session.agents || !Object.hasOwn(session.agents, agent)) {
|
|
2649
|
+
return errorResult(`Agent ${agent} is not part of the canonical v2 session.`, 'INVALID_AGENT_ADVANCE');
|
|
2650
|
+
}
|
|
2651
|
+
try {
|
|
2652
|
+
assertV2PlanningPredecessors(session, agent);
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
return errorResult(error.message, error.code || 'V2_PLANNING_PREDECESSORS_INCOMPLETE');
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
const agentState = session.agents && session.agents[agent];
|
|
2658
|
+
|
|
2659
|
+
// Legacy preview and scaffold decisions mutate the v1 Build/Deploy state
|
|
2660
|
+
// machine. Runtime v2 ends planning at a sealed RAIL handoff, so accepting
|
|
2661
|
+
// any of those decisions here would create a second execution authority.
|
|
2662
|
+
if (runtimeV2 && decision) {
|
|
2663
|
+
return errorResult(
|
|
2664
|
+
`Decision ${decision} belongs to the legacy Build/Deploy pipeline. Runtime v2 must continue through RAIL.`,
|
|
2665
|
+
'V2_RAIL_DECISION_REQUIRED',
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
if (runtimeV2 && agent === 'tasks' && status === 'completed' && score < 95) {
|
|
2670
|
+
return {
|
|
2671
|
+
advanced: false,
|
|
2672
|
+
blocked: true,
|
|
2673
|
+
reason: 'V2_TASKS_SCORE_BELOW_THRESHOLD',
|
|
2674
|
+
message: `Tasks scored ${score}. A score of at least 95 is required before the planning handoff can be sealed for RAIL.`,
|
|
2675
|
+
score,
|
|
2676
|
+
required_score: 95,
|
|
2677
|
+
agent_state: agentState?.status || null,
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
|
|
1405
2681
|
// Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
|
|
1406
2682
|
// dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
|
|
1407
2683
|
// pending scaffold decision after qa-planning is marked complete.
|
|
@@ -1413,7 +2689,6 @@ async function handleAdvance(projectDir, args) {
|
|
|
1413
2689
|
// Idempotency guard: if this agent is already marked completed, return no-op.
|
|
1414
2690
|
// Prevents duplicate advance calls (hook + user-approval path) from double-
|
|
1415
2691
|
// incrementing pipeline_position or corrupting completed_agents.
|
|
1416
|
-
const agentState = session.agents && session.agents[agent];
|
|
1417
2692
|
const alreadyCompleted =
|
|
1418
2693
|
agentState && agentState.status === 'completed' &&
|
|
1419
2694
|
Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
|
|
@@ -1429,6 +2704,13 @@ async function handleAdvance(projectDir, args) {
|
|
|
1429
2704
|
};
|
|
1430
2705
|
}
|
|
1431
2706
|
|
|
2707
|
+
if (runtimeV2 && status === 'completed' && agentState?.status !== 'awaiting_advance') {
|
|
2708
|
+
return errorResult(
|
|
2709
|
+
`Agent ${agent} has no successful routed result awaiting canonical advance.`,
|
|
2710
|
+
'V2_AGENT_RESULT_NOT_HELD',
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
1432
2714
|
// Handle user_preview decisions
|
|
1433
2715
|
if (decision) {
|
|
1434
2716
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
@@ -2235,24 +3517,28 @@ async function handleInit(projectDir, args) {
|
|
|
2235
3517
|
} catch { /* expected: license file may not exist */ }
|
|
2236
3518
|
|
|
2237
3519
|
const type = args.type || 'greenfield';
|
|
2238
|
-
|
|
3520
|
+
let language = args.language || null;
|
|
2239
3521
|
const workflow = args.workflow || 'full';
|
|
3522
|
+
const runtimeV2 = Boolean(loadRuntimeInstallationV2(projectDir));
|
|
2240
3523
|
|
|
2241
|
-
// Preserve fields from session
|
|
3524
|
+
// Preserve fields from the canonical session created by the installer.
|
|
2242
3525
|
let preservedName = args.name || '';
|
|
2243
3526
|
let preservedIdes = [];
|
|
2244
3527
|
let preservedMcps = [];
|
|
2245
3528
|
let preservedProviders = [];
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
3529
|
+
const existingSession = loadSession(projectDir);
|
|
3530
|
+
if (existingSession.loaded) {
|
|
3531
|
+
const existing = existingSession.session || {};
|
|
3532
|
+
language = language || existing.language || 'en';
|
|
3533
|
+
preservedName = preservedName || existing.project?.name || '';
|
|
3534
|
+
preservedIdes = existing.ides || [];
|
|
3535
|
+
preservedMcps = existing.mcps || [];
|
|
3536
|
+
preservedProviders = existing.providers_enabled || [];
|
|
3537
|
+
} else if (existsSync(join(projectDir, '.chati', 'session.yaml'))
|
|
3538
|
+
|| !['Session file not found', 'Session directory not found'].includes(existingSession.error)) {
|
|
3539
|
+
return errorResult(`Cannot read the existing session safely: ${existingSession.error}`, 'INVALID_SESSION_PATH');
|
|
3540
|
+
}
|
|
3541
|
+
language = language || 'en';
|
|
2256
3542
|
if (preservedProviders.length === 0) {
|
|
2257
3543
|
preservedProviders = parseProviderConfig(projectDir).enabled;
|
|
2258
3544
|
}
|
|
@@ -2272,23 +3558,21 @@ async function handleInit(projectDir, args) {
|
|
|
2272
3558
|
const p = initPipeline({ isGreenfield, targetDir: projectDir });
|
|
2273
3559
|
pipelineAgents = Object.keys(p.agents);
|
|
2274
3560
|
}
|
|
3561
|
+
if (runtimeV2) pipelineAgents = pipelineAgents.filter(agent => V2_PLANNING_AGENTS.includes(agent));
|
|
2275
3562
|
|
|
2276
3563
|
// RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
|
|
2277
3564
|
// this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|| (s.mode && s.mode !== 'discover')
|
|
2284
|
-
|| (s.current_agent && s.current_agent !== '');
|
|
2285
|
-
return hasProgress;
|
|
2286
|
-
} catch { return false; }
|
|
3565
|
+
let isResume = existingSession.loaded && (() => {
|
|
3566
|
+
const s = existingSession.session || {};
|
|
3567
|
+
return (s.completed_agents && s.completed_agents.length > 0)
|
|
3568
|
+
|| (s.mode && s.mode !== 'discover')
|
|
3569
|
+
|| (s.current_agent && s.current_agent !== '');
|
|
2287
3570
|
})();
|
|
2288
3571
|
|
|
2289
3572
|
if (isResume) {
|
|
2290
3573
|
// Resume path — keep session.yaml intact, just refresh workflow if changed
|
|
2291
|
-
|
|
3574
|
+
const updated = updateSession(projectDir, { workflow });
|
|
3575
|
+
if (!updated.saved) return errorResult(`Failed to resume session: ${updated.error}`, 'SESSION_WRITE_FAILED');
|
|
2292
3576
|
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
|
|
2293
3577
|
} else {
|
|
2294
3578
|
// Fresh init — create new session
|
|
@@ -2300,34 +3584,73 @@ async function handleInit(projectDir, args) {
|
|
|
2300
3584
|
ides: preservedIdes,
|
|
2301
3585
|
mcps: preservedMcps,
|
|
2302
3586
|
providersEnabled: preservedProviders,
|
|
3587
|
+
runtimeVersion: runtimeV2 ? 2 : 1,
|
|
2303
3588
|
});
|
|
2304
3589
|
|
|
2305
3590
|
if (!result.created) {
|
|
2306
3591
|
return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
|
|
2307
3592
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
3593
|
+
if (result.preserved) {
|
|
3594
|
+
isResume = true;
|
|
3595
|
+
const updated = updateSession(projectDir, { workflow });
|
|
3596
|
+
if (!updated.saved) return errorResult(`Failed to resume preserved session: ${updated.error}`, 'SESSION_WRITE_FAILED');
|
|
3597
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow, concurrent_init_preserved: true });
|
|
3598
|
+
} else {
|
|
3599
|
+
const updated = updateSession(projectDir, { workflow });
|
|
3600
|
+
if (!updated.saved) return errorResult(`Failed to initialize workflow state: ${updated.error}`, 'SESSION_WRITE_FAILED');
|
|
3601
|
+
clearTimeline(projectDir);
|
|
3602
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
|
|
3603
|
+
}
|
|
2312
3604
|
}
|
|
2313
3605
|
|
|
2314
3606
|
// Determine first/active agent — on resume, use current_agent from session
|
|
2315
3607
|
let firstAgent;
|
|
3608
|
+
let firstAgentMutation = null;
|
|
2316
3609
|
if (isResume) {
|
|
2317
|
-
const
|
|
3610
|
+
const resumed = loadSession(projectDir);
|
|
3611
|
+
if (!resumed.loaded) return errorResult(`Failed to load resumed session: ${resumed.error}`, 'INVALID_SESSION_PATH');
|
|
3612
|
+
const s = resumed.session || {};
|
|
2318
3613
|
firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
|
|
2319
3614
|
} else {
|
|
2320
3615
|
firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
|
|
2321
|
-
|
|
3616
|
+
const updated = activateAgentExecutionState(projectDir, {
|
|
3617
|
+
agent: firstAgent,
|
|
3618
|
+
markInProgress: false,
|
|
3619
|
+
});
|
|
3620
|
+
if (!updated.saved) return errorResult(`Failed to select the first agent: ${updated.error}`, 'SESSION_WRITE_FAILED');
|
|
3621
|
+
firstAgentMutation = {
|
|
3622
|
+
expectedAgent: firstAgent,
|
|
3623
|
+
previous: updated.previous,
|
|
3624
|
+
};
|
|
2322
3625
|
}
|
|
2323
3626
|
const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
2324
3627
|
|
|
2325
3628
|
// Write session lock
|
|
2326
3629
|
try {
|
|
2327
|
-
writeSessionLock(projectDir, firstAgent);
|
|
3630
|
+
const projection = writeSessionLock(projectDir, firstAgent);
|
|
3631
|
+
if (!projection.projected) {
|
|
3632
|
+
if (firstAgentMutation) {
|
|
3633
|
+
const rollback = rollbackAgentDispatch(projectDir, firstAgentMutation);
|
|
3634
|
+
if (!rollback.saved || !rollback.rolledBack) {
|
|
3635
|
+
return errorResult(
|
|
3636
|
+
`Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
|
|
3637
|
+
rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return errorResult(`Native session lock projection failed: ${projection.error}`, projection.code || 'SESSION_PROJECTION_FAILED');
|
|
3642
|
+
}
|
|
2328
3643
|
} catch (err) {
|
|
2329
|
-
|
|
2330
|
-
|
|
3644
|
+
if (firstAgentMutation) {
|
|
3645
|
+
const rollback = rollbackAgentDispatch(projectDir, firstAgentMutation);
|
|
3646
|
+
if (!rollback.saved || !rollback.rolledBack) {
|
|
3647
|
+
return errorResult(
|
|
3648
|
+
`Native session lock projection failed (${err.message}); canonical rollback also failed: ${rollback.error}`,
|
|
3649
|
+
rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
|
|
3650
|
+
);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
return errorResult(`Native session lock projection failed: ${err.message}`, err.code || 'SESSION_PROJECTION_FAILED');
|
|
2331
3654
|
}
|
|
2332
3655
|
|
|
2333
3656
|
return {
|
|
@@ -2405,8 +3728,8 @@ async function handleStatus(projectDir) {
|
|
|
2405
3728
|
// bracket prism-engine last computed. If the session predates v1.2
|
|
2406
3729
|
// (no context_* fields yet), the block falls back to estimateContextBracket
|
|
2407
3730
|
// so the existing response shape stays stable for consumers.
|
|
2408
|
-
const activeModel = session.active_model || null;
|
|
2409
|
-
const activeProvider = session.
|
|
3731
|
+
const activeModel = session.current_execution_binding?.model || session.active_model || null;
|
|
3732
|
+
const activeProvider = session.current_execution_binding?.provider || session.active_provider || null;
|
|
2410
3733
|
const windowTokens = session.context_window_tokens != null
|
|
2411
3734
|
? session.context_window_tokens
|
|
2412
3735
|
: resolveContextLimit(activeModel, activeProvider);
|
|
@@ -2425,6 +3748,7 @@ async function handleStatus(projectDir) {
|
|
|
2425
3748
|
pipeline: progress,
|
|
2426
3749
|
handoff_history: handoffHistory,
|
|
2427
3750
|
deviation_count: (session.deviations || []).length,
|
|
3751
|
+
decision_trail: session.decision_trail || [],
|
|
2428
3752
|
backlog: session.backlog || [],
|
|
2429
3753
|
agents: Object.fromEntries(Object.entries(session.agents || {}).filter(([name]) => runtimeAgentNames.has(name))),
|
|
2430
3754
|
agent_models: agentModels,
|
|
@@ -2440,9 +3764,64 @@ async function handleStatus(projectDir) {
|
|
|
2440
3764
|
},
|
|
2441
3765
|
user_level: session.user_level || 'auto',
|
|
2442
3766
|
execution_mode: session.execution_mode || 'interactive',
|
|
3767
|
+
active_harness: session.active_harness || null,
|
|
3768
|
+
current_execution_binding: session.current_execution_binding || null,
|
|
3769
|
+
pending_interaction: session.pending_interaction || null,
|
|
2443
3770
|
};
|
|
2444
3771
|
}
|
|
2445
3772
|
|
|
3773
|
+
async function handleActivateHarness(projectDir, args) {
|
|
3774
|
+
const harness = args.harness;
|
|
3775
|
+
const installation = loadRuntimeInstallationV2(projectDir);
|
|
3776
|
+
const supported = installation
|
|
3777
|
+
? [...new Set(installation.installation.enabled_providers.map(({ harness_id }) => harness_id))]
|
|
3778
|
+
: parseProviderConfig(projectDir).enabled;
|
|
3779
|
+
if (!supported.includes(harness)) {
|
|
3780
|
+
const installed = supported.length > 0 ? supported.join(', ') : 'none';
|
|
3781
|
+
return errorResult(`Harness is not enabled by this installation. Enabled harnesses: ${installed}.`, 'INVALID_HARNESS');
|
|
3782
|
+
}
|
|
3783
|
+
const saved = transitionSessionLifecycle(projectDir, { activeHarness: harness, sessionStatus: 'active' });
|
|
3784
|
+
if (!saved.saved) {
|
|
3785
|
+
const code = ['Session file not found', 'Session directory not found'].includes(saved.error)
|
|
3786
|
+
? 'NO_SESSION'
|
|
3787
|
+
: 'SESSION_WRITE_FAILED';
|
|
3788
|
+
return errorResult(code === 'NO_SESSION' ? 'No session found' : saved.error, code);
|
|
3789
|
+
}
|
|
3790
|
+
const session = saved.session;
|
|
3791
|
+
const previous = saved.previous.active_harness;
|
|
3792
|
+
const wasActive = saved.previous.session_status === 'active';
|
|
3793
|
+
let projection;
|
|
3794
|
+
try {
|
|
3795
|
+
projection = writeSessionLock(projectDir, session.current_agent || '', {
|
|
3796
|
+
phase: session.mode,
|
|
3797
|
+
mode: session.execution_mode || 'interactive',
|
|
3798
|
+
});
|
|
3799
|
+
} catch (error) {
|
|
3800
|
+
projection = { projected: false, error: error.message, code: error.code };
|
|
3801
|
+
}
|
|
3802
|
+
if (!projection.projected) {
|
|
3803
|
+
const rollback = rollbackSessionLifecycle(projectDir, {
|
|
3804
|
+
expectedActiveHarness: harness,
|
|
3805
|
+
expectedSessionStatus: 'active',
|
|
3806
|
+
previous: saved.previous,
|
|
3807
|
+
});
|
|
3808
|
+
if (!rollback.saved || !rollback.rolledBack) {
|
|
3809
|
+
return errorResult(
|
|
3810
|
+
`Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
|
|
3811
|
+
rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
return errorResult(projection.error, projection.code || 'SESSION_PROJECTION_FAILED');
|
|
3815
|
+
}
|
|
3816
|
+
if (previous !== harness) {
|
|
3817
|
+
recordEvent(projectDir, EventType.HARNESS_ACTIVATED, 'orchestrator', { harness, previous_harness: previous });
|
|
3818
|
+
}
|
|
3819
|
+
if (previous !== harness || !wasActive) {
|
|
3820
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, session.current_agent || 'orchestrator', { harness, resumed: Boolean(previous) });
|
|
3821
|
+
}
|
|
3822
|
+
return { activated: true, harness, previous_harness: previous, language: session.language || 'en' };
|
|
3823
|
+
}
|
|
3824
|
+
|
|
2446
3825
|
async function handleDeviation(projectDir, args) {
|
|
2447
3826
|
const type = args.type;
|
|
2448
3827
|
const target = args.target || null;
|
|
@@ -2519,14 +3898,18 @@ async function handleDeviation(projectDir, args) {
|
|
|
2519
3898
|
}
|
|
2520
3899
|
|
|
2521
3900
|
async function handleExit(projectDir, args) {
|
|
2522
|
-
const
|
|
2523
|
-
if (!
|
|
3901
|
+
const persisted = transitionSessionLifecycle(projectDir, { sessionStatus: 'paused' });
|
|
3902
|
+
if (!persisted.saved && ['Session file not found', 'Session directory not found'].includes(persisted.error)) {
|
|
2524
3903
|
return { exited: true, session_saved: false, lock_removed: false, resume_message: null };
|
|
2525
3904
|
}
|
|
3905
|
+
if (!persisted.saved) {
|
|
3906
|
+
return { exited: false, session_saved: false, lock_removed: false, resume_message: null, error: persisted.error };
|
|
3907
|
+
}
|
|
3908
|
+
const session = persisted.previous;
|
|
2526
3909
|
|
|
2527
3910
|
// Save current state
|
|
2528
|
-
recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
|
|
2529
|
-
|
|
3911
|
+
recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode, harness: session.active_harness || null });
|
|
3912
|
+
releaseReservedAgentExecutions(projectDir);
|
|
2530
3913
|
|
|
2531
3914
|
// Release session ownership
|
|
2532
3915
|
const userId = args['user-id'] || process.env.USER || 'default';
|
|
@@ -2536,15 +3919,22 @@ async function handleExit(projectDir, args) {
|
|
|
2536
3919
|
|
|
2537
3920
|
// Remove lock from CLAUDE.local.md
|
|
2538
3921
|
const lang = session.language || 'en';
|
|
2539
|
-
const
|
|
3922
|
+
const genericResumeMsg = RESUME_MESSAGES[lang] || RESUME_MESSAGES.en;
|
|
3923
|
+
const resumeMsg = session.active_harness === 'codex'
|
|
3924
|
+
? genericResumeMsg.replaceAll('/chati', '$chati')
|
|
3925
|
+
: genericResumeMsg;
|
|
3926
|
+
let projection;
|
|
2540
3927
|
try {
|
|
2541
|
-
|
|
2542
|
-
|
|
3928
|
+
// Each lock target translates the generic command to its native syntax.
|
|
3929
|
+
// The returned message below remains native to the harness that exited.
|
|
3930
|
+
projection = removeSessionLock(projectDir, genericResumeMsg);
|
|
3931
|
+
} catch (error) { projection = { projected: false, error: error.message }; }
|
|
2543
3932
|
|
|
2544
3933
|
return {
|
|
2545
3934
|
exited: true,
|
|
2546
3935
|
session_saved: true,
|
|
2547
|
-
lock_removed:
|
|
3936
|
+
lock_removed: projection.projected,
|
|
3937
|
+
...(projection.projected ? {} : { lock_projection_error: projection.error }),
|
|
2548
3938
|
resume_message: resumeMsg,
|
|
2549
3939
|
language: lang,
|
|
2550
3940
|
};
|
|
@@ -2789,11 +4179,8 @@ export function isAgentTeamsEnabled(projectDir) {
|
|
|
2789
4179
|
|
|
2790
4180
|
// Never infer a primary harness. The legacy native team path is available
|
|
2791
4181
|
// only while the session explicitly records Claude as the active provider.
|
|
2792
|
-
const
|
|
2793
|
-
|
|
2794
|
-
const sessionRaw = readFileSync(sessionPath, 'utf-8');
|
|
2795
|
-
const providerMatch = sessionRaw.match(/^\s*active_provider:\s*["']?(\w+)/m);
|
|
2796
|
-
return providerMatch?.[1] === 'claude';
|
|
4182
|
+
const sessionRead = readSessionSafe(projectDir);
|
|
4183
|
+
return sessionRead.loaded && sessionRead.session?.active_provider === 'claude';
|
|
2797
4184
|
}
|
|
2798
4185
|
|
|
2799
4186
|
async function handleSpawnTeam(projectDir, args) {
|
|
@@ -2936,13 +4323,13 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
2936
4323
|
|
|
2937
4324
|
// Build spawn command
|
|
2938
4325
|
const spawnCmd = [
|
|
2939
|
-
'node', RUNNER('run-team.js'),
|
|
2940
|
-
'--team-id', teamId,
|
|
2941
|
-
'--team-type', teamType,
|
|
2942
|
-
'--project-dir',
|
|
2943
|
-
'--previous-agent', previousAgent,
|
|
2944
|
-
'--provider', provider,
|
|
2945
|
-
'--timeout', args.timeout || '1800000',
|
|
4326
|
+
'node', shellQuote(RUNNER('run-team.js')),
|
|
4327
|
+
'--team-id', shellQuote(teamId),
|
|
4328
|
+
'--team-type', shellQuote(teamType),
|
|
4329
|
+
'--project-dir', shellQuote(resolve(projectDir)),
|
|
4330
|
+
'--previous-agent', shellQuote(previousAgent),
|
|
4331
|
+
'--provider', shellQuote(provider),
|
|
4332
|
+
'--timeout', shellQuote(args.timeout || '1800000'),
|
|
2946
4333
|
].join(' ');
|
|
2947
4334
|
|
|
2948
4335
|
return {
|
|
@@ -3349,31 +4736,103 @@ export async function handleRunScriptTask(projectDir, args) {
|
|
|
3349
4736
|
return { ran: false, reason: 'The execution_kinds feature is off. Run this task through the agent path.', fallback: 'agent' };
|
|
3350
4737
|
}
|
|
3351
4738
|
|
|
3352
|
-
|
|
4739
|
+
let runtimeRecord;
|
|
4740
|
+
try {
|
|
4741
|
+
let request = args;
|
|
4742
|
+
let runtimeClaim = null;
|
|
4743
|
+
if (args['request-file']) {
|
|
4744
|
+
try {
|
|
4745
|
+
runtimeRecord = captureRuntimePath(projectDir, 'script-task-request.json');
|
|
4746
|
+
const requestedPath = isAbsolute(args['request-file'])
|
|
4747
|
+
? resolve(args['request-file'])
|
|
4748
|
+
: resolve(projectDir, args['request-file']);
|
|
4749
|
+
if (requestedPath !== runtimeRecord.target) {
|
|
4750
|
+
return scriptRequestError('INVALID_SCRIPT_TASK_REQUEST_PATH', 'The script task request file must be .chati/runtime/script-task-request.json.');
|
|
4751
|
+
}
|
|
4752
|
+
runtimeClaim = claimRuntimeRequest(runtimeRecord);
|
|
4753
|
+
request = readClaimedRuntimeJson(runtimeClaim);
|
|
4754
|
+
} catch (err) {
|
|
4755
|
+
runtimeClaim = runtimeClaim || err.runtimeClaim || null;
|
|
4756
|
+
if (runtimeClaim?.fd !== null && runtimeClaim?.fd !== undefined) {
|
|
4757
|
+
try { closeSync(runtimeClaim.fd); } catch { /* already closed */ }
|
|
4758
|
+
runtimeClaim.fd = null;
|
|
4759
|
+
}
|
|
4760
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* keep the claimed copy for manual recovery */ }
|
|
4761
|
+
const code = (err.code === 'ENOENT' && !runtimeClaim?.identity) || err.code === 'RUNTIME_REQUEST_IN_PROGRESS'
|
|
4762
|
+
? 'INVALID_SCRIPT_TASK_REQUEST_PATH'
|
|
4763
|
+
: 'INVALID_SCRIPT_TASK_REQUEST';
|
|
4764
|
+
return scriptRequestError(code, code === 'INVALID_SCRIPT_TASK_REQUEST_PATH'
|
|
4765
|
+
? 'The script task request file must be .chati/runtime/script-task-request.json.'
|
|
4766
|
+
: 'The script task request file is invalid or changed during validation.');
|
|
4767
|
+
} finally {
|
|
4768
|
+
if (runtimeClaim?.fd !== null && runtimeClaim?.fd !== undefined) {
|
|
4769
|
+
try { closeSync(runtimeClaim.fd); } catch { /* already closed */ }
|
|
4770
|
+
runtimeClaim.fd = null;
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
if (!validateScriptRequestSchema(request) && !isExecutedScriptReceipt(projectDir, request)) {
|
|
4774
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
|
|
4775
|
+
return scriptRequestError('INVALID_SCRIPT_TASK_REQUEST_SCHEMA', 'The script task request does not match the required kind/ref/id/title schema.');
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
let executionReceipt = isExecutedScriptReceipt(projectDir, request) ? request : null;
|
|
4780
|
+
if (executionReceipt) request = executionReceipt.request;
|
|
4781
|
+
|
|
4782
|
+
const kind = String(request.kind || 'script');
|
|
3353
4783
|
if (kind !== 'script') {
|
|
3354
|
-
|
|
4784
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
|
|
4785
|
+
return scriptRequestError('UNSUPPORTED_EXECUTION_KIND', `execution_kind "${kind}" is not auto-run by the script runner (only "script" is).`);
|
|
3355
4786
|
}
|
|
3356
4787
|
|
|
3357
|
-
const ref =
|
|
4788
|
+
const ref = request.ref || '';
|
|
3358
4789
|
if (!ref) {
|
|
3359
|
-
|
|
4790
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
|
|
4791
|
+
return scriptRequestError('MISSING_EXECUTION_REF', 'Missing --ref (the execution_ref, for example "npm-script:test").');
|
|
3360
4792
|
}
|
|
3361
4793
|
|
|
3362
|
-
const id =
|
|
3363
|
-
const title =
|
|
3364
|
-
|
|
4794
|
+
const id = request.id || request.task || '';
|
|
4795
|
+
const title = request.title || '';
|
|
4796
|
+
let result = executionReceipt?.result;
|
|
4797
|
+
let timestamp = executionReceipt?.executed_at;
|
|
4798
|
+
if (!executionReceipt) {
|
|
4799
|
+
try {
|
|
4800
|
+
result = await runScriptTask(projectDir, { id, title, execution_ref: ref });
|
|
4801
|
+
} catch {
|
|
4802
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
|
|
4803
|
+
return scriptRequestError('SCRIPT_TASK_EXECUTION_FAILED', 'The script task executor failed before producing a result. The request was preserved for retry.');
|
|
4804
|
+
}
|
|
4805
|
+
timestamp = new Date().toISOString();
|
|
4806
|
+
if (runtimeClaim) {
|
|
4807
|
+
try {
|
|
4808
|
+
executionReceipt = persistExecutedRuntimeRequest(runtimeRecord, runtimeClaim, request, result, timestamp);
|
|
4809
|
+
} catch {
|
|
4810
|
+
return scriptRequestError('SCRIPT_TASK_EXECUTION_RECEIPT_FAILED', 'The script task ran, but its durable execution receipt could not be persisted. The claimed request was quarantined to prevent automatic re-execution.');
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
3365
4814
|
|
|
3366
4815
|
// Write a synthetic handoff so the pipeline reads it like an agent handoff.
|
|
3367
|
-
const timestamp = new Date().toISOString();
|
|
3368
4816
|
const handoff = formatScriptHandoff({ id, title }, result, timestamp);
|
|
3369
|
-
let handoffPath
|
|
4817
|
+
let handoffPath;
|
|
3370
4818
|
try {
|
|
3371
4819
|
const dir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
|
|
3372
|
-
mkdirSync(dir, { recursive: true });
|
|
3373
4820
|
handoffPath = join(dir, `script-${(id || 'task').replace(/[^A-Za-z0-9._-]/g, '_')}.md`);
|
|
3374
|
-
|
|
4821
|
+
writeDurableScriptHandoff(handoffPath, handoff);
|
|
4822
|
+
runRuntimeSecurityTestHook('afterScriptHandoffDurable', { handoffPath, runtimeClaimPath: runtimeClaim?.claimPath });
|
|
3375
4823
|
} catch {
|
|
3376
|
-
|
|
4824
|
+
try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
|
|
4825
|
+
return scriptRequestError('SCRIPT_TASK_HANDOFF_FAILED', 'The script task ran, but its handoff could not be persisted. Its execution receipt was preserved so retry will only persist the handoff.');
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
if (runtimeClaim) {
|
|
4829
|
+
try {
|
|
4830
|
+
if (!consumeRuntimeRequest(runtimeClaim)) {
|
|
4831
|
+
return scriptRequestError('SCRIPT_TASK_REQUEST_FINALIZATION_FAILED', 'The script task ran and its handoff was persisted, but the claimed request changed before finalization.');
|
|
4832
|
+
}
|
|
4833
|
+
} catch {
|
|
4834
|
+
return scriptRequestError('SCRIPT_TASK_REQUEST_FINALIZATION_FAILED', 'The script task ran and its handoff was persisted, but the claimed request could not be finalized.');
|
|
4835
|
+
}
|
|
3377
4836
|
}
|
|
3378
4837
|
|
|
3379
4838
|
return {
|
|
@@ -3386,6 +4845,9 @@ export async function handleRunScriptTask(projectDir, args) {
|
|
|
3386
4845
|
handoffPath,
|
|
3387
4846
|
details: result.details,
|
|
3388
4847
|
};
|
|
4848
|
+
} finally {
|
|
4849
|
+
closeRuntimeRecord(runtimeRecord);
|
|
4850
|
+
}
|
|
3389
4851
|
}
|
|
3390
4852
|
|
|
3391
4853
|
/**
|
|
@@ -3561,6 +5023,9 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
3561
5023
|
case 'exit':
|
|
3562
5024
|
output(await handleExit(projectDir, args));
|
|
3563
5025
|
break;
|
|
5026
|
+
case 'activate-harness':
|
|
5027
|
+
output(await handleActivateHarness(projectDir, args));
|
|
5028
|
+
break;
|
|
3564
5029
|
case 'providers':
|
|
3565
5030
|
output(await handleProviders(projectDir));
|
|
3566
5031
|
break;
|
|
@@ -3614,7 +5079,7 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
3614
5079
|
break;
|
|
3615
5080
|
}
|
|
3616
5081
|
} catch (err) {
|
|
3617
|
-
output(errorResult(`Orchestrate error: ${err.message}`, 'INTERNAL_ERROR'));
|
|
5082
|
+
output(errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR'));
|
|
3618
5083
|
process.exitCode = 1;
|
|
3619
5084
|
}
|
|
3620
5085
|
}
|