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.
- package/dist/client/assets/{index-BkQMGFPJ.js → index-SZM7lpix.js} +23 -23
- package/dist/client/index.html +1 -1
- package/dist/server/server/composition.contracts.js +936 -3
- package/dist/server/server/composition.js +175 -11
- package/dist/server/server/features/autopilot/application/service.js +944 -102
- package/dist/server/server/features/autopilot/domain/autopilot-session.js +42 -36
- package/dist/server/server/features/autopilot/domain/supervised-lifecycle.js +196 -1
- package/dist/server/server/features/autopilot/domain/supervision-protocol.js +98 -0
- package/dist/server/server/platform/codex/json-rpc-client.js +19 -2
- package/dist/server/server/platform/codex/session-runtime.js +87 -10
- package/dist/server/shared/contracts/autopilot-recovery.js +7 -0
- package/dist/server/shared/contracts/autopilot-wait-lease.js +13 -3
- package/package.json +1 -1
|
@@ -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 =
|
|
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({
|
|
@@ -1368,6 +2242,65 @@ describe('production composition', () => {
|
|
|
1368
2242
|
});
|
|
1369
2243
|
});
|
|
1370
2244
|
describeCompositionConcern('lifecycle', () => {
|
|
2245
|
+
it('rejects a wait lease when automatic continuation is unavailable', async () => {
|
|
2246
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
2247
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
2248
|
+
ownTemporaryPaths(root, dataDir);
|
|
2249
|
+
await mkdir(join(root, 'workspace'));
|
|
2250
|
+
const handles = [];
|
|
2251
|
+
const app = await composeAuthorizedApp({
|
|
2252
|
+
root,
|
|
2253
|
+
dataDir,
|
|
2254
|
+
relyingParty,
|
|
2255
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
2256
|
+
startAppServers: true,
|
|
2257
|
+
launchAppServer: liveAppServer(handles),
|
|
2258
|
+
profiles: {
|
|
2259
|
+
list: async () => [],
|
|
2260
|
+
require: async () => ({
|
|
2261
|
+
name: 'default',
|
|
2262
|
+
state: 'ok',
|
|
2263
|
+
status: 'ready',
|
|
2264
|
+
}),
|
|
2265
|
+
},
|
|
2266
|
+
});
|
|
2267
|
+
const sessionId = await createComposedSession(app);
|
|
2268
|
+
const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
|
|
2269
|
+
const started = await app.inject({
|
|
2270
|
+
method: 'POST',
|
|
2271
|
+
url: `/api/sessions/${sessionId}/turns`,
|
|
2272
|
+
payload: { text: 'continue supervised work' },
|
|
2273
|
+
});
|
|
2274
|
+
const turnId = started.json().activeTurnId;
|
|
2275
|
+
const threadId = (await app.inject(`/api/sessions/${sessionId}`)).json().threadId;
|
|
2276
|
+
handle.notify?.({ method: 'turn/started', params: { threadId, turn: { id: turnId } } });
|
|
2277
|
+
await expect(handle.request({
|
|
2278
|
+
id: 89,
|
|
2279
|
+
method: 'item/tool/call',
|
|
2280
|
+
params: {
|
|
2281
|
+
threadId,
|
|
2282
|
+
turnId,
|
|
2283
|
+
tool: 'gestalt_autopilot_wait_lease',
|
|
2284
|
+
arguments: {
|
|
2285
|
+
version: 2,
|
|
2286
|
+
reportId: 'unavailable-report',
|
|
2287
|
+
leaseId: 'unavailable-lease',
|
|
2288
|
+
wakeConditions: ['executorChanged'],
|
|
2289
|
+
maxWaitMs: 60_000,
|
|
2290
|
+
},
|
|
2291
|
+
},
|
|
2292
|
+
})).resolves.toEqual({
|
|
2293
|
+
success: true,
|
|
2294
|
+
contentItems: [
|
|
2295
|
+
{
|
|
2296
|
+
type: 'inputText',
|
|
2297
|
+
text: '{"accepted":false,"reason":"automaticContinuationUnavailable","next":"continueSameTurn"}',
|
|
2298
|
+
},
|
|
2299
|
+
],
|
|
2300
|
+
});
|
|
2301
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json().activeTurnId).toBe(turnId);
|
|
2302
|
+
await app.close();
|
|
2303
|
+
});
|
|
1371
2304
|
it('acknowledges root-owned capacity recovery before recycling only its app-server', async () => {
|
|
1372
2305
|
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1373
2306
|
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|