gestalt-mobile 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1368,6 +1368,87 @@ describe('production composition', () => {
1368
1368
  });
1369
1369
  });
1370
1370
  describeCompositionConcern('lifecycle', () => {
1371
+ it('acknowledges root-owned capacity recovery before recycling only its app-server', async () => {
1372
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1373
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
1374
+ ownTemporaryPaths(root, dataDir);
1375
+ await mkdir(join(root, 'workspace'));
1376
+ const handles = [];
1377
+ const app = await composeAuthorizedApp({
1378
+ root,
1379
+ dataDir,
1380
+ relyingParty,
1381
+ installedCodexVersion: 'codex-cli 0.144.3',
1382
+ startAppServers: true,
1383
+ launchAppServer: liveAppServer(handles),
1384
+ profiles: {
1385
+ list: async () => [],
1386
+ require: async () => ({
1387
+ name: 'default',
1388
+ state: 'ok',
1389
+ status: 'ready',
1390
+ }),
1391
+ },
1392
+ });
1393
+ const sessionId = await createComposedSession(app);
1394
+ const first = handles.find((candidate) => candidate.calls.includes('thread/start'));
1395
+ expect(first?.request).toBeDefined();
1396
+ const started = await app.inject({
1397
+ method: 'POST',
1398
+ url: `/api/sessions/${sessionId}/turns`,
1399
+ payload: { text: 'continue supervised work' },
1400
+ });
1401
+ expect(started.statusCode).toBe(202);
1402
+ const turnId = started.json().activeTurnId;
1403
+ const threadId = (await app.inject(`/api/sessions/${sessionId}`)).json().threadId;
1404
+ const handlesBeforeRecovery = handles.length;
1405
+ first.notify?.({
1406
+ method: 'turn/started',
1407
+ params: { threadId, turn: { id: turnId } },
1408
+ });
1409
+ await expect(first.request({
1410
+ id: 90,
1411
+ method: 'item/tool/call',
1412
+ params: {
1413
+ threadId: 'child-thread',
1414
+ turnId,
1415
+ tool: 'gestalt_agent_capacity_recovery',
1416
+ arguments: { version: 1, reason: 'agentThreadLimit' },
1417
+ },
1418
+ })).rejects.toThrow('CODEX_SERVER_REQUEST_UNSUPPORTED');
1419
+ expect(handles).toHaveLength(handlesBeforeRecovery);
1420
+ await expect(first.request({
1421
+ id: 91,
1422
+ method: 'item/tool/call',
1423
+ params: {
1424
+ threadId,
1425
+ turnId,
1426
+ tool: 'gestalt_agent_capacity_recovery',
1427
+ arguments: { version: 1, reason: 'agentThreadLimit' },
1428
+ },
1429
+ })).resolves.toEqual({
1430
+ success: true,
1431
+ contentItems: [
1432
+ { type: 'inputText', text: '{"accepted":true,"action":"sessionRuntimeRecycle"}' },
1433
+ ],
1434
+ });
1435
+ await expect.poll(() => handles.length).toBeGreaterThan(handlesBeforeRecovery);
1436
+ const replacement = handles.find((candidate) => candidate !== first &&
1437
+ candidate.requests.some((request) => request.method === 'thread/resume'));
1438
+ expect(replacement?.requests).toContainEqual({
1439
+ method: 'thread/resume',
1440
+ params: expect.objectContaining({ threadId, cwd: join(root, 'workspace') }),
1441
+ });
1442
+ await expect
1443
+ .poll(async () => (await app.inject(`/api/sessions/${sessionId}`)).json().state)
1444
+ .toBe('ready');
1445
+ expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
1446
+ threadId,
1447
+ activeTurnId: null,
1448
+ desiredState: 'active',
1449
+ });
1450
+ await app.close();
1451
+ });
1371
1452
  it('publishes an idle websocket status after saving a completed turn', async () => {
1372
1453
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1373
1454
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -36,7 +36,7 @@ import { RelaySession, } from './features/sessions/model/relay-session.js';
36
36
  import { AgentActivityRegistry } from './features/agent-activity/registry.js';
37
37
  import { toAgentActivityDto } from './features/agent-activity/activity-dto.js';
38
38
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
39
- import { isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
39
+ import { isAgentCapacityRecoveryCall, isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
40
40
  import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
41
41
  import { validOrgPlanCheckpoint } from './features/org-plan-checkpoint/application/validate.js';
42
42
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
@@ -58,6 +58,7 @@ import { createRelyingPartyConfig } from './config.js';
58
58
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
59
59
  import { GestaltUpdateRestartScheduler } from './platform/process/gestalt-update-restart.js';
60
60
  import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
61
+ import { agentCapacityRecoveryToolResponse } from '../shared/contracts/agent-capacity-recovery.js';
61
62
  const generatedProtocolVersion = 'codex-cli 0.144.3';
62
63
  export async function composeRelayApp(options) {
63
64
  const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
@@ -346,6 +347,7 @@ export async function composeRelayApp(options) {
346
347
  const gitFetches = new GitFetchCoordinator(fetchUpstream);
347
348
  const gitSummaries = new GitSummaryCache(inspectGit);
348
349
  let recoverExitedSession = () => { };
350
+ let scheduleAgentCapacityRecovery = () => false;
349
351
  let planMeasurementRefresh;
350
352
  const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
351
353
  const interaction = interactions
@@ -464,6 +466,11 @@ export async function composeRelayApp(options) {
464
466
  const session = withPendingInteractions(sessions.find(sessionId));
465
467
  if (!session)
466
468
  return false;
469
+ if (!rawInteraction && isAgentCapacityRecoveryCall(request)) {
470
+ const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
471
+ return (rootOwned &&
472
+ scheduleAgentCapacityRecovery(sessionId, () => runtime?.resolveServerRequest(sessionId, String(request.id), agentCapacityRecoveryToolResponse()) === true));
473
+ }
467
474
  if (!rawInteraction && isAutopilotWaitLeaseCall(request)) {
468
475
  const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
469
476
  if (rootOwned)
@@ -565,6 +572,39 @@ export async function composeRelayApp(options) {
565
572
  autopilot.restore(session.id);
566
573
  };
567
574
  if (runtime) {
575
+ const capacityRecoveries = new Set();
576
+ scheduleAgentCapacityRecovery = (sessionId, acknowledge) => {
577
+ if (capacityRecoveries.has(sessionId) || !acknowledge())
578
+ return false;
579
+ capacityRecoveries.add(sessionId);
580
+ setTimeout(() => {
581
+ void (async () => {
582
+ try {
583
+ if (closing)
584
+ return;
585
+ const session = sessions.find(sessionId);
586
+ if (!session || session.desiredState !== 'active' || !session.threadId)
587
+ return;
588
+ const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
589
+ saveSession(recovering);
590
+ try {
591
+ const restored = await runtime.recycle(recovering, new Date().toISOString());
592
+ saveSession(restored);
593
+ }
594
+ catch {
595
+ const current = sessions.find(sessionId);
596
+ if (current)
597
+ saveSession(RelaySession.rehydrate(current).requireAttention(new Date().toISOString())
598
+ .snapshot);
599
+ }
600
+ }
601
+ finally {
602
+ capacityRecoveries.delete(sessionId);
603
+ }
604
+ })();
605
+ }, 500);
606
+ return true;
607
+ };
568
608
  const supervisor = new SessionSupervisor(async (sessionId) => {
569
609
  const session = sessions.find(sessionId);
570
610
  if (!session || session.desiredState !== 'active' || !session.threadId)
@@ -7,6 +7,7 @@ import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/qui
7
7
  import { GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME, parseOrgPlanAttention, } from '../../../shared/contracts/org-plan-attention.js';
8
8
  import { GESTALT_ORG_PLAN_CHECKPOINT_TOOL_NAME, parseOrgPlanCheckpoint, } from '../../../shared/contracts/org-plan-checkpoint.js';
9
9
  import { GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME, parseAutopilotWaitLease, } from '../../../shared/contracts/autopilot-wait-lease.js';
10
+ import { GESTALT_AGENT_CAPACITY_RECOVERY_TOOL_NAME, parseAgentCapacityRecovery, } from '../../../shared/contracts/agent-capacity-recovery.js';
10
11
  export function toPendingInteraction(input) {
11
12
  if (!Number.isSafeInteger(input.id) || !isRecord(input.params))
12
13
  return null;
@@ -58,6 +59,13 @@ export function isAutopilotWaitLeaseCall(input) {
58
59
  isRecord(input.params) &&
59
60
  input.params.tool === GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME);
60
61
  }
62
+ export function isAgentCapacityRecoveryCall(input) {
63
+ return (Number.isSafeInteger(input.id) &&
64
+ input.method === 'item/tool/call' &&
65
+ isRecord(input.params) &&
66
+ input.params.tool === GESTALT_AGENT_CAPACITY_RECOVERY_TOOL_NAME &&
67
+ parseAgentCapacityRecovery(input.params.arguments) !== null);
68
+ }
61
69
  function isRecord(value) {
62
70
  return typeof value === 'object' && value !== null && !Array.isArray(value);
63
71
  }
@@ -11,6 +11,7 @@ import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
11
11
  import { gestaltOrgPlanAttentionDynamicTool } from '../../../shared/contracts/org-plan-attention.js';
12
12
  import { gestaltOrgPlanCheckpointDynamicTool } from '../../../shared/contracts/org-plan-checkpoint.js';
13
13
  import { gestaltAutopilotWaitLeaseDynamicTool } from '../../../shared/contracts/autopilot-wait-lease.js';
14
+ import { gestaltAgentCapacityRecoveryDynamicTool } from '../../../shared/contracts/agent-capacity-recovery.js';
14
15
  import { countDiffLines } from '../../../shared/contracts/file-change.js';
15
16
  import { threadPlanName } from './thread-plan-name.js';
16
17
  import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
@@ -145,6 +146,11 @@ export class CodexSessionRuntime {
145
146
  }
146
147
  this.stop(sessionId);
147
148
  }
149
+ /** Replaces only this session's app-server while retaining its durable root thread. */
150
+ async recycle(session, now) {
151
+ this.stop(session.id);
152
+ return this.restore(session, now);
153
+ }
148
154
  /** Releases all relay-owned app-server children during graceful shutdown. */
149
155
  stopAll() {
150
156
  // Exit delivery can already be queued when shutdown begins. Declare the
@@ -574,6 +580,7 @@ export class CodexSessionRuntime {
574
580
  gestaltOrgPlanAttentionDynamicTool,
575
581
  gestaltOrgPlanCheckpointDynamicTool,
576
582
  gestaltAutopilotWaitLeaseDynamicTool,
583
+ gestaltAgentCapacityRecoveryDynamicTool,
577
584
  ],
578
585
  });
579
586
  result = {
@@ -665,6 +672,7 @@ export class CodexSessionRuntime {
665
672
  gestaltOrgPlanAttentionDynamicTool,
666
673
  gestaltOrgPlanCheckpointDynamicTool,
667
674
  gestaltAutopilotWaitLeaseDynamicTool,
675
+ gestaltAgentCapacityRecoveryDynamicTool,
668
676
  ],
669
677
  ...(session.model ? { model: session.model } : {}),
670
678
  ...(session.executionPolicy?.sandbox ? { sandbox: session.executionPolicy.sandbox } : {}),
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ /** Bounded recovery for a Codex collaboration registry whose resident slots leaked. */
7
+ export const GESTALT_AGENT_CAPACITY_RECOVERY_TOOL_NAME = 'gestalt_agent_capacity_recovery';
8
+ export const gestaltAgentCapacityRecoveryDynamicTool = {
9
+ type: 'function',
10
+ name: GESTALT_AGENT_CAPACITY_RECOVERY_TOOL_NAME,
11
+ description: 'Recover the current supervised session after Codex reports its agent thread limit and stale pending-init agents cannot be closed. This recycles only the current Codex runtime, preserves the durable root thread and Org Plan, and cannot request human authority.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ additionalProperties: false,
15
+ required: ['version', 'reason'],
16
+ properties: {
17
+ version: { const: 1 },
18
+ reason: { const: 'agentThreadLimit' },
19
+ },
20
+ },
21
+ };
22
+ export function parseAgentCapacityRecovery(value) {
23
+ if (typeof value !== 'object' ||
24
+ value === null ||
25
+ Array.isArray(value) ||
26
+ Object.keys(value).length !== 2)
27
+ return null;
28
+ const candidate = value;
29
+ return candidate.version === 1 && candidate.reason === 'agentThreadLimit'
30
+ ? { version: 1, reason: 'agentThreadLimit' }
31
+ : null;
32
+ }
33
+ export function agentCapacityRecoveryToolResponse() {
34
+ return {
35
+ success: true,
36
+ contentItems: [
37
+ { type: 'inputText', text: '{"accepted":true,"action":"sessionRuntimeRecycle"}' },
38
+ ],
39
+ };
40
+ }
@@ -6,6 +6,7 @@
6
6
  "supervision-start",
7
7
  "wait-lease-tool",
8
8
  "checkpoint-tool",
9
+ "agent-capacity-recovery",
9
10
  "controller-status",
10
11
  "canonical-agent-identity",
11
12
  "session-verdict",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",