gestalt-mobile 0.30.3 → 0.31.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-DHqGG2Sf.css → index-DDwVphGb.css} +1 -1
- package/dist/client/assets/{index-BK3qsn4l.js → index-DKzUE8x1.js} +24 -23
- package/dist/client/index.html +2 -2
- package/dist/server/server/composition.contracts.js +62 -1
- package/dist/server/server/composition.js +30 -4
- package/dist/server/server/features/agent-activity/activity-dto.js +35 -0
- package/dist/server/server/features/agent-activity/model.js +21 -4
- package/dist/server/server/features/autopilot/application/service.js +172 -24
- package/dist/server/server/features/autopilot/domain/autopilot-session.js +100 -1
- package/dist/server/server/features/sessions/get-session/endpoint.js +23 -9
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +14 -2
- package/dist/server/server/features/sessions/register-routes.js +1 -1
- package/dist/server/server/features/sessions/session-status.js +55 -0
- package/dist/server/server/platform/codex/session-runtime.js +15 -4
- package/gestalt-supervision-capabilities.json +15 -0
- package/package.json +3 -2
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-DKzUE8x1.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DDwVphGb.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -690,7 +690,9 @@ describe('production composition', () => {
|
|
|
690
690
|
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
691
691
|
payload: { enabled: true },
|
|
692
692
|
})).statusCode).toBe(200);
|
|
693
|
-
|
|
693
|
+
// Initial supervision schedules its control and keeps a separate,
|
|
694
|
+
// bounded reconciliation watchdog while activity is still stale.
|
|
695
|
+
await vi.waitFor(() => expect(timers).toHaveLength(2));
|
|
694
696
|
const staleTimer = timers[0];
|
|
695
697
|
if (action === 'close') {
|
|
696
698
|
await writeFile(join(fixture.workspacePath, 'autopilot.org'), completedAutopilotPlanText());
|
|
@@ -1366,6 +1368,64 @@ describe('production composition', () => {
|
|
|
1366
1368
|
});
|
|
1367
1369
|
});
|
|
1368
1370
|
describeCompositionConcern('lifecycle', () => {
|
|
1371
|
+
it('publishes an idle websocket status after saving a completed turn', async () => {
|
|
1372
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1373
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1374
|
+
ownTemporaryPaths(root, dataDir);
|
|
1375
|
+
await mkdir(join(root, 'workspace'));
|
|
1376
|
+
const handles = [];
|
|
1377
|
+
const app = await composeAuthorizedApp({
|
|
1378
|
+
root,
|
|
1379
|
+
dataDir,
|
|
1380
|
+
relyingParty,
|
|
1381
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1382
|
+
startAppServers: true,
|
|
1383
|
+
launchAppServer: liveAppServer(handles),
|
|
1384
|
+
profiles: {
|
|
1385
|
+
list: async () => [],
|
|
1386
|
+
require: async () => ({
|
|
1387
|
+
name: 'default',
|
|
1388
|
+
state: 'ok',
|
|
1389
|
+
status: 'ready',
|
|
1390
|
+
}),
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
const sessionId = await createComposedSession(app);
|
|
1394
|
+
const handle = handles.find((candidate) => candidate.notify);
|
|
1395
|
+
expect(handle?.notify).toBeDefined();
|
|
1396
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
1397
|
+
const address = app.server.address();
|
|
1398
|
+
if (!address || typeof address === 'string')
|
|
1399
|
+
throw new Error('Expected TCP listener');
|
|
1400
|
+
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
1401
|
+
headers: {
|
|
1402
|
+
origin: relyingParty.publicOrigin,
|
|
1403
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1406
|
+
const messages = [];
|
|
1407
|
+
socket.on('message', (data) => messages.push(JSON.parse(String(data))));
|
|
1408
|
+
await once(socket, 'open');
|
|
1409
|
+
const started = await app.inject({
|
|
1410
|
+
method: 'POST',
|
|
1411
|
+
url: `/api/sessions/${sessionId}/turns`,
|
|
1412
|
+
payload: { text: 'complete this turn' },
|
|
1413
|
+
});
|
|
1414
|
+
expect(started.statusCode).toBe(202);
|
|
1415
|
+
const turnId = started.json().activeTurnId;
|
|
1416
|
+
handle.notify({ method: 'turn/completed', params: { turn: { id: turnId } } });
|
|
1417
|
+
await vi.waitFor(() => {
|
|
1418
|
+
const status = messages
|
|
1419
|
+
.filter((message) => message.event.type === 'session.status.updated')
|
|
1420
|
+
.at(-1)?.event.payload;
|
|
1421
|
+
expect(status).toMatchObject({ state: 'idle' });
|
|
1422
|
+
});
|
|
1423
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1424
|
+
activeTurnId: null,
|
|
1425
|
+
});
|
|
1426
|
+
socket.close();
|
|
1427
|
+
await app.close();
|
|
1428
|
+
});
|
|
1369
1429
|
it('forgets a session while activity reconciliation is pending without publishing late activity', async () => {
|
|
1370
1430
|
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1371
1431
|
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
@@ -2342,6 +2402,7 @@ describe('production composition', () => {
|
|
|
2342
2402
|
agentActivity: { root: { state: 'working' } },
|
|
2343
2403
|
});
|
|
2344
2404
|
expect(activityEvents.filter((message) => message.event.type === 'agent.activity.updated')).toHaveLength(initialActivityEvents + 1);
|
|
2405
|
+
expect(JSON.stringify(activityEvents)).not.toMatch(/ownerThreadId|ownerTaskPath|itemId|cpuPercent|rssBytes|resultArtifact/);
|
|
2345
2406
|
const activitySequence = activityEvents.find((message) => message.event.type === 'agent.activity.updated').event.sequence;
|
|
2346
2407
|
socket.close();
|
|
2347
2408
|
const replaySocket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=${activitySequence - 1}`, {
|
|
@@ -34,6 +34,7 @@ import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
|
|
|
34
34
|
import { mapWithConcurrency } from './platform/runtime/concurrency.js';
|
|
35
35
|
import { RelaySession, } from './features/sessions/model/relay-session.js';
|
|
36
36
|
import { AgentActivityRegistry } from './features/agent-activity/registry.js';
|
|
37
|
+
import { toAgentActivityDto } from './features/agent-activity/activity-dto.js';
|
|
37
38
|
import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
|
|
38
39
|
import { isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
|
|
39
40
|
import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
|
|
@@ -51,6 +52,7 @@ import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-com
|
|
|
51
52
|
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
52
53
|
import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
|
|
53
54
|
import { AutopilotCoordinator } from './features/autopilot/application/service.js';
|
|
55
|
+
import { deriveSessionStatus } from './features/sessions/session-status.js';
|
|
54
56
|
import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
|
|
55
57
|
import { createRelyingPartyConfig } from './config.js';
|
|
56
58
|
import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
|
|
@@ -95,6 +97,7 @@ export async function composeRelayApp(options) {
|
|
|
95
97
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
96
98
|
const events = new SessionEventBus();
|
|
97
99
|
let notifyAutopilotActivity = () => undefined;
|
|
100
|
+
let publishSessionStatus = () => undefined;
|
|
98
101
|
const attentionTransitions = {
|
|
99
102
|
subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
|
|
100
103
|
if (event.type !== 'org-plan.attention-required' &&
|
|
@@ -116,7 +119,8 @@ export async function composeRelayApp(options) {
|
|
|
116
119
|
};
|
|
117
120
|
options.onAttentionTransitions?.(attentionTransitions);
|
|
118
121
|
const activity = new AgentActivityRegistry((snapshot, occurredAt) => {
|
|
119
|
-
events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt));
|
|
122
|
+
events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', toAgentActivityDto(snapshot), occurredAt));
|
|
123
|
+
publishSessionStatus(snapshot.sessionId, occurredAt);
|
|
120
124
|
notifyAutopilotActivity(snapshot.sessionId);
|
|
121
125
|
}, {
|
|
122
126
|
// Evidence arms one bounded reconciliation; healthy sessions are never polled.
|
|
@@ -287,6 +291,19 @@ export async function composeRelayApp(options) {
|
|
|
287
291
|
},
|
|
288
292
|
});
|
|
289
293
|
notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
|
|
294
|
+
publishSessionStatus = (sessionId, occurredAt) => {
|
|
295
|
+
const session = sessions.find(sessionId);
|
|
296
|
+
if (!session)
|
|
297
|
+
return;
|
|
298
|
+
events.publish(journal.append(sessionId, 'session.status.updated', deriveSessionStatus({
|
|
299
|
+
session,
|
|
300
|
+
plan: supervisedPlans.find(sessionId),
|
|
301
|
+
activity: activity.snapshot(sessionId, occurredAt),
|
|
302
|
+
autopilot: autopilot.snapshot(sessionId),
|
|
303
|
+
pendingAttention: interactions.list(sessionId).length > 0,
|
|
304
|
+
observedAt: occurredAt,
|
|
305
|
+
}), occurredAt));
|
|
306
|
+
};
|
|
290
307
|
options.onAutopilotCoordinator?.(autopilot);
|
|
291
308
|
const workspaces = new FilesystemWorkspaceCatalog(root);
|
|
292
309
|
const workspaceFiles = new FilesystemWorkspaceFiles();
|
|
@@ -337,6 +354,7 @@ export async function composeRelayApp(options) {
|
|
|
337
354
|
if (interaction?.kind === 'orgPlanAttention')
|
|
338
355
|
events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
339
356
|
autopilot.semanticEvent(sessionId, 'interactionChanged');
|
|
357
|
+
publishSessionStatus(sessionId, occurredAt);
|
|
340
358
|
};
|
|
341
359
|
const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
|
|
342
360
|
const remaining = interactions.list(sessionId);
|
|
@@ -385,12 +403,12 @@ export async function composeRelayApp(options) {
|
|
|
385
403
|
if (update.kind === 'updated' && update.reason === 'supervision-start')
|
|
386
404
|
autopilot.supervisionStarted(sessionId);
|
|
387
405
|
autopilot.planStatusChanged(sessionId);
|
|
406
|
+
publishSessionStatus(sessionId, new Date().toISOString());
|
|
388
407
|
};
|
|
389
408
|
runtime = options.startAppServers
|
|
390
409
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification, origin) => {
|
|
391
410
|
const occurredAt = new Date().toISOString();
|
|
392
|
-
|
|
393
|
-
activity.observe(activityFact);
|
|
411
|
+
const activityFacts = decodeAgentActivityFacts(sessionId, occurredAt, notification);
|
|
394
412
|
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
395
413
|
if (resolvedRequestId) {
|
|
396
414
|
const interaction = interactions.find(sessionId, resolvedRequestId);
|
|
@@ -416,8 +434,11 @@ export async function composeRelayApp(options) {
|
|
|
416
434
|
? { ...origin, kind: 'root' }
|
|
417
435
|
: origin;
|
|
418
436
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId, resolvedOrigin);
|
|
419
|
-
if (!normalized)
|
|
437
|
+
if (!normalized) {
|
|
438
|
+
for (const activityFact of activityFacts)
|
|
439
|
+
activity.observe(activityFact);
|
|
420
440
|
return;
|
|
441
|
+
}
|
|
421
442
|
let completedSession;
|
|
422
443
|
if (normalized.type === 'turnCompleted') {
|
|
423
444
|
const turnId = normalized.payload.turn?.id;
|
|
@@ -429,6 +450,11 @@ export async function composeRelayApp(options) {
|
|
|
429
450
|
}
|
|
430
451
|
planMeasurementRefresh?.refreshNow(sessionId);
|
|
431
452
|
}
|
|
453
|
+
// Activity publication also derives and broadcasts the session verdict.
|
|
454
|
+
// Complete the durable turn first so its status cannot retain the just-finished
|
|
455
|
+
// activeTurnId while the completion fact makes the root appear idle.
|
|
456
|
+
for (const activityFact of activityFacts)
|
|
457
|
+
activity.observe(activityFact);
|
|
432
458
|
events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
|
|
433
459
|
if (completedSession)
|
|
434
460
|
events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
/** Public roster projection: never expose process, thread, task-path, or host metrics identifiers. */
|
|
7
|
+
export function toAgentActivityDto(snapshot) {
|
|
8
|
+
return {
|
|
9
|
+
sessionId: snapshot.sessionId,
|
|
10
|
+
root: snapshot.root,
|
|
11
|
+
subagents: snapshot.subagents.slice(0, 64).map((child) => ({
|
|
12
|
+
id: child.id,
|
|
13
|
+
...(child.nickname ? { nickname: child.nickname } : {}),
|
|
14
|
+
...(child.role ? { role: child.role } : {}),
|
|
15
|
+
...(child.model ? { model: child.model } : {}),
|
|
16
|
+
...(child.canonicalTaskName ? { canonicalTaskName: child.canonicalTaskName } : {}),
|
|
17
|
+
...(child.canonicalPosition ? { canonicalPosition: child.canonicalPosition } : {}),
|
|
18
|
+
...(child.continuationGeneration
|
|
19
|
+
? { continuationGeneration: child.continuationGeneration }
|
|
20
|
+
: {}),
|
|
21
|
+
...(child.outcome ? { outcome: child.outcome } : {}),
|
|
22
|
+
ownedProcesses: (child.ownedProcesses ?? []).slice(0, 64).map((process) => ({
|
|
23
|
+
state: process.state,
|
|
24
|
+
ownership: process.ownership,
|
|
25
|
+
observedAt: process.observedAt,
|
|
26
|
+
})),
|
|
27
|
+
state: child.state,
|
|
28
|
+
reason: child.reason,
|
|
29
|
+
observedAt: child.observedAt,
|
|
30
|
+
lastActivityAt: child.lastActivityAt,
|
|
31
|
+
})),
|
|
32
|
+
aggregateSubagents: snapshot.aggregateSubagents,
|
|
33
|
+
confidence: snapshot.confidence,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -84,6 +84,9 @@ export function projectAgentActivity(current, fact) {
|
|
|
84
84
|
const before = children.get(fact.childId);
|
|
85
85
|
const taskPath = fact.childTaskPath ?? before?.taskPath;
|
|
86
86
|
const identity = taskPath ? parseOrgPlanAgentIdentity(taskPath) : null;
|
|
87
|
+
const terminalReviewer = taskPath?.split('/').filter(Boolean).at(-1) === 'final_review' ||
|
|
88
|
+
before?.canonicalTaskName === 'final_review' ||
|
|
89
|
+
fact.childRole === 'org-plan-reviewer';
|
|
87
90
|
const outcome = childOutcome(fact.childStatus, fact.collaborationAction, before?.outcome);
|
|
88
91
|
const child = Object.freeze({
|
|
89
92
|
id: fact.childId,
|
|
@@ -110,7 +113,9 @@ export function projectAgentActivity(current, fact) {
|
|
|
110
113
|
canonicalPosition: identity.canonicalPosition,
|
|
111
114
|
continuationGeneration: identity.generation,
|
|
112
115
|
}
|
|
113
|
-
:
|
|
116
|
+
: terminalReviewer
|
|
117
|
+
? { canonicalTaskName: 'final_review' }
|
|
118
|
+
: {}),
|
|
114
119
|
...(outcome ? { outcome } : {}),
|
|
115
120
|
...(fact.childOwnedProcesses
|
|
116
121
|
? { ownedProcesses: Object.freeze([...fact.childOwnedProcesses]) }
|
|
@@ -143,14 +148,20 @@ export function projectAgentActivity(current, fact) {
|
|
|
143
148
|
export function withActivityConfidence(snapshot, confidence) {
|
|
144
149
|
return snapshot.confidence === confidence ? snapshot : Object.freeze({ ...snapshot, confidence });
|
|
145
150
|
}
|
|
146
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* A process disconnect makes the roster stale, but is not evidence that its
|
|
153
|
+
* children ceased to exist. Keep their authoritative identity and present the
|
|
154
|
+
* last known physical generation as disconnected until disposal or a later
|
|
155
|
+
* reconciliation replaces it.
|
|
156
|
+
*/
|
|
147
157
|
export function clearAgentActivityChildren(snapshot) {
|
|
148
158
|
if (snapshot.subagents.length === 0 && snapshot.aggregateSubagents === 'idle')
|
|
149
159
|
return snapshot;
|
|
160
|
+
const subagents = Object.freeze(snapshot.subagents.map((child) => Object.freeze({ ...child, state: 'disconnected', reason: 'processExited' })));
|
|
150
161
|
return Object.freeze({
|
|
151
162
|
...snapshot,
|
|
152
|
-
subagents
|
|
153
|
-
aggregateSubagents:
|
|
163
|
+
subagents,
|
|
164
|
+
aggregateSubagents: aggregate(subagents),
|
|
154
165
|
});
|
|
155
166
|
}
|
|
156
167
|
function applyStatus(root, status) {
|
|
@@ -231,6 +242,12 @@ function semantic(snapshot) {
|
|
|
231
242
|
nickname: child.nickname,
|
|
232
243
|
role: child.role,
|
|
233
244
|
model: child.model,
|
|
245
|
+
taskPath: child.taskPath,
|
|
246
|
+
canonicalTaskName: child.canonicalTaskName,
|
|
247
|
+
canonicalPosition: child.canonicalPosition,
|
|
248
|
+
continuationGeneration: child.continuationGeneration,
|
|
249
|
+
outcome: child.outcome,
|
|
250
|
+
ownedProcesses: child.ownedProcesses,
|
|
234
251
|
state: child.state,
|
|
235
252
|
reason: child.reason,
|
|
236
253
|
})),
|
|
@@ -3,6 +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 { deriveSessionStatus } from '../../sessions/session-status.js';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
|
|
8
9
|
import { classifyExecutorOutcome, decideSupervisedLifecycle, executorIdentity, validStructuredBlock, } from '../domain/supervised-lifecycle.js';
|
|
@@ -16,13 +17,21 @@ export class AutopilotCoordinator {
|
|
|
16
17
|
publishedSnapshots = new Map();
|
|
17
18
|
planEventKeys = new Map();
|
|
18
19
|
activityEventKeys = new Map();
|
|
20
|
+
/** Durable wait data is not proof that this process has a live subscription. */
|
|
21
|
+
parkedSubscriptions = new Map();
|
|
22
|
+
/** One reconciliation owns a session until it has published its next wake. */
|
|
23
|
+
reconciling = new Set();
|
|
19
24
|
/** Serializes asynchronous watchdog and timer work per relay session. */
|
|
20
25
|
operations = new Map();
|
|
21
26
|
constructor(deps) {
|
|
22
27
|
this.deps = deps;
|
|
23
28
|
}
|
|
24
29
|
snapshot(sessionId) {
|
|
25
|
-
|
|
30
|
+
const state = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, this.deps.now());
|
|
31
|
+
const control = state.lastControlId
|
|
32
|
+
? this.deps.store.findControl(sessionId, state.lastControlId)
|
|
33
|
+
: null;
|
|
34
|
+
return this.snapshotFor(state, control ?? undefined);
|
|
26
35
|
}
|
|
27
36
|
controlIds(sessionId) {
|
|
28
37
|
return this.deps.store.controlIds(sessionId);
|
|
@@ -45,8 +54,31 @@ export class AutopilotCoordinator {
|
|
|
45
54
|
// coordinator. The plan-status watcher is authoritative and asynchronous,
|
|
46
55
|
// so retaining enabled durable state until it supplies the projection is
|
|
47
56
|
// safer than interpreting this short bootstrap gap as plan removal.
|
|
48
|
-
|
|
57
|
+
const retained = this.deps.plan(sessionId);
|
|
58
|
+
if (!retained)
|
|
49
59
|
return;
|
|
60
|
+
if (state.supervision?.outcome === 'parked') {
|
|
61
|
+
if (retained.identity !== state.planIdentity) {
|
|
62
|
+
this.parkedSubscriptions.delete(sessionId);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const validated = autopilotSnapshot(state, this.deps.policy.retryLimit, {
|
|
66
|
+
activeTurn: Boolean(session.activeTurnId),
|
|
67
|
+
executorActive: false,
|
|
68
|
+
control: 'none',
|
|
69
|
+
timerArmed: false,
|
|
70
|
+
reconciling: false,
|
|
71
|
+
planMatches: true,
|
|
72
|
+
parkedSubscriptionActive: true,
|
|
73
|
+
transitionFresh: true,
|
|
74
|
+
observedAt: this.deps.now(),
|
|
75
|
+
});
|
|
76
|
+
if (state.supervision.waitLease && validated.health.wait.wakeCategories.length > 0) {
|
|
77
|
+
this.parkedSubscriptions.set(sessionId, state.supervision.waitLease.id);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.parkedSubscriptions.delete(sessionId);
|
|
81
|
+
}
|
|
50
82
|
const control = state.lastControlId
|
|
51
83
|
? this.deps.store.findControl(sessionId, state.lastControlId)
|
|
52
84
|
: null;
|
|
@@ -83,6 +115,8 @@ export class AutopilotCoordinator {
|
|
|
83
115
|
this.cancelTimer(sessionId);
|
|
84
116
|
this.planEventKeys.delete(sessionId);
|
|
85
117
|
this.activityEventKeys.delete(sessionId);
|
|
118
|
+
this.reconciling.delete(sessionId);
|
|
119
|
+
this.parkedSubscriptions.delete(sessionId);
|
|
86
120
|
}
|
|
87
121
|
/** Accepts a session-owned structured probe response; no transcript text is inspected. */
|
|
88
122
|
reportProbe(sessionId, report) {
|
|
@@ -92,6 +126,10 @@ export class AutopilotCoordinator {
|
|
|
92
126
|
const nextProtocol = reportProbe(state.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), report);
|
|
93
127
|
if (nextProtocol === state.supervision)
|
|
94
128
|
return false;
|
|
129
|
+
if (nextProtocol.outcome === 'parked' && nextProtocol.waitLease)
|
|
130
|
+
this.parkedSubscriptions.set(sessionId, nextProtocol.waitLease.id);
|
|
131
|
+
else
|
|
132
|
+
this.parkedSubscriptions.delete(sessionId);
|
|
95
133
|
const now = this.deps.now();
|
|
96
134
|
this.persist({
|
|
97
135
|
...state,
|
|
@@ -149,6 +187,7 @@ export class AutopilotCoordinator {
|
|
|
149
187
|
const nextProtocol = consumeObservableWake(state.supervision, wake);
|
|
150
188
|
if (nextProtocol === state.supervision)
|
|
151
189
|
return false;
|
|
190
|
+
this.parkedSubscriptions.delete(sessionId);
|
|
152
191
|
this.persist({ ...state, supervision: nextProtocol, updatedAt: this.deps.now() });
|
|
153
192
|
this.evaluate(sessionId);
|
|
154
193
|
return true;
|
|
@@ -176,6 +215,7 @@ export class AutopilotCoordinator {
|
|
|
176
215
|
const now = this.deps.now();
|
|
177
216
|
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
178
217
|
const nextFingerprint = fingerprint(currentPlan.plan);
|
|
218
|
+
const replacing = Boolean(prior.planIdentity && prior.planIdentity !== currentPlan.identity);
|
|
179
219
|
if (prior.requestedEnabled &&
|
|
180
220
|
prior.planIdentity === currentPlan.identity &&
|
|
181
221
|
prior.state !== 'attentionRequired')
|
|
@@ -192,7 +232,12 @@ export class AutopilotCoordinator {
|
|
|
192
232
|
stopReason: null,
|
|
193
233
|
executor: undefined,
|
|
194
234
|
blocking: undefined,
|
|
195
|
-
|
|
235
|
+
// A replacement plan cannot inherit a parked lease, retry key, or probe
|
|
236
|
+
// budget from a different semantic plan identity. Re-enabling the same
|
|
237
|
+
// plan still preserves its non-terminal protocol state.
|
|
238
|
+
supervision: replacing
|
|
239
|
+
? startSupervisionProtocol(this.progressKey(sessionId))
|
|
240
|
+
: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
|
|
196
241
|
updatedAt: now,
|
|
197
242
|
};
|
|
198
243
|
this.persist(next);
|
|
@@ -263,7 +308,17 @@ export class AutopilotCoordinator {
|
|
|
263
308
|
break;
|
|
264
309
|
case 'reconcile':
|
|
265
310
|
next = { ...prior, state: 'monitoring', nextEvaluationAt: null, updatedAt: now };
|
|
266
|
-
|
|
311
|
+
if (!this.reconciling.has(sessionId)) {
|
|
312
|
+
this.reconciling.add(sessionId);
|
|
313
|
+
this.enqueue(sessionId, async () => {
|
|
314
|
+
try {
|
|
315
|
+
await this.reconcile(sessionId, prior.generation);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
this.reconciling.delete(sessionId);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
267
322
|
break;
|
|
268
323
|
case 'scheduleContinuation':
|
|
269
324
|
if (session?.activeTurnId || this.deps.pendingInteraction(sessionId))
|
|
@@ -308,6 +363,9 @@ export class AutopilotCoordinator {
|
|
|
308
363
|
failureCode: null,
|
|
309
364
|
turnId: null,
|
|
310
365
|
};
|
|
366
|
+
// The durable event must describe an actually armed future wake, not
|
|
367
|
+
// merely a control row that is about to receive one.
|
|
368
|
+
this.arm(sessionId, prior.generation, decision.at);
|
|
311
369
|
this.persist(next, control, [
|
|
312
370
|
{
|
|
313
371
|
sessionId,
|
|
@@ -317,7 +375,6 @@ export class AutopilotCoordinator {
|
|
|
317
375
|
},
|
|
318
376
|
]);
|
|
319
377
|
next = prior;
|
|
320
|
-
this.arm(sessionId, prior.generation, decision.at);
|
|
321
378
|
break;
|
|
322
379
|
case 'requestAttention': {
|
|
323
380
|
const blocking = this.deps.attention?.(sessionId) ?? undefined;
|
|
@@ -475,7 +532,11 @@ export class AutopilotCoordinator {
|
|
|
475
532
|
occurredAt,
|
|
476
533
|
},
|
|
477
534
|
]);
|
|
478
|
-
|
|
535
|
+
// A lease only optimizes a probe that deliberately yielded. Checkpoints are
|
|
536
|
+
// authoritative progress events in their own right, so an incomplete plan
|
|
537
|
+
// must be evaluated even when no probe (and therefore no lease) exists.
|
|
538
|
+
if (!this.semanticEvent(sessionId, 'checkpointChanged'))
|
|
539
|
+
this.evaluate(sessionId);
|
|
479
540
|
return true;
|
|
480
541
|
}
|
|
481
542
|
/** Handles only plan lifecycle safety; ordinary plan mutations are ignored. */
|
|
@@ -495,13 +556,15 @@ export class AutopilotCoordinator {
|
|
|
495
556
|
if (this.semanticEvent(sessionId, 'planChanged') ||
|
|
496
557
|
this.semanticEvent(sessionId, 'reviewChanged'))
|
|
497
558
|
return;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
559
|
+
// Plan and review changes are mandatory lifecycle inputs, not merely a
|
|
560
|
+
// completion detector. A parked lease consumes the event above; without
|
|
561
|
+
// one, directly evaluate so an accepted L1 or a partial transition cannot
|
|
562
|
+
// strand the root waiting for unrelated activity.
|
|
563
|
+
const eventKey = `plan:${fingerprint(plan.plan)}`;
|
|
564
|
+
if (this.planEventKeys.get(sessionId) === eventKey)
|
|
565
|
+
return;
|
|
566
|
+
this.planEventKeys.set(sessionId, eventKey);
|
|
567
|
+
this.evaluate(sessionId);
|
|
505
568
|
}
|
|
506
569
|
/**
|
|
507
570
|
* Records a validated, session-private supervision request. It intentionally
|
|
@@ -540,6 +603,9 @@ export class AutopilotCoordinator {
|
|
|
540
603
|
stopReason: null,
|
|
541
604
|
executor: undefined,
|
|
542
605
|
blocking: undefined,
|
|
606
|
+
supervision: replacing
|
|
607
|
+
? startSupervisionProtocol(this.progressKey(sessionId))
|
|
608
|
+
: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
|
|
543
609
|
updatedAt: now,
|
|
544
610
|
};
|
|
545
611
|
this.persist(next, cancelled);
|
|
@@ -554,8 +620,12 @@ export class AutopilotCoordinator {
|
|
|
554
620
|
if (!prior?.requestedEnabled)
|
|
555
621
|
return;
|
|
556
622
|
const activity = this.deps.activity(sessionId);
|
|
557
|
-
|
|
623
|
+
// A stale or absent activity projection is itself a mandatory wake input.
|
|
624
|
+
// Do not require a probe lease to restore the authoritative topology.
|
|
625
|
+
if (!activity || activity.confidence !== 'fresh') {
|
|
626
|
+
this.evaluate(sessionId);
|
|
558
627
|
return;
|
|
628
|
+
}
|
|
559
629
|
const eventKey = JSON.stringify({
|
|
560
630
|
root: [activity.root.state, activity.root.reason],
|
|
561
631
|
children: activity.subagents.map((child) => [
|
|
@@ -580,6 +650,10 @@ export class AutopilotCoordinator {
|
|
|
580
650
|
this.evaluate(sessionId);
|
|
581
651
|
return;
|
|
582
652
|
}
|
|
653
|
+
if (disposition === 'reconcile') {
|
|
654
|
+
this.evaluate(sessionId);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
583
657
|
if (disposition === 'active') {
|
|
584
658
|
this.cancelTimer(sessionId);
|
|
585
659
|
const now = this.deps.now();
|
|
@@ -651,8 +725,12 @@ export class AutopilotCoordinator {
|
|
|
651
725
|
consecutiveNoProgress: prior.consecutiveNoProgress + 1,
|
|
652
726
|
updatedAt: now,
|
|
653
727
|
};
|
|
728
|
+
const priorControl = this.deps.store.findControl(sessionId, controlId);
|
|
729
|
+
if (!priorControl || priorControl.status !== 'scheduled')
|
|
730
|
+
return false;
|
|
731
|
+
const issuedControl = { ...priorControl, status: 'issued', updatedAt: now };
|
|
654
732
|
const events = [
|
|
655
|
-
...this.snapshotEvents(next),
|
|
733
|
+
...this.snapshotEvents(next, issuedControl),
|
|
656
734
|
{ sessionId, type: 'autopilot.control-issued', payload: { controlId }, occurredAt: now },
|
|
657
735
|
];
|
|
658
736
|
const control = this.deps.store.claimControlIssued
|
|
@@ -660,19 +738,19 @@ export class AutopilotCoordinator {
|
|
|
660
738
|
: this.legacyClaim(sessionId, controlId, next, events);
|
|
661
739
|
if (!control)
|
|
662
740
|
return false;
|
|
663
|
-
this.publishedSnapshots.set(sessionId, this.semanticSnapshot(next));
|
|
741
|
+
this.publishedSnapshots.set(sessionId, this.semanticSnapshot(next, control));
|
|
664
742
|
this.flushOutbox(sessionId);
|
|
665
743
|
return true;
|
|
666
744
|
}
|
|
667
745
|
persist(next, control, events = []) {
|
|
668
|
-
const snapshotEvents = this.snapshotEvents(next);
|
|
746
|
+
const snapshotEvents = this.snapshotEvents(next, control);
|
|
669
747
|
this.commit({
|
|
670
748
|
state: next,
|
|
671
749
|
...(control ? { control } : {}),
|
|
672
750
|
events: [...snapshotEvents, ...events],
|
|
673
751
|
});
|
|
674
752
|
if (snapshotEvents.length)
|
|
675
|
-
this.publishedSnapshots.set(next.sessionId, this.semanticSnapshot(next));
|
|
753
|
+
this.publishedSnapshots.set(next.sessionId, this.semanticSnapshot(next, control));
|
|
676
754
|
this.flushOutbox(next.sessionId);
|
|
677
755
|
}
|
|
678
756
|
arm(sessionId, generation, at) {
|
|
@@ -1081,23 +1159,86 @@ export class AutopilotCoordinator {
|
|
|
1081
1159
|
});
|
|
1082
1160
|
this.flushOutbox(sessionId);
|
|
1083
1161
|
}
|
|
1084
|
-
|
|
1162
|
+
authoritativeExecutorActive(next, plan, activity) {
|
|
1163
|
+
if (!plan || activity?.confidence !== 'fresh')
|
|
1164
|
+
return false;
|
|
1165
|
+
const selected = plan.steps.findIndex((step) => step.id === plan.currentStepId || step.state === 'WIP');
|
|
1166
|
+
const index = selected >= 0 ? selected : plan.steps.findIndex((step) => step.state !== 'DONE');
|
|
1167
|
+
if (index < 0)
|
|
1168
|
+
return false;
|
|
1169
|
+
const position = `L${index + 1}`;
|
|
1170
|
+
const taskName = `l${index + 1}`;
|
|
1171
|
+
const child = activity.subagents
|
|
1172
|
+
.filter((candidate) => candidate.canonicalPosition === position &&
|
|
1173
|
+
candidate.canonicalTaskName === taskName &&
|
|
1174
|
+
(!next.executor ||
|
|
1175
|
+
((candidate.threadId ?? candidate.id) === next.executor.threadId &&
|
|
1176
|
+
candidate.taskPath === next.executor.taskPath)))
|
|
1177
|
+
.sort((left, right) => (right.continuationGeneration ?? 1) - (left.continuationGeneration ?? 1))[0];
|
|
1178
|
+
return Boolean(child &&
|
|
1179
|
+
(child.state === 'working' ||
|
|
1180
|
+
child.state === 'awaitingAgent' ||
|
|
1181
|
+
child.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active')));
|
|
1182
|
+
}
|
|
1183
|
+
/** Builds both GET and pre-commit event payloads from the same prospective facts. */
|
|
1184
|
+
snapshotFor(next, prospectiveControl) {
|
|
1185
|
+
const session = this.deps.session(next.sessionId);
|
|
1186
|
+
const plan = this.deps.plan(next.sessionId);
|
|
1187
|
+
const activity = this.deps.activity(next.sessionId);
|
|
1188
|
+
const now = this.deps.now();
|
|
1189
|
+
const control = prospectiveControl ??
|
|
1190
|
+
(next.lastControlId ? this.deps.store.findControl(next.sessionId, next.lastControlId) : null);
|
|
1191
|
+
return autopilotSnapshot(next, this.deps.policy.retryLimit, {
|
|
1192
|
+
activeTurn: Boolean(session?.activeTurnId),
|
|
1193
|
+
executorActive: this.authoritativeExecutorActive(next, plan?.plan ?? null, activity),
|
|
1194
|
+
control: control?.status ?? 'none',
|
|
1195
|
+
timerArmed: this.timers.has(next.sessionId),
|
|
1196
|
+
reconciling: this.reconciling.has(next.sessionId),
|
|
1197
|
+
planMatches: Boolean(plan && plan.identity === next.planIdentity),
|
|
1198
|
+
parkedSubscriptionActive: Boolean(next.supervision?.waitLease) &&
|
|
1199
|
+
this.parkedSubscriptions.get(next.sessionId) === next.supervision?.waitLease?.id,
|
|
1200
|
+
transitionFresh: Date.parse(now) - Date.parse(next.updatedAt) <= 120_000,
|
|
1201
|
+
observedAt: now,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
semanticSnapshot(next, control) {
|
|
1085
1205
|
return JSON.stringify({
|
|
1086
|
-
...
|
|
1206
|
+
...this.snapshotFor(next, control),
|
|
1087
1207
|
updatedAt: '',
|
|
1088
1208
|
});
|
|
1089
1209
|
}
|
|
1090
|
-
snapshotEvents(next) {
|
|
1091
|
-
const semantic = this.semanticSnapshot(next);
|
|
1210
|
+
snapshotEvents(next, control) {
|
|
1211
|
+
const semantic = this.semanticSnapshot(next, control);
|
|
1092
1212
|
if (this.publishedSnapshots.get(next.sessionId) === semantic)
|
|
1093
1213
|
return [];
|
|
1214
|
+
const session = this.deps.session(next.sessionId);
|
|
1215
|
+
const plan = this.deps.plan(next.sessionId)?.plan ?? null;
|
|
1216
|
+
const activity = this.deps.activity(next.sessionId);
|
|
1217
|
+
const snapshot = this.snapshotFor(next, control);
|
|
1094
1218
|
return [
|
|
1095
1219
|
{
|
|
1096
1220
|
sessionId: next.sessionId,
|
|
1097
1221
|
type: 'autopilot.updated',
|
|
1098
|
-
payload:
|
|
1222
|
+
payload: snapshot,
|
|
1099
1223
|
occurredAt: next.updatedAt,
|
|
1100
1224
|
},
|
|
1225
|
+
...(session
|
|
1226
|
+
? [
|
|
1227
|
+
{
|
|
1228
|
+
sessionId: next.sessionId,
|
|
1229
|
+
type: 'session.status.updated',
|
|
1230
|
+
payload: deriveSessionStatus({
|
|
1231
|
+
session,
|
|
1232
|
+
plan,
|
|
1233
|
+
activity,
|
|
1234
|
+
autopilot: snapshot,
|
|
1235
|
+
pendingAttention: this.deps.pendingInteraction(next.sessionId),
|
|
1236
|
+
observedAt: next.updatedAt,
|
|
1237
|
+
}),
|
|
1238
|
+
occurredAt: next.updatedAt,
|
|
1239
|
+
},
|
|
1240
|
+
]
|
|
1241
|
+
: []),
|
|
1101
1242
|
];
|
|
1102
1243
|
}
|
|
1103
1244
|
flushOutbox(sessionId) {
|
|
@@ -1215,8 +1356,15 @@ export class AutopilotCoordinator {
|
|
|
1215
1356
|
const activity = this.deps.activity(sessionId);
|
|
1216
1357
|
if (activity?.confidence === 'fresh' &&
|
|
1217
1358
|
Date.parse(this.deps.now()) - Date.parse(activity.root.lastActivityAt) <=
|
|
1218
|
-
this.deps.policy.staleAfterMs)
|
|
1359
|
+
this.deps.policy.staleAfterMs) {
|
|
1219
1360
|
this.evaluate(sessionId);
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
// A compatible read that did not yield fresh actor evidence is not a
|
|
1364
|
+
// healthy wait. Keep one bounded watchdog armed for missed activity or
|
|
1365
|
+
// late runtime recovery rather than silently settling the supervisor.
|
|
1366
|
+
this.armExecutorRefresh(sessionId, this.deps.policy.executorContinuationMaxMs);
|
|
1367
|
+
}
|
|
1220
1368
|
}
|
|
1221
1369
|
catch {
|
|
1222
1370
|
const current = this.deps.store.find(sessionId);
|