@bongos/core 1.20.13 → 1.20.15

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.13",
6
- "core_contract": "1.20.13",
7
- "source_commit": "1e8134c4bbd7d8623139bfd31ba2701c5bf120ac",
5
+ "core_version": "1.20.15",
6
+ "core_contract": "1.20.15",
7
+ "source_commit": "139b563191ef97af7e538f783d3fb256baa6c075",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T11:48:47.948Z",
9
+ "built_at": "2026-09-30T12:16:56.172Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 556,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2626,
14
+ "functional_verbatim": 2628,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3208,
20
- "tree_sha256": "ae284b5e2f56e08cb0b29b13e77a015dc35db8f5be3a99a96a731704c7da0053",
19
+ "file_count": 3210,
20
+ "tree_sha256": "814cbc7e5df7346b441a3ac6faa4217852105b51708505d98b9e090d68bd8b6a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "a0725bf013ddc823d06b866f5008497dddf1bf63e2e22852cd6eb79525ce5052"
2775
+ "sha256": "05833e301ee659bde426bdae2d327183b03a6e40b08230a07ce99ba8ccef9705"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -2882,7 +2882,7 @@
2882
2882
  {
2883
2883
  "path": "docs/recipes/autobongos-windows-host.md",
2884
2884
  "mode": "0000644",
2885
- "sha256": "566cf83d1f58fb41d211e9db7cd7d616ae4c2186f1b308baef805ad112bc9aab"
2885
+ "sha256": "e70faa969725827307388ccb0620bde203500333ed299d860ca7a001cc8577a1"
2886
2886
  },
2887
2887
  {
2888
2888
  "path": "docs/recipes/bongos-cli-release.md",
@@ -8862,12 +8862,12 @@
8862
8862
  {
8863
8863
  "path": "package-lock.json",
8864
8864
  "mode": "0000644",
8865
- "sha256": "09eb2f9f11cdcb5cb24ccddee43c9d09d5e1135a452e0827542ea96d70b2e85f"
8865
+ "sha256": "cc8381c4866cb96ae92e56199b4666a515f8c4fb6aa0214fe332a2c960f5c9df"
8866
8866
  },
8867
8867
  {
8868
8868
  "path": "package.json",
8869
8869
  "mode": "0000644",
8870
- "sha256": "c80938c396a4982970d0850791d002ea2f5c49b93c8e75f6066c33c77bc7ae78"
8870
+ "sha256": "5125b2ece4cbf29c1c89b0eec97b4bce224fa0b25c8b3351e0b6253ade41c8e5"
8871
8871
  },
8872
8872
  {
8873
8873
  "path": "public-docs/index.html",
@@ -8887,7 +8887,7 @@
8887
8887
  {
8888
8888
  "path": "release-notes.json",
8889
8889
  "mode": "0000644",
8890
- "sha256": "a3de1adc96eea0bd98814403baf635fc2523506d45ca0df0ae263c917b324757"
8890
+ "sha256": "d12e72f6946fb8c0884d7b014d8673e58314ad5ff4d01593d397f5d96d8c8a6e"
8891
8891
  },
8892
8892
  {
8893
8893
  "path": "scripts/bongos-mcp.js",
@@ -9047,7 +9047,7 @@
9047
9047
  {
9048
9048
  "path": "scripts/gds/autobongos-run.js",
9049
9049
  "mode": "0000644",
9050
- "sha256": "5467681b59e569c4d7fccd4ed6ea93cd598a26ddd2fd42c384375f37ae0243ea"
9050
+ "sha256": "c8e6967fe75be8573e65c1fd73caec6912aeb75648dfdb3c82a04cd7bc7f6a6d"
9051
9051
  },
9052
9052
  {
9053
9053
  "path": "scripts/gds/autobongos-service.cmd",
@@ -9057,7 +9057,7 @@
9057
9057
  {
9058
9058
  "path": "scripts/gds/autobongos-service.ps1",
9059
9059
  "mode": "0000644",
9060
- "sha256": "ed95f836655535827c14a32caf197fee7c238e15d8e8bf7a9e987b78ab266887"
9060
+ "sha256": "b1e038bb70ace1652b85a4d29f5a68761c6b432fd50fbfdafb5e1e38d93627de"
9061
9061
  },
9062
9062
  {
9063
9063
  "path": "scripts/gds/autobongos-verify.js",
@@ -11002,7 +11002,7 @@
11002
11002
  {
11003
11003
  "path": "src/module-api.js",
11004
11004
  "mode": "0000644",
11005
- "sha256": "20017aec62763445738eeed4b8c1d23cbe6789a77223992022cb4c9e7747111c"
11005
+ "sha256": "d97c483c3599928877210a4ceea2c9a384cf61dc86d1fec156de8a95dce743e5"
11006
11006
  },
11007
11007
  {
11008
11008
  "path": "src/module-loader/catalog.js",
@@ -11437,18 +11437,28 @@
11437
11437
  {
11438
11438
  "path": "tests/autobongos_cadence.mjs",
11439
11439
  "mode": "0000644",
11440
- "sha256": "32ad1726b5af44707dde1d01425bf6435671685ab8077185e8ee83a6d7ef248a"
11440
+ "sha256": "98f86c0f7cb84ecfe955f74a066326d82e8af1008c17d0a28c0ad40042f876b8"
11441
11441
  },
11442
11442
  {
11443
11443
  "path": "tests/autobongos_fence.mjs",
11444
11444
  "mode": "0000644",
11445
11445
  "sha256": "00709f1c010f594c457b9bdb49bd0d7377cebdb140e080869a8f7458960841ff"
11446
11446
  },
11447
+ {
11448
+ "path": "tests/autobongos_heartbeat_head.mjs",
11449
+ "mode": "0000644",
11450
+ "sha256": "f750e0978dc811420e61ac2ae7be4abd896631447c47004b1ab662e742ec3ac3"
11451
+ },
11447
11452
  {
11448
11453
  "path": "tests/autobongos_loop.mjs",
11449
11454
  "mode": "0000644",
11450
11455
  "sha256": "e14d2c211c28ebada8e915d0d336989e74410afb68924d9b89dc8efd666457c8"
11451
11456
  },
11457
+ {
11458
+ "path": "tests/autobongos_service_ps1.mjs",
11459
+ "mode": "0000644",
11460
+ "sha256": "34ca7d75bc83d9c4a77706dd06ecc170011ef52b7ee77eea5c3b77ac514389d0"
11461
+ },
11452
11462
  {
11453
11463
  "path": "tests/autobongos_verify.mjs",
11454
11464
  "mode": "0000644",
@@ -2683,5 +2683,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2683
2683
  landed since 1.20.11 with no explicit bump. run 36709211337. (task 1002620)
2684
2684
  1.20.13 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2685
2685
  landed since 1.20.12 with no explicit bump. run 36710762884. (task 1002620)
2686
+ 1.20.14 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2687
+ landed since 1.20.13 with no explicit bump. run 36712510525. (task 1002620)
2688
+ 1.20.15 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2689
+ landed since 1.20.14 with no explicit bump. run 36713710510. (task 1002620)
2686
2690
  ---------------------------------------------------------------------------
2687
2691
  ```
@@ -18,7 +18,7 @@ Task [1003905](https://cloudbongos.com/builders#/task/1003905).
18
18
 
19
19
  ## What you need on the box first
20
20
 
21
- Open PowerShell as your normal user (not Administrator) and check all three:
21
+ Open PowerShell as your own user and check all three:
22
22
 
23
23
  ```powershell
24
24
  node --version ; git --version ; claude --version
@@ -28,7 +28,7 @@ All three must print a version. The installer refuses to proceed otherwise —
28
28
  a runner that starts without `claude` on its PATH fails every worker and looks
29
29
  like a mysterious outage in the log rather than a missing program.
30
30
 
31
- You also need, as **your own user** (not an administrator account):
31
+ You also need, as **your own user** (not a separate administrator account):
32
32
 
33
33
  - a `git` credential that can fetch the repo,
34
34
  - a signed-in Claude Code session,
@@ -53,13 +53,29 @@ machine can write, the worker running on it can write too.
53
53
 
54
54
  ## 2. Install the scheduled task
55
55
 
56
- From the checkout:
56
+ **This step, and only this step, needs an Administrator shell.** Right-click
57
+ PowerShell, choose **Run as administrator**, and sign in to the prompt as the
58
+ **same user** you checked the prerequisites with — then, from the checkout:
57
59
 
58
60
  ```powershell
59
61
  powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 install
60
62
  powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 start
61
63
  ```
62
64
 
65
+ Why admin, when the runner deliberately is not: two different things happen
66
+ here. **Registering** the task needs admin, because it uses the S4U logon type
67
+ (run whether or not anyone is signed in), and Windows only lets an elevated
68
+ shell register one — from a normal shell `install` stops and says so, rather
69
+ than failing with a bare *Access is denied*. **Running** the task does not: it
70
+ runs as you, unelevated (`-RunLevel Limited`), which is what lets it use your
71
+ git credential and Claude session. Elevating the same account keeps your
72
+ username, so the task is still registered to run as you.
73
+
74
+ Do **not** install it as SYSTEM, or from a different administrator account:
75
+ the task would run as that account, which has none of your credentials. If
76
+ your own account is not an administrator on this PC, make it one for the
77
+ install rather than elevating as someone else.
78
+
63
79
  That registers a task called `Autobongos` that starts **at boot** (not at sign-in
64
80
  — nobody should have to log in), restarts if it crashes, and never times out.
65
81
 
@@ -134,8 +150,16 @@ Use the **kill switch in the hall**, not the scheduled task. The switch survives
134
150
  a reboot and cannot be undone by anything running on the machine; stopping the
135
151
  task only stops this run, and the next boot starts it again.
136
152
 
137
- `autobongos-service.ps1 stop` exists for maintenance on the box itself and says
138
- this when you use it.
153
+ `autobongos-service.ps1 stop` exists for maintenance on the box itself. It asks
154
+ Task Scheduler to stop the task, then **watches the runner's own process** (the
155
+ pid in its heartbeat file) and tells you which of these happened:
156
+
157
+ | It says | Means |
158
+ |---|---|
159
+ | `stopped ... has exited` | the process was watched to exit. The next boot starts it again |
160
+ | `NOT STOPPED ... still running after Ns` | the process outlived the stop (exit code 1). It runs under a different logon session (S4U) that a normal shell cannot end — `Stop-Process` fails the same way. From an **Administrator** PowerShell run the `taskkill /F /PID <pid>` it prints, or reboot. Until then `start` does nothing, because the task will not launch a second copy over a live one |
161
+ | `was not running` | the heartbeat's process had already exited |
162
+ | `CANNOT CONFIRM` | there is no heartbeat pid to check; look with `Get-Process node` |
139
163
 
140
164
  ## Keeping the checkout current
141
165
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.13",
3
+ "version": "1.20.15",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.13",
9
+ "version": "1.20.15",
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.13",
3
+ "version": "1.20.15",
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",
@@ -8130,5 +8130,21 @@
8130
8130
  "id": "1004180",
8131
8131
  "text": "When the unattended runner fails to pick up a task, its log now always says why, starting with the sentence that explains it, instead of an empty line or a fragment cut off mid-word."
8132
8132
  }
8133
+ ],
8134
+ "1.20.14": [
8135
+ {
8136
+ "id": "1004375",
8137
+ "text": "Stopping the Windows runner now checks that it really stopped, and when it could not, says so and tells you exactly how to finish the job."
8138
+ },
8139
+ {
8140
+ "id": "1004397",
8141
+ "text": "The unattended runner now records which version of the code it is running from its very first check-in, instead of leaving that blank for its first minute, which is exactly when someone is trying to find out."
8142
+ }
8143
+ ],
8144
+ "1.20.15": [
8145
+ {
8146
+ "id": "1004177",
8147
+ "text": "Installing the Windows runner now tells you up front that it needs an administrator window, and why, instead of failing with a bare \"access denied\"; the setup guide no longer says the opposite."
8148
+ }
8133
8149
  ]
8134
8150
  }
@@ -876,6 +876,14 @@ async function forever(opts, deps = {}) {
876
876
  try { beatFile(); } catch (_) { /* a runner that cannot write its file still runs */ }
877
877
  try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
878
878
  };
879
+ // The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
880
+ // to be read lazily by codeDrifted(), which sits behind the one-minute uptime
881
+ // floor, so every beat in a process's first minute said head:null — the window
882
+ // in which a crash-looping or repeatedly-restarted runner is being looked at.
883
+ // The floor exists to stop a restart STORM, not to avoid this read: one local
884
+ // rev-parse. Fails soft to null inside loadedHead(), and a null is retried by
885
+ // the drift check later, so an unreadable head never stops the runner.
886
+ try { await (deps.loadedHead ? deps.loadedHead(deps) : loadedHead(deps)); } catch (_) { /* head stays null */ }
879
887
  await beat({ last_event: 'boot' });
880
888
 
881
889
  // BEFORE ANY WORK. A claim left locked by a previous run is the worst thing
@@ -25,7 +25,8 @@
25
25
  # UNPRIVILEGED ON PURPOSE. It runs as the logged-in user, not SYSTEM: the runner
26
26
  # needs that user's git credential, npm config and Claude session, and SYSTEM has
27
27
  # none of them. It is also the smaller blast radius for a process that spawns
28
- # workers with blanket bypassPermissions.
28
+ # workers with blanket bypassPermissions. (INSTALLING it is the one step that
29
+ # needs an elevated shell of that same user -- see Assert-Prereqs, task 1004177.)
29
30
 
30
31
  [CmdletBinding()]
31
32
  param(
@@ -49,10 +50,32 @@ $LogDir = Join-Path $env:LOCALAPPDATA 'cloudbongos'
49
50
  $LogFile = Join-Path $LogDir 'autobongos.log'
50
51
  $Wrapper = Join-Path $RepoRoot 'scripts\gds\autobongos-service.cmd'
51
52
 
53
+ # Is this shell elevated (Run as administrator)? Its own function so the tests
54
+ # can stand it in: the check is Windows-only, and CI runs on Linux.
55
+ function Test-Elevated {
56
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
57
+ return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
58
+ }
59
+
60
+ # REGISTERING vs RUNNING (task 1004177). The task RUNS as this user, unelevated
61
+ # (-RunLevel Limited): that is what reaches the user's git credential and Claude
62
+ # session, and it is load-bearing. But REGISTERING a task with an S4U principal
63
+ # needs an elevated shell -- unelevated, Register-ScheduledTask fails with a bare
64
+ # "Access is denied" (0x80070005) that says nothing about why. Elevating the SAME
65
+ # account keeps $env:USERNAME, so the principal below is still this user. So
66
+ # install -- and only install -- checks, and says so in words, before trying.
67
+ $ElevationNeeded = @(
68
+ 'install needs an elevated shell: registering a task that runs whether or not you are signed in (the S4U logon type) requires Administrator.',
69
+ 'Open PowerShell with "Run as administrator" AS THIS SAME USER and run install again. The task still RUNS as you, unelevated (-RunLevel Limited) -- only the registration needs admin.',
70
+ 'Do NOT install it as SYSTEM or another admin account: the runner needs your git credential and Claude session, and SYSTEM has neither.'
71
+ ) -join ' '
72
+
52
73
  function Assert-Prereqs {
74
+ param([switch]$ForInstall)
53
75
  if (-not (Test-Path (Join-Path $RepoRoot 'scripts\gds\autobongos-run.js'))) {
54
76
  throw "No autobongos-run.js under $RepoRoot -- pass -RepoRoot <path to the checkout>."
55
77
  }
78
+ if ($ForInstall -and -not (Test-Elevated)) { throw $ElevationNeeded }
56
79
  foreach ($exe in @('node', 'git', 'claude')) {
57
80
  if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
58
81
  # Named, not guessed at: a runner that starts without `claude` on PATH will
@@ -84,9 +107,114 @@ function Show-Status {
84
107
  Write-Host 'is actually working is the hall: it reads the heartbeat this posts.'
85
108
  }
86
109
 
110
+ # --- stop, and say what actually happened (task 1004375) -----------------------
111
+ #
112
+ # Stop-ScheduledTask returns as soon as the scheduler has been ASKED. It does not
113
+ # wait, and it does not report whether the process went away -- and on the host
114
+ # it did not: the runner, started under the S4U logon type, sat in a session the
115
+ # interactive user could not terminate, survived Stop-ScheduledTask AND a
116
+ # Stop-Process -Force, while this script printed "stopped". Because the task is
117
+ # MultipleInstances IgnoreNew, a following `start` then no-ops against the old
118
+ # process, and the pair reads as a clean restart that changed nothing.
119
+ #
120
+ # So 'stop' now reads the runner's own pid from the heartbeat it writes
121
+ # (autobongos-run.js writeHeartbeat -> <config dir>\autobongos-heartbeat.json),
122
+ # asks the scheduler to stop, POLLS for that pid to exit, and reports one of four
123
+ # outcomes. It never says "stopped" about a process it has not watched exit.
124
+
125
+ # Where the runner writes its heartbeat: asked of the RUNNER, via its own
126
+ # exported heartbeatPath(), not re-derived here. That function is the only
127
+ # definition -- AUTOBONGOS_HEARTBEAT_FILE first, then the branding pack's config
128
+ # dir, then the repo root -- and a second copy of it in PowerShell would drift
129
+ # from the file the runner actually writes, which is the exact bug this fixes.
130
+ # Only if node itself cannot answer does this fall back to the repo-root name.
131
+ function Get-HeartbeatFile {
132
+ $fromRunner = $null
133
+ try {
134
+ $run = Join-Path $RepoRoot 'scripts\gds\autobongos-run.js'
135
+ $fromRunner = & node -e "process.stdout.write(String(require(process.argv[1]).heartbeatPath()))" $run
136
+ } catch { $fromRunner = $null }
137
+ if ($fromRunner) { return [string]$fromRunner }
138
+ return (Join-Path $RepoRoot '.autobongos-heartbeat.json')
139
+ }
140
+
141
+ # The pid the runner recorded, or $null when there is no file or no usable pid.
142
+ function Read-RunnerPid([string]$HeartbeatFile) {
143
+ if (-not $HeartbeatFile -or -not (Test-Path $HeartbeatFile)) { return $null }
144
+ try { $hb = Get-Content -Raw -Path $HeartbeatFile | ConvertFrom-Json } catch { return $null }
145
+ $n = 0
146
+ if ($hb -and [int]::TryParse([string]$hb.pid, [ref]$n) -and $n -gt 0) { return $n }
147
+ return $null
148
+ }
149
+
150
+ # Alive AND node: a heartbeat left behind by a crash can name a pid that Windows
151
+ # has since handed to some unrelated process, and that must not read as "the
152
+ # runner is still running".
153
+ function Test-RunnerAlive([int]$ProcessId) {
154
+ $p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
155
+ return [bool]($p -and $p.ProcessName -eq 'node')
156
+ }
157
+
158
+ # Outcome is one of:
159
+ # stopped -- the pid was alive, and was watched to exit
160
+ # still_running -- the pid was alive and is STILL alive after the wait
161
+ # not_running -- the heartbeat's pid was already gone before the stop
162
+ # unknown -- no pid to check, so nothing can be claimed either way
163
+ # StopTask is injectable so tests/autobongos_service_ps1.mjs can drive the real
164
+ # polling against a real process without a scheduled task.
165
+ function Invoke-RunnerStop {
166
+ param(
167
+ [string]$HeartbeatFile,
168
+ [int]$TimeoutSeconds = 20,
169
+ [scriptblock]$StopTask = { Stop-ScheduledTask -TaskName $TaskName }
170
+ )
171
+ $runnerPid = Read-RunnerPid $HeartbeatFile
172
+ $wasAlive = [bool]($runnerPid -and (Test-RunnerAlive $runnerPid))
173
+ & $StopTask
174
+ if (-not $runnerPid) { return [pscustomobject]@{ Outcome = 'unknown'; Pid = $null; Waited = 0 } }
175
+ if (-not $wasAlive) { return [pscustomobject]@{ Outcome = 'not_running'; Pid = $runnerPid; Waited = 0 } }
176
+ $started = Get-Date
177
+ $deadline = $started.AddSeconds($TimeoutSeconds)
178
+ while ((Test-RunnerAlive $runnerPid) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Milliseconds 250 }
179
+ $waited = [int][math]::Round(((Get-Date) - $started).TotalSeconds)
180
+ $outcome = if (Test-RunnerAlive $runnerPid) { 'still_running' } else { 'stopped' }
181
+ return [pscustomobject]@{ Outcome = $outcome; Pid = $runnerPid; Waited = $waited }
182
+ }
183
+
184
+ function Format-StopReport($Result, [string]$HeartbeatFile) {
185
+ $p = $Result.Pid
186
+ switch ($Result.Outcome) {
187
+ 'stopped' {
188
+ "stopped '$TaskName' -- runner pid $p has exited (watched, not assumed)."
189
+ 'That is for now only: the next boot starts it again. To keep the runner from'
190
+ 'working, use the kill switch in the hall (/builders/gate) -- it survives a'
191
+ 'reboot and nothing running on this machine can undo it.'
192
+ }
193
+ 'still_running' {
194
+ "NOT STOPPED: asked Task Scheduler to stop '$TaskName', but runner pid $p is still running after $($Result.Waited)s."
195
+ 'It may be under a different logon session (the task runs as S4U), which this'
196
+ 'shell cannot end -- Stop-Process from here fails the same way.'
197
+ "Remedy: from an elevated (Administrator) PowerShell run taskkill /F /PID $p"
198
+ 'or reboot the machine. Until it exits, `start` does nothing: the task will not'
199
+ 'launch a second copy over a live one (MultipleInstances IgnoreNew).'
200
+ }
201
+ 'not_running' {
202
+ "'$TaskName' was not running: runner pid $p from the heartbeat had already exited."
203
+ }
204
+ default {
205
+ "asked Task Scheduler to stop '$TaskName', but CANNOT CONFIRM the runner exited:"
206
+ "no runner pid in $HeartbeatFile. Check Get-Process node before assuming it stopped."
207
+ }
208
+ }
209
+ }
210
+
211
+ # Dot-sourced (the tests do this to reach the functions above), stop here: the
212
+ # switch below is the command-line entry point and must not run on a load.
213
+ if ($MyInvocation.InvocationName -eq '.') { return }
214
+
87
215
  switch ($Action) {
88
216
  'install' {
89
- Assert-Prereqs
217
+ Assert-Prereqs -ForInstall
90
218
  New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
91
219
 
92
220
  # NOT $action: PowerShell variable names are CASE-INSENSITIVE, so $action IS the
@@ -131,10 +259,12 @@ switch ($Action) {
131
259
  }
132
260
  'start' { Start-ScheduledTask -TaskName $TaskName; Write-Host "started '$TaskName'" }
133
261
  'stop' {
134
- Stop-ScheduledTask -TaskName $TaskName
135
- Write-Host "stopped '$TaskName' -- but note this only stops the PROCESS on this machine."
136
- Write-Host 'The durable way to stop the runner is the kill switch in the hall, which'
137
- Write-Host 'survives a reboot and cannot be undone by anything running here.'
262
+ $hbFile = Get-HeartbeatFile
263
+ $result = Invoke-RunnerStop -HeartbeatFile $hbFile
264
+ Format-StopReport $result $hbFile | ForEach-Object { Write-Host $_ }
265
+ # Non-zero when the process outlived the stop, so a script driving this
266
+ # (a restart, a maintenance step) cannot carry on as if it had worked.
267
+ if ($result.Outcome -eq 'still_running') { exit 1 }
138
268
  }
139
269
  default { Show-Status }
140
270
  }
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.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.15'; // 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');
@@ -277,6 +277,9 @@ function loopHarness({ rows, maxLoops = 6 }) {
277
277
  // Declaring no drift keeps each test about the one thing it is testing;
278
278
  // tests/autobongos_loop.mjs covers the drift exit itself.
279
279
  codeDrifted: async () => false,
280
+ // forever() reads the running commit at startup (task 1004397); answered here
281
+ // rather than by a real git call, for the same reason as the beats above.
282
+ loadedHead: async () => null,
280
283
  };
281
284
  return { deps, waits: emitted, recorded, opts: { goals: [], maxTasks: 3, maxLoops } };
282
285
  }
@@ -0,0 +1,75 @@
1
+ // tests/autobongos_heartbeat_head.mjs — the FIRST heartbeat carries the commit
2
+ // the process is running (task 1004397).
3
+ //
4
+ // The heartbeat's `head` exists so "alive" can be told apart from "alive but
5
+ // running week-old code" (task 1004374). It was filled lazily, via codeDrifted(),
6
+ // which sits behind the one-minute uptime floor — so every beat in a process's
7
+ // first minute said head:null. That is exactly the window someone is looking in
8
+ // when a runner crash-loops, or keeps being restarted to pick up a fix.
9
+ //
10
+ // ITS OWN FILE ON PURPOSE. The running head is cached in a module-level variable
11
+ // (read once per process, by design), and node:test gives each file its own
12
+ // process. In a shared file an earlier test that reached loadedHead() would
13
+ // already have filled the cache, and the "first beat has a head" case would pass
14
+ // for a reason unrelated to the fix. The two tests below also depend on their
15
+ // ORDER for the same reason: the unreadable case runs first, while the cache is
16
+ // still empty.
17
+ //
18
+ // MINIMUM INJECTION: writeHeartbeat and loadedHead are NOT injected — the real
19
+ // ones run, as they do under main(). Only the outward calls are stubbed: run()
20
+ // (so git is answered, not executed), the server beat, and the loop's own
21
+ // collaborators.
22
+
23
+ import assert from 'node:assert/strict';
24
+ import { test } from 'node:test';
25
+ import { createRequire } from 'node:module';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+
32
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ab-head-'));
33
+ process.env.AUTOBONGOS_HEARTBEAT_FILE = path.join(dir, 'beat.json');
34
+ process.env.AUTOBONGOS_LOG_FILE = path.join(dir, 'runs.jsonl');
35
+
36
+ const runner = require('../scripts/gds/autobongos-run.js');
37
+
38
+ const SHA = 'abcdef0123456789abcdef0123456789abcdef01';
39
+
40
+ function deps(run, seen) {
41
+ return {
42
+ run,
43
+ // Read the file at the moment the boot beat has been written and nothing
44
+ // else has run yet: reconcile is the next step after it.
45
+ reconcileLeftoverClaims: async () => { seen.push(runner.readHeartbeat()); return { event: 'boot_reconcile', ok: true, leftover: 0 }; },
46
+ iteration: async () => ({ event: 'nothing_claimable', skipped: [] }),
47
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
48
+ postHeartbeat: async () => true,
49
+ codeDrifted: async () => false,
50
+ record: (r) => r,
51
+ emit: () => {},
52
+ };
53
+ }
54
+
55
+ test('an unreadable head leaves head null and never stops the runner', async () => {
56
+ const seen = [];
57
+ const run = async () => ({ ok: false, code: 128, stdout: '', stderr: 'fatal: not a git repository' });
58
+ const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 1 }, deps(run, seen));
59
+ assert.equal(code, 0);
60
+ assert.equal(seen[0].pid, process.pid);
61
+ assert.equal(seen[0].head, null, 'cannot-tell is written as null, not as a guess');
62
+ });
63
+
64
+ test('the FIRST heartbeat a process writes carries the commit it is running', async () => {
65
+ const seen = [];
66
+ const calls = [];
67
+ const run = async (bin, args) => {
68
+ calls.push(args.join(' '));
69
+ return args[0] === 'rev-parse' ? { ok: true, code: 0, stdout: SHA + String.fromCharCode(10), stderr: '' } : { ok: false, code: 1, stdout: '', stderr: '' };
70
+ };
71
+ await runner.forever({ goals: [], maxTasks: 1, maxLoops: 1 }, deps(run, seen));
72
+ assert.equal(seen[0].head, SHA, 'the boot beat — inside the uptime floor — must already name the commit');
73
+ assert.ok(calls.includes('rev-parse HEAD'), 'read from git, once, at startup');
74
+ assert.equal(await runner.loadedHead(), SHA, 'and cached: the drift check later compares against the same read');
75
+ });
@@ -0,0 +1,206 @@
1
+ // tests/autobongos_service_ps1.mjs — the Windows host installer's own behaviour
2
+ // (scripts/gds/autobongos-service.ps1).
3
+ //
4
+ // task 1004375: `stop` printed "stopped" while the runner kept running. The
5
+ // runner, started under the S4U logon type, sat in a session the interactive user
6
+ // could not end, so Stop-ScheduledTask asked, returned, and the script reported
7
+ // success without ever checking. These cases drive the REAL stop logic — dot-
8
+ // sourced out of the script, against a real node process whose pid is in a real
9
+ // heartbeat file — with only the scheduler call injected, because there is no
10
+ // scheduled task to stop on a test runner.
11
+ //
12
+ // Needs a PowerShell. Windows ships `powershell`; the ubuntu CI image ships
13
+ // `pwsh`. In CI a missing shell FAILS rather than skips: a suite that quietly
14
+ // skips where it is gated is a suite that rots green.
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const PS1 = fileURLToPath(new URL('../scripts/gds/autobongos-service.ps1', import.meta.url));
26
+
27
+ function findShell() {
28
+ for (const bin of ['pwsh', 'powershell']) {
29
+ const r = spawnSync(bin, ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], { encoding: 'utf8' });
30
+ if (r.status === 0) return bin;
31
+ }
32
+ return null;
33
+ }
34
+ const SHELL = findShell();
35
+ if (!SHELL && process.env.CI) throw new Error('no PowerShell (pwsh/powershell) on this CI runner — these cases must run, not skip');
36
+ const skip = SHELL ? false : 'no PowerShell on this machine';
37
+
38
+ const tmp = mkdtempSync(path.join(tmpdir(), 'abs-ps1-'));
39
+ process.on('exit', () => { try { rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
40
+
41
+ // Dot-source the script, then run `body`. Single quotes inside PS literals are
42
+ // doubled; every path handed in goes through q().
43
+ //
44
+ // ASYNC ON PURPOSE. On Linux a killed child stays a zombie -- still listed by
45
+ // Get-Process, still named node -- until its parent reaps it, and node reaps only
46
+ // while its event loop runs. A spawnSync here would block that loop for the whole
47
+ // PowerShell run, so a fake runner the case kills would never leave the process
48
+ // table and a correct stop would read as still_running. Windows has no zombies,
49
+ // which is why a spawnSync first cut passed there and failed on the ubuntu image.
50
+ const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
51
+ function ps(body, extraEnv = {}) {
52
+ const script = `$ErrorActionPreference = 'Stop'; . ${q(PS1)}; ${body}`;
53
+ return new Promise((resolve) => {
54
+ const c = spawn(SHELL, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
55
+ // The script's top level joins LOCALAPPDATA, which a Linux runner lacks.
56
+ env: { ...process.env, LOCALAPPDATA: process.env.LOCALAPPDATA || tmp, ...extraEnv },
57
+ timeout: 60_000,
58
+ });
59
+ let out = '';
60
+ c.stdout.on('data', (d) => { out += d; });
61
+ c.stderr.on('data', (d) => { out += d; });
62
+ c.on('close', (status) => resolve({ status, out }));
63
+ });
64
+ }
65
+
66
+ // A stand-in runner: a real, long-lived node process, and a heartbeat naming it
67
+ // the way autobongos-run.js writeHeartbeat does.
68
+ function fakeRunner(name) {
69
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
70
+ const hb = path.join(tmp, `${name}.json`);
71
+ writeFileSync(hb, JSON.stringify({ pid: child.pid, at: new Date().toISOString(), head: 'abc' }));
72
+ return { child, hb, pid: child.pid };
73
+ }
74
+ const report = (hb, stopTask, timeout = 2) =>
75
+ `$r = Invoke-RunnerStop -HeartbeatFile ${q(hb)} -TimeoutSeconds ${timeout} -StopTask { ${stopTask} }; ` +
76
+ `Format-StopReport $r ${q(hb)} | ForEach-Object { $_ }; 'OUTCOME=' + $r.Outcome`;
77
+
78
+ test('a stop that leaves the process running says so, and names the pid and the remedy', { skip }, async () => {
79
+ // The host's case exactly: the scheduler call returns and the process stays.
80
+ const { child, hb, pid } = fakeRunner('survivor');
81
+ try {
82
+ const r = await ps(report(hb, '<# the scheduler asked; nothing died #>'));
83
+ assert.equal(r.status, 0, r.out);
84
+ assert.match(r.out, /OUTCOME=still_running/, r.out);
85
+ assert.doesNotMatch(r.out, /^stopped/m, 'must never claim a stop it did not see');
86
+ assert.match(r.out, /NOT STOPPED/);
87
+ assert.match(r.out, new RegExp(`runner pid ${pid} is still running after \\d+s`));
88
+ assert.match(r.out, /different logon session/);
89
+ assert.match(r.out, new RegExp(`taskkill /F /PID ${pid}`), 'the remedy names the actual pid');
90
+ assert.match(r.out, /elevated/);
91
+ assert.match(r.out, /reboot/);
92
+ } finally { child.kill(); }
93
+ });
94
+
95
+ test('a stop that ends the process is watched to exit before it says "stopped"', { skip }, async () => {
96
+ const { child, hb, pid } = fakeRunner('ends');
97
+ try {
98
+ const r = await ps(report(hb, `Stop-Process -Id ${pid} -Force`, 10));
99
+ assert.equal(r.status, 0, r.out);
100
+ assert.match(r.out, /OUTCOME=stopped/, r.out);
101
+ assert.match(r.out, new RegExp(`stopped 'Autobongos' -- runner pid ${pid} has exited`));
102
+ // The caveat is about the NEXT boot, not a hedge on whether this stop worked.
103
+ assert.match(r.out, /next boot starts it again/);
104
+ assert.match(r.out, /kill switch/);
105
+ } finally { child.kill(); }
106
+ });
107
+
108
+ test('no pid to check means no claim either way', { skip }, async () => {
109
+ const r = await ps(report(path.join(tmp, 'absent.json'), ''));
110
+ assert.match(r.out, /OUTCOME=unknown/, r.out);
111
+ assert.match(r.out, /CANNOT CONFIRM/);
112
+ assert.doesNotMatch(r.out, /^stopped/m);
113
+ });
114
+
115
+ test('a heartbeat pid now held by a NON-node process is not "the runner still running"', { skip }, async () => {
116
+ // A crash leaves the file behind and Windows reuses pids. The PowerShell's own
117
+ // $PID is alive and is not node: that must read as already gone.
118
+ const hb = path.join(tmp, 'reused.json');
119
+ const r = await ps(`Set-Content -Path ${q(hb)} -Value ('{"pid":' + $PID + '}'); ${report(hb, '')}`);
120
+ assert.match(r.out, /OUTCOME=not_running/, r.out);
121
+ assert.doesNotMatch(r.out, /NOT STOPPED/);
122
+ });
123
+
124
+ test('the command-line stop exits non-zero when the process survived', () => {
125
+ // Structural half: the entry point must route through the checked stop and
126
+ // fail loudly on still_running, so a restart script cannot carry on as if it
127
+ // worked. Comments are stripped so a commented-out line cannot satisfy it.
128
+ const live = readFileSync(PS1, 'utf8').split(/\r?\n/).map((l) => l.replace(/#.*$/, '')).join('\n');
129
+ const stopArm = live.slice(live.indexOf("'stop' {"));
130
+ assert.match(stopArm, /Invoke-RunnerStop/);
131
+ assert.match(stopArm, /still_running'\)\s*\{\s*exit 1\s*\}/);
132
+ assert.doesNotMatch(live, /Write-Host "stopped '\$TaskName' -- but note/, 'the old unchecked claim is gone');
133
+ });
134
+
135
+
136
+ // The pid is only as good as the FILE it is read from. stop must look where the
137
+ // runner writes, so Get-HeartbeatFile asks the runner's own heartbeatPath() --
138
+ // these pin that the two agree, including its first-precedence env override.
139
+ const runner = createRequire(import.meta.url)('../scripts/gds/autobongos-run.js');
140
+
141
+ test('stop reads the heartbeat file the runner itself writes', { skip }, async () => {
142
+ const saved = process.env.AUTOBONGOS_HEARTBEAT_FILE;
143
+ delete process.env.AUTOBONGOS_HEARTBEAT_FILE;
144
+ const expected = runner.heartbeatPath();
145
+ if (saved !== undefined) process.env.AUTOBONGOS_HEARTBEAT_FILE = saved;
146
+ const r = await ps("'HB=' + (Get-HeartbeatFile)", { AUTOBONGOS_HEARTBEAT_FILE: '' });
147
+ assert.equal(path.resolve(r.out.match(/HB=(.*)/)[1].trim()), path.resolve(expected), r.out);
148
+ });
149
+
150
+ test('stop honours AUTOBONGOS_HEARTBEAT_FILE exactly as the runner does', { skip }, async () => {
151
+ const override = path.join(tmp, 'override-heartbeat.json');
152
+ const r = await ps("'HB=' + (Get-HeartbeatFile)", { AUTOBONGOS_HEARTBEAT_FILE: override });
153
+ assert.equal(path.resolve(r.out.match(/HB=(.*)/)[1].trim()), path.resolve(override), r.out);
154
+ });
155
+
156
+ // --- install needs an elevated shell, and says why (task 1004177) -------------
157
+ //
158
+ // Registering an S4U task needs Administrator; unelevated, Register-ScheduledTask
159
+ // throws a bare "Access is denied". The recipe used to say the opposite. Test-
160
+ // Elevated is stood in (it is Windows-only), everything else is the real script.
161
+ // The stub runs AFTER the dot-source, so it replaces the script's own definition.
162
+
163
+ const assertPrereqs = (elevated, args) =>
164
+ `function Test-Elevated { $${elevated} }; ` +
165
+ `try { Assert-Prereqs ${args}; 'PASSED' } catch { 'THREW=' + $_.Exception.Message }`;
166
+
167
+ test('install from an unelevated shell is refused with the reason, not a bare access-denied', { skip }, async () => {
168
+ const r = await ps(assertPrereqs('false', '-ForInstall'));
169
+ assert.match(r.out, /THREW=install needs an elevated shell/, r.out);
170
+ assert.match(r.out, /S4U/);
171
+ assert.match(r.out, /Run as administrator/);
172
+ assert.match(r.out, /SAME USER/, 'elevating the same account is what keeps the principal right');
173
+ assert.match(r.out, /still RUNS as you, unelevated/);
174
+ assert.match(r.out, /Do NOT install it as SYSTEM/);
175
+ });
176
+
177
+ test('an elevated shell passes the elevation check', { skip }, async () => {
178
+ const r = await ps(assertPrereqs('true', '-ForInstall'));
179
+ // It may still stop on a later prereq (a CI runner has no `claude`), but
180
+ // never on elevation.
181
+ assert.doesNotMatch(r.out, /elevated shell/, r.out);
182
+ });
183
+
184
+ test('only install asks for elevation', { skip }, async () => {
185
+ const r = await ps(assertPrereqs('false', ''));
186
+ assert.doesNotMatch(r.out, /elevated shell/, r.out);
187
+ });
188
+
189
+ test('the install arm runs the install-only check', () => {
190
+ const live = readFileSync(PS1, 'utf8').split(/\r?\n/).map((l) => l.replace(/#.*$/, '')).join('\n');
191
+ const installArm = live.slice(live.indexOf("'install' {"), live.indexOf("'uninstall' {"));
192
+ assert.match(installArm, /Assert-Prereqs -ForInstall/);
193
+ assert.ok(installArm.indexOf('Assert-Prereqs -ForInstall') < installArm.indexOf('Register-ScheduledTask'),
194
+ 'the check must run before the registration it explains');
195
+ });
196
+
197
+ test('the recipe says install needs Administrator, and why, and not SYSTEM', () => {
198
+ const doc = readFileSync(fileURLToPath(new URL('../docs/recipes/autobongos-windows-host.md', import.meta.url)), 'utf8');
199
+ assert.doesNotMatch(doc, /normal user \(not Administrator\)/, 'the instruction that made install fail');
200
+ const install = doc.slice(doc.indexOf('## 2. Install the scheduled task'), doc.indexOf('## 3.'));
201
+ assert.match(install, /Run as administrator/);
202
+ assert.match(install, /S4U/);
203
+ assert.match(install, /same (user|account)/i);
204
+ assert.match(install, /SYSTEM/);
205
+ assert.match(install, /-RunLevel Limited/);
206
+ });