chati-dev 4.5.16 → 4.5.28
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 +1 -1
- 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 +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -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 +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- 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 +247 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- 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 +1167 -53
- 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 +9 -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 +384 -24
- 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/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
|
+
}
|
|
1446
|
+
|
|
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
|
+
}
|
|
736
1462
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
// the lock to drift ahead of session.yaml (e.g. show "build" while
|
|
741
|
-
// session.mode was still "plan" awaiting approval).
|
|
742
|
-
let authoritativePhase = null;
|
|
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,6 +1733,7 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
798
1733
|
|
|
799
1734
|
async function handleNext(projectDir) {
|
|
800
1735
|
const result = await _handleNextInner(projectDir);
|
|
1736
|
+
let dispatchMutation = null;
|
|
801
1737
|
|
|
802
1738
|
// The canonical session owns the active task. Native harness lock files are
|
|
803
1739
|
// projections only, so a CLI switch cannot create a second execution.
|
|
@@ -817,28 +1753,50 @@ async function handleNext(projectDir) {
|
|
|
817
1753
|
reasoning_configuration: result.model_info.reasoning_configuration || null,
|
|
818
1754
|
catalog_snapshot_ref: result.model_info.catalog_snapshot_ref || null,
|
|
819
1755
|
} : null;
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
recordEvent(projectDir, EventType.AGENT_ACTIVATED, result.agent, { action: result.action, phase: result.phase });
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// Fase 8: attach state-at-a-glance card to every non-error response.
|
|
832
|
-
// The card renders as a JSON string field; the terminal / orchestrator
|
|
833
|
-
// prompt can console.log it verbatim. Silent on setup/error responses
|
|
834
|
-
// so a missing session does not produce a misleading card.
|
|
835
|
-
if (result && result.action !== 'setup' && result.action !== 'error') {
|
|
836
|
-
try {
|
|
837
|
-
const { session } = loadSession(projectDir);
|
|
838
|
-
if (session) {
|
|
839
|
-
result.state_card = buildStateCard({ session, projectDir });
|
|
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');
|
|
840
1763
|
}
|
|
841
|
-
|
|
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
|
+
}
|
|
842
1800
|
}
|
|
843
1801
|
|
|
844
1802
|
// Centralized session lock update: applies to ALL return paths.
|
|
@@ -846,31 +1804,77 @@ async function handleNext(projectDir) {
|
|
|
846
1804
|
if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
|
|
847
1805
|
const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel', 'resume_pending_interaction'];
|
|
848
1806
|
if (activating.includes(result.action)) {
|
|
1807
|
+
let projection;
|
|
849
1808
|
try {
|
|
850
1809
|
const runtimePipeline = getRuntimeAgentPipeline(projectDir, result.session);
|
|
851
|
-
writeSessionLock(projectDir, result.agent, {
|
|
1810
|
+
projection = writeSessionLock(projectDir, result.agent, {
|
|
852
1811
|
phase: result.phase || 'discover',
|
|
853
1812
|
mode: result.session?.execution_mode || 'interactive',
|
|
854
1813
|
position: result.pipeline_progress?.completedAgents?.length ?? 0,
|
|
855
1814
|
total: runtimePipeline.length,
|
|
856
1815
|
progress: result.pipeline_progress?.progress ?? 0,
|
|
857
1816
|
});
|
|
858
|
-
} 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
|
+
}
|
|
859
1835
|
}
|
|
860
1836
|
} else if (result && result.action === 'complete') {
|
|
1837
|
+
let projection;
|
|
861
1838
|
try {
|
|
862
1839
|
const { session } = loadSession(projectDir);
|
|
863
1840
|
const lang = session?.language || 'en';
|
|
864
|
-
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
865
|
-
} 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 */ }
|
|
866
1864
|
}
|
|
867
1865
|
|
|
868
1866
|
return result;
|
|
869
1867
|
}
|
|
870
1868
|
|
|
871
1869
|
async function _handleNextInner(projectDir) {
|
|
872
|
-
const { loaded, session } = loadSession(projectDir);
|
|
1870
|
+
const { loaded, session, error } = loadSession(projectDir);
|
|
873
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
|
+
}
|
|
874
1878
|
return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
|
|
875
1879
|
}
|
|
876
1880
|
|
|
@@ -883,9 +1887,118 @@ async function _handleNextInner(projectDir) {
|
|
|
883
1887
|
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
884
1888
|
|
|
885
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
|
+
}
|
|
886
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
|
+
}
|
|
887
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
|
+
}
|
|
888
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
|
+
);
|
|
889
2002
|
return {
|
|
890
2003
|
action: 'resume_pending_interaction',
|
|
891
2004
|
agent: pending.agent,
|
|
@@ -893,16 +2006,13 @@ async function _handleNextInner(projectDir) {
|
|
|
893
2006
|
phase: agentDef?.phase || session.mode,
|
|
894
2007
|
question: pending.question,
|
|
895
2008
|
relay_cycles: pending.relay_cycles || 1,
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
Boolean(installationV2),
|
|
904
|
-
binding,
|
|
905
|
-
),
|
|
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,
|
|
906
2016
|
model_info: binding,
|
|
907
2017
|
redispatched: false,
|
|
908
2018
|
pipeline_progress: getPipelineProgress(sessionToPipelineState(session, projectDir)),
|
|
@@ -962,6 +2072,16 @@ async function _handleNextInner(projectDir) {
|
|
|
962
2072
|
if (currentAgent && !completedAgents.includes(currentAgent)) {
|
|
963
2073
|
const agent = currentAgent;
|
|
964
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
|
+
}
|
|
965
2085
|
if (agentStatus === 'in_progress') {
|
|
966
2086
|
const agentFile = getAgentFile(agent, projectDir) || null;
|
|
967
2087
|
const agentDef = getAgentDefinition(agent);
|
|
@@ -1069,7 +2189,56 @@ async function _handleNextInner(projectDir) {
|
|
|
1069
2189
|
};
|
|
1070
2190
|
}
|
|
1071
2191
|
|
|
1072
|
-
|
|
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
|
+
}
|
|
1073
2242
|
const agentFile = getAgentFile(nextAgent, projectDir) || null;
|
|
1074
2243
|
const agentDef = getAgentDefinition(nextAgent);
|
|
1075
2244
|
const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
|
|
@@ -1465,6 +2634,50 @@ async function handleAdvance(projectDir, args) {
|
|
|
1465
2634
|
const { loaded, session } = loadSession(projectDir);
|
|
1466
2635
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
1467
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
|
+
|
|
1468
2681
|
// Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
|
|
1469
2682
|
// dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
|
|
1470
2683
|
// pending scaffold decision after qa-planning is marked complete.
|
|
@@ -1476,7 +2689,6 @@ async function handleAdvance(projectDir, args) {
|
|
|
1476
2689
|
// Idempotency guard: if this agent is already marked completed, return no-op.
|
|
1477
2690
|
// Prevents duplicate advance calls (hook + user-approval path) from double-
|
|
1478
2691
|
// incrementing pipeline_position or corrupting completed_agents.
|
|
1479
|
-
const agentState = session.agents && session.agents[agent];
|
|
1480
2692
|
const alreadyCompleted =
|
|
1481
2693
|
agentState && agentState.status === 'completed' &&
|
|
1482
2694
|
Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
|
|
@@ -1492,6 +2704,13 @@ async function handleAdvance(projectDir, args) {
|
|
|
1492
2704
|
};
|
|
1493
2705
|
}
|
|
1494
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
|
+
|
|
1495
2714
|
// Handle user_preview decisions
|
|
1496
2715
|
if (decision) {
|
|
1497
2716
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
@@ -2298,24 +3517,28 @@ async function handleInit(projectDir, args) {
|
|
|
2298
3517
|
} catch { /* expected: license file may not exist */ }
|
|
2299
3518
|
|
|
2300
3519
|
const type = args.type || 'greenfield';
|
|
2301
|
-
|
|
3520
|
+
let language = args.language || null;
|
|
2302
3521
|
const workflow = args.workflow || 'full';
|
|
3522
|
+
const runtimeV2 = Boolean(loadRuntimeInstallationV2(projectDir));
|
|
2303
3523
|
|
|
2304
|
-
// Preserve fields from session
|
|
3524
|
+
// Preserve fields from the canonical session created by the installer.
|
|
2305
3525
|
let preservedName = args.name || '';
|
|
2306
3526
|
let preservedIdes = [];
|
|
2307
3527
|
let preservedMcps = [];
|
|
2308
3528
|
let preservedProviders = [];
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
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';
|
|
2319
3542
|
if (preservedProviders.length === 0) {
|
|
2320
3543
|
preservedProviders = parseProviderConfig(projectDir).enabled;
|
|
2321
3544
|
}
|
|
@@ -2335,23 +3558,21 @@ async function handleInit(projectDir, args) {
|
|
|
2335
3558
|
const p = initPipeline({ isGreenfield, targetDir: projectDir });
|
|
2336
3559
|
pipelineAgents = Object.keys(p.agents);
|
|
2337
3560
|
}
|
|
3561
|
+
if (runtimeV2) pipelineAgents = pipelineAgents.filter(agent => V2_PLANNING_AGENTS.includes(agent));
|
|
2338
3562
|
|
|
2339
3563
|
// RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
|
|
2340
3564
|
// this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|| (s.mode && s.mode !== 'discover')
|
|
2347
|
-
|| (s.current_agent && s.current_agent !== '');
|
|
2348
|
-
return hasProgress;
|
|
2349
|
-
} 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 !== '');
|
|
2350
3570
|
})();
|
|
2351
3571
|
|
|
2352
3572
|
if (isResume) {
|
|
2353
3573
|
// Resume path — keep session.yaml intact, just refresh workflow if changed
|
|
2354
|
-
|
|
3574
|
+
const updated = updateSession(projectDir, { workflow });
|
|
3575
|
+
if (!updated.saved) return errorResult(`Failed to resume session: ${updated.error}`, 'SESSION_WRITE_FAILED');
|
|
2355
3576
|
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
|
|
2356
3577
|
} else {
|
|
2357
3578
|
// Fresh init — create new session
|
|
@@ -2363,34 +3584,73 @@ async function handleInit(projectDir, args) {
|
|
|
2363
3584
|
ides: preservedIdes,
|
|
2364
3585
|
mcps: preservedMcps,
|
|
2365
3586
|
providersEnabled: preservedProviders,
|
|
3587
|
+
runtimeVersion: runtimeV2 ? 2 : 1,
|
|
2366
3588
|
});
|
|
2367
3589
|
|
|
2368
3590
|
if (!result.created) {
|
|
2369
3591
|
return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
|
|
2370
3592
|
}
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
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
|
+
}
|
|
2375
3604
|
}
|
|
2376
3605
|
|
|
2377
3606
|
// Determine first/active agent — on resume, use current_agent from session
|
|
2378
3607
|
let firstAgent;
|
|
3608
|
+
let firstAgentMutation = null;
|
|
2379
3609
|
if (isResume) {
|
|
2380
|
-
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 || {};
|
|
2381
3613
|
firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
|
|
2382
3614
|
} else {
|
|
2383
3615
|
firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
|
|
2384
|
-
|
|
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
|
+
};
|
|
2385
3625
|
}
|
|
2386
3626
|
const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
2387
3627
|
|
|
2388
3628
|
// Write session lock
|
|
2389
3629
|
try {
|
|
2390
|
-
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
|
+
}
|
|
2391
3643
|
} catch (err) {
|
|
2392
|
-
|
|
2393
|
-
|
|
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');
|
|
2394
3654
|
}
|
|
2395
3655
|
|
|
2396
3656
|
return {
|
|
@@ -2488,6 +3748,7 @@ async function handleStatus(projectDir) {
|
|
|
2488
3748
|
pipeline: progress,
|
|
2489
3749
|
handoff_history: handoffHistory,
|
|
2490
3750
|
deviation_count: (session.deviations || []).length,
|
|
3751
|
+
decision_trail: session.decision_trail || [],
|
|
2491
3752
|
backlog: session.backlog || [],
|
|
2492
3753
|
agents: Object.fromEntries(Object.entries(session.agents || {}).filter(([name]) => runtimeAgentNames.has(name))),
|
|
2493
3754
|
agent_models: agentModels,
|
|
@@ -2511,15 +3772,47 @@ async function handleStatus(projectDir) {
|
|
|
2511
3772
|
|
|
2512
3773
|
async function handleActivateHarness(projectDir, args) {
|
|
2513
3774
|
const harness = args.harness;
|
|
2514
|
-
|
|
2515
|
-
|
|
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');
|
|
2516
3815
|
}
|
|
2517
|
-
const { loaded, session } = loadSession(projectDir);
|
|
2518
|
-
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
2519
|
-
const previous = session.active_harness || null;
|
|
2520
|
-
const wasActive = session.session_status === 'active';
|
|
2521
|
-
const saved = updateSession(projectDir, { active_harness: harness, session_status: 'active' });
|
|
2522
|
-
if (!saved.saved) return errorResult(saved.error, 'SESSION_WRITE_FAILED');
|
|
2523
3816
|
if (previous !== harness) {
|
|
2524
3817
|
recordEvent(projectDir, EventType.HARNESS_ACTIVATED, 'orchestrator', { harness, previous_harness: previous });
|
|
2525
3818
|
}
|
|
@@ -2605,14 +3898,18 @@ async function handleDeviation(projectDir, args) {
|
|
|
2605
3898
|
}
|
|
2606
3899
|
|
|
2607
3900
|
async function handleExit(projectDir, args) {
|
|
2608
|
-
const
|
|
2609
|
-
if (!
|
|
3901
|
+
const persisted = transitionSessionLifecycle(projectDir, { sessionStatus: 'paused' });
|
|
3902
|
+
if (!persisted.saved && ['Session file not found', 'Session directory not found'].includes(persisted.error)) {
|
|
2610
3903
|
return { exited: true, session_saved: false, lock_removed: false, resume_message: null };
|
|
2611
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;
|
|
2612
3909
|
|
|
2613
3910
|
// Save current state
|
|
2614
3911
|
recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode, harness: session.active_harness || null });
|
|
2615
|
-
|
|
3912
|
+
releaseReservedAgentExecutions(projectDir);
|
|
2616
3913
|
|
|
2617
3914
|
// Release session ownership
|
|
2618
3915
|
const userId = args['user-id'] || process.env.USER || 'default';
|
|
@@ -2626,16 +3923,18 @@ async function handleExit(projectDir, args) {
|
|
|
2626
3923
|
const resumeMsg = session.active_harness === 'codex'
|
|
2627
3924
|
? genericResumeMsg.replaceAll('/chati', '$chati')
|
|
2628
3925
|
: genericResumeMsg;
|
|
3926
|
+
let projection;
|
|
2629
3927
|
try {
|
|
2630
3928
|
// Each lock target translates the generic command to its native syntax.
|
|
2631
3929
|
// The returned message below remains native to the harness that exited.
|
|
2632
|
-
removeSessionLock(projectDir, genericResumeMsg);
|
|
2633
|
-
} catch {
|
|
3930
|
+
projection = removeSessionLock(projectDir, genericResumeMsg);
|
|
3931
|
+
} catch (error) { projection = { projected: false, error: error.message }; }
|
|
2634
3932
|
|
|
2635
3933
|
return {
|
|
2636
3934
|
exited: true,
|
|
2637
3935
|
session_saved: true,
|
|
2638
|
-
lock_removed:
|
|
3936
|
+
lock_removed: projection.projected,
|
|
3937
|
+
...(projection.projected ? {} : { lock_projection_error: projection.error }),
|
|
2639
3938
|
resume_message: resumeMsg,
|
|
2640
3939
|
language: lang,
|
|
2641
3940
|
};
|
|
@@ -2880,11 +4179,8 @@ export function isAgentTeamsEnabled(projectDir) {
|
|
|
2880
4179
|
|
|
2881
4180
|
// Never infer a primary harness. The legacy native team path is available
|
|
2882
4181
|
// only while the session explicitly records Claude as the active provider.
|
|
2883
|
-
const
|
|
2884
|
-
|
|
2885
|
-
const sessionRaw = readFileSync(sessionPath, 'utf-8');
|
|
2886
|
-
const providerMatch = sessionRaw.match(/^\s*active_provider:\s*["']?(\w+)/m);
|
|
2887
|
-
return providerMatch?.[1] === 'claude';
|
|
4182
|
+
const sessionRead = readSessionSafe(projectDir);
|
|
4183
|
+
return sessionRead.loaded && sessionRead.session?.active_provider === 'claude';
|
|
2888
4184
|
}
|
|
2889
4185
|
|
|
2890
4186
|
async function handleSpawnTeam(projectDir, args) {
|
|
@@ -3027,13 +4323,13 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
3027
4323
|
|
|
3028
4324
|
// Build spawn command
|
|
3029
4325
|
const spawnCmd = [
|
|
3030
|
-
'node', RUNNER('run-team.js'),
|
|
3031
|
-
'--team-id', teamId,
|
|
3032
|
-
'--team-type', teamType,
|
|
3033
|
-
'--project-dir',
|
|
3034
|
-
'--previous-agent', previousAgent,
|
|
3035
|
-
'--provider', provider,
|
|
3036
|
-
'--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'),
|
|
3037
4333
|
].join(' ');
|
|
3038
4334
|
|
|
3039
4335
|
return {
|
|
@@ -3440,31 +4736,103 @@ export async function handleRunScriptTask(projectDir, args) {
|
|
|
3440
4736
|
return { ran: false, reason: 'The execution_kinds feature is off. Run this task through the agent path.', fallback: 'agent' };
|
|
3441
4737
|
}
|
|
3442
4738
|
|
|
3443
|
-
|
|
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');
|
|
3444
4783
|
if (kind !== 'script') {
|
|
3445
|
-
|
|
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).`);
|
|
3446
4786
|
}
|
|
3447
4787
|
|
|
3448
|
-
const ref =
|
|
4788
|
+
const ref = request.ref || '';
|
|
3449
4789
|
if (!ref) {
|
|
3450
|
-
|
|
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").');
|
|
3451
4792
|
}
|
|
3452
4793
|
|
|
3453
|
-
const id =
|
|
3454
|
-
const title =
|
|
3455
|
-
|
|
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
|
+
}
|
|
3456
4814
|
|
|
3457
4815
|
// Write a synthetic handoff so the pipeline reads it like an agent handoff.
|
|
3458
|
-
const timestamp = new Date().toISOString();
|
|
3459
4816
|
const handoff = formatScriptHandoff({ id, title }, result, timestamp);
|
|
3460
|
-
let handoffPath
|
|
4817
|
+
let handoffPath;
|
|
3461
4818
|
try {
|
|
3462
4819
|
const dir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
|
|
3463
|
-
mkdirSync(dir, { recursive: true });
|
|
3464
4820
|
handoffPath = join(dir, `script-${(id || 'task').replace(/[^A-Za-z0-9._-]/g, '_')}.md`);
|
|
3465
|
-
|
|
4821
|
+
writeDurableScriptHandoff(handoffPath, handoff);
|
|
4822
|
+
runRuntimeSecurityTestHook('afterScriptHandoffDurable', { handoffPath, runtimeClaimPath: runtimeClaim?.claimPath });
|
|
3466
4823
|
} catch {
|
|
3467
|
-
|
|
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
|
+
}
|
|
3468
4836
|
}
|
|
3469
4837
|
|
|
3470
4838
|
return {
|
|
@@ -3477,6 +4845,9 @@ export async function handleRunScriptTask(projectDir, args) {
|
|
|
3477
4845
|
handoffPath,
|
|
3478
4846
|
details: result.details,
|
|
3479
4847
|
};
|
|
4848
|
+
} finally {
|
|
4849
|
+
closeRuntimeRecord(runtimeRecord);
|
|
4850
|
+
}
|
|
3480
4851
|
}
|
|
3481
4852
|
|
|
3482
4853
|
/**
|
|
@@ -3708,7 +5079,7 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
3708
5079
|
break;
|
|
3709
5080
|
}
|
|
3710
5081
|
} catch (err) {
|
|
3711
|
-
output(errorResult(`Orchestrate error: ${err.message}`, 'INTERNAL_ERROR'));
|
|
5082
|
+
output(errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR'));
|
|
3712
5083
|
process.exitCode = 1;
|
|
3713
5084
|
}
|
|
3714
5085
|
}
|