@myagentroam/node 0.11.7 → 0.11.9

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 (30) hide show
  1. package/dist/connector.js +18 -7
  2. package/dist/marctl-guidance.js +36 -0
  3. package/dist/mcp/marctl-guide-stdio.js +18 -0
  4. package/dist/mcp/marctl-guide.js +30 -0
  5. package/dist/mcp/naming.js +1 -0
  6. package/dist/opencode-server.js +50 -8
  7. package/dist/runner/abstract-runner.js +8 -0
  8. package/dist/runner/claude-code-runner.js +2 -1
  9. package/dist/runner/codex-runner.js +27 -1
  10. package/dist/runner/execution-context.js +1 -0
  11. package/dist/runner/mar-agent/execution-input.js +46 -0
  12. package/dist/runner/mar-agent/managed-run-controller.js +10 -36
  13. package/dist/runner/mar-agent/marctl-skill.js +3 -34
  14. package/dist/runner/mar-agent-runner.js +4 -0
  15. package/dist/runner/opencode/managed-run-controller.js +117 -11
  16. package/dist/runner/opencode-runner.js +2 -1
  17. package/dist/runtime-state.js +32 -6
  18. package/dist/service/continuous-run-coordinator.js +2 -10
  19. package/dist/service/conversation-history-service.js +23 -40
  20. package/dist/service/goal-continuation-coordinator.js +5 -27
  21. package/dist/service/restart-session-restorer.js +3 -8
  22. package/dist/service/scheduled-session-coordinator.js +5 -3
  23. package/dist/service/scheduled-session-recurrence.js +41 -19
  24. package/dist/service/scheduled-session-store.js +6 -4
  25. package/dist/service/session-command-service.js +4 -12
  26. package/dist/service/session-lifecycle-service.js +4 -6
  27. package/dist/service/session-message-service.js +7 -18
  28. package/dist/service/workspace-queue-workbench-service.js +1 -1
  29. package/dist/util/conversation-identity.js +11 -0
  30. package/package.json +4 -4
@@ -18,6 +18,7 @@ export class OpenCodeManagedRunController {
18
18
  startingCwds = new Map();
19
19
  promptAdmitted = new Set();
20
20
  pendingIdle = new Set();
21
+ cancelling = new Set();
21
22
  messageRoles = new Map();
22
23
  messageParents = new Map();
23
24
  compactionPartIds = new Map();
@@ -158,11 +159,12 @@ export class OpenCodeManagedRunController {
158
159
  const execution = this.runner.execution.get(runId);
159
160
  if (execution === undefined)
160
161
  return false;
161
- void execution.client.abort(execution.cwd, execution.nativeSessionId).finally(() => {
162
- this.finish(runId, intent === 'INTERRUPT' ? 'INTERRUPTED' : 'CANCELLED', 'run.completed', {
163
- status: intent === 'INTERRUPT' ? 'INTERRUPTED' : 'CANCELLED'
164
- });
165
- });
162
+ if (this.cancelling.has(runId))
163
+ return true;
164
+ this.cancelling.add(runId);
165
+ void execution.client.abort(execution.cwd, execution.nativeSessionId).then(() => this.finish(runId, intent === 'INTERRUPT' ? 'INTERRUPTED' : 'CANCELLED', 'run.completed', {
166
+ status: intent === 'INTERRUPT' ? 'INTERRUPTED' : 'CANCELLED'
167
+ }), (error) => this.finish(runId, 'FAILED', 'run.failed', error, 'OPENCODE_CANCEL_FAILED'));
166
168
  return true;
167
169
  }
168
170
  decideApproval(input) {
@@ -207,6 +209,10 @@ export class OpenCodeManagedRunController {
207
209
  this.startingCwds.set(input.runId, input.cwd);
208
210
  this.host.active.add(input.runId);
209
211
  this.host.emit(input.runId, 'run.started', {}, 'STARTING');
212
+ let settlePrompt;
213
+ const promptSettled = new Promise((resolve) => {
214
+ settlePrompt = resolve;
215
+ });
210
216
  try {
211
217
  if (hasMcpConfiguration(input.mcp))
212
218
  await client.configureMcp(input.cwd, input.mcp);
@@ -251,16 +257,22 @@ export class OpenCodeManagedRunController {
251
257
  });
252
258
  void client
253
259
  .subscribe(input.cwd, abort.signal, (event) => this.event(input.runId, nativeSessionId, event), ready)
254
- .catch((error) => {
255
- if (abort.signal.aborted)
256
- return;
260
+ .then(() => {
261
+ throw new Error('OPENCODE_EVENT_STREAM_ENDED');
262
+ })
263
+ .catch(async (error) => {
257
264
  if (!subscriptionStarted) {
258
265
  rejectReady(error);
259
266
  return;
260
267
  }
261
- this.finish(input.runId, 'FAILED', 'run.failed', error, 'OPENCODE_EVENT_FAILED');
268
+ await promptSettled;
269
+ if (abort.signal.aborted || this.cancelling.has(input.runId))
270
+ return;
271
+ await this.recoverSubscription(input.runId);
262
272
  });
263
273
  await subscriptionReady;
274
+ if (abort.signal.aborted || this.cancelling.has(input.runId))
275
+ return;
264
276
  this.host.emit(input.runId, 'run.running', {}, 'RUNNING');
265
277
  // The initial SSE snapshot can contain an idle event from before this prompt.
266
278
  this.pendingIdle.delete(input.runId);
@@ -277,7 +289,7 @@ export class OpenCodeManagedRunController {
277
289
  ? existingImagePaths
278
290
  : await this.host.persistAttachments(input.cwd, input.runId, input.attachments);
279
291
  const execution = this.runner.execution.get(input.runId);
280
- if (execution?.abort !== abort || abort.signal.aborted)
292
+ if (execution?.abort !== abort || abort.signal.aborted || this.cancelling.has(input.runId))
281
293
  return;
282
294
  execution.responseLatency = new ResponseLatencyTracker();
283
295
  execution.responsePartKinds = new Map();
@@ -294,12 +306,103 @@ export class OpenCodeManagedRunController {
294
306
  ...(input.personalInstructions === undefined ? {} : { system: input.personalInstructions }),
295
307
  attachments: input.attachments
296
308
  });
309
+ if (this.runner.execution.get(input.runId) !== execution || abort.signal.aborted)
310
+ return;
297
311
  this.promptAdmitted.add(input.runId);
298
312
  if (this.pendingIdle.delete(input.runId))
299
313
  this.finish(input.runId, 'SUCCEEDED', 'run.completed', { status: 'SUCCEEDED' });
300
314
  }
301
315
  catch (error) {
302
- this.finish(input.runId, 'FAILED', 'run.failed', error, 'OPENCODE_RUN_START_FAILED');
316
+ if (!this.cancelling.has(input.runId))
317
+ this.finish(input.runId, 'FAILED', 'run.failed', error, 'OPENCODE_RUN_START_FAILED');
318
+ }
319
+ finally {
320
+ settlePrompt();
321
+ }
322
+ }
323
+ async recoverSubscription(runId) {
324
+ const execution = this.runner.execution.get(runId);
325
+ if (execution === undefined)
326
+ return;
327
+ const { client, cwd, nativeSessionId, abort } = execution;
328
+ // A missing event interval and historical replay cannot produce live performance timings.
329
+ delete execution.responseLatency;
330
+ delete execution.generationTiming;
331
+ const buffered = [];
332
+ let reconciling = true;
333
+ let reconciliation;
334
+ const fail = (error) => {
335
+ if (!abort.signal.aborted && !this.cancelling.has(runId))
336
+ this.finish(runId, 'FAILED', 'run.failed', error, 'OPENCODE_EVENT_FAILED');
337
+ };
338
+ try {
339
+ await client.subscribe(cwd, abort.signal, (event) => {
340
+ if (reconciling)
341
+ buffered.push(event);
342
+ else
343
+ this.event(runId, nativeSessionId, event);
344
+ }, () => {
345
+ reconciliation = (async () => {
346
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(10_000)]);
347
+ const status = record(await client.sessionStatus(cwd, nativeSessionId, signal));
348
+ if (abort.signal.aborted || this.cancelling.has(runId))
349
+ return;
350
+ const messages = await client.messages(cwd, nativeSessionId, 100, {
351
+ since: execution.responseStartedAt ?? Infinity,
352
+ signal
353
+ });
354
+ if (abort.signal.aborted || this.cancelling.has(runId))
355
+ return;
356
+ // Fold queued content before the authoritative snapshot; terminal events must
357
+ // wait until the recovered items have been published.
358
+ const terminalEvents = [];
359
+ for (const event of buffered) {
360
+ const type = record(event)?.type;
361
+ if (type === 'session.idle' || type === 'session.error')
362
+ terminalEvents.push(event);
363
+ else
364
+ this.event(runId, nativeSessionId, event);
365
+ }
366
+ buffered.length = 0;
367
+ for (const value of messages) {
368
+ const message = record(value);
369
+ const info = record(message?.info);
370
+ // Do not replay earlier turns into the current Run.
371
+ const created = record(info?.time)?.created;
372
+ if (typeof created !== 'number' ||
373
+ created < (execution.responseStartedAt ?? Infinity))
374
+ continue;
375
+ this.event(runId, nativeSessionId, { type: 'message.updated', properties: { info } });
376
+ if (Array.isArray(message?.parts))
377
+ for (const part of message.parts)
378
+ this.event(runId, nativeSessionId, {
379
+ type: 'message.part.updated',
380
+ properties: { part }
381
+ });
382
+ if (info?.role === 'assistant' && info.error !== undefined) {
383
+ this.finish(runId, 'FAILED', 'run.failed', info.error);
384
+ return;
385
+ }
386
+ }
387
+ reconciling = false;
388
+ for (const event of terminalEvents)
389
+ this.event(runId, nativeSessionId, event);
390
+ if (abort.signal.aborted)
391
+ return;
392
+ if (status?.type === 'idle')
393
+ this.event(runId, nativeSessionId, {
394
+ type: 'session.idle',
395
+ properties: { sessionID: nativeSessionId }
396
+ });
397
+ else if (status?.type !== 'busy' && status?.type !== 'retry')
398
+ throw new Error('OPENCODE_SESSION_STATUS_INVALID');
399
+ })().catch(fail);
400
+ });
401
+ await reconciliation;
402
+ fail(new Error('OPENCODE_EVENT_STREAM_ENDED'));
403
+ }
404
+ catch (error) {
405
+ fail(error);
303
406
  }
304
407
  }
305
408
  event(runId, nativeSessionId, value) {
@@ -307,6 +410,8 @@ export class OpenCodeManagedRunController {
307
410
  const properties = record(event?.properties);
308
411
  const nested = record(properties?.part) ?? record(properties?.info);
309
412
  const execution = this.runner.execution.get(runId);
413
+ if (execution === undefined)
414
+ return;
310
415
  const receiptAt = execution?.generationTiming?.timestamp();
311
416
  const sessionID = typeof properties?.sessionID === 'string'
312
417
  ? properties.sessionID
@@ -648,6 +753,7 @@ export class OpenCodeManagedRunController {
648
753
  this.startingClients.delete(runId);
649
754
  this.promptAdmitted.delete(runId);
650
755
  this.pendingIdle.delete(runId);
756
+ this.cancelling.delete(runId);
651
757
  this.messageRoles.delete(runId);
652
758
  this.messageParents.delete(runId);
653
759
  this.compactionPartIds.delete(runId);
@@ -1,3 +1,4 @@
1
+ import { withMarctlGuide } from '../mcp/marctl-guide.js';
1
2
  import { OpenCodeServerClient } from '../opencode-server.js';
2
3
  import { openCodeContextLimits, openCodeProfileModels } from '../opencode-runtime.js';
3
4
  import { declaredRunnerProfiles } from '../runner-profiles.js';
@@ -169,7 +170,7 @@ export class OpenCodeRunner extends AbstractRunner {
169
170
  this.profileRefreshTimer.unref();
170
171
  }
171
172
  mcpConfiguration(raw, secrets, executionServers = []) {
172
- const servers = super.mcpConfiguration(raw, secrets, executionServers);
173
+ const servers = withMarctlGuide(super.mcpConfiguration(raw, secrets, executionServers), secrets);
173
174
  return Object.fromEntries(servers.map((server) => [
174
175
  server.name,
175
176
  server.transport === 'STDIO'
@@ -25,6 +25,7 @@ export class NodeRuntimeState {
25
25
  recentUserMessages = new Map();
26
26
  messageSubmissions = new Map();
27
27
  failedMessageSubmissions = new Map();
28
+ terminalMessageSubmissions = new Map();
28
29
  queueItems = new Map();
29
30
  executions = new Map();
30
31
  retainedExecutionTurns = new Map();
@@ -286,6 +287,10 @@ export class NodeRuntimeState {
286
287
  this.messageSubmissions.delete(key);
287
288
  this.messageSubmissions.set(`${nativeSessionId}${key.slice(id.length)}`, submission);
288
289
  }
290
+ const terminalSubmissions = [...this.terminalMessageSubmissions];
291
+ this.terminalMessageSubmissions.clear();
292
+ for (const [key, runId] of terminalSubmissions)
293
+ this.terminalMessageSubmissions.set(key.startsWith(`${id}\u0000`) ? `${nativeSessionId}${key.slice(id.length)}` : key, runId);
289
294
  for (const [executionId, reference] of this.retainedExecutionTurns)
290
295
  if (reference.sessionId === id)
291
296
  this.retainedExecutionTurns.set(executionId, {
@@ -689,9 +694,13 @@ export class NodeRuntimeState {
689
694
  return submission?.kind === 'run' ? this.runs.get(submission.id) : undefined;
690
695
  }
691
696
  findMessageSubmission(sessionId, clientMessageId) {
692
- const submission = this.messageSubmissions.get(`${sessionId}\u0000${clientMessageId}`);
693
- if (submission === undefined)
694
- return undefined;
697
+ const key = `${sessionId}\u0000${clientMessageId}`;
698
+ const submission = this.messageSubmissions.get(key);
699
+ if (submission === undefined) {
700
+ const runId = this.terminalMessageSubmissions.get(key);
701
+ const run = runId === undefined ? undefined : this.terminalRunIds.get(runId);
702
+ return run === undefined ? undefined : { kind: 'run', run };
703
+ }
695
704
  if (submission.kind === 'run')
696
705
  return { kind: 'run', run: this.requireRun(submission.id) };
697
706
  const item = this.requireQueueItem(submission.id);
@@ -1016,8 +1025,13 @@ export class NodeRuntimeState {
1016
1025
  if (!terminal(run.status))
1017
1026
  throw new Error('RUN_ACTIVE');
1018
1027
  this.terminalRunIds.set(runId, run);
1019
- if (this.terminalRunIds.size > 1_024)
1020
- this.terminalRunIds.delete(this.terminalRunIds.keys().next().value);
1028
+ if (this.terminalRunIds.size > 1_024) {
1029
+ const oldest = this.terminalRunIds.keys().next().value;
1030
+ this.terminalRunIds.delete(oldest);
1031
+ for (const [key, id] of this.terminalMessageSubmissions)
1032
+ if (id === oldest)
1033
+ this.terminalMessageSubmissions.delete(key);
1034
+ }
1021
1035
  const executionIds = new Set([...this.executions.values()]
1022
1036
  .filter((execution) => execution.runId === runId)
1023
1037
  .map((execution) => execution.id));
@@ -1051,12 +1065,21 @@ export class NodeRuntimeState {
1051
1065
  this.recentUserMessages.delete(runId);
1052
1066
  this.runSequences.delete(runId);
1053
1067
  for (const [key, candidate] of this.messageSubmissions)
1054
- if (candidate.kind === 'run' && candidate.id === runId)
1068
+ if (candidate.kind === 'run' && candidate.id === runId) {
1055
1069
  this.messageSubmissions.delete(key);
1070
+ this.terminalMessageSubmissions.set(key, runId);
1071
+ if (this.terminalMessageSubmissions.size > 1_024)
1072
+ this.terminalMessageSubmissions.delete(this.terminalMessageSubmissions.keys().next().value);
1073
+ }
1056
1074
  for (const [approvalId, approval] of this.approvals)
1057
1075
  if (approval.runId === runId || executionIds.has(approval.runId))
1058
1076
  this.approvals.delete(approvalId);
1059
1077
  }
1078
+ clearTerminalMessageSubmissions(sessionId) {
1079
+ for (const key of this.terminalMessageSubmissions.keys())
1080
+ if (key.startsWith(`${sessionId}\u0000`))
1081
+ this.terminalMessageSubmissions.delete(key);
1082
+ }
1060
1083
  hasActiveWorkspaceLease(workspaceId) {
1061
1084
  return [...this.leases.values()]
1062
1085
  .map((runId) => this.runs.get(runId))
@@ -1073,6 +1096,7 @@ export class NodeRuntimeState {
1073
1096
  .filter((execution) => runIds.has(execution.runId))
1074
1097
  .map((execution) => execution.id));
1075
1098
  this.sessions.delete(sessionId);
1099
+ this.clearTerminalMessageSubmissions(sessionId);
1076
1100
  this.completeHistorySessions.delete(sessionId);
1077
1101
  this.removePromotedSessionAliases(sessionId);
1078
1102
  this.turns.delete(sessionId);
@@ -1125,6 +1149,7 @@ export class NodeRuntimeState {
1125
1149
  if (this.sessionQueue(sessionId).itemIds.length > 0)
1126
1150
  throw new Error('SESSION_QUEUE_NOT_EMPTY');
1127
1151
  this.turns.delete(sessionId);
1152
+ this.clearTerminalMessageSubmissions(sessionId);
1128
1153
  for (const [id, run] of this.terminalRunIds)
1129
1154
  if (run?.sessionId === sessionId)
1130
1155
  this.terminalRunIds.set(id, undefined);
@@ -1148,6 +1173,7 @@ export class NodeRuntimeState {
1148
1173
  const queueItemIds = new Set([...sessionIds].flatMap((sessionId) => this.queuedItems.get(sessionId) ?? []));
1149
1174
  for (const sessionId of sessionIds) {
1150
1175
  this.sessions.delete(sessionId);
1176
+ this.clearTerminalMessageSubmissions(sessionId);
1151
1177
  this.completeHistorySessions.delete(sessionId);
1152
1178
  this.removePromotedSessionAliases(sessionId);
1153
1179
  this.turns.delete(sessionId);
@@ -605,7 +605,7 @@ export class ContinuousRunCoordinator {
605
605
  runId: executionId,
606
606
  sessionId: pending.sessionId,
607
607
  workspaceId: pending.workspaceId,
608
- runner: pending.runner,
608
+ ...pending.executionContext,
609
609
  input: pending.input,
610
610
  ...(pending.clientMessageId === undefined
611
611
  ? {}
@@ -626,17 +626,9 @@ export class ContinuousRunCoordinator {
626
626
  MAR_CTL_WORKSPACE_ID: pending.workspaceId,
627
627
  MAR_CTL_SESSION_ID: this.options.runtime.getAgentSession(pending.sessionId)?.id ?? pending.sessionId,
628
628
  MAR_CTL_RUN_ID: active.id,
629
- MAR_CTL_RUNNER: pending.runner
629
+ MAR_CTL_RUNNER: pending.executionContext.runner
630
630
  },
631
631
  ...(personalInstructions === undefined ? {} : { personalInstructions }),
632
- ...(pending.marAgentModel === undefined ? {} : { marAgentModel: pending.marAgentModel }),
633
- ...(pending.marAgentModels === undefined ? {} : { marAgentModels: pending.marAgentModels }),
634
- ...(pending.marAgentCompact === undefined
635
- ? {}
636
- : { marAgentCompact: pending.marAgentCompact }),
637
- ...(pending.marAgentRuntimeScopeId === undefined
638
- ? {}
639
- : { marAgentRuntimeScopeId: pending.marAgentRuntimeScopeId }),
640
632
  ...(pending.computerUse === undefined ? {} : { computerUse: pending.computerUse }),
641
633
  mcpInstallations: this.options.mcps(pending.workspaceId),
642
634
  executionMcpServers: [goalMcp, scheduleMcp, notificationMcp].filter((server) => server !== undefined)
@@ -2,6 +2,7 @@ import { projectConversationWorkProcesses } from './conversation-work-process.js
2
2
  import { discoverNativeSessions, readNativeSession } from '../native-session-history.js';
3
3
  import { nativeConversationPage } from '../util/runner-native-session-parsers.js';
4
4
  import { isPlainRecord } from '../util/node-operation-parsers.js';
5
+ import { conversationClientMessageId } from '../util/conversation-identity.js';
5
6
  import { nodeLog } from '../operational.js';
6
7
  export function hasManagedActiveRun(runtime, session) {
7
8
  return (session.nativeControl === 'MAR_MANAGED' &&
@@ -475,34 +476,29 @@ export class ConversationHistoryService {
475
476
  }
476
477
  async readManagedRuntime(session, input, cursor, snapshotSequence, readAt) {
477
478
  const page = await this.readManagedActive(session, input, cursor, snapshotSequence, readAt);
478
- const codexActive = session.runner === 'codex' && this.options.hasManagedActiveRun(session);
479
- if (input.cursor !== undefined ||
480
- session.externalSessionId === null ||
481
- !codexActive ||
482
- !page.turns.some((turn) => turn.rewind === undefined &&
483
- terminalConversationTurn(turn.status) &&
484
- conversationClientMessageId(turn) !== null))
485
- return page;
486
- // A completed managed Turn keeps its live items and Run identity until Runtime cleanup.
487
- // Only its matched native Turn may supply a Rewind boundary.
488
- const historyInput = { limit: Math.min(500, Math.max(30, page.turns.length + 1)) };
489
- const authoritative = (await this.readRunnerHistory(session, historyInput).catch(() => undefined))?.page;
490
- if (authoritative === undefined)
479
+ try {
480
+ const supplements = await this.options.runners
481
+ .require(session.runner)
482
+ .readConversationRewind({
483
+ session,
484
+ turns: page.turns,
485
+ managedActive: this.options.hasManagedActiveRun(session),
486
+ firstPage: input.cursor === undefined,
487
+ readHistory: async (historyInput) => (await this.readRunnerHistory(session, historyInput)).page
488
+ });
489
+ if (supplements.size === 0)
490
+ return page;
491
+ return {
492
+ ...page,
493
+ turns: page.turns.map((turn) => {
494
+ const rewind = supplements.get(turn.id);
495
+ return turn.rewind !== undefined || rewind === undefined ? turn : { ...turn, rewind };
496
+ })
497
+ };
498
+ }
499
+ catch {
491
500
  return page;
492
- return {
493
- ...page,
494
- turns: page.turns.map((turn) => {
495
- if (turn.rewind !== undefined || !terminalConversationTurn(turn.status))
496
- return turn;
497
- const messageId = conversationClientMessageId(turn);
498
- if (messageId === null)
499
- return turn;
500
- const native = authoritative.turns.find((candidate) => candidate.rewind !== undefined &&
501
- candidate.userItemId !== null &&
502
- conversationClientMessageId(candidate) === messageId);
503
- return native?.rewind === undefined ? turn : { ...turn, rewind: native.rewind };
504
- })
505
- };
501
+ }
506
502
  }
507
503
  async refreshExternalActivity(session) {
508
504
  const runner = this.options.runners.require(session.runner);
@@ -754,19 +750,6 @@ function sameConversationTurn(left, right) {
754
750
  candidate.id === `${local.runId}:${item.id}` &&
755
751
  ((native.userItemId === null && local.userItemId === null) || item.kind === 'error')));
756
752
  }
757
- function terminalConversationTurn(status) {
758
- return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
759
- }
760
- function conversationClientMessageId(turn) {
761
- for (const item of turn.items) {
762
- if (item.kind !== 'user_message' || !isPlainRecord(item.payload))
763
- continue;
764
- const value = item.payload.clientMessageId;
765
- if (typeof value === 'string' && value.length > 0)
766
- return value;
767
- }
768
- return null;
769
- }
770
753
  function sourceRank(source) {
771
754
  if (source === 'official')
772
755
  return 3;
@@ -111,16 +111,7 @@ export class GoalContinuationCoordinator {
111
111
  ...(pending.personalInstructions === undefined
112
112
  ? {}
113
113
  : { personalInstructions: pending.personalInstructions }),
114
- ...(pending.marAgentModel === undefined ? {} : { marAgentModel: pending.marAgentModel }),
115
- ...(pending.marAgentModels === undefined
116
- ? {}
117
- : { marAgentModels: pending.marAgentModels }),
118
- ...(pending.marAgentCompact === undefined
119
- ? {}
120
- : { marAgentCompact: pending.marAgentCompact }),
121
- ...(pending.marAgentRuntimeScopeId === undefined
122
- ? {}
123
- : { marAgentRuntimeScopeId: pending.marAgentRuntimeScopeId }),
114
+ executionContext: pending.executionContext,
124
115
  continuationEpoch,
125
116
  deliveryIntent: 'SEND'
126
117
  });
@@ -182,7 +173,7 @@ export class GoalContinuationCoordinator {
182
173
  goalAccessChanged: prepared.changed
183
174
  };
184
175
  }
185
- async start({ session, objective, initiatedByUserId, suppressNotifications, secretEnvironment, personalInstructions, marAgentModel, marAgentModels, marAgentCompact, marAgentRuntimeScopeId, usageGrant }) {
176
+ async start({ session, objective, initiatedByUserId, suppressNotifications, secretEnvironment, personalInstructions, executionContext, usageGrant }) {
186
177
  this.invalidateGoalContinuation(session.id);
187
178
  const prepared = this.prepareGoalSession(session);
188
179
  const goalSession = prepared.session;
@@ -208,10 +199,7 @@ export class GoalContinuationCoordinator {
208
199
  ...(initiatedByUserId === undefined ? {} : { initiatedByUserId }),
209
200
  secretEnvironment,
210
201
  ...(personalInstructions === undefined ? {} : { personalInstructions }),
211
- ...(marAgentModel === undefined ? {} : { marAgentModel }),
212
- ...(marAgentModels === undefined ? {} : { marAgentModels }),
213
- ...(marAgentCompact === undefined ? {} : { marAgentCompact }),
214
- ...(marAgentRuntimeScopeId === undefined ? {} : { marAgentRuntimeScopeId })
202
+ executionContext
215
203
  });
216
204
  if (!this.goalGenerationCurrent(attempt.sessionId, goalRef)) {
217
205
  this.removeStaleGoalSubmission(attempt.sessionId, result);
@@ -330,12 +318,7 @@ export class GoalContinuationCoordinator {
330
318
  ...(input.personalInstructions === undefined
331
319
  ? {}
332
320
  : { personalInstructions: input.personalInstructions }),
333
- ...(input.marAgentModel === undefined ? {} : { marAgentModel: input.marAgentModel }),
334
- ...(input.marAgentModels === undefined ? {} : { marAgentModels: input.marAgentModels }),
335
- ...(input.marAgentCompact === undefined ? {} : { marAgentCompact: input.marAgentCompact }),
336
- ...(input.marAgentRuntimeScopeId === undefined
337
- ? {}
338
- : { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
321
+ executionContext: input.executionContext
339
322
  });
340
323
  if (updated !== undefined &&
341
324
  session !== undefined &&
@@ -440,12 +423,7 @@ export class GoalContinuationCoordinator {
440
423
  ...(input.personalInstructions === undefined
441
424
  ? {}
442
425
  : { personalInstructions: input.personalInstructions }),
443
- ...(input.marAgentModel === undefined ? {} : { marAgentModel: input.marAgentModel }),
444
- ...(input.marAgentModels === undefined ? {} : { marAgentModels: input.marAgentModels }),
445
- ...(input.marAgentCompact === undefined ? {} : { marAgentCompact: input.marAgentCompact }),
446
- ...(input.marAgentRuntimeScopeId === undefined
447
- ? {}
448
- : { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
426
+ executionContext: input.executionContext
449
427
  });
450
428
  if (!this.goalGenerationCurrent(attempt.sessionId, generation)) {
451
429
  this.removeStaleGoalSubmission(attempt.sessionId, result);
@@ -1,5 +1,6 @@
1
1
  import { parseComposerTags, serializeComposerTag } from '@myagentroam/protocol';
2
2
  import { parseMessageAttachments } from '../util/node-operation-parsers.js';
3
+ import { RUNNER_EXECUTION_CONTEXT } from '../runner/execution-context.js';
3
4
  import { RESTART_NOTICE } from './planned-restart-handoff.js';
4
5
  /** Rebuild inputs through the existing preparation and FIFO boundary. */
5
6
  export class RestartSessionRestorer {
@@ -79,10 +80,7 @@ export class RestartSessionRestorer {
79
80
  __suppressNotifications: input.suppressNotifications === true,
80
81
  secretEnvironment: context.secretEnvironment,
81
82
  personalInstructions: context.personalInstructions,
82
- marAgentModel: context.marAgentModel,
83
- marAgentModels: context.marAgentModels,
84
- marAgentCompact: context.marAgentCompact,
85
- marAgentRuntimeScopeId: context.marAgentRuntimeScopeId,
83
+ [RUNNER_EXECUTION_CONTEXT]: context.executionContext,
86
84
  __usageGrant: context.usageGrant
87
85
  }));
88
86
  if (input.immediate)
@@ -124,10 +122,7 @@ ${RESTART_NOTICE}`,
124
122
  ...(typeof context.personalInstructions === 'string'
125
123
  ? { personalInstructions: context.personalInstructions }
126
124
  : {}),
127
- marAgentModel: context.marAgentModel,
128
- marAgentModels: context.marAgentModels,
129
- marAgentCompact: context.marAgentCompact,
130
- marAgentRuntimeScopeId: context.marAgentRuntimeScopeId
125
+ executionContext: context.executionContext
131
126
  }));
132
127
  }
133
128
  if (!entry.paused)
@@ -14,7 +14,9 @@ export class ScheduledSessionCoordinator {
14
14
  if (run.source !== 'SCHEDULED')
15
15
  return;
16
16
  const store = this.options.store();
17
- store.finish(run.clientMessageId, status === 'SUCCEEDED' ? 'SUCCEEDED' : 'FAILED', status === 'SUCCEEDED' ? undefined : `SCHEDULE_RUN_${status}`, run.id);
17
+ const finished = store.finish(run.clientMessageId, status === 'SUCCEEDED' ? 'SUCCEEDED' : 'FAILED', status === 'SUCCEEDED' ? undefined : `SCHEDULE_RUN_${status}`, run.id);
18
+ if (!finished)
19
+ return;
18
20
  const task = store.getById(run.clientMessageId.split(':', 1)[0]);
19
21
  if (task !== undefined)
20
22
  this.publishTaskNotification(task, run.clientMessageId, {
@@ -169,8 +171,8 @@ export class ScheduledSessionCoordinator {
169
171
  }
170
172
  if (failureCode !== undefined) {
171
173
  const status = submitting && failureCode !== 'SCHEDULE_SESSION_BUSY' ? 'FAILED' : 'SKIPPED';
172
- this.options.store().finish(occurrenceId, status, failureCode);
173
- this.notify(task, occurrenceId, failureCode, status);
174
+ if (this.options.store().finish(occurrenceId, status, failureCode))
175
+ this.notify(task, occurrenceId, failureCode, status);
174
176
  }
175
177
  }
176
178
  }
@@ -37,17 +37,44 @@ export function nextScheduledAt(cron, timezone, after) {
37
37
  year: 'numeric',
38
38
  month: '2-digit',
39
39
  day: '2-digit',
40
- weekday: 'short',
41
40
  hour: '2-digit',
42
41
  minute: '2-digit',
43
42
  hourCycle: 'h23'
44
43
  });
45
- const previousKey = localKey(formatter, after);
46
- let candidate = Math.floor(after / 60_000) * 60_000 + 60_000;
47
- for (let index = 0; index < 8 * 366 * 24 * 60; index++, candidate += 60_000) {
48
- const local = localParts(formatter, candidate);
49
- if (matches(schedule, local) && local.key !== previousKey)
50
- return candidate;
44
+ const start = localParts(formatter, after);
45
+ const dayMs = 24 * 60 * 60_000;
46
+ const limit = Math.floor(after / 60_000) * 60_000 + 8 * 366 * dayMs;
47
+ const firstDay = Date.UTC(start.year, start.month - 1, start.day);
48
+ const hours = [...schedule.hour].sort((a, b) => a - b);
49
+ const minutes = [...schedule.minute].sort((a, b) => a - b);
50
+ // These are calendar dates represented in UTC, not fixed-length local days.
51
+ for (let day = firstDay; day <= limit + dayMs; day += dayMs) {
52
+ const date = new Date(day);
53
+ if (!matchesDate(schedule, date))
54
+ continue;
55
+ // Sample both sides of this date to include offsets on either side of a
56
+ // transition, including half-hour folds and whole-day calendar jumps.
57
+ const offsets = new Set([day - dayMs, day, day + 2 * dayMs].map((time) => localParts(formatter, time).timestamp - time));
58
+ for (const hour of hours) {
59
+ if (day === firstDay && hour < start.hour)
60
+ continue;
61
+ for (const minute of minutes) {
62
+ if (day === firstDay && hour === start.hour && minute < start.minute)
63
+ continue;
64
+ const localTime = day + (hour * 60 + minute) * 60_000;
65
+ let earliest = Infinity;
66
+ for (const offset of offsets) {
67
+ const candidate = localTime - offset;
68
+ if (localParts(formatter, candidate).timestamp === localTime) {
69
+ earliest = Math.min(earliest, candidate);
70
+ }
71
+ }
72
+ // Resolve an ambiguous minute before filtering by after: the second
73
+ // physical occurrence must never become a replacement for the first.
74
+ if (earliest > after && earliest <= limit)
75
+ return earliest;
76
+ }
77
+ }
51
78
  }
52
79
  throw new Error('SCHEDULE_NEXT_TIME_UNAVAILABLE');
53
80
  }
@@ -135,23 +162,21 @@ function localParts(formatter, time) {
135
162
  const minute = Number(fields['minute']);
136
163
  const day = Number(fields['day']);
137
164
  const month = Number(fields['month']);
138
- const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(fields['weekday'] ?? '');
165
+ const year = Number(fields['year']);
139
166
  return {
167
+ year,
140
168
  hour,
141
169
  minute,
142
170
  day,
143
171
  month,
144
- weekday,
145
- key: `${fields['year']}-${fields['month']}-${fields['day']}T${fields['hour']}:${fields['minute']}`
172
+ timestamp: Date.UTC(year, month - 1, day, hour, minute)
146
173
  };
147
174
  }
148
- function matches(schedule, local) {
149
- if (!schedule.minute.has(local.minute) ||
150
- !schedule.hour.has(local.hour) ||
151
- !schedule.month.has(local.month))
175
+ function matchesDate(schedule, date) {
176
+ if (!schedule.month.has(date.getUTCMonth() + 1))
152
177
  return false;
153
- const dayOfMonthMatches = schedule.dayOfMonth.has(local.day);
154
- const dayOfWeekMatches = schedule.dayOfWeek.has(local.weekday);
178
+ const dayOfMonthMatches = schedule.dayOfMonth.has(date.getUTCDate());
179
+ const dayOfWeekMatches = schedule.dayOfWeek.has(date.getUTCDay());
155
180
  const dayMatches = schedule.dayOfMonthWildcard && schedule.dayOfWeekWildcard
156
181
  ? true
157
182
  : schedule.dayOfMonthWildcard
@@ -161,6 +186,3 @@ function matches(schedule, local) {
161
186
  : dayOfMonthMatches || dayOfWeekMatches;
162
187
  return dayMatches;
163
188
  }
164
- function localKey(formatter, time) {
165
- return localParts(formatter, time).key;
166
- }
@@ -146,7 +146,7 @@ export class ScheduledSessionStore {
146
146
  const enabled = patch.enabled ?? current.enabled;
147
147
  const ownerUserId = patch.ownerUserId ?? current.ownerUserId;
148
148
  const nextRunAt = enabled
149
- ? patch.enabled === true || patch.cron !== undefined || patch.timezone !== undefined
149
+ ? !current.enabled || cron !== current.cron || timezone !== current.timezone
150
150
  ? nextScheduledAt(cron, timezone, this.now())
151
151
  : current.nextRunAt
152
152
  : null;
@@ -258,9 +258,11 @@ export class ScheduledSessionStore {
258
258
  .prepare(`UPDATE scheduled_session_occurrences SET status=?,code=?,run_id=?,updated_at=?
259
259
  WHERE id=? AND status IN ('CLAIMED','SUBMITTED')`)
260
260
  .run(status, code ?? null, runId ?? null, this.now(), occurrenceId);
261
- if (result.changes === 0 || status === 'SUBMITTED')
262
- return;
263
- this.pruneTerminal(occurrenceId);
261
+ if (result.changes !== 1)
262
+ return false;
263
+ if (status !== 'SUBMITTED')
264
+ this.pruneTerminal(occurrenceId);
265
+ return true;
264
266
  }
265
267
  pruneTerminal(occurrenceId) {
266
268
  this.database