gestalt-mobile 0.17.2 → 0.18.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/README.md +5 -1
- package/dist/client/assets/index-B0Uq4OB0.css +1 -0
- package/dist/client/assets/index-orfZBEaD.js +27 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +6 -0
- package/dist/server/server/composition.js +413 -21
- package/dist/server/server/features/agent-activity/model.js +197 -0
- package/dist/server/server/features/agent-activity/registry.js +155 -0
- package/dist/server/server/features/autopilot/application/policy.js +55 -0
- package/dist/server/server/features/autopilot/application/ports.js +6 -0
- package/dist/server/server/features/autopilot/application/service.js +542 -0
- package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
- package/dist/server/server/features/autopilot/register-routes.js +9 -0
- package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
- package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
- package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
- package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
- package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
- package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
- package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
- package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
- package/dist/server/server/features/sessions/interaction/kind.js +1 -0
- package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
- package/dist/server/server/features/sessions/model/relay-session.js +3 -0
- package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
- package/dist/server/server/features/sessions/register-routes.js +15 -2
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
- package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
- package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
- package/dist/server/server/features/skills/model/skill-profile.js +13 -5
- package/dist/server/server/platform/codex/activity-facts.js +88 -0
- package/dist/server/server/platform/codex/server-request.js +21 -4
- package/dist/server/server/platform/codex/session-runtime.js +114 -22
- package/dist/server/server/platform/persistence/migrate.js +16 -1
- package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
- package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
- package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
- package/dist/server/shared/contracts/org-plan-attention.js +101 -0
- package/package.json +1 -1
- package/dist/client/assets/index-DgD-0I3M.js +0 -25
- package/dist/client/assets/index-e2lJ5XqL.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
16
16
|
<link rel="icon" href="/icons/gestalt-mobile-192.png" />
|
|
17
17
|
<link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
|
|
18
18
|
<title>Gestalt Mobile</title>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-orfZBEaD.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B0Uq4OB0.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -15,6 +15,8 @@ import { registerSessionRoutes } from './features/sessions/register-routes.js';
|
|
|
15
15
|
import { registerProblemHandler } from './platform/http/problem-handler.js';
|
|
16
16
|
import { registerAuthorizationBoundary } from './platform/http/authorization-boundary.js';
|
|
17
17
|
import { registerSkillRoutes } from './features/skills/register-routes.js';
|
|
18
|
+
import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
|
|
19
|
+
import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
|
|
18
20
|
export async function buildApp(deps) {
|
|
19
21
|
// Keep the default boundary finite even when an endpoint forgot a narrower schema.
|
|
20
22
|
const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
|
|
@@ -41,6 +43,10 @@ export async function buildApp(deps) {
|
|
|
41
43
|
if (deps.bootstrap)
|
|
42
44
|
registerGetBootstrap(app, deps.bootstrap);
|
|
43
45
|
registerSessionRoutes(app, deps);
|
|
46
|
+
if (deps.autopilot)
|
|
47
|
+
registerAutopilotRoutes(app, deps.autopilot, deps.sessionRoutes?.idempotency);
|
|
48
|
+
if (deps.orgPlanAttention)
|
|
49
|
+
registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
|
|
44
50
|
registerPlanRoutes(app, deps);
|
|
45
51
|
registerGitRoutes(app, deps);
|
|
46
52
|
registerSkillRoutes(app, deps);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
6
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
@@ -32,7 +32,9 @@ import { GitSummaryCache } from './platform/git/git-summary-cache.js';
|
|
|
32
32
|
import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
|
|
33
33
|
import { mapWithConcurrency } from './platform/runtime/concurrency.js';
|
|
34
34
|
import { RelaySession } from './features/sessions/model/relay-session.js';
|
|
35
|
-
import {
|
|
35
|
+
import { AgentActivityRegistry } from './features/agent-activity/registry.js';
|
|
36
|
+
import { decodeAgentActivityFact } from './platform/codex/activity-facts.js';
|
|
37
|
+
import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
|
|
36
38
|
import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
|
|
37
39
|
import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
|
|
38
40
|
import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
|
|
@@ -45,7 +47,11 @@ import { FilesystemWorkspacePlanCatalog } from './platform/plans/filesystem-work
|
|
|
45
47
|
import { OrgPlanCommandValidator } from './platform/plans/org-plan-command-validator.js';
|
|
46
48
|
import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
|
|
47
49
|
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
50
|
+
import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
|
|
51
|
+
import { AutopilotCoordinator } from './features/autopilot/application/service.js';
|
|
52
|
+
import { AUTOPILOT_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
|
|
48
53
|
import { createRelyingPartyConfig } from './config.js';
|
|
54
|
+
import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
|
|
49
55
|
const generatedProtocolVersion = 'codex-cli 0.144.3';
|
|
50
56
|
export async function composeRelayApp(options) {
|
|
51
57
|
const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
|
|
@@ -70,14 +76,141 @@ export async function composeRelayApp(options) {
|
|
|
70
76
|
throw error;
|
|
71
77
|
}
|
|
72
78
|
const sessions = new SqliteSessionRepository(database);
|
|
79
|
+
// Only sessions found while opening the relay database belong to a previous
|
|
80
|
+
// process. A listen hook can run after a new session has already started in
|
|
81
|
+
// this process; detaching that live writer would make restore and activity
|
|
82
|
+
// reconciliation race their own owner.
|
|
83
|
+
const persistedSessionIds = new Set(sessions.list().map((session) => session.id));
|
|
73
84
|
const journal = new SqliteEventJournal(database);
|
|
74
85
|
const interactions = new SqlitePendingInteractionStore(database);
|
|
75
86
|
const idempotency = new SqliteIdempotencyStore(database);
|
|
87
|
+
const autopilotStore = new SqliteAutopilotStore(database);
|
|
88
|
+
const attentionResolutionOperations = new Map();
|
|
76
89
|
const supervisedPlans = new SupervisedPlanRegistry();
|
|
77
90
|
const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
|
|
78
91
|
const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
|
|
79
92
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
80
93
|
const events = new SessionEventBus();
|
|
94
|
+
const attentionTransitions = {
|
|
95
|
+
subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
|
|
96
|
+
if (event.type !== 'org-plan.attention-required' &&
|
|
97
|
+
event.type !== 'org-plan.attention-resolved')
|
|
98
|
+
return;
|
|
99
|
+
const payload = event.payload;
|
|
100
|
+
if (typeof payload.requestId !== 'string')
|
|
101
|
+
return;
|
|
102
|
+
listener({
|
|
103
|
+
kind: event.type === 'org-plan.attention-required'
|
|
104
|
+
? 'required'
|
|
105
|
+
: payload.outcome === 'failed'
|
|
106
|
+
? 'failed'
|
|
107
|
+
: 'resolved',
|
|
108
|
+
requestId: payload.requestId,
|
|
109
|
+
occurredAt: event.occurredAt,
|
|
110
|
+
});
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
options.onAttentionTransitions?.(attentionTransitions);
|
|
114
|
+
const activity = new AgentActivityRegistry((snapshot, occurredAt) => events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt)), {
|
|
115
|
+
// Evidence arms one bounded reconciliation; healthy sessions are never polled.
|
|
116
|
+
schedule: options.activitySchedule ??
|
|
117
|
+
((callback, delayMs) => {
|
|
118
|
+
const timer = setTimeout(callback, delayMs);
|
|
119
|
+
return () => clearTimeout(timer);
|
|
120
|
+
}),
|
|
121
|
+
now: () => new Date().toISOString(),
|
|
122
|
+
diagnostic: options.activityDiagnostic ??
|
|
123
|
+
((sessionId, code) => console.warn(`agent activity ${code} session=${sessionId}`)),
|
|
124
|
+
reconcile: async (sessionId) => {
|
|
125
|
+
const session = sessions.find(sessionId);
|
|
126
|
+
if (!session || !runtime)
|
|
127
|
+
return;
|
|
128
|
+
const history = await runtime.readHistory(session);
|
|
129
|
+
const occurredAt = new Date().toISOString();
|
|
130
|
+
activity.observe({
|
|
131
|
+
sessionId,
|
|
132
|
+
occurredAt,
|
|
133
|
+
kind: history.activeTurnId ? 'turnStarted' : 'turnCompleted',
|
|
134
|
+
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
135
|
+
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
136
|
+
});
|
|
137
|
+
activity.childrenReconciled(sessionId, occurredAt, await runtime.listDirectChildren(session));
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const autopilot = new AutopilotCoordinator({
|
|
141
|
+
store: autopilotStore,
|
|
142
|
+
now: () => new Date().toISOString(),
|
|
143
|
+
policy: defaultAutopilotPolicy,
|
|
144
|
+
plan: (sessionId) => {
|
|
145
|
+
const plan = supervisedPlans.find(sessionId);
|
|
146
|
+
const identity = supervisedPlans.identity(sessionId);
|
|
147
|
+
return plan && identity ? { plan, identity } : null;
|
|
148
|
+
},
|
|
149
|
+
session: (sessionId) => sessions.find(sessionId),
|
|
150
|
+
activity: (sessionId) => options.autopilotActivity
|
|
151
|
+
? options.autopilotActivity(sessionId)
|
|
152
|
+
: activity.snapshot(sessionId, new Date().toISOString()),
|
|
153
|
+
pendingInteraction: (sessionId) => interactions.list(sessionId).length > 0,
|
|
154
|
+
reconcile: async (sessionId) => {
|
|
155
|
+
if (options.autopilotReconcile)
|
|
156
|
+
return options.autopilotReconcile(sessionId);
|
|
157
|
+
await activity.refresh(sessionId);
|
|
158
|
+
return {
|
|
159
|
+
compatible: activity.snapshot(sessionId, new Date().toISOString()).confidence === 'fresh',
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
schedule: options.autopilotSchedule ??
|
|
163
|
+
((callback, delayMs) => {
|
|
164
|
+
const timer = setTimeout(callback, delayMs);
|
|
165
|
+
return () => clearTimeout(timer);
|
|
166
|
+
}),
|
|
167
|
+
nextControlId: (sessionId, generation) => `autopilot-${generation}-${createHash('sha256').update(`${sessionId}:${randomUUID()}`).digest('hex').slice(0, 16)}`,
|
|
168
|
+
turnStarter: {
|
|
169
|
+
start: async (sessionId, controlId, generation) => {
|
|
170
|
+
const current = () => {
|
|
171
|
+
const state = autopilotStore.find(sessionId);
|
|
172
|
+
return Boolean(state &&
|
|
173
|
+
state.requestedEnabled &&
|
|
174
|
+
state.generation === generation &&
|
|
175
|
+
state.lastControlId === controlId);
|
|
176
|
+
};
|
|
177
|
+
if (!current())
|
|
178
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
179
|
+
const session = sessions.find(sessionId);
|
|
180
|
+
if (!session || !runtime || session.activeTurnId || interactions.list(sessionId).length)
|
|
181
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
182
|
+
// A relay restart can leave a durable, otherwise eligible session without this
|
|
183
|
+
// process's writer. Reacquire through the normal ownership boundary before a
|
|
184
|
+
// synthetic start; never bypass its single-writer and replacement semantics.
|
|
185
|
+
const writer = await runtime.ensureWriter(session, new Date().toISOString());
|
|
186
|
+
if (!current() || writer.session.activeTurnId || interactions.list(sessionId).length)
|
|
187
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
188
|
+
await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
|
|
189
|
+
const started = await runtime.startTurn(writer.session, AUTOPILOT_CONTINUATION_PROMPT, controlId, new Date().toISOString());
|
|
190
|
+
if (!started.activeTurnId)
|
|
191
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
192
|
+
sessions.save(started);
|
|
193
|
+
await options.autopilotAfterTurnAccepted?.({
|
|
194
|
+
sessionId,
|
|
195
|
+
controlId,
|
|
196
|
+
turnId: started.activeTurnId,
|
|
197
|
+
});
|
|
198
|
+
activity.observe({
|
|
199
|
+
sessionId,
|
|
200
|
+
occurredAt: started.updatedAt,
|
|
201
|
+
kind: 'turnStarted',
|
|
202
|
+
...(started.threadId ? { threadId: started.threadId } : {}),
|
|
203
|
+
...(started.activeTurnId ? { turnId: started.activeTurnId } : {}),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
publish: (sessionId, type, payload, occurredAt, outboxId) => {
|
|
208
|
+
if (!sessions.find(sessionId))
|
|
209
|
+
return;
|
|
210
|
+
events.publish(journal.append(sessionId, type, payload, occurredAt, outboxId));
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
options.onAutopilotCoordinator?.(autopilot);
|
|
81
214
|
const workspaces = new FilesystemWorkspaceCatalog(root);
|
|
82
215
|
const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
|
|
83
216
|
const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
|
|
@@ -113,9 +246,51 @@ export async function composeRelayApp(options) {
|
|
|
113
246
|
const gitSummaries = new GitSummaryCache(inspectGit);
|
|
114
247
|
let recoverExitedSession = () => { };
|
|
115
248
|
let planMeasurementRefresh;
|
|
116
|
-
const
|
|
249
|
+
const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
|
|
250
|
+
const interaction = interactions
|
|
251
|
+
.snapshot(sessionId)
|
|
252
|
+
.find((item) => item.requestId === requestId);
|
|
253
|
+
events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
254
|
+
if (interaction?.kind === 'orgPlanAttention')
|
|
255
|
+
events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
256
|
+
};
|
|
257
|
+
const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
|
|
258
|
+
const remaining = interactions.list(sessionId);
|
|
259
|
+
const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
|
|
260
|
+
activity.observe({
|
|
261
|
+
sessionId,
|
|
262
|
+
occurredAt,
|
|
263
|
+
kind: 'interactionResolved',
|
|
264
|
+
hasPendingInteraction: remaining.length > 0,
|
|
265
|
+
...(attention ? { attentionReason: attention.payload.reason } : {}),
|
|
266
|
+
});
|
|
267
|
+
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
268
|
+
};
|
|
269
|
+
const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
|
|
270
|
+
for (const interaction of interactions.list(sessionId)) {
|
|
271
|
+
if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
|
|
272
|
+
publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
let closing = false;
|
|
276
|
+
let runtime = null;
|
|
277
|
+
runtime = options.startAppServers
|
|
117
278
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
118
279
|
const occurredAt = new Date().toISOString();
|
|
280
|
+
const activityFact = decodeAgentActivityFact(sessionId, occurredAt, notification);
|
|
281
|
+
if (activityFact)
|
|
282
|
+
activity.observe(activityFact);
|
|
283
|
+
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
284
|
+
if (resolvedRequestId) {
|
|
285
|
+
const interaction = interactions.find(sessionId, resolvedRequestId);
|
|
286
|
+
const outcome = interaction?.kind === 'orgPlanAttention' ? 'failed' : 'dismissed';
|
|
287
|
+
if (interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
|
|
288
|
+
if (outcome === 'failed')
|
|
289
|
+
publishAttentionSettlement(sessionId, resolvedRequestId, occurredAt, outcome);
|
|
290
|
+
else
|
|
291
|
+
publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, outcome);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
119
294
|
const currentSession = sessions.find(sessionId);
|
|
120
295
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
|
|
121
296
|
if (!normalized)
|
|
@@ -127,6 +302,7 @@ export async function composeRelayApp(options) {
|
|
|
127
302
|
if (session && turnId && session.activeTurnId === turnId) {
|
|
128
303
|
completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
|
|
129
304
|
sessions.save(completedSession);
|
|
305
|
+
autopilot.turnCompleted(sessionId);
|
|
130
306
|
}
|
|
131
307
|
planMeasurementRefresh?.refreshNow(sessionId);
|
|
132
308
|
}
|
|
@@ -144,11 +320,28 @@ export async function composeRelayApp(options) {
|
|
|
144
320
|
requestedAt: new Date().toISOString(),
|
|
145
321
|
};
|
|
146
322
|
interactions.add(sessionId, interaction);
|
|
323
|
+
activity.observe({
|
|
324
|
+
sessionId,
|
|
325
|
+
occurredAt: interaction.requestedAt,
|
|
326
|
+
kind: 'interactionPending',
|
|
327
|
+
...(interaction.kind === 'orgPlanAttention'
|
|
328
|
+
? { attentionReason: interaction.payload.reason }
|
|
329
|
+
: {}),
|
|
330
|
+
});
|
|
147
331
|
const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
|
|
148
332
|
sessions.save(updated);
|
|
149
333
|
events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
|
|
334
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
335
|
+
events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
|
|
336
|
+
autopilot.evaluate(sessionId);
|
|
150
337
|
return true;
|
|
151
|
-
}, (sessionId) =>
|
|
338
|
+
}, (sessionId) => {
|
|
339
|
+
activity.disconnected(sessionId, new Date().toISOString());
|
|
340
|
+
dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
|
|
341
|
+
recoverExitedSession(sessionId);
|
|
342
|
+
}, resolveSkills, planStatusSource, (sessionId, update) => {
|
|
343
|
+
if (closing)
|
|
344
|
+
return;
|
|
152
345
|
supervisedPlans.accept(sessionId, update);
|
|
153
346
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
154
347
|
if (update.kind === 'updated') {
|
|
@@ -166,6 +359,7 @@ export async function composeRelayApp(options) {
|
|
|
166
359
|
}
|
|
167
360
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
168
361
|
}
|
|
362
|
+
autopilot.planUpdated(sessionId);
|
|
169
363
|
}, options.planMeasurementBaseUrl, 30_000, 64, root)
|
|
170
364
|
: null;
|
|
171
365
|
if (runtime && planMeasurementHelperPath) {
|
|
@@ -177,8 +371,19 @@ export async function composeRelayApp(options) {
|
|
|
177
371
|
}, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
|
|
178
372
|
}
|
|
179
373
|
const saveSession = (session) => {
|
|
374
|
+
const prior = sessions.find(session.id);
|
|
180
375
|
sessions.save(session);
|
|
181
376
|
events.publish(journal.append(session.id, 'session.updated', session, session.updatedAt));
|
|
377
|
+
const becameRuntimeReady = prior &&
|
|
378
|
+
prior.state !== 'ready' &&
|
|
379
|
+
prior.state !== 'turnActive' &&
|
|
380
|
+
(session.state === 'ready' || session.state === 'turnActive');
|
|
381
|
+
if (runtime &&
|
|
382
|
+
session.threadId &&
|
|
383
|
+
(!prior || prior.threadId !== session.threadId || becameRuntimeReady))
|
|
384
|
+
void activity.refresh(session.id);
|
|
385
|
+
if (becameRuntimeReady)
|
|
386
|
+
autopilot.restore(session.id);
|
|
182
387
|
};
|
|
183
388
|
if (runtime) {
|
|
184
389
|
const supervisor = new SessionSupervisor(async (sessionId) => {
|
|
@@ -187,7 +392,8 @@ export async function composeRelayApp(options) {
|
|
|
187
392
|
return;
|
|
188
393
|
const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
|
|
189
394
|
saveSession(recovering);
|
|
190
|
-
|
|
395
|
+
const restored = await runtime.restore(recovering, new Date().toISOString());
|
|
396
|
+
saveSession(restored);
|
|
191
397
|
}, (sessionId) => {
|
|
192
398
|
const session = sessions.find(sessionId);
|
|
193
399
|
if (session)
|
|
@@ -198,8 +404,10 @@ export async function composeRelayApp(options) {
|
|
|
198
404
|
recoverExitedSession = (sessionId) => {
|
|
199
405
|
supervisor.cancel(sessionId);
|
|
200
406
|
const session = sessions.find(sessionId);
|
|
201
|
-
if (session)
|
|
407
|
+
if (session) {
|
|
408
|
+
autopilot.cancel(sessionId, 'sessionEnded');
|
|
202
409
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
410
|
+
}
|
|
203
411
|
};
|
|
204
412
|
}
|
|
205
413
|
let authorization;
|
|
@@ -291,16 +499,46 @@ export async function composeRelayApp(options) {
|
|
|
291
499
|
skillCatalog,
|
|
292
500
|
defaultSkillProfile: options.explicitSkillProfile,
|
|
293
501
|
activate: runtime
|
|
294
|
-
? async (session, settings) =>
|
|
502
|
+
? async (session, settings) => {
|
|
503
|
+
const now = new Date().toISOString();
|
|
504
|
+
dismissPendingInteractions(session.id, now);
|
|
505
|
+
const started = await runtime.start(session, now, settings);
|
|
506
|
+
return started;
|
|
507
|
+
}
|
|
295
508
|
: undefined,
|
|
296
509
|
startTurn: runtime
|
|
297
|
-
? async (session, text, clientUserMessageId) =>
|
|
510
|
+
? async (session, text, clientUserMessageId) => {
|
|
511
|
+
autopilot.manualSend(session.id);
|
|
512
|
+
return runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString());
|
|
513
|
+
}
|
|
298
514
|
: undefined,
|
|
299
515
|
ensureWriter: runtime
|
|
300
|
-
? (session) =>
|
|
516
|
+
? (session) => {
|
|
517
|
+
dismissPendingInteractions(session.id, new Date().toISOString());
|
|
518
|
+
return runtime.ensureWriter(session, new Date().toISOString());
|
|
519
|
+
}
|
|
301
520
|
: undefined,
|
|
302
|
-
releaseWriter: runtime
|
|
303
|
-
|
|
521
|
+
releaseWriter: runtime
|
|
522
|
+
? (id) => {
|
|
523
|
+
dismissPendingInteractions(id, new Date().toISOString());
|
|
524
|
+
return runtime.release(id);
|
|
525
|
+
}
|
|
526
|
+
: undefined,
|
|
527
|
+
onTurnStarted: (session) => {
|
|
528
|
+
activity.observe({
|
|
529
|
+
sessionId: session.id,
|
|
530
|
+
occurredAt: session.updatedAt,
|
|
531
|
+
kind: 'turnStarted',
|
|
532
|
+
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
533
|
+
...(session.activeTurnId ? { turnId: session.activeTurnId } : {}),
|
|
534
|
+
});
|
|
535
|
+
planMeasurementRefresh?.refreshNow(session.id);
|
|
536
|
+
},
|
|
537
|
+
agentActivity: (id) => activity.snapshot(id, new Date().toISOString()),
|
|
538
|
+
autopilotSnapshot: (id) => autopilot.snapshot(id),
|
|
539
|
+
autopilotControlTurns: (id) => autopilot.acceptedControlTurns(id),
|
|
540
|
+
autopilotAudit: (id, limit) => journal.autopilotAuditTail(id, limit),
|
|
541
|
+
refreshActivity: (id) => activity.refresh(id),
|
|
304
542
|
models,
|
|
305
543
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
306
544
|
currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
|
|
@@ -308,8 +546,20 @@ export async function composeRelayApp(options) {
|
|
|
308
546
|
? (session, turnId) => runtime.interruptTurn(session, turnId)
|
|
309
547
|
: undefined,
|
|
310
548
|
restore: runtime
|
|
311
|
-
? (session) =>
|
|
549
|
+
? async (session) => {
|
|
550
|
+
const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
|
|
551
|
+
// An exit can be reported while resume is resolving. Do not let the
|
|
552
|
+
// route persist a stale ready snapshot over that recovered exit.
|
|
553
|
+
if (!runtime.ownsWriter(session.id))
|
|
554
|
+
return {
|
|
555
|
+
...restored,
|
|
556
|
+
session: RelaySession.rehydrate(restored.session).stop(new Date().toISOString())
|
|
557
|
+
.snapshot,
|
|
558
|
+
};
|
|
559
|
+
return restored;
|
|
560
|
+
}
|
|
312
561
|
: undefined,
|
|
562
|
+
ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
|
|
313
563
|
promoteRecent: runtime
|
|
314
564
|
? (thread) => promoteRecentThread(thread, {
|
|
315
565
|
createId: randomUUID,
|
|
@@ -319,23 +569,41 @@ export async function composeRelayApp(options) {
|
|
|
319
569
|
read: (session) => runtime.readHistory(session),
|
|
320
570
|
})
|
|
321
571
|
: undefined,
|
|
322
|
-
release: (session) =>
|
|
323
|
-
|
|
572
|
+
release: (session) => {
|
|
573
|
+
autopilot.cancel(session.id, 'sessionEnded');
|
|
574
|
+
return RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot;
|
|
575
|
+
},
|
|
576
|
+
remove: (id) => {
|
|
577
|
+
autopilot.cancel(id, 'sessionEnded');
|
|
578
|
+
activity.dispose(id);
|
|
579
|
+
sessions.remove(id);
|
|
580
|
+
},
|
|
324
581
|
idempotency,
|
|
325
582
|
close: runtime
|
|
326
583
|
? (id) => {
|
|
584
|
+
autopilot.cancel(id, 'sessionEnded');
|
|
327
585
|
planMeasurementRefresh?.stop(id);
|
|
586
|
+
dismissPendingInteractions(id, new Date().toISOString());
|
|
587
|
+
activity.dispose(id);
|
|
328
588
|
return runtime.release(id);
|
|
329
589
|
}
|
|
330
590
|
: undefined,
|
|
331
591
|
replyInteraction: runtime
|
|
332
|
-
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
|
|
592
|
+
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
|
|
333
593
|
: undefined,
|
|
334
594
|
interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
595
|
+
const remaining = interactions.list(sessionId);
|
|
596
|
+
const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
|
|
597
|
+
activity.observe({
|
|
598
|
+
sessionId,
|
|
599
|
+
occurredAt,
|
|
600
|
+
kind: 'interactionResolved',
|
|
601
|
+
hasPendingInteraction: remaining.length > 0,
|
|
602
|
+
...(attention
|
|
603
|
+
? { attentionReason: attention.payload.reason }
|
|
604
|
+
: {}),
|
|
605
|
+
});
|
|
606
|
+
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
339
607
|
},
|
|
340
608
|
},
|
|
341
609
|
sessionEvents: {
|
|
@@ -355,12 +623,14 @@ export async function composeRelayApp(options) {
|
|
|
355
623
|
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
356
624
|
clear: (id) => supervisedPlans.clear(id),
|
|
357
625
|
closed: (id) => {
|
|
626
|
+
autopilot.cancel(id, 'planRemoved');
|
|
358
627
|
planMeasurementRefresh?.stop(id);
|
|
359
628
|
const occurredAt = new Date().toISOString();
|
|
360
629
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
361
630
|
},
|
|
362
631
|
},
|
|
363
632
|
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
633
|
+
autopilot,
|
|
364
634
|
...(runtime
|
|
365
635
|
? {
|
|
366
636
|
planMeasurementRoutes: {
|
|
@@ -386,9 +656,108 @@ export async function composeRelayApp(options) {
|
|
|
386
656
|
return false;
|
|
387
657
|
if (interaction.kind === 'quiz')
|
|
388
658
|
return isValidQuizInteractionResponse(interaction.payload, value);
|
|
659
|
+
// Attention must use its operation-keyed boundary; generic interaction
|
|
660
|
+
// responses deliberately cannot bypass durable operation identity.
|
|
661
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
662
|
+
return false;
|
|
389
663
|
return isValidInteractionResponse(interaction.kind, value);
|
|
390
664
|
},
|
|
391
665
|
},
|
|
666
|
+
orgPlanAttention: {
|
|
667
|
+
exists: (id) => sessions.find(id) !== null,
|
|
668
|
+
reader: {
|
|
669
|
+
active: (sessionId) => {
|
|
670
|
+
const interaction = interactions
|
|
671
|
+
.list(sessionId)
|
|
672
|
+
.find((item) => item.kind === 'orgPlanAttention');
|
|
673
|
+
const attention = interaction ? parseOrgPlanAttention(interaction.payload) : null;
|
|
674
|
+
return interaction && attention
|
|
675
|
+
? {
|
|
676
|
+
requestId: interaction.requestId,
|
|
677
|
+
turnId: interaction.turnId ?? null,
|
|
678
|
+
requestedAt: interaction.requestedAt ?? null,
|
|
679
|
+
attention,
|
|
680
|
+
}
|
|
681
|
+
: null;
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
resolver: {
|
|
685
|
+
resolve: async ({ sessionId, requestId, operationKey, response }) => {
|
|
686
|
+
const scope = `org-plan-attention:${sessionId}:${requestId}`;
|
|
687
|
+
const stored = idempotency.get(scope, operationKey);
|
|
688
|
+
if (stored)
|
|
689
|
+
return JSON.parse(stored.body);
|
|
690
|
+
const terminal = interactions.terminalOperation(sessionId, requestId);
|
|
691
|
+
if (terminal)
|
|
692
|
+
return terminal.operationKey === operationKey
|
|
693
|
+
? terminal.outcome === 'failed'
|
|
694
|
+
? { kind: 'writerCleared', resolvedAt: terminal.resolvedAt }
|
|
695
|
+
: { kind: 'replayed', resolvedAt: terminal.resolvedAt }
|
|
696
|
+
: { kind: 'staleOperation' };
|
|
697
|
+
const key = `${scope}:${operationKey}`;
|
|
698
|
+
const inFlight = attentionResolutionOperations.get(key);
|
|
699
|
+
if (inFlight)
|
|
700
|
+
return inFlight;
|
|
701
|
+
const operation = (async () => {
|
|
702
|
+
const interaction = interactions.find(sessionId, requestId);
|
|
703
|
+
if (!interaction || interaction.kind !== 'orgPlanAttention')
|
|
704
|
+
return { kind: 'noActive' };
|
|
705
|
+
const claim = interactions.claimOperation(sessionId, requestId, operationKey);
|
|
706
|
+
if (claim === 'resolved') {
|
|
707
|
+
const resolved = interactions.resolved(sessionId, requestId);
|
|
708
|
+
return resolved
|
|
709
|
+
? { kind: 'replayed', resolvedAt: resolved.resolvedAt }
|
|
710
|
+
: { kind: 'staleOperation' };
|
|
711
|
+
}
|
|
712
|
+
if (claim === 'stale')
|
|
713
|
+
return { kind: 'staleOperation' };
|
|
714
|
+
if (claim === 'missing')
|
|
715
|
+
return { kind: 'noActive' };
|
|
716
|
+
// A durable capability belongs to the session/thread, not the
|
|
717
|
+
// relay process. A supported stopped writer is retryable;
|
|
718
|
+
// missing capability identifies a pre-rollout legacy thread.
|
|
719
|
+
if (sessions.find(sessionId)?.attentionToolCapability !== 'supported')
|
|
720
|
+
return { kind: 'legacyUnsupported' };
|
|
721
|
+
if (!runtime)
|
|
722
|
+
return { kind: 'writerUnavailable' };
|
|
723
|
+
if (!interactions.beginDelivery(sessionId, requestId, operationKey))
|
|
724
|
+
return { kind: 'staleOperation' };
|
|
725
|
+
const writer = runtime.attentionWriterState(sessionId, requestId);
|
|
726
|
+
if (writer === 'unavailable') {
|
|
727
|
+
interactions.retryDelivery(sessionId, requestId, operationKey);
|
|
728
|
+
return { kind: 'writerUnavailable' };
|
|
729
|
+
}
|
|
730
|
+
const resolvedAt = new Date().toISOString();
|
|
731
|
+
if (writer === 'cleared') {
|
|
732
|
+
if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'failed'))
|
|
733
|
+
return { kind: 'staleOperation' };
|
|
734
|
+
publishAttentionSettlement(sessionId, requestId, resolvedAt, 'failed');
|
|
735
|
+
return { kind: 'writerCleared', resolvedAt };
|
|
736
|
+
}
|
|
737
|
+
if (!runtime.resolveServerRequest(sessionId, requestId, response)) {
|
|
738
|
+
// The state check and delivery are synchronous, but retain a
|
|
739
|
+
// defensive retry path for a future runtime implementation.
|
|
740
|
+
interactions.retryDelivery(sessionId, requestId, operationKey);
|
|
741
|
+
return { kind: 'writerUnavailable' };
|
|
742
|
+
}
|
|
743
|
+
if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'answered'))
|
|
744
|
+
return { kind: 'staleOperation' };
|
|
745
|
+
const accepted = { kind: 'accepted', resolvedAt };
|
|
746
|
+
idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
|
|
747
|
+
publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
|
|
748
|
+
return accepted;
|
|
749
|
+
})();
|
|
750
|
+
attentionResolutionOperations.set(key, operation);
|
|
751
|
+
try {
|
|
752
|
+
return await operation;
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
attentionResolutionOperations.delete(key);
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
transitions: attentionTransitions,
|
|
760
|
+
},
|
|
392
761
|
gitSummary: {
|
|
393
762
|
workspaces,
|
|
394
763
|
inspect: async (path) => {
|
|
@@ -424,10 +793,18 @@ export async function composeRelayApp(options) {
|
|
|
424
793
|
throw error;
|
|
425
794
|
}
|
|
426
795
|
const detachActiveSessions = async () => {
|
|
427
|
-
await mapWithConcurrency(sessions
|
|
428
|
-
|
|
796
|
+
await mapWithConcurrency(sessions
|
|
797
|
+
.list()
|
|
798
|
+
.filter((session) => persistedSessionIds.has(session.id) && session.threadId !== null), 2, async (session) => {
|
|
799
|
+
// The status lease must repopulate the authoritative plan projection before
|
|
800
|
+
// a durable coordinator is restored. Writer detachment is a process concern,
|
|
801
|
+
// not a human disable: a later fenced continuation can reacquire it safely.
|
|
802
|
+
if (session.desiredState === 'active') {
|
|
429
803
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
804
|
+
await runtime?.release(session.id);
|
|
805
|
+
}
|
|
430
806
|
await runtime?.watchPlanStatus(session);
|
|
807
|
+
autopilot.restore(session.id);
|
|
431
808
|
});
|
|
432
809
|
};
|
|
433
810
|
app.addHook('onListen', async () => {
|
|
@@ -437,7 +814,22 @@ export async function composeRelayApp(options) {
|
|
|
437
814
|
await detachActiveSessions();
|
|
438
815
|
});
|
|
439
816
|
app.addHook('onClose', async () => {
|
|
817
|
+
closing = true;
|
|
440
818
|
planMeasurementRefresh?.stopAll();
|
|
819
|
+
for (const session of sessions.list()) {
|
|
820
|
+
autopilot.dispose(session.id);
|
|
821
|
+
activity.dispose(session.id);
|
|
822
|
+
// Relay shutdown only releases this process's writer. A typed attention
|
|
823
|
+
// request remains a durable human-visible blocker for the next relay
|
|
824
|
+
// instance; only an app-server-cleared request is a failed audit outcome.
|
|
825
|
+
for (const interaction of interactions.list(session.id)) {
|
|
826
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
827
|
+
continue;
|
|
828
|
+
const occurredAt = new Date().toISOString();
|
|
829
|
+
if (interactions.resolve(session.id, interaction.requestId, occurredAt, 'dismissed'))
|
|
830
|
+
publishInteractionResolved(session.id, interaction.requestId, occurredAt, 'dismissed');
|
|
831
|
+
}
|
|
832
|
+
}
|
|
441
833
|
runtime?.stopAll();
|
|
442
834
|
planStatusSource.closeAll();
|
|
443
835
|
database.close();
|