gestalt-mobile 0.17.3 → 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 +382 -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/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 +12 -4
- package/dist/server/server/platform/codex/session-runtime.js +91 -8
- 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 +57 -8
- 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-Ba_4zoY1.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,6 +32,8 @@ 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 { AgentActivityRegistry } from './features/agent-activity/registry.js';
|
|
36
|
+
import { decodeAgentActivityFact } from './platform/codex/activity-facts.js';
|
|
35
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';
|
|
@@ -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());
|
|
@@ -118,20 +251,46 @@ export async function composeRelayApp(options) {
|
|
|
118
251
|
.snapshot(sessionId)
|
|
119
252
|
.find((item) => item.requestId === requestId);
|
|
120
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);
|
|
121
268
|
};
|
|
122
|
-
const dismissPendingInteractions = (sessionId, occurredAt) => {
|
|
269
|
+
const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
|
|
123
270
|
for (const interaction of interactions.list(sessionId)) {
|
|
124
|
-
if (interactions.resolve(sessionId, interaction.requestId, occurredAt,
|
|
125
|
-
publishInteractionResolved(sessionId, interaction.requestId, occurredAt,
|
|
271
|
+
if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
|
|
272
|
+
publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
|
|
126
273
|
}
|
|
127
274
|
};
|
|
128
|
-
|
|
275
|
+
let closing = false;
|
|
276
|
+
let runtime = null;
|
|
277
|
+
runtime = options.startAppServers
|
|
129
278
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
130
279
|
const occurredAt = new Date().toISOString();
|
|
280
|
+
const activityFact = decodeAgentActivityFact(sessionId, occurredAt, notification);
|
|
281
|
+
if (activityFact)
|
|
282
|
+
activity.observe(activityFact);
|
|
131
283
|
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
132
|
-
if (resolvedRequestId
|
|
133
|
-
interactions.
|
|
134
|
-
|
|
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
|
+
}
|
|
135
294
|
const currentSession = sessions.find(sessionId);
|
|
136
295
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
|
|
137
296
|
if (!normalized)
|
|
@@ -143,6 +302,7 @@ export async function composeRelayApp(options) {
|
|
|
143
302
|
if (session && turnId && session.activeTurnId === turnId) {
|
|
144
303
|
completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
|
|
145
304
|
sessions.save(completedSession);
|
|
305
|
+
autopilot.turnCompleted(sessionId);
|
|
146
306
|
}
|
|
147
307
|
planMeasurementRefresh?.refreshNow(sessionId);
|
|
148
308
|
}
|
|
@@ -160,14 +320,28 @@ export async function composeRelayApp(options) {
|
|
|
160
320
|
requestedAt: new Date().toISOString(),
|
|
161
321
|
};
|
|
162
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
|
+
});
|
|
163
331
|
const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
|
|
164
332
|
sessions.save(updated);
|
|
165
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);
|
|
166
337
|
return true;
|
|
167
338
|
}, (sessionId) => {
|
|
168
|
-
|
|
339
|
+
activity.disconnected(sessionId, new Date().toISOString());
|
|
340
|
+
dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
|
|
169
341
|
recoverExitedSession(sessionId);
|
|
170
342
|
}, resolveSkills, planStatusSource, (sessionId, update) => {
|
|
343
|
+
if (closing)
|
|
344
|
+
return;
|
|
171
345
|
supervisedPlans.accept(sessionId, update);
|
|
172
346
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
173
347
|
if (update.kind === 'updated') {
|
|
@@ -185,6 +359,7 @@ export async function composeRelayApp(options) {
|
|
|
185
359
|
}
|
|
186
360
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
187
361
|
}
|
|
362
|
+
autopilot.planUpdated(sessionId);
|
|
188
363
|
}, options.planMeasurementBaseUrl, 30_000, 64, root)
|
|
189
364
|
: null;
|
|
190
365
|
if (runtime && planMeasurementHelperPath) {
|
|
@@ -196,8 +371,19 @@ export async function composeRelayApp(options) {
|
|
|
196
371
|
}, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
|
|
197
372
|
}
|
|
198
373
|
const saveSession = (session) => {
|
|
374
|
+
const prior = sessions.find(session.id);
|
|
199
375
|
sessions.save(session);
|
|
200
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);
|
|
201
387
|
};
|
|
202
388
|
if (runtime) {
|
|
203
389
|
const supervisor = new SessionSupervisor(async (sessionId) => {
|
|
@@ -206,7 +392,8 @@ export async function composeRelayApp(options) {
|
|
|
206
392
|
return;
|
|
207
393
|
const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
|
|
208
394
|
saveSession(recovering);
|
|
209
|
-
|
|
395
|
+
const restored = await runtime.restore(recovering, new Date().toISOString());
|
|
396
|
+
saveSession(restored);
|
|
210
397
|
}, (sessionId) => {
|
|
211
398
|
const session = sessions.find(sessionId);
|
|
212
399
|
if (session)
|
|
@@ -217,8 +404,10 @@ export async function composeRelayApp(options) {
|
|
|
217
404
|
recoverExitedSession = (sessionId) => {
|
|
218
405
|
supervisor.cancel(sessionId);
|
|
219
406
|
const session = sessions.find(sessionId);
|
|
220
|
-
if (session)
|
|
407
|
+
if (session) {
|
|
408
|
+
autopilot.cancel(sessionId, 'sessionEnded');
|
|
221
409
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
410
|
+
}
|
|
222
411
|
};
|
|
223
412
|
}
|
|
224
413
|
let authorization;
|
|
@@ -313,11 +502,15 @@ export async function composeRelayApp(options) {
|
|
|
313
502
|
? async (session, settings) => {
|
|
314
503
|
const now = new Date().toISOString();
|
|
315
504
|
dismissPendingInteractions(session.id, now);
|
|
316
|
-
|
|
505
|
+
const started = await runtime.start(session, now, settings);
|
|
506
|
+
return started;
|
|
317
507
|
}
|
|
318
508
|
: undefined,
|
|
319
509
|
startTurn: runtime
|
|
320
|
-
? 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
|
+
}
|
|
321
514
|
: undefined,
|
|
322
515
|
ensureWriter: runtime
|
|
323
516
|
? (session) => {
|
|
@@ -331,7 +524,21 @@ export async function composeRelayApp(options) {
|
|
|
331
524
|
return runtime.release(id);
|
|
332
525
|
}
|
|
333
526
|
: undefined,
|
|
334
|
-
onTurnStarted: (session) =>
|
|
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),
|
|
335
542
|
models,
|
|
336
543
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
337
544
|
currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
|
|
@@ -339,8 +546,20 @@ export async function composeRelayApp(options) {
|
|
|
339
546
|
? (session, turnId) => runtime.interruptTurn(session, turnId)
|
|
340
547
|
: undefined,
|
|
341
548
|
restore: runtime
|
|
342
|
-
? (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
|
+
}
|
|
343
561
|
: undefined,
|
|
562
|
+
ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
|
|
344
563
|
promoteRecent: runtime
|
|
345
564
|
? (thread) => promoteRecentThread(thread, {
|
|
346
565
|
createId: randomUUID,
|
|
@@ -350,13 +569,22 @@ export async function composeRelayApp(options) {
|
|
|
350
569
|
read: (session) => runtime.readHistory(session),
|
|
351
570
|
})
|
|
352
571
|
: undefined,
|
|
353
|
-
release: (session) =>
|
|
354
|
-
|
|
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
|
+
},
|
|
355
581
|
idempotency,
|
|
356
582
|
close: runtime
|
|
357
583
|
? (id) => {
|
|
584
|
+
autopilot.cancel(id, 'sessionEnded');
|
|
358
585
|
planMeasurementRefresh?.stop(id);
|
|
359
586
|
dismissPendingInteractions(id, new Date().toISOString());
|
|
587
|
+
activity.dispose(id);
|
|
360
588
|
return runtime.release(id);
|
|
361
589
|
}
|
|
362
590
|
: undefined,
|
|
@@ -364,6 +592,17 @@ export async function composeRelayApp(options) {
|
|
|
364
592
|
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
|
|
365
593
|
: undefined,
|
|
366
594
|
interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
|
|
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
|
+
});
|
|
367
606
|
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
368
607
|
},
|
|
369
608
|
},
|
|
@@ -384,12 +623,14 @@ export async function composeRelayApp(options) {
|
|
|
384
623
|
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
385
624
|
clear: (id) => supervisedPlans.clear(id),
|
|
386
625
|
closed: (id) => {
|
|
626
|
+
autopilot.cancel(id, 'planRemoved');
|
|
387
627
|
planMeasurementRefresh?.stop(id);
|
|
388
628
|
const occurredAt = new Date().toISOString();
|
|
389
629
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
390
630
|
},
|
|
391
631
|
},
|
|
392
632
|
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
633
|
+
autopilot,
|
|
393
634
|
...(runtime
|
|
394
635
|
? {
|
|
395
636
|
planMeasurementRoutes: {
|
|
@@ -415,9 +656,108 @@ export async function composeRelayApp(options) {
|
|
|
415
656
|
return false;
|
|
416
657
|
if (interaction.kind === 'quiz')
|
|
417
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;
|
|
418
663
|
return isValidInteractionResponse(interaction.kind, value);
|
|
419
664
|
},
|
|
420
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
|
+
},
|
|
421
761
|
gitSummary: {
|
|
422
762
|
workspaces,
|
|
423
763
|
inspect: async (path) => {
|
|
@@ -453,10 +793,18 @@ export async function composeRelayApp(options) {
|
|
|
453
793
|
throw error;
|
|
454
794
|
}
|
|
455
795
|
const detachActiveSessions = async () => {
|
|
456
|
-
await mapWithConcurrency(sessions
|
|
457
|
-
|
|
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') {
|
|
458
803
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
804
|
+
await runtime?.release(session.id);
|
|
805
|
+
}
|
|
459
806
|
await runtime?.watchPlanStatus(session);
|
|
807
|
+
autopilot.restore(session.id);
|
|
460
808
|
});
|
|
461
809
|
};
|
|
462
810
|
app.addHook('onListen', async () => {
|
|
@@ -466,9 +814,22 @@ export async function composeRelayApp(options) {
|
|
|
466
814
|
await detachActiveSessions();
|
|
467
815
|
});
|
|
468
816
|
app.addHook('onClose', async () => {
|
|
817
|
+
closing = true;
|
|
469
818
|
planMeasurementRefresh?.stopAll();
|
|
470
|
-
for (const session of sessions.list())
|
|
471
|
-
|
|
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
|
+
}
|
|
472
833
|
runtime?.stopAll();
|
|
473
834
|
planStatusSource.closeAll();
|
|
474
835
|
database.close();
|