mcp-pacemaker 1.3.1 → 2.0.2

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.
@@ -0,0 +1,218 @@
1
+ import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { threadId } from 'node:worker_threads';
5
+
6
+ export const TRACE_MAX_EVENTS = 192;
7
+ export const TRACE_MAX_BYTES = 65536;
8
+ export const TRACE_MAX_LINE_BYTES = 1024;
9
+ const methods = ['apply', 'undo', 'stageApply', 'stageUndo', 'rejectStage', 'commitBatch'];
10
+ const codes = ['NONE', 'UNKNOWN', 'ETIMEDOUT', 'EACCES', 'EPERM', 'ENOENT', 'EIO',
11
+ 'WRITER_DEADLINE', 'WRITER_CANCELLED', 'WRITER_FAILED', 'WRITER_OUTCOME_UNKNOWN',
12
+ 'WRITER_DEADLINE_COMMITTED', 'WRITER_BUSY', 'WRITER_CLOSED', 'REVISION_CONFLICT',
13
+ 'IO_ERROR', 'ACCESS_DENIED', 'FILE_BUSY', 'RECOVERY_REQUIRED', 'STORE_BUSY'];
14
+ const integer = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 0xffffffff;
15
+ const state = (value) => Number.isInteger(value) && value >= 0 && value <= 6;
16
+ const duration = (value) => Number.isFinite(value) && Math.abs(value) <= 120000;
17
+ const boolean = (value) => typeof value === 'boolean';
18
+ const method = (value) => methods.includes(value);
19
+ const code = (value) => codes.includes(value);
20
+ const action = (value) => ['inspect-access', 'stage', 'move-no-replace'].includes(value);
21
+ const fields = {
22
+ 'request-start': {},
23
+ 'body-end': { bytes: integer },
24
+ 'response': { status: (value) => Number.isInteger(value) && value >= 100 && value <= 599 },
25
+ 'queue-enter': { queued: integer, applying: boolean },
26
+ 'queue-leave': {},
27
+ 'writer-send': { method, queued: integer, cold: boolean },
28
+ 'worker-ready': {},
29
+ 'worker-enter': { method, state },
30
+ 'worker-result': { state },
31
+ 'worker-error': { code, state },
32
+ 'writer-expire': { expired: boolean, state },
33
+ 'writer-result': { code, state },
34
+ 'store-start': {},
35
+ 'store-ready': {},
36
+ 'helper-start': { action, call: integer, timeoutMs: duration },
37
+ 'helper-end': { action, call: integer, helperPid: integer,
38
+ exit: (value) => value === null || Number.isInteger(value) && Math.abs(value) <= 0xffffffff, code },
39
+ 'publish-start': {},
40
+ 'publish-end': {},
41
+ 'capture-truncated': {},
42
+ 'capture-failed': {},
43
+ 'capture-invalid': {},
44
+ };
45
+ const base = ['version', 'event', 'operation', 'pid', 'thread', 'sequence', 'elapsedMs', 'remainingMs'];
46
+ const uuid = (value) => typeof value === 'string' &&
47
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
48
+
49
+ export function validateTraceRecord(record) {
50
+ if (!record ||
51
+ Object.getPrototypeOf(record) !== Object.prototype ||
52
+ !Object.hasOwn(fields, record.event)) throw new Error('Invalid pooling trace record.');
53
+ const schema = fields[record.event];
54
+ const keys = [...base, ...Object.keys(schema)];
55
+ const valid = record.version === 1 && uuid(record.operation) &&
56
+ integer(record.pid) && integer(record.thread) && integer(record.sequence) &&
57
+ record.sequence > 0 && record.sequence <= TRACE_MAX_EVENTS &&
58
+ duration(record.elapsedMs) && duration(record.remainingMs) &&
59
+ Object.keys(record).length === keys.length &&
60
+ keys.every((key) => Object.hasOwn(record, key)) &&
61
+ Object.entries(schema).every(([key, validate]) => validate(record[key]));
62
+ if (!valid ||
63
+ Buffer.byteLength(JSON.stringify(record) + '\n') > TRACE_MAX_LINE_BYTES) {
64
+ throw new Error('Invalid pooling trace record.');
65
+ }
66
+ return record;
67
+ }
68
+
69
+ export function traceErrorCode(error) {
70
+ try {
71
+ return error == null ? 'NONE' : codes.includes(error.code) ? error.code : 'UNKNOWN';
72
+ } catch {
73
+ return 'UNKNOWN';
74
+ }
75
+ }
76
+
77
+ class PoolingTraceOperation {
78
+ constructor(sink, deadline, context) {
79
+ this.sink = sink;
80
+ this.deadline = deadline;
81
+ this.context = context;
82
+ this.calls = 0;
83
+ }
84
+
85
+ helper() {
86
+ return ++this.calls;
87
+ }
88
+
89
+ record(event, extra = {}) {
90
+ this.sink.record(this, event, extra);
91
+ }
92
+ }
93
+
94
+ export class PoolingTrace {
95
+ #descriptor;
96
+ #path;
97
+ #identity;
98
+ #written = 0;
99
+ #owned = false;
100
+ #closed = false;
101
+
102
+ constructor(directory) {
103
+ this.directory = directory;
104
+ this.events = 0;
105
+ this.bytes = 0;
106
+ this.stopped = false;
107
+ this.failed = false;
108
+ }
109
+
110
+ operation(deadline, context) {
111
+ if (!this.directory ||
112
+ this.#closed) return undefined;
113
+ try {
114
+ context ??= { operation: randomUUID(), started: String(process.hrtime.bigint()), deadline: String(deadline) };
115
+ const valid = uuid(context.operation) &&
116
+ typeof context.started === 'string' && /^\d{1,24}$/.test(context.started) &&
117
+ typeof context.deadline === 'string' && /^\d{1,24}$/.test(context.deadline);
118
+ if (!valid) return undefined;
119
+ return new PoolingTraceOperation(this, deadline ?? BigInt(context.deadline), {
120
+ operation: context.operation, started: context.started, deadline: context.deadline,
121
+ });
122
+ } catch {
123
+ this.failed = true;
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ record(operation, event, extra) {
129
+ if (this.stopped ||
130
+ this.#closed) return;
131
+ try {
132
+ const now = process.hrtime.bigint();
133
+ const milliseconds = (value) => Math.round(Math.max(-120000, Math.min(120000, Number(value) / 1e6)) * 1000) / 1000;
134
+ const record = {
135
+ version: 1, event, operation: operation.context.operation,
136
+ pid: process.pid, thread: threadId, sequence: this.events + 1,
137
+ elapsedMs: milliseconds(now - BigInt(operation.context.started)),
138
+ remainingMs: milliseconds(operation.deadline - now),
139
+ };
140
+ try {
141
+ if (!Object.hasOwn(fields, event) ||
142
+ Object.keys(extra).some((key) => !Object.hasOwn(fields[event], key))) {
143
+ throw new Error('Invalid pooling trace fields.');
144
+ }
145
+ Object.assign(record, extra);
146
+ validateTraceRecord(record);
147
+ } catch {
148
+ record.event = 'capture-invalid';
149
+ for (const key of Object.keys(record)) {
150
+ if (!base.includes(key)) delete record[key];
151
+ }
152
+ this.stopped = true;
153
+ }
154
+ const full = this.events >= TRACE_MAX_EVENTS - 1 ||
155
+ this.bytes + Buffer.byteLength(JSON.stringify(record) + '\n') > TRACE_MAX_BYTES - TRACE_MAX_LINE_BYTES;
156
+ if (full ||
157
+ this.failed) {
158
+ record.event = this.failed ? 'capture-failed' : 'capture-truncated';
159
+ for (const key of Object.keys(record)) {
160
+ if (!base.includes(key)) delete record[key];
161
+ }
162
+ this.stopped = true;
163
+ }
164
+ validateTraceRecord(record);
165
+ const line = JSON.stringify(record) + '\n';
166
+ this.#append(line);
167
+ this.events++;
168
+ this.bytes += Buffer.byteLength(line);
169
+ if (this.stopped) this.close();
170
+ } catch {
171
+ // Diagnostics must never replace the operation's result or error.
172
+ this.failed = true;
173
+ if (this.#owned) this.close();
174
+ }
175
+ }
176
+
177
+ #append(line) {
178
+ if (this.#descriptor === undefined) {
179
+ this.#path = join(this.directory, `${process.pid}-${threadId}.jsonl`);
180
+ this.#descriptor = fs.openSync(this.#path, 'wx', 0o600);
181
+ this.#owned = true;
182
+ this.#identity = fs.fstatSync(this.#descriptor, { bigint: true });
183
+ }
184
+ this.#checkOwned(this.#written);
185
+ // A replacement after the check cannot redirect this write to another file.
186
+ fs.appendFileSync(this.#descriptor, line);
187
+ const written = this.#written + Buffer.byteLength(line);
188
+ this.#checkOwned(written);
189
+ this.#written = written;
190
+ }
191
+
192
+ #checkOwned(bytes) {
193
+ const open = fs.fstatSync(this.#descriptor, { bigint: true });
194
+ const named = fs.lstatSync(this.#path, { bigint: true });
195
+ const owned = [open, named].every((stat) =>
196
+ stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n &&
197
+ stat.dev === this.#identity.dev && stat.ino === this.#identity.ino &&
198
+ stat.size === BigInt(bytes));
199
+ if (!owned) throw new Error('Pooling trace file ownership changed.');
200
+ }
201
+
202
+ close() {
203
+ if (this.#closed) return;
204
+ this.#closed = true;
205
+ this.stopped = true;
206
+ const descriptor = this.#descriptor;
207
+ this.#descriptor = undefined;
208
+ if (descriptor === undefined) return;
209
+ try {
210
+ fs.closeSync(descriptor);
211
+ } catch {
212
+ this.failed = true;
213
+ }
214
+ }
215
+ }
216
+
217
+ export const poolingTrace = new PoolingTrace(process.env.MCP_POOLING_TRACE_DIR);
218
+ if (poolingTrace.directory) process.once('exit', () => poolingTrace.close());
@@ -1,32 +1,51 @@
1
1
  import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
2
2
  import { PoolingConfigStore, PoolingConfigError } from './pooling-config.mjs';
3
3
  import { PoolingExecution, POOLING_BUDGET_MS } from './pooling-execution.mjs';
4
+ import { poolingTrace, traceErrorCode } from './pooling-trace.mjs';
4
5
 
5
6
  if (!isMainThread &&
6
7
  workerData?.kind === 'mcp-pooling-writer') {
8
+ poolingTrace.operation(undefined, workerData.traceContext)?.record('worker-ready');
7
9
  const store = new PoolingConfigStore(workerData.configPath);
8
- parentPort.on('message', ({ id, method, request, deadline, buffer }) => {
10
+ parentPort.on('message', ({ id, method, request, deadline, buffer, traceContext }) => {
9
11
  if (method === 'close') {
12
+ store.close();
10
13
  parentPort.close();
11
14
  return;
12
15
  }
13
16
  const execution = new PoolingExecution(deadline, buffer);
17
+ execution.trace = poolingTrace.operation(deadline, traceContext);
18
+ execution.trace?.record('worker-enter', { method, state: Atomics.load(execution.state, 0) });
19
+ let cancelledBatchId;
14
20
  try {
15
21
  execution.check();
16
- if (method !== 'apply' &&
17
- method !== 'undo') {
22
+ if (!['apply', 'undo', 'stageApply', 'stageUndo', 'rejectStage', 'commitBatch'].includes(method)) {
18
23
  throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Unknown pooling operation.');
19
24
  }
20
25
  const result = store[method](request, execution);
21
- const completedLate = execution.complete();
26
+ if (result.cancelled) cancelledBatchId = result.batchId;
27
+ let completedLate;
28
+ try {
29
+ completedLate = execution.complete();
30
+ } catch (error) {
31
+ if (result.pending &&
32
+ result.accepted !== false) {
33
+ store.rejectStage(result, new PoolingExecution(
34
+ process.hrtime.bigint() + BigInt(POOLING_BUDGET_MS) * 1000000n));
35
+ }
36
+ throw error;
37
+ }
38
+ execution.trace?.record('worker-result', { state: Atomics.load(execution.state, 0) });
22
39
  parentPort.postMessage({ id, result, completedLate });
23
40
  } catch (error) {
24
- execution.fail();
41
+ execution.trace?.record('worker-error', { code: traceErrorCode(error), state: Atomics.load(execution.state, 0) });
42
+ execution.fail(error.commitState);
25
43
  const known = error instanceof PoolingConfigError;
26
- parentPort.postMessage({ id, error: {
44
+ parentPort.postMessage({ id, cancelledBatchId, error: {
27
45
  statusCode: known ? error.statusCode : 500,
28
46
  code: known ? error.code : 'WRITER_FAILED',
29
47
  message: known ? error.message : 'Pooling writer failed. Reread the config before retrying.',
48
+ commitState: error.commitState,
30
49
  } });
31
50
  }
32
51
  });
@@ -54,6 +73,22 @@ export class PoolingConfigWriter {
54
73
  return this.#send('undo', request, options);
55
74
  }
56
75
 
76
+ stageApply(request, options = {}) {
77
+ return this.#send('stageApply', request, options);
78
+ }
79
+
80
+ stageUndo(request, options = {}) {
81
+ return this.#send('stageUndo', request, options);
82
+ }
83
+
84
+ commitBatch(request, options = {}) {
85
+ return this.#send('commitBatch', request, options);
86
+ }
87
+
88
+ rejectStage(request) {
89
+ return this.#send('rejectStage', request, {});
90
+ }
91
+
57
92
  #send(method, request, options) {
58
93
  if (this.#closed) {
59
94
  return Promise.reject(new PoolingConfigError(503, 'WRITER_CLOSED', 'Pooling writer is shutting down.'));
@@ -63,6 +98,7 @@ export class PoolingConfigWriter {
63
98
  const deadline = options.deadline === undefined ? defaultDeadline
64
99
  : options.deadline < defaultDeadline ? options.deadline : defaultDeadline;
65
100
  const execution = new PoolingExecution(deadline);
101
+ execution.trace = options.trace ?? poolingTrace.operation(deadline);
66
102
  if (options.signal?.aborted) return Promise.reject(execution.cancel());
67
103
  try {
68
104
  execution.check();
@@ -72,12 +108,14 @@ export class PoolingConfigWriter {
72
108
  if (this.#pending.size >= 16) {
73
109
  return Promise.reject(new PoolingConfigError(503, 'WRITER_BUSY', 'Too many pending pooling changes.'));
74
110
  }
75
- if (!this.#worker) this.#start();
111
+ execution.trace?.record('writer-send', { method, queued: this.#pending.size, cold: !this.#worker });
112
+ if (!this.#worker) this.#start(execution.trace);
76
113
  const id = ++this.#sequence;
77
114
  return new Promise((resolve, reject) => {
78
- const pending = { resolve, reject, execution, options, settled: false };
115
+ const pending = { resolve, reject, execution, options, method, settled: false };
79
116
  const cancel = (expired) => {
80
117
  if (pending.settled) return;
118
+ execution.trace?.record('writer-expire', { expired, state: Atomics.load(execution.state, 0) });
81
119
  const error = execution.cancel(expired);
82
120
  if (!error) return;
83
121
  this.#settle(pending, error);
@@ -87,7 +125,8 @@ export class PoolingConfigWriter {
87
125
  try {
88
126
  options.signal?.addEventListener('abort', pending.abort, { once: true });
89
127
  pending.timer = setTimeout(() => cancel(true), execution.remainingMs());
90
- this.#worker.postMessage({ id, method, request, deadline, buffer: execution.state.buffer });
128
+ this.#worker.postMessage({ id, method, request, deadline, buffer: execution.state.buffer,
129
+ traceContext: execution.trace?.context });
91
130
  } catch (error) {
92
131
  this.#pending.delete(id);
93
132
  this.#settle(pending, error);
@@ -95,17 +134,45 @@ export class PoolingConfigWriter {
95
134
  });
96
135
  }
97
136
 
98
- #start() {
137
+ #start(trace) {
99
138
  this.#worker = new Worker(new URL(import.meta.url), {
100
- workerData: { kind: 'mcp-pooling-writer', configPath: this.#configPath },
139
+ workerData: { kind: 'mcp-pooling-writer', configPath: this.#configPath, traceContext: trace?.context },
101
140
  });
102
- this.#worker.on('message', ({ id, result, error, completedLate }) => {
141
+ this.#worker.on('message', async ({ id, result, error, completedLate, cancelledBatchId }) => {
103
142
  const pending = this.#pending.get(id);
104
143
  if (!pending) return;
144
+ pending.execution.trace?.record('writer-result', {
145
+ code: traceErrorCode(error), state: Atomics.load(pending.execution.state, 0),
146
+ });
105
147
  this.#pending.delete(id);
106
- const failure = error
148
+ let failure = error
107
149
  ? new PoolingConfigError(error.statusCode, error.code, error.message)
108
150
  : completedLate ? pending.execution.committedLate() : undefined;
151
+ cancelledBatchId ??= result?.cancelled ? result.batchId : undefined;
152
+ if (!failure &&
153
+ cancelledBatchId &&
154
+ pending.options.signal?.aborted) {
155
+ failure = new PoolingConfigError(408, 'WRITER_CANCELLED',
156
+ 'Pooling change was cancelled before config commit.');
157
+ }
158
+ if (failure) {
159
+ failure.commitState = error?.commitState ?? failure.commitState ??
160
+ (pending.method === 'commitBatch' ? 'not-committed' : undefined);
161
+ if (cancelledBatchId) failure.cancelledBatchId = cancelledBatchId;
162
+ }
163
+ if (!failure &&
164
+ result?.pending &&
165
+ result.accepted !== false &&
166
+ pending.options.signal?.aborted) {
167
+ try {
168
+ await this.rejectStage({ generation: result.generation });
169
+ this.#settle(pending, new PoolingConfigError(408, 'WRITER_CANCELLED',
170
+ 'Pooling change was cancelled before config commit.'));
171
+ } catch (cleanupError) {
172
+ this.#settle(pending, cleanupError);
173
+ }
174
+ return;
175
+ }
109
176
  if (pending.settled ||
110
177
  completedLate) this.#lateSettlement(pending, failure, result);
111
178
  this.#settle(pending, failure, result);
@@ -147,7 +214,13 @@ export class PoolingConfigWriter {
147
214
  #lateSettlement(pending, error, result, commitStarted = false) {
148
215
  if (pending.options.onLateSettlement) {
149
216
  try {
150
- pending.options.onLateSettlement({ error, committed: Boolean(result), commitStarted });
217
+ pending.options.onLateSettlement({
218
+ error, result,
219
+ committed: Boolean(result && !result.pending && !result.cancelled) ||
220
+ error?.commitState === 'committed',
221
+ cancelledBatchId: error?.cancelledBatchId ?? (result?.cancelled ? result.batchId : undefined),
222
+ commitStarted,
223
+ });
151
224
  } catch {
152
225
  process.emitWarning('Unable to reconcile a pooling operation without a normal result.');
153
226
  }
package/bin/prewarm.mjs CHANGED
@@ -29,10 +29,46 @@ async function jsonRequest(port, path, options = {}) {
29
29
  if (!response.ok) {
30
30
  throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
31
31
  }
32
- return result;
32
+ return { status: response.status, body: result };
33
33
  }
34
34
 
35
- export function formatPrewarm(snapshot) {
35
+ function validatePendingReceipt(receipt, name, port) {
36
+ const invalid = 'Bridge returned an invalid HTTP 202 receipt. Reread settings before retrying.';
37
+ const nonempty = (value) => typeof value === 'string' && value.length > 0;
38
+ const fieldsValid = receipt?.ok === true && receipt.pending === true &&
39
+ receipt.name === name && nonempty(receipt.batchId) &&
40
+ nonempty(receipt.undoId) && nonempty(receipt.revision);
41
+ if (!fieldsValid) throw new Error(invalid);
42
+
43
+ const snapshot = receipt.snapshot;
44
+ const snapshotValid = snapshot?.service === 'mcp-pacemaker' &&
45
+ snapshot.port === port && nonempty(snapshot.instanceId) &&
46
+ Number.isSafeInteger(snapshot.snapshotVersion) && snapshot.snapshotVersion > 0 &&
47
+ Array.isArray(snapshot.servers) && snapshot.servers.some((server) => server?.name === name) &&
48
+ snapshot.prewarm?.revision === receipt.revision &&
49
+ Number.isSafeInteger(snapshot.prewarm.maxWarm) && snapshot.prewarm.maxWarm > 0 &&
50
+ Number.isFinite(snapshot.prewarm.batchDelayMs) && snapshot.prewarm.batchDelayMs > 0 &&
51
+ Array.isArray(snapshot.prewarm.batches);
52
+ if (!snapshotValid) throw new Error(invalid);
53
+
54
+ const batch = snapshot.prewarm.batches.find((candidate) => candidate?.id === receipt.batchId);
55
+ const timingValid = batch?.status === 'pending' ? Number.isFinite(batch.applyAt)
56
+ : batch?.status === 'applying' && batch.applyAt === null;
57
+ const changesValid = Array.isArray(batch?.changes) && batch.changes.length > 0 &&
58
+ batch.changes.some((change) => change?.name === name) &&
59
+ batch.changes.every((change) => nonempty(change?.name) &&
60
+ (change.mode === 'pool'
61
+ ? change.minWarm === undefined ||
62
+ Number.isSafeInteger(change.minWarm) && change.minWarm >= 1 && change.minWarm <= snapshot.prewarm.maxWarm
63
+ : change.mode === 'isolated' && change.minWarm === undefined));
64
+ if (!timingValid ||
65
+ !changesValid ||
66
+ batch.revision !== receipt.revision) {
67
+ throw new Error(invalid);
68
+ }
69
+ }
70
+
71
+ export function formatPrewarm(snapshot, now = Date.now()) {
36
72
  const seconds = (ms) => ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
37
73
  const rows = [
38
74
  ['SERVER', 'MODE', 'WARM/TARGET', 'STARTS', 'COLD P50', 'P95', 'SUGGESTED', 'SHARED CHILD'],
@@ -50,11 +86,30 @@ export function formatPrewarm(snapshot) {
50
86
  ]),
51
87
  ];
52
88
  const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
89
+ const history = snapshot.prewarm?.batches ?? [];
90
+ const latestFinished = history.find((batch) => ['applied', 'failed', 'cancelled'].includes(batch.status));
91
+ const batches = history
92
+ .filter((batch) => ['pending', 'applying'].includes(batch.status) ||
93
+ batch === latestFinished && batch.status === 'failed')
94
+ .map((batch) => {
95
+ const changes = batch.changes.map((change) => {
96
+ const target = change.minWarm === undefined
97
+ ? 'pooling with configured target' : `${change.minWarm} warm slot(s)`;
98
+ return `${change.name}: ${change.mode === 'pool' ? target : 'pre-warming off'}`;
99
+ }).join(', ');
100
+ if (batch.status === 'failed') return `Batch failed (${changes}): ${batch.error || 'Reread settings before retrying.'}`;
101
+ if (batch.status === 'applying') return `Applying batch: ${changes}.`;
102
+ const secondsLeft = Math.max(0, Math.ceil((batch.applyAt - now) / 1000));
103
+ return `Pending batch (reload in ${secondsLeft}s): ${changes}. Active settings above have not changed.`;
104
+ });
53
105
  return [
54
106
  `Pre-warming on :${snapshot.port} (process counters since ${snapshot.startedAt || 'bridge start'})`,
55
107
  ...rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd()),
56
108
  'Pre-warming is opt-in. Each warm slot holds one additional resident process.',
57
109
  'Shared mode is separately opt-in: compatible stateless tools sessions reuse one initialized child.',
110
+ ...(typeof snapshot.prewarm?.saveWarning === 'string'
111
+ ? [`File save notice: ${snapshot.prewarm.saveWarning}`] : []),
112
+ ...batches,
58
113
  ].join('\n');
59
114
  }
60
115
 
@@ -99,7 +154,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
99
154
 
100
155
  const snapshots = [];
101
156
  for (const port of selectedPorts) {
102
- const snapshot = await jsonRequest(port, '/api/status');
157
+ const { body: snapshot } = await jsonRequest(port, '/api/status');
103
158
  if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
104
159
  snapshots.push(snapshot);
105
160
  }
@@ -135,14 +190,40 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
135
190
  }
136
191
  }
137
192
  const nonce = (await readFile(join(dirname(options.config || configPath), 'admin.nonce'), 'utf8')).trim();
138
- const result = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
193
+ if (!options.json &&
194
+ typeof snapshot.prewarm.saveWarning === 'string') {
195
+ write(`File save notice: ${snapshot.prewarm.saveWarning}`);
196
+ }
197
+ const response = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
139
198
  method: 'POST',
140
- headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce },
199
+ headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
141
200
  body: JSON.stringify(body),
142
201
  });
143
- if (result.ok !== true) throw new Error(result.error || 'Bridge did not confirm the pooling change');
202
+ const result = response.body;
203
+ if (response.status === 202) {
204
+ validatePendingReceipt(result, name, snapshot.port);
205
+ } else if (response.status !== 200 ||
206
+ result?.pending === true) {
207
+ throw new Error('Bridge returned an unexpected pooling response status. Reread settings before retrying.');
208
+ }
209
+ if (result?.ok !== true) throw new Error(result?.error || 'Bridge did not confirm the pooling change');
144
210
  if (options.json) {
145
211
  write(JSON.stringify(result));
212
+ } else if (result.pending) {
213
+ const batch = result.snapshot?.prewarm?.batches?.find((candidate) => candidate.id === result.batchId);
214
+ const scope = batch?.changes.map((change) => change.name).join(', ') || name;
215
+ const secondsLeft = batch?.applyAt == null ? null : Math.max(0, Math.ceil((batch.applyAt - Date.now()) / 1000));
216
+ const timing = secondsLeft === null ? '' : ` in ${secondsLeft}s`;
217
+ let action;
218
+ if (options.undo) action = 'previous batch configuration staged';
219
+ else if (body.mode === 'pool') action = `${body.minWarm} warm slot(s) staged`;
220
+ else action = 'pre-warming disable staged';
221
+ write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
222
+ if (result.undoId) {
223
+ write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
224
+ }
225
+ } else if (result.cancelled) {
226
+ write(`${name}: pending batch cancelled. Active settings were not changed.`);
146
227
  } else {
147
228
  write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
148
229
  if (result.undoId) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "binary": "PoolingSecurityHelper.exe",
4
- "binarySha256": "effdc8deb06beab195523f714b8d1a396ec53f00b6b4a437e251a25d94234c9e",
4
+ "binarySha256": "db026df3593cf4b9467aa1a6a445f2dd4f3290198becb5bbdbfb484ce6b0ccc0",
5
5
  "inputs": {
6
6
  "targetFramework": ".NETFramework,Version=v4.6.2",
7
7
  "platform": "AnyCPU",
@@ -28,12 +28,15 @@
28
28
  "sourceSha256": {
29
29
  "AssemblyInfo.cs": "e81e107352fbcb82818d8265dfcdf392eac8d25df37e6d65ca7f02d1e63a6c9a",
30
30
  "PoolingSecurityReader.cs": "91620ba5e0b3c6fcc673319f59c96466115a6be74fd8b93808f08f07087b7038",
31
- "PoolingSecurityHelper.cs": "aff0222cf28258f1004908b0ad6e471d00012d5ea7dd90af163d83eb743df6f0"
31
+ "PoolingSecurityHelper.cs": "1d14768fbf602368f20302bbe382e6d195e12db6008edb3aa95be0de88d75935",
32
+ "PoolingNativeFiles.cs": "1234fd12fb41910981385010a071358476b6a8a4c42827714f3f270f05f23c1d"
32
33
  },
33
- "buildScriptSha256": "29dcb4641182bfe5552e9e913f90d4a37e99ddc7677b65b7dcb22cae16d98c60",
34
+ "buildScriptSha256": "8413c3ea3239a474379103cbf456f52a0a3bc75fcdb23b5f769474abe4a1eea0",
34
35
  "referenceSha256": {
35
36
  "mscorlib.dll": "7118fe658567606fa0538f7b5a2071e4606756c07e9062e1e5b24fa49e97d1f6",
36
- "System.dll": "b76822f9c08aff597120e3167b3e0fbda836d6bc6f14bc4bc2eeffab5b2c6125"
37
+ "System.dll": "b76822f9c08aff597120e3167b3e0fbda836d6bc6f14bc4bc2eeffab5b2c6125",
38
+ "System.Core.dll": "774e965a6c25891a96d6d3b838ae10226ed9963a73b09e4be7c2ec275ffb5886",
39
+ "System.Web.Extensions.dll": "7a32bf3263aebed93c3a0847294aebdde15f7427541ed223a4cc4eb93c36d556"
37
40
  }
38
41
  }
39
42
  }