gestalt-mobile 0.25.2 → 0.25.4

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.
@@ -0,0 +1,255 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { orgPlanAgentDisplayName } from '../../../../shared/org-plan-position.js';
7
+ export const blockingResumeConditions = {
8
+ planChange: 'planRevision',
9
+ hardBlock: 'externalStateChanged',
10
+ missingDependency: 'dependencyInstalled',
11
+ permissionRequired: 'permissionGranted',
12
+ externalState: 'externalStateChanged',
13
+ materialAmbiguity: 'userGuidance',
14
+ };
15
+ export function validStructuredBlock(value) {
16
+ return Boolean(value && blockingResumeConditions[value.reason] === value.resumeCondition);
17
+ }
18
+ export function parsePersistedSupervisedLifecycle(value) {
19
+ const root = record(value);
20
+ if (!root)
21
+ return undefined;
22
+ const blockingValue = record(root.blocking);
23
+ const blocking = blockingValue ? parseStructuredBlock(blockingValue) : undefined;
24
+ if (blockingValue && !blocking)
25
+ return undefined;
26
+ const executorValue = record(root.executor);
27
+ if (!executorValue)
28
+ return blocking ? { blocking } : {};
29
+ const processesValue = executorValue.ownedProcesses;
30
+ if (!Array.isArray(processesValue) || processesValue.length > 64)
31
+ return undefined;
32
+ const ownedProcesses = processesValue.flatMap((candidate) => {
33
+ const process = record(candidate);
34
+ if (!process)
35
+ return [];
36
+ const processId = boundedText(process.processId);
37
+ const itemId = boundedText(process.itemId);
38
+ const ownerThreadId = boundedText(process.ownerThreadId);
39
+ const ownerTaskPath = boundedText(process.ownerTaskPath);
40
+ const ownership = stringValue(process.ownership, ['executor', 'supervisor']);
41
+ const state = stringValue(process.state, [
42
+ 'running',
43
+ 'detached-active',
44
+ 'exited-awaiting-result',
45
+ 'result-consumed',
46
+ 'terminated-for-budget',
47
+ ]);
48
+ const observedAt = boundedText(process.observedAt);
49
+ const elapsedMs = nonNegativeNumber(process.elapsedMs);
50
+ const cpuPercent = nullableNonNegativeNumber(process.cpuPercent);
51
+ const rssBytes = nullableNonNegativeNumber(process.rssBytes);
52
+ if (!processId ||
53
+ !itemId ||
54
+ !ownerThreadId ||
55
+ !ownerTaskPath ||
56
+ !ownership ||
57
+ !state ||
58
+ !observedAt ||
59
+ elapsedMs === null ||
60
+ cpuPercent === undefined ||
61
+ rssBytes === undefined)
62
+ return [];
63
+ const osPid = nonNegativeInteger(process.osPid);
64
+ const exitStatus = integer(process.exitStatus);
65
+ const resultArtifact = boundedText(process.resultArtifact);
66
+ return [
67
+ {
68
+ processId,
69
+ itemId,
70
+ ownerThreadId,
71
+ ownerTaskPath,
72
+ ownership,
73
+ state,
74
+ observedAt,
75
+ elapsedMs,
76
+ cpuPercent,
77
+ rssBytes,
78
+ ...(osPid === null ? {} : { osPid }),
79
+ ...(exitStatus === null ? {} : { exitStatus }),
80
+ ...(resultArtifact ? { resultArtifact } : {}),
81
+ },
82
+ ];
83
+ });
84
+ if (ownedProcesses.length !== processesValue.length)
85
+ return undefined;
86
+ const canonicalPosition = boundedText(executorValue.canonicalPosition);
87
+ const canonicalTaskName = boundedText(executorValue.canonicalTaskName);
88
+ const taskPath = boundedText(executorValue.taskPath);
89
+ const threadId = boundedText(executorValue.threadId);
90
+ const l1State = stringValue(executorValue.l1State, ['TODO', 'WIP', 'DONE']);
91
+ const l2State = stringValue(executorValue.l2State, ['TODO', 'WIP', 'DONE']);
92
+ const lastActivityAt = boundedText(executorValue.lastActivityAt);
93
+ const outcome = stringValue(executorValue.outcome, [
94
+ 'objective_complete',
95
+ 'partial',
96
+ 'blocked',
97
+ 'cancelled',
98
+ 'failed',
99
+ ]);
100
+ const continuationGeneration = nonNegativeInteger(executorValue.continuationGeneration);
101
+ const continuationCount = nonNegativeInteger(executorValue.continuationCount);
102
+ if (!canonicalPosition ||
103
+ !canonicalTaskName ||
104
+ !taskPath ||
105
+ !threadId ||
106
+ !l1State ||
107
+ !lastActivityAt ||
108
+ !outcome ||
109
+ continuationGeneration === null ||
110
+ continuationCount === null)
111
+ return undefined;
112
+ const executorBlockingValue = record(executorValue.blocking);
113
+ const executorBlocking = executorBlockingValue
114
+ ? parseStructuredBlock(executorBlockingValue)
115
+ : undefined;
116
+ if (executorBlockingValue && !executorBlocking)
117
+ return undefined;
118
+ return {
119
+ executor: {
120
+ canonicalPosition,
121
+ canonicalTaskName,
122
+ taskPath,
123
+ threadId,
124
+ l1State,
125
+ ...(l2State ? { l2State } : {}),
126
+ lastActivityAt,
127
+ ownedProcesses,
128
+ outcome,
129
+ ...(executorBlocking ? { blocking: executorBlocking } : {}),
130
+ continuationGeneration,
131
+ continuationCount,
132
+ },
133
+ ...(blocking ? { blocking } : {}),
134
+ };
135
+ }
136
+ export function classifyExecutorOutcome(input) {
137
+ if (input.objectiveComplete)
138
+ return { outcome: 'objective_complete' };
139
+ if (input.reportedOutcome === 'cancelled' || input.reportedOutcome === 'failed')
140
+ return { outcome: input.reportedOutcome };
141
+ const blocking = input.blockingReason && input.resumeCondition
142
+ ? { reason: input.blockingReason, resumeCondition: input.resumeCondition }
143
+ : undefined;
144
+ if (input.reportedOutcome === 'blocked' && validStructuredBlock(blocking))
145
+ return { outcome: 'blocked', blocking };
146
+ // A turn ending, a checkpoint, or free-form language about time/context is
147
+ // not objective state. Incomplete Org state remains mechanically partial.
148
+ return { outcome: 'partial' };
149
+ }
150
+ export function decideSupervisedLifecycle(input) {
151
+ if (input.explicitlyPausedOrCancelled || executionComplete(input.plan))
152
+ return { finalAllowed: true, action: { kind: 'allowFinal' } };
153
+ if (validStructuredBlock(input.attention))
154
+ return {
155
+ finalAllowed: true,
156
+ action: { kind: 'invokeAttention', ...input.attention },
157
+ };
158
+ const processes = input.executor?.ownedProcesses ?? [];
159
+ const overBudget = processes.find((process) => (process.state === 'running' || process.state === 'detached-active') &&
160
+ (process.elapsedMs > input.policy.processMaxElapsedMs ||
161
+ (process.rssBytes !== null && process.rssBytes > input.policy.processMaxRssBytes)));
162
+ if (overBudget)
163
+ return {
164
+ finalAllowed: false,
165
+ action: {
166
+ kind: 'terminateProcess',
167
+ threadId: overBudget.ownerThreadId,
168
+ processId: overBudget.processId,
169
+ },
170
+ };
171
+ const exited = processes.find((process) => process.state === 'exited-awaiting-result' && process.resultArtifact);
172
+ if (exited)
173
+ return {
174
+ finalAllowed: false,
175
+ action: {
176
+ kind: 'consumeProcessResult',
177
+ threadId: exited.ownerThreadId,
178
+ processId: exited.processId,
179
+ resultArtifact: exited.resultArtifact,
180
+ },
181
+ };
182
+ const active = processes.find((process) => process.state === 'running' || process.state === 'detached-active');
183
+ if (active)
184
+ return {
185
+ finalAllowed: false,
186
+ action: {
187
+ kind: 'monitorProcess',
188
+ process: { ...active, ownership: 'supervisor', state: 'detached-active' },
189
+ pollAfterMs: input.policy.processPollMs,
190
+ },
191
+ };
192
+ if (input.event === 'waitTimeout' && !input.executor)
193
+ return { finalAllowed: false, action: { kind: 'reinspect' } };
194
+ if (!input.executor || input.executor.l1State === 'DONE')
195
+ return { finalAllowed: false, action: { kind: 'continueSupervisor' } };
196
+ const exponent = Math.min(30, Math.max(0, input.executor.continuationCount));
197
+ const delayMs = Math.min(input.policy.continuationMaxDelayMs, input.policy.continuationBaseDelayMs * 2 ** exponent);
198
+ return {
199
+ finalAllowed: false,
200
+ action: {
201
+ kind: 'resumeExecutor',
202
+ threadId: input.executor.threadId,
203
+ generation: input.executor.continuationGeneration + 1,
204
+ delayMs,
205
+ },
206
+ };
207
+ }
208
+ export function executorIdentity(canonicalTaskName, generation) {
209
+ if (!/^l[1-9]\d*(?:_[1-9]\d*)?$/.test(canonicalTaskName) ||
210
+ !Number.isSafeInteger(generation) ||
211
+ generation < 1)
212
+ throw new Error('INVALID_EXECUTOR_IDENTITY');
213
+ return {
214
+ canonicalTaskName,
215
+ canonicalPosition: orgPlanAgentDisplayName(canonicalTaskName),
216
+ generation,
217
+ taskName: generation === 1 ? canonicalTaskName : `${canonicalTaskName}_g${generation}`,
218
+ };
219
+ }
220
+ function executionComplete(plan) {
221
+ return (plan.executionComplete ??
222
+ plan.steps.every((step) => step.state === 'DONE' &&
223
+ step.reviewStatus === 'REVIEWED' &&
224
+ step.children.every((child) => child.state === 'DONE')));
225
+ }
226
+ function record(value) {
227
+ return value && typeof value === 'object' && !Array.isArray(value)
228
+ ? value
229
+ : undefined;
230
+ }
231
+ function parseStructuredBlock(value) {
232
+ const reason = stringValue(value.reason, Object.keys(blockingResumeConditions));
233
+ const resumeCondition = stringValue(value.resumeCondition, Object.values(blockingResumeConditions));
234
+ const blocking = reason && resumeCondition ? { reason, resumeCondition } : undefined;
235
+ return validStructuredBlock(blocking) ? blocking : undefined;
236
+ }
237
+ function boundedText(value) {
238
+ return typeof value === 'string' && value.length > 0 && value.length <= 512 ? value : undefined;
239
+ }
240
+ function stringValue(value, values) {
241
+ return typeof value === 'string' && values.includes(value) ? value : undefined;
242
+ }
243
+ function nonNegativeNumber(value) {
244
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
245
+ }
246
+ function nullableNonNegativeNumber(value) {
247
+ return value === null ? null : (nonNegativeNumber(value) ?? undefined);
248
+ }
249
+ function integer(value) {
250
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
251
+ }
252
+ function nonNegativeInteger(value) {
253
+ const parsed = integer(value);
254
+ return parsed !== null && parsed >= 0 ? parsed : null;
255
+ }
@@ -29,6 +29,8 @@ class SessionResource {
29
29
  writtenThreadName;
30
30
  capabilities = new Map();
31
31
  spawnedAgentModels = new Map();
32
+ attemptedAgentModelRecovery = new Set();
33
+ ownedChildProcesses = new Map();
32
34
  constructor(sessionId, process, planStatusLease, planMeasurementToken, unregister, onDisposed) {
33
35
  this.sessionId = sessionId;
34
36
  this.process = process;
@@ -182,6 +184,17 @@ export class CodexSessionRuntime {
182
184
  }));
183
185
  return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
184
186
  }
187
+ async startExecutorTurn(session, childThreadId, text, clientUserMessageId) {
188
+ const resource = this.sessions.get(session.id);
189
+ if (!resource)
190
+ throw new Error('CODEX_SESSION_NOT_RUNNING');
191
+ return decodeTurnStart(await resource.process.rpc.request('turn/start', {
192
+ threadId: childThreadId,
193
+ input: [{ type: 'text', text, text_elements: [] }],
194
+ clientUserMessageId,
195
+ ...(session.model ? { model: session.model } : {}),
196
+ }));
197
+ }
185
198
  /** The sole authoritative in-process ownership probe. It never launches a child. */
186
199
  ownsWriter(sessionId) {
187
200
  return this.sessions.get(sessionId)?.active === true;
@@ -315,6 +328,9 @@ export class CodexSessionRuntime {
315
328
  ...(owned.spawnedAgentModels.get(value.id)
316
329
  ? { model: owned.spawnedAgentModels.get(value.id) }
317
330
  : {}),
331
+ ...(decodeAgentTaskPath(value.source)
332
+ ? { taskPath: decodeAgentTaskPath(value.source) }
333
+ : {}),
318
334
  },
319
335
  ];
320
336
  }));
@@ -322,7 +338,7 @@ export class CodexSessionRuntime {
322
338
  ? response.nextCursor
323
339
  : undefined;
324
340
  if (!next)
325
- return children;
341
+ return this.withResolvedChildModels(owned, children);
326
342
  if (cursors.has(next) || children.length >= 64)
327
343
  throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
328
344
  cursors.add(next);
@@ -330,7 +346,159 @@ export class CodexSessionRuntime {
330
346
  }
331
347
  if (cursor)
332
348
  throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
333
- return children;
349
+ return this.withResolvedChildModels(owned, children);
350
+ }
351
+ async withResolvedChildModels(resource, children) {
352
+ for (const child of children) {
353
+ if (child.status !== 'notLoaded' ||
354
+ resource.spawnedAgentModels.has(child.id) ||
355
+ resource.attemptedAgentModelRecovery.has(child.id))
356
+ continue;
357
+ resource.attemptedAgentModelRecovery.add(child.id);
358
+ try {
359
+ const response = await resource.process.rpc.request('thread/resume', {
360
+ threadId: child.id,
361
+ });
362
+ const model = boundedString(asRecord(response)?.model, 256);
363
+ if (model)
364
+ resource.spawnedAgentModels.set(child.id, model);
365
+ }
366
+ catch {
367
+ // A live settings notification can still provide the model later.
368
+ }
369
+ }
370
+ return children.map((child) => {
371
+ const model = child.model ?? resource.spawnedAgentModels.get(child.id);
372
+ return model && model !== child.model ? { ...child, model } : child;
373
+ });
374
+ }
375
+ /** Reads only bounded process metadata; command text and output never enter lifecycle state. */
376
+ async inspectChildProcesses(session, child) {
377
+ const owned = this.sessions.get(session.id);
378
+ if (!owned)
379
+ return [];
380
+ const active = await this.listChildBackgroundTerminals(owned, child);
381
+ const activeIds = new Set(active.map((process) => process.processId));
382
+ for (const process of active)
383
+ owned.ownedChildProcesses.set(childProcessKey(child.id, process.processId), process);
384
+ const prior = [...owned.ownedChildProcesses.values()].filter((process) => process.ownerThreadId === child.id && !activeIds.has(process.processId));
385
+ if (prior.some((process) => process.state === 'running' || process.state === 'detached-active')) {
386
+ const results = await this.readChildProcessResults(owned, child.id);
387
+ for (const process of prior) {
388
+ if (process.state !== 'running' && process.state !== 'detached-active')
389
+ continue;
390
+ const result = results.get(process.itemId);
391
+ owned.ownedChildProcesses.set(childProcessKey(child.id, process.processId), {
392
+ ...process,
393
+ state: 'exited-awaiting-result',
394
+ cpuPercent: 0,
395
+ rssBytes: 0,
396
+ ...(result?.exitStatus === undefined ? {} : { exitStatus: result.exitStatus }),
397
+ resultArtifact: `${child.id}:${process.itemId}`,
398
+ });
399
+ }
400
+ }
401
+ return [...owned.ownedChildProcesses.values()].filter((process) => process.ownerThreadId === child.id &&
402
+ process.state !== 'result-consumed' &&
403
+ process.state !== 'terminated-for-budget');
404
+ }
405
+ consumeChildProcessResult(sessionId, childThreadId, processId) {
406
+ this.sessions
407
+ .get(sessionId)
408
+ ?.ownedChildProcesses.delete(childProcessKey(childThreadId, processId));
409
+ }
410
+ async terminateChildProcess(session, childThreadId, processId) {
411
+ const owned = this.sessions.get(session.id);
412
+ if (!owned)
413
+ return false;
414
+ const result = await owned.process.rpc.request('thread/backgroundTerminals/terminate', {
415
+ threadId: childThreadId,
416
+ processId,
417
+ });
418
+ const terminated = asRecord(result)?.terminated === true;
419
+ if (terminated) {
420
+ const key = childProcessKey(childThreadId, processId);
421
+ const process = owned.ownedChildProcesses.get(key);
422
+ if (process)
423
+ owned.ownedChildProcesses.set(key, { ...process, state: 'terminated-for-budget' });
424
+ }
425
+ return terminated;
426
+ }
427
+ async listChildBackgroundTerminals(owned, child) {
428
+ const processes = [];
429
+ const cursors = new Set();
430
+ let cursor;
431
+ for (let page = 0; page < 4 && processes.length < 64; page += 1) {
432
+ const result = await owned.process.rpc.request('thread/backgroundTerminals/list', {
433
+ threadId: child.id,
434
+ limit: 64,
435
+ ...(cursor ? { cursor } : {}),
436
+ });
437
+ const response = asRecord(result);
438
+ const data = Array.isArray(response?.data) ? response.data : [];
439
+ for (const candidate of data) {
440
+ const value = asRecord(candidate);
441
+ const itemId = boundedString(value?.itemId, 256);
442
+ const processId = boundedString(value?.processId, 256);
443
+ if (!itemId || !processId || processes.length >= 64)
444
+ continue;
445
+ const key = childProcessKey(child.id, processId);
446
+ const before = owned.ownedChildProcesses.get(key);
447
+ const observedAt = before?.observedAt ?? new Date().toISOString();
448
+ const rssKb = boundedNonNegativeNumber(value?.rssKb);
449
+ processes.push({
450
+ processId,
451
+ itemId,
452
+ ownerThreadId: child.id,
453
+ ownerTaskPath: child.taskPath ?? child.id,
454
+ ownership: before?.ownership ?? 'executor',
455
+ state: before?.state === 'detached-active' ? 'detached-active' : 'running',
456
+ observedAt,
457
+ elapsedMs: Math.max(0, Date.now() - Date.parse(observedAt)),
458
+ cpuPercent: boundedNonNegativeNumber(value?.cpuPercent),
459
+ rssBytes: rssKb === null ? null : Math.min(Number.MAX_SAFE_INTEGER, rssKb * 1024),
460
+ ...(boundedNonNegativeInteger(value?.osPid) === null
461
+ ? {}
462
+ : { osPid: boundedNonNegativeInteger(value?.osPid) }),
463
+ });
464
+ }
465
+ const next = boundedString(response?.nextCursor, 256);
466
+ if (!next)
467
+ return processes;
468
+ if (cursors.has(next) || processes.length >= 64)
469
+ throw new Error('CODEX_BACKGROUND_TERMINAL_LIST_UNSUPPORTED');
470
+ cursors.add(next);
471
+ cursor = next;
472
+ }
473
+ if (cursor)
474
+ throw new Error('CODEX_BACKGROUND_TERMINAL_LIST_UNSUPPORTED');
475
+ return processes;
476
+ }
477
+ async readChildProcessResults(owned, childThreadId) {
478
+ const result = await owned.process.rpc.request('thread/read', {
479
+ threadId: childThreadId,
480
+ includeTurns: true,
481
+ });
482
+ const decoded = new Map();
483
+ const turns = asRecord(asRecord(result)?.thread)?.turns;
484
+ if (!Array.isArray(turns) || turns.length > 10_000)
485
+ return decoded;
486
+ for (const turn of turns) {
487
+ const items = asRecord(turn)?.items;
488
+ if (!Array.isArray(items) || items.length > 10_000)
489
+ continue;
490
+ for (const candidate of items) {
491
+ const item = asRecord(candidate);
492
+ const itemId = boundedString(item?.id, 256);
493
+ if (item?.type !== 'commandExecution' ||
494
+ !itemId ||
495
+ !['completed', 'failed', 'declined'].includes(String(item.status)))
496
+ continue;
497
+ const exitStatus = boundedInteger(item.exitCode);
498
+ decoded.set(itemId, exitStatus === null ? {} : { exitStatus });
499
+ }
500
+ }
501
+ return decoded;
334
502
  }
335
503
  async readDetachedHistory(session) {
336
504
  // A reader is intentionally not a SessionResource: it owns no subscriptions,
@@ -353,7 +521,6 @@ export class CodexSessionRuntime {
353
521
  async decodeHistory(process, threadId, resource) {
354
522
  const response = await process.rpc.request('thread/read', { threadId, includeTurns: true });
355
523
  if (resource) {
356
- resource.spawnedAgentModels.clear();
357
524
  for (const [childId, model] of decodeSpawnedAgentModels(response))
358
525
  resource.spawnedAgentModels.set(childId, model);
359
526
  }
@@ -525,6 +692,11 @@ export class CodexSessionRuntime {
525
692
  const notificationUnsubscribe = process.rpc.onNotification((notification) => {
526
693
  if (!resource.active)
527
694
  return;
695
+ const childModel = decodeThreadSettingsModel(notification);
696
+ if (childModel &&
697
+ (resource.spawnedAgentModels.has(childModel.threadId) ||
698
+ resource.spawnedAgentModels.size < 256))
699
+ resource.spawnedAgentModels.set(childModel.threadId, childModel.model);
528
700
  const resolvedRequestId = resolvedServerRequestId(notification);
529
701
  if (resolvedRequestId) {
530
702
  const pending = resource.pendingRequests.get(resolvedRequestId);
@@ -622,6 +794,35 @@ function threadTokenUsage(value) {
622
794
  function asRecord(value) {
623
795
  return value && typeof value === 'object' ? value : undefined;
624
796
  }
797
+ function decodeAgentTaskPath(value) {
798
+ const subagent = asRecord(asRecord(value)?.subagent);
799
+ const spawn = asRecord(subagent?.thread_spawn);
800
+ const path = boundedString(spawn?.agent_path, 256);
801
+ return path?.startsWith('/') && !path.includes('..') ? path : undefined;
802
+ }
803
+ /** Captures the resolved model that Codex applies after spawning a child thread. */
804
+ function decodeThreadSettingsModel(notification) {
805
+ if (notification.method !== 'thread/settings/updated')
806
+ return undefined;
807
+ const params = asRecord(notification.params);
808
+ const threadId = boundedString(params?.threadId, 256);
809
+ const model = boundedString(asRecord(params?.threadSettings)?.model, 256);
810
+ return threadId && model ? { threadId, model } : undefined;
811
+ }
812
+ function childProcessKey(childThreadId, processId) {
813
+ return `${childThreadId}:${processId}`;
814
+ }
815
+ function boundedNonNegativeNumber(value) {
816
+ const number = typeof value === 'bigint' ? Number(value) : value;
817
+ return typeof number === 'number' && Number.isFinite(number) && number >= 0 ? number : null;
818
+ }
819
+ function boundedInteger(value) {
820
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
821
+ }
822
+ function boundedNonNegativeInteger(value) {
823
+ const number = boundedInteger(value);
824
+ return number !== null && number >= 0 ? number : null;
825
+ }
625
826
  function decodeThreadStart(value) {
626
827
  const id = asRecord(asRecord(value)?.thread)?.id;
627
828
  if (typeof id !== 'string' || !id || id.length > 256)
@@ -3,10 +3,10 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, sandbox TEXT, approval_policy TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
6
+ const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, sandbox TEXT, approval_policy TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, lifecycle_json TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
7
7
  export function migrate(database) {
8
8
  database.exec(schema);
9
- database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v2 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
9
+ database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v3 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.final-rejected','autopilot.executor-resumed','autopilot.process-monitoring','autopilot.process-result-consumed','autopilot.process-terminated','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
10
10
  const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
11
11
  if (!columns.some((column) => column.name === 'effective_skill_selection_json'))
12
12
  database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
@@ -22,6 +22,11 @@ export function migrate(database) {
22
22
  database.exec('ALTER TABLE relay_sessions ADD COLUMN sandbox TEXT');
23
23
  if (!columns.some((column) => column.name === 'approval_policy'))
24
24
  database.exec('ALTER TABLE relay_sessions ADD COLUMN approval_policy TEXT');
25
+ const autopilotColumns = database
26
+ .prepare('PRAGMA table_info(autopilot_sessions)')
27
+ .all();
28
+ if (!autopilotColumns.some((column) => column.name === 'lifecycle_json'))
29
+ database.exec('ALTER TABLE autopilot_sessions ADD COLUMN lifecycle_json TEXT');
25
30
  const interactionColumns = database
26
31
  .prepare('PRAGMA table_info(pending_interactions)')
27
32
  .all();
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { parsePersistedSupervisedLifecycle } from '../../features/autopilot/domain/supervised-lifecycle.js';
6
7
  export class SqliteAutopilotStore {
7
8
  db;
8
9
  constructor(db) {
@@ -14,6 +15,7 @@ export class SqliteAutopilotStore {
14
15
  .get(sessionId);
15
16
  if (!row)
16
17
  return null;
18
+ const lifecycle = parseLifecycle(row.lifecycle_json);
17
19
  if (!['disabled', 'monitoring', 'backoff', 'attentionRequired', 'completed'].includes(String(row.state)) ||
18
20
  ![
19
21
  'manualDisabled',
@@ -34,7 +36,8 @@ export class SqliteAutopilotStore {
34
36
  Number(row.generation) < 0 ||
35
37
  !Number.isSafeInteger(Number(row.no_progress_count)) ||
36
38
  Number(row.no_progress_count) < 0 ||
37
- ![0, 1].includes(Number(row.requested_enabled)))
39
+ ![0, 1].includes(Number(row.requested_enabled)) ||
40
+ lifecycle === null)
38
41
  return null;
39
42
  return {
40
43
  sessionId: String(row.session_id),
@@ -47,13 +50,16 @@ export class SqliteAutopilotStore {
47
50
  nextEvaluationAt: row.next_evaluation_at === null ? null : String(row.next_evaluation_at),
48
51
  lastControlId: row.last_control_id === null ? null : String(row.last_control_id),
49
52
  stopReason: row.stop_reason,
53
+ ...lifecycle,
50
54
  updatedAt: String(row.updated_at),
51
55
  };
52
56
  }
53
57
  save(state) {
54
58
  this.db
55
- .prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,updated_at=excluded.updated_at')
56
- .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.updatedAt);
59
+ .prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,lifecycle_json,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,lifecycle_json=excluded.lifecycle_json,updated_at=excluded.updated_at')
60
+ .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.executor || state.blocking
61
+ ? JSON.stringify({ executor: state.executor, blocking: state.blocking })
62
+ : null, state.updatedAt);
57
63
  }
58
64
  findControl(sessionId, controlId) {
59
65
  const row = this.db
@@ -158,3 +164,15 @@ export class SqliteAutopilotStore {
158
164
  this.db.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?').run(sessionId);
159
165
  }
160
166
  }
167
+ function parseLifecycle(value) {
168
+ if (value === null || value === undefined)
169
+ return {};
170
+ if (typeof value !== 'string' || value.length > 256_000)
171
+ return null;
172
+ try {
173
+ return parsePersistedSupervisedLifecycle(JSON.parse(value)) ?? null;
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
@@ -6,6 +6,11 @@
6
6
  export const autopilotAuditEventTypes = [
7
7
  'autopilot.turn-started',
8
8
  'autopilot.turn-failed',
9
+ 'autopilot.final-rejected',
10
+ 'autopilot.executor-resumed',
11
+ 'autopilot.process-monitoring',
12
+ 'autopilot.process-result-consumed',
13
+ 'autopilot.process-terminated',
9
14
  'autopilot.updated',
10
15
  'org-plan.attention-required',
11
16
  'org-plan.attention-resolved',
@@ -7,6 +7,18 @@
7
7
  export function autopilotAuditLabel(type, payload) {
8
8
  if (type === 'autopilot.turn-started')
9
9
  return 'Continued execution automatically';
10
+ if (type === 'autopilot.final-rejected')
11
+ return 'Kept incomplete supervised work active';
12
+ if (type === 'autopilot.executor-resumed')
13
+ return 'Resumed the assigned executor';
14
+ if (type === 'autopilot.process-monitoring')
15
+ return 'Monitoring executor background work';
16
+ if (type === 'autopilot.process-result-consumed')
17
+ return 'Consumed background work result';
18
+ if (type === 'autopilot.process-terminated')
19
+ return property(payload, 'reason') === 'resourceBudget'
20
+ ? 'Stopped background work at its resource limit'
21
+ : 'Stopped executor background work';
10
22
  if (type === 'autopilot.turn-failed') {
11
23
  const code = property(payload, 'code');
12
24
  return code === 'START_UNAVAILABLE'
@@ -6,8 +6,18 @@
6
6
  export function orgPlanPosition(l1Position, l2Position) {
7
7
  return l2Position === undefined ? `L${l1Position}` : `L${l1Position}.${l2Position}`;
8
8
  }
9
+ export function parseOrgPlanAgentIdentity(nameOrPath) {
10
+ const name = nameOrPath.split('/').filter(Boolean).at(-1) ?? nameOrPath;
11
+ const match = /^(l([1-9]\d*)(?:_([1-9]\d*))?)(?:_g([1-9]\d*))?$/.exec(name);
12
+ if (!match)
13
+ return null;
14
+ return {
15
+ canonicalTaskName: match[1],
16
+ canonicalPosition: orgPlanPosition(Number(match[2]), match[3] ? Number(match[3]) : undefined),
17
+ generation: match[4] ? Number(match[4]) : 1,
18
+ };
19
+ }
9
20
  /** Converts the collaboration API's tool-safe task name into its canonical plan label. */
10
21
  export function orgPlanAgentDisplayName(name) {
11
- const match = /^l([1-9]\d*)(?:_([1-9]\d*))?$/.exec(name);
12
- return match ? orgPlanPosition(Number(match[1]), match[2] ? Number(match[2]) : undefined) : name;
22
+ return parseOrgPlanAgentIdentity(name)?.canonicalPosition ?? name;
13
23
  }