@bongos/core 1.20.12 → 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 +29 -19
- package/docs/module-api-changelog.md +4 -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 +16 -0
- package/scripts/gds/autobongos-run.js +12 -4
- package/scripts/gds/autobongos-service.ps1 +111 -4
- package/scripts/gds/autobongos-verify.js +27 -2
- 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_loop.mjs +41 -0
- package/tests/autobongos_service_ps1.mjs +154 -0
- package/tests/autobongos_verify.mjs +22 -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,12 +9057,12 @@
|
|
|
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",
|
|
9064
9064
|
"mode": "0000644",
|
|
9065
|
-
"sha256": "
|
|
9065
|
+
"sha256": "0be5358189145464d2893cebd53057b9fb35be94c2b56bb1b09f386c1f5a6252"
|
|
9066
9066
|
},
|
|
9067
9067
|
{
|
|
9068
9068
|
"path": "scripts/gds/autonomy-gate.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,22 +11437,32 @@
|
|
|
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
|
-
"sha256": "
|
|
11455
|
+
"sha256": "e14d2c211c28ebada8e915d0d336989e74410afb68924d9b89dc8efd666457c8"
|
|
11456
|
+
},
|
|
11457
|
+
{
|
|
11458
|
+
"path": "tests/autobongos_service_ps1.mjs",
|
|
11459
|
+
"mode": "0000644",
|
|
11460
|
+
"sha256": "541a3030ed91fe74b98b837e4d45b59e6a337b9f45c99f848a9df53a20b781a1"
|
|
11451
11461
|
},
|
|
11452
11462
|
{
|
|
11453
11463
|
"path": "tests/autobongos_verify.mjs",
|
|
11454
11464
|
"mode": "0000644",
|
|
11455
|
-
"sha256": "
|
|
11465
|
+
"sha256": "8b0958dba2589ca94671843a30d317da35b76df6eff9ab75660a0a253f98b7d3"
|
|
11456
11466
|
},
|
|
11457
11467
|
{
|
|
11458
11468
|
"path": "tests/autonomous_fit_protected_paths.mjs",
|
|
@@ -2681,5 +2681,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2681
2681
|
landed since 1.20.10 with no explicit bump. run 36672606449. (task 1002620)
|
|
2682
2682
|
1.20.12 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2683
2683
|
landed since 1.20.11 with no explicit bump. run 36709211337. (task 1002620)
|
|
2684
|
+
1.20.13 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
2684
2688
|
---------------------------------------------------------------------------
|
|
2685
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
|
@@ -8124,5 +8124,21 @@
|
|
|
8124
8124
|
"id": "1004190",
|
|
8125
8125
|
"text": "The task list no longer offers you work the system will refuse: a job waiting on another job that hasn't finished yet is now hidden until it's actually ready to start."
|
|
8126
8126
|
}
|
|
8127
|
+
],
|
|
8128
|
+
"1.20.13": [
|
|
8129
|
+
{
|
|
8130
|
+
"id": "1004180",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
8127
8143
|
]
|
|
8128
8144
|
}
|
|
@@ -42,7 +42,7 @@ const gauge = require('./autonomy-gauge.js');
|
|
|
42
42
|
const {
|
|
43
43
|
buildWorkerArgs, buildWorkerPrompt, classifyRun, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
|
|
44
44
|
} = require('./autobongos-loop.js');
|
|
45
|
-
const { verifyShip } = require('./autobongos-verify.js');
|
|
45
|
+
const { verifyShip, failureReason } = require('./autobongos-verify.js');
|
|
46
46
|
const fence = require('../../modules/autonomy/fence.js');
|
|
47
47
|
// The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
|
|
48
48
|
// sits outside the kernel boundary, so this direct core require is allowed here
|
|
@@ -466,7 +466,7 @@ async function iteration(opts, deps = {}) {
|
|
|
466
466
|
const wtName = deps.worktreeName ? deps.worktreeName(task.id) : worktreeName(task.id);
|
|
467
467
|
const made = await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'add', wtName, '--base', 'origin/main']);
|
|
468
468
|
if (!made.ok) {
|
|
469
|
-
return log({ event: 'worktree_failed', task_id: task.id, worktree: wtName, reason: (made
|
|
469
|
+
return log({ event: 'worktree_failed', task_id: task.id, worktree: wtName, reason: failureReason(made, 'worktree.js') });
|
|
470
470
|
}
|
|
471
471
|
const wtPath = path.join(REPO_ROOT, '.claude', 'worktrees', wtName);
|
|
472
472
|
|
|
@@ -475,7 +475,7 @@ async function iteration(opts, deps = {}) {
|
|
|
475
475
|
const claimed = await runCmd(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'claim.js'), String(task.id)], { cwd: wtPath });
|
|
476
476
|
if (!claimed.ok) {
|
|
477
477
|
await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'remove', wtName]);
|
|
478
|
-
return log({ event: 'claim_failed', task_id: task.id, worktree: wtName, reason: (claimed
|
|
478
|
+
return log({ event: 'claim_failed', task_id: task.id, worktree: wtName, reason: failureReason(claimed, 'claim.js') });
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
// 5. Work, in that tree.
|
|
@@ -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
|
|
@@ -966,5 +974,5 @@ module.exports = {
|
|
|
966
974
|
readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
|
|
967
975
|
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, countWorkedSince, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
968
976
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
969
|
-
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT,
|
|
977
|
+
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
|
|
970
978
|
};
|
|
@@ -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
|
}
|
|
@@ -40,6 +40,31 @@ const { execFile } = require('node:child_process');
|
|
|
40
40
|
|
|
41
41
|
const { classifyStrand, WATCHED } = require('./strand-watch.js');
|
|
42
42
|
|
|
43
|
+
// failureReason — what a failed child process said, fit for one run-log field
|
|
44
|
+
// (task 1004180).
|
|
45
|
+
//
|
|
46
|
+
// Two defects this replaces, both seen live. `(stderr || stdout).slice(-400)`
|
|
47
|
+
// logged reason:"" when a child failed with nothing on either stream — which
|
|
48
|
+
// reads as "the cause was captured and it was nothing". And it kept only the
|
|
49
|
+
// TAIL, so a long refusal card lost its first line, the one that says WHY, and
|
|
50
|
+
// the log began mid-word ("ot look for a way round it."). So: prefer stderr,
|
|
51
|
+
// else stdout; collapse whitespace so a multi-line card reads as one line; keep
|
|
52
|
+
// the HEAD plus a short tail with a marked cut; and when there is no text at all,
|
|
53
|
+
// say which program exited with which code rather than saying nothing.
|
|
54
|
+
function failureReason(res, what, max = 400) {
|
|
55
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
56
|
+
const text = flat(res && res.stderr) || flat(res && res.stdout);
|
|
57
|
+
if (!text) {
|
|
58
|
+
const code = res && res.code !== undefined && res.code !== null ? res.code : '?';
|
|
59
|
+
return `${what} exited ${code} with no output`;
|
|
60
|
+
}
|
|
61
|
+
if (text.length <= max) return text;
|
|
62
|
+
const cut = ' … ';
|
|
63
|
+
const tail = Math.floor((max - cut.length) / 3);
|
|
64
|
+
const head = max - cut.length - tail;
|
|
65
|
+
return text.slice(0, head) + cut + text.slice(text.length - tail);
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
44
69
|
|
|
45
70
|
// Statuses a claim can still be sitting in when the worker is already gone.
|
|
@@ -318,7 +343,7 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
|
|
|
318
343
|
else {
|
|
319
344
|
const r = await release(taskId, `autobongos: ${v.shape} — ${String(v.reason || '').slice(0, 180)}`);
|
|
320
345
|
out.released = !!(r && r.ok);
|
|
321
|
-
if (!out.released) out.release_error = (r
|
|
346
|
+
if (!out.released) out.release_error = failureReason(r, 'release.js', 300);
|
|
322
347
|
}
|
|
323
348
|
}
|
|
324
349
|
|
|
@@ -374,4 +399,4 @@ if (require.main === module) {
|
|
|
374
399
|
});
|
|
375
400
|
}
|
|
376
401
|
|
|
377
|
-
module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip };
|
|
402
|
+
module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason };
|
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
|
+
});
|
|
@@ -381,6 +381,47 @@ test('a refused claim tears the tree down again and never spawns a worker', asyn
|
|
|
381
381
|
assert.match(row.reason, /ALREADY_CLAIMED/);
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
+
// task 1004180 — observed live: reason:"" when claim.js failed with nothing on
|
|
385
|
+
// either stream, and reasons that began mid-word ("ot look for a way round it.")
|
|
386
|
+
// because `.slice(-400)` kept only the TAIL of a long refusal card.
|
|
387
|
+
test('a claim that fails SILENTLY still says what happened, never an empty reason', async () => {
|
|
388
|
+
const h = harness({ run: async (bin, args) => {
|
|
389
|
+
const script = String(args[0]).split(/[\\/]/).pop();
|
|
390
|
+
return script === 'claim.js' ? { ok: false, code: 3, stdout: '', stderr: ' ' } : { ok: true, code: 0, stdout: '', stderr: '' };
|
|
391
|
+
} });
|
|
392
|
+
const row = await runner.iteration(opts, h.deps);
|
|
393
|
+
assert.equal(row.event, 'claim_failed');
|
|
394
|
+
assert.equal(row.reason, 'claim.js exited 3 with no output');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('a worktree that cannot be made says so even with no output', async () => {
|
|
398
|
+
const h = harness({ run: async () => ({ ok: false, code: 128, stdout: '', stderr: '' }) });
|
|
399
|
+
const row = await runner.iteration(opts, h.deps);
|
|
400
|
+
assert.equal(row.event, 'worktree_failed');
|
|
401
|
+
assert.equal(row.reason, 'worktree.js exited 128 with no output');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('a long refusal keeps its HEAD — the part that says why — not just the tail', async () => {
|
|
405
|
+
const why = 'Your work changes the tools that would be used to update it, so the automatic update was refused on purpose.';
|
|
406
|
+
const long = why + ' ' + 'x'.repeat(2000) + ' By design: Yes. Do not look for a way round it.';
|
|
407
|
+
const h = harness({ run: async (bin, args) => {
|
|
408
|
+
const script = String(args[0]).split(/[\\/]/).pop();
|
|
409
|
+
return script === 'claim.js' ? { ok: false, code: 1, stdout: '', stderr: long } : { ok: true, code: 0, stdout: '', stderr: '' };
|
|
410
|
+
} });
|
|
411
|
+
const row = await runner.iteration(opts, h.deps);
|
|
412
|
+
assert.ok(row.reason.startsWith(why), 'the reason must start where the message starts');
|
|
413
|
+
assert.ok(row.reason.endsWith('Do not look for a way round it.'), 'and still carry the tail');
|
|
414
|
+
assert.ok(row.reason.includes('…'), 'the cut is marked');
|
|
415
|
+
assert.ok(row.reason.length <= 400, `bounded, got ${row.reason.length}`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('failureReason: stdout is used when stderr is empty, whitespace is collapsed, short text is untouched', () => {
|
|
419
|
+
assert.equal(runner.failureReason({ code: 1, stdout: 'line one' + String.fromCharCode(10) + ' line two', stderr: '' }, 'claim.js'), 'line one line two');
|
|
420
|
+
assert.equal(runner.failureReason({ code: 1, stdout: 'ignored', stderr: 'wins' }, 'claim.js'), 'wins');
|
|
421
|
+
assert.equal(runner.failureReason(null, 'claim.js'), 'claim.js exited ? with no output');
|
|
422
|
+
assert.equal(runner.failureReason({ code: 1, stderr: 'a'.repeat(400) }, 'x').length, 400);
|
|
423
|
+
});
|
|
424
|
+
|
|
384
425
|
test('a worker that cannot finish frees the claim; one that really shipped does not', async () => {
|
|
385
426
|
// Which of these happens is now decided by the LEDGER, not by the worker's own
|
|
386
427
|
// verdict: a stuck worker leaves the task still `active`, a finished one leaves
|
|
@@ -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
|
+
});
|
|
@@ -265,6 +265,28 @@ test('a release that fails does not get reported as a release', async () => {
|
|
|
265
265
|
assert.equal(out.blocker.linked, false);
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
// task 1004180 — the release error used to keep only the last 300 characters,
|
|
269
|
+
// cutting off the start of the message, which is the part that says why.
|
|
270
|
+
test('a long release error keeps its head, and an empty one still names the exit', async () => {
|
|
271
|
+
const why = 'Only the original session can release it.';
|
|
272
|
+
const long = await verifyShip({ taskId: 42 }, {
|
|
273
|
+
api: fakeApi(),
|
|
274
|
+
task: task({ id: '42', status: 'active' }),
|
|
275
|
+
release: async () => ({ ok: false, code: 1, stderr: why + ' ' + 'y'.repeat(1000) }),
|
|
276
|
+
fileBlocker: async (_a, _r, opts) => ({ filed: true, blockerId: 7, linked: opts.link }),
|
|
277
|
+
});
|
|
278
|
+
assert.ok(long.release_error.startsWith(why), long.release_error.slice(0, 60));
|
|
279
|
+
assert.ok(long.release_error.length <= 300);
|
|
280
|
+
|
|
281
|
+
const silent = await verifyShip({ taskId: 42 }, {
|
|
282
|
+
api: fakeApi(),
|
|
283
|
+
task: task({ id: '42', status: 'active' }),
|
|
284
|
+
release: async () => ({ ok: false, code: 2, stdout: '', stderr: '' }),
|
|
285
|
+
fileBlocker: async (_a, _r, opts) => ({ filed: true, blockerId: 7, linked: opts.link }),
|
|
286
|
+
});
|
|
287
|
+
assert.equal(silent.release_error, 'release.js exited 2 with no output');
|
|
288
|
+
});
|
|
289
|
+
|
|
268
290
|
test('the artifact probe only runs when the ledger claims success', async () => {
|
|
269
291
|
let probes = 0;
|
|
270
292
|
const probeArtifactSpy = async () => { probes += 1; return notOnMain; };
|