@myagentroam/node 0.10.35 → 0.10.37

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.
Files changed (33) hide show
  1. package/dist/connector.js +115 -18
  2. package/dist/database.js +29 -12
  3. package/dist/main.js +18 -2
  4. package/dist/migrations/v019.js +9 -0
  5. package/dist/migrations/v020.js +46 -0
  6. package/dist/runner/mar-agent/computer-use-probe.js +126 -0
  7. package/dist/runner/mar-agent/computer-use.js +50 -20
  8. package/dist/runner/mar-agent/conversation-normalizer.js +28 -0
  9. package/dist/runner/mar-agent/marctl-skill.js +3 -2
  10. package/dist/runner/mar-agent-runner.js +32 -0
  11. package/dist/runtime-state.js +15 -10
  12. package/dist/service/continuous-run-coordinator.js +58 -5
  13. package/dist/service/direct-transfer-service.js +58 -11
  14. package/dist/service/external-session-resume-service.js +2 -1
  15. package/dist/service/goal-runtime.js +13 -0
  16. package/dist/service/hosted-conversation-node-service.js +5 -4
  17. package/dist/service/native-session-projection-service.js +77 -11
  18. package/dist/service/planned-restart-handoff.js +279 -0
  19. package/dist/service/restart-session-restorer.js +141 -0
  20. package/dist/service/{scheduled-run-authorization-control.js → run-authorization-control.js} +17 -10
  21. package/dist/service/run-event-service.js +2 -0
  22. package/dist/service/scheduled-session-coordinator.js +13 -0
  23. package/dist/service/scheduled-session-store.js +24 -0
  24. package/dist/service/session-catalog-service.js +57 -4
  25. package/dist/service/session-command-service.js +1 -0
  26. package/dist/service/session-lifecycle-service.js +25 -8
  27. package/dist/service/session-message-service.js +80 -15
  28. package/dist/service/session-presentation-service.js +2 -0
  29. package/dist/service/session-query-service.js +14 -9
  30. package/dist/service/workspace-queue-workbench-service.js +15 -8
  31. package/dist/service/workspace-upload-service.js +128 -29
  32. package/dist/supervisor.js +7 -3
  33. package/package.json +4 -4
@@ -50,20 +50,16 @@ export class NativeSessionProjectionService {
50
50
  this.remember(sessions);
51
51
  return sessions;
52
52
  }
53
- async resolve(sessionId) {
53
+ async resolve(sessionId, registeredOnly = false) {
54
54
  const cached = this.options.state.directNative.get(sessionId);
55
55
  if (cached !== undefined)
56
56
  return cached;
57
- const discovered = (await this.list()).find((session) => session.id === sessionId);
58
- if (discovered !== undefined)
59
- return discovered;
60
57
  const database = this.options.database();
61
- const record = database
62
- ?.listWorkspaces()
63
- .flatMap((workspace) => database.listSessionRecords(workspace.id))
64
- .find((candidate) => candidate.id === sessionId);
58
+ const record = database?.getSessionRecord(sessionId);
65
59
  if (record === undefined)
66
- return undefined;
60
+ return registeredOnly
61
+ ? undefined
62
+ : (await this.list()).find((session) => session.id === sessionId);
67
63
  const workspace = database?.getWorkspace(record.workspaceId);
68
64
  if (workspace === undefined)
69
65
  return undefined;
@@ -80,6 +76,7 @@ export class NativeSessionProjectionService {
80
76
  effort: record.metadata.effort,
81
77
  access: record.metadata.access,
82
78
  customTitle: record.metadata.customTitle,
79
+ parentSessionId: record.metadata.parentSessionId ?? null,
83
80
  sessionPrompt: record.metadata.sessionPrompt ?? null,
84
81
  runnerTitle: record.metadata.runnerTitle,
85
82
  pinnedAt: record.pinOrder === null ? null : 1,
@@ -114,7 +111,8 @@ export class NativeSessionProjectionService {
114
111
  ...(direct.access === null ? {} : { access: direct.access }),
115
112
  ...(direct.runnerTitle === null ? {} : { title: direct.runnerTitle }),
116
113
  ...(direct.customTitle === null ? {} : { customTitle: direct.customTitle }),
117
- sessionPrompt: direct.sessionPrompt ?? null
114
+ sessionPrompt: direct.sessionPrompt ?? null,
115
+ parentSessionId: direct.parentSessionId ?? null
118
116
  });
119
117
  }
120
118
  resumedConfiguration(session) {
@@ -186,6 +184,7 @@ export class NativeSessionProjectionService {
186
184
  access: metadata?.access ?? null,
187
185
  customTitle: metadata?.customTitle ?? null,
188
186
  sessionPrompt: metadata?.sessionPrompt ?? null,
187
+ parentSessionId: metadata?.parentSessionId ?? null,
189
188
  runnerTitle,
190
189
  pinnedAt: metadata?.pinnedAt ?? null,
191
190
  pinOrder: metadata?.pinOrder ?? null,
@@ -202,8 +201,61 @@ export class NativeSessionProjectionService {
202
201
  };
203
202
  }
204
203
  remember(sessions) {
205
- for (const session of sessions)
204
+ const database = this.options.database();
205
+ for (const session of sessions) {
206
206
  this.options.state.directNative.set(session.id, session);
207
+ if (database === undefined || session.externalSessionId === null)
208
+ continue;
209
+ const metadata = database.getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId);
210
+ const title = session.runnerTitle?.trim().slice(0, 160) || undefined;
211
+ if (metadata === undefined ||
212
+ (title !== undefined && title !== metadata.runnerTitle && !metadata.runnerTitlePending))
213
+ database.saveNativeSessionMetadata({
214
+ runner: session.runner,
215
+ externalSessionId: session.externalSessionId,
216
+ workspaceId: session.workspaceId,
217
+ ...(title === undefined || metadata?.runnerTitlePending ? {} : { runnerTitle: title })
218
+ });
219
+ }
220
+ }
221
+ similarSessionDetails(sessionId, workspaceId) {
222
+ if (sessionId.length < 8 || sessionId.length > 128)
223
+ return undefined;
224
+ const records = this.options.database()?.listSessionRecords(workspaceId) ?? [];
225
+ const recordById = new Map(records.map((record) => [record.id, record]));
226
+ const known = new Map(records.map((record) => [
227
+ record.id,
228
+ record.metadata.customTitle ?? record.metadata.runnerTitle ?? 'Untitled session'
229
+ ]));
230
+ for (const session of [
231
+ ...this.options.state.directNative.values(),
232
+ ...this.options.runtime.listAgentSessions()
233
+ ]) {
234
+ if (session.workspaceId !== workspaceId)
235
+ continue;
236
+ const metadata = recordById.get(session.id)?.metadata;
237
+ known.set(session.id, metadata?.customTitle ??
238
+ session.customTitle ??
239
+ session.runnerTitle ??
240
+ metadata?.runnerTitle ??
241
+ 'Untitled session');
242
+ }
243
+ const maximum = Math.min(3, Math.floor(sessionId.length / 8));
244
+ const candidates = [...known]
245
+ .map(([id, title]) => ({
246
+ id,
247
+ title,
248
+ distance: sessionIdDistance(sessionId.toLowerCase(), id.toLowerCase(), maximum)
249
+ }))
250
+ .filter((candidate) => candidate.distance <= maximum)
251
+ .sort((left, right) => left.distance - right.distance || left.id.localeCompare(right.id))
252
+ .slice(0, 3);
253
+ return candidates.length === 0
254
+ ? undefined
255
+ : [
256
+ 'Similar Sessions (suggestions only; no action was redirected):',
257
+ ...candidates.map(({ id, title }) => id + ' ' + title.replace(/[\u0000-\u001f\u007f]/gu, ' ').slice(0, 160))
258
+ ].join('\n');
207
259
  }
208
260
  projectionForNative(native) {
209
261
  if (native.externalSessionId === null)
@@ -297,3 +349,17 @@ export class NativeSessionProjectionService {
297
349
  return database;
298
350
  }
299
351
  }
352
+ function sessionIdDistance(left, right, maximum) {
353
+ if (Math.abs(left.length - right.length) > maximum)
354
+ return maximum + 1;
355
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
356
+ for (let row = 1; row <= left.length; row += 1) {
357
+ const current = [row];
358
+ for (let column = 1; column <= right.length; column += 1)
359
+ current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1));
360
+ if (Math.min(...current) > maximum)
361
+ return maximum + 1;
362
+ previous = current;
363
+ }
364
+ return previous[right.length];
365
+ }
@@ -0,0 +1,279 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { parseComposerTags } from '@myagentroam/protocol';
6
+ const identifier = z.string().min(1).max(512);
7
+ const count = z.number().int().nonnegative();
8
+ const attachmentSchema = z
9
+ .object({
10
+ name: z.string().min(1).max(512),
11
+ mime: z.string().min(1).max(128),
12
+ dataBase64: z.string().min(1).max(7_000_000)
13
+ })
14
+ .strict();
15
+ const messageSchema = z
16
+ .object({
17
+ id: identifier,
18
+ clientMessageId: identifier,
19
+ content: z.string().max(1_000_000),
20
+ attachments: z.array(attachmentSchema).max(5),
21
+ initiatedByUserId: z.number().int().positive(),
22
+ immediate: z.boolean()
23
+ })
24
+ .strict();
25
+ const goalSchema = z
26
+ .object({
27
+ goal: z
28
+ .object({
29
+ id: identifier,
30
+ sessionId: identifier,
31
+ objective: z.string().min(1).max(20_000),
32
+ inputTags: z.string().default(''),
33
+ startedAt: count,
34
+ revisionStartedAt: count,
35
+ revision: z.number().int().positive(),
36
+ status: z.enum(['active', 'completed', 'blocked']),
37
+ turns: count,
38
+ retryRequired: z.boolean(),
39
+ retryStartedAtTurn: count.nullable(),
40
+ consecutiveNoProgressTurns: count
41
+ })
42
+ .strict(),
43
+ executionFailures: count
44
+ })
45
+ .strict();
46
+ const sessionSchema = z
47
+ .object({
48
+ session: z
49
+ .object({
50
+ id: identifier,
51
+ workspaceId: identifier,
52
+ cwd: z.string().min(1),
53
+ model: z.string().nullable(),
54
+ effort: z.string().nullable(),
55
+ access: z.string().nullable()
56
+ })
57
+ .strict(),
58
+ ownerUserId: z.number().int().positive(),
59
+ paused: z.boolean(),
60
+ plan: z.boolean(),
61
+ awaitingGoal: z.boolean(),
62
+ goal: goalSchema.optional(),
63
+ current: messageSchema.optional(),
64
+ queue: z.array(messageSchema)
65
+ })
66
+ .strict();
67
+ export const plannedRestartSchema = z
68
+ .object({
69
+ schemaVersion: z.literal(1),
70
+ id: identifier,
71
+ nodeId: identifier,
72
+ serverUrl: z.string().min(1),
73
+ createdAt: count,
74
+ sessions: z.array(sessionSchema)
75
+ })
76
+ .strict();
77
+ export const RESTART_NOTICE = 'The previous execution was interrupted by a system restart. Check the session history and current workspace, then continue unfinished work. Verify completed or uncertain actions before proceeding; do not blindly repeat actions with external side effects.';
78
+ /** A one-shot orderly-shutdown handoff, not a crash-recovery journal. */
79
+ export class PlannedRestartHandoff {
80
+ options;
81
+ pending;
82
+ blocked = new Set();
83
+ recovery;
84
+ owners = new Map();
85
+ constructor(options) {
86
+ this.options = options;
87
+ }
88
+ path(processing = false) {
89
+ return resolve(this.options.directory(), processing ? 'planned-restart-handoff.processing.json' : 'planned-restart-handoff.json');
90
+ }
91
+ blocks(sessionId) {
92
+ return this.blocked.has(sessionId);
93
+ }
94
+ blocksOperation(operation, input) {
95
+ const sessionId = typeof input.sessionId === 'string'
96
+ ? input.sessionId
97
+ : typeof input.queueItemId === 'string'
98
+ ? this.options.runtime.getQueueItem(input.queueItemId)?.sessionId
99
+ : undefined;
100
+ if (sessionId === undefined || !this.blocks(sessionId))
101
+ return false;
102
+ return (operation === 'session.message' ||
103
+ operation.startsWith('session.command.') ||
104
+ (operation.startsWith('workspace.queue.') && operation !== 'workspace.queue.get') ||
105
+ [
106
+ 'session.remove',
107
+ 'session.rewind',
108
+ 'session.rollback',
109
+ 'session.config.update',
110
+ 'session.metadata.update'
111
+ ].includes(operation));
112
+ }
113
+ rememberUser(sessionId, userId) {
114
+ this.owners.set(sessionId, userId);
115
+ }
116
+ async load() {
117
+ this.recovery = undefined;
118
+ this.pending = undefined;
119
+ this.blocked.clear();
120
+ let text;
121
+ try {
122
+ text = await readFile(this.path(), 'utf8');
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'ENOENT')
126
+ this.options.diagnose('RESTART_HANDOFF_READ_FAILED');
127
+ return;
128
+ }
129
+ try {
130
+ const value = plannedRestartSchema.parse(JSON.parse(text));
131
+ const identity = this.options.identity();
132
+ if (value.nodeId !== identity.nodeId || value.serverUrl !== identity.serverUrl)
133
+ throw new Error('RESTART_HANDOFF_IDENTITY_MISMATCH');
134
+ this.pending = value;
135
+ for (const entry of value.sessions)
136
+ this.blocked.add(entry.session.id);
137
+ }
138
+ catch {
139
+ this.options.diagnose('RESTART_HANDOFF_INVALID');
140
+ }
141
+ }
142
+ capture() {
143
+ const { runtime, coordinator, goals } = this.options;
144
+ const sessions = [...(this.pending?.sessions ?? [])];
145
+ for (const session of runtime.listAgentSessions()) {
146
+ if (session.runner !== 'mar-agent' || this.blocks(session.id))
147
+ continue;
148
+ const queue = runtime.sessionQueue(session.id);
149
+ const active = runtime.activeSessionRun(session.id);
150
+ const goal = goals.restartState(session.id);
151
+ const awaitingGoal = goal === undefined && this.options.commandActive(session.id, 'goal');
152
+ const currentId = active && (runtime.currentExecutionId(active.id) ?? active.id);
153
+ const pending = currentId === undefined ? undefined : coordinator.pending(currentId);
154
+ const toMessage = (id, clientMessageId, content, attachments, owner) => ({
155
+ id,
156
+ clientMessageId,
157
+ content: withoutAttachmentTags(content),
158
+ attachments,
159
+ initiatedByUserId: owner,
160
+ immediate: coordinator.isImmediate(id)
161
+ });
162
+ const items = queue.itemIds.flatMap((id) => {
163
+ if (coordinator.pending(id)?.goal !== undefined)
164
+ return [];
165
+ const item = runtime.getQueueItem(id);
166
+ const original = coordinator.pending(id)?.restartMessage;
167
+ return [
168
+ toMessage(id, item.clientMessageId, original?.content ?? item.content, [...(original?.attachments ?? item.attachments)], item.initiatedByUserId)
169
+ ];
170
+ });
171
+ const current = pending?.restartMessage === undefined || pending.goal !== undefined
172
+ ? undefined
173
+ : toMessage(currentId, pending.clientMessageId ?? active.clientMessageId, pending.restartMessage.content, [...pending.restartMessage.attachments], pending.restartMessage.initiatedByUserId ?? active?.initiatedByUserId);
174
+ if (current === undefined && items.length === 0 && goal === undefined && !awaitingGoal)
175
+ continue;
176
+ if (session.externalSessionId === null)
177
+ throw new Error('RESTART_SESSION_NOT_READY');
178
+ const ownerUserId = active?.initiatedByUserId ??
179
+ items[0]?.initiatedByUserId ??
180
+ this.owners.get(session.id) ??
181
+ runtime.listRunsForSession(session.id).find((run) => run.initiatedByUserId !== undefined)
182
+ ?.initiatedByUserId;
183
+ sessions.push({
184
+ session: {
185
+ id: session.externalSessionId,
186
+ workspaceId: session.workspaceId,
187
+ cwd: session.cwd,
188
+ model: session.model,
189
+ effort: session.effort,
190
+ access: session.access
191
+ },
192
+ ownerUserId: ownerUserId,
193
+ paused: queue.paused,
194
+ plan: this.options.commandActive(session.id, 'plan'),
195
+ awaitingGoal,
196
+ ...(goal === undefined ? {} : { goal: goalSchema.parse(goal) }),
197
+ ...(current === undefined ? {} : { current }),
198
+ queue: items
199
+ });
200
+ }
201
+ return plannedRestartSchema.parse({
202
+ schemaVersion: 1,
203
+ id: randomUUID(),
204
+ ...this.options.identity(),
205
+ createdAt: Date.now(),
206
+ sessions
207
+ });
208
+ }
209
+ async publish(snapshot) {
210
+ for (const entry of snapshot.sessions) {
211
+ const goal = this.options.goals.restartState(entry.session.id);
212
+ if (goal !== undefined)
213
+ entry.goal = goalSchema.parse(goal);
214
+ if (entry.current !== undefined) {
215
+ const state = await this.options.inputState(entry, entry.current);
216
+ if (state === 'completed')
217
+ delete entry.current;
218
+ if (state === 'paused')
219
+ entry.paused = true;
220
+ }
221
+ }
222
+ if (snapshot.sessions.length === 0)
223
+ return;
224
+ const temporary = `${this.path()}.${process.pid}.tmp`;
225
+ await mkdir(this.options.directory(), { recursive: true, mode: 0o700 });
226
+ try {
227
+ await writeFile(temporary, JSON.stringify(plannedRestartSchema.parse(snapshot)), {
228
+ mode: 0o600
229
+ });
230
+ await rename(temporary, this.path());
231
+ }
232
+ finally {
233
+ await rm(temporary, { force: true });
234
+ }
235
+ }
236
+ recover() {
237
+ if (this.pending === undefined)
238
+ return this.recovery ?? Promise.resolve();
239
+ return (this.recovery ??= this.consume());
240
+ }
241
+ async wait() {
242
+ await this.recovery;
243
+ }
244
+ async consume() {
245
+ const value = this.pending;
246
+ this.pending = undefined;
247
+ let failed = false;
248
+ try {
249
+ await rename(this.path(), this.path(true));
250
+ for (const entry of value.sessions) {
251
+ try {
252
+ this.rememberUser(entry.session.id, entry.ownerUserId);
253
+ await this.options.restore(entry, value.id);
254
+ }
255
+ catch {
256
+ failed = true;
257
+ this.options.diagnose('RESTART_SESSION_RESTORE_FAILED', entry.session.id);
258
+ }
259
+ finally {
260
+ this.blocked.delete(entry.session.id);
261
+ }
262
+ }
263
+ if (!failed)
264
+ await rm(this.path(true), { force: true });
265
+ }
266
+ catch {
267
+ this.options.diagnose('RESTART_HANDOFF_CONSUME_FAILED');
268
+ }
269
+ finally {
270
+ this.blocked.clear();
271
+ }
272
+ }
273
+ }
274
+ function withoutAttachmentTags(content) {
275
+ return parseComposerTags(content)
276
+ .map((part) => part.kind === 'text' ? part.text : part.kind === 'mar_attachments' ? '' : part.raw)
277
+ .join('')
278
+ .trim();
279
+ }
@@ -0,0 +1,141 @@
1
+ import { serializeComposerTag } from '@myagentroam/protocol';
2
+ import { parseMessageAttachments } from '../util/node-operation-parsers.js';
3
+ import { RESTART_NOTICE } from './planned-restart-handoff.js';
4
+ /** Rebuild inputs through the existing preparation and FIFO boundary. */
5
+ export class RestartSessionRestorer {
6
+ options;
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ async restore(entry, handoffId) {
11
+ const contexts = new Map();
12
+ const authorize = async (ownerUserId) => {
13
+ if (!contexts.has(ownerUserId))
14
+ contexts.set(ownerUserId, await this.options.authorize({
15
+ handoffId,
16
+ sessionId: entry.session.id,
17
+ workspaceId: entry.session.workspaceId,
18
+ ownerUserId,
19
+ runner: 'mar-agent',
20
+ model: entry.session.model
21
+ }));
22
+ return contexts.get(ownerUserId);
23
+ };
24
+ await authorize(entry.ownerUserId);
25
+ for (const input of [...entry.queue, ...(entry.current ? [entry.current] : [])]) {
26
+ parseMessageAttachments(input.attachments);
27
+ await authorize(input.initiatedByUserId);
28
+ }
29
+ let session = await this.options.resolve(entry.session.id);
30
+ if (session.runner !== 'mar-agent' ||
31
+ session.workspaceId !== entry.session.workspaceId ||
32
+ session.cwd !== entry.session.cwd ||
33
+ session.externalSessionId !== entry.session.id)
34
+ throw new Error('RESTART_SESSION_MISMATCH');
35
+ session = this.options.runtime.updateAgentSessionConfiguration({
36
+ sessionId: session.id,
37
+ model: entry.session.model ?? session.model,
38
+ effort: entry.session.effort ?? session.effort,
39
+ access: entry.session.access ?? session.access
40
+ });
41
+ const { runtime, coordinator, goals, commands } = this.options;
42
+ runtime.pauseSessionQueue(session.id);
43
+ if (entry.plan)
44
+ commands.set(session.id, {
45
+ commandId: 'plan',
46
+ active: true,
47
+ label: 'Plan',
48
+ detail: null,
49
+ closable: true,
50
+ closeInput: '/plan'
51
+ });
52
+ if (entry.goal) {
53
+ if (entry.goal.goal.sessionId !== session.id)
54
+ throw new Error('RESTART_GOAL_SESSION_MISMATCH');
55
+ goals.restoreRestartState(entry.goal);
56
+ this.options.publishGoal(session, entry.goal.goal);
57
+ }
58
+ else if (entry.awaitingGoal)
59
+ commands.set(session.id, {
60
+ commandId: 'goal',
61
+ active: true,
62
+ label: 'Goal',
63
+ detail: null,
64
+ closable: true,
65
+ closeInput: '/goal --clear'
66
+ });
67
+ const submit = async (input, content = input.content) => {
68
+ const context = contexts.get(input.initiatedByUserId);
69
+ await this.options.withGrant(context, () => this.options.messages.restoreQueued({
70
+ sessionId: session.id,
71
+ content,
72
+ clientMessageId: input.clientMessageId,
73
+ ...(content.startsWith('<mar_restart>')
74
+ ? { __runnerContent: `${content}${RESTART_NOTICE}` }
75
+ : {}),
76
+ attachments: input.attachments,
77
+ __queueItemId: input.id,
78
+ __requestUserId: input.initiatedByUserId,
79
+ secretEnvironment: context.secretEnvironment,
80
+ personalInstructions: context.personalInstructions,
81
+ marAgentModel: context.marAgentModel,
82
+ marAgentModels: context.marAgentModels,
83
+ marAgentCompact: context.marAgentCompact,
84
+ marAgentRuntimeScopeId: context.marAgentRuntimeScopeId,
85
+ __usageGrant: context.usageGrant
86
+ }));
87
+ if (input.immediate)
88
+ coordinator.restoreImmediate(input.id);
89
+ };
90
+ try {
91
+ if (entry.current &&
92
+ entry.goal?.goal.status !== 'active' &&
93
+ !entry.queue.some((item) => item.immediate)) {
94
+ const state = await this.options.inputState(entry, entry.current);
95
+ if (state === 'paused')
96
+ entry.paused = true;
97
+ if (state === 'missing')
98
+ await submit(entry.current);
99
+ else if (state !== 'completed')
100
+ await submit({
101
+ ...entry.current,
102
+ id: `restart:${handoffId}:${session.id}`,
103
+ clientMessageId: `restart:${handoffId}:${session.id}`,
104
+ attachments: []
105
+ }, serializeComposerTag('mar_restart', {}));
106
+ }
107
+ for (const input of entry.queue)
108
+ await submit(input);
109
+ if (entry.goal?.goal.status === 'active') {
110
+ const context = contexts.get(entry.ownerUserId);
111
+ await this.options.withGrant(context, () => this.options.messages.submitGoal({
112
+ sessionId: session.id,
113
+ kind: 'continue',
114
+ deliveryIntent: 'QUEUE',
115
+ content: `${goals.prompt(session.id, 'continue')}
116
+ ${RESTART_NOTICE}`,
117
+ goal: { id: entry.goal.goal.id, revision: entry.goal.goal.revision, epoch: 0 },
118
+ initiatedByUserId: entry.ownerUserId,
119
+ secretEnvironment: context.secretEnvironment,
120
+ restoreQueued: true,
121
+ ...(context.usageGrant === undefined ? {} : { usageGrant: context.usageGrant }),
122
+ ...(typeof context.personalInstructions === 'string'
123
+ ? { personalInstructions: context.personalInstructions }
124
+ : {}),
125
+ marAgentModel: context.marAgentModel,
126
+ marAgentModels: context.marAgentModels,
127
+ marAgentCompact: context.marAgentCompact,
128
+ marAgentRuntimeScopeId: context.marAgentRuntimeScopeId
129
+ }));
130
+ }
131
+ if (!entry.paused)
132
+ coordinator.resume(session.id);
133
+ this.options.publishQueue(session);
134
+ }
135
+ catch (error) {
136
+ runtime.pauseSessionQueue(session.id);
137
+ this.options.publishQueue(session);
138
+ throw error;
139
+ }
140
+ }
141
+ }
@@ -1,12 +1,17 @@
1
- import { createEnvelope, scheduledRunAuthorizeRequestSchema, scheduledRunAuthorizeResponseSchema } from '@myagentroam/protocol';
2
- export class ScheduledRunAuthorizationControl {
1
+ import { createEnvelope, scheduledRunAuthorizeRequestSchema, scheduledRunAuthorizeResponseSchema, restartRunAuthorizeRequestSchema } from '@myagentroam/protocol';
2
+ export class RunAuthorizationControl {
3
3
  send;
4
+ purpose;
4
5
  #pending = new Map();
5
6
  #generation = 0;
6
7
  #connected = false;
7
8
  #closed = false;
8
- constructor(send) {
9
+ constructor(send, purpose = 'schedule') {
9
10
  this.send = send;
11
+ this.purpose = purpose;
12
+ }
13
+ code(suffix) {
14
+ return `${this.purpose.toUpperCase()}_${suffix}`;
10
15
  }
11
16
  connected() {
12
17
  this.disconnected();
@@ -16,29 +21,31 @@ export class ScheduledRunAuthorizationControl {
16
21
  this.#connected = false;
17
22
  this.#generation++;
18
23
  for (const pending of [...this.#pending.values()])
19
- pending.reject(new Error('SCHEDULE_SERVER_UNAVAILABLE'));
24
+ pending.reject(new Error(this.code('SERVER_UNAVAILABLE')));
20
25
  }
21
26
  close() {
22
27
  this.#closed = true;
23
28
  this.disconnected();
24
29
  }
25
30
  handle(envelope) {
26
- if (envelope.type !== 'schedule.authorize.response')
31
+ if (envelope.type !== `${this.purpose}.authorize.response`)
27
32
  return false;
28
33
  const pending = envelope.replyTo === undefined ? undefined : this.#pending.get(envelope.replyTo);
29
34
  if (pending === undefined || pending.generation !== this.#generation)
30
35
  return true;
31
36
  const parsed = scheduledRunAuthorizeResponseSchema.safeParse(envelope.payload);
32
37
  if (!parsed.success)
33
- pending.reject(new Error('SCHEDULE_AUTH_RESPONSE_INVALID'));
38
+ pending.reject(new Error(this.code('AUTH_RESPONSE_INVALID')));
34
39
  else
35
40
  pending.resolve(parsed.data);
36
41
  return true;
37
42
  }
38
43
  authorize(request) {
39
44
  if (!this.#connected || this.#closed)
40
- return Promise.reject(new Error('SCHEDULE_SERVER_UNAVAILABLE'));
41
- const envelope = createEnvelope('schedule.authorize', scheduledRunAuthorizeRequestSchema.parse(request));
45
+ return Promise.reject(new Error(this.code('SERVER_UNAVAILABLE')));
46
+ const envelope = createEnvelope(`${this.purpose}.authorize`, (this.purpose === 'schedule'
47
+ ? scheduledRunAuthorizeRequestSchema
48
+ : restartRunAuthorizeRequestSchema).parse(request));
42
49
  return new Promise((resolve, reject) => {
43
50
  const settle = (action) => {
44
51
  const pending = this.#pending.get(envelope.id);
@@ -48,7 +55,7 @@ export class ScheduledRunAuthorizationControl {
48
55
  clearTimeout(pending.timer);
49
56
  action();
50
57
  };
51
- const timer = setTimeout(() => settle(() => reject(new Error('SCHEDULE_AUTH_TIMEOUT'))), 15_000);
58
+ const timer = setTimeout(() => settle(() => reject(new Error(this.code('AUTH_TIMEOUT')))), 15_000);
52
59
  timer.unref();
53
60
  this.#pending.set(envelope.id, {
54
61
  generation: this.#generation,
@@ -65,7 +72,7 @@ export class ScheduledRunAuthorizationControl {
65
72
  this.send(envelope);
66
73
  }
67
74
  catch {
68
- settle(() => reject(new Error('SCHEDULE_SERVER_UNAVAILABLE')));
75
+ settle(() => reject(new Error(this.code('SERVER_UNAVAILABLE'))));
69
76
  }
70
77
  });
71
78
  }
@@ -232,6 +232,8 @@ export class RunEventService {
232
232
  const run = this.options.runtime.getRun(runId);
233
233
  if (run === undefined)
234
234
  return undefined;
235
+ if (run.suppressNotifications === true)
236
+ return undefined;
235
237
  if (run.source === 'SCHEDULED' && eventType === 'approval.request')
236
238
  return undefined;
237
239
  const session = this.options.runtime.getAgentSession(run.sessionId);
@@ -1,13 +1,26 @@
1
+ import { SCHEDULE_RESTART_OUTCOME_UNKNOWN } from './scheduled-session-store.js';
1
2
  export class ScheduledSessionCoordinator {
2
3
  options;
3
4
  #now;
4
5
  #timer;
5
6
  #running = false;
6
7
  #closed = true;
8
+ #recovered = [];
7
9
  constructor(options) {
8
10
  this.options = options;
9
11
  this.#now = options.now ?? Date.now;
10
12
  }
13
+ /** Called only at Node startup, before admitting any new scheduled occurrence. */
14
+ recoverInterrupted() {
15
+ for (const recovered of this.options.store().recoverInterrupted())
16
+ this.#recovered.push(recovered);
17
+ }
18
+ connected() {
19
+ // Startup has no control connection yet. Send these best-effort alerts once it is ready.
20
+ for (const { task, occurrenceId } of this.#recovered.splice(0)) {
21
+ this.options.notify(task, occurrenceId, SCHEDULE_RESTART_OUTCOME_UNKNOWN, 'FAILED');
22
+ }
23
+ }
11
24
  start() {
12
25
  if (!this.#closed)
13
26
  return;