@markusylisiurunen/tau 0.3.44 → 0.3.45

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.
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { z } from "zod";
@@ -24,10 +24,49 @@ const persistedTelegramSessionRecordV1Schema = z
24
24
  tauSessionId: z.string().min(1).optional(),
25
25
  })
26
26
  .strip();
27
- const persistedTelegramSessionRecordSchema = persistedTelegramSessionRecordV1Schema.extend({
27
+ const persistedTelegramSessionRecordV2Schema = persistedTelegramSessionRecordV1Schema.extend({
28
28
  state: telegramSessionStateValueSchema,
29
29
  error: z.string().min(1).optional(),
30
30
  });
31
+ const persistedTelegramSessionRecordV3Schema = persistedTelegramSessionRecordV2Schema.extend({
32
+ activeTurnIds: z.array(z.string().min(1)),
33
+ });
34
+ const persistedTelegramTurnFailureSchema = z.discriminatedUnion("status", [
35
+ z
36
+ .object({
37
+ status: z.literal("failed"),
38
+ stopReason: z.literal("error"),
39
+ errorMessage: z.string().optional(),
40
+ })
41
+ .strip(),
42
+ z
43
+ .object({
44
+ status: z.literal("blocked"),
45
+ reason: z.literal("auto-compaction-failed"),
46
+ message: z.string(),
47
+ })
48
+ .strip(),
49
+ ]);
50
+ const persistedTelegramTurnNotificationSchema = z.discriminatedUnion("kind", [
51
+ z
52
+ .object({
53
+ kind: z.literal("failed"),
54
+ historyEntryId: z.string().min(1),
55
+ timestamp: z.string().datetime(),
56
+ failure: persistedTelegramTurnFailureSchema,
57
+ })
58
+ .strip(),
59
+ z
60
+ .object({
61
+ kind: z.literal("rejected"),
62
+ historyEntryId: z.string().min(1),
63
+ timestamp: z.string().datetime(),
64
+ })
65
+ .strip(),
66
+ ]);
67
+ const persistedTelegramSessionRecordSchema = persistedTelegramSessionRecordV3Schema.extend({
68
+ pendingTurnNotifications: z.array(persistedTelegramTurnNotificationSchema),
69
+ });
31
70
  const telegramSessionStateSchema = z.discriminatedUnion("version", [
32
71
  z
33
72
  .object({
@@ -38,17 +77,41 @@ const telegramSessionStateSchema = z.discriminatedUnion("version", [
38
77
  z
39
78
  .object({
40
79
  version: z.literal(2),
80
+ sessions: z.array(persistedTelegramSessionRecordV2Schema),
81
+ })
82
+ .strip(),
83
+ z
84
+ .object({
85
+ version: z.literal(3),
86
+ sessions: z.array(persistedTelegramSessionRecordV3Schema),
87
+ })
88
+ .strip(),
89
+ z
90
+ .object({
91
+ version: z.literal(4),
41
92
  sessions: z.array(persistedTelegramSessionRecordSchema),
42
93
  })
43
94
  .strip(),
44
95
  ]);
45
96
  function normalizePersistedTelegramSessions(state) {
46
- if (state.version === 2) {
97
+ if (state.version === 4) {
47
98
  return state.sessions;
48
99
  }
100
+ if (state.version === 3) {
101
+ return state.sessions.map((record) => ({ ...record, pendingTurnNotifications: [] }));
102
+ }
103
+ if (state.version === 2) {
104
+ return state.sessions.map((record) => ({
105
+ ...record,
106
+ activeTurnIds: [],
107
+ pendingTurnNotifications: [],
108
+ }));
109
+ }
49
110
  return state.sessions.map((record) => ({
50
111
  ...record,
51
112
  state: record.tauSessionId ? "waiting-input" : "queued",
113
+ activeTurnIds: [],
114
+ pendingTurnNotifications: [],
52
115
  }));
53
116
  }
54
117
  export function resolveTelegramSessionStatePath(workspaceRoot) {
@@ -59,6 +122,7 @@ const PUBLIC_SESSION_ID_LENGTH = 8;
59
122
  const NANOSECONDS_PER_MILLISECOND = 1000000n;
60
123
  const PROVISION_SCRIPT_PATH = ".tau/scripts/provision";
61
124
  const MAX_PROVISION_DIAGNOSTIC_CHARS = 2_000;
125
+ const TURN_RECOVERY_POLL_INTERVAL_MS = 1_000;
62
126
  const ACTIVE_STATES = new Set([
63
127
  "queued",
64
128
  "preparing-workspace",
@@ -215,7 +279,11 @@ class TelegramSessionManagerImpl {
215
279
  consumedFacetEventCounts: new Map(),
216
280
  emittedAssistantMessageIds: new Set(),
217
281
  emittedNoticeIds: new Set(),
218
- emittedTurnFailureIds: new Set(),
282
+ activeTurnIds: new Set(),
283
+ settledTurnIds: new Set(),
284
+ pendingTurnNotifications: new Map(),
285
+ emittedTurnNotificationIds: new Set(),
286
+ turnChangeResolvers: new Map(),
219
287
  };
220
288
  this.sessions.set(id, entry);
221
289
  try {
@@ -259,6 +327,21 @@ class TelegramSessionManagerImpl {
259
327
  }
260
328
  return (this.provisionFailures.get(sessionId) ?? []).map((failure) => ({ ...failure }));
261
329
  }
330
+ getPendingTurnNotifications(sessionId) {
331
+ const entry = this.getEntryBySessionId(sessionId);
332
+ if (!entry) {
333
+ return [];
334
+ }
335
+ return Array.from(entry.pendingTurnNotifications.values(), (notification) => this.toTurnNotificationEvent(entry, notification));
336
+ }
337
+ async acknowledgeTurnNotification(sessionId, historyEntryId) {
338
+ const entry = this.requireSession(sessionId);
339
+ if (!entry.pendingTurnNotifications.delete(historyEntryId)) {
340
+ return;
341
+ }
342
+ entry.emittedTurnNotificationIds.delete(historyEntryId);
343
+ await this.persistSessions();
344
+ }
262
345
  async getSessionSnapshot(sessionId) {
263
346
  const entry = this.getEntryBySessionId(sessionId);
264
347
  if (!entry?.tauSession) {
@@ -284,8 +367,10 @@ class TelegramSessionManagerImpl {
284
367
  if (!entry.tauSession) {
285
368
  throw new TelegramSessionManagerError("not_ready", "session is still preparing");
286
369
  }
287
- const mode = options?.mode ?? "submit";
288
- if (mode !== "steer" && (entry.record.state === "running" || entry.activeSubmit)) {
370
+ const requestedMode = options?.mode ?? "submit";
371
+ const running = entry.record.state === "running" || Boolean(entry.activeSubmit);
372
+ const mode = requestedMode === "auto" ? (running ? "steer" : "submit") : requestedMode;
373
+ if (mode !== "steer" && running) {
289
374
  throw new TelegramSessionManagerError("busy", "session is running");
290
375
  }
291
376
  void this.submitText(entry, trimmed, "user-message", options?.additionalSystemMessage, mode);
@@ -293,7 +378,8 @@ class TelegramSessionManagerImpl {
293
378
  }
294
379
  async interruptSession(sessionId) {
295
380
  const entry = this.requireSession(sessionId);
296
- if (entry.record.state !== "running" || !entry.activeSubmit) {
381
+ if (entry.record.state !== "running" ||
382
+ (!entry.activeSubmit && entry.activeTurnIds.size === 0)) {
297
383
  return {
298
384
  session: this.toRecord(entry),
299
385
  interrupted: false,
@@ -381,8 +467,9 @@ class TelegramSessionManagerImpl {
381
467
  if (!project || this.sessions.has(record.id)) {
382
468
  continue;
383
469
  }
470
+ const { activeTurnIds, pendingTurnNotifications, ...sessionRecord } = record;
384
471
  const entry = {
385
- record: { ...record },
472
+ record: sessionRecord,
386
473
  logs: [],
387
474
  project,
388
475
  abortController: new AbortController(),
@@ -390,13 +477,20 @@ class TelegramSessionManagerImpl {
390
477
  consumedFacetEventCounts: new Map(),
391
478
  emittedAssistantMessageIds: new Set(),
392
479
  emittedNoticeIds: new Set(),
393
- emittedTurnFailureIds: new Set(),
480
+ activeTurnIds: new Set(activeTurnIds),
481
+ settledTurnIds: new Set(),
482
+ pendingTurnNotifications: new Map(pendingTurnNotifications.map((notification) => [
483
+ notification.historyEntryId,
484
+ notification,
485
+ ])),
486
+ emittedTurnNotificationIds: new Set(),
487
+ turnChangeResolvers: new Map(),
394
488
  };
395
489
  this.sessions.set(record.id, entry);
396
490
  }
397
491
  await this.cleanupOrphanedWorkspaces();
398
492
  await Promise.all(Array.from(this.sessions.values(), async (entry) => {
399
- if (entry.record.state === "failed") {
493
+ if (entry.record.state === "failed" && entry.activeTurnIds.size === 0) {
400
494
  return;
401
495
  }
402
496
  try {
@@ -415,6 +509,9 @@ class TelegramSessionManagerImpl {
415
509
  }
416
510
  }));
417
511
  await this.persistSessions();
512
+ for (const entry of this.sessions.values()) {
513
+ this.emitPendingTurnNotifications(entry);
514
+ }
418
515
  }
419
516
  async cleanupOrphanedWorkspaces() {
420
517
  const workspaceRoots = new Set([this.workspaceRoot]);
@@ -498,7 +595,13 @@ class TelegramSessionManagerImpl {
498
595
  });
499
596
  this.initializeRecoveredSnapshotDeliveryState(entry, await tauSession.snapshot());
500
597
  entry.record.error = undefined;
501
- this.setState(entry, "waiting-input");
598
+ if (entry.activeTurnIds.size > 0) {
599
+ this.setState(entry, "running");
600
+ this.trackRecoveredTurns(entry, tauSession);
601
+ }
602
+ else {
603
+ this.setState(entry, "waiting-input");
604
+ }
502
605
  this.log(entry, "info", "session recovered", { tauSessionId, workspacePath });
503
606
  if (shouldPrepareWorkspace) {
504
607
  this.startProvision(entry, tauSession, provisionTargets);
@@ -513,7 +616,7 @@ class TelegramSessionManagerImpl {
513
616
  .catch(() => undefined)
514
617
  .then(async () => {
515
618
  const state = {
516
- version: 2,
619
+ version: 4,
517
620
  sessions: Array.from(this.sessions.values(), (entry) => ({
518
621
  id: entry.record.id,
519
622
  projectId: entry.record.projectId,
@@ -523,6 +626,8 @@ class TelegramSessionManagerImpl {
523
626
  updatedAt: entry.record.updatedAt,
524
627
  ...(entry.record.tauSessionId ? { tauSessionId: entry.record.tauSessionId } : {}),
525
628
  ...(entry.record.error ? { error: entry.record.error } : {}),
629
+ activeTurnIds: [...entry.activeTurnIds],
630
+ pendingTurnNotifications: [...entry.pendingTurnNotifications.values()],
526
631
  })),
527
632
  };
528
633
  await mkdir(dirname(persistencePath), { recursive: true });
@@ -591,6 +696,7 @@ class TelegramSessionManagerImpl {
591
696
  this.handleClientEvent(entry, event);
592
697
  });
593
698
  const snapshot = await tauSession.snapshot();
699
+ this.reconcileSnapshotTurns(entry, snapshot, { rejectMissingActiveTurns: false });
594
700
  this.initializeSnapshotMessageDeliveryState(entry, snapshot);
595
701
  this.handleSnapshotNotices(entry, snapshot);
596
702
  this.log(entry, "info", "session preparation complete", {
@@ -698,35 +804,57 @@ class TelegramSessionManagerImpl {
698
804
  const previousSubmit = entry.activeSubmit;
699
805
  const tauSession = entry.tauSession;
700
806
  const payload = this.buildSubmitPayload(text, additionalSystemMessage);
807
+ const requestedHistoryEntryId = mode === "submit" ? `telegram-turn-${randomUUID()}` : undefined;
701
808
  let submitPromise;
702
809
  submitPromise = (async () => {
810
+ let requestStarted = false;
703
811
  try {
704
- const result = mode === "steer" ? await tauSession.steer(payload) : await tauSession.submit(payload);
705
- const failure = result.turn.status === "failed" || result.turn.status === "blocked"
706
- ? result.turn
707
- : undefined;
708
- this.log(entry, failure ? "error" : "info", "message finished", {
812
+ if (requestedHistoryEntryId) {
813
+ entry.activeTurnIds.add(requestedHistoryEntryId);
814
+ await this.persistSessions();
815
+ }
816
+ requestStarted = true;
817
+ const result = mode === "steer"
818
+ ? await tauSession.steer(payload)
819
+ : await tauSession.submit(payload, { historyEntryId: requestedHistoryEntryId });
820
+ if (requestedHistoryEntryId && result.userHistoryEntryId !== requestedHistoryEntryId) {
821
+ throw new Error(`Tau accepted turn '${result.userHistoryEntryId}' instead of requested turn '${requestedHistoryEntryId}'`);
822
+ }
823
+ this.reconcileTurnRecord(entry, {
824
+ userHistoryEntryId: result.userHistoryEntryId,
825
+ state: "settled",
826
+ outcome: result.turn,
827
+ }, { owned: true });
828
+ await this.persistSessions();
829
+ this.emitPendingTurnNotifications(entry);
830
+ this.log(entry, result.turn.status === "failed" || result.turn.status === "blocked" ? "error" : "info", "message finished", {
709
831
  source,
710
832
  turn: result.turn,
711
833
  userHistoryEntryId: result.userHistoryEntryId,
712
834
  });
713
- if (!entry.cancelRequested) {
714
- if (failure && !entry.emittedTurnFailureIds.has(result.userHistoryEntryId)) {
715
- entry.emittedTurnFailureIds.add(result.userHistoryEntryId);
716
- this.emit({
717
- type: "session-turn-failed",
718
- sessionId: entry.record.id,
719
- projectId: entry.record.projectId,
720
- timestamp: this.now().toISOString(),
721
- failure,
722
- });
723
- }
724
- else if (!failure && (!entry.activeSubmit || entry.activeSubmit === submitPromise)) {
725
- this.setState(entry, "waiting-input");
726
- }
835
+ if (!entry.cancelRequested &&
836
+ (!entry.activeSubmit || entry.activeSubmit === submitPromise) &&
837
+ entry.activeTurnIds.size === 0) {
838
+ this.setState(entry, "waiting-input");
727
839
  }
728
840
  }
729
841
  catch (error) {
842
+ if (requestedHistoryEntryId && !requestStarted) {
843
+ entry.activeTurnIds.delete(requestedHistoryEntryId);
844
+ }
845
+ if (!entry.cancelRequested && requestedHistoryEntryId && requestStarted) {
846
+ try {
847
+ if (await this.recoverRejectedSubmit(entry, tauSession, requestedHistoryEntryId, error)) {
848
+ return;
849
+ }
850
+ }
851
+ catch (recoveryError) {
852
+ this.log(entry, "warn", "failed to inspect rejected submit", {
853
+ userHistoryEntryId: requestedHistoryEntryId,
854
+ cause: formatErrorDiagnostic(recoveryError),
855
+ });
856
+ }
857
+ }
730
858
  if (!entry.cancelRequested) {
731
859
  const diagnostic = formatErrorDiagnostic(error);
732
860
  entry.record.error = diagnostic;
@@ -765,13 +893,234 @@ class TelegramSessionManagerImpl {
765
893
  .finally(() => {
766
894
  if (entry.activeSubmit === trackedSubmit) {
767
895
  entry.activeSubmit = undefined;
768
- if (!entry.cancelRequested && entry.record.state === "running") {
896
+ if (!entry.cancelRequested &&
897
+ entry.record.state === "running" &&
898
+ entry.activeTurnIds.size === 0) {
769
899
  this.setState(entry, "waiting-input");
770
900
  }
771
901
  }
772
902
  });
773
903
  return submitPromise;
774
904
  }
905
+ async recoverRejectedSubmit(entry, tauSession, historyEntryId, error) {
906
+ const snapshot = await tauSession.snapshot();
907
+ const turn = Object.hasOwn(snapshot.turns, historyEntryId)
908
+ ? snapshot.turns[historyEntryId]
909
+ : undefined;
910
+ if (!turn) {
911
+ this.recordTurnRejection(entry, historyEntryId);
912
+ await this.persistSessions();
913
+ this.emitPendingTurnNotifications(entry);
914
+ this.log(entry, "warn", "submit rejected before turn acceptance", {
915
+ userHistoryEntryId: historyEntryId,
916
+ cause: formatErrorDiagnostic(error),
917
+ });
918
+ return true;
919
+ }
920
+ this.reconcileTurnRecord(entry, turn);
921
+ await this.persistSessions();
922
+ this.emitPendingTurnNotifications(entry);
923
+ this.log(entry, "warn", "submit response recovered from turn ledger", {
924
+ userHistoryEntryId: historyEntryId,
925
+ turn,
926
+ cause: formatErrorDiagnostic(error),
927
+ });
928
+ if (turn.state === "running") {
929
+ await this.followRunningTurn(entry, tauSession, historyEntryId);
930
+ }
931
+ else if (entry.activeTurnIds.size === 0) {
932
+ this.setState(entry, "waiting-input");
933
+ }
934
+ return true;
935
+ }
936
+ trackRecoveredTurns(entry, tauSession) {
937
+ const trackedTurns = Promise.all([...entry.activeTurnIds].map(async (historyEntryId) => await this.followRunningTurn(entry, tauSession, historyEntryId))).then(() => undefined);
938
+ entry.activeSubmit = trackedTurns;
939
+ void trackedTurns
940
+ .catch((error) => {
941
+ this.log(entry, "error", "recovered turn tracking failed", {
942
+ cause: formatErrorDiagnostic(error),
943
+ });
944
+ })
945
+ .finally(() => {
946
+ if (entry.activeSubmit === trackedTurns) {
947
+ entry.activeSubmit = undefined;
948
+ if (!entry.cancelRequested && entry.record.state === "running") {
949
+ this.setState(entry, "waiting-input");
950
+ }
951
+ }
952
+ });
953
+ }
954
+ async followRunningTurn(entry, tauSession, historyEntryId) {
955
+ while (entry.activeTurnIds.has(historyEntryId) && !entry.cancelRequested) {
956
+ await this.waitForTurnChange(entry, historyEntryId);
957
+ if (!entry.activeTurnIds.has(historyEntryId) || entry.cancelRequested) {
958
+ return;
959
+ }
960
+ let snapshot;
961
+ try {
962
+ snapshot = await tauSession.snapshot();
963
+ }
964
+ catch (error) {
965
+ this.log(entry, "warn", "failed to refresh recovered turn", {
966
+ userHistoryEntryId: historyEntryId,
967
+ cause: formatErrorDiagnostic(error),
968
+ });
969
+ continue;
970
+ }
971
+ const turn = Object.hasOwn(snapshot.turns, historyEntryId)
972
+ ? snapshot.turns[historyEntryId]
973
+ : undefined;
974
+ if (!turn) {
975
+ this.recordTurnRejection(entry, historyEntryId);
976
+ }
977
+ else {
978
+ this.reconcileTurnRecord(entry, turn);
979
+ }
980
+ await this.persistSessions();
981
+ this.emitPendingTurnNotifications(entry);
982
+ }
983
+ }
984
+ waitForTurnChange(entry, historyEntryId) {
985
+ return new Promise((resolve) => {
986
+ let settled = false;
987
+ const finish = () => {
988
+ if (settled) {
989
+ return;
990
+ }
991
+ settled = true;
992
+ clearTimeout(timeout);
993
+ entry.abortController.signal.removeEventListener("abort", finish);
994
+ if (entry.turnChangeResolvers.get(historyEntryId) === finish) {
995
+ entry.turnChangeResolvers.delete(historyEntryId);
996
+ }
997
+ resolve();
998
+ };
999
+ const timeout = setTimeout(finish, TURN_RECOVERY_POLL_INTERVAL_MS);
1000
+ entry.turnChangeResolvers.set(historyEntryId, finish);
1001
+ entry.abortController.signal.addEventListener("abort", finish, { once: true });
1002
+ });
1003
+ }
1004
+ reconcileTurnRecord(entry, turn, options = {}) {
1005
+ const historyEntryId = turn.userHistoryEntryId;
1006
+ if (turn.state === "running") {
1007
+ if (entry.settledTurnIds.has(historyEntryId) || entry.activeTurnIds.has(historyEntryId)) {
1008
+ return false;
1009
+ }
1010
+ entry.activeTurnIds.add(historyEntryId);
1011
+ return true;
1012
+ }
1013
+ if (entry.settledTurnIds.has(historyEntryId)) {
1014
+ return false;
1015
+ }
1016
+ entry.settledTurnIds.add(historyEntryId);
1017
+ const wasActive = entry.activeTurnIds.delete(historyEntryId);
1018
+ if (!wasActive && !options.owned) {
1019
+ return false;
1020
+ }
1021
+ entry.turnChangeResolvers.get(historyEntryId)?.();
1022
+ if (turn.outcome.status === "failed" || turn.outcome.status === "blocked") {
1023
+ this.recordTurnFailure(entry, historyEntryId, turn.outcome);
1024
+ }
1025
+ if (entry.activeTurnIds.size === 0 &&
1026
+ !entry.activeSubmit &&
1027
+ !entry.cancelRequested &&
1028
+ entry.record.state === "running") {
1029
+ this.setState(entry, "waiting-input");
1030
+ }
1031
+ return true;
1032
+ }
1033
+ recordTurnFailure(entry, historyEntryId, failure) {
1034
+ if (entry.pendingTurnNotifications.has(historyEntryId)) {
1035
+ return;
1036
+ }
1037
+ entry.pendingTurnNotifications.set(historyEntryId, {
1038
+ kind: "failed",
1039
+ historyEntryId,
1040
+ timestamp: this.now().toISOString(),
1041
+ failure: structuredClone(failure),
1042
+ });
1043
+ }
1044
+ recordTurnRejection(entry, historyEntryId) {
1045
+ entry.activeTurnIds.delete(historyEntryId);
1046
+ entry.settledTurnIds.add(historyEntryId);
1047
+ entry.turnChangeResolvers.get(historyEntryId)?.();
1048
+ if (!entry.pendingTurnNotifications.has(historyEntryId)) {
1049
+ entry.pendingTurnNotifications.set(historyEntryId, {
1050
+ kind: "rejected",
1051
+ historyEntryId,
1052
+ timestamp: this.now().toISOString(),
1053
+ });
1054
+ }
1055
+ if (entry.activeTurnIds.size === 0 &&
1056
+ !entry.activeSubmit &&
1057
+ !entry.cancelRequested &&
1058
+ entry.record.state === "running") {
1059
+ this.setState(entry, "waiting-input");
1060
+ }
1061
+ }
1062
+ toTurnNotificationEvent(entry, notification) {
1063
+ if (notification.kind === "failed") {
1064
+ return {
1065
+ type: "session-turn-failed",
1066
+ sessionId: entry.record.id,
1067
+ projectId: entry.record.projectId,
1068
+ timestamp: notification.timestamp,
1069
+ historyEntryId: notification.historyEntryId,
1070
+ failure: structuredClone(notification.failure),
1071
+ };
1072
+ }
1073
+ return {
1074
+ type: "session-turn-rejected",
1075
+ sessionId: entry.record.id,
1076
+ projectId: entry.record.projectId,
1077
+ timestamp: notification.timestamp,
1078
+ historyEntryId: notification.historyEntryId,
1079
+ };
1080
+ }
1081
+ emitPendingTurnNotifications(entry) {
1082
+ for (const notification of entry.pendingTurnNotifications.values()) {
1083
+ if (entry.emittedTurnNotificationIds.has(notification.historyEntryId)) {
1084
+ continue;
1085
+ }
1086
+ entry.emittedTurnNotificationIds.add(notification.historyEntryId);
1087
+ this.emit(this.toTurnNotificationEvent(entry, notification));
1088
+ }
1089
+ }
1090
+ reconcileSnapshotTurns(entry, snapshot, options) {
1091
+ let changed = false;
1092
+ const snapshotTurnIds = new Set(Object.keys(snapshot.turns));
1093
+ if (options.rejectMissingActiveTurns) {
1094
+ for (const historyEntryId of entry.activeTurnIds) {
1095
+ if (!snapshotTurnIds.has(historyEntryId)) {
1096
+ this.recordTurnRejection(entry, historyEntryId);
1097
+ changed = true;
1098
+ }
1099
+ }
1100
+ }
1101
+ for (const turn of Object.values(snapshot.turns)) {
1102
+ if (turn.state === "running" || entry.activeTurnIds.has(turn.userHistoryEntryId)) {
1103
+ changed = this.reconcileTurnRecord(entry, turn) || changed;
1104
+ }
1105
+ else {
1106
+ entry.settledTurnIds.add(turn.userHistoryEntryId);
1107
+ }
1108
+ }
1109
+ return changed;
1110
+ }
1111
+ persistTurnState(entry) {
1112
+ void this.persistSessions()
1113
+ .then(() => {
1114
+ this.emitPendingTurnNotifications(entry);
1115
+ })
1116
+ .catch((error) => {
1117
+ this.onLog?.({
1118
+ level: "error",
1119
+ message: "turn state persistence failed",
1120
+ data: { cause: formatErrorDiagnostic(error) },
1121
+ });
1122
+ });
1123
+ }
775
1124
  buildSessionAttributes(entry) {
776
1125
  const configuredRepositories = "repo" in entry.project
777
1126
  ? [entry.project.repo]
@@ -1031,6 +1380,11 @@ class TelegramSessionManagerImpl {
1031
1380
  }
1032
1381
  handleClientEvent(entry, clientEvent) {
1033
1382
  if (clientEvent.delta.type === "snapshot.reset") {
1383
+ if (this.reconcileSnapshotTurns(entry, clientEvent.delta.snapshot, {
1384
+ rejectMissingActiveTurns: false,
1385
+ })) {
1386
+ this.persistTurnState(entry);
1387
+ }
1034
1388
  this.handleSnapshotNotices(entry, clientEvent.delta.snapshot);
1035
1389
  if (clientEvent.cause.type !== "assistant-message") {
1036
1390
  return;
@@ -1046,6 +1400,12 @@ class TelegramSessionManagerImpl {
1046
1400
  return;
1047
1401
  }
1048
1402
  for (const change of clientEvent.delta.changes) {
1403
+ if (change.type === "turn.set") {
1404
+ if (this.reconcileTurnRecord(entry, change.turn)) {
1405
+ this.persistTurnState(entry);
1406
+ }
1407
+ continue;
1408
+ }
1049
1409
  if (change.type === "facet.set") {
1050
1410
  this.handleFacetProgress(entry, change.facet);
1051
1411
  continue;
@@ -1063,6 +1423,7 @@ class TelegramSessionManagerImpl {
1063
1423
  }
1064
1424
  }
1065
1425
  initializeRecoveredSnapshotDeliveryState(entry, snapshot) {
1426
+ this.reconcileSnapshotTurns(entry, snapshot, { rejectMissingActiveTurns: true });
1066
1427
  this.initializeSnapshotMessageDeliveryState(entry, snapshot);
1067
1428
  for (const item of snapshot.timeline.items) {
1068
1429
  if (item.type === "notice") {
@@ -1232,6 +1593,17 @@ class ScopedTelegramSessionManager {
1232
1593
  }
1233
1594
  return this.sessionManager.getProvisionFailures(sessionId);
1234
1595
  }
1596
+ getPendingTurnNotifications(sessionId) {
1597
+ const session = this.sessionManager.getSession(sessionId);
1598
+ if (!session || !this.isVisibleSession(session)) {
1599
+ return [];
1600
+ }
1601
+ return this.sessionManager.getPendingTurnNotifications(sessionId);
1602
+ }
1603
+ async acknowledgeTurnNotification(sessionId, historyEntryId) {
1604
+ this.requireSession(sessionId);
1605
+ await this.sessionManager.acknowledgeTurnNotification(sessionId, historyEntryId);
1606
+ }
1235
1607
  async getSessionSnapshot(sessionId) {
1236
1608
  this.requireSession(sessionId);
1237
1609
  return await this.sessionManager.getSessionSnapshot(sessionId);