@bongos/core 1.20.36 → 1.20.38
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 +104 -49
- package/.claude/skills/backlog-review/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +66 -0
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/priority-session/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +1 -1
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +188 -2
- package/docs/api-reference.md +12 -4
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +9 -9
- package/docs/copy-registry.json +9 -9
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +2 -2
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/autonomy/db.js +39 -8
- package/modules/autonomy/fence.js +26 -4
- package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
- package/modules/autonomy/routes/autonomy.js +28 -1
- package/modules/hall-ui/public/gate.js +33 -4
- package/modules/ideas/routing.js +8 -1
- package/modules/platform-identity/instance-feedback.js +142 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso.js +62 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +121 -16
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/module-assess-tests.js +248 -0
- package/scripts/gds/update-channel.js +82 -0
- package/src/bongos/routes/feedback.js +110 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +62 -0
- package/tests/autobongos_loop.mjs +281 -0
- package/tests/autonomy_fence_priority.mjs +211 -0
- package/tests/bongos_cli.mjs +7 -1
- package/tests/filing_abuse_matrix.mjs +7 -2
- package/tests/idea_routing.mjs +1 -1
- package/tests/instance_feedback.mjs +327 -0
- package/tests/module_assess_tests.mjs +232 -0
- package/tests/update_channel_db.mjs +124 -0
|
@@ -26,6 +26,10 @@ const hubDevice = require('../hub-device');
|
|
|
26
26
|
// The D7 applicant-profile read (task 1002972) and the echo that gates it.
|
|
27
27
|
const applicantProfile = require('../applicant-profile');
|
|
28
28
|
const applicationEcho = require('../application-echo');
|
|
29
|
+
const instanceFeedback = require('../instance-feedback');
|
|
30
|
+
|
|
31
|
+
// One limiter per hub process for POST /sso/feedback (task 1004462).
|
|
32
|
+
const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
29
33
|
|
|
30
34
|
// Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
|
|
31
35
|
// gate, not the gate itself (task 1002972 grade advisory). The echo already
|
|
@@ -692,5 +696,63 @@ module.exports = function ssoRoutes() {
|
|
|
692
696
|
}
|
|
693
697
|
});
|
|
694
698
|
|
|
699
|
+
// POST /sso/feedback — a federated project relays one builder's feedback about
|
|
700
|
+
// Bongos itself (task 1004462). A bug becomes a 'backlog' task in the building
|
|
701
|
+
// version's maintenance goal; an idea becomes an inbox row. Why always backlog,
|
|
702
|
+
// and why the sender must be a witnessed member: ../instance-feedback.js.
|
|
703
|
+
//
|
|
704
|
+
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
705
|
+
// The per-project and per-sender caps run after the client check, so an
|
|
706
|
+
// unauthenticated caller cannot spend a real project's allowance.
|
|
707
|
+
router.post('/sso/feedback', async (req, res) => {
|
|
708
|
+
if (validateOrRespond(req, res, {
|
|
709
|
+
client_id: { required: true, type: 'string', maxLength: 200 },
|
|
710
|
+
client_secret: { required: true, type: 'string', maxLength: 512 },
|
|
711
|
+
github_id: { required: true },
|
|
712
|
+
message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
|
|
713
|
+
kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
|
|
714
|
+
context: {},
|
|
715
|
+
})) return;
|
|
716
|
+
const b = req.body || {};
|
|
717
|
+
try {
|
|
718
|
+
const client = await pi.getClient(b.client_id, { pool });
|
|
719
|
+
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
|
|
720
|
+
return res.fail('invalid_client', 401);
|
|
721
|
+
}
|
|
722
|
+
if (!pi.isNumericGithubId(b.github_id)) return res.fail('invalid_github_id', 400);
|
|
723
|
+
if (!String(b.message).trim()) return res.fail('message_required', 400);
|
|
724
|
+
const membership = await pi.getMembership(client.client_id, b.github_id, { pool });
|
|
725
|
+
if (!pi.membershipBacksRollup(membership)) return res.fail('membership_required', 409);
|
|
726
|
+
const allowed = feedbackLimiter.take(client.client_id, String(b.github_id));
|
|
727
|
+
if (!allowed.ok) {
|
|
728
|
+
res.set('Retry-After', String(allowed.retryAfter));
|
|
729
|
+
return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
|
|
730
|
+
}
|
|
731
|
+
const landing = await instanceFeedback.fileInstanceFeedback({
|
|
732
|
+
client,
|
|
733
|
+
githubId: String(b.github_id),
|
|
734
|
+
message: b.message,
|
|
735
|
+
kind: b.kind || 'bug',
|
|
736
|
+
context: b.context && typeof b.context === 'object' ? b.context : {},
|
|
737
|
+
}, {
|
|
738
|
+
capture: (...a) => api.resolve('ideas.capture')(...a),
|
|
739
|
+
getAccount: (id) => pi.getAccount(id, { pool }),
|
|
740
|
+
hubBuilderId: async (id) => {
|
|
741
|
+
const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
|
|
742
|
+
return rows[0] ? rows[0].id : null;
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
res.status(201).json({ ok: true, ...landing });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
// No building version / no maintenance goal: the hub cannot take a bug right
|
|
748
|
+
// now. Say so rather than 500, so the CLI can tell the builder to retry later.
|
|
749
|
+
if (err && (err.code === 'NO_BUILDING_VERSION' || err.code === 'NO_MAINTENANCE_GOAL')) {
|
|
750
|
+
return res.fail('feedback_unavailable', 503, { reason: err.code });
|
|
751
|
+
}
|
|
752
|
+
log.error('[platform-identity] /sso/feedback', err);
|
|
753
|
+
res.fail('feedback_failed', { status: 500, message: 'internal error' });
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
695
757
|
return router;
|
|
696
758
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.38",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.38",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.38",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8303,5 +8303,35 @@
|
|
|
8303
8303
|
"id": "1004450",
|
|
8304
8304
|
"text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
|
|
8305
8305
|
}
|
|
8306
|
+
],
|
|
8307
|
+
"1.20.37": [
|
|
8308
|
+
{
|
|
8309
|
+
"id": "1003791",
|
|
8310
|
+
"text": "A module's own tests can now be run and scored: the store records what share of them pass, and a module with no tests shows as 'no data yet' instead of failing."
|
|
8311
|
+
},
|
|
8312
|
+
{
|
|
8313
|
+
"id": "1004406",
|
|
8314
|
+
"text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
|
|
8315
|
+
}
|
|
8316
|
+
],
|
|
8317
|
+
"1.20.38": [
|
|
8318
|
+
{
|
|
8319
|
+
"id": "1004468",
|
|
8320
|
+
"text": "The nightly auto-update now follows the update rule you set on /deploy for each project, instead of a file on the server."
|
|
8321
|
+
},
|
|
8322
|
+
{
|
|
8323
|
+
"id": "1004453",
|
|
8324
|
+
"text": "When you start Autobongos from the Gate page you can now pick a priority goal: the runner works that goal first and moves to the other allowed goals only when it runs out of work there."
|
|
8325
|
+
},
|
|
8326
|
+
{
|
|
8327
|
+
"id": "1004472",
|
|
8328
|
+
"text": "Fixes the broken automatic check on main that was stopping everyone's finished work from landing."
|
|
8329
|
+
},
|
|
8330
|
+
{
|
|
8331
|
+
"text": "When a builder complains that Bongos isn't working, their assistant now reports it to the Bongos team by itself, so every frustration becomes something on the fix list."
|
|
8332
|
+
},
|
|
8333
|
+
{
|
|
8334
|
+
"text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
|
|
8335
|
+
}
|
|
8306
8336
|
]
|
|
8307
8337
|
}
|
|
@@ -106,6 +106,15 @@ function record(entry) {
|
|
|
106
106
|
return row;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// pickedFrom — the row fields that say where a pick came from (task 1004453).
|
|
110
|
+
// `priority` is true only when the fence named a priority goal AND this pick came
|
|
111
|
+
// from it, so "the priority goal ran dry and the runner moved on" reads as
|
|
112
|
+
// priority:false beside the goal it moved on to.
|
|
113
|
+
function pickedFrom(goalId, gate) {
|
|
114
|
+
const p = gate && gate.priorityGoal != null ? gate.priorityGoal : null;
|
|
115
|
+
return { priority: p !== null && Number(goalId) === p };
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
// pickTask — the allowlisted goals, in order, through sequence.js's halt matrix.
|
|
110
119
|
// The matrix is reused rather than re-derived: it already refuses thin specs,
|
|
111
120
|
// protected paths, judgement kinds and unmet dependencies, from facts on the row.
|
|
@@ -359,7 +368,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
|
|
|
359
368
|
const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
|
|
360
369
|
|
|
361
370
|
function newRunnerState() {
|
|
362
|
-
return { setAside: new Map(), queueGate: null, limitUntil: null };
|
|
371
|
+
return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
|
|
363
372
|
}
|
|
364
373
|
const RUNNER_STATE = newRunnerState();
|
|
365
374
|
|
|
@@ -479,7 +488,7 @@ async function iteration(opts, deps = {}) {
|
|
|
479
488
|
// looks like, and it must still reach the failure ladder (task 1004179).
|
|
480
489
|
if (lastRefusal) return lastRefusal;
|
|
481
490
|
return log({
|
|
482
|
-
event: 'nothing_claimable', goals: gate.goals,
|
|
491
|
+
event: 'nothing_claimable', goals: gate.goals, priority_goal: gate.priorityGoal ?? null,
|
|
483
492
|
// Carried so the cadence can tell an EMPTY queue from one where every task
|
|
484
493
|
// halts — the reasons are the matrix's own codes, safe to print.
|
|
485
494
|
skipped: (got.skipped || []).map((sk) => ({ id: sk.id, goal_id: sk.goal_id, reasons: (sk.reasons || []).map((r) => r.code) })),
|
|
@@ -501,10 +510,10 @@ async function iteration(opts, deps = {}) {
|
|
|
501
510
|
task: candidate, goals: gate.goals, protectedHits: matchProtected(candidate.touches || []),
|
|
502
511
|
});
|
|
503
512
|
if (!taskGate.go) {
|
|
504
|
-
return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, globs: taskGate.globs || [] });
|
|
513
|
+
return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, ...pickedFrom(got.goalId, gate), globs: taskGate.globs || [] });
|
|
505
514
|
}
|
|
506
515
|
|
|
507
|
-
if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId });
|
|
516
|
+
if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId, ...pickedFrom(got.goalId, gate) });
|
|
508
517
|
|
|
509
518
|
// 3. The task's own working tree.
|
|
510
519
|
const name = deps.worktreeName ? deps.worktreeName(candidate.id) : worktreeName(candidate.id);
|
|
@@ -566,6 +575,7 @@ async function iteration(opts, deps = {}) {
|
|
|
566
575
|
|
|
567
576
|
const row = {
|
|
568
577
|
event: 'worked', task_id: task.id, title: task.title, goal_id: picked.goalId,
|
|
578
|
+
...pickedFrom(picked.goalId, gate),
|
|
569
579
|
worktree: wtName,
|
|
570
580
|
outcome: verdict.outcome, reason: verdict.reason,
|
|
571
581
|
session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
|
|
@@ -666,13 +676,16 @@ function heartbeatPath() {
|
|
|
666
676
|
// output, reddening `unit` on main and blocking every PR. Use a regular FILE as the
|
|
667
677
|
// parent directory instead: mkdir under it is ENOTDIR immediately, everywhere, and it
|
|
668
678
|
// does not depend on the caller's permissions.
|
|
669
|
-
function writeHeartbeat(now = Date.now()) {
|
|
679
|
+
function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
|
|
670
680
|
try {
|
|
671
681
|
const p = heartbeatPath();
|
|
672
682
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
673
683
|
// `head` is what this process is RUNNING, not what is on disk (task 1004374).
|
|
674
684
|
// Alive and alive-on-stale-code look identical from a check-in age alone.
|
|
675
|
-
|
|
685
|
+
// `disk_head` appears only when the checkout was refreshed in place past what
|
|
686
|
+
// is running (task 1004406): "up to date on disk, old supervisor" is visible.
|
|
687
|
+
const disk = runnerState && runnerState.diskHead && runnerState.diskHead !== LOADED_HEAD ? runnerState.diskHead : undefined;
|
|
688
|
+
fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD, ...(disk ? { disk_head: disk } : {}) }));
|
|
676
689
|
return true;
|
|
677
690
|
} catch (_) { return false; }
|
|
678
691
|
}
|
|
@@ -851,10 +864,79 @@ async function codeDrifted(deps = {}) {
|
|
|
851
864
|
// the comparison untestable in isolation.
|
|
852
865
|
const mine = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
853
866
|
if (!mine) return null;
|
|
867
|
+
// Measured from what is on DISK once the checkout has been refreshed in place
|
|
868
|
+
// (task 1004406), or every loop after a refresh would see the same drift again.
|
|
869
|
+
const base = ((deps.state || RUNNER_STATE).diskHead) || mine;
|
|
854
870
|
let tip = null;
|
|
855
871
|
try { tip = await runFetchHead(deps); } catch (_) { return null; }
|
|
856
872
|
if (!tip) return null;
|
|
857
|
-
return tip !==
|
|
873
|
+
return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── pull always, restart rarely (task 1004406) ──────────────────────────────
|
|
877
|
+
//
|
|
878
|
+
// Owner-approved 2026-09-30. Main moves every time ANY session lands, and a
|
|
879
|
+
// relaunch per merge meant a busy day of restarts (four in fourteen minutes on
|
|
880
|
+
// 2026-09-29). But a restart only refreshes code THIS process holds in memory:
|
|
881
|
+
// every worker, and every claim.js / worktree.js / release.js the loop shells
|
|
882
|
+
// out to, is a fresh process that runs whatever is on disk. So on drift the
|
|
883
|
+
// checkout is updated in place, and the process exits for the wrapper only when
|
|
884
|
+
// a change touches code it has actually loaded.
|
|
885
|
+
//
|
|
886
|
+
// SUPERVISOR_PATH_RES catch the supervisor's own files even before they are
|
|
887
|
+
// required (autobongos-verify.js is loaded lazily on the first ship), so a change
|
|
888
|
+
// to a file the loop is ABOUT to load cannot mix old callers with a new callee.
|
|
889
|
+
const SUPERVISOR_PATH_RES = [/^scripts\/gds\/autobongos-[^/]+$/, /^modules\/autonomy\//];
|
|
890
|
+
|
|
891
|
+
// The repo files this process has loaded, repo-relative with forward slashes.
|
|
892
|
+
function loadedFiles() {
|
|
893
|
+
const root = REPO_ROOT + path.sep;
|
|
894
|
+
const nm = path.sep + 'node_modules' + path.sep;
|
|
895
|
+
return Object.keys(require.cache)
|
|
896
|
+
.filter((p) => p.startsWith(root) && !p.includes(nm))
|
|
897
|
+
.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join('/'));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// The changed files that force a restart: ones this process holds, or ones under
|
|
901
|
+
// the supervisor's own paths. Everything else is picked up by the next child.
|
|
902
|
+
function supervisorTouched(changed = [], loaded = loadedFiles()) {
|
|
903
|
+
const held = new Set(loaded);
|
|
904
|
+
return changed.filter((f) => held.has(f) || SUPERVISOR_PATH_RES.some((re) => re.test(f)));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// refreshCheckout — the wrapper's fetch + reset, done from inside the process.
|
|
908
|
+
//
|
|
909
|
+
// THE MAIN CHECKOUT ONLY. It runs `git reset --hard`, and a linked worktree is a
|
|
910
|
+
// claim somebody is building in; resetting one would destroy their work. The
|
|
911
|
+
// check compares git-dir to git-common-dir, which are equal only in the main
|
|
912
|
+
// checkout. Armed only by main() (`opts.refreshInPlace`), so a test driving
|
|
913
|
+
// forever() can never reach it — in CI that reset would wipe the code under test.
|
|
914
|
+
//
|
|
915
|
+
// The diff is from the LOADED commit, not the last refresh, so a supervisor
|
|
916
|
+
// change that landed several refreshes ago still forces the restart it needs.
|
|
917
|
+
async function refreshCheckout(deps = {}) {
|
|
918
|
+
const runCmd = deps.run || run;
|
|
919
|
+
const git = (args) => runCmd('git', args, { cwd: REPO_ROOT });
|
|
920
|
+
try {
|
|
921
|
+
const [gd, cd] = await Promise.all([git(['rev-parse', '--git-dir']), git(['rev-parse', '--git-common-dir'])]);
|
|
922
|
+
if (!gd.ok || !cd.ok) return { ok: false, reason: 'could not tell which checkout this is' };
|
|
923
|
+
if (path.resolve(REPO_ROOT, String(gd.stdout).trim()) !== path.resolve(REPO_ROOT, String(cd.stdout).trim())) {
|
|
924
|
+
return { ok: false, reason: 'not the main checkout — refusing to reset a linked worktree' };
|
|
925
|
+
}
|
|
926
|
+
const loaded = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
927
|
+
if (!loaded) return { ok: false, reason: 'the running commit is unknown' };
|
|
928
|
+
if (!(await git(['fetch', '--quiet', 'origin', 'main'])).ok) return { ok: false, reason: 'fetch failed' };
|
|
929
|
+
const fh = await git(['rev-parse', 'FETCH_HEAD']);
|
|
930
|
+
const to = fh.ok ? String(fh.stdout).trim() : '';
|
|
931
|
+
if (!/^[0-9a-f]{40}$/.test(to)) return { ok: false, reason: 'could not read the fetched commit' };
|
|
932
|
+
const diff = await git(['diff', '--name-only', loaded, to]);
|
|
933
|
+
if (!diff.ok) return { ok: false, reason: 'could not diff the running commit against main' };
|
|
934
|
+
if (!(await git(['reset', '--quiet', '--hard', to])).ok) return { ok: false, reason: 'reset failed' };
|
|
935
|
+
const changed = String(diff.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
936
|
+
return { ok: true, to, changed };
|
|
937
|
+
} catch (e) {
|
|
938
|
+
return { ok: false, reason: e && e.message ? e.message : String(e) };
|
|
939
|
+
}
|
|
858
940
|
}
|
|
859
941
|
|
|
860
942
|
// The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
|
|
@@ -932,7 +1014,9 @@ async function forever(opts, deps = {}) {
|
|
|
932
1014
|
// reports itself anyway: the hall reads the AGE of the last check-in, so a
|
|
933
1015
|
// failed beat shows up as silence, which is the signal.
|
|
934
1016
|
const beat = async (fields) => {
|
|
935
|
-
|
|
1017
|
+
// The runner state is passed, not defaulted: the file names `disk_head` from
|
|
1018
|
+
// it (task 1004406), and an injected state must reach the beat that reports it.
|
|
1019
|
+
try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
|
|
936
1020
|
try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
|
|
937
1021
|
};
|
|
938
1022
|
// The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
|
|
@@ -969,7 +1053,7 @@ async function forever(opts, deps = {}) {
|
|
|
969
1053
|
// task 1234" from "quiet because it is dead". A worker may hold the loop for
|
|
970
1054
|
// ninety minutes, and without this every honest hour of work looks like a
|
|
971
1055
|
// corpse.
|
|
972
|
-
await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined });
|
|
1056
|
+
await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined, working_goal_id: Number(row.goal_id) || undefined });
|
|
973
1057
|
const seen = cadence.classifyEvent(row);
|
|
974
1058
|
// A hold that names its reset SECOND waits until that second (task 1004454).
|
|
975
1059
|
// classifyEvent carries it as an absolute epoch; the cadence has no clock of
|
|
@@ -993,14 +1077,35 @@ async function forever(opts, deps = {}) {
|
|
|
993
1077
|
// spends nearly all of its time and an upgrade should not sit behind it.
|
|
994
1078
|
const upMs = (deps.now ? deps.now() : Date.now()) - Date.parse(RUNNER_STARTED_AT);
|
|
995
1079
|
const mayUpgrade = upMs >= MIN_UPTIME_BEFORE_UPGRADE_MS;
|
|
996
|
-
|
|
1080
|
+
let drift = mayUpgrade
|
|
997
1081
|
? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
|
|
998
1082
|
: false;
|
|
999
1083
|
if (drift) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1084
|
+
// Pull always, restart rarely (task 1004406). Only main() arms the refresh.
|
|
1085
|
+
let why = 'exiting so the wrapper can relaunch on the new code';
|
|
1086
|
+
if (opts.refreshInPlace) {
|
|
1087
|
+
const refreshed = await (deps.refreshCheckout || refreshCheckout)(deps);
|
|
1088
|
+
if (refreshed && refreshed.ok) {
|
|
1089
|
+
const touched = supervisorTouched(refreshed.changed, deps.loadedFiles ? deps.loadedFiles() : loadedFiles());
|
|
1090
|
+
if (!touched.length) {
|
|
1091
|
+
(deps.state || RUNNER_STATE).diskHead = refreshed.to;
|
|
1092
|
+
log({ event: 'code_refreshed', from: drift.from, to: refreshed.to.slice(0, 8), changed: refreshed.changed.length,
|
|
1093
|
+
reason: `main moved from ${drift.from} to ${refreshed.to.slice(0, 8)} — checkout updated in place; none of the ${refreshed.changed.length} changed file(s) is code this process runs, so no restart` });
|
|
1094
|
+
await beat({ mode: state.mode, last_event: 'code_refreshed' });
|
|
1095
|
+
drift = null;
|
|
1096
|
+
} else {
|
|
1097
|
+
const more = touched.length > 5 ? `, +${touched.length - 5} more` : '';
|
|
1098
|
+
why = `it changed code this process runs (${touched.slice(0, 5).join(', ')}${more}) — exiting so the wrapper can relaunch on it`;
|
|
1099
|
+
}
|
|
1100
|
+
} else {
|
|
1101
|
+
why = `the in-place refresh did not run (${(refreshed && refreshed.reason) || 'no result'}) — exiting so the wrapper's own refresh can`;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (drift) {
|
|
1105
|
+
log({ event: 'upgrade_exit', from: drift.from, to: drift.to, reason: `main moved from ${drift.from} to ${drift.to} — ${why}` });
|
|
1106
|
+
await beat({ mode: state.mode, last_event: 'upgrade_exit' });
|
|
1107
|
+
return UPGRADE_EXIT_CODE;
|
|
1108
|
+
}
|
|
1004
1109
|
}
|
|
1005
1110
|
|
|
1006
1111
|
const { waited, jumped } = await wait(state.waitS, { ...deps, beat }, opts.cadence);
|
|
@@ -1015,7 +1120,7 @@ async function main(argv) {
|
|
|
1015
1120
|
// it lived on the machine being fenced; the database owns that now, and an empty
|
|
1016
1121
|
// `--goal` means "every allowlisted goal" rather than "anything claimable". The
|
|
1017
1122
|
// refusal did not disappear — it moved somewhere the runner host cannot edit.
|
|
1018
|
-
if (opts.forever) return forever(opts);
|
|
1123
|
+
if (opts.forever) return forever({ ...opts, refreshInPlace: true });
|
|
1019
1124
|
|
|
1020
1125
|
// The one-shot path, kept for an operator running a single task by hand. It
|
|
1021
1126
|
// still reconciles first: a leftover claim is a leftover claim whoever is
|
|
@@ -1043,5 +1148,5 @@ module.exports = {
|
|
|
1043
1148
|
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
1044
1149
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
1045
1150
|
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
|
|
1046
|
-
newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1151
|
+
newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1047
1152
|
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Send feedback about Bongos itself to the Cloud Bongos maintainers (task 1004462).
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// bongos feedback "the claim command hung for two minutes"
|
|
6
|
+
// bongos feedback --idea "let me filter the task list by goal"
|
|
7
|
+
// bongos feedback --message-file notes.md
|
|
8
|
+
// [--json]
|
|
9
|
+
//
|
|
10
|
+
// Not the /feedback skill, which loads a locally recorded walkthrough into a
|
|
11
|
+
// session. This SENDS: it posts to the builder's OWN project (POST /feedback,
|
|
12
|
+
// src/bongos/routes/feedback.js), which relays it to the hub. A bug lands on the
|
|
13
|
+
// hub as a 'backlog' task in the maintenance goal awaiting a maintainer's nod; an
|
|
14
|
+
// idea lands in the hub's idea inbox. Only the typed text and version stamps
|
|
15
|
+
// travel — never code, a transcript or file contents.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const { cliClient, requireSession, argText, hasFlag, errCode, errDetails, cliExit } = require('./cli-lib');
|
|
21
|
+
|
|
22
|
+
const USAGE = [
|
|
23
|
+
'usage:',
|
|
24
|
+
' bongos feedback "what went wrong" report a problem with Bongos',
|
|
25
|
+
' bongos feedback --idea "what you wish for" suggest an improvement',
|
|
26
|
+
' bongos feedback --message-file notes.md send a longer note from a file',
|
|
27
|
+
'',
|
|
28
|
+
'Goes to the Cloud Bongos maintainers. Only the text you type and the Bongos',
|
|
29
|
+
'versions you run are sent — no code, chat or files from your project.',
|
|
30
|
+
].join('\n');
|
|
31
|
+
|
|
32
|
+
// The CLI release, as `bongos version` reports it: config/bongos-cli.json where
|
|
33
|
+
// the core checkout carries it (an instance does not — task 1824), else the
|
|
34
|
+
// package version. Read from disk, never required, so a vanilla core still runs.
|
|
35
|
+
function cliVersion() {
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
for (const rel of ['../../config/bongos-cli.json', '../../package.json']) {
|
|
39
|
+
try {
|
|
40
|
+
const f = path.join(__dirname, rel);
|
|
41
|
+
if (fs.existsSync(f)) { const v = JSON.parse(fs.readFileSync(f, 'utf8')).version; if (v) return v; }
|
|
42
|
+
} catch { /* try the next */ }
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The message: --message / --message-file, else every positional word joined.
|
|
48
|
+
function readMessage(args) {
|
|
49
|
+
if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args);
|
|
50
|
+
return args.filter((a) => !a.startsWith('--')).join(' ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Every report goes through the transcript-grade secret scrubber before it leaves
|
|
54
|
+
// this machine (task 1004467). An assistant may file unasked when a builder vents,
|
|
55
|
+
// so nobody reviews the text first — the scrubber is the check that does not
|
|
56
|
+
// depend on the assistant's judgment. FAIL CLOSED: no scrubber, nothing is sent.
|
|
57
|
+
// It also strips the builder's environment secrets by value, whatever their shape.
|
|
58
|
+
function scrubReport(message, env = process.env) {
|
|
59
|
+
let scrub;
|
|
60
|
+
try { scrub = require('../../modules/security/secret-scrub.js'); } catch { return null; }
|
|
61
|
+
const text = scrub.scrubSecrets(message, scrub.collectEnvSecrets(env));
|
|
62
|
+
const removed = text.split(scrub.REDACTED).length - message.split(scrub.REDACTED).length;
|
|
63
|
+
return { text, removed };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Plain-language lines for each refusal the route can give.
|
|
67
|
+
const REFUSALS = {
|
|
68
|
+
message_required: 'Nothing to send — add your feedback after the command, in quotes.',
|
|
69
|
+
validation_failed: 'That could not be sent as written — keep it under 4,000 characters, or split it into two.',
|
|
70
|
+
not_hub_connected: 'This project is not connected to Cloud Bongos, so feedback cannot be relayed from here.\n'
|
|
71
|
+
+ 'Report it to the Bongos maintainers on the Bongos core repository instead.',
|
|
72
|
+
membership_required: 'Cloud Bongos does not yet know you as a member of this project.\n'
|
|
73
|
+
+ 'Sign in to this project once through Cloud Bongos in a browser, then try again.',
|
|
74
|
+
rate_limited: 'Too much feedback from you or this project in the last hour. Try again later.',
|
|
75
|
+
feedback_unavailable: 'Cloud Bongos is not taking bug reports right now (no version is being built). Try again later.',
|
|
76
|
+
hub_unreachable: 'Could not reach Cloud Bongos. Check your connection and try again.',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
async function main(argv = process.argv.slice(2)) {
|
|
80
|
+
if (hasFlag('--help', argv) || hasFlag('-h', argv)) { console.log(USAGE); return 0; }
|
|
81
|
+
const json = hasFlag('--json', argv);
|
|
82
|
+
const kind = hasFlag('--idea', argv) ? 'idea' : 'bug';
|
|
83
|
+
const message = String(readMessage(argv) || '').trim();
|
|
84
|
+
if (!message) { console.error(USAGE); return 2; }
|
|
85
|
+
|
|
86
|
+
const scrubbed = scrubReport(message);
|
|
87
|
+
if (!scrubbed) {
|
|
88
|
+
console.error('Feedback was not sent: the secret filter that checks every report is missing from this install.');
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await requireSession();
|
|
93
|
+
const api = await cliClient();
|
|
94
|
+
const res = await api.request('POST', '/feedback', {
|
|
95
|
+
body: { message: scrubbed.text, kind, context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` } },
|
|
96
|
+
});
|
|
97
|
+
if (scrubbed.removed > 0 && !json) {
|
|
98
|
+
console.log(`Removed ${scrubbed.removed} secret-looking value(s) from the report before sending.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (json) { console.log(JSON.stringify(res.data, null, 2)); return res.ok ? 0 : 1; }
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
const code = errCode(res.data);
|
|
104
|
+
const details = errDetails(res.data) || {};
|
|
105
|
+
let line = REFUSALS[code] || `Feedback was not sent (${code || `HTTP ${res.status}`}).`;
|
|
106
|
+
if (code === 'rate_limited' && details.retry_after_seconds) {
|
|
107
|
+
line += ` (in about ${Math.ceil(details.retry_after_seconds / 60)} min)`;
|
|
108
|
+
}
|
|
109
|
+
console.error(line);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
const d = res.data || {};
|
|
113
|
+
if (d.landed === 'task') {
|
|
114
|
+
console.log(`Thanks — sent to the Cloud Bongos maintainers as task ${d.task_id}.`);
|
|
115
|
+
console.log('It waits for a maintainer to approve it before anyone works on it.');
|
|
116
|
+
if (d.hub_origin) console.log(`${String(d.hub_origin).replace(/\/+$/, '')}/builders#/task/${d.task_id}`);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(`Thanks — your idea is in the Cloud Bongos idea inbox (idea ${d.idea_id}) for the maintainers to review.`);
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (require.main === module) {
|
|
124
|
+
// cliExit, never process.exit, after a request (task 1002734 — the Windows abort).
|
|
125
|
+
main().then((code) => cliExit(code), (err) => {
|
|
126
|
+
console.error(`Feedback was not sent: ${err && err.message ? err.message : err}`);
|
|
127
|
+
cliExit(1);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { main, readMessage, REFUSALS, scrubReport };
|