@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 +26 -16
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/autobongos-windows-host.md +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/autobongos-run.js +8 -0
- package/scripts/gds/autobongos-service.ps1 +111 -4
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +3 -0
- package/tests/autobongos_heartbeat_head.mjs +75 -0
- package/tests/autobongos_service_ps1.mjs +154 -0
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.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 2628,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
8865
|
+
"sha256": "908c96be621ea531a175e213836861eddf3bd3b652f793aaefd6fd098d7e5543"
|
|
8866
8866
|
},
|
|
8867
8867
|
{
|
|
8868
8868
|
"path": "package.json",
|
|
8869
8869
|
"mode": "0000644",
|
|
8870
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
|
138
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
+
});
|