atris 3.70.0 → 3.71.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +246 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-run-lock.js +59 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +395 -18
- package/lib/member-board.js +7 -1
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +127 -8
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +47 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/lib/fleet.js
CHANGED
|
@@ -27,6 +27,7 @@ const { setEngineHealth, recordEngineRunHealth, engineStallReason, engineFailure
|
|
|
27
27
|
const { appendRosterAttempts, parseRunUsage } = require('./roster-runs');
|
|
28
28
|
const { engineRunsView } = require('./roster-models');
|
|
29
29
|
const { resolveDefaultVerifier } = require('./default-verifier');
|
|
30
|
+
const { taskCheck } = require('./member-task-pick');
|
|
30
31
|
const { rankEnginesDetailed } = require('./router-brain');
|
|
31
32
|
const {
|
|
32
33
|
buildOneLapValidatorPrompt,
|
|
@@ -50,6 +51,7 @@ const {
|
|
|
50
51
|
PROXY_ENV_KEYS: SECRET_GATEWAY_PROXY_ENV_KEYS,
|
|
51
52
|
} = require('./secret-gateway');
|
|
52
53
|
const { appendEngineLiveLogChunk, createEngineLiveLog, engineTerminalReason } = require('./engine-job-lifecycle');
|
|
54
|
+
const { recordEngineResource } = require('./engine-resource');
|
|
53
55
|
|
|
54
56
|
// Lanes a fleet may never staff on its own: the human keeps irreversible
|
|
55
57
|
// calls. Mirrors the autoland denied lanes.
|
|
@@ -86,28 +88,50 @@ const METHOD_KERNEL = [
|
|
|
86
88
|
const ATRIS_BUILD_PROCESS_PREAMBLE = [
|
|
87
89
|
'you are set up to do this well.',
|
|
88
90
|
'use the atris process:',
|
|
89
|
-
'
|
|
91
|
+
'this task is already claimed for you and its record is kept for you.',
|
|
92
|
+
'run no atris command except `atris worktree guard` and the landing step below; skip any boot step the repo agent files ask for.',
|
|
93
|
+
'read the map, and work only the named contract.',
|
|
90
94
|
'run every gate bare and read its real exit code.',
|
|
91
95
|
'report honestly, including failures, and stop at the named stop point.',
|
|
92
96
|
'if a git hook blocks your commit, stop and report it; never use --no-verify.',
|
|
93
97
|
].join('\n');
|
|
94
98
|
|
|
95
|
-
// The
|
|
96
|
-
//
|
|
99
|
+
// The check a dispatched task must pass, best source first: the task's own
|
|
100
|
+
// verify field (`atris task new --verify`), then a "Check:" written in the
|
|
101
|
+
// title, then the repo's default. The verify field used to be ignored, so a
|
|
102
|
+
// task that named its check got `git diff --check` instead.
|
|
103
|
+
function taskDispatchCheck(task, root = process.cwd()) {
|
|
104
|
+
const own = taskCheck(task).command;
|
|
105
|
+
if (own) return own;
|
|
106
|
+
const { check } = parseDoneCheck(task && task.title);
|
|
107
|
+
return check || resolveDefaultVerifier(root);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One shell word, so a check with quotes or spaces survives being pasted
|
|
111
|
+
// into `atris worktree ship --verify <check>`.
|
|
112
|
+
function shellQuote(value) {
|
|
113
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The bounded prompt every engine gets: isolated worktree, MAP first, the
|
|
117
|
+
// task's own check, one landing order per mode. Yolo: the engine lands it
|
|
118
|
+
// itself with worktree ship. Otherwise: commit only, the conductor lands it.
|
|
97
119
|
function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
98
120
|
const ref = task.display_id || task.id || 'TASK';
|
|
99
121
|
const title = String(task.title || '').trim();
|
|
100
|
-
const { done
|
|
101
|
-
const
|
|
122
|
+
const { done: titleDone } = parseDoneCheck(title);
|
|
123
|
+
const done = titleDone || String((task.metadata && task.metadata.done_looks_like) || '').trim();
|
|
124
|
+
const check = taskDispatchCheck(task, worktreePath || process.cwd());
|
|
125
|
+
const shipMessage = title.split(/[.:](?:\s|$)/)[0].replace(/"/g, "'").slice(0, 90) || ref;
|
|
102
126
|
const commitRule = yolo
|
|
103
|
-
?
|
|
104
|
-
: 'Commit on the current branch with a clear message. Do not push
|
|
127
|
+
? `Land it yourself. Commit on the current branch with a plain-English message, then run: atris worktree ship --message "${shipMessage} (${ref})" --verify ${shellQuote(check)} --merge, and report the PR URL. That ship command is the only push allowed. If it reports a conflict or a failed check, stop and report; never resolve conflicts yourself.`
|
|
128
|
+
: 'Commit on the current branch with a clear message. Do not push and do not create branches; the conductor lands your commit.';
|
|
105
129
|
const lines = [
|
|
106
130
|
'First, run `atris worktree guard`; if it fails, stop immediately, report back, and do not edit anything. Do this before any file edit.',
|
|
107
131
|
'',
|
|
108
132
|
ATRIS_BUILD_PROCESS_PREAMBLE,
|
|
109
133
|
'',
|
|
110
|
-
`You are working task ${ref} in
|
|
134
|
+
`You are working task ${ref} in its own git worktree${worktreePath ? ` at ${worktreePath}` : ''}.`,
|
|
111
135
|
'',
|
|
112
136
|
`Task: ${title}`,
|
|
113
137
|
'',
|
|
@@ -121,11 +145,13 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
|
121
145
|
: '- Read atris/MAP.md first to locate the code; never guess file locations.',
|
|
122
146
|
...SHARED_BRIEF_RULES,
|
|
123
147
|
'- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
|
|
124
|
-
'-
|
|
148
|
+
'- Never stage or commit anything under .atris/ or atris/TODO.md; atris rewrites those itself and they clash between parallel tasks.',
|
|
149
|
+
'- If the task changes how code behaves, add or update a focused regression test for it. A text, docs, or config edit needs no new test.',
|
|
150
|
+
`- Run the check bare before committing: ${check}`,
|
|
125
151
|
`- ${commitRule}`,
|
|
126
152
|
...METHOD_KERNEL.map((rule) => `- ${rule}`),
|
|
127
153
|
'',
|
|
128
|
-
|
|
154
|
+
`Final report (plain text): files changed, check command + result, commit sha${yolo ? ', PR URL' : ''} (or say what failed and why).`
|
|
129
155
|
);
|
|
130
156
|
const lessonFiles = [
|
|
131
157
|
...(Array.isArray(task.files) ? task.files : []),
|
|
@@ -1248,8 +1274,32 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
|
|
|
1248
1274
|
if (!deferredCleanup) cleanup();
|
|
1249
1275
|
}
|
|
1250
1276
|
});
|
|
1251
|
-
|
|
1277
|
+
// Machine memory and load at both ends of the run, so a SIGKILL that leaves
|
|
1278
|
+
// no report of its own still says whether the machine was short on memory.
|
|
1279
|
+
const resourceTask = dispatchTaskId(task);
|
|
1280
|
+
const resourceRunId = crypto.randomUUID();
|
|
1281
|
+
recordEngineResource(root, { phase: 'start', engine, task: resourceTask, run_id: resourceRunId });
|
|
1282
|
+
const resourceStartedAt = process.hrtime.bigint();
|
|
1283
|
+
const recordResourceEnd = (run) => recordEngineResource(root, {
|
|
1284
|
+
phase: 'end', engine, task: resourceTask, run_id: resourceRunId,
|
|
1285
|
+
signal: dispatchResultSignal(run),
|
|
1286
|
+
timed_out: Boolean(run && run.error && run.error.code === 'ETIMEDOUT'),
|
|
1287
|
+
// Why a run failed to start, whether the runner threw it or returned it.
|
|
1288
|
+
error: run instanceof Error ? run.message : (run && run.error instanceof Error ? run.error.message : ''),
|
|
1289
|
+
duration_ms: Math.round(Number(process.hrtime.bigint() - resourceStartedAt) / 1e6),
|
|
1290
|
+
});
|
|
1291
|
+
const runnerFailed = (error) => {
|
|
1292
|
+
recordResourceEnd(error);
|
|
1293
|
+
throw error;
|
|
1294
|
+
};
|
|
1295
|
+
let result;
|
|
1296
|
+
try {
|
|
1297
|
+
result = exec(command, { timeoutMs: dispatchTimeoutMs });
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
return runnerFailed(error);
|
|
1300
|
+
}
|
|
1252
1301
|
const toDispatch = (run) => {
|
|
1302
|
+
const resourceEnd = recordResourceEnd(run);
|
|
1253
1303
|
let watchdogReceipt = null;
|
|
1254
1304
|
if (watchdogArtifact && fs.existsSync(watchdogArtifact)) {
|
|
1255
1305
|
try { watchdogReceipt = JSON.parse(fs.readFileSync(watchdogArtifact, 'utf8')); } catch {}
|
|
@@ -1277,11 +1327,12 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
|
|
|
1277
1327
|
cancelled: Boolean(run.cancelled),
|
|
1278
1328
|
report: stdout.slice(-8000),
|
|
1279
1329
|
stderr: String(run.stderr || '').slice(-2000),
|
|
1330
|
+
...(resourceEnd.cause ? { kill_cause: resourceEnd.cause } : {}),
|
|
1280
1331
|
...(unwrapped && unwrapped.usage ? { usage: unwrapped.usage } : {}),
|
|
1281
1332
|
...(unwrapped && unwrapped.cost_usd !== undefined ? { cost_usd: unwrapped.cost_usd } : {}),
|
|
1282
1333
|
};
|
|
1283
1334
|
};
|
|
1284
|
-
if (result && typeof result.then === 'function') return result.then(toDispatch);
|
|
1335
|
+
if (result && typeof result.then === 'function') return result.then(toDispatch, runnerFailed);
|
|
1285
1336
|
return toDispatch(result);
|
|
1286
1337
|
}
|
|
1287
1338
|
|
|
@@ -1403,6 +1454,19 @@ function dispatchResultToTerminal(entry = {}, result = {}) {
|
|
|
1403
1454
|
// always surfaces the missing-module line from either stream instead of
|
|
1404
1455
|
// letting head/tail clipping truncate it away (cost measured live: CLI-1185
|
|
1405
1456
|
// receipts kept only a loader stack tail and the module name was lost).
|
|
1457
|
+
// True when the worktree's current branch already exists on origin.
|
|
1458
|
+
function branchOnOrigin(worktreePath) {
|
|
1459
|
+
const branch = spawnSync('git', ['-C', worktreePath, 'symbolic-ref', '--quiet', '--short', 'HEAD'], { encoding: 'utf8' });
|
|
1460
|
+
const name = String(branch.stdout || '').trim();
|
|
1461
|
+
if (branch.status !== 0 || !name) return false;
|
|
1462
|
+
const remote = spawnSync('git', ['-C', worktreePath, 'ls-remote', '--exit-code', '--heads', 'origin', name], {
|
|
1463
|
+
encoding: 'utf8',
|
|
1464
|
+
env: gitNetworkEnvironment(process.env, worktreePath),
|
|
1465
|
+
timeout: 15000,
|
|
1466
|
+
});
|
|
1467
|
+
return remote.status === 0;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1406
1470
|
function shipWithRetry(cli, args, cwd) {
|
|
1407
1471
|
let shipped = cli(args, cwd);
|
|
1408
1472
|
if (shipped.status !== 0 && /MODULE_NOT_FOUND|Cannot find module/.test(`${shipped.stderr}${shipped.stdout}`)) {
|
|
@@ -1603,6 +1667,7 @@ module.exports = {
|
|
|
1603
1667
|
clipHeadTail,
|
|
1604
1668
|
guardGlobalCliLink,
|
|
1605
1669
|
defaultSelfLandCheck,
|
|
1670
|
+
defaultDispatchStartCommit,
|
|
1606
1671
|
};
|
|
1607
1672
|
|
|
1608
1673
|
// ---------------------------------------------------------------------------
|
|
@@ -2867,6 +2932,8 @@ function focusedCheck(task) {
|
|
|
2867
2932
|
// does not apply: fall back to the task's whole Check: text so the operator's
|
|
2868
2933
|
// actual check command is what gets re-run as verification.
|
|
2869
2934
|
function dispatchCheck(task) {
|
|
2935
|
+
const own = taskCheck(task).command;
|
|
2936
|
+
if (own) return own;
|
|
2870
2937
|
const narrow = focusedCheck(task);
|
|
2871
2938
|
if (narrow) return narrow;
|
|
2872
2939
|
const { check } = parseDoneCheck(task.title);
|
|
@@ -2947,8 +3014,19 @@ function defaultTrustedVerifyRunner(command, cwd) {
|
|
|
2947
3014
|
};
|
|
2948
3015
|
}
|
|
2949
3016
|
|
|
3017
|
+
// A retry's worktree starts on the last run's unlanded commits. Measuring
|
|
3018
|
+
// from that HEAD made a retry that landed those commits read "no work
|
|
3019
|
+
// landed", so a resumed worktree measures from where it left the mainline.
|
|
2950
3020
|
function defaultDispatchStartCommit({ worktreePath, git = null } = {}) {
|
|
2951
3021
|
const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
|
|
3022
|
+
let sidecar = null;
|
|
3023
|
+
try {
|
|
3024
|
+
sidecar = JSON.parse(fs.readFileSync(path.join(worktreePath, '.atris', 'agent-worktree.json'), 'utf8'));
|
|
3025
|
+
} catch {}
|
|
3026
|
+
if (sidecar && sidecar.resumed_from && sidecar.checkout_base) {
|
|
3027
|
+
const base = run(['merge-base', 'HEAD', sidecar.checkout_base]);
|
|
3028
|
+
if (base && base.status === 0 && String(base.stdout || '').trim()) return String(base.stdout).trim();
|
|
3029
|
+
}
|
|
2952
3030
|
const head = run(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
2953
3031
|
if (!head || head.status !== 0) return '';
|
|
2954
3032
|
return String(head.stdout || '').trim();
|
|
@@ -3278,7 +3356,12 @@ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
|
|
|
3278
3356
|
// paused task whose worktree holds no commits past base and nothing but
|
|
3279
3357
|
// conductor litter gets its claim released; a paused task WITH work keeps the
|
|
3280
3358
|
// claim so takeover stays attributed.
|
|
3281
|
-
|
|
3359
|
+
//
|
|
3360
|
+
// keepClaimsWithWork=false is for one-shot dispatch: when its flight ends
|
|
3361
|
+
// nothing is working the task any more, and a kept claim made the retry
|
|
3362
|
+
// fail at "claim failed". The work stays on its branch and the retry's
|
|
3363
|
+
// worktree start resumes from it.
|
|
3364
|
+
function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
|
|
3282
3365
|
for (const row of paused || []) {
|
|
3283
3366
|
if (row.stage === 'claim' || row.stage === 'task_lookup' || row.landed === true) continue; // no claim, or work already landed
|
|
3284
3367
|
if (row.claim_released !== undefined) continue;
|
|
@@ -3286,6 +3369,12 @@ function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTa
|
|
|
3286
3369
|
const actor = actorByTask.get(taskId);
|
|
3287
3370
|
if (!taskId || !actor) continue;
|
|
3288
3371
|
const worktreePath = row.worktree || worktreeByTask.get(taskId) || '';
|
|
3372
|
+
if (!keepClaimsWithWork) {
|
|
3373
|
+
const released = cli(['task', 'release', taskId, '--as', actor]);
|
|
3374
|
+
row.claim_released = Boolean(released && released.status === 0);
|
|
3375
|
+
if (row.claim_released) log(` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
|
|
3376
|
+
continue;
|
|
3377
|
+
}
|
|
3289
3378
|
if (worktreePath && fs.existsSync(worktreePath)) {
|
|
3290
3379
|
const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
|
|
3291
3380
|
if (!baseRef && resolvedBase === 'HEAD') continue; // no known start point: preserve the claim
|
|
@@ -3438,7 +3527,11 @@ async function runDispatchFlight({
|
|
|
3438
3527
|
if (enforceRemoteBoundary) {
|
|
3439
3528
|
remoteBoundary = prepareReviewSandbox({ root, taskId, engine, baseRef: landTarget });
|
|
3440
3529
|
} else {
|
|
3441
|
-
|
|
3530
|
+
// The claim just taken is the lock against a second live flight, so an
|
|
3531
|
+
// earlier finished run's branch must not refuse the retry. Worktree
|
|
3532
|
+
// start resumes that branch's commits, or starts fresh when they no
|
|
3533
|
+
// longer merge cleanly (the member-path retry fix).
|
|
3534
|
+
const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, '--force', ...startBaseArgs]);
|
|
3442
3535
|
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
3443
3536
|
if (!wt) {
|
|
3444
3537
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
@@ -3486,6 +3579,9 @@ async function runDispatchFlight({
|
|
|
3486
3579
|
remoteBoundary,
|
|
3487
3580
|
remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
|
|
3488
3581
|
startCommit,
|
|
3582
|
+
// The attempt loop rebuilds the brief from this flag. Without it every
|
|
3583
|
+
// yolo engine got the commit-only brief and could never land.
|
|
3584
|
+
yolo: yolo && !localOnly,
|
|
3489
3585
|
...(maxSeconds ? { maxSeconds } : {}),
|
|
3490
3586
|
...(trustedPrompt ? { prompt: trustedPrompt } : {}),
|
|
3491
3587
|
});
|
|
@@ -3583,7 +3679,7 @@ async function runDispatchFlight({
|
|
|
3583
3679
|
const rebaseArrival = rebase || landArrival;
|
|
3584
3680
|
const checkSelfLand = selfLandCheck || defaultSelfLandCheck;
|
|
3585
3681
|
const land = lander || (({ entry }) => {
|
|
3586
|
-
const rebased = enforceRemoteBoundary || localOnly
|
|
3682
|
+
const rebased = enforceRemoteBoundary || localOnly || entry.alreadyPushed
|
|
3587
3683
|
? { ok: true, stage: 'frozen_base' }
|
|
3588
3684
|
: rebaseArrival({ worktreePath: entry.worktreePath, targetRef: landTarget });
|
|
3589
3685
|
if (!rebased.ok) return rebased;
|
|
@@ -3740,6 +3836,18 @@ async function runDispatchFlight({
|
|
|
3740
3836
|
});
|
|
3741
3837
|
stampDispatchBrief(root, result.brief_id, 'pass', `self-landed ${entry.taskId}`);
|
|
3742
3838
|
log(` ✓ self-landed ${entry.taskId}`);
|
|
3839
|
+
continue;
|
|
3840
|
+
}
|
|
3841
|
+
// The engine committed real work but did not land it (no ship run, or
|
|
3842
|
+
// ship failed on its own). Land it here the same way a plain dispatch
|
|
3843
|
+
// does: rebase, re-run the task's check, ship. A conflict or failed
|
|
3844
|
+
// check still pauses below.
|
|
3845
|
+
if (selfLanded.stage === 'self_land_missing') {
|
|
3846
|
+
// An engine whose own ship pushed but failed to merge left a PR open.
|
|
3847
|
+
// Rebasing would make that branch impossible to push again, so ship
|
|
3848
|
+
// the pushed branch as it is; ship's merge check still stops a clash.
|
|
3849
|
+
landingEntry.alreadyPushed = branchOnOrigin(entry.worktreePath);
|
|
3850
|
+
log(` ${entry.taskId} is committed but not landed; landing it now`);
|
|
3743
3851
|
} else {
|
|
3744
3852
|
const stage = selfLanded.stage || 'self_land_missing';
|
|
3745
3853
|
flight.paused.push({
|
|
@@ -3755,8 +3863,8 @@ async function runDispatchFlight({
|
|
|
3755
3863
|
log(stage === 'no_work_landed'
|
|
3756
3864
|
? ` ⏸ no work landed for ${entry.taskId}`
|
|
3757
3865
|
: ` ⏸ paused ${entry.taskId} at ${stage}`);
|
|
3866
|
+
continue;
|
|
3758
3867
|
}
|
|
3759
|
-
continue;
|
|
3760
3868
|
}
|
|
3761
3869
|
log(` ${proofOnly ? 'checking' : 'landing'} ${entry.taskId}...`);
|
|
3762
3870
|
const landed = land({ entry: landingEntry, result });
|
|
@@ -4070,6 +4178,7 @@ async function runDispatchFlight({
|
|
|
4070
4178
|
actorByTask: new Map(ids.map((taskId) => [String(taskId), taskActor])),
|
|
4071
4179
|
baseRef: landTarget,
|
|
4072
4180
|
log,
|
|
4181
|
+
keepClaimsWithWork: proofOnly,
|
|
4073
4182
|
});
|
|
4074
4183
|
|
|
4075
4184
|
recordFlightRosterRuns(root, flight, results, {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { runGit } = require('./git-spawn');
|
|
5
|
+
|
|
6
|
+
// Member work copies are cut from committed history, so in a brand new repo
|
|
7
|
+
// with no commits yet there is nothing for a member's first run to start from.
|
|
8
|
+
// Init snapshots what was already dirty, then commits only the paths it touched
|
|
9
|
+
// itself. The person's own uncommitted work and anything they had staged stay
|
|
10
|
+
// exactly as they were. A repo that already has history is left alone: a
|
|
11
|
+
// surprise commit on someone's branch is theirs to make, and members cut from
|
|
12
|
+
// the shared mainline would not see it until it lands there anyway.
|
|
13
|
+
|
|
14
|
+
const FALLBACK_IDENTITY = ['-c', 'user.name=Atris', '-c', 'user.email=299057014+atris-builder[bot]@users.noreply.github.com'];
|
|
15
|
+
|
|
16
|
+
function repoRootFor(cwd) {
|
|
17
|
+
const result = runGit(['rev-parse', '--show-toplevel'], { cwd, check: false });
|
|
18
|
+
if (result.status !== 0) return null;
|
|
19
|
+
return result.stdout.trim() || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Paths git reports as changed or untracked, relative to the repo root.
|
|
23
|
+
function dirtyPaths(root) {
|
|
24
|
+
const result = runGit(['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: root, check: false, maxBuffer: 64 * 1024 * 1024 });
|
|
25
|
+
if (result.status !== 0) return null;
|
|
26
|
+
const entries = result.stdout.split('\0');
|
|
27
|
+
const paths = new Set();
|
|
28
|
+
for (let i = 0; i < entries.length; i++) {
|
|
29
|
+
const entry = entries[i];
|
|
30
|
+
if (!entry) continue;
|
|
31
|
+
const code = entry.slice(0, 2);
|
|
32
|
+
paths.add(entry.slice(3));
|
|
33
|
+
// Renames and copies carry the old path as the next entry.
|
|
34
|
+
if (code[0] === 'R' || code[0] === 'C') i++;
|
|
35
|
+
}
|
|
36
|
+
return paths;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Machine-local config (for example .devin/config.local.json) never belongs in history.
|
|
40
|
+
function isLocalOnly(relPath) {
|
|
41
|
+
return /\.local(\.|$)/.test(path.basename(relPath));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasCommits(root) {
|
|
45
|
+
return runGit(['rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, check: false }).status === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function snapshotBeforeInit(cwd = process.cwd()) {
|
|
49
|
+
const root = repoRootFor(cwd);
|
|
50
|
+
if (!root) return null;
|
|
51
|
+
const before = dirtyPaths(root);
|
|
52
|
+
if (!before) return null;
|
|
53
|
+
return { root, before, fresh: !hasCommits(root) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commitInitFiles(snapshot, { message = 'Set up Atris' } = {}) {
|
|
57
|
+
if (!snapshot) return { committed: false, reason: 'not_git' };
|
|
58
|
+
if (!snapshot.fresh) return { committed: false, reason: 'has_history' };
|
|
59
|
+
const after = dirtyPaths(snapshot.root);
|
|
60
|
+
if (!after) return { committed: false, reason: 'status_failed' };
|
|
61
|
+
const paths = [...after].filter((p) => !snapshot.before.has(p) && !isLocalOnly(p)).sort();
|
|
62
|
+
if (paths.length === 0) return { committed: false, reason: 'nothing_new', paths };
|
|
63
|
+
|
|
64
|
+
const add = runGit(['add', '--', ...paths], { cwd: snapshot.root, check: false });
|
|
65
|
+
if (add.status !== 0) return { committed: false, reason: 'add_failed', detail: (add.stderr || '').trim(), paths };
|
|
66
|
+
|
|
67
|
+
const commitArgs = ['commit', '-q', '-m', message, '--only', '--', ...paths];
|
|
68
|
+
let commit = runGit(commitArgs, { cwd: snapshot.root, check: false });
|
|
69
|
+
if (commit.status !== 0 && /tell me who you are|unable to auto-detect email/i.test(commit.stderr || '')) {
|
|
70
|
+
commit = runGit([...FALLBACK_IDENTITY, ...commitArgs], { cwd: snapshot.root, check: false });
|
|
71
|
+
}
|
|
72
|
+
if (commit.status !== 0) {
|
|
73
|
+
// Leave the index as we found it so a blocked commit stays easy to redo by hand.
|
|
74
|
+
runGit(['reset', '-q', '--', ...paths], { cwd: snapshot.root, check: false });
|
|
75
|
+
return { committed: false, reason: 'commit_failed', detail: (commit.stderr || commit.stdout || '').trim(), paths };
|
|
76
|
+
}
|
|
77
|
+
return { committed: true, paths };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { snapshotBeforeInit, commitInitFiles, dirtyPaths, isLocalOnly };
|
package/lib/intents.js
CHANGED
|
@@ -60,6 +60,14 @@ const INTENTS = Object.freeze([
|
|
|
60
60
|
plain: 'your agent turns the idea into a clear job and starts once any gaps are answered.',
|
|
61
61
|
offer: 'always',
|
|
62
62
|
}),
|
|
63
|
+
Object.freeze({
|
|
64
|
+
id: 'run_a_campaign',
|
|
65
|
+
say: Object.freeze(['run a campaign', 'long goal', 'work on this until the check passes', 'big goal with a budget']),
|
|
66
|
+
do: 'atris campaign new "<their words>"',
|
|
67
|
+
plain: 'your agent writes a check that fails today, shows it to you, then works the goal under a budget until the check passes.',
|
|
68
|
+
offer: 'always',
|
|
69
|
+
confirm: true,
|
|
70
|
+
}),
|
|
63
71
|
Object.freeze({
|
|
64
72
|
id: 'catch_up',
|
|
65
73
|
say: Object.freeze(['catch me up', 'what happened', 'what landed', 'show me recent progress']),
|
package/lib/known-commands.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
|
-
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout',
|
|
6
|
+
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout',
|
|
7
7
|
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
8
|
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
|
|
9
9
|
|
package/lib/land-green.js
CHANGED
|
@@ -20,6 +20,7 @@ const os = require('os');
|
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const { spawnSync } = require('child_process');
|
|
22
22
|
const { runGit } = require('./git-spawn');
|
|
23
|
+
const { proveCheckBeforePublish, proveLanding } = require('./landing-proof');
|
|
23
24
|
|
|
24
25
|
const DEFAULT_VERIFY = 'npm test';
|
|
25
26
|
const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
|
|
@@ -239,8 +240,15 @@ function landOneBranch(root, base, branch, {
|
|
|
239
240
|
if (!scratch.ok) return verdictFor(branch, base, { action: 'error', reason: 'scratch_checkout_failed', detail: scratch.error });
|
|
240
241
|
try {
|
|
241
242
|
// Verify the merged state, not the branch tip: base may have moved since.
|
|
243
|
+
const before = gitOut(['rev-parse', target.target], scratch.dir);
|
|
242
244
|
const merged = gitOut(['merge', '--no-edit', '--no-ff', target.target, '-m', `land: ${branch} onto ${base} after a green verify`], scratch.dir);
|
|
243
245
|
if (!merged.ok) return verdictFor(branch, base, { action: 'error', reason: 'merge_failed', detail: merged.err || merged.out });
|
|
246
|
+
// A branch of empty commits (a "Start flight" marker, a change already on
|
|
247
|
+
// base) merges clean and passes every check. Landing it would record a
|
|
248
|
+
// landing over nothing, so it is named a no-op and never pushed.
|
|
249
|
+
if (gitOut(['diff', '--quiet', target.target, 'HEAD'], scratch.dir).ok) {
|
|
250
|
+
return verdictFor(branch, base, { action: 'no_op', reason: 'empty_diff', detail: `${branch} changes nothing on ${base}` });
|
|
251
|
+
}
|
|
244
252
|
const deps = installDeps(scratch.dir);
|
|
245
253
|
if (!deps.ok) return verdictFor(branch, base, { action: 'error', reason: 'install_failed', detail: deps.detail });
|
|
246
254
|
const verify = runVerifyFn(verifyCommand, scratch.dir, { timeoutMs: verifyTimeoutMs, env });
|
|
@@ -254,6 +262,18 @@ function landOneBranch(root, base, branch, {
|
|
|
254
262
|
}
|
|
255
263
|
const sha = gitOut(['rev-parse', 'HEAD'], scratch.dir);
|
|
256
264
|
if (!sha.ok) return verdictFor(branch, base, { action: 'error', reason: 'merged_sha_unreadable' });
|
|
265
|
+
const checkCommand = verify.command || verifyCommand;
|
|
266
|
+
// Nothing is published on a check that could not have caught a break: it
|
|
267
|
+
// must be one that can fail, and it must have run on this merged commit
|
|
268
|
+
// with every changed file committed and tracked. Otherwise hold.
|
|
269
|
+
const unbacked = proveCheckBeforePublish(scratch.dir, {
|
|
270
|
+
commit: sha.out,
|
|
271
|
+
base: before.ok ? before.out : target.target,
|
|
272
|
+
verify: checkCommand,
|
|
273
|
+
});
|
|
274
|
+
if (unbacked) {
|
|
275
|
+
return verdictFor(branch, base, { action: 'skipped', reason: unbacked.reason, detail: unbacked.detail, verify });
|
|
276
|
+
}
|
|
257
277
|
const local = localBaseName(base);
|
|
258
278
|
const pushed = push ? pushBase(root, local, sha.out) : { attempted: false, ok: false, reason: 'push_disabled' };
|
|
259
279
|
if (pushed.attempted && !pushed.ok) {
|
|
@@ -262,8 +282,35 @@ function landOneBranch(root, base, branch, {
|
|
|
262
282
|
return verdictFor(branch, base, { action: 'skipped', reason: 'push_rejected', detail: pushed.detail, verify });
|
|
263
283
|
}
|
|
264
284
|
const advanced = advanceBase(root, local, sha.out);
|
|
265
|
-
|
|
266
|
-
|
|
285
|
+
// "landed" is a fact about the target branch, checked after the fact by
|
|
286
|
+
// the same rule every receipt uses: the commit must be on origin's copy.
|
|
287
|
+
// A local branch only counts in a repo with no remote at all; with a
|
|
288
|
+
// remote, an unpushed merge is not landed however the local ref moved.
|
|
289
|
+
const noRemote = !gitOut(['remote'], root).out;
|
|
290
|
+
const landing = proveLanding(root, {
|
|
291
|
+
commit: sha.out,
|
|
292
|
+
base: before.ok ? before.out : '',
|
|
293
|
+
target: noRemote ? `refs/heads/${local}` : `refs/remotes/origin/${local}`,
|
|
294
|
+
verify: checkCommand,
|
|
295
|
+
localTargetOk: noRemote,
|
|
296
|
+
});
|
|
297
|
+
if (landing.status !== 'landed') {
|
|
298
|
+
// Once pushed, the merge is on origin whatever the check says; the
|
|
299
|
+
// record names both facts instead of a bare "unverified".
|
|
300
|
+
return verdictFor(branch, base, {
|
|
301
|
+
action: pushed.ok ? 'pushed' : landing.status,
|
|
302
|
+
reason: landing.reason,
|
|
303
|
+
detail: landing.detail,
|
|
304
|
+
landing_check: landing.status,
|
|
305
|
+
sha: sha.out,
|
|
306
|
+
verify,
|
|
307
|
+
push: pushed,
|
|
308
|
+
local_base: advanced.ok ? undefined : advanced.reason,
|
|
309
|
+
landing,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!advanced.ok) return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, local_base: advanced.reason, landing });
|
|
313
|
+
return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, landing });
|
|
267
314
|
} finally {
|
|
268
315
|
dropScratch(root, scratch.dir);
|
|
269
316
|
}
|
|
@@ -311,10 +358,12 @@ function landStaleGreenBranches(root, {
|
|
|
311
358
|
.filter((b) => b.state === 'active' && b.stale)
|
|
312
359
|
.sort((a, b) => b.activityHours - a.activityHours);
|
|
313
360
|
const verdicts = [];
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
361
|
+
// Any verdict that ran the verify suite spends the per-tick budget, landed
|
|
362
|
+
// or not: a run of pushed-but-unproven or no-landing verdicts must not run
|
|
363
|
+
// four 20-minute suites in one tick. An erroring or ineligible branch that
|
|
364
|
+
// never reached the suite does not starve the rest of the queue. A bounded
|
|
365
|
+
// run of misses still stops the walk so a fully broken queue does not burn
|
|
366
|
+
// a scratch checkout per branch per tick.
|
|
318
367
|
let spent = 0;
|
|
319
368
|
let misses = 0;
|
|
320
369
|
for (const candidate of candidates) {
|
|
@@ -328,7 +377,7 @@ function landStaleGreenBranches(root, {
|
|
|
328
377
|
} catch (err) {
|
|
329
378
|
verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
|
|
330
379
|
}
|
|
331
|
-
if (verdict.action === 'landed' || verdict.action === 'red') {
|
|
380
|
+
if (verdict.action === 'landed' || verdict.action === 'red' || verdict.verify) {
|
|
332
381
|
spent += 1;
|
|
333
382
|
misses = 0;
|
|
334
383
|
} else {
|
|
@@ -362,6 +411,8 @@ function summaryLine(result) {
|
|
|
362
411
|
for (const v of result.verdicts) {
|
|
363
412
|
if (v.action === 'skipped' && v.reason !== 'per_tick_limit') parts.push(`${v.branch} held (${v.reason.replace(/_/g, ' ')}${v.detail ? `: ${v.detail}` : ''})`);
|
|
364
413
|
if (v.action === 'error') parts.push(`${v.branch} error (${v.reason.replace(/_/g, ' ')})`);
|
|
414
|
+
if (v.action === 'pushed') parts.push(`${v.branch} pushed, landing check: ${String(v.landing_check).replace(/_/g, ' ')} (${v.reason.replace(/_/g, ' ')})`);
|
|
415
|
+
if (['no_op', 'not_landed', 'unverified'].includes(v.action)) parts.push(`${v.branch} not landed (${v.reason.replace(/_/g, ' ')})`);
|
|
365
416
|
}
|
|
366
417
|
const waiting = result.verdicts.filter((v) => v.reason === 'per_tick_limit').length;
|
|
367
418
|
if (waiting) parts.push(`${waiting} more waiting for the next tick`);
|