@myagentroam/node 0.10.34 → 0.10.36
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/dist/connector.js +113 -18
- package/dist/database.js +27 -12
- package/dist/main.js +18 -2
- package/dist/migrations/v019.js +9 -0
- package/dist/runner/mar-agent/conversation-normalizer.js +28 -0
- package/dist/runner/mar-agent/marctl-skill.js +7 -5
- package/dist/runner/mar-agent-runner.js +32 -0
- package/dist/runtime-state.js +15 -10
- package/dist/service/continuous-run-coordinator.js +31 -0
- package/dist/service/direct-transfer-service.js +58 -11
- package/dist/service/external-session-resume-service.js +2 -1
- package/dist/service/goal-runtime.js +13 -0
- package/dist/service/native-session-projection-service.js +77 -11
- package/dist/service/planned-restart-handoff.js +279 -0
- package/dist/service/restart-session-restorer.js +141 -0
- package/dist/service/{scheduled-run-authorization-control.js → run-authorization-control.js} +17 -10
- package/dist/service/run-event-service.js +2 -0
- package/dist/service/session-catalog-service.js +57 -4
- package/dist/service/session-command-service.js +1 -0
- package/dist/service/session-lifecycle-service.js +25 -8
- package/dist/service/session-message-service.js +60 -14
- package/dist/service/session-presentation-service.js +2 -0
- package/dist/service/session-query-service.js +14 -9
- package/dist/service/workspace-queue-workbench-service.js +8 -3
- package/dist/service/workspace-upload-service.js +128 -29
- package/dist/supervisor.js +7 -3
- package/package.json +4 -4
|
@@ -10,9 +10,25 @@ export class ContinuousRunCoordinator {
|
|
|
10
10
|
startedExecutions = new Set();
|
|
11
11
|
scheduledSessions = new Map();
|
|
12
12
|
immediateQueueItems = new Set();
|
|
13
|
+
suspended = false;
|
|
13
14
|
constructor(options) {
|
|
14
15
|
this.options = options;
|
|
15
16
|
}
|
|
17
|
+
suspend() {
|
|
18
|
+
this.suspended = true;
|
|
19
|
+
for (const timer of this.scheduledSessions.values())
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
this.scheduledSessions.clear();
|
|
22
|
+
}
|
|
23
|
+
resumeScheduling() {
|
|
24
|
+
this.suspended = false;
|
|
25
|
+
}
|
|
26
|
+
isImmediate(itemId) {
|
|
27
|
+
return this.immediateQueueItems.has(itemId);
|
|
28
|
+
}
|
|
29
|
+
restoreImmediate(itemId) {
|
|
30
|
+
this.immediateQueueItems.add(itemId);
|
|
31
|
+
}
|
|
16
32
|
accept(message, environment = {}) {
|
|
17
33
|
const release = this.options.retainEnvironment?.(environment);
|
|
18
34
|
let retained = false;
|
|
@@ -31,6 +47,7 @@ export class ContinuousRunCoordinator {
|
|
|
31
47
|
sessionId: message.sessionId,
|
|
32
48
|
clientMessageId: message.clientMessageId,
|
|
33
49
|
content: message.content,
|
|
50
|
+
...(message.suppressNotifications === true ? { suppressNotifications: true } : {}),
|
|
34
51
|
...(message.initiatedByUserId === undefined
|
|
35
52
|
? {}
|
|
36
53
|
: { initiatedByUserId: message.initiatedByUserId }),
|
|
@@ -186,6 +203,14 @@ export class ContinuousRunCoordinator {
|
|
|
186
203
|
const resources = prepareComposerResourceInput(input, pending.cwd, pending.workspacePath ?? pending.cwd);
|
|
187
204
|
this.preparedInputs.set(itemId, {
|
|
188
205
|
...pending,
|
|
206
|
+
...(pending.restartMessage === undefined
|
|
207
|
+
? {}
|
|
208
|
+
: {
|
|
209
|
+
restartMessage: {
|
|
210
|
+
...pending.restartMessage,
|
|
211
|
+
content: this.options.runtime.getQueueItem(itemId)?.content ?? input
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
189
214
|
input: withNonImageAttachmentPrompt(resources, pending.attachmentPaths, pending.attachmentFiles)
|
|
190
215
|
});
|
|
191
216
|
}
|
|
@@ -238,6 +263,8 @@ export class ContinuousRunCoordinator {
|
|
|
238
263
|
this.startIdleQueue(sessionId);
|
|
239
264
|
}
|
|
240
265
|
schedule(sessionId, delayMs = 0) {
|
|
266
|
+
if (this.suspended)
|
|
267
|
+
return;
|
|
241
268
|
if (this.scheduledSessions.has(sessionId))
|
|
242
269
|
return;
|
|
243
270
|
const timer = setTimeout(() => {
|
|
@@ -256,6 +283,8 @@ export class ContinuousRunCoordinator {
|
|
|
256
283
|
this.schedule(sessionId);
|
|
257
284
|
}
|
|
258
285
|
async dispatch(sessionId) {
|
|
286
|
+
if (this.suspended || this.options.stopped())
|
|
287
|
+
return;
|
|
259
288
|
const active = this.options.runtime.activeSessionRun(sessionId);
|
|
260
289
|
if (active === undefined)
|
|
261
290
|
return;
|
|
@@ -323,6 +352,8 @@ export class ContinuousRunCoordinator {
|
|
|
323
352
|
const goalMcp = pending.goal === undefined ? undefined : await this.options.goalMcp?.(executionInput);
|
|
324
353
|
const scheduledMcp = await this.options.scheduledMcp?.(executionInput);
|
|
325
354
|
const mailMcp = await this.options.mailMcp?.(executionInput);
|
|
355
|
+
if (this.suspended || this.options.stopped())
|
|
356
|
+
return;
|
|
326
357
|
if (!this.goalCurrent(pending)) {
|
|
327
358
|
this.dispatchedExecutions.delete(executionId);
|
|
328
359
|
this.rejectStaleGoal(executionId);
|
|
@@ -77,12 +77,12 @@ export class DirectTransferService {
|
|
|
77
77
|
handleTerminal(value) {
|
|
78
78
|
return this.terminalDirect?.handle(value, (id) => this.transports.get(id)?.peer) ?? false;
|
|
79
79
|
}
|
|
80
|
-
close() {
|
|
80
|
+
close(code = 'DIRECT_NODE_STOPPED') {
|
|
81
81
|
this.terminalDirect?.close();
|
|
82
82
|
for (const transfer of [...this.transfers.values()])
|
|
83
|
-
void this.removeTransfer(transfer,
|
|
83
|
+
void this.removeTransfer(transfer, code);
|
|
84
84
|
for (const transport of [...this.transports.values()])
|
|
85
|
-
this.removeTransport(transport,
|
|
85
|
+
this.removeTransport(transport, code);
|
|
86
86
|
}
|
|
87
87
|
dispose() {
|
|
88
88
|
if (this.disposed)
|
|
@@ -215,6 +215,7 @@ export class DirectTransferService {
|
|
|
215
215
|
uploadCommitted: false,
|
|
216
216
|
opened: false,
|
|
217
217
|
processing: Promise.resolve(),
|
|
218
|
+
controller: new AbortController(),
|
|
218
219
|
lastLeaseAliveAt: this.now(),
|
|
219
220
|
leaseExpiresAt: command.expiresAt,
|
|
220
221
|
leaseTimer: undefined
|
|
@@ -267,10 +268,11 @@ export class DirectTransferService {
|
|
|
267
268
|
channel.close();
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
271
|
+
const pendingTransfer = this.transfers.get(openFrameSessionId(event.data) ?? '');
|
|
270
272
|
void this.openChannel(transport, channel, event.data).catch((error) => {
|
|
271
273
|
channel.close();
|
|
272
274
|
const id = openFrameSessionId(event.data);
|
|
273
|
-
if (id !== undefined)
|
|
275
|
+
if (id !== undefined && this.transfers.get(id) === pendingTransfer)
|
|
274
276
|
this.fail(id, errorCode(error), errorMessage(error));
|
|
275
277
|
});
|
|
276
278
|
};
|
|
@@ -294,16 +296,25 @@ export class DirectTransferService {
|
|
|
294
296
|
transfer.opened = true;
|
|
295
297
|
channel.onmessage = (event) => {
|
|
296
298
|
transfer.processing = transfer.processing
|
|
297
|
-
.then(() =>
|
|
298
|
-
|
|
299
|
+
.then(() => {
|
|
300
|
+
if (this.current(transfer))
|
|
301
|
+
return this.channelMessage(transfer, event.data);
|
|
302
|
+
})
|
|
303
|
+
.catch((error) => {
|
|
304
|
+
if (this.current(transfer))
|
|
305
|
+
this.fail(transfer.command.directSessionId, errorCode(error), errorMessage(error));
|
|
306
|
+
});
|
|
299
307
|
};
|
|
300
308
|
channel.onclose = () => {
|
|
301
309
|
if (this.transfers.get(transfer.command.directSessionId) === transfer)
|
|
302
310
|
void this.removeTransfer(transfer, 'DIRECT_CHANNEL_CLOSED');
|
|
303
311
|
};
|
|
304
|
-
|
|
312
|
+
transfer.processing = this.open(transfer);
|
|
313
|
+
await transfer.processing;
|
|
305
314
|
}
|
|
306
315
|
async channelMessage(transfer, data) {
|
|
316
|
+
if (!this.current(transfer))
|
|
317
|
+
return;
|
|
307
318
|
const descriptor = transfer.command.transfer;
|
|
308
319
|
const expectedSize = transferredSize(descriptor);
|
|
309
320
|
if (descriptor.direction === 'DOWNLOAD') {
|
|
@@ -326,8 +337,10 @@ export class DirectTransferService {
|
|
|
326
337
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
327
338
|
if (control.size !== descriptor.size)
|
|
328
339
|
throw new Error('DIRECT_INTEGRITY_FAILED');
|
|
329
|
-
await transfer.upload.complete(control.size);
|
|
340
|
+
await transfer.upload.complete(control.size, transfer.controller.signal);
|
|
330
341
|
transfer.uploadCommitted = true;
|
|
342
|
+
if (!this.current(transfer))
|
|
343
|
+
return;
|
|
331
344
|
this.emit(transfer.command.directSessionId, {
|
|
332
345
|
type: 'direct.complete',
|
|
333
346
|
directSessionId: transfer.command.directSessionId,
|
|
@@ -340,6 +353,8 @@ export class DirectTransferService {
|
|
|
340
353
|
if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
|
|
341
354
|
throw new Error('DIRECT_CHUNK_INVALID');
|
|
342
355
|
const offset = await transfer.upload.write(bytes);
|
|
356
|
+
if (!this.current(transfer))
|
|
357
|
+
return;
|
|
343
358
|
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
|
|
344
359
|
}
|
|
345
360
|
async open(transfer) {
|
|
@@ -348,6 +363,11 @@ export class DirectTransferService {
|
|
|
348
363
|
if (this.options.openUpload === undefined)
|
|
349
364
|
throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
|
|
350
365
|
transfer.upload = await this.options.openUpload(descriptor);
|
|
366
|
+
if (!this.current(transfer)) {
|
|
367
|
+
if (!transfer.preserveUpload)
|
|
368
|
+
await this.cancelUpload(transfer);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
351
371
|
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: transfer.upload.offset }));
|
|
352
372
|
this.emit(transfer.command.directSessionId, {
|
|
353
373
|
type: 'direct.opened',
|
|
@@ -359,6 +379,8 @@ export class DirectTransferService {
|
|
|
359
379
|
if (this.options.openDownload === undefined)
|
|
360
380
|
throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
|
|
361
381
|
const source = await this.options.openDownload(descriptor);
|
|
382
|
+
if (!this.current(transfer))
|
|
383
|
+
return;
|
|
362
384
|
const expectedSize = transferredSize(descriptor);
|
|
363
385
|
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
|
|
364
386
|
this.emit(transfer.command.directSessionId, {
|
|
@@ -444,12 +466,20 @@ export class DirectTransferService {
|
|
|
444
466
|
});
|
|
445
467
|
}
|
|
446
468
|
async removeTransfer(transfer, code) {
|
|
447
|
-
if (!this.
|
|
469
|
+
if (!this.current(transfer))
|
|
448
470
|
return;
|
|
471
|
+
this.transfers.delete(transfer.command.directSessionId);
|
|
449
472
|
clearInterval(transfer.leaseTimer);
|
|
450
|
-
|
|
451
|
-
|
|
473
|
+
const preserveWorkspaceUpload = transfer.command.transfer.direction === 'UPLOAD' &&
|
|
474
|
+
transfer.command.transfer.composerAttachment === undefined &&
|
|
475
|
+
(code === 'DIRECT_CHANNEL_CLOSED' ||
|
|
476
|
+
code === 'DIRECT_CONNECTION_FAILED' ||
|
|
477
|
+
code === 'DIRECT_TRANSFER_CONTROL_UNAVAILABLE');
|
|
478
|
+
transfer.preserveUpload = preserveWorkspaceUpload;
|
|
479
|
+
transfer.controller.abort();
|
|
452
480
|
transfer.channel?.close();
|
|
481
|
+
if (!transfer.uploadCommitted && !preserveWorkspaceUpload)
|
|
482
|
+
await this.cancelUpload(transfer);
|
|
453
483
|
if (code !== undefined)
|
|
454
484
|
this.options.send('direct.error', {
|
|
455
485
|
type: 'direct.error',
|
|
@@ -458,6 +488,23 @@ export class DirectTransferService {
|
|
|
458
488
|
message: directErrorMessage(code)
|
|
459
489
|
});
|
|
460
490
|
}
|
|
491
|
+
current(transfer) {
|
|
492
|
+
return (this.transfers.get(transfer.command.directSessionId) === transfer &&
|
|
493
|
+
!transfer.controller.signal.aborted);
|
|
494
|
+
}
|
|
495
|
+
async cancelUpload(transfer) {
|
|
496
|
+
if (transfer.upload === undefined)
|
|
497
|
+
return;
|
|
498
|
+
transfer.cleanup ??= transfer.upload.cancel().catch(() => {
|
|
499
|
+
this.options.send('direct.error', {
|
|
500
|
+
type: 'direct.error',
|
|
501
|
+
directSessionId: transfer.command.directSessionId,
|
|
502
|
+
code: 'DIRECT_UPLOAD_CLEANUP_FAILED',
|
|
503
|
+
message: 'Temporary upload cleanup could not be completed.'
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
await transfer.cleanup;
|
|
507
|
+
}
|
|
461
508
|
}
|
|
462
509
|
function transferredSize(descriptor) {
|
|
463
510
|
return descriptor.direction === 'DOWNLOAD' && descriptor.range !== undefined
|
|
@@ -70,7 +70,8 @@ export class ExternalSessionResumeService {
|
|
|
70
70
|
access: configuration.access,
|
|
71
71
|
...(session.runnerTitle === null ? {} : { title: session.runnerTitle }),
|
|
72
72
|
...(session.customTitle === null ? {} : { customTitle: session.customTitle }),
|
|
73
|
-
sessionPrompt: session.sessionPrompt ?? null
|
|
73
|
+
sessionPrompt: session.sessionPrompt ?? null,
|
|
74
|
+
parentSessionId: session.parentSessionId ?? null
|
|
74
75
|
});
|
|
75
76
|
},
|
|
76
77
|
workspacePath: this.options.workspacePath,
|
|
@@ -31,6 +31,8 @@ export function stripGoalStartPrompt(text) {
|
|
|
31
31
|
return close === -1 ? '' : text.slice(close + tags.close.length).trimStart();
|
|
32
32
|
}
|
|
33
33
|
export function publicGoalHistoryText(text) {
|
|
34
|
+
if (parseComposerTags(text)[0]?.kind === 'mar_restart')
|
|
35
|
+
return serializeComposerTag('mar_restart', {});
|
|
34
36
|
const tags = goalPromptTags(text);
|
|
35
37
|
if (tags === undefined)
|
|
36
38
|
return text;
|
|
@@ -61,6 +63,17 @@ export class GoalRuntime {
|
|
|
61
63
|
get(sessionId) {
|
|
62
64
|
return this.goals.get(sessionId);
|
|
63
65
|
}
|
|
66
|
+
restartState(sessionId) {
|
|
67
|
+
const goal = this.goals.get(sessionId);
|
|
68
|
+
return goal === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: { goal: { ...goal }, executionFailures: this.executionFailures.get(sessionId) ?? 0 };
|
|
71
|
+
}
|
|
72
|
+
restoreRestartState(state) {
|
|
73
|
+
this.goals.set(state.goal.sessionId, { ...state.goal });
|
|
74
|
+
this.executionFailures.set(state.goal.sessionId, state.executionFailures);
|
|
75
|
+
this.changed(state.goal);
|
|
76
|
+
}
|
|
64
77
|
migrateSession(previousId, nextId) {
|
|
65
78
|
if (previousId === nextId)
|
|
66
79
|
return;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|