@bongos/core 1.19.877 → 1.19.878
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 +31 -16
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/autobongos-windows-host.md +26 -1
- package/modules/autonomy/digest.js +344 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-digest.js +123 -0
- package/scripts/gds/autobongos-run.js +7 -0
- package/scripts/gds/autobongos-service.ps1 +7 -1
- package/scripts/gds/provision-core-upgrade.js +23 -3
- package/src/module-api.js +1 -1
- package/tests/autonomy_digest.mjs +302 -0
- package/tests/core_upgrade_runner.mjs +43 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.878",
|
|
6
|
+
"core_contract": "1.19.878",
|
|
7
|
+
"source_commit": "2b2c4d57dea2fc815d5edf5a1fd9ddb076f49be9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-19T11:02:46.880Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 501,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2319,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2846,
|
|
20
|
+
"tree_sha256": "5cf258930712618621a4d89ca402aef67b76d4b0212318336edb63c4a4a0502b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2602,7 +2602,7 @@
|
|
|
2602
2602
|
{
|
|
2603
2603
|
"path": "docs/module-api-changelog.md",
|
|
2604
2604
|
"mode": "0000644",
|
|
2605
|
-
"sha256": "
|
|
2605
|
+
"sha256": "2c1b16d4be5696489456b2c49d7fbef8b09d846d722860bd019968e20a88db02"
|
|
2606
2606
|
},
|
|
2607
2607
|
{
|
|
2608
2608
|
"path": "docs/modules-contract.md",
|
|
@@ -2707,7 +2707,7 @@
|
|
|
2707
2707
|
{
|
|
2708
2708
|
"path": "docs/recipes/autobongos-windows-host.md",
|
|
2709
2709
|
"mode": "0000644",
|
|
2710
|
-
"sha256": "
|
|
2710
|
+
"sha256": "72f38de74c8cbfd1d44de916d19572dc705b1039a4270d53e3a9065a50d8faf7"
|
|
2711
2711
|
},
|
|
2712
2712
|
{
|
|
2713
2713
|
"path": "docs/recipes/bongos-cli-release.md",
|
|
@@ -3799,6 +3799,11 @@
|
|
|
3799
3799
|
"mode": "0000644",
|
|
3800
3800
|
"sha256": "722b9f3ddbd59fda3e92ae6c6fb0400eb3eadc343f0a3f84369d69bea72b7d65"
|
|
3801
3801
|
},
|
|
3802
|
+
{
|
|
3803
|
+
"path": "modules/autonomy/digest.js",
|
|
3804
|
+
"mode": "0000644",
|
|
3805
|
+
"sha256": "cfe22a887e5a69cf314a318f7b410c81f91cfba65f7643605e9f3ac1ebef8022"
|
|
3806
|
+
},
|
|
3802
3807
|
{
|
|
3803
3808
|
"path": "modules/autonomy/fence.js",
|
|
3804
3809
|
"mode": "0000644",
|
|
@@ -7967,12 +7972,12 @@
|
|
|
7967
7972
|
{
|
|
7968
7973
|
"path": "package-lock.json",
|
|
7969
7974
|
"mode": "0000644",
|
|
7970
|
-
"sha256": "
|
|
7975
|
+
"sha256": "da57fd75ea411bf8a7ecad98041b7d38f6ce568bca460243b24b67bd2ddd60ba"
|
|
7971
7976
|
},
|
|
7972
7977
|
{
|
|
7973
7978
|
"path": "package.json",
|
|
7974
7979
|
"mode": "0000644",
|
|
7975
|
-
"sha256": "
|
|
7980
|
+
"sha256": "ef72c5d5fd50006bbb62080ebf77339025a5979363d10c655e5db6f1b507850c"
|
|
7976
7981
|
},
|
|
7977
7982
|
{
|
|
7978
7983
|
"path": "public-docs/index.html",
|
|
@@ -8129,6 +8134,11 @@
|
|
|
8129
8134
|
"mode": "0000644",
|
|
8130
8135
|
"sha256": "891e795c68d43d11958a2e68af0cfb1fcd5a1f3308c821f86ff37512326ef72d"
|
|
8131
8136
|
},
|
|
8137
|
+
{
|
|
8138
|
+
"path": "scripts/gds/autobongos-digest.js",
|
|
8139
|
+
"mode": "0000644",
|
|
8140
|
+
"sha256": "b406f73bbc0f577a2a58ec99597fb92c7edc0d1628c0ca7729cd25b0a1270045"
|
|
8141
|
+
},
|
|
8132
8142
|
{
|
|
8133
8143
|
"path": "scripts/gds/autobongos-loop.js",
|
|
8134
8144
|
"mode": "0000644",
|
|
@@ -8137,7 +8147,7 @@
|
|
|
8137
8147
|
{
|
|
8138
8148
|
"path": "scripts/gds/autobongos-run.js",
|
|
8139
8149
|
"mode": "0000644",
|
|
8140
|
-
"sha256": "
|
|
8150
|
+
"sha256": "24f0be946550a89f717278a418ebb68330fad3c7cdd45d3b79e85315564c7425"
|
|
8141
8151
|
},
|
|
8142
8152
|
{
|
|
8143
8153
|
"path": "scripts/gds/autobongos-service.cmd",
|
|
@@ -8147,7 +8157,7 @@
|
|
|
8147
8157
|
{
|
|
8148
8158
|
"path": "scripts/gds/autobongos-service.ps1",
|
|
8149
8159
|
"mode": "0000644",
|
|
8150
|
-
"sha256": "
|
|
8160
|
+
"sha256": "3f3e44969809b3c22c9cf338af68695af6e835b861ac056d38408a4be1d202a7"
|
|
8151
8161
|
},
|
|
8152
8162
|
{
|
|
8153
8163
|
"path": "scripts/gds/autobongos-verify.js",
|
|
@@ -8917,7 +8927,7 @@
|
|
|
8917
8927
|
{
|
|
8918
8928
|
"path": "scripts/gds/provision-core-upgrade.js",
|
|
8919
8929
|
"mode": "0000644",
|
|
8920
|
-
"sha256": "
|
|
8930
|
+
"sha256": "5fad0beb0a3d4b322918a99765cb922fcce1de4196c19cb08a0d3bc91c3d149e"
|
|
8921
8931
|
},
|
|
8922
8932
|
{
|
|
8923
8933
|
"path": "scripts/gds/provision-disconnect.js",
|
|
@@ -9972,7 +9982,7 @@
|
|
|
9972
9982
|
{
|
|
9973
9983
|
"path": "src/module-api.js",
|
|
9974
9984
|
"mode": "0000644",
|
|
9975
|
-
"sha256": "
|
|
9985
|
+
"sha256": "f38e0d4d6eeb707065f278ed386fec6a66d502553ac99d1e683b7216c0bb6a99"
|
|
9976
9986
|
},
|
|
9977
9987
|
{
|
|
9978
9988
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10399,6 +10409,11 @@
|
|
|
10399
10409
|
"mode": "0000644",
|
|
10400
10410
|
"sha256": "1b5b6e5010c45a77015a1d0d63d49cb3286eca91a83f45cb94d145af01aab7dc"
|
|
10401
10411
|
},
|
|
10412
|
+
{
|
|
10413
|
+
"path": "tests/autonomy_digest.mjs",
|
|
10414
|
+
"mode": "0000644",
|
|
10415
|
+
"sha256": "633ede3096916c1d5571def211fabac9ae934f48723d182c54f2fe27c2ead105"
|
|
10416
|
+
},
|
|
10402
10417
|
{
|
|
10403
10418
|
"path": "tests/autonomy_gate.mjs",
|
|
10404
10419
|
"mode": "0000644",
|
|
@@ -10882,7 +10897,7 @@
|
|
|
10882
10897
|
{
|
|
10883
10898
|
"path": "tests/core_upgrade_runner.mjs",
|
|
10884
10899
|
"mode": "0000644",
|
|
10885
|
-
"sha256": "
|
|
10900
|
+
"sha256": "a8fc77b7a32e4377b4b327bd485cbc17428421ef3fc871770fdaeee3e02e48f4"
|
|
10886
10901
|
},
|
|
10887
10902
|
{
|
|
10888
10903
|
"path": "tests/cosmos_token_contrast.mjs",
|
|
@@ -2227,6 +2227,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2227
2227
|
1.19.876 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2228
2228
|
landed since 1.19.875 with no explicit bump. run 35420916304. (task 1002620)
|
|
2229
2229
|
1.19.877 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2230
|
-
landed since 1.19.876 with no explicit bump. run
|
|
2230
|
+
landed since 1.19.876 with no explicit bump. run 35438492966. (task 1002620)
|
|
2231
|
+
1.19.878 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2232
|
+
landed since 1.19.877 with no explicit bump. run 35439004814. (task 1002620)
|
|
2231
2233
|
---------------------------------------------------------------------------
|
|
2232
2234
|
```
|
|
@@ -101,7 +101,32 @@ A runner can be **Allowed to work** and **Not checking in** at the same time:
|
|
|
101
101
|
that is a machine that is off, unplugged or wedged, and it is the state worth
|
|
102
102
|
noticing.
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
### Reading the night
|
|
105
|
+
|
|
106
|
+
The log answers "what happened" only if you are willing to read a log, and the
|
|
107
|
+
whole premise is that you are not at the keyboard. One command gives you the
|
|
108
|
+
night in five answers instead:
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 digest
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
or from any checkout: `node scripts/gds/autobongos-digest.js` (add `--since 24h`
|
|
115
|
+
for a rolling window, `--all` for everything, `--json` for the structured shape).
|
|
116
|
+
|
|
117
|
+
It answers: **what shipped**, **what is on hold and why**, **what each worker
|
|
118
|
+
decided that its task description did not determine**, **what it cost**, and
|
|
119
|
+
**where both limit windows stand**.
|
|
120
|
+
|
|
121
|
+
The third one is the one to actually read. A worker will not reliably stop and
|
|
122
|
+
ask — given an under-specified task it makes the call and writes it into the
|
|
123
|
+
file, which is what a good engineer does. So the decisions happen whether or not
|
|
124
|
+
anyone sees them, and every line in that section is a sentence the task
|
|
125
|
+
description should have contained. The task with the most entries is the thinnest
|
|
126
|
+
spec you wrote, and the digest names it.
|
|
127
|
+
|
|
128
|
+
The full log still lives on the box at `%LOCALAPPDATA%\cloudbongos\autobongos.log`
|
|
129
|
+
if you want the raw rows.
|
|
105
130
|
|
|
106
131
|
## 5. Stopping it
|
|
107
132
|
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/digest.js — the night, readable afterwards (task 1003906,
|
|
4
|
+
// ADR 0307). Pure: rows in, a digest out. No I/O, no clock of its own.
|
|
5
|
+
//
|
|
6
|
+
// THE OWNER IS NOT AT THE KEYBOARD. That is the whole premise, and it means the
|
|
7
|
+
// run log — an append-only JSONL nobody will ever open — is not an answer. One
|
|
8
|
+
// page has to answer five questions, and it has to answer them without anyone
|
|
9
|
+
// reading a log: what shipped, what is on hold and why, what each worker decided
|
|
10
|
+
// that its task description did not determine, what it cost, and where the two
|
|
11
|
+
// limit windows stand.
|
|
12
|
+
//
|
|
13
|
+
// THE THIRD QUESTION IS THE ONE THAT COMPOUNDS, and it is worth saying why it is
|
|
14
|
+
// here at all. A worker will NOT reliably stop to ask: tested on an ambiguous
|
|
15
|
+
// task, it made four genuine judgement calls and wrote them straight into the
|
|
16
|
+
// file, which is what a competent engineer does with an under-specified brief.
|
|
17
|
+
// So the decisions happen either way. What the digest changes is whether anyone
|
|
18
|
+
// finds out. And every entry is also a sentence the task description SHOULD have
|
|
19
|
+
// contained — which makes this list a spec-quality signal, not just an audit
|
|
20
|
+
// trail: the goals whose decisions pile up are the goals whose specs are thin,
|
|
21
|
+
// and fixing those is what lowers tomorrow's halt count.
|
|
22
|
+
//
|
|
23
|
+
// WHAT COUNTS AS SHIPPED IS THE LEDGER'S ANSWER, never the worker's. A `worked`
|
|
24
|
+
// row with `verified: false` is a task that ran and did not ship, and putting it
|
|
25
|
+
// under "shipped" because a session ended would reintroduce the exact confusion
|
|
26
|
+
// task 1003903 exists to end.
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const isRow = (r) => r && typeof r === 'object' && typeof r.event === 'string';
|
|
33
|
+
|
|
34
|
+
// num — a number, or null for "not a number I can use".
|
|
35
|
+
//
|
|
36
|
+
// THE GUARD ON null IS LOAD-BEARING, and it is not defensive noise:
|
|
37
|
+
// `Number(null)` is 0 and 0 is finite, so the obvious one-liner turns every
|
|
38
|
+
// absent cost into a free worker and every absent duration into an instant one.
|
|
39
|
+
// The same trap cost a bug in modules/autonomy/cadence.js's fingerprint, where an
|
|
40
|
+
// unread claimable count became the literal string "null" for the same reason.
|
|
41
|
+
// Empty string behaves identically. Both are spelled out rather than trusted to
|
|
42
|
+
// Number().
|
|
43
|
+
const num = (v) => {
|
|
44
|
+
if (v === null || v === undefined || v === '') return null;
|
|
45
|
+
const n = Number(v);
|
|
46
|
+
return Number.isFinite(n) ? n : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseAt(row) {
|
|
50
|
+
const t = Date.parse(row && row.at);
|
|
51
|
+
return Number.isFinite(t) ? t : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// humanDuration — minutes and hours, the way a person says them.
|
|
55
|
+
function humanDuration(s) {
|
|
56
|
+
const n = num(s);
|
|
57
|
+
if (n === null || n < 0) return null;
|
|
58
|
+
if (n < 90) return `${Math.round(n)}s`;
|
|
59
|
+
if (n < 5400) return `${Math.round(n / 60)}m`;
|
|
60
|
+
return `${(n / 3600).toFixed(1)}h`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function usd(n) {
|
|
64
|
+
const v = num(n);
|
|
65
|
+
return v === null ? null : `$${v.toFixed(2)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// windowsFromGauge — question five, from the gauge file's own shape.
|
|
69
|
+
//
|
|
70
|
+
// Reports what it can SEE and says so when it cannot. A digest that silently
|
|
71
|
+
// omits a window reads as "nothing to report", and the one morning that matters
|
|
72
|
+
// is the morning the gauge stopped being written.
|
|
73
|
+
// STALENESS IS GAUGE.JS'S RULE, NOT A SECOND ONE. modules/autonomy/gauge.js holds
|
|
74
|
+
// a window UNKNOWN when its `observed_at` is older than staleAfterS (30 minutes
|
|
75
|
+
// by default), and the runner HOLDS on unknown. A digest that applied a laxer
|
|
76
|
+
// test would print "41% used, resets in 2h" about a window the runner had already
|
|
77
|
+
// decided it could not vouch for — two surfaces disagreeing about one file, with
|
|
78
|
+
// the reader believing the one that is wrong. Same threshold, same conclusion.
|
|
79
|
+
// (Grader finding, task 1003906.)
|
|
80
|
+
function windowsFromGauge(gauge, nowEpochS, { staleAfterS = null } = {}) {
|
|
81
|
+
const limit = staleAfterS === null ? require('./gauge.js').DEFAULT_STALE_AFTER_S : staleAfterS;
|
|
82
|
+
const writtenAt = gauge && typeof gauge === 'object' ? num(gauge.written_at) : null;
|
|
83
|
+
const one = (key, label) => {
|
|
84
|
+
const w = gauge && typeof gauge === 'object' ? gauge[key] : null;
|
|
85
|
+
const used = w ? num(w.used_percentage) : null;
|
|
86
|
+
const resets = w ? num(w.resets_at) : null;
|
|
87
|
+
// Per-window observed_at, falling back to the file's written_at — exactly the
|
|
88
|
+
// shape gauge.js reads, because one window can go unreported while the other
|
|
89
|
+
// is carried forward.
|
|
90
|
+
const observedAt = w ? (num(w.observed_at) ?? writtenAt) : null;
|
|
91
|
+
if (used === null) return { key, label, known: false, detail: 'not reported' };
|
|
92
|
+
|
|
93
|
+
const ageS = observedAt !== null && Number.isFinite(nowEpochS) ? nowEpochS - observedAt : null;
|
|
94
|
+
if (ageS !== null && ageS > limit) {
|
|
95
|
+
return {
|
|
96
|
+
key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, ageS,
|
|
97
|
+
detail: `last read ${Math.round(used)}% used ${humanDuration(ageS)} ago — older than the runner trusts, so it is holding`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const ahead = resets !== null && Number.isFinite(nowEpochS) ? resets - nowEpochS : null;
|
|
101
|
+
|
|
102
|
+
// A RESET TIME IN THE PAST MEANS THE READING IS STALE, not that the window is
|
|
103
|
+
// about to turn over. The percentage was true of a window that has since
|
|
104
|
+
// rolled, so printing it as a live figure is worse than printing nothing —
|
|
105
|
+
// "41% used, resets in 0s" invites a decision about headroom that no longer
|
|
106
|
+
// exists. Say the reading is old and let the reader go look.
|
|
107
|
+
if (ahead !== null && ahead <= 0) {
|
|
108
|
+
return {
|
|
109
|
+
key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, resetsInS: 0,
|
|
110
|
+
detail: `last read ${Math.round(used)}% used, but that window has since reset — the gauge is stale`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
key, label, known: true, usedPct: Math.round(used),
|
|
115
|
+
resetsAt: resets, resetsInS: ahead,
|
|
116
|
+
detail: `${Math.round(used)}% used${ahead !== null ? `, resets in ${humanDuration(ahead)}` : ''}`,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
return [one('five_hour', 'Five-hour window'), one('seven_day', 'Seven-day window')];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// The digest
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
// buildDigest — every question answered from the rows, or explicitly unanswered.
|
|
127
|
+
//
|
|
128
|
+
// `rows` is the run log for the period, oldest first. `gauge` is the raw gauge
|
|
129
|
+
// file (the same shape modules/autonomy/gauge.js reads). Unrecognised rows are
|
|
130
|
+
// COUNTED rather than dropped: a digest that silently ignores what it does not
|
|
131
|
+
// understand is how a new failure mode stays invisible for a week.
|
|
132
|
+
function buildDigest(rows = [], { gauge = null, nowEpochS = null, sinceMs = null, untilMs = null, staleAfterS = null } = {}) {
|
|
133
|
+
const all = (Array.isArray(rows) ? rows : []).filter(isRow);
|
|
134
|
+
const worked = all.filter((r) => r.event === 'worked');
|
|
135
|
+
|
|
136
|
+
// 1. WHAT SHIPPED — the ledger's answer.
|
|
137
|
+
const shipped = worked.filter((r) => r.verified === true).map((r) => ({
|
|
138
|
+
taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
|
|
139
|
+
durationS: num(r.duration_s), costUsd: num(r.cost_usd),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// 2. WHAT IS ON HOLD, AND WHY. Four different shapes of "did not ship", kept
|
|
143
|
+
// apart because the owner's next action differs for each.
|
|
144
|
+
const ranButDidNotShip = worked.filter((r) => r.verified !== true).map((r) => ({
|
|
145
|
+
taskId: String(r.task_id ?? ''), title: r.title || null,
|
|
146
|
+
shape: r.ledger_shape || 'unknown',
|
|
147
|
+
// THE LEDGER'S REASON FIRST, and the worker's only as a fallback. `row.reason`
|
|
148
|
+
// is verdict.reason — the WORKER's account of itself — and quoting that in the
|
|
149
|
+
// "did not ship" section is the precise inversion this whole goal exists to
|
|
150
|
+
// undo: a worker that stopped dead needing a decision reports success. The
|
|
151
|
+
// ledger's explanation ("the grade passed and the land never finished") is the
|
|
152
|
+
// one a person can act on. Older rows predate ledger_reason, so the fallback
|
|
153
|
+
// stays rather than leaving them blank.
|
|
154
|
+
reason: r.ledger_reason || r.reason || null,
|
|
155
|
+
remedy: r.ledger_remedy || null,
|
|
156
|
+
workerSaid: r.reason || null,
|
|
157
|
+
blockerId: r.blocker_id ?? null,
|
|
158
|
+
parked: !!r.task_parked,
|
|
159
|
+
claimReleased: !!r.claim_released,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const fenced = all.filter((r) => r.event === 'fenced').map((r) => ({
|
|
163
|
+
code: r.code || 'unknown', reason: r.reason || null, taskId: r.task_id ? String(r.task_id) : null,
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
// An UNWORKABLE QUEUE is not breakage, and the digest must not present it as
|
|
167
|
+
// such: the machine is healthy and there is nothing it may work on.
|
|
168
|
+
const unworkable = [];
|
|
169
|
+
for (const r of all) {
|
|
170
|
+
if (r.event !== 'nothing_claimable') continue;
|
|
171
|
+
for (const sk of (Array.isArray(r.skipped) ? r.skipped : [])) {
|
|
172
|
+
unworkable.push({ taskId: String(sk.id ?? ''), goalId: sk.goal_id ?? null, reasons: sk.reasons || [] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const held = all.filter((r) => r.event === 'hold').map((r) => ({ reason: r.reason || null, sleepUntil: r.sleep_until ?? null }));
|
|
177
|
+
|
|
178
|
+
// 3. WHAT EACH WORKER DECIDED that its spec did not determine — grouped by task,
|
|
179
|
+
// because the SHAPE of this list is the signal. One task with six decisions is a
|
|
180
|
+
// thin spec; six tasks with one each is a normal night.
|
|
181
|
+
const decisions = [];
|
|
182
|
+
for (const r of worked) {
|
|
183
|
+
const list = Array.isArray(r.undetermined_decisions) ? r.undetermined_decisions : [];
|
|
184
|
+
if (!list.length) continue;
|
|
185
|
+
decisions.push({
|
|
186
|
+
taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
|
|
187
|
+
shipped: r.verified === true,
|
|
188
|
+
decisions: list.map((d) => String(d)),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const decisionCount = decisions.reduce((n, d) => n + d.decisions.length, 0);
|
|
192
|
+
// The thinnest spec of the night, named. A list is browsable; a name is
|
|
193
|
+
// actionable, and this one is the sentence the description should have carried.
|
|
194
|
+
const thinnestSpec = decisions.slice().sort((a, b) => b.decisions.length - a.decisions.length)[0] || null;
|
|
195
|
+
|
|
196
|
+
// 4. WHAT IT COST. Nulls are EXCLUDED and counted, never coerced to zero — a
|
|
197
|
+
// worker whose cost was not reported must not make the night look cheaper.
|
|
198
|
+
const costs = worked.map((r) => num(r.cost_usd));
|
|
199
|
+
const knownCosts = costs.filter((c) => c !== null);
|
|
200
|
+
const cost = {
|
|
201
|
+
totalUsd: knownCosts.reduce((a, b) => a + b, 0),
|
|
202
|
+
reported: knownCosts.length,
|
|
203
|
+
unreported: costs.length - knownCosts.length,
|
|
204
|
+
workerRuns: worked.length,
|
|
205
|
+
totalDurationS: worked.reduce((a, r) => a + (num(r.duration_s) || 0), 0),
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 5. THE TWO LIMIT WINDOWS.
|
|
209
|
+
const windows = windowsFromGauge(gauge, nowEpochS, { staleAfterS });
|
|
210
|
+
|
|
211
|
+
// How the runner spent the night, so "nothing shipped" can be told apart from
|
|
212
|
+
// "nothing ran".
|
|
213
|
+
const KNOWN = new Set(['worked', 'fenced', 'hold', 'nothing_claimable', 'dry_run',
|
|
214
|
+
'pick_failed', 'worktree_failed', 'claim_failed', 'verify_halt', 'cadence', 'boot_reconcile']);
|
|
215
|
+
const byEvent = {};
|
|
216
|
+
let unrecognised = 0;
|
|
217
|
+
for (const r of all) {
|
|
218
|
+
byEvent[r.event] = (byEvent[r.event] || 0) + 1;
|
|
219
|
+
if (!KNOWN.has(r.event)) unrecognised += 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const cadenceRows = all.filter((r) => r.event === 'cadence' && r.mode);
|
|
223
|
+
const endedProbing = cadenceRows.length ? cadenceRows[cadenceRows.length - 1].mode === 'probing' : false;
|
|
224
|
+
|
|
225
|
+
// reduce, not Math.min(...times): `--all` on a long-lived log can hold more
|
|
226
|
+
// timestamps than the engine will accept as call arguments, and a reporting CLI
|
|
227
|
+
// that throws on a big log fails exactly when the log is most worth reading.
|
|
228
|
+
const times = all.map(parseAt).filter((t) => t !== null);
|
|
229
|
+
const minAt = times.length ? times.reduce((a, b) => (b < a ? b : a)) : null;
|
|
230
|
+
const maxAt = times.length ? times.reduce((a, b) => (b > a ? b : a)) : null;
|
|
231
|
+
return {
|
|
232
|
+
period: {
|
|
233
|
+
fromMs: sinceMs ?? minAt,
|
|
234
|
+
toMs: untilMs ?? maxAt,
|
|
235
|
+
rows: all.length,
|
|
236
|
+
},
|
|
237
|
+
shipped,
|
|
238
|
+
onHold: { ranButDidNotShip, fenced, unworkable, held },
|
|
239
|
+
decisions, decisionCount, thinnestSpec,
|
|
240
|
+
cost,
|
|
241
|
+
windows,
|
|
242
|
+
runner: { byEvent, unrecognised, endedProbing, bootReconciles: byEvent.boot_reconcile || 0 },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Rendering
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
251
|
+
|
|
252
|
+
// renderDigest — the five questions, in the order they are asked, as plain text a
|
|
253
|
+
// person reads over coffee. Deliberately not a table: the widths are unknowable
|
|
254
|
+
// and a wrapped table is worse than prose.
|
|
255
|
+
//
|
|
256
|
+
// EVERY SECTION PRINTS, even when empty. A digest whose quiet sections vanish
|
|
257
|
+
// teaches its reader that absence means "fine", and then the one morning a
|
|
258
|
+
// section is missing because the DATA is missing reads exactly the same.
|
|
259
|
+
function renderDigest(d, { title = 'Autobongos — overnight' } = {}) {
|
|
260
|
+
const L = [];
|
|
261
|
+
const when = d.period.fromMs && d.period.toMs
|
|
262
|
+
? `${new Date(d.period.fromMs).toISOString().slice(0, 16).replace('T', ' ')} → ${new Date(d.period.toMs).toISOString().slice(11, 16)} UTC`
|
|
263
|
+
: 'no runs recorded';
|
|
264
|
+
L.push(title, '='.repeat(title.length), when, '');
|
|
265
|
+
|
|
266
|
+
// 1
|
|
267
|
+
L.push(`SHIPPED — ${plural(d.shipped.length, 'task', 'tasks')}`);
|
|
268
|
+
if (!d.shipped.length) L.push(' nothing shipped.');
|
|
269
|
+
for (const s of d.shipped) {
|
|
270
|
+
L.push(` ${s.taskId} ${s.title || '(untitled)'}`);
|
|
271
|
+
const bits = [humanDuration(s.durationS), usd(s.costUsd)].filter(Boolean);
|
|
272
|
+
if (bits.length) L.push(` ${bits.join(' · ')}`);
|
|
273
|
+
}
|
|
274
|
+
L.push('');
|
|
275
|
+
|
|
276
|
+
// 2
|
|
277
|
+
const holdTotal = d.onHold.ranButDidNotShip.length + d.onHold.fenced.length + d.onHold.unworkable.length + d.onHold.held.length;
|
|
278
|
+
L.push(`ON HOLD — ${plural(holdTotal, 'thing', 'things')}`);
|
|
279
|
+
if (!holdTotal) L.push(' nothing is waiting on you.');
|
|
280
|
+
for (const r of d.onHold.ranButDidNotShip) {
|
|
281
|
+
L.push(` ${r.taskId} ran but did not ship — ${r.shape}`);
|
|
282
|
+
if (r.reason) L.push(` ${String(r.reason).slice(0, 160)}`);
|
|
283
|
+
if (r.remedy) L.push(` next: ${String(r.remedy).slice(0, 160)}`);
|
|
284
|
+
// Printed only when it ADDS something. A worker that agreed with the ledger
|
|
285
|
+
// tells the reader nothing twice; one that claimed success against a ledger
|
|
286
|
+
// that says otherwise is the single most useful line on the page.
|
|
287
|
+
if (r.workerSaid && r.workerSaid !== r.reason) L.push(` the worker said: ${String(r.workerSaid).slice(0, 160)}`);
|
|
288
|
+
if (r.blockerId) L.push(` blocker ${r.blockerId}${r.parked ? ' (task parked, out of the queue)' : ''}`);
|
|
289
|
+
}
|
|
290
|
+
// Fenced refusals are collapsed by code: twenty identical lines say nothing the
|
|
291
|
+
// first one did not.
|
|
292
|
+
const byCode = {};
|
|
293
|
+
for (const f of d.onHold.fenced) (byCode[f.code] = byCode[f.code] || []).push(f);
|
|
294
|
+
for (const [code, list] of Object.entries(byCode)) {
|
|
295
|
+
L.push(` fence: ${code} ×${list.length}`);
|
|
296
|
+
if (list[0].reason) L.push(` ${String(list[0].reason).slice(0, 160)}`);
|
|
297
|
+
}
|
|
298
|
+
if (d.onHold.unworkable.length) {
|
|
299
|
+
L.push(` queue: ${plural(d.onHold.unworkable.length, 'task', 'tasks')} in the allowlist, every one halted`);
|
|
300
|
+
L.push(' (this is not a runner failure — it has nothing it may work on)');
|
|
301
|
+
for (const u of d.onHold.unworkable.slice(0, 5)) L.push(` ${u.taskId}: ${(u.reasons || []).join(', ') || 'halted'}`);
|
|
302
|
+
}
|
|
303
|
+
if (d.onHold.held.length) L.push(` capacity: held ${plural(d.onHold.held.length, 'time', 'times')} — ${d.onHold.held[0].reason || 'no headroom'}`);
|
|
304
|
+
L.push('');
|
|
305
|
+
|
|
306
|
+
// 3 — the one that compounds.
|
|
307
|
+
L.push(`DECIDED FOR YOU — ${plural(d.decisionCount, 'decision', 'decisions')} across ${plural(d.decisions.length, 'task', 'tasks')}`);
|
|
308
|
+
if (!d.decisionCount) {
|
|
309
|
+
L.push(' no worker had to fill a gap in its spec.');
|
|
310
|
+
} else {
|
|
311
|
+
L.push(' Each line is a sentence the task description should have contained.');
|
|
312
|
+
for (const t of d.decisions) {
|
|
313
|
+
L.push(` ${t.taskId} ${t.title || '(untitled)'}${t.shipped ? '' : ' [did not ship]'}`);
|
|
314
|
+
for (const one of t.decisions) L.push(` · ${one}`);
|
|
315
|
+
}
|
|
316
|
+
if (d.thinnestSpec && d.thinnestSpec.decisions.length > 1) {
|
|
317
|
+
L.push('');
|
|
318
|
+
L.push(` Thinnest spec: ${d.thinnestSpec.taskId} (${d.thinnestSpec.decisions.length} decisions).`);
|
|
319
|
+
L.push(' Tightening that description is what lowers tomorrow’s halts.');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
L.push('');
|
|
323
|
+
|
|
324
|
+
// 4
|
|
325
|
+
L.push('COST');
|
|
326
|
+
L.push(` ${usd(d.cost.totalUsd)} across ${plural(d.cost.workerRuns, 'worker run', 'worker runs')}` +
|
|
327
|
+
(d.cost.unreported ? ` (${d.cost.unreported} did not report a cost)` : ''));
|
|
328
|
+
if (d.cost.totalDurationS) L.push(` ${humanDuration(d.cost.totalDurationS)} of worker time`);
|
|
329
|
+
L.push('');
|
|
330
|
+
|
|
331
|
+
// 5
|
|
332
|
+
L.push('LIMITS');
|
|
333
|
+
for (const w of d.windows) L.push(` ${w.label.padEnd(18)} ${w.detail}`);
|
|
334
|
+
L.push('');
|
|
335
|
+
|
|
336
|
+
// The footer answers "did it run at all", which none of the five questions do.
|
|
337
|
+
const events = Object.entries(d.runner.byEvent).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(' · ');
|
|
338
|
+
L.push(`RUNNER ${events || 'no rows'}`);
|
|
339
|
+
if (d.runner.endedProbing) L.push(' Ended the night PROBING — it hit repeated failures and slowed itself down.');
|
|
340
|
+
if (d.runner.unrecognised) L.push(` ${plural(d.runner.unrecognised, 'row', 'rows')} of a kind this digest does not know about.`);
|
|
341
|
+
return L.join('\n');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = { buildDigest, renderDigest, windowsFromGauge, humanDuration };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.878",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.878",
|
|
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.19.
|
|
3
|
+
"version": "1.19.878",
|
|
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",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/autobongos-digest.js — read the night without opening a log
|
|
5
|
+
// (task 1003906, ADR 0307).
|
|
6
|
+
//
|
|
7
|
+
// node scripts/gds/autobongos-digest.js # since midnight, local
|
|
8
|
+
// node scripts/gds/autobongos-digest.js --since 24h # a rolling window
|
|
9
|
+
// node scripts/gds/autobongos-digest.js --json # the structured shape
|
|
10
|
+
// node scripts/gds/autobongos-digest.js --all # the whole log
|
|
11
|
+
//
|
|
12
|
+
// The decision logic is modules/autonomy/digest.js and has no I/O; this half
|
|
13
|
+
// finds the two files it reads and prints the result. Exit 0 always, including on
|
|
14
|
+
// an empty night: a digest is a report, and a non-zero exit would make a cron
|
|
15
|
+
// wrapper treat "nothing happened" as a failure.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const digest = require('../../modules/autonomy/digest.js');
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
23
|
+
|
|
24
|
+
function arg(flag, argv) {
|
|
25
|
+
const i = argv.indexOf(flag);
|
|
26
|
+
return i >= 0 ? argv[i + 1] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function logPath() {
|
|
30
|
+
if (process.env.AUTOBONGOS_LOG_FILE) return process.env.AUTOBONGOS_LOG_FILE;
|
|
31
|
+
try { return require('../../src/instance-config.js').configPath('autobongos-runs.jsonl'); }
|
|
32
|
+
catch (_) { return path.join(REPO_ROOT, 'autobongos-runs.jsonl'); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gaugePath() {
|
|
36
|
+
if (process.env.AUTOBONGOS_GAUGE_FILE) return process.env.AUTOBONGOS_GAUGE_FILE;
|
|
37
|
+
try { return require('../../src/instance-config.js').configPath('autobongos-gauge.json'); }
|
|
38
|
+
catch (_) { return path.join(REPO_ROOT, 'autobongos-gauge.json'); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// readRows — an append-only JSONL, and a single unparseable line must not cost
|
|
42
|
+
// the whole night. A JSONL written by a process that can be killed mid-write has
|
|
43
|
+
// a torn last line more often than never.
|
|
44
|
+
function readRows(file) {
|
|
45
|
+
let text;
|
|
46
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
47
|
+
catch (_) { return { rows: [], missing: true, skipped: 0 }; }
|
|
48
|
+
const rows = [];
|
|
49
|
+
let skipped = 0;
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
const t = line.trim();
|
|
52
|
+
if (!t) continue;
|
|
53
|
+
try { rows.push(JSON.parse(t)); } catch (_) { skipped += 1; }
|
|
54
|
+
}
|
|
55
|
+
return { rows, missing: false, skipped };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readGaugeFile(file) {
|
|
59
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
60
|
+
catch (_) { return null; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// sinceMs — `--since 24h|90m|3d`, or midnight LOCAL by default.
|
|
64
|
+
//
|
|
65
|
+
// Local rather than UTC because the question is "what happened last night" and
|
|
66
|
+
// night is a local idea; a UTC midnight cut would split an owner's evening in
|
|
67
|
+
// half depending on where they live.
|
|
68
|
+
function sinceMs(spec, now = new Date()) {
|
|
69
|
+
if (!spec) {
|
|
70
|
+
const d = new Date(now);
|
|
71
|
+
d.setHours(0, 0, 0, 0);
|
|
72
|
+
return d.getTime();
|
|
73
|
+
}
|
|
74
|
+
const m = /^(\d+(?:\.\d+)?)\s*([hmd])$/i.exec(String(spec).trim());
|
|
75
|
+
if (!m) return null;
|
|
76
|
+
const mult = { h: 3600e3, m: 60e3, d: 86400e3 }[m[2].toLowerCase()];
|
|
77
|
+
return now.getTime() - Number(m[1]) * mult;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function main(argv) {
|
|
81
|
+
const all = argv.includes('--all');
|
|
82
|
+
const asJson = argv.includes('--json');
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const from = all ? null : sinceMs(arg('--since', argv), now);
|
|
85
|
+
if (!all && from === null) {
|
|
86
|
+
console.error('autobongos-digest: --since wants a span like 24h, 90m or 3d.');
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lf = logPath();
|
|
91
|
+
const { rows, missing, skipped } = readRows(lf);
|
|
92
|
+
const inWindow = all ? rows : rows.filter((r) => {
|
|
93
|
+
const t = Date.parse(r && r.at);
|
|
94
|
+
return Number.isFinite(t) ? t >= from : true; // an undated row is kept, not silently dropped
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const d = digest.buildDigest(inWindow, {
|
|
98
|
+
gauge: readGaugeFile(gaugePath()),
|
|
99
|
+
nowEpochS: Math.floor(now.getTime() / 1000),
|
|
100
|
+
sinceMs: all ? null : from,
|
|
101
|
+
untilMs: now.getTime(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (asJson) { console.log(JSON.stringify(d, null, 2)); return 0; }
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
console.log(digest.renderDigest(d));
|
|
108
|
+
// Both of these are about the DIGEST's own reliability, so they sit outside the
|
|
109
|
+
// five questions rather than being folded into them.
|
|
110
|
+
if (missing) {
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(` No run log at ${lf} — the runner has not written one here yet.`);
|
|
113
|
+
}
|
|
114
|
+
if (skipped) console.log(` (${skipped} unreadable line(s) in the log were skipped.)`);
|
|
115
|
+
console.log('');
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (require.main === module) {
|
|
120
|
+
process.exit(main(process.argv.slice(2)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { main, sinceMs, readRows, logPath, gaugePath };
|
|
@@ -381,6 +381,13 @@ async function iteration(opts, deps = {}) {
|
|
|
381
381
|
// What the LEDGER says, kept separate from what the worker said, so the run
|
|
382
382
|
// log can be read afterwards without having to trust either one alone.
|
|
383
383
|
ledger_status: ledger.status, ledger_shape: ledger.shape, verified: !!ledger.verified,
|
|
384
|
+
// The LEDGER's own explanation, kept beside the worker's. `reason` above is
|
|
385
|
+
// verdict.reason — what the WORKER said happened — and it was the only reason
|
|
386
|
+
// in the row, so anything reading the log afterwards was quoting the thing
|
|
387
|
+
// being checked rather than the check. The morning digest did exactly that
|
|
388
|
+
// until the grader caught it (task 1003906).
|
|
389
|
+
ledger_reason: ledger.reason || null,
|
|
390
|
+
ledger_remedy: ledger.remedy || null,
|
|
384
391
|
artifact_on_main: ledger.artifact && ledger.artifact.checked ? !!ledger.artifact.onMain : null,
|
|
385
392
|
claim_released: !!ledger.released,
|
|
386
393
|
blocker_id: ledger.blocker && ledger.blocker.blockerId ? ledger.blocker.blockerId : null,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
#
|
|
4
4
|
# powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 install
|
|
5
5
|
# powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 status
|
|
6
|
+
# powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 digest
|
|
6
7
|
# powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 start|stop|uninstall
|
|
7
8
|
#
|
|
8
9
|
# WHY TASK SCHEDULER AND NOT A REAL SERVICE. Windows services speak a protocol
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
[CmdletBinding()]
|
|
30
31
|
param(
|
|
31
32
|
[Parameter(Position = 0)]
|
|
32
|
-
[ValidateSet('install', 'uninstall', 'start', 'stop', 'status')]
|
|
33
|
+
[ValidateSet('install', 'uninstall', 'start', 'stop', 'status', 'digest')]
|
|
33
34
|
[string]$Action = 'status',
|
|
34
35
|
|
|
35
36
|
# The checkout to run from. Defaults to the repo this script lives in, so the
|
|
@@ -118,6 +119,11 @@ switch ($Action) {
|
|
|
118
119
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
119
120
|
Write-Host "removed '$TaskName'. The log at $LogFile is left in place."
|
|
120
121
|
}
|
|
122
|
+
'digest' {
|
|
123
|
+
# The night in one page (task 1003906). On the box for convenience; the same
|
|
124
|
+
# five answers are what the Gate page and a mailed digest would carry.
|
|
125
|
+
& node (Join-Path $RepoRoot 'scripts\gds\autobongos-digest.js') @args
|
|
126
|
+
}
|
|
121
127
|
'start' { Start-ScheduledTask -TaskName $TaskName; Write-Host "started '$TaskName'" }
|
|
122
128
|
'stop' {
|
|
123
129
|
Stop-ScheduledTask -TaskName $TaskName
|
|
@@ -187,6 +187,25 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
187
187
|
const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
|
|
188
188
|
const versionUrl = `http://127.0.0.1:${inst.port}/version`;
|
|
189
189
|
const sudoP = privileged ? 'sudo ' : '';
|
|
190
|
+
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
|
|
191
|
+
//
|
|
192
|
+
// `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
|
|
193
|
+
// no scripts/ directory at all — its core sits under node_modules/@bongos/core — so
|
|
194
|
+
// running the CLI with cwd = the instance root resolved nothing, and EVERY standalone
|
|
195
|
+
// upgrade died before it began:
|
|
196
|
+
//
|
|
197
|
+
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
198
|
+
//
|
|
199
|
+
// It worked only where instanceDir HAPPENED to be this checkout — the control-plane self
|
|
200
|
+
// row and a co-tenant — which is why it survived to production: the shapes that are
|
|
201
|
+
// upgraded by hand are the shapes the relative path fits. Every ACTIVE tenant is
|
|
202
|
+
// standalone, so the deploy door could not move a single one of them.
|
|
203
|
+
//
|
|
204
|
+
// upgrade.js's own header states the contract this restores: "Runs from the consumer
|
|
205
|
+
// instance root (default) or `--instance <dir>`. This script ships IN the core … against
|
|
206
|
+
// `--instance <consumer>`." So: run it where it exists, and NAME the target.
|
|
207
|
+
const runDir = REPO_ROOT;
|
|
208
|
+
const targetArg = instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`;
|
|
190
209
|
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
191
210
|
const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
|
|
192
211
|
? intent.requested_by.replace(/^api:archon:/, '')
|
|
@@ -195,6 +214,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
195
214
|
`${sudoP}node ${cli}`,
|
|
196
215
|
'--registry',
|
|
197
216
|
`--to ${to}`,
|
|
217
|
+
targetArg,
|
|
198
218
|
`--service ${inst.slug}`,
|
|
199
219
|
`--health-url ${healthUrl}`,
|
|
200
220
|
`--version-url ${versionUrl}`,
|
|
@@ -204,13 +224,13 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
204
224
|
].filter(Boolean).join(' ');
|
|
205
225
|
|
|
206
226
|
if (!apply) {
|
|
207
|
-
log(` ✓ would run: ${cmd} (dry-run — nothing ran)`);
|
|
227
|
+
log(` ✓ would run (in ${runDir}): ${cmd} (dry-run — nothing ran)`);
|
|
208
228
|
log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
|
|
209
229
|
return { ok: true, served: null };
|
|
210
230
|
}
|
|
211
231
|
try {
|
|
212
|
-
log(` [upgrade] ${cmd}`);
|
|
213
|
-
boxExec(cmd, { cwd:
|
|
232
|
+
log(` [upgrade] (in ${runDir}) ${cmd}`);
|
|
233
|
+
boxExec(cmd, { cwd: runDir });
|
|
214
234
|
} catch (e) {
|
|
215
235
|
// upgrade.js rolls itself back on a failed health check or an unchanged served
|
|
216
236
|
// version, so by the time this throws the instance is back on the core it had.
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.878'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// tests/autonomy_digest.mjs — the night, readable afterwards (task 1003906).
|
|
2
|
+
//
|
|
3
|
+
// The done-when is "a night produces one digest answering all five questions
|
|
4
|
+
// without anyone opening a log". So the spine of this file is one test that
|
|
5
|
+
// feeds a realistic night through and asserts all five answers are present and
|
|
6
|
+
// correct — because five separately-passing sections can still add up to a page
|
|
7
|
+
// that does not answer the question.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { buildDigest, renderDigest, windowsFromGauge, humanDuration } = require('../modules/autonomy/digest.js');
|
|
15
|
+
const cli = require('../scripts/gds/autobongos-digest.js');
|
|
16
|
+
|
|
17
|
+
const T0 = Date.parse('2026-09-19T02:00:00.000Z');
|
|
18
|
+
const at = (m) => new Date(T0 + m * 60000).toISOString();
|
|
19
|
+
const NOW_S = Math.floor((T0 + 8 * 3600e3) / 1000);
|
|
20
|
+
|
|
21
|
+
const night = () => [
|
|
22
|
+
{ at: at(0), event: 'boot_reconcile', ok: true, leftover: 0 },
|
|
23
|
+
{ at: at(1), event: 'worked', task_id: 111, title: 'A shipped thing', goal_id: 1000119,
|
|
24
|
+
duration_s: 2820, cost_usd: 3.41, verified: true, ledger_shape: 'shipped',
|
|
25
|
+
undetermined_decisions: ['Cached for 60s rather than 5m'] },
|
|
26
|
+
{ at: at(50), event: 'worked', task_id: 222, title: 'A stranded thing', goal_id: 1000119,
|
|
27
|
+
duration_s: 5400, cost_usd: 6.8, verified: false, ledger_shape: 'unlanded',
|
|
28
|
+
reason: 'the grade passed and the land never finished', blocker_id: '1000161',
|
|
29
|
+
undetermined_decisions: ['Sorted inactive last', 'Kept the id tie-break', 'Chose ascending'] },
|
|
30
|
+
{ at: at(120), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
|
|
31
|
+
{ at: at(121), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
|
|
32
|
+
{ at: at(122), event: 'cadence', mode: 'probing', class: 'transient', consecutive_failures: 3, wait_s: 60 },
|
|
33
|
+
{ at: at(200), event: 'nothing_claimable', goals: [1000119],
|
|
34
|
+
skipped: [{ id: 333, goal_id: 1000119, reasons: ['needs_migration'] }] },
|
|
35
|
+
{ at: at(201), event: 'hold', reason: 'seven-day pace: 78% used' },
|
|
36
|
+
];
|
|
37
|
+
const gauge = {
|
|
38
|
+
five_hour: { used_percentage: 41, resets_at: NOW_S + 3600 },
|
|
39
|
+
seven_day: { used_percentage: 78, resets_at: NOW_S + 240000 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ── the done-when, in one test ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
test('one night produces one page answering all five questions', () => {
|
|
45
|
+
const text = renderDigest(buildDigest(night(), { gauge, nowEpochS: NOW_S }));
|
|
46
|
+
|
|
47
|
+
// 1. what shipped — and ONLY what the ledger agreed shipped.
|
|
48
|
+
assert.match(text, /SHIPPED — 1 task/);
|
|
49
|
+
assert.match(text, /111\s+A shipped thing/);
|
|
50
|
+
assert.doesNotMatch(text.split('ON HOLD')[0], /222/, 'a task that ran but did not ship is not "shipped"');
|
|
51
|
+
|
|
52
|
+
// 2. what is on hold, and why.
|
|
53
|
+
assert.match(text, /ON HOLD/);
|
|
54
|
+
assert.match(text, /222\s+ran but did not ship — unlanded/);
|
|
55
|
+
assert.match(text, /blocker 1000161/);
|
|
56
|
+
assert.match(text, /fence: grader_unavailable ×2/);
|
|
57
|
+
assert.match(text, /every one halted/);
|
|
58
|
+
assert.match(text, /capacity: held 1 time/);
|
|
59
|
+
|
|
60
|
+
// 3. what each worker decided that its spec did not determine.
|
|
61
|
+
assert.match(text, /DECIDED FOR YOU — 4 decisions across 2 tasks/);
|
|
62
|
+
assert.match(text, /Cached for 60s rather than 5m/);
|
|
63
|
+
assert.match(text, /sentence the task description should have contained/);
|
|
64
|
+
assert.match(text, /Thinnest spec: 222/);
|
|
65
|
+
|
|
66
|
+
// 4. what it cost.
|
|
67
|
+
assert.match(text, /COST/);
|
|
68
|
+
assert.match(text, /\$10\.21 across 2 worker runs/);
|
|
69
|
+
|
|
70
|
+
// 5. where both limit windows stand.
|
|
71
|
+
assert.match(text, /Five-hour window\s+41% used, resets in 60m/);
|
|
72
|
+
assert.match(text, /Seven-day window\s+78% used/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every section prints even when the night was empty', () => {
|
|
76
|
+
// A digest whose quiet sections vanish teaches its reader that absence means
|
|
77
|
+
// "fine" — and then the morning a section is missing because the DATA is
|
|
78
|
+
// missing reads exactly the same.
|
|
79
|
+
const text = renderDigest(buildDigest([], { gauge, nowEpochS: NOW_S }));
|
|
80
|
+
for (const heading of ['SHIPPED', 'ON HOLD', 'DECIDED FOR YOU', 'COST', 'LIMITS', 'RUNNER']) {
|
|
81
|
+
assert.ok(text.includes(heading), `${heading} must print on an empty night`);
|
|
82
|
+
}
|
|
83
|
+
assert.match(text, /nothing shipped/);
|
|
84
|
+
assert.match(text, /nothing is waiting on you/);
|
|
85
|
+
assert.match(text, /no worker had to fill a gap/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── the answers, individually ──────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('shipped is the LEDGER’s answer, never the worker’s', () => {
|
|
91
|
+
const d = buildDigest([
|
|
92
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, ledger_shape: 'shipped' },
|
|
93
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: false, ledger_shape: 'still_held' },
|
|
94
|
+
{ at: at(2), event: 'worked', task_id: 3, outcome: 'claims_shipped', ledger_shape: 'shipped_no_artifact', verified: false },
|
|
95
|
+
], { nowEpochS: NOW_S });
|
|
96
|
+
assert.deepEqual(d.shipped.map((s) => s.taskId), ['1']);
|
|
97
|
+
assert.deepEqual(d.onHold.ranButDidNotShip.map((r) => r.shape), ['still_held', 'shipped_no_artifact']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an unworkable queue is reported as a queue problem, not as breakage', () => {
|
|
101
|
+
const d = buildDigest([{ at: at(0), event: 'nothing_claimable', skipped: [{ id: 9, reasons: ['needs_migration'] }] }], { nowEpochS: NOW_S });
|
|
102
|
+
assert.equal(d.onHold.unworkable.length, 1);
|
|
103
|
+
const text = renderDigest(d);
|
|
104
|
+
assert.match(text, /this is not a runner failure/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the decisions list groups by task and names the thinnest spec', () => {
|
|
108
|
+
// The SHAPE is the signal: one task with six decisions is a thin spec, six
|
|
109
|
+
// tasks with one each is a normal night.
|
|
110
|
+
const d = buildDigest([
|
|
111
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, undetermined_decisions: ['a'] },
|
|
112
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: true, undetermined_decisions: ['b', 'c', 'd'] },
|
|
113
|
+
{ at: at(2), event: 'worked', task_id: 3, verified: true, undetermined_decisions: [] },
|
|
114
|
+
], { nowEpochS: NOW_S });
|
|
115
|
+
assert.equal(d.decisionCount, 4);
|
|
116
|
+
assert.equal(d.decisions.length, 2, 'a task with no decisions is not listed');
|
|
117
|
+
assert.equal(d.thinnestSpec.taskId, '2');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a decision from a task that did NOT ship is still surfaced', () => {
|
|
121
|
+
// These are the ones that matter most: the judgement was made, the work is not
|
|
122
|
+
// in, and the call is sitting in a branch nobody has read.
|
|
123
|
+
const text = renderDigest(buildDigest([
|
|
124
|
+
{ at: at(0), event: 'worked', task_id: 7, title: 'T', verified: false, ledger_shape: 'unlanded', undetermined_decisions: ['picked a default'] },
|
|
125
|
+
], { nowEpochS: NOW_S }));
|
|
126
|
+
assert.match(text, /picked a default/);
|
|
127
|
+
assert.match(text, /\[did not ship\]/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an unreported cost is excluded and counted, never coerced to zero', () => {
|
|
131
|
+
const d = buildDigest([
|
|
132
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, cost_usd: 2.5 },
|
|
133
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: true, cost_usd: null },
|
|
134
|
+
{ at: at(2), event: 'worked', task_id: 3, verified: true },
|
|
135
|
+
], { nowEpochS: NOW_S });
|
|
136
|
+
assert.equal(d.cost.totalUsd, 2.5, 'a worker whose cost was not reported must not make the night look cheaper');
|
|
137
|
+
assert.equal(d.cost.reported, 1);
|
|
138
|
+
assert.equal(d.cost.unreported, 2);
|
|
139
|
+
assert.match(renderDigest(d), /2 did not report a cost/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── the limit windows ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('a window whose reset has PASSED is reported as stale, not as headroom', () => {
|
|
145
|
+
// "41% used, resets in 0s" invites a decision about headroom that no longer
|
|
146
|
+
// exists: the percentage was true of a window that has since rolled.
|
|
147
|
+
const [five] = windowsFromGauge({ five_hour: { used_percentage: 41, resets_at: NOW_S - 60 } }, NOW_S);
|
|
148
|
+
assert.equal(five.known, false);
|
|
149
|
+
assert.equal(five.stale, true);
|
|
150
|
+
assert.match(five.detail, /has since reset/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a window the gauge never reported says so rather than reading as zero', () => {
|
|
154
|
+
const [five, seven] = windowsFromGauge({ seven_day: { used_percentage: 12, resets_at: NOW_S + 999 } }, NOW_S);
|
|
155
|
+
assert.equal(five.known, false);
|
|
156
|
+
assert.equal(five.detail, 'not reported');
|
|
157
|
+
assert.equal(seven.known, true);
|
|
158
|
+
for (const g of [null, undefined, {}, 'nope']) {
|
|
159
|
+
assert.deepEqual(windowsFromGauge(g, NOW_S).map((w) => w.known), [false, false], JSON.stringify(g));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── the runner footer ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
test('a night that ended probing says so — "nothing shipped" has two causes', () => {
|
|
166
|
+
const text = renderDigest(buildDigest([
|
|
167
|
+
{ at: at(0), event: 'cadence', mode: 'full', class: 'quiet' },
|
|
168
|
+
{ at: at(1), event: 'cadence', mode: 'probing', class: 'transient' },
|
|
169
|
+
], { nowEpochS: NOW_S }));
|
|
170
|
+
assert.match(text, /Ended the night PROBING/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('an event this digest does not know about is counted, never dropped', () => {
|
|
174
|
+
// A digest that silently ignores what it does not understand is how a new
|
|
175
|
+
// failure mode stays invisible for a week.
|
|
176
|
+
const d = buildDigest([{ at: at(0), event: 'something_new_entirely' }], { nowEpochS: NOW_S });
|
|
177
|
+
assert.equal(d.runner.unrecognised, 1);
|
|
178
|
+
assert.equal(d.runner.byEvent.something_new_entirely, 1);
|
|
179
|
+
assert.match(renderDigest(d), /a kind this digest does not know about/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('malformed rows never take the night down', () => {
|
|
183
|
+
const d = buildDigest([null, undefined, 42, 'nope', {}, { event: 'worked', verified: true }], { nowEpochS: NOW_S });
|
|
184
|
+
assert.equal(d.shipped.length, 1);
|
|
185
|
+
assert.doesNotThrow(() => renderDigest(d));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── the CLI half ───────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test('--since accepts a span and defaults to local midnight', () => {
|
|
191
|
+
const now = new Date('2026-09-19T14:30:00');
|
|
192
|
+
assert.equal(cli.sinceMs('24h', now), now.getTime() - 86400e3);
|
|
193
|
+
assert.equal(cli.sinceMs('90m', now), now.getTime() - 5400e3);
|
|
194
|
+
assert.equal(cli.sinceMs('3d', now), now.getTime() - 3 * 86400e3);
|
|
195
|
+
// Local midnight, because "last night" is a local idea — a UTC cut would split
|
|
196
|
+
// an owner's evening in half depending on where they live.
|
|
197
|
+
const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
|
|
198
|
+
assert.equal(cli.sinceMs(null, now), midnight.getTime());
|
|
199
|
+
assert.equal(cli.sinceMs('soon', now), null, 'an unparseable span must refuse, not guess');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a torn last line costs one row, not the night', async () => {
|
|
203
|
+
// A JSONL written by a process that can be killed mid-write has a torn last
|
|
204
|
+
// line more often than never.
|
|
205
|
+
const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
|
|
206
|
+
const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ab-digest-')), 'runs.jsonl');
|
|
207
|
+
fs.writeFileSync(f, `${JSON.stringify({ at: at(0), event: 'worked', task_id: 1, verified: true })}\n{"at":"x","event":"wor`);
|
|
208
|
+
const { rows, skipped, missing } = cli.readRows(f);
|
|
209
|
+
assert.equal(missing, false);
|
|
210
|
+
assert.equal(rows.length, 1);
|
|
211
|
+
assert.equal(skipped, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a missing log is reported, not mistaken for a quiet night', async () => {
|
|
215
|
+
const { rows, missing } = cli.readRows('/definitely/not/here/runs.jsonl');
|
|
216
|
+
assert.deepEqual(rows, []);
|
|
217
|
+
assert.equal(missing, true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('humanDuration reads like a person wrote it', () => {
|
|
221
|
+
assert.equal(humanDuration(45), '45s');
|
|
222
|
+
assert.equal(humanDuration(2820), '47m');
|
|
223
|
+
assert.equal(humanDuration(10080), '2.8h');
|
|
224
|
+
assert.equal(humanDuration(null), null);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── the grader's three findings (task 1003906) ─────────────────────────────────
|
|
228
|
+
|
|
229
|
+
test('the "did not ship" reason is the LEDGER’s, not the worker’s', () => {
|
|
230
|
+
// THE INVERSION THIS GOAL EXISTS TO UNDO. `row.reason` is verdict.reason — the
|
|
231
|
+
// WORKER's account of itself — and quoting it here means the digest reports the
|
|
232
|
+
// thing being checked rather than the check. A worker that stopped dead needing
|
|
233
|
+
// a decision reports success; the ledger says what actually happened.
|
|
234
|
+
const d = buildDigest([{
|
|
235
|
+
at: at(0), event: 'worked', task_id: 9, title: 'T', verified: false, ledger_shape: 'unlanded',
|
|
236
|
+
reason: 'I finished the work and shipped it',
|
|
237
|
+
ledger_reason: 'the grade passed and the land never finished',
|
|
238
|
+
ledger_remedy: '/merge-mode is the documented manual fallback',
|
|
239
|
+
}], { nowEpochS: NOW_S });
|
|
240
|
+
|
|
241
|
+
assert.equal(d.onHold.ranButDidNotShip[0].reason, 'the grade passed and the land never finished');
|
|
242
|
+
assert.equal(d.onHold.ranButDidNotShip[0].workerSaid, 'I finished the work and shipped it');
|
|
243
|
+
|
|
244
|
+
const text = renderDigest(d);
|
|
245
|
+
assert.match(text, /the grade passed and the land never finished/);
|
|
246
|
+
assert.match(text, /next: \/merge-mode/);
|
|
247
|
+
// The disagreement is the single most useful line on the page.
|
|
248
|
+
assert.match(text, /the worker said: I finished the work and shipped it/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the worker’s account is printed only when it disagrees', () => {
|
|
252
|
+
// A worker that agreed with the ledger tells the reader nothing twice.
|
|
253
|
+
const same = 'the grade parked this';
|
|
254
|
+
const text = renderDigest(buildDigest([{
|
|
255
|
+
at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'ungraded', reason: same, ledger_reason: same,
|
|
256
|
+
}], { nowEpochS: NOW_S }));
|
|
257
|
+
assert.equal((text.match(/the grade parked this/g) || []).length, 1);
|
|
258
|
+
assert.doesNotMatch(text, /the worker said:/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a row from before ledger_reason existed still shows something', () => {
|
|
262
|
+
const d = buildDigest([{ at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'unlanded', reason: 'older row' }], { nowEpochS: NOW_S });
|
|
263
|
+
assert.equal(d.onHold.ranButDidNotShip[0].reason, 'older row');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the digest calls a gauge stale on the SAME rule the runner holds on', () => {
|
|
267
|
+
// A digest that applied a laxer test would print live headroom about a window
|
|
268
|
+
// the runner had already decided it could not vouch for — two surfaces
|
|
269
|
+
// disagreeing about one file, with the reader believing the wrong one.
|
|
270
|
+
const gaugeMod = require('../modules/autonomy/gauge.js');
|
|
271
|
+
const tooOld = gaugeMod.DEFAULT_STALE_AFTER_S + 60;
|
|
272
|
+
|
|
273
|
+
const [fresh] = windowsFromGauge(
|
|
274
|
+
{ five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - 60 } }, NOW_S);
|
|
275
|
+
assert.equal(fresh.known, true, 'a recent reading is usable');
|
|
276
|
+
|
|
277
|
+
const [stale] = windowsFromGauge(
|
|
278
|
+
{ five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - tooOld } }, NOW_S);
|
|
279
|
+
assert.equal(stale.known, false);
|
|
280
|
+
assert.equal(stale.stale, true);
|
|
281
|
+
assert.match(stale.detail, /older than the runner trusts/);
|
|
282
|
+
assert.doesNotMatch(stale.detail, /resets in/, 'never offer headroom from a reading the runner rejected');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('a window with no observed_at falls back to the file’s written_at', () => {
|
|
286
|
+
// One window can go unreported while the other is carried forward — the exact
|
|
287
|
+
// shape gauge.js reads.
|
|
288
|
+
const gaugeMod = require('../modules/autonomy/gauge.js');
|
|
289
|
+
const old = NOW_S - gaugeMod.DEFAULT_STALE_AFTER_S - 60;
|
|
290
|
+
const [w] = windowsFromGauge({ written_at: old, five_hour: { used_percentage: 41, resets_at: NOW_S + 7200 } }, NOW_S);
|
|
291
|
+
assert.equal(w.stale, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('the period bounds survive a log too long to spread as arguments', () => {
|
|
295
|
+
// `Math.min(...times)` throws past the engine's argument cap, and a reporting
|
|
296
|
+
// CLI that dies on a big log fails exactly when the log is worth reading.
|
|
297
|
+
const many = Array.from({ length: 200000 }, (_, i) => ({ at: at(i % 1000), event: 'cadence', mode: 'full' }));
|
|
298
|
+
let d;
|
|
299
|
+
assert.doesNotThrow(() => { d = buildDigest(many, { nowEpochS: NOW_S }); });
|
|
300
|
+
assert.equal(d.period.rows, 200000);
|
|
301
|
+
assert.equal(d.period.fromMs, T0);
|
|
302
|
+
});
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
|
|
29
29
|
import { strict as assert } from 'node:assert';
|
|
30
30
|
import { createRequire } from 'node:module';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
31
33
|
|
|
32
34
|
const require = createRequire(import.meta.url);
|
|
33
35
|
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
@@ -46,13 +48,19 @@ const t = async (name, fn) => {
|
|
|
46
48
|
// instant. `apply:true` because the interesting behaviour is the applied path.
|
|
47
49
|
function makeDeps({ shape = 'control-plane', served = '1.19.760', available = ['1.19.758', '1.19.760', '1.19.762'], installed = '1.19.760', queries = [] } = {}) {
|
|
48
50
|
const ran = [];
|
|
49
|
-
|
|
51
|
+
// task 1004053: the CWD is half of what makes a relative CLI path resolve, and the
|
|
52
|
+
// bug that shipped was invisible to every assertion that read only the command
|
|
53
|
+
// string. Captured alongside, so a test can check the pair.
|
|
54
|
+
const ranWith = [];
|
|
55
|
+
const exec = (cmd, opts) => {
|
|
50
56
|
ran.push(cmd);
|
|
57
|
+
ranWith.push({ cmd, cwd: opts && opts.cwd });
|
|
51
58
|
if (/\/version/.test(cmd)) return { ok: true, stdout: served == null ? '' : JSON.stringify({ coreVersion: served }) };
|
|
52
59
|
return { ok: true, stdout: '' };
|
|
53
60
|
};
|
|
54
61
|
return {
|
|
55
62
|
ran,
|
|
63
|
+
ranWith,
|
|
56
64
|
queries,
|
|
57
65
|
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
|
|
58
66
|
provisioning: { recordEvent: async () => {}, clearErrorNote: async () => {} },
|
|
@@ -245,6 +253,40 @@ await t('drives upgrade.js with the registry, the health gate, the version read-
|
|
|
245
253
|
assert.ok(cmd.includes('--commit-pin'));
|
|
246
254
|
});
|
|
247
255
|
|
|
256
|
+
await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that broke every standalone tenant (task 1004053)', async () => {
|
|
257
|
+
// The whole failure was a path that resolved nowhere:
|
|
258
|
+
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
259
|
+
// No assertion about FLAGS could have caught it, so this one resolves the composed
|
|
260
|
+
// cwd + relative CLI against the real filesystem, for every shape the door admits.
|
|
261
|
+
for (const shape of ['control-plane', 'standalone', 'co-tenant']) {
|
|
262
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
263
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
264
|
+
const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
|
|
265
|
+
assert.ok(call, `${shape}: it ran the upgrade CLI`);
|
|
266
|
+
const rel = (call.cmd.match(/node (\S*scripts\/gds\/upgrade\.js)/) || [])[1];
|
|
267
|
+
assert.ok(rel, `${shape}: the command names the CLI`);
|
|
268
|
+
assert.ok(fs.existsSync(path.join(call.cwd, rel)),
|
|
269
|
+
`${shape}: ${path.join(call.cwd, rel)} must exist — this is the assertion the original bug would have failed`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
|
|
274
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
275
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
276
|
+
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
277
|
+
assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
|
|
278
|
+
assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await t('the control-plane row carries NO --instance — its target IS the checkout the CLI runs in', async () => {
|
|
282
|
+
// Naming it would be harmless but wrong, and the shapes that already worked must not
|
|
283
|
+
// change behaviour on the way to fixing the one that never did.
|
|
284
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
285
|
+
await R.coreUpgradeInstance(inst(), d, intent());
|
|
286
|
+
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
287
|
+
assert.ok(!cmd.includes('--instance'), 'unchanged for the platform itself');
|
|
288
|
+
});
|
|
289
|
+
|
|
248
290
|
await t('the health URL and the version URL are LOOPBACK — the apex is behind basic_auth and a CDN (ADR 0293 D6)', async () => {
|
|
249
291
|
const d = makeDeps();
|
|
250
292
|
await R.coreUpgradeInstance(inst(), d, intent());
|