@bongos/core 1.20.13 → 1.20.14

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.14",
6
+ "core_contract": "1.20.14",
7
+ "source_commit": "791ad29beab00019cab69c01896dbb132b40b5ad",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T11:48:47.948Z",
9
+ "built_at": "2026-09-30T12:05:40.305Z",
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": "521479f97c392987b24cca51bd5ca57048b353ae978e89f748a15793d8e80990",
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": "b3abd2f21a34a8547f169368ddcaa19364e2fcb5f5ddeb4f296bcfaf67acf3b5"
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": "e8c45b5b1ab16fc5ae7bbfaef677672f338195dde885e734d53d752c5113deeb"
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": "908c96be621ea531a175e213836861eddf3bd3b652f793aaefd6fd098d7e5543"
8866
8866
  },
8867
8867
  {
8868
8868
  "path": "package.json",
8869
8869
  "mode": "0000644",
8870
- "sha256": "c80938c396a4982970d0850791d002ea2f5c49b93c8e75f6066c33c77bc7ae78"
8870
+ "sha256": "88ae8247e31afb3b9e49258788fe2f01132af4779ba70f3377ca6b8af3756adf"
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": "4d06b7011b86e611f4ae20cd9f925c3a27715663139ceb06d4b3f841a8c764f0"
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": "2dcf13abd58dfaf6cce56285dc1f156efe06be9e2307acc0d9e2bc7912c9f424"
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": "111c9b37716e607675072df7499f9040e048c7b6f5e4c006890d4b898dc6c9d3"
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": "541a3030ed91fe74b98b837e4d45b59e6a337b9f45c99f848a9df53a20b781a1"
11461
+ },
11452
11462
  {
11453
11463
  "path": "tests/autobongos_verify.mjs",
11454
11464
  "mode": "0000644",
@@ -2683,5 +2683,7 @@ 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)
2686
2688
  ---------------------------------------------------------------------------
2687
2689
  ```
@@ -134,8 +134,16 @@ Use the **kill switch in the hall**, not the scheduled task. The switch survives
134
134
  a reboot and cannot be undone by anything running on the machine; stopping the
135
135
  task only stops this run, and the next boot starts it again.
136
136
 
137
- `autobongos-service.ps1 stop` exists for maintenance on the box itself and says
138
- this when you use it.
137
+ `autobongos-service.ps1 stop` exists for maintenance on the box itself. It asks
138
+ Task Scheduler to stop the task, then **watches the runner's own process** (the
139
+ pid in its heartbeat file) and tells you which of these happened:
140
+
141
+ | It says | Means |
142
+ |---|---|
143
+ | `stopped ... has exited` | the process was watched to exit. The next boot starts it again |
144
+ | `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 |
145
+ | `was not running` | the heartbeat's process had already exited |
146
+ | `CANNOT CONFIRM` | there is no heartbeat pid to check; look with `Get-Process node` |
139
147
 
140
148
  ## Keeping the checkout current
141
149
 
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.14",
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.14",
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.14",
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,15 @@
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
+ }
8133
8143
  ]
8134
8144
  }
@@ -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
@@ -84,6 +84,111 @@ function Show-Status {
84
84
  Write-Host 'is actually working is the hall: it reads the heartbeat this posts.'
85
85
  }
86
86
 
87
+ # --- stop, and say what actually happened (task 1004375) -----------------------
88
+ #
89
+ # Stop-ScheduledTask returns as soon as the scheduler has been ASKED. It does not
90
+ # wait, and it does not report whether the process went away -- and on the host
91
+ # it did not: the runner, started under the S4U logon type, sat in a session the
92
+ # interactive user could not terminate, survived Stop-ScheduledTask AND a
93
+ # Stop-Process -Force, while this script printed "stopped". Because the task is
94
+ # MultipleInstances IgnoreNew, a following `start` then no-ops against the old
95
+ # process, and the pair reads as a clean restart that changed nothing.
96
+ #
97
+ # So 'stop' now reads the runner's own pid from the heartbeat it writes
98
+ # (autobongos-run.js writeHeartbeat -> <config dir>\autobongos-heartbeat.json),
99
+ # asks the scheduler to stop, POLLS for that pid to exit, and reports one of four
100
+ # outcomes. It never says "stopped" about a process it has not watched exit.
101
+
102
+ # Where the runner writes its heartbeat: asked of the RUNNER, via its own
103
+ # exported heartbeatPath(), not re-derived here. That function is the only
104
+ # definition -- AUTOBONGOS_HEARTBEAT_FILE first, then the branding pack's config
105
+ # dir, then the repo root -- and a second copy of it in PowerShell would drift
106
+ # from the file the runner actually writes, which is the exact bug this fixes.
107
+ # Only if node itself cannot answer does this fall back to the repo-root name.
108
+ function Get-HeartbeatFile {
109
+ $fromRunner = $null
110
+ try {
111
+ $run = Join-Path $RepoRoot 'scripts\gds\autobongos-run.js'
112
+ $fromRunner = & node -e "process.stdout.write(String(require(process.argv[1]).heartbeatPath()))" $run
113
+ } catch { $fromRunner = $null }
114
+ if ($fromRunner) { return [string]$fromRunner }
115
+ return (Join-Path $RepoRoot '.autobongos-heartbeat.json')
116
+ }
117
+
118
+ # The pid the runner recorded, or $null when there is no file or no usable pid.
119
+ function Read-RunnerPid([string]$HeartbeatFile) {
120
+ if (-not $HeartbeatFile -or -not (Test-Path $HeartbeatFile)) { return $null }
121
+ try { $hb = Get-Content -Raw -Path $HeartbeatFile | ConvertFrom-Json } catch { return $null }
122
+ $n = 0
123
+ if ($hb -and [int]::TryParse([string]$hb.pid, [ref]$n) -and $n -gt 0) { return $n }
124
+ return $null
125
+ }
126
+
127
+ # Alive AND node: a heartbeat left behind by a crash can name a pid that Windows
128
+ # has since handed to some unrelated process, and that must not read as "the
129
+ # runner is still running".
130
+ function Test-RunnerAlive([int]$ProcessId) {
131
+ $p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
132
+ return [bool]($p -and $p.ProcessName -eq 'node')
133
+ }
134
+
135
+ # Outcome is one of:
136
+ # stopped -- the pid was alive, and was watched to exit
137
+ # still_running -- the pid was alive and is STILL alive after the wait
138
+ # not_running -- the heartbeat's pid was already gone before the stop
139
+ # unknown -- no pid to check, so nothing can be claimed either way
140
+ # StopTask is injectable so tests/autobongos_service_ps1.mjs can drive the real
141
+ # polling against a real process without a scheduled task.
142
+ function Invoke-RunnerStop {
143
+ param(
144
+ [string]$HeartbeatFile,
145
+ [int]$TimeoutSeconds = 20,
146
+ [scriptblock]$StopTask = { Stop-ScheduledTask -TaskName $TaskName }
147
+ )
148
+ $runnerPid = Read-RunnerPid $HeartbeatFile
149
+ $wasAlive = [bool]($runnerPid -and (Test-RunnerAlive $runnerPid))
150
+ & $StopTask
151
+ if (-not $runnerPid) { return [pscustomobject]@{ Outcome = 'unknown'; Pid = $null; Waited = 0 } }
152
+ if (-not $wasAlive) { return [pscustomobject]@{ Outcome = 'not_running'; Pid = $runnerPid; Waited = 0 } }
153
+ $started = Get-Date
154
+ $deadline = $started.AddSeconds($TimeoutSeconds)
155
+ while ((Test-RunnerAlive $runnerPid) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Milliseconds 250 }
156
+ $waited = [int][math]::Round(((Get-Date) - $started).TotalSeconds)
157
+ $outcome = if (Test-RunnerAlive $runnerPid) { 'still_running' } else { 'stopped' }
158
+ return [pscustomobject]@{ Outcome = $outcome; Pid = $runnerPid; Waited = $waited }
159
+ }
160
+
161
+ function Format-StopReport($Result, [string]$HeartbeatFile) {
162
+ $p = $Result.Pid
163
+ switch ($Result.Outcome) {
164
+ 'stopped' {
165
+ "stopped '$TaskName' -- runner pid $p has exited (watched, not assumed)."
166
+ 'That is for now only: the next boot starts it again. To keep the runner from'
167
+ 'working, use the kill switch in the hall (/builders/gate) -- it survives a'
168
+ 'reboot and nothing running on this machine can undo it.'
169
+ }
170
+ 'still_running' {
171
+ "NOT STOPPED: asked Task Scheduler to stop '$TaskName', but runner pid $p is still running after $($Result.Waited)s."
172
+ 'It may be under a different logon session (the task runs as S4U), which this'
173
+ 'shell cannot end -- Stop-Process from here fails the same way.'
174
+ "Remedy: from an elevated (Administrator) PowerShell run taskkill /F /PID $p"
175
+ 'or reboot the machine. Until it exits, `start` does nothing: the task will not'
176
+ 'launch a second copy over a live one (MultipleInstances IgnoreNew).'
177
+ }
178
+ 'not_running' {
179
+ "'$TaskName' was not running: runner pid $p from the heartbeat had already exited."
180
+ }
181
+ default {
182
+ "asked Task Scheduler to stop '$TaskName', but CANNOT CONFIRM the runner exited:"
183
+ "no runner pid in $HeartbeatFile. Check Get-Process node before assuming it stopped."
184
+ }
185
+ }
186
+ }
187
+
188
+ # Dot-sourced (the tests do this to reach the functions above), stop here: the
189
+ # switch below is the command-line entry point and must not run on a load.
190
+ if ($MyInvocation.InvocationName -eq '.') { return }
191
+
87
192
  switch ($Action) {
88
193
  'install' {
89
194
  Assert-Prereqs
@@ -131,10 +236,12 @@ switch ($Action) {
131
236
  }
132
237
  'start' { Start-ScheduledTask -TaskName $TaskName; Write-Host "started '$TaskName'" }
133
238
  '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.'
239
+ $hbFile = Get-HeartbeatFile
240
+ $result = Invoke-RunnerStop -HeartbeatFile $hbFile
241
+ Format-StopReport $result $hbFile | ForEach-Object { Write-Host $_ }
242
+ # Non-zero when the process outlived the stop, so a script driving this
243
+ # (a restart, a maintenance step) cannot carry on as if it had worked.
244
+ if ($result.Outcome -eq 'still_running') { exit 1 }
138
245
  }
139
246
  default { Show-Status }
140
247
  }
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.14'; // 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,154 @@
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
+ });