gestalt-mobile 0.35.1 → 0.35.3

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.
@@ -16,6 +16,7 @@ import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
16
16
  import { createRecentThreadLister } from './platform/codex/recent-thread-lister.js';
17
17
  import { CodexSessionRuntime } from './platform/codex/session-runtime.js';
18
18
  import { normalizeCodexNotification } from './platform/codex/normalizer.js';
19
+ import { isMissingCodexThreadRollout } from './platform/codex/json-rpc-client.js';
19
20
  import { migrate } from './platform/persistence/migrate.js';
20
21
  import { openRelayDatabase } from './platform/persistence/sqlite.js';
21
22
  import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
@@ -93,6 +94,8 @@ export async function composeRelayApp(options) {
93
94
  const idempotency = new SqliteIdempotencyStore(database);
94
95
  const autopilotStore = new SqliteAutopilotStore(database);
95
96
  const attentionResolutionOperations = new Map();
97
+ const checkpointHandoffTimers = new Map();
98
+ const checkpointHandoffRecoveries = new Set();
96
99
  const supervisedPlans = new SupervisedPlanRegistry();
97
100
  const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
98
101
  const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
@@ -263,7 +266,17 @@ export async function composeRelayApp(options) {
263
266
  .update(`${sessionId}:${threadId}:${generation}:${randomUUID()}`)
264
267
  .digest('hex')
265
268
  .slice(0, 16)}`;
266
- const turnId = await runtime.startExecutorTurn(writer.session, threadId, `${AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT}${context}`, clientId);
269
+ let turnId;
270
+ try {
271
+ turnId = await runtime.startExecutorTurn(writer.session, threadId, `${AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT}${context}`, clientId);
272
+ }
273
+ catch (error) {
274
+ // A confirmed app-server missing-rollout response is a rejection;
275
+ // transport loss remains ambiguous and must retain the issued fence.
276
+ if (isMissingCodexThreadRollout(error))
277
+ throw new Error('AUTOPILOT_EXECUTOR_REJECTED');
278
+ throw error;
279
+ }
267
280
  activity.observe({
268
281
  sessionId,
269
282
  occurredAt: new Date().toISOString(),
@@ -273,16 +286,21 @@ export async function composeRelayApp(options) {
273
286
  });
274
287
  },
275
288
  refresh: (sessionId) => activity.refresh(sessionId),
276
- transferProcess: (sessionId, threadId, processId) => {
289
+ interrupt: async (sessionId, threadId) => {
290
+ const session = sessions.find(sessionId);
291
+ return session && runtime ? runtime.interruptExecutor(session, threadId) : false;
292
+ },
293
+ transferProcess: (sessionId, threadId, processId, actionId) => {
294
+ void actionId;
277
295
  activity.transferProcessOwnership(sessionId, threadId, processId, new Date().toISOString());
278
296
  },
279
- consumeProcess: (sessionId, threadId, processId) => {
280
- runtime?.consumeChildProcessResult(sessionId, threadId, processId);
297
+ consumeProcess: (sessionId, threadId, processId, _actionId) => {
298
+ runtime?.consumeChildProcessResult(sessionId, threadId, processId, _actionId);
281
299
  },
282
- terminateProcess: async (sessionId, threadId, processId) => {
300
+ terminateProcess: async (sessionId, threadId, processId, actionId, processInstance) => {
283
301
  const session = sessions.find(sessionId);
284
302
  return session && runtime
285
- ? runtime.terminateChildProcess(session, threadId, processId)
303
+ ? runtime.terminateChildProcess(session, threadId, processId, actionId, processInstance)
286
304
  : false;
287
305
  },
288
306
  },
@@ -381,6 +399,91 @@ export async function composeRelayApp(options) {
381
399
  publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
382
400
  }
383
401
  };
402
+ const checkpointTimerKey = (sessionId, requestId) => `${sessionId}:${requestId}`;
403
+ const cancelCheckpointHandoffTimer = (sessionId, requestId) => {
404
+ const key = checkpointTimerKey(sessionId, requestId);
405
+ const timer = checkpointHandoffTimers.get(key);
406
+ if (!timer)
407
+ return;
408
+ clearTimeout(timer.handle);
409
+ checkpointHandoffTimers.delete(key);
410
+ };
411
+ /** Fail a held checkpoint exactly once and retain its durable root boundary. */
412
+ const failCheckpointHandoff = (sessionId, requestId, turnId) => {
413
+ cancelCheckpointHandoffTimer(sessionId, requestId);
414
+ const occurredAt = new Date().toISOString();
415
+ if (!interactions.resolve(sessionId, requestId, occurredAt, 'failed'))
416
+ return false;
417
+ autopilot.checkpointHandoffFailed(sessionId, turnId);
418
+ events.publish(journal.append(sessionId, 'org-plan.checkpoint-handoff-failed', { requestId, reason: 'checkpointHandoffFailed' }, occurredAt));
419
+ publishAttentionSettlement(sessionId, requestId, occurredAt, 'failed');
420
+ // Neither this timeout nor a failed transport may schedule continuation.
421
+ // If a successful response is already blocked in the writable stream, drop
422
+ // its local ownership so any late write cannot be mistaken for an ack.
423
+ const rejected = runtime?.rejectServerRequest(sessionId, requestId, new Error('CHECKPOINT_HANDOFF_FAILED'));
424
+ if (rejected === false) {
425
+ const recoveryKey = checkpointTimerKey(sessionId, requestId);
426
+ const ownedRuntime = runtime;
427
+ const session = sessions.find(sessionId);
428
+ if (ownedRuntime &&
429
+ session?.activeTurnId === turnId &&
430
+ !checkpointHandoffRecoveries.has(recoveryKey)) {
431
+ checkpointHandoffRecoveries.add(recoveryKey);
432
+ void (async () => {
433
+ try {
434
+ // Recycle only the session-local writer. Its close cancels the
435
+ // blocked output, and only successful reconstruction releases the
436
+ // durable checkpoint through the runtime-recovery boundary.
437
+ if (runtime !== ownedRuntime || sessions.find(sessionId)?.activeTurnId !== turnId)
438
+ return;
439
+ const rebuilt = await ownedRuntime.recycle(session, new Date().toISOString());
440
+ if (runtime !== ownedRuntime)
441
+ return;
442
+ const recovered = turnId && rebuilt.activeTurnId === turnId
443
+ ? RelaySession.rehydrate(rebuilt).completeTurn(turnId, new Date().toISOString())
444
+ .snapshot
445
+ : rebuilt;
446
+ saveSession(recovered);
447
+ activity.observe({
448
+ sessionId,
449
+ occurredAt: new Date().toISOString(),
450
+ kind: 'turnCompleted',
451
+ });
452
+ autopilot.recoverCheckpointHandoff(sessionId);
453
+ }
454
+ catch {
455
+ // The durable failure remains visible; a later normal recovery can retry.
456
+ }
457
+ finally {
458
+ checkpointHandoffRecoveries.delete(recoveryKey);
459
+ }
460
+ })();
461
+ }
462
+ }
463
+ return true;
464
+ };
465
+ const armCheckpointHandoffDeadline = (sessionId, requestId, turnId) => {
466
+ cancelCheckpointHandoffTimer(sessionId, requestId);
467
+ const key = checkpointTimerKey(sessionId, requestId);
468
+ const token = Symbol(key);
469
+ const handle = setTimeout(() => {
470
+ if (checkpointHandoffTimers.get(key)?.token !== token)
471
+ return;
472
+ checkpointHandoffTimers.delete(key);
473
+ failCheckpointHandoff(sessionId, requestId, turnId);
474
+ }, options.checkpointHandoffDeadlineMs ?? 5_000);
475
+ handle.unref?.();
476
+ checkpointHandoffTimers.set(key, { handle, token });
477
+ };
478
+ const failPendingCheckpointHandoffs = (sessionId, recoverBoundary) => {
479
+ for (const interaction of interactions.list(sessionId)) {
480
+ if (interaction.kind !== 'orgPlanCheckpoint')
481
+ continue;
482
+ failCheckpointHandoff(sessionId, interaction.requestId, interaction.turnId ?? null);
483
+ }
484
+ if (recoverBoundary)
485
+ autopilot.recoverCheckpointHandoff(sessionId);
486
+ };
384
487
  let closing = false;
385
488
  let runtime = null;
386
489
  const acceptPlanUpdate = (sessionId, update) => {
@@ -478,7 +581,7 @@ export async function composeRelayApp(options) {
478
581
  const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
479
582
  if (rootOwned)
480
583
  autopilot.rejectProbe(sessionId);
481
- return (runtime?.resolveServerRequest(sessionId, String(request.id), autopilotWaitLeaseToolResponse()) === true);
584
+ return (runtime?.resolveServerRequest(sessionId, String(request.id), autopilotWaitLeaseToolResponse(false)) === true);
482
585
  }
483
586
  if (!rawInteraction)
484
587
  return false;
@@ -502,7 +605,7 @@ export async function composeRelayApp(options) {
502
605
  if (!accepted) {
503
606
  if (rootOwned)
504
607
  autopilot.rejectProbe(sessionId);
505
- return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse()) === true);
608
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse(false)) === true);
506
609
  }
507
610
  return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse()) === true);
508
611
  }
@@ -526,9 +629,42 @@ export async function composeRelayApp(options) {
526
629
  });
527
630
  if (!valid)
528
631
  return false;
529
- if (!autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString()))
632
+ if (!session.activeTurnId)
633
+ return false;
634
+ let accepted = false;
635
+ try {
636
+ accepted = autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString());
637
+ }
638
+ catch {
639
+ if (!autopilot.checkpointHandoffFailed(sessionId, session.activeTurnId))
640
+ return false;
641
+ const interaction = {
642
+ ...rawInteraction,
643
+ turnId: session.activeTurnId,
644
+ requestedAt: new Date().toISOString(),
645
+ };
646
+ interactions.add(sessionId, interaction);
647
+ failCheckpointHandoff(sessionId, rawInteraction.requestId, session.activeTurnId);
648
+ return true;
649
+ }
650
+ if (!accepted)
530
651
  return false;
531
- return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse()) === true);
652
+ const interaction = {
653
+ ...rawInteraction,
654
+ turnId: session.activeTurnId,
655
+ requestedAt: new Date().toISOString(),
656
+ };
657
+ interactions.add(sessionId, interaction);
658
+ armCheckpointHandoffDeadline(sessionId, rawInteraction.requestId, session.activeTurnId);
659
+ try {
660
+ runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse());
661
+ }
662
+ catch {
663
+ // The accepted hold remains owned by the bounded deadline.
664
+ }
665
+ // `true` retains the checkpoint-specific held request. Its timer
666
+ // is cancelled solely by the JSON-RPC write-settlement callback.
667
+ return true;
532
668
  }
533
669
  const interaction = {
534
670
  ...rawInteraction,
@@ -554,10 +690,24 @@ export async function composeRelayApp(options) {
554
690
  return true;
555
691
  }, (sessionId) => {
556
692
  activity.disconnected(sessionId, new Date().toISOString());
693
+ failPendingCheckpointHandoffs(sessionId, false);
557
694
  dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
558
695
  recoverExitedSession(sessionId);
559
696
  }, resolveSkills, planStatusSource, acceptPlanUpdate, options.planMeasurementBaseUrl, 30_000, 64, root)
560
697
  : null;
698
+ runtime?.onServerResponseSettled((sessionId, requestId, outcome) => {
699
+ const interaction = interactions.find(sessionId, requestId);
700
+ if (interaction?.kind !== 'orgPlanCheckpoint')
701
+ return;
702
+ if (outcome !== 'resultWritten') {
703
+ failCheckpointHandoff(sessionId, requestId, interaction.turnId ?? null);
704
+ return;
705
+ }
706
+ cancelCheckpointHandoffTimer(sessionId, requestId);
707
+ const occurredAt = new Date().toISOString();
708
+ if (interactions.resolve(sessionId, requestId, occurredAt, 'answered'))
709
+ publishAttentionSettlement(sessionId, requestId, occurredAt, 'answered');
710
+ });
561
711
  if (runtime && planMeasurementHelperPath) {
562
712
  planMeasurementRefresh = new PlanMeasurementRefresh(async (sessionId) => {
563
713
  const session = sessions.find(sessionId);
@@ -1069,6 +1219,10 @@ export async function composeRelayApp(options) {
1069
1219
  }
1070
1220
  await runtime?.watchPlanStatus(session);
1071
1221
  autopilot.restore(session.id);
1222
+ // A prior process cannot resolve its original JSON-RPC request. Turn
1223
+ // that bounded handoff into one durable failure and release the
1224
+ // checkpoint only through the explicit runtime-recovery boundary.
1225
+ failPendingCheckpointHandoffs(session.id, true);
1072
1226
  });
1073
1227
  };
1074
1228
  app.addHook('onListen', async () => {
@@ -1083,11 +1237,21 @@ export async function composeRelayApp(options) {
1083
1237
  for (const session of sessions.list()) {
1084
1238
  autopilot.dispose(session.id);
1085
1239
  activity.dispose(session.id);
1240
+ for (const key of [...checkpointHandoffTimers.keys()]) {
1241
+ if (key.startsWith(`${session.id}:`)) {
1242
+ const timer = checkpointHandoffTimers.get(key);
1243
+ if (timer)
1244
+ clearTimeout(timer.handle);
1245
+ checkpointHandoffTimers.delete(key);
1246
+ }
1247
+ }
1086
1248
  // Relay shutdown only releases this process's writer. A typed attention
1087
1249
  // request remains a durable human-visible blocker for the next relay
1088
1250
  // instance; only an app-server-cleared request is a failed audit outcome.
1089
1251
  for (const interaction of interactions.list(session.id)) {
1090
- if (interaction.kind === 'orgPlanAttention')
1252
+ // Checkpoints are recovered by the next relay: their original root
1253
+ // boundary is durable and must not be replaced by a shutdown dismissal.
1254
+ if (interaction.kind === 'orgPlanAttention' || interaction.kind === 'orgPlanCheckpoint')
1091
1255
  continue;
1092
1256
  const occurredAt = new Date().toISOString();
1093
1257
  if (interactions.resolve(session.id, interaction.requestId, occurredAt, 'dismissed'))