hoi4-agent-tools 3.10.0 → 4.0.1

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 (163) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +91 -147
  3. package/dist/bin/http.js +10 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +10 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
  9. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  10. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  11. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  12. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  13. package/dist/hoi4_agent_tools/core/engine.js +29 -2
  14. package/dist/hoi4_agent_tools/core/index.js +2 -2
  15. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  16. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  17. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  18. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  19. package/dist/hoi4_agent_tools/core/job-worker-host.js +314 -127
  20. package/dist/hoi4_agent_tools/core/job-worker.js +96 -37
  21. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  22. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  23. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  24. package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
  29. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  30. package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
  31. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  32. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  33. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  34. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  35. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  36. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  37. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  38. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  39. package/dist/hoi4_agent_tools/gui/assets.js +3 -0
  40. package/dist/hoi4_agent_tools/gui/dds.js +8 -4
  41. package/dist/hoi4_agent_tools/gui/layout.js +45 -5
  42. package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
  43. package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
  44. package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
  45. package/dist/hoi4_agent_tools/gui/studio.js +33 -21
  46. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  47. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  48. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  49. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  50. package/dist/hoi4_agent_tools/map/model.js +103 -0
  51. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  52. package/dist/hoi4_agent_tools/map/references.js +483 -0
  53. package/dist/hoi4_agent_tools/map/service.js +136 -61
  54. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  55. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  56. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  57. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
  58. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  59. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  60. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  61. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  62. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  63. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  64. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +28 -8
  65. package/dist/hoi4_agent_tools/mcp/tools/event.js +19 -30
  66. package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -13
  67. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
  68. package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
  69. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -18
  70. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  71. package/dist/hoi4_agent_tools/mcp/tools/reference.js +26 -9
  72. package/dist/hoi4_agent_tools/mcp/tools/technology.js +17 -30
  73. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  74. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
  75. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  76. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  77. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  78. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  79. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  80. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  81. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  82. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  83. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  84. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  85. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  86. package/dist/hoi4_agent_tools/runtime.js +14 -1
  87. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  88. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  89. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  90. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  91. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  92. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  93. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  94. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  95. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  96. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  97. package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
  98. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  99. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  100. package/dist/hoi4_agent_tools/version.js +1 -1
  101. package/docs/README.md +7 -4
  102. package/docs/analysis.md +15 -2
  103. package/docs/configuration.md +281 -0
  104. package/docs/development.md +26 -2
  105. package/docs/events.md +29 -6
  106. package/docs/examples.md +13 -28
  107. package/docs/focus.md +127 -5
  108. package/docs/gui.md +105 -21
  109. package/docs/helper-expansion.md +9 -9
  110. package/docs/http.md +3 -1
  111. package/docs/jobs.md +23 -14
  112. package/docs/map.md +199 -7
  113. package/docs/mechanics.md +6 -2
  114. package/docs/probability.md +46 -16
  115. package/docs/reference.md +73 -5
  116. package/docs/setup.md +45 -14
  117. package/docs/technology.md +28 -4
  118. package/docs/third-party-notices.md +11 -0
  119. package/docs/tools.md +150 -42
  120. package/docs/troubleshooting.md +61 -0
  121. package/examples/clients/codex-config.toml +5 -2
  122. package/examples/clients/generic.json +2 -2
  123. package/examples/config/config.json +7 -0
  124. package/npm-shrinkwrap.json +151 -156
  125. package/package.json +13 -8
  126. package/schemas/configuration.schema.json +144 -128
  127. package/schemas/continuous-focus-palette.schema.json +1 -1
  128. package/schemas/custom-weighted-pool.schema.json +1 -1
  129. package/schemas/decision-inspect-input.schema.json +1 -1
  130. package/schemas/error-log-input.schema.json +55 -0
  131. package/schemas/event-feature-manifest.schema.json +1 -1
  132. package/schemas/focus-plan.schema.json +1 -1
  133. package/schemas/focus-planning-sidecar.schema.json +1 -1
  134. package/schemas/gui-animation-source.schema.json +1 -1
  135. package/schemas/gui-helper.schema.json +1 -1
  136. package/schemas/gui-scenario.schema.json +1 -1
  137. package/schemas/helper-expansion-request.schema.json +1 -1
  138. package/schemas/helper-expansion-summary.schema.json +1 -1
  139. package/schemas/impact-inspect-input.schema.json +1 -1
  140. package/schemas/map-operation.schema.json +597 -126
  141. package/schemas/mechanic-test-input.schema.json +1 -1
  142. package/schemas/mod-index-input.schema.json +51 -0
  143. package/schemas/operation-result.schema.json +2 -2
  144. package/schemas/package-check-input.schema.json +1 -1
  145. package/schemas/package-manifest.schema.json +1 -1
  146. package/schemas/probability-analysis-result.schema.json +1 -1
  147. package/schemas/probability-compare-input.schema.json +1 -1
  148. package/schemas/probability-evaluate-input.schema.json +1 -1
  149. package/schemas/probability-inspect-input.schema.json +1 -1
  150. package/schemas/probability-render-input.schema.json +1 -1
  151. package/schemas/probability-scenario-set.schema.json +1 -1
  152. package/schemas/probability-sequence-input.schema.json +1 -1
  153. package/schemas/probability-simulate-input.schema.json +1 -1
  154. package/schemas/probability-sweep-input.schema.json +1 -1
  155. package/schemas/reference-context-input.schema.json +1 -1
  156. package/schemas/reference-read-input.schema.json +1 -1
  157. package/schemas/reference-search-input.schema.json +1 -1
  158. package/schemas/scenario-suite.schema.json +3 -5
  159. package/schemas/scenario-test-input.schema.json +3 -5
  160. package/schemas/script-validate-input.schema.json +1 -1
  161. package/schemas/shared-scenario.schema.json +1 -1
  162. package/schemas/source-lookup-input.schema.json +1 -1
  163. package/server.json +3 -3
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { closeSync, fstatSync, openSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
2
3
  import { mkdir } from 'node:fs/promises';
4
+ import path from 'node:path';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import { setTimeout as delay } from 'node:timers/promises';
5
7
  import { JobService } from './job-service.js';
@@ -8,8 +10,16 @@ import { RequestScheduler } from './request-scheduler.js';
8
10
  import { SharedRequestCapacity } from './shared-request-capacity.js';
9
11
  import { containedGeneratedPath } from './workspace.js';
10
12
  import { ServiceError } from './result.js';
13
+ import { isDomainToolName } from './domain-tools.js';
14
+ import { classifyWorkerExit, defaultWorkerHeapMiB, } from './worker-exit.js';
11
15
  const maximumCheckpointRecoveries = 2;
12
16
  const maximumPreDispatchRecoveries = 2;
17
+ /** Used only when the exit status of the worker is unknown to this process. */
18
+ const WORKER_EXIT_MESSAGE = 'The worker stopped without publishing a terminal job outcome; its exit status is not available to this server, so retry the call';
19
+ /** Bytes of a worker's standard error read back when it exits unexpectedly. */
20
+ const STDERR_TAIL_BYTES = 16_384;
21
+ /** Standard-error logs of workers that exited unexpectedly kept for diagnosis. */
22
+ const RETAINED_STDERR_LOGS = 10;
13
23
  // Native and IPC codes are fixed vocabulary. Error messages can contain paths or source
14
24
  // text, so only an allowlisted code and the fixed coordination stage are reported.
15
25
  const nativeFailureCodes = new Set([
@@ -58,13 +68,68 @@ export function workerFailure(error, stage) {
58
68
  };
59
69
  }
60
70
  /** Stop a supervised worker and wait, bounded, until the operating system reports its exit. */
61
- async function stopChild(child) {
71
+ async function stopChild(child, stopped) {
62
72
  if (child.exitCode !== null || child.signalCode !== null)
63
73
  return;
74
+ stopped?.add(child);
64
75
  const exited = new Promise((resolve) => child.once('exit', () => resolve()));
65
76
  child.kill('SIGKILL');
66
77
  await Promise.race([exited, delay(10_000)]);
67
78
  }
79
+ /** Read the last bytes of a worker's standard-error log; empty when it cannot be read. */
80
+ function readTail(file) {
81
+ try {
82
+ const fd = openSync(file, 'r');
83
+ try {
84
+ const size = fstatSync(fd).size;
85
+ const length = Math.min(size, STDERR_TAIL_BYTES);
86
+ const buffer = Buffer.alloc(length);
87
+ readSync(fd, buffer, 0, length, size - length);
88
+ return buffer.toString('utf8');
89
+ }
90
+ finally {
91
+ closeSync(fd);
92
+ }
93
+ }
94
+ catch {
95
+ return '';
96
+ }
97
+ }
98
+ /** Keep only the newest few worker logs; the rest are older diagnostics. */
99
+ function pruneWorkerLogs(logRoot) {
100
+ try {
101
+ const logs = readdirSync(logRoot)
102
+ .filter((name) => name.startsWith('worker-') && name.endsWith('.log'))
103
+ .map((name) => ({ name, modified: statSync(path.join(logRoot, name)).mtimeMs }))
104
+ .sort((left, right) => right.modified - left.modified);
105
+ // A recent log may belong to a worker that is still running, so only older ones go.
106
+ const cutoff = Date.now() - 10 * 60_000;
107
+ for (const { name, modified } of logs.slice(RETAINED_STDERR_LOGS))
108
+ if (modified < cutoff)
109
+ rmSync(path.join(logRoot, name), { force: true });
110
+ }
111
+ catch {
112
+ // Diagnostics are best effort and must never change a job outcome.
113
+ }
114
+ }
115
+ /**
116
+ * Remove the log of a worker that ended normally or because the host stopped it. Keep the
117
+ * newest few logs of workers that ended unexpectedly, because their stack is the evidence.
118
+ */
119
+ function retainOrRemoveLog(file, evidence, logRoot) {
120
+ try {
121
+ const unexpected = !evidence.stoppedByHost &&
122
+ (evidence.signal !== null || (evidence.code !== null && evidence.code !== 0));
123
+ if (!unexpected) {
124
+ rmSync(file, { force: true });
125
+ return;
126
+ }
127
+ pruneWorkerLogs(logRoot);
128
+ }
129
+ catch {
130
+ // Diagnostics are best effort and must never change a job outcome.
131
+ }
132
+ }
68
133
  /**
69
134
  * Bounded, fixed-entry child execution. Client disconnection never kills a worker; an
70
135
  * overdue or unresponsive cancelled read-only worker is stopped by its supervising host.
@@ -74,12 +139,14 @@ export class JobWorkerHost {
74
139
  jobs;
75
140
  capacity;
76
141
  scheduler;
142
+ logRoot;
77
143
  owner = {};
78
- constructor(engine, jobs, capacity, scheduler) {
144
+ constructor(engine, jobs, capacity, scheduler, logRoot) {
79
145
  this.engine = engine;
80
146
  this.jobs = jobs;
81
147
  this.capacity = capacity;
82
148
  this.scheduler = scheduler;
149
+ this.logRoot = logRoot;
83
150
  }
84
151
  static async create(engine, jobs) {
85
152
  const state = engine.resolver.serverState();
@@ -87,43 +154,17 @@ export class JobWorkerHost {
87
154
  throw new ServiceError('JOB_STORAGE_UNAVAILABLE', 'Workers require persistent server state');
88
155
  const root = await containedGeneratedPath(state.root, 'job-workers');
89
156
  await mkdir(root, { recursive: true, mode: 0o700 });
157
+ const logRoot = path.join(root, 'logs');
158
+ await mkdir(logRoot, { recursive: true, mode: 0o700 });
159
+ pruneWorkerLogs(logRoot);
90
160
  const config = engine.resolver.config();
91
- return new JobWorkerHost(engine, jobs ?? (await JobService.create(engine.resolver)), new SharedRequestCapacity(root, config.maxSharedTools), new RequestScheduler(config.maxConcurrentTools));
161
+ return new JobWorkerHost(engine, jobs ?? (await JobService.create(engine.resolver)), new SharedRequestCapacity(root, config.maxSharedTools), new RequestScheduler(config.maxConcurrentTools), logRoot);
92
162
  }
93
163
  async run(workspaceId, id, principal) {
94
164
  const initial = await this.jobs.get(workspaceId, id, principal);
95
165
  if (['completed', 'cancelled', 'failed'].includes(initial.status))
96
166
  return initial;
97
- if (![
98
- 'hoi4.impact_inspect',
99
- 'hoi4.decision_inspect',
100
- 'hoi4.mechanic_test',
101
- 'hoi4.package_check',
102
- 'hoi4.scenario_test',
103
- 'hoi4.event_inspect',
104
- 'hoi4.event_render',
105
- 'hoi4.event_compare',
106
- 'hoi4.tech_inspect',
107
- 'hoi4.tech_render',
108
- 'hoi4.tech_compare',
109
- 'hoi4.probability_inspect',
110
- 'hoi4.probability_evaluate',
111
- 'hoi4.probability_sweep',
112
- 'hoi4.probability_simulate',
113
- 'hoi4.probability_sequence',
114
- 'hoi4.probability_compare',
115
- 'hoi4.probability_render',
116
- 'hoi4.map_inspect',
117
- 'hoi4.map_render',
118
- 'hoi4.map_rewrite',
119
- 'hoi4.gui_inspect',
120
- 'hoi4.gui_render',
121
- 'hoi4.gui_rewrite',
122
- 'hoi4.focus_inspect',
123
- 'hoi4.focus_render',
124
- 'hoi4.focus_raster',
125
- 'hoi4.focus_rewrite',
126
- ].includes(initial.request.toolName))
167
+ if (!isDomainToolName(initial.request.toolName))
127
168
  throw new ServiceError('JOB_OPERATION_UNAVAILABLE', 'This domain has not yet been registered for worker execution');
128
169
  const signal = new AbortController().signal;
129
170
  const recovered = new Set();
@@ -139,7 +180,7 @@ export class JobWorkerHost {
139
180
  return current;
140
181
  if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
141
182
  return await this.followOwner(workspaceId, id, principal);
142
- throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
183
+ throw new ServiceError('JOB_WORKER_EXIT', WORKER_EXIT_MESSAGE);
143
184
  }
144
185
  catch (error) {
145
186
  const current = await this.jobs.get(workspaceId, id, principal);
@@ -178,15 +219,19 @@ export class JobWorkerHost {
178
219
  if (liveness !== 'alive')
179
220
  throw new ServiceError(liveness === 'unknown' ? 'JOB_OWNER_UNRESOLVED' : 'JOB_WORKER_EXIT', liveness === 'unknown'
180
221
  ? 'The replacement execution owner cannot be verified on this host'
181
- : 'The replacement worker stopped without publishing a terminal job outcome');
222
+ : `The replacement worker stopped without publishing a terminal job outcome; its exit status belongs to the server that started it, so retry the call`);
182
223
  await delay(100);
183
224
  }
184
225
  }
185
226
  async launch(workspaceId, id, lease, progress, principal) {
186
- // Resolve the same configured workspace and grants again immediately before dispatch.
187
- await this.jobs.get(workspaceId, id, principal);
188
- const sourceMode = import.meta.url.endsWith('.ts');
189
- const entry = fileURLToPath(new URL(sourceMode ? './job-worker.ts' : './job-worker.js', import.meta.url));
227
+ // Resolve the same configured workspace and grants again immediately before dispatch. A
228
+ // job that finished, or that a live worker already runs, needs no new worker: another
229
+ // launcher may have run it while this one waited for capacity.
230
+ const current = await this.jobs.get(workspaceId, id, principal);
231
+ if (['completed', 'failed', 'cancelled'].includes(current.status))
232
+ return;
233
+ if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
234
+ return;
190
235
  const configuration = this.engine.resolver.config();
191
236
  const registeredWorkspaceIds = new Set(configuration.workspaces.map((workspace) => workspace.id));
192
237
  const resolvedWorkspaces = this.engine.resolver.list();
@@ -199,96 +244,54 @@ export class JobWorkerHost {
199
244
  workspaceIds: [...new Set([...grant.workspaceIds, ...discoveredWorkspaceIds])],
200
245
  }
201
246
  : grant;
247
+ const message = {
248
+ configuration: {
249
+ ...configuration,
250
+ modRoots: [],
251
+ workspaces: resolvedWorkspaces.map(({ registration }) => registration),
252
+ http: {
253
+ ...configuration.http,
254
+ tokens: configuration.http.tokens.map(preserveDiscoveredGrants),
255
+ principals: configuration.http.principals.map(preserveDiscoveredGrants),
256
+ },
257
+ },
258
+ workspaceId,
259
+ jobId: id,
260
+ ...(principal === undefined ? {} : { principal }),
261
+ };
262
+ const warm = configuration.jobWorkerIdleSeconds > 0;
202
263
  progress.stage = 'startup';
203
- const child = spawn(process.execPath, [
204
- ...(configuration.jobWorkerMaxHeapMiB === undefined
205
- ? []
206
- : [`--max-old-space-size=${configuration.jobWorkerMaxHeapMiB}`]),
207
- ...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []),
208
- entry,
209
- ], {
210
- detached: true,
211
- cwd: process.cwd(),
212
- stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
213
- windowsHide: true,
214
- });
215
- let phase = 'waiting_for_ready';
216
- let acknowledgmentTimer;
217
- const initialized = new Promise((resolve, reject) => {
218
- child.once('error', reject);
219
- child.once('exit', (code, signal) => reject(new ServiceError('JOB_WORKER_STARTUP_EXIT', `The worker exited before completing its startup handshake (phase ${phase}, exit ${code ?? 'none'}, signal ${signal ?? 'none'})`)));
220
- child.once('message', (value) => {
221
- void (async () => {
222
- if (typeof value !== 'object' ||
223
- value === null ||
224
- !('type' in value) ||
225
- value.type !== 'ready' ||
226
- child.pid === undefined)
227
- throw new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not provide its readiness handshake');
228
- progress.stage = 'handoff';
229
- await lease.handoffToProcess(child.pid);
230
- progress.stage = 'dispatch';
231
- await new Promise((resolve, reject) => {
232
- phase = 'waiting_for_acceptance';
233
- acknowledgmentTimer = setTimeout(() => reject(new ServiceError('JOB_WORKER_ACCEPT_TIMEOUT', 'The worker did not acknowledge receipt within its startup window')), 30_000);
234
- child.once('message', (accepted) => {
235
- if (typeof accepted !== 'object' ||
236
- accepted === null ||
237
- !('type' in accepted) ||
238
- accepted.type !== 'accepted') {
239
- reject(new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not acknowledge the dispatched job'));
240
- return;
241
- }
242
- phase = 'accepted';
243
- // Only close IPC after the child confirms receipt. The persistent job,
244
- // rather than the launcher's lifetime, owns execution after this point.
245
- if (child.connected)
246
- child.disconnect();
247
- resolve();
248
- });
249
- child.send({
250
- configuration: {
251
- ...configuration,
252
- modRoots: [],
253
- workspaces: resolvedWorkspaces.map(({ registration }) => registration),
254
- http: {
255
- ...configuration.http,
256
- tokens: configuration.http.tokens.map(preserveDiscoveredGrants),
257
- principals: configuration.http.principals.map(preserveDiscoveredGrants),
258
- },
259
- },
260
- workspaceId,
261
- jobId: id,
262
- ...(principal === undefined ? {} : { principal }),
263
- }, (error) => {
264
- if (error !== null)
265
- reject(error);
266
- });
267
- });
268
- resolve();
269
- })().catch((error) => {
270
- if (child.connected)
271
- child.disconnect();
272
- reject(error instanceof Error
273
- ? error
274
- : new ServiceError('JOB_WORKER_PROTOCOL', 'Worker initialization failed'));
275
- });
276
- });
277
- });
264
+ const reused = warm ? this.takeIdleWorker() : undefined;
265
+ const child = reused ?? (await this.startWorker(configuration.jobWorkerMaxHeapMiB));
278
266
  try {
279
- await initialized;
267
+ progress.stage = 'handoff';
268
+ await lease.handoffToProcess(child.pid);
280
269
  }
281
- finally {
282
- clearTimeout(acknowledgmentTimer);
270
+ catch (error) {
271
+ // A reused worker that just exited cannot take the slot; no job reached it.
272
+ if (reused !== undefined && error instanceof ServiceError)
273
+ throw new ServiceError('REQUEST_LEASE_HANDOFF_LOST', 'The warm worker stopped before it received the job');
274
+ if (reused === undefined)
275
+ await stopChild(child, this.stoppedByHost);
276
+ throw error;
283
277
  }
278
+ progress.stage = 'dispatch';
279
+ await this.dispatch(child, message, reused !== undefined);
280
+ // Without warm reuse the persistent job, not the launcher's lifetime, owns execution once
281
+ // the child confirmed receipt, so close the channel.
282
+ if (!warm && child.connected)
283
+ child.disconnect();
284
284
  progress.stage = 'supervision';
285
285
  const { jobDeadlineSeconds, jobCancelGraceSeconds } = this.engine.resolver.config();
286
286
  const supervisedSince = Date.now();
287
287
  let cancellationSeenAt;
288
288
  for (;;) {
289
289
  const record = await this.jobs.get(workspaceId, id, principal);
290
- if (['completed', 'failed', 'cancelled'].includes(record.status))
290
+ if (['completed', 'failed', 'cancelled'].includes(record.status)) {
291
+ if (warm)
292
+ await this.keepWarm(child, lease);
291
293
  return;
294
+ }
292
295
  if (record.owner !== undefined && record.owner.pid !== child.pid) {
293
296
  if (jobOwnerLiveness(record.owner) === 'alive')
294
297
  return;
@@ -303,7 +306,7 @@ export class JobWorkerHost {
303
306
  const unresponsive = cancellationSeenAt !== undefined &&
304
307
  Date.now() - cancellationSeenAt > jobCancelGraceSeconds * 1000;
305
308
  if (overdue || unresponsive) {
306
- await stopChild(child);
309
+ await stopChild(child, this.stoppedByHost);
307
310
  await this.jobs.failInterrupted(workspaceId, id, {
308
311
  code: 'JOB_DEADLINE_EXCEEDED',
309
312
  message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
@@ -311,16 +314,200 @@ export class JobWorkerHost {
311
314
  return;
312
315
  }
313
316
  }
314
- if (child.pid !== undefined) {
315
- try {
316
- process.kill(child.pid, 0);
317
- }
318
- catch (error) {
319
- if (error.code === 'ESRCH')
320
- throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
321
- }
317
+ if (child.exitCode !== null || child.signalCode !== null) {
318
+ const evidence = await this.exits.get(child);
319
+ throw new ServiceError('JOB_WORKER_EXIT', evidence === undefined ? WORKER_EXIT_MESSAGE : classifyWorkerExit(evidence).message);
322
320
  }
323
321
  await delay(100);
324
322
  }
325
323
  }
324
+ /** Workers this host stopped itself, so their exit is not reported as a fault. */
325
+ stoppedByHost = new WeakSet();
326
+ /** Settles with the exit status and last standard error of each worker this host started. */
327
+ exits = new WeakMap();
328
+ /** Idle warm workers of this host, most recently used last. */
329
+ idleWorkers = [];
330
+ /**
331
+ * Workers that reported idle since their last dispatch. The report usually arrives before the
332
+ * supervision loop notices the finished job, so it is recorded as it comes.
333
+ */
334
+ reportedIdle = new WeakSet();
335
+ watched = new WeakSet();
336
+ workerSequence = 0;
337
+ watchIdle(child) {
338
+ if (this.watched.has(child))
339
+ return;
340
+ this.watched.add(child);
341
+ child.on('message', (value) => {
342
+ if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'idle')
343
+ this.reportedIdle.add(child);
344
+ });
345
+ }
346
+ /** Let every idle warm worker exit; a busy one finishes its job first. */
347
+ releaseIdleWorkers() {
348
+ for (const child of this.idleWorkers.splice(0))
349
+ if (child.connected)
350
+ child.disconnect();
351
+ }
352
+ takeIdleWorker() {
353
+ for (;;) {
354
+ const child = this.idleWorkers.pop();
355
+ if (child === undefined)
356
+ return undefined;
357
+ if (child.connected &&
358
+ child.exitCode === null &&
359
+ child.signalCode === null &&
360
+ child.pid !== undefined) {
361
+ child.ref();
362
+ child.channel?.ref();
363
+ return child;
364
+ }
365
+ }
366
+ }
367
+ /** Start a fixed-entry worker and wait for its readiness handshake. */
368
+ async startWorker(maxHeapMiB) {
369
+ const sourceMode = import.meta.url.endsWith('.ts');
370
+ const entry = fileURLToPath(new URL(sourceMode ? './job-worker.ts' : './job-worker.js', import.meta.url));
371
+ const heapLimitMiB = maxHeapMiB ?? defaultWorkerHeapMiB();
372
+ const stderrLog = path.join(this.logRoot, `worker-${process.pid}-${++this.workerSequence}.log`);
373
+ const stderrFd = openSync(stderrLog, 'w', 0o600);
374
+ let child;
375
+ try {
376
+ child = spawn(process.execPath, [
377
+ `--max-old-space-size=${heapLimitMiB}`,
378
+ ...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []),
379
+ entry,
380
+ ], {
381
+ detached: true,
382
+ cwd: process.cwd(),
383
+ stdio: ['ignore', 'ignore', stderrFd, 'ipc'],
384
+ windowsHide: true,
385
+ });
386
+ }
387
+ finally {
388
+ closeSync(stderrFd);
389
+ }
390
+ this.exits.set(child, new Promise((resolve) => {
391
+ child.once('exit', (code, signal) => {
392
+ const evidence = {
393
+ code,
394
+ signal,
395
+ stderrTail: readTail(stderrLog),
396
+ stoppedByHost: this.stoppedByHost.has(child),
397
+ heapLimitMiB,
398
+ };
399
+ retainOrRemoveLog(stderrLog, evidence, this.logRoot);
400
+ resolve(evidence);
401
+ });
402
+ }));
403
+ try {
404
+ await new Promise((resolve, reject) => {
405
+ const onExit = (code, signal) => reject(new ServiceError('JOB_WORKER_STARTUP_EXIT', `The worker exited before completing its startup handshake (phase waiting_for_ready, exit ${code ?? 'none'}, signal ${signal ?? 'none'})`));
406
+ child.once('error', reject);
407
+ child.once('exit', onExit);
408
+ child.once('message', (value) => {
409
+ child.off('exit', onExit);
410
+ child.off('error', reject);
411
+ if (typeof value !== 'object' ||
412
+ value === null ||
413
+ !('type' in value) ||
414
+ value.type !== 'ready' ||
415
+ child.pid === undefined)
416
+ reject(new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not provide its readiness handshake'));
417
+ else
418
+ resolve();
419
+ });
420
+ });
421
+ }
422
+ catch (error) {
423
+ if (child.connected)
424
+ child.disconnect();
425
+ throw error;
426
+ }
427
+ return child;
428
+ }
429
+ /** Send the job and wait until the worker acknowledges receipt. */
430
+ async dispatch(child, message, reused) {
431
+ let acknowledgmentTimer;
432
+ this.watchIdle(child);
433
+ this.reportedIdle.delete(child);
434
+ try {
435
+ await new Promise((resolve, reject) => {
436
+ const onExit = (code, signal) => reject(reused
437
+ ? new ServiceError('REQUEST_LEASE_HANDOFF_LOST', 'The warm worker stopped before it received the job')
438
+ : new ServiceError('JOB_WORKER_STARTUP_EXIT', `The worker exited before completing its startup handshake (phase waiting_for_acceptance, exit ${code ?? 'none'}, signal ${signal ?? 'none'})`));
439
+ child.once('exit', onExit);
440
+ acknowledgmentTimer = setTimeout(() => reject(new ServiceError('JOB_WORKER_ACCEPT_TIMEOUT', 'The worker did not acknowledge receipt within its startup window')), 30_000);
441
+ const onMessage = (accepted) => {
442
+ if (typeof accepted === 'object' &&
443
+ accepted !== null &&
444
+ 'type' in accepted &&
445
+ accepted.type === 'idle')
446
+ return;
447
+ child.off('message', onMessage);
448
+ child.off('exit', onExit);
449
+ if (typeof accepted !== 'object' ||
450
+ accepted === null ||
451
+ !('type' in accepted) ||
452
+ accepted.type !== 'accepted')
453
+ reject(new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not acknowledge the dispatched job'));
454
+ else
455
+ resolve();
456
+ };
457
+ child.on('message', onMessage);
458
+ child.send(message, (error) => {
459
+ if (error !== null)
460
+ reject(error);
461
+ });
462
+ });
463
+ }
464
+ catch (error) {
465
+ if (child.connected)
466
+ child.disconnect();
467
+ throw error;
468
+ }
469
+ finally {
470
+ clearTimeout(acknowledgmentTimer);
471
+ }
472
+ }
473
+ /**
474
+ * After a job, wait briefly for the worker to report itself idle, release its capacity slot
475
+ * and keep it for the next job. A worker that does not report idle is left to exit.
476
+ */
477
+ async keepWarm(child, lease) {
478
+ if (!child.connected || child.exitCode !== null || child.signalCode !== null)
479
+ return;
480
+ const idle = await new Promise((resolve) => {
481
+ if (this.reportedIdle.has(child)) {
482
+ resolve(true);
483
+ return;
484
+ }
485
+ const timer = setTimeout(() => finish(false), 10_000);
486
+ const onMessage = (value) => {
487
+ if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'idle')
488
+ finish(true);
489
+ };
490
+ const onExit = () => finish(false);
491
+ function finish(result) {
492
+ clearTimeout(timer);
493
+ child.off('message', onMessage);
494
+ child.off('exit', onExit);
495
+ resolve(result);
496
+ }
497
+ child.on('message', onMessage);
498
+ child.once('exit', onExit);
499
+ });
500
+ // The channel can close while waiting; read it afresh rather than from the earlier check.
501
+ const connected = () => child.connected;
502
+ if (!idle || !connected()) {
503
+ if (connected())
504
+ child.disconnect();
505
+ return;
506
+ }
507
+ await lease.reclaimFromProcess();
508
+ // An idle worker must not keep this server's event loop alive.
509
+ child.unref();
510
+ child.channel?.unref();
511
+ this.idleWorkers.push(child);
512
+ }
326
513
  }