@marcomigozzi/trailhead 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead test run (self-contained, ships in templates/ so it reaches every
4
+ // install channel). Runs the configured test command behind a machine-wide queue
5
+ // (at most `testing.maxParallel` heavy runs at once across every session and
6
+ // worktree) and a green-result cache keyed by tree SHA + command, so the same
7
+ // code is never tested twice. #362.
8
+ //
9
+ // CLI:
10
+ // node trailhead-test-run.js run --repo <working-root> [--config-root <project-root>]
11
+ // [--kind full|targeted] [--files <a,b,...>] [--no-cache]
12
+ // --config-root defaults to --repo (a worktree never re-resolves its config).
13
+ // Config (nested {"testing": {...}} or flat "testing.full"; nested wins with a
14
+ // warning; the project file .trailhead/config.json replaces the global
15
+ // ${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json key by key):
16
+ // testing.full string, the full-suite command
17
+ // testing.targeted string containing {files}, replaced by the single-quoted files
18
+ // testing.maxParallel positive integer, default 1
19
+ // Other testing.* keys (webapp, url) are left alone.
20
+ //
21
+ // Cache dir: $TRAILHEAD_CACHE_DIR, else ${XDG_CACHE_HOME:-~/.cache}/trailhead, with
22
+ // results/<key>.json green results only (key = sha256(tree NUL command NUL git common dir))
23
+ // logs/<tree12>-<epoch>-<pid>.log
24
+ // queue/ holder files (+ the short .gate mutex)
25
+ // Tree: HEAD^{tree} when the checkout is clean, else the tree of the working files
26
+ // (temp index), so uncommitted edits never reuse a result computed for HEAD. Not a
27
+ // git repo, or a submodule with uncommitted content: no caching for that run.
28
+ // Boundary: gitignored files (dependencies, build output), env and toolchain are
29
+ // not in the key; --no-cache forces a run.
30
+ //
31
+ // Output: one JSON line on stdout
32
+ // {"status":"passed|failed|interrupted|unconfigured|invalid|error","exitCode","tree",
33
+ // "command","source":"cache|run","cached","log","waitedMs","queuedBehind"?,"warnings"?,"message"?}
34
+ // Exit: 0 passed / cached / unconfigured, 1 failed, 2 usage error or invalid config,
35
+ // 3 internal error, 130/143/129 interrupted by SIGINT/SIGTERM/SIGHUP.
36
+ // Teardown covers the launched process group only; a command that escapes it
37
+ // (setsid, a daemon) is outside the guarantee.
38
+ // Test-only env: TRAILHEAD_TEST_POLL_MS (default 1000), TRAILHEAD_TEST_GRACE_MS
39
+ // (default 5000), TRAILHEAD_TEST_PAUSE_AFTER_HIT_MS, TRAILHEAD_TEST_CRASH_AT=before-go,
40
+ // TRAILHEAD_TEST_FAIL_PGID_UPDATE (inject a failure when recording the pgid).
41
+
42
+ const fs = require('fs');
43
+ const os = require('os');
44
+ const path = require('path');
45
+ const crypto = require('crypto');
46
+ const { execFileSync, spawn, spawnSync } = require('child_process');
47
+ const { gitEnv } = require('./trailhead-git-env.js');
48
+
49
+ const CONFIG_REL = path.join('.trailhead', 'config.json');
50
+ const STALE_MS = 30 * 1000;
51
+ const LONG_RUNNING_MS = 30 * 60 * 1000;
52
+ const DEFAULT_POLL_MS = 1000;
53
+ const DEFAULT_GRACE_MS = 5000;
54
+ const KINDS = ['full', 'targeted'];
55
+
56
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
57
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
58
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
+ const errMsg = (err) => (err && err.message ? err.message : String(err));
60
+
61
+ // ---- pure layer ---------------------------------------------------------------
62
+
63
+ // POSIX single quoting.
64
+ const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
65
+
66
+ // Replaces every {files} with the quoted files joined by spaces.
67
+ const substituteFiles = (template, files) => template.split('{files}').join(files.map(shQuote).join(' '));
68
+
69
+ const cacheKey = (tree, command, commonDir) =>
70
+ crypto.createHash('sha256').update(`${tree}\0${command}\0${commonDir}`).digest('hex');
71
+
72
+ // Picks `testing.<key>` out of a parsed config: { found, value, warning? }.
73
+ function pick(cfg, key, file) {
74
+ const nested = isObject(cfg.testing) && has(cfg.testing, key);
75
+ const flat = has(cfg, `testing.${key}`);
76
+ if (!nested && !flat) return { found: false };
77
+ const out = { found: true, value: nested ? cfg.testing[key] : cfg[`testing.${key}`] };
78
+ if (nested && flat) out.warning = `${file} sets testing.${key} in both nested and flat form; the nested form wins`;
79
+ return out;
80
+ }
81
+
82
+ function readFile(file, fs_) {
83
+ let text;
84
+ try {
85
+ text = fs_.readFileSync(file, 'utf8');
86
+ } catch (err) {
87
+ if (err && err.code === 'ENOENT') return { state: 'unset' };
88
+ return { state: 'invalid', message: `cannot read ${file}: ${errMsg(err)}` };
89
+ }
90
+ let cfg;
91
+ try {
92
+ cfg = JSON.parse(text);
93
+ } catch {
94
+ return { state: 'invalid', message: `${file} is not valid JSON` };
95
+ }
96
+ if (!isObject(cfg)) return { state: 'invalid', message: `${file} is not a JSON object` };
97
+ if (has(cfg, 'testing') && !isObject(cfg.testing)) return { state: 'invalid', message: `testing in ${file} must be an object` };
98
+ return { state: 'ok', cfg };
99
+ }
100
+
101
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
102
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
103
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
104
+
105
+ function globalConfigPath(env) {
106
+ const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
107
+ return path.join(base, 'trailhead', 'config.json');
108
+ }
109
+
110
+ const VALIDATORS = {
111
+ full: (v, where) => (typeof v === 'string' && v.trim() !== '' ? null : `testing.full in ${where} must be a non-empty string`),
112
+ targeted: (v, where) => (typeof v === 'string' && v.includes('{files}') ? null : `testing.targeted in ${where} must be a string containing {files}`),
113
+ maxParallel: (v, where) => ((Number.isInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9]\d*$/.test(v))
114
+ ? null : `testing.maxParallel in ${where} must be a positive integer`),
115
+ };
116
+
117
+ // Effective { full, targeted, maxParallel } (undefined when unset) or { error }.
118
+ function readConfig(configRoot, env, fs_, warnings) {
119
+ const files = [path.join(configRoot, CONFIG_REL), globalConfigPath(env)];
120
+ const parsed = [];
121
+ for (const file of files) {
122
+ const r = readFile(file, fs_);
123
+ if (r.state === 'invalid') return { error: r.message };
124
+ parsed.push({ file, cfg: r.state === 'ok' ? r.cfg : null });
125
+ }
126
+ const out = {};
127
+ for (const key of Object.keys(VALIDATORS)) {
128
+ for (const { file, cfg } of parsed) {
129
+ if (!cfg) continue;
130
+ const p = pick(cfg, key, file);
131
+ if (!p.found) continue;
132
+ const problem = VALIDATORS[key](p.value, file);
133
+ if (problem) return { error: problem };
134
+ if (p.warning) warnings.push(p.warning);
135
+ out[key] = key === 'maxParallel' ? Number(p.value) : p.value;
136
+ break;
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // Command prefix: nice / ionice when each is on the PATH.
143
+ function niceArgs(pathEnv, fs_ = fs) {
144
+ const onPath = (bin) => String(pathEnv || '').split(path.delimiter).filter(Boolean).some((d) => {
145
+ try {
146
+ fs_.accessSync(path.join(d, bin), fs.constants.X_OK);
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ });
152
+ const out = [];
153
+ if (onPath('nice')) out.push('nice', '-n', '10');
154
+ if (onPath('ionice')) out.push('ionice', '-c', '3');
155
+ return out;
156
+ }
157
+
158
+ // Like niceArgs, but each present binary is probed once (`<bin> <args> true`) and
159
+ // only the ones that work are used (ionice can be denied by ioprio_set).
160
+ // Returns { args, warnings }.
161
+ function priorityPrefix(pathEnv, fs_ = fs) {
162
+ const args = [];
163
+ const warnings = [];
164
+ const parts = [['nice', ['-n', '10']], ['ionice', ['-c', '3']]];
165
+ for (const [bin, binArgs] of parts) {
166
+ if (niceArgs(pathEnv, fs_).indexOf(bin) < 0) continue;
167
+ const r = spawnSync(bin, [...binArgs, 'true'], { env: { ...process.env, PATH: pathEnv }, stdio: 'ignore' });
168
+ if (r.status === 0) args.push(bin, ...binArgs);
169
+ else warnings.push(`priority-unavailable: ${bin} is on the PATH but failed its probe, so the suite runs without it`);
170
+ }
171
+ return { args, warnings };
172
+ }
173
+
174
+ function parseArgs(argv) {
175
+ if (argv[0] !== 'run') return { error: `usage: expected "run", got: ${argv[0] === undefined ? '(nothing)' : argv[0]}` };
176
+ const out = { kind: 'full', repo: null, configRoot: null, files: null, noCache: false };
177
+ for (let i = 1; i < argv.length; i++) {
178
+ const flag = argv[i];
179
+ if (flag === '--no-cache') { out.noCache = true; continue; }
180
+ if (!['--repo', '--config-root', '--kind', '--files'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
181
+ i++;
182
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
183
+ const value = argv[i];
184
+ if (flag === '--repo') out.repo = value;
185
+ else if (flag === '--config-root') out.configRoot = value;
186
+ else if (flag === '--kind') {
187
+ if (!KINDS.includes(value)) return { error: `usage: --kind must be full or targeted, got: ${value}` };
188
+ out.kind = value;
189
+ } else {
190
+ out.files = value.split(',').filter(Boolean);
191
+ if (out.files.length === 0) return { error: 'usage: --files needs at least one file' };
192
+ }
193
+ }
194
+ if (!out.repo) return { error: 'usage: run needs --repo' };
195
+ if (out.kind === 'targeted' && !out.files) return { error: 'usage: --kind targeted needs --files' };
196
+ if (out.kind === 'full' && out.files) return { error: 'usage: --files only goes with --kind targeted' };
197
+ return out;
198
+ }
199
+
200
+ // ---- process liveness -----------------------------------------------------------
201
+
202
+ function pidAlive(pid) {
203
+ if (!Number.isInteger(pid) || pid <= 0) return false;
204
+ try {
205
+ process.kill(pid, 0);
206
+ return true;
207
+ } catch (err) {
208
+ return !!(err && err.code === 'EPERM');
209
+ }
210
+ }
211
+
212
+ function groupAlive(pgid) {
213
+ if (!Number.isInteger(pgid) || pgid <= 1) return false;
214
+ try {
215
+ process.kill(-pgid, 0);
216
+ return true;
217
+ } catch (err) {
218
+ return !!(err && err.code === 'EPERM');
219
+ }
220
+ }
221
+
222
+ function killGroup(pgid, signal) {
223
+ try { process.kill(-pgid, signal); } catch { /* group already gone */ }
224
+ }
225
+
226
+ // SIGTERM, poll for the grace period, SIGKILL, wait until the group is gone.
227
+ async function teardownGroup(pgid, graceMs) {
228
+ if (!groupAlive(pgid)) return;
229
+ killGroup(pgid, 'SIGTERM');
230
+ const step = 20;
231
+ for (let waited = 0; groupAlive(pgid) && waited < graceMs; waited += step) await sleep(step);
232
+ if (!groupAlive(pgid)) return;
233
+ killGroup(pgid, 'SIGKILL');
234
+ for (let waited = 0; groupAlive(pgid) && waited < 5000; waited += step) await sleep(step);
235
+ }
236
+
237
+ // ---- filesystem helpers ---------------------------------------------------------
238
+
239
+ const rand = () => crypto.randomBytes(4).toString('hex');
240
+
241
+ // Temp name readers ignore, then rename into place.
242
+ function writeAtomic(file, text) {
243
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}-${rand()}.tmp`);
244
+ try {
245
+ fs.writeFileSync(tmp, text);
246
+ fs.renameSync(tmp, file);
247
+ } catch (err) {
248
+ try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
249
+ throw err;
250
+ }
251
+ }
252
+
253
+ const rmQuiet = (file) => { try { fs.unlinkSync(file); } catch { /* already gone */ } };
254
+
255
+ function readJson(file) {
256
+ try {
257
+ return { ok: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) };
258
+ } catch (err) {
259
+ if (err && (err.code === 'ENOENT')) return { ok: false, missing: true };
260
+ if (err instanceof SyntaxError) return { ok: false, corrupt: true };
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ function ageMs(file, nowMs) {
266
+ try { return nowMs - fs.statSync(file).mtimeMs; } catch { return 0; }
267
+ }
268
+
269
+ // ---- git: tree snapshot -----------------------------------------------------------
270
+
271
+ function git(repo, args, env, extra) {
272
+ return execFileSync('git', ['--no-optional-locks', '-C', repo, ...args],
273
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv(env, extra), maxBuffer: 64 * 1024 * 1024 });
274
+ }
275
+
276
+ // True when a `git status --porcelain=v2` entry is a submodule with uncommitted
277
+ // content (m) or untracked files (u); a different checked-out commit (C) is
278
+ // covered by the tree.
279
+ function hasDirtySubmodule(statusText) {
280
+ return statusText.split('\n').some((line) => {
281
+ if (!/^[12] /.test(line)) return false;
282
+ const sub = line.split(' ')[2] || '';
283
+ return sub[0] === 'S' && (sub[2] === 'M' || sub[3] === 'U');
284
+ });
285
+ }
286
+
287
+ // { tree, commonDir, dirtySubmodule }; tree null when not a git repo (or no commit yet).
288
+ function snapshotTree(repo, env) {
289
+ let common;
290
+ try {
291
+ common = fs.realpathSync(git(repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'], env).trim());
292
+ git(repo, ['rev-parse', '--verify', 'HEAD'], env);
293
+ } catch {
294
+ return { tree: null, commonDir: null, dirtySubmodule: false };
295
+ }
296
+ const status = git(repo, ['status', '--porcelain=v2', '--ignore-submodules=none'], env);
297
+ const dirtySubmodule = hasDirtySubmodule(status);
298
+ if (status.trim() === '') {
299
+ return { tree: git(repo, ['rev-parse', 'HEAD^{tree}'], env).trim(), commonDir: common, dirtySubmodule };
300
+ }
301
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-test-run-idx-'));
302
+ try {
303
+ const extra = { GIT_INDEX_FILE: path.join(dir, 'index') };
304
+ // Seed the temp index from the real one so stat data is reused and
305
+ // `git add -A` does not rehash every file; read-tree HEAD is the fallback.
306
+ let seeded = false;
307
+ try {
308
+ const real = path.resolve(repo, git(repo, ['rev-parse', '--git-path', 'index'], env).trim());
309
+ fs.copyFileSync(real, extra.GIT_INDEX_FILE);
310
+ git(repo, ['add', '-A'], env, extra);
311
+ seeded = true;
312
+ } catch {
313
+ rmQuiet(extra.GIT_INDEX_FILE);
314
+ }
315
+ if (!seeded) {
316
+ git(repo, ['read-tree', 'HEAD'], env, extra);
317
+ git(repo, ['add', '-A'], env, extra);
318
+ }
319
+ return { tree: git(repo, ['write-tree'], env, extra).trim(), commonDir: common, dirtySubmodule };
320
+ } finally {
321
+ fs.rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ }
324
+
325
+ // ---- cache dir, results, queue ----------------------------------------------------
326
+
327
+ function cacheDir(env) {
328
+ if (env.TRAILHEAD_CACHE_DIR) return env.TRAILHEAD_CACHE_DIR;
329
+ const xdg = env.XDG_CACHE_HOME || path.join(env.HOME || os.homedir(), '.cache');
330
+ return path.join(xdg, 'trailhead');
331
+ }
332
+
333
+ function readResult(dirs, key) {
334
+ const r = readJson(path.join(dirs.results, `${key}.json`));
335
+ if (!r.ok) return null;
336
+ const v = r.value;
337
+ return isObject(v) && v.exitCode === 0 && v.key === key ? v : null;
338
+ }
339
+
340
+ const HOLDER_RE = /^holder-.*\.json$/;
341
+
342
+ // Gate: born complete via link(), so it is never empty or partial.
343
+ // hooks.afterRead (test-only) runs between reading a dead gate and renaming it.
344
+ function acquireGate(dirs, nowMs, hooks = {}) {
345
+ const gate = path.join(dirs.queue, '.gate');
346
+ const token = rand();
347
+ const tmp = path.join(dirs.queue, `.gate.tmp-${process.pid}-${token}`);
348
+ fs.writeFileSync(tmp, JSON.stringify({ pid: process.pid, token }));
349
+ try {
350
+ for (let attempt = 0; attempt < 3; attempt++) {
351
+ try {
352
+ fs.linkSync(tmp, gate);
353
+ return { held: true, gate, token };
354
+ } catch (err) {
355
+ if (!err || err.code !== 'EEXIST') throw err;
356
+ }
357
+ const cur = readJson(gate);
358
+ if (cur.missing) continue;
359
+ const seen = cur.ok && isObject(cur.value) ? cur.value : null;
360
+ const owner = seen ? seen.pid : null;
361
+ const dead = owner === null ? ageMs(gate, nowMs) > STALE_MS : !pidAlive(owner);
362
+ if (!dead) return { held: false, pid: owner };
363
+ if (hooks.afterRead) hooks.afterRead();
364
+ // Reap a dead gate by renaming it away (atomic), then confirm what was
365
+ // renamed is the dead gate that was read: another reaper may have swapped
366
+ // in a live gate between the read and the rename.
367
+ const stale = `${gate}.stale-${process.pid}-${rand()}`;
368
+ try { fs.renameSync(gate, stale); } catch { continue; /* raced */ }
369
+ const moved = readJson(stale);
370
+ const same = seen
371
+ ? moved.ok && isObject(moved.value) && moved.value.pid === seen.pid && moved.value.token === seen.token
372
+ : !moved.ok;
373
+ if (same) { rmQuiet(stale); continue; }
374
+ // A live gate was displaced: put it back. EEXIST means someone else holds
375
+ // the gate now; either way this attempt did not get it.
376
+ const liveOwner = moved.ok && isObject(moved.value) ? moved.value.pid : null;
377
+ try { fs.linkSync(stale, gate); } catch { /* someone else holds it */ }
378
+ rmQuiet(stale);
379
+ return { held: false, pid: liveOwner };
380
+ }
381
+ return { held: false, pid: null };
382
+ } finally {
383
+ rmQuiet(tmp);
384
+ }
385
+ }
386
+
387
+ function releaseGate(g) {
388
+ const cur = readJson(g.gate);
389
+ if (cur.ok && isObject(cur.value) && cur.value.token === g.token) rmQuiet(g.gate);
390
+ }
391
+
392
+ // Sweeps leftover gate temps and stale-renamed gates of dead creators.
393
+ function sweepGateLeftovers(dirs) {
394
+ for (const name of fs.readdirSync(dirs.queue)) {
395
+ const m = /^\.gate\.(?:tmp|stale)-(\d+)-/.exec(name);
396
+ if (m && m[1] !== String(process.pid) && !pidAlive(Number(m[1]))) rmQuiet(path.join(dirs.queue, name));
397
+ }
398
+ }
399
+
400
+ // Live holders after reaping the dead ones. Alive = wrapper pid or process group alive.
401
+ function liveHolders(dirs, nowMs) {
402
+ const live = [];
403
+ for (const name of fs.readdirSync(dirs.queue)) {
404
+ const file = path.join(dirs.queue, name);
405
+ if (/^\.holder-.*\.tmp$/.test(name) || /^\..*\.tmp$/.test(name)) {
406
+ if (ageMs(file, nowMs) > STALE_MS) rmQuiet(file);
407
+ continue;
408
+ }
409
+ if (!HOLDER_RE.test(name)) continue;
410
+ const r = readJson(file);
411
+ if (r.missing) continue;
412
+ if (!r.ok || !isObject(r.value)) {
413
+ if (ageMs(file, nowMs) > STALE_MS) rmQuiet(file);
414
+ else live.push({ file, limit: Infinity, key: null, pid: null });
415
+ continue;
416
+ }
417
+ const h = r.value;
418
+ if (pidAlive(h.pid) || groupAlive(h.pgid)) live.push({ ...h, file, limit: Number.isInteger(h.limit) && h.limit > 0 ? h.limit : 1 });
419
+ else rmQuiet(file);
420
+ }
421
+ return live;
422
+ }
423
+
424
+ const describeHolder = (h, nowMs) => {
425
+ const age = Date.parse(h.startedAt);
426
+ const long = Number.isFinite(age) && nowMs - age > LONG_RUNNING_MS;
427
+ return `queued behind ${h.command || '(unknown command)'} on ${h.repo || '?'} (tree ${String(h.tree || 'none').slice(0, 12)}), pid ${h.pid}${long ? ' [long-running]' : ''}`;
428
+ };
429
+
430
+ // One admission attempt. { admitted, holderFile } or { admitted: false, behind }.
431
+ // Total order of holders: (number, token). A holder still choosing (no number yet)
432
+ // is a late arrival and sorts after everyone; one with no stamp at all (unparseable,
433
+ // counted live) sorts first, which is the conservative side.
434
+ const rank = (h) => (Number.isFinite(h.number) ? h.number : h.choosing === true ? Infinity : -Infinity);
435
+ const holderBefore = (a, b) => {
436
+ const ra = rank(a);
437
+ const rb = rank(b);
438
+ if (ra !== rb) return ra < rb;
439
+ return String(a.token || '') < String(b.token || '');
440
+ };
441
+
442
+ // Admission decision over the live holders (ours included): true when no holder
443
+ // ahead of us carries our cache key and our position is below the smallest limit
444
+ // among the holders at or ahead of us (the strictest live policy wins).
445
+ function admittedByRecount(live, mine) {
446
+ const ahead = live.filter((h) => h.token !== mine.token && holderBefore(h, mine));
447
+ if (mine.key && ahead.some((h) => h.key === mine.key)) return false;
448
+ const limit = Math.min(mine.limit, ...ahead.map((h) => h.limit));
449
+ return ahead.length < limit;
450
+ }
451
+
452
+ // Mutual exclusion does not rest on the gate (a contention optimisation that can
453
+ // fail under crash recovery) but on Lamport's bakery doorway, correct with atomic
454
+ // file writes and reads: (1) write the holder with choosing:true and no number;
455
+ // (2) read the live holders and take number = 1 + the largest number seen, then
456
+ // rewrite the holder as {number, choosing:false}; (3) wait until no other live
457
+ // holder is choosing (a dead one is reaped by the liveness rules, so a crashed
458
+ // chooser never blocks); (4) recount over (number, token) and admit only when no
459
+ // earlier holder has our cache key and our position is below the strictest limit
460
+ // at or ahead of us, else withdraw and let the caller retry with a fresh doorway.
461
+ // If B finishes its doorway before A starts its own, A's number is larger, so A
462
+ // defers; if the doorways overlap, each waits in (3) until the other has a number,
463
+ // and both then see each other with numbers and agree on (number, token).
464
+ // Hooks (test-only): forgeGate makes the gate always held, skipPrecheck skips the
465
+ // count under the gate, afterPrecheck runs before the holder write,
466
+ // afterChoosingWrite between the doorway steps 1 and 2, afterNumber after step 2.
467
+ async function tryAdmit(dirs, me, nowMs, hooks = {}, pollMs = 50) {
468
+ fs.mkdirSync(dirs.queue, { recursive: true });
469
+ const gate = hooks.forgeGate ? { held: true, gate: path.join(dirs.queue, '.gate'), token: 'forged' } : acquireGate(dirs, nowMs);
470
+ if (!gate.held) return { admitted: false, behind: `queued behind the queue gate held by pid ${gate.pid === null ? '?' : gate.pid}` };
471
+ const token = rand();
472
+ const holderFile = path.join(dirs.queue, `holder-${process.pid}-${token}.json`);
473
+ try {
474
+ sweepGateLeftovers(dirs);
475
+ const before = liveHolders(dirs, nowMs);
476
+ if (!hooks.skipPrecheck) {
477
+ const same = me.key ? before.find((h) => h.key === me.key) : null;
478
+ const limit = Math.min(me.limit, ...before.map((h) => h.limit));
479
+ if (same || before.length >= limit) {
480
+ const blocker = same || before.slice().sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)))[0];
481
+ return { admitted: false, behind: describeHolder(blocker, nowMs) };
482
+ }
483
+ }
484
+ if (hooks.afterPrecheck) await hooks.afterPrecheck();
485
+ writeAtomic(holderFile, JSON.stringify({ pid: process.pid, pgid: null, limit: me.limit, key: me.key, repo: me.repo, tree: me.tree, command: me.command, startedAt: new Date(nowMs).toISOString(), choosing: true, token }));
486
+ } finally {
487
+ releaseGate(gate);
488
+ }
489
+ try {
490
+ if (hooks.afterChoosingWrite) await hooks.afterChoosingWrite();
491
+ const seen = liveHolders(dirs, nowMs).filter((h) => h.token !== token && Number.isFinite(h.number));
492
+ const number = 1 + Math.max(0, ...seen.map((h) => h.number));
493
+ updateHolder(holderFile, { number, choosing: false });
494
+ if (hooks.afterNumber) await hooks.afterNumber();
495
+ let live = liveHolders(dirs, nowMs);
496
+ while (live.some((h) => h.token !== token && h.choosing === true)) {
497
+ await sleep(pollMs);
498
+ live = liveHolders(dirs, nowMs);
499
+ }
500
+ if (!admittedByRecount(live, { token, number, key: me.key, limit: me.limit })) {
501
+ rmQuiet(holderFile);
502
+ const ahead = live.filter((h) => h.token !== token && holderBefore(h, { token, number }));
503
+ return { admitted: false, behind: ahead.length > 0 ? describeHolder(ahead[0], nowMs) : 'queued behind a concurrent admission (retrying)' };
504
+ }
505
+ return { admitted: true, holderFile };
506
+ } catch (e) {
507
+ rmQuiet(holderFile);
508
+ throw e;
509
+ }
510
+ }
511
+
512
+ function updateHolder(holderFile, patch) {
513
+ const r = readJson(holderFile);
514
+ const cur = r.ok && isObject(r.value) ? r.value : {};
515
+ writeAtomic(holderFile, JSON.stringify({ ...cur, ...patch }));
516
+ }
517
+
518
+ // ---- running ------------------------------------------------------------------------
519
+
520
+ const launcherScript = (prefix) =>
521
+ `IFS= read -r go || exit 125; [ "$go" = go ] || exit 125; exec ${prefix.concat('sh').join(' ')} -c "$TRAILHEAD_TEST_CMD" </dev/null`;
522
+
523
+ // Spawns the handshake launcher detached in its own process group. Returns
524
+ // { child, pgid, exited } where exited resolves { code, signal } when it ends.
525
+ function launch(command, repo, logFd, env, prefix) {
526
+ const child = spawn('sh', ['-c', launcherScript(prefix)], {
527
+ cwd: repo,
528
+ detached: true,
529
+ stdio: ['pipe', logFd, logFd],
530
+ env: gitEnv(env, { TRAILHEAD_TEST_CMD: command }),
531
+ });
532
+ const exited = new Promise((resolve, reject) => {
533
+ child.once('error', reject);
534
+ child.once('exit', (code, signal) => resolve({ code, signal }));
535
+ });
536
+ child.stdin.on('error', () => { /* launcher already gone */ });
537
+ return { child, pgid: child.pid, exited };
538
+ }
539
+
540
+ const exitCodeOf = ({ code, signal }) => (code !== null ? code : 128 + (os.constants.signals[signal] || 0));
541
+
542
+ function output(stdout, result) {
543
+ stdout.write(JSON.stringify(result) + '\n');
544
+ }
545
+
546
+ async function main(argv, { cwd, env, stdout, stderr, now, signals = true } = {}) {
547
+ const cwd_ = cwd || process.cwd();
548
+ const env_ = env || process.env;
549
+ const out = stdout || process.stdout;
550
+ const err = stderr || process.stderr;
551
+ const nowMs = () => (now ? now().getTime() : Date.now());
552
+ const started = nowMs();
553
+
554
+ const parsed = parseArgs(argv || []);
555
+ if (parsed.error) {
556
+ err.write(parsed.error + '\n');
557
+ return 2;
558
+ }
559
+ const repo = path.resolve(cwd_, parsed.repo);
560
+ const configRoot = parsed.configRoot ? path.resolve(cwd_, parsed.configRoot) : repo;
561
+ const warnings = [];
562
+ const base = { status: 'error', exitCode: null, tree: null, command: null, source: 'run', cached: false, log: null, waitedMs: 0 };
563
+ const emit = (result) => {
564
+ const final = { ...base, ...result };
565
+ if (warnings.length > 0) final.warnings = warnings.slice();
566
+ output(out, final);
567
+ };
568
+
569
+ let signalled = null;
570
+ let activeGroup = null;
571
+ let teardown = null;
572
+ const graceMs = Number(env_.TRAILHEAD_TEST_GRACE_MS) || DEFAULT_GRACE_MS;
573
+ const pollMs = Number(env_.TRAILHEAD_TEST_POLL_MS) || DEFAULT_POLL_MS;
574
+ const handlers = {};
575
+ if (signals) {
576
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
577
+ handlers[sig] = () => {
578
+ signalled = signalled || sig;
579
+ if (activeGroup !== null && !teardown) teardown = teardownGroup(activeGroup, graceMs);
580
+ };
581
+ process.on(sig, handlers[sig]);
582
+ }
583
+ }
584
+
585
+ let holderFile = null;
586
+ let logFd = null;
587
+ try {
588
+ const cfg = readConfig(configRoot, env_, fs, warnings);
589
+ if (cfg.error) {
590
+ emit({ status: 'invalid', message: cfg.error });
591
+ return 2;
592
+ }
593
+ const template = cfg[parsed.kind];
594
+ if (template === undefined) {
595
+ emit({ status: 'unconfigured' });
596
+ return 0;
597
+ }
598
+ const command = parsed.kind === 'targeted' ? substituteFiles(template, parsed.files) : template;
599
+ base.command = command;
600
+ const limit = cfg.maxParallel || 1;
601
+ const dirs = {
602
+ root: cacheDir(env_),
603
+ results: path.join(cacheDir(env_), 'results'),
604
+ logs: path.join(cacheDir(env_), 'logs'),
605
+ queue: path.join(cacheDir(env_), 'queue'),
606
+ };
607
+
608
+ const snap = () => {
609
+ const s = snapshotTree(repo, env_);
610
+ return { ...s, key: s.tree && s.commonDir ? cacheKey(s.tree, command, s.commonDir) : null };
611
+ };
612
+ const cacheable = (s) => s.key !== null && !s.dirtySubmodule;
613
+ let dirtyNoted = false;
614
+ const noteDirty = (s) => {
615
+ if (s.dirtySubmodule && !dirtyNoted) {
616
+ dirtyNoted = true;
617
+ warnings.push('dirty-submodule: a submodule has uncommitted content or untracked files, so this run is not cached');
618
+ }
619
+ };
620
+
621
+ // Cache hit that is still valid: the tree is recomputed before the hit is returned.
622
+ const lookup = async () => {
623
+ for (;;) {
624
+ const s = snap();
625
+ noteDirty(s);
626
+ if (parsed.noCache || !cacheable(s)) return { s, hit: null };
627
+ const hit = readResult(dirs, s.key);
628
+ if (!hit) return { s, hit: null };
629
+ const pause = Number(env_.TRAILHEAD_TEST_PAUSE_AFTER_HIT_MS);
630
+ if (pause > 0) await sleep(pause);
631
+ const again = snap();
632
+ if (again.key === s.key) return { s, hit };
633
+ }
634
+ };
635
+
636
+ const cached = (s, hit) => {
637
+ emit({ status: 'passed', exitCode: 0, tree: s.tree, command, source: 'cache', cached: true, waitedMs: nowMs() - started, log: null, ...(base.queuedBehind ? { queuedBehind: base.queuedBehind } : {}) });
638
+ return 0;
639
+ };
640
+ const interrupted = (s) => {
641
+ emit({ status: 'interrupted', signal: signalled, tree: s ? s.tree : null, command, log: base.log, waitedMs: nowMs() - started });
642
+ return signalled === 'SIGINT' ? 130 : signalled === 'SIGHUP' ? 129 : 143;
643
+ };
644
+ const release = () => { if (holderFile) { rmQuiet(holderFile); holderFile = null; } };
645
+
646
+ let s = null;
647
+ let lastBehind = null;
648
+ for (;;) {
649
+ const first = await lookup();
650
+ s = first.s;
651
+ if (first.hit) return cached(s, first.hit);
652
+
653
+ // Queue admission; the key can move while waiting (re-entered below).
654
+ for (;;) {
655
+ if (signalled) return interrupted(s);
656
+ const adm = await tryAdmit(dirs, { key: s.key, limit, repo, tree: s.tree, command }, nowMs(), {}, pollMs);
657
+ if (adm.admitted) { holderFile = adm.holderFile; break; }
658
+ if (adm.behind !== lastBehind) {
659
+ lastBehind = adm.behind;
660
+ base.queuedBehind = adm.behind;
661
+ err.write(adm.behind + '\n');
662
+ }
663
+ await sleep(pollMs);
664
+ }
665
+
666
+ // Recompute after acquiring: a moved key re-enters admission under the new key.
667
+ const second = await lookup();
668
+ if (second.s.key !== s.key) { release(); continue; }
669
+ if (second.hit) { release(); return cached(second.s, second.hit); }
670
+ s = second.s;
671
+ break;
672
+ }
673
+
674
+ // Run through the handshake launcher.
675
+ fs.mkdirSync(dirs.logs, { recursive: true });
676
+ base.log = path.join(dirs.logs, `${(s.tree || 'notree').slice(0, 12)}-${Math.floor(nowMs() / 1000)}-${process.pid}.log`);
677
+ logFd = fs.openSync(base.log, 'a');
678
+ const prio = priorityPrefix(env_.PATH);
679
+ warnings.push(...prio.warnings);
680
+ const run = launch(command, repo, logFd, env_, prio.args);
681
+ activeGroup = run.pgid;
682
+ if (signalled) teardown = teardown || teardownGroup(run.pgid, graceMs);
683
+ try {
684
+ if (env_.TRAILHEAD_TEST_FAIL_PGID_UPDATE) throw new Error('injected pgid update failure');
685
+ updateHolder(holderFile, { pgid: run.pgid });
686
+ } catch (e) {
687
+ // Never leave a launched suite unrecorded: close the handshake pipe (the launcher
688
+ // exits 125 without running) and tear the group down before reporting the error.
689
+ run.child.stdin.destroy();
690
+ await teardownGroup(run.pgid, graceMs);
691
+ activeGroup = null;
692
+ throw e;
693
+ }
694
+ if (env_.TRAILHEAD_TEST_CRASH_AT === 'before-go') process.exit(99);
695
+ run.child.stdin.end('go\n');
696
+
697
+ const ended = await run.exited;
698
+ let leftover = false;
699
+ if (!signalled) {
700
+ for (let i = 0; i < 3 && !leftover; i++) {
701
+ if (!groupAlive(run.pgid)) break;
702
+ if (i === 2) leftover = true; else await sleep(20);
703
+ }
704
+ if (leftover) warnings.push('leftover-processes: the command left running processes behind, so the result is not cached');
705
+ }
706
+ await (teardown || teardownGroup(run.pgid, graceMs));
707
+ activeGroup = null;
708
+
709
+ if (signalled) return interrupted(s);
710
+
711
+ const exitCode = exitCodeOf(ended);
712
+ const after = snap();
713
+ const moved = after.key !== s.key;
714
+ if (moved) warnings.push('tree-changed: the working tree changed while the command ran, so the result is not cached');
715
+ let stored = false;
716
+ if (exitCode === 0 && !moved && !leftover && cacheable(s)) {
717
+ try {
718
+ fs.mkdirSync(dirs.results, { recursive: true });
719
+ writeAtomic(path.join(dirs.results, `${s.key}.json`), JSON.stringify({ key: s.key, tree: s.tree, command, exitCode: 0, finishedAt: new Date(nowMs()).toISOString() }));
720
+ stored = true;
721
+ } catch (e) {
722
+ warnings.push(`cache-write-failed: ${errMsg(e)}`);
723
+ }
724
+ }
725
+ emit({
726
+ status: exitCode === 0 ? 'passed' : 'failed', exitCode, tree: s.tree, command, source: 'run', cached: false,
727
+ log: base.log, waitedMs: nowMs() - started, stored,
728
+ ...(base.queuedBehind ? { queuedBehind: base.queuedBehind } : {}),
729
+ });
730
+ return exitCode === 0 ? 0 : 1;
731
+ } catch (e) {
732
+ emit({ status: 'error', message: 'error: ' + errMsg(e) });
733
+ return 3;
734
+ } finally {
735
+ if (activeGroup !== null) await teardownGroup(activeGroup, graceMs).catch(() => {});
736
+ if (holderFile) rmQuiet(holderFile);
737
+ if (logFd !== null) { try { fs.closeSync(logFd); } catch { /* already closed */ } }
738
+ for (const sig of Object.keys(handlers)) process.removeListener(sig, handlers[sig]);
739
+ }
740
+ }
741
+
742
+ module.exports = { globalConfigPath,
743
+ shQuote, substituteFiles, cacheKey, pick, readConfig, niceArgs, priorityPrefix, parseArgs, hasDirtySubmodule,
744
+ snapshotTree, pidAlive, groupAlive, tryAdmit, liveHolders, acquireGate, releaseGate, readResult, main,
745
+ };
746
+
747
+ if (require.main === module) {
748
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
749
+ process.stderr.write('error: ' + errMsg(e) + '\n');
750
+ process.exit(3);
751
+ });
752
+ }