@bongos/core 1.21.5 → 1.21.6
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/.bongos-core.json +138 -68
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +31 -4
- package/docs/page-readings.json +121 -94
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +58 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -42,7 +42,8 @@ const gauge = require('./autonomy-gauge.js');
|
|
|
42
42
|
const {
|
|
43
43
|
buildWorkerArgs, buildWorkerPrompt, classifyRun, usageLimitHit, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
|
|
44
44
|
} = require('./autobongos-loop.js');
|
|
45
|
-
const { verifyShip, failureReason } = require('./autobongos-verify.js');
|
|
45
|
+
const { verifyShip, failureReason, recheckLands, pendingLandIds } = require('./autobongos-verify.js');
|
|
46
|
+
const gradeCap = require('./autobongos-grade-cap.js');
|
|
46
47
|
const fence = require('../../modules/autonomy/fence.js');
|
|
47
48
|
// The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
|
|
48
49
|
// sits outside the kernel boundary, so this direct core require is allowed here
|
|
@@ -51,6 +52,7 @@ const fence = require('../../modules/autonomy/fence.js');
|
|
|
51
52
|
const { matchProtected } = require('../../src/bongos/permission-path-check');
|
|
52
53
|
const { resolveEnv, envPrefix, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
|
|
53
54
|
const cadence = require('../../modules/autonomy/cadence.js');
|
|
55
|
+
const configIdle = require('../../modules/autonomy/config-idle.js');
|
|
54
56
|
|
|
55
57
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
56
58
|
|
|
@@ -106,6 +108,58 @@ function record(entry) {
|
|
|
106
108
|
return row;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// readRunLogRows — the run log back as rows, newest last. Only the TAIL is read
|
|
112
|
+
// (a log that runs for weeks must not be slurped whole every loop), and a line
|
|
113
|
+
// that will not parse — the half-written last line of a kill -9 — is skipped.
|
|
114
|
+
function readRunLogRows(p = logPath(), maxBytes = 4 * 1024 * 1024) {
|
|
115
|
+
let text = '';
|
|
116
|
+
try {
|
|
117
|
+
const size = fs.statSync(p).size;
|
|
118
|
+
const fd = fs.openSync(p, 'r');
|
|
119
|
+
try {
|
|
120
|
+
const len = Math.min(size, maxBytes);
|
|
121
|
+
const buf = Buffer.alloc(len);
|
|
122
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
123
|
+
text = buf.toString('utf8');
|
|
124
|
+
} finally { fs.closeSync(fd); }
|
|
125
|
+
} catch (_) { return []; }
|
|
126
|
+
const rows = [];
|
|
127
|
+
for (const line of text.split('\n')) {
|
|
128
|
+
if (!line.trim()) continue;
|
|
129
|
+
try { rows.push(JSON.parse(line)); } catch (_) { /* partial line */ }
|
|
130
|
+
}
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// recheckPendingLands — once per loop (task 1004538). Which tasks are waiting on
|
|
135
|
+
// a land comes from the run log on disk and when they shipped comes from the
|
|
136
|
+
// ledger, so a restart loses neither. Open `shipped_no_artifact` blockers are read
|
|
137
|
+
// from the database and resolved once main carries the commit. Every outcome is a
|
|
138
|
+
// row, so the next pass reads it back.
|
|
139
|
+
//
|
|
140
|
+
// The blocker sweep runs at most once per LAND_SWEEP_MS per process (and at once
|
|
141
|
+
// after a restart): a false alarm closing within the hour is plenty, and the
|
|
142
|
+
// open-blocker list need not be read on every loop. Pending lands are re-probed
|
|
143
|
+
// every loop — and cost nothing when there are none.
|
|
144
|
+
const LAND_SWEEP_MS = 60 * 60 * 1000;
|
|
145
|
+
async function recheckPendingLands(deps = {}) {
|
|
146
|
+
const log = deps.record || record;
|
|
147
|
+
const st = deps.state || RUNNER_STATE;
|
|
148
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
149
|
+
const sweepBlockers = !st.landSweepAt || now - st.landSweepAt >= LAND_SWEEP_MS;
|
|
150
|
+
const ids = pendingLandIds(readRunLogRows());
|
|
151
|
+
if (!ids.length && !sweepBlockers) return { rechecked: [], resolved: [], errors: [] };
|
|
152
|
+
const out = await recheckLands({ pendingIds: ids, sweepBlockers }, { ...(deps.api ? { api: deps.api } : {}), ...(deps.landDeps || {}) });
|
|
153
|
+
if (sweepBlockers) st.landSweepAt = now;
|
|
154
|
+
for (const r of out.rechecked) log({ event: 'land_recheck', ...r });
|
|
155
|
+
for (const r of out.resolved) {
|
|
156
|
+
log({ event: 'land_blocker_resolved', task_id: r.task_id, blocker_id: r.blocker_id, sha: r.sha,
|
|
157
|
+
reason: `main now carries ${r.sha} naming task ${r.task_id} — the no-artifact blocker was a slow land, resolved` });
|
|
158
|
+
}
|
|
159
|
+
if (out.errors.length) log({ event: 'land_recheck_failed', errors: out.errors.slice(0, 10) });
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
109
163
|
// pickedFrom — the row fields that say where a pick came from (task 1004453).
|
|
110
164
|
// `priority` is true only when the fence named a priority goal AND this pick came
|
|
111
165
|
// from it, so "the priority goal ran dry and the runner moved on" reads as
|
|
@@ -246,9 +300,41 @@ function resolveWorkerBin(env = process.env, platform = process.platform) {
|
|
|
246
300
|
return { bin: 'claude', shell: true };
|
|
247
301
|
}
|
|
248
302
|
|
|
303
|
+
// gradeLedgerPath — the run's LOCAL ledger (task 1004536): the file ship.js reads
|
|
304
|
+
// to refuse a third grade early. A fresh file per run, under the instance config
|
|
305
|
+
// dir, never inside the task's tree. It is the courtesy half of the cap — see the
|
|
306
|
+
// header of autobongos-grade-cap.js; the boundary is the server count below.
|
|
307
|
+
const GRADE_CAP_WATCH_MS = 30_000;
|
|
308
|
+
function gradeLedgerPath(taskId) {
|
|
309
|
+
const id = Number(taskId);
|
|
310
|
+
if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`gradeLedgerPath: task id must be a positive integer, got ${taskId}`);
|
|
311
|
+
const name = path.join('autobongos-grades', `${id}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.jsonl`);
|
|
312
|
+
try { return require('../../src/instance-config.js').configPath(name); }
|
|
313
|
+
catch (_) { return path.join(os.tmpdir(), name); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// gradeCapReader — how the supervisor counts this run's graded rounds.
|
|
317
|
+
//
|
|
318
|
+
// The SERVER's grade_attempts is the count that matters: the worker has no write
|
|
319
|
+
// path to it, so clearing an env var or deleting a file cannot reset it. The
|
|
320
|
+
// baseline is the newest attempt recorded BEFORE the worker starts; only rounds
|
|
321
|
+
// after it belong to this run. When the server cannot be read, the local ledger
|
|
322
|
+
// is the fallback — and either source showing the cap exhausted is enough.
|
|
323
|
+
async function gradeCapReader({ api, taskId, ledger }) {
|
|
324
|
+
const before = await gradeCap.readServerAttempts(api, taskId);
|
|
325
|
+
const afterMs = before ? gradeCap.newestAttemptAt(before) : null;
|
|
326
|
+
return async function read() {
|
|
327
|
+
const local = gradeCap.gradeCapState(gradeCap.readLedger(ledger), taskId);
|
|
328
|
+
const attempts = before ? await gradeCap.readServerAttempts(api, taskId) : null;
|
|
329
|
+
if (!attempts) return { ...local, source: 'local' };
|
|
330
|
+
const server = gradeCap.gradeCapState(gradeCap.roundsFromAttempts(attempts, taskId, afterMs), taskId);
|
|
331
|
+
return server.exhausted || !local.exhausted ? { ...server, source: 'server' } : { ...local, source: 'local' };
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
249
335
|
// spawnWorker — one headless session, bounded by WALL CLOCK, in the task's own
|
|
250
336
|
// working tree.
|
|
251
|
-
async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
|
|
337
|
+
async function spawnWorker({ task, model, timeoutMs, cwd, gradeLedger = null, capCheck = null, deps = {} }) {
|
|
252
338
|
const spawnFn = deps.spawn || spawn;
|
|
253
339
|
const sessionId = deps.sessionId || crypto.randomUUID(); // pinned, so the session can be resumed
|
|
254
340
|
const { bin, shell: workerShell } = deps.resolveBin ? deps.resolveBin() : resolveWorkerBin();
|
|
@@ -267,22 +353,42 @@ async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
|
|
|
267
353
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
268
354
|
shell: workerShell,
|
|
269
355
|
windowsHide: true,
|
|
270
|
-
|
|
356
|
+
// The run's local grade ledger (task 1004536): ship.js appends each graded
|
|
357
|
+
// round to it and refuses a third early. The worker can see this variable,
|
|
358
|
+
// so it is the courtesy half only — capCheck below is the boundary.
|
|
359
|
+
env: workerEnv(process.env, gradeLedger ? { [gradeCap.LEDGER_ENV]: gradeLedger } : {}),
|
|
271
360
|
});
|
|
272
361
|
} catch (e) { return resolve({ spawnError: e.message }); }
|
|
273
362
|
|
|
274
|
-
let stdout = ''; let stderr = ''; let timedOut = false; let truncated = false;
|
|
363
|
+
let stdout = ''; let stderr = ''; let timedOut = false; let truncated = false; let gradeCapped = false;
|
|
275
364
|
const cap = (cur, d) => {
|
|
276
365
|
if (cur.length >= MAX_WORKER_OUTPUT) { truncated = true; return cur; }
|
|
277
366
|
return cur + String(d).slice(0, MAX_WORKER_OUTPUT - cur.length);
|
|
278
367
|
};
|
|
279
368
|
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch (_) {} }, timeoutMs);
|
|
369
|
+
// task 1004536: once the second graded FAIL is recorded the run is over — the
|
|
370
|
+
// task goes to the owner. capCheck reads the SERVER's grade_attempts (see
|
|
371
|
+
// gradeCapReader), so a worker that cleared the ledger env or deleted the file
|
|
372
|
+
// is still stopped. One read at a time: a slow API must not stack polls.
|
|
373
|
+
let capBusy = false;
|
|
374
|
+
const capWatch = capCheck ? setInterval(async () => {
|
|
375
|
+
if (capBusy || gradeCapped) return;
|
|
376
|
+
capBusy = true;
|
|
377
|
+
try {
|
|
378
|
+
if ((await capCheck()).exhausted) {
|
|
379
|
+
gradeCapped = true; clearInterval(capWatch);
|
|
380
|
+
try { child.kill('SIGKILL'); } catch (_) {}
|
|
381
|
+
}
|
|
382
|
+
} catch (_) { /* an unreadable count is retried next tick; the post-run read still decides */ }
|
|
383
|
+
capBusy = false;
|
|
384
|
+
}, deps.capWatchMs || GRADE_CAP_WATCH_MS) : null;
|
|
385
|
+
const stopWatch = () => { if (capWatch) clearInterval(capWatch); };
|
|
280
386
|
child.stdout.on('data', (d) => { stdout = cap(stdout, d); });
|
|
281
387
|
child.stderr.on('data', (d) => { stderr = cap(stderr, d); });
|
|
282
|
-
child.on('error', (e) => { clearTimeout(timer); resolve({ spawnError: e.message, sessionId }); });
|
|
388
|
+
child.on('error', (e) => { clearTimeout(timer); stopWatch(); resolve({ spawnError: e.message, sessionId }); });
|
|
283
389
|
child.on('close', (code) => {
|
|
284
|
-
clearTimeout(timer);
|
|
285
|
-
resolve({ envelope: stdout, exitCode: code, timedOut, truncated, sessionId, stderr: stderr.slice(-2000) });
|
|
390
|
+
clearTimeout(timer); stopWatch();
|
|
391
|
+
resolve({ envelope: stdout, exitCode: code, timedOut, truncated, gradeCapped, sessionId, stderr: stderr.slice(-2000) });
|
|
286
392
|
});
|
|
287
393
|
child.stdin.end(prompt);
|
|
288
394
|
});
|
|
@@ -368,7 +474,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
|
|
|
368
474
|
const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
|
|
369
475
|
|
|
370
476
|
function newRunnerState() {
|
|
371
|
-
return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null };
|
|
477
|
+
return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null, landSweepAt: null };
|
|
372
478
|
}
|
|
373
479
|
const RUNNER_STATE = newRunnerState();
|
|
374
480
|
|
|
@@ -543,8 +649,24 @@ async function iteration(opts, deps = {}) {
|
|
|
543
649
|
|
|
544
650
|
// 5. Work, in that tree.
|
|
545
651
|
const started = Date.now();
|
|
546
|
-
const
|
|
547
|
-
|
|
652
|
+
const gradeLedger = deps.gradeLedgerPath ? deps.gradeLedgerPath(task.id) : gradeLedgerPath(task.id);
|
|
653
|
+
// Same client rule as verifyShip below: an injected api is used as given.
|
|
654
|
+
let capApi = deps.api || null;
|
|
655
|
+
if (!capApi) { try { capApi = await (require('./cli-lib').cliClient)(); } catch (_) { capApi = null; } }
|
|
656
|
+
const capCheck = await gradeCapReader({ api: capApi, taskId: task.id, ledger: gradeLedger });
|
|
657
|
+
const res = await spawnWork({ task, model: opts.model, timeoutMs: opts.timeoutMs, cwd: wtPath, gradeLedger, capCheck, deps });
|
|
658
|
+
let verdict = classifyRun(res);
|
|
659
|
+
// 5b. THE GRADE CAP (task 1004536). The owner's rule — a task that fails the
|
|
660
|
+
// grade twice goes to the owner — read from the run's ledger, not from the
|
|
661
|
+
// worker's verdict: the worker was told this in its prompt and kept re-grading.
|
|
662
|
+
const grades = await capCheck();
|
|
663
|
+
try { fs.rmSync(gradeLedger, { force: true }); } catch (_) {} // its counts travel in the run-log row
|
|
664
|
+
if (grades && grades.exhausted) {
|
|
665
|
+
verdict = {
|
|
666
|
+
...verdict, outcome: 'needs_human', releaseClaim: true,
|
|
667
|
+
reason: `the grade failed ${grades.fails} times in this run — handed to the owner instead of re-graded again (task 1004536)`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
548
670
|
// Cut off by the usage limit? Recorded here so the NEXT iteration sleeps to the
|
|
549
671
|
// reset instead of spawning a worker that will be cut off the same way.
|
|
550
672
|
const limit = verdict.outcome === 'claims_shipped' ? null : usageLimitHit({ envelope: res.envelope, stderr: res.stderr });
|
|
@@ -580,6 +702,7 @@ async function iteration(opts, deps = {}) {
|
|
|
580
702
|
outcome: verdict.outcome, reason: verdict.reason,
|
|
581
703
|
session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
|
|
582
704
|
cost_usd: verdict.costUsd ?? null,
|
|
705
|
+
...(grades && (grades.graded || grades.outages) ? { grade_rounds: grades.graded, grade_outages: grades.outages, grade_cap_reached: grades.exhausted, grade_count_source: grades.source } : {}),
|
|
583
706
|
output_truncated: !!res.truncated,
|
|
584
707
|
...(limit ? { usage_limit: { reset_at: limit.resetAt } } : {}),
|
|
585
708
|
undetermined_decisions: verdict.verdict ? verdict.verdict.undetermined_decisions : null,
|
|
@@ -1054,7 +1177,14 @@ async function forever(opts, deps = {}) {
|
|
|
1054
1177
|
// down the thing it watches is strictly worse than a gap in the log, and the gap
|
|
1055
1178
|
// reports itself anyway: the hall reads the AGE of the last check-in, so a
|
|
1056
1179
|
// failed beat shows up as silence, which is the signal.
|
|
1057
|
-
|
|
1180
|
+
// How long this runner has been idle on a setting the owner can fix (task
|
|
1181
|
+
// 1004539). Past that setting's grace, every beat that is not about a task says
|
|
1182
|
+
// `config_idle:<code>` instead of "waiting", so the hall shows the fix rather
|
|
1183
|
+
// than "alive" for ten hours. Any row that is not that refusal ends the stretch.
|
|
1184
|
+
let idle = null;
|
|
1185
|
+
const clock = () => (deps.now ? deps.now() : Date.now());
|
|
1186
|
+
const beat = async (rawFields) => {
|
|
1187
|
+
const fields = configIdle.markBeat(rawFields, idle, clock());
|
|
1058
1188
|
// The runner state is passed, not defaulted: the file names `disk_head` from
|
|
1059
1189
|
// it (task 1004406), and an injected state must reach the beat that reports it.
|
|
1060
1190
|
try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
|
|
@@ -1081,6 +1211,15 @@ async function forever(opts, deps = {}) {
|
|
|
1081
1211
|
// Every loop, so a crash leaves a heartbeat that goes stale on its own — the
|
|
1082
1212
|
// runner never has to notice it is dying for its death to become visible.
|
|
1083
1213
|
await beat({ mode: state.mode, consecutive_failures: state.consecutiveFailures, last_event: 'loop' });
|
|
1214
|
+
// Slow lands (task 1004538): re-probe shipped tasks still waiting on main, and
|
|
1215
|
+
// close no-artifact blockers whose commit has since landed. Armed only by
|
|
1216
|
+
// main() (or an injected stub), like refreshInPlace, so no test harness that
|
|
1217
|
+
// predates it can fall through to the network. It can never stop the loop.
|
|
1218
|
+
if (opts.recheckLands || deps.recheckLands) {
|
|
1219
|
+
try { await (deps.recheckLands || recheckPendingLands)(deps); } catch (e) {
|
|
1220
|
+
log({ event: 'land_recheck_failed', errors: [e && e.message ? e.message : String(e)] });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1084
1223
|
// In `probing` the runner takes exactly ONE task and only after its wait, so
|
|
1085
1224
|
// a broken window costs a handful of cheap requests rather than a night of
|
|
1086
1225
|
// failing spawns. `maxTasks` still bounds a full-speed burst.
|
|
@@ -1090,6 +1229,7 @@ async function forever(opts, deps = {}) {
|
|
|
1090
1229
|
for (let i = 0; i < budget; i++) {
|
|
1091
1230
|
row = await iterate(opts, deps);
|
|
1092
1231
|
emit(row);
|
|
1232
|
+
idle = configIdle.trackIdle(idle, row, clock());
|
|
1093
1233
|
// Carry the task id so the hall can tell "quiet because it is building
|
|
1094
1234
|
// task 1234" from "quiet because it is dead". A worker may hold the loop for
|
|
1095
1235
|
// ninety minutes, and without this every honest hour of work looks like a
|
|
@@ -1161,7 +1301,7 @@ async function main(argv) {
|
|
|
1161
1301
|
// it lived on the machine being fenced; the database owns that now, and an empty
|
|
1162
1302
|
// `--goal` means "every allowlisted goal" rather than "anything claimable". The
|
|
1163
1303
|
// refusal did not disappear — it moved somewhere the runner host cannot edit.
|
|
1164
|
-
if (opts.forever) return forever({ ...opts, refreshInPlace: true });
|
|
1304
|
+
if (opts.forever) return forever({ ...opts, refreshInPlace: true, recheckLands: true });
|
|
1165
1305
|
|
|
1166
1306
|
// The one-shot path, kept for an operator running a single task by hand. It
|
|
1167
1307
|
// still reconciles first: a leftover claim is a leftover claim whoever is
|
|
@@ -1186,8 +1326,9 @@ if (require.main === module) {
|
|
|
1186
1326
|
|
|
1187
1327
|
module.exports = {
|
|
1188
1328
|
readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
|
|
1189
|
-
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
1329
|
+
worktreeName, readFence, gradeLedgerPath, gradeCapReader, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
1190
1330
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
1191
1331
|
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, claudeAccountLabel, RUNNER_STARTED_AT, failureReason,
|
|
1192
1332
|
newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1333
|
+
readRunLogRows, recheckPendingLands, LAND_SWEEP_MS,
|
|
1193
1334
|
};
|
|
@@ -72,6 +72,33 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
72
72
|
// fail loudly here rather than silently reclassify a held claim as untouched.
|
|
73
73
|
const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
|
|
74
74
|
|
|
75
|
+
// LAND_GRACE_MS — how long a SHIPPED task may have nothing on main before that
|
|
76
|
+
// is an owner's problem (task 1004538).
|
|
77
|
+
//
|
|
78
|
+
// The one place this file deliberately waits. Everywhere else the session that
|
|
79
|
+
// was meant to finish the job has exited, so waiting cannot change the verdict.
|
|
80
|
+
// A shipped task is different: the ship is done, and what is outstanding is the
|
|
81
|
+
// LAND, which runs on the server's own clock and can legitimately take hours on a
|
|
82
|
+
// red main. Task 1004513 was flagged at 22:24Z and its merge landed ~11 hours
|
|
83
|
+
// later; the owner got a false alarm (blocker 1000185). A day covers that with
|
|
84
|
+
// room to spare, and a genuinely codeless ship is already credited, so a day's
|
|
85
|
+
// delay in saying so costs nothing. AUTOBONGOS_LAND_GRACE_HOURS overrides it.
|
|
86
|
+
const LAND_GRACE_MS = (() => {
|
|
87
|
+
const h = Number(process.env.AUTOBONGOS_LAND_GRACE_HOURS);
|
|
88
|
+
return Number.isFinite(h) && h > 0 ? h * 3600_000 : 24 * 3600_000;
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
// When the ship happened, from the LEDGER — never from this process, so a runner
|
|
92
|
+
// restart cannot reset the clock. `shipped_at` first: `updated_at` moves on any
|
|
93
|
+
// touch. Null when neither parses, which classifyLedger reads as "no grace".
|
|
94
|
+
function shippedAtMs(task) {
|
|
95
|
+
for (const k of ['shipped_at', 'updated_at']) {
|
|
96
|
+
const t = Date.parse(task && task[k]);
|
|
97
|
+
if (Number.isFinite(t)) return t;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
75
102
|
// ---------------------------------------------------------------------------
|
|
76
103
|
// The pure half
|
|
77
104
|
// ---------------------------------------------------------------------------
|
|
@@ -92,7 +119,7 @@ const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
|
|
|
92
119
|
// modules/ideas/blockers.js is exactly ['ready']), which is why
|
|
93
120
|
// the caller releases BEFORE it links.
|
|
94
121
|
// halt — the run stops here rather than picking another task.
|
|
95
|
-
function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
|
|
122
|
+
function classifyLedger({ task, artifact = null, nowMs = Date.now(), landGraceMs = LAND_GRACE_MS } = {}) {
|
|
96
123
|
const none = { releaseClaim: false, fileBlocker: false, linkBlocker: false, halt: false };
|
|
97
124
|
|
|
98
125
|
if (!task) {
|
|
@@ -110,6 +137,19 @@ function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
|
|
|
110
137
|
|
|
111
138
|
if (status === 'shipped') {
|
|
112
139
|
if (artifact && artifact.checked && !artifact.onMain) {
|
|
140
|
+
// Not yet is not never (task 1004538). Inside the grace window this is a
|
|
141
|
+
// land still in flight: nothing is filed, and the runner re-probes it on
|
|
142
|
+
// later iterations (recheckLands). With no readable clock the grace cannot
|
|
143
|
+
// be measured, so it falls through to the strict verdict below.
|
|
144
|
+
const shippedAt = shippedAtMs(task);
|
|
145
|
+
if (shippedAt !== null && nowMs - shippedAt < landGraceMs) {
|
|
146
|
+
const hours = Math.round(landGraceMs / 3600_000);
|
|
147
|
+
return {
|
|
148
|
+
...none, verified: false, shape: 'pending_land', status, pendingLand: true,
|
|
149
|
+
reason: 'shipped, but main does not carry a commit naming it yet — the land can take hours on a red main',
|
|
150
|
+
remedy: `nothing yet: the runner re-checks main on later iterations and files a blocker only if it is still missing ${hours}h after the ship`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
113
153
|
// The flip happened with nothing behind it — tasks 1002459 and 1002792,
|
|
114
154
|
// both credited, neither carrying a line of code on any branch. The task is
|
|
115
155
|
// NOT released and NOT linked: it is shipped, credits are paid, and parking
|
|
@@ -240,26 +280,50 @@ const runGit = (args, cwd) => new Promise((resolve) => {
|
|
|
240
280
|
//
|
|
241
281
|
// Deliberately weak and deliberately cheap. It answers "is there a trace", not
|
|
242
282
|
// "which commit is it": a body mention counts, because the question being asked
|
|
243
|
-
// is the 1002459 question — was anything at all produced.
|
|
244
|
-
//
|
|
283
|
+
// is the 1002459 question — was anything at all produced.
|
|
284
|
+
//
|
|
285
|
+
// WHAT COUNTS AS NAMING THE TASK (task 1004538). `(task N)` alone was too
|
|
286
|
+
// narrow: the commits PR 1456 landed for task 1004513 never wrote it — the merge
|
|
287
|
+
// subject names the ship BRANCH (`…/autobongos-1004513-978953`, or
|
|
288
|
+
// `…/task-1004440` for a hand ship). So the old probe said "no artifact" even
|
|
289
|
+
// after the land. Now git narrows by the bare id (fixed string, cheap) and
|
|
290
|
+
// namesTask() makes the exact call in JS, never matching the prefix of a longer
|
|
291
|
+
// id. The SUBJECT may name it as `task N` / `task-N` / `autobongos-N-`; the BODY
|
|
292
|
+
// only as `(task N)`, because bodies routinely DISCUSS other tasks ("task 1004513
|
|
293
|
+
// was flagged…" is in this very file's commit), and a mention is not an artifact.
|
|
245
294
|
//
|
|
246
295
|
// A probe that could not run returns `checked: false`, which classifyLedger
|
|
247
296
|
// treats as no evidence either way.
|
|
248
|
-
|
|
297
|
+
function namesTask(subject, body, id) {
|
|
298
|
+
const inSubject = new RegExp(`(?:\\btask[\\s-]+|\\bautobongos-)${id}(?!\\d)`, 'i');
|
|
299
|
+
const inBody = new RegExp(`\\(task ${id}\\)`, 'i');
|
|
300
|
+
return inSubject.test(String(subject || '')) || inBody.test(String(body || ''));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// `fetch: false` skips the fetch and reads the FETCH_HEAD a caller has just
|
|
304
|
+
// fetched itself — recheckLands fetches once per pass, not once per task.
|
|
305
|
+
async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT, fetch = true } = {}) {
|
|
249
306
|
const id = String(taskId);
|
|
250
307
|
if (!/^\d{1,19}$/.test(id)) return { checked: false, reason: 'task id is not a plain number' };
|
|
251
308
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
309
|
+
if (fetch) {
|
|
310
|
+
const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
|
|
311
|
+
if (!fetched.ok) {
|
|
312
|
+
return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
|
|
313
|
+
}
|
|
255
314
|
}
|
|
256
|
-
|
|
315
|
+
// %x1f between fields, %x1e after each commit: a body may hold any text.
|
|
316
|
+
const log = await git(['log', 'FETCH_HEAD', '--max-count=50', '--format=%H%x1f%s%x1f%b%x1e', '-F', `--grep=${id}`], cwd);
|
|
257
317
|
if (!log.ok) return { checked: false, reason: `git log failed: ${(log.stderr || '').trim().slice(-200)}` };
|
|
258
318
|
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
319
|
+
for (const rec of log.stdout.split('\x1e')) {
|
|
320
|
+
const [sha, subject, body] = rec.replace(/^\s+/, '').split('\x1f');
|
|
321
|
+
if (!sha || !sha.trim()) continue;
|
|
322
|
+
if (namesTask(subject, body, id)) {
|
|
323
|
+
return { checked: true, onMain: true, sha: sha.trim(), subject: subject || null };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { checked: true, onMain: false, sha: null, subject: null };
|
|
263
327
|
}
|
|
264
328
|
|
|
265
329
|
// fetchTask returns the row or null, and says WHY on a null. The reason is the
|
|
@@ -330,7 +394,7 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
|
|
|
330
394
|
artifact = await probe(taskId, { cwd: deps.cwd || REPO_ROOT });
|
|
331
395
|
}
|
|
332
396
|
|
|
333
|
-
const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now() });
|
|
397
|
+
const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now(), ...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}) });
|
|
334
398
|
const out = { ...v, task_id: String(taskId), artifact, released: false, blocker: null };
|
|
335
399
|
if (readError) out.read_error = readError;
|
|
336
400
|
if (!act) return out;
|
|
@@ -360,6 +424,136 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
|
|
|
360
424
|
return out;
|
|
361
425
|
}
|
|
362
426
|
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Pending lands, across iterations and restarts (task 1004538)
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
// pendingLandIds — which tasks are still waiting on a land, from the run log.
|
|
432
|
+
//
|
|
433
|
+
// The run log is append-only JSONL on disk, so this survives a restart; nothing
|
|
434
|
+
// lives only in memory. The LATEST verdict per task wins: a `worked` or
|
|
435
|
+
// `land_recheck` row whose ledger_shape is `pending_land` keeps it on the list,
|
|
436
|
+
// anything later (shipped, shipped_no_artifact, …) takes it off. The grace clock
|
|
437
|
+
// itself is not here — it is the ledger's `shipped_at`, re-read on every check.
|
|
438
|
+
function pendingLandIds(rows) {
|
|
439
|
+
const latest = new Map();
|
|
440
|
+
for (const r of rows || []) {
|
|
441
|
+
if (!r || typeof r !== 'object' || !r.task_id || !r.ledger_shape) continue;
|
|
442
|
+
if (r.event !== 'worked' && r.event !== 'land_recheck') continue;
|
|
443
|
+
latest.set(String(r.task_id), r.ledger_shape);
|
|
444
|
+
}
|
|
445
|
+
return Array.from(latest).filter(([, shape]) => shape === 'pending_land').map(([id]) => id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const NO_ARTIFACT_REF = /^task-(\d{1,19})-shipped_no_artifact$/;
|
|
449
|
+
|
|
450
|
+
// openNoArtifactBlockers — every open blocker this file filed as
|
|
451
|
+
// `shipped_no_artifact`, read from the DATABASE (so a blocker filed by a
|
|
452
|
+
// previous process, or before this code existed, is still found).
|
|
453
|
+
//
|
|
454
|
+
// GET /blockers lists OPEN blockers only (listOpenBlockers) — the owner's
|
|
455
|
+
// to-do list, normally tens of rows — so one or two pages is the real cost; the
|
|
456
|
+
// route has no source filter, hence the client-side match. Paged by the route's
|
|
457
|
+
// own `page.total`. MAX_PAGES is a runaway guard, and hitting it is reported, not
|
|
458
|
+
// silently treated as "looked everywhere".
|
|
459
|
+
const BLOCKER_PAGE = 200;
|
|
460
|
+
const MAX_BLOCKER_PAGES = 25;
|
|
461
|
+
async function openNoArtifactBlockers(api) {
|
|
462
|
+
const out = [];
|
|
463
|
+
for (let page = 0; ; page++) {
|
|
464
|
+
if (page >= MAX_BLOCKER_PAGES) {
|
|
465
|
+
throw new Error(`more than ${MAX_BLOCKER_PAGES * BLOCKER_PAGE} open blockers — stopped paging; the ones past that were not checked`);
|
|
466
|
+
}
|
|
467
|
+
const offset = page * BLOCKER_PAGE;
|
|
468
|
+
const r = await api.blockers.getBlockers({ query: { limit: BLOCKER_PAGE, offset } });
|
|
469
|
+
if (!r || !r.ok) throw new Error(`GET /blockers → ${r ? r.status : 'no response'}`);
|
|
470
|
+
const rows = (r.data && r.data.blockers) || [];
|
|
471
|
+
for (const b of rows) {
|
|
472
|
+
const m = b && b.source === 'autobongos' && NO_ARTIFACT_REF.exec(String(b.source_ref || ''));
|
|
473
|
+
if (m && (!b.status || b.status === 'open')) out.push({ id: String(b.id), taskId: m[1] });
|
|
474
|
+
}
|
|
475
|
+
const total = r.data && r.data.page && Number(r.data.page.total);
|
|
476
|
+
if (!rows.length || (Number.isFinite(total) ? offset + rows.length >= total : rows.length < BLOCKER_PAGE)) break;
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// recheckLands — one pass, run by the runner once per loop.
|
|
482
|
+
//
|
|
483
|
+
// 1. Every pending land is verified again. Inside the grace window it stays
|
|
484
|
+
// `pending_land` and nothing is filed; past it, verifyShip files the
|
|
485
|
+
// `shipped_no_artifact` blocker exactly as before. Shipped tasks are never
|
|
486
|
+
// released, so no release function is passed.
|
|
487
|
+
// 2. Every OPEN `shipped_no_artifact` blocker is re-probed, and resolved with
|
|
488
|
+
// a note once main carries the commit — the false alarm closes itself.
|
|
489
|
+
// Only a probe that actually ran and found the commit resolves anything.
|
|
490
|
+
//
|
|
491
|
+
// I/O per pass: at most ONE `git fetch` (only when there is something to probe),
|
|
492
|
+
// one `git log` per distinct task id, and the blocker list only when
|
|
493
|
+
// `sweepBlockers` is not false — the runner sweeps hourly, not every loop.
|
|
494
|
+
//
|
|
495
|
+
// Never throws: a pass that fails is reported in `errors` and retried next loop.
|
|
496
|
+
async function recheckLands({ pendingIds = [], sweepBlockers = true } = {}, deps = {}) {
|
|
497
|
+
const api = deps.api || await (require('./cli-lib').cliClient)();
|
|
498
|
+
const cwd = deps.cwd || REPO_ROOT;
|
|
499
|
+
const git = deps.git || runGit;
|
|
500
|
+
const out = { rechecked: [], resolved: [], errors: [] };
|
|
501
|
+
|
|
502
|
+
let open = [];
|
|
503
|
+
if (sweepBlockers) {
|
|
504
|
+
try { open = await openNoArtifactBlockers(api); }
|
|
505
|
+
catch (e) { out.errors.push(`blocker list: ${e && e.message ? e.message : String(e)}`); }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// One fetch for the whole pass; every probe then reads the same FETCH_HEAD.
|
|
509
|
+
// An injected probe owns its own I/O, so none is done for it.
|
|
510
|
+
let fetchFailed = null;
|
|
511
|
+
if (!deps.probeArtifact && (pendingIds.length || open.length)) {
|
|
512
|
+
const f = await git(['fetch', '-q', 'origin', 'main'], cwd);
|
|
513
|
+
if (!f.ok) fetchFailed = `git fetch failed: ${(f.stderr || '').trim().slice(-200)}`;
|
|
514
|
+
}
|
|
515
|
+
const rawProbe = deps.probeArtifact || ((id) => (fetchFailed
|
|
516
|
+
? { checked: false, reason: fetchFailed }
|
|
517
|
+
: probeArtifact(id, { git, cwd, fetch: false })));
|
|
518
|
+
const memo = new Map();
|
|
519
|
+
const probe = (id) => {
|
|
520
|
+
if (!memo.has(String(id))) {
|
|
521
|
+
memo.set(String(id), (async () => rawProbe(id, { cwd }))()
|
|
522
|
+
.catch((e) => ({ checked: false, reason: e && e.message ? e.message : String(e) })));
|
|
523
|
+
}
|
|
524
|
+
return memo.get(String(id));
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
for (const id of pendingIds) {
|
|
528
|
+
try {
|
|
529
|
+
const v = await verifyShip({ taskId: id }, {
|
|
530
|
+
api, cwd, probeArtifact: (tid) => probe(tid),
|
|
531
|
+
...(deps.fileBlocker ? { fileBlocker: deps.fileBlocker } : {}),
|
|
532
|
+
...(deps.nowMs ? { nowMs: deps.nowMs } : {}),
|
|
533
|
+
...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}),
|
|
534
|
+
});
|
|
535
|
+
out.rechecked.push({
|
|
536
|
+
task_id: String(id), ledger_status: v.status, ledger_shape: v.shape, verified: !!v.verified,
|
|
537
|
+
ledger_reason: v.reason || null,
|
|
538
|
+
blocker_id: v.blocker && v.blocker.blockerId ? v.blocker.blockerId : null,
|
|
539
|
+
});
|
|
540
|
+
} catch (e) { out.errors.push(`task ${id}: ${e && e.message ? e.message : String(e)}`); }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const b of open) {
|
|
544
|
+
const a = await probe(b.taskId);
|
|
545
|
+
if (!a || !a.checked || !a.onMain) continue;
|
|
546
|
+
const sha = a.sha ? String(a.sha).slice(0, 9) : 'a commit';
|
|
547
|
+
const note = `Auto-resolved by autobongos-verify.js (task 1004538): main now carries ${sha}${a.subject ? ` ("${String(a.subject).slice(0, 120)}")` : ''}, which names task ${b.taskId}. The land was slow, not missing.`;
|
|
548
|
+
try {
|
|
549
|
+
const r = await api.blockers.postBlockersIdResolve({ id: Number(b.id), body: { resolution_note: note } });
|
|
550
|
+
if (r && r.ok) out.resolved.push({ blocker_id: b.id, task_id: b.taskId, sha });
|
|
551
|
+
else out.errors.push(`resolve blocker ${b.id} → ${r ? r.status : 'no response'}`);
|
|
552
|
+
} catch (e) { out.errors.push(`resolve blocker ${b.id}: ${e && e.message ? e.message : String(e)}`); }
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
|
|
363
557
|
// ---------------------------------------------------------------------------
|
|
364
558
|
// CLI
|
|
365
559
|
// ---------------------------------------------------------------------------
|
|
@@ -399,4 +593,7 @@ if (require.main === module) {
|
|
|
399
593
|
});
|
|
400
594
|
}
|
|
401
595
|
|
|
402
|
-
module.exports = {
|
|
596
|
+
module.exports = {
|
|
597
|
+
classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason,
|
|
598
|
+
LAND_GRACE_MS, namesTask, pendingLandIds, recheckLands,
|
|
599
|
+
};
|
|
@@ -117,7 +117,7 @@ function parseSchemaPending(stdout) {
|
|
|
117
117
|
* @param {string} installed the version running now
|
|
118
118
|
* @param {string} channel this instance's update channel
|
|
119
119
|
*/
|
|
120
|
-
function targetFault(to, available, installed, channel, channelAllows) {
|
|
120
|
+
function targetFault(to, available, installed, channel, channelAllows, compare) {
|
|
121
121
|
if (!Array.isArray(available) || available.length === 0) {
|
|
122
122
|
return `could not read the published core versions from the registry — nothing was changed; the release may still be publishing (a tag is minted before the publish completes)`;
|
|
123
123
|
}
|
|
@@ -136,6 +136,18 @@ function targetFault(to, available, installed, channel, channelAllows) {
|
|
|
136
136
|
if (!installed) {
|
|
137
137
|
return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
|
|
138
138
|
}
|
|
139
|
+
// A TARGET THE BOX HAS ALREADY REACHED IS NOT A CHANNEL QUESTION (task 1004534).
|
|
140
|
+
// channelAllows answers "may an unattended jump go here" and says no to anything not
|
|
141
|
+
// strictly newer, so on 2026-10-01 a press for 1.20.73, landed a minute earlier by the
|
|
142
|
+
// sweep, was reported as "outside this project's 'minor' update channel … across a major
|
|
143
|
+
// version or onto a prerelease" — false on every count, and it put a "your call" card on
|
|
144
|
+
// /deploy for a project that was simply up to date. Already there: no fault, so the
|
|
145
|
+
// caller's already-serving no-op answers it. Already past it: say that, in words that
|
|
146
|
+
// classify as a downgrade, never as the update rule.
|
|
147
|
+
if (installed === to) return null;
|
|
148
|
+
if (compare && compare(to, installed) < 0) {
|
|
149
|
+
return `core ${to} is older than the core this project already runs (it already runs core ${installed}) — nothing was changed. A move never goes back to an older core on its own.`;
|
|
150
|
+
}
|
|
139
151
|
if (channelAllows && !channelAllows(installed, to, channel)) {
|
|
140
152
|
return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. ${channelNextStep(channel)}`;
|
|
141
153
|
}
|
|
@@ -276,7 +288,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
276
288
|
const channel = instanceChannel(inst, channelMod);
|
|
277
289
|
const listed = offeredVersions(channelMod, inst, instanceDir);
|
|
278
290
|
const available = (listed && listed.versions) || [];
|
|
279
|
-
const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
|
|
291
|
+
const fault = targetFault(to, available, installed, channel, channelMod.channelAllows, channelMod.compareSemver);
|
|
280
292
|
if (fault) return failed(outcome.runnerReason(fault), fault);
|
|
281
293
|
if (installed && installed === to) {
|
|
282
294
|
// `bongos upgrade` would no-op on this too, but saying so here keeps a re-drained
|