@bongos/core 1.19.650 → 1.19.652
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 +35 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0277-a-box-is-in-use-only-while-a-human-is-attached.md +53 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +4 -1
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_238_box_unattended_cap.sql +57 -0
- package/modules/dev-box/boxes.js +112 -8
- package/modules/dev-box/routes/box.js +14 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +8 -0
- package/scripts/gds/box.js +35 -9
- package/scripts/gds/ship-land.js +34 -1
- package/scripts/gds/ship-merge.js +76 -10
- package/scripts/gds/ship.js +13 -1
- package/src/module-api.js +1 -1
- package/tests/boxes.mjs +160 -3
- package/tests/ship_ci_deploy.mjs +106 -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.652",
|
|
6
|
+
"core_contract": "1.19.652",
|
|
7
|
+
"source_commit": "addabb3fbae5b20acb74b0d7ee3cf369d6a4e8f5",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-10T21:00:32.591Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 472,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2110,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2606,
|
|
20
|
+
"tree_sha256": "0625f6b55212a6ec7203db50d856c576c15b3eda23ea574a46fb212641dc163c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
@@ -382,7 +382,7 @@
|
|
|
382
382
|
{
|
|
383
383
|
"path": "clients/bongos-client/index.d.ts",
|
|
384
384
|
"mode": "0000644",
|
|
385
|
-
"sha256": "
|
|
385
|
+
"sha256": "a82dc5f2fdcf44a1abff4bd8ba0aec70cc49004ad5feae72c362e8b45c5f04e5"
|
|
386
386
|
},
|
|
387
387
|
{
|
|
388
388
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1889,20 +1889,25 @@
|
|
|
1889
1889
|
"mode": "0000644",
|
|
1890
1890
|
"sha256": "2830a09a2d0520e7992c8e7d1545304092857267a9060f5127bd2ffcff237ebc"
|
|
1891
1891
|
},
|
|
1892
|
+
{
|
|
1893
|
+
"path": "docs/adr/0277-a-box-is-in-use-only-while-a-human-is-attached.md",
|
|
1894
|
+
"mode": "0000644",
|
|
1895
|
+
"sha256": "e5e99f19152b5a98c21f70fc0e54a09e9ae59beeb81fea8d26df2de4ef196b90"
|
|
1896
|
+
},
|
|
1892
1897
|
{
|
|
1893
1898
|
"path": "docs/adr/README.md",
|
|
1894
1899
|
"mode": "0000644",
|
|
1895
|
-
"sha256": "
|
|
1900
|
+
"sha256": "5ee80f0739cfbe9543f746ed13efbfeeaa56c350fb4e9570c7c122723b86a34a"
|
|
1896
1901
|
},
|
|
1897
1902
|
{
|
|
1898
1903
|
"path": "docs/api-reference.md",
|
|
1899
1904
|
"mode": "0000644",
|
|
1900
|
-
"sha256": "
|
|
1905
|
+
"sha256": "fe08952e837f4e79243823d66876f2413ca1d743c22bc2a287c18275616692d4"
|
|
1901
1906
|
},
|
|
1902
1907
|
{
|
|
1903
1908
|
"path": "docs/api/openapi.json",
|
|
1904
1909
|
"mode": "0000644",
|
|
1905
|
-
"sha256": "
|
|
1910
|
+
"sha256": "db8be14055cc32b31376585ad954e1e91a8b1cbe77401bad3d5215c1d3b419aa"
|
|
1906
1911
|
},
|
|
1907
1912
|
{
|
|
1908
1913
|
"path": "docs/architecture.md",
|
|
@@ -2777,7 +2782,7 @@
|
|
|
2777
2782
|
{
|
|
2778
2783
|
"path": "docs/module-api-changelog.md",
|
|
2779
2784
|
"mode": "0000644",
|
|
2780
|
-
"sha256": "
|
|
2785
|
+
"sha256": "7b9836503b654f29b308db8418484f1f707bfe6edcd32aaae6aa1c1a17216b5b"
|
|
2781
2786
|
},
|
|
2782
2787
|
{
|
|
2783
2788
|
"path": "docs/modules-contract.md",
|
|
@@ -3824,6 +3829,11 @@
|
|
|
3824
3829
|
"mode": "0000644",
|
|
3825
3830
|
"sha256": "2cda282b43ea71dd82ddf4e5ece90f11ad4013cbee0820f0a33391f202feff9c"
|
|
3826
3831
|
},
|
|
3832
|
+
{
|
|
3833
|
+
"path": "migrations/core_238_box_unattended_cap.sql",
|
|
3834
|
+
"mode": "0000644",
|
|
3835
|
+
"sha256": "03df9104a0309011df1b6e96627c62cc6612f34b654134306d13fd5c89b8f038"
|
|
3836
|
+
},
|
|
3827
3837
|
{
|
|
3828
3838
|
"path": "modules/agents/lib/validate.js",
|
|
3829
3839
|
"mode": "0000644",
|
|
@@ -4247,7 +4257,7 @@
|
|
|
4247
4257
|
{
|
|
4248
4258
|
"path": "modules/dev-box/boxes.js",
|
|
4249
4259
|
"mode": "0000644",
|
|
4250
|
-
"sha256": "
|
|
4260
|
+
"sha256": "cd1fe37f04e6a32964fd165045ac6613ce2e1236e1ff388da38d428a769beb4b"
|
|
4251
4261
|
},
|
|
4252
4262
|
{
|
|
4253
4263
|
"path": "modules/dev-box/db.js",
|
|
@@ -4262,7 +4272,7 @@
|
|
|
4262
4272
|
{
|
|
4263
4273
|
"path": "modules/dev-box/routes/box.js",
|
|
4264
4274
|
"mode": "0000644",
|
|
4265
|
-
"sha256": "
|
|
4275
|
+
"sha256": "c5cef340b2a5c85202d07709f1b96983eb03ce5a15168684922e2de1ae025d71"
|
|
4266
4276
|
},
|
|
4267
4277
|
{
|
|
4268
4278
|
"path": "modules/discord/CLAUDE.md",
|
|
@@ -7732,12 +7742,12 @@
|
|
|
7732
7742
|
{
|
|
7733
7743
|
"path": "package-lock.json",
|
|
7734
7744
|
"mode": "0000644",
|
|
7735
|
-
"sha256": "
|
|
7745
|
+
"sha256": "9fcb8bfb10f688b6442649f49bd4cc91454e56a44169414ca8839caab3fc05c7"
|
|
7736
7746
|
},
|
|
7737
7747
|
{
|
|
7738
7748
|
"path": "package.json",
|
|
7739
7749
|
"mode": "0000644",
|
|
7740
|
-
"sha256": "
|
|
7750
|
+
"sha256": "ed0d91d1a9a16e1f8e97ce2b538de568c297bc281cf15ea99f22f9bb92f1b5f0"
|
|
7741
7751
|
},
|
|
7742
7752
|
{
|
|
7743
7753
|
"path": "public-docs/index.html",
|
|
@@ -7922,7 +7932,7 @@
|
|
|
7922
7932
|
{
|
|
7923
7933
|
"path": "scripts/gds/box-infra.js",
|
|
7924
7934
|
"mode": "0000644",
|
|
7925
|
-
"sha256": "
|
|
7935
|
+
"sha256": "811545b6769947c5399f4be52782e90c1a6c2e628e72d7751266d2c7dafb62c3"
|
|
7926
7936
|
},
|
|
7927
7937
|
{
|
|
7928
7938
|
"path": "scripts/gds/box-sync.js",
|
|
@@ -7932,7 +7942,7 @@
|
|
|
7932
7942
|
{
|
|
7933
7943
|
"path": "scripts/gds/box.js",
|
|
7934
7944
|
"mode": "0000644",
|
|
7935
|
-
"sha256": "
|
|
7945
|
+
"sha256": "e7d82101c8212d1c46da944bb52f2d18a7fc87293f66f18e6856844f8766666b"
|
|
7936
7946
|
},
|
|
7937
7947
|
{
|
|
7938
7948
|
"path": "scripts/gds/bug-triage.js",
|
|
@@ -8902,7 +8912,7 @@
|
|
|
8902
8912
|
{
|
|
8903
8913
|
"path": "scripts/gds/ship-land.js",
|
|
8904
8914
|
"mode": "0000644",
|
|
8905
|
-
"sha256": "
|
|
8915
|
+
"sha256": "d8cd184816230b7b246ec7afaa2ea285b7ba0e24bb8f47f79d2bc7fa5b423429"
|
|
8906
8916
|
},
|
|
8907
8917
|
{
|
|
8908
8918
|
"path": "scripts/gds/ship-memory-sync.js",
|
|
@@ -8912,7 +8922,7 @@
|
|
|
8912
8922
|
{
|
|
8913
8923
|
"path": "scripts/gds/ship-merge.js",
|
|
8914
8924
|
"mode": "0000644",
|
|
8915
|
-
"sha256": "
|
|
8925
|
+
"sha256": "b602b4cf8f0e66610991237e1cb7ade9cd5c14659b155ce3512268d40ae670eb"
|
|
8916
8926
|
},
|
|
8917
8927
|
{
|
|
8918
8928
|
"path": "scripts/gds/ship-preflight-steps.js",
|
|
@@ -8952,7 +8962,7 @@
|
|
|
8952
8962
|
{
|
|
8953
8963
|
"path": "scripts/gds/ship.js",
|
|
8954
8964
|
"mode": "0000644",
|
|
8955
|
-
"sha256": "
|
|
8965
|
+
"sha256": "6fd0329b8ad7d45fbb93deda02142c2b766c9e01b669d2d94eeafcb1caa225b0"
|
|
8956
8966
|
},
|
|
8957
8967
|
{
|
|
8958
8968
|
"path": "scripts/gds/skill-lint.js",
|
|
@@ -9492,7 +9502,7 @@
|
|
|
9492
9502
|
{
|
|
9493
9503
|
"path": "src/module-api.js",
|
|
9494
9504
|
"mode": "0000644",
|
|
9495
|
-
"sha256": "
|
|
9505
|
+
"sha256": "d0459d72e42fdd8e712146f10545b27b6f82a310afa7bd0acb1b9290e1ef17b3"
|
|
9496
9506
|
},
|
|
9497
9507
|
{
|
|
9498
9508
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9962,7 +9972,7 @@
|
|
|
9962
9972
|
{
|
|
9963
9973
|
"path": "tests/boxes.mjs",
|
|
9964
9974
|
"mode": "0000644",
|
|
9965
|
-
"sha256": "
|
|
9975
|
+
"sha256": "30a553024c9fe50076efd447c7f50ddca8e6d20bb91392f20ec30ab3b414aab6"
|
|
9966
9976
|
},
|
|
9967
9977
|
{
|
|
9968
9978
|
"path": "tests/branding.mjs",
|
|
@@ -12552,7 +12562,7 @@
|
|
|
12552
12562
|
{
|
|
12553
12563
|
"path": "tests/ship_ci_deploy.mjs",
|
|
12554
12564
|
"mode": "0000644",
|
|
12555
|
-
"sha256": "
|
|
12565
|
+
"sha256": "83f8bfb0979b8950a8895eae7710981c19e30878b89fe648eb2dc2e8098c9de1"
|
|
12556
12566
|
},
|
|
12557
12567
|
{
|
|
12558
12568
|
"path": "tests/ship_error_shape.mjs",
|
|
@@ -213,7 +213,7 @@ export interface PostBlockersRequest { title: string; body_md?: string; source?:
|
|
|
213
213
|
export interface PostBlockersResponse { skipped?: boolean; reason?: unknown; blocker?: unknown }
|
|
214
214
|
export interface PostBoxCloseResponse { ok: boolean; queued: unknown; action: unknown; message: unknown }
|
|
215
215
|
export interface PostBoxEnsureResponse { state: unknown; action: unknown; queued: boolean; intent?: unknown }
|
|
216
|
-
export interface PostBoxHeartbeatRequest { claude_active?: boolean }
|
|
216
|
+
export interface PostBoxHeartbeatRequest { claude_active?: boolean; attached?: boolean }
|
|
217
217
|
export interface PostBoxHeartbeatResponse { ok: boolean; state: unknown }
|
|
218
218
|
export interface PostBoxHostKeysRequest { host_keys: string }
|
|
219
219
|
export interface PostBoxHostKeysResponse { ok: boolean; state: unknown; count: unknown }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ADR 0277 — A box is "in use" only while a human is attached, and the claim expires
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-10
|
|
5
|
+
- **Task:** [1003507](https://cloudbongos.com/builders#/task/1003507) (goal 1000095 — *Working area 6*, criterion `wa6-role-experience`)
|
|
6
|
+
- **Supersedes the "never touch a box with a claude process" rule** in [ADR 0031](<redacted>.md) §5(a); the park/preserve guarantee of tasks 1002726/1002727 is unchanged and is what makes this safe.
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
`sweep-idle` parks an `active` box whose heartbeat has gone quiet. Which boxes it may touch was decided by `selectIdleBoxes()`, and the rule was: skip any box with `claude_active = true`, at any age, plus skip anything whose `last_activity_at` is recent.
|
|
11
|
+
|
|
12
|
+
Both halves were satisfiable without a human being anywhere near the box.
|
|
13
|
+
|
|
14
|
+
1. **`claude_active` is a latch, not a level.** Only a heartbeat ping writes it, and `infra/box-heartbeat.sh` **exits without pinging** when it sees no activity. So silence — the very signal the sweep exists to act on — could never clear the flag. Reproduced against the shipped selector: a row with `claude_active = true` was skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not "reaped late": never.
|
|
15
|
+
2. **`load > 0.2` kept the activity clock fresh.** A running devcontainer plus an idle `claude` clears that floor on its own, so `last_activity_at` was bumped every 5 minutes indefinitely.
|
|
16
|
+
|
|
17
|
+
Either leg alone kept a box alive, which is why the incident looked inexplicable: `example-owner`'s box had been up since 2026-08-12 with a heartbeat every 5 minutes, while tmux session `otb` had been **unattached since 2026-08-15 18:01 UTC** and the `claude` inside it had burned 16 minutes of CPU across 25 hours of wall clock. An abandoned interactive `claude` sitting at its prompt is byte-for-byte indistinguishable from a working one. Cost: **$20.21** on one box, almost all of it unattended.
|
|
18
|
+
|
|
19
|
+
**A third defect made the whole sweep inert**, found while testing this fix and confirmed on clean `origin/main`: `loadDeps()` in `scripts/gds/box.js` opened with `if (_deps) return _deps;` and **`_deps` was never declared**. The assignment further down would have created a global in sloppy mode, but the read comes first, and reading an undeclared identifier is a `ReferenceError`. So `loadDeps()` threw on its first call, every time, and every command that resolves a DigitalOcean client died with it — including `sweep-idle --apply`, which builds that client *before* the park loop. **The idle sweep could not park any box at all.** No test caught it because the only `apply: true` test in the suite relied on the `claude_active` veto emptying the idle set before `makeDo()` was reached: the bug was hidden behind the other bug.
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
**A box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (default 12) without evidence that a human was actually attached to it.** One number bounds both proxies.
|
|
24
|
+
|
|
25
|
+
The heartbeat already computed the human signal and threw it away by folding it into one boolean. It now reports **`attached`** separately — a login session (`who`), an established inbound SSH connection, an open browser terminal (ttyd), or an **attached tmux client** (`#{session_attached}`, the signal that distinguishes this incident's box from a working one). The server stamps `last_attached_at` from it. `claude_active` keeps its meaning and its veto, but the veto now expires: `claude_active_since` records the `false→true` edge (`COALESCE`d across a run of true beats, so it measures the session and not the last 5 minutes), and past the cap the flag stops counting.
|
|
26
|
+
|
|
27
|
+
`pgrep -x claude` and the load floor stay as reasons the box **pings**; they are no longer accepted as evidence anyone is **there**. That distinction is the whole decision — a running process is not a person.
|
|
28
|
+
|
|
29
|
+
Both new clocks are cleared with `claude_active` at park, wake and deprovision, or a woken box would inherit an expired latch and be parked instantly — the fix reintroducing the bug from the far side.
|
|
30
|
+
|
|
31
|
+
### `NULL` means "no data", never "unattended"
|
|
32
|
+
|
|
33
|
+
A box still running the pre-1003507 heartbeat never sends `attached`, so its `last_attached_at` stays `NULL` forever. Treating that as unattended would park **every un-upgraded box mid-work**, and box source sync is not reliably prompt (idea 1000745 records a spell where a box sat 257 commits behind for three days). So `NULL` is read as unknown and the box keeps the old behaviour, and migration `core_238` deliberately **does not backfill** the column — a backfilled `now()` would start a clock nothing could ever advance and park the box one cap later.
|
|
34
|
+
|
|
35
|
+
`claude_active_since` takes the opposite default and **is** backfilled, because an unknown latch age *is* the forever-latch being fixed; after the migration a `true` flag with no stamp is unreachable, so refusing it costs nothing and closes the hole.
|
|
36
|
+
|
|
37
|
+
### Why 12 hours
|
|
38
|
+
|
|
39
|
+
Longer than any interactive session and long enough for a real overnight autonomous run. The asymmetry justifies the generosity: since task 1002726 the idle sweep **parks** — snapshot kept, filesystem preserved — so a false positive costs one wake, while no cap at all cost $20.21 on a single box. Tunable per instance via the env var.
|
|
40
|
+
|
|
41
|
+
## Consequences
|
|
42
|
+
|
|
43
|
+
- `sweep-idle` can park a box that claims `claude_active`. Its log names the clock that selected it (`unattended 21h (cap 12h) — abandoned session`) through a shared `idleReason()`, so the log cannot drift from the selection.
|
|
44
|
+
- **`sweep-idle --apply` works for the first time.** Fixing `_deps` was not optional scope: the selection fix is inert without it, since the sweep crashes before parking anything. A regression test now reaches dep resolution through the same `apply: true` path that hid it.
|
|
45
|
+
- A box nobody ever attaches to (a pure autonomous run on an upgraded heartbeat, `last_attached_at` still `NULL`) is bounded by the `claude_active_since` cap rather than by attachment. That is the intended fallback and the reason the claude clock kept its own bound rather than deferring entirely to attachment.
|
|
46
|
+
|
|
47
|
+
## Rejected
|
|
48
|
+
|
|
49
|
+
- **Tracking attachment *instead of* bounding `claude_active`** (the task's option (b), and the more honest-looking fix): it does not close the reported hole. A box that stops pinging keeps its last `claude_active = true` forever, because only a ping can clear it — so the forever-latch survives untouched, and the fix would have shipped against a stale-heartbeat fixture looking green.
|
|
50
|
+
- **Bounding `claude_active` alone** (options (a)/(c)): this is what was built first, and the verification probe caught it — the incident's heartbeat was *fresh*, so lifting the veto changed nothing and the box was still never idle. Recorded because it is the plausible-looking half-fix, and the task's own framing offered the three options as alternatives when the reported cost needs two of them.
|
|
51
|
+
- **Deleting the `load > 0.2` floor** so an abandoned box simply stops pinging: it is what makes an autonomous run count as use, and removing it would park legitimate unattended runs after `idleMinutes`.
|
|
52
|
+
- **Measuring the cap from `active_since`**: that is uptime. A box worked on continuously for days has an old `active_since` and would be parked mid-work.
|
|
53
|
+
- **Raising `BOX_IDLE_MINUTES`**: the reproduction shows no threshold reaches these boxes. The veto was unbounded, so the threshold was never the variable.
|
package/docs/adr/README.md
CHANGED
|
@@ -368,3 +368,4 @@ This keeps the decision history honest and traceable.
|
|
|
368
368
|
| 0274 | [**One kernel, three role packs: the always-loaded instructions stop being an engineer's** ([task 1002990](https://cloudbongos.com/builders#/task/1002990) · goal 1000095 — *Working area 6, Governor / Builder / Artist / Ideator experience*, criterion `wa6-kernel-and-packs`). The root `CLAUDE.md` loads in full every session for every craft, and it was an engineer's document: 250 lines / 32,422 chars of claim-before-working, worktree-per-claim, `DEPS_NOT_SHIPPED`, `touches[]` and `/merge-mode`. An ideator and an artist each paid the full token cost of a pipeline they were not going to run and read it as instructions addressed to them — and it CONTRADICTED the same goal's `wa6-drift-stops-the-session`, which says ideation *"needs no claim"*, while the kernel's hard rule said every change is backed by one. The craft-specific half lived in three SKILLS (`/dev`, `/ideate`, `/paint`), the wrong carrier twice over: a skill's description is resident in every session's listing whatever craft is running (27,022 chars against a ~8,000 budget, three of them over-length), and a skill must be INVOKED — a session that never types the command works from the kernel alone, which is the engineer's document again. **Decision: a role-neutral kernel plus one pack per craft under `docs/packs/`, bound by a registry CI checks.** The kernel keeps what is true for every craft (the lifecycle, the money, the trust boundary, the identity split, the charter, navigation) and drops to 216 lines / 22,572 chars, clearing the character target it had been over; the per-line test gains a second half — *is it true for every craft?* The Engineer pack inherits today's methodology; the Ideator pack absorbs `/ideate` and writes down the two grades the criterion names (a quick idea stays ONE LINE, then Claude ASKS whether to develop it into a Full Idea — five questions drawn out of the conversation, in the ideator's own words, scored on completeness); the Artist pack absorbs `/paint`, generalised off the pixel-art pipeline per [ADR 0272](<redacted>.md) — show-first, as little interpretation as possible, and ARRIVE PRECALCULATED, because a session that opens with intake questions is a form. The three skills are deleted. `scripts/gds/discipline-modes.json` becomes the ROLE REGISTRY: a core craft names a `pack` (a file to read), a module-contributed discipline may still name a `skill` (`ui` → `/design`, unchanged as ADR 0272 requires), and `claim.js` prints whichever with the right verb. **`scripts/gds/role-pack-guard.js` HARD-FAILS CI** on a pack path that resolves to nothing, an entry with neither pointer, fewer than three packs, or a pack over budget (16,000 hard / 13,000 target) — hard precisely BECAUSE `claim.js` fails open: a broken path degrades to silence, the role is hollow, and no error says so, so CI is the only gate ("CI budgets each pack"). `docs/packs/` over `.claude/packs/` because `docs/` is already owned by the `methodology` scope key — a new top-level path resolves to NO key, and the scope map's own comment records the cost (a missing key is an empty wall, and goal-scope-check then denies every member-authored task); it is registered explicitly in the default-deny publish allowlist, in lockstep with the registry, or a released core would print an unresolvable pack path on every claim. `ship-card.js` moved with the claim (it would have shipped an "Open the work" button firing a deleted command — the 1003487 divergence in reverse) and `tests/idea_ideate_full.mjs` moved with its subject, its thirteen Full-Idea assertions passing against the pack unchanged. Governor is deliberately DEFERRED — three packs, not four, until the owner decides; the guard floors at three. Rejected: keeping the skills beside the packs (two homes drift, and the listing cost stays); packs AS skills (the exact failure being fixed); duplicating the safety rules into all three packs (the charter forbids it — three copies drift and the kernel becomes the one nobody trusts); leaving the guard advisory like the doc-comment check (that one gates a judgement, this one gates a resolvable path). **Not done:** the kernel is 216 lines against a ~200 target — still a warning, as it was at 250; the line count is the context-budget cluster's work (tasks 1003620/1003687/1003688), which the goal's audit sequenced behind this one.](<redacted>.md) | methodology / roles / context budget |
|
|
369
369
|
| 0275 | [**A role's written responsibility has one source, and the pack's copy is generated** ([task 1003732](https://cloudbongos.com/builders#/task/1003732) · goal 1000095 — *Working area 6*, criterion `wa6-written-responsibilities`; statements fixed by the area owner 2026-09-08). The criterion is one sentence — *"one source, shown on the profile, injected into the pack, referenced by grading"* — and before this task the three statements existed only in the criterion record, nowhere in the tree. The interesting half is why ONE source and not three good copies: if each consumer holds its own, they drift, and the drift has a specific unfair shape — the project shows a builder one standard on their profile, teaches their session a second, and grades the shipped work against a third, so someone is judged by a rule they were never shown. **Decision: a frozen map at `src/role-responsibilities.js` (verbatim owner text, imports nothing), read by three consumers, with the one copy that CAN drift generated and CI-gated.** The hall reads it through `clientModules()` — already injected into every served page as `window.__MODULES__` and already carrying the discipline roster the statements are keyed by, so the profile renders with no new route, no fetch and no copy. The grader reads `responsibilityFor()` through the module doorway and states the standard in the prompt as the project's, not its own. The PACKS get a generated block: markdown cannot require a JS module, so their copy is the only one that can go stale — and it is the worst to lose, being what a session is actually instructed by. `gen-role-responsibilities.js` writes it from the ROLE REGISTRY (so a renamed pack, or Governor when the owner decides, needs no edit), and `role-pack-guard.js` fails CI on drift. Three consequences worth the record. (1) `fitness.js` needed an EXEMPTION for the owner's own prose: the ideator's statement opens "Make good ideas" and `ideas` is a module key, so the no-module-keys scan flagged an English word — paraphrasing to satisfy a scanner is precisely what the task forbids, so the reason is written down and the file's import DIRECTION is still guarded. (2) Registering the freshness check inside `checkGeneratedArtifactsFresh` pushed `fitness.js` one line past its 1,500-line ratchet; rather than raise the baseline (which the check forbids in its own message) the assertion MOVED into `role-pack-guard.js`, which already owns the packs — one check now answers "are the packs correct". (3) The generator matches each pack's own line ending, because `.md` checks out CRLF on Windows and LF in CI, and a generator that always wrote `\n` would make the gate red on one checkout and green on the other for a file nobody touched. A craft with no statement (`ui`, module-contributed per [ADR 0272](<redacted>.md); Governor, deferred per [ADR 0274](<redacted>.md)) renders NOTHING everywhere — no empty quote, no grader line — because a grader told a role has no written standard would supply its own. The profile's avatar was repinned to the top row (the head grid is `align-items:center`, fine for three short lines, wrong once the block made the column tall); verified in both themes in the hall preview. `tests/role_responsibilities.mjs` asserts the criterion's actual content by sweeping `src/`, `scripts/`, `modules/`, `tests/` and `docs/packs/` for any second copy. Rejected: three hand-written paragraphs (the drift would be invisible); a dedicated route for the hall (a fetch and a failure mode for static text already on a rail); storing the statements in the DB (not live state — a row puts the project's written standard outside code review); letting the grader describe a craft from its name (the invented standard this removes); raising `oversized_file_count` to let `fitness.js` grow.](<redacted>.md) | roles / grading / generated artifacts |
|
|
370
370
|
| 0276 | [**The skill-listing budget cannot hold 64 skills and every trigger, so the number is ratcheted and the choice goes to the owner** ([task 1003620](https://cloudbongos.com/builders#/task/1003620) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). Every SKILL.md description is resident in EVERY session for every craft, and `skill-lint` budgets the whole listing at 8,000 chars. It has been trimmed twice ([#1003548], [#1003587]) and grown back both times, because the 400-char per-description aim was a WARNING THAT ACCUMULATES. This task rewrote 49 of the 50 `.claude/skills/` descriptions — 17 warnings to 0, listing 25,624 → 19,316 chars, about 1,570 tokens returned to every session — with every trigger phrase preserved and CHECKED MECHANICALLY (a checker diffed the quoted phrases against HEAD and caught five real losses, all restored; four dropped strings were UI labels, not triggers). **8,000 was not reached, and the arithmetic says it cannot be:** across 64 skills the names (880) plus the mandatory quoted triggers (~5,370) are an irreducible ~6,250, leaving ~27 chars per skill to say what the skill DOES. Reaching the budget means trigger-only descriptions — trading truncation for the loss of the semantic signal routing leans on when the user’s words do not literally match a trigger. **Decision: ratchet `skill_listing_chars` at 19,316** in `fitness-ratchets.js` (fails open if the linter cannot load), so it cannot grow while the real question is open; the baseline is deliberately ABOVE skill-lint’s budget and is not a claim the budget is met. **Open and owner-gated:** delist skills (64 is a menu), scale the budget with the roster, or accept trigger-only text — recommendation is delist then rescale, filed as a blocker. `fitness.js`’s over-budget warning STAYS, as the visible trace of that question. Rejected: raising `LISTING_BUDGET_CHARS` to silence the warning (deletes the signal, not the debt); re-cutting the 14 module ui-design descriptions a week after [#1003587] wrote them, for ~2,000 chars toward a target still 9,000 away.](<redacted>.md) | skills / context budget / routing |
|
|
371
|
+
| 0277 | [**A box is "in use" only while a human is attached, and the claim expires** ([task 1003507](https://cloudbongos.com/builders#/task/1003507) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). `sweep-idle` skipped any box with `claude_active = true` at ANY age, and `claude_active` is a LATCH, not a level: only a heartbeat ping writes it, and `infra/box-heartbeat.sh` exits WITHOUT pinging when it sees nothing — so silence, the very signal the sweep exists to act on, could never clear it. Reproduced against the shipped selector: skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not reaped late; never. The second leg is that `load > 0.2` kept `last_activity_at` bumping every 5 minutes anyway (a devcontainer plus an idle `claude` clears that floor on its own), so EITHER leg alone kept a box alive — which is why bounding only the veto looks like a fix and is not: the incident box's heartbeat was FRESH. Measured: `example-owner`'s box up since 2026-08-12, tmux `otb` UNATTACHED since 2026-08-15 18:01 UTC, `claude` burning 16 min of CPU across 25h of wall clock, **$20.21** of mostly-unattended compute. A THIRD defect, found while testing this and confirmed on clean `origin/main`, made the sweep inert regardless: `loadDeps()` read `_deps` before anything declared it, so it threw `ReferenceError` on first call and every command resolving a DigitalOcean client went with it — including `sweep-idle --apply`, which builds that client before the park loop. **The idle sweep could not park any box at all**, hidden because the suite's only `apply: true` test relied on the veto emptying the idle set before `makeDo()` was reached: one bug shielded by the other. **Decision: a box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (12) without evidence a HUMAN was attached.** The heartbeat already computed that signal and folded it into one boolean; it now reports `attached` separately (login session, inbound SSH, open ttyd, or an ATTACHED tmux client via `#{session_attached}` — the signal that separates this box from a working one) and the server stamps `last_attached_at`. `claude_active` keeps its veto but it EXPIRES, bounded by a `claude_active_since` edge stamp. `pgrep -x claude` and the load floor remain reasons the box PINGS, never evidence anyone is THERE — a running process is not a person, and that distinction is the whole decision. The two `NULL` defaults deliberately DISAGREE: `last_attached_at` NULL means "no data" and keeps a pre-1003507 box on the old behaviour (core_238 pointedly does NOT backfill it — a backfilled `now()` starts a clock nothing can advance and parks every un-upgraded box one cap later, and box source sync is not prompt: idea 1000745 records 257 commits behind for three days), while `claude_active_since` NULL is REFUSED because an unknown latch age is the forever-latch itself, and is backfilled so the state is unreachable after deploy. 12h because parking is reversible since task 1002726 (snapshot kept), so a false positive costs one wake against $20.21 for no cap. Both clocks clear at park/wake/deprovision, or a woken box inherits an expired latch and is parked instantly — the fix reintroducing the bug from the far side. Rejected: tracking attachment INSTEAD of bounding the latch (the silent box keeps `true` forever, so the reported hole survives); bounding the latch alone (built first, and the verification probe caught it — the fresh heartbeat meant lifting the veto changed nothing); deleting the load floor (it is what makes an autonomous run count); measuring from `active_since` (that is uptime — parks a box worked on for days); raising `BOX_IDLE_MINUTES` (no threshold reaches an unbounded veto).](<redacted>.md) | dev box / cost / idle sweep |
|
package/docs/api/openapi.json
CHANGED
|
@@ -1775,7 +1775,7 @@
|
|
|
1775
1775
|
"box"
|
|
1776
1776
|
],
|
|
1777
1777
|
"summary": "POST /box/heartbeat",
|
|
1778
|
-
"description": "POST /box/heartbeat — the box reports it is alive, resetting the idle clock. rank: any authenticated builder (own resource write). Optional body: { claude_active: bool
|
|
1778
|
+
"description": "POST /box/heartbeat — the box reports it is alive, resetting the idle clock. rank: any authenticated builder (own resource write). Optional body: { claude_active: bool, attached: bool }. `claude_active` records whether a `claude` process is running; the idle sweep honours it as a veto, but since task 1003507 only for BOX_UNATTENDED_MAX_HOURS (a running process is not a person, and an unbounded veto pinned boxes active forever). `attached` is the human signal — a login session, an inbound SSH connection, an open browser terminal, or an attached tmux client — and stamps last_attached_at, the clock that cap is measured against. A heartbeat that omits `attached` (any box still running the pre-1003507 script) leaves the column NULL, which the sweep reads as \"no data\" and not as \"unattended\". Heartbeats without a body behave as before (last_activity_at bumped only). task 919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
1779
1779
|
"x-rank": "any-builder",
|
|
1780
1780
|
"x-source": "modules/dev-box/routes/box.js",
|
|
1781
1781
|
"requestBody": {
|
|
@@ -19399,6 +19399,9 @@
|
|
|
19399
19399
|
"properties": {
|
|
19400
19400
|
"claude_active": {
|
|
19401
19401
|
"type": "boolean"
|
|
19402
|
+
},
|
|
19403
|
+
"attached": {
|
|
19404
|
+
"type": "boolean"
|
|
19402
19405
|
}
|
|
19403
19406
|
},
|
|
19404
19407
|
"additionalProperties": true
|
package/docs/api-reference.md
CHANGED
|
@@ -91,7 +91,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
91
91
|
| GET | `/api/bongos/box/connect.ps1` | `public` | — | GET /box/connect.ps1 — the Windows (PowerShell) bootstrap script. |
|
|
92
92
|
| GET | `/api/bongos/box/connect.sh` | `public` | — | GET /box/connect.sh — the macOS/Linux bootstrap script. |
|
|
93
93
|
| POST | `/api/bongos/box/ensure` | `any-builder` | — | POST /box/ensure — "make my box ready". |
|
|
94
|
-
| POST | `/api/bongos/box/heartbeat` | `any-builder` | `claude_active` | POST /box/heartbeat — the box reports it is alive, resetting the idle clock. |
|
|
94
|
+
| POST | `/api/bongos/box/heartbeat` | `any-builder` | `claude_active`, `attached` | POST /box/heartbeat — the box reports it is alive, resetting the idle clock. |
|
|
95
95
|
| POST | `/api/bongos/box/host-keys` | `any-builder` | `host_keys` | POST /box/host-keys — the box publishes its own PUBLIC SSH host keys (task 1187 / idea 235). |
|
|
96
96
|
| GET | `/api/bongos/box/managed-settings` | `any-builder` | — | GET /box/managed-settings — the caller's Claude Desktop managed-settings.json. |
|
|
97
97
|
| GET | `/api/bongos/box/me` | `any-builder` | — | GET /box/me — the caller's own box. |
|
|
@@ -1749,5 +1749,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1749
1749
|
landed since 1.19.648 with no explicit bump. run 34523111338. (task 1002620)
|
|
1750
1750
|
1.19.650 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1751
1751
|
landed since 1.19.649 with no explicit bump. run 34523753119. (task 1002620)
|
|
1752
|
+
1.19.651 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1753
|
+
landed since 1.19.650 with no explicit bump. run 34526936330. (task 1002620)
|
|
1754
|
+
1.19.652 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1755
|
+
landed since 1.19.651 with no explicit bump. run 34529655648. (task 1002620)
|
|
1752
1756
|
---------------------------------------------------------------------------
|
|
1753
1757
|
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- migration core_238: bound how long a dev box may claim to be in use
|
|
2
|
+
-- (task 1003507).
|
|
3
|
+
--
|
|
4
|
+
-- WHY: a box could assert "in use" forever from two proxies, neither of which
|
|
5
|
+
-- implies a human is present.
|
|
6
|
+
--
|
|
7
|
+
-- 1. claude_active is a LATCH, not a level. Only a heartbeat ping writes it,
|
|
8
|
+
-- and infra/box-heartbeat.sh exits WITHOUT pinging when it sees no
|
|
9
|
+
-- activity - so silence, the very signal the idle sweep exists to act on,
|
|
10
|
+
-- could never clear it. selectIdleBoxes() skipped claude_active=true rows
|
|
11
|
+
-- with no time bound, so such a box was unreapable at EVERY threshold.
|
|
12
|
+
-- 2. `load > 0.2` kept last_activity_at bumping, so the activity clock never
|
|
13
|
+
-- went stale either. A running devcontainer plus an idle `claude` clears
|
|
14
|
+
-- that floor on its own.
|
|
15
|
+
--
|
|
16
|
+
-- Together they made an abandoned interactive `claude` at its prompt in an
|
|
17
|
+
-- UNATTACHED tmux session indistinguishable from a working one. Measured: one
|
|
18
|
+
-- box unattended since 2026-08-15 burned 20.21 USD of compute the sweep could
|
|
19
|
+
-- not reach.
|
|
20
|
+
--
|
|
21
|
+
-- Two columns, because the fix needs two clocks:
|
|
22
|
+
-- claude_active_since - when claude_active last went false->true, so the veto
|
|
23
|
+
-- can be bounded by how long it has been claimed.
|
|
24
|
+
-- last_attached_at - when a HUMAN was last actually attached (a login
|
|
25
|
+
-- session, an inbound SSH connection, an open browser
|
|
26
|
+
-- terminal, or an attached tmux client). The heartbeat
|
|
27
|
+
-- already computed this and folded it into one boolean;
|
|
28
|
+
-- it now reports it separately.
|
|
29
|
+
--
|
|
30
|
+
-- Idempotent: ADD COLUMN IF NOT EXISTS, and the backfill is guarded on IS NULL
|
|
31
|
+
-- so a fresh restore + replay never moves a stamp later beats have written.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
ALTER TABLE builder_boxes
|
|
36
|
+
ADD COLUMN IF NOT EXISTS claude_active_since timestamptz;
|
|
37
|
+
|
|
38
|
+
ALTER TABLE builder_boxes
|
|
39
|
+
ADD COLUMN IF NOT EXISTS last_attached_at timestamptz;
|
|
40
|
+
|
|
41
|
+
-- Backfill claude_active_since ONLY. A box that is claude_active right now has
|
|
42
|
+
-- no recorded latch age, and refusing an unknown age would park boxes that are
|
|
43
|
+
-- genuinely in use the moment this deploys. Stamp now() so every live box gets
|
|
44
|
+
-- one full grace window; the next heartbeat keeps it honest from there.
|
|
45
|
+
UPDATE builder_boxes
|
|
46
|
+
SET claude_active_since = now()
|
|
47
|
+
WHERE claude_active = true
|
|
48
|
+
AND claude_active_since IS NULL;
|
|
49
|
+
|
|
50
|
+
-- last_attached_at is deliberately NOT backfilled. A box still running a
|
|
51
|
+
-- heartbeat from before this change never reports attachment, so its column
|
|
52
|
+
-- stays NULL forever - and selectIdleBoxes reads NULL as "no attachment data"
|
|
53
|
+
-- rather than "unattended", which is what keeps such a box on the old behaviour
|
|
54
|
+
-- instead of parking it mid-work. Backfilling now() would instead start a clock
|
|
55
|
+
-- that nothing can ever advance, and park every un-upgraded box after the cap.
|
|
56
|
+
|
|
57
|
+
COMMIT;
|
package/modules/dev-box/boxes.js
CHANGED
|
@@ -164,22 +164,100 @@ function effectiveActivityMs(row) {
|
|
|
164
164
|
return toMs(row.last_activity_at) ?? toMs(row.active_since) ?? toMs(row.provisioned_at);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
167
|
+
// ---- the unattended cap (task 1003507) ------------------------------------
|
|
168
|
+
//
|
|
169
|
+
// A box used to be able to assert "in use" forever from two proxies, neither of
|
|
170
|
+
// which implies a human is present:
|
|
171
|
+
//
|
|
172
|
+
// 1. `pgrep -x claude` -> claude_active=true, which selectIdleBoxes skipped
|
|
173
|
+
// with NO time bound. claude_active is a LATCH, not a level: only a
|
|
174
|
+
// heartbeat ping writes it, and box-heartbeat.sh exits WITHOUT pinging when
|
|
175
|
+
// it sees nothing — so silence, the very signal the sweep exists to act on,
|
|
176
|
+
// could never clear it. Unreapable at EVERY threshold, forever.
|
|
177
|
+
// 2. `load > 0.2` -> ACTIVE=1, which keeps bumping last_activity_at. A running
|
|
178
|
+
// devcontainer plus an idle `claude` clears that floor on its own, so the
|
|
179
|
+
// activity clock never went stale either.
|
|
180
|
+
//
|
|
181
|
+
// Together they made an abandoned interactive `claude` at its prompt in an
|
|
182
|
+
// UNATTACHED tmux session indistinguishable from a working one, and one such box
|
|
183
|
+
// burned $20.21 of unattended compute the sweep could not reach. Bounding only
|
|
184
|
+
// (1) does not stop the bleeding, because (2) keeps the box out of the idle set
|
|
185
|
+
// on its own — so both proxies are bounded by the same policy:
|
|
186
|
+
//
|
|
187
|
+
// A box may not stay active for longer than `unattendedMaxHours` without
|
|
188
|
+
// evidence that a human was actually attached to it.
|
|
189
|
+
//
|
|
190
|
+
// "Attached" is what the heartbeat already knew and used to throw away by
|
|
191
|
+
// folding it into one boolean: a login session (`who`), an established inbound
|
|
192
|
+
// SSH connection, an open browser terminal (ttyd), or an ATTACHED tmux client.
|
|
193
|
+
// It now reports that separately and the server stamps `last_attached_at`.
|
|
194
|
+
const HOUR_MS = 3_600_000;
|
|
195
|
+
|
|
196
|
+
// Has a human been attached recently enough for this box's claims to stand?
|
|
197
|
+
// - no cap configured -> yes (the pre-1003507 unbounded behaviour;
|
|
198
|
+
// the policy lives in the caller's CONFIG)
|
|
199
|
+
// - last_attached_at unset -> yes. A box whose heartbeat predates this change
|
|
200
|
+
// never reports attachment, so its column stays
|
|
201
|
+
// NULL forever — treating unknown as unattended
|
|
202
|
+
// would park every such box mid-work. NULL is
|
|
203
|
+
// deliberately NOT backfilled for that reason.
|
|
204
|
+
// - within the cap -> yes
|
|
205
|
+
// - older than the cap -> no; the box is unattended
|
|
206
|
+
function attendedRecently(row, { nowMs, unattendedMaxHours } = {}) {
|
|
207
|
+
if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
|
|
208
|
+
const at = toMs(row.last_attached_at);
|
|
209
|
+
if (at == null) return true;
|
|
210
|
+
return at > nowMs - unattendedMaxHours * HOUR_MS;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Does claude_active still earn this box a pass from the idle sweep? Bounded by
|
|
214
|
+
// how long it has been claimed, read off `claude_active_since` (migration
|
|
215
|
+
// core_238, stamped on the false->true edge so it measures the whole session
|
|
216
|
+
// rather than the last beat). An unknown age is refused: core_238 backfills every
|
|
217
|
+
// box that is claude_active at deploy, so a NULL stamp under a true flag is the
|
|
218
|
+
// forever-latch this fixes, not a live session.
|
|
219
|
+
function claudeVetoHolds(row, { nowMs, unattendedMaxHours } = {}) {
|
|
220
|
+
if (!row.claude_active) return false;
|
|
221
|
+
if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
|
|
222
|
+
const since = toMs(row.claude_active_since);
|
|
223
|
+
if (since == null) return false;
|
|
224
|
+
return since > nowMs - unattendedMaxHours * HOUR_MS;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Idle = active boxes that are either (a) quiet past idleMinutes with no live
|
|
228
|
+
// claude_active veto, or (b) still chattering but unattended past the cap. (b) is
|
|
229
|
+
// the abandoned-session case: the heartbeat is fresh, so (a) can never fire.
|
|
230
|
+
// Pure: caller passes the candidate rows and nowMs; returns the subset to park.
|
|
231
|
+
function selectIdleBoxes(rows, { idleMinutes, nowMs, unattendedMaxHours } = {}) {
|
|
172
232
|
if (!Number.isFinite(idleMinutes) || idleMinutes <= 0) return [];
|
|
173
233
|
if (!Number.isFinite(nowMs)) return [];
|
|
174
234
|
const cutoff = nowMs - idleMinutes * 60_000;
|
|
175
235
|
return (rows || []).filter((r) => {
|
|
176
236
|
if (r.state !== 'active') return false;
|
|
177
|
-
if (r
|
|
237
|
+
if (!attendedRecently(r, { nowMs, unattendedMaxHours })) return true;
|
|
238
|
+
if (claudeVetoHolds(r, { nowMs, unattendedMaxHours })) return false;
|
|
178
239
|
const a = effectiveActivityMs(r);
|
|
179
240
|
return a != null && a <= cutoff;
|
|
180
241
|
});
|
|
181
242
|
}
|
|
182
243
|
|
|
244
|
+
// Why the sweep picked a box, for the operator log. Mirrors selectIdleBoxes'
|
|
245
|
+
// branches exactly — if these two ever disagree the log is lying, so they are
|
|
246
|
+
// read together.
|
|
247
|
+
function idleReason(row, { nowMs, idleMinutes, unattendedMaxHours } = {}) {
|
|
248
|
+
if (!attendedRecently(row, { nowMs, unattendedMaxHours })) {
|
|
249
|
+
const h = Math.round((nowMs - toMs(row.last_attached_at)) / HOUR_MS);
|
|
250
|
+
return `unattended ${h}h (cap ${unattendedMaxHours}h) — abandoned session`;
|
|
251
|
+
}
|
|
252
|
+
const mins = Math.round((nowMs - (effectiveActivityMs(row) || nowMs)) / 60_000);
|
|
253
|
+
if (row.claude_active) {
|
|
254
|
+
const since = toMs(row.claude_active_since);
|
|
255
|
+
const held = since == null ? 'no claude_active_since recorded' : `claude_active ${Math.round((nowMs - since) / HOUR_MS)}h`;
|
|
256
|
+
return `idle ${mins}m, ${held} past the ${unattendedMaxHours}h cap`;
|
|
257
|
+
}
|
|
258
|
+
return `idle ${mins}m`;
|
|
259
|
+
}
|
|
260
|
+
|
|
183
261
|
// Dormant = parked boxes that have been parked longer than dormantDays. These
|
|
184
262
|
// get fully de-provisioned (snapshot deleted too) to reclaim the last cents.
|
|
185
263
|
// Since task 1002726 this is the ONLY automated path that destroys a snapshot —
|
|
@@ -469,7 +547,7 @@ async function recordEvent(db, { boxId, builderId, event, detail, costUsd, actor
|
|
|
469
547
|
// active box, so logging one per beat would grow the ledger unbounded
|
|
470
548
|
// (~100K rows/builder/year). last_activity_at IS the heartbeat record; box_events
|
|
471
549
|
// is reserved for the rare lifecycle transitions (provision/park/wake/deprovision/cost).
|
|
472
|
-
async function bumpActivity(db, builderId, { claudeActive } = {}) {
|
|
550
|
+
async function bumpActivity(db, builderId, { claudeActive, attached } = {}) {
|
|
473
551
|
const hasClaude = typeof claudeActive === 'boolean';
|
|
474
552
|
const claudeCol = hasClaude ? ', claude_active = $2' : '';
|
|
475
553
|
// The FIRST time a box reports a live `claude` process, stamp
|
|
@@ -481,10 +559,29 @@ async function bumpActivity(db, builderId, { claudeActive } = {}) {
|
|
|
481
559
|
const connectedCol = (hasClaude && claudeActive === true)
|
|
482
560
|
? ', first_connected_at = COALESCE(first_connected_at, now())'
|
|
483
561
|
: '';
|
|
562
|
+
// task 1003507 — claude_active_since is the EDGE, not the level: stamped when
|
|
563
|
+
// the flag goes false->true (COALESCE keeps the original edge across a run of
|
|
564
|
+
// true beats, so the latch measures the whole session, not the last 5 minutes)
|
|
565
|
+
// and cleared on a false beat so the next session starts a fresh clock. Without
|
|
566
|
+
// this the flag has no age, and selectIdleBoxes cannot bound its own veto.
|
|
567
|
+
const sinceCol = hasClaude
|
|
568
|
+
? (claudeActive === true
|
|
569
|
+
? ', claude_active_since = COALESCE(claude_active_since, now())'
|
|
570
|
+
: ', claude_active_since = NULL')
|
|
571
|
+
: '';
|
|
572
|
+
// task 1003507 — `attached` is the HUMAN signal (a login session, an inbound
|
|
573
|
+
// SSH connection, an open browser terminal, or an attached tmux client), kept
|
|
574
|
+
// separate from claude_active because a running process is not a person. Only
|
|
575
|
+
// a true beat writes it: last_attached_at is a high-water mark, so a beat that
|
|
576
|
+
// reports nobody attached leaves the last real attachment where it was and the
|
|
577
|
+
// unattended clock keeps running. A box whose heartbeat predates this change
|
|
578
|
+
// never sends the field, leaving the column NULL, which selectIdleBoxes reads
|
|
579
|
+
// as "no data" rather than "unattended".
|
|
580
|
+
const attachedCol = attached === true ? ', last_attached_at = now()' : '';
|
|
484
581
|
const params = hasClaude ? [builderId, claudeActive] : [builderId];
|
|
485
582
|
const { rows } = await db.query(
|
|
486
583
|
`UPDATE builder_boxes
|
|
487
|
-
SET last_activity_at = now(), updated_at = now()${claudeCol}${connectedCol}
|
|
584
|
+
SET last_activity_at = now(), updated_at = now()${claudeCol}${connectedCol}${sinceCol}${attachedCol}
|
|
488
585
|
WHERE builder_id = $1 AND state = 'active'
|
|
489
586
|
RETURNING state`,
|
|
490
587
|
params
|
|
@@ -516,6 +613,10 @@ const SETTABLE_BOX_COLUMNS = new Set([
|
|
|
516
613
|
'scope', 'provisioned_rank', 'region', 'size_slug', 'droplet_id', 'droplet_name',
|
|
517
614
|
'snapshot_id', 'ip', 'hostname', 'last_activity_at', 'provisioned_at',
|
|
518
615
|
'active_since', 'parked_at', 'destroyed_at', 'error_note', 'claude_active',
|
|
616
|
+
// task 1003507 — park/wake/deprovision clear the claude_active latch and its
|
|
617
|
+
// clock together. A woken box that kept a stale claude_active_since would be
|
|
618
|
+
// instantly reapable, which is the fix reintroducing the bug from the far side.
|
|
619
|
+
'claude_active_since', 'last_attached_at',
|
|
519
620
|
// host_keys/host_keys_at are normally written by setBoxHostKeys(), but
|
|
520
621
|
// deprovisionOne() clears them on teardown so a rebuilt box never serves its
|
|
521
622
|
// predecessor's SSH identity (idea 310) — hence they are settable here too.
|
|
@@ -889,7 +990,10 @@ module.exports = {
|
|
|
889
990
|
storageCostUsd,
|
|
890
991
|
summarizeBoxCost,
|
|
891
992
|
// pure — selection
|
|
993
|
+
attendedRecently,
|
|
994
|
+
claudeVetoHolds,
|
|
892
995
|
effectiveActivityMs,
|
|
996
|
+
idleReason,
|
|
893
997
|
selectIdleBoxes,
|
|
894
998
|
selectDormantBoxes,
|
|
895
999
|
selectDormantWarnBoxes,
|
|
@@ -633,21 +633,29 @@ module.exports = function buildBoxRouter() {
|
|
|
633
633
|
|
|
634
634
|
// POST /box/heartbeat — the box reports it is alive, resetting the idle clock.
|
|
635
635
|
// rank: any authenticated builder (own resource write). Optional body:
|
|
636
|
-
// { claude_active: bool
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
636
|
+
// { claude_active: bool, attached: bool }.
|
|
637
|
+
//
|
|
638
|
+
// `claude_active` records whether a `claude` process is running; the idle sweep
|
|
639
|
+
// honours it as a veto, but since task 1003507 only for BOX_UNATTENDED_MAX_HOURS
|
|
640
|
+
// (a running process is not a person, and an unbounded veto pinned boxes active
|
|
641
|
+
// forever). `attached` is the human signal — a login session, an inbound SSH
|
|
642
|
+
// connection, an open browser terminal, or an attached tmux client — and stamps
|
|
643
|
+
// last_attached_at, the clock that cap is measured against. A heartbeat that
|
|
644
|
+
// omits `attached` (any box still running the pre-1003507 script) leaves the
|
|
645
|
+
// column NULL, which the sweep reads as "no data" and not as "unattended".
|
|
646
|
+
// Heartbeats without a body behave as before (last_activity_at bumped only).
|
|
640
647
|
// #919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.
|
|
641
648
|
router.post('/box/heartbeat', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
|
|
642
649
|
// strict:false — heartbeat is a machine ping from SHIPPED box clients that
|
|
643
650
|
// can't update in lockstep with the server (the ADR-0117 un-updatable-consumer
|
|
644
651
|
// concern). Validate the known field's type, but tolerate a future/extra field
|
|
645
652
|
// rather than reject a resilience-critical ping (the idle sweep keys off it).
|
|
646
|
-
if (validateOrRespond(req, res, { claude_active: { type: 'boolean' } }, { strict: false })) return;
|
|
653
|
+
if (validateOrRespond(req, res, { claude_active: { type: 'boolean' }, attached: { type: 'boolean' } }, { strict: false })) return;
|
|
647
654
|
try {
|
|
648
655
|
const body = req.body || {};
|
|
649
656
|
const claudeActive = typeof body.claude_active === 'boolean' ? body.claude_active : undefined;
|
|
650
|
-
const
|
|
657
|
+
const attached = typeof body.attached === 'boolean' ? body.attached : undefined;
|
|
658
|
+
const state = await boxes.bumpActivity(pool, req.builder.id, { claudeActive, attached });
|
|
651
659
|
res.json({ ok: true, state: state || 'none' });
|
|
652
660
|
} catch (err) {
|
|
653
661
|
log.error('[gds] POST /box/heartbeat', err);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.652",
|
|
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.652",
|
|
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.652",
|
|
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",
|