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.
- package/CHANGELOG.md +131 -0
- package/README.md +91 -147
- package/dist/bin/http.js +10 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +10 -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/condition-evaluator.js +18 -0
- 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 +29 -2
- package/dist/hoi4_agent_tools/core/index.js +2 -2
- 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 +314 -127
- package/dist/hoi4_agent_tools/core/job-worker.js +96 -37
- 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 +22 -5
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
- 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/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/assets.js +3 -0
- package/dist/hoi4_agent_tools/gui/dds.js +8 -4
- package/dist/hoi4_agent_tools/gui/layout.js +45 -5
- package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
- package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
- package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
- package/dist/hoi4_agent_tools/gui/studio.js +33 -21
- 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/prompts/probability.js +2 -2
- 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/instructions.js +1 -1
- 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 +28 -8
- package/dist/hoi4_agent_tools/mcp/tools/event.js +19 -30
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -13
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -18
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +26 -9
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +17 -30
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
- 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/runtime.js +14 -1
- 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 +79 -21
- 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/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +15 -2
- package/docs/configuration.md +281 -0
- package/docs/development.md +26 -2
- package/docs/events.md +29 -6
- package/docs/examples.md +13 -28
- package/docs/focus.md +127 -5
- package/docs/gui.md +105 -21
- package/docs/helper-expansion.md +9 -9
- package/docs/http.md +3 -1
- package/docs/jobs.md +23 -14
- package/docs/map.md +199 -7
- package/docs/mechanics.md +6 -2
- package/docs/probability.md +46 -16
- package/docs/reference.md +73 -5
- package/docs/setup.md +45 -14
- package/docs/technology.md +28 -4
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +150 -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 +151 -156
- 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
|
@@ -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',
|
|
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
|
-
:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
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
|
|
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
|
-
});
|
|
264
|
+
const reused = warm ? this.takeIdleWorker() : undefined;
|
|
265
|
+
const child = reused ?? (await this.startWorker(configuration.jobWorkerMaxHeapMiB));
|
|
278
266
|
try {
|
|
279
|
-
|
|
267
|
+
progress.stage = 'handoff';
|
|
268
|
+
await lease.handoffToProcess(child.pid);
|
|
280
269
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
315
|
-
|
|
316
|
-
|
|
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
|
}
|