@myagentroam/node 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/codex-app-server.d.ts +24 -0
  2. package/dist/codex-app-server.js +100 -5
  3. package/dist/connector.js +18 -15
  4. package/dist/control-outbound-scheduler.d.ts +4 -1
  5. package/dist/control-outbound-scheduler.js +39 -6
  6. package/dist/database.d.ts +2 -19
  7. package/dist/database.js +1 -62
  8. package/dist/event-buffer.d.ts +2 -5
  9. package/dist/event-buffer.js +3 -12
  10. package/dist/native-session-history.js +33 -12
  11. package/dist/runner/claude/managed-run-controller.d.ts +0 -1
  12. package/dist/runner/claude/managed-run-controller.js +17 -12
  13. package/dist/runner/codex/conversation-parser.d.ts +1 -1
  14. package/dist/runner/codex/conversation-parser.js +3 -0
  15. package/dist/runner/codex/managed-run-controller.d.ts +11 -2
  16. package/dist/runner/codex/managed-run-controller.js +79 -24
  17. package/dist/runner/codex-runner.d.ts +3 -0
  18. package/dist/runner/codex-runner.js +42 -3
  19. package/dist/runner/opencode/conversation-parser.js +1 -1
  20. package/dist/runner/opencode/managed-run-controller.d.ts +7 -2
  21. package/dist/runner/opencode/managed-run-controller.js +72 -14
  22. package/dist/runner/opencode-runner.d.ts +3 -0
  23. package/dist/runner/opencode-runner.js +25 -0
  24. package/dist/runner-profiles.js +10 -6
  25. package/dist/runtime-state.d.ts +7 -12
  26. package/dist/runtime-state.js +43 -53
  27. package/dist/service/attachment-domain-state.d.ts +2 -0
  28. package/dist/service/attachment-domain-state.js +2 -0
  29. package/dist/service/conversation-history-service.d.ts +8 -0
  30. package/dist/service/conversation-history-service.js +181 -9
  31. package/dist/service/conversation-segment-service.js +17 -10
  32. package/dist/service/external-session-resume-service.js +9 -0
  33. package/dist/service/fake-managed-run-service.d.ts +0 -1
  34. package/dist/service/fake-managed-run-service.js +0 -2
  35. package/dist/service/native-session-projection-service.js +5 -0
  36. package/dist/service/native-session-watch-service.d.ts +8 -2
  37. package/dist/service/native-session-watch-service.js +28 -10
  38. package/dist/service/node-connection-lifecycle-service.d.ts +0 -2
  39. package/dist/service/node-connection-lifecycle-service.js +0 -1
  40. package/dist/service/node-control-channel.js +1 -1
  41. package/dist/service/node-control-message-service.d.ts +0 -1
  42. package/dist/service/node-control-message-service.js +9 -6
  43. package/dist/service/node-request-service.js +2 -1
  44. package/dist/service/node-workspace-coordinator.d.ts +1 -4
  45. package/dist/service/node-workspace-coordinator.js +2 -12
  46. package/dist/service/run-attachment-service.js +30 -6
  47. package/dist/service/run-event-service.d.ts +1 -0
  48. package/dist/service/run-event-service.js +80 -0
  49. package/dist/service/run-workbench-service.d.ts +1 -1
  50. package/dist/service/run-workbench-service.js +1 -5
  51. package/dist/service/runner-interaction-service.d.ts +1 -2
  52. package/dist/service/runner-interaction-service.js +1 -4
  53. package/dist/service/session-context-service.d.ts +1 -0
  54. package/dist/service/session-context-service.js +14 -0
  55. package/dist/service/session-domain-state.d.ts +1 -0
  56. package/dist/service/session-domain-state.js +1 -0
  57. package/dist/service/session-lifecycle-service.js +4 -12
  58. package/dist/service/session-message-service.d.ts +1 -0
  59. package/dist/service/session-message-service.js +109 -85
  60. package/dist/service/skill-directory-service.d.ts +3 -1
  61. package/dist/service/skill-directory-service.js +28 -4
  62. package/dist/service/skill-node-operation-service.d.ts +1 -1
  63. package/dist/service/skill-node-operation-service.js +4 -2
  64. package/dist/service/workbench-event-service.d.ts +1 -13
  65. package/dist/service/workbench-event-service.js +2 -36
  66. package/dist/service/workbench-manifest-service.d.ts +0 -1
  67. package/dist/service/workbench-manifest-service.js +0 -1
  68. package/dist/service/workspace-change-service.js +1 -1
  69. package/dist/service/workspace-domain-state.d.ts +1 -0
  70. package/dist/service/workspace-queue-workbench-service.d.ts +13 -2
  71. package/dist/service/workspace-queue-workbench-service.js +8 -2
  72. package/dist/service/workspace-upload-service.d.ts +2 -0
  73. package/dist/service/workspace-upload-service.js +28 -6
  74. package/dist/service/workspace-workbench-service.d.ts +0 -3
  75. package/dist/service/workspace-workbench-service.js +0 -17
  76. package/dist/util/runner-error.d.ts +6 -0
  77. package/dist/util/runner-error.js +52 -0
  78. package/dist/util/runner-native-session-parsers.d.ts +2 -1
  79. package/dist/util/runner-native-session-parsers.js +63 -15
  80. package/dist/workspace.js +33 -8
  81. package/package.json +2 -2
@@ -9,22 +9,22 @@ export class NodeRuntimeState {
9
9
  nodeId;
10
10
  getWorkspace;
11
11
  now;
12
+ static MAX_COMPLETED_TURNS_PER_SESSION = 10;
12
13
  sessions = new Map();
13
14
  runs = new Map();
14
15
  messageRuns = new Map();
15
16
  events = new Map();
16
17
  turns = new Map();
17
18
  approvals = new Map();
18
- workspaceEvents = new Map();
19
19
  /** A Session owns at most one running turn; Workspace sessions may run concurrently. */
20
20
  leases = new Map();
21
21
  /** Session FIFO metadata is deliberately process-local with the rest of run state. */
22
22
  queuedRuns = new Map();
23
23
  queueVersions = new Map();
24
24
  pausedQueues = new Set();
25
- snapshots = new Map();
25
+ /** Bounded tombstones reject stale internal start envelopes after terminal cleanup. */
26
+ terminalRunIds = new Set();
26
27
  nextEventId = 1;
27
- nextWorkspaceEventId = 1;
28
28
  constructor(nodeId, getWorkspace, now = Date.now) {
29
29
  this.nodeId = nodeId;
30
30
  this.getWorkspace = getWorkspace;
@@ -73,6 +73,8 @@ export class NodeRuntimeState {
73
73
  }
74
74
  /** Internal envelope compatibility for a Run already accepted in this process. */
75
75
  adoptRun(input) {
76
+ if (this.terminalRunIds.has(input.runId))
77
+ throw new Error('RUN_TERMINAL');
76
78
  let session = this.sessions.get(input.sessionId);
77
79
  if (session === undefined) {
78
80
  const now = this.now();
@@ -253,7 +255,7 @@ export class NodeRuntimeState {
253
255
  const session = this.requireSession(input.sessionId);
254
256
  const now = this.now();
255
257
  const run = {
256
- id: randomUUID(),
258
+ id: input.runId ?? randomUUID(),
257
259
  sessionId: session.id,
258
260
  nodeId: this.nodeId(),
259
261
  workspaceId: session.workspaceId,
@@ -262,6 +264,9 @@ export class NodeRuntimeState {
262
264
  recoveryState: 'ACTIVE',
263
265
  version: 0,
264
266
  clientMessageId: input.clientMessageId,
267
+ ...(input.initiatedByUserId === undefined
268
+ ? {}
269
+ : { initiatedByUserId: input.initiatedByUserId }),
265
270
  createdAt: now,
266
271
  updatedAt: now
267
272
  };
@@ -316,11 +321,6 @@ export class NodeRuntimeState {
316
321
  this.queuedRuns.set(session.id, [...(this.queuedRuns.get(session.id) ?? []), run.id]);
317
322
  this.bumpQueueVersion(session.id);
318
323
  this.turns.set(session.id, [...(this.turns.get(session.id) ?? []), fixedTurn]);
319
- this.recordWorkspaceActivity(run.workspaceId, 'run.created', {
320
- runId: run.id,
321
- sessionId: session.id,
322
- runner: run.runner
323
- });
324
324
  this.replaceSession(session.id, { lastActivityAt: now });
325
325
  return { run, created: true };
326
326
  }
@@ -402,7 +402,6 @@ export class NodeRuntimeState {
402
402
  this.bumpQueueVersion(run.sessionId);
403
403
  this.runs.delete(runId);
404
404
  this.events.delete(runId);
405
- this.snapshots.delete(runId);
406
405
  this.turns.set(run.sessionId, (this.turns.get(run.sessionId) ?? []).filter((turn) => turn.runId !== runId));
407
406
  for (const [key, candidate] of this.messageRuns)
408
407
  if (candidate === runId)
@@ -468,12 +467,6 @@ export class NodeRuntimeState {
468
467
  if (input.status !== undefined)
469
468
  this.transitionRun(input.runId, input.status);
470
469
  this.projectEvent(value);
471
- const run = this.requireRun(input.runId);
472
- this.recordWorkspaceActivity(run.workspaceId, 'run.updated', {
473
- runId: run.id,
474
- eventType: input.eventType,
475
- status: input.status ?? null
476
- });
477
470
  return value;
478
471
  }
479
472
  listRunEvents(id, after = 0) {
@@ -481,15 +474,16 @@ export class NodeRuntimeState {
481
474
  }
482
475
  listConversationTurns(input) {
483
476
  this.requireSession(input.sessionId);
484
- // Queued turns have not received `run.started` yet. Their user item still
485
- // has the acceptance timestamp, so use it rather than sorting every
486
- // queued turn as time zero. The insertion index makes same-millisecond
487
- // sends deterministic; callers receive every page from oldest to newest.
477
+ // A managed Turn keeps the user's acceptance time as its stable timeline
478
+ // position. Runner start/completion timestamps must not move it behind a
479
+ // message that was accepted later. Native Turns without a user timestamp
480
+ // fall back to their own lifecycle time.
488
481
  const all = [...(this.turns.get(input.sessionId) ?? [])]
489
482
  .map((turn, index) => ({ turn, index }))
490
483
  .sort((a, b) => {
491
- const timestamp = (value) => value.startedAt ??
492
- value.items.find((item) => item.id === value.userItemId)?.startedAt ??
484
+ const timestamp = (value) => value.items.find((item) => item.id === value.userItemId)?.startedAt ??
485
+ value.startedAt ??
486
+ value.completedAt ??
493
487
  0;
494
488
  return timestamp(a.turn) - timestamp(b.turn) || a.index - b.index;
495
489
  })
@@ -559,16 +553,6 @@ export class NodeRuntimeState {
559
553
  this.replaceSession(session.id, { lastActivityAt: now });
560
554
  return completedTurn;
561
555
  }
562
- listWorkspaceActivity(input) {
563
- const all = this.workspaceEvents.get(input.workspaceId) ?? [];
564
- const start = decodeCursor(input.cursor, input.workspaceId);
565
- const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
566
- const events = all.slice(start, start + limit);
567
- return {
568
- events,
569
- nextCursor: start + limit < all.length ? encodeCursor(input.workspaceId, start + limit) : null
570
- };
571
- }
572
556
  createApproval(input) {
573
557
  this.requireRun(input.runId);
574
558
  if (this.approvals.has(input.approvalId))
@@ -631,11 +615,30 @@ export class NodeRuntimeState {
631
615
  lastRunSequence(id) {
632
616
  return this.events.get(id)?.at(-1)?.sequence ?? 0;
633
617
  }
634
- saveSnapshot(id, value) {
635
- this.snapshots.set(id, value);
636
- }
637
- getSnapshot(id) {
638
- return this.snapshots.get(id);
618
+ removeTerminalRun(runId) {
619
+ const run = this.requireRun(runId);
620
+ if (!terminal(run.status))
621
+ throw new Error('RUN_ACTIVE');
622
+ this.terminalRunIds.add(runId);
623
+ if (this.terminalRunIds.size > 1_024)
624
+ this.terminalRunIds.delete(this.terminalRunIds.values().next().value);
625
+ const queue = this.queuedRuns.get(run.sessionId) ?? [];
626
+ if (queue.includes(runId)) {
627
+ this.queuedRuns.set(run.sessionId, queue.filter((candidate) => candidate !== runId));
628
+ this.bumpQueueVersion(run.sessionId);
629
+ }
630
+ this.runs.delete(runId);
631
+ this.events.delete(runId);
632
+ const turns = this.turns.get(run.sessionId) ?? [];
633
+ const completed = turns.filter((turn) => terminalTurn(turn.status));
634
+ const retainedCompleted = new Set(completed.slice(-NodeRuntimeState.MAX_COMPLETED_TURNS_PER_SESSION).map((turn) => turn.id));
635
+ this.turns.set(run.sessionId, turns.filter((turn) => !terminalTurn(turn.status) || retainedCompleted.has(turn.id)));
636
+ for (const [key, candidate] of this.messageRuns)
637
+ if (candidate === runId)
638
+ this.messageRuns.delete(key);
639
+ for (const [approvalId, approval] of this.approvals)
640
+ if (approval.runId === runId)
641
+ this.approvals.delete(approvalId);
639
642
  }
640
643
  hasActiveWorkspaceLease(workspaceId) {
641
644
  return [...this.leases.values()]
@@ -658,7 +661,6 @@ export class NodeRuntimeState {
658
661
  for (const runId of runIds) {
659
662
  this.runs.delete(runId);
660
663
  this.events.delete(runId);
661
- this.snapshots.delete(runId);
662
664
  }
663
665
  for (const [key, runId] of this.messageRuns)
664
666
  if (runIds.has(runId))
@@ -681,7 +683,6 @@ export class NodeRuntimeState {
681
683
  for (const runId of runIds) {
682
684
  this.runs.delete(runId);
683
685
  this.events.delete(runId);
684
- this.snapshots.delete(runId);
685
686
  }
686
687
  for (const [key, runId] of this.messageRuns)
687
688
  if (runIds.has(runId))
@@ -689,7 +690,6 @@ export class NodeRuntimeState {
689
690
  for (const [approvalId, approval] of this.approvals)
690
691
  if (runIds.has(approval.runId))
691
692
  this.approvals.delete(approvalId);
692
- this.workspaceEvents.delete(workspaceId);
693
693
  for (const sessionId of sessionIds) {
694
694
  this.queuedRuns.delete(sessionId);
695
695
  this.queueVersions.delete(sessionId);
@@ -730,19 +730,6 @@ export class NodeRuntimeState {
730
730
  throw new Error('APPROVAL_NOT_FOUND');
731
731
  return value;
732
732
  }
733
- recordWorkspaceActivity(workspaceId, eventType, payload) {
734
- const event = {
735
- id: this.nextWorkspaceEventId++,
736
- workspaceId,
737
- eventType,
738
- payload,
739
- createdAt: this.now()
740
- };
741
- this.workspaceEvents.set(workspaceId, [
742
- ...(this.workspaceEvents.get(workspaceId) ?? []),
743
- event
744
- ]);
745
- }
746
733
  updateTurnForRun(runId, update) {
747
734
  const run = this.requireRun(runId);
748
735
  const current = this.turns.get(run.sessionId) ?? [];
@@ -840,6 +827,9 @@ export class NodeRuntimeState {
840
827
  function terminal(status) {
841
828
  return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
842
829
  }
830
+ function terminalTurn(status) {
831
+ return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED'].includes(status);
832
+ }
843
833
  function transitionAllowed(from, to) {
844
834
  return !terminal(from) || from === to;
845
835
  }
@@ -1,5 +1,7 @@
1
1
  export declare class AttachmentDomainState<TCachedRunImages, TNativeImageSource> {
2
2
  readonly runDirectories: Map<string, string>;
3
+ readonly runFileDirectories: Map<string, string>;
3
4
  readonly runImages: Map<string, TCachedRunImages>;
4
5
  readonly nativeHistoryImages: Map<string, TNativeImageSource[]>;
6
+ readonly nativeHistoryImageTimers: Map<string, NodeJS.Timeout>;
5
7
  }
@@ -1,5 +1,7 @@
1
1
  export class AttachmentDomainState {
2
2
  runDirectories = new Map();
3
+ runFileDirectories = new Map();
3
4
  runImages = new Map();
4
5
  nativeHistoryImages = new Map();
6
+ nativeHistoryImageTimers = new Map();
5
7
  }
@@ -5,6 +5,10 @@ import type { RunnerRegistry } from '../runner/runner-registry.js';
5
5
  import { type ConversationHistoryPage } from '../util/runner-native-session-parsers.js';
6
6
  import type { RunAttachmentService } from './run-attachment-service.js';
7
7
  export declare function hasManagedActiveRun(runtime: NodeRuntimeState, session: NodeAgentSession): boolean;
8
+ export declare function convergeConversationWatchPage(previous: ConversationHistoryPage | undefined, previousObserved: ConversationHistoryPage | undefined, next: ConversationHistoryPage, previousAuthority: unknown): {
9
+ readonly page: ConversationHistoryPage;
10
+ readonly authority: ConversationHistoryPage['source'];
11
+ };
8
12
  interface ConversationHistoryServiceOptions {
9
13
  readonly runtime: NodeRuntimeState;
10
14
  readonly runners: RunnerRegistry;
@@ -16,11 +20,15 @@ interface ConversationHistoryServiceOptions {
16
20
  }
17
21
  export declare class ConversationHistoryService {
18
22
  private readonly options;
23
+ private readonly activeOfficialReads;
19
24
  constructor(options: ConversationHistoryServiceOptions);
20
25
  read(session: NodeAgentSession, input: {
21
26
  readonly cursor?: string;
22
27
  readonly limit?: number;
23
28
  }): Promise<ConversationHistoryPage>;
29
+ private readManagedActive;
30
+ private prefetchActiveOfficial;
31
+ private takeActiveOfficialRead;
24
32
  refreshExternalActivity(session: NodeAgentSession): Promise<void>;
25
33
  readNative(session: NodeAgentSession): Promise<import("../native-session-history.js").NativeSessionHistory | undefined>;
26
34
  private attachNativeImages;
@@ -8,14 +8,55 @@ export function hasManagedActiveRun(runtime, session) {
8
8
  .listRunsForSession(session.id)
9
9
  .some((run) => run.status === 'STARTING' || run.status === 'RUNNING' || run.status === 'CANCELLING'));
10
10
  }
11
+ export function convergeConversationWatchPage(previous, previousObserved, next, previousAuthority) {
12
+ const authority = isConversationHistorySource(previousAuthority)
13
+ ? previousAuthority
14
+ : previous?.source;
15
+ if (previous === undefined ||
16
+ authority === undefined ||
17
+ sourceRank(next.source) >= sourceRank(authority))
18
+ return { page: next, authority: next.source };
19
+ if (previous.turns.length === 0)
20
+ return { page: next, authority };
21
+ const boundary = next.turns.findLastIndex((turn) => previous.turns.some((candidate) => sameConversationTurn(candidate, turn)));
22
+ const observedBoundary = boundary >= 0 || previousObserved === undefined
23
+ ? -1
24
+ : next.turns.findLastIndex((turn) => previousObserved.turns.some((candidate) => sameConversationTurn(candidate, turn)));
25
+ const safeBoundary = Math.max(boundary, observedBoundary);
26
+ if (safeBoundary < 0)
27
+ return { page: previous, authority };
28
+ const additions = next.turns
29
+ .slice(safeBoundary + 1)
30
+ .filter((turn) => !previous.turns.some((candidate) => sameConversationTurn(candidate, turn)));
31
+ if (additions.length === 0)
32
+ return { page: previous, authority };
33
+ return {
34
+ page: {
35
+ ...previous,
36
+ source: next.source,
37
+ snapshotSequence: Math.max(previous.snapshotSequence, next.snapshotSequence),
38
+ readAt: next.readAt,
39
+ latestVisibleAt: next.latestVisibleAt ?? previous.latestVisibleAt,
40
+ turns: [...previous.turns, ...additions]
41
+ },
42
+ authority
43
+ };
44
+ }
45
+ function isConversationHistorySource(value) {
46
+ return value === 'official' || value === 'native' || value === 'runtime';
47
+ }
11
48
  export class ConversationHistoryService {
12
49
  options;
50
+ activeOfficialReads = new Map();
13
51
  constructor(options) {
14
52
  this.options = options;
15
53
  }
16
54
  async read(session, input) {
17
55
  const snapshotSequence = this.options.snapshotSequence();
18
56
  const readAt = Date.now();
57
+ const activeCursor = decodeActiveHistoryCursor(input.cursor, session.id);
58
+ if (activeCursor !== undefined || this.options.hasManagedActiveRun(session))
59
+ return this.readManagedActive(session, input, activeCursor, snapshotSequence, readAt);
19
60
  let official = await this.readOfficial(session, input);
20
61
  if (official !== undefined) {
21
62
  const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
@@ -23,13 +64,11 @@ export class ConversationHistoryService {
23
64
  ? []
24
65
  : this.options.runtime.listConversationTurns({ sessionId: session.id, limit: 500 }).turns;
25
66
  const initialOfficialRunIds = new Set(official.turns.flatMap((turn) => (turn.runId === null ? [] : [turn.runId])));
26
- const absentLocalTurns = runtimeTurns.filter((turn) => turn.runId === null || !initialOfficialRunIds.has(turn.runId));
27
- const officialFloor = Math.min(...official.turns.map((turn) => Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0)));
28
- const missingLocalTurns = official.turns.length === 0
29
- ? absentLocalTurns.slice(-limit)
30
- : officialFloor > 0
31
- ? absentLocalTurns.filter((turn) => Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0) >= officialFloor)
32
- : absentLocalTurns.slice(-1);
67
+ const lastRepresentedRuntimeIndex = runtimeTurns.findLastIndex((turn) => turn.runId !== null && initialOfficialRunIds.has(turn.runId));
68
+ const officialHasTurns = official.turns.length > 0;
69
+ const absentLocalTurns = runtimeTurns.filter((turn, index) => (turn.runId === null || !initialOfficialRunIds.has(turn.runId)) &&
70
+ (!officialHasTurns || index > lastRepresentedRuntimeIndex));
71
+ const missingLocalTurns = absentLocalTurns.slice(-limit);
33
72
  if (input.cursor === undefined &&
34
73
  missingLocalTurns.length > 0 &&
35
74
  official.turns.length + missingLocalTurns.length > limit) {
@@ -49,7 +88,7 @@ export class ConversationHistoryService {
49
88
  pendingItemsByRunId.set(turn.runId, pendingItems);
50
89
  }
51
90
  const availableLocalSlots = Math.max(0, limit - officialTurns.length);
52
- const localCandidates = (input.cursor === undefined ? missingLocalTurns : []).sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
91
+ const localCandidates = input.cursor === undefined ? missingLocalTurns : [];
53
92
  const localTurns = availableLocalSlots === 0 ? [] : localCandidates.slice(-availableLocalSlots);
54
93
  const localRequestIds = new Set([
55
94
  ...localTurns.flatMap((turn) => turn.items.flatMap((item) => {
@@ -78,7 +117,7 @@ export class ConversationHistoryService {
78
117
  })
79
118
  .filter((turn) => turn.items.length > 0),
80
119
  ...localTurns
81
- ].sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
120
+ ];
82
121
  return this.page(official.nextCursor, turns, {
83
122
  snapshotSequence,
84
123
  source: 'official',
@@ -119,6 +158,86 @@ export class ConversationHistoryService {
119
158
  const page = this.options.runtime.listConversationTurns({ sessionId: session.id, ...input });
120
159
  return this.page(page.nextCursor, page.turns, { snapshotSequence, source: 'runtime', readAt });
121
160
  }
161
+ async readManagedActive(session, input, cursor, snapshotSequence, readAt) {
162
+ const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
163
+ const runtimeTurns = this.options.runtime.listConversationTurns({
164
+ sessionId: session.id,
165
+ limit: 500
166
+ }).turns;
167
+ const beforeAt = cursor?.beforeAt ?? oldestTurnActivityAt(runtimeTurns);
168
+ if (cursor?.stage === 'OFFICIAL') {
169
+ const prefetched = this.takeActiveOfficialRead(session.id, limit, cursor.cursor);
170
+ const official = await (prefetched ??
171
+ this.readOfficial(session, {
172
+ ...(cursor.cursor === null ? {} : { cursor: cursor.cursor }),
173
+ limit
174
+ }));
175
+ if (official === undefined)
176
+ return this.page(null, [], { snapshotSequence, source: 'runtime', readAt });
177
+ const turns = (await this.attachNativeImages(session, official.turns)).filter((turn) => latestTurnActivityAt(turn) < beforeAt);
178
+ return this.page(official.nextCursor === null
179
+ ? null
180
+ : encodeActiveHistoryCursor({
181
+ sessionId: session.id,
182
+ stage: 'OFFICIAL',
183
+ beforeAt,
184
+ cursor: official.nextCursor
185
+ }), turns, { snapshotSequence, source: 'official', readAt });
186
+ }
187
+ if (cursor === undefined && session.externalSessionId !== null)
188
+ this.prefetchActiveOfficial(session, limit);
189
+ const runtimePage = this.options.runtime.listConversationTurns({
190
+ sessionId: session.id,
191
+ ...(cursor?.cursor === null || cursor === undefined ? {} : { cursor: cursor.cursor }),
192
+ limit
193
+ });
194
+ const nextCursor = runtimePage.nextCursor !== null
195
+ ? encodeActiveHistoryCursor({
196
+ sessionId: session.id,
197
+ stage: 'RUNTIME',
198
+ beforeAt,
199
+ cursor: runtimePage.nextCursor
200
+ })
201
+ : session.externalSessionId === null
202
+ ? null
203
+ : encodeActiveHistoryCursor({
204
+ sessionId: session.id,
205
+ stage: 'OFFICIAL',
206
+ beforeAt,
207
+ cursor: null
208
+ });
209
+ return this.page(nextCursor, runtimePage.turns, {
210
+ snapshotSequence,
211
+ source: 'runtime',
212
+ readAt,
213
+ deferredOlderHistory: true
214
+ });
215
+ }
216
+ prefetchActiveOfficial(session, limit) {
217
+ const now = Date.now();
218
+ for (const [sessionId, read] of this.activeOfficialReads)
219
+ if (read.createdAt + 60_000 <= now)
220
+ this.activeOfficialReads.delete(sessionId);
221
+ const current = this.activeOfficialReads.get(session.id);
222
+ if (current !== undefined && current.limit === limit)
223
+ return;
224
+ this.activeOfficialReads.set(session.id, {
225
+ createdAt: now,
226
+ limit,
227
+ // The active Run fast path must stay independent from an optional
228
+ // background reader failure until the user explicitly requests older history.
229
+ page: this.readOfficial(session, { limit }).catch(() => undefined)
230
+ });
231
+ }
232
+ takeActiveOfficialRead(sessionId, limit, cursor) {
233
+ if (cursor !== null)
234
+ return undefined;
235
+ const read = this.activeOfficialReads.get(sessionId);
236
+ if (read === undefined || read.limit !== limit || read.createdAt + 60_000 <= Date.now())
237
+ return undefined;
238
+ this.activeOfficialReads.delete(sessionId);
239
+ return read.page;
240
+ }
122
241
  async refreshExternalActivity(session) {
123
242
  const runner = this.options.runners.require(session.runner);
124
243
  if (!runner.available(this.options.capabilities())) {
@@ -187,3 +306,56 @@ export class ConversationHistoryService {
187
306
  };
188
307
  }
189
308
  }
309
+ const ACTIVE_HISTORY_CURSOR_PREFIX = 'mar-active-history:1:';
310
+ function encodeActiveHistoryCursor(cursor) {
311
+ return `${ACTIVE_HISTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString('base64url')}`;
312
+ }
313
+ function decodeActiveHistoryCursor(value, sessionId) {
314
+ if (value === undefined || !value.startsWith(ACTIVE_HISTORY_CURSOR_PREFIX))
315
+ return undefined;
316
+ try {
317
+ const decoded = JSON.parse(Buffer.from(value.slice(ACTIVE_HISTORY_CURSOR_PREFIX.length), 'base64url').toString('utf8'));
318
+ if (decoded.sessionId !== sessionId ||
319
+ (decoded.stage !== 'RUNTIME' && decoded.stage !== 'OFFICIAL') ||
320
+ typeof decoded.beforeAt !== 'number' ||
321
+ !Number.isFinite(decoded.beforeAt) ||
322
+ decoded.beforeAt < 0 ||
323
+ (decoded.cursor !== null && typeof decoded.cursor !== 'string'))
324
+ throw new Error('EVENT_CURSOR_INVALID');
325
+ return decoded;
326
+ }
327
+ catch {
328
+ throw new Error('EVENT_CURSOR_INVALID');
329
+ }
330
+ }
331
+ function latestTurnActivityAt(turn) {
332
+ return Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0);
333
+ }
334
+ function oldestTurnActivityAt(turns) {
335
+ return turns.length === 0 ? 0 : Math.min(...turns.map(latestTurnActivityAt));
336
+ }
337
+ function sameConversationTurn(left, right) {
338
+ if (left.id === right.id)
339
+ return true;
340
+ if (left.runId !== null && left.runId === right.runId)
341
+ return true;
342
+ const leftMessageId = conversationClientMessageId(left);
343
+ return leftMessageId !== null && leftMessageId === conversationClientMessageId(right);
344
+ }
345
+ function conversationClientMessageId(turn) {
346
+ for (const item of turn.items) {
347
+ if (item.kind !== 'user_message' || !isPlainRecord(item.payload))
348
+ continue;
349
+ const value = item.payload.clientMessageId;
350
+ if (typeof value === 'string' && value.length > 0)
351
+ return value;
352
+ }
353
+ return null;
354
+ }
355
+ function sourceRank(source) {
356
+ if (source === 'official')
357
+ return 3;
358
+ if (source === 'native')
359
+ return 2;
360
+ return 1;
361
+ }
@@ -3,11 +3,11 @@ export function conversationSegments(turn) {
3
3
  const groups = [];
4
4
  let current = [];
5
5
  for (const item of turn.items) {
6
+ if (current.at(-1)?.kind === 'assistant_message' && item.kind !== 'assistant_message') {
7
+ groups.push(current);
8
+ current = [];
9
+ }
6
10
  current.push(item);
7
- if (item.kind !== 'assistant_message')
8
- continue;
9
- groups.push(current);
10
- current = [];
11
11
  }
12
12
  if (current.length > 0 || groups.length === 0)
13
13
  groups.push(current);
@@ -16,6 +16,7 @@ export function conversationSegments(turn) {
16
16
  const latest = index === groups.length - 1;
17
17
  const projectedItems = items.map((item) => ({
18
18
  ...item,
19
+ runId: turn.runId,
19
20
  segmentId: id,
20
21
  segmentIndex: index
21
22
  }));
@@ -36,14 +37,15 @@ export function conversationSegments(turn) {
36
37
  export function conversationSegmentIdentityForItem(turn, itemId, itemKind) {
37
38
  let index = 0;
38
39
  const runtimeItemId = turn.runId === null ? itemId : `${turn.runId}:${itemId}`;
39
- for (const item of turn.items) {
40
+ for (const [itemIndex, item] of turn.items.entries()) {
41
+ const previous = turn.items[itemIndex - 1];
42
+ if (previous?.kind === 'assistant_message' && item.kind !== 'assistant_message')
43
+ index += 1;
40
44
  if (item.id === itemId || item.id === runtimeItemId)
41
45
  return { id: `${turn.id}:segment:${index}`, index };
42
- if (item.kind === 'assistant_message')
43
- index += 1;
44
46
  }
45
- if (itemKind === 'assistant_message' && turn.items.at(-1)?.kind === 'assistant_message')
46
- index -= 1;
47
+ if (turn.items.at(-1)?.kind === 'assistant_message' && itemKind !== 'assistant_message')
48
+ index += 1;
47
49
  return { id: `${turn.id}:segment:${index}`, index };
48
50
  }
49
51
  export class ConversationSegmentService {
@@ -88,7 +90,10 @@ export class ConversationSegmentService {
88
90
  snapshotSequence: page.snapshotSequence,
89
91
  source: page.source,
90
92
  readAt: page.readAt,
91
- latestVisibleAt: page.latestVisibleAt
93
+ latestVisibleAt: page.latestVisibleAt,
94
+ ...(boundary === undefined && page.deferredOlderHistory === true
95
+ ? { deferredOlderHistory: true }
96
+ : {})
92
97
  };
93
98
  const pageSegments = page.turns.flatMap(conversationSegments);
94
99
  let eligible = pageSegments;
@@ -107,6 +112,8 @@ export class ConversationSegmentService {
107
112
  hasOlder = eligible.length > take.length || page.nextCursor !== null;
108
113
  collected.unshift(...take);
109
114
  }
115
+ if (boundary === undefined && page.deferredOlderHistory === true)
116
+ break;
110
117
  if (collected.length >= limit || page.nextCursor === null)
111
118
  break;
112
119
  if (visited.has(page.nextCursor))
@@ -1,3 +1,4 @@
1
+ const RESUME_FAILURE_TTL_MS = 30 * 60_000;
1
2
  export class ExternalSessionResumeService {
2
3
  options;
3
4
  constructor(options) {
@@ -84,15 +85,23 @@ export class ExternalSessionResumeService {
84
85
  : 'REJECTED';
85
86
  }
86
87
  clearFailure(sessionId) {
88
+ const timer = this.options.state.failureTimers.get(sessionId);
89
+ if (timer !== undefined)
90
+ clearTimeout(timer);
91
+ this.options.state.failureTimers.delete(sessionId);
87
92
  this.options.state.resumeFailures.delete(sessionId);
88
93
  this.options.state.resumeFailureDetails.delete(sessionId);
89
94
  this.options.state.messageResumeFailures.delete(sessionId);
90
95
  }
91
96
  setFailure(sessionId, code, detail) {
97
+ this.clearFailure(sessionId);
92
98
  this.options.state.resumeFailures.set(sessionId, code);
93
99
  if (detail === undefined)
94
100
  this.options.state.resumeFailureDetails.delete(sessionId);
95
101
  else
96
102
  this.options.state.resumeFailureDetails.set(sessionId, detail);
103
+ const timer = setTimeout(() => this.clearFailure(sessionId), RESUME_FAILURE_TTL_MS);
104
+ timer.unref();
105
+ this.options.state.failureTimers.set(sessionId, timer);
97
106
  }
98
107
  }
@@ -17,7 +17,6 @@ interface FakeManagedRunServiceOptions {
17
17
  readonly payload: unknown;
18
18
  readonly expiresAt: number;
19
19
  }) => void;
20
- readonly captureSnapshot: (runId: string, cwd: string) => Promise<void>;
21
20
  readonly disconnect: () => void;
22
21
  }
23
22
  /** Test-only managed Run adapter; production Runner paths never depend on it. */
@@ -32,8 +32,6 @@ export class FakeManagedRunService {
32
32
  this.options.emitRun(runId, eventType, eventPayload, status);
33
33
  if (status !== undefined && isTerminalRunStatus(status)) {
34
34
  this.options.state.active.delete(runId);
35
- if (cwd !== undefined)
36
- void this.options.captureSnapshot(runId, cwd);
37
35
  }
38
36
  },
39
37
  approval: (approvalId, approvalPayload, expiresAt) => {
@@ -238,7 +238,12 @@ export class NativeSessionProjectionService {
238
238
  this.options.state.externalActivity.delete(directId);
239
239
  this.options.state.resumedByDirectId.delete(directId);
240
240
  this.options.state.resumeFailures.delete(directId);
241
+ this.options.state.messageResumeFailures.delete(directId);
241
242
  this.options.state.resumeFailureDetails.delete(directId);
243
+ const timer = this.options.state.failureTimers.get(directId);
244
+ if (timer !== undefined)
245
+ clearTimeout(timer);
246
+ this.options.state.failureTimers.delete(directId);
242
247
  }
243
248
  }
244
249
  for (const [directId, resumedId] of this.options.state.resumedByDirectId)
@@ -2,18 +2,24 @@ import type { NodeAgentSession } from '../database.js';
2
2
  import type { NodeOperationHandler } from '../connector/node-operation-router.js';
3
3
  export interface NativeSessionWatchOptions<TPage extends {
4
4
  readonly turns: readonly unknown[];
5
+ readonly source?: unknown;
5
6
  }> {
6
7
  readonly resolve: (sessionId: string) => Promise<NodeAgentSession | undefined>;
7
8
  readonly managedActive: (session: NodeAgentSession) => boolean;
8
9
  readonly refreshActivity: (session: NodeAgentSession) => Promise<void>;
9
10
  readonly present: (session: NodeAgentSession) => unknown;
10
11
  readonly readPage: (session: NodeAgentSession, limit: number) => Promise<TPage>;
12
+ readonly convergePage: (previous: TPage | undefined, previousObserved: TPage | undefined, next: TPage, previousAuthority: unknown) => {
13
+ readonly page: TPage;
14
+ readonly authority: unknown;
15
+ };
11
16
  readonly projectInitialPage?: (session: NodeAgentSession, page: TPage, unit: 'SEGMENT', limit: number) => unknown;
12
17
  readonly emitSession: (session: unknown) => void;
13
- readonly emitTurn: (turn: unknown) => void;
18
+ readonly emitPage: (session: NodeAgentSession, page: TPage) => void;
14
19
  }
15
20
  export declare class NativeSessionWatchService<TPage extends {
16
21
  readonly turns: readonly unknown[];
22
+ readonly source?: unknown;
17
23
  }> {
18
24
  private readonly options;
19
25
  private readonly watches;
@@ -33,6 +39,6 @@ export declare class NativeSessionWatchService<TPage extends {
33
39
  readonly expiresAt: number;
34
40
  } | undefined;
35
41
  get size(): number;
36
- refresh(sessionId: string, limit?: number): Promise<TPage | undefined>;
42
+ refresh(sessionId: string, limit?: number, emit?: boolean): Promise<TPage | undefined>;
37
43
  private performRefresh;
38
44
  }