@bongos/core 1.24.76 → 1.24.78

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.
Files changed (58) hide show
  1. package/.bongos-core.json +129 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +45 -0
  8. package/docs/api/openapi.json +209 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +109 -92
  11. package/docs/copy-registry.json +291 -108
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +2 -0
  14. package/docs/page-readings.json +111 -97
  15. package/docs/recipes/autobongos-runner-setup.md +148 -0
  16. package/docs/recipes/autobongos-windows-host.md +10 -7
  17. package/modules/autonomy/cadence.js +3 -0
  18. package/modules/autonomy/db.js +86 -7
  19. package/modules/autonomy/fence.js +20 -5
  20. package/modules/autonomy/gauge.js +25 -1
  21. package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
  22. package/modules/autonomy/module.json +2 -1
  23. package/modules/autonomy/routes/autonomy.js +165 -3
  24. package/modules/autonomy/runner-health.js +27 -0
  25. package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
  26. package/modules/hall-ui/public/commands-lib.js +1 -1
  27. package/modules/hall-ui/public/gate.js +12 -5
  28. package/modules/hall-ui/public/runner-button.css +55 -0
  29. package/modules/hall-ui/public/runner-button.js +187 -0
  30. package/modules/hall-ui/public/settings-autobongos.js +349 -63
  31. package/modules/hall-ui/public/settings.html +23 -10
  32. package/modules/hall-ui/records/runner-button.md +16 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +20 -0
  36. package/scripts/bongos/autobongos-bootstrap.ps1 +169 -0
  37. package/scripts/bongos/autobongos-bootstrap.sh +151 -0
  38. package/scripts/bongos/autobongos-host.js +883 -0
  39. package/scripts/bongos/autobongos-run.js +25 -4
  40. package/scripts/bongos/autonomy-gauge.js +11 -3
  41. package/scripts/bongos/cli-lib.js +5 -1
  42. package/scripts/bongos/stranded-rows.js +39 -2
  43. package/scripts/gds/autobongos-bootstrap.ps1 +8 -0
  44. package/scripts/gds/autobongos-bootstrap.sh +8 -0
  45. package/scripts/gds/autobongos-host.js +7 -0
  46. package/scripts/hall-preview/server.js +7 -0
  47. package/src/instance-config.js +22 -3
  48. package/src/module-api.js +1 -1
  49. package/tests/autobongos_host.mjs +647 -0
  50. package/tests/autobongos_loop.mjs +11 -0
  51. package/tests/autonomy_config_idle.mjs +9 -4
  52. package/tests/autonomy_gauge.mjs +43 -0
  53. package/tests/autonomy_runner_device.mjs +444 -0
  54. package/tests/autonomy_runner_identity.mjs +3 -1
  55. package/tests/cli_sessions.mjs +16 -0
  56. package/tests/hall_runner_button.mjs +467 -0
  57. package/tests/hall_runner_panel_connect.mjs +73 -0
  58. package/tests/stranded_rows.mjs +50 -0
@@ -1,4 +1,12 @@
1
- # Setting up the Autobongos runner on the Windows PC
1
+ # The always-on Windows host: Autobongos that starts at boot
2
+
3
+ > **Most builders want [Running Autobongos on your own device](autobongos-runner-setup.md)
4
+ > instead.** That flow is one copied command and one Run button, works on Windows
5
+ > and Mac, needs no administrator, and starts the runner when you sign in. This
6
+ > page is the advanced path for ONE case it does not cover: a Windows PC that must
7
+ > come back after a reboot or a power cut **with nobody signed in**. It needs an
8
+ > Administrator window once. Use one or the other on a device, never both — the
9
+ > setup command refuses to add a second runner beside this one.
2
10
 
3
11
  The runner is a program that claims tasks and spawns Claude Code sessions to work
4
12
  them, on an always-on machine with nobody at the keyboard. This is how it gets
@@ -7,12 +15,7 @@ installed so it survives a reboot, and how you check on it afterwards.
7
15
  **Design of record:** [ADR 0307](../adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md).
8
16
  Task [1003905](https://cloudbongos.com/builders#/task/1003905).
9
17
 
10
- > **NOT YET VERIFIED ON THE REAL MACHINE.** Everything below was written and
11
- > reviewed on a Mac; the install, the cold reboot and the crash-restart have not
12
- > been run on the Windows PC, because that machine is not reachable from where
13
- > this was built. The heartbeat half — the part that tells you whether the runner
14
- > is alive — *was* verified end to end against a real database. Treat step 3 as
15
- > the acceptance test, not as a formality.
18
+ > Step 3 is the acceptance test, not a formality: do it after every install.
16
19
 
17
20
  ---
18
21
 
@@ -164,6 +164,9 @@ function classifyEvent(row = {}) {
164
164
  // The per-builder stops (task 1004501) are the same kind of fact as the project
165
165
  // switch: a person decided, and probing will not change their mind.
166
166
  if (code === 'builder_paused' || code === 'not_permitted') return { class: 'stopped', detail: row.reason || 'this builder’s runner is stopped' };
167
+ // The builder's own Stop on this device (task 1005094): the same kind of fact
168
+ // again. It waits, cheaply, for the press of Run.
169
+ if (code === 'device_stopped') return { class: 'stopped', detail: row.reason || 'this device is stopped' };
167
170
  if (code === 'grader_unavailable') return { class: 'transient', detail: 'the grader is unavailable, and it is the only review there is' };
168
171
  if (code === 'fence_unreadable') return { class: 'transient', detail: 'the fence could not be read' };
169
172
  // no_allowlist / goal_not_allowlisted / protected_path: the fence is working
@@ -267,7 +267,7 @@ async function readAllRunnerScopes() {
267
267
  // hosts can never push a quieter builder out of the owner's view.
268
268
  pool.query(
269
269
  `SELECT builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
270
- head, disk_head, main_head, claude_account, last_seen_at
270
+ head, disk_head, main_head, claude_account, last_seen_at, run_requested, hold_until
271
271
  FROM (SELECT h.*, row_number() OVER (PARTITION BY builder_id ORDER BY last_seen_at DESC) AS rn
272
272
  FROM autonomy_runner_heartbeat h) t
273
273
  WHERE rn <= 20
@@ -302,11 +302,18 @@ async function readAllRunnerScopes() {
302
302
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
303
303
  // case is one write, and `host` is the key so a second runner appears as a second
304
304
  // row rather than silently overwriting the first.
305
- 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 }) {
305
+ //
306
+ // THE DEVICE'S RUN SWITCH IS NEVER WRITTEN FROM HERE (task 1005094), with one
307
+ // exception that is not a write of anyone's choice: `startStopped` decides the
308
+ // value a BRAND-NEW row is born with. A device added through the new setup checks
309
+ // in under its own login and starts stopped, so the first press of Run is the
310
+ // person's; an existing row keeps whatever its builder last chose. The conflict
311
+ // arm does not name run_requested at all, so no check-in can ever move it.
312
+ 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, holdUntil = null, sessionId = null, startStopped = false }) {
306
313
  const { rows } = await pool.query(
307
314
  `INSERT INTO autonomy_runner_heartbeat
308
- (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)
309
- VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, now(), now())
315
+ (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, hold_until, session_id, run_requested, last_seen_at, updated_at)
316
+ VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, $14, $15, $16, now(), now())
310
317
  ON CONFLICT (builder_id, host) DO UPDATE SET
311
318
  pid = EXCLUDED.pid,
312
319
  started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
@@ -321,10 +328,15 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
321
328
  disk_head = EXCLUDED.disk_head,
322
329
  main_head = EXCLUDED.main_head,
323
330
  claude_account = EXCLUDED.claude_account,
331
+ -- Overwritten too: a runner that has stopped resting must stop saying so,
332
+ -- and a device moved back to its builder's CLI session has no login of
333
+ -- its own for Remove to revoke.
334
+ hold_until = EXCLUDED.hold_until,
335
+ session_id = EXCLUDED.session_id,
324
336
  last_seen_at = now(),
325
337
  updated_at = now()
326
- 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`,
327
- [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount]
338
+ RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, hold_until, run_requested, last_seen_at`,
339
+ [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount, holdUntil, sessionId, startStopped !== true]
328
340
  );
329
341
  return rows[0] || null;
330
342
  }
@@ -340,7 +352,8 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
340
352
  async function readRunnerHeartbeats(builderId) {
341
353
  const { rows } = await pool.query(
342
354
  `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
343
- head, disk_head, main_head, claude_account, last_seen_at
355
+ head, disk_head, main_head, claude_account, last_seen_at,
356
+ run_requested, run_changed_at, hold_until, session_id
344
357
  FROM autonomy_runner_heartbeat
345
358
  WHERE builder_id = $1
346
359
  ORDER BY last_seen_at DESC
@@ -351,9 +364,71 @@ async function readRunnerHeartbeats(builderId) {
351
364
  ...r,
352
365
  working_task_id: r.working_task_id ? String(r.working_task_id) : null,
353
366
  working_goal_id: r.working_goal_id ? String(r.working_goal_id) : null,
367
+ // The caller's OWN session id (a bigint, so a string): what "Remove this
368
+ // device" hands to the own-session revoke. Null when the runner has no login
369
+ // of its own.
370
+ session_id: r.session_id ? String(r.session_id) : null,
354
371
  }));
355
372
  }
356
373
 
374
+ // ── each device's Run switch (task 1005094) ──────────────────────────────────
375
+
376
+ const deviceHost = (host) => String(host == null ? '' : host).toLowerCase().slice(0, 255);
377
+
378
+ // readRunnerDevice — one device's switch, for the fence read. Null when that
379
+ // device has never checked in, which the fence reads as "no device layer": a
380
+ // runner's first act is a check-in, so a missing row is a caller that is not one.
381
+ async function readRunnerDevice(builderId, host) {
382
+ const h = deviceHost(host);
383
+ if (!h) return null;
384
+ const { rows } = await pool.query(
385
+ 'SELECT host, run_requested, run_changed_at FROM autonomy_runner_heartbeat WHERE builder_id = $1 AND host = $2',
386
+ [builderId, h]
387
+ );
388
+ return rows[0] || null;
389
+ }
390
+
391
+ // readRunnerDeviceBySession — the device a runner's OWN login checks in from.
392
+ // The session id is the server's (it came off the request's session, never the
393
+ // body or the query), so this is the one way to find a runner's device that the
394
+ // runner cannot steer. Null when that login has not checked in yet.
395
+ async function readRunnerDeviceBySession(builderId, sessionId) {
396
+ if (sessionId == null) return null;
397
+ const { rows } = await pool.query(
398
+ `SELECT host, run_requested, run_changed_at FROM autonomy_runner_heartbeat
399
+ WHERE builder_id = $1 AND session_id = $2
400
+ ORDER BY last_seen_at DESC LIMIT 1`,
401
+ [builderId, sessionId]
402
+ );
403
+ return rows[0] || null;
404
+ }
405
+
406
+ // setRunnerRun — the builder presses Run or Stop on ONE of their own devices.
407
+ // Keyed by the caller's id, so nobody can reach another builder's device. Returns
408
+ // null when there is no such device, so a typo is a 404 and never an orphan row.
409
+ async function setRunnerRun({ builderId, host, run }) {
410
+ const { rows } = await pool.query(
411
+ `UPDATE autonomy_runner_heartbeat
412
+ SET run_requested = $3, run_changed_at = now(), updated_at = now()
413
+ WHERE builder_id = $1 AND host = $2
414
+ RETURNING host, run_requested, run_changed_at`,
415
+ [builderId, deviceHost(host), run === true]
416
+ );
417
+ return rows[0] || null;
418
+ }
419
+
420
+ // removeRunner — forget one of the caller's own devices. Returns the row's
421
+ // session id (or null) so the caller can say whether it had a login of its own;
422
+ // revoking that login is the core's own-session route, not this module's.
423
+ async function removeRunner({ builderId, host }) {
424
+ const { rows } = await pool.query(
425
+ 'DELETE FROM autonomy_runner_heartbeat WHERE builder_id = $1 AND host = $2 RETURNING host, session_id',
426
+ [builderId, deviceHost(host)]
427
+ );
428
+ const r = rows[0];
429
+ return r ? { host: r.host, session_id: r.session_id ? String(r.session_id) : null } : null;
430
+ }
431
+
357
432
  module.exports = {
358
433
  recordAutonomyRun,
359
434
  lastAutonomyHead,
@@ -361,6 +436,10 @@ module.exports = {
361
436
  readRankRoster,
362
437
  recordRunnerHeartbeat,
363
438
  readRunnerHeartbeats,
439
+ readRunnerDevice,
440
+ readRunnerDeviceBySession,
441
+ setRunnerRun,
442
+ removeRunner,
364
443
  readFence,
365
444
  setFenceEnabled,
366
445
  allowGoal,
@@ -55,10 +55,11 @@ const REFUSALS = {
55
55
  builder_paused: 'the owner paused your runner',
56
56
  no_builder_scope: 'you have not picked any goals for your runner — choose them in Settings',
57
57
  pin_move: 'the worker would be told to move a live core pin, and going live always waits for a person',
58
+ device_stopped: 'this device is stopped — press Run on its card in Settings',
58
59
  };
59
60
 
60
61
  // The stop codes effectiveFence may set, and the refusal each one becomes.
61
- const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
62
+ const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused', 'device_stopped']);
62
63
 
63
64
  // effectiveFence — the fence ONE builder's runner reads (task 1004501).
64
65
  //
@@ -79,7 +80,16 @@ const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
79
80
  // ({ goals:[ids], owner_paused, owner_paused_reason, updated_at }) or null for a
80
81
  // builder with no row; `mayRun` is the autonomy.run answer, where anything but
81
82
  // `true` refuses. Returns null when the project fence is null (unreadable).
82
- function effectiveFence({ project, scope = null, mayRun = false } = {}) {
83
+ //
84
+ // THE DEVICE'S OWN SWITCH is the last layer (task 1005094, owner direction
85
+ // 2026-11-09: one Run button per device). `device` is db.readRunnerDevice()'s row
86
+ // for the host the runner named, or null — no host named (a runner that predates
87
+ // this), or a device that has never checked in. Only an explicit
88
+ // `run_requested === false` stops it, so a caller that names no device is exactly
89
+ // as fenced as before. It comes last because it is the builder's own choice about
90
+ // one machine: every stop above it is someone else's decision or the project's,
91
+ // and the reason a runner reports should be the widest one that applies.
92
+ function effectiveFence({ project, scope = null, mayRun = false, device = null } = {}) {
83
93
  if (!project || typeof project !== 'object') return null;
84
94
  const picks = new Set(((scope && scope.goals) || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0));
85
95
  const projectGoals = Array.isArray(project.goals) ? project.goals : [];
@@ -96,11 +106,13 @@ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
96
106
  } else if (enabled && scope && scope.owner_paused === true) {
97
107
  enabled = false; stopCode = 'builder_paused';
98
108
  pausedReason = scope.owner_paused_reason ? `${REFUSALS.builder_paused}: ${scope.owner_paused_reason}` : REFUSALS.builder_paused;
109
+ } else if (enabled && device && device.run_requested === false) {
110
+ enabled = false; stopCode = 'device_stopped'; pausedReason = REFUSALS.device_stopped;
99
111
  }
100
112
 
101
- // The later of the two rows, so a pick or a pause wakes a runner that waits on
102
- // the fence's updated_at exactly as a project change does.
103
- const stamps = [project.updated_at, scope && scope.updated_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
113
+ // The latest of the rows, so a pick, a pause or a press of Run wakes a runner
114
+ // that waits on the fence's updated_at exactly as a project change does.
115
+ const stamps = [project.updated_at, scope && scope.updated_at, device && device.run_changed_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
104
116
  const updatedAt = stamps.length ? new Date(Math.max(...stamps)).toISOString() : (project.updated_at || null);
105
117
 
106
118
  return {
@@ -119,6 +131,9 @@ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
119
131
  owner_paused: !!(scope && scope.owner_paused === true),
120
132
  owner_paused_reason: (scope && scope.owner_paused_reason) || null,
121
133
  },
134
+ // The device the runner named and where its switch stands, so the runner's
135
+ // log can say which machine a stop was about. Null when it named none.
136
+ device: device ? { host: device.host || null, run_requested: device.run_requested !== false } : null,
122
137
  // The unnarrowed project fence, for the owner's gate page.
123
138
  project,
124
139
  };
@@ -116,7 +116,12 @@ function reserveFor(nowEpochS, config = {}) {
116
116
  // decide identically and a caller that has to enumerate them will miss one.
117
117
  function readGauge(raw, nowEpochS, config = {}) {
118
118
  const c = cfg(config);
119
- if (!raw || typeof raw !== 'object') return { known: false, reason: 'no gauge file — the statusline writer has never run' };
119
+ // `absent` marks the one shape that is not a bad reading but NO reading: this
120
+ // device has never had a gauge at all (see decideCapacity, task 1005095).
121
+ // Only NOTHING is absent. A file that is there but is not a gauge — torn, or
122
+ // not JSON — is a bad reading like any other, and must never read as "none".
123
+ if (raw === null || raw === undefined) return { known: false, absent: true, reason: 'no gauge file — the statusline writer has never run' };
124
+ if (typeof raw !== 'object') return { known: false, reason: 'the gauge file is there but cannot be read as a gauge' };
120
125
 
121
126
  const writtenAt = Number(raw.written_at);
122
127
  if (!Number.isFinite(writtenAt)) return { known: false, reason: 'gauge file has no readable written_at' };
@@ -239,6 +244,25 @@ function decideCapacity({ gauge, now, config = {} } = {}) {
239
244
  const c = cfg(config);
240
245
  const g = readGauge(gauge, now, c);
241
246
 
247
+ // A DEVICE WITH NO GAUGE AT ALL (task 1005095, owner direction 2026-11-09: the
248
+ // runner must run on any device from one button). The gauge's only writer is a
249
+ // terminal statusLine, so a laptop whose owner uses the desktop app, or a PC
250
+ // set up five minutes ago, has no file and never will — and "unknown, so hold"
251
+ // left such a runner armed, checking in and idle forever. The owner already
252
+ // ruled (2026-09-30, task 1004454) that running until the usage limit is hit
253
+ // and resuming at its reset is acceptable; a device with no gauge is that same
254
+ // bargain with nothing to read first. So when the CALLER asks for it
255
+ // (`absentMeansGo`, which only the supervisor passes), no file is a go, marked
256
+ // `absent` and `derived` so the log says which kind of go it was. Everything
257
+ // else that cannot be vouched for — malformed, future-dated, aged out inside a
258
+ // window still running — is still UNKNOWN, and the default is unchanged: the
259
+ // gauge CLI and any other caller never default to go.
260
+ if (!g.known && g.absent && c.absentMeansGo === true) {
261
+ return {
262
+ go: true, unknown: false, sleepUntil: null, absent: true, derived: true, derivedSince: null,
263
+ reason: 'no usage gauge on this device — working until the usage limit says stop, then waiting for its reset',
264
+ };
265
+ }
242
266
  if (!g.known) return { go: false, unknown: true, sleepUntil: null, reason: g.reason };
243
267
 
244
268
  if (g.sevenDay) {
@@ -0,0 +1,45 @@
1
+ -- autonomy_006_runner_device.sql — each device's own Run switch, and what its
2
+ -- button says while it waits (task 1005094, goal 1000139).
3
+ --
4
+ -- Owner direction, 2026-11-09: running the runner on a device is ONE button on
5
+ -- that device's card, and any cooldown is shown on the button itself. Until now
6
+ -- a builder had no switch of their own at all: a set-up device ran whenever the
7
+ -- project switch and their goal picks allowed it, on every device at once.
8
+ --
9
+ -- run_requested the device's switch. The builder flips it from Settings; the
10
+ -- runner reads it as part of its fence (GET /autonomy/fence
11
+ -- ?host=) and cannot write it — the routes that change it
12
+ -- refuse a runner's own login, so a worker can never press Run
13
+ -- for itself after its builder pressed Stop.
14
+ -- DEFAULT true ON PURPOSE: every runner that already exists
15
+ -- keeps working when this lands. A device added through the
16
+ -- new setup is written false by its first check-in (the route
17
+ -- does that, for a runner's own login only), so the first
18
+ -- press of Run is the person's.
19
+ -- run_changed_at when the switch last moved. It feeds the fence's updated_at,
20
+ -- which a waiting runner polls, so a press reaches the device
21
+ -- in under a minute instead of at its next idle wake; the hall
22
+ -- compares it with last_seen_at to show "Starting".
23
+ -- hold_until when a resting runner will resume: the reset time of the
24
+ -- usage limit or capacity hold it is sleeping to, as the runner
25
+ -- itself reports it. Null when it is not resting. The button
26
+ -- shows this time; it decides nothing.
27
+ -- session_id the runner's OWN login (builder_sessions.id), recorded from
28
+ -- the session that checked in — never from the body — when
29
+ -- that session is a runner's. "Remove this device" revokes
30
+ -- exactly that login. Null for a runner still on its builder's
31
+ -- CLI session, which Remove must not revoke.
32
+ --
33
+ -- No foreign key on session_id: a revoked session's row may be swept, and a
34
+ -- check-in row that outlives it must simply read as "no login of its own".
35
+ -- Additive and idempotent.
36
+
37
+ BEGIN;
38
+
39
+ ALTER TABLE autonomy_runner_heartbeat
40
+ ADD COLUMN IF NOT EXISTS run_requested boolean NOT NULL DEFAULT true,
41
+ ADD COLUMN IF NOT EXISTS run_changed_at timestamptz,
42
+ ADD COLUMN IF NOT EXISTS hold_until timestamptz,
43
+ ADD COLUMN IF NOT EXISTS session_id bigint;
44
+
45
+ COMMIT;
@@ -9,7 +9,8 @@
9
9
  "maintenance": { "status": "core-maintained" },
10
10
  "contributes": {
11
11
  "routes": ["autonomy"],
12
- "pollers": ["routine-timers", "daily-checks"]
12
+ "pollers": ["routine-timers", "daily-checks"],
13
+ "skills": ["autobongos-setup"]
13
14
  },
14
15
  "provides": [],
15
16
  "consumes": []
@@ -18,6 +18,8 @@
18
18
  // may not do. This route is a thin wrapper over its `precheck`.
19
19
 
20
20
  const express = require('express');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
21
23
  // The published doorway — requireBuilder / requireRank live on the doorway surface
22
24
  // (a module never reaches into core auth directly; the one-way boundary, ADR 0083).
23
25
  const api = require('../../../src/module-api');
@@ -102,6 +104,40 @@ function commitOrNull(v) {
102
104
  return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
103
105
  }
104
106
 
107
+ // holdUntilOrNull — the time a resting runner says it resumes (task 1005094), as a
108
+ // Date, or null. The runner sends epoch SECONDS. Kept only inside a window a real
109
+ // rest can fall in: a usage window is at most seven days, so anything further out,
110
+ // or more than a day in the past, is a clock or a bug and is dropped rather than
111
+ // printed on a button.
112
+ function holdUntilOrNull(v, nowMs = Date.now()) {
113
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
114
+ const ms = v * 1000;
115
+ if (ms < nowMs - 24 * 3600 * 1000 || ms > nowMs + 8 * 24 * 3600 * 1000) return null;
116
+ return new Date(ms);
117
+ }
118
+
119
+ // A device name as a path or query value: what os.hostname() can be, and no more.
120
+ // The row key is the lowercased name, so that is what is returned.
121
+ function hostOrNull(v) {
122
+ if (typeof v !== 'string') return null;
123
+ const h = v.trim().toLowerCase();
124
+ return h && h.length <= 255 && /^[a-z0-9._ -]+$/.test(h) ? h : null;
125
+ }
126
+
127
+ // The two first lines of setup (task 1005095), served as text. They live under
128
+ // scripts/ with the rest of the runner's device program; this module only hands
129
+ // them out, read once per process.
130
+ const BOOTSTRAP_DIR = path.join(__dirname, '..', '..', '..', 'scripts', 'bongos');
131
+ const bootstrapCache = new Map();
132
+ function bootstrapScript(name) {
133
+ if (!bootstrapCache.has(name)) {
134
+ let text = null;
135
+ try { text = fs.readFileSync(path.join(BOOTSTRAP_DIR, name), 'utf8'); } catch (_) { text = null; }
136
+ bootstrapCache.set(name, text);
137
+ }
138
+ return bootstrapCache.get(name);
139
+ }
140
+
105
141
  // The governor's docket (task 1004523): the fence + the money reading,
106
142
  // contributed from this factory (see ../governor-docket.js).
107
143
  const governorDocket = require('../governor-docket');
@@ -182,17 +218,50 @@ module.exports = function buildAutonomyRouter() {
182
218
  // narrowed to their own picks (fence.js effectiveFence). Computing it here keeps
183
219
  // the runner dumb and means a runner that predates this change is narrowed too.
184
220
  // The unnarrowed project fence rides along as `project` for the owner's page.
221
+ //
222
+ // AND THE CALLER'S DEVICE (task 1005094). A runner names the machine it is on
223
+ // (?host=), and that device's own Run switch is the last layer: stopped there,
224
+ // the answer is enabled:false / stop_code device_stopped, whatever every other
225
+ // layer says. A caller that names no host — a runner that predates this, the
226
+ // Settings page — gets no device layer and is exactly as fenced as before. A
227
+ // host that will not parse is treated as not named, never as an error: the one
228
+ // thing this read must not do is fail a runner into "unreadable" over a name.
229
+ //
230
+ // ONE MORE READ, knowingly. holdsPermission above was written so that this
231
+ // route, which every runner asks on every iteration and every waiting poll,
232
+ // adds no query for the permission. The device layer does add one: a single
233
+ // row by primary key (builder, host), or by (builder, session) for a runner
234
+ // login, run beside the other reads rather than after them. It is paid only by
235
+ // a caller that names a device or is a runner login; the Settings page and a
236
+ // runner that predates this pay nothing new.
237
+ //
238
+ // A RUNNER'S OWN LOGIN DOES NOT GET TO CHOOSE. For such a session the device is
239
+ // the one that login checks in from, found by the session id the server holds;
240
+ // the name it sends is not consulted. Otherwise a runner whose builder pressed
241
+ // Stop could read its fence without a name, or under another device's, and be
242
+ // told to carry on. And a runner login with no check-in yet reads as STOPPED:
243
+ // its row will be born stopped anyway, and a login that has not said which
244
+ // device it is must not be answered as if it had none.
185
245
  router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
186
246
  try {
187
- const [project, scope, mayRun] = await Promise.all([
188
- db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'),
247
+ const host = hostOrNull(req.query && req.query.host);
248
+ const runnerLogin = req.builder.runner === true;
249
+ const sessionId = req.gdsSession && req.gdsSession.session_id != null ? req.gdsSession.session_id : null;
250
+ const readDevice = async () => {
251
+ if (runnerLogin) {
252
+ return (await db.readRunnerDeviceBySession(req.builder.id, sessionId)) || { host, run_requested: false, run_changed_at: null };
253
+ }
254
+ return host ? db.readRunnerDevice(req.builder.id, host) : null;
255
+ };
256
+ const [project, scope, mayRun, device] = await Promise.all([
257
+ db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'), readDevice(),
189
258
  ]);
190
259
  // Null means the singleton row is missing, i.e. the migration has not run.
191
260
  // Say so rather than synthesising a default: a fabricated { enabled: false }
192
261
  // is indistinguishable from a real one, and an operator would chase a kill
193
262
  // switch nobody threw.
194
263
  if (!project) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
195
- res.json(fence.effectiveFence({ project, scope, mayRun }));
264
+ res.json(fence.effectiveFence({ project, scope, mayRun, device }));
196
265
  } catch (err) {
197
266
  log.error('[gds] GET /autonomy/fence', err);
198
267
  // FAIL CLOSED, unlike the precheck above. That one is fail-open because an
@@ -378,8 +447,14 @@ module.exports = function buildAutonomyRouter() {
378
447
  disk_head: { type: 'string', maxLength: 40 },
379
448
  main_head: { type: 'string', maxLength: 40 },
380
449
  claude_account: { type: 'string', maxLength: 200 },
450
+ // When a resting runner resumes, in epoch seconds (task 1005094).
451
+ hold_until: { type: 'number' },
381
452
  })) return;
382
453
  const b = req.body || {};
454
+ // The runner's OWN login, when that is what checked in (task 1005094). From
455
+ // the session, never the body: it is what "Remove this device" revokes, and a
456
+ // caller must not be able to point that at a session of their choosing.
457
+ const ownLogin = req.builder.runner === true && req.gdsSession && req.gdsSession.session_id != null;
383
458
  try {
384
459
  const row = await db.recordRunnerHeartbeat({
385
460
  // From the SESSION, never the body. A caller cannot name someone else's
@@ -399,6 +474,11 @@ module.exports = function buildAutonomyRouter() {
399
474
  diskHead: commitOrNull(b.disk_head),
400
475
  mainHead: commitOrNull(b.main_head),
401
476
  claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
477
+ holdUntil: holdUntilOrNull(b.hold_until),
478
+ sessionId: ownLogin ? req.gdsSession.session_id : null,
479
+ // A device set up the new way starts STOPPED, so the first press of Run is
480
+ // the person's. Only ever the birth value of a new row (db.js).
481
+ startStopped: ownLogin,
402
482
  });
403
483
  res.json({ ok: true, heartbeat: row });
404
484
  // AFTER the answer, and never awaited by it: a notice that cannot be posted
@@ -441,5 +521,87 @@ module.exports = function buildAutonomyRouter() {
441
521
  }
442
522
  });
443
523
 
524
+ // ── each device's Run button (task 1005094) ────────────────────────────────
525
+ //
526
+ // Owner direction, 2026-11-09: running the runner on a device is ONE button on
527
+ // that device's card. These two routes are that button and its Remove.
528
+ //
529
+ // NEITHER ANSWERS A RUNNER'S OWN LOGIN. A worker holds that login and runs with
530
+ // blanket permissions; if it could reach these, a runner whose builder pressed
531
+ // Stop could press Run for itself, or delete its own row and be born again. So
532
+ // both refuse `req.builder.runner` before anything is read. The builder's own
533
+ // browser or CLI session is the only thing that moves the switch.
534
+ const refuseRunnerLogin = (req, res) => {
535
+ if (req.builder && req.builder.runner === true) {
536
+ res.fail('runner_session', { status: 403, message: 'A runner cannot start, stop or remove itself. Use its card in Settings, signed in as yourself.' });
537
+ return true;
538
+ }
539
+ return false;
540
+ };
541
+
542
+ // Run or Stop ONE of the caller's own devices. Behind `autonomy.run`, like the
543
+ // goal picks: a builder who may not run a runner has nothing to start. Stopping
544
+ // is allowed to the same people and no wider — it is their own machine.
545
+ router.post('/autonomy/runners/:host/run', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
546
+ if (refuseRunnerLogin(req, res)) return;
547
+ if (validateOrRespond(req, res, { run: { required: true, type: 'boolean' } })) return;
548
+ const host = hostOrNull(req.params.host);
549
+ if (!host) return res.fail('bad_host', { status: 400, message: 'that is not a device name' });
550
+ try {
551
+ const row = await db.setRunnerRun({ builderId: req.builder.id, host, run: req.body.run });
552
+ if (!row) return res.fail('device_not_found', { status: 404, message: 'You have no device by that name. It appears once its runner has checked in.' });
553
+ log.info(`[gds] autonomy: builder ${req.builder.id} pressed ${req.body.run ? 'RUN' : 'STOP'} on device ${host}`);
554
+ res.json({ host: row.host, run_requested: row.run_requested, run_changed_at: row.run_changed_at });
555
+ } catch (err) {
556
+ log.error('[gds] POST /autonomy/runners/:host/run', err);
557
+ res.fail('run_write_failed', { status: 500, message: 'internal error' });
558
+ }
559
+ });
560
+
561
+ // Forget one of the caller's own devices. ANY-BUILDER, deliberately wider than
562
+ // Run: a builder who has since lost `autonomy.run` must still be able to clear
563
+ // their own device off their own page, and the row is keyed by the caller, so
564
+ // the only thing reachable is theirs. It does not revoke the device's login —
565
+ // that is the core's own-session route (POST /me/sessions/revoke), which the
566
+ // Settings page calls first with the `session_id` GET /autonomy/runners gave it.
567
+ // `had_own_login` says whether there was one to revoke.
568
+ router.delete('/autonomy/runners/:host', auth.requireBuilder, async (req, res) => {
569
+ if (refuseRunnerLogin(req, res)) return;
570
+ const host = hostOrNull(req.params.host);
571
+ if (!host) return res.fail('bad_host', { status: 400, message: 'that is not a device name' });
572
+ try {
573
+ const out = await db.removeRunner({ builderId: req.builder.id, host });
574
+ if (!out) return res.fail('device_not_found', { status: 404, message: 'You have no device by that name.' });
575
+ log.info(`[gds] autonomy: builder ${req.builder.id} removed device ${host}`);
576
+ res.json({ removed: true, host: out.host, had_own_login: !!out.session_id });
577
+ } catch (err) {
578
+ log.error('[gds] DELETE /autonomy/runners/:host', err);
579
+ res.fail('remove_failed', { status: 500, message: 'internal error' });
580
+ }
581
+ });
582
+
583
+ // The first lines of setup, one per platform (task 1005095). The command the
584
+ // Settings page shows fetches one of these and runs it with the one-time code.
585
+ //
586
+ // Not left open-ended: like every read with no limiter of its own they sit under
587
+ // the server's default per-address read ceiling, and each answer is a string
588
+ // already in memory (read once per process), so a flood costs no disk and no
589
+ // database.
590
+ //
591
+ // rank: public — a clean device has no login yet, which is the point of setup.
592
+ // The files hold no secret: they are the same text as scripts/bongos/ in the
593
+ // repository, they take the code, the project address and the repository as
594
+ // arguments, and nothing about this instance is written into them.
595
+ const sendBootstrap = (file, type) => (req, res) => {
596
+ const text = bootstrapScript(file);
597
+ if (!text) return res.fail('setup_script_missing', { status: 503, message: 'this instance cannot serve the runner setup script' });
598
+ res.set('Cache-Control', 'no-store');
599
+ res.type(type).send(text);
600
+ };
601
+ // rank: public — the Windows first lines; see the note above.
602
+ router.get('/autonomy/runner-setup.ps1', sendBootstrap('autobongos-bootstrap.ps1', 'text/plain; charset=utf-8'));
603
+ // rank: public — the Mac first lines; see the note above.
604
+ router.get('/autonomy/runner-setup.sh', sendBootstrap('autobongos-bootstrap.sh', 'text/x-shellscript; charset=utf-8'));
605
+
444
606
  return router;
445
607
  };
@@ -30,6 +30,16 @@
30
30
  // it carries the fix as `remedy`. The runner decides the grace and says
31
31
  // `config_idle:<code>` on its heartbeat; only a code config-idle.js knows is
32
32
  // honoured, so the owner's kill switch and pause can never read as an alarm.
33
+ // · stopped — alive, and its builder pressed Stop on this device (task
34
+ // 1005094). Not an alarm and not "running": the device is doing what it was
35
+ // told, and its card shows Run. Only a runner that is still checking in can
36
+ // be stopped; a silent one is late or dead first, because a device nobody
37
+ // has heard from cannot vouch for anything, its switch included.
38
+ //
39
+ // Every state also carries `runRequested` (the device's switch, true unless its
40
+ // builder turned it off) and `holdUntil` (when a resting runner resumes, as the
41
+ // runner reported it — only while that time is still ahead), so the Run button
42
+ // can show Stop, Starting or a rest time without a second read.
33
43
 
34
44
  // Reasoned from the runner's own numbers, not picked. The loop beats at the top of
35
45
  // every pass and again on each poll inside a wait (every 60s), so a healthy runner
@@ -118,10 +128,17 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
118
128
  const mode = heartbeat.mode === 'probing' ? 'probing' : (heartbeat.mode === 'full' ? 'full' : null);
119
129
  const working = heartbeat.working_task_id ? String(heartbeat.working_task_id) : null;
120
130
  const where = host ? ` on ${host}` : '';
131
+ const runRequested = heartbeat.run_requested !== false;
132
+ // A rest the runner named, kept only while it is still ahead: a time already
133
+ // past is a runner that has resumed, or one that has gone quiet, and either
134
+ // way it is no longer what the button should say.
135
+ const holdMs = heartbeat.hold_until ? Date.parse(heartbeat.hold_until instanceof Date ? heartbeat.hold_until.toISOString() : heartbeat.hold_until) : NaN;
121
136
  const ident = {
122
137
  host, mode, workingTaskId: working,
123
138
  account: typeof heartbeat.claude_account === 'string' && heartbeat.claude_account ? heartbeat.claude_account : null,
124
139
  code: describeCode(heartbeat),
140
+ runRequested,
141
+ holdUntil: Number.isFinite(holdMs) && holdMs > nowMs ? new Date(holdMs).toISOString() : null,
125
142
  };
126
143
 
127
144
  if (heartbeat.last_event === 'upgrade_exit') {
@@ -149,6 +166,16 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
149
166
  };
150
167
  }
151
168
 
169
+ // Stopped on this device by its builder, and not mid-task: the card shows Run.
170
+ // A runner still building when Stop was pressed finishes that task first, and
171
+ // reads as the working runner it is until then.
172
+ if (!runRequested && !working && age <= aliveS) {
173
+ return {
174
+ state: 'stopped', ok: true, ageS: age, age: humanAge(age), ...ident,
175
+ detail: `stopped${where} — press Run on its card to start it`,
176
+ };
177
+ }
178
+
152
179
  if (age <= aliveS) {
153
180
  // A setting still inside its grace reads as the fence wait it is, not "running".
154
181
  const doing = ACTIVITY[heartbeat.last_event] || (configIdle.pendingCode(heartbeat.last_event) ? ACTIVITY.fenced : null);