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.
@@ -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-BK3qsn4l.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DHqGG2Sf.css">
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
- await vi.waitFor(() => expect(timers).toHaveLength(1));
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
- for (const activityFact of decodeAgentActivityFacts(sessionId, occurredAt, notification))
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
- /** Process loss revokes child identities; recovery must repopulate them from a fresh list read. */
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: Object.freeze([]),
153
- aggregateSubagents: 'idle',
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
- return autopilotSnapshot(this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, this.deps.now()), this.deps.policy.retryLimit);
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
- if (!this.deps.plan(sessionId))
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
- supervision: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
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
- this.enqueue(sessionId, () => this.reconcile(sessionId, prior.generation));
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
- this.semanticEvent(sessionId, 'checkpointChanged');
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
- if (executionComplete(plan.plan)) {
499
- const eventKey = `plan:${fingerprint(plan.plan)}`;
500
- if (this.planEventKeys.get(sessionId) === eventKey)
501
- return;
502
- this.planEventKeys.set(sessionId, eventKey);
503
- this.evaluate(sessionId);
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
- if (!activity || activity.confidence !== 'fresh')
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
- semanticSnapshot(next) {
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
- ...autopilotSnapshot(next, this.deps.policy.retryLimit),
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: autopilotSnapshot(next, this.deps.policy.retryLimit),
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);