@bongos/core 1.19.871 → 1.19.873
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 +167 -97
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
- package/docs/api/openapi.json +61 -3
- package/docs/api-reference.md +7 -1
- package/docs/architecture.md +7 -7
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +190 -189
- package/docs/copy-registry.json +264 -255
- package/docs/design/hall-direction-v2.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/README.md +4 -4
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/onboarding/primer.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/bongos-cli-release.md +4 -4
- package/docs/recipes/core-release-pipeline.md +3 -3
- package/docs/recipes/instance-deploy.md +2 -2
- package/docs/recipes/ops-gotchas.md +2 -2
- package/docs/recipes/private-npm-distribution.md +16 -21
- package/docs/recipes/search-index-ingestion.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +218 -0
- package/modules/dev-box/app/README.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/blockers.js +138 -34
- package/modules/hall-ui/public/builders.js +2 -1
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/collab.js +6 -3
- package/modules/hall-ui/public/dom-utils.js +46 -5
- package/modules/hall-ui/public/goals-page.js +7 -3
- package/modules/hall-ui/public/goals-render.js +4 -3
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/people.js +3 -3
- package/modules/hall-ui/public/profile-charts.js +5 -2
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/profile.js +6 -6
- package/modules/hall-ui/public/settings-specialities.js +2 -2
- package/modules/hall-ui/public/settings.html +4 -4
- package/modules/hall-ui/public/settings.js +2 -2
- package/modules/hall-ui/public/shell.js +70 -0
- package/modules/hall-ui/public/style.css +31 -0
- package/modules/hall-ui/public/work.js +12 -14
- package/modules/provisioning/module.json +3 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-loop.js +170 -0
- package/scripts/gds/autobongos-run.js +317 -0
- package/scripts/gds/autobongos-verify.js +377 -0
- package/scripts/gds/autonomy-gauge-statusline.js +124 -0
- package/scripts/gds/autonomy-gauge.js +84 -0
- package/scripts/gds/provision-repo.js +4 -2
- package/scripts/gds/run-unit-tests.js +120 -2
- package/scripts/gds/sequence.js +3 -0
- package/scripts/gds/start.js +16 -10
- package/src/bongos/core-update.js +147 -0
- package/src/bongos/routes/core-update.js +49 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +3 -1
- package/tests/autobongos_loop.mjs +446 -0
- package/tests/autobongos_verify.mjs +325 -0
- package/tests/autonomy_gauge.mjs +344 -0
- package/tests/blockers_hall.mjs +20 -6
- package/tests/blockers_kit_pager.mjs +260 -0
- package/tests/blockers_page_ui.mjs +5 -4
- package/tests/box_ship_permissions.mjs +30 -0
- package/tests/core_update_banner.mjs +308 -0
- package/tests/eviction_policy.mjs +22 -1
- package/tests/git_guard.mjs +68 -0
- package/tests/goal_criterion_confirm_ui.mjs +7 -0
- package/tests/goal_invite_consent.mjs +7 -0
- package/tests/goal_sort_staleness.mjs +6 -2
- package/tests/hall_board_world.mjs +4 -1
- package/tests/hall_builders_page_pass.mjs +7 -0
- package/tests/hall_landing_boot.mjs +7 -0
- package/tests/hall_profile_world.mjs +20 -2
- package/tests/hall_record_world.mjs +7 -0
- package/tests/hall_roster_world.mjs +2 -1
- package/tests/hall_studio_world.mjs +7 -0
- package/tests/hook_shell_tools.mjs +23 -1
- package/tests/idea_detail_page.mjs +7 -0
- package/tests/idea_spark_hall.mjs +7 -0
- package/tests/integration_parked_ratchet.mjs +79 -0
- package/tests/module_contributions.mjs +134 -8
- package/tests/package_core.mjs +31 -0
- package/tests/people_directory_clip.mjs +7 -0
- package/tests/profile_activity.mjs +7 -0
- package/tests/profile_ui_cross_project.mjs +9 -1
- package/tests/provision_disconnect.mjs +7 -0
- package/tests/provision_restart.mjs +7 -0
- package/tests/provision_settings_apply.mjs +7 -0
- package/tests/sessions_kit_pager.mjs +7 -0
- package/tests/watch_applications_queue.mjs +7 -0
- package/tests/watch_roster_self_row.mjs +7 -0
- package/tests/watch_sealed_floor.mjs +7 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/autobongos-verify.js — read completion back from the LEDGER, never
|
|
5
|
+
// from the session that claims it (task 1003903; ADR 0307 §4).
|
|
6
|
+
//
|
|
7
|
+
// THE CONFUSION THIS EXISTS TO END. A session exiting is not a task shipping,
|
|
8
|
+
// and this project's worst incidents are all that one confusion wearing
|
|
9
|
+
// different clothes: task 1002483 sat at `completed` for three days carrying 651
|
|
10
|
+
// lines of tested work on a branch nobody had pushed; task 1002459 sat at
|
|
11
|
+
// `confirmed` for four days with no artifact on any branch at all; a ship once
|
|
12
|
+
// "timed out" after its merge had already landed. Every one of them was found by
|
|
13
|
+
// accident, because a strand emits no signal of its own.
|
|
14
|
+
//
|
|
15
|
+
// The headless envelope makes it worse rather than better. A worker that stopped
|
|
16
|
+
// dead needing a decision returns `subtype: "success"` — structurally identical
|
|
17
|
+
// to one that finished the job. So the worker's `shipped` is a CLAIM, which is
|
|
18
|
+
// why autobongos-loop.js names it `claims_shipped`, and this file is the half
|
|
19
|
+
// that turns a claim into a fact or into a filed blocker.
|
|
20
|
+
//
|
|
21
|
+
// THE VOCABULARY IS BORROWED, NOT INVENTED. strand-watch.js (task 1003163)
|
|
22
|
+
// already named the three mid-ship shapes — `unprovable`, `unlanded`,
|
|
23
|
+
// `ungraded` — and /strand-fix routes on those names. Calling the same condition
|
|
24
|
+
// something else here would give one situation two names and split the remedies,
|
|
25
|
+
// so classifyStrand() is called directly. What changes is the THRESHOLD: the
|
|
26
|
+
// board sweep waits an hour (a slow CI queue is not a strand), while here the
|
|
27
|
+
// session that was supposed to finish the job has already exited, so waiting
|
|
28
|
+
// cannot change the verdict. The thresholds are zeroed deliberately, and this
|
|
29
|
+
// file is strictly stricter than the board check — never quieter.
|
|
30
|
+
//
|
|
31
|
+
// Usage (normally called by autobongos-run.js, but runnable by hand):
|
|
32
|
+
// node scripts/gds/autobongos-verify.js 1003902 # verify, report, change nothing
|
|
33
|
+
// node scripts/gds/autobongos-verify.js 1003902 --act # also release + file the blocker
|
|
34
|
+
// node scripts/gds/autobongos-verify.js 1003902 --json
|
|
35
|
+
//
|
|
36
|
+
// Exit codes: 0 verified shipped · 1 not shipped (a shape is named) · 2 undetermined.
|
|
37
|
+
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
const { execFile } = require('node:child_process');
|
|
40
|
+
|
|
41
|
+
const { classifyStrand, WATCHED } = require('./strand-watch.js');
|
|
42
|
+
|
|
43
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
44
|
+
|
|
45
|
+
// Statuses a claim can still be sitting in when the worker is already gone.
|
|
46
|
+
// `active` is the live one; the others are listed because a future rename must
|
|
47
|
+
// fail loudly here rather than silently reclassify a held claim as untouched.
|
|
48
|
+
const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// The pure half
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
// classifyLedger — what the ledger says happened, in one word.
|
|
55
|
+
//
|
|
56
|
+
// `artifact` is the result of probeArtifact() or null for "not probed". The
|
|
57
|
+
// distinction is load-bearing: `checked: false` means we could not look (no
|
|
58
|
+
// network, a detached checkout), and NOT LOOKING IS NOT EVIDENCE OF ABSENCE. A
|
|
59
|
+
// failed probe must never manufacture a missing artifact.
|
|
60
|
+
//
|
|
61
|
+
// Every shape carries what to DO, because the caller must not re-derive it:
|
|
62
|
+
// releaseClaim — the task is still held by a session that no longer exists.
|
|
63
|
+
// fileBlocker — a human has to look at this.
|
|
64
|
+
// linkBlocker — link the blocker to the task, which parks it at `blocked` and
|
|
65
|
+
// takes it out of claimable_tasks. Only meaningful once the task
|
|
66
|
+
// is back at `ready` (LINK_BLOCKABLE_STATUSES in
|
|
67
|
+
// modules/ideas/blockers.js is exactly ['ready']), which is why
|
|
68
|
+
// the caller releases BEFORE it links.
|
|
69
|
+
// halt — the run stops here rather than picking another task.
|
|
70
|
+
function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
|
|
71
|
+
const none = { releaseClaim: false, fileBlocker: false, linkBlocker: false, halt: false };
|
|
72
|
+
|
|
73
|
+
if (!task) {
|
|
74
|
+
// Cannot read the ledger => cannot verify anything => touch nothing. Releasing
|
|
75
|
+
// a claim on a guess is how work gets stranded, and this is the one branch
|
|
76
|
+
// where the honest move is to stop and let a person look.
|
|
77
|
+
return {
|
|
78
|
+
...none, verified: false, shape: 'undetermined', status: null, halt: true,
|
|
79
|
+
reason: 'the task could not be read back from Bongos, so nothing about it is known',
|
|
80
|
+
remedy: 'check the instance is reachable, then re-run the verify by hand',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const status = String(task.status || '');
|
|
85
|
+
|
|
86
|
+
if (status === 'shipped') {
|
|
87
|
+
if (artifact && artifact.checked && !artifact.onMain) {
|
|
88
|
+
// The flip happened with nothing behind it — tasks 1002459 and 1002792,
|
|
89
|
+
// both credited, neither carrying a line of code on any branch. The task is
|
|
90
|
+
// NOT released and NOT linked: it is shipped, credits are paid, and parking
|
|
91
|
+
// it would help nobody. The blocker is the whole point — a person has to
|
|
92
|
+
// decide whether the work was codeless (legitimate: a DB-only change, work
|
|
93
|
+
// on another checkout) or simply never done.
|
|
94
|
+
return {
|
|
95
|
+
...none, verified: false, shape: 'shipped_no_artifact', status, fileBlocker: true,
|
|
96
|
+
reason: 'the ledger says shipped but no commit on main names this task',
|
|
97
|
+
remedy: 'confirm the work was codeless, or redo it — do not try to deploy what is not there',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
...none, verified: true, shape: 'shipped', status,
|
|
102
|
+
reason: artifact && artifact.checked
|
|
103
|
+
? `shipped, and main carries ${artifact.sha ? artifact.sha.slice(0, 8) : 'a commit'} naming it`
|
|
104
|
+
: 'shipped',
|
|
105
|
+
remedy: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (status === 'abandoned') {
|
|
110
|
+
// Terminal, but not a delivery. An unattended worker deciding to cut a task
|
|
111
|
+
// is a judgement call it was never given, so it stops the run.
|
|
112
|
+
return {
|
|
113
|
+
...none, verified: false, shape: 'abandoned', status, fileBlocker: true, halt: true,
|
|
114
|
+
reason: 'the task was abandoned, not shipped — a cut is a decision a worker does not get to make',
|
|
115
|
+
remedy: 'decide whether the cut was right; re-open the task if it was not',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (WATCHED.includes(status)) {
|
|
120
|
+
// The three shapes strand-watch already names. Thresholds zeroed: the board
|
|
121
|
+
// sweep is allowed to wait, this is not (see the header).
|
|
122
|
+
const strand = classifyStrand(task, { nowMs, confirmedStaleMs: -1, completedStaleMs: -1 });
|
|
123
|
+
// classifyStrand fails QUIET on an unparseable updated_at — right for a line
|
|
124
|
+
// injected into every session, wrong here, where quiet means the loop calls a
|
|
125
|
+
// strand a success. The status alone decides the shape when the date will not
|
|
126
|
+
// parse; `unprovable` is the only shape that needs a field to be sure of.
|
|
127
|
+
const shape = strand ? strand.shape : (status === 'confirmed' ? 'unlanded' : 'ungraded');
|
|
128
|
+
const remedy = strand ? strand.remedy : (status === 'confirmed'
|
|
129
|
+
? '/merge-mode is the documented manual fallback'
|
|
130
|
+
: '/grade-recover diagnoses which of the three shapes parked it');
|
|
131
|
+
return {
|
|
132
|
+
...none, verified: false, shape, status, fileBlocker: true,
|
|
133
|
+
reason: {
|
|
134
|
+
unprovable: 'the reconciler probed this, could not prove a land, and gave up — it will never self-heal',
|
|
135
|
+
unlanded: 'the grade passed and the land never finished',
|
|
136
|
+
ungraded: 'the grade parked this; it is not waiting on a land',
|
|
137
|
+
}[shape],
|
|
138
|
+
strandReason: strand ? strand.reason : null,
|
|
139
|
+
remedy,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (HELD_STATUSES.has(status)) {
|
|
144
|
+
// The worker is gone and the claim is not. This is the one shape that
|
|
145
|
+
// actually needs the claim released — and the only one where the link can
|
|
146
|
+
// then park the task, because release returns it to `ready`.
|
|
147
|
+
return {
|
|
148
|
+
...none, verified: false, shape: 'still_held', status,
|
|
149
|
+
releaseClaim: true, fileBlocker: true, linkBlocker: true,
|
|
150
|
+
reason: `the worker exited with the task still at '${status}' — the claim outlived the session holding it`,
|
|
151
|
+
remedy: 'read the worker output in the run log; the tree may hold committed work',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (status === 'blocked') {
|
|
156
|
+
// Already out of the pool with a blocker on it. Filing a second one would
|
|
157
|
+
// just be noise, and the loop will skip it on the next pick either way.
|
|
158
|
+
return {
|
|
159
|
+
...none, verified: false, shape: 'blocked', status,
|
|
160
|
+
reason: 'the task is blocked — it is already out of the claimable pool',
|
|
161
|
+
remedy: 'resolve the blocker that is on it',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// `ready`, `backlog`, or anything a future migration adds. The worker either
|
|
166
|
+
// released the claim itself or never took it. Still not shipped, so it still
|
|
167
|
+
// gets a blocker: a task a worker could not finish will be picked straight back
|
|
168
|
+
// up next iteration otherwise, and fail the same way, for the whole window.
|
|
169
|
+
return {
|
|
170
|
+
...none, verified: false, shape: 'not_started', status, fileBlocker: true, linkBlocker: true,
|
|
171
|
+
reason: `the worker returned with the task back at '${status}' — no work was recorded against it`,
|
|
172
|
+
remedy: 'read the worker output in the run log before re-queueing it',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// blockerFor — the row a shape becomes. Pure, so the wording is testable.
|
|
177
|
+
//
|
|
178
|
+
// `source_ref` is the DEDUPE KEY: POST /blockers has ON CONFLICT (source,
|
|
179
|
+
// source_ref) DO NOTHING, so a second failure of the SAME shape on the SAME task
|
|
180
|
+
// is skipped rather than piling up a column of identical blockers. The shape is
|
|
181
|
+
// part of the key on purpose — a task that fails a DIFFERENT way is a different
|
|
182
|
+
// fact and deserves to be said out loud.
|
|
183
|
+
function blockerFor(v, { taskId, taskTitle = null, worker = null } = {}) {
|
|
184
|
+
const lines = [
|
|
185
|
+
`Autobongos verified task ${taskId} against the ledger after its worker exited, and it is not shipped.`,
|
|
186
|
+
'',
|
|
187
|
+
`**Ledger status:** \`${v.status || 'unreadable'}\` · **shape:** \`${v.shape}\``,
|
|
188
|
+
`**What that means:** ${v.reason}`,
|
|
189
|
+
];
|
|
190
|
+
if (v.strandReason) lines.push(`**Reconciler recorded:** \`${v.strandReason}\``);
|
|
191
|
+
if (worker && worker.outcome) {
|
|
192
|
+
lines.push('', `**What the worker reported:** \`${worker.outcome}\`${worker.reason ? ` — ${String(worker.reason).slice(0, 400)}` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
if (v.remedy) lines.push('', `**Next:** ${v.remedy}`);
|
|
195
|
+
lines.push('', 'Filed by `scripts/gds/autobongos-verify.js`. The supervisor reads completion from Bongos, never from the session — a session exiting is not a task shipping (ADR 0307 §4).');
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
title: `Autobongos: task ${taskId} came back ${v.shape}${taskTitle ? ` — ${String(taskTitle).slice(0, 120)}` : ''}`.slice(0, 200),
|
|
199
|
+
body_md: lines.join('\n'),
|
|
200
|
+
source: 'autobongos',
|
|
201
|
+
source_ref: `task-${taskId}-${v.shape}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// The I/O half
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const runGit = (args, cwd) => new Promise((resolve) => {
|
|
210
|
+
execFile('git', args, { cwd, maxBuffer: 4 * 1024 * 1024 },
|
|
211
|
+
(err, stdout, stderr) => resolve({ ok: !err, stdout: stdout || '', stderr: stderr || '' }));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// probeArtifact — is there ANY trace of this task on main?
|
|
215
|
+
//
|
|
216
|
+
// Deliberately weak and deliberately cheap. It answers "is there a trace", not
|
|
217
|
+
// "which commit is it": a body mention counts, because the question being asked
|
|
218
|
+
// is the 1002459 question — was anything at all produced. The fixed-string grep
|
|
219
|
+
// is against `(task N)`, the convention every ship commit follows.
|
|
220
|
+
//
|
|
221
|
+
// A probe that could not run returns `checked: false`, which classifyLedger
|
|
222
|
+
// treats as no evidence either way.
|
|
223
|
+
async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT } = {}) {
|
|
224
|
+
const id = String(taskId);
|
|
225
|
+
if (!/^\d{1,19}$/.test(id)) return { checked: false, reason: 'task id is not a plain number' };
|
|
226
|
+
|
|
227
|
+
const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
|
|
228
|
+
if (!fetched.ok) {
|
|
229
|
+
return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
|
|
230
|
+
}
|
|
231
|
+
const log = await git(['log', 'FETCH_HEAD', '--max-count=5', '--format=%H %s', '-F', `--grep=(task ${id})`], cwd);
|
|
232
|
+
if (!log.ok) return { checked: false, reason: `git log failed: ${(log.stderr || '').trim().slice(-200)}` };
|
|
233
|
+
|
|
234
|
+
const first = log.stdout.split('\n').find((l) => l.trim());
|
|
235
|
+
if (!first) return { checked: true, onMain: false, sha: null, subject: null };
|
|
236
|
+
const sp = first.indexOf(' ');
|
|
237
|
+
return { checked: true, onMain: true, sha: first.slice(0, sp < 0 ? first.length : sp), subject: sp < 0 ? null : first.slice(sp + 1) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// fetchTask returns the row or null, and says WHY on a null. The reason is the
|
|
241
|
+
// only thing a person reading the run log afterwards has to go on — "undetermined"
|
|
242
|
+
// alone cannot tell an expired session from an unreachable instance.
|
|
243
|
+
async function fetchTask(api, taskId) {
|
|
244
|
+
try {
|
|
245
|
+
const r = await api.tasks.getTasksId({ id: Number(taskId) });
|
|
246
|
+
if (!r || !r.ok) return { task: null, error: `GET /tasks/${taskId} → ${r ? r.status : 'no response'}` };
|
|
247
|
+
const task = r.data && r.data.task;
|
|
248
|
+
return task ? { task } : { task: null, error: `GET /tasks/${taskId} returned no task` };
|
|
249
|
+
} catch (e) { return { task: null, error: e && e.message ? e.message : String(e) }; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// fileBlocker — create the row, then link it to the task.
|
|
253
|
+
//
|
|
254
|
+
// The two steps are separate calls and the SECOND one is what actually takes the
|
|
255
|
+
// task out of the pool: POST /blockers only creates a row, and claimable_tasks
|
|
256
|
+
// excludes a task through `task_blockers`, not through the blockers table. A
|
|
257
|
+
// blocker filed and never linked is a note nobody reads.
|
|
258
|
+
//
|
|
259
|
+
// The link is rank-gated (metic+, `blocker.review`), while filing is open to any
|
|
260
|
+
// builder. A runner below that rank still gets the blocker written down; the
|
|
261
|
+
// result says the link did not happen rather than pretending it did.
|
|
262
|
+
async function fileBlocker(api, row, { taskId, link }) {
|
|
263
|
+
let created = null;
|
|
264
|
+
try {
|
|
265
|
+
const r = await api.blockers.postBlockers({ body: row });
|
|
266
|
+
if (!r || !r.ok) return { filed: false, reason: `POST /blockers → ${r ? r.status : 'no response'}`, linked: false };
|
|
267
|
+
if (r.data && r.data.skipped) return { filed: false, duplicate: true, reason: 'this task already carries a blocker of this shape', linked: false };
|
|
268
|
+
created = r.data && r.data.blocker;
|
|
269
|
+
} catch (e) { return { filed: false, reason: e && e.message ? e.message : String(e), linked: false }; }
|
|
270
|
+
|
|
271
|
+
if (!created || !created.id) return { filed: false, reason: 'POST /blockers returned no blocker', linked: false };
|
|
272
|
+
if (!link) return { filed: true, blockerId: created.id, linked: false };
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const r = await api.blockers.postBlockersIdLink({ id: Number(created.id), body: { task_id: Number(taskId) } });
|
|
276
|
+
// `blocked` is the server saying whether the task actually MOVED. It answers
|
|
277
|
+
// false when the task was not at `ready` — a dead link loop went unnoticed for
|
|
278
|
+
// six weeks on exactly that (task 1002559), so it is reported, not discarded.
|
|
279
|
+
return { filed: true, blockerId: created.id, linked: !!(r && r.ok), parked: !!(r && r.ok && r.data && r.data.blocked) };
|
|
280
|
+
} catch (e) { return { filed: true, blockerId: created.id, linked: false, reason: e && e.message ? e.message : String(e) }; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// verifyShip — the whole step, in the order the order matters.
|
|
284
|
+
//
|
|
285
|
+
// RELEASE BEFORE LINK, always. linkBlockerToTask() parks the task only when it is
|
|
286
|
+
// at `ready`; releasing is what puts it there. Link first and the row is written,
|
|
287
|
+
// the task is not parked, and the release then hands it back to the pool — the
|
|
288
|
+
// loop picks the same doomed task up next iteration and burns the window on it.
|
|
289
|
+
async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}) {
|
|
290
|
+
const api = deps.api || await (require('./cli-lib').cliClient)();
|
|
291
|
+
const probe = deps.probeArtifact || probeArtifact;
|
|
292
|
+
const release = deps.release || null;
|
|
293
|
+
const file = deps.fileBlocker || fileBlocker;
|
|
294
|
+
const act = deps.act !== false;
|
|
295
|
+
|
|
296
|
+
let task = deps.task;
|
|
297
|
+
let readError = null;
|
|
298
|
+
if (task === undefined) ({ task, error: readError } = await fetchTask(api, taskId));
|
|
299
|
+
|
|
300
|
+
// The artifact probe only earns its network round-trip when the ledger claims
|
|
301
|
+
// success. Every other shape is already not-shipped; what is on main cannot
|
|
302
|
+
// change that.
|
|
303
|
+
let artifact = null;
|
|
304
|
+
if (task && String(task.status) === 'shipped') {
|
|
305
|
+
artifact = await probe(taskId, { cwd: deps.cwd || REPO_ROOT });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now() });
|
|
309
|
+
const out = { ...v, task_id: String(taskId), artifact, released: false, blocker: null };
|
|
310
|
+
if (readError) out.read_error = readError;
|
|
311
|
+
if (!act) return out;
|
|
312
|
+
|
|
313
|
+
if (v.releaseClaim) {
|
|
314
|
+
// A caller that forgot to hand over a release function must not read as "no
|
|
315
|
+
// release was needed". Silently skipping the one write that unlocks a task is
|
|
316
|
+
// precisely the class of failure this whole step exists to catch.
|
|
317
|
+
if (!release) out.release_error = 'no release function was given to verifyShip — the claim is still held';
|
|
318
|
+
else {
|
|
319
|
+
const r = await release(taskId, `autobongos: ${v.shape} — ${String(v.reason || '').slice(0, 180)}`);
|
|
320
|
+
out.released = !!(r && r.ok);
|
|
321
|
+
if (!out.released) out.release_error = (r && (r.stderr || r.stdout) ? String(r.stderr || r.stdout).slice(-300) : 'release failed');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (v.fileBlocker) {
|
|
326
|
+
out.blocker = await file(api, blockerFor(v, { taskId, taskTitle: taskTitle || (task && task.title), worker }), {
|
|
327
|
+
taskId,
|
|
328
|
+
// Linking is only worth attempting where it can park the task. A shipped or
|
|
329
|
+
// mid-ship task is not claimable anyway, so a link there writes a row that
|
|
330
|
+
// changes nothing and muddies the blocker board.
|
|
331
|
+
link: v.linkBlocker && (out.released || String(v.status) === 'ready'),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// CLI
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
async function main(argv) {
|
|
343
|
+
const taskId = argv.find((a) => /^\d+$/.test(a));
|
|
344
|
+
if (!taskId) {
|
|
345
|
+
console.error('usage: node scripts/gds/autobongos-verify.js <task-id> [--act] [--json]');
|
|
346
|
+
return 2;
|
|
347
|
+
}
|
|
348
|
+
const act = argv.includes('--act');
|
|
349
|
+
const release = async (id, reason) => new Promise((resolve) => {
|
|
350
|
+
execFile(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'release.js'), String(id), '--reason', reason],
|
|
351
|
+
{ cwd: REPO_ROOT }, (err, stdout, stderr) => resolve({ ok: !err, stdout: stdout || '', stderr: stderr || '' }));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const out = await verifyShip({ taskId }, { act, release });
|
|
355
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(out, null, 2)); }
|
|
356
|
+
else {
|
|
357
|
+
console.log('');
|
|
358
|
+
console.log(` task ${taskId} ledger says: ${out.status || 'unreadable'} → ${out.shape}`);
|
|
359
|
+
console.log(` ${out.reason}`);
|
|
360
|
+
if (out.remedy) console.log(` next: ${out.remedy}`);
|
|
361
|
+
if (out.artifact && out.artifact.checked === false) console.log(` (artifact not probed: ${out.artifact.reason})`);
|
|
362
|
+
if (out.released) console.log(' claim released');
|
|
363
|
+
if (out.blocker) console.log(` blocker: ${out.blocker.filed ? `#${out.blocker.blockerId}${out.blocker.parked ? ' (task parked at blocked)' : out.blocker.linked ? ' (linked)' : ' (not linked)'}` : `not filed — ${out.blocker.reason}`}`);
|
|
364
|
+
if (!act && (out.releaseClaim || out.fileBlocker)) console.log(' (read-only — pass --act to release and file)');
|
|
365
|
+
console.log('');
|
|
366
|
+
}
|
|
367
|
+
return out.shape === 'undetermined' ? 2 : (out.verified ? 0 : 1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (require.main === module) {
|
|
371
|
+
main(process.argv.slice(2)).then((c) => process.exit(c)).catch((e) => {
|
|
372
|
+
console.error(`autobongos-verify: ${e && e.message ? e.message : e}`);
|
|
373
|
+
process.exit(2);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/autonomy-gauge-statusline.js — the WRITER half of the Autobongos
|
|
5
|
+
// usage gauge (task 1003901; ADR 0307 §2).
|
|
6
|
+
//
|
|
7
|
+
// Wire this as Claude Code's `statusLine.command`. On every render Claude Code
|
|
8
|
+
// pipes the status JSON in on stdin; for a subscriber, after the session's first
|
|
9
|
+
// API response, that JSON carries the only structured read of remaining capacity
|
|
10
|
+
// there is:
|
|
11
|
+
//
|
|
12
|
+
// rate_limits.five_hour.{ used_percentage, resets_at }
|
|
13
|
+
// rate_limits.seven_day.{ used_percentage, resets_at }
|
|
14
|
+
//
|
|
15
|
+
// It is absent before that first response, which is exactly why this has to
|
|
16
|
+
// persist it: the supervisor needs the number BETWEEN sessions, when nothing is
|
|
17
|
+
// running to ask. So this script's real output is the file; the line it prints
|
|
18
|
+
// is a courtesy.
|
|
19
|
+
//
|
|
20
|
+
// TWO HARD RULES, both because this runs inside somebody's prompt:
|
|
21
|
+
//
|
|
22
|
+
// 1. IT NEVER THROWS AND NEVER EXITS NON-ZERO. A statusline that crashes breaks
|
|
23
|
+
// the prompt of every session on the machine, including the owner's. Every
|
|
24
|
+
// failure path prints something harmless and exits 0.
|
|
25
|
+
// 2. IT NEVER DESTROYS A READING IT DID NOT REPLACE. This is per-WINDOW, not
|
|
26
|
+
// per-file (grader finding, task 1003901). A render with no rate_limits at
|
|
27
|
+
// all leaves the file alone — but so does a render that reports only
|
|
28
|
+
// five_hour: the previously-known seven_day is CARRIED FORWARD rather than
|
|
29
|
+
// dropped, because the seven-day number is what the weekly pacing depends on
|
|
30
|
+
// and it is the half most likely to be transiently absent. Rebuilding the
|
|
31
|
+
// object from scratch each render silently lost it.
|
|
32
|
+
//
|
|
33
|
+
// Carrying a window forward means it must age on its own, so each window
|
|
34
|
+
// records its OWN `observed_at` and the reader applies staleness per window.
|
|
35
|
+
// Refreshing a single file-level written_at while keeping old data is exactly
|
|
36
|
+
// how "unknown" would become "headroom" — the one mistake the reader exists
|
|
37
|
+
// to prevent.
|
|
38
|
+
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
|
|
42
|
+
function main(raw) {
|
|
43
|
+
let input = null;
|
|
44
|
+
try { input = JSON.parse(raw); } catch (_) { return 'bongos: (no status json)'; }
|
|
45
|
+
if (!input || typeof input !== 'object') return 'bongos: (no status json)';
|
|
46
|
+
|
|
47
|
+
const rl = input.rate_limits;
|
|
48
|
+
const five = rl && rl.five_hour;
|
|
49
|
+
const seven = rl && rl.seven_day;
|
|
50
|
+
|
|
51
|
+
const pct = (w) => (w && Number.isFinite(Number(w.used_percentage)) ? Number(w.used_percentage) : null);
|
|
52
|
+
const fivePct = pct(five);
|
|
53
|
+
const sevenPct = pct(seven);
|
|
54
|
+
|
|
55
|
+
// Rule 2: nothing to record → say so, touch nothing.
|
|
56
|
+
if (fivePct === null && sevenPct === null) return 'bongos: limits not reported yet';
|
|
57
|
+
|
|
58
|
+
const now = Math.floor(Date.now() / 1000);
|
|
59
|
+
|
|
60
|
+
// Same redirect the reader honours (autonomy-gauge.js). Without it the two
|
|
61
|
+
// halves disagree about where the file lives, and a test that thinks it
|
|
62
|
+
// redirected the writer silently writes into the real config dir instead —
|
|
63
|
+
// which is how that was found.
|
|
64
|
+
let target;
|
|
65
|
+
try {
|
|
66
|
+
target = process.env.AUTOBONGOS_GAUGE_FILE
|
|
67
|
+
|| require('../../src/instance-config.js').configPath('autobongos-gauge.json');
|
|
68
|
+
} catch (_) {
|
|
69
|
+
return `bongos: 5h ${fivePct ?? '–'}% · 7d ${sevenPct ?? '–'}% (not persisted)`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rule 2: start from what is already on disk, so a window this render did not
|
|
73
|
+
// report survives instead of being overwritten with nothing.
|
|
74
|
+
let prior = {};
|
|
75
|
+
try { const p = JSON.parse(fs.readFileSync(target, 'utf8')); if (p && typeof p === 'object') prior = p; } catch (_) {}
|
|
76
|
+
|
|
77
|
+
const gauge = { written_at: now, source: 'statusline' };
|
|
78
|
+
const carry = (key) => {
|
|
79
|
+
const old = prior[key];
|
|
80
|
+
if (old && Number.isFinite(Number(old.used_percentage)) && Number.isFinite(Number(old.resets_at))) {
|
|
81
|
+
// Keep its ORIGINAL observed_at so it ages out on its own; a carried
|
|
82
|
+
// window that inherited `now` would read as fresh evidence forever.
|
|
83
|
+
gauge[key] = { ...old, observed_at: Number.isFinite(Number(old.observed_at)) ? Number(old.observed_at) : Number(prior.written_at) || now };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const put = (key, w, p) => {
|
|
87
|
+
const resets = w && Number(w.resets_at);
|
|
88
|
+
if (p !== null && Number.isFinite(resets)) gauge[key] = { used_percentage: p, resets_at: resets, observed_at: now };
|
|
89
|
+
else carry(key);
|
|
90
|
+
};
|
|
91
|
+
put('five_hour', five, fivePct);
|
|
92
|
+
put('seven_day', seven, sevenPct);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
96
|
+
// Atomic: the supervisor may read this at any instant, and a half-written
|
|
97
|
+
// file parses as "no gauge file", which is a hold. Correct, but needlessly
|
|
98
|
+
// pessimistic when the real answer was one rename away.
|
|
99
|
+
const tmp = `${target}.tmp-${process.pid}-${require('node:crypto').randomBytes(6).toString('hex')}`;
|
|
100
|
+
fs.writeFileSync(tmp, `${JSON.stringify(gauge, null, 2)}\n`);
|
|
101
|
+
fs.renameSync(tmp, target);
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return `bongos: 5h ${fivePct ?? '–'}% · 7d ${sevenPct ?? '–'}% (not persisted)`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bits = [];
|
|
107
|
+
if (fivePct !== null) bits.push(`5h ${Math.round(fivePct)}%`);
|
|
108
|
+
if (sevenPct !== null) bits.push(`7d ${Math.round(sevenPct)}%`);
|
|
109
|
+
return `bongos: ${bits.join(' · ')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let buf = '';
|
|
113
|
+
process.stdin.setEncoding('utf8');
|
|
114
|
+
process.stdin.on('data', (d) => { buf += d; });
|
|
115
|
+
process.stdin.on('end', () => {
|
|
116
|
+
let line;
|
|
117
|
+
try { line = main(buf); } catch (_) { line = 'bongos: gauge error'; }
|
|
118
|
+
process.stdout.write(`${line}\n`);
|
|
119
|
+
process.exit(0); // rule 1, unconditionally
|
|
120
|
+
});
|
|
121
|
+
// A statusline invoked with no stdin must still return promptly.
|
|
122
|
+
setTimeout(() => { try { process.stdout.write('bongos: (no input)\n'); } catch (_) {} process.exit(0); }, 2000).unref();
|
|
123
|
+
|
|
124
|
+
module.exports = { main };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/autonomy-gauge.js — the READER half of the Autobongos usage gauge
|
|
5
|
+
// (task 1003901; ADR 0307 §2). The supervisor calls this between tasks to ask
|
|
6
|
+
// one question: may I start another task right now, and if not, until when?
|
|
7
|
+
//
|
|
8
|
+
// node scripts/gds/autonomy-gauge.js # human-readable
|
|
9
|
+
// node scripts/gds/autonomy-gauge.js --json # machine-readable
|
|
10
|
+
// node scripts/gds/autonomy-gauge.js --sleep # block until capacity, then exit 0
|
|
11
|
+
//
|
|
12
|
+
// Exit codes: 0 = go 1 = hold (sleepUntil printed) 2 = unknown gauge
|
|
13
|
+
//
|
|
14
|
+
// The DECISION is not here — it is the pure `decideCapacity` in
|
|
15
|
+
// modules/autonomy/gauge.js, which is unit-tested with no clock and no disk.
|
|
16
|
+
// This file is the I/O half only: find the file, read it, print the verdict.
|
|
17
|
+
// Same split autonomy-gate.js draws between `decide` and `gatherSignals`.
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const { decideCapacity } = require('../../modules/autonomy/gauge.js');
|
|
21
|
+
|
|
22
|
+
function gaugePath() {
|
|
23
|
+
if (process.env.AUTOBONGOS_GAUGE_FILE) return process.env.AUTOBONGOS_GAUGE_FILE;
|
|
24
|
+
try { return require('../../src/instance-config.js').configPath('autobongos-gauge.json'); }
|
|
25
|
+
catch (_) { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadGauge(p) {
|
|
29
|
+
if (!p) return null;
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readConfig() {
|
|
34
|
+
// Operator overrides ride on env so the runner can be retuned without a deploy.
|
|
35
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
36
|
+
const c = {};
|
|
37
|
+
if (process.env.AUTOBONGOS_TIMEZONE) c.timezone = process.env.AUTOBONGOS_TIMEZONE;
|
|
38
|
+
if (num(process.env.AUTOBONGOS_STALE_AFTER_S) !== undefined) c.staleAfterS = num(process.env.AUTOBONGOS_STALE_AFTER_S);
|
|
39
|
+
if (num(process.env.AUTOBONGOS_WEEKLY_SLACK) !== undefined) c.weeklySlack = num(process.env.AUTOBONGOS_WEEKLY_SLACK);
|
|
40
|
+
const rw = num(process.env.AUTOBONGOS_RESERVE_WORKING);
|
|
41
|
+
const ro = num(process.env.AUTOBONGOS_RESERVE_OVERNIGHT);
|
|
42
|
+
if (rw !== undefined || ro !== undefined) c.reserve = { ...(rw !== undefined && { working: rw }), ...(ro !== undefined && { overnight: ro }) };
|
|
43
|
+
const hs = num(process.env.AUTOBONGOS_HOURS_START);
|
|
44
|
+
const he = num(process.env.AUTOBONGOS_HOURS_END);
|
|
45
|
+
if (hs !== undefined || he !== undefined) c.ownerHours = { ...(hs !== undefined && { start: hs }), ...(he !== undefined && { end: he }) };
|
|
46
|
+
return c;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evaluate(nowEpochS = Math.floor(Date.now() / 1000)) {
|
|
50
|
+
const p = gaugePath();
|
|
51
|
+
return { path: p, decision: decideCapacity({ gauge: loadGauge(p), now: nowEpochS, config: readConfig() }) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main(argv) {
|
|
55
|
+
const json = argv.includes('--json');
|
|
56
|
+
const sleep = argv.includes('--sleep');
|
|
57
|
+
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { path: p, decision: d } = evaluate();
|
|
60
|
+
if (json) console.log(JSON.stringify({ ...d, gauge_path: p }, null, 2));
|
|
61
|
+
else {
|
|
62
|
+
const when = d.sleepUntil ? ` → wake ${new Date(d.sleepUntil * 1000).toISOString()}` : '';
|
|
63
|
+
console.log(`${d.go ? 'GO' : d.unknown ? 'UNKNOWN' : 'HOLD'}: ${d.reason}${when}`);
|
|
64
|
+
}
|
|
65
|
+
if (d.go) return 0;
|
|
66
|
+
if (!sleep) return d.unknown ? 2 : 1;
|
|
67
|
+
|
|
68
|
+
// --sleep: wait exactly as long as the decision says, then re-read. An
|
|
69
|
+
// UNKNOWN gauge has no reset to aim at, so poll gently rather than spin.
|
|
70
|
+
const now = Math.floor(Date.now() / 1000);
|
|
71
|
+
const waitS = d.sleepUntil ? Math.max(1, d.sleepUntil - now) : 60;
|
|
72
|
+
if (!json) console.log(` sleeping ${waitS}s…`);
|
|
73
|
+
await new Promise((r) => setTimeout(r, waitS * 1000));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (require.main === module) {
|
|
78
|
+
main(process.argv.slice(2)).then((c) => process.exit(c)).catch((e) => {
|
|
79
|
+
console.error(`autonomy-gauge: ${e && e.message ? e.message : e}`);
|
|
80
|
+
process.exit(2);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { evaluate, gaugePath, loadGauge, readConfig };
|
|
@@ -374,8 +374,10 @@ function installedCoreTarball({ instanceRoot, fsImpl = fs } = {}) {
|
|
|
374
374
|
// abort of task 1002275, resurrected by the cutover). The installed package itself is
|
|
375
375
|
// still the exact core this hub runs: return its node_modules dir + version + the
|
|
376
376
|
// `npm pack` tarball name so the scaffold can pack it into a vendorable .tgz. The child
|
|
377
|
-
// stays file:-pinned — its owner needs no npm token (
|
|
378
|
-
//
|
|
377
|
+
// stays file:-pinned — its owner needs no npm token. (That was originally because the
|
|
378
|
+
// registry package was PRIVATE; since ADR 0283 it is public and a token would buy
|
|
379
|
+
// nothing either way. The file: pin is kept for the reason in ADR 0168 — a child is born
|
|
380
|
+
// on the core its parent actually runs — not for access control.) Returns { dir, version, tarballName } or null
|
|
379
381
|
// (a file: pin, an uninstalled dep, or a source checkout). fs reads only → dry-run safe.
|
|
380
382
|
function installedCorePackDir({ instanceRoot, fsImpl = fs } = {}) {
|
|
381
383
|
const root = instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
|