hoi4-agent-tools 3.0.7 → 3.0.8
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 +14 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
- package/dist/hoi4_agent_tools/core/configuration.js +14 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -1
- package/dist/hoi4_agent_tools/core/index.js +25 -0
- package/dist/hoi4_agent_tools/core/request-scheduler.js +79 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +108 -0
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/server/progress.js +29 -8
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +14 -0
- package/dist/hoi4_agent_tools/mcp/transports/bounded-stdio.js +44 -14
- package/dist/hoi4_agent_tools/mcp/transports/http.js +75 -7
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/http.md +5 -1
- package/package.json +3 -2
- package/schemas/configuration.schema.json +151 -133
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- 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/map-operation.schema.json +1 -1
- package/schemas/operation-result.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/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 3.0.8 - 2026-09-05
|
|
8
|
+
|
|
9
|
+
- Queue simultaneous tool calls fairly across HTTP sessions and coordinate expensive operations across local task processes sharing private server state.
|
|
10
|
+
- Rotate across every waiting session and canonicalize configured storage aliases, including Windows short-name paths, before acquiring shared execution capacity.
|
|
11
|
+
- Keep every tool call responsive with strictly increasing MCP progress notifications during execution and queue waits; allow cancellation and discovery while tool capacity is occupied.
|
|
12
|
+
- Separate HTTP request admission and body-byte reservations from expensive execution capacity, replacing the two-request ceiling with defaults suitable for parallel agents.
|
|
13
|
+
- Keep active HTTP sessions alive, yield during shared source indexing, and serialize backpressured stdio output without leaking listeners or leaving sends pending after a disconnect.
|
|
14
|
+
- Share cached GUI source graphs across sessions using the same engine instead of retaining a separate graph per client.
|
|
15
|
+
- Reverify artifacts after metadata changes caused by concurrent publication-link cleanup, avoiding false hash collisions while still rejecting altered bytes.
|
|
16
|
+
- Serialize artifact admission, publication, and failed-batch cleanup across processes so one writer cannot remove evidence committed by another.
|
|
17
|
+
- Add concurrent production workflows spanning focus, GUI, map, event, technology, and probability tools over both transports, including shared artifacts, session expiry, cancelled queue entries, and abandoned process recovery.
|
|
18
|
+
- Qualify each map layer separately from semantic and pixel diffs so failures and deadlines identify the affected operation rather than an aggregate rendering loop.
|
|
19
|
+
- Update `fast-uri` and `qs` to patched releases identified by the dependency audit.
|
|
20
|
+
|
|
7
21
|
## 3.0.7 - 2026-09-01
|
|
8
22
|
|
|
9
23
|
- Reclaim detached parser, graph, and render memory after the idle cache window so parallel coding-agent tasks do not retain large V8 heaps and trigger `Transport closed` through host memory exhaustion. Idle cleanup is coordinated across every MCP domain and never compacts during an active tool call.
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { z } from 'zod/v4';
|
|
5
5
|
import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
|
|
6
6
|
import { ServiceError } from './result.js';
|
|
7
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
7
8
|
import { containedGeneratedPath, isPortablePathSegment } from './workspace.js';
|
|
8
9
|
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
9
10
|
const artifactProvenanceSchema = z
|
|
@@ -329,7 +330,9 @@ async function withArtifactQueue(root, action, signal) {
|
|
|
329
330
|
artifactQueues.set(root, tail);
|
|
330
331
|
try {
|
|
331
332
|
await waitForArtifactTurn(previous, signal);
|
|
332
|
-
|
|
333
|
+
// Admission, publication, and rollback must be one critical section across
|
|
334
|
+
// processes too: another writer may reuse the same immutable address.
|
|
335
|
+
return await new SharedRequestCapacity(root, 1).run(signal ?? new AbortController().signal, action);
|
|
333
336
|
}
|
|
334
337
|
finally {
|
|
335
338
|
release();
|
|
@@ -607,6 +610,23 @@ async function publishExclusiveFile(target, bytes, signal) {
|
|
|
607
610
|
}
|
|
608
611
|
}
|
|
609
612
|
async function readVerifiedArtifactRange(filePath, expectedSha256, range, verifiedIdentity, signal) {
|
|
613
|
+
for (let attempt = 0;; attempt += 1) {
|
|
614
|
+
try {
|
|
615
|
+
return await readArtifactRangeOnce(filePath, expectedSha256, range, attempt === 0 ? verifiedIdentity : undefined, signal);
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (attempt >= 2 ||
|
|
619
|
+
!(error instanceof ServiceError) ||
|
|
620
|
+
error.code !== 'ARTIFACT_READ_INCOMPLETE')
|
|
621
|
+
throw error;
|
|
622
|
+
// Removing the temporary hard link after atomic publication changes ctime
|
|
623
|
+
// without changing content. Retry from a fresh handle and hash all bytes;
|
|
624
|
+
// never reuse cached verification after an identity change.
|
|
625
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function readArtifactRangeOnce(filePath, expectedSha256, range, verifiedIdentity, signal) {
|
|
610
630
|
signal?.throwIfAborted();
|
|
611
631
|
if (range !== undefined &&
|
|
612
632
|
(!Number.isSafeInteger(range.offset) ||
|
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { z } from 'zod/v4';
|
|
4
4
|
import { CONFIG_VERSION } from '../version.js';
|
|
5
5
|
import { ServiceError } from './result.js';
|
|
6
|
-
export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS =
|
|
6
|
+
export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS = 1024;
|
|
7
|
+
export const HTTP_DEFAULT_CONCURRENT_REQUESTS = 128;
|
|
7
8
|
export const HTTP_MAX_BODY_BYTES = 67_108_864;
|
|
8
9
|
export const HTTP_MAX_AGGREGATE_BODY_BYTES = 134_217_728;
|
|
9
10
|
export const HTTP_DEFAULT_SESSION_EVENT_BYTES = 2_097_152;
|
|
@@ -132,6 +133,8 @@ export const serverConfigurationSchema = z
|
|
|
132
133
|
})
|
|
133
134
|
.optional(),
|
|
134
135
|
scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
|
|
136
|
+
maxConcurrentTools: z.number().int().min(1).max(32).default(2),
|
|
137
|
+
maxSharedTools: z.number().int().min(1).max(128).default(4),
|
|
135
138
|
scanMaxBytes: z
|
|
136
139
|
.number()
|
|
137
140
|
.int()
|
|
@@ -193,18 +196,18 @@ export const serverConfigurationSchema = z
|
|
|
193
196
|
headersTimeoutMs: z.number().int().min(1_000).max(120_000).default(10_000),
|
|
194
197
|
requestTimeoutMs: z.number().int().min(1_000).max(300_000).default(30_000),
|
|
195
198
|
keepAliveTimeoutMs: z.number().int().min(1_000).max(120_000).default(5_000),
|
|
196
|
-
maxConnections: z.number().int().min(1).max(100_000).default(
|
|
199
|
+
maxConnections: z.number().int().min(1).max(100_000).default(512),
|
|
197
200
|
maxRequestsPerSocket: z.number().int().min(1).max(100_000).default(100),
|
|
198
201
|
maxConcurrentRequests: z
|
|
199
202
|
.number()
|
|
200
203
|
.int()
|
|
201
204
|
.min(1)
|
|
202
205
|
.max(HTTP_MAX_SAFE_CONCURRENT_REQUESTS)
|
|
203
|
-
.default(
|
|
206
|
+
.default(HTTP_DEFAULT_CONCURRENT_REQUESTS),
|
|
204
207
|
maxSessions: z.number().int().min(1).max(10_000).default(128),
|
|
205
208
|
maxSessionsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
|
|
206
|
-
maxEventStreams: z.number().int().min(1).max(10_000).default(
|
|
207
|
-
maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(
|
|
209
|
+
maxEventStreams: z.number().int().min(1).max(10_000).default(128),
|
|
210
|
+
maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
|
|
208
211
|
maxSessionEventBytes: z
|
|
209
212
|
.number()
|
|
210
213
|
.int()
|
|
@@ -212,7 +215,7 @@ export const serverConfigurationSchema = z
|
|
|
212
215
|
.max(67_108_864)
|
|
213
216
|
.default(HTTP_DEFAULT_SESSION_EVENT_BYTES),
|
|
214
217
|
maxEventStoreBytes: z.number().int().min(65_536).max(268_435_456).default(16_777_216),
|
|
215
|
-
requestsPerMinute: z.number().int().min(1).max(100_000).default(
|
|
218
|
+
requestsPerMinute: z.number().int().min(1).max(100_000).default(6_000),
|
|
216
219
|
sessionTtlSeconds: z.number().int().min(60).max(86_400).default(3600),
|
|
217
220
|
})
|
|
218
221
|
.strict()
|
|
@@ -274,16 +277,16 @@ export const serverConfigurationSchema = z
|
|
|
274
277
|
headersTimeoutMs: 10_000,
|
|
275
278
|
requestTimeoutMs: 30_000,
|
|
276
279
|
keepAliveTimeoutMs: 5_000,
|
|
277
|
-
maxConnections:
|
|
280
|
+
maxConnections: 512,
|
|
278
281
|
maxRequestsPerSocket: 100,
|
|
279
|
-
maxConcurrentRequests:
|
|
282
|
+
maxConcurrentRequests: HTTP_DEFAULT_CONCURRENT_REQUESTS,
|
|
280
283
|
maxSessions: 128,
|
|
281
284
|
maxSessionsPerPrincipal: 32,
|
|
282
|
-
maxEventStreams:
|
|
283
|
-
maxEventStreamsPerPrincipal:
|
|
285
|
+
maxEventStreams: 128,
|
|
286
|
+
maxEventStreamsPerPrincipal: 32,
|
|
284
287
|
maxSessionEventBytes: HTTP_DEFAULT_SESSION_EVENT_BYTES,
|
|
285
288
|
maxEventStoreBytes: 16_777_216,
|
|
286
|
-
requestsPerMinute:
|
|
289
|
+
requestsPerMinute: 6_000,
|
|
287
290
|
sessionTtlSeconds: 3600,
|
|
288
291
|
}),
|
|
289
292
|
})
|
|
@@ -332,23 +335,6 @@ export const serverConfigurationSchema = z
|
|
|
332
335
|
message: 'HTTP keep-alive timeout must be shorter than the header timeout',
|
|
333
336
|
});
|
|
334
337
|
}
|
|
335
|
-
const concurrentScanBytes = value.scanMaxBytes * value.http.maxConcurrentRequests;
|
|
336
|
-
if (!Number.isSafeInteger(concurrentScanBytes) || concurrentScanBytes > 536_870_912) {
|
|
337
|
-
context.addIssue({
|
|
338
|
-
code: 'custom',
|
|
339
|
-
path: ['scanMaxBytes'],
|
|
340
|
-
message: 'Scan-byte ceiling multiplied by HTTP concurrency must not exceed 512 MiB',
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
const concurrentBodyBytes = value.http.maxBodyBytes * value.http.maxConcurrentRequests;
|
|
344
|
-
if (!Number.isSafeInteger(concurrentBodyBytes) ||
|
|
345
|
-
concurrentBodyBytes > HTTP_MAX_AGGREGATE_BODY_BYTES) {
|
|
346
|
-
context.addIssue({
|
|
347
|
-
code: 'custom',
|
|
348
|
-
path: ['http', 'maxBodyBytes'],
|
|
349
|
-
message: 'HTTP body-byte ceiling multiplied by concurrency must not exceed 128 MiB',
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
338
|
});
|
|
353
339
|
export async function loadConfiguration(filePath) {
|
|
354
340
|
const absolute = path.resolve(filePath);
|
|
@@ -3,6 +3,8 @@ import { SymbolIndex } from './index.js';
|
|
|
3
3
|
import { WorkspaceScanner } from './scanner.js';
|
|
4
4
|
import { ArtifactStore } from './artifacts.js';
|
|
5
5
|
import { TransactionManager } from './transactions.js';
|
|
6
|
+
import { RequestScheduler } from './request-scheduler.js';
|
|
7
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
6
8
|
const RECOVERY_TTL_SECONDS = 3_600;
|
|
7
9
|
const RECOVERY_MAX_BYTES = 536_870_912;
|
|
8
10
|
const RECOVERY_MAX_RECORDS = 128;
|
|
@@ -74,6 +76,8 @@ function defaultPatterns(workspace) {
|
|
|
74
76
|
}
|
|
75
77
|
export class CoreEngine {
|
|
76
78
|
resolver;
|
|
79
|
+
requests;
|
|
80
|
+
sharedRequests;
|
|
77
81
|
scanner;
|
|
78
82
|
artifacts;
|
|
79
83
|
transactions;
|
|
@@ -84,6 +88,8 @@ export class CoreEngine {
|
|
|
84
88
|
#activeScanAdmissions = new Set();
|
|
85
89
|
constructor(resolver, services = {}) {
|
|
86
90
|
this.resolver = resolver;
|
|
91
|
+
this.requests = new RequestScheduler(resolver.config().maxConcurrentTools);
|
|
92
|
+
this.sharedRequests = new SharedRequestCapacity(resolver.serverState()?.root, resolver.config().maxSharedTools);
|
|
87
93
|
this.scanner =
|
|
88
94
|
services.scanner ??
|
|
89
95
|
new WorkspaceScanner(resolver.config().scanMaxFiles, resolver.config().scanMaxBytes, resolver.config().scanMaxFileBytes);
|
|
@@ -159,7 +165,7 @@ export class CoreEngine {
|
|
|
159
165
|
const cached = this.#scanCache.get(cacheKey);
|
|
160
166
|
if (cached !== undefined)
|
|
161
167
|
return cached;
|
|
162
|
-
const index = SymbolIndex.
|
|
168
|
+
const index = await SymbolIndex.buildAsync(files, controller.signal);
|
|
163
169
|
const snapshot = {
|
|
164
170
|
workspaceId,
|
|
165
171
|
revision,
|
|
@@ -284,6 +284,31 @@ export class SymbolIndex {
|
|
|
284
284
|
index.finalize();
|
|
285
285
|
return index;
|
|
286
286
|
}
|
|
287
|
+
/** Yield between source files so protocol cancellation and progress remain responsive. */
|
|
288
|
+
static async buildAsync(files, signal) {
|
|
289
|
+
signal?.throwIfAborted();
|
|
290
|
+
const index = new SymbolIndex();
|
|
291
|
+
const definitionSelection = selectedDefinitionFiles(files);
|
|
292
|
+
for (const displayPath of definitionSelection.selected)
|
|
293
|
+
index.#definitionFiles.add(displayPath);
|
|
294
|
+
for (const skipped of definitionSelection.skipped) {
|
|
295
|
+
index.recordSkippedSource(skipped.file, skipped.diagnostics);
|
|
296
|
+
}
|
|
297
|
+
let yieldedAt = performance.now();
|
|
298
|
+
for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
299
|
+
signal?.throwIfAborted();
|
|
300
|
+
index.files.set(file.displayPath, file);
|
|
301
|
+
index.indexFile(file);
|
|
302
|
+
if (performance.now() - yieldedAt >= 20) {
|
|
303
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
304
|
+
signal?.throwIfAborted();
|
|
305
|
+
yieldedAt = performance.now();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
index.finalize();
|
|
309
|
+
signal?.throwIfAborted();
|
|
310
|
+
return index;
|
|
311
|
+
}
|
|
287
312
|
get complete() {
|
|
288
313
|
return this.#complete;
|
|
289
314
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ServiceError } from './result.js';
|
|
2
|
+
/** Shares execution capacity across all sessions using an engine, with fair, cancellable waits. */
|
|
3
|
+
export class RequestScheduler {
|
|
4
|
+
capacity;
|
|
5
|
+
maxPending;
|
|
6
|
+
maxPendingBytes;
|
|
7
|
+
active = 0;
|
|
8
|
+
pendingBytes = 0;
|
|
9
|
+
pending = [];
|
|
10
|
+
lastOwner;
|
|
11
|
+
constructor(capacity = 2, maxPending = 1024, maxPendingBytes = 134_217_728) {
|
|
12
|
+
this.capacity = capacity;
|
|
13
|
+
this.maxPending = maxPending;
|
|
14
|
+
this.maxPendingBytes = maxPendingBytes;
|
|
15
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1) {
|
|
16
|
+
throw new RangeError('Execution capacity must be a positive integer');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async run(owner, bytes, signal, action) {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
if (this.pending.length >= this.maxPending ||
|
|
23
|
+
bytes > this.maxPendingBytes - this.pendingBytes) {
|
|
24
|
+
reject(new ServiceError('REQUEST_QUEUE_FULL', 'The server request queue is full; retry after current work completes'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const entry = {
|
|
28
|
+
owner,
|
|
29
|
+
bytes,
|
|
30
|
+
start: () => {
|
|
31
|
+
signal.removeEventListener('abort', entry.cancel);
|
|
32
|
+
this.active += 1;
|
|
33
|
+
this.lastOwner = owner;
|
|
34
|
+
resolve();
|
|
35
|
+
},
|
|
36
|
+
cancel: () => {
|
|
37
|
+
const index = this.pending.indexOf(entry);
|
|
38
|
+
if (index < 0)
|
|
39
|
+
return;
|
|
40
|
+
this.pending.splice(index, 1);
|
|
41
|
+
this.pendingBytes -= bytes;
|
|
42
|
+
signal.removeEventListener('abort', entry.cancel);
|
|
43
|
+
reject(signal.reason instanceof Error
|
|
44
|
+
? signal.reason
|
|
45
|
+
: new DOMException('Request cancelled', 'AbortError'));
|
|
46
|
+
this.drain();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
signal.addEventListener('abort', entry.cancel, { once: true });
|
|
50
|
+
this.pending.push(entry);
|
|
51
|
+
this.pendingBytes += bytes;
|
|
52
|
+
this.drain();
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
return await action();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.active -= 1;
|
|
60
|
+
this.drain();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
drain() {
|
|
64
|
+
while (this.active < this.capacity && this.pending.length > 0) {
|
|
65
|
+
const other = this.pending.findIndex(({ owner }) => owner !== this.lastOwner);
|
|
66
|
+
const entry = this.pending.splice(Math.max(0, other), 1)[0];
|
|
67
|
+
// Move this session's remaining work behind every other waiting session.
|
|
68
|
+
// Merely avoiding the last owner can alternate two busy sessions forever.
|
|
69
|
+
const sameOwner = [];
|
|
70
|
+
const otherOwners = [];
|
|
71
|
+
for (const pending of this.pending) {
|
|
72
|
+
(pending.owner === entry.owner ? sameOwner : otherOwners).push(pending);
|
|
73
|
+
}
|
|
74
|
+
this.pending.splice(0, this.pending.length, ...otherOwners, ...sameOwner);
|
|
75
|
+
this.pendingBytes -= entry.bytes;
|
|
76
|
+
entry.start();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { sha256Bytes } from './canonical.js';
|
|
6
|
+
import { canonicalPath, containedGeneratedPath } from './workspace.js';
|
|
7
|
+
function processAlive(pid) {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return error.code !== 'ESRCH';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Bounds heavy work across task processes sharing the same private server state. */
|
|
17
|
+
export class SharedRequestCapacity {
|
|
18
|
+
stateRoot;
|
|
19
|
+
capacity;
|
|
20
|
+
constructor(stateRoot, capacity = 4) {
|
|
21
|
+
this.stateRoot = stateRoot;
|
|
22
|
+
this.capacity = capacity;
|
|
23
|
+
}
|
|
24
|
+
async run(signal, action) {
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
if (this.stateRoot === undefined)
|
|
27
|
+
return action();
|
|
28
|
+
const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
|
|
29
|
+
await mkdir(root, { recursive: true });
|
|
30
|
+
const lease = `${process.pid}-${randomUUID()}.lease`;
|
|
31
|
+
for (;;) {
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
for (let index = 0; index < this.capacity; index += 1) {
|
|
34
|
+
let slot;
|
|
35
|
+
try {
|
|
36
|
+
slot = await containedGeneratedPath(root, String(index));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// Another process can finish a slot while canonical containment is checked.
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
continue;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await mkdir(slot);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code !== 'EEXIST')
|
|
49
|
+
throw error;
|
|
50
|
+
await this.reap(slot);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let owner;
|
|
54
|
+
try {
|
|
55
|
+
owner = await containedGeneratedPath(slot, lease);
|
|
56
|
+
await writeFile(owner, '', { flag: 'wx', mode: 0o600 });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// A competing stale-owner cleanup can remove an empty slot before our
|
|
60
|
+
// owner file is published. No work starts until the owner exists.
|
|
61
|
+
if (error.code === 'ENOENT')
|
|
62
|
+
continue;
|
|
63
|
+
await rmdir(slot).catch(() => undefined);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
return await action();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await unlink(owner);
|
|
72
|
+
await rmdir(slot).catch((error) => {
|
|
73
|
+
const code = error.code;
|
|
74
|
+
if (code !== 'ENOENT' && code !== 'ENOTEMPTY')
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await delay(100, undefined, { signal });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async reap(slot) {
|
|
83
|
+
try {
|
|
84
|
+
const owners = await readdir(slot);
|
|
85
|
+
if (owners.length === 0) {
|
|
86
|
+
if (Date.now() - (await stat(slot)).mtimeMs < 30_000)
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
for (const name of owners) {
|
|
91
|
+
const match = /^([1-9][0-9]*)-[0-9a-f-]{36}\.lease$/u.exec(name);
|
|
92
|
+
if (match === null || processAlive(Number(match[1])))
|
|
93
|
+
return;
|
|
94
|
+
// Unique owner names prevent a concurrent reaper from deleting a new lease.
|
|
95
|
+
await unlink(await containedGeneratedPath(slot, name)).catch((error) => {
|
|
96
|
+
if (error.code !== 'ENOENT')
|
|
97
|
+
throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
await rmdir(slot);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -672,13 +672,14 @@ export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes
|
|
|
672
672
|
},
|
|
673
673
|
};
|
|
674
674
|
}
|
|
675
|
+
const sharedGuiGraphs = new WeakMap();
|
|
675
676
|
export class ScriptedGuiStudio {
|
|
676
677
|
engine;
|
|
677
678
|
resolver;
|
|
678
679
|
transactions;
|
|
679
680
|
scanner;
|
|
680
681
|
artifacts;
|
|
681
|
-
#graphCache
|
|
682
|
+
#graphCache;
|
|
682
683
|
constructor(engineOrResolver, transactions, scanner = new WorkspaceScanner(), artifacts = new ArtifactStore()) {
|
|
683
684
|
this.engine =
|
|
684
685
|
engineOrResolver instanceof CoreEngine
|
|
@@ -689,6 +690,8 @@ export class ScriptedGuiStudio {
|
|
|
689
690
|
...(transactions === undefined ? {} : { transactions }),
|
|
690
691
|
});
|
|
691
692
|
this.resolver = this.engine.resolver;
|
|
693
|
+
this.#graphCache = sharedGuiGraphs.get(this.engine) ?? new Map();
|
|
694
|
+
sharedGuiGraphs.set(this.engine, this.#graphCache);
|
|
692
695
|
this.transactions = this.engine.transactions;
|
|
693
696
|
this.scanner = this.engine.scanner;
|
|
694
697
|
this.artifacts = this.engine.artifacts;
|
|
@@ -8,7 +8,8 @@ import { registerEventTools } from '../tools/event.js';
|
|
|
8
8
|
import { registerTechnologyTools } from '../tools/technology.js';
|
|
9
9
|
import { registerProbabilityTools } from '../tools/probability.js';
|
|
10
10
|
import { registerMcpResources } from '../resources/register.js';
|
|
11
|
-
|
|
11
|
+
import { installRequestLifecycle } from './request-lifecycle.js';
|
|
12
|
+
export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Large evidence is linked as resources.';
|
|
12
13
|
export function createMcpServer(engine, context = {}) {
|
|
13
14
|
const server = new McpServer({
|
|
14
15
|
name: PACKAGE_NAME,
|
|
@@ -18,6 +19,7 @@ export function createMcpServer(engine, context = {}) {
|
|
|
18
19
|
capabilities: { logging: {} },
|
|
19
20
|
instructions: SERVER_INSTRUCTIONS,
|
|
20
21
|
});
|
|
22
|
+
installRequestLifecycle(server, engine);
|
|
21
23
|
const serverContext = {
|
|
22
24
|
...context,
|
|
23
25
|
resolveCurrentWorkspaceId: async (signal) => {
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
* common MCP clients, while still keeping notification traffic negligible for ordinary calls.
|
|
4
4
|
*/
|
|
5
5
|
export const PROGRESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
6
|
+
const reporters = new WeakMap();
|
|
7
|
+
const heartbeatOwners = new WeakSet();
|
|
6
8
|
export function progressReporter(extra) {
|
|
9
|
+
const existing = reporters.get(extra);
|
|
10
|
+
if (existing !== undefined)
|
|
11
|
+
return existing;
|
|
7
12
|
let latest = Number.NEGATIVE_INFINITY;
|
|
8
13
|
let latestTotal = 1;
|
|
9
|
-
let latestMessage = '';
|
|
10
|
-
|
|
14
|
+
let latestMessage = 'Waiting for server execution capacity';
|
|
15
|
+
const reporter = {
|
|
11
16
|
signal: extra.signal,
|
|
12
17
|
async report(progress, total, message) {
|
|
13
18
|
extra.signal.throwIfAborted();
|
|
@@ -20,27 +25,38 @@ export function progressReporter(extra) {
|
|
|
20
25
|
if (normalized <= latest)
|
|
21
26
|
return;
|
|
22
27
|
latest = normalized;
|
|
23
|
-
await extra
|
|
28
|
+
await extra
|
|
29
|
+
.sendNotification({
|
|
24
30
|
method: 'notifications/progress',
|
|
25
31
|
params: { progressToken, progress: normalized, total, message },
|
|
26
|
-
})
|
|
32
|
+
})
|
|
33
|
+
.catch(() => undefined);
|
|
27
34
|
},
|
|
28
35
|
async pulse(message = latestMessage) {
|
|
29
36
|
extra.signal.throwIfAborted();
|
|
30
37
|
const progressToken = extra._meta?.progressToken;
|
|
31
38
|
if (progressToken === undefined)
|
|
32
39
|
return;
|
|
33
|
-
|
|
40
|
+
// Use the next representably larger value rather than repeating a value, which
|
|
41
|
+
// violates MCP progress ordering. These pulses do not invent completed work.
|
|
42
|
+
latest = Number.isFinite(latest)
|
|
43
|
+
? latest + Math.max(1, Math.abs(latest)) * Number.EPSILON * 2
|
|
44
|
+
: 0;
|
|
45
|
+
await extra
|
|
46
|
+
.sendNotification({
|
|
34
47
|
method: 'notifications/progress',
|
|
35
48
|
params: {
|
|
36
49
|
progressToken,
|
|
37
|
-
progress:
|
|
38
|
-
total: latestTotal,
|
|
50
|
+
progress: latest,
|
|
51
|
+
...(latest <= latestTotal ? { total: latestTotal } : {}),
|
|
39
52
|
message,
|
|
40
53
|
},
|
|
41
|
-
})
|
|
54
|
+
})
|
|
55
|
+
.catch(() => undefined);
|
|
42
56
|
},
|
|
43
57
|
};
|
|
58
|
+
reporters.set(extra, reporter);
|
|
59
|
+
return reporter;
|
|
44
60
|
}
|
|
45
61
|
/**
|
|
46
62
|
* Keep an MCP request alive while a domain service is in a long, opaque asynchronous stage.
|
|
@@ -52,6 +68,10 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
|
|
|
52
68
|
throw new RangeError('Progress heartbeat interval must be a positive finite number');
|
|
53
69
|
}
|
|
54
70
|
reporter.signal.throwIfAborted();
|
|
71
|
+
if (heartbeatOwners.has(reporter)) {
|
|
72
|
+
return typeof operation === 'function' ? operation() : operation;
|
|
73
|
+
}
|
|
74
|
+
heartbeatOwners.add(reporter);
|
|
55
75
|
let active = true;
|
|
56
76
|
let inFlight = false;
|
|
57
77
|
const tick = () => {
|
|
@@ -76,5 +96,6 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
|
|
|
76
96
|
finally {
|
|
77
97
|
active = false;
|
|
78
98
|
clearInterval(timer);
|
|
99
|
+
heartbeatOwners.delete(reporter);
|
|
79
100
|
}
|
|
80
101
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { progressReporter, withProgressHeartbeat } from './progress.js';
|
|
2
|
+
/** Wrap the public SDK handler registration so optional tools receive the same lifecycle. */
|
|
3
|
+
export function installRequestLifecycle(server, engine) {
|
|
4
|
+
const register = server.server.setRequestHandler.bind(server.server);
|
|
5
|
+
const owner = {};
|
|
6
|
+
server.server.setRequestHandler = (schema, handler) => {
|
|
7
|
+
register(schema, (request, extra) => {
|
|
8
|
+
if (request.method !== 'tools/call')
|
|
9
|
+
return handler(request, extra);
|
|
10
|
+
const progress = progressReporter(extra);
|
|
11
|
+
return withProgressHeartbeat(() => engine.requests.run(owner, Buffer.byteLength(JSON.stringify(request)), extra.signal, () => engine.sharedRequests.run(extra.signal, async () => handler(request, extra))), progress);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -49,6 +49,9 @@ export class BoundedStdioServerTransport {
|
|
|
49
49
|
frameBytes = 0;
|
|
50
50
|
started = false;
|
|
51
51
|
closed = false;
|
|
52
|
+
writing = false;
|
|
53
|
+
queuedBytes = 0;
|
|
54
|
+
writes = [];
|
|
52
55
|
constructor(options = {}) {
|
|
53
56
|
this.input = options.stdin ?? process.stdin;
|
|
54
57
|
this.output = options.stdout ?? process.stdout;
|
|
@@ -149,8 +152,13 @@ export class BoundedStdioServerTransport {
|
|
|
149
152
|
this.input.off('error', this.handleInputError);
|
|
150
153
|
this.input.off('end', this.handleInputEnd);
|
|
151
154
|
this.input.off('close', this.handleInputClose);
|
|
152
|
-
|
|
155
|
+
if (!this.writing)
|
|
156
|
+
this.output.off('error', this.handleOutputError);
|
|
153
157
|
this.output.off('close', this.handleOutputClose);
|
|
158
|
+
const error = new Error('Stdio transport is closed');
|
|
159
|
+
for (const write of this.writes.splice(0))
|
|
160
|
+
write.reject(error);
|
|
161
|
+
this.queuedBytes = 0;
|
|
154
162
|
if (destroyInput)
|
|
155
163
|
this.input.destroy();
|
|
156
164
|
else if (this.input.listenerCount('data') === 0)
|
|
@@ -179,22 +187,44 @@ export class BoundedStdioServerTransport {
|
|
|
179
187
|
send(message) {
|
|
180
188
|
if (this.closed)
|
|
181
189
|
return Promise.reject(new Error('Stdio transport is closed'));
|
|
190
|
+
const data = serializeMessage(message);
|
|
191
|
+
const bytes = Buffer.byteLength(data);
|
|
192
|
+
if (bytes > 134_217_728 - this.queuedBytes) {
|
|
193
|
+
return Promise.reject(new Error('Stdio output queue is full'));
|
|
194
|
+
}
|
|
182
195
|
return new Promise((resolve, reject) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
this.queuedBytes += bytes;
|
|
197
|
+
this.writes.push({ data, bytes, resolve, reject });
|
|
198
|
+
this.flushWrites();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
flushWrites() {
|
|
202
|
+
if (this.writing || this.closed)
|
|
203
|
+
return;
|
|
204
|
+
const write = this.writes[0];
|
|
205
|
+
if (write === undefined)
|
|
206
|
+
return;
|
|
207
|
+
this.writing = true;
|
|
208
|
+
this.output.write(write.data, (error) => {
|
|
209
|
+
this.writing = false;
|
|
210
|
+
if (this.closed) {
|
|
211
|
+
// A stream error is emitted after its write callback. Keep the listener through
|
|
212
|
+
// that turn so closing an output with buffered writes cannot crash the process.
|
|
213
|
+
setImmediate(() => this.output.off('error', this.handleOutputError));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.writes.shift();
|
|
217
|
+
this.queuedBytes -= write.bytes;
|
|
218
|
+
if (error != null) {
|
|
219
|
+
write.reject(error);
|
|
220
|
+
this.writing = true;
|
|
221
|
+
this.finish(true);
|
|
222
|
+
this.writing = false;
|
|
223
|
+
setImmediate(() => this.output.off('error', this.handleOutputError));
|
|
192
224
|
}
|
|
193
225
|
else {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
resolve();
|
|
197
|
-
});
|
|
226
|
+
write.resolve();
|
|
227
|
+
this.flushWrites();
|
|
198
228
|
}
|
|
199
229
|
});
|
|
200
230
|
}
|