gestalt-mobile 0.17.3 → 0.18.1
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-CJBtrNWw.css +1 -0
- package/dist/client/assets/index-D4ZAKUN3.js +27 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +6 -0
- package/dist/server/server/composition.js +383 -26
- 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/server/platform/plans/filesystem-workspace-plan-catalog.js +43 -12
- 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/server/server/platform/plans/org-plan-command-validator.js +0 -62
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-D4ZAKUN3.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CJBtrNWw.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';
|
|
@@ -42,10 +44,13 @@ import { compileSkillOverride } from './features/skills/model/skill-profile.js';
|
|
|
42
44
|
import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
|
|
43
45
|
import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
|
|
44
46
|
import { FilesystemWorkspacePlanCatalog } from './platform/plans/filesystem-workspace-plan-catalog.js';
|
|
45
|
-
import { OrgPlanCommandValidator } from './platform/plans/org-plan-command-validator.js';
|
|
46
47
|
import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
|
|
47
48
|
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
49
|
+
import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
|
|
50
|
+
import { AutopilotCoordinator } from './features/autopilot/application/service.js';
|
|
51
|
+
import { AUTOPILOT_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
|
|
48
52
|
import { createRelyingPartyConfig } from './config.js';
|
|
53
|
+
import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
|
|
49
54
|
const generatedProtocolVersion = 'codex-cli 0.144.3';
|
|
50
55
|
export async function composeRelayApp(options) {
|
|
51
56
|
const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
|
|
@@ -70,23 +75,147 @@ export async function composeRelayApp(options) {
|
|
|
70
75
|
throw error;
|
|
71
76
|
}
|
|
72
77
|
const sessions = new SqliteSessionRepository(database);
|
|
78
|
+
// Only sessions found while opening the relay database belong to a previous
|
|
79
|
+
// process. A listen hook can run after a new session has already started in
|
|
80
|
+
// this process; detaching that live writer would make restore and activity
|
|
81
|
+
// reconciliation race their own owner.
|
|
82
|
+
const persistedSessionIds = new Set(sessions.list().map((session) => session.id));
|
|
73
83
|
const journal = new SqliteEventJournal(database);
|
|
74
84
|
const interactions = new SqlitePendingInteractionStore(database);
|
|
75
85
|
const idempotency = new SqliteIdempotencyStore(database);
|
|
86
|
+
const autopilotStore = new SqliteAutopilotStore(database);
|
|
87
|
+
const attentionResolutionOperations = new Map();
|
|
76
88
|
const supervisedPlans = new SupervisedPlanRegistry();
|
|
77
89
|
const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
|
|
78
90
|
const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
|
|
79
91
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
80
92
|
const events = new SessionEventBus();
|
|
93
|
+
const attentionTransitions = {
|
|
94
|
+
subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
|
|
95
|
+
if (event.type !== 'org-plan.attention-required' &&
|
|
96
|
+
event.type !== 'org-plan.attention-resolved')
|
|
97
|
+
return;
|
|
98
|
+
const payload = event.payload;
|
|
99
|
+
if (typeof payload.requestId !== 'string')
|
|
100
|
+
return;
|
|
101
|
+
listener({
|
|
102
|
+
kind: event.type === 'org-plan.attention-required'
|
|
103
|
+
? 'required'
|
|
104
|
+
: payload.outcome === 'failed'
|
|
105
|
+
? 'failed'
|
|
106
|
+
: 'resolved',
|
|
107
|
+
requestId: payload.requestId,
|
|
108
|
+
occurredAt: event.occurredAt,
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
options.onAttentionTransitions?.(attentionTransitions);
|
|
113
|
+
const activity = new AgentActivityRegistry((snapshot, occurredAt) => events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt)), {
|
|
114
|
+
// Evidence arms one bounded reconciliation; healthy sessions are never polled.
|
|
115
|
+
schedule: options.activitySchedule ??
|
|
116
|
+
((callback, delayMs) => {
|
|
117
|
+
const timer = setTimeout(callback, delayMs);
|
|
118
|
+
return () => clearTimeout(timer);
|
|
119
|
+
}),
|
|
120
|
+
now: () => new Date().toISOString(),
|
|
121
|
+
diagnostic: options.activityDiagnostic ??
|
|
122
|
+
((sessionId, code) => console.warn(`agent activity ${code} session=${sessionId}`)),
|
|
123
|
+
reconcile: async (sessionId) => {
|
|
124
|
+
const session = sessions.find(sessionId);
|
|
125
|
+
if (!session || !runtime)
|
|
126
|
+
return;
|
|
127
|
+
const history = await runtime.readHistory(session);
|
|
128
|
+
const occurredAt = new Date().toISOString();
|
|
129
|
+
activity.observe({
|
|
130
|
+
sessionId,
|
|
131
|
+
occurredAt,
|
|
132
|
+
kind: history.activeTurnId ? 'turnStarted' : 'turnCompleted',
|
|
133
|
+
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
134
|
+
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
135
|
+
});
|
|
136
|
+
activity.childrenReconciled(sessionId, occurredAt, await runtime.listDirectChildren(session));
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const autopilot = new AutopilotCoordinator({
|
|
140
|
+
store: autopilotStore,
|
|
141
|
+
now: () => new Date().toISOString(),
|
|
142
|
+
policy: defaultAutopilotPolicy,
|
|
143
|
+
plan: (sessionId) => {
|
|
144
|
+
const plan = supervisedPlans.find(sessionId);
|
|
145
|
+
const identity = supervisedPlans.identity(sessionId);
|
|
146
|
+
return plan && identity ? { plan, identity } : null;
|
|
147
|
+
},
|
|
148
|
+
session: (sessionId) => sessions.find(sessionId),
|
|
149
|
+
activity: (sessionId) => options.autopilotActivity
|
|
150
|
+
? options.autopilotActivity(sessionId)
|
|
151
|
+
: activity.snapshot(sessionId, new Date().toISOString()),
|
|
152
|
+
pendingInteraction: (sessionId) => interactions.list(sessionId).length > 0,
|
|
153
|
+
reconcile: async (sessionId) => {
|
|
154
|
+
if (options.autopilotReconcile)
|
|
155
|
+
return options.autopilotReconcile(sessionId);
|
|
156
|
+
await activity.refresh(sessionId);
|
|
157
|
+
return {
|
|
158
|
+
compatible: activity.snapshot(sessionId, new Date().toISOString()).confidence === 'fresh',
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
schedule: options.autopilotSchedule ??
|
|
162
|
+
((callback, delayMs) => {
|
|
163
|
+
const timer = setTimeout(callback, delayMs);
|
|
164
|
+
return () => clearTimeout(timer);
|
|
165
|
+
}),
|
|
166
|
+
nextControlId: (sessionId, generation) => `autopilot-${generation}-${createHash('sha256').update(`${sessionId}:${randomUUID()}`).digest('hex').slice(0, 16)}`,
|
|
167
|
+
turnStarter: {
|
|
168
|
+
start: async (sessionId, controlId, generation) => {
|
|
169
|
+
const current = () => {
|
|
170
|
+
const state = autopilotStore.find(sessionId);
|
|
171
|
+
return Boolean(state &&
|
|
172
|
+
state.requestedEnabled &&
|
|
173
|
+
state.generation === generation &&
|
|
174
|
+
state.lastControlId === controlId);
|
|
175
|
+
};
|
|
176
|
+
if (!current())
|
|
177
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
178
|
+
const session = sessions.find(sessionId);
|
|
179
|
+
if (!session || !runtime || session.activeTurnId || interactions.list(sessionId).length)
|
|
180
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
181
|
+
// A relay restart can leave a durable, otherwise eligible session without this
|
|
182
|
+
// process's writer. Reacquire through the normal ownership boundary before a
|
|
183
|
+
// synthetic start; never bypass its single-writer and replacement semantics.
|
|
184
|
+
const writer = await runtime.ensureWriter(session, new Date().toISOString());
|
|
185
|
+
if (!current() || writer.session.activeTurnId || interactions.list(sessionId).length)
|
|
186
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
187
|
+
await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
|
|
188
|
+
const started = await runtime.startTurn(writer.session, AUTOPILOT_CONTINUATION_PROMPT, controlId, new Date().toISOString());
|
|
189
|
+
if (!started.activeTurnId)
|
|
190
|
+
throw new Error('AUTOPILOT_START_UNAVAILABLE');
|
|
191
|
+
sessions.save(started);
|
|
192
|
+
await options.autopilotAfterTurnAccepted?.({
|
|
193
|
+
sessionId,
|
|
194
|
+
controlId,
|
|
195
|
+
turnId: started.activeTurnId,
|
|
196
|
+
});
|
|
197
|
+
activity.observe({
|
|
198
|
+
sessionId,
|
|
199
|
+
occurredAt: started.updatedAt,
|
|
200
|
+
kind: 'turnStarted',
|
|
201
|
+
...(started.threadId ? { threadId: started.threadId } : {}),
|
|
202
|
+
...(started.activeTurnId ? { turnId: started.activeTurnId } : {}),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
publish: (sessionId, type, payload, occurredAt, outboxId) => {
|
|
207
|
+
if (!sessions.find(sessionId))
|
|
208
|
+
return;
|
|
209
|
+
events.publish(journal.append(sessionId, type, payload, occurredAt, outboxId));
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
options.onAutopilotCoordinator?.(autopilot);
|
|
81
213
|
const workspaces = new FilesystemWorkspaceCatalog(root);
|
|
82
214
|
const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
|
|
83
215
|
const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
|
|
84
216
|
const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
|
|
85
217
|
const editorSkillCatalog = new CachedSkillCatalog((profile, workspace) => skillCatalog(profile).list(workspace));
|
|
86
|
-
const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog(
|
|
87
|
-
...(planMeasurementHelperPath ? { helperPath: planMeasurementHelperPath } : {}),
|
|
88
|
-
discoverSkills: async (workspace) => (await skillCatalog('default').list(workspace)).skills,
|
|
89
|
-
}));
|
|
218
|
+
const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog();
|
|
90
219
|
const resolveSkills = async (session) => {
|
|
91
220
|
const catalog = await skillCatalog(session.profile).list(session.workspacePath);
|
|
92
221
|
if (session.effectiveSkillSelection)
|
|
@@ -118,20 +247,46 @@ export async function composeRelayApp(options) {
|
|
|
118
247
|
.snapshot(sessionId)
|
|
119
248
|
.find((item) => item.requestId === requestId);
|
|
120
249
|
events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
250
|
+
if (interaction?.kind === 'orgPlanAttention')
|
|
251
|
+
events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
252
|
+
};
|
|
253
|
+
const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
|
|
254
|
+
const remaining = interactions.list(sessionId);
|
|
255
|
+
const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
|
|
256
|
+
activity.observe({
|
|
257
|
+
sessionId,
|
|
258
|
+
occurredAt,
|
|
259
|
+
kind: 'interactionResolved',
|
|
260
|
+
hasPendingInteraction: remaining.length > 0,
|
|
261
|
+
...(attention ? { attentionReason: attention.payload.reason } : {}),
|
|
262
|
+
});
|
|
263
|
+
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
121
264
|
};
|
|
122
|
-
const dismissPendingInteractions = (sessionId, occurredAt) => {
|
|
265
|
+
const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
|
|
123
266
|
for (const interaction of interactions.list(sessionId)) {
|
|
124
|
-
if (interactions.resolve(sessionId, interaction.requestId, occurredAt,
|
|
125
|
-
publishInteractionResolved(sessionId, interaction.requestId, occurredAt,
|
|
267
|
+
if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
|
|
268
|
+
publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
|
|
126
269
|
}
|
|
127
270
|
};
|
|
128
|
-
|
|
271
|
+
let closing = false;
|
|
272
|
+
let runtime = null;
|
|
273
|
+
runtime = options.startAppServers
|
|
129
274
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
130
275
|
const occurredAt = new Date().toISOString();
|
|
276
|
+
const activityFact = decodeAgentActivityFact(sessionId, occurredAt, notification);
|
|
277
|
+
if (activityFact)
|
|
278
|
+
activity.observe(activityFact);
|
|
131
279
|
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
132
|
-
if (resolvedRequestId
|
|
133
|
-
interactions.
|
|
134
|
-
|
|
280
|
+
if (resolvedRequestId) {
|
|
281
|
+
const interaction = interactions.find(sessionId, resolvedRequestId);
|
|
282
|
+
const outcome = interaction?.kind === 'orgPlanAttention' ? 'failed' : 'dismissed';
|
|
283
|
+
if (interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
|
|
284
|
+
if (outcome === 'failed')
|
|
285
|
+
publishAttentionSettlement(sessionId, resolvedRequestId, occurredAt, outcome);
|
|
286
|
+
else
|
|
287
|
+
publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, outcome);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
135
290
|
const currentSession = sessions.find(sessionId);
|
|
136
291
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
|
|
137
292
|
if (!normalized)
|
|
@@ -143,6 +298,7 @@ export async function composeRelayApp(options) {
|
|
|
143
298
|
if (session && turnId && session.activeTurnId === turnId) {
|
|
144
299
|
completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
|
|
145
300
|
sessions.save(completedSession);
|
|
301
|
+
autopilot.turnCompleted(sessionId);
|
|
146
302
|
}
|
|
147
303
|
planMeasurementRefresh?.refreshNow(sessionId);
|
|
148
304
|
}
|
|
@@ -160,14 +316,28 @@ export async function composeRelayApp(options) {
|
|
|
160
316
|
requestedAt: new Date().toISOString(),
|
|
161
317
|
};
|
|
162
318
|
interactions.add(sessionId, interaction);
|
|
319
|
+
activity.observe({
|
|
320
|
+
sessionId,
|
|
321
|
+
occurredAt: interaction.requestedAt,
|
|
322
|
+
kind: 'interactionPending',
|
|
323
|
+
...(interaction.kind === 'orgPlanAttention'
|
|
324
|
+
? { attentionReason: interaction.payload.reason }
|
|
325
|
+
: {}),
|
|
326
|
+
});
|
|
163
327
|
const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
|
|
164
328
|
sessions.save(updated);
|
|
165
329
|
events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
|
|
330
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
331
|
+
events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
|
|
332
|
+
autopilot.evaluate(sessionId);
|
|
166
333
|
return true;
|
|
167
334
|
}, (sessionId) => {
|
|
168
|
-
|
|
335
|
+
activity.disconnected(sessionId, new Date().toISOString());
|
|
336
|
+
dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
|
|
169
337
|
recoverExitedSession(sessionId);
|
|
170
338
|
}, resolveSkills, planStatusSource, (sessionId, update) => {
|
|
339
|
+
if (closing)
|
|
340
|
+
return;
|
|
171
341
|
supervisedPlans.accept(sessionId, update);
|
|
172
342
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
173
343
|
if (update.kind === 'updated') {
|
|
@@ -185,6 +355,7 @@ export async function composeRelayApp(options) {
|
|
|
185
355
|
}
|
|
186
356
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
187
357
|
}
|
|
358
|
+
autopilot.planUpdated(sessionId);
|
|
188
359
|
}, options.planMeasurementBaseUrl, 30_000, 64, root)
|
|
189
360
|
: null;
|
|
190
361
|
if (runtime && planMeasurementHelperPath) {
|
|
@@ -196,8 +367,19 @@ export async function composeRelayApp(options) {
|
|
|
196
367
|
}, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
|
|
197
368
|
}
|
|
198
369
|
const saveSession = (session) => {
|
|
370
|
+
const prior = sessions.find(session.id);
|
|
199
371
|
sessions.save(session);
|
|
200
372
|
events.publish(journal.append(session.id, 'session.updated', session, session.updatedAt));
|
|
373
|
+
const becameRuntimeReady = prior &&
|
|
374
|
+
prior.state !== 'ready' &&
|
|
375
|
+
prior.state !== 'turnActive' &&
|
|
376
|
+
(session.state === 'ready' || session.state === 'turnActive');
|
|
377
|
+
if (runtime &&
|
|
378
|
+
session.threadId &&
|
|
379
|
+
(!prior || prior.threadId !== session.threadId || becameRuntimeReady))
|
|
380
|
+
void activity.refresh(session.id);
|
|
381
|
+
if (becameRuntimeReady)
|
|
382
|
+
autopilot.restore(session.id);
|
|
201
383
|
};
|
|
202
384
|
if (runtime) {
|
|
203
385
|
const supervisor = new SessionSupervisor(async (sessionId) => {
|
|
@@ -206,7 +388,8 @@ export async function composeRelayApp(options) {
|
|
|
206
388
|
return;
|
|
207
389
|
const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
|
|
208
390
|
saveSession(recovering);
|
|
209
|
-
|
|
391
|
+
const restored = await runtime.restore(recovering, new Date().toISOString());
|
|
392
|
+
saveSession(restored);
|
|
210
393
|
}, (sessionId) => {
|
|
211
394
|
const session = sessions.find(sessionId);
|
|
212
395
|
if (session)
|
|
@@ -217,8 +400,10 @@ export async function composeRelayApp(options) {
|
|
|
217
400
|
recoverExitedSession = (sessionId) => {
|
|
218
401
|
supervisor.cancel(sessionId);
|
|
219
402
|
const session = sessions.find(sessionId);
|
|
220
|
-
if (session)
|
|
403
|
+
if (session) {
|
|
404
|
+
autopilot.cancel(sessionId, 'sessionEnded');
|
|
221
405
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
406
|
+
}
|
|
222
407
|
};
|
|
223
408
|
}
|
|
224
409
|
let authorization;
|
|
@@ -313,11 +498,15 @@ export async function composeRelayApp(options) {
|
|
|
313
498
|
? async (session, settings) => {
|
|
314
499
|
const now = new Date().toISOString();
|
|
315
500
|
dismissPendingInteractions(session.id, now);
|
|
316
|
-
|
|
501
|
+
const started = await runtime.start(session, now, settings);
|
|
502
|
+
return started;
|
|
317
503
|
}
|
|
318
504
|
: undefined,
|
|
319
505
|
startTurn: runtime
|
|
320
|
-
? async (session, text, clientUserMessageId) =>
|
|
506
|
+
? async (session, text, clientUserMessageId) => {
|
|
507
|
+
autopilot.manualSend(session.id);
|
|
508
|
+
return runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString());
|
|
509
|
+
}
|
|
321
510
|
: undefined,
|
|
322
511
|
ensureWriter: runtime
|
|
323
512
|
? (session) => {
|
|
@@ -331,7 +520,21 @@ export async function composeRelayApp(options) {
|
|
|
331
520
|
return runtime.release(id);
|
|
332
521
|
}
|
|
333
522
|
: undefined,
|
|
334
|
-
onTurnStarted: (session) =>
|
|
523
|
+
onTurnStarted: (session) => {
|
|
524
|
+
activity.observe({
|
|
525
|
+
sessionId: session.id,
|
|
526
|
+
occurredAt: session.updatedAt,
|
|
527
|
+
kind: 'turnStarted',
|
|
528
|
+
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
529
|
+
...(session.activeTurnId ? { turnId: session.activeTurnId } : {}),
|
|
530
|
+
});
|
|
531
|
+
planMeasurementRefresh?.refreshNow(session.id);
|
|
532
|
+
},
|
|
533
|
+
agentActivity: (id) => activity.snapshot(id, new Date().toISOString()),
|
|
534
|
+
autopilotSnapshot: (id) => autopilot.snapshot(id),
|
|
535
|
+
autopilotControlTurns: (id) => autopilot.acceptedControlTurns(id),
|
|
536
|
+
autopilotAudit: (id, limit) => journal.autopilotAuditTail(id, limit),
|
|
537
|
+
refreshActivity: (id) => activity.refresh(id),
|
|
335
538
|
models,
|
|
336
539
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
337
540
|
currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
|
|
@@ -339,8 +542,20 @@ export async function composeRelayApp(options) {
|
|
|
339
542
|
? (session, turnId) => runtime.interruptTurn(session, turnId)
|
|
340
543
|
: undefined,
|
|
341
544
|
restore: runtime
|
|
342
|
-
? (session) =>
|
|
545
|
+
? async (session) => {
|
|
546
|
+
const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
|
|
547
|
+
// An exit can be reported while resume is resolving. Do not let the
|
|
548
|
+
// route persist a stale ready snapshot over that recovered exit.
|
|
549
|
+
if (!runtime.ownsWriter(session.id))
|
|
550
|
+
return {
|
|
551
|
+
...restored,
|
|
552
|
+
session: RelaySession.rehydrate(restored.session).stop(new Date().toISOString())
|
|
553
|
+
.snapshot,
|
|
554
|
+
};
|
|
555
|
+
return restored;
|
|
556
|
+
}
|
|
343
557
|
: undefined,
|
|
558
|
+
ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
|
|
344
559
|
promoteRecent: runtime
|
|
345
560
|
? (thread) => promoteRecentThread(thread, {
|
|
346
561
|
createId: randomUUID,
|
|
@@ -350,13 +565,22 @@ export async function composeRelayApp(options) {
|
|
|
350
565
|
read: (session) => runtime.readHistory(session),
|
|
351
566
|
})
|
|
352
567
|
: undefined,
|
|
353
|
-
release: (session) =>
|
|
354
|
-
|
|
568
|
+
release: (session) => {
|
|
569
|
+
autopilot.cancel(session.id, 'sessionEnded');
|
|
570
|
+
return RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot;
|
|
571
|
+
},
|
|
572
|
+
remove: (id) => {
|
|
573
|
+
autopilot.cancel(id, 'sessionEnded');
|
|
574
|
+
activity.dispose(id);
|
|
575
|
+
sessions.remove(id);
|
|
576
|
+
},
|
|
355
577
|
idempotency,
|
|
356
578
|
close: runtime
|
|
357
579
|
? (id) => {
|
|
580
|
+
autopilot.cancel(id, 'sessionEnded');
|
|
358
581
|
planMeasurementRefresh?.stop(id);
|
|
359
582
|
dismissPendingInteractions(id, new Date().toISOString());
|
|
583
|
+
activity.dispose(id);
|
|
360
584
|
return runtime.release(id);
|
|
361
585
|
}
|
|
362
586
|
: undefined,
|
|
@@ -364,6 +588,17 @@ export async function composeRelayApp(options) {
|
|
|
364
588
|
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
|
|
365
589
|
: undefined,
|
|
366
590
|
interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
|
|
591
|
+
const remaining = interactions.list(sessionId);
|
|
592
|
+
const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
|
|
593
|
+
activity.observe({
|
|
594
|
+
sessionId,
|
|
595
|
+
occurredAt,
|
|
596
|
+
kind: 'interactionResolved',
|
|
597
|
+
hasPendingInteraction: remaining.length > 0,
|
|
598
|
+
...(attention
|
|
599
|
+
? { attentionReason: attention.payload.reason }
|
|
600
|
+
: {}),
|
|
601
|
+
});
|
|
367
602
|
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
368
603
|
},
|
|
369
604
|
},
|
|
@@ -384,12 +619,14 @@ export async function composeRelayApp(options) {
|
|
|
384
619
|
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
385
620
|
clear: (id) => supervisedPlans.clear(id),
|
|
386
621
|
closed: (id) => {
|
|
622
|
+
autopilot.cancel(id, 'planRemoved');
|
|
387
623
|
planMeasurementRefresh?.stop(id);
|
|
388
624
|
const occurredAt = new Date().toISOString();
|
|
389
625
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
390
626
|
},
|
|
391
627
|
},
|
|
392
628
|
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
629
|
+
autopilot,
|
|
393
630
|
...(runtime
|
|
394
631
|
? {
|
|
395
632
|
planMeasurementRoutes: {
|
|
@@ -415,9 +652,108 @@ export async function composeRelayApp(options) {
|
|
|
415
652
|
return false;
|
|
416
653
|
if (interaction.kind === 'quiz')
|
|
417
654
|
return isValidQuizInteractionResponse(interaction.payload, value);
|
|
655
|
+
// Attention must use its operation-keyed boundary; generic interaction
|
|
656
|
+
// responses deliberately cannot bypass durable operation identity.
|
|
657
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
658
|
+
return false;
|
|
418
659
|
return isValidInteractionResponse(interaction.kind, value);
|
|
419
660
|
},
|
|
420
661
|
},
|
|
662
|
+
orgPlanAttention: {
|
|
663
|
+
exists: (id) => sessions.find(id) !== null,
|
|
664
|
+
reader: {
|
|
665
|
+
active: (sessionId) => {
|
|
666
|
+
const interaction = interactions
|
|
667
|
+
.list(sessionId)
|
|
668
|
+
.find((item) => item.kind === 'orgPlanAttention');
|
|
669
|
+
const attention = interaction ? parseOrgPlanAttention(interaction.payload) : null;
|
|
670
|
+
return interaction && attention
|
|
671
|
+
? {
|
|
672
|
+
requestId: interaction.requestId,
|
|
673
|
+
turnId: interaction.turnId ?? null,
|
|
674
|
+
requestedAt: interaction.requestedAt ?? null,
|
|
675
|
+
attention,
|
|
676
|
+
}
|
|
677
|
+
: null;
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
resolver: {
|
|
681
|
+
resolve: async ({ sessionId, requestId, operationKey, response }) => {
|
|
682
|
+
const scope = `org-plan-attention:${sessionId}:${requestId}`;
|
|
683
|
+
const stored = idempotency.get(scope, operationKey);
|
|
684
|
+
if (stored)
|
|
685
|
+
return JSON.parse(stored.body);
|
|
686
|
+
const terminal = interactions.terminalOperation(sessionId, requestId);
|
|
687
|
+
if (terminal)
|
|
688
|
+
return terminal.operationKey === operationKey
|
|
689
|
+
? terminal.outcome === 'failed'
|
|
690
|
+
? { kind: 'writerCleared', resolvedAt: terminal.resolvedAt }
|
|
691
|
+
: { kind: 'replayed', resolvedAt: terminal.resolvedAt }
|
|
692
|
+
: { kind: 'staleOperation' };
|
|
693
|
+
const key = `${scope}:${operationKey}`;
|
|
694
|
+
const inFlight = attentionResolutionOperations.get(key);
|
|
695
|
+
if (inFlight)
|
|
696
|
+
return inFlight;
|
|
697
|
+
const operation = (async () => {
|
|
698
|
+
const interaction = interactions.find(sessionId, requestId);
|
|
699
|
+
if (!interaction || interaction.kind !== 'orgPlanAttention')
|
|
700
|
+
return { kind: 'noActive' };
|
|
701
|
+
const claim = interactions.claimOperation(sessionId, requestId, operationKey);
|
|
702
|
+
if (claim === 'resolved') {
|
|
703
|
+
const resolved = interactions.resolved(sessionId, requestId);
|
|
704
|
+
return resolved
|
|
705
|
+
? { kind: 'replayed', resolvedAt: resolved.resolvedAt }
|
|
706
|
+
: { kind: 'staleOperation' };
|
|
707
|
+
}
|
|
708
|
+
if (claim === 'stale')
|
|
709
|
+
return { kind: 'staleOperation' };
|
|
710
|
+
if (claim === 'missing')
|
|
711
|
+
return { kind: 'noActive' };
|
|
712
|
+
// A durable capability belongs to the session/thread, not the
|
|
713
|
+
// relay process. A supported stopped writer is retryable;
|
|
714
|
+
// missing capability identifies a pre-rollout legacy thread.
|
|
715
|
+
if (sessions.find(sessionId)?.attentionToolCapability !== 'supported')
|
|
716
|
+
return { kind: 'legacyUnsupported' };
|
|
717
|
+
if (!runtime)
|
|
718
|
+
return { kind: 'writerUnavailable' };
|
|
719
|
+
if (!interactions.beginDelivery(sessionId, requestId, operationKey))
|
|
720
|
+
return { kind: 'staleOperation' };
|
|
721
|
+
const writer = runtime.attentionWriterState(sessionId, requestId);
|
|
722
|
+
if (writer === 'unavailable') {
|
|
723
|
+
interactions.retryDelivery(sessionId, requestId, operationKey);
|
|
724
|
+
return { kind: 'writerUnavailable' };
|
|
725
|
+
}
|
|
726
|
+
const resolvedAt = new Date().toISOString();
|
|
727
|
+
if (writer === 'cleared') {
|
|
728
|
+
if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'failed'))
|
|
729
|
+
return { kind: 'staleOperation' };
|
|
730
|
+
publishAttentionSettlement(sessionId, requestId, resolvedAt, 'failed');
|
|
731
|
+
return { kind: 'writerCleared', resolvedAt };
|
|
732
|
+
}
|
|
733
|
+
if (!runtime.resolveServerRequest(sessionId, requestId, response)) {
|
|
734
|
+
// The state check and delivery are synchronous, but retain a
|
|
735
|
+
// defensive retry path for a future runtime implementation.
|
|
736
|
+
interactions.retryDelivery(sessionId, requestId, operationKey);
|
|
737
|
+
return { kind: 'writerUnavailable' };
|
|
738
|
+
}
|
|
739
|
+
if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'answered'))
|
|
740
|
+
return { kind: 'staleOperation' };
|
|
741
|
+
const accepted = { kind: 'accepted', resolvedAt };
|
|
742
|
+
idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
|
|
743
|
+
publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
|
|
744
|
+
return accepted;
|
|
745
|
+
})();
|
|
746
|
+
attentionResolutionOperations.set(key, operation);
|
|
747
|
+
try {
|
|
748
|
+
return await operation;
|
|
749
|
+
}
|
|
750
|
+
finally {
|
|
751
|
+
attentionResolutionOperations.delete(key);
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
transitions: attentionTransitions,
|
|
756
|
+
},
|
|
421
757
|
gitSummary: {
|
|
422
758
|
workspaces,
|
|
423
759
|
inspect: async (path) => {
|
|
@@ -453,10 +789,18 @@ export async function composeRelayApp(options) {
|
|
|
453
789
|
throw error;
|
|
454
790
|
}
|
|
455
791
|
const detachActiveSessions = async () => {
|
|
456
|
-
await mapWithConcurrency(sessions
|
|
457
|
-
|
|
792
|
+
await mapWithConcurrency(sessions
|
|
793
|
+
.list()
|
|
794
|
+
.filter((session) => persistedSessionIds.has(session.id) && session.threadId !== null), 2, async (session) => {
|
|
795
|
+
// The status lease must repopulate the authoritative plan projection before
|
|
796
|
+
// a durable coordinator is restored. Writer detachment is a process concern,
|
|
797
|
+
// not a human disable: a later fenced continuation can reacquire it safely.
|
|
798
|
+
if (session.desiredState === 'active') {
|
|
458
799
|
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
800
|
+
await runtime?.release(session.id);
|
|
801
|
+
}
|
|
459
802
|
await runtime?.watchPlanStatus(session);
|
|
803
|
+
autopilot.restore(session.id);
|
|
460
804
|
});
|
|
461
805
|
};
|
|
462
806
|
app.addHook('onListen', async () => {
|
|
@@ -466,9 +810,22 @@ export async function composeRelayApp(options) {
|
|
|
466
810
|
await detachActiveSessions();
|
|
467
811
|
});
|
|
468
812
|
app.addHook('onClose', async () => {
|
|
813
|
+
closing = true;
|
|
469
814
|
planMeasurementRefresh?.stopAll();
|
|
470
|
-
for (const session of sessions.list())
|
|
471
|
-
|
|
815
|
+
for (const session of sessions.list()) {
|
|
816
|
+
autopilot.dispose(session.id);
|
|
817
|
+
activity.dispose(session.id);
|
|
818
|
+
// Relay shutdown only releases this process's writer. A typed attention
|
|
819
|
+
// request remains a durable human-visible blocker for the next relay
|
|
820
|
+
// instance; only an app-server-cleared request is a failed audit outcome.
|
|
821
|
+
for (const interaction of interactions.list(session.id)) {
|
|
822
|
+
if (interaction.kind === 'orgPlanAttention')
|
|
823
|
+
continue;
|
|
824
|
+
const occurredAt = new Date().toISOString();
|
|
825
|
+
if (interactions.resolve(session.id, interaction.requestId, occurredAt, 'dismissed'))
|
|
826
|
+
publishInteractionResolved(session.id, interaction.requestId, occurredAt, 'dismissed');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
472
829
|
runtime?.stopAll();
|
|
473
830
|
planStatusSource.closeAll();
|
|
474
831
|
database.close();
|