gestalt-mobile 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-Db9p-xm2.css +1 -0
- package/dist/client/assets/{index-ufTniVfO.js → index-NHzJXg1w.js} +12 -12
- package/dist/client/index.html +2 -2
- package/dist/server/server/composition.contracts.js +106 -6
- package/dist/server/server/composition.js +36 -8
- package/dist/server/server/features/agent-activity/model.js +5 -0
- package/dist/server/server/features/agent-activity/registry.js +12 -1
- package/dist/server/server/features/autopilot/application/service.js +53 -1
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
- package/dist/server/server/features/sessions/model/relay-session.js +19 -0
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
- package/dist/server/server/features/sessions/register-routes.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
- package/dist/server/server/platform/auth/sqlite.js +54 -0
- package/dist/server/server/platform/codex/activity-facts.js +4 -0
- package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
- package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
- package/dist/server/server/platform/codex/session-runtime.js +76 -17
- package/dist/server/server/platform/persistence/migrate.js +5 -1
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
- package/package.json +1 -1
- package/dist/client/assets/index-D9-XA9jj.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
16
16
|
<link rel="icon" href="/icons/gestalt-mobile-192.png" />
|
|
17
17
|
<link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
|
|
18
18
|
<title>Gestalt Mobile</title>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-NHzJXg1w.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Db9p-xm2.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -565,6 +565,9 @@ describe('production composition', () => {
|
|
|
565
565
|
});
|
|
566
566
|
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
567
567
|
const timestamp = new Date().toISOString();
|
|
568
|
+
database
|
|
569
|
+
.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
|
|
570
|
+
.run(fixture.sessionId);
|
|
568
571
|
database
|
|
569
572
|
.prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-without-turn', NULL, ?)")
|
|
570
573
|
.run(fixture.sessionId, timestamp, timestamp);
|
|
@@ -595,6 +598,9 @@ describe('production composition', () => {
|
|
|
595
598
|
},
|
|
596
599
|
});
|
|
597
600
|
const acceptedDatabase = new DatabaseSync(join(accepted.dataDir, 'relay.sqlite'));
|
|
601
|
+
acceptedDatabase
|
|
602
|
+
.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
|
|
603
|
+
.run(accepted.sessionId);
|
|
598
604
|
acceptedDatabase
|
|
599
605
|
.prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-with-turn', NULL, ?)")
|
|
600
606
|
.run(accepted.sessionId, timestamp, timestamp);
|
|
@@ -715,20 +721,22 @@ describe('production composition', () => {
|
|
|
715
721
|
// absence is the durable proof that no queued control can be reopened.
|
|
716
722
|
expect(row).toBeUndefined();
|
|
717
723
|
}
|
|
724
|
+
else if (action === 'replace') {
|
|
725
|
+
await vi.waitFor(() => expect(staleTimer.cancelled).toBe(true));
|
|
726
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
727
|
+
autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
718
730
|
else {
|
|
719
731
|
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
720
732
|
autopilot: {
|
|
721
733
|
enabled: false,
|
|
722
734
|
state: 'disabled',
|
|
723
|
-
reason: action === '
|
|
724
|
-
? 'planReplaced'
|
|
725
|
-
: action === 'close'
|
|
726
|
-
? 'planRemoved'
|
|
727
|
-
: 'sessionEnded',
|
|
735
|
+
reason: action === 'close' ? 'planRemoved' : 'sessionEnded',
|
|
728
736
|
},
|
|
729
737
|
}));
|
|
730
738
|
}
|
|
731
|
-
expect(staleTimer.cancelled).toBe(true);
|
|
739
|
+
expect(staleTimer.cancelled, action).toBe(true);
|
|
732
740
|
staleTimer.callback();
|
|
733
741
|
await Promise.resolve();
|
|
734
742
|
expect(fixture.handles.flatMap((candidate) => candidate.calls)).not.toContain('turn/start');
|
|
@@ -804,6 +812,12 @@ describe('production composition', () => {
|
|
|
804
812
|
};
|
|
805
813
|
},
|
|
806
814
|
});
|
|
815
|
+
expect((await fixture.app.inject({
|
|
816
|
+
method: 'PUT',
|
|
817
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
818
|
+
payload: { enabled: false },
|
|
819
|
+
})).statusCode).toBe(200);
|
|
820
|
+
timers.length = 0;
|
|
807
821
|
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
808
822
|
handle.notify({
|
|
809
823
|
method: 'thread/started',
|
|
@@ -868,6 +882,12 @@ describe('production composition', () => {
|
|
|
868
882
|
};
|
|
869
883
|
},
|
|
870
884
|
});
|
|
885
|
+
expect((await fixture.app.inject({
|
|
886
|
+
method: 'PUT',
|
|
887
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
888
|
+
payload: { enabled: false },
|
|
889
|
+
})).statusCode).toBe(200);
|
|
890
|
+
timers.length = 0;
|
|
871
891
|
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
872
892
|
handle.notify({
|
|
873
893
|
method: 'thread/started',
|
|
@@ -1356,6 +1376,86 @@ describe('production composition', () => {
|
|
|
1356
1376
|
});
|
|
1357
1377
|
});
|
|
1358
1378
|
describeCompositionConcern('lifecycle', () => {
|
|
1379
|
+
it('forgets a session while activity reconciliation is pending without publishing late activity', async () => {
|
|
1380
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1381
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1382
|
+
ownTemporaryPaths(root, dataDir);
|
|
1383
|
+
await mkdir(join(root, 'workspace'));
|
|
1384
|
+
const writers = [];
|
|
1385
|
+
const unhandled = [];
|
|
1386
|
+
const onUnhandled = (reason) => unhandled.push(reason);
|
|
1387
|
+
process.on('unhandledRejection', onUnhandled);
|
|
1388
|
+
onTestFinished(() => {
|
|
1389
|
+
process.off('unhandledRejection', onUnhandled);
|
|
1390
|
+
});
|
|
1391
|
+
const app = await composeAuthorizedApp({
|
|
1392
|
+
root,
|
|
1393
|
+
dataDir,
|
|
1394
|
+
relyingParty,
|
|
1395
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1396
|
+
startAppServers: true,
|
|
1397
|
+
profiles: {
|
|
1398
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1399
|
+
require: async () => ({
|
|
1400
|
+
name: 'default',
|
|
1401
|
+
state: 'ok',
|
|
1402
|
+
status: 'ready',
|
|
1403
|
+
}),
|
|
1404
|
+
},
|
|
1405
|
+
launchAppServer: () => {
|
|
1406
|
+
const writer = {};
|
|
1407
|
+
return {
|
|
1408
|
+
rpc: {
|
|
1409
|
+
request: async (method, params) => {
|
|
1410
|
+
if (method === 'thread/start') {
|
|
1411
|
+
writers.push(writer);
|
|
1412
|
+
return { thread: { id: `thread-${writers.length}` } };
|
|
1413
|
+
}
|
|
1414
|
+
if (method === 'thread/read')
|
|
1415
|
+
return await new Promise((resolve, reject) => {
|
|
1416
|
+
writer.releaseRead = () => resolve({ thread: { turns: [] } });
|
|
1417
|
+
writer.rejectRead = () => reject(new Error('LATE_READ_FAILURE'));
|
|
1418
|
+
});
|
|
1419
|
+
if (method === 'thread/list')
|
|
1420
|
+
return { data: [] };
|
|
1421
|
+
if (method === 'model/list')
|
|
1422
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
1423
|
+
if (method === 'skills/list')
|
|
1424
|
+
return {
|
|
1425
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
1426
|
+
};
|
|
1427
|
+
return {};
|
|
1428
|
+
},
|
|
1429
|
+
onNotification: (listener) => {
|
|
1430
|
+
writer.notify = listener;
|
|
1431
|
+
return () => { };
|
|
1432
|
+
},
|
|
1433
|
+
onServerRequest: () => () => { },
|
|
1434
|
+
},
|
|
1435
|
+
close: () => { },
|
|
1436
|
+
onExit: () => () => { },
|
|
1437
|
+
};
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
const forgottenId = await createComposedSession(app);
|
|
1441
|
+
await vi.waitFor(() => expect(writers[0]?.releaseRead).toBeTypeOf('function'));
|
|
1442
|
+
expect((await app.inject({ method: 'DELETE', url: `/api/sessions/${forgottenId}` })).statusCode).toBe(204);
|
|
1443
|
+
writers[0].rejectRead();
|
|
1444
|
+
await Promise.resolve();
|
|
1445
|
+
await Promise.resolve();
|
|
1446
|
+
expect(unhandled).toEqual([]);
|
|
1447
|
+
expect((await app.inject('/api/bootstrap')).statusCode).toBe(200);
|
|
1448
|
+
const liveId = await createComposedSession(app);
|
|
1449
|
+
await vi.waitFor(() => expect(writers[1]?.notify).toBeTypeOf('function'));
|
|
1450
|
+
writers[1].notify({
|
|
1451
|
+
method: 'thread/started',
|
|
1452
|
+
params: { thread: { id: 'thread-2', status: { type: 'active' } } },
|
|
1453
|
+
});
|
|
1454
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${liveId}`)).json()).toMatchObject({
|
|
1455
|
+
agentActivity: { root: { state: 'working' } },
|
|
1456
|
+
}));
|
|
1457
|
+
await app.close();
|
|
1458
|
+
});
|
|
1359
1459
|
it('uses the relay root for detached history reads and Open never resumes', async () => {
|
|
1360
1460
|
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1361
1461
|
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
@@ -32,7 +32,7 @@ import { GitFetchCoordinator } from './platform/git/git-fetch-coordinator.js';
|
|
|
32
32
|
import { GitSummaryCache } from './platform/git/git-summary-cache.js';
|
|
33
33
|
import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
|
|
34
34
|
import { mapWithConcurrency } from './platform/runtime/concurrency.js';
|
|
35
|
-
import { RelaySession } from './features/sessions/model/relay-session.js';
|
|
35
|
+
import { RelaySession, } from './features/sessions/model/relay-session.js';
|
|
36
36
|
import { AgentActivityRegistry } from './features/agent-activity/registry.js';
|
|
37
37
|
import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
|
|
38
38
|
import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
|
|
@@ -130,6 +130,11 @@ export async function composeRelayApp(options) {
|
|
|
130
130
|
if (!session || !runtime)
|
|
131
131
|
return;
|
|
132
132
|
const history = await runtime.readHistory(session);
|
|
133
|
+
// Forget may commit while the detached history reader is still in
|
|
134
|
+
// flight. Revalidate ownership before handing its result to the
|
|
135
|
+
// registry, whose publisher intentionally keeps the journal strict.
|
|
136
|
+
if (!sessions.find(sessionId))
|
|
137
|
+
return;
|
|
133
138
|
const occurredAt = new Date().toISOString();
|
|
134
139
|
activity.observe({
|
|
135
140
|
sessionId,
|
|
@@ -138,7 +143,12 @@ export async function composeRelayApp(options) {
|
|
|
138
143
|
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
139
144
|
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
140
145
|
});
|
|
141
|
-
|
|
146
|
+
const children = await runtime.listDirectChildren(session);
|
|
147
|
+
// The writer read is also asynchronous; it cannot publish after the
|
|
148
|
+
// durable owner has gone away either.
|
|
149
|
+
if (!sessions.find(sessionId))
|
|
150
|
+
return;
|
|
151
|
+
activity.childrenReconciled(sessionId, occurredAt, children);
|
|
142
152
|
},
|
|
143
153
|
});
|
|
144
154
|
const autopilot = new AutopilotCoordinator({
|
|
@@ -244,6 +254,14 @@ export async function composeRelayApp(options) {
|
|
|
244
254
|
profiles: options.profiles,
|
|
245
255
|
launch: options.launchAppServer ?? launchCodexAppServer,
|
|
246
256
|
});
|
|
257
|
+
const recoverExecutionPolicy = async (session) => {
|
|
258
|
+
if (session.executionPolicy || !session.threadId)
|
|
259
|
+
return session;
|
|
260
|
+
const executionPolicy = await recentThreads.executionPolicy(session.threadId);
|
|
261
|
+
return executionPolicy
|
|
262
|
+
? { ...session, executionPolicy, updatedAt: new Date().toISOString() }
|
|
263
|
+
: session;
|
|
264
|
+
};
|
|
247
265
|
const protocol = protocolCompatibility(options.installedCodexVersion, generatedProtocolVersion);
|
|
248
266
|
const gitFetches = new GitFetchCoordinator(fetchUpstream);
|
|
249
267
|
const gitSummaries = new GitSummaryCache(inspectGit);
|
|
@@ -297,6 +315,8 @@ export async function composeRelayApp(options) {
|
|
|
297
315
|
}
|
|
298
316
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
299
317
|
}
|
|
318
|
+
if (update.kind === 'updated' && update.reason === 'supervision-start')
|
|
319
|
+
autopilot.supervisionStarted(sessionId);
|
|
300
320
|
autopilot.planStatusChanged(sessionId);
|
|
301
321
|
};
|
|
302
322
|
runtime = options.startAppServers
|
|
@@ -486,7 +506,13 @@ export async function composeRelayApp(options) {
|
|
|
486
506
|
profiles: options.profiles,
|
|
487
507
|
models,
|
|
488
508
|
sessions: {
|
|
489
|
-
list: () => sessions.list().map((session) =>
|
|
509
|
+
list: () => sessions.list().map((session) => {
|
|
510
|
+
const plan = supervisedPlans.find(session.id);
|
|
511
|
+
return {
|
|
512
|
+
...withPendingInteractions(session),
|
|
513
|
+
...(plan ? { plan } : {}),
|
|
514
|
+
};
|
|
515
|
+
}),
|
|
490
516
|
},
|
|
491
517
|
protocolCompatible: protocol.compatible,
|
|
492
518
|
},
|
|
@@ -496,16 +522,17 @@ export async function composeRelayApp(options) {
|
|
|
496
522
|
save: saveSession,
|
|
497
523
|
find: (id) => withPendingInteractions(sessions.find(id)),
|
|
498
524
|
list: () => sessions.list().map((session) => withPendingInteractions(session)),
|
|
525
|
+
plan: (id) => supervisedPlans.find(id),
|
|
499
526
|
workspaces,
|
|
500
527
|
profiles: options.profiles,
|
|
501
528
|
skillProfiles,
|
|
502
529
|
skillCatalog,
|
|
503
530
|
defaultSkillProfile: options.explicitSkillProfile,
|
|
504
531
|
activate: runtime
|
|
505
|
-
? async (session
|
|
532
|
+
? async (session) => {
|
|
506
533
|
const now = new Date().toISOString();
|
|
507
534
|
dismissPendingInteractions(session.id, now);
|
|
508
|
-
const started = await runtime.start(session, now
|
|
535
|
+
const started = await runtime.start(session, now);
|
|
509
536
|
return started;
|
|
510
537
|
}
|
|
511
538
|
: undefined,
|
|
@@ -516,9 +543,9 @@ export async function composeRelayApp(options) {
|
|
|
516
543
|
}
|
|
517
544
|
: undefined,
|
|
518
545
|
ensureWriter: runtime
|
|
519
|
-
? (session) => {
|
|
546
|
+
? async (session) => {
|
|
520
547
|
dismissPendingInteractions(session.id, new Date().toISOString());
|
|
521
|
-
return runtime.ensureWriter(session, new Date().toISOString());
|
|
548
|
+
return runtime.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
|
|
522
549
|
}
|
|
523
550
|
: undefined,
|
|
524
551
|
releaseWriter: runtime
|
|
@@ -556,7 +583,7 @@ export async function composeRelayApp(options) {
|
|
|
556
583
|
: undefined,
|
|
557
584
|
restore: runtime
|
|
558
585
|
? async (session) => {
|
|
559
|
-
const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
|
|
586
|
+
const restored = await runtime.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
|
|
560
587
|
// An exit can be reported while resume is resolving. Do not let the
|
|
561
588
|
// route persist a stale ready snapshot over that recovered exit.
|
|
562
589
|
if (!runtime.ownsWriter(session.id))
|
|
@@ -576,6 +603,7 @@ export async function composeRelayApp(options) {
|
|
|
576
603
|
list: () => sessions.list(),
|
|
577
604
|
save: saveSession,
|
|
578
605
|
read: (session) => runtime.readHistory(session),
|
|
606
|
+
executionPolicy: (candidate) => recentThreads.executionPolicy(candidate.id),
|
|
579
607
|
})
|
|
580
608
|
: undefined,
|
|
581
609
|
release: (session) => {
|
|
@@ -94,6 +94,11 @@ export function projectAgentActivity(current, fact) {
|
|
|
94
94
|
? { nickname: before.nickname }
|
|
95
95
|
: {}),
|
|
96
96
|
...(fact.childRole ? { role: fact.childRole } : before?.role ? { role: before.role } : {}),
|
|
97
|
+
...(fact.childModel
|
|
98
|
+
? { model: fact.childModel }
|
|
99
|
+
: before?.model
|
|
100
|
+
? { model: before.model }
|
|
101
|
+
: {}),
|
|
97
102
|
state: childState(fact.childStatus, fact.collaborationAction, before?.state),
|
|
98
103
|
reason: childReason(fact.childStatus, fact.collaborationAction),
|
|
99
104
|
observedAt: fact.occurredAt,
|
|
@@ -12,6 +12,7 @@ export class AgentActivityRegistry {
|
|
|
12
12
|
#timers = new Map();
|
|
13
13
|
#generation = new Map();
|
|
14
14
|
#inFlight = new Map();
|
|
15
|
+
#disposed = new Set();
|
|
15
16
|
constructor(publish, options = {}) {
|
|
16
17
|
this.publish = publish;
|
|
17
18
|
this.options = options;
|
|
@@ -20,6 +21,8 @@ export class AgentActivityRegistry {
|
|
|
20
21
|
return this.#snapshots.get(sessionId) ?? createAgentActivitySnapshot(sessionId, now);
|
|
21
22
|
}
|
|
22
23
|
observe(fact) {
|
|
24
|
+
if (this.#disposed.has(fact.sessionId))
|
|
25
|
+
return this.snapshot(fact.sessionId, fact.occurredAt);
|
|
23
26
|
const current = this.snapshot(fact.sessionId, fact.occurredAt);
|
|
24
27
|
const next = withActivityConfidence(projectAgentActivity(current, fact), fact.kind === 'collaboration' && !fact.childId ? 'stale' : 'fresh');
|
|
25
28
|
if (next !== current) {
|
|
@@ -34,6 +37,8 @@ export class AgentActivityRegistry {
|
|
|
34
37
|
return this.#qualify(sessionId, occurredAt, 'reconciling');
|
|
35
38
|
}
|
|
36
39
|
disconnected(sessionId, occurredAt) {
|
|
40
|
+
if (this.#disposed.has(sessionId))
|
|
41
|
+
return this.snapshot(sessionId, occurredAt);
|
|
37
42
|
this.suspend(sessionId);
|
|
38
43
|
const current = this.snapshot(sessionId, occurredAt);
|
|
39
44
|
const next = withActivityConfidence(clearAgentActivityChildren(projectAgentActivity(current, { sessionId, occurredAt, kind: 'processExited' })), 'stale');
|
|
@@ -55,7 +60,7 @@ export class AgentActivityRegistry {
|
|
|
55
60
|
return this.#qualify(sessionId, occurredAt, 'fresh');
|
|
56
61
|
}
|
|
57
62
|
refresh(sessionId) {
|
|
58
|
-
if (!this.options.reconcile)
|
|
63
|
+
if (this.#disposed.has(sessionId) || !this.options.reconcile)
|
|
59
64
|
return Promise.resolve();
|
|
60
65
|
const existing = this.#inFlight.get(sessionId);
|
|
61
66
|
if (existing)
|
|
@@ -71,6 +76,8 @@ export class AgentActivityRegistry {
|
|
|
71
76
|
}
|
|
72
77
|
/** Replaces the observed direct-child set; absent children are disconnected. */
|
|
73
78
|
childrenReconciled(sessionId, occurredAt, children) {
|
|
79
|
+
if (this.#disposed.has(sessionId))
|
|
80
|
+
return this.snapshot(sessionId, occurredAt);
|
|
74
81
|
let next = this.snapshot(sessionId, occurredAt);
|
|
75
82
|
const seen = new Set(children.map((child) => child.id));
|
|
76
83
|
for (const child of children)
|
|
@@ -83,6 +90,7 @@ export class AgentActivityRegistry {
|
|
|
83
90
|
...(child.status ? { childStatus: child.status } : {}),
|
|
84
91
|
...(child.nickname ? { childNickname: child.nickname } : {}),
|
|
85
92
|
...(child.role ? { childRole: child.role } : {}),
|
|
93
|
+
...(child.model ? { childModel: child.model } : {}),
|
|
86
94
|
});
|
|
87
95
|
for (const child of next.subagents)
|
|
88
96
|
if (!seen.has(child.id))
|
|
@@ -105,6 +113,7 @@ export class AgentActivityRegistry {
|
|
|
105
113
|
return next;
|
|
106
114
|
}
|
|
107
115
|
dispose(sessionId) {
|
|
116
|
+
this.#disposed.add(sessionId);
|
|
108
117
|
this.#snapshots.delete(sessionId);
|
|
109
118
|
this.#timers.get(sessionId)?.();
|
|
110
119
|
this.#timers.delete(sessionId);
|
|
@@ -142,6 +151,8 @@ export class AgentActivityRegistry {
|
|
|
142
151
|
}
|
|
143
152
|
}
|
|
144
153
|
#qualify(sessionId, occurredAt, confidence, fact) {
|
|
154
|
+
if (this.#disposed.has(sessionId))
|
|
155
|
+
return this.snapshot(sessionId, occurredAt);
|
|
145
156
|
const current = this.snapshot(sessionId, occurredAt);
|
|
146
157
|
const next = fact
|
|
147
158
|
? withActivityConfidence(projectAgentActivity(current, { sessionId, occurredAt, ...fact }), confidence)
|
|
@@ -110,13 +110,22 @@ export class AutopilotCoordinator {
|
|
|
110
110
|
disable(sessionId) {
|
|
111
111
|
const now = this.deps.now();
|
|
112
112
|
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
113
|
+
// Retain the current plan identity even when Autopilot has not previously
|
|
114
|
+
// been enabled. That makes an explicit Off a durable, plan-scoped choice
|
|
115
|
+
// instead of a generic disabled default that a later supervision signal
|
|
116
|
+
// could not distinguish from never having been configured.
|
|
117
|
+
const currentPlan = this.deps.plan(sessionId);
|
|
113
118
|
const cancelled = this.cancelScheduledControl(prior, now);
|
|
114
|
-
if (!prior.requestedEnabled &&
|
|
119
|
+
if (!prior.requestedEnabled &&
|
|
120
|
+
prior.state === 'disabled' &&
|
|
121
|
+
!cancelled &&
|
|
122
|
+
(!currentPlan || prior.planIdentity === currentPlan.identity))
|
|
115
123
|
return this.snapshot(sessionId);
|
|
116
124
|
const next = {
|
|
117
125
|
...prior,
|
|
118
126
|
state: 'disabled',
|
|
119
127
|
requestedEnabled: false,
|
|
128
|
+
...(currentPlan ? { planIdentity: currentPlan.identity } : {}),
|
|
120
129
|
generation: prior.generation + 1,
|
|
121
130
|
nextEvaluationAt: null,
|
|
122
131
|
...(cancelled ? { lastControlId: null } : {}),
|
|
@@ -275,6 +284,49 @@ export class AutopilotCoordinator {
|
|
|
275
284
|
if (executionComplete(plan.plan))
|
|
276
285
|
this.evaluate(sessionId);
|
|
277
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Records a validated, session-private supervision request. It intentionally
|
|
289
|
+
* does not discover plans: composition must first bind the update to this
|
|
290
|
+
* relay session. A manual Off is authoritative for the retained identity,
|
|
291
|
+
* while a new identity can receive its own explicit supervision request.
|
|
292
|
+
*/
|
|
293
|
+
supervisionStarted(sessionId) {
|
|
294
|
+
const currentPlan = this.deps.plan(sessionId);
|
|
295
|
+
if (!currentPlan)
|
|
296
|
+
return { code: 'AUTOPILOT_PLAN_REQUIRED' };
|
|
297
|
+
if (executionComplete(currentPlan.plan))
|
|
298
|
+
return { code: 'AUTOPILOT_PLAN_COMPLETE' };
|
|
299
|
+
const now = this.deps.now();
|
|
300
|
+
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
301
|
+
if (prior.planIdentity === currentPlan.identity &&
|
|
302
|
+
!prior.requestedEnabled &&
|
|
303
|
+
prior.stopReason === 'manualDisabled')
|
|
304
|
+
return this.snapshot(sessionId);
|
|
305
|
+
if (prior.requestedEnabled && prior.planIdentity === currentPlan.identity)
|
|
306
|
+
return this.snapshot(sessionId);
|
|
307
|
+
const replacing = Boolean(prior.planIdentity && prior.planIdentity !== currentPlan.identity);
|
|
308
|
+
const cancelled = replacing ? this.cancelScheduledControl(prior, now) : undefined;
|
|
309
|
+
if (replacing)
|
|
310
|
+
this.cancelTimer(sessionId);
|
|
311
|
+
const next = {
|
|
312
|
+
...prior,
|
|
313
|
+
state: 'monitoring',
|
|
314
|
+
requestedEnabled: true,
|
|
315
|
+
planIdentity: currentPlan.identity,
|
|
316
|
+
planFingerprint: fingerprint(currentPlan.plan),
|
|
317
|
+
generation: prior.generation + 1,
|
|
318
|
+
consecutiveNoProgress: 0,
|
|
319
|
+
nextEvaluationAt: null,
|
|
320
|
+
...(cancelled ? { lastControlId: null } : {}),
|
|
321
|
+
stopReason: null,
|
|
322
|
+
updatedAt: now,
|
|
323
|
+
};
|
|
324
|
+
this.persist(next, cancelled);
|
|
325
|
+
const session = this.deps.session(sessionId);
|
|
326
|
+
if (session?.threadId && ['ready', 'turnActive'].includes(session.state))
|
|
327
|
+
this.evaluate(sessionId);
|
|
328
|
+
return this.snapshot(sessionId);
|
|
329
|
+
}
|
|
278
330
|
/** Reacts only to fresh actor status; plan mutations are not scheduling signals. */
|
|
279
331
|
activityChanged(sessionId) {
|
|
280
332
|
const prior = this.deps.store.find(sessionId);
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { buildResumeCommand } from '../application/resume-command.js';
|
|
7
7
|
export function registerListSessions(app, deps) {
|
|
8
|
-
app.get('/api/sessions', async () => deps.list().map((session) =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
app.get('/api/sessions', async () => deps.list().map((session) => {
|
|
9
|
+
const plan = deps.plan?.(session.id) ?? null;
|
|
10
|
+
return {
|
|
11
|
+
...session,
|
|
12
|
+
...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
|
|
13
|
+
...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
|
|
14
|
+
...(plan ? { plan } : {}),
|
|
15
|
+
resumeCommand: session.threadId ? buildResumeCommand(session) : null,
|
|
16
|
+
};
|
|
17
|
+
}));
|
|
14
18
|
}
|
|
@@ -14,6 +14,21 @@ export function isSessionReadable(session) {
|
|
|
14
14
|
export function relayOwnsWriter(session) {
|
|
15
15
|
return session.state === 'ready' || session.state === 'turnActive';
|
|
16
16
|
}
|
|
17
|
+
const sandboxes = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
18
|
+
const approvalPolicies = new Set(['untrusted', 'on-request', 'never']);
|
|
19
|
+
export function createSessionExecutionPolicy(input) {
|
|
20
|
+
if (input.sandbox !== undefined &&
|
|
21
|
+
(typeof input.sandbox !== 'string' || !sandboxes.has(input.sandbox)))
|
|
22
|
+
throw new DomainError('SESSION_EXECUTION_POLICY_INVALID');
|
|
23
|
+
const approvalPolicy = input.approvalPolicy ?? 'on-request';
|
|
24
|
+
if (typeof approvalPolicy !== 'string' ||
|
|
25
|
+
!approvalPolicies.has(approvalPolicy))
|
|
26
|
+
throw new DomainError('SESSION_EXECUTION_POLICY_INVALID');
|
|
27
|
+
return {
|
|
28
|
+
...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }),
|
|
29
|
+
approvalPolicy: approvalPolicy,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
17
32
|
export function createEffectiveSkillSelection(input) {
|
|
18
33
|
return {
|
|
19
34
|
...(input.selectedProfileName === undefined
|
|
@@ -38,6 +53,7 @@ export class RelaySession {
|
|
|
38
53
|
...(input.model === undefined ? {} : { model: input.model }),
|
|
39
54
|
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
40
55
|
effectiveSkillSelection: createEffectiveSkillSelection(input.effectiveSkillSelection),
|
|
56
|
+
executionPolicy: createSessionExecutionPolicy(input),
|
|
41
57
|
threadId: null,
|
|
42
58
|
state: 'starting',
|
|
43
59
|
desiredState: 'active',
|
|
@@ -55,6 +71,9 @@ export class RelaySession {
|
|
|
55
71
|
workspaceId: workspaceId(input.workspaceId),
|
|
56
72
|
workspacePath: workspacePath(input.workspacePath),
|
|
57
73
|
profile: profileName(input.profile),
|
|
74
|
+
...(input.executionPolicy === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { executionPolicy: createSessionExecutionPolicy(input.executionPolicy) }),
|
|
58
77
|
threadId: threadId(input.threadId),
|
|
59
78
|
state: 'stopped',
|
|
60
79
|
desiredState: 'stopped',
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
-
import { RelaySession } from '../model/relay-session.js';
|
|
6
|
+
import { RelaySession, } from '../model/relay-session.js';
|
|
7
7
|
export class RecentThreadHistoryUnavailable extends Error {
|
|
8
8
|
constructor() {
|
|
9
9
|
super('RECENT_THREAD_HISTORY_UNAVAILABLE');
|
|
@@ -28,15 +28,24 @@ export async function promoteRecentThread(thread, deps) {
|
|
|
28
28
|
async function promote(thread, deps) {
|
|
29
29
|
const existing = deps.list().find((session) => session.threadId === thread.id);
|
|
30
30
|
if (existing) {
|
|
31
|
+
const executionPolicy = existing.executionPolicy
|
|
32
|
+
? undefined
|
|
33
|
+
: await deps.executionPolicy?.(thread);
|
|
34
|
+
const recovered = executionPolicy
|
|
35
|
+
? { ...existing, executionPolicy, updatedAt: deps.now() }
|
|
36
|
+
: existing;
|
|
31
37
|
try {
|
|
32
|
-
await deps.read(
|
|
38
|
+
await deps.read(recovered);
|
|
33
39
|
}
|
|
34
40
|
catch {
|
|
35
41
|
throw new RecentThreadHistoryUnavailable();
|
|
36
42
|
}
|
|
37
|
-
|
|
43
|
+
if (recovered !== existing)
|
|
44
|
+
deps.save(recovered);
|
|
45
|
+
return recovered;
|
|
38
46
|
}
|
|
39
47
|
const now = deps.now();
|
|
48
|
+
const executionPolicy = await deps.executionPolicy?.(thread);
|
|
40
49
|
const imported = existing ??
|
|
41
50
|
RelaySession.fromExistingThread({
|
|
42
51
|
id: deps.createId(),
|
|
@@ -44,6 +53,7 @@ async function promote(thread, deps) {
|
|
|
44
53
|
workspacePath: thread.cwd,
|
|
45
54
|
profile: thread.profile,
|
|
46
55
|
threadId: thread.id,
|
|
56
|
+
...(executionPolicy === undefined ? {} : { executionPolicy }),
|
|
47
57
|
now,
|
|
48
58
|
}).snapshot;
|
|
49
59
|
// Validate first: an unavailable thread must never leave an imported stub.
|
|
@@ -44,17 +44,15 @@ export async function startSession(input, deps) {
|
|
|
44
44
|
profile: input.profile,
|
|
45
45
|
model,
|
|
46
46
|
...(branch ? { branch } : {}),
|
|
47
|
+
sandbox: input.sandbox,
|
|
48
|
+
approvalPolicy: input.approvalPolicy,
|
|
47
49
|
effectiveSkillSelection,
|
|
48
50
|
now: deps.now(),
|
|
49
51
|
}).snapshot;
|
|
50
52
|
deps.save(session);
|
|
51
53
|
if (!deps.activate)
|
|
52
54
|
return session;
|
|
53
|
-
const active = await deps.activate(session, {
|
|
54
|
-
model,
|
|
55
|
-
sandbox: input.sandbox,
|
|
56
|
-
approvalPolicy: input.approvalPolicy,
|
|
57
|
-
});
|
|
55
|
+
const active = await deps.activate(session, {});
|
|
58
56
|
deps.save(active);
|
|
59
57
|
return active;
|
|
60
58
|
}
|