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.
- package/CHANGELOG.md +118 -0
- package/README.md +115 -147
- package/dist/bin/http.js +12 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +12 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
- package/dist/hoi4_agent_tools/core/canonical.js +18 -4
- package/dist/hoi4_agent_tools/core/configuration.js +7 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
- package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
- package/dist/hoi4_agent_tools/core/engine.js +56 -10
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
- package/dist/hoi4_agent_tools/core/job-store.js +78 -5
- package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
- package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
- package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
- package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
- package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
- package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/workspace.js +84 -8
- package/dist/hoi4_agent_tools/decision/ai.js +1 -1
- package/dist/hoi4_agent_tools/decision/service.js +28 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
- package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
- package/dist/hoi4_agent_tools/event/operations.js +14 -1
- package/dist/hoi4_agent_tools/event/service.js +7 -0
- package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
- package/dist/hoi4_agent_tools/gui/studio.js +20 -7
- package/dist/hoi4_agent_tools/gui/validators.js +8 -6
- package/dist/hoi4_agent_tools/map/bmp.js +1 -1
- package/dist/hoi4_agent_tools/map/derived.js +748 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
- package/dist/hoi4_agent_tools/map/model.js +103 -0
- package/dist/hoi4_agent_tools/map/operations.js +427 -58
- package/dist/hoi4_agent_tools/map/references.js +483 -0
- package/dist/hoi4_agent_tools/map/service.js +136 -61
- package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
- package/dist/hoi4_agent_tools/map/validation.js +3 -0
- package/dist/hoi4_agent_tools/map/world.js +1280 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
- package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
- package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
- package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
- package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
- package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
- package/dist/hoi4_agent_tools/probability/operations.js +175 -13
- package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
- package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
- package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
- package/dist/hoi4_agent_tools/reference/operations.js +88 -19
- package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
- package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
- package/dist/hoi4_agent_tools/reference/service.js +6 -0
- package/dist/hoi4_agent_tools/schemas/event.js +78 -19
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
- package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
- package/dist/hoi4_agent_tools/schemas/map.js +122 -3
- package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
- package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
- package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
- package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
- package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
- package/dist/hoi4_agent_tools/technology/operations.js +14 -1
- package/dist/hoi4_agent_tools/technology/service.js +7 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +13 -0
- package/docs/configuration.md +281 -0
- package/docs/development.md +24 -2
- package/docs/events.md +27 -3
- package/docs/examples.md +6 -6
- package/docs/focus.md +125 -4
- package/docs/gui.md +98 -17
- package/docs/helper-expansion.md +8 -8
- package/docs/http.md +3 -1
- package/docs/jobs.md +19 -13
- package/docs/map.md +197 -6
- package/docs/mechanics.md +4 -0
- package/docs/probability.md +42 -15
- package/docs/reference.md +71 -4
- package/docs/setup.md +45 -11
- package/docs/technology.md +26 -1
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +151 -42
- package/docs/troubleshooting.md +61 -0
- package/examples/clients/codex-config.toml +5 -2
- package/examples/clients/generic.json +2 -2
- package/examples/config/config.json +7 -0
- package/npm-shrinkwrap.json +161 -162
- package/package.json +13 -8
- package/schemas/configuration.schema.json +144 -128
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +55 -0
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +597 -126
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +51 -0
- package/schemas/operation-result.schema.json +2 -2
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +3 -5
- package/schemas/scenario-test-input.schema.json +3 -5
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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
|
-
?
|
|
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',
|
|
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',
|
|
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
|
-
:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
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
|
|
204
|
-
|
|
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
|
-
|
|
201
|
+
progress.stage = 'handoff';
|
|
202
|
+
await lease.handoffToProcess(child.pid);
|
|
280
203
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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',
|
|
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
|
-
|
|
27
|
-
if (
|
|
28
|
-
process.
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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' });
|