gestalt-mobile 0.30.2 → 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.
@@ -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);
@@ -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 { observableWakeConditions, } from './supervision-protocol.js';
6
7
  export function disabledAutopilot(sessionId, now) {
7
8
  return {
8
9
  sessionId,
@@ -18,10 +19,99 @@ export function disabledAutopilot(sessionId, now) {
18
19
  updatedAt: now,
19
20
  };
20
21
  }
21
- export function autopilotSnapshot(state, retryLimit) {
22
+ export function autopilotSnapshot(state, retryLimit, facts = {
23
+ activeTurn: false,
24
+ executorActive: false,
25
+ control: 'none',
26
+ timerArmed: false,
27
+ reconciling: false,
28
+ planMatches: false,
29
+ parkedSubscriptionActive: false,
30
+ transitionFresh: false,
31
+ observedAt: state.updatedAt,
32
+ }) {
33
+ const supervision = state.supervision?.outcome ?? 'none';
34
+ const waitCategories = supportedWakeCategories(state.supervision?.waitLease?.wakeConditions ?? []);
35
+ const parkedWait = supervision === 'parked' && waitCategories.length > 0 && facts.parkedSubscriptionActive;
36
+ const evaluationAt = state.nextEvaluationAt ? Date.parse(state.nextEvaluationAt) : Number.NaN;
37
+ const scheduled = state.state === 'backoff' &&
38
+ facts.control === 'scheduled' &&
39
+ facts.timerArmed &&
40
+ Number.isFinite(evaluationAt) &&
41
+ evaluationAt > Date.parse(facts.observedAt);
42
+ const rootWorking = facts.activeTurn;
43
+ // Runtime observations are current at observedAt. A slow or old durable
44
+ // transition must not turn an actually active root/timer/reconciliation or
45
+ // installed wait lease into a false idle signal.
46
+ const liveContinuation = parkedWait || scheduled || rootWorking || facts.executorActive || facts.reconciling;
47
+ const healthy = state.requestedEnabled &&
48
+ facts.planMatches &&
49
+ !['attentionRequired', 'safetyPaused'].includes(supervision) &&
50
+ !['attentionRequired', 'safetyPaused', 'completed', 'disabled'].includes(state.state) &&
51
+ liveContinuation;
52
+ const phase = state.state === 'disabled'
53
+ ? 'off'
54
+ : state.state === 'completed'
55
+ ? 'complete'
56
+ : state.state === 'attentionRequired'
57
+ ? 'needsYou'
58
+ : state.state === 'safetyPaused' || supervision === 'safetyPaused'
59
+ ? 'safetyPaused'
60
+ : healthy && (parkedWait || facts.executorActive)
61
+ ? 'waitingForAgentEvent'
62
+ : healthy && scheduled
63
+ ? 'continuationScheduled'
64
+ : healthy && (supervision === 'probeRequired' || facts.reconciling)
65
+ ? 'checkingState'
66
+ : healthy && rootWorking
67
+ ? 'rootWorking'
68
+ : 'degraded';
69
+ const nextExpectedAction = phase === 'rootWorking'
70
+ ? 'Wait for the root turn to settle.'
71
+ : phase === 'continuationScheduled'
72
+ ? 'Run the scheduled continuation.'
73
+ : phase === 'waitingForAgentEvent'
74
+ ? parkedWait
75
+ ? 'Wait for a subscribed agent or process event.'
76
+ : 'Wait for the active executor or owned process to settle.'
77
+ : phase === 'checkingState'
78
+ ? 'Reconcile supervised execution state.'
79
+ : phase === 'needsYou'
80
+ ? 'Respond to the pending attention request.'
81
+ : phase === 'complete'
82
+ ? 'No further plan action is required.'
83
+ : phase === 'off'
84
+ ? 'Enable Autopilot to supervise an incomplete plan.'
85
+ : phase === 'safetyPaused'
86
+ ? 'Resume manually after reviewing the safety pause.'
87
+ : 'Restore a valid continuation before relying on Autopilot.';
22
88
  return {
23
89
  state: state.state,
24
90
  enabled: state.requestedEnabled,
91
+ health: {
92
+ healthy,
93
+ phase,
94
+ supervision,
95
+ wait: {
96
+ present: Boolean(state.supervision?.waitLease),
97
+ wakeCategories: [...waitCategories].slice(0, 9),
98
+ },
99
+ observedAt: facts.observedAt,
100
+ lastTransitionAt: state.updatedAt,
101
+ nextExpectedAction,
102
+ ...(!healthy && state.requestedEnabled
103
+ ? {
104
+ degradationReason: !facts.planMatches
105
+ ? 'planMismatch'
106
+ : !facts.transitionFresh ||
107
+ (Number.isFinite(evaluationAt) && evaluationAt <= Date.parse(facts.observedAt))
108
+ ? 'staleTransition'
109
+ : state.supervision?.waitLease && !parkedWait
110
+ ? 'invalidWaitLease'
111
+ : 'missingContinuation',
112
+ }
113
+ : {}),
114
+ },
25
115
  ...(state.stopReason ? { reason: state.stopReason } : {}),
26
116
  retry: { position: state.consecutiveNoProgress, limit: retryLimit },
27
117
  ...(state.lastControlId
@@ -40,3 +130,12 @@ export function autopilotSnapshot(state, retryLimit) {
40
130
  updatedAt: state.updatedAt,
41
131
  };
42
132
  }
133
+ function supportedWakeCategories(conditions) {
134
+ if (conditions.length > observableWakeConditions.length)
135
+ return [];
136
+ const categories = conditions.filter((condition) => typeof condition === 'string' &&
137
+ observableWakeConditions.includes(condition));
138
+ return categories.length === conditions.length && new Set(categories).size === categories.length
139
+ ? categories
140
+ : [];
141
+ }
@@ -4,16 +4,30 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
- export function registerGetSession(app, find, activity, autopilot) {
7
+ import { toAgentActivityDto } from '../../agent-activity/activity-dto.js';
8
+ import { deriveSessionStatus } from '../session-status.js';
9
+ export function registerGetSession(app, find, activity, autopilot, plan) {
8
10
  app.get('/api/sessions/:id', async (request, reply) => {
9
11
  const session = find(request.params.id);
10
- return session
11
- ? reply.send({
12
- ...session,
13
- ...(activity ? { agentActivity: activity(session.id) } : {}),
14
- ...(autopilot ? { autopilot: autopilot(session.id) } : {}),
15
- resumeCommand: session.threadId ? buildResumeCommand(session) : null,
16
- })
17
- : reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
+ if (!session)
13
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
14
+ const activitySnapshot = activity?.(session.id) ?? null;
15
+ const autopilotSnapshot = autopilot?.(session.id) ?? null;
16
+ const retainedPlan = plan?.(session.id) ?? null;
17
+ return reply.send({
18
+ ...session,
19
+ ...(activitySnapshot ? { agentActivity: toAgentActivityDto(activitySnapshot) } : {}),
20
+ ...(autopilotSnapshot ? { autopilot: autopilotSnapshot } : {}),
21
+ ...(retainedPlan ? { plan: retainedPlan } : {}),
22
+ sessionStatus: deriveSessionStatus({
23
+ session,
24
+ plan: retainedPlan,
25
+ activity: activitySnapshot,
26
+ autopilot: autopilotSnapshot,
27
+ pendingAttention: Boolean(session.pendingInteractions?.length),
28
+ observedAt: new Date().toISOString(),
29
+ }),
30
+ resumeCommand: session.threadId ? buildResumeCommand(session) : null,
31
+ });
18
32
  });
19
33
  }
@@ -4,14 +4,26 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
+ import { toAgentActivityDto } from '../../agent-activity/activity-dto.js';
8
+ import { deriveSessionStatus } from '../session-status.js';
7
9
  export function registerListSessions(app, deps) {
8
10
  app.get('/api/sessions', async () => deps.list().map((session) => {
9
11
  const plan = deps.plan?.(session.id) ?? null;
12
+ const activity = deps.activity?.(session.id) ?? null;
13
+ const autopilot = deps.autopilot?.(session.id) ?? null;
10
14
  return {
11
15
  ...session,
12
- ...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
13
- ...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
16
+ ...(activity ? { agentActivity: toAgentActivityDto(activity) } : {}),
17
+ ...(autopilot ? { autopilot } : {}),
14
18
  ...(plan ? { plan } : {}),
19
+ sessionStatus: deriveSessionStatus({
20
+ session,
21
+ plan,
22
+ activity,
23
+ autopilot,
24
+ pendingAttention: Boolean(session.pendingInteractions?.length),
25
+ observedAt: new Date().toISOString(),
26
+ }),
15
27
  resumeCommand: session.threadId ? buildResumeCommand(session) : null,
16
28
  };
17
29
  }));
@@ -48,7 +48,7 @@ export function registerSessionRoutes(app, deps) {
48
48
  ...sessions,
49
49
  reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
50
50
  });
51
- registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot);
51
+ registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot, sessions.plan);
52
52
  if (sessions.refreshActivity)
53
53
  registerRefreshActivity(app, {
54
54
  exists: (id) => sessions.find(id) !== null,
@@ -0,0 +1,55 @@
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
+ /**
7
+ * A conservative, server-owned verdict. It deliberately uses only durable
8
+ * state and bounded activity facts: neither model prose nor opaque identity is
9
+ * accepted as evidence of progress.
10
+ */
11
+ export function deriveSessionStatus(input) {
12
+ const confidence = input.activity?.confidence ?? 'stale';
13
+ const base = (state, reason, nextExpectedAction) => ({
14
+ state,
15
+ reason,
16
+ confidence,
17
+ observedAt: input.observedAt,
18
+ nextExpectedAction,
19
+ });
20
+ if (input.pendingAttention || input.autopilot?.state === 'attentionRequired')
21
+ return base('idle', 'needsYou', 'Respond to the pending request.');
22
+ const activeProcess = input.activity?.subagents.some((agent) => agent.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active'));
23
+ const rootWorking = Boolean(input.session.activeTurnId) ||
24
+ (input.activity?.confidence === 'fresh' && input.activity.root.state === 'working');
25
+ const observedChildWorking = input.activity?.subagents.some((agent) => agent.state === 'working' || agent.state === 'awaitingAgent');
26
+ // A completed plan is authoritative. Old child/process rows are historical
27
+ // unless their activity observation is fresh; an active root turn remains
28
+ // direct session evidence regardless of roster freshness.
29
+ const childWorking = input.activity?.confidence === 'fresh' ? observedChildWorking : false;
30
+ const currentProcess = input.activity?.confidence === 'fresh' ? activeProcess : false;
31
+ if (input.plan?.executionComplete || input.plan?.allDone) {
32
+ if (rootWorking)
33
+ return base('working', 'rootTurn', 'Wait for the active turn to settle.');
34
+ if (childWorking)
35
+ return base('working', 'agent', 'Wait for the active agent to settle.');
36
+ if (currentProcess)
37
+ return base('working', 'process', 'Wait for the owned process result.');
38
+ return base('idle', 'complete', 'No further plan action is required.');
39
+ }
40
+ if (rootWorking)
41
+ return base('working', 'rootTurn', 'Wait for the supervisor turn to settle.');
42
+ if (childWorking)
43
+ return base('working', 'agent', 'Wait for the active agent to settle.');
44
+ if (currentProcess)
45
+ return base('working', 'process', 'Wait for the owned process result.');
46
+ if (input.plan && input.autopilot?.health?.healthy)
47
+ return base('working', 'autopilot', input.autopilot.health.nextExpectedAction);
48
+ if (input.plan)
49
+ return base('idle', 'incompleteWithoutContinuation', 'Resume supervision or enable a healthy Autopilot continuation.');
50
+ if (input.activity?.root.state === 'disconnected')
51
+ return base('idle', 'disconnected', 'Restore or reopen the session.');
52
+ if (input.session.state === 'stopped' || input.session.state === 'released')
53
+ return base('idle', 'stopped', 'Open the session to continue.');
54
+ return base('idle', 'unknown', 'Refresh session status.');
55
+ }