hoi4-agent-tools 3.9.0 → 4.0.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -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/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -55,7 +55,7 @@ export class JobTransactions {
55
55
  return this.fail(job, ownerToken, transaction.rollbackStatus === 'applied'
56
56
  ? 'JOB_REWRITE_RESTORED'
57
57
  : 'JOB_REWRITE_RECOVERY_REQUIRED', transaction.rollbackStatus === 'applied'
58
- ? 'The interrupted rewrite was restored; it was not replayed'
58
+ ? `The interrupted rewrite was restored; it was not replayed${failureCause(undefined, transaction)}`
59
59
  : 'The transaction could not establish a completed or restored outcome', principal);
60
60
  throw new ServiceError('JOB_TRANSACTION_UNRESOLVED', 'The transaction has no stable outcome after recovery');
61
61
  }
@@ -101,7 +101,7 @@ export class JobTransactions {
101
101
  if (outcome.state === 'applied')
102
102
  return this.complete(current, ownerToken, { transaction: outcome, outcome: 'applied', artifacts: [] }, true, principal, current.writeRecipe ?? recipe, completion);
103
103
  if (outcome.state === 'rolled_back')
104
- return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', 'The rewrite failed and its original source was restored', principal);
104
+ return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', `The rewrite failed and its original source was restored${failureCause(error, outcome)}`, principal);
105
105
  if (outcome.state === 'failed')
106
106
  return this.fail(current, ownerToken, 'JOB_REWRITE_RECOVERY_REQUIRED', 'The transaction could not establish a completed or restored outcome', principal);
107
107
  }
@@ -124,3 +124,32 @@ export class JobTransactions {
124
124
  return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, { status: 'failed', failure: { code, message } });
125
125
  }
126
126
  }
127
+ /**
128
+ * Why the write stopped: the error code and message of a service error, else only the error
129
+ * kind, followed by the first check or diagnostic of the transaction that failed validation.
130
+ */
131
+ function failureCause(error, transaction) {
132
+ const recorded = transaction.failure === undefined
133
+ ? ''
134
+ : `${transaction.failure.code}: ${transaction.failure.message}`;
135
+ const cause = error instanceof ServiceError
136
+ ? `${error.code}: ${error.message}`
137
+ : error instanceof Error
138
+ ? error.name
139
+ : recorded;
140
+ const failedCheck = transaction.validation.checks.find(({ passed }) => !passed);
141
+ const blocking = transaction.diagnostics.find(({ severity }) => severity === 'error' || severity === 'blocker');
142
+ // The blocking diagnostic comes first: it is the part the agent can act on.
143
+ const text = [
144
+ blocking === undefined
145
+ ? ''
146
+ : `${blocking.code}: ${blocking.message}${blocking.location === undefined
147
+ ? ''
148
+ : ` at ${blocking.location.path}:${blocking.location.start.line}`}`,
149
+ failedCheck?.message ?? '',
150
+ cause,
151
+ ]
152
+ .filter((part) => part !== '')
153
+ .join('; ');
154
+ return text === '' ? '' : ` (${text})`.slice(0, 700);
155
+ }
@@ -8,8 +8,12 @@ import { RequestScheduler } from './request-scheduler.js';
8
8
  import { SharedRequestCapacity } from './shared-request-capacity.js';
9
9
  import { containedGeneratedPath } from './workspace.js';
10
10
  import { ServiceError } from './result.js';
11
+ import { isDomainToolName } from './domain-tools.js';
11
12
  const maximumCheckpointRecoveries = 2;
12
13
  const maximumPreDispatchRecoveries = 2;
14
+ /** An exit with no outcome is most often the worker running out of memory on a large mod. */
15
+ const WORKER_EXIT_HINT = '; if the mod is large the worker may have run out of memory, so retry the call or raise jobWorkerMaxHeapMiB';
16
+ const WORKER_EXIT_MESSAGE = `The worker stopped without publishing a terminal job outcome${WORKER_EXIT_HINT}`;
13
17
  // Native and IPC codes are fixed vocabulary. Error messages can contain paths or source
14
18
  // text, so only an allowlisted code and the fixed coordination stage are reported.
15
19
  const nativeFailureCodes = new Set([
@@ -94,36 +98,7 @@ export class JobWorkerHost {
94
98
  const initial = await this.jobs.get(workspaceId, id, principal);
95
99
  if (['completed', 'cancelled', 'failed'].includes(initial.status))
96
100
  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))
101
+ if (!isDomainToolName(initial.request.toolName))
127
102
  throw new ServiceError('JOB_OPERATION_UNAVAILABLE', 'This domain has not yet been registered for worker execution');
128
103
  const signal = new AbortController().signal;
129
104
  const recovered = new Set();
@@ -139,7 +114,7 @@ export class JobWorkerHost {
139
114
  return current;
140
115
  if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
141
116
  return await this.followOwner(workspaceId, id, principal);
142
- throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
117
+ throw new ServiceError('JOB_WORKER_EXIT', WORKER_EXIT_MESSAGE);
143
118
  }
144
119
  catch (error) {
145
120
  const current = await this.jobs.get(workspaceId, id, principal);
@@ -178,15 +153,19 @@ export class JobWorkerHost {
178
153
  if (liveness !== 'alive')
179
154
  throw new ServiceError(liveness === 'unknown' ? 'JOB_OWNER_UNRESOLVED' : 'JOB_WORKER_EXIT', liveness === 'unknown'
180
155
  ? 'The replacement execution owner cannot be verified on this host'
181
- : 'The replacement worker stopped without publishing a terminal job outcome');
156
+ : `The replacement worker stopped without publishing a terminal job outcome${WORKER_EXIT_HINT}`);
182
157
  await delay(100);
183
158
  }
184
159
  }
185
160
  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));
161
+ // Resolve the same configured workspace and grants again immediately before dispatch. A
162
+ // job that finished, or that a live worker already runs, needs no new worker: another
163
+ // launcher may have run it while this one waited for capacity.
164
+ const current = await this.jobs.get(workspaceId, id, principal);
165
+ if (['completed', 'failed', 'cancelled'].includes(current.status))
166
+ return;
167
+ if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
168
+ return;
190
169
  const configuration = this.engine.resolver.config();
191
170
  const registeredWorkspaceIds = new Set(configuration.workspaces.map((workspace) => workspace.id));
192
171
  const resolvedWorkspaces = this.engine.resolver.list();
@@ -199,96 +178,54 @@ export class JobWorkerHost {
199
178
  workspaceIds: [...new Set([...grant.workspaceIds, ...discoveredWorkspaceIds])],
200
179
  }
201
180
  : grant;
181
+ const message = {
182
+ configuration: {
183
+ ...configuration,
184
+ modRoots: [],
185
+ workspaces: resolvedWorkspaces.map(({ registration }) => registration),
186
+ http: {
187
+ ...configuration.http,
188
+ tokens: configuration.http.tokens.map(preserveDiscoveredGrants),
189
+ principals: configuration.http.principals.map(preserveDiscoveredGrants),
190
+ },
191
+ },
192
+ workspaceId,
193
+ jobId: id,
194
+ ...(principal === undefined ? {} : { principal }),
195
+ };
196
+ const warm = configuration.jobWorkerIdleSeconds > 0;
202
197
  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
- });
198
+ const reused = warm ? this.takeIdleWorker() : undefined;
199
+ const child = reused ?? (await this.startWorker(configuration.jobWorkerMaxHeapMiB));
278
200
  try {
279
- await initialized;
201
+ progress.stage = 'handoff';
202
+ await lease.handoffToProcess(child.pid);
280
203
  }
281
- finally {
282
- clearTimeout(acknowledgmentTimer);
204
+ catch (error) {
205
+ // A reused worker that just exited cannot take the slot; no job reached it.
206
+ if (reused !== undefined && error instanceof ServiceError)
207
+ throw new ServiceError('REQUEST_LEASE_HANDOFF_LOST', 'The warm worker stopped before it received the job');
208
+ if (reused === undefined)
209
+ await stopChild(child);
210
+ throw error;
283
211
  }
212
+ progress.stage = 'dispatch';
213
+ await this.dispatch(child, message, reused !== undefined);
214
+ // Without warm reuse the persistent job, not the launcher's lifetime, owns execution once
215
+ // the child confirmed receipt, so close the channel.
216
+ if (!warm && child.connected)
217
+ child.disconnect();
284
218
  progress.stage = 'supervision';
285
219
  const { jobDeadlineSeconds, jobCancelGraceSeconds } = this.engine.resolver.config();
286
220
  const supervisedSince = Date.now();
287
221
  let cancellationSeenAt;
288
222
  for (;;) {
289
223
  const record = await this.jobs.get(workspaceId, id, principal);
290
- if (['completed', 'failed', 'cancelled'].includes(record.status))
224
+ if (['completed', 'failed', 'cancelled'].includes(record.status)) {
225
+ if (warm)
226
+ await this.keepWarm(child, lease);
291
227
  return;
228
+ }
292
229
  if (record.owner !== undefined && record.owner.pid !== child.pid) {
293
230
  if (jobOwnerLiveness(record.owner) === 'alive')
294
231
  return;
@@ -317,10 +254,172 @@ export class JobWorkerHost {
317
254
  }
318
255
  catch (error) {
319
256
  if (error.code === 'ESRCH')
320
- throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
257
+ throw new ServiceError('JOB_WORKER_EXIT', WORKER_EXIT_MESSAGE);
321
258
  }
322
259
  }
323
260
  await delay(100);
324
261
  }
325
262
  }
263
+ /** Idle warm workers of this host, most recently used last. */
264
+ idleWorkers = [];
265
+ /**
266
+ * Workers that reported idle since their last dispatch. The report usually arrives before the
267
+ * supervision loop notices the finished job, so it is recorded as it comes.
268
+ */
269
+ reportedIdle = new WeakSet();
270
+ watched = new WeakSet();
271
+ watchIdle(child) {
272
+ if (this.watched.has(child))
273
+ return;
274
+ this.watched.add(child);
275
+ child.on('message', (value) => {
276
+ if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'idle')
277
+ this.reportedIdle.add(child);
278
+ });
279
+ }
280
+ /** Let every idle warm worker exit; a busy one finishes its job first. */
281
+ releaseIdleWorkers() {
282
+ for (const child of this.idleWorkers.splice(0))
283
+ if (child.connected)
284
+ child.disconnect();
285
+ }
286
+ takeIdleWorker() {
287
+ for (;;) {
288
+ const child = this.idleWorkers.pop();
289
+ if (child === undefined)
290
+ return undefined;
291
+ if (child.connected &&
292
+ child.exitCode === null &&
293
+ child.signalCode === null &&
294
+ child.pid !== undefined) {
295
+ child.ref();
296
+ child.channel?.ref();
297
+ return child;
298
+ }
299
+ }
300
+ }
301
+ /** Start a fixed-entry worker and wait for its readiness handshake. */
302
+ async startWorker(maxHeapMiB) {
303
+ const sourceMode = import.meta.url.endsWith('.ts');
304
+ const entry = fileURLToPath(new URL(sourceMode ? './job-worker.ts' : './job-worker.js', import.meta.url));
305
+ const child = spawn(process.execPath, [
306
+ ...(maxHeapMiB === undefined ? [] : [`--max-old-space-size=${maxHeapMiB}`]),
307
+ ...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []),
308
+ entry,
309
+ ], {
310
+ detached: true,
311
+ cwd: process.cwd(),
312
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
313
+ windowsHide: true,
314
+ });
315
+ try {
316
+ await new Promise((resolve, reject) => {
317
+ 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'})`));
318
+ child.once('error', reject);
319
+ child.once('exit', onExit);
320
+ child.once('message', (value) => {
321
+ child.off('exit', onExit);
322
+ child.off('error', reject);
323
+ if (typeof value !== 'object' ||
324
+ value === null ||
325
+ !('type' in value) ||
326
+ value.type !== 'ready' ||
327
+ child.pid === undefined)
328
+ reject(new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not provide its readiness handshake'));
329
+ else
330
+ resolve();
331
+ });
332
+ });
333
+ }
334
+ catch (error) {
335
+ if (child.connected)
336
+ child.disconnect();
337
+ throw error;
338
+ }
339
+ return child;
340
+ }
341
+ /** Send the job and wait until the worker acknowledges receipt. */
342
+ async dispatch(child, message, reused) {
343
+ let acknowledgmentTimer;
344
+ this.watchIdle(child);
345
+ this.reportedIdle.delete(child);
346
+ try {
347
+ await new Promise((resolve, reject) => {
348
+ const onExit = (code, signal) => reject(reused
349
+ ? new ServiceError('REQUEST_LEASE_HANDOFF_LOST', 'The warm worker stopped before it received the job')
350
+ : new ServiceError('JOB_WORKER_STARTUP_EXIT', `The worker exited before completing its startup handshake (phase waiting_for_acceptance, exit ${code ?? 'none'}, signal ${signal ?? 'none'})`));
351
+ child.once('exit', onExit);
352
+ acknowledgmentTimer = setTimeout(() => reject(new ServiceError('JOB_WORKER_ACCEPT_TIMEOUT', 'The worker did not acknowledge receipt within its startup window')), 30_000);
353
+ const onMessage = (accepted) => {
354
+ if (typeof accepted === 'object' &&
355
+ accepted !== null &&
356
+ 'type' in accepted &&
357
+ accepted.type === 'idle')
358
+ return;
359
+ child.off('message', onMessage);
360
+ child.off('exit', onExit);
361
+ if (typeof accepted !== 'object' ||
362
+ accepted === null ||
363
+ !('type' in accepted) ||
364
+ accepted.type !== 'accepted')
365
+ reject(new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not acknowledge the dispatched job'));
366
+ else
367
+ resolve();
368
+ };
369
+ child.on('message', onMessage);
370
+ child.send(message, (error) => {
371
+ if (error !== null)
372
+ reject(error);
373
+ });
374
+ });
375
+ }
376
+ catch (error) {
377
+ if (child.connected)
378
+ child.disconnect();
379
+ throw error;
380
+ }
381
+ finally {
382
+ clearTimeout(acknowledgmentTimer);
383
+ }
384
+ }
385
+ /**
386
+ * After a job, wait briefly for the worker to report itself idle, release its capacity slot
387
+ * and keep it for the next job. A worker that does not report idle is left to exit.
388
+ */
389
+ async keepWarm(child, lease) {
390
+ if (!child.connected || child.exitCode !== null || child.signalCode !== null)
391
+ return;
392
+ const idle = await new Promise((resolve) => {
393
+ if (this.reportedIdle.has(child)) {
394
+ resolve(true);
395
+ return;
396
+ }
397
+ const timer = setTimeout(() => finish(false), 10_000);
398
+ const onMessage = (value) => {
399
+ if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'idle')
400
+ finish(true);
401
+ };
402
+ const onExit = () => finish(false);
403
+ function finish(result) {
404
+ clearTimeout(timer);
405
+ child.off('message', onMessage);
406
+ child.off('exit', onExit);
407
+ resolve(result);
408
+ }
409
+ child.on('message', onMessage);
410
+ child.once('exit', onExit);
411
+ });
412
+ // The channel can close while waiting; read it afresh rather than from the earlier check.
413
+ const connected = () => child.connected;
414
+ if (!idle || !connected()) {
415
+ if (connected())
416
+ child.disconnect();
417
+ return;
418
+ }
419
+ await lease.reclaimFromProcess();
420
+ // An idle worker must not keep this server's event loop alive.
421
+ child.unref();
422
+ child.channel?.unref();
423
+ this.idleWorkers.push(child);
424
+ }
326
425
  }
@@ -1,12 +1,21 @@
1
+ import { setFlagsFromString, getHeapStatistics } from 'node:v8';
2
+ import { runInNewContext } from 'node:vm';
1
3
  import { z } from 'zod/v4';
4
+ import { hashCanonical } from './canonical.js';
2
5
  import { serverConfigurationSchema } from './configuration.js';
3
6
  import { CoreEngine } from './engine.js';
4
7
  import { JobExecutor } from './job-executor.js';
5
8
  import { JobService } from './job-service.js';
6
9
  import { WorkspaceResolver } from './workspace.js';
7
- import { registerWorkerOperations } from './job-operation-registration.js';
10
+ import { registerWorkerOperations, workerDomain } from './job-operation-registration.js';
8
11
  // Fixed internal process entry point. It accepts only a trusted host's structured IPC
9
12
  // message, never a script path, module name, callback, command, or transport request.
13
+ //
14
+ // A worker runs one job at a time. After a job it stays warm for jobWorkerIdleSeconds while
15
+ // its host keeps the IPC channel open, so the host's next job reuses the loaded indexes
16
+ // instead of scanning cold. It exits when the idle time ends or the host disconnects. A worker
17
+ // whose heap stays large after a job is not kept warm: several heavy jobs in a row would
18
+ // otherwise exhaust the heap limit in the middle of the next one.
10
19
  const inputSchema = z
11
20
  .object({
12
21
  configuration: serverConfigurationSchema,
@@ -17,54 +26,99 @@ const inputSchema = z
17
26
  .strict();
18
27
  if (process.send === undefined)
19
28
  throw new Error('Job workers require their internal IPC host');
29
+ let warm;
30
+ let busy = false;
20
31
  let started = false;
32
+ let idleTimer;
33
+ /** Share of the heap limit a worker may still hold after a job and a full collection. */
34
+ const WARM_HEAP_SHARE = 0.4;
35
+ /** Whether the heap, after a full collection, is small enough to serve another job. */
36
+ function heapAllowsReuse() {
37
+ const limit = getHeapStatistics().heap_size_limit * WARM_HEAP_SHARE;
38
+ if (getHeapStatistics().used_heap_size <= limit)
39
+ return true;
40
+ try {
41
+ setFlagsFromString('--expose-gc');
42
+ runInNewContext('gc')();
43
+ }
44
+ catch {
45
+ // Without a collection the figure above stands: recycle rather than risk the next job.
46
+ return false;
47
+ }
48
+ return getHeapStatistics().used_heap_size <= limit;
49
+ }
21
50
  const startup = setTimeout(() => {
22
51
  if (!started)
23
52
  process.exit(1);
24
53
  }, 30_000);
25
54
  startup.unref();
26
- process.once('disconnect', () => {
27
- if (!started)
28
- process.exit(0);
55
+ function finish() {
56
+ if (process.connected)
57
+ process.disconnect();
58
+ process.exit(process.exitCode ?? 0);
59
+ }
60
+ process.on('disconnect', () => {
61
+ // A host that leaves while a job runs does not cancel it; the job finishes and the worker
62
+ // exits afterwards. An idle worker has nothing left to do.
63
+ if (!busy)
64
+ finish();
29
65
  });
30
- process.once('message', (value) => {
66
+ async function warmState(configuration) {
67
+ const configurationHash = hashCanonical(configuration);
68
+ if (warm?.configurationHash === configurationHash)
69
+ return warm;
70
+ const resolver = await WorkspaceResolver.create(configuration);
71
+ const engine = new CoreEngine(resolver);
72
+ // Recovery is targeted by the job executor. Do not sweep or interfere with
73
+ // unrelated live rewrite journals when a worker starts.
74
+ const jobs = await JobService.create(resolver);
75
+ warm = { configurationHash, resolver, engine, jobs, operations: new Map() };
76
+ return warm;
77
+ }
78
+ async function runJob(value) {
79
+ const input = inputSchema.parse(value);
80
+ // The send callback confirms enqueueing, not receipt. Acknowledge before the host
81
+ // continues; losing that channel after receipt never cancels durable execution.
82
+ if (process.connected)
83
+ process.send?.({ type: 'accepted' }, () => undefined);
84
+ const state = await warmState(input.configuration);
85
+ const record = await state.jobs.get(input.workspaceId, input.jobId, input.principal);
86
+ // The host enforces the deadline while it lives; the executor also stops a read-only job
87
+ // at its deadline and records why, so an orphaned worker never runs indefinitely.
88
+ const deadlineMs = Date.now() + state.resolver.config().jobDeadlineSeconds * 1000;
89
+ const domain = workerDomain(record.request.toolName);
90
+ let operations = state.operations.get(domain);
91
+ if (operations === undefined) {
92
+ operations = await registerWorkerOperations(state.engine, record.request.toolName);
93
+ state.operations.set(domain, operations);
94
+ }
95
+ else
96
+ operations.get(record.request.toolName);
97
+ await new JobExecutor(state.engine, state.jobs, operations).run(input.workspaceId, input.jobId, input.principal, { deadlineMs });
98
+ }
99
+ process.on('message', (value) => {
100
+ if (busy)
101
+ return;
31
102
  started = true;
103
+ busy = true;
32
104
  clearTimeout(startup);
33
- void (async () => {
34
- const input = inputSchema.parse(value);
35
- // The send callback confirms enqueueing, not receipt. Acknowledge before the host
36
- // disconnects; losing that channel after receipt never cancels durable execution.
37
- if (process.connected)
38
- process.send?.({ type: 'accepted' }, () => undefined);
39
- const resolver = await WorkspaceResolver.create(input.configuration);
40
- const engine = new CoreEngine(resolver);
41
- // Recovery is targeted by the job executor. Do not sweep or interfere with
42
- // unrelated live rewrite journals when a worker starts.
43
- const jobs = await JobService.create(resolver);
44
- const record = await jobs.get(input.workspaceId, input.jobId, input.principal);
45
- if (!record.request.mutation) {
46
- // The host enforces the deadline while it lives; a worker whose host has gone stops
47
- // itself, so an orphaned read-only analysis never holds memory indefinitely.
48
- const { jobDeadlineSeconds } = resolver.config();
49
- setTimeout(() => {
50
- void jobs
51
- .failInterrupted(input.workspaceId, input.jobId, {
52
- code: 'JOB_DEADLINE_EXCEEDED',
53
- message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
54
- }, input.principal)
55
- .catch(() => undefined)
56
- .finally(() => process.exit(1));
57
- }, jobDeadlineSeconds * 1000).unref();
58
- }
59
- const operations = await registerWorkerOperations(engine, record.request.toolName);
60
- await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
61
- })()
105
+ if (idleTimer !== undefined)
106
+ clearTimeout(idleTimer);
107
+ void runJob(value)
62
108
  .catch(() => {
63
109
  process.exitCode = 1;
64
110
  })
65
111
  .finally(() => {
66
- if (process.connected)
67
- process.disconnect();
112
+ busy = false;
113
+ const idleSeconds = warm?.resolver.config().jobWorkerIdleSeconds ?? 0;
114
+ // A failed job leaves state of unknown health; start the next job in a fresh worker.
115
+ if (!process.connected || idleSeconds === 0 || process.exitCode === 1 || !heapAllowsReuse()) {
116
+ finish();
117
+ return;
118
+ }
119
+ process.send?.({ type: 'idle' }, () => undefined);
120
+ idleTimer = setTimeout(finish, idleSeconds * 1000);
121
+ idleTimer.unref();
68
122
  });
69
123
  });
70
124
  process.send({ type: 'ready' });