@bongos/core 1.20.60 → 1.20.61

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 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.20.60",
6
- "core_contract": "1.20.60",
7
- "source_commit": "1c5e4ffeb15746f66de752df791062c4e2d8db52",
5
+ "core_version": "1.20.61",
6
+ "core_contract": "1.20.61",
7
+ "source_commit": "dfbe913b447708d16a0e3bd1b01f22a3423078ca",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T07:32:53.914Z",
9
+ "built_at": "2026-10-01T10:03:11.617Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 572,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2779,
14
+ "functional_verbatim": 2781,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3379,
20
- "tree_sha256": "e38bb61977874c88e1e344e1f977fcf6b8e8343eaeca0e52f68ae46701b33c4b",
19
+ "file_count": 3381,
20
+ "tree_sha256": "de019aef7d57c2169e2176dd4ee7d0461fbd0c1ec64e475bbca8b3afc13c9eae",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -337,7 +337,7 @@
337
337
  {
338
338
  "path": "clients/bongos-client/index.d.ts",
339
339
  "mode": "0000644",
340
- "sha256": "b6b2151a51f9d0da572ec9381110c187e9ff257c4611f9a3037600e2fff45b94"
340
+ "sha256": "d4bb4d0fc24c4ad077f939eb4e899d6b0eeeceef7d922ef35ce55bb2c8db4c41"
341
341
  },
342
342
  {
343
343
  "path": "clients/bongos-client/index.mjs",
@@ -2262,12 +2262,12 @@
2262
2262
  {
2263
2263
  "path": "docs/api-reference.md",
2264
2264
  "mode": "0000644",
2265
- "sha256": "7c1cd2f16f6b0cf1122c3ef3dc1fae1dc4cd772a3171f8493c51824350ce3958"
2265
+ "sha256": "f527f77fcc1957831e89e46991a2916966dca81ffcf5a63832bf3cb6a23ef38f"
2266
2266
  },
2267
2267
  {
2268
2268
  "path": "docs/api/openapi.json",
2269
2269
  "mode": "0000644",
2270
- "sha256": "7c8f74463c832b0177b2e92535c3056eab14c383ebbaceca6cffc935ba3b6191"
2270
+ "sha256": "4411ebb2e8bf4dbdd6d403671af60d030294a9171807e466b611acb3274eda2b"
2271
2271
  },
2272
2272
  {
2273
2273
  "path": "docs/architecture.md",
@@ -2292,12 +2292,12 @@
2292
2292
  {
2293
2293
  "path": "docs/copy-inventory.md",
2294
2294
  "mode": "0000644",
2295
- "sha256": "4088ded5782ee2f7f814e77429d02bf80d7332b232012548bd2726fecf5532ee"
2295
+ "sha256": "2daaf44779e2c5870d95d87f76a1e03f96d51e9e24e49ba88dedafde3b041554"
2296
2296
  },
2297
2297
  {
2298
2298
  "path": "docs/copy-registry.json",
2299
2299
  "mode": "0000644",
2300
- "sha256": "d5d915abaca83b33070be430d0d26266c3cc5c4592aca9dd79e6552bdabe0ce0"
2300
+ "sha256": "c2c3e3a91b2f005cea4795176fef9065db8b617126dafb6f9f9fe0b06183a376"
2301
2301
  },
2302
2302
  {
2303
2303
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "d14e109f4db1aea7e159d1402cf5a3894621ecea1a670447ea234abe7ec4ec91"
2805
+ "sha256": "2c33e494daebf6147d0587edb3f3125cc3cc5a5f647c3a1db2d7968f05fa4184"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -2892,7 +2892,7 @@
2892
2892
  {
2893
2893
  "path": "docs/page-readings.json",
2894
2894
  "mode": "0000644",
2895
- "sha256": "53b501d71b929e013a66b79542a4e553880f25f1d1969c3553271470fc45d321"
2895
+ "sha256": "b9606273995e2ffc2079ee7c93dc1b13177ecdeded8e2a2e2a7e0bb1d768cdab"
2896
2896
  },
2897
2897
  {
2898
2898
  "path": "docs/project-context.template.md",
@@ -4092,7 +4092,7 @@
4092
4092
  {
4093
4093
  "path": "modules/autonomy/db.js",
4094
4094
  "mode": "0000644",
4095
- "sha256": "e561325ef0d63653dfaf7682214b529e92561f65913a5632a66bdef243c60741"
4095
+ "sha256": "7a35aedbf486079f1330b060280abe70898ce676f1bd0f01fe3bc0b3ab8b967c"
4096
4096
  },
4097
4097
  {
4098
4098
  "path": "modules/autonomy/digest.js",
@@ -4124,6 +4124,11 @@
4124
4124
  "mode": "0000644",
4125
4125
  "sha256": "5cee946899d3cbf9c73c02977a6441ce34b0f41e36997dbf27cb11f90fb89178"
4126
4126
  },
4127
+ {
4128
+ "path": "modules/autonomy/migrations/autonomy_004_runner_identity.sql",
4129
+ "mode": "0000644",
4130
+ "sha256": "84b2cea5e4991017c907f07cedd5a75448f5f4dc5cdc009df46b0ad2bd89780c"
4131
+ },
4127
4132
  {
4128
4133
  "path": "modules/autonomy/module.json",
4129
4134
  "mode": "0000644",
@@ -4137,12 +4142,12 @@
4137
4142
  {
4138
4143
  "path": "modules/autonomy/routes/autonomy.js",
4139
4144
  "mode": "0000644",
4140
- "sha256": "f6283e3dfbb5c420b41eaec0cee963c4045b2ecbf8a7954db4fff14b2c95272d"
4145
+ "sha256": "bd2d65b731ff1e9eea66cb028ec246fe95b8e1e867386baa9ad6fc9f79bd2a5b"
4141
4146
  },
4142
4147
  {
4143
4148
  "path": "modules/autonomy/runner-health.js",
4144
4149
  "mode": "0000644",
4145
- "sha256": "3752cff6742e0d72b83697948e14547afb2b7b158e5fef57bfa75cf68ce525cf"
4150
+ "sha256": "24c56042659ce118ef552da93610d75b9d4e17c3ea49f2fe78667892c943d43a"
4146
4151
  },
4147
4152
  {
4148
4153
  "path": "modules/builder-settings/CLAUDE.md",
@@ -5432,7 +5437,7 @@
5432
5437
  {
5433
5438
  "path": "modules/hall-ui/public/gate.js",
5434
5439
  "mode": "0000644",
5435
- "sha256": "595402cd77879c6c81ab5d7997eb6947c9c3a29a72e8cd171578737256975b66"
5440
+ "sha256": "2bd2d51a984ea6cab821d46ec9f1480e7231c5f4b32a46ce2d3c01a7adc21967"
5436
5441
  },
5437
5442
  {
5438
5443
  "path": "modules/hall-ui/public/gate.states.json",
@@ -9412,12 +9417,12 @@
9412
9417
  {
9413
9418
  "path": "package-lock.json",
9414
9419
  "mode": "0000644",
9415
- "sha256": "9324dcd074a5dfd4ad4e2a98a9ab732ea05d4084dee8a30087164769254b7e76"
9420
+ "sha256": "ab6fcfd7d4d42e1528dd531ec990be71b3b4d2221654c735ae4e1301726bb8a8"
9416
9421
  },
9417
9422
  {
9418
9423
  "path": "package.json",
9419
9424
  "mode": "0000644",
9420
- "sha256": "a1200cc1e8a1aea6abafa7f0529d5da06c43f36da1f416178a08d432c059aad7"
9425
+ "sha256": "24ea0d2cdd97a8a47824d23c4dabce026af3fe98273b3df4c07427466d983866"
9421
9426
  },
9422
9427
  {
9423
9428
  "path": "public-docs/index.html",
@@ -9437,7 +9442,7 @@
9437
9442
  {
9438
9443
  "path": "release-notes.json",
9439
9444
  "mode": "0000644",
9440
- "sha256": "937158bc61be2048ddce75860d05299007420de12cdfbb4413b2c8fd6795c631"
9445
+ "sha256": "027d1fbff3c739f12f9de1f3853a4e8cdb3f2dd7acc50e5a6b623a208186eeac"
9441
9446
  },
9442
9447
  {
9443
9448
  "path": "scripts/bongos-mcp.js",
@@ -9597,7 +9602,7 @@
9597
9602
  {
9598
9603
  "path": "scripts/gds/autobongos-run.js",
9599
9604
  "mode": "0000644",
9600
- "sha256": "c87e0ed439bee651517c6bd2bac4aadeac66fc6b6e271a212b1ccbe7f7d1be24"
9605
+ "sha256": "05dd20d28db2ddbb19710bb32fd1c3df560da4e9a2150b9f9f315956682c52ee"
9601
9606
  },
9602
9607
  {
9603
9608
  "path": "scripts/gds/autobongos-service.cmd",
@@ -11612,7 +11617,7 @@
11612
11617
  {
11613
11618
  "path": "src/module-api.js",
11614
11619
  "mode": "0000644",
11615
- "sha256": "430d2964f2eea97d2fc9460cc81c611a115f84279ac2db6c4c5a15a191aa6632"
11620
+ "sha256": "54e3ec3b65078a04894736e6f52c1bf1d31b0f56fa9d9d4b439fdf60623b7a11"
11616
11621
  },
11617
11622
  {
11618
11623
  "path": "src/module-loader/catalog.js",
@@ -12119,6 +12124,11 @@
12119
12124
  "mode": "0000644",
12120
12125
  "sha256": "e5dc6ec6261a18113876f4e362e8cb801d78f5266e077f0b8a27eeda70fe800b"
12121
12126
  },
12127
+ {
12128
+ "path": "tests/autonomy_runner_identity.mjs",
12129
+ "mode": "0000644",
12130
+ "sha256": "2bc963a47a41a90711bec4d23fef9e4a51203136f446a0abab272d78d2f6f6bf"
12131
+ },
12122
12132
  {
12123
12133
  "path": "tests/backfill_sessions.mjs",
12124
12134
  "mode": "0000644",
@@ -257,7 +257,7 @@ export interface PostAutonomyFenceGoalsRequest { goal_id: StringifiableId; note?
257
257
  export interface PostAutonomyFenceGoalsResponse { goal: unknown }
258
258
  export interface PostAutonomyFencePriorityRequest { goal_id?: StringifiableId }
259
259
  export interface PostAutonomyFenceRequest { enabled: boolean; reason?: string }
260
- export interface PostAutonomyHeartbeatRequest { host: string; pid?: number; started_at?: string; mode?: string; consecutive_failures?: number; last_event?: string; working_task_id?: number; working_goal_id?: number }
260
+ export interface PostAutonomyHeartbeatRequest { host: string; pid?: number; started_at?: string; mode?: string; consecutive_failures?: number; last_event?: string; working_task_id?: number; working_goal_id?: number; head?: string; disk_head?: string; main_head?: string; claude_account?: string }
261
261
  export interface PostAutonomyHeartbeatResponse { ok: boolean; heartbeat: unknown }
262
262
  export interface PostBackupTriggerResponse { ok: boolean; message: unknown; started_at: unknown }
263
263
  export interface PostBlockersIdLinkRequest { task_id: StringifiableId }
@@ -26060,6 +26060,22 @@
26060
26060
  },
26061
26061
  "working_goal_id": {
26062
26062
  "type": "number"
26063
+ },
26064
+ "head": {
26065
+ "type": "string",
26066
+ "maxLength": 40
26067
+ },
26068
+ "disk_head": {
26069
+ "type": "string",
26070
+ "maxLength": 40
26071
+ },
26072
+ "main_head": {
26073
+ "type": "string",
26074
+ "maxLength": 40
26075
+ },
26076
+ "claude_account": {
26077
+ "type": "string",
26078
+ "maxLength": 200
26063
26079
  }
26064
26080
  },
26065
26081
  "required": [
@@ -90,7 +90,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
90
90
  | POST | `/api/bongos/autonomy/fence/goals` | `archon` | `goal_id`, `note` | Add a goal to the allowlist. |
91
91
  | DELETE | `/api/bongos/autonomy/fence/goals/:goalId` | `archon` | — | Revoke one. |
92
92
  | POST | `/api/bongos/autonomy/fence/priority` | `archon` | `goal_id` | The priority goal (task 1004453): which allowlisted goal the runner works first. |
93
- | POST | `/api/bongos/autonomy/heartbeat` | `any-builder` | `host`, `pid`, `started_at`, `mode`, `consecutive_failures`, `last_event`, `working_task_id`, `working_goal_id` | The runner's check-in, once per loop. |
93
+ | POST | `/api/bongos/autonomy/heartbeat` | `any-builder` | `host`, `pid`, `started_at`, `mode`, `consecutive_failures`, `last_event`, `working_task_id`, `working_goal_id`, `head`, `disk_head`, `main_head`, `claude_account` | The runner's check-in, once per loop. |
94
94
  | GET | `/api/bongos/autonomy/precheck` | `metic+archon` | — | |
95
95
  | GET | `/api/bongos/autonomy/runners` | `any-builder` | — | What the hall renders: the CALLER'S OWN runners, with the pure verdict attached so the page and any future alert cannot disagree about wh… |
96
96
  | GET | `/api/bongos/autonomy/runs` | `metic+archon` | — | The gate's past decisions — did a routine fire, and what did it decide, and why (task 1002669). |
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **2317 strings** across 3 surfaces · 1996 distinct pieces of copy · 2317 certain, 0 heuristic · 221 shown in more than one place.
7
+ **2318 strings** across 3 surfaces · 1997 distinct pieces of copy · 2318 certain, 0 heuristic · 221 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 116 | 1717 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 116 | 1718 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
15
  | `landing` | `modules/public-landing/public` | 6 | 500 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `4e25e6872321` | 3x | Your projects | modules/public-landing/public/index.html:691, modules/public-landing/public/projects.html:586, modules/public-landing/public/projects.html:748 |
79
79
  | … | | _181 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1717 strings
81
+ ## `builders-hall` — 1718 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -257,7 +257,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
257
257
  | `906c2947dfc1` | certain | Changes save at once and take effect the next time you start a session on this machine. | `modules/hall-ui/public/settings.html:346` |
258
258
  | `52384bdc035c` | certain | Changes save at once. Your new choice plays the next time that event fires. | `modules/hall-ui/public/settings.html:359` |
259
259
  | `cab7a9526ca1` | certain | Changes save the moment you pick. “Default” clears your override and falls back to the system's choice. | `modules/hall-ui/public/settings.html:329` |
260
- | `4b714191b423` | certain | Changes touching protected build surfaces, held for your approval. | `modules/hall-ui/public/gate.js:432` |
260
+ | `4b714191b423` | certain | Changes touching protected build surfaces, held for your approval. | `modules/hall-ui/public/gate.js:448` |
261
261
  | `864503935eca` | certain | Changing the rule… | `modules/hall-ui/public/deploy.js:671` |
262
262
  | `55ad21bd03eb` | certain | Chart the routes | `modules/hall-ui/public/goal-map.html:100` |
263
263
  | `55ad21bd03eb` | certain | Chart the routes | `modules/hall-ui/public/thinking.html:108` |
@@ -281,6 +281,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
281
281
  | `c10806b50c61` | certain | Claim task {…} | `modules/hall-ui/public/work.js:482` |
282
282
  | `84664050c372` | certain | Claimed by another builder | `modules/hall-ui/public/goals-render.js:372` |
283
283
  | `18a03475e536` | certain | Claude Code | `modules/hall-ui/public/hall-render.js:832` |
284
+ | `fe6fab20b149` | certain | Claude account {…} | `modules/hall-ui/public/gate.js:393` |
284
285
  | `ccb94814e3c2` | certain | Claude can start it when you ask in plain words. | `modules/hall-ui/public/commands.html:36` |
285
286
  | `6cb414d119be` | certain | Claude sees it and can start it when you ask in plain words | `modules/hall-ui/public/commands-lib.js:94` |
286
287
  | `5b3b75ee0596` | certain | Claude's notes on this goal | `modules/hall-ui/public/goals.html:86` |
@@ -1518,7 +1519,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1518
1519
  | `6b530a7007bc` | certain | jump to a goal or a thought | `modules/hall-ui/public/goal-map.html:84` |
1519
1520
  | `6b530a7007bc` | certain | jump to a goal or a thought | `modules/hall-ui/public/thinking.html:92` |
1520
1521
  | `9328033a890a` | certain | land every shuttle | `modules/hall-ui/public/sky-interaction.js:45` |
1521
- | `966b32c8af7e` | certain | last check-in {…} ago | `modules/hall-ui/public/gate.js:391` |
1522
+ | `966b32c8af7e` | certain | last check-in {…} ago | `modules/hall-ui/public/gate.js:405` |
1522
1523
  | `9018bf6e99eb` | certain | last used {…} · signed in {…} | `modules/hall-ui/public/settings-sessions.js:46` |
1523
1524
  | `2ce7738b1eb0` | certain | loading goals… | `modules/hall-ui/public/task-detail.js:482` |
1524
1525
  | `754bdec368ed` | certain | lowest credits | `modules/hall-ui/public/hall-widgets.js:167` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 2317,
7
- "unique_ids": 1996,
8
- "certain": 2317,
6
+ "strings": 2318,
7
+ "unique_ids": 1997,
8
+ "certain": 2318,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 221
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 116,
16
- "strings": 1717
16
+ "strings": 1718
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -3930,7 +3930,7 @@
3930
3930
  "surface": "builders-hall",
3931
3931
  "text": "Changes touching protected build surfaces, held for your approval.",
3932
3932
  "file": "modules/hall-ui/public/gate.js",
3933
- "line": 432,
3933
+ "line": 448,
3934
3934
  "origin": "js-text-assign",
3935
3935
  "confidence": "certain"
3936
3936
  },
@@ -4141,6 +4141,15 @@
4141
4141
  "origin": "js-markup",
4142
4142
  "confidence": "certain"
4143
4143
  },
4144
+ {
4145
+ "id": "fe6fab20b149",
4146
+ "surface": "builders-hall",
4147
+ "text": "Claude account {…}",
4148
+ "file": "modules/hall-ui/public/gate.js",
4149
+ "line": 393,
4150
+ "origin": "js-markup",
4151
+ "confidence": "certain"
4152
+ },
4144
4153
  {
4145
4154
  "id": "ccb94814e3c2",
4146
4155
  "surface": "builders-hall",
@@ -15279,7 +15288,7 @@
15279
15288
  "surface": "builders-hall",
15280
15289
  "text": "last check-in {…} ago",
15281
15290
  "file": "modules/hall-ui/public/gate.js",
15282
- "line": 391,
15291
+ "line": 405,
15283
15292
  "origin": "js-markup",
15284
15293
  "confidence": "certain"
15285
15294
  },
@@ -2777,5 +2777,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2777
2777
  landed since 1.20.58 with no explicit bump. run 36828212808. (task 1002620)
2778
2778
  1.20.60 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2779
2779
  landed since 1.20.59 with no explicit bump. run 36830884810. (task 1002620)
2780
+ 1.20.61 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2781
+ landed since 1.20.60 with no explicit bump. run 36846600005. (task 1002620)
2780
2782
  ---------------------------------------------------------------------------
2781
2783
  ```
@@ -946,7 +946,7 @@
946
946
  "surface": "builders",
947
947
  "title": "Gate",
948
948
  "states_read": ["queue","out","approve"],
949
- "files_hash": "3e862af3bb2c5b18",
949
+ "files_hash": "fce003b152adb982",
950
950
  "reading_hash": "08d3e5d3657e5afb",
951
951
  "counts": {"lines":41,"placed":17,"shared":5,"unplaced":19},
952
952
  "omitted": {"data":34},
@@ -957,7 +957,7 @@
957
957
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":495,"string_id":"eba610d25523"},
958
958
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
959
959
  {"key":"L0005","section":"Gate","text":"Gate","placement":"unplaced"},
960
- {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":432,"string_id":"4b714191b423"},
960
+ {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":448,"string_id":"4b714191b423"},
961
961
  {"key":"L0007","section":"Gate","text":"Awaiting your approval","placement":"unplaced"},
962
962
  {"key":"L0008","section":"Gate","text":"held at the gate check","placement":"unplaced"},
963
963
  {"key":"L0009","section":"Gate","text":"Hard-floor holds","placement":"unplaced"},
@@ -162,11 +162,11 @@ async function disallowGoal(goalId) {
162
162
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
163
163
  // case is one write, and `host` is the key so a second runner appears as a second
164
164
  // row rather than silently overwriting the first.
165
- async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null }) {
165
+ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null, head = null, diskHead = null, mainHead = null, claudeAccount = null }) {
166
166
  const { rows } = await pool.query(
167
167
  `INSERT INTO autonomy_runner_heartbeat
168
- (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at, updated_at)
169
- VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, now(), now())
168
+ (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at, updated_at)
169
+ VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, now(), now())
170
170
  ON CONFLICT (builder_id, host) DO UPDATE SET
171
171
  pid = EXCLUDED.pid,
172
172
  started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
@@ -175,10 +175,16 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
175
175
  last_event = EXCLUDED.last_event,
176
176
  working_task_id = EXCLUDED.working_task_id,
177
177
  working_goal_id = EXCLUDED.working_goal_id,
178
+ -- Overwritten, never COALESCEd (task 1004407): a fresh process that cannot
179
+ -- read its own commit must say "unknown", not inherit the last process's.
180
+ head = EXCLUDED.head,
181
+ disk_head = EXCLUDED.disk_head,
182
+ main_head = EXCLUDED.main_head,
183
+ claude_account = EXCLUDED.claude_account,
178
184
  last_seen_at = now(),
179
185
  updated_at = now()
180
- RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at`,
181
- [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId]
186
+ RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at`,
187
+ [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount]
182
188
  );
183
189
  return rows[0] || null;
184
190
  }
@@ -193,7 +199,8 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
193
199
  // liveness view entirely (grader finding, task 1003905).
194
200
  async function readRunnerHeartbeats(builderId) {
195
201
  const { rows } = await pool.query(
196
- `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at
202
+ `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
203
+ head, disk_head, main_head, claude_account, last_seen_at
197
204
  FROM autonomy_runner_heartbeat
198
205
  WHERE builder_id = $1
199
206
  ORDER BY last_seen_at DESC
@@ -0,0 +1,30 @@
1
+ -- autonomy_004_runner_identity.sql — what the runner card can say about a runner
2
+ -- (task 1004407).
3
+ --
4
+ -- Owner ruling, 2026-10-01: the gate page must show which code each runner is
5
+ -- running, whether it is live or restarting, which always-on PC it is on, and
6
+ -- which Claude account its workers spend. `host` already names the PC
7
+ -- (autonomy_002); these four columns carry the rest.
8
+ --
9
+ -- head the commit the supervisor process is RUNNING (40-char hex)
10
+ -- disk_head the commit on disk, when the checkout was refreshed in place
11
+ -- past what is running (task 1004406) — null otherwise
12
+ -- main_head the newest main the runner has seen, so the card can say
13
+ -- "behind main" without the server reaching GitHub
14
+ -- claude_account a LABEL for the Claude login its workers use (an email, or
15
+ -- "API key" / "Claude token" when one is set). Never a token or
16
+ -- a key: the route caps it at 200 characters and the runner only
17
+ -- ever sends the label.
18
+ --
19
+ -- Nullable, no defaults: a runner that predates this reports nothing, and the
20
+ -- card says nothing rather than guessing. Additive and idempotent.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE autonomy_runner_heartbeat
25
+ ADD COLUMN IF NOT EXISTS head text,
26
+ ADD COLUMN IF NOT EXISTS disk_head text,
27
+ ADD COLUMN IF NOT EXISTS main_head text,
28
+ ADD COLUMN IF NOT EXISTS claude_account text;
29
+
30
+ COMMIT;
@@ -52,6 +52,11 @@ function runsLimit(raw) {
52
52
  return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
53
53
  }
54
54
 
55
+ // commitOrNull — a full lowercase commit sha, or null (task 1004407).
56
+ function commitOrNull(v) {
57
+ return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
58
+ }
59
+
55
60
  module.exports = function buildAutonomyRouter() {
56
61
  const router = express.Router();
57
62
 
@@ -219,6 +224,11 @@ module.exports = function buildAutonomyRouter() {
219
224
  last_event: { type: 'string', maxLength: 64 },
220
225
  working_task_id: { type: 'number' },
221
226
  working_goal_id: { type: 'number' },
227
+ // What the runner card says about the runner (task 1004407).
228
+ head: { type: 'string', maxLength: 40 },
229
+ disk_head: { type: 'string', maxLength: 40 },
230
+ main_head: { type: 'string', maxLength: 40 },
231
+ claude_account: { type: 'string', maxLength: 200 },
222
232
  })) return;
223
233
  const b = req.body || {};
224
234
  try {
@@ -234,6 +244,12 @@ module.exports = function buildAutonomyRouter() {
234
244
  lastEvent: b.last_event || null,
235
245
  workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
236
246
  workingGoalId: Number.isInteger(b.working_goal_id) ? b.working_goal_id : null,
247
+ // A commit is stored only if it IS one: anything that is not 40 hex
248
+ // characters is dropped to null rather than rendered as a version.
249
+ head: commitOrNull(b.head),
250
+ diskHead: commitOrNull(b.disk_head),
251
+ mainHead: commitOrNull(b.main_head),
252
+ claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
237
253
  });
238
254
  res.json({ ok: true, heartbeat: row });
239
255
  } catch (err) {
@@ -19,6 +19,11 @@
19
19
  // exactly what it is for, and crying dead every time it works is worse than
20
20
  // saying nothing.
21
21
  // · dead — quiet longer than any legitimate explanation.
22
+ // · restarting — its last word was "exiting to pick up new code" (upgrade_exit,
23
+ // task 1004406), and that was recently. The wrapper fetches, resets and
24
+ // relaunches, which takes a minute or two, and the owner ruled that a restart
25
+ // must read as a restart, never as a death (task 1004407). Past RESTART_S the
26
+ // relaunch has failed and it is dead again — with a detail that says so.
22
27
 
23
28
  // Reasoned from the runner's own numbers, not picked. The loop beats at the top of
24
29
  // every pass and again on each poll inside a wait (every 60s), so a healthy runner
@@ -28,6 +33,43 @@ const ALIVE_S = 15 * 60;
28
33
  // worker holds the loop for its whole run. Two hours is that ceiling plus room for
29
34
  // the ship, the grade and the verify that follow it.
30
35
  const LATE_S = 2 * 60 * 60;
36
+ // A relaunch is fetch + reset + boot + the first beat. Ten minutes is several
37
+ // times that, so a slow fetch is not called a death, and short enough that a
38
+ // relaunch which never happened is noticed the same evening.
39
+ const RESTART_S = 10 * 60;
40
+
41
+ const SHA_RE = /^[0-9a-f]{40}$/;
42
+ const sha = (v) => (typeof v === 'string' && SHA_RE.test(v) ? v : null);
43
+
44
+ // describeCode — what the card says about the code a runner is on (task 1004407).
45
+ // `behind` is true only when BOTH sides are known and differ: the runner reports
46
+ // the newest main it has seen, and an unknown is never drawn as "out of date".
47
+ // A checkout refreshed in place counts by what is on disk, because that is what
48
+ // every worker and child process runs (task 1004406).
49
+ function describeCode(hb) {
50
+ const head = sha(hb && hb.head);
51
+ if (!head) return null;
52
+ const disk = sha(hb.disk_head);
53
+ const main = sha(hb.main_head);
54
+ const current = disk || head;
55
+ return {
56
+ head, short: head.slice(0, 8),
57
+ diskShort: disk && disk !== head ? disk.slice(0, 8) : null,
58
+ mainShort: main ? main.slice(0, 8) : null,
59
+ behind: main ? main !== current : null,
60
+ };
61
+ }
62
+
63
+ // What the runner was last doing, in words. Keyed on the run-log event names the
64
+ // runner sends as last_event; an unknown event says nothing rather than guessing.
65
+ const ACTIVITY = {
66
+ boot: 'just started',
67
+ waiting: 'waiting for work',
68
+ code_refreshed: 'updated its code in place',
69
+ nothing_claimable: 'waiting — nothing to claim',
70
+ fenced: 'held by the fence',
71
+ hold: 'pausing',
72
+ };
31
73
 
32
74
  function ageS(lastSeenAt, nowMs) {
33
75
  const t = Date.parse(lastSeenAt || '');
@@ -68,19 +110,38 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
68
110
  const mode = heartbeat.mode === 'probing' ? 'probing' : (heartbeat.mode === 'full' ? 'full' : null);
69
111
  const working = heartbeat.working_task_id ? String(heartbeat.working_task_id) : null;
70
112
  const where = host ? ` on ${host}` : '';
113
+ const ident = {
114
+ host, mode, workingTaskId: working,
115
+ account: typeof heartbeat.claude_account === 'string' && heartbeat.claude_account ? heartbeat.claude_account : null,
116
+ code: describeCode(heartbeat),
117
+ };
118
+
119
+ if (heartbeat.last_event === 'upgrade_exit') {
120
+ if (age <= RESTART_S) {
121
+ return {
122
+ state: 'restarting', ok: true, ageS: age, age: humanAge(age), ...ident,
123
+ detail: `restarting${where} to pick up new code`,
124
+ };
125
+ }
126
+ return {
127
+ state: 'dead', ok: false, ageS: age, age: humanAge(age), ...ident,
128
+ detail: `exited${where} ${humanAge(age)} ago to restart on new code and has not come back`,
129
+ };
130
+ }
71
131
 
72
132
  if (age <= aliveS) {
133
+ const doing = ACTIVITY[heartbeat.last_event];
73
134
  return {
74
- state: 'alive', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
135
+ state: 'alive', ok: true, ageS: age, age: humanAge(age), ...ident,
75
136
  detail: working
76
137
  ? `working task ${working}${where}`
77
- : `${mode === 'probing' ? 'probing after failures' : 'running'}${where}`,
138
+ : `${mode === 'probing' ? 'probing after failures' : (doing || 'running')}${where}`,
78
139
  };
79
140
  }
80
141
 
81
142
  if (age <= lateS) {
82
143
  return {
83
- state: 'late', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
144
+ state: 'late', ok: true, ageS: age, age: humanAge(age), ...ident,
84
145
  // Deliberately NOT an alarm. This is what a runner doing its job looks like.
85
146
  detail: working
86
147
  ? `quiet for ${humanAge(age)} — expected while task ${working} builds`
@@ -89,9 +150,9 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
89
150
  }
90
151
 
91
152
  return {
92
- state: 'dead', ok: false, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
153
+ state: 'dead', ok: false, ageS: age, age: humanAge(age), ...ident,
93
154
  detail: `no check-in for ${humanAge(age)}${where} — longer than any worker takes, so the runner is not running`,
94
155
  };
95
156
  }
96
157
 
97
- module.exports = { ALIVE_S, LATE_S, ageS, humanAge, describeRunner };
158
+ module.exports = { ALIVE_S, LATE_S, RESTART_S, ageS, humanAge, describeRunner };
@@ -358,7 +358,7 @@
358
358
  // exist here, resolved to inherited grey, and drew a dead runner in exactly the
359
359
  // same colour as a healthy one. Named classes cannot fail that way silently —
360
360
  // a missing class is visible in the stylesheet, a missing var is not.
361
- const HEALTH_CLASS = { alive: 'fact-pill--ok', late: 'fact-pill--warn', dead: 'fact-pill--danger', never_seen: 'fact-pill--info' };
361
+ const HEALTH_CLASS = { alive: 'fact-pill--ok', late: 'fact-pill--warn', dead: 'fact-pill--danger', never_seen: 'fact-pill--info', restarting: 'fact-pill--info' };
362
362
 
363
363
  async function loadRunners() {
364
364
  const el = document.getElementById('fence-runner');
@@ -384,13 +384,29 @@
384
384
  // NOT the word "running": the switch below already uses it for a different
385
385
  // thing (allowed to work), and the two lines sit one above the other. A
386
386
  // reader should never have to work out which sense is meant.
387
- const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in' }[h.state] || 'Unknown';
388
- return `<p class="ov-foot" style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;">
389
- <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
390
- <span>${escapeHtml(h.detail || '')}</span>
391
- ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
392
- <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
393
- </p>`;
387
+ const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in', restarting: 'Restarting' }[h.state] || 'Unknown';
388
+ // Which PC, which Claude account, which code (task 1004407). Each line is
389
+ // drawn only when the runner reported it, so an older runner shows less
390
+ // rather than a guess.
391
+ const facts = [];
392
+ if (h.host) facts.push(`<span class="ov-fact">PC <strong>${escapeHtml(h.host)}</strong></span>`);
393
+ if (h.account) facts.push(`<span class="ov-fact">Claude account ${escapeHtml(h.account)}</span>`);
394
+ if (h.code) {
395
+ const c = h.code;
396
+ const status = c.behind === true ? ` — behind main (${escapeHtml(c.mainShort)})`
397
+ : c.behind === false ? ' — up to date' : '';
398
+ const disk = c.diskShort ? `, updated in place to ${escapeHtml(c.diskShort)}` : '';
399
+ facts.push(`<span class="ov-fact">code <code>${escapeHtml(c.short)}</code>${disk}${status}</span>`);
400
+ }
401
+ return `<div class="ov-foot">
402
+ <p style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:0;">
403
+ <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
404
+ <span>${escapeHtml(h.detail || '')}</span>
405
+ ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
406
+ <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
407
+ </p>
408
+ ${facts.length ? `<p style="display:flex;gap:14px;flex-wrap:wrap;margin:4px 0 0;">${facts.join('')}</p>` : ''}
409
+ </div>`;
394
410
  }).join('');
395
411
  }
396
412
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.60",
3
+ "version": "1.20.61",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.60",
9
+ "version": "1.20.61",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.60",
3
+ "version": "1.20.61",
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",
@@ -8477,5 +8477,11 @@
8477
8477
  "id": "1004421",
8478
8478
  "text": "Every project can now have its own look, so builders who work in several projects always know which hall they are in. Creating a project has a new Look step: four ready-made looks or your own colour, a switch to tint dark mode"
8479
8479
  }
8480
+ ],
8481
+ "1.20.61": [
8482
+ {
8483
+ "id": "1004407",
8484
+ "text": "The Gate page now shows, for each Autobongos runner, which PC it is on, which Claude account it is spending, which code version it runs (and whether that is behind), and says \"Restarting\" while it relaunches for new code inste"
8485
+ }
8480
8486
  ]
8481
8487
  }
@@ -368,7 +368,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
368
368
  const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
369
369
 
370
370
  function newRunnerState() {
371
- return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
371
+ return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null };
372
372
  }
373
373
  const RUNNER_STATE = newRunnerState();
374
374
 
@@ -703,6 +703,43 @@ function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
703
703
  // is the signal.
704
704
  const RUNNER_STARTED_AT = new Date().toISOString();
705
705
 
706
+ // claudeAccountLabel — WHICH Claude account this machine's workers spend
707
+ // (task 1004407), as a label and never a secret. Workers inherit
708
+ // ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN from this process
709
+ // (WORKER_ENV_ALLOW), and either one outranks the stored login, so those are
710
+ // named first, by the variable that is set and never by its value. Otherwise it
711
+ // is the login Claude Code itself records in ~/.claude.json (oauthAccount): the
712
+ // email, plus the organisation when it has one. Read on every beat, so signing
713
+ // a different account in shows up without a restart; null when nothing can be
714
+ // read, which the card leaves blank rather than guessing.
715
+ function claudeAccountLabel({ env = process.env, readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
716
+ if (env.ANTHROPIC_API_KEY) return 'an API key (ANTHROPIC_API_KEY)';
717
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) return 'a Claude token (CLAUDE_CODE_OAUTH_TOKEN)';
718
+ try {
719
+ const dir = env.CLAUDE_CONFIG_DIR || os.homedir();
720
+ const acct = JSON.parse(readFile(path.join(dir, '.claude.json'))).oauthAccount || {};
721
+ const email = typeof acct.emailAddress === 'string' ? acct.emailAddress.trim() : '';
722
+ if (!email) return null;
723
+ const org = typeof acct.organizationName === 'string' ? acct.organizationName.trim() : '';
724
+ return (org ? `${email} · ${org}` : email).slice(0, 200);
725
+ } catch (_) { return null; }
726
+ }
727
+
728
+ // heartbeatIdentity — the fields that say which code and which account (task
729
+ // 1004407). Undefined fields are dropped, so a runner that cannot read its own
730
+ // commit sends nothing for it rather than an empty string the route would refuse.
731
+ function heartbeatIdentity(deps = {}) {
732
+ const st = deps.state || RUNNER_STATE;
733
+ const out = {
734
+ head: LOADED_HEAD || undefined,
735
+ disk_head: st.diskHead && st.diskHead !== LOADED_HEAD ? st.diskHead : undefined,
736
+ main_head: st.mainHead || undefined,
737
+ claude_account: (deps.claudeAccountLabel || claudeAccountLabel)() || undefined,
738
+ };
739
+ for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
740
+ return out;
741
+ }
742
+
706
743
  async function postHeartbeat(fields = {}, deps = {}) {
707
744
  try {
708
745
  const api = deps.api || await (require('./cli-lib').cliClient)();
@@ -711,6 +748,7 @@ async function postHeartbeat(fields = {}, deps = {}) {
711
748
  host: os.hostname(),
712
749
  pid: process.pid,
713
750
  started_at: RUNNER_STARTED_AT,
751
+ ...heartbeatIdentity(deps),
714
752
  ...fields,
715
753
  },
716
754
  });
@@ -870,6 +908,9 @@ async function codeDrifted(deps = {}) {
870
908
  let tip = null;
871
909
  try { tip = await runFetchHead(deps); } catch (_) { return null; }
872
910
  if (!tip) return null;
911
+ // Kept for the heartbeat (task 1004407), so the hall can say "behind main"
912
+ // without the server ever reaching GitHub itself.
913
+ (deps.state || RUNNER_STATE).mainHead = tip;
873
914
  return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
874
915
  }
875
916
 
@@ -1147,6 +1188,6 @@ module.exports = {
1147
1188
  readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
1148
1189
  worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1149
1190
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1150
- isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
1191
+ isRunnerTree, RUNNER_TREE_RE, postHeartbeat, claudeAccountLabel, RUNNER_STARTED_AT, failureReason,
1151
1192
  newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1152
1193
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.60'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.61'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,215 @@
1
+ // tests/autonomy_runner_identity.mjs — the runner card says which PC, which Claude
2
+ // account, which code, and whether the runner is live or restarting (task 1004407).
3
+ //
4
+ // Owner ruling, 2026-10-01: showing the code version is good, and showing when it
5
+ // is restarting versus live is better, along with which always-on PC and which
6
+ // Claude account it runs on. These pin each of those from the runner that reports
7
+ // them, through the route and the SQL, to the verdict the hall renders. The rule
8
+ // that holds across all of it: an unknown is shown as nothing, never as a guess,
9
+ // and a secret is never what gets sent.
10
+ //
11
+ // DB-free, no network. Run: node --test tests/autonomy_runner_identity.mjs
12
+
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const req = createRequire(import.meta.url);
22
+ const express = req('express');
23
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
24
+ const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
25
+ const runner = req(path.join(ROOT, 'scripts/gds/autobongos-run.js'));
26
+
27
+ const NOW = Date.parse('2026-10-01T12:00:00.000Z');
28
+ const agoS = (s) => new Date(NOW - s * 1000).toISOString();
29
+ const A = 'a'.repeat(40);
30
+ const B = 'b'.repeat(40);
31
+ const hb = (over = {}) => ({ host: 'gmk-always-on', mode: 'full', last_event: 'loop', last_seen_at: agoS(30), ...over });
32
+
33
+ // ── live or restarting ───────────────────────────────────────────────────────────
34
+
35
+ test('a runner that just exited to pick up new code reads RESTARTING, not dead', () => {
36
+ const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(90) }), { nowMs: NOW });
37
+ assert.equal(d.state, 'restarting');
38
+ assert.equal(d.ok, true, 'a restart is not a fault');
39
+ assert.match(d.detail, /restarting on gmk-always-on to pick up new code/);
40
+ });
41
+
42
+ test('a restart that never came back is dead, and says it was a restart', () => {
43
+ const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 60) }), { nowMs: NOW });
44
+ assert.equal(d.state, 'dead');
45
+ assert.equal(d.ok, false);
46
+ assert.match(d.detail, /exited on gmk-always-on .* to restart on new code and has not come back/);
47
+ });
48
+
49
+ test('the restart window ends exactly at RESTART_S', () => {
50
+ assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S) }), { nowMs: NOW }).state, 'restarting');
51
+ assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 1) }), { nowMs: NOW }).state, 'dead');
52
+ });
53
+
54
+ test('a live runner says what it last did, in words', () => {
55
+ assert.match(health.describeRunner(hb({ last_event: 'code_refreshed' }), { nowMs: NOW }).detail, /updated its code in place on gmk-always-on/);
56
+ assert.match(health.describeRunner(hb({ last_event: 'waiting' }), { nowMs: NOW }).detail, /waiting for work/);
57
+ assert.match(health.describeRunner(hb({ last_event: 'loop' }), { nowMs: NOW }).detail, /^running on/, 'an event with no words keeps the old wording');
58
+ assert.match(health.describeRunner(hb({ working_task_id: '4242', last_event: 'worked' }), { nowMs: NOW }).detail, /working task 4242/);
59
+ });
60
+
61
+ // ── which code ───────────────────────────────────────────────────────────────────
62
+
63
+ test('the code version is shown short, and flagged when it is behind main', () => {
64
+ const d = health.describeRunner(hb({ head: A, main_head: B }), { nowMs: NOW });
65
+ assert.equal(d.code.short, 'aaaaaaaa');
66
+ assert.equal(d.code.behind, true);
67
+ assert.equal(d.code.mainShort, 'bbbbbbbb');
68
+ assert.equal(health.describeRunner(hb({ head: A, main_head: A }), { nowMs: NOW }).code.behind, false);
69
+ });
70
+
71
+ test('an unknown main is never drawn as "out of date"', () => {
72
+ const c = health.describeRunner(hb({ head: A }), { nowMs: NOW }).code;
73
+ assert.equal(c.behind, null);
74
+ assert.equal(c.mainShort, null);
75
+ });
76
+
77
+ test('a checkout refreshed in place is current by what is on DISK', () => {
78
+ // Workers and child processes run what is on disk (task 1004406), so a
79
+ // refreshed runner whose own process is older is still up to date.
80
+ const c = health.describeRunner(hb({ head: A, disk_head: B, main_head: B }), { nowMs: NOW }).code;
81
+ assert.equal(c.behind, false);
82
+ assert.equal(c.diskShort, 'bbbbbbbb');
83
+ });
84
+
85
+ test('no head, or a head that is not a commit, shows no code line at all', () => {
86
+ for (const head of [null, undefined, '', 'abc', 'Z'.repeat(40), A.toUpperCase()]) {
87
+ assert.equal(health.describeRunner(hb({ head }), { nowMs: NOW }).code, null, String(head));
88
+ }
89
+ });
90
+
91
+ // ── which PC and which account ───────────────────────────────────────────────────
92
+
93
+ test('the PC and the Claude account ride on every state, dead included', () => {
94
+ for (const [over, state] of [[{}, 'alive'], [{ last_seen_at: agoS(health.LATE_S + 1) }, 'dead'], [{ last_event: 'upgrade_exit' }, 'restarting']]) {
95
+ const d = health.describeRunner(hb({ claude_account: 'me@example.com', head: A, ...over }), { nowMs: NOW });
96
+ assert.equal(d.state, state);
97
+ assert.equal(d.host, 'gmk-always-on');
98
+ assert.equal(d.account, 'me@example.com', `${state} must still say whose account it spent`);
99
+ assert.ok(d.code, `${state} must still say which code`);
100
+ }
101
+ });
102
+
103
+ test('the account label names a key or a token by its variable, never its value', () => {
104
+ const secret = 'sk-ant-SECRET-VALUE';
105
+ const viaKey = runner.claudeAccountLabel({ env: { ANTHROPIC_API_KEY: secret }, readFile: () => { throw new Error('unread'); } });
106
+ assert.equal(viaKey, 'an API key (ANTHROPIC_API_KEY)');
107
+ const viaToken = runner.claudeAccountLabel({ env: { CLAUDE_CODE_OAUTH_TOKEN: secret }, readFile: () => { throw new Error('unread'); } });
108
+ assert.equal(viaToken, 'a Claude token (CLAUDE_CODE_OAUTH_TOKEN)');
109
+ assert.ok(!viaKey.includes(secret) && !viaToken.includes(secret));
110
+ });
111
+
112
+ test('otherwise the label is the login Claude Code records, with its organisation', () => {
113
+ const seen = [];
114
+ const file = (o) => (p) => { seen.push(p); return JSON.stringify({ oauthAccount: o, primaryApiKey: 'never-read' }); };
115
+ assert.equal(runner.claudeAccountLabel({ env: { CLAUDE_CONFIG_DIR: '/cfg' }, readFile: file({ emailAddress: 'me@example.com', organizationName: 'Acme' }) }), 'me@example.com · Acme');
116
+ assert.equal(path.normalize(seen[0]), path.normalize('/cfg/.claude.json'), 'CLAUDE_CONFIG_DIR is where Claude Code keeps it when set');
117
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({ emailAddress: 'me@example.com' }) }), 'me@example.com');
118
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({}) }), null, 'no login reads as unknown');
119
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => 'not json' }), null);
120
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => { throw new Error('ENOENT'); } }), null);
121
+ });
122
+
123
+ // ── the runner sends it ──────────────────────────────────────────────────────────
124
+
125
+ test('every server check-in carries the code and the account, and the newest main seen', async () => {
126
+ const bodies = [];
127
+ const api = { autonomy: { postAutonomyHeartbeat: async ({ body }) => { bodies.push(body); return { ok: true }; } } };
128
+ const state = runner.newRunnerState();
129
+ // codeDrifted is what learns main's tip; it must keep it for the heartbeat.
130
+ await runner.codeDrifted({ loadedHead: async () => A, run: async () => ({ ok: true, stdout: `${B}\trefs/heads/main\n` }), state });
131
+ assert.equal(state.mainHead, B);
132
+ await runner.loadedHead({ run: async () => ({ ok: true, stdout: `${A}\n` }) });
133
+ await runner.postHeartbeat({ last_event: 'loop' }, { api, state, claudeAccountLabel: () => 'me@example.com' });
134
+ assert.equal(bodies[0].head, A);
135
+ assert.equal(bodies[0].main_head, B);
136
+ assert.equal(bodies[0].claude_account, 'me@example.com');
137
+ assert.equal(bodies[0].last_event, 'loop');
138
+ assert.equal('disk_head' in bodies[0], false, 'no refresh in place, so no disk_head');
139
+
140
+ state.diskHead = B;
141
+ await runner.postHeartbeat({}, { api, state, claudeAccountLabel: () => null });
142
+ assert.equal(bodies[1].disk_head, B);
143
+ assert.equal('claude_account' in bodies[1], false, 'an unknown account is left out, not sent empty');
144
+ });
145
+
146
+ // ── the route and the SQL ────────────────────────────────────────────────────────
147
+
148
+ function autonomyRouter() {
149
+ const api = req(path.join(ROOT, 'src/module-api.js'));
150
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
151
+ api.requireBuilder = (r, _s, next) => { r.builder = { id: 3, rank: 'archon' }; next(); };
152
+ api.requirePermission = () => (_r, _s, next) => next();
153
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
154
+ delete req.cache[routePath];
155
+ try { return req(routePath)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
156
+ }
157
+
158
+ test('the route stores a commit only if it is one, and the account label as sent', async () => {
159
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
160
+ const saved = db.recordRunnerHeartbeat;
161
+ const seen = [];
162
+ db.recordRunnerHeartbeat = async (a) => { seen.push(a); return {}; };
163
+ const app = express(); app.use(express.json()); app.use(attachFail); app.use(autonomyRouter());
164
+ const server = app.listen(0); await new Promise((r) => server.once('listening', r));
165
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
166
+ try {
167
+ let r = await post({ host: 'gmk-always-on', head: A, disk_head: B, main_head: B, claude_account: ' me@example.com ' });
168
+ assert.equal(r.status, 200);
169
+ assert.deepEqual([seen[0].head, seen[0].diskHead, seen[0].mainHead, seen[0].claudeAccount], [A, B, B, 'me@example.com']);
170
+ r = await post({ host: 'gmk-always-on', head: 'not-a-commit', main_head: A.toUpperCase(), claude_account: '' });
171
+ assert.equal(r.status, 200, 'a bad version never costs the runner its check-in');
172
+ assert.deepEqual([seen[1].head, seen[1].mainHead, seen[1].claudeAccount], [null, null, null]);
173
+ r = await post({ host: 'gmk-always-on', claude_account: 'x'.repeat(201) });
174
+ assert.equal(r.status, 400, 'the label is capped, so a key pasted into it by mistake is refused, not stored');
175
+ } finally {
176
+ server.close();
177
+ db.recordRunnerHeartbeat = saved;
178
+ }
179
+ });
180
+
181
+ test('the heartbeat SQL overwrites the identity every beat and reads it back', async () => {
182
+ const api = req(path.join(ROOT, 'src/module-api.js'));
183
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
184
+ const saved = api.pool.query;
185
+ const calls = [];
186
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ head: A }] }; };
187
+ try {
188
+ await db.recordRunnerHeartbeat({ builderId: 3, host: 'gmk-always-on', head: A, diskHead: B, mainHead: B, claudeAccount: 'me@example.com' });
189
+ for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
190
+ assert.match(calls[0].sql, new RegExp(`\\b${col} = EXCLUDED\\.${col}\\b`), `${col} is overwritten, never kept from the last process`);
191
+ }
192
+ assert.deepEqual(calls[0].params.slice(9), [A, B, B, 'me@example.com']);
193
+ await db.readRunnerHeartbeats(3);
194
+ assert.match(calls[1].sql, /head, disk_head, main_head, claude_account/);
195
+ } finally {
196
+ api.pool.query = saved;
197
+ }
198
+ });
199
+
200
+ test('the migration adds the four columns, additively', () => {
201
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_004_runner_identity.sql'), 'utf8')
202
+ .split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
203
+ for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
204
+ assert.match(sql, new RegExp(`ADD COLUMN IF NOT EXISTS ${col} text`));
205
+ }
206
+ assert.doesNotMatch(sql, /\bDROP\b/i);
207
+ });
208
+
209
+ test('the gate card draws the PC, the account, the code and the restarting label', () => {
210
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
211
+ assert.match(src, /restarting: 'Restarting'/);
212
+ assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
213
+ assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
214
+ assert.match(src, /behind main/);
215
+ });