@chrischall/gemini-mcp 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The durable half of the job registry (`src/jobs.ts`).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `JobRegistry` keeps jobs in a `Map`, which is exactly right for stdio: the
7
+ * process that starts a generation is the process that finishes it, and if it
8
+ * dies the user is sitting at a terminal watching it die.
9
+ *
10
+ * Hosted, that premise is false in a specific and non-obvious way. The
11
+ * connector is this same stdio server run as a Node child on an mcp-host Fly
12
+ * machine configured `auto_stop_machines = "stop"` with
13
+ * `min_machines_running = 0`. Fly stops that machine when nothing is in
14
+ * flight — mcp-host's own docs put it plainly: *"an open connection is exactly
15
+ * what stops the machine stopping"*. An `async: true` call is a request that
16
+ * answers in milliseconds, so it holds nothing open. The machine stops, the
17
+ * child is killed, and the generation dies **with the only record that it ever
18
+ * existed**. Polling `gemini_get_result` a couple of minutes later reports the
19
+ * id as unknown, which reads like an expiry bug and is actually a machine that
20
+ * went to sleep.
21
+ *
22
+ * This is also why a long `max_wait_ms` works where `async: true` does not: the
23
+ * wait keeps the HTTP request open, and the open request keeps the machine up.
24
+ *
25
+ * ## What durability can and cannot buy here
26
+ *
27
+ * It buys the RECORD, not the EXECUTION. There is no Workflows, no Queue, no
28
+ * Durable Object and no KV in reach — the child's environment is allowlist-only
29
+ * (mcp-host `spawn-env.ts`), and the only durable thing it can address is the
30
+ * blob store its media already goes to. Nothing here resumes a killed
31
+ * generation; re-running it would re-bill it, which is the cost this whole
32
+ * mechanism exists to avoid.
33
+ *
34
+ * So the contract is: a job never silently vanishes, and a job whose executor
35
+ * died ends in a **terminal** state that says so. The bytes a dead job managed
36
+ * to write are still in `gen/`, which is what `gemini_list_recent_media` is
37
+ * for.
38
+ *
39
+ * ## Liveness is a heartbeat, not a guess
40
+ *
41
+ * "Still running" and "the machine ate it" are indistinguishable from a single
42
+ * stored status, and a timeout long enough to be safe for a 12-scene set is far
43
+ * too long to be useful. So a running job re-stamps `updatedAt` on an interval
44
+ * ({@link HEARTBEAT_MS}), and a record whose stamp is older than
45
+ * {@link EXECUTOR_LOST_AFTER_MS} is read as `failed` — decided at READ time, so
46
+ * no boot scan is needed and a machine that never comes back still resolves its
47
+ * jobs the moment anybody asks.
48
+ *
49
+ * Every operation is best-effort and swallows its errors. A durable store that
50
+ * is down must degrade to the in-memory behaviour that came before it, never
51
+ * fail a generation the user is paying for.
52
+ */
53
+ import { createHash } from 'node:crypto';
54
+ /** How often a running job re-stamps its record so a reader can see it is alive. */
55
+ export const HEARTBEAT_MS = 60_000;
56
+ /**
57
+ * How stale a `running` record may get before its executor is presumed dead.
58
+ *
59
+ * Three missed heartbeats: long enough that a paused event loop mid-generation
60
+ * is not mistaken for a dead machine, short enough that a caller learns the
61
+ * truth in minutes rather than being told to keep polling forever.
62
+ */
63
+ export const EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
64
+ /** Where a job record lives, relative to the store root. */
65
+ const PREFIX = 'jobs';
66
+ /**
67
+ * An idempotency key is an arbitrary caller-supplied string: it can contain
68
+ * slashes, spaces, or `..`. Hashing it makes the key space closed by
69
+ * construction rather than by escaping, so no crafted key can address an object
70
+ * outside this tenant's prefix.
71
+ */
72
+ function keyHash(idempotencyKey) {
73
+ return createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 32);
74
+ }
75
+ /** Job records over the mcp-host blob store. */
76
+ export function createBlobJobStore(bucket, opts) {
77
+ const now = opts.now ?? (() => Date.now());
78
+ const tenantOf = async () => (typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant);
79
+ const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
80
+ const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
81
+ async function writeJson(key, value) {
82
+ try {
83
+ await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: 'application/json' } });
84
+ }
85
+ catch {
86
+ // A durable write that fails leaves the in-memory registry exactly as
87
+ // capable as it was before this module existed. Failing the generation
88
+ // instead would trade a recoverable gap for a lost paid result.
89
+ }
90
+ }
91
+ async function readJson(key) {
92
+ try {
93
+ const obj = await bucket.get(key);
94
+ if (!obj)
95
+ return undefined;
96
+ return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ return {
103
+ async put(record) {
104
+ await writeJson(await recordKey(record.jobId), record);
105
+ },
106
+ async get(jobId) {
107
+ const record = await readJson(await recordKey(jobId));
108
+ if (!record || typeof record.status !== 'string')
109
+ return undefined;
110
+ if (record.status !== 'running')
111
+ return record;
112
+ if (now() - record.updatedAt <= EXECUTOR_LOST_AFTER_MS)
113
+ return record;
114
+ // Terminal, and honest about which kind of gone it is. Derived on read
115
+ // rather than persisted: the process that could have written the
116
+ // transition is by definition the one that is no longer there.
117
+ return {
118
+ ...record,
119
+ status: 'failed',
120
+ error: {
121
+ message: `Job "${jobId}" was lost: its executor stopped reporting ${Math.round((now() - record.updatedAt) / 1000)}s ` +
122
+ `ago, before the generation finished. This connector runs on a machine that stops when no request is ` +
123
+ `in flight, so background work started with \`async: true\` does not reliably survive.`,
124
+ hint: 'Re-run with `max_wait_ms` (e.g. 240000) instead of `async: true` — holding the request open keeps the ' +
125
+ 'executor alive. Any images the lost run did finish are still stored: list them with ' +
126
+ 'gemini_list_recent_media before paying for a regeneration.',
127
+ },
128
+ };
129
+ },
130
+ async putKey(idempotencyKey, jobId) {
131
+ await writeJson(await pointerKey(idempotencyKey), { jobId });
132
+ },
133
+ async jobIdForKey(idempotencyKey) {
134
+ const pointer = await readJson(await pointerKey(idempotencyKey));
135
+ return typeof pointer?.jobId === 'string' ? pointer.jobId : undefined;
136
+ },
137
+ };
138
+ }
package/dist/jobs.js CHANGED
@@ -1,8 +1,23 @@
1
1
  import { randomUUID, createHash } from 'node:crypto';
2
2
  import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
3
  import { downloadFilename } from './media-name.js';
4
+ import { HEARTBEAT_MS } from './job-store.js';
4
5
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
5
6
  const JOB_TTL_MS = 600_000; // 10 min
7
+ /**
8
+ * How long a DURABLE record stays reusable by idempotency key.
9
+ *
10
+ * Longer than {@link JOB_TTL_MS} on purpose: the in-memory window is bounded by
11
+ * a process that no longer exists, and the case this covers — a retry after the
12
+ * hosted machine went to sleep — can easily land further out than ten minutes.
13
+ * Still bounded, because a stored record survives for the blob store's ~30-day
14
+ * retention and silently replaying a month-old generation is a wrong answer
15
+ * that looks like a cache hit.
16
+ *
17
+ * 24h is also the longest a signed media link can live here, so a replay inside
18
+ * this window is one whose URLs can still be re-minted from their keys.
19
+ */
20
+ const DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1000;
6
21
  /** Hard cap on retained entries (oldest-settled evicted first). */
7
22
  const JOB_MAX = 64;
8
23
  function isExpired(entry, now) {
@@ -131,13 +146,38 @@ function annotateReused(result, jobId) {
131
146
  };
132
147
  }
133
148
  /** Immediate handle for an async call — the caller polls `gemini_get_result`. */
134
- function jobHandle(jobId, status) {
149
+ function jobHandle(jobId, status, durable = false) {
135
150
  return textResult({
136
151
  job_id: jobId,
137
152
  status,
138
- hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
153
+ hint: durable
154
+ ? `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done". The job record is stored durably, so a poll still works if this connector restarts — but background work is NOT restarted, so a long generation is safer with max_wait_ms than with async.`
155
+ : `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
139
156
  });
140
157
  }
158
+ /**
159
+ * Whether a result is worth persisting to the durable store.
160
+ *
161
+ * A hosted result is a small JSON manifest of `r2_key`s, which is exactly what
162
+ * a restart needs — the keys outlive every signed URL and `refreshMedia`
163
+ * re-signs from them. An `inline: true` result is raw base64 image bytes: too
164
+ * big to keep, and pointless to keep, since nothing about it survives usefully.
165
+ * Recording the omission is what lets a poll say *why* rather than reporting a
166
+ * success with nothing in it.
167
+ */
168
+ const MAX_PERSISTED_RESULT_BYTES = 64 * 1024;
169
+ function persistableResult(result) {
170
+ if ((result.content ?? []).some((block) => block.type !== 'text'))
171
+ return { resultOmitted: 'inline' };
172
+ try {
173
+ if (JSON.stringify(result).length > MAX_PERSISTED_RESULT_BYTES)
174
+ return { resultOmitted: 'too_large' };
175
+ }
176
+ catch {
177
+ return { resultOmitted: 'too_large' };
178
+ }
179
+ return { result };
180
+ }
141
181
  /**
142
182
  * Await `promise` for at most `waitMs`; `undefined` means the budget expired
143
183
  * with the work still pending (rejections propagate as usual). The timer never
@@ -163,14 +203,124 @@ export class JobRegistry {
163
203
  * never expire.
164
204
  */
165
205
  resigner;
206
+ /**
207
+ * Durable backing for this registry, when the deployment has somewhere to put
208
+ * it. Absent on a local stdio install, where the process that starts a
209
+ * generation is the process that finishes it and a `Map` is the whole truth.
210
+ *
211
+ * Present on the hosted connector, where it is load-bearing: see the header of
212
+ * `src/job-store.ts` for why an in-memory-only registry cannot work on a
213
+ * machine that stops whenever no request is in flight.
214
+ */
215
+ store;
216
+ /**
217
+ * When set, `async: true` is served as a bounded in-band wait instead of an
218
+ * immediate hand-off.
219
+ *
220
+ * Set by the hosted client, because there the two are not the trade-off they
221
+ * look like. Returning immediately releases the request, and the released
222
+ * request is the thing keeping the executor alive — so `async: true` is
223
+ * precisely the way to get a generation killed. Waiting holds the machine up
224
+ * until the work is done. If the budget still expires the caller gets the same
225
+ * job handle it asked for, now backed by a durable record.
226
+ */
227
+ asyncWaitFallbackMs;
228
+ /** Injectable clock (tests). */
229
+ now = () => Date.now();
166
230
  jobs = new Map();
167
231
  byKey = new Map(); // idempotencyKey -> jobId
168
232
  runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
233
+ /** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
234
+ pending = new Set();
235
+ heartbeatTimer;
169
236
  /** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
170
237
  reset() {
171
238
  this.jobs.clear();
172
239
  this.byKey.clear();
173
240
  this.runningByFingerprint.clear();
241
+ this.stopHeartbeat();
242
+ }
243
+ /** Await outstanding durable writes. Tests only — handlers never need it. */
244
+ async drain() {
245
+ // Yield a macrotask first so a settle handler that has already fired gets
246
+ // its write queued before we look. Deliberately flushes only what is
247
+ // ISSUABLE: a job still running has no terminal write to wait for, and
248
+ // blocking on one would hang any test that drains before finishing a job.
249
+ for (let round = 0; round < 50; round++) {
250
+ await new Promise((resolve) => setTimeout(resolve, 0));
251
+ if (!this.pending.size)
252
+ return;
253
+ await Promise.allSettled([...this.pending]);
254
+ }
255
+ }
256
+ /**
257
+ * Re-stamp every running job's durable record.
258
+ *
259
+ * This is what separates "still working" from "the machine went away": a
260
+ * reader compares the stamp against `EXECUTOR_LOST_AFTER_MS`, so a 12-scene
261
+ * set that legitimately runs for ten minutes stays `running` while a job whose
262
+ * child was killed goes terminal within three missed beats.
263
+ */
264
+ async beat() {
265
+ if (!this.store)
266
+ return;
267
+ await Promise.allSettled([...this.jobs.values()]
268
+ .filter((e) => e.status === 'running')
269
+ // The status is re-read INSIDE the queued task, not sampled here: a job
270
+ // that finishes while this beat waits its turn must not have `running`
271
+ // written back over its terminal record.
272
+ .map((e) => this.writeRecord(e, { onlyWhileRunning: true })));
273
+ }
274
+ /**
275
+ * Queue one durable write for `entry`, after any write already in flight for
276
+ * it. `onlyWhileRunning` drops the write if the job settled in the meantime —
277
+ * which is what makes a late heartbeat a no-op rather than a resurrection.
278
+ */
279
+ writeRecord(entry, opts = {}) {
280
+ const next = (entry.writeChain ?? Promise.resolve()).then(async () => {
281
+ if (opts.onlyWhileRunning && entry.status !== 'running')
282
+ return;
283
+ await this.store.put(this.recordFor(entry, this.now()));
284
+ }, () => undefined);
285
+ entry.writeChain = next;
286
+ return next;
287
+ }
288
+ recordFor(entry, updatedAt) {
289
+ return {
290
+ jobId: entry.jobId,
291
+ toolName: entry.toolName,
292
+ fingerprint: entry.fingerprint,
293
+ ...(entry.idempotencyKey !== undefined ? { idempotencyKey: entry.idempotencyKey } : {}),
294
+ status: entry.status,
295
+ createdAt: entry.createdAt,
296
+ updatedAt,
297
+ ...(entry.interactionId !== undefined ? { interactionId: entry.interactionId } : {}),
298
+ ...(entry.error ? { error: entry.error } : {}),
299
+ ...(entry.status === 'done' && entry.result ? persistableResult(entry.result) : {}),
300
+ };
301
+ }
302
+ /** Run `p` as durable bookkeeping: tracked for `drain()`, never thrown from. */
303
+ track(p) {
304
+ const wrapped = p.catch(() => undefined).finally(() => this.pending.delete(wrapped));
305
+ this.pending.add(wrapped);
306
+ }
307
+ startHeartbeat() {
308
+ if (this.heartbeatTimer || !this.store)
309
+ return;
310
+ this.heartbeatTimer = setInterval(() => {
311
+ if (![...this.jobs.values()].some((e) => e.status === 'running')) {
312
+ this.stopHeartbeat();
313
+ return;
314
+ }
315
+ this.track(this.beat());
316
+ }, HEARTBEAT_MS);
317
+ // Never hold the process open for bookkeeping.
318
+ this.heartbeatTimer.unref?.();
319
+ }
320
+ stopHeartbeat() {
321
+ if (this.heartbeatTimer)
322
+ clearInterval(this.heartbeatTimer);
323
+ this.heartbeatTimer = undefined;
174
324
  }
175
325
  /** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
176
326
  evict(now) {
@@ -213,8 +363,13 @@ export class JobRegistry {
213
363
  * different registry and therefore a genuinely separate generation.
214
364
  */
215
365
  async dispatch(opts, work) {
216
- const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
217
- const now = Date.now();
366
+ const { toolName, fingerprint, idempotencyKey } = opts;
367
+ // Hosted, an immediate hand-off is what gets the executor reclaimed, so
368
+ // `async` is honoured by HOLDING the request rather than releasing it.
369
+ const degrade = opts.async === true && this.asyncWaitFallbackMs !== undefined;
370
+ const async = degrade ? false : opts.async;
371
+ const waitMs = degrade ? (opts.waitMs ?? this.asyncWaitFallbackMs) : opts.waitMs;
372
+ const now = this.now();
218
373
  let hit;
219
374
  if (idempotencyKey !== undefined) {
220
375
  const id = this.byKey.get(idempotencyKey);
@@ -228,6 +383,14 @@ export class JobRegistry {
228
383
  if (entry && entry.status === 'running')
229
384
  hit = entry;
230
385
  }
386
+ // Nothing in memory, but the key may name a job an earlier child ran. Only
387
+ // an explicit key unlocks this — the same rule the in-memory path uses, so
388
+ // a deliberate same-prompt variation is never silently deduplicated.
389
+ if (!hit && idempotencyKey !== undefined && this.store) {
390
+ const durable = await this.durableByKey(idempotencyKey, fingerprint);
391
+ if (durable)
392
+ return durable;
393
+ }
231
394
  if (hit) {
232
395
  if (async)
233
396
  return jobHandle(hit.jobId, hit.status);
@@ -243,31 +406,98 @@ export class JobRegistry {
243
406
  }
244
407
  const jobId = randomUUID();
245
408
  this.evict(now);
246
- const promise = work();
247
- const entry = { jobId, toolName, fingerprint, idempotencyKey, status: 'running', promise, createdAt: now };
409
+ // The work starts before `entry` can exist (the entry holds its promise),
410
+ // yet a well-behaved job reports its interaction id in its very first
411
+ // `await` — synchronously, from here's point of view. Dropping that report
412
+ // would lose exactly the ids that arrive fastest, so it is held until the
413
+ // entry is there and folded into the record's first write.
414
+ let entry;
415
+ let earlyInteractionId;
416
+ const ctx = {
417
+ reportInteraction: (interactionId) => {
418
+ if (!entry) {
419
+ earlyInteractionId = interactionId;
420
+ return;
421
+ }
422
+ if (entry.interactionId === interactionId)
423
+ return;
424
+ entry.interactionId = interactionId;
425
+ if (this.store)
426
+ this.track(this.writeRecord(entry));
427
+ },
428
+ };
429
+ const promise = work(ctx);
430
+ entry = {
431
+ jobId, toolName, fingerprint, idempotencyKey, status: 'running', promise, createdAt: now,
432
+ ...(earlyInteractionId !== undefined ? { interactionId: earlyInteractionId } : {}),
433
+ };
248
434
  this.jobs.set(jobId, entry);
249
435
  if (idempotencyKey !== undefined)
250
436
  this.byKey.set(idempotencyKey, jobId);
251
437
  this.runningByFingerprint.set(fingerprint, jobId);
252
- promise.then((res) => { entry.status = 'done'; entry.result = res; entry.settledAt = Date.now(); }, (err) => {
438
+ // Write the "running" record BEFORE the work can finish. A record that only
439
+ // appeared on completion would be missing for exactly the window this whole
440
+ // mechanism exists to cover.
441
+ if (this.store) {
442
+ this.track(this.writeRecord(entry));
443
+ if (idempotencyKey !== undefined)
444
+ this.track(this.store.putKey(idempotencyKey, jobId));
445
+ this.startHeartbeat();
446
+ }
447
+ const settle = promise.then((res) => { entry.status = 'done'; entry.result = res; entry.settledAt = this.now(); }, (err) => {
253
448
  entry.status = 'failed';
254
449
  entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
255
- entry.settledAt = Date.now();
450
+ entry.settledAt = this.now();
256
451
  }).finally(() => {
257
452
  if (this.runningByFingerprint.get(fingerprint) === jobId)
258
453
  this.runningByFingerprint.delete(fingerprint);
259
454
  });
455
+ if (this.store)
456
+ void settle.then(() => this.track(this.writeRecord(entry)));
260
457
  if (async)
261
- return jobHandle(jobId, 'running');
458
+ return jobHandle(jobId, 'running', this.store !== undefined);
262
459
  if (waitMs !== undefined) {
263
460
  const settled = await awaitWithBudget(promise, waitMs);
264
461
  // Budget expired with the work still running: hand back the job id — the
265
462
  // work continues and the caller polls, exactly as with `async: true`,
266
463
  // except a fast result would have been returned in-band.
267
- return settled ?? jobHandle(jobId, 'running');
464
+ return settled ?? jobHandle(jobId, 'running', this.store !== undefined);
268
465
  }
269
466
  return promise;
270
467
  }
468
+ /**
469
+ * Resolve an idempotency key against the durable store — the path a retry
470
+ * takes when the child that ran the original job is gone.
471
+ *
472
+ * `undefined` means "nothing durable to reuse", which includes a job that
473
+ * FAILED: a retry after an error must really retry. A job still recorded as
474
+ * running belongs to an executor this process cannot await, so the caller gets
475
+ * the handle back rather than a promise that would never settle.
476
+ */
477
+ async durableByKey(idempotencyKey, fingerprint) {
478
+ const id = await this.store.jobIdForKey(idempotencyKey);
479
+ if (!id)
480
+ return undefined;
481
+ const record = await this.store.get(id);
482
+ if (!record)
483
+ return undefined;
484
+ // An idempotency key is a habit as often as it is a promise — "1", "test",
485
+ // "retry". In memory that was survivable because the entry expired in ten
486
+ // minutes; a durable record lives for the store's retention, so replaying
487
+ // on the key ALONE would hand back a different prompt's image, weeks later,
488
+ // and label it a cache hit. The fingerprint is what makes this idempotency
489
+ // rather than a name lookup: same key + different request is a genuinely
490
+ // new generation.
491
+ if (record.fingerprint !== fingerprint)
492
+ return undefined;
493
+ if (this.now() - record.updatedAt > DURABLE_JOB_REUSE_MS)
494
+ return undefined;
495
+ if (record.status === 'running')
496
+ return jobHandle(id, 'running', true);
497
+ if (record.status !== 'done' || !record.result)
498
+ return undefined;
499
+ return annotateReused(await refreshMedia(record.result, this.resigner), id);
500
+ }
271
501
  /**
272
502
  * Retrieve an async job by id (backs the `gemini_get_result` tool):
273
503
  * `running` → a status handle; `done` → the recorded result payload;
@@ -277,15 +507,67 @@ export class JobRegistry {
277
507
  * the unknown-id path — the poll tool cannot be used to read another user's
278
508
  * result even if they know the uuid.
279
509
  */
280
- getResult(jobId) {
510
+ /**
511
+ * The durable record for `jobId`, if there is one — including the
512
+ * executor-lost rewrite. Read-only view for a caller that wants to act on a
513
+ * record's contents (see `tools/jobs.ts` recovering an interaction id) rather
514
+ * than just render it.
515
+ */
516
+ async peekRecord(jobId) {
517
+ return this.store ? await this.store.get(jobId) : undefined;
518
+ }
519
+ /**
520
+ * Adopt a result produced OUTSIDE this registry — recovery reading a lost
521
+ * job's output back off the upstream interaction (`tools/jobs.ts`).
522
+ *
523
+ * Without this the job stays `failed` and every later poll re-enters
524
+ * recovery: another interaction fetch, another media download, and another
525
+ * copy on disk (`…-2.mp4`, `…-3.mp4`) or in R2, with nothing telling the
526
+ * caller they are duplicates. The ordinary done path is idempotent, so this
527
+ * one has to be too.
528
+ */
529
+ async adoptResult(jobId, toolName, result, interactionId) {
530
+ const now = this.now();
531
+ let entry = this.jobs.get(jobId);
532
+ if (!entry) {
533
+ // A different child ran the job, so there is nothing in memory to update.
534
+ // The synthetic entry exists only to render the record and to answer the
535
+ // next poll from this session; its fingerprint is namespaced so it can
536
+ // never collide with (or dedup against) a real request identity.
537
+ entry = {
538
+ jobId,
539
+ toolName,
540
+ fingerprint: `recovered:${jobId}`,
541
+ status: 'done',
542
+ promise: Promise.resolve(result),
543
+ createdAt: now,
544
+ };
545
+ this.jobs.set(jobId, entry);
546
+ }
547
+ entry.status = 'done';
548
+ entry.result = result;
549
+ entry.settledAt = now;
550
+ if (interactionId !== undefined)
551
+ entry.interactionId = interactionId;
552
+ if (this.store)
553
+ await this.writeRecord(entry);
554
+ }
555
+ async getResult(jobId) {
281
556
  const entry = this.jobs.get(jobId);
282
557
  if (!entry) {
283
- throw new McpToolError(`No job "${jobId}" — unknown or expired.`, {
284
- hint: 'Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling.',
558
+ const durable = this.store ? await this.store.get(jobId) : undefined;
559
+ if (durable)
560
+ return this.fromRecord(durable);
561
+ throw new McpToolError(this.store
562
+ ? `No job "${jobId}" — no record of it exists. Either the id is wrong, or the job is older than the ${JOB_RECORD_RETENTION_LABEL} job records are kept.`
563
+ : `No job "${jobId}" — unknown or expired.`, {
564
+ hint: this.store
565
+ ? 'Job records are stored durably here, so a restart alone does not lose one — an id with no record was never started under this account. Images from a completed run are listed by gemini_list_recent_media.'
566
+ : 'Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling.',
285
567
  });
286
568
  }
287
569
  if (entry.status === 'running')
288
- return jobHandle(jobId, 'running');
570
+ return jobHandle(jobId, 'running', this.store !== undefined);
289
571
  if (entry.status === 'failed') {
290
572
  throw new McpToolError(entry.error?.message ?? 'Job failed.', entry.error?.hint ? { hint: entry.error.hint } : undefined);
291
573
  }
@@ -296,4 +578,36 @@ export class JobRegistry {
296
578
  throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
297
579
  return entry.result;
298
580
  }
581
+ /**
582
+ * Render a durable record as a poll answer — the path taken when the child
583
+ * that ran the job is gone.
584
+ *
585
+ * The three outcomes are kept distinct on purpose. Collapsing "your job
586
+ * failed", "your job was killed" and "no such job" into one message is what
587
+ * made the original bug read as an expiry problem when it was a machine
588
+ * lifecycle problem.
589
+ */
590
+ async fromRecord(record) {
591
+ if (record.status === 'running')
592
+ return jobHandle(record.jobId, 'running', true);
593
+ if (record.status === 'failed') {
594
+ throw new McpToolError(record.error?.message ?? 'Job failed.', record.error?.hint ? { hint: record.error.hint } : undefined);
595
+ }
596
+ if (record.resultOmitted) {
597
+ // Both reasons mean "the generation happened, the payload is not here",
598
+ // but only one of them is the caller's to change.
599
+ throw new McpToolError(record.resultOmitted === 'inline'
600
+ ? `Job "${record.jobId}" finished, but its result was returned inline (base64 image data) and inline results are not stored, so there is nothing to replay. Re-run without \`inline: true\` so the media is stored and returned as URLs, which a poll can replay.`
601
+ : `Job "${record.jobId}" finished, but its result was too large to store (over ${Math.round(MAX_PERSISTED_RESULT_BYTES / 1024)} KB), so there is nothing to replay.`, {
602
+ hint: 'The generation itself completed — list what it produced with gemini_list_recent_media rather than paying for it again.',
603
+ });
604
+ }
605
+ if (!record.result)
606
+ throw new McpToolError(`Job "${record.jobId}" completed without a recorded result.`);
607
+ // Re-sign before replaying: the record outlives the signed URLs inside it,
608
+ // and handing back a dead link is a failure that looks like a success.
609
+ return refreshMedia(record.result, this.resigner);
610
+ }
299
611
  }
612
+ /** How long the host keeps a stored object, quoted in the unknown-job message. */
613
+ const JOB_RECORD_RETENTION_LABEL = '30 days';
package/dist/session.js CHANGED
@@ -5,16 +5,17 @@ import { JobRegistry } from './jobs.js';
5
5
  * All of this used to be module-level (`jobs`/`byKey`/`runningByFingerprint` in
6
6
  * jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That is
7
7
  * correct for stdio, where a process serves exactly one user — and a
8
- * cross-tenant leak on the hosted connector, where Cloudflare shares ONE Worker
9
- * isolate across many Durable Object instances. Module state there is shared by
10
- * every authenticated claude.ai session in the isolate: the per-session
8
+ * cross-tenant leak on the hosted connector, where ONE child process serves
9
+ * every session of this registration at once (mcp-host runs the stdio server as
10
+ * a supervised child). Module state there is shared by
11
+ * every authenticated claude.ai session on that child: the per-session
11
12
  * `GeminiClient` isolated the API key but not the memory around it, so a
12
13
  * colliding `idempotency_key` replayed another user's result, an identical
13
14
  * prompt attached to another user's in-flight (billable) job, and
14
15
  * `continue_last: true` resumed another user's interaction under your key.
15
16
  *
16
17
  * So session memory hangs off the `GeminiClient` — the one object the connector
17
- * already builds per authenticated session (`buildClient` in src/worker.ts) and
18
+ * already builds per authenticated session and
18
19
  * threads into every registrar. Handlers reach it as `client.session`.
19
20
  *
20
21
  * The rule this encodes: **`src/` must hold no module-level mutable state.**