atris 3.34.0 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +35 -0
- package/FOR_AGENTS.md +5 -3
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +35 -8
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +6 -2
- package/ax +617 -31
- package/bin/atris.js +719 -266
- package/commands/activate.js +194 -88
- package/commands/aeo.js +52 -0
- package/commands/agents.js +166 -0
- package/commands/autoland.js +718 -72
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/business.js +91 -8
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/codex-goal.js +26 -2
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/drive.js +187 -0
- package/commands/engine.js +1061 -29
- package/commands/experiments.js +28 -0
- package/commands/feed.js +202 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +285 -3
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +83 -43
- package/commands/integrations.js +39 -11
- package/commands/interview.js +209 -0
- package/commands/land.js +253 -52
- package/commands/lesson.js +112 -1
- package/commands/lifecycle.js +39 -3
- package/commands/linear.js +38 -0
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +899 -66
- package/commands/mission.js +3512 -332
- package/commands/next.js +137 -0
- package/commands/now.js +240 -21
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/radar.js +259 -14
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/serve.js +54 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/status.js +50 -5
- package/commands/stream.js +861 -0
- package/commands/stripe.js +38 -0
- package/commands/study.js +693 -0
- package/commands/supabase.js +39 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +2275 -182
- package/commands/team.js +73 -0
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +299 -20
- package/commands/write.js +399 -0
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +391 -43
- package/lib/autoland.js +353 -52
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2431 -38
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/memory-view.js +14 -5
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/next-moves.js +327 -10
- package/lib/official-cli-integration.js +174 -0
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/review-integrity.js +147 -0
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +33 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +319 -27
- package/lib/task-proof.js +43 -1
- package/lib/task-receipt.js +93 -0
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
package/lib/fleet.js
CHANGED
|
@@ -12,13 +12,28 @@
|
|
|
12
12
|
// is just missions in flight, resumable the normal way.
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const os = require('os');
|
|
15
17
|
const path = require('path');
|
|
16
18
|
const { spawnSync } = require('child_process');
|
|
19
|
+
const {
|
|
20
|
+
appendBriefRecord,
|
|
21
|
+
mirrorBriefRecord,
|
|
22
|
+
stampBriefOutcome,
|
|
23
|
+
worktreeBaseRef,
|
|
24
|
+
} = require('./brief-ledger');
|
|
17
25
|
const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
|
|
26
|
+
const { resolveDefaultVerifier } = require('./default-verifier');
|
|
27
|
+
const { rankEngines } = require('./router-brain');
|
|
28
|
+
const {
|
|
29
|
+
buildOneLapValidatorPrompt,
|
|
30
|
+
parseOneLapValidatorVerdict,
|
|
31
|
+
} = require('./one-lap-validator');
|
|
32
|
+
const { listWorktrees } = require('../commands/worktree');
|
|
18
33
|
|
|
19
34
|
// Lanes a fleet may never staff on its own: the human keeps irreversible
|
|
20
35
|
// calls. Mirrors the autoland denied lanes.
|
|
21
|
-
const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback'
|
|
36
|
+
const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback'];
|
|
22
37
|
|
|
23
38
|
// ---------------------------------------------------------------------------
|
|
24
39
|
// T1 — dispatch primitive
|
|
@@ -36,26 +51,45 @@ function parseDoneCheck(text) {
|
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
// Working-method kernel every dispatched engine inherits, distilled from
|
|
55
|
+
// atris/skills/fable-method: verify unpiped, receipts, caller sweeps,
|
|
56
|
+
// hypothesis-driven unsticking, smallest diff.
|
|
57
|
+
const METHOD_KERNEL = [
|
|
58
|
+
'Read this whole brief before acting; locate every file you will touch before editing any of them.',
|
|
59
|
+
'Never pipe a verify or test command through tail/head/grep — run it bare and read the real exit code.',
|
|
60
|
+
'Done requires a receipt: paste the exact verify command and its final output lines in your report.',
|
|
61
|
+
'Before deleting or renaming any function or call site, grep the whole repo for its callers; a caller outside your change means the contract stays intact.',
|
|
62
|
+
'Stuck? Never run the same failing command a third time — write 3 one-line hypotheses, then run the cheapest test that discriminates between them.',
|
|
63
|
+
'Smallest diff that satisfies Done wins; prefer deleting code over adding it.',
|
|
64
|
+
];
|
|
65
|
+
|
|
39
66
|
// The bounded prompt every engine gets. Same contract the manual flight used:
|
|
40
67
|
// isolated worktree, commit never push, MAP first, focused verify, report.
|
|
41
|
-
function buildFleetPrompt(task, { worktreePath } = {}) {
|
|
68
|
+
function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
42
69
|
const ref = task.display_id || task.id || 'TASK';
|
|
43
70
|
const title = String(task.title || '').trim();
|
|
44
|
-
const { done, check } = parseDoneCheck(title);
|
|
71
|
+
const { done, check: declaredCheck } = parseDoneCheck(title);
|
|
72
|
+
const check = declaredCheck || resolveDefaultVerifier(worktreePath || process.cwd());
|
|
73
|
+
const commitRule = yolo
|
|
74
|
+
? 'Commit on the current branch with a plain-English message, then land it yourself: run atris worktree ship --message "<msg>" --verify "npm run test:fast && node --test <focused files>" --merge and report the PR URL. If ship reports a rebase conflict or the verify fails, stop and report; never resolve conflicts yourself.'
|
|
75
|
+
: 'Commit on the current branch with a clear message. Do not push. Do not create branches.';
|
|
45
76
|
const lines = [
|
|
77
|
+
'First, run `atris worktree guard`; if it fails, stop immediately, report back, and do not edit anything. Do this before any file edit.',
|
|
78
|
+
'',
|
|
46
79
|
`You are working task ${ref} in this repo checkout (an isolated git worktree${worktreePath ? ` at ${worktreePath}` : ''} — commit here, NEVER push).`,
|
|
47
80
|
'',
|
|
48
81
|
`Task: ${title}`,
|
|
49
82
|
'',
|
|
50
83
|
];
|
|
51
84
|
if (done) lines.push(`Done criteria: ${done}`, '');
|
|
52
|
-
|
|
85
|
+
lines.push(`Check: ${check}`, '');
|
|
53
86
|
lines.push(
|
|
54
87
|
'Rules:',
|
|
55
88
|
'- Read atris/MAP.md first to locate the code; never guess file locations.',
|
|
56
89
|
'- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
|
|
57
|
-
'- Include or update a focused regression test; run it with node --test before committing.',
|
|
58
|
-
|
|
90
|
+
'- Include or update a focused regression test; run it with npm run test:fast && node --test <focused files> before committing.',
|
|
91
|
+
`- ${commitRule}`,
|
|
92
|
+
...METHOD_KERNEL.map((rule) => `- ${rule}`),
|
|
59
93
|
'',
|
|
60
94
|
'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
|
|
61
95
|
);
|
|
@@ -69,17 +103,63 @@ function buildFleetPrompt(task, { worktreePath } = {}) {
|
|
|
69
103
|
// spawn; template engines (cursor/codex/devin) ignore it — their CLIs manage
|
|
70
104
|
// their own permissions.
|
|
71
105
|
const FLEET_ALLOWED_TOOLS = 'Bash,Read,Edit,Write,Grep,Glob';
|
|
106
|
+
const VALIDATOR_ALLOWED_TOOLS = 'Bash,Read,Grep,Glob';
|
|
107
|
+
const DEAD_ENGINE_OUTPUT_PATTERNS = Object.freeze([
|
|
108
|
+
'usage limit',
|
|
109
|
+
'purchase more credits',
|
|
110
|
+
'rate limit',
|
|
111
|
+
]);
|
|
112
|
+
const YOLO_ENGINE_FLAGS = Object.freeze({
|
|
113
|
+
codex: '--dangerously-bypass-approvals-and-sandbox',
|
|
114
|
+
claude: '--dangerously-skip-permissions',
|
|
115
|
+
});
|
|
116
|
+
const DISPATCH_SELF_LAND_TARGET = 'origin/master';
|
|
117
|
+
|
|
118
|
+
function realpathOrResolve(value) {
|
|
119
|
+
const resolved = path.resolve(String(value));
|
|
120
|
+
try {
|
|
121
|
+
return fs.realpathSync(resolved);
|
|
122
|
+
} catch {
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
72
126
|
|
|
73
|
-
function
|
|
127
|
+
function assertIsolatedWorktree(worktreePath, root = process.cwd()) {
|
|
128
|
+
if (!worktreePath) {
|
|
129
|
+
throw new Error('fleet dispatch blocked: worktreePath is required; refusing to dispatch without an isolated worktree');
|
|
130
|
+
}
|
|
131
|
+
const resolvedWorktree = realpathOrResolve(worktreePath);
|
|
132
|
+
const worktrees = listWorktrees(root);
|
|
133
|
+
const primaryRoot = realpathOrResolve(worktrees[0]?.path || root);
|
|
134
|
+
if (resolvedWorktree === primaryRoot) {
|
|
135
|
+
throw new Error(`fleet dispatch blocked: worktreePath resolves to the primary repo checkout (${primaryRoot}); refusing to dispatch outside an isolated worktree`);
|
|
136
|
+
}
|
|
137
|
+
return { worktreePath: resolvedWorktree, primaryRoot };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = false, allowedTools = FLEET_ALLOWED_TOOLS } = {}) {
|
|
74
141
|
if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
|
|
75
142
|
const prev = process.env.ATRIS_RUNNER_PROFILE;
|
|
76
143
|
process.env.ATRIS_RUNNER_PROFILE = engineName;
|
|
77
144
|
try {
|
|
78
|
-
|
|
145
|
+
let cmd = buildRunnerCommand({ promptFile, allowedTools });
|
|
79
146
|
// devin's default is read-only for writes; fleet builds ALWAYS run in an
|
|
80
147
|
// isolated worktree, so the conductor grants write permission here and
|
|
81
148
|
// only here (the profile itself stays safe for non-worktree ticks).
|
|
149
|
+
if (sealed && engineName === 'codex') {
|
|
150
|
+
cmd = cmd.replace(/\bexec\b/, 'exec --sandbox workspace-write --ephemeral --ignore-user-config --ignore-rules');
|
|
151
|
+
}
|
|
152
|
+
if (sealed && engineName === 'claude') {
|
|
153
|
+
cmd = `${cmd} --safe-mode --no-session-persistence --permission-mode acceptEdits --settings '${JSON.stringify({ sandbox: { enabled: true, autoAllowBashIfSandboxed: true } })}'`;
|
|
154
|
+
}
|
|
155
|
+
if (sealed && engineName === 'cursor') cmd = `${cmd} --sandbox enabled`;
|
|
156
|
+
if (sealed && engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --sandbox --permission-mode accept-edits ');
|
|
157
|
+
if (sealed && engineName === 'grok') {
|
|
158
|
+
cmd = `${cmd.replace(/\s+--always-approve\b/, '')} --sandbox enabled --permission-mode acceptEdits --no-memory --no-subagents`;
|
|
159
|
+
}
|
|
82
160
|
if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
|
|
161
|
+
if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
|
|
162
|
+
if (yolo && engineName === 'claude') cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
|
|
83
163
|
return cmd;
|
|
84
164
|
} finally {
|
|
85
165
|
if (prev === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
|
|
@@ -87,25 +167,372 @@ function buildEngineCommand(engineName, promptFile) {
|
|
|
87
167
|
}
|
|
88
168
|
}
|
|
89
169
|
|
|
170
|
+
function trackedSandboxPids(stateFile, leaseFile) {
|
|
171
|
+
const pids = new Set();
|
|
172
|
+
let state = {};
|
|
173
|
+
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch {}
|
|
174
|
+
const pgid = Number(state.pgid);
|
|
175
|
+
if (Number.isInteger(pgid) && pgid > 0) {
|
|
176
|
+
const grouped = spawnSync('/usr/bin/pgrep', ['-g', String(pgid)], { encoding: 'utf8' });
|
|
177
|
+
for (const value of String(grouped.stdout || '').trim().split(/\s+/)) {
|
|
178
|
+
const pid = Number(value);
|
|
179
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const leased = spawnSync('/usr/sbin/lsof', ['-t', leaseFile], { encoding: 'utf8' });
|
|
183
|
+
for (const value of String(leased.stdout || '').trim().split(/\s+/)) {
|
|
184
|
+
const pid = Number(value);
|
|
185
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
186
|
+
}
|
|
187
|
+
if (state.cwd) {
|
|
188
|
+
const rooted = spawnSync('/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', String(state.cwd), '-t'], { encoding: 'utf8' });
|
|
189
|
+
for (const value of String(rooted.stdout || '').trim().split(/\s+/)) {
|
|
190
|
+
const pid = Number(value);
|
|
191
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
pids.delete(process.pid);
|
|
195
|
+
return { pgid, pids: [...pids] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function terminateTrackedSandbox(stateFile, leaseFile) {
|
|
199
|
+
for (const signal of ['SIGTERM', 'SIGKILL', 'SIGKILL']) {
|
|
200
|
+
const tracked = trackedSandboxPids(stateFile, leaseFile);
|
|
201
|
+
if (tracked.pgid > 0) {
|
|
202
|
+
try { process.kill(-tracked.pgid, signal); } catch {}
|
|
203
|
+
}
|
|
204
|
+
for (const pid of tracked.pids) {
|
|
205
|
+
try { process.kill(pid, signal); } catch {}
|
|
206
|
+
}
|
|
207
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function terminateSameSandboxProfile(executable, args, options) {
|
|
212
|
+
if (executable !== '/usr/bin/sandbox-exec' || args[0] !== '-p' || !args[1]) return;
|
|
213
|
+
spawnSync(executable, ['-p', args[1], '/bin/kill', '-KILL', '-1'], {
|
|
214
|
+
cwd: options.cwd,
|
|
215
|
+
env: options.env,
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
stdio: 'ignore',
|
|
218
|
+
timeout: 5000,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function runInReapedProcessGroup(executable, args, options, controlDir, statusFile) {
|
|
223
|
+
if (!controlDir || !statusFile) throw new Error('sealed execution requires isolated control and status paths');
|
|
224
|
+
const supervisor = path.join(controlDir, 'sandbox-supervisor.js');
|
|
225
|
+
const stateFile = path.join(controlDir, 'sandbox-process.json');
|
|
226
|
+
const leaseFile = path.join(controlDir, 'sandbox-process.lease');
|
|
227
|
+
fs.writeFileSync(supervisor, [
|
|
228
|
+
"'use strict';",
|
|
229
|
+
"const fs = require('node:fs');",
|
|
230
|
+
"const { spawn, spawnSync } = require('node:child_process');",
|
|
231
|
+
"const [stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
|
|
232
|
+
"const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
|
|
233
|
+
"const statusFd = fs.openSync(statusFile, 'w', 0o600);",
|
|
234
|
+
"const child = spawn(executable, args, { cwd: process.cwd(), env: process.env, detached: true, stdio: ['ignore', 'inherit', 'inherit', leaseFd, statusFd] });",
|
|
235
|
+
"fs.closeSync(leaseFd);",
|
|
236
|
+
"fs.closeSync(statusFd);",
|
|
237
|
+
"fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
|
|
238
|
+
"let stopping = false;",
|
|
239
|
+
"const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));",
|
|
240
|
+
"function trackedPids() {",
|
|
241
|
+
" const pids = new Set();",
|
|
242
|
+
" for (const [bin, argv] of [['/usr/bin/pgrep', ['-g', String(child.pid)]], ['/usr/sbin/lsof', ['-t', leaseFile]], ['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', process.cwd(), '-t']]]) {",
|
|
243
|
+
" const found = spawnSync(bin, argv, { encoding: 'utf8' });",
|
|
244
|
+
" for (const value of String(found.stdout || '').trim().split(/\\s+/)) {",
|
|
245
|
+
" const pid = Number(value);",
|
|
246
|
+
" if (Number.isInteger(pid) && pid > 0) pids.add(pid);",
|
|
247
|
+
" }",
|
|
248
|
+
" }",
|
|
249
|
+
" pids.delete(process.pid);",
|
|
250
|
+
" return [...pids];",
|
|
251
|
+
"}",
|
|
252
|
+
"async function stop(code) {",
|
|
253
|
+
" if (stopping) return;",
|
|
254
|
+
" stopping = true;",
|
|
255
|
+
" if (executable === '/usr/bin/sandbox-exec' && args[0] === '-p' && args[1]) {",
|
|
256
|
+
" spawnSync(executable, ['-p', args[1], '/bin/kill', '-KILL', '-1'], { cwd: process.cwd(), env: process.env, stdio: 'ignore', timeout: 5000 });",
|
|
257
|
+
" }",
|
|
258
|
+
" for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
|
|
259
|
+
" try { process.kill(-child.pid, signal); } catch {}",
|
|
260
|
+
" for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }",
|
|
261
|
+
" await wait(delay);",
|
|
262
|
+
" }",
|
|
263
|
+
" let exitCode = Number.isInteger(code) ? code : 128;",
|
|
264
|
+
" try {",
|
|
265
|
+
" const savedText = fs.readFileSync(statusFile, 'utf8').trim();",
|
|
266
|
+
" const saved = Number(savedText);",
|
|
267
|
+
" if (savedText && Number.isInteger(saved) && saved >= 0 && saved <= 255) exitCode = saved;",
|
|
268
|
+
" } catch {}",
|
|
269
|
+
" process.exit(exitCode);",
|
|
270
|
+
"}",
|
|
271
|
+
"child.once('error', () => { void stop(1); });",
|
|
272
|
+
"child.once('exit', (code) => { void stop(code); });",
|
|
273
|
+
"for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { void stop(143); });",
|
|
274
|
+
'',
|
|
275
|
+
].join('\n'), { mode: 0o700 });
|
|
276
|
+
let result;
|
|
277
|
+
try {
|
|
278
|
+
result = spawnSync(process.execPath, [supervisor, stateFile, leaseFile, statusFile, executable, ...args], options);
|
|
279
|
+
} finally {
|
|
280
|
+
terminateSameSandboxProfile(executable, args, options);
|
|
281
|
+
terminateTrackedSandbox(stateFile, leaseFile);
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sandboxLifecycleWrapper(runtimeDir, controlDir) {
|
|
287
|
+
const wrapper = path.join(runtimeDir, 'sandbox-lifecycle.sh');
|
|
288
|
+
const statusFile = path.join(controlDir, 'sandbox-status');
|
|
289
|
+
fs.writeFileSync(wrapper, [
|
|
290
|
+
'#!/bin/sh',
|
|
291
|
+
'"$@" 4>&-',
|
|
292
|
+
'status=$?',
|
|
293
|
+
'printf "%s\\n" "$status" >&4',
|
|
294
|
+
"trap '' EXIT TERM INT HUP",
|
|
295
|
+
'/bin/kill -TERM -1 2>/dev/null || true',
|
|
296
|
+
'/bin/sleep 0.1',
|
|
297
|
+
'/bin/kill -KILL -1 2>/dev/null || true',
|
|
298
|
+
'exit "$status"',
|
|
299
|
+
'',
|
|
300
|
+
].join('\n'), { mode: 0o700 });
|
|
301
|
+
return { wrapper, statusFile };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function dispatchResultOutput(result) {
|
|
305
|
+
if (!result) return '';
|
|
306
|
+
return [
|
|
307
|
+
result.report,
|
|
308
|
+
result.stdout,
|
|
309
|
+
result.stderr,
|
|
310
|
+
].map((value) => String(value || '')).filter(Boolean).join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function dispatchResultExitCode(result) {
|
|
314
|
+
if (!result || typeof result !== 'object') return 0;
|
|
315
|
+
if (Object.prototype.hasOwnProperty.call(result, 'exitCode')) return result.exitCode;
|
|
316
|
+
if (Object.prototype.hasOwnProperty.call(result, 'status')) return result.status;
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function dispatchTaskId(task) {
|
|
321
|
+
return task && (task.display_id || task.task_id || task.id) ? String(task.display_id || task.task_id || task.id) : '';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function dispatchBriefAuthor(worktreePath, fallback = 'orb') {
|
|
325
|
+
try {
|
|
326
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(worktreePath, '.atris', 'agent-worktree.json'), 'utf8'));
|
|
327
|
+
return String(sidecar.owner || sidecar.member || sidecar.agent || fallback).trim() || fallback;
|
|
328
|
+
} catch {
|
|
329
|
+
return fallback;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function captureDispatchBrief({ root, task, engine, worktreePath, prompt, missionId = '', author = '', yolo = false }) {
|
|
334
|
+
const promptText = prompt || buildFleetPrompt(task, { worktreePath, yolo });
|
|
335
|
+
const record = appendBriefRecord(root, {
|
|
336
|
+
author: author || dispatchBriefAuthor(worktreePath, 'orb'),
|
|
337
|
+
engine,
|
|
338
|
+
task_id: dispatchTaskId(task),
|
|
339
|
+
mission_id: missionId,
|
|
340
|
+
prompt_text: promptText,
|
|
341
|
+
context: {
|
|
342
|
+
worktree: worktreePath,
|
|
343
|
+
base_ref: worktreeBaseRef(worktreePath, ''),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
if (path.resolve(root) !== path.resolve(worktreePath || root)) {
|
|
347
|
+
mirrorBriefRecord(worktreePath, record);
|
|
348
|
+
}
|
|
349
|
+
return record;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function stampDispatchBrief(root, briefId, result, note) {
|
|
353
|
+
if (!briefId) return { ok: false, error: 'missing brief id' };
|
|
354
|
+
try {
|
|
355
|
+
return stampBriefOutcome(root, briefId, { result, note });
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { ok: false, error: err.message };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function detectDeadEngineDispatch(result) {
|
|
362
|
+
const exitCode = dispatchResultExitCode(result);
|
|
363
|
+
if (exitCode === 0) return null;
|
|
364
|
+
const output = dispatchResultOutput(result).toLowerCase();
|
|
365
|
+
const pattern = DEAD_ENGINE_OUTPUT_PATTERNS.find((p) => output.includes(p));
|
|
366
|
+
if (pattern) return { reason: 'usage_limit', pattern };
|
|
367
|
+
return { reason: 'nonzero_exit', exitCode };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normalizeInstalledEngines(engines) {
|
|
371
|
+
return [...new Set((engines || [])
|
|
372
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry && entry.name))
|
|
373
|
+
.map((name) => String(name || '').trim())
|
|
374
|
+
.filter((name) => FLEET_CAPABLE.includes(name) && RUNNER_PROFILE_DEFS[name]))];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function installedFleetEngines(root) {
|
|
378
|
+
const { roster } = require('../commands/engine');
|
|
379
|
+
return normalizeInstalledEngines(roster(root).filter((e) => e.installed));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function rankFleetEngines(engines, root = process.cwd()) {
|
|
383
|
+
return rankEngines(normalizeInstalledEngines(engines), {
|
|
384
|
+
root,
|
|
385
|
+
taskType: 'executor',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function nextInstalledFleetEngine(current, { root = process.cwd(), installedEngines = null } = {}) {
|
|
390
|
+
const engines = installedEngines ? normalizeInstalledEngines(installedEngines) : installedFleetEngines(root);
|
|
391
|
+
const currentName = String(current || '').trim();
|
|
392
|
+
if (!engines.length) return '';
|
|
393
|
+
const index = engines.indexOf(currentName);
|
|
394
|
+
const ordered = index === -1
|
|
395
|
+
? engines
|
|
396
|
+
: [...engines.slice(index + 1), ...engines.slice(0, index)];
|
|
397
|
+
const candidates = ordered.filter((name) => name && name !== currentName);
|
|
398
|
+
return rankFleetEngines(candidates, root)[0] || '';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizeDispatchResult(result, engineName) {
|
|
402
|
+
const normalized = { ...(result || {}) };
|
|
403
|
+
normalized.engine = normalized.engine || engineName;
|
|
404
|
+
normalized.exitCode = dispatchResultExitCode(normalized);
|
|
405
|
+
return normalized;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function failedDispatchLeg(result, engineName) {
|
|
409
|
+
return {
|
|
410
|
+
engine: String(result && result.engine || engineName || ''),
|
|
411
|
+
exitCode: dispatchResultExitCode(result),
|
|
412
|
+
stderr: String(result && result.stderr || '').slice(-2000),
|
|
413
|
+
report: String(result && result.report || '').slice(-2000),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function dispatchEntryWithRestaff({
|
|
418
|
+
entry,
|
|
419
|
+
engine,
|
|
420
|
+
root,
|
|
421
|
+
dispatch,
|
|
422
|
+
installedEngines = null,
|
|
423
|
+
restaffState,
|
|
424
|
+
}) {
|
|
425
|
+
const runOnce = async (engineName) => {
|
|
426
|
+
const prompt = entry.prompt || buildFleetPrompt(entry.task, { worktreePath: entry.worktreePath, yolo: entry.yolo });
|
|
427
|
+
const brief = captureDispatchBrief({
|
|
428
|
+
root,
|
|
429
|
+
task: entry.task,
|
|
430
|
+
engine: engineName,
|
|
431
|
+
worktreePath: entry.worktreePath,
|
|
432
|
+
prompt,
|
|
433
|
+
missionId: entry.mission_id || entry.missionId || '',
|
|
434
|
+
author: entry.author || '',
|
|
435
|
+
yolo: entry.yolo,
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
const runResult = normalizeDispatchResult(await dispatch({ ...entry, engine: engineName, prompt, brief_id: brief.brief_id, briefId: brief.brief_id, skipBriefCapture: true }), engineName);
|
|
439
|
+
if (!runResult.brief_id) runResult.brief_id = brief.brief_id;
|
|
440
|
+
return runResult;
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return normalizeDispatchResult({
|
|
443
|
+
brief_id: brief.brief_id,
|
|
444
|
+
exitCode: 1,
|
|
445
|
+
report: '',
|
|
446
|
+
stderr: String(err && err.message || err),
|
|
447
|
+
}, engineName);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const first = await runOnce(engine);
|
|
452
|
+
const deadEngine = detectDeadEngineDispatch(first);
|
|
453
|
+
if (!deadEngine) return first;
|
|
454
|
+
stampDispatchBrief(root, first.brief_id, 'fail', `restaffed from ${engine}: ${deadEngine.reason}`);
|
|
455
|
+
|
|
456
|
+
const outage = { from: engine, reason: deadEngine.reason };
|
|
457
|
+
if (restaffState.used) {
|
|
458
|
+
return { ...first, deadEngine: { ...outage, skipped: 'already_restaffed' } };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const fallback = nextInstalledFleetEngine(engine, { root, installedEngines });
|
|
462
|
+
if (!fallback) {
|
|
463
|
+
return { ...first, deadEngine: { ...outage, skipped: 'no_fallback_engine' } };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
restaffState.used = true;
|
|
467
|
+
const fallbackResult = await runOnce(fallback);
|
|
468
|
+
return {
|
|
469
|
+
...fallbackResult,
|
|
470
|
+
restaffed: {
|
|
471
|
+
from: engine,
|
|
472
|
+
to: fallback,
|
|
473
|
+
reason: deadEngine.reason,
|
|
474
|
+
failed_legs: [failedDispatchLeg(first, engine)],
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
90
479
|
// Run one engine on one task in one worktree. Blocking; the conductor runs
|
|
91
480
|
// dispatches in parallel via child processes, not threads. `runner` is
|
|
92
|
-
// injectable for tests.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
481
|
+
// injectable for tests. `prompt` is injectable too: a caller-supplied prompt
|
|
482
|
+
// (e.g. `atris engine dispatch --prompt-file`) skips the generated
|
|
483
|
+
// buildFleetPrompt text entirely.
|
|
484
|
+
function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), timeoutMs = 900000, runner = null, prompt: promptOverride = '', yolo = false, sealed = false, briefId = '', skipBriefCapture = false, environment = null, allowedTools = FLEET_ALLOWED_TOOLS }) {
|
|
485
|
+
assertIsolatedWorktree(worktreePath, root);
|
|
486
|
+
const prompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
|
|
487
|
+
let capturedBriefId = briefId;
|
|
488
|
+
if (!skipBriefCapture) {
|
|
489
|
+
capturedBriefId = captureDispatchBrief({ root, task, engine, worktreePath, prompt, yolo }).brief_id;
|
|
490
|
+
}
|
|
491
|
+
const runtimeDir = String(environment && environment.ATRIS_ONE_LAP_RUNTIME_DIR || '');
|
|
492
|
+
const promptFile = path.join(sealed && runtimeDir ? runtimeDir : path.join(worktreePath, '.atris'), `fleet-prompt-${task.display_id || 'task'}.md`);
|
|
96
493
|
fs.mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
97
494
|
fs.writeFileSync(promptFile, prompt);
|
|
98
|
-
const command = buildEngineCommand(engine, promptFile);
|
|
99
|
-
const exec = runner || ((cmd) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
495
|
+
const command = buildEngineCommand(engine, promptFile, { yolo, sealed, allowedTools });
|
|
496
|
+
const exec = runner || ((cmd) => {
|
|
497
|
+
const childEnv = sealed && environment
|
|
498
|
+
? { ...environment }
|
|
499
|
+
: (environment ? { ...process.env, ...environment } : { ...process.env });
|
|
500
|
+
const sandboxProfile = String(childEnv.ATRIS_ONE_LAP_SANDBOX_PROFILE || '');
|
|
501
|
+
const cleanupRuntimeDir = String(childEnv.ATRIS_ONE_LAP_RUNTIME_DIR || '');
|
|
502
|
+
const cleanupControlDir = String(childEnv.ATRIS_ONE_LAP_CONTROL_DIR || '');
|
|
503
|
+
const lifecycleWrapper = String(childEnv.ATRIS_ONE_LAP_LIFECYCLE_WRAPPER || '');
|
|
504
|
+
const statusFile = String(childEnv.ATRIS_ONE_LAP_STATUS_FILE || '');
|
|
505
|
+
delete childEnv.ATRIS_ONE_LAP_SANDBOX_PROFILE;
|
|
506
|
+
delete childEnv.ATRIS_ONE_LAP_RUNTIME_DIR;
|
|
507
|
+
delete childEnv.ATRIS_ONE_LAP_CONTROL_DIR;
|
|
508
|
+
delete childEnv.ATRIS_ONE_LAP_LIFECYCLE_WRAPPER;
|
|
509
|
+
delete childEnv.ATRIS_ONE_LAP_STATUS_FILE;
|
|
510
|
+
try {
|
|
511
|
+
const executable = sandboxProfile ? '/usr/bin/sandbox-exec' : 'sh';
|
|
512
|
+
const args = sandboxProfile
|
|
513
|
+
? ['-p', sandboxProfile, lifecycleWrapper, '/bin/sh', '-c', cmd]
|
|
514
|
+
: ['-c', cmd];
|
|
515
|
+
const spawnOptions = {
|
|
516
|
+
cwd: worktreePath,
|
|
517
|
+
env: childEnv,
|
|
518
|
+
encoding: 'utf8',
|
|
519
|
+
timeout: timeoutMs,
|
|
520
|
+
};
|
|
521
|
+
return sandboxProfile
|
|
522
|
+
? runInReapedProcessGroup(executable, args, spawnOptions, cleanupControlDir, statusFile)
|
|
523
|
+
: spawnSync(executable, args, spawnOptions);
|
|
524
|
+
} finally {
|
|
525
|
+
if (cleanupRuntimeDir) fs.rmSync(cleanupRuntimeDir, { recursive: true, force: true });
|
|
526
|
+
if (cleanupControlDir) fs.rmSync(cleanupControlDir, { recursive: true, force: true });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
104
529
|
const result = exec(command);
|
|
105
530
|
return {
|
|
106
531
|
task: task.display_id || task.id,
|
|
107
532
|
engine,
|
|
108
533
|
worktreePath,
|
|
534
|
+
promptFile,
|
|
535
|
+
brief_id: capturedBriefId || null,
|
|
109
536
|
command,
|
|
110
537
|
exitCode: result.status,
|
|
111
538
|
report: String(result.stdout || '').slice(-8000),
|
|
@@ -118,12 +545,25 @@ function dispatchToEngine({ task, engine, worktreePath, timeoutMs = 900000, runn
|
|
|
118
545
|
|
|
119
546
|
function taskTags(task) {
|
|
120
547
|
const fromTags = Array.isArray(task.tags) ? task.tags : [];
|
|
548
|
+
const fromTag = task && task.tag ? [task.tag] : [];
|
|
549
|
+
// Tags added after creation live in metadata.tags (`atris task tag`); a
|
|
550
|
+
// fleet that only read task.tags/title hashtags would ignore an owner-hold
|
|
551
|
+
// flag stamped on a live task and keep restaffing it (CLI-879).
|
|
552
|
+
const fromMeta = task && task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
|
|
121
553
|
const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
|
|
122
|
-
return [...fromTags, ...fromTitle].map((t) => String(t).toLowerCase());
|
|
554
|
+
return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle].map((t) => String(t).toLowerCase());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A task flagged for a human decision is never fleet-staffable, whatever its
|
|
558
|
+
// lane. Mirrors the sweep's needs-human hold so both loops agree.
|
|
559
|
+
function isHumanHoldTag(tag) {
|
|
560
|
+
const normalized = String(tag).trim().toLowerCase().replace(/_/g, '-');
|
|
561
|
+
return normalized === 'needs-human' || normalized === 'needshuman';
|
|
123
562
|
}
|
|
124
563
|
|
|
125
564
|
function isSafeLane(task) {
|
|
126
565
|
const tags = taskTags(task);
|
|
566
|
+
if (tags.some(isHumanHoldTag)) return false;
|
|
127
567
|
return !tags.some((t) => DENIED_TAGS.includes(t));
|
|
128
568
|
}
|
|
129
569
|
|
|
@@ -177,6 +617,20 @@ function assignEngines(staffed, engines) {
|
|
|
177
617
|
// Land one arrival: rebase onto latest base first; a conflict pauses the
|
|
178
618
|
// landing (never auto-resolve) and reports which files collided so the
|
|
179
619
|
// conductor or a human can take it. `git` is injectable for tests.
|
|
620
|
+
// Fleet landings always target master. Without the explicit --target, ship
|
|
621
|
+
// falls back to the launcher branch's atris-base — a flight launched from a
|
|
622
|
+
// feature-branch checkout would merge PRs into that branch while the receipt
|
|
623
|
+
// and land board define "landed" as in-master (PRs #207/#208, 2026-07-04).
|
|
624
|
+
function fleetShipArgs(entry, check) {
|
|
625
|
+
return [
|
|
626
|
+
'worktree', 'ship',
|
|
627
|
+
'--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`,
|
|
628
|
+
'--verify', check,
|
|
629
|
+
'--target', 'origin/master',
|
|
630
|
+
'--merge',
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
|
|
180
634
|
function landArrival({ worktreePath, git = null }) {
|
|
181
635
|
const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
|
|
182
636
|
const fetch = run(['fetch', 'origin']);
|
|
@@ -196,20 +650,35 @@ function landArrival({ worktreePath, git = null }) {
|
|
|
196
650
|
|
|
197
651
|
module.exports = {
|
|
198
652
|
DENIED_TAGS,
|
|
653
|
+
DEAD_ENGINE_OUTPUT_PATTERNS,
|
|
199
654
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
200
655
|
get runFleetFlight() { return runFleetFlight; },
|
|
201
656
|
get focusedCheck() { return focusedCheck; },
|
|
657
|
+
get dispatchCheck() { return dispatchCheck; },
|
|
658
|
+
get runDispatchFlight() { return runDispatchFlight; },
|
|
659
|
+
YOLO_ENGINE_FLAGS,
|
|
660
|
+
DISPATCH_SELF_LAND_TARGET,
|
|
202
661
|
parseDoneCheck,
|
|
662
|
+
METHOD_KERNEL,
|
|
203
663
|
buildFleetPrompt,
|
|
664
|
+
assertIsolatedWorktree,
|
|
204
665
|
buildEngineCommand,
|
|
666
|
+
isSafeLane,
|
|
667
|
+
taskTags,
|
|
668
|
+
detectDeadEngineDispatch,
|
|
669
|
+
rankFleetEngines,
|
|
670
|
+
nextInstalledFleetEngine,
|
|
205
671
|
dispatchToEngine,
|
|
206
672
|
taskTags,
|
|
673
|
+
isHumanHoldTag,
|
|
207
674
|
isSafeLane,
|
|
208
675
|
fileSurface,
|
|
209
676
|
surfacesOverlap,
|
|
210
677
|
staffFlight,
|
|
211
678
|
assignEngines,
|
|
212
679
|
landArrival,
|
|
680
|
+
fleetShipArgs,
|
|
681
|
+
defaultSelfLandCheck,
|
|
213
682
|
};
|
|
214
683
|
|
|
215
684
|
// ---------------------------------------------------------------------------
|
|
@@ -217,10 +686,1101 @@ module.exports = {
|
|
|
217
686
|
|
|
218
687
|
// Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
|
|
219
688
|
// not a repo worker — it keeps owning normal mission ticks, not fleet builds.
|
|
220
|
-
const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin'];
|
|
689
|
+
const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin', 'grok'];
|
|
221
690
|
|
|
691
|
+
let receiptSequence = 0;
|
|
222
692
|
function nowStamp() {
|
|
223
|
-
|
|
693
|
+
receiptSequence += 1;
|
|
694
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
|
|
695
|
+
return `${stamp}-p${process.pid}-${receiptSequence}`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function shellSingleQuote(value) {
|
|
699
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function canonicalPath(value) {
|
|
703
|
+
const resolved = path.resolve(String(value || ''));
|
|
704
|
+
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function seatbeltRule(kind, value) {
|
|
708
|
+
return `(${kind} ${JSON.stringify(canonicalPath(value))})`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function seatbeltAncestors(target) {
|
|
712
|
+
const out = [];
|
|
713
|
+
let current = path.dirname(canonicalPath(target));
|
|
714
|
+
while (current && current !== path.dirname(current)) {
|
|
715
|
+
out.push(current);
|
|
716
|
+
current = path.dirname(current);
|
|
717
|
+
}
|
|
718
|
+
out.push('/');
|
|
719
|
+
return out.reverse();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function reviewOnlySandboxProfile({ worktreePath, gitDir, quarantine, runtimeTmp, engine = '', network = false, writable = true }) {
|
|
723
|
+
if (process.platform !== 'darwin' || !fs.existsSync('/usr/bin/sandbox-exec')) {
|
|
724
|
+
throw new Error('review-only execution requires the macOS sandbox-exec isolation backend');
|
|
725
|
+
}
|
|
726
|
+
const home = String(process.env.HOME || '').trim();
|
|
727
|
+
const readSubpaths = [
|
|
728
|
+
'/System', '/usr', '/bin', '/sbin', '/Library', '/opt/homebrew', '/dev',
|
|
729
|
+
'/private/etc', '/private/var/db', '/private/var/run', '/private/var/select',
|
|
730
|
+
worktreePath, gitDir, quarantine, runtimeTmp,
|
|
731
|
+
];
|
|
732
|
+
const writeSubpaths = ['/dev', runtimeTmp];
|
|
733
|
+
if (writable) writeSubpaths.push(worktreePath, gitDir, quarantine);
|
|
734
|
+
const installByEngine = {
|
|
735
|
+
codex: ['.bun'],
|
|
736
|
+
claude: ['.local/bin', '.local/share/claude'],
|
|
737
|
+
cursor: ['.local/bin', '.local/share/cursor-agent'],
|
|
738
|
+
devin: ['.local/bin', '.local/share/devin'],
|
|
739
|
+
grok: ['.local/bin', '.grok/downloads'],
|
|
740
|
+
};
|
|
741
|
+
if (home && engine) {
|
|
742
|
+
for (const relative of installByEngine[engine] || []) readSubpaths.push(path.join(home, relative));
|
|
743
|
+
}
|
|
744
|
+
if (engine && RUNNER_PROFILE_DEFS[engine]) {
|
|
745
|
+
const engineBin = String(RUNNER_PROFILE_DEFS[engine].bin || '').trim();
|
|
746
|
+
const located = engineBin
|
|
747
|
+
? spawnSync('/bin/sh', ['-c', `command -v ${engineBin}`], { encoding: 'utf8' })
|
|
748
|
+
: null;
|
|
749
|
+
const executable = located && located.status === 0 ? String(located.stdout || '').trim() : '';
|
|
750
|
+
if (executable) {
|
|
751
|
+
readSubpaths.push(path.dirname(executable));
|
|
752
|
+
readSubpaths.push(path.dirname(canonicalPath(executable)));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const readLiterals = [];
|
|
756
|
+
const writeLiterals = [];
|
|
757
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
758
|
+
if (!/^ATRIS_(?:ENGINE|VALIDATOR|PUSH|BOUNDARY)_(?:COUNT|PROMPT|RESULT|URL|CONFIG|DUMP)$/.test(key)) continue;
|
|
759
|
+
if (!value || !path.isAbsolute(value)) continue;
|
|
760
|
+
readLiterals.push(value);
|
|
761
|
+
writeLiterals.push(value);
|
|
762
|
+
}
|
|
763
|
+
const ancestorLiterals = [...new Set([
|
|
764
|
+
...readSubpaths,
|
|
765
|
+
...readLiterals,
|
|
766
|
+
...writeLiterals,
|
|
767
|
+
].flatMap(seatbeltAncestors))];
|
|
768
|
+
const readRules = [
|
|
769
|
+
...ancestorLiterals.map((value) => seatbeltRule('literal', value)),
|
|
770
|
+
...[...new Set(readSubpaths)].map((value) => seatbeltRule('subpath', value)),
|
|
771
|
+
...[...new Set(readLiterals)].map((value) => seatbeltRule('literal', value)),
|
|
772
|
+
];
|
|
773
|
+
const writeRules = [
|
|
774
|
+
...[...new Set(writeSubpaths)].map((value) => seatbeltRule('subpath', value)),
|
|
775
|
+
...[...new Set(writeLiterals)].map((value) => seatbeltRule('literal', value)),
|
|
776
|
+
];
|
|
777
|
+
return [
|
|
778
|
+
'(version 1)',
|
|
779
|
+
'(deny default)',
|
|
780
|
+
'(import "system.sb")',
|
|
781
|
+
'(import "com.apple.corefoundation.sb")',
|
|
782
|
+
'(allow process*)',
|
|
783
|
+
'(allow signal (target same-sandbox))',
|
|
784
|
+
...(network ? ['(allow network*)'] : []),
|
|
785
|
+
'(allow sysctl-read)',
|
|
786
|
+
'(allow mach-lookup)',
|
|
787
|
+
'(allow ipc-posix*)',
|
|
788
|
+
`(allow file-read* ${readRules.join(' ')})`,
|
|
789
|
+
`(allow file-write* ${writeRules.join(' ')})`,
|
|
790
|
+
].join('\n');
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function prepareEphemeralEngineHome(runtimeTmp, engine) {
|
|
794
|
+
const sourceHome = String(process.env.HOME || '').trim();
|
|
795
|
+
const runtimeHome = path.join(runtimeTmp, `home-${engine || 'verifier'}`);
|
|
796
|
+
fs.mkdirSync(runtimeHome, { recursive: true, mode: 0o700 });
|
|
797
|
+
const copy = (sourceRelative, targetRelative, pickKeys = null) => {
|
|
798
|
+
if (!sourceHome) return;
|
|
799
|
+
const source = path.join(sourceHome, sourceRelative);
|
|
800
|
+
if (!fs.existsSync(source) || !fs.lstatSync(source).isFile()) return;
|
|
801
|
+
const target = path.join(runtimeHome, targetRelative);
|
|
802
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
803
|
+
if (pickKeys) {
|
|
804
|
+
let parsed;
|
|
805
|
+
try { parsed = JSON.parse(fs.readFileSync(source, 'utf8')); } catch { return; }
|
|
806
|
+
const selected = {};
|
|
807
|
+
for (const key of pickKeys) {
|
|
808
|
+
if (Object.prototype.hasOwnProperty.call(parsed, key)) selected[key] = parsed[key];
|
|
809
|
+
}
|
|
810
|
+
fs.writeFileSync(target, `${JSON.stringify(selected, null, 2)}\n`, { mode: 0o600 });
|
|
811
|
+
} else {
|
|
812
|
+
fs.copyFileSync(source, target);
|
|
813
|
+
fs.chmodSync(target, 0o600);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
if (engine === 'codex') copy('.codex/auth.json', '.codex/auth.json');
|
|
817
|
+
if (engine === 'claude') {
|
|
818
|
+
copy('.claude.json', '.claude.json', [
|
|
819
|
+
'oauthAccount', 'userID', 'anonymousId', 'machineID', 'hasCompletedOnboarding',
|
|
820
|
+
'installMethod', 'claudeMaxTier', 'hasAvailableMaxSubscription', 'hasAvailableSubscription',
|
|
821
|
+
]);
|
|
822
|
+
}
|
|
823
|
+
if (engine === 'cursor') {
|
|
824
|
+
copy('.cursor/cli-config.json', '.cursor/cli-config.json', ['authInfo', 'version', 'privacyCache', 'network']);
|
|
825
|
+
}
|
|
826
|
+
if (engine === 'devin') copy('.config/devin/config.json', '.config/devin/config.json');
|
|
827
|
+
if (engine === 'grok') {
|
|
828
|
+
copy('.grok/auth.json', '.grok/auth.json');
|
|
829
|
+
copy('.grok/config.toml', '.grok/config.toml');
|
|
830
|
+
}
|
|
831
|
+
return runtimeHome;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
|
|
835
|
+
const selectedEngine = String(options.engine || '');
|
|
836
|
+
const selectedSecret = (engines, name) => engines.includes(selectedEngine) ? String(process.env[name] || '') : '';
|
|
837
|
+
const runtimeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-one-lap-runtime-'));
|
|
838
|
+
const guardDir = path.join(runtimeTmp, 'bin');
|
|
839
|
+
fs.mkdirSync(guardDir, { recursive: true });
|
|
840
|
+
const locate = (name) => {
|
|
841
|
+
const result = spawnSync('sh', ['-c', `command -v ${name}`], { encoding: 'utf8' });
|
|
842
|
+
return result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
843
|
+
};
|
|
844
|
+
const gitBin = locate('git');
|
|
845
|
+
if (!gitBin) throw new Error('review-only dispatch requires git');
|
|
846
|
+
fs.writeFileSync(path.join(guardDir, 'git'), [
|
|
847
|
+
'#!/bin/sh',
|
|
848
|
+
'for arg in "$@"; do',
|
|
849
|
+
' case "$arg" in',
|
|
850
|
+
' push|send-pack) echo "one lap blocked git $arg" >&2; exit 2 ;;',
|
|
851
|
+
' esac',
|
|
852
|
+
'done',
|
|
853
|
+
`exec ${shellSingleQuote(gitBin)} "$@"`,
|
|
854
|
+
'',
|
|
855
|
+
].join('\n'), { mode: 0o755 });
|
|
856
|
+
|
|
857
|
+
const npmBin = locate('npm');
|
|
858
|
+
if (npmBin) {
|
|
859
|
+
fs.writeFileSync(path.join(guardDir, 'npm'), [
|
|
860
|
+
'#!/bin/sh',
|
|
861
|
+
'case "${1:-}" in',
|
|
862
|
+
' publish|install|i|add|login|logout|owner|token|deprecate|unpublish|dist-tag|access|team|org) echo "one lap blocked npm $1" >&2; exit 2 ;;',
|
|
863
|
+
'esac',
|
|
864
|
+
`exec ${shellSingleQuote(npmBin)} "$@"`,
|
|
865
|
+
'',
|
|
866
|
+
].join('\n'), { mode: 0o755 });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
for (const command of ['gh', 'curl', 'wget', 'ssh', 'scp', 'rsync', 'fly', 'flyctl', 'vercel', 'render', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'stripe']) {
|
|
870
|
+
fs.writeFileSync(path.join(guardDir, command), `#!/bin/sh\necho "one lap blocked ${command}" >&2\nexit 2\n`, { mode: 0o755 });
|
|
871
|
+
}
|
|
872
|
+
const commonResult = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: worktreePath, encoding: 'utf8' });
|
|
873
|
+
const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: worktreePath, encoding: 'utf8' });
|
|
874
|
+
if (commonResult.status !== 0 || remoteResult.status !== 0) {
|
|
875
|
+
throw new Error('review-only execution could not resolve its sealed Git boundary');
|
|
876
|
+
}
|
|
877
|
+
const gitDir = canonicalPath(path.resolve(worktreePath, String(commonResult.stdout || '').trim()));
|
|
878
|
+
const quarantine = canonicalPath(path.resolve(worktreePath, String(remoteResult.stdout || '').trim()));
|
|
879
|
+
const runtimeHome = prepareEphemeralEngineHome(runtimeTmp, selectedEngine);
|
|
880
|
+
const controlTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-one-lap-control-'));
|
|
881
|
+
const lifecycle = sandboxLifecycleWrapper(runtimeTmp, controlTmp);
|
|
882
|
+
let sandboxProfile;
|
|
883
|
+
try {
|
|
884
|
+
sandboxProfile = reviewOnlySandboxProfile({
|
|
885
|
+
worktreePath,
|
|
886
|
+
gitDir,
|
|
887
|
+
quarantine,
|
|
888
|
+
runtimeTmp,
|
|
889
|
+
engine: String(options.engine || ''),
|
|
890
|
+
network: options.network === true,
|
|
891
|
+
writable: options.writable !== false,
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
fs.rmSync(runtimeTmp, { recursive: true, force: true });
|
|
895
|
+
fs.rmSync(controlTmp, { recursive: true, force: true });
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
const environment = {
|
|
899
|
+
PATH: `${guardDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
900
|
+
GIT_CONFIG_COUNT: '2',
|
|
901
|
+
GIT_CONFIG_KEY_0: 'remote.origin.pushurl',
|
|
902
|
+
GIT_CONFIG_VALUE_0: path.join(guardDir, 'blocked-push'),
|
|
903
|
+
GIT_CONFIG_KEY_1: 'remote.origin.receivepack',
|
|
904
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
905
|
+
GH_TOKEN: '',
|
|
906
|
+
GITHUB_TOKEN: '',
|
|
907
|
+
NPM_TOKEN: '',
|
|
908
|
+
NODE_AUTH_TOKEN: '',
|
|
909
|
+
AWS_ACCESS_KEY_ID: '',
|
|
910
|
+
AWS_SECRET_ACCESS_KEY: '',
|
|
911
|
+
GOOGLE_APPLICATION_CREDENTIALS: '',
|
|
912
|
+
AZURE_CLIENT_SECRET: '',
|
|
913
|
+
STRIPE_SECRET_KEY: '',
|
|
914
|
+
OPENAI_API_KEY: selectedSecret(['codex'], 'OPENAI_API_KEY'),
|
|
915
|
+
CODEX_API_KEY: selectedSecret(['codex'], 'CODEX_API_KEY'),
|
|
916
|
+
ANTHROPIC_API_KEY: selectedSecret(['claude'], 'ANTHROPIC_API_KEY'),
|
|
917
|
+
CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN'),
|
|
918
|
+
CURSOR_API_KEY: selectedSecret(['cursor'], 'CURSOR_API_KEY'),
|
|
919
|
+
DEVIN_API_KEY: selectedSecret(['devin'], 'DEVIN_API_KEY'),
|
|
920
|
+
XAI_API_KEY: selectedSecret(['grok'], 'XAI_API_KEY'),
|
|
921
|
+
GROK_API_KEY: selectedSecret(['grok'], 'GROK_API_KEY'),
|
|
922
|
+
SSH_AUTH_SOCK: '',
|
|
923
|
+
TMPDIR: runtimeTmp,
|
|
924
|
+
HOME: runtimeHome,
|
|
925
|
+
XDG_CONFIG_HOME: path.join(runtimeHome, '.config'),
|
|
926
|
+
XDG_CACHE_HOME: path.join(runtimeTmp, 'cache'),
|
|
927
|
+
npm_config_cache: path.join(runtimeTmp, 'npm-cache'),
|
|
928
|
+
GIT_CONFIG_GLOBAL: path.join(runtimeTmp, 'gitconfig'),
|
|
929
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
930
|
+
CODEX_HOME: path.join(runtimeHome, '.codex'),
|
|
931
|
+
CLAUDE_CONFIG_DIR: path.join(runtimeHome, '.claude'),
|
|
932
|
+
ATRIS_ONE_LAP_SANDBOX_PROFILE: sandboxProfile,
|
|
933
|
+
ATRIS_ONE_LAP_RUNTIME_DIR: runtimeTmp,
|
|
934
|
+
ATRIS_ONE_LAP_CONTROL_DIR: controlTmp,
|
|
935
|
+
ATRIS_ONE_LAP_LIFECYCLE_WRAPPER: lifecycle.wrapper,
|
|
936
|
+
ATRIS_ONE_LAP_STATUS_FILE: lifecycle.statusFile,
|
|
937
|
+
};
|
|
938
|
+
for (const key of [
|
|
939
|
+
'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'NO_COLOR', 'USER', 'LOGNAME', 'SHELL',
|
|
940
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
|
941
|
+
'NODE_EXTRA_CA_CERTS', 'NODE_NO_WARNINGS', 'DISABLE_AUTOUPDATER',
|
|
942
|
+
'ATRIS_ENGINE_MODE', 'ATRIS_ENGINE_DELAY', 'ATRIS_ENGINE_COUNT', 'ATRIS_ENGINE_PROMPT',
|
|
943
|
+
'ATRIS_VALIDATOR_MODE', 'ATRIS_VALIDATOR_COUNT', 'ATRIS_VALIDATOR_PROMPT',
|
|
944
|
+
'ATRIS_PUSH_RESULT', 'ATRIS_PUSH_URL', 'ATRIS_PUSH_CONFIG', 'ATRIS_BOUNDARY_DUMP',
|
|
945
|
+
'ATRIS_REAL_GIT', 'ATRIS_TASKS_DB',
|
|
946
|
+
]) {
|
|
947
|
+
if (process.env[key] !== undefined) environment[key] = String(process.env[key]);
|
|
948
|
+
}
|
|
949
|
+
return environment;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function conductorGitEnvironment() {
|
|
953
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
954
|
+
for (const key of Object.keys(env)) {
|
|
955
|
+
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) delete env[key];
|
|
956
|
+
}
|
|
957
|
+
for (const key of [
|
|
958
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
959
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_REPLACE_REF_BASE', 'GIT_GRAFT_FILE', 'GIT_CONFIG_PARAMETERS',
|
|
960
|
+
]) delete env[key];
|
|
961
|
+
return env;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function remoteMasterOid(worktreePath, remote = 'origin') {
|
|
965
|
+
const result = spawnSync('git', ['ls-remote', '--exit-code', remote, 'refs/heads/master'], {
|
|
966
|
+
cwd: worktreePath,
|
|
967
|
+
encoding: 'utf8',
|
|
968
|
+
timeout: 15000,
|
|
969
|
+
env: conductorGitEnvironment(),
|
|
970
|
+
});
|
|
971
|
+
if (result.status !== 0) return '';
|
|
972
|
+
return String(result.stdout || '').trim().split(/\s+/)[0] || '';
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function remoteRefsDigest(worktreePath, remote = 'origin') {
|
|
976
|
+
const result = spawnSync('git', ['ls-remote', remote], {
|
|
977
|
+
cwd: worktreePath,
|
|
978
|
+
encoding: 'utf8',
|
|
979
|
+
timeout: 30000,
|
|
980
|
+
env: conductorGitEnvironment(),
|
|
981
|
+
});
|
|
982
|
+
if (result.status !== 0) return '';
|
|
983
|
+
return crypto.createHash('sha256').update(String(result.stdout || '')).digest('hex');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function digestFiles(paths) {
|
|
987
|
+
const hash = crypto.createHash('sha256');
|
|
988
|
+
const visit = (target) => {
|
|
989
|
+
hash.update(`\0${target}\0`);
|
|
990
|
+
if (!fs.existsSync(target)) {
|
|
991
|
+
hash.update('missing');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const stat = fs.lstatSync(target);
|
|
995
|
+
if (stat.isSymbolicLink()) {
|
|
996
|
+
hash.update(`link:${fs.readlinkSync(target)}`);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (stat.isDirectory()) {
|
|
1000
|
+
hash.update('directory');
|
|
1001
|
+
for (const name of fs.readdirSync(target).sort()) visit(path.join(target, name));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
hash.update(`file:${stat.mode}:`);
|
|
1005
|
+
hash.update(fs.readFileSync(target));
|
|
1006
|
+
};
|
|
1007
|
+
for (const target of paths) visit(target);
|
|
1008
|
+
return hash.digest('hex');
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function reviewSandboxMetadataDigest(boundary) {
|
|
1012
|
+
return digestFiles([
|
|
1013
|
+
path.join(boundary.worktreePath, '.git'),
|
|
1014
|
+
path.join(boundary.gitDir, 'config'),
|
|
1015
|
+
path.join(boundary.gitDir, 'hooks'),
|
|
1016
|
+
path.join(boundary.gitDir, 'info'),
|
|
1017
|
+
path.join(boundary.gitDir, 'objects', 'info'),
|
|
1018
|
+
path.join(boundary.gitDir, 'shallow'),
|
|
1019
|
+
path.join(boundary.worktreeGitDir, 'config.worktree'),
|
|
1020
|
+
path.join(boundary.worktreeGitDir, 'commondir'),
|
|
1021
|
+
path.join(boundary.worktreeGitDir, 'gitdir'),
|
|
1022
|
+
path.join(boundary.worktreeGitDir, 'HEAD'),
|
|
1023
|
+
]);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function bareRefsDigest(gitDir) {
|
|
1027
|
+
const result = spawnSync('git', ['--git-dir', gitDir, 'show-ref', '--head'], { encoding: 'utf8', env: trustedGitEnvironment() });
|
|
1028
|
+
if (result.status !== 0) return '';
|
|
1029
|
+
return crypto.createHash('sha256').update(String(result.stdout || '')).digest('hex');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function prepareReviewSandbox({ root, taskId, engine }) {
|
|
1033
|
+
const run = (args, options = {}) => spawnSync('git', args, {
|
|
1034
|
+
cwd: options.cwd || root,
|
|
1035
|
+
encoding: 'utf8',
|
|
1036
|
+
env: trustedGitEnvironment(),
|
|
1037
|
+
timeout: options.timeout || 30000,
|
|
1038
|
+
});
|
|
1039
|
+
const runConductorGit = (args) => spawnSync('git', args, {
|
|
1040
|
+
cwd: root,
|
|
1041
|
+
encoding: 'utf8',
|
|
1042
|
+
env: conductorGitEnvironment(),
|
|
1043
|
+
timeout: 30000,
|
|
1044
|
+
});
|
|
1045
|
+
const fetched = runConductorGit(['fetch', 'origin', 'master']);
|
|
1046
|
+
const original = runConductorGit(['remote', 'get-url', 'origin']);
|
|
1047
|
+
const base = run(['rev-parse', '--verify', 'origin/master^{commit}']);
|
|
1048
|
+
if (fetched.status !== 0 || original.status !== 0 || base.status !== 0) {
|
|
1049
|
+
return { ok: false, detail: 'review-only dispatch requires a current protected origin/master' };
|
|
1050
|
+
}
|
|
1051
|
+
const originalUrl = String(original.stdout || '').trim();
|
|
1052
|
+
const protectedMaster = remoteMasterOid(root, originalUrl);
|
|
1053
|
+
const protectedRefs = remoteRefsDigest(root, originalUrl);
|
|
1054
|
+
const baseOid = String(base.stdout || '').trim();
|
|
1055
|
+
if (!protectedMaster || !protectedRefs || baseOid !== protectedMaster) {
|
|
1056
|
+
return { ok: false, detail: 'review-only origin/master is not at the protected remote snapshot' };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const token = `${String(taskId || 'task').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'task'}-${process.pid}-${crypto.randomBytes(5).toString('hex')}`;
|
|
1060
|
+
const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), `atris-one-lap-${token}-`));
|
|
1061
|
+
const worktreePath = path.join(sandboxRoot, 'worktree');
|
|
1062
|
+
const gitDir = path.join(sandboxRoot, 'objects.git');
|
|
1063
|
+
const quarantine = path.join(sandboxRoot, 'quarantine.git');
|
|
1064
|
+
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-one-lap-seed-'));
|
|
1065
|
+
const seedBundle = path.join(seedDir, 'seed.bundle');
|
|
1066
|
+
const branch = `one-lap-${token}`;
|
|
1067
|
+
const cleanup = () => {
|
|
1068
|
+
fs.rmSync(seedDir, { recursive: true, force: true });
|
|
1069
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
1070
|
+
fs.rmSync(gitDir, { recursive: true, force: true });
|
|
1071
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
1072
|
+
fs.rmSync(sandboxRoot, { recursive: true, force: true });
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
fs.mkdirSync(sandboxRoot, { recursive: true });
|
|
1077
|
+
let result = run(['bundle', 'create', seedBundle, 'origin/master']);
|
|
1078
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not create seed bundle').trim());
|
|
1079
|
+
result = run(['init', '--bare', '--initial-branch=master', gitDir], { cwd: sandboxRoot });
|
|
1080
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not initialize sandbox object store').trim());
|
|
1081
|
+
result = spawnSync('git', ['--git-dir', gitDir, 'bundle', 'unbundle', seedBundle], { encoding: 'utf8', timeout: 30000 });
|
|
1082
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not seed sandbox object store').trim());
|
|
1083
|
+
result = run(['init', '--bare', '--initial-branch=master', quarantine], { cwd: sandboxRoot });
|
|
1084
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not initialize quarantine remote').trim());
|
|
1085
|
+
result = spawnSync('git', ['--git-dir', quarantine, 'bundle', 'unbundle', seedBundle], { encoding: 'utf8', timeout: 30000 });
|
|
1086
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not seed quarantine remote').trim());
|
|
1087
|
+
for (const [repo, ref, oid] of [
|
|
1088
|
+
[gitDir, `refs/heads/${branch}`, baseOid],
|
|
1089
|
+
[gitDir, 'refs/remotes/origin/master', baseOid],
|
|
1090
|
+
[quarantine, 'refs/heads/master', baseOid],
|
|
1091
|
+
]) {
|
|
1092
|
+
result = spawnSync('git', ['--git-dir', repo, 'update-ref', ref, oid], { encoding: 'utf8' });
|
|
1093
|
+
if (result.status !== 0) throw new Error(String(result.stderr || `could not seed ${ref}`).trim());
|
|
1094
|
+
}
|
|
1095
|
+
const configure = (args) => spawnSync('git', ['--git-dir', gitDir, 'config', ...args], { encoding: 'utf8' });
|
|
1096
|
+
for (const args of [
|
|
1097
|
+
['extensions.worktreeConfig', 'true'],
|
|
1098
|
+
['remote.origin.url', quarantine],
|
|
1099
|
+
['remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'],
|
|
1100
|
+
['user.name', String(runConductorGit(['config', '--get', 'user.name']).stdout || 'Atris One Lap').trim() || 'Atris One Lap'],
|
|
1101
|
+
['user.email', String(runConductorGit(['config', '--get', 'user.email']).stdout || 'one-lap@localhost').trim() || 'one-lap@localhost'],
|
|
1102
|
+
]) {
|
|
1103
|
+
result = configure(args);
|
|
1104
|
+
if (result.status !== 0) throw new Error(String(result.stderr || 'could not configure sandbox').trim());
|
|
1105
|
+
}
|
|
1106
|
+
result = spawnSync('git', ['--git-dir', gitDir, 'worktree', 'add', '--no-checkout', worktreePath, branch], {
|
|
1107
|
+
encoding: 'utf8',
|
|
1108
|
+
env: trustedGitEnvironment(),
|
|
1109
|
+
timeout: 30000,
|
|
1110
|
+
});
|
|
1111
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not create sandbox worktree').trim());
|
|
1112
|
+
result = spawnSync('git', ['-C', worktreePath, 'config', '--worktree', 'core.bare', 'false'], { encoding: 'utf8' });
|
|
1113
|
+
if (result.status !== 0) throw new Error(String(result.stderr || 'could not activate sandbox worktree').trim());
|
|
1114
|
+
const worktreeGitDirResult = spawnSync('git', ['-C', worktreePath, 'rev-parse', '--absolute-git-dir'], { encoding: 'utf8' });
|
|
1115
|
+
if (worktreeGitDirResult.status !== 0) throw new Error(String(worktreeGitDirResult.stderr || 'could not locate sandbox worktree metadata').trim());
|
|
1116
|
+
const worktreeGitDir = String(worktreeGitDirResult.stdout || '').trim();
|
|
1117
|
+
result = spawnSync('git', ['-C', worktreePath, 'read-tree', 'HEAD'], { encoding: 'utf8', env: trustedGitEnvironment() });
|
|
1118
|
+
if (result.status !== 0) throw new Error(String(result.stderr || 'could not seed sandbox index').trim());
|
|
1119
|
+
materializeVerifiedTree(worktreePath, [], readGitTree(gitDir, baseOid));
|
|
1120
|
+
fs.mkdirSync(path.join(worktreePath, '.atris'), { recursive: true });
|
|
1121
|
+
fs.writeFileSync(path.join(worktreePath, '.atris', 'agent-worktree.json'), `${JSON.stringify({
|
|
1122
|
+
agent: engine || null,
|
|
1123
|
+
owner: engine || 'one-lap',
|
|
1124
|
+
task: String(taskId || ''),
|
|
1125
|
+
branch,
|
|
1126
|
+
base: 'origin/master',
|
|
1127
|
+
sealed_review_sandbox: true,
|
|
1128
|
+
created_at: new Date().toISOString(),
|
|
1129
|
+
}, null, 2)}\n`, 'utf8');
|
|
1130
|
+
fs.rmSync(seedDir, { recursive: true, force: true });
|
|
1131
|
+
|
|
1132
|
+
const boundary = {
|
|
1133
|
+
ok: true,
|
|
1134
|
+
trustedRoot: root,
|
|
1135
|
+
sandboxRoot,
|
|
1136
|
+
worktreePath,
|
|
1137
|
+
gitDir,
|
|
1138
|
+
worktreeGitDir,
|
|
1139
|
+
quarantine,
|
|
1140
|
+
originalUrl,
|
|
1141
|
+
protectedMaster,
|
|
1142
|
+
protectedRefs,
|
|
1143
|
+
quarantineRefs: bareRefsDigest(quarantine),
|
|
1144
|
+
branch,
|
|
1145
|
+
baseOid,
|
|
1146
|
+
};
|
|
1147
|
+
boundary.metadataDigest = reviewSandboxMetadataDigest(boundary);
|
|
1148
|
+
const configText = fs.readFileSync(path.join(gitDir, 'config'), 'utf8');
|
|
1149
|
+
const gitFile = fs.readFileSync(path.join(worktreePath, '.git'), 'utf8');
|
|
1150
|
+
if (!boundary.quarantineRefs || configText.includes(originalUrl) || configText.includes(root) || gitFile.includes(root)) {
|
|
1151
|
+
throw new Error('sealed sandbox leaked a protected repository locator');
|
|
1152
|
+
}
|
|
1153
|
+
return boundary;
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
cleanup();
|
|
1156
|
+
return { ok: false, detail: `review-only sandbox could not be prepared: ${error.message}` };
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function reviewRemoteBoundaryState(worktreePath, boundary) {
|
|
1161
|
+
if (!boundary || !boundary.ok) {
|
|
1162
|
+
return { ok: false, stage: 'remote_quarantine', detail: 'review-only sealed sandbox is not armed' };
|
|
1163
|
+
}
|
|
1164
|
+
let metadataDigest = '';
|
|
1165
|
+
try { metadataDigest = reviewSandboxMetadataDigest(boundary); } catch {}
|
|
1166
|
+
if (!metadataDigest || metadataDigest !== boundary.metadataDigest) {
|
|
1167
|
+
return {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
stage: 'sandbox_metadata_changed',
|
|
1170
|
+
detail: 'the worker changed sealed Git metadata',
|
|
1171
|
+
protected_master: boundary.protectedMaster,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
const protectedMaster = remoteMasterOid(boundary.trustedRoot, boundary.originalUrl);
|
|
1175
|
+
const protectedRefs = remoteRefsDigest(boundary.trustedRoot, boundary.originalUrl);
|
|
1176
|
+
const quarantineMasterResult = spawnSync('git', ['--git-dir', boundary.quarantine, 'rev-parse', '--verify', 'refs/heads/master^{commit}'], { encoding: 'utf8' });
|
|
1177
|
+
const quarantineMaster = quarantineMasterResult.status === 0 ? String(quarantineMasterResult.stdout || '').trim() : '';
|
|
1178
|
+
const quarantineRefs = bareRefsDigest(boundary.quarantine);
|
|
1179
|
+
if (!protectedMaster || !protectedRefs || protectedMaster !== boundary.protectedMaster || protectedRefs !== boundary.protectedRefs) {
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
stage: 'master_changed',
|
|
1183
|
+
detail: 'the protected remote changed during isolated execution',
|
|
1184
|
+
protected_master: protectedMaster || null,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
if (!quarantineMaster || !quarantineRefs || quarantineMaster !== boundary.protectedMaster || quarantineRefs !== boundary.quarantineRefs) {
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
stage: 'outbound_attempt',
|
|
1191
|
+
detail: 'the worker attempted to change the quarantined remote',
|
|
1192
|
+
protected_master: protectedMaster,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
return { ok: true, protected_master: protectedMaster };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function reviewCandidateSnapshot(boundary, expected = null) {
|
|
1199
|
+
const fail = (detail, extra = {}) => ({ ok: false, stage: 'candidate_changed', detail, ...extra });
|
|
1200
|
+
const run = (args) => spawnSync('git', args, {
|
|
1201
|
+
cwd: boundary.worktreePath,
|
|
1202
|
+
encoding: 'utf8',
|
|
1203
|
+
env: trustedGitEnvironment(),
|
|
1204
|
+
timeout: 30000,
|
|
1205
|
+
});
|
|
1206
|
+
const firstHead = run(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
1207
|
+
const tree = run(['rev-parse', '--verify', 'HEAD^{tree}']);
|
|
1208
|
+
const status = run(['status', '--porcelain=v1', '--untracked-files=all']);
|
|
1209
|
+
const secondHead = run(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
1210
|
+
const failed = [firstHead, tree, status, secondHead].find((result) => result.status !== 0);
|
|
1211
|
+
if (failed) return fail(String(failed.stderr || failed.stdout || 'could not freeze the executor candidate').trim());
|
|
1212
|
+
const commit = String(firstHead.stdout || '').trim();
|
|
1213
|
+
const repeatedCommit = String(secondHead.stdout || '').trim();
|
|
1214
|
+
const treeOid = String(tree.stdout || '').trim();
|
|
1215
|
+
if (commit !== repeatedCommit) return fail('the executor changed HEAD while its candidate was being frozen');
|
|
1216
|
+
const conductorEntry = /^\?\? \.atris\/(?:agent-worktree\.json|state\/briefs\.jsonl)$/;
|
|
1217
|
+
const dirty = String(status.stdout || '').trim().split(/\r?\n/).filter(Boolean)
|
|
1218
|
+
.filter((line) => !conductorEntry.test(line));
|
|
1219
|
+
if (dirty.length) return fail(`the executor left uncommitted changes: ${dirty.slice(0, 10).join(', ')}`);
|
|
1220
|
+
if (expected && (commit !== expected.commit || treeOid !== expected.tree)) {
|
|
1221
|
+
return fail('the sealed candidate changed after the executor exited', { commit, tree: treeOid });
|
|
1222
|
+
}
|
|
1223
|
+
return { ok: true, stage: 'candidate_frozen', commit, tree: treeOid };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function trustedGitEnvironment() {
|
|
1227
|
+
const env = {
|
|
1228
|
+
...process.env,
|
|
1229
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
1230
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
1231
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
1232
|
+
GIT_ATTR_NOSYSTEM: '1',
|
|
1233
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
1234
|
+
};
|
|
1235
|
+
for (const key of Object.keys(env)) {
|
|
1236
|
+
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) delete env[key];
|
|
1237
|
+
}
|
|
1238
|
+
for (const key of [
|
|
1239
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
1240
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_REPLACE_REF_BASE', 'GIT_GRAFT_FILE', 'GIT_CONFIG_PARAMETERS',
|
|
1241
|
+
]) delete env[key];
|
|
1242
|
+
return env;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function readGitTree(gitDir, ref) {
|
|
1246
|
+
const env = trustedGitEnvironment();
|
|
1247
|
+
const listed = spawnSync('git', ['--git-dir', gitDir, 'ls-tree', '-rz', '--full-tree', ref], {
|
|
1248
|
+
encoding: 'utf8',
|
|
1249
|
+
env,
|
|
1250
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1251
|
+
});
|
|
1252
|
+
if (listed.status !== 0) throw new Error(String(listed.stderr || 'could not read verified tree').trim());
|
|
1253
|
+
const entries = String(listed.stdout || '').split('\0').filter(Boolean).map((line) => {
|
|
1254
|
+
const match = line.match(/^([0-7]{6}) (blob|commit) ([0-9a-f]{40,64})\t([^]*)$/);
|
|
1255
|
+
if (!match) throw new Error('verified tree contains an unreadable entry');
|
|
1256
|
+
const [, mode, type, oid, relative] = match;
|
|
1257
|
+
if (type !== 'blob' || !['100644', '100755', '120000'].includes(mode)) {
|
|
1258
|
+
throw new Error(`verified tree entry is not a regular file or symlink: ${relative}`);
|
|
1259
|
+
}
|
|
1260
|
+
if (path.isAbsolute(relative) || relative.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
1261
|
+
throw new Error(`verified tree contains an unsafe path: ${relative}`);
|
|
1262
|
+
}
|
|
1263
|
+
if (!/^[\x20-\x7e]+$/.test(relative)) {
|
|
1264
|
+
throw new Error(`verified tree contains a non-ASCII path that cannot be imported safely: ${relative}`);
|
|
1265
|
+
}
|
|
1266
|
+
const folded = relative.toLowerCase();
|
|
1267
|
+
if (folded === '.atris' || folded === '.atris/agent-worktree.json' || folded === '.git' || folded.startsWith('.git/')) {
|
|
1268
|
+
throw new Error('verified tree conflicts with Atris worktree metadata');
|
|
1269
|
+
}
|
|
1270
|
+
return { mode, oid, relative };
|
|
1271
|
+
});
|
|
1272
|
+
const uniqueOids = [...new Set(entries.map((entry) => entry.oid))];
|
|
1273
|
+
const batch = spawnSync('git', ['--git-dir', gitDir, 'cat-file', '--batch'], {
|
|
1274
|
+
encoding: null,
|
|
1275
|
+
env,
|
|
1276
|
+
input: `${uniqueOids.join('\n')}\n`,
|
|
1277
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
1278
|
+
});
|
|
1279
|
+
if (batch.status !== 0) throw new Error(String(batch.stderr || 'could not read verified blobs').trim());
|
|
1280
|
+
const blobs = new Map();
|
|
1281
|
+
let offset = 0;
|
|
1282
|
+
for (const requestedOid of uniqueOids) {
|
|
1283
|
+
const newline = batch.stdout.indexOf(0x0a, offset);
|
|
1284
|
+
if (newline === -1) throw new Error('verified blob batch ended before its header');
|
|
1285
|
+
const header = batch.stdout.subarray(offset, newline).toString('utf8').split(/\s+/);
|
|
1286
|
+
const size = Number(header[2]);
|
|
1287
|
+
if (header[0] !== requestedOid || header[1] !== 'blob' || !Number.isSafeInteger(size) || size < 0) {
|
|
1288
|
+
throw new Error('verified blob batch returned an invalid object');
|
|
1289
|
+
}
|
|
1290
|
+
const start = newline + 1;
|
|
1291
|
+
const end = start + size;
|
|
1292
|
+
if (end >= batch.stdout.length || batch.stdout[end] !== 0x0a) throw new Error('verified blob batch returned a truncated object');
|
|
1293
|
+
blobs.set(requestedOid, Buffer.from(batch.stdout.subarray(start, end)));
|
|
1294
|
+
offset = end + 1;
|
|
1295
|
+
}
|
|
1296
|
+
return entries.map((entry) => ({ ...entry, data: blobs.get(entry.oid) }));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function materializeVerifiedTree(landingPath, baseEntries, sourceEntries) {
|
|
1300
|
+
const within = (relative) => {
|
|
1301
|
+
const absolute = path.resolve(landingPath, relative);
|
|
1302
|
+
if (absolute !== landingPath && !absolute.startsWith(`${path.resolve(landingPath)}${path.sep}`)) {
|
|
1303
|
+
throw new Error(`verified tree path escapes the landing worktree: ${relative}`);
|
|
1304
|
+
}
|
|
1305
|
+
return absolute;
|
|
1306
|
+
};
|
|
1307
|
+
for (const entry of [...baseEntries].sort((a, b) => b.relative.length - a.relative.length)) {
|
|
1308
|
+
fs.rmSync(within(entry.relative), { recursive: true, force: true });
|
|
1309
|
+
}
|
|
1310
|
+
for (const entry of sourceEntries) {
|
|
1311
|
+
const target = within(entry.relative);
|
|
1312
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1313
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
1314
|
+
if (entry.mode === '120000') {
|
|
1315
|
+
fs.symlinkSync(entry.data.toString('utf8'), target);
|
|
1316
|
+
} else {
|
|
1317
|
+
fs.writeFileSync(target, entry.data, { mode: entry.mode === '100755' ? 0o755 : 0o644 });
|
|
1318
|
+
fs.chmodSync(target, entry.mode === '100755' ? 0o755 : 0o644);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const sourceByPath = new Map(sourceEntries.map((entry) => [entry.relative, entry]));
|
|
1322
|
+
for (const entry of sourceEntries) {
|
|
1323
|
+
const target = within(entry.relative);
|
|
1324
|
+
const stat = fs.lstatSync(target);
|
|
1325
|
+
if (entry.mode === '120000') {
|
|
1326
|
+
if (!stat.isSymbolicLink() || fs.readlinkSync(target) !== entry.data.toString('utf8')) {
|
|
1327
|
+
throw new Error(`materialized symlink does not match verified tree: ${entry.relative}`);
|
|
1328
|
+
}
|
|
1329
|
+
} else if (!stat.isFile() || !fs.readFileSync(target).equals(entry.data) || Boolean(stat.mode & 0o111) !== (entry.mode === '100755')) {
|
|
1330
|
+
throw new Error(`materialized file does not match verified tree: ${entry.relative}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
for (const entry of baseEntries) {
|
|
1334
|
+
if (!sourceByPath.has(entry.relative) && fs.existsSync(within(entry.relative))) {
|
|
1335
|
+
throw new Error(`deleted verified path remains in landing worktree: ${entry.relative}`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function persistVerifiedCommit(boundary, landingPath, headOid) {
|
|
1341
|
+
const transferDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-one-lap-proof-'));
|
|
1342
|
+
const bundle = path.join(transferDir, 'verified.bundle');
|
|
1343
|
+
const proofRef = `refs/atris/one-lap/${headOid}`;
|
|
1344
|
+
const env = trustedGitEnvironment();
|
|
1345
|
+
try {
|
|
1346
|
+
let result = spawnSync('git', ['bundle', 'create', bundle, 'HEAD', `^${boundary.baseOid}`], {
|
|
1347
|
+
cwd: boundary.worktreePath,
|
|
1348
|
+
encoding: 'utf8',
|
|
1349
|
+
env,
|
|
1350
|
+
timeout: 60000,
|
|
1351
|
+
});
|
|
1352
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not export proof objects').trim());
|
|
1353
|
+
result = spawnSync('git', ['bundle', 'unbundle', bundle], {
|
|
1354
|
+
cwd: landingPath,
|
|
1355
|
+
encoding: 'utf8',
|
|
1356
|
+
env,
|
|
1357
|
+
timeout: 60000,
|
|
1358
|
+
});
|
|
1359
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'could not import proof objects').trim());
|
|
1360
|
+
result = spawnSync('git', ['update-ref', proofRef, headOid], { cwd: landingPath, encoding: 'utf8', env });
|
|
1361
|
+
if (result.status !== 0) throw new Error(String(result.stderr || 'could not retain proof ref').trim());
|
|
1362
|
+
result = spawnSync('git', ['cat-file', '-e', `${headOid}^{commit}`], { cwd: landingPath, encoding: 'utf8', env });
|
|
1363
|
+
if (result.status !== 0) throw new Error('the retained proof commit is not readable from the review worktree');
|
|
1364
|
+
return proofRef;
|
|
1365
|
+
} finally {
|
|
1366
|
+
fs.rmSync(transferDir, { recursive: true, force: true });
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function bindOneLapProof(worktreePath, proofRef, sourceCommit) {
|
|
1371
|
+
const env = trustedGitEnvironment();
|
|
1372
|
+
const branchResult = spawnSync('git', ['branch', '--show-current'], { cwd: worktreePath, encoding: 'utf8', env });
|
|
1373
|
+
const treeResult = spawnSync('git', ['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: worktreePath, encoding: 'utf8', env });
|
|
1374
|
+
const branch = String(branchResult.stdout || '').trim();
|
|
1375
|
+
const sourceTree = String(treeResult.stdout || '').trim();
|
|
1376
|
+
if (branchResult.status !== 0 || !branch || treeResult.status !== 0 || !/^[0-9a-f]{40,64}$/.test(sourceTree)) {
|
|
1377
|
+
throw new Error('could not bind the review worktree to its verified proof tree');
|
|
1378
|
+
}
|
|
1379
|
+
const sidecarPath = path.join(worktreePath, '.atris', 'agent-worktree.json');
|
|
1380
|
+
let sidecar;
|
|
1381
|
+
try {
|
|
1382
|
+
sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
|
|
1383
|
+
} catch {
|
|
1384
|
+
throw new Error('could not read review worktree metadata for proof binding');
|
|
1385
|
+
}
|
|
1386
|
+
sidecar.one_lap_proof = {
|
|
1387
|
+
schema: 'atris.one_lap_proof.v1',
|
|
1388
|
+
ref: proofRef,
|
|
1389
|
+
commit: sourceCommit,
|
|
1390
|
+
tree: sourceTree,
|
|
1391
|
+
};
|
|
1392
|
+
fs.writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
|
|
1393
|
+
const configured = spawnSync('git', ['config', `branch.${branch}.atris-proof-ref`, proofRef], {
|
|
1394
|
+
cwd: worktreePath,
|
|
1395
|
+
encoding: 'utf8',
|
|
1396
|
+
env,
|
|
1397
|
+
});
|
|
1398
|
+
if (configured.status !== 0) throw new Error(String(configured.stderr || 'could not persist one-lap proof binding').trim());
|
|
1399
|
+
return sourceTree;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function importReviewCommit(boundary, { cli, taskId, engine, expectedCommit = '', expectedTree = '', startBaseArgs = [] } = {}) {
|
|
1403
|
+
const fail = (stage, detail, extra = {}) => ({ ok: false, stage, detail: String(detail || '').trim().slice(-500), ...extra });
|
|
1404
|
+
const runSandbox = (args) => spawnSync('git', args, {
|
|
1405
|
+
cwd: boundary.worktreePath,
|
|
1406
|
+
encoding: 'utf8',
|
|
1407
|
+
env: trustedGitEnvironment(),
|
|
1408
|
+
timeout: 30000,
|
|
1409
|
+
});
|
|
1410
|
+
const boundaryState = reviewRemoteBoundaryState(boundary.worktreePath, boundary);
|
|
1411
|
+
if (!boundaryState.ok) return boundaryState;
|
|
1412
|
+
if (!expectedCommit || !expectedTree) return fail('candidate_changed', 'the verified candidate identity was not frozen before import');
|
|
1413
|
+
const candidate = reviewCandidateSnapshot(boundary, { commit: expectedCommit, tree: expectedTree });
|
|
1414
|
+
if (!candidate.ok) return candidate;
|
|
1415
|
+
const status = runSandbox(['status', '--porcelain=v1', '--untracked-files=all']);
|
|
1416
|
+
if (status.status !== 0) return fail('review_import', status.stderr || 'could not inspect the sealed sandbox');
|
|
1417
|
+
const dirtyEntries = String(status.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
1418
|
+
const conductorEntries = /^\?\? \.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
|
|
1419
|
+
const uncommitted = dirtyEntries.filter((line) => !conductorEntries.test(line));
|
|
1420
|
+
if (uncommitted.length) return fail('uncommitted_change', `the sealed executor must commit every requested change before Review: ${uncommitted.join(', ')}`);
|
|
1421
|
+
const headOid = expectedCommit;
|
|
1422
|
+
if (headOid === boundary.baseOid) return fail('no_change', 'the executor produced no committed change');
|
|
1423
|
+
if (runSandbox(['merge-base', '--is-ancestor', boundary.baseOid, headOid]).status !== 0) {
|
|
1424
|
+
return fail('review_import', 'the verified commit is not descended from the protected snapshot');
|
|
1425
|
+
}
|
|
1426
|
+
const refs = spawnSync('git', ['--git-dir', boundary.gitDir, 'show-ref'], { encoding: 'utf8', env: trustedGitEnvironment() });
|
|
1427
|
+
if (refs.status !== 0) return fail('review_import', refs.stderr || 'could not inspect sealed refs');
|
|
1428
|
+
const allowedRefs = new Map([
|
|
1429
|
+
[`refs/heads/${boundary.branch}`, headOid],
|
|
1430
|
+
['refs/remotes/origin/HEAD', boundary.baseOid],
|
|
1431
|
+
['refs/remotes/origin/master', boundary.baseOid],
|
|
1432
|
+
]);
|
|
1433
|
+
const refLines = String(refs.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
1434
|
+
const seenRefs = new Set();
|
|
1435
|
+
for (const line of refLines) {
|
|
1436
|
+
const [oid, ref] = line.split(/\s+/, 2);
|
|
1437
|
+
if (!allowedRefs.has(ref) || allowedRefs.get(ref) !== oid) return fail('sandbox_metadata_changed', `the worker created an unexpected Git ref (${ref || 'unknown'})`);
|
|
1438
|
+
seenRefs.add(ref);
|
|
1439
|
+
}
|
|
1440
|
+
for (const ref of [`refs/heads/${boundary.branch}`, 'refs/remotes/origin/master']) {
|
|
1441
|
+
if (!seenRefs.has(ref)) return fail('sandbox_metadata_changed', `the sealed sandbox is missing a required Git ref (${ref})`);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
let baseEntries;
|
|
1445
|
+
let sourceEntries;
|
|
1446
|
+
try {
|
|
1447
|
+
baseEntries = readGitTree(boundary.gitDir, boundary.baseOid);
|
|
1448
|
+
sourceEntries = readGitTree(boundary.gitDir, headOid);
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
return fail('review_import', error.message);
|
|
1451
|
+
}
|
|
1452
|
+
const baseByPath = new Map(baseEntries.map((entry) => [entry.relative, entry]));
|
|
1453
|
+
const sourceByPath = new Map(sourceEntries.map((entry) => [entry.relative, entry]));
|
|
1454
|
+
const changedEntries = [...new Set([...baseByPath.keys(), ...sourceByPath.keys()])]
|
|
1455
|
+
.filter((relative) => {
|
|
1456
|
+
const before = baseByPath.get(relative);
|
|
1457
|
+
const after = sourceByPath.get(relative);
|
|
1458
|
+
return !before || !after || before.oid !== after.oid || before.mode !== after.mode;
|
|
1459
|
+
})
|
|
1460
|
+
.sort()
|
|
1461
|
+
.map((relative) => `${baseByPath.has(relative) ? (sourceByPath.has(relative) ? 'M' : 'D') : 'A'} ${relative}`);
|
|
1462
|
+
if (!changedEntries.length) return fail('no_change', 'the verified commit has no tree change');
|
|
1463
|
+
|
|
1464
|
+
const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${String(taskId).toLowerCase()}`, ...startBaseArgs]);
|
|
1465
|
+
const landingPath = (String(started && started.stdout || '').match(/next: cd (.+)/) || [])[1];
|
|
1466
|
+
if (!landingPath) return fail('worktree_start', String(started && (started.stderr || started.stdout) || 'could not create review worktree'));
|
|
1467
|
+
const worktreePath = landingPath.trim();
|
|
1468
|
+
const landingHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
|
|
1469
|
+
cwd: worktreePath,
|
|
1470
|
+
encoding: 'utf8',
|
|
1471
|
+
env: trustedGitEnvironment(),
|
|
1472
|
+
});
|
|
1473
|
+
if (landingHead.status !== 0 || String(landingHead.stdout || '').trim() !== boundary.baseOid) {
|
|
1474
|
+
return fail('review_import', 'the review worktree was not cut from the verified base', { worktreePath });
|
|
1475
|
+
}
|
|
1476
|
+
let proofRef;
|
|
1477
|
+
let proofTree;
|
|
1478
|
+
try {
|
|
1479
|
+
proofRef = persistVerifiedCommit(boundary, worktreePath, headOid);
|
|
1480
|
+
proofTree = bindOneLapProof(worktreePath, proofRef, headOid);
|
|
1481
|
+
materializeVerifiedTree(worktreePath, baseEntries, sourceEntries);
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
return fail('review_import', error.message, { worktreePath });
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
ok: true,
|
|
1487
|
+
stage: 'verified_for_review',
|
|
1488
|
+
worktreePath,
|
|
1489
|
+
sourceWorktreePath: boundary.worktreePath,
|
|
1490
|
+
head: headOid,
|
|
1491
|
+
proof_ref: proofRef,
|
|
1492
|
+
proof_tree: proofTree,
|
|
1493
|
+
protected_master: boundary.protectedMaster,
|
|
1494
|
+
change: {
|
|
1495
|
+
has_change: true,
|
|
1496
|
+
base: boundary.baseOid,
|
|
1497
|
+
head: boundary.baseOid,
|
|
1498
|
+
source_commit: headOid,
|
|
1499
|
+
proof_ref: proofRef,
|
|
1500
|
+
proof_tree: proofTree,
|
|
1501
|
+
commit: headOid,
|
|
1502
|
+
dirty: true,
|
|
1503
|
+
changed_entries: changedEntries.slice(0, 100),
|
|
1504
|
+
},
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function disposeReviewSandbox(boundary) {
|
|
1509
|
+
spawnSync('git', ['--git-dir', boundary.gitDir, 'worktree', 'remove', '--force', boundary.worktreePath], { encoding: 'utf8' });
|
|
1510
|
+
fs.rmSync(boundary.worktreePath, { recursive: true, force: true });
|
|
1511
|
+
fs.rmSync(boundary.gitDir, { recursive: true, force: true });
|
|
1512
|
+
fs.rmSync(boundary.quarantine, { recursive: true, force: true });
|
|
1513
|
+
fs.rmSync(boundary.sandboxRoot, { recursive: true, force: true });
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function inspectReviewChange(worktreePath, baseRef = 'origin/master') {
|
|
1517
|
+
const run = (args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8', env: trustedGitEnvironment() });
|
|
1518
|
+
const base = run(['rev-parse', '--verify', `${baseRef}^{commit}`]);
|
|
1519
|
+
const head = run(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
1520
|
+
if (base.status !== 0 || head.status !== 0) {
|
|
1521
|
+
return {
|
|
1522
|
+
has_change: false,
|
|
1523
|
+
detail: String(base.stderr || head.stderr || 'could not resolve the review change boundary').trim(),
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
const baseOid = String(base.stdout || '').trim();
|
|
1527
|
+
const headOid = String(head.stdout || '').trim();
|
|
1528
|
+
const committed = run(['diff', '--quiet', baseOid, headOid, '--']);
|
|
1529
|
+
if (committed.status !== 0 && committed.status !== 1) {
|
|
1530
|
+
return {
|
|
1531
|
+
has_change: false,
|
|
1532
|
+
base: baseOid,
|
|
1533
|
+
head: headOid,
|
|
1534
|
+
detail: String(committed.stderr || 'could not inspect committed changes').trim(),
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const status = run(['status', '--porcelain=v1', '--untracked-files=all']);
|
|
1538
|
+
if (status.status !== 0) {
|
|
1539
|
+
return {
|
|
1540
|
+
has_change: false,
|
|
1541
|
+
base: baseOid,
|
|
1542
|
+
head: headOid,
|
|
1543
|
+
detail: String(status.stderr || 'could not inspect worktree changes').trim(),
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
const conductorEntry = /^\?\? \.atris\/(?:agent-worktree\.json|state\/briefs\.jsonl)$/;
|
|
1547
|
+
const dirty = String(status.stdout || '').trim().split('\n').filter(Boolean)
|
|
1548
|
+
.filter((line) => !conductorEntry.test(line));
|
|
1549
|
+
const hasCommittedDiff = committed.status === 1;
|
|
1550
|
+
return {
|
|
1551
|
+
has_change: hasCommittedDiff || dirty.length > 0,
|
|
1552
|
+
base: baseOid,
|
|
1553
|
+
head: headOid,
|
|
1554
|
+
commit: hasCommittedDiff ? headOid : null,
|
|
1555
|
+
dirty: dirty.length > 0,
|
|
1556
|
+
changed_entries: dirty.slice(0, 100),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function reviewWorktreeSnapshot(worktreePath) {
|
|
1561
|
+
const run = (args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8', env: trustedGitEnvironment() });
|
|
1562
|
+
const head = run(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
1563
|
+
const status = run(['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
1564
|
+
const diff = run(['diff', '--binary', 'HEAD', '--']);
|
|
1565
|
+
const untracked = run(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
1566
|
+
const localConfig = run(['config', '--local', '--null', '--list', '--show-origin']);
|
|
1567
|
+
const worktreeConfig = run(['config', '--worktree', '--null', '--list', '--show-origin']);
|
|
1568
|
+
const refs = run(['show-ref', '--head']);
|
|
1569
|
+
const failed = [head, status, diff, untracked, localConfig, worktreeConfig, refs]
|
|
1570
|
+
.find((result) => result.status !== 0);
|
|
1571
|
+
if (failed) {
|
|
1572
|
+
return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
detail: String(failed.stderr || failed.stdout || 'could not snapshot validator worktree').trim(),
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
const hash = crypto.createHash('sha256');
|
|
1578
|
+
hash.update(String(head.stdout || ''));
|
|
1579
|
+
hash.update('\0status\0');
|
|
1580
|
+
hash.update(String(status.stdout || ''));
|
|
1581
|
+
hash.update('\0diff\0');
|
|
1582
|
+
hash.update(String(diff.stdout || ''));
|
|
1583
|
+
hash.update('\0local-config\0');
|
|
1584
|
+
hash.update(String(localConfig.stdout || ''));
|
|
1585
|
+
hash.update('\0worktree-config\0');
|
|
1586
|
+
hash.update(String(worktreeConfig.stdout || ''));
|
|
1587
|
+
hash.update('\0refs\0');
|
|
1588
|
+
hash.update(String(refs.stdout || ''));
|
|
1589
|
+
for (const relative of String(untracked.stdout || '').split('\0').filter(Boolean).sort()) {
|
|
1590
|
+
hash.update('\0untracked\0');
|
|
1591
|
+
hash.update(relative);
|
|
1592
|
+
const absolute = path.resolve(worktreePath, relative);
|
|
1593
|
+
try {
|
|
1594
|
+
const info = fs.lstatSync(absolute);
|
|
1595
|
+
if (info.isSymbolicLink()) hash.update(`symlink:${fs.readlinkSync(absolute)}`);
|
|
1596
|
+
else if (info.isFile()) hash.update(fs.readFileSync(absolute));
|
|
1597
|
+
else hash.update(`mode:${info.mode}:size:${info.size}`);
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
return { ok: false, detail: `could not hash validator worktree entry ${relative}: ${error.message}` };
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
ok: true,
|
|
1604
|
+
head: String(head.stdout || '').trim(),
|
|
1605
|
+
status: String(status.stdout || ''),
|
|
1606
|
+
digest: hash.digest('hex'),
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
async function runIndependentValidator({
|
|
1611
|
+
root,
|
|
1612
|
+
task,
|
|
1613
|
+
worktreePath,
|
|
1614
|
+
executorEngine,
|
|
1615
|
+
verifierCommand,
|
|
1616
|
+
validatorEngines,
|
|
1617
|
+
validatorDispatcher = null,
|
|
1618
|
+
stateInspector = reviewWorktreeSnapshot,
|
|
1619
|
+
}) {
|
|
1620
|
+
const candidates = [...new Set((validatorEngines || []).map((value) => String(value || '').trim()).filter(Boolean))]
|
|
1621
|
+
.filter((name) => RUNNER_PROFILE_DEFS[name] && name !== executorEngine);
|
|
1622
|
+
if (!candidates.length) {
|
|
1623
|
+
return {
|
|
1624
|
+
ok: false,
|
|
1625
|
+
stage: 'validator_unavailable',
|
|
1626
|
+
validator_result: {
|
|
1627
|
+
engine: null,
|
|
1628
|
+
executor_engine: executorEngine,
|
|
1629
|
+
independent: false,
|
|
1630
|
+
passed: false,
|
|
1631
|
+
verdict: 'unavailable',
|
|
1632
|
+
reason: 'no distinct ready validator is available',
|
|
1633
|
+
exit_code: null,
|
|
1634
|
+
output: '',
|
|
1635
|
+
brief_id: null,
|
|
1636
|
+
worktree_unchanged: null,
|
|
1637
|
+
},
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
const dispatch = validatorDispatcher || ((entry) => Promise.resolve(dispatchToEngine({
|
|
1641
|
+
task,
|
|
1642
|
+
engine: entry.engine,
|
|
1643
|
+
worktreePath,
|
|
1644
|
+
root,
|
|
1645
|
+
prompt: entry.prompt,
|
|
1646
|
+
environment: reviewOnlyEngineEnvironment(worktreePath, {
|
|
1647
|
+
engine: entry.engine,
|
|
1648
|
+
network: true,
|
|
1649
|
+
writable: false,
|
|
1650
|
+
}),
|
|
1651
|
+
sealed: true,
|
|
1652
|
+
allowedTools: VALIDATOR_ALLOWED_TOOLS,
|
|
1653
|
+
skipBriefCapture: true,
|
|
1654
|
+
})));
|
|
1655
|
+
|
|
1656
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
1657
|
+
const validatorEngine = candidates[index];
|
|
1658
|
+
const prompt = buildOneLapValidatorPrompt(task, { verifierCommand, executorEngine });
|
|
1659
|
+
const before = stateInspector(worktreePath);
|
|
1660
|
+
if (!before || before.ok !== true) {
|
|
1661
|
+
return {
|
|
1662
|
+
ok: false,
|
|
1663
|
+
stage: 'validator_snapshot_failed',
|
|
1664
|
+
validator_result: {
|
|
1665
|
+
engine: validatorEngine,
|
|
1666
|
+
executor_engine: executorEngine,
|
|
1667
|
+
independent: true,
|
|
1668
|
+
passed: false,
|
|
1669
|
+
verdict: 'invalid',
|
|
1670
|
+
reason: String(before && before.detail || 'could not snapshot worktree before validation'),
|
|
1671
|
+
exit_code: null,
|
|
1672
|
+
output: '',
|
|
1673
|
+
brief_id: null,
|
|
1674
|
+
worktree_unchanged: null,
|
|
1675
|
+
},
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
let dispatched;
|
|
1679
|
+
try {
|
|
1680
|
+
dispatched = normalizeDispatchResult(await dispatch({
|
|
1681
|
+
task,
|
|
1682
|
+
engine: validatorEngine,
|
|
1683
|
+
worktreePath,
|
|
1684
|
+
prompt,
|
|
1685
|
+
}), validatorEngine);
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
dispatched = normalizeDispatchResult({ exitCode: 1, stderr: error.message || String(error) }, validatorEngine);
|
|
1688
|
+
}
|
|
1689
|
+
const after = stateInspector(worktreePath);
|
|
1690
|
+
const output = dispatchResultOutput(dispatched).slice(-8000);
|
|
1691
|
+
const verdictOutput = [dispatched.report, dispatched.stdout]
|
|
1692
|
+
.map((value) => String(value || ''))
|
|
1693
|
+
.filter(Boolean)
|
|
1694
|
+
.join('\n');
|
|
1695
|
+
const verdict = parseOneLapValidatorVerdict(verdictOutput);
|
|
1696
|
+
const unchanged = Boolean(after && after.ok === true && before.digest === after.digest);
|
|
1697
|
+
const exitCode = dispatchResultExitCode(dispatched);
|
|
1698
|
+
const validatorResult = {
|
|
1699
|
+
engine: validatorEngine,
|
|
1700
|
+
executor_engine: executorEngine,
|
|
1701
|
+
independent: validatorEngine !== executorEngine,
|
|
1702
|
+
passed: exitCode === 0 && verdict.passed === true && unchanged,
|
|
1703
|
+
verdict: verdict.verdict,
|
|
1704
|
+
reason: unchanged ? verdict.reason : String(after && after.detail || 'validator changed the worktree'),
|
|
1705
|
+
exit_code: exitCode,
|
|
1706
|
+
output,
|
|
1707
|
+
brief_id: dispatched.brief_id || null,
|
|
1708
|
+
worktree_unchanged: unchanged,
|
|
1709
|
+
};
|
|
1710
|
+
if (!unchanged) return { ok: false, stage: 'validator_mutated_worktree', validator_result: validatorResult };
|
|
1711
|
+
if (exitCode !== 0) {
|
|
1712
|
+
const outage = detectDeadEngineDispatch(dispatched);
|
|
1713
|
+
if (outage && outage.reason === 'usage_limit' && index + 1 < candidates.length) continue;
|
|
1714
|
+
validatorResult.reason = String(dispatched.stderr || verdict.reason || `validator exited ${exitCode}`).trim().slice(-500);
|
|
1715
|
+
return { ok: false, stage: 'validator_failed', validator_result: validatorResult };
|
|
1716
|
+
}
|
|
1717
|
+
if (verdict.verdict === 'reject') return { ok: false, stage: 'validation_rejected', validator_result: validatorResult };
|
|
1718
|
+
if (!verdict.passed) return { ok: false, stage: 'validator_failed', validator_result: validatorResult };
|
|
1719
|
+
return { ok: true, stage: 'validator_signed_off', validator_result: validatorResult };
|
|
1720
|
+
}
|
|
1721
|
+
return { ok: false, stage: 'validator_failed', validator_result: null };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary }) {
|
|
1725
|
+
const validatorResult = flight.validator_result || flight.result?.validator_result;
|
|
1726
|
+
const successfulRows = reviewOnly ? flight.ready : flight.landed;
|
|
1727
|
+
const successfulVerifierRows = successfulRows
|
|
1728
|
+
.map((row) => row.verifier_result)
|
|
1729
|
+
.filter((row) => row && typeof row === 'object');
|
|
1730
|
+
const verifierRows = [
|
|
1731
|
+
...successfulVerifierRows,
|
|
1732
|
+
...flight.paused.map((row) => row.verifier_result).filter((row) => row && typeof row === 'object'),
|
|
1733
|
+
];
|
|
1734
|
+
const basePassed = flight.paused.length === 0
|
|
1735
|
+
&& successfulRows.length === ids.length
|
|
1736
|
+
&& successfulVerifierRows.length === ids.length
|
|
1737
|
+
&& successfulVerifierRows.every((row) => row.passed === true);
|
|
1738
|
+
const oneLapReview = reviewOnly && flight.context && flight.context.source === 'one_lap';
|
|
1739
|
+
const validatorPassed = validatorResult
|
|
1740
|
+
&& validatorResult.passed === true
|
|
1741
|
+
&& validatorResult.independent === true
|
|
1742
|
+
&& validatorResult.worktree_unchanged === true
|
|
1743
|
+
&& validatorResult.engine
|
|
1744
|
+
&& validatorResult.executor_engine
|
|
1745
|
+
&& validatorResult.engine !== validatorResult.executor_engine;
|
|
1746
|
+
const passed = basePassed && (!oneLapReview || validatorPassed === true);
|
|
1747
|
+
const result = {
|
|
1748
|
+
kind: reviewOnly ? 'dispatch_review_ready' : 'dispatch_landed',
|
|
1749
|
+
passed,
|
|
1750
|
+
verifier_result: verifierRows.length === 1
|
|
1751
|
+
? verifierRows[0]
|
|
1752
|
+
: { passed, checks: verifierRows },
|
|
1753
|
+
};
|
|
1754
|
+
if (reviewOnly) {
|
|
1755
|
+
result.master_boundary_enforced = enforceRemoteBoundary;
|
|
1756
|
+
result.master_unchanged = enforceRemoteBoundary
|
|
1757
|
+
? flight.ready.length === ids.length && flight.ready.every((row) => row.master_before && row.master_before === row.master_after)
|
|
1758
|
+
: null;
|
|
1759
|
+
}
|
|
1760
|
+
if (validatorResult && typeof validatorResult === 'object') {
|
|
1761
|
+
result.validator_result = validatorResult;
|
|
1762
|
+
}
|
|
1763
|
+
return result;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function writeDispatchReceipt(flight, receiptPath, resultOptions) {
|
|
1767
|
+
flight.result = dispatchReceiptResult(flight, resultOptions);
|
|
1768
|
+
flight.receipt = receiptPath;
|
|
1769
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
1770
|
+
const tempPath = `${receiptPath}.tmp-${process.pid}`;
|
|
1771
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(flight, null, 2)}\n`);
|
|
1772
|
+
fs.renameSync(tempPath, receiptPath);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function stampDispatchResultVerification(flight, taskId, engine, startedAtMs, verifierResult) {
|
|
1776
|
+
if (!verifierResult || typeof verifierResult.passed !== 'boolean') return;
|
|
1777
|
+
const row = flight.results.find((result) => result.task === taskId);
|
|
1778
|
+
if (!row) return;
|
|
1779
|
+
const completedAtMs = Date.now();
|
|
1780
|
+
row.engine = engine;
|
|
1781
|
+
row.verified_passed = verifierResult.passed;
|
|
1782
|
+
row.duration_ms = Math.max(0, completedAtMs - startedAtMs);
|
|
1783
|
+
row.at = new Date(completedAtMs).toISOString();
|
|
224
1784
|
}
|
|
225
1785
|
|
|
226
1786
|
function readProjectionTasks(root) {
|
|
@@ -245,8 +1805,119 @@ function defaultOwnCli(root) {
|
|
|
245
1805
|
// gates (npm test, CI) stay at the ship gate, not per-arrival.
|
|
246
1806
|
function focusedCheck(task) {
|
|
247
1807
|
const { check } = parseDoneCheck(task.title);
|
|
248
|
-
const m = check.match(/node --test
|
|
249
|
-
|
|
1808
|
+
const m = check.match(/node --test \S+/);
|
|
1809
|
+
if (!m) return '';
|
|
1810
|
+
let cmd = m[0].trim();
|
|
1811
|
+
// Board convention often ends the Check: sentence with a trailing period
|
|
1812
|
+
// right after the test file path (e.g. "node --test test/x.test.js."). A
|
|
1813
|
+
// naive [^.]+ stop would also truncate the ".test.js" extension itself, so
|
|
1814
|
+
// only strip the LAST period, and only when the path still ends in a real
|
|
1815
|
+
// extension without it (otherwise the period is part of the path).
|
|
1816
|
+
if (cmd.endsWith('.')) {
|
|
1817
|
+
const stripped = cmd.slice(0, -1);
|
|
1818
|
+
if (/\.(js|mjs|cjs|ts|tsx)$/.test(stripped)) cmd = stripped;
|
|
1819
|
+
}
|
|
1820
|
+
return cmd;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// One-command dispatch runs a single explicit task, so the wide-gate caution
|
|
1824
|
+
// behind focusedCheck (many staffed tasks, keep per-arrival checks narrow)
|
|
1825
|
+
// does not apply: fall back to the task's whole Check: text so the operator's
|
|
1826
|
+
// actual check command is what gets re-run as verification.
|
|
1827
|
+
function dispatchCheck(task) {
|
|
1828
|
+
const narrow = focusedCheck(task);
|
|
1829
|
+
if (narrow) return narrow;
|
|
1830
|
+
const { check } = parseDoneCheck(task.title);
|
|
1831
|
+
return check;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function readTaskById(cli, taskId) {
|
|
1835
|
+
const result = cli(['task', 'show', String(taskId), '--json']);
|
|
1836
|
+
if (!result || result.status !== 0) return null;
|
|
1837
|
+
try {
|
|
1838
|
+
const parsed = JSON.parse(result.stdout);
|
|
1839
|
+
return parsed && parsed.display_id ? parsed : null;
|
|
1840
|
+
} catch {
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function defaultVerifyRunner(command, cwd) {
|
|
1846
|
+
const result = spawnSync(command, { cwd, encoding: 'utf8', shell: true });
|
|
1847
|
+
return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function defaultTrustedVerifyRunner(command, cwd) {
|
|
1851
|
+
const parsed = require('./auto-accept-certified').parseVerifyCommand(command);
|
|
1852
|
+
if (!parsed.ok) return { status: 2, stdout: '', stderr: parsed.reason };
|
|
1853
|
+
const worktreeRoot = path.resolve(cwd);
|
|
1854
|
+
const insideWorktree = (target) => target === worktreeRoot || target.startsWith(`${worktreeRoot}${path.sep}`);
|
|
1855
|
+
const commandCwd = parsed.cwd ? path.resolve(worktreeRoot, parsed.cwd) : worktreeRoot;
|
|
1856
|
+
if (!insideWorktree(commandCwd)) {
|
|
1857
|
+
return { status: 2, stdout: '', stderr: 'verify_workdir_outside_worktree' };
|
|
1858
|
+
}
|
|
1859
|
+
if (parsed.argv[0] === 'git' && parsed.argv[1] === '-C') {
|
|
1860
|
+
const gitCwd = path.resolve(commandCwd, parsed.argv[2]);
|
|
1861
|
+
if (!insideWorktree(gitCwd)) {
|
|
1862
|
+
return { status: 2, stdout: '', stderr: 'verify_git_path_outside_worktree' };
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
let boundaryEnv;
|
|
1866
|
+
try {
|
|
1867
|
+
boundaryEnv = reviewOnlyEngineEnvironment(worktreeRoot);
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
return { status: 2, stdout: '', stderr: error.message || String(error) };
|
|
1870
|
+
}
|
|
1871
|
+
const sandboxProfile = String(boundaryEnv.ATRIS_ONE_LAP_SANDBOX_PROFILE || '');
|
|
1872
|
+
const runtimeDir = String(boundaryEnv.ATRIS_ONE_LAP_RUNTIME_DIR || '');
|
|
1873
|
+
const controlDir = String(boundaryEnv.ATRIS_ONE_LAP_CONTROL_DIR || '');
|
|
1874
|
+
const lifecycleWrapper = String(boundaryEnv.ATRIS_ONE_LAP_LIFECYCLE_WRAPPER || '');
|
|
1875
|
+
const statusFile = String(boundaryEnv.ATRIS_ONE_LAP_STATUS_FILE || '');
|
|
1876
|
+
delete boundaryEnv.ATRIS_ONE_LAP_SANDBOX_PROFILE;
|
|
1877
|
+
delete boundaryEnv.ATRIS_ONE_LAP_RUNTIME_DIR;
|
|
1878
|
+
delete boundaryEnv.ATRIS_ONE_LAP_CONTROL_DIR;
|
|
1879
|
+
delete boundaryEnv.ATRIS_ONE_LAP_LIFECYCLE_WRAPPER;
|
|
1880
|
+
delete boundaryEnv.ATRIS_ONE_LAP_STATUS_FILE;
|
|
1881
|
+
const executable = sandboxProfile ? '/usr/bin/sandbox-exec' : parsed.argv[0];
|
|
1882
|
+
const args = sandboxProfile
|
|
1883
|
+
? ['-p', sandboxProfile, lifecycleWrapper, parsed.argv[0], ...parsed.argv.slice(1)]
|
|
1884
|
+
: parsed.argv.slice(1);
|
|
1885
|
+
let result;
|
|
1886
|
+
try {
|
|
1887
|
+
const spawnOptions = {
|
|
1888
|
+
cwd: commandCwd,
|
|
1889
|
+
env: { ...(parsed.env || {}), ...boundaryEnv },
|
|
1890
|
+
encoding: 'utf8',
|
|
1891
|
+
shell: false,
|
|
1892
|
+
timeout: 120000,
|
|
1893
|
+
};
|
|
1894
|
+
result = sandboxProfile
|
|
1895
|
+
? runInReapedProcessGroup(executable, args, spawnOptions, controlDir, statusFile)
|
|
1896
|
+
: spawnSync(executable, args, spawnOptions);
|
|
1897
|
+
} finally {
|
|
1898
|
+
if (runtimeDir) fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
1899
|
+
if (controlDir) fs.rmSync(controlDir, { recursive: true, force: true });
|
|
1900
|
+
}
|
|
1901
|
+
return {
|
|
1902
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
1903
|
+
stdout: String(result.stdout || ''),
|
|
1904
|
+
stderr: String(result.stderr || result.error && result.error.message || ''),
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function defaultSelfLandCheck({ worktreePath, targetRef = DISPATCH_SELF_LAND_TARGET, git = null } = {}) {
|
|
1909
|
+
const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
|
|
1910
|
+
const branch = String(targetRef || '').startsWith('origin/') ? String(targetRef).slice('origin/'.length) : '';
|
|
1911
|
+
const fetch = run(branch ? ['fetch', 'origin', `${branch}:refs/remotes/origin/${branch}`] : ['fetch', 'origin']);
|
|
1912
|
+
if (fetch.status !== 0) {
|
|
1913
|
+
return { ok: false, stage: 'self_land_check', target: targetRef, detail: String(fetch.stderr || fetch.stdout || '').trim() };
|
|
1914
|
+
}
|
|
1915
|
+
const ancestor = run(['merge-base', '--is-ancestor', 'HEAD', targetRef]);
|
|
1916
|
+
if (ancestor.status === 0) return { ok: true, stage: 'self_landed', target: targetRef };
|
|
1917
|
+
if (ancestor.status === 1) {
|
|
1918
|
+
return { ok: false, stage: 'self_land_missing', target: targetRef, detail: `HEAD is not an ancestor of ${targetRef}` };
|
|
1919
|
+
}
|
|
1920
|
+
return { ok: false, stage: 'self_land_check', target: targetRef, detail: String(ancestor.stderr || ancestor.stdout || '').trim() };
|
|
250
1921
|
}
|
|
251
1922
|
|
|
252
1923
|
// One flight. Staff -> dispatch in parallel -> land serially -> receipt.
|
|
@@ -261,11 +1932,15 @@ async function runFleetFlight({
|
|
|
261
1932
|
ownCli = null,
|
|
262
1933
|
dispatcher = null,
|
|
263
1934
|
lander = null,
|
|
1935
|
+
checkoutBase = 'origin/master',
|
|
264
1936
|
} = {}) {
|
|
265
1937
|
const cli = ownCli || defaultOwnCli(root);
|
|
266
1938
|
const roster = engines || (() => {
|
|
267
1939
|
const { roster: fullRoster } = require('../commands/engine');
|
|
268
|
-
|
|
1940
|
+
const installed = fullRoster(root)
|
|
1941
|
+
.filter((e) => e.installed && FLEET_CAPABLE.includes(e.name))
|
|
1942
|
+
.map((e) => e.name);
|
|
1943
|
+
return rankFleetEngines(installed, root);
|
|
269
1944
|
})();
|
|
270
1945
|
|
|
271
1946
|
const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
|
|
@@ -295,13 +1970,29 @@ async function runFleetFlight({
|
|
|
295
1970
|
|
|
296
1971
|
// Claim + cut a worktree per assignment, then dispatch all in parallel.
|
|
297
1972
|
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
298
|
-
resolve(dispatchToEngine({
|
|
1973
|
+
resolve(dispatchToEngine({
|
|
1974
|
+
task: entry.task,
|
|
1975
|
+
engine: entry.engine,
|
|
1976
|
+
worktreePath: entry.worktreePath,
|
|
1977
|
+
root,
|
|
1978
|
+
prompt: entry.prompt,
|
|
1979
|
+
briefId: entry.brief_id,
|
|
1980
|
+
skipBriefCapture: true,
|
|
1981
|
+
}));
|
|
299
1982
|
}));
|
|
1983
|
+
const restaffState = { used: false };
|
|
300
1984
|
|
|
1985
|
+
// Cut every build worktree from origin/master by default, not the launcher's
|
|
1986
|
+
// HEAD. A flight launched from a long-lived feature-branch checkout would
|
|
1987
|
+
// otherwise stack all that branch's commits onto the build branch, so
|
|
1988
|
+
// rebase-before-ship replays them onto master and pauses at rebase_conflict
|
|
1989
|
+
// (three backend pauses on members.py, 2026-07-05). --base overrides only
|
|
1990
|
+
// when the operator explicitly wants launcher-HEAD.
|
|
1991
|
+
const startBaseArgs = checkoutBase ? ['--base', checkoutBase] : [];
|
|
301
1992
|
const prepared = [];
|
|
302
1993
|
for (const entry of staffed) {
|
|
303
1994
|
cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
|
|
304
|
-
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}
|
|
1995
|
+
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`, ...startBaseArgs]);
|
|
305
1996
|
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
306
1997
|
if (!wt) {
|
|
307
1998
|
flight.paused.push({ task: entry.task.display_id, stage: 'worktree_start', detail: started.stderr.slice(0, 200) });
|
|
@@ -312,16 +2003,29 @@ async function runFleetFlight({
|
|
|
312
2003
|
}
|
|
313
2004
|
|
|
314
2005
|
const results = await Promise.all(prepared.map((entry) =>
|
|
315
|
-
|
|
2006
|
+
dispatchEntryWithRestaff({
|
|
2007
|
+
entry,
|
|
2008
|
+
engine: entry.engine,
|
|
2009
|
+
root,
|
|
2010
|
+
dispatch,
|
|
2011
|
+
installedEngines: roster,
|
|
2012
|
+
restaffState,
|
|
2013
|
+
}).then((result) => ({ entry, result }))
|
|
316
2014
|
));
|
|
317
|
-
flight.results = results.map(({ entry, result }) =>
|
|
2015
|
+
flight.results = results.map(({ entry, result }) => {
|
|
2016
|
+
const row = { task: entry.task.display_id, engine: result.engine || entry.engine, exitCode: result.exitCode };
|
|
2017
|
+
if (result.brief_id) row.brief_id = result.brief_id;
|
|
2018
|
+
if (result.restaffed) row.restaffed = result.restaffed;
|
|
2019
|
+
if (result.deadEngine) row.deadEngine = result.deadEngine;
|
|
2020
|
+
return row;
|
|
2021
|
+
});
|
|
318
2022
|
|
|
319
2023
|
// Land serially: rebase-before-ship, conflict pauses (never auto-resolve).
|
|
320
2024
|
const land = lander || (({ entry }) => {
|
|
321
2025
|
const rebased = landArrival({ worktreePath: entry.worktreePath });
|
|
322
2026
|
if (!rebased.ok) return rebased;
|
|
323
2027
|
const check = focusedCheck(entry.task) || 'git log -1 --oneline';
|
|
324
|
-
const shipped = cli(
|
|
2028
|
+
const shipped = cli(fleetShipArgs(entry, check), entry.worktreePath);
|
|
325
2029
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
326
2030
|
return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
|
|
327
2031
|
}
|
|
@@ -329,20 +2033,38 @@ async function runFleetFlight({
|
|
|
329
2033
|
});
|
|
330
2034
|
|
|
331
2035
|
for (const { entry, result } of results) {
|
|
2036
|
+
const activeEngine = result.engine || entry.engine;
|
|
2037
|
+
const landingEntry = { ...entry, engine: activeEngine };
|
|
2038
|
+
if (result.restaffed) log(` restaffed ${entry.task.display_id}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.reason})`);
|
|
332
2039
|
if (result.exitCode !== 0) {
|
|
333
|
-
|
|
334
|
-
|
|
2040
|
+
const paused = { task: entry.task.display_id, engine: activeEngine, stage: 'build', detail: (result.stderr || '').slice(0, 200) };
|
|
2041
|
+
if (result.restaffed) paused.restaffed = result.restaffed;
|
|
2042
|
+
if (result.deadEngine) paused.deadEngine = result.deadEngine;
|
|
2043
|
+
flight.paused.push(paused);
|
|
2044
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `build failed for ${entry.task.display_id}`);
|
|
2045
|
+
log(` ${activeEngine.padEnd(8)} ✗ build failed ${entry.task.display_id} — worktree kept for takeover`);
|
|
335
2046
|
continue;
|
|
336
2047
|
}
|
|
337
|
-
log(` ${
|
|
338
|
-
const landed = land({ entry, result });
|
|
2048
|
+
log(` ${activeEngine.padEnd(8)} landing ${entry.task.display_id}...`);
|
|
2049
|
+
const landed = land({ entry: landingEntry, result });
|
|
339
2050
|
if (landed.ok) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
2051
|
+
const landedRow = { task: entry.task.display_id, engine: activeEngine };
|
|
2052
|
+
if (result.brief_id) landedRow.brief_id = result.brief_id;
|
|
2053
|
+
if (result.restaffed) landedRow.restaffed = result.restaffed;
|
|
2054
|
+
flight.landed.push(landedRow);
|
|
2055
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `landed ${entry.task.display_id} via fleet ship`);
|
|
2056
|
+
const restaffProof = result.restaffed ? ` Restaffed from ${result.restaffed.from} to ${result.restaffed.to} (${result.restaffed.reason}).` : '';
|
|
2057
|
+
cli([
|
|
2058
|
+
'task', 'ready', String(entry.task.display_id),
|
|
2059
|
+
'--proof', `Built by ${activeEngine} engine in fleet flight.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`,
|
|
2060
|
+
'--result', 'Operators can now review fleet-shipped work faster because the worktree was verified before landing.',
|
|
2061
|
+
'--as', `fleet-${activeEngine}`,
|
|
2062
|
+
]);
|
|
2063
|
+
log(` ${activeEngine.padEnd(8)} ✓ landed ${entry.task.display_id}`);
|
|
343
2064
|
} else {
|
|
344
|
-
flight.paused.push({ task: entry.task.display_id, engine:
|
|
345
|
-
|
|
2065
|
+
flight.paused.push({ task: entry.task.display_id, engine: activeEngine, ...(result.restaffed ? { restaffed: result.restaffed } : {}), ...landed });
|
|
2066
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `paused ${entry.task.display_id} at ${landed.stage || 'landing'}`);
|
|
2067
|
+
log(` ${activeEngine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
|
|
346
2068
|
}
|
|
347
2069
|
}
|
|
348
2070
|
|
|
@@ -354,3 +2076,674 @@ async function runFleetFlight({
|
|
|
354
2076
|
log('');
|
|
355
2077
|
return flight;
|
|
356
2078
|
}
|
|
2079
|
+
|
|
2080
|
+
// ---------------------------------------------------------------------------
|
|
2081
|
+
// T5 — one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
|
|
2082
|
+
|
|
2083
|
+
// The manual version of this loop took 6 Bash calls per task the night this
|
|
2084
|
+
// was written: claim, worktree start, prompt file, engine -p, verify, ship.
|
|
2085
|
+
// One or more explicit task ids build in parallel isolated worktrees on ONE
|
|
2086
|
+
// named engine; landings are serial, reusing the same rebase-before-ship,
|
|
2087
|
+
// never-auto-resolve contract as the fleet. Unlike the fleet, dispatch
|
|
2088
|
+
// re-runs the task's own Check: command directly (not just via the ship
|
|
2089
|
+
// gate) and captures its real output for the ready proof, so proof text cites
|
|
2090
|
+
// an actual re-runnable verifier instead of an engine's self-report.
|
|
2091
|
+
async function runDispatchFlight({
|
|
2092
|
+
root = process.cwd(),
|
|
2093
|
+
taskIds = [],
|
|
2094
|
+
engine,
|
|
2095
|
+
prompt: promptOverride = '',
|
|
2096
|
+
log = console.log,
|
|
2097
|
+
ownCli = null,
|
|
2098
|
+
dispatcher = null,
|
|
2099
|
+
lander = null,
|
|
2100
|
+
verifier = null,
|
|
2101
|
+
rebase = null,
|
|
2102
|
+
checkoutBase = 'origin/master',
|
|
2103
|
+
installedEngines = null,
|
|
2104
|
+
selfLandCheck = null,
|
|
2105
|
+
yolo = false,
|
|
2106
|
+
reviewOnly = false,
|
|
2107
|
+
verifierCommand = '',
|
|
2108
|
+
receiptContext = null,
|
|
2109
|
+
actor = '',
|
|
2110
|
+
changeInspector = null,
|
|
2111
|
+
validatorEngines = null,
|
|
2112
|
+
validatorDispatcher = null,
|
|
2113
|
+
validatorStateInspector = null,
|
|
2114
|
+
} = {}) {
|
|
2115
|
+
if (!engine) throw new Error('runDispatchFlight: engine is required');
|
|
2116
|
+
if (!FLEET_CAPABLE.includes(engine)) {
|
|
2117
|
+
throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${FLEET_CAPABLE.join(', ')})`);
|
|
2118
|
+
}
|
|
2119
|
+
const ids = [...new Set((taskIds || []).map((id) => String(id).trim()).filter(Boolean))];
|
|
2120
|
+
if (!ids.length) throw new Error('runDispatchFlight: at least one task id is required');
|
|
2121
|
+
if (promptOverride && ids.length > 1) {
|
|
2122
|
+
throw new Error('runDispatchFlight: --prompt-file only supports a single task id');
|
|
2123
|
+
}
|
|
2124
|
+
const trustedVerifier = String(verifierCommand || '').trim();
|
|
2125
|
+
if (trustedVerifier) {
|
|
2126
|
+
const parsedVerifier = require('./auto-accept-certified').parseVerifyCommand(trustedVerifier);
|
|
2127
|
+
if (!parsedVerifier.ok) {
|
|
2128
|
+
throw new Error(`runDispatchFlight: verifier command is not allowed (${parsedVerifier.reason})`);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if (reviewOnly && !trustedVerifier) {
|
|
2132
|
+
throw new Error('runDispatchFlight: review-only dispatch requires an explicit verifier command');
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const cli = ownCli || defaultOwnCli(root);
|
|
2136
|
+
const verify = verifier || (trustedVerifier ? defaultTrustedVerifyRunner : defaultVerifyRunner);
|
|
2137
|
+
const inspectChange = changeInspector || inspectReviewChange;
|
|
2138
|
+
const enforceRemoteBoundary = reviewOnly && !dispatcher;
|
|
2139
|
+
const explicitActor = String(actor || '').trim();
|
|
2140
|
+
const taskActor = explicitActor || `fleet-${engine}`;
|
|
2141
|
+
const receiptPath = path.join(root, 'atris', 'runs', `dispatch-${nowStamp()}.json`);
|
|
2142
|
+
const flight = {
|
|
2143
|
+
schema: 'atris.dispatch_receipt.v1',
|
|
2144
|
+
at: new Date().toISOString(),
|
|
2145
|
+
root,
|
|
2146
|
+
engine,
|
|
2147
|
+
tasks: ids,
|
|
2148
|
+
results: [],
|
|
2149
|
+
landed: [],
|
|
2150
|
+
ready: [],
|
|
2151
|
+
paused: [],
|
|
2152
|
+
};
|
|
2153
|
+
if (yolo) flight.yolo = true;
|
|
2154
|
+
if (reviewOnly) flight.review_only = true;
|
|
2155
|
+
flight.actor = taskActor;
|
|
2156
|
+
if (receiptContext && typeof receiptContext === 'object') flight.context = receiptContext;
|
|
2157
|
+
const requiresIndependentValidator = reviewOnly && flight.context && flight.context.source === 'one_lap';
|
|
2158
|
+
|
|
2159
|
+
log('');
|
|
2160
|
+
log(` dispatch — ${ids.length} task${ids.length === 1 ? '' : 's'} -> ${engine}`);
|
|
2161
|
+
log('');
|
|
2162
|
+
|
|
2163
|
+
// Same rule as the fleet: dispatch worktrees cut from origin/master by
|
|
2164
|
+
// default so rebase-before-ship never replays a launcher feature branch.
|
|
2165
|
+
const startBaseArgs = checkoutBase ? ['--base', checkoutBase] : [];
|
|
2166
|
+
const prepared = [];
|
|
2167
|
+
for (const taskId of ids) {
|
|
2168
|
+
const task = readTaskById(cli, taskId);
|
|
2169
|
+
if (!task) {
|
|
2170
|
+
flight.paused.push({ task: taskId, stage: 'task_lookup', detail: 'task not found' });
|
|
2171
|
+
log(` ✗ ${taskId} not found`);
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
const claimed = cli(['task', 'claim', taskId, '--as', taskActor]);
|
|
2175
|
+
if (!claimed || claimed.status !== 0) {
|
|
2176
|
+
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
2177
|
+
flight.paused.push({ task: taskId, stage: 'claim', detail });
|
|
2178
|
+
log(` x ${taskId} claim failed`);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
let landingWorktreePath = '';
|
|
2182
|
+
let remoteBoundary = null;
|
|
2183
|
+
if (enforceRemoteBoundary) {
|
|
2184
|
+
remoteBoundary = prepareReviewSandbox({ root, taskId, engine });
|
|
2185
|
+
} else {
|
|
2186
|
+
const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
|
|
2187
|
+
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
2188
|
+
if (!wt) {
|
|
2189
|
+
flight.paused.push({ task: taskId, stage: 'worktree_start', detail: String(started.stderr || '').slice(0, 200) });
|
|
2190
|
+
log(` ✗ ${taskId} worktree start failed`);
|
|
2191
|
+
continue;
|
|
2192
|
+
}
|
|
2193
|
+
landingWorktreePath = wt.trim();
|
|
2194
|
+
}
|
|
2195
|
+
if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
|
|
2196
|
+
flight.paused.push({
|
|
2197
|
+
task: taskId,
|
|
2198
|
+
stage: 'remote_quarantine',
|
|
2199
|
+
detail: String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox').slice(-500),
|
|
2200
|
+
worktree: null,
|
|
2201
|
+
});
|
|
2202
|
+
log(` paused ${taskId} because its sealed review sandbox could not be prepared`);
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
2205
|
+
const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
|
|
2206
|
+
const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
|
|
2207
|
+
const safetyPrompt = reviewOnly
|
|
2208
|
+
? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
|
|
2209
|
+
: basePrompt;
|
|
2210
|
+
const trustedPrompt = trustedVerifier
|
|
2211
|
+
? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
|
|
2212
|
+
: promptOverride;
|
|
2213
|
+
prepared.push({
|
|
2214
|
+
task,
|
|
2215
|
+
taskId,
|
|
2216
|
+
worktreePath,
|
|
2217
|
+
landingWorktreePath,
|
|
2218
|
+
engine,
|
|
2219
|
+
remoteBoundary,
|
|
2220
|
+
remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
|
|
2221
|
+
...(trustedPrompt ? { prompt: trustedPrompt } : {}),
|
|
2222
|
+
});
|
|
2223
|
+
log(` building ${taskId} in ${path.basename(worktreePath)}`);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
2227
|
+
resolve(dispatchToEngine({
|
|
2228
|
+
task: entry.task,
|
|
2229
|
+
engine: entry.engine,
|
|
2230
|
+
worktreePath: entry.worktreePath,
|
|
2231
|
+
root,
|
|
2232
|
+
prompt: entry.prompt || promptOverride || undefined,
|
|
2233
|
+
environment: enforceRemoteBoundary ? reviewOnlyEngineEnvironment(entry.worktreePath, {
|
|
2234
|
+
engine: entry.engine,
|
|
2235
|
+
network: true,
|
|
2236
|
+
writable: true,
|
|
2237
|
+
}) : null,
|
|
2238
|
+
sealed: enforceRemoteBoundary,
|
|
2239
|
+
yolo,
|
|
2240
|
+
briefId: entry.brief_id,
|
|
2241
|
+
skipBriefCapture: true,
|
|
2242
|
+
}));
|
|
2243
|
+
}));
|
|
2244
|
+
const restaffState = { used: false };
|
|
2245
|
+
|
|
2246
|
+
const results = await Promise.all(prepared.map((entry) => {
|
|
2247
|
+
const startedAtMs = Date.now();
|
|
2248
|
+
return dispatchEntryWithRestaff({
|
|
2249
|
+
entry,
|
|
2250
|
+
engine: entry.engine,
|
|
2251
|
+
root,
|
|
2252
|
+
dispatch,
|
|
2253
|
+
installedEngines,
|
|
2254
|
+
restaffState,
|
|
2255
|
+
}).then((result) => {
|
|
2256
|
+
const completedAtMs = Date.now();
|
|
2257
|
+
return {
|
|
2258
|
+
entry,
|
|
2259
|
+
result,
|
|
2260
|
+
startedAtMs,
|
|
2261
|
+
completedAtMs,
|
|
2262
|
+
candidate: enforceRemoteBoundary && result.exitCode === 0
|
|
2263
|
+
? reviewCandidateSnapshot(entry.remoteBoundary)
|
|
2264
|
+
: null,
|
|
2265
|
+
};
|
|
2266
|
+
});
|
|
2267
|
+
}));
|
|
2268
|
+
flight.results = results.map(({ entry, result, candidate, startedAtMs, completedAtMs }) => {
|
|
2269
|
+
const row = {
|
|
2270
|
+
task: entry.taskId,
|
|
2271
|
+
engine: result.engine || entry.engine,
|
|
2272
|
+
task_type: 'executor',
|
|
2273
|
+
verified_passed: null,
|
|
2274
|
+
duration_ms: Math.max(0, completedAtMs - startedAtMs),
|
|
2275
|
+
at: new Date(completedAtMs).toISOString(),
|
|
2276
|
+
exitCode: result.exitCode,
|
|
2277
|
+
};
|
|
2278
|
+
if (result.brief_id) row.brief_id = result.brief_id;
|
|
2279
|
+
if (result.restaffed) row.restaffed = result.restaffed;
|
|
2280
|
+
if (result.deadEngine) row.deadEngine = result.deadEngine;
|
|
2281
|
+
if (candidate && candidate.ok) {
|
|
2282
|
+
row.candidate_commit = candidate.commit;
|
|
2283
|
+
row.candidate_tree = candidate.tree;
|
|
2284
|
+
}
|
|
2285
|
+
return row;
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
// Land serially: rebase, re-run the trusted verifier for real, then either
|
|
2289
|
+
// stop proof-ready in Review or ship. Conflict/verify failure always pauses.
|
|
2290
|
+
const rebaseArrival = rebase || landArrival;
|
|
2291
|
+
const checkSelfLand = selfLandCheck || defaultSelfLandCheck;
|
|
2292
|
+
const land = lander || (({ entry }) => {
|
|
2293
|
+
const rebased = enforceRemoteBoundary
|
|
2294
|
+
? { ok: true, stage: 'frozen_base' }
|
|
2295
|
+
: rebaseArrival({ worktreePath: entry.worktreePath });
|
|
2296
|
+
if (!rebased.ok) return rebased;
|
|
2297
|
+
if (enforceRemoteBoundary) {
|
|
2298
|
+
const candidate = reviewCandidateSnapshot(entry.remoteBoundary, entry.candidate);
|
|
2299
|
+
if (!candidate.ok) return candidate;
|
|
2300
|
+
}
|
|
2301
|
+
const change = reviewOnly ? inspectChange(entry.worktreePath, checkoutBase || 'origin/master') : null;
|
|
2302
|
+
if (reviewOnly && (!change || change.has_change !== true)) {
|
|
2303
|
+
return {
|
|
2304
|
+
ok: false,
|
|
2305
|
+
stage: 'no_change',
|
|
2306
|
+
detail: String(change && change.detail || 'the engine produced no committed or worktree diff').slice(-500),
|
|
2307
|
+
change: change || null,
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
if (enforceRemoteBoundary && (change.head !== entry.candidate.commit || change.dirty)) {
|
|
2311
|
+
return {
|
|
2312
|
+
ok: false,
|
|
2313
|
+
stage: 'candidate_changed',
|
|
2314
|
+
detail: 'the change selected for verification differs from the frozen executor commit',
|
|
2315
|
+
change,
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
const check = trustedVerifier || dispatchCheck(entry.task) || 'git log -1 --oneline';
|
|
2319
|
+
const verified = verify(check, entry.worktreePath);
|
|
2320
|
+
const verifierResult = {
|
|
2321
|
+
command: check,
|
|
2322
|
+
passed: verified.status === 0,
|
|
2323
|
+
status: verified.status,
|
|
2324
|
+
output: `${verified.stdout || ''}${verified.stderr || ''}`.slice(-4000),
|
|
2325
|
+
...(entry.candidate && entry.candidate.ok ? {
|
|
2326
|
+
candidate_commit: entry.candidate.commit,
|
|
2327
|
+
candidate_tree: entry.candidate.tree,
|
|
2328
|
+
} : {}),
|
|
2329
|
+
};
|
|
2330
|
+
if (verified.status !== 0) {
|
|
2331
|
+
return {
|
|
2332
|
+
ok: false,
|
|
2333
|
+
stage: 'verify_failed',
|
|
2334
|
+
detail: `${verified.stdout}${verified.stderr}`.slice(-500),
|
|
2335
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
2336
|
+
check,
|
|
2337
|
+
verifier_result: verifierResult,
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
if (enforceRemoteBoundary) {
|
|
2341
|
+
const candidate = reviewCandidateSnapshot(entry.remoteBoundary, entry.candidate);
|
|
2342
|
+
if (!candidate.ok) return { ...candidate, check, verifier_result: verifierResult };
|
|
2343
|
+
}
|
|
2344
|
+
if (reviewOnly) {
|
|
2345
|
+
return {
|
|
2346
|
+
ok: true,
|
|
2347
|
+
stage: 'verified_for_review',
|
|
2348
|
+
check,
|
|
2349
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
2350
|
+
verifier_result: verifierResult,
|
|
2351
|
+
change,
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
const shipped = cli(fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check), entry.worktreePath);
|
|
2355
|
+
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
2356
|
+
return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-500) };
|
|
2357
|
+
}
|
|
2358
|
+
return {
|
|
2359
|
+
ok: true,
|
|
2360
|
+
stage: 'shipped',
|
|
2361
|
+
check,
|
|
2362
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
2363
|
+
verifier_result: verifierResult,
|
|
2364
|
+
};
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
for (const { entry, result, candidate, startedAtMs } of results) {
|
|
2368
|
+
const activeEngine = result.engine || entry.engine || engine;
|
|
2369
|
+
const readyActor = explicitActor || `fleet-${activeEngine}`;
|
|
2370
|
+
const landingEntry = { ...entry, engine: activeEngine, candidate };
|
|
2371
|
+
if (result.restaffed) log(` restaffed ${entry.taskId}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.reason})`);
|
|
2372
|
+
if (result.exitCode !== 0) {
|
|
2373
|
+
const paused = {
|
|
2374
|
+
task: entry.taskId,
|
|
2375
|
+
engine: activeEngine,
|
|
2376
|
+
stage: 'build',
|
|
2377
|
+
detail: String(result.stderr || '').slice(0, 300),
|
|
2378
|
+
worktree: entry.worktreePath,
|
|
2379
|
+
};
|
|
2380
|
+
if (result.restaffed) paused.restaffed = result.restaffed;
|
|
2381
|
+
if (result.deadEngine) paused.deadEngine = result.deadEngine;
|
|
2382
|
+
flight.paused.push(paused);
|
|
2383
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `build failed for ${entry.taskId}`);
|
|
2384
|
+
log(` ✗ build failed ${entry.taskId} — worktree kept for takeover`);
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if (enforceRemoteBoundary && (!candidate || candidate.ok !== true)) {
|
|
2388
|
+
const paused = {
|
|
2389
|
+
task: entry.taskId,
|
|
2390
|
+
engine: activeEngine,
|
|
2391
|
+
stage: candidate && candidate.stage || 'candidate_changed',
|
|
2392
|
+
detail: String(candidate && candidate.detail || 'the executor candidate could not be frozen').slice(-500),
|
|
2393
|
+
worktree: entry.worktreePath,
|
|
2394
|
+
};
|
|
2395
|
+
flight.paused.push(paused);
|
|
2396
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: candidate could not be frozen`);
|
|
2397
|
+
log(` paused ${entry.taskId} at ${paused.stage} - worktree kept`);
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
if (enforceRemoteBoundary) {
|
|
2401
|
+
const boundaryAfterBuild = reviewRemoteBoundaryState(entry.worktreePath, entry.remoteBoundary);
|
|
2402
|
+
if (!boundaryAfterBuild.ok) {
|
|
2403
|
+
flight.paused.push({
|
|
2404
|
+
task: entry.taskId,
|
|
2405
|
+
engine: activeEngine,
|
|
2406
|
+
stage: boundaryAfterBuild.stage,
|
|
2407
|
+
detail: boundaryAfterBuild.detail,
|
|
2408
|
+
worktree: entry.worktreePath,
|
|
2409
|
+
master_before: entry.remoteMasterBefore || null,
|
|
2410
|
+
master_after: boundaryAfterBuild.protected_master || null,
|
|
2411
|
+
});
|
|
2412
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: ${boundaryAfterBuild.stage} during build`);
|
|
2413
|
+
log(` paused ${entry.taskId} at ${boundaryAfterBuild.stage} - worktree kept`);
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
if (yolo) {
|
|
2418
|
+
log(` checking self-land ${entry.taskId}...`);
|
|
2419
|
+
const selfLanded = checkSelfLand({ entry, result, worktreePath: entry.worktreePath, targetRef: DISPATCH_SELF_LAND_TARGET });
|
|
2420
|
+
if (selfLanded.ok) {
|
|
2421
|
+
const target = selfLanded.target || DISPATCH_SELF_LAND_TARGET;
|
|
2422
|
+
flight.landed.push({
|
|
2423
|
+
task: entry.taskId,
|
|
2424
|
+
engine: activeEngine,
|
|
2425
|
+
landing: 'self',
|
|
2426
|
+
target,
|
|
2427
|
+
verifier_result: {
|
|
2428
|
+
command: `git merge-base --is-ancestor HEAD ${target}`,
|
|
2429
|
+
passed: true,
|
|
2430
|
+
status: 0,
|
|
2431
|
+
output: `HEAD is an ancestor of ${target}`,
|
|
2432
|
+
},
|
|
2433
|
+
...(result.brief_id ? { brief_id: result.brief_id } : {}),
|
|
2434
|
+
});
|
|
2435
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `self-landed ${entry.taskId}`);
|
|
2436
|
+
log(` ✓ self-landed ${entry.taskId}`);
|
|
2437
|
+
} else {
|
|
2438
|
+
const stage = selfLanded.stage || 'self_land_missing';
|
|
2439
|
+
flight.paused.push({
|
|
2440
|
+
task: entry.taskId,
|
|
2441
|
+
engine,
|
|
2442
|
+
stage,
|
|
2443
|
+
target: selfLanded.target || DISPATCH_SELF_LAND_TARGET,
|
|
2444
|
+
detail: selfLanded.detail || '',
|
|
2445
|
+
});
|
|
2446
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `self-land check paused ${entry.taskId} at ${stage}`);
|
|
2447
|
+
log(` ⏸ paused ${entry.taskId} at ${stage}`);
|
|
2448
|
+
}
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
log(` ${reviewOnly ? 'checking' : 'landing'} ${entry.taskId}...`);
|
|
2452
|
+
const landed = land({ entry: landingEntry, result });
|
|
2453
|
+
stampDispatchResultVerification(
|
|
2454
|
+
flight,
|
|
2455
|
+
entry.taskId,
|
|
2456
|
+
activeEngine,
|
|
2457
|
+
startedAtMs,
|
|
2458
|
+
landed.verifier_result,
|
|
2459
|
+
);
|
|
2460
|
+
if (landed.ok) {
|
|
2461
|
+
const verifyTail = String(landed.verifyOutput || '').trim().slice(-1200).replace(/\n/g, ' ');
|
|
2462
|
+
const restaffProof = result.restaffed ? ` Restaffed from ${result.restaffed.from} to ${result.restaffed.to} (${result.restaffed.reason}).` : '';
|
|
2463
|
+
if (reviewOnly) {
|
|
2464
|
+
let finalMaster = entry.remoteMasterBefore;
|
|
2465
|
+
const boundaryBeforeReview = enforceRemoteBoundary
|
|
2466
|
+
? reviewRemoteBoundaryState(entry.worktreePath, entry.remoteBoundary)
|
|
2467
|
+
: { ok: true, protected_master: '' };
|
|
2468
|
+
if (!boundaryBeforeReview.ok) {
|
|
2469
|
+
flight.paused.push({
|
|
2470
|
+
task: entry.taskId,
|
|
2471
|
+
engine: activeEngine,
|
|
2472
|
+
stage: boundaryBeforeReview.stage,
|
|
2473
|
+
detail: boundaryBeforeReview.detail,
|
|
2474
|
+
worktree: entry.worktreePath,
|
|
2475
|
+
master_before: entry.remoteMasterBefore,
|
|
2476
|
+
master_after: boundaryBeforeReview.protected_master || null,
|
|
2477
|
+
});
|
|
2478
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: ${boundaryBeforeReview.stage} before Review`);
|
|
2479
|
+
log(` paused ${entry.taskId} at ${boundaryBeforeReview.stage} - worktree kept`);
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
finalMaster = boundaryBeforeReview.protected_master || finalMaster;
|
|
2483
|
+
const change = landed.change || inspectChange(entry.worktreePath, checkoutBase || 'origin/master');
|
|
2484
|
+
if (!change || change.has_change !== true) {
|
|
2485
|
+
flight.paused.push({
|
|
2486
|
+
task: entry.taskId,
|
|
2487
|
+
engine: activeEngine,
|
|
2488
|
+
stage: 'no_change',
|
|
2489
|
+
detail: String(change && change.detail || 'the engine produced no committed or worktree diff').slice(-500),
|
|
2490
|
+
check: landed.check,
|
|
2491
|
+
verifier_result: landed.verifier_result,
|
|
2492
|
+
worktree: entry.worktreePath,
|
|
2493
|
+
change: change || null,
|
|
2494
|
+
});
|
|
2495
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `verified ${entry.taskId}, but no code change was found`);
|
|
2496
|
+
log(` paused ${entry.taskId} at no_change - worktree kept`);
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
let validatorResult = null;
|
|
2500
|
+
if (requiresIndependentValidator) {
|
|
2501
|
+
log(` validating ${entry.taskId} in a fresh context...`);
|
|
2502
|
+
const validation = await runIndependentValidator({
|
|
2503
|
+
root,
|
|
2504
|
+
task: entry.task,
|
|
2505
|
+
worktreePath: entry.worktreePath,
|
|
2506
|
+
executorEngine: activeEngine,
|
|
2507
|
+
verifierCommand: landed.check,
|
|
2508
|
+
validatorEngines,
|
|
2509
|
+
validatorDispatcher,
|
|
2510
|
+
stateInspector: validatorStateInspector || reviewWorktreeSnapshot,
|
|
2511
|
+
});
|
|
2512
|
+
validatorResult = validation.validator_result;
|
|
2513
|
+
flight.validator_result = validatorResult;
|
|
2514
|
+
if (!validation.ok) {
|
|
2515
|
+
flight.paused.push({
|
|
2516
|
+
task: entry.taskId,
|
|
2517
|
+
engine: activeEngine,
|
|
2518
|
+
stage: validation.stage,
|
|
2519
|
+
detail: String(validatorResult && validatorResult.reason || 'independent validator failed').slice(-500),
|
|
2520
|
+
check: landed.check,
|
|
2521
|
+
verifier_result: landed.verifier_result,
|
|
2522
|
+
validator_result: validatorResult,
|
|
2523
|
+
worktree: entry.worktreePath,
|
|
2524
|
+
change,
|
|
2525
|
+
});
|
|
2526
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `validator paused ${entry.taskId} at ${validation.stage}`);
|
|
2527
|
+
log(` paused ${entry.taskId} at ${validation.stage} - worktree kept`);
|
|
2528
|
+
continue;
|
|
2529
|
+
}
|
|
2530
|
+
if (enforceRemoteBoundary) {
|
|
2531
|
+
const candidateAfterValidator = reviewCandidateSnapshot(entry.remoteBoundary, candidate);
|
|
2532
|
+
if (!candidateAfterValidator.ok) {
|
|
2533
|
+
validatorResult.passed = false;
|
|
2534
|
+
validatorResult.reason = candidateAfterValidator.detail;
|
|
2535
|
+
flight.paused.push({
|
|
2536
|
+
task: entry.taskId,
|
|
2537
|
+
engine: activeEngine,
|
|
2538
|
+
stage: candidateAfterValidator.stage,
|
|
2539
|
+
detail: candidateAfterValidator.detail,
|
|
2540
|
+
check: landed.check,
|
|
2541
|
+
verifier_result: landed.verifier_result,
|
|
2542
|
+
validator_result: validatorResult,
|
|
2543
|
+
worktree: entry.worktreePath,
|
|
2544
|
+
change,
|
|
2545
|
+
});
|
|
2546
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: candidate changed during validation`);
|
|
2547
|
+
log(` paused ${entry.taskId} at ${candidateAfterValidator.stage} - worktree kept`);
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
if (enforceRemoteBoundary) {
|
|
2552
|
+
const boundaryAfterValidator = reviewRemoteBoundaryState(entry.worktreePath, entry.remoteBoundary);
|
|
2553
|
+
if (!boundaryAfterValidator.ok) {
|
|
2554
|
+
validatorResult.passed = false;
|
|
2555
|
+
validatorResult.reason = boundaryAfterValidator.detail;
|
|
2556
|
+
flight.paused.push({
|
|
2557
|
+
task: entry.taskId,
|
|
2558
|
+
engine: activeEngine,
|
|
2559
|
+
stage: boundaryAfterValidator.stage,
|
|
2560
|
+
detail: validatorResult.reason,
|
|
2561
|
+
check: landed.check,
|
|
2562
|
+
verifier_result: landed.verifier_result,
|
|
2563
|
+
validator_result: validatorResult,
|
|
2564
|
+
worktree: entry.worktreePath,
|
|
2565
|
+
master_before: entry.remoteMasterBefore,
|
|
2566
|
+
master_after: boundaryAfterValidator.protected_master || null,
|
|
2567
|
+
change,
|
|
2568
|
+
});
|
|
2569
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: ${boundaryAfterValidator.stage} during validation`);
|
|
2570
|
+
log(` paused ${entry.taskId} at ${boundaryAfterValidator.stage} - worktree kept`);
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
finalMaster = boundaryAfterValidator.protected_master;
|
|
2574
|
+
}
|
|
2575
|
+
log(` validator ${validatorResult.engine} signed off ${entry.taskId}`);
|
|
2576
|
+
}
|
|
2577
|
+
let reviewWorktreePath = entry.worktreePath;
|
|
2578
|
+
let reviewChange = change;
|
|
2579
|
+
if (enforceRemoteBoundary) {
|
|
2580
|
+
const imported = importReviewCommit(entry.remoteBoundary, {
|
|
2581
|
+
cli,
|
|
2582
|
+
taskId: entry.taskId,
|
|
2583
|
+
engine: activeEngine,
|
|
2584
|
+
expectedCommit: candidate.commit,
|
|
2585
|
+
expectedTree: candidate.tree,
|
|
2586
|
+
startBaseArgs,
|
|
2587
|
+
});
|
|
2588
|
+
if (!imported.ok) {
|
|
2589
|
+
flight.paused.push({
|
|
2590
|
+
task: entry.taskId,
|
|
2591
|
+
engine: activeEngine,
|
|
2592
|
+
stage: imported.stage || 'review_import',
|
|
2593
|
+
detail: imported.detail || 'the verified commit could not be imported for Review',
|
|
2594
|
+
check: landed.check,
|
|
2595
|
+
verifier_result: landed.verifier_result,
|
|
2596
|
+
...(validatorResult ? { validator_result: validatorResult } : {}),
|
|
2597
|
+
worktree: imported.worktreePath || entry.worktreePath,
|
|
2598
|
+
master_before: entry.remoteMasterBefore,
|
|
2599
|
+
master_after: imported.protected_master || finalMaster || null,
|
|
2600
|
+
change,
|
|
2601
|
+
});
|
|
2602
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: ${imported.stage || 'review_import'}`);
|
|
2603
|
+
log(` paused ${entry.taskId} at ${imported.stage || 'review_import'} - sandbox kept`);
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
const boundaryAfterImport = reviewRemoteBoundaryState(entry.worktreePath, entry.remoteBoundary);
|
|
2607
|
+
if (!boundaryAfterImport.ok) {
|
|
2608
|
+
flight.paused.push({
|
|
2609
|
+
task: entry.taskId,
|
|
2610
|
+
engine: activeEngine,
|
|
2611
|
+
stage: boundaryAfterImport.stage,
|
|
2612
|
+
detail: boundaryAfterImport.detail,
|
|
2613
|
+
check: landed.check,
|
|
2614
|
+
verifier_result: landed.verifier_result,
|
|
2615
|
+
...(validatorResult ? { validator_result: validatorResult } : {}),
|
|
2616
|
+
worktree: imported.worktreePath,
|
|
2617
|
+
master_before: entry.remoteMasterBefore,
|
|
2618
|
+
master_after: boundaryAfterImport.protected_master || null,
|
|
2619
|
+
change,
|
|
2620
|
+
});
|
|
2621
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: ${boundaryAfterImport.stage} after import`);
|
|
2622
|
+
log(` paused ${entry.taskId} at ${boundaryAfterImport.stage} after import`);
|
|
2623
|
+
continue;
|
|
2624
|
+
}
|
|
2625
|
+
reviewWorktreePath = imported.worktreePath;
|
|
2626
|
+
reviewChange = imported.change;
|
|
2627
|
+
if (!reviewChange || reviewChange.has_change !== true
|
|
2628
|
+
|| reviewChange.source_commit !== candidate.commit
|
|
2629
|
+
|| reviewChange.proof_tree !== candidate.tree
|
|
2630
|
+
|| imported.head !== candidate.commit) {
|
|
2631
|
+
flight.paused.push({
|
|
2632
|
+
task: entry.taskId,
|
|
2633
|
+
engine: activeEngine,
|
|
2634
|
+
stage: 'review_import',
|
|
2635
|
+
detail: 'the imported review worktree did not preserve the verified change',
|
|
2636
|
+
check: landed.check,
|
|
2637
|
+
verifier_result: landed.verifier_result,
|
|
2638
|
+
...(validatorResult ? { validator_result: validatorResult } : {}),
|
|
2639
|
+
worktree: reviewWorktreePath,
|
|
2640
|
+
master_before: entry.remoteMasterBefore,
|
|
2641
|
+
master_after: boundaryAfterImport.protected_master || null,
|
|
2642
|
+
change: reviewChange || null,
|
|
2643
|
+
});
|
|
2644
|
+
stampDispatchBrief(root, result.brief_id, 'fail', `blocked ${entry.taskId}: imported review mismatch`);
|
|
2645
|
+
log(` paused ${entry.taskId} at review_import - review worktree kept`);
|
|
2646
|
+
continue;
|
|
2647
|
+
}
|
|
2648
|
+
finalMaster = boundaryAfterImport.protected_master || finalMaster;
|
|
2649
|
+
disposeReviewSandbox(entry.remoteBoundary);
|
|
2650
|
+
}
|
|
2651
|
+
const shipArgs = fleetShipArgs({ task: entry.task, engine: activeEngine }, landed.check);
|
|
2652
|
+
const shellQuote = (value) => /^[A-Za-z0-9_./:-]+$/.test(String(value))
|
|
2653
|
+
? String(value)
|
|
2654
|
+
: `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
2655
|
+
const nextCommand = `cd ${shellQuote(reviewWorktreePath)} && atris ${shipArgs.map(shellQuote).join(' ')}`;
|
|
2656
|
+
const readyRow = {
|
|
2657
|
+
task: entry.taskId,
|
|
2658
|
+
engine: activeEngine,
|
|
2659
|
+
check: landed.check,
|
|
2660
|
+
verifier_result: landed.verifier_result,
|
|
2661
|
+
worktree: reviewWorktreePath,
|
|
2662
|
+
next_action: nextCommand,
|
|
2663
|
+
change: reviewChange,
|
|
2664
|
+
...(validatorResult ? { validator_result: validatorResult } : {}),
|
|
2665
|
+
review_recorded: false,
|
|
2666
|
+
...(enforceRemoteBoundary ? {
|
|
2667
|
+
master_before: entry.remoteMasterBefore,
|
|
2668
|
+
master_after: finalMaster,
|
|
2669
|
+
} : {}),
|
|
2670
|
+
};
|
|
2671
|
+
if (result.brief_id) readyRow.brief_id = result.brief_id;
|
|
2672
|
+
if (result.restaffed) readyRow.restaffed = result.restaffed;
|
|
2673
|
+
|
|
2674
|
+
flight.ready.push(readyRow);
|
|
2675
|
+
writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
|
|
2676
|
+
const verificationCommit = String(reviewChange.source_commit || reviewChange.commit || reviewChange.head || '').trim();
|
|
2677
|
+
const verificationCitation = verificationCommit
|
|
2678
|
+
? ` Verification snapshot: commit ${verificationCommit}${reviewChange.dirty ? ' with a worktree diff' : ''}; ${landed.check} passed (exit 0).`
|
|
2679
|
+
: ` Verification snapshot: the worktree diff was present; ${landed.check} passed (exit 0).`;
|
|
2680
|
+
const validatorCitation = validatorResult
|
|
2681
|
+
? ` Independent validator ${validatorResult.engine} signed off: ${validatorResult.reason}.`
|
|
2682
|
+
: '';
|
|
2683
|
+
const readyResult = cli([
|
|
2684
|
+
'task', 'ready', entry.taskId,
|
|
2685
|
+
'--proof', `Built by ${activeEngine} engine via one-lap dispatch.${restaffProof} Preserved in isolated worktree ${reviewWorktreePath}. Check re-run: ${landed.check}.${verificationCitation}${validatorCitation} Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
2686
|
+
'--result', 'Operators can review a completed, verified change before it lands, reducing the risk of unreviewed changes reaching users.',
|
|
2687
|
+
'--landing', 'Operators can review the verified change before it reaches users, reducing the risk of an unreviewed release.',
|
|
2688
|
+
'--checked', `${landed.check} passed in the isolated worktree`,
|
|
2689
|
+
'--tested', 'The requested behavior passed its declared verifier.',
|
|
2690
|
+
'--as', readyActor,
|
|
2691
|
+
]);
|
|
2692
|
+
if (!readyResult || readyResult.status !== 0) {
|
|
2693
|
+
flight.ready.splice(flight.ready.indexOf(readyRow), 1);
|
|
2694
|
+
flight.paused.push({
|
|
2695
|
+
task: entry.taskId,
|
|
2696
|
+
engine: activeEngine,
|
|
2697
|
+
stage: 'task_ready',
|
|
2698
|
+
detail: String(readyResult && (readyResult.stderr || readyResult.stdout) || 'task ready failed').slice(-500),
|
|
2699
|
+
check: landed.check,
|
|
2700
|
+
verifier_result: landed.verifier_result,
|
|
2701
|
+
worktree: reviewWorktreePath,
|
|
2702
|
+
change: reviewChange,
|
|
2703
|
+
});
|
|
2704
|
+
writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
|
|
2705
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `verified ${entry.taskId}, but task ready failed`);
|
|
2706
|
+
log(` paused ${entry.taskId} at task_ready - worktree kept`);
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
readyRow.review_recorded = true;
|
|
2710
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `verified ${entry.taskId} for Review`);
|
|
2711
|
+
log(` proof ready ${entry.taskId}`);
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
const landedRow = { task: entry.taskId, engine: activeEngine, check: landed.check };
|
|
2715
|
+
landedRow.verifier_result = landed.verifier_result;
|
|
2716
|
+
if (result.brief_id) landedRow.brief_id = result.brief_id;
|
|
2717
|
+
if (result.restaffed) landedRow.restaffed = result.restaffed;
|
|
2718
|
+
flight.landed.push(landedRow);
|
|
2719
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `landed ${entry.taskId} via engine dispatch`);
|
|
2720
|
+
cli([
|
|
2721
|
+
'task', 'ready', entry.taskId,
|
|
2722
|
+
'--proof', `Built by ${activeEngine} engine via atris engine dispatch.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). Check re-run: ${landed.check}. Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
2723
|
+
'--result', 'Operators can now review engine-built work faster because dispatch reran the verifier before landing.',
|
|
2724
|
+
'--landing', 'The verified change is on master and ready for review.',
|
|
2725
|
+
'--checked', `${landed.check} passed before landing`,
|
|
2726
|
+
'--tested', 'The requested behavior passed its declared verifier.',
|
|
2727
|
+
'--as', readyActor,
|
|
2728
|
+
]);
|
|
2729
|
+
log(` ✓ landed ${entry.taskId}`);
|
|
2730
|
+
} else {
|
|
2731
|
+
flight.paused.push({
|
|
2732
|
+
task: entry.taskId,
|
|
2733
|
+
engine: activeEngine,
|
|
2734
|
+
worktree: entry.worktreePath,
|
|
2735
|
+
...(result.restaffed ? { restaffed: result.restaffed } : {}),
|
|
2736
|
+
...landed,
|
|
2737
|
+
});
|
|
2738
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `paused ${entry.taskId} at ${landed.stage || 'landing'}`);
|
|
2739
|
+
log(` ⏸ paused ${entry.taskId} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
|
|
2744
|
+
log('');
|
|
2745
|
+
const completedLabel = reviewOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
|
|
2746
|
+
log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
|
|
2747
|
+
log('');
|
|
2748
|
+
return flight;
|
|
2749
|
+
}
|