gestalt-mobile 0.35.0 → 0.35.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.
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { once } from 'node:events';
7
+ import { createHash } from 'node:crypto';
7
8
  import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
8
9
  import { DatabaseSync } from 'node:sqlite';
9
10
  import { tmpdir } from 'node:os';
@@ -12,12 +13,14 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest';
12
13
  import WebSocket from 'ws';
13
14
  import { composeRelayApp } from './composition.js';
14
15
  import { AUTOPILOT_CONTINUATION_PROMPT } from './features/autopilot/application/policy.js';
16
+ import { CodexJsonRpcError } from './platform/codex/json-rpc-client.js';
15
17
  import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
16
18
  import { authorizationSessionId, authorizedDeviceId, localOwnerId, webAuthnCredentialId, } from './features/auth/domain/identifiers.js';
17
19
  import { deviceNickname } from './features/auth/domain/device-nickname.js';
18
20
  import { workspaceId } from './platform/catalog/workspace-id.js';
19
21
  import { planStatusDirectoryPath, planStatusFilePath, } from './platform/plans/filesystem-plan-status-source.js';
20
22
  import { toOrgPlanAttentionToolResponse } from '../shared/contracts/org-plan-attention.js';
23
+ import { toOrgPlanCheckpointToolResponse } from '../shared/contracts/org-plan-checkpoint.js';
21
24
  function fakeAppServer(calls) {
22
25
  return {
23
26
  rpc: {
@@ -42,7 +45,7 @@ function fakeAppServer(calls) {
42
45
  }
43
46
  function liveAppServer(handles) {
44
47
  return () => {
45
- const handle = { calls: [], requests: [] };
48
+ const handle = { calls: [], requests: [], pendingResponses: new Map() };
46
49
  handles.push(handle);
47
50
  return {
48
51
  rpc: {
@@ -71,11 +74,45 @@ function liveAppServer(handles) {
71
74
  return () => { };
72
75
  },
73
76
  onServerRequest: (listener) => {
74
- handle.request = listener;
77
+ handle.request = async (request) => {
78
+ try {
79
+ const result = await listener(request);
80
+ if (!handle.deferResponseSettlement)
81
+ handle.responseSettled?.({ id: request.id, outcome: 'resultWritten' });
82
+ else
83
+ await new Promise((resolve, reject) => handle.pendingResponses.set(request.id, { resolve, reject }));
84
+ return result;
85
+ }
86
+ catch (error) {
87
+ if (!handle.deferResponseSettlement)
88
+ handle.responseSettled?.({ id: request.id, outcome: 'errorWritten' });
89
+ throw error;
90
+ }
91
+ };
75
92
  return () => { };
76
93
  },
94
+ onServerResponseSettled: (listener) => {
95
+ handle.responseSettled = (settlement) => {
96
+ listener(settlement);
97
+ const pending = handle.pendingResponses.get(settlement.id);
98
+ if (!pending)
99
+ return;
100
+ handle.pendingResponses.delete(settlement.id);
101
+ if (settlement.outcome === 'resultWritten')
102
+ pending.resolve();
103
+ else
104
+ pending.reject(new Error('CODEX_SERVER_REQUEST_WRITE_FAILED'));
105
+ };
106
+ return () => {
107
+ handle.responseSettled = undefined;
108
+ };
109
+ },
110
+ },
111
+ close: () => {
112
+ for (const pending of handle.pendingResponses.values())
113
+ pending.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
114
+ handle.pendingResponses.clear();
77
115
  },
78
- close: () => { },
79
116
  onExit: () => () => { },
80
117
  };
81
118
  };
@@ -158,6 +195,36 @@ async function createProductionAutopilotFixture(overrides = {}) {
158
195
  await installAutopilotPlan(app, sessionId, workspacePath, 'autopilot');
159
196
  return { app, dataDir, handles, root, sessionId, workspacePath };
160
197
  }
198
+ async function holdCheckpointRequest(fixture, timers, requestId, expectHeld = true) {
199
+ timers.find((timer) => !timer.cancelled).callback();
200
+ await vi.waitFor(() => expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(1));
201
+ const handle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start'));
202
+ await vi.waitFor(() => expect(handle.request).toBeDefined());
203
+ const session = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json();
204
+ const threadId = session.threadId;
205
+ const turnId = session.activeTurnId;
206
+ handle.notify({ method: 'turn/started', params: { threadId, turn: { id: turnId } } });
207
+ await writeFile(join(fixture.workspacePath, 'autopilot.org'), autopilotPlanText('DONE'));
208
+ await expect
209
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json().steps[0]
210
+ .children[0].state)
211
+ .toBe('DONE');
212
+ handle.deferResponseSettlement = true;
213
+ const planIdentity = createHash('sha256')
214
+ .update(join(fixture.workspacePath, 'autopilot.org'))
215
+ .digest('hex');
216
+ const writersBefore = fixture.handles.length;
217
+ const startsBefore = fixture.handles
218
+ .flatMap((candidate) => candidate.requests)
219
+ .filter((request) => request.method === 'turn/start').length;
220
+ const response = handle.request(l2CheckpointCall(requestId, threadId, turnId, planIdentity));
221
+ // The caller can be synchronously rejected by a coordinator exception.
222
+ // Mark it observed immediately; callers still assert its exact outcome.
223
+ void response.catch(() => { });
224
+ if (expectHeld)
225
+ await vi.waitFor(() => expect(handle.pendingResponses.has(requestId)).toBe(true));
226
+ return { handle, response, startsBefore, threadId, turnId, writersBefore };
227
+ }
161
228
  function attentionCall(id, reason = 'hardBlock') {
162
229
  return {
163
230
  id,
@@ -173,6 +240,29 @@ function attentionCall(id, reason = 'hardBlock') {
173
240
  },
174
241
  };
175
242
  }
243
+ function l2CheckpointCall(id, threadId, turnId, planIdentity) {
244
+ return {
245
+ id,
246
+ method: 'item/tool/call',
247
+ params: {
248
+ threadId,
249
+ turnId,
250
+ tool: 'gestalt_org_plan_checkpoint',
251
+ arguments: {
252
+ version: 1,
253
+ kind: 'l2Completed',
254
+ planIdentity,
255
+ l1Id: 'parent',
256
+ l2Id: 'child',
257
+ position: 'L1.1',
258
+ status: 'DONE',
259
+ changes: 'Recorded the completed child.',
260
+ files: 'src/checkpoint.ts',
261
+ tests: 'Focused tests passed.',
262
+ },
263
+ },
264
+ };
265
+ }
176
266
  function ownTemporaryPaths(...paths) {
177
267
  onTestFinished(async () => {
178
268
  await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })));
@@ -632,6 +722,585 @@ describe('production composition', () => {
632
722
  expect(accepted.handles.flatMap((handle) => handle.calls)).not.toContain('turn/start');
633
723
  await accepted.app.close();
634
724
  });
725
+ it('acknowledges a checkpoint before scheduling and drops an obsolete executor wait lease', async () => {
726
+ const timers = [];
727
+ let coordinator;
728
+ const fixture = await createProductionAutopilotFixture({
729
+ autopilotSchedule: (callback) => {
730
+ const timer = { callback, cancelled: false, fired: false };
731
+ timers.push(timer);
732
+ return () => {
733
+ timer.cancelled = true;
734
+ };
735
+ },
736
+ onAutopilotCoordinator: (value) => {
737
+ coordinator = value;
738
+ },
739
+ });
740
+ const first = timers.find((timer) => !timer.cancelled && !timer.fired);
741
+ expect(first).toBeDefined();
742
+ first.fired = true;
743
+ first.callback();
744
+ await vi.waitFor(() => expect(fixture.handles.flatMap((handle) => handle.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(1));
745
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
746
+ const active = database
747
+ .prepare('SELECT active_turn_id FROM relay_sessions WHERE id = ?')
748
+ .get(fixture.sessionId);
749
+ database.close();
750
+ expect(coordinator.registerProactiveWait(fixture.sessionId, {
751
+ id: 'pre-checkpoint-report',
752
+ leaseId: 'pre-checkpoint-lease',
753
+ wakeConditions: ['executorChanged'],
754
+ maxWaitMs: 60_000,
755
+ })).toBe(true);
756
+ await writeFile(join(fixture.workspacePath, 'autopilot.org'), autopilotPlanText('DONE'));
757
+ await expect
758
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json().steps[0]
759
+ .children[0].state)
760
+ .toBe('DONE');
761
+ const handle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start'));
762
+ const threadId = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()
763
+ .threadId;
764
+ const schedulesBeforeCheckpoint = timers.length;
765
+ const planIdentity = createHash('sha256')
766
+ .update(join(fixture.workspacePath, 'autopilot.org'))
767
+ .digest('hex');
768
+ handle.deferResponseSettlement = true;
769
+ const checkpointResponse = handle.request(l2CheckpointCall(811, threadId, active.active_turn_id, planIdentity));
770
+ await vi.waitFor(() => expect(handle.pendingResponses.has(811)).toBe(true));
771
+ const beforeWriteDatabase = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
772
+ const beforeWrite = beforeWriteDatabase
773
+ .prepare('SELECT resolved_at FROM pending_interactions WHERE session_id = ? AND request_id = ?')
774
+ .get(fixture.sessionId, '811');
775
+ beforeWriteDatabase.close();
776
+ expect(beforeWrite.resolved_at).toBeNull();
777
+ handle.responseSettled({ id: 811, outcome: 'resultWritten' });
778
+ await expect(checkpointResponse).resolves.toEqual(toOrgPlanCheckpointToolResponse());
779
+ const afterWriteDatabase = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
780
+ const afterWrite = afterWriteDatabase
781
+ .prepare('SELECT resolved_at FROM pending_interactions WHERE session_id = ? AND request_id = ?')
782
+ .get(fixture.sessionId, '811');
783
+ afterWriteDatabase.close();
784
+ expect(afterWrite.resolved_at).toEqual(expect.any(String));
785
+ expect(timers).toHaveLength(schedulesBeforeCheckpoint);
786
+ expect(coordinator.snapshot(fixture.sessionId).health).not.toMatchObject({
787
+ phase: 'waitingForAgentEvent',
788
+ });
789
+ const startsBeforeFinal = fixture.handles
790
+ .flatMap((candidate) => candidate.requests)
791
+ .filter((request) => request.method === 'turn/start').length;
792
+ // A completion from another turn cannot release the durable checkpoint
793
+ // boundary, including after the short acknowledgement has returned.
794
+ handle.notify({
795
+ method: 'turn/completed',
796
+ params: { threadId, turn: { id: 'wrong-turn' } },
797
+ });
798
+ await Promise.resolve();
799
+ expect(fixture.handles
800
+ .flatMap((candidate) => candidate.requests)
801
+ .filter((request) => request.method === 'turn/start')).toHaveLength(startsBeforeFinal);
802
+ // Destroy the old runtime and rebuild the coordinator over the same
803
+ // SQLite store. The persisted checkpoint remains inert until its owning
804
+ // root final is observed by the replacement runtime.
805
+ await fixture.app.close();
806
+ const rebuiltTimers = [];
807
+ const rebuiltHandles = [];
808
+ let rebuiltCoordinator;
809
+ const rebuilt = await composeAuthorizedApp({
810
+ root: fixture.root,
811
+ dataDir: fixture.dataDir,
812
+ relyingParty,
813
+ installedCodexVersion: 'codex-cli 0.144.3',
814
+ startAppServers: true,
815
+ launchAppServer: liveAppServer(rebuiltHandles),
816
+ autopilotSchedule: (callback) => {
817
+ const timer = { callback, cancelled: false, fired: false };
818
+ rebuiltTimers.push(timer);
819
+ return () => {
820
+ timer.cancelled = true;
821
+ };
822
+ },
823
+ onAutopilotCoordinator: (value) => {
824
+ rebuiltCoordinator = value;
825
+ },
826
+ profiles: {
827
+ list: async () => [],
828
+ require: async () => ({
829
+ name: 'default',
830
+ state: 'ok',
831
+ status: 'ready',
832
+ }),
833
+ },
834
+ });
835
+ await vi.waitFor(() => expect(rebuiltCoordinator).toBeDefined());
836
+ expect(rebuiltTimers).toHaveLength(0);
837
+ expect(rebuiltCoordinator.turnCompleted(fixture.sessionId)).toBe(true);
838
+ await vi.waitFor(() => expect(rebuiltTimers.length).toBe(1));
839
+ await rebuilt.close();
840
+ });
841
+ it('recycles a stuck checkpoint writer at the acknowledgement deadline and recovers once', async () => {
842
+ const timers = [];
843
+ const fixture = await createProductionAutopilotFixture({
844
+ checkpointHandoffDeadlineMs: 100,
845
+ autopilotSchedule: (callback) => {
846
+ const timer = { callback, cancelled: false };
847
+ timers.push(timer);
848
+ return () => {
849
+ timer.cancelled = true;
850
+ };
851
+ },
852
+ });
853
+ timers.find((timer) => !timer.cancelled).callback();
854
+ await vi.waitFor(() => expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(1));
855
+ await vi.waitFor(() => expect(fixture.handles.some((candidate) => candidate.request)).toBe(true));
856
+ const handle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start'));
857
+ const threadId = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()
858
+ .threadId;
859
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
860
+ const active = database
861
+ .prepare('SELECT active_turn_id FROM relay_sessions WHERE id = ?')
862
+ .get(fixture.sessionId);
863
+ database.close();
864
+ await writeFile(join(fixture.workspacePath, 'autopilot.org'), autopilotPlanText('DONE'));
865
+ await expect
866
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json().steps[0]
867
+ .children[0].state)
868
+ .toBe('DONE');
869
+ handle.notify({
870
+ method: 'turn/started',
871
+ params: { threadId, turn: { id: active.active_turn_id } },
872
+ });
873
+ handle.deferResponseSettlement = true;
874
+ const identity = createHash('sha256')
875
+ .update(join(fixture.workspacePath, 'autopilot.org'))
876
+ .digest('hex');
877
+ const checkpointResponse = handle.request(l2CheckpointCall(991, threadId, active.active_turn_id, identity));
878
+ const rejectedCheckpoint = expect(checkpointResponse).rejects.toThrow('CODEX_SERVER_REQUEST_CANCELLED');
879
+ await vi.waitFor(() => expect(handle.pendingResponses.has(991)).toBe(true));
880
+ const timersBeforeRecovery = timers.length;
881
+ await rejectedCheckpoint;
882
+ expect(fixture.handles
883
+ .flatMap((candidate) => candidate.requests)
884
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1);
885
+ await vi.waitFor(() => expect(fixture.handles.length).toBeGreaterThan(1), { timeout: 1_000 });
886
+ expect(fixture.handles
887
+ .flatMap((candidate) => candidate.requests)
888
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1);
889
+ await expect
890
+ .poll(() => {
891
+ const audit = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
892
+ const count = audit
893
+ .prepare("SELECT count(*) AS count FROM session_events WHERE type = 'org-plan.checkpoint-handoff-failed'")
894
+ .get();
895
+ audit.close();
896
+ return count.count;
897
+ })
898
+ .toBe(1);
899
+ await expect
900
+ .poll(() => {
901
+ const recoveredState = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
902
+ const lifecycle = recoveredState
903
+ .prepare('SELECT lifecycle_json FROM autopilot_sessions WHERE session_id = ?')
904
+ .get(fixture.sessionId);
905
+ recoveredState.close();
906
+ return JSON.parse(lifecycle.lifecycle_json).checkpoints;
907
+ })
908
+ .toMatchObject({ pendingTurnId: null, checkpointHandoffFailed: false });
909
+ await vi.waitFor(() => expect(timers.length).toBeGreaterThan(timersBeforeRecovery));
910
+ const recoveryTimer = timers.at(-1);
911
+ recoveryTimer.callback();
912
+ await vi.waitFor(() => expect(fixture.handles
913
+ .flatMap((candidate) => candidate.requests)
914
+ .filter((request) => request.method === 'turn/start')).toHaveLength(2));
915
+ await fixture.app.close();
916
+ });
917
+ it('leaves an unsupported checkpoint exception before persistence inert', async () => {
918
+ let coordinator;
919
+ const timers = [];
920
+ const fixture = await createProductionAutopilotFixture({
921
+ autopilotSchedule: (callback) => {
922
+ const timer = { callback, cancelled: false };
923
+ timers.push(timer);
924
+ return () => {
925
+ timer.cancelled = true;
926
+ };
927
+ },
928
+ onAutopilotCoordinator: (value) => {
929
+ coordinator = value;
930
+ },
931
+ });
932
+ const original = coordinator.checkpointAccepted.bind(coordinator);
933
+ vi.spyOn(coordinator, 'checkpointAccepted').mockImplementation(() => {
934
+ throw new Error('injected-before-checkpoint-persistence');
935
+ });
936
+ const { response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 995, false);
937
+ await expect(response).rejects.toThrow('CODEX_SERVER_REQUEST_UNSUPPORTED');
938
+ expect(coordinator.checkpointAccepted).not.toBe(original);
939
+ expect(fixture.handles).toHaveLength(writersBefore);
940
+ expect(fixture.handles
941
+ .flatMap((candidate) => candidate.requests)
942
+ .filter((request) => request.method === 'turn/start')).toHaveLength(startsBefore);
943
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
944
+ const events = database
945
+ .prepare("SELECT type FROM session_events WHERE type IN ('org-plan.step-checkpointed', 'org-plan.checkpoint-handoff-failed')")
946
+ .all();
947
+ const lifecycle = database
948
+ .prepare('SELECT lifecycle_json FROM autopilot_sessions WHERE session_id = ?')
949
+ .get(fixture.sessionId);
950
+ database.close();
951
+ expect(events).toEqual([]);
952
+ expect(JSON.parse(lifecycle.lifecycle_json).checkpoints).toBeUndefined();
953
+ expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}/history`)).json()
954
+ .interactions).not.toEqual(expect.arrayContaining([expect.objectContaining({ requestId: '995' })]));
955
+ await fixture.app.close();
956
+ });
957
+ it('turns a post-persistence checkpoint exception into one explicit failed handoff', async () => {
958
+ let coordinator;
959
+ const timers = [];
960
+ const fixture = await createProductionAutopilotFixture({
961
+ autopilotSchedule: (callback) => {
962
+ const timer = { callback, cancelled: false };
963
+ timers.push(timer);
964
+ return () => {
965
+ timer.cancelled = true;
966
+ };
967
+ },
968
+ onAutopilotCoordinator: (value) => {
969
+ coordinator = value;
970
+ },
971
+ });
972
+ const original = coordinator.checkpointAccepted.bind(coordinator);
973
+ vi.spyOn(coordinator, 'checkpointAccepted').mockImplementation((...input) => {
974
+ original(...input);
975
+ throw new Error('injected-after-checkpoint-persistence');
976
+ });
977
+ const { response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 996, false);
978
+ await expect(response).rejects.toThrow('CHECKPOINT_HANDOFF_FAILED');
979
+ await vi.waitFor(async () => {
980
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
981
+ const failures = database
982
+ .prepare("SELECT count(*) AS count FROM session_events WHERE type = 'org-plan.checkpoint-handoff-failed'")
983
+ .get();
984
+ database.close();
985
+ expect(failures.count).toBe(1);
986
+ });
987
+ const history = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/history`)).json();
988
+ expect(history.interactions).toEqual(expect.arrayContaining([
989
+ expect.objectContaining({
990
+ requestId: '996',
991
+ kind: 'orgPlanCheckpoint',
992
+ outcome: 'failed',
993
+ }),
994
+ ]));
995
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
996
+ const lifecycle = database
997
+ .prepare('SELECT lifecycle_json FROM autopilot_sessions WHERE session_id = ?')
998
+ .get(fixture.sessionId);
999
+ database.close();
1000
+ expect(JSON.parse(lifecycle.lifecycle_json).checkpoints).toMatchObject({
1001
+ pendingTurnId: expect.any(String),
1002
+ checkpointHandoffFailed: true,
1003
+ });
1004
+ expect(fixture.handles).toHaveLength(writersBefore);
1005
+ expect(fixture.handles
1006
+ .flatMap((candidate) => candidate.requests)
1007
+ .filter((request) => request.method === 'turn/start')).toHaveLength(startsBefore);
1008
+ await fixture.app.close();
1009
+ });
1010
+ it('restores a pending checkpoint handoff once from the same database', async () => {
1011
+ const timers = [];
1012
+ const fixture = await createProductionAutopilotFixture({
1013
+ autopilotSchedule: (callback) => {
1014
+ const timer = { callback, cancelled: false };
1015
+ timers.push(timer);
1016
+ return () => {
1017
+ timer.cancelled = true;
1018
+ };
1019
+ },
1020
+ });
1021
+ const { response } = await holdCheckpointRequest(fixture, timers, 997);
1022
+ await fixture.app.close();
1023
+ await expect(response).rejects.toThrow('CODEX_SERVER_REQUEST_CANCELLED');
1024
+ const rebuiltHandles = [];
1025
+ const rebuiltTimers = [];
1026
+ let rebuiltCoordinator;
1027
+ const rebuilt = await composeAuthorizedApp({
1028
+ root: fixture.root,
1029
+ dataDir: fixture.dataDir,
1030
+ relyingParty,
1031
+ installedCodexVersion: 'codex-cli 0.144.3',
1032
+ startAppServers: true,
1033
+ launchAppServer: liveAppServer(rebuiltHandles),
1034
+ autopilotSchedule: (callback) => {
1035
+ const timer = { callback, cancelled: false };
1036
+ rebuiltTimers.push(timer);
1037
+ return () => {
1038
+ timer.cancelled = true;
1039
+ };
1040
+ },
1041
+ onAutopilotCoordinator: (value) => {
1042
+ rebuiltCoordinator = value;
1043
+ },
1044
+ profiles: {
1045
+ list: async () => [],
1046
+ require: async () => ({
1047
+ name: 'default',
1048
+ state: 'ok',
1049
+ status: 'ready',
1050
+ }),
1051
+ },
1052
+ });
1053
+ await rebuilt.listen({ port: 0 });
1054
+ await vi.waitFor(() => expect(rebuiltCoordinator).toBeDefined());
1055
+ await vi.waitFor(() => expect(rebuiltTimers.some((timer) => !timer.cancelled)).toBe(true));
1056
+ rebuiltTimers.find((timer) => !timer.cancelled).callback();
1057
+ await vi.waitFor(() => expect(rebuiltHandles
1058
+ .flatMap((candidate) => candidate.requests)
1059
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1));
1060
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1061
+ const failures = database
1062
+ .prepare("SELECT count(*) AS count FROM session_events WHERE type = 'org-plan.checkpoint-handoff-failed'")
1063
+ .get();
1064
+ const interaction = database
1065
+ .prepare('SELECT outcome FROM pending_interactions WHERE session_id = ? AND request_id = ?')
1066
+ .get(fixture.sessionId, '997');
1067
+ database.close();
1068
+ expect(failures.count).toBe(1);
1069
+ expect(interaction.outcome).toBe('failed');
1070
+ expect(rebuiltCoordinator.snapshot(fixture.sessionId).health).toMatchObject({
1071
+ phase: expect.not.stringMatching('checkpointRecovery'),
1072
+ });
1073
+ await rebuilt.close();
1074
+ });
1075
+ it.each([
1076
+ 'resultWritten-before-timeout',
1077
+ 'timeout-before-late-resultWritten',
1078
+ 'writeFailed',
1079
+ 'duplicate-settlement',
1080
+ ])('settles checkpoint write/deadline races exactly once: %s', async (scenario) => {
1081
+ const timers = [];
1082
+ const fixture = await createProductionAutopilotFixture({
1083
+ checkpointHandoffDeadlineMs: 200,
1084
+ autopilotSchedule: (callback) => {
1085
+ const timer = { callback, cancelled: false };
1086
+ timers.push(timer);
1087
+ return () => {
1088
+ timer.cancelled = true;
1089
+ };
1090
+ },
1091
+ });
1092
+ const { handle, response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 992);
1093
+ const starts = () => fixture.handles
1094
+ .flatMap((candidate) => candidate.requests)
1095
+ .filter((request) => request.method === 'turn/start').length;
1096
+ const failures = () => {
1097
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1098
+ const count = database
1099
+ .prepare("SELECT count(*) AS count FROM session_events WHERE type = 'org-plan.checkpoint-handoff-failed'")
1100
+ .get();
1101
+ database.close();
1102
+ return count.count;
1103
+ };
1104
+ if (scenario === 'resultWritten-before-timeout') {
1105
+ handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1106
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse());
1107
+ await new Promise((resolve) => setTimeout(resolve, 30));
1108
+ expect(fixture.handles).toHaveLength(writersBefore);
1109
+ expect(failures()).toBe(0);
1110
+ }
1111
+ else if (scenario === 'duplicate-settlement') {
1112
+ handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1113
+ handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1114
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse());
1115
+ await new Promise((resolve) => setTimeout(resolve, 30));
1116
+ expect(fixture.handles).toHaveLength(writersBefore);
1117
+ expect(failures()).toBe(0);
1118
+ }
1119
+ else {
1120
+ if (scenario === 'writeFailed')
1121
+ handle.responseSettled({ id: 992, outcome: 'writeFailed' });
1122
+ const lateSettlement = handle.responseSettled;
1123
+ await expect(response).rejects.toThrow('CODEX_SERVER_REQUEST_CANCELLED');
1124
+ await vi.waitFor(() => expect(fixture.handles.length).toBeGreaterThan(writersBefore), {
1125
+ timeout: 1_000,
1126
+ });
1127
+ if (scenario === 'timeout-before-late-resultWritten')
1128
+ lateSettlement({ id: 992, outcome: 'resultWritten' });
1129
+ await new Promise((resolve) => setTimeout(resolve, 30));
1130
+ expect(failures()).toBe(1);
1131
+ }
1132
+ expect(starts()).toBe(startsBefore);
1133
+ await fixture.app.close();
1134
+ });
1135
+ it('does not apply checkpoint deadlines to quiz or approval requests', async () => {
1136
+ const checkpointFailures = (dataDir) => {
1137
+ const database = new DatabaseSync(join(dataDir, 'relay.sqlite'));
1138
+ const count = database
1139
+ .prepare("SELECT count(*) AS count FROM session_events WHERE type = 'org-plan.checkpoint-handoff-failed'")
1140
+ .get();
1141
+ database.close();
1142
+ return count.count;
1143
+ };
1144
+ const fixture = await createProductionAutopilotFixture({ checkpointHandoffDeadlineMs: 1 });
1145
+ const handle = fixture.handles.find((candidate) => candidate.request);
1146
+ const started = await fixture.app.inject({
1147
+ method: 'POST',
1148
+ url: `/api/sessions/${fixture.sessionId}/turns`,
1149
+ payload: { text: 'request user input' },
1150
+ });
1151
+ expect(started.statusCode).toBe(202);
1152
+ await vi.waitFor(() => expect(handle.request).toBeDefined());
1153
+ handle.notify({
1154
+ method: 'turn/started',
1155
+ params: {
1156
+ threadId: (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()
1157
+ .threadId,
1158
+ turn: { id: started.json().activeTurnId },
1159
+ },
1160
+ });
1161
+ handle.deferResponseSettlement = true;
1162
+ const quizWriters = fixture.handles.length;
1163
+ const quiz = handle.request({
1164
+ id: 993,
1165
+ method: 'item/tool/call',
1166
+ params: {
1167
+ tool: 'gestalt_quiz',
1168
+ arguments: {
1169
+ questions: [
1170
+ {
1171
+ id: 'mode',
1172
+ header: 'Mode',
1173
+ question: 'Choose a mode.',
1174
+ choices: [
1175
+ { label: 'Solo', description: 'Use one agent.' },
1176
+ { label: 'Team', description: 'Use several agents.' },
1177
+ ],
1178
+ allowCustom: false,
1179
+ },
1180
+ ],
1181
+ },
1182
+ },
1183
+ });
1184
+ await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}/history`)).json()
1185
+ .interactions).toEqual(expect.arrayContaining([
1186
+ expect.objectContaining({ requestId: '993', kind: 'quiz', resolvedAt: null }),
1187
+ ])));
1188
+ await new Promise((resolve) => setTimeout(resolve, 20));
1189
+ expect(fixture.handles).toHaveLength(quizWriters);
1190
+ expect(checkpointFailures(fixture.dataDir)).toBe(0);
1191
+ expect(handle.pendingResponses).toHaveLength(0);
1192
+ expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}/history`)).json()
1193
+ .interactions).toEqual(expect.arrayContaining([expect.objectContaining({ requestId: '993', resolvedAt: null })]));
1194
+ const quizSubmission = fixture.app.inject({
1195
+ method: 'POST',
1196
+ url: `/api/sessions/${fixture.sessionId}/interactions/993`,
1197
+ payload: {
1198
+ success: true,
1199
+ contentItems: [{ type: 'inputText', text: '{"answers":{"mode":"Solo"}}' }],
1200
+ },
1201
+ });
1202
+ await vi.waitFor(() => {
1203
+ expect(handle.pendingResponses.has(993)).toBe(true);
1204
+ });
1205
+ handle.responseSettled({ id: 993, outcome: 'resultWritten' });
1206
+ expect((await quizSubmission).statusCode).toBe(202);
1207
+ await expect(quiz).resolves.toEqual(expect.anything());
1208
+ expect(fixture.handles).toHaveLength(quizWriters);
1209
+ await fixture.app.close();
1210
+ const approvalFixture = await createProductionAutopilotFixture({
1211
+ checkpointHandoffDeadlineMs: 1,
1212
+ });
1213
+ const approvalHandle = approvalFixture.handles.find((candidate) => candidate.request);
1214
+ const approvalTurn = await approvalFixture.app.inject({
1215
+ method: 'POST',
1216
+ url: `/api/sessions/${approvalFixture.sessionId}/turns`,
1217
+ payload: { text: 'approve' },
1218
+ });
1219
+ await vi.waitFor(() => expect(approvalHandle.request).toBeDefined());
1220
+ approvalHandle.notify({
1221
+ method: 'turn/started',
1222
+ params: {
1223
+ threadId: (await approvalFixture.app.inject(`/api/sessions/${approvalFixture.sessionId}`)).json().threadId,
1224
+ turn: { id: approvalTurn.json().activeTurnId },
1225
+ },
1226
+ });
1227
+ approvalHandle.deferResponseSettlement = true;
1228
+ const approvalWriters = approvalFixture.handles.length;
1229
+ const approval = approvalHandle.request({
1230
+ id: 994,
1231
+ method: 'item/commandExecution/requestApproval',
1232
+ params: { command: 'git status' },
1233
+ });
1234
+ await vi.waitFor(async () => expect((await approvalFixture.app.inject(`/api/sessions/${approvalFixture.sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
1235
+ expect.objectContaining({
1236
+ requestId: '994',
1237
+ kind: 'commandApproval',
1238
+ resolvedAt: null,
1239
+ }),
1240
+ ])));
1241
+ await new Promise((resolve) => setTimeout(resolve, 20));
1242
+ expect(approvalFixture.handles).toHaveLength(approvalWriters);
1243
+ expect(checkpointFailures(approvalFixture.dataDir)).toBe(0);
1244
+ expect((await approvalFixture.app.inject(`/api/sessions/${approvalFixture.sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
1245
+ expect.objectContaining({ requestId: '994', kind: 'commandApproval', resolvedAt: null }),
1246
+ ]));
1247
+ const approvalSubmission = approvalFixture.app.inject({
1248
+ method: 'POST',
1249
+ url: `/api/sessions/${approvalFixture.sessionId}/interactions/994`,
1250
+ payload: { decision: 'accept' },
1251
+ });
1252
+ await vi.waitFor(() => expect(approvalHandle.pendingResponses.has(994)).toBe(true));
1253
+ approvalHandle.responseSettled({ id: 994, outcome: 'resultWritten' });
1254
+ expect((await approvalSubmission).statusCode).toBe(202);
1255
+ await expect(approval).resolves.toEqual(expect.anything());
1256
+ expect(approvalTurn.statusCode).toBe(202);
1257
+ await approvalFixture.app.close();
1258
+ });
1259
+ it('rejects a checkpoint delivered after its owning root final has closed', async () => {
1260
+ const timers = [];
1261
+ const fixture = await createProductionAutopilotFixture({
1262
+ autopilotSchedule: (callback) => {
1263
+ const timer = { callback, cancelled: false };
1264
+ timers.push(timer);
1265
+ return () => {
1266
+ timer.cancelled = true;
1267
+ };
1268
+ },
1269
+ });
1270
+ const first = timers[0];
1271
+ first.callback();
1272
+ await vi.waitFor(() => expect(fixture.handles
1273
+ .flatMap((handle) => handle.requests)
1274
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1));
1275
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1276
+ const active = database
1277
+ .prepare('SELECT active_turn_id FROM relay_sessions WHERE id = ?')
1278
+ .get(fixture.sessionId);
1279
+ database.close();
1280
+ const handle = fixture.handles.find((candidate) => candidate.notify && candidate.request);
1281
+ const threadId = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()
1282
+ .threadId;
1283
+ handle.notify({
1284
+ method: 'turn/completed',
1285
+ params: { threadId, turn: { id: active.active_turn_id } },
1286
+ });
1287
+ await expect
1288
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json().activeTurnId)
1289
+ .toBeNull();
1290
+ const timersAfterFinal = timers.length;
1291
+ const startsAfterFinal = fixture.handles
1292
+ .flatMap((candidate) => candidate.requests)
1293
+ .filter((request) => request.method === 'turn/start').length;
1294
+ const planIdentity = createHash('sha256')
1295
+ .update(join(fixture.workspacePath, 'autopilot.org'))
1296
+ .digest('hex');
1297
+ await expect(handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity))).rejects.toThrow('CODEX_SERVER_REQUEST_UNSUPPORTED');
1298
+ expect(timers).toHaveLength(timersAfterFinal);
1299
+ expect(fixture.handles
1300
+ .flatMap((candidate) => candidate.requests)
1301
+ .filter((request) => request.method === 'turn/start')).toHaveLength(startsAfterFinal);
1302
+ await fixture.app.close();
1303
+ });
635
1304
  it('production attention request requires explicit re-enable and a complete plan transitions to completed', async () => {
636
1305
  const fixture = await createProductionAutopilotFixture();
637
1306
  const handle = fixture.handles.find((candidate) => candidate.request);
@@ -867,6 +1536,211 @@ describe('production composition', () => {
867
1536
  expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((call) => call.method === 'turn/start'))[0]?.params).toMatchObject({ threadId: 'child-1' });
868
1537
  await fixture.app.close();
869
1538
  });
1539
+ it('production treats only a confirmed missing rollout as an executor rejection and starts one fenced replacement', async () => {
1540
+ const timers = [];
1541
+ const fixture = await createProductionAutopilotFixture({
1542
+ autopilotActivity: (sessionId) => {
1543
+ const observedAt = new Date().toISOString();
1544
+ return {
1545
+ sessionId,
1546
+ rootThreadId: 'thread-1',
1547
+ root: {
1548
+ state: 'idle',
1549
+ reason: 'turnCompleted',
1550
+ observedAt,
1551
+ lastActivityAt: observedAt,
1552
+ },
1553
+ subagents: [
1554
+ {
1555
+ id: 'child-1',
1556
+ threadId: 'child-1',
1557
+ taskPath: '/root/l1',
1558
+ canonicalTaskName: 'l1',
1559
+ canonicalPosition: 'L1',
1560
+ continuationGeneration: 1,
1561
+ outcome: 'partial',
1562
+ ownedProcesses: [],
1563
+ state: 'idle',
1564
+ reason: 'turnCompleted',
1565
+ observedAt,
1566
+ lastActivityAt: observedAt,
1567
+ },
1568
+ ],
1569
+ aggregateSubagents: 'idle',
1570
+ confidence: 'fresh',
1571
+ };
1572
+ },
1573
+ autopilotSchedule: (callback) => {
1574
+ const timer = { callback, cancelled: false, fired: false };
1575
+ timers.push(timer);
1576
+ return () => {
1577
+ timer.cancelled = true;
1578
+ };
1579
+ },
1580
+ });
1581
+ // Session setup may have used a retired writer; apply the failure seam to
1582
+ // every live writer that can own the executor continuation.
1583
+ for (const handle of fixture.handles)
1584
+ handle.respond = (method, params) => {
1585
+ if (method === 'turn/start' && params.threadId === 'child-1')
1586
+ throw new CodexJsonRpcError(-32600, 'no rollout found for thread id child-1');
1587
+ return undefined;
1588
+ };
1589
+ const fireNext = async () => {
1590
+ await vi.waitFor(() => expect(timers.some((candidate) => !candidate.cancelled && !candidate.fired)).toBe(true));
1591
+ const timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
1592
+ expect(timer).toBeDefined();
1593
+ timer.fired = true;
1594
+ timer.callback();
1595
+ await Promise.resolve();
1596
+ await Promise.resolve();
1597
+ };
1598
+ expect((await fixture.app.inject({
1599
+ method: 'PUT',
1600
+ url: `/api/sessions/${fixture.sessionId}/autopilot`,
1601
+ payload: { enabled: true },
1602
+ })).statusCode).toBe(200);
1603
+ // Three confirmed rejections consume the bounded executor retry budget;
1604
+ // the fourth scheduled control is the root-owned physical g2 handoff.
1605
+ await fireNext();
1606
+ await fireNext();
1607
+ await fireNext();
1608
+ await fireNext();
1609
+ await fireNext();
1610
+ await fireNext();
1611
+ await fireNext();
1612
+ await vi.waitFor(() => expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(4));
1613
+ const starts = fixture.handles.flatMap((candidate) => candidate.requests.filter((request) => request.method === 'turn/start'));
1614
+ expect(starts.slice(0, 3).map((request) => request.params)).toEqual(Array.from({ length: 3 }, () => expect.objectContaining({ threadId: 'child-1' })));
1615
+ // The root control may reacquire a new app-server writer after the
1616
+ // rejected child continuation; it must never target the dead child.
1617
+ expect((starts[3]?.params).threadId).not.toBe('child-1');
1618
+ expect((starts[3]?.params).input?.[0]?.text).toContain('Launch task_name l1_g2 for canonical L1');
1619
+ expect((starts[3]?.params).input?.[0]?.text).toContain('explicit model selected by the supervisor');
1620
+ // Capacity recovery is allowed only for the active root control. It
1621
+ // recycles that writer but must retain the one durable g2 handoff.
1622
+ const rootHandle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start' &&
1623
+ request.params.threadId !== 'child-1'));
1624
+ const active = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json();
1625
+ const writersBeforeRecovery = fixture.handles.length;
1626
+ await expect(rootHandle.request({
1627
+ id: 992,
1628
+ method: 'item/tool/call',
1629
+ params: {
1630
+ threadId: active.threadId,
1631
+ turnId: active.activeTurnId,
1632
+ tool: 'gestalt_agent_capacity_recovery',
1633
+ arguments: { version: 1, reason: 'agentThreadLimit' },
1634
+ },
1635
+ })).resolves.toMatchObject({ success: true });
1636
+ await expect.poll(() => fixture.handles.length).toBeGreaterThan(writersBeforeRecovery);
1637
+ expect(fixture.handles
1638
+ .flatMap((candidate) => candidate.requests)
1639
+ .filter((request) => request.method === 'turn/start')
1640
+ .map((request) => request.params.input?.[0]?.text)).not.toContain(expect.stringContaining('l1_g3'));
1641
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1642
+ const lifecycle = database
1643
+ .prepare('SELECT lifecycle_json FROM autopilot_sessions WHERE session_id = ?')
1644
+ .get(fixture.sessionId);
1645
+ const replacements = database
1646
+ .prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.executor-replacement-scheduled'")
1647
+ .get(fixture.sessionId);
1648
+ database.close();
1649
+ expect(JSON.parse(lifecycle.lifecycle_json)).toMatchObject({
1650
+ executor: {
1651
+ replacement: { taskName: 'l1_g2', canonicalTaskName: 'l1', generation: 2 },
1652
+ commands: expect.arrayContaining([
1653
+ expect.objectContaining({ status: 'failed', threadId: 'child-1' }),
1654
+ ]),
1655
+ },
1656
+ });
1657
+ expect(replacements.count).toBe(1);
1658
+ await fixture.app.close();
1659
+ });
1660
+ it('production leaves a transport-lost executor command issued and never replays it as a replacement', async () => {
1661
+ const timers = [];
1662
+ const fixture = await createProductionAutopilotFixture({
1663
+ autopilotActivity: (sessionId) => {
1664
+ const observedAt = new Date().toISOString();
1665
+ return {
1666
+ sessionId,
1667
+ rootThreadId: 'thread-1',
1668
+ root: {
1669
+ state: 'idle',
1670
+ reason: 'turnCompleted',
1671
+ observedAt,
1672
+ lastActivityAt: observedAt,
1673
+ },
1674
+ subagents: [
1675
+ {
1676
+ id: 'child-1',
1677
+ threadId: 'child-1',
1678
+ taskPath: '/root/l1',
1679
+ canonicalTaskName: 'l1',
1680
+ canonicalPosition: 'L1',
1681
+ continuationGeneration: 1,
1682
+ outcome: 'partial',
1683
+ ownedProcesses: [],
1684
+ state: 'idle',
1685
+ reason: 'turnCompleted',
1686
+ observedAt,
1687
+ lastActivityAt: observedAt,
1688
+ },
1689
+ ],
1690
+ aggregateSubagents: 'idle',
1691
+ confidence: 'fresh',
1692
+ };
1693
+ },
1694
+ autopilotSchedule: (callback) => {
1695
+ const timer = { callback, cancelled: false, fired: false };
1696
+ timers.push(timer);
1697
+ return () => {
1698
+ timer.cancelled = true;
1699
+ };
1700
+ },
1701
+ });
1702
+ for (const handle of fixture.handles)
1703
+ handle.respond = (method, params) => {
1704
+ if (method === 'turn/start' && params.threadId === 'child-1')
1705
+ throw new Error('transport dropped after request write');
1706
+ return undefined;
1707
+ };
1708
+ expect((await fixture.app.inject({
1709
+ method: 'PUT',
1710
+ url: `/api/sessions/${fixture.sessionId}/autopilot`,
1711
+ payload: { enabled: true },
1712
+ })).statusCode).toBe(200);
1713
+ const timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
1714
+ timer.fired = true;
1715
+ timer.callback();
1716
+ await vi.waitFor(() => expect(fixture.handles
1717
+ .flatMap((candidate) => candidate.requests)
1718
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1));
1719
+ // Drive the bounded reinspection once: the durable issued command blocks replay.
1720
+ const refresh = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
1721
+ refresh.fired = true;
1722
+ refresh.callback();
1723
+ await Promise.resolve();
1724
+ await Promise.resolve();
1725
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1726
+ const lifecycle = database
1727
+ .prepare('SELECT lifecycle_json FROM autopilot_sessions WHERE session_id = ?')
1728
+ .get(fixture.sessionId);
1729
+ const replacements = database
1730
+ .prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.executor-replacement-scheduled'")
1731
+ .get(fixture.sessionId);
1732
+ database.close();
1733
+ expect(JSON.parse(lifecycle.lifecycle_json)).toMatchObject({
1734
+ executor: {
1735
+ commands: [expect.objectContaining({ status: 'issued', threadId: 'child-1' })],
1736
+ },
1737
+ });
1738
+ expect(replacements.count).toBe(0);
1739
+ expect(fixture.handles
1740
+ .flatMap((candidate) => candidate.requests)
1741
+ .filter((request) => request.method === 'turn/start')).toHaveLength(1);
1742
+ await fixture.app.close();
1743
+ });
870
1744
  it('production blocked child wakes the idle supervisor for bounded recovery', async () => {
871
1745
  const timers = [];
872
1746
  const fixture = await createProductionAutopilotFixture({