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.
- package/dist/client/assets/index-DDwVphGb.css +1 -0
- package/dist/client/assets/index-DKzUE8x1.js +36 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/composition.contracts.js +92 -1
- package/dist/server/server/composition.js +36 -5
- 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/assets/index-CKhqx71S.css +0 -1
- package/dist/client/assets/index-v8UC90bI.js +0 -34
|
@@ -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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
:
|
|
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
|
-
...(
|
|
13
|
-
...(
|
|
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
|
+
}
|