dominds 1.16.1 → 1.16.2

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 (175) hide show
  1. package/dist/dialog-display-state.d.ts +4 -0
  2. package/dist/dialog-display-state.js +138 -19
  3. package/dist/dialog-fork.js +13 -5
  4. package/dist/dialog-interruption.d.ts +18 -0
  5. package/dist/dialog-interruption.js +34 -0
  6. package/dist/docs/dominds-agent-collaboration.zh.md +401 -0
  7. package/dist/llm/api-quirks.d.ts +11 -0
  8. package/dist/llm/api-quirks.js +322 -24
  9. package/dist/llm/defaults.yaml +2 -1
  10. package/dist/llm/gen/mock.d.ts +1 -1
  11. package/dist/llm/gen/mock.js +12 -0
  12. package/dist/llm/gen/openai-compatible.d.ts +1 -1
  13. package/dist/llm/gen/openai-compatible.js +9 -1
  14. package/dist/llm/gen/openai.d.ts +1 -1
  15. package/dist/llm/gen/openai.js +16 -2
  16. package/dist/llm/gen.d.ts +10 -0
  17. package/dist/llm/gen.js +10 -0
  18. package/dist/llm/kernel-driver/drive.js +994 -866
  19. package/dist/llm/kernel-driver/runtime.d.ts +16 -2
  20. package/dist/llm/kernel-driver/runtime.js +75 -10
  21. package/dist/llm/stop-reason-i18n.d.ts +9 -0
  22. package/dist/llm/stop-reason-i18n.js +114 -0
  23. package/dist/persistence-errors.d.ts +26 -0
  24. package/dist/persistence-errors.js +68 -0
  25. package/dist/persistence.d.ts +119 -82
  26. package/dist/persistence.js +1055 -426
  27. package/dist/server/api-routes.js +174 -58
  28. package/dist/server/websocket-handler.js +125 -21
  29. package/dist/tools/team_mgmt.js +2 -2
  30. package/package.json +3 -3
  31. package/webapp/dist/assets/{_basePickBy-C0MXVhMo.js → _basePickBy-BJWCmtUm.js} +3 -3
  32. package/webapp/dist/assets/_basePickBy-BJWCmtUm.js.map +1 -0
  33. package/webapp/dist/assets/{_baseUniq-CRygl41M.js → _baseUniq-D6k_lGfs.js} +2 -2
  34. package/webapp/dist/assets/_baseUniq-D6k_lGfs.js.map +1 -0
  35. package/webapp/dist/assets/{arc-BeHJtnt2.js → arc-DYDaESgj.js} +2 -2
  36. package/webapp/dist/assets/arc-DYDaESgj.js.map +1 -0
  37. package/webapp/dist/assets/{architectureDiagram-2XIMDMQ5-CGdo5TVi.js → architectureDiagram-VXUJARFQ-DZMT7dqc.js} +8 -26
  38. package/webapp/dist/assets/architectureDiagram-VXUJARFQ-DZMT7dqc.js.map +1 -0
  39. package/webapp/dist/assets/{blockDiagram-WCTKOSBZ-C59VaSGw.js → blockDiagram-VD42YOAC-CABpgVAa.js} +170 -187
  40. package/webapp/dist/assets/blockDiagram-VD42YOAC-CABpgVAa.js.map +1 -0
  41. package/webapp/dist/assets/{c4Diagram-IC4MRINW-B3YCBmTx.js → c4Diagram-YG6GDRKO-COLZS8Ul.js} +4 -4
  42. package/webapp/dist/assets/c4Diagram-YG6GDRKO-COLZS8Ul.js.map +1 -0
  43. package/webapp/dist/assets/{channel-Dn4i8EAY.js → channel-CYFm9Cri.js} +2 -2
  44. package/webapp/dist/assets/channel-CYFm9Cri.js.map +1 -0
  45. package/webapp/dist/assets/{chunk-4BX2VUAB-CQbB3HlW.js → chunk-4BX2VUAB-CX_-XbaN.js} +2 -2
  46. package/webapp/dist/assets/chunk-4BX2VUAB-CX_-XbaN.js.map +1 -0
  47. package/webapp/dist/assets/{chunk-55IACEB6-CkQR_Qu9.js → chunk-55IACEB6-ByD-NdBC.js} +2 -2
  48. package/webapp/dist/assets/chunk-55IACEB6-ByD-NdBC.js.map +1 -0
  49. package/webapp/dist/assets/{chunk-WL4C6EOR-Bjl-j3bf.js → chunk-B4BG7PRW-BANemsDD.js} +121 -171
  50. package/webapp/dist/assets/chunk-B4BG7PRW-BANemsDD.js.map +1 -0
  51. package/webapp/dist/assets/{chunk-NQ4KR5QH-DBxY8LL2.js → chunk-DI55MBZ5-DV7sdJmQ.js} +7 -9
  52. package/webapp/dist/assets/chunk-DI55MBZ5-DV7sdJmQ.js.map +1 -0
  53. package/webapp/dist/assets/{chunk-FMBD7UC4-YSIOuaST.js → chunk-FMBD7UC4-DYGviJnf.js} +2 -2
  54. package/webapp/dist/assets/chunk-FMBD7UC4-DYGviJnf.js.map +1 -0
  55. package/webapp/dist/assets/{chunk-KX2RTZJC-DFiwgnFf.js → chunk-QN33PNHL-B8DWRL9f.js} +2 -2
  56. package/webapp/dist/assets/chunk-QN33PNHL-B8DWRL9f.js.map +1 -0
  57. package/webapp/dist/assets/{chunk-QZHKN3VN-BAdR6lLc.js → chunk-QZHKN3VN-B1aYIzMR.js} +2 -2
  58. package/webapp/dist/assets/chunk-QZHKN3VN-B1aYIzMR.js.map +1 -0
  59. package/webapp/dist/assets/{chunk-JSJVCQXG-CWDLRawz.js → chunk-TZMSLE5B-_HISzxl3.js} +6 -14
  60. package/webapp/dist/assets/chunk-TZMSLE5B-_HISzxl3.js.map +1 -0
  61. package/webapp/dist/assets/{classDiagram-VBA2DB6C-KBeS6WXD.js → classDiagram-2ON5EDUG-CpyYj1Rc.js} +6 -7
  62. package/webapp/dist/assets/classDiagram-2ON5EDUG-CpyYj1Rc.js.map +1 -0
  63. package/webapp/dist/assets/{classDiagram-v2-RAHNMMFH-KBeS6WXD.js → classDiagram-v2-WZHVMYZB-CpyYj1Rc.js} +6 -7
  64. package/webapp/dist/assets/classDiagram-v2-WZHVMYZB-CpyYj1Rc.js.map +1 -0
  65. package/webapp/dist/assets/{clone-C7-aS2vm.js → clone-B_9AxWIU.js} +2 -2
  66. package/webapp/dist/assets/clone-B_9AxWIU.js.map +1 -0
  67. package/webapp/dist/assets/{cose-bilkent-S5V4N54A-Bo5IsL-5.js → cose-bilkent-S5V4N54A-17ECLfPR.js} +2 -2
  68. package/webapp/dist/assets/cose-bilkent-S5V4N54A-17ECLfPR.js.map +1 -0
  69. package/webapp/dist/assets/cytoscape.esm-Bm8DJGmZ.js.map +1 -1
  70. package/webapp/dist/assets/{dagre-KLK3FWXG-C3FeHj7D.js → dagre-6UL2VRFP-DhRqcF1o.js} +7 -7
  71. package/webapp/dist/assets/dagre-6UL2VRFP-DhRqcF1o.js.map +1 -0
  72. package/webapp/dist/assets/defaultLocale-B2RvLBDe.js.map +1 -1
  73. package/webapp/dist/assets/{diagram-E7M64L7V-0HfiRW0u.js → diagram-PSM6KHXK-lpDT6Wwb.js} +10 -10
  74. package/webapp/dist/assets/diagram-PSM6KHXK-lpDT6Wwb.js.map +1 -0
  75. package/webapp/dist/assets/{diagram-IFDJBPK2-DJwh7SMO.js → diagram-QEK2KX5R-ohxbPpDH.js} +8 -9
  76. package/webapp/dist/assets/diagram-QEK2KX5R-ohxbPpDH.js.map +1 -0
  77. package/webapp/dist/assets/{diagram-P4PSJMXO-Y0RbvUZX.js → diagram-S2PKOQOG-DAFFGfch.js} +8 -8
  78. package/webapp/dist/assets/diagram-S2PKOQOG-DAFFGfch.js.map +1 -0
  79. package/webapp/dist/assets/{erDiagram-INFDFZHY-B183xyLa.js → erDiagram-Q2GNP2WA-BH-7rI43.js} +75 -96
  80. package/webapp/dist/assets/erDiagram-Q2GNP2WA-BH-7rI43.js.map +1 -0
  81. package/webapp/dist/assets/{flowDiagram-PKNHOUZH-C_BR3Y3J.js → flowDiagram-NV44I4VS-CdEjFbz4.js} +81 -98
  82. package/webapp/dist/assets/flowDiagram-NV44I4VS-CdEjFbz4.js.map +1 -0
  83. package/webapp/dist/assets/{ganttDiagram-A5KZAMGK-CHLgkuJT.js → ganttDiagram-JELNMOA3-Cju2t-lK.js} +3 -28
  84. package/webapp/dist/assets/ganttDiagram-JELNMOA3-Cju2t-lK.js.map +1 -0
  85. package/webapp/dist/assets/{gitGraphDiagram-K3NZZRJ6-D6C_HU1q.js → gitGraphDiagram-V2S2FVAM-CUJ5oxCg.js} +46 -38
  86. package/webapp/dist/assets/gitGraphDiagram-V2S2FVAM-CUJ5oxCg.js.map +1 -0
  87. package/webapp/dist/assets/graph-mhcc7ldf.js +425 -0
  88. package/webapp/dist/assets/graph-mhcc7ldf.js.map +1 -0
  89. package/webapp/dist/assets/{index-CLoNHmSQ.js → index-BLnM-uON.js} +2098 -1841
  90. package/webapp/dist/assets/{index-CLoNHmSQ.js.map → index-BLnM-uON.js.map} +1 -1
  91. package/webapp/dist/assets/{index-YaxF76or.css → index-xvYYeHuy.css} +1 -1
  92. package/webapp/dist/assets/{infoDiagram-LFFYTUFH-mnMS61NO.js → infoDiagram-HS3SLOUP-Df8p0okQ.js} +7 -7
  93. package/webapp/dist/assets/infoDiagram-HS3SLOUP-Df8p0okQ.js.map +1 -0
  94. package/webapp/dist/assets/init-ZxktEp_H.js.map +1 -1
  95. package/webapp/dist/assets/{journeyDiagram-4ABVD52K-BdeERn0r.js → journeyDiagram-XKPGCS4Q-BXMl8H-d.js} +5 -5
  96. package/webapp/dist/assets/journeyDiagram-XKPGCS4Q-BXMl8H-d.js.map +1 -0
  97. package/webapp/dist/assets/{kanban-definition-K7BYSVSG-MLeCGcYg.js → kanban-definition-3W4ZIXB7-Cc5RwCEh.js} +3 -5
  98. package/webapp/dist/assets/kanban-definition-3W4ZIXB7-Cc5RwCEh.js.map +1 -0
  99. package/webapp/dist/assets/{layout-DY9VEQ9M.js → layout-BB2SvQcE.js} +5 -5
  100. package/webapp/dist/assets/layout-BB2SvQcE.js.map +1 -0
  101. package/webapp/dist/assets/{linear-D-T1xRsr.js → linear-Cj50lA0a.js} +2 -2
  102. package/webapp/dist/assets/linear-Cj50lA0a.js.map +1 -0
  103. package/webapp/dist/assets/{mindmap-definition-YRQLILUH-CrVm8vj2.js → mindmap-definition-VGOIOE7T-BLBklJVX.js} +5 -7
  104. package/webapp/dist/assets/mindmap-definition-VGOIOE7T-BLBklJVX.js.map +1 -0
  105. package/webapp/dist/assets/ordinal-CxptdPJm.js.map +1 -1
  106. package/webapp/dist/assets/{pieDiagram-SKSYHLDU-GtLLV0r1.js → pieDiagram-ADFJNKIX-BQrOgSc-.js} +8 -8
  107. package/webapp/dist/assets/pieDiagram-ADFJNKIX-BQrOgSc-.js.map +1 -0
  108. package/webapp/dist/assets/{quadrantDiagram-337W2JSQ-BCgk_2LP.js → quadrantDiagram-AYHSOK5B-C62TxtsO.js} +3 -3
  109. package/webapp/dist/assets/quadrantDiagram-AYHSOK5B-C62TxtsO.js.map +1 -0
  110. package/webapp/dist/assets/{requirementDiagram-Z7DCOOCP-DYUKHWKR.js → requirementDiagram-UZGBJVZJ-Is6Q3osM.js} +6 -16
  111. package/webapp/dist/assets/requirementDiagram-UZGBJVZJ-Is6Q3osM.js.map +1 -0
  112. package/webapp/dist/assets/{sankeyDiagram-WA2Y5GQK-CFAH8OaY.js → sankeyDiagram-TZEHDZUN-CCAW8Dr2.js} +2 -2
  113. package/webapp/dist/assets/sankeyDiagram-TZEHDZUN-CCAW8Dr2.js.map +1 -0
  114. package/webapp/dist/assets/{sequenceDiagram-2WXFIKYE-BTnhbv6w.js → sequenceDiagram-WL72ISMW-DpODpbl6.js} +201 -601
  115. package/webapp/dist/assets/sequenceDiagram-WL72ISMW-DpODpbl6.js.map +1 -0
  116. package/webapp/dist/assets/{stateDiagram-RAJIS63D-DWUnJTwO.js → stateDiagram-FKZM4ZOC-B8LHaf5T.js} +9 -9
  117. package/webapp/dist/assets/stateDiagram-FKZM4ZOC-B8LHaf5T.js.map +1 -0
  118. package/webapp/dist/assets/{stateDiagram-v2-FVOUBMTO-B-fc52bp.js → stateDiagram-v2-4FDKWEC3-4FzTkpgz.js} +5 -5
  119. package/webapp/dist/assets/stateDiagram-v2-4FDKWEC3-4FzTkpgz.js.map +1 -0
  120. package/webapp/dist/assets/{timeline-definition-YZTLITO2-BMZ52QaG.js → timeline-definition-IT6M3QCI-Ckxc4qZe.js} +3 -3
  121. package/webapp/dist/assets/timeline-definition-IT6M3QCI-Ckxc4qZe.js.map +1 -0
  122. package/webapp/dist/assets/{treemap-KZPCXAKY-fgSrze0S.js → treemap-GDKQZRPO-QQa4vKMv.js} +24 -37
  123. package/webapp/dist/assets/treemap-GDKQZRPO-QQa4vKMv.js.map +1 -0
  124. package/webapp/dist/assets/{xychartDiagram-JWTSCODW-D6aviLtC.js → xychartDiagram-PRI3JC2R-Ba2uJcs6.js} +4 -4
  125. package/webapp/dist/assets/xychartDiagram-PRI3JC2R-Ba2uJcs6.js.map +1 -0
  126. package/webapp/dist/index.html +2 -2
  127. package/webapp/dist/assets/_basePickBy-C0MXVhMo.js.map +0 -1
  128. package/webapp/dist/assets/_baseUniq-CRygl41M.js.map +0 -1
  129. package/webapp/dist/assets/arc-BeHJtnt2.js.map +0 -1
  130. package/webapp/dist/assets/architectureDiagram-2XIMDMQ5-CGdo5TVi.js.map +0 -1
  131. package/webapp/dist/assets/blockDiagram-WCTKOSBZ-C59VaSGw.js.map +0 -1
  132. package/webapp/dist/assets/c4Diagram-IC4MRINW-B3YCBmTx.js.map +0 -1
  133. package/webapp/dist/assets/channel-Dn4i8EAY.js.map +0 -1
  134. package/webapp/dist/assets/chunk-4BX2VUAB-CQbB3HlW.js.map +0 -1
  135. package/webapp/dist/assets/chunk-55IACEB6-CkQR_Qu9.js.map +0 -1
  136. package/webapp/dist/assets/chunk-FMBD7UC4-YSIOuaST.js.map +0 -1
  137. package/webapp/dist/assets/chunk-JSJVCQXG-CWDLRawz.js.map +0 -1
  138. package/webapp/dist/assets/chunk-KX2RTZJC-DFiwgnFf.js.map +0 -1
  139. package/webapp/dist/assets/chunk-NQ4KR5QH-DBxY8LL2.js.map +0 -1
  140. package/webapp/dist/assets/chunk-QZHKN3VN-BAdR6lLc.js.map +0 -1
  141. package/webapp/dist/assets/chunk-WL4C6EOR-Bjl-j3bf.js.map +0 -1
  142. package/webapp/dist/assets/classDiagram-VBA2DB6C-KBeS6WXD.js.map +0 -1
  143. package/webapp/dist/assets/classDiagram-v2-RAHNMMFH-KBeS6WXD.js.map +0 -1
  144. package/webapp/dist/assets/clone-C7-aS2vm.js.map +0 -1
  145. package/webapp/dist/assets/cose-bilkent-S5V4N54A-Bo5IsL-5.js.map +0 -1
  146. package/webapp/dist/assets/dagre-KLK3FWXG-C3FeHj7D.js.map +0 -1
  147. package/webapp/dist/assets/diagram-E7M64L7V-0HfiRW0u.js.map +0 -1
  148. package/webapp/dist/assets/diagram-IFDJBPK2-DJwh7SMO.js.map +0 -1
  149. package/webapp/dist/assets/diagram-P4PSJMXO-Y0RbvUZX.js.map +0 -1
  150. package/webapp/dist/assets/erDiagram-INFDFZHY-B183xyLa.js.map +0 -1
  151. package/webapp/dist/assets/flowDiagram-PKNHOUZH-C_BR3Y3J.js.map +0 -1
  152. package/webapp/dist/assets/ganttDiagram-A5KZAMGK-CHLgkuJT.js.map +0 -1
  153. package/webapp/dist/assets/gitGraphDiagram-K3NZZRJ6-D6C_HU1q.js.map +0 -1
  154. package/webapp/dist/assets/graph-D0hgB-X8.js +0 -782
  155. package/webapp/dist/assets/graph-D0hgB-X8.js.map +0 -1
  156. package/webapp/dist/assets/infoDiagram-LFFYTUFH-mnMS61NO.js.map +0 -1
  157. package/webapp/dist/assets/ishikawaDiagram-PHBUUO56-XFIJsnJj.js +0 -966
  158. package/webapp/dist/assets/ishikawaDiagram-PHBUUO56-XFIJsnJj.js.map +0 -1
  159. package/webapp/dist/assets/journeyDiagram-4ABVD52K-BdeERn0r.js.map +0 -1
  160. package/webapp/dist/assets/kanban-definition-K7BYSVSG-MLeCGcYg.js.map +0 -1
  161. package/webapp/dist/assets/layout-DY9VEQ9M.js.map +0 -1
  162. package/webapp/dist/assets/linear-D-T1xRsr.js.map +0 -1
  163. package/webapp/dist/assets/mindmap-definition-YRQLILUH-CrVm8vj2.js.map +0 -1
  164. package/webapp/dist/assets/pieDiagram-SKSYHLDU-GtLLV0r1.js.map +0 -1
  165. package/webapp/dist/assets/quadrantDiagram-337W2JSQ-BCgk_2LP.js.map +0 -1
  166. package/webapp/dist/assets/requirementDiagram-Z7DCOOCP-DYUKHWKR.js.map +0 -1
  167. package/webapp/dist/assets/sankeyDiagram-WA2Y5GQK-CFAH8OaY.js.map +0 -1
  168. package/webapp/dist/assets/sequenceDiagram-2WXFIKYE-BTnhbv6w.js.map +0 -1
  169. package/webapp/dist/assets/stateDiagram-RAJIS63D-DWUnJTwO.js.map +0 -1
  170. package/webapp/dist/assets/stateDiagram-v2-FVOUBMTO-B-fc52bp.js.map +0 -1
  171. package/webapp/dist/assets/timeline-definition-YZTLITO2-BMZ52QaG.js.map +0 -1
  172. package/webapp/dist/assets/treemap-KZPCXAKY-fgSrze0S.js.map +0 -1
  173. package/webapp/dist/assets/vennDiagram-LZ73GAT5-D5DmDY9l.js +0 -2487
  174. package/webapp/dist/assets/vennDiagram-LZ73GAT5-D5DmDY9l.js.map +0 -1
  175. package/webapp/dist/assets/xychartDiagram-JWTSCODW-D6aviLtC.js.map +0 -1
@@ -40,6 +40,9 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.DialogPersistence = exports.DiskFileDialogStore = void 0;
43
+ exports.setDialogsQuarantinedBroadcaster = setDialogsQuarantinedBroadcaster;
44
+ exports.setPrepareDialogQuarantineHook = setPrepareDialogQuarantineHook;
45
+ exports.setFinalizeDialogQuarantineHook = setFinalizeDialogQuarantineHook;
43
46
  const language_1 = require("@longrun-ai/kernel/types/language");
44
47
  const storage_1 = require("@longrun-ai/kernel/types/storage");
45
48
  const time_1 = require("@longrun-ai/kernel/utils/time");
@@ -48,12 +51,45 @@ const node_crypto_1 = require("node:crypto");
48
51
  const path = __importStar(require("path"));
49
52
  const yaml = __importStar(require("yaml"));
50
53
  const dialog_1 = require("./dialog");
54
+ const dialog_interruption_1 = require("./dialog-interruption");
51
55
  const evt_registry_1 = require("./evt-registry");
52
56
  const log_1 = require("./log");
57
+ const persistence_errors_1 = require("./persistence-errors");
53
58
  const async_fifo_mutex_1 = require("./runtime/async-fifo-mutex");
54
59
  const reply_prompt_copy_1 = require("./runtime/reply-prompt-copy");
55
60
  const tool_1 = require("./tool");
56
61
  const registry_1 = require("./tools/registry");
62
+ let dialogsQuarantinedBroadcaster = null;
63
+ let prepareDialogQuarantineHook = null;
64
+ let finalizeDialogQuarantineHook = null;
65
+ const quarantiningRootDialogs = new Set();
66
+ const PERSISTABLE_DIALOG_STATUSES = ['running', 'completed', 'archived'];
67
+ const RUN_STATUS_DIR = 'run';
68
+ const DONE_STATUS_DIR = 'done';
69
+ const ARCHIVE_STATUS_DIR = 'archive';
70
+ function assertPersistableDialogStatus(status, context) {
71
+ if (status === 'quarantining') {
72
+ throw new Error(`${context} does not support status 'quarantining'`);
73
+ }
74
+ return status;
75
+ }
76
+ function getPersistableStatusDirName(status, context) {
77
+ const persistableStatus = assertPersistableDialogStatus(status, context);
78
+ if (persistableStatus === 'running')
79
+ return RUN_STATUS_DIR;
80
+ if (persistableStatus === 'completed')
81
+ return DONE_STATUS_DIR;
82
+ return ARCHIVE_STATUS_DIR;
83
+ }
84
+ function setDialogsQuarantinedBroadcaster(fn) {
85
+ dialogsQuarantinedBroadcaster = fn;
86
+ }
87
+ function setPrepareDialogQuarantineHook(fn) {
88
+ prepareDialogQuarantineHook = fn;
89
+ }
90
+ function setFinalizeDialogQuarantineHook(fn) {
91
+ finalizeDialogQuarantineHook = fn;
92
+ }
57
93
  function isTellaskBusinessCallName(value) {
58
94
  return (value === 'tellask' ||
59
95
  value === 'tellaskSessionless' ||
@@ -83,6 +119,19 @@ function isSuppressedTellaskPlaceholderFuncResult(args) {
83
119
  }
84
120
  return false;
85
121
  }
122
+ function isYamlUnexpectedEofLikeError(error) {
123
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
124
+ return (message.includes('unexpected eof') ||
125
+ message.includes('unexpected end') ||
126
+ message.includes('end of the stream') ||
127
+ message.includes('unexpected end of document'));
128
+ }
129
+ function isJsonUnexpectedEofLikeError(error) {
130
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
131
+ return (message.includes('unterminated string in json') ||
132
+ message.includes('unexpected end of json input') ||
133
+ message.includes('unexpected eof'));
134
+ }
86
135
  function buildTellaskResultRoute(route, fallback) {
87
136
  const effective = route ?? fallback;
88
137
  if (!effective)
@@ -133,6 +182,79 @@ function getErrorCode(error) {
133
182
  const maybeCode = error.code;
134
183
  return typeof maybeCode === 'string' ? maybeCode : undefined;
135
184
  }
185
+ function isGenericUnexpectedEofLikeError(error) {
186
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
187
+ return message.includes('unexpected eof') || message.includes('unexpected end');
188
+ }
189
+ function isPersistenceFileUnexpectedEofLikeError(error, format) {
190
+ if (format === 'yaml') {
191
+ return isYamlUnexpectedEofLikeError(error) || isGenericUnexpectedEofLikeError(error);
192
+ }
193
+ if (format === 'json' || format === 'jsonl') {
194
+ return isJsonUnexpectedEofLikeError(error) || isGenericUnexpectedEofLikeError(error);
195
+ }
196
+ return isGenericUnexpectedEofLikeError(error);
197
+ }
198
+ function buildInvalidPersistenceFileError(args) {
199
+ return new persistence_errors_1.DomindsPersistenceFileError({
200
+ message: `Invalid ${path.basename(args.filePath)} in ${args.filePath}`,
201
+ source: args.source,
202
+ operation: 'parse',
203
+ format: args.format,
204
+ filePath: args.filePath,
205
+ eofLike: args.cause === undefined
206
+ ? false
207
+ : isPersistenceFileUnexpectedEofLikeError(args.cause, args.format),
208
+ ...(args.lineNumber !== undefined ? { lineNumber: args.lineNumber } : {}),
209
+ ...(args.cause !== undefined ? { cause: args.cause } : {}),
210
+ });
211
+ }
212
+ async function readPersistenceTextFile(args) {
213
+ try {
214
+ return await fs.promises.readFile(args.filePath, 'utf-8');
215
+ }
216
+ catch (error) {
217
+ if (getErrorCode(error) === 'ENOENT') {
218
+ throw error;
219
+ }
220
+ throw new persistence_errors_1.DomindsPersistenceFileError({
221
+ message: `Failed to read ${path.basename(args.filePath)} in ${args.filePath}`,
222
+ source: args.source,
223
+ operation: 'read',
224
+ format: args.format,
225
+ filePath: args.filePath,
226
+ eofLike: isPersistenceFileUnexpectedEofLikeError(error, args.format),
227
+ cause: error,
228
+ });
229
+ }
230
+ }
231
+ function parsePersistenceYaml(args) {
232
+ try {
233
+ return yaml.parse(args.content);
234
+ }
235
+ catch (error) {
236
+ throw buildInvalidPersistenceFileError({
237
+ source: args.source,
238
+ format: 'yaml',
239
+ filePath: args.filePath,
240
+ cause: error,
241
+ });
242
+ }
243
+ }
244
+ function parsePersistenceJson(args) {
245
+ try {
246
+ return JSON.parse(args.content);
247
+ }
248
+ catch (error) {
249
+ throw buildInvalidPersistenceFileError({
250
+ source: args.source,
251
+ format: args.lineNumber === undefined ? 'json' : 'jsonl',
252
+ filePath: args.filePath,
253
+ cause: error,
254
+ ...(args.lineNumber !== undefined ? { lineNumber: args.lineNumber } : {}),
255
+ });
256
+ }
257
+ }
136
258
  function isRecord(value) {
137
259
  return typeof value === 'object' && value !== null;
138
260
  }
@@ -164,6 +286,21 @@ function parseDialogRetryDisplay(value) {
164
286
  summaryTextI18n,
165
287
  };
166
288
  }
289
+ function parseDialogLlmRetryRecoveryAction(value) {
290
+ if (value === undefined) {
291
+ return { kind: 'none' };
292
+ }
293
+ if (!isRecord(value) || typeof value.kind !== 'string')
294
+ return null;
295
+ switch (value.kind) {
296
+ case 'none':
297
+ return { kind: 'none' };
298
+ case 'diligence_push_once':
299
+ return { kind: 'diligence_push_once' };
300
+ default:
301
+ return null;
302
+ }
303
+ }
167
304
  function parseDialogInterruptionReason(value) {
168
305
  if (!isRecord(value) || typeof value.kind !== 'string')
169
306
  return null;
@@ -180,7 +317,12 @@ function parseDialogInterruptionReason(value) {
180
317
  const detail = value.detail;
181
318
  if (typeof detail !== 'string')
182
319
  return null;
183
- return { kind: 'system_stop', detail };
320
+ const i18nStopReason = parseDisplayTextI18n(value.i18nStopReason);
321
+ if (i18nStopReason === null)
322
+ return null;
323
+ return i18nStopReason
324
+ ? { kind: 'system_stop', detail, i18nStopReason }
325
+ : { kind: 'system_stop', detail };
184
326
  }
185
327
  case 'llm_retry_stopped': {
186
328
  const error = value.error;
@@ -189,10 +331,14 @@ function parseDialogInterruptionReason(value) {
189
331
  const display = parseDialogRetryDisplay(value.display);
190
332
  if (display === null)
191
333
  return null;
334
+ const recoveryAction = parseDialogLlmRetryRecoveryAction(value.recoveryAction);
335
+ if (recoveryAction === null)
336
+ return null;
192
337
  return {
193
338
  kind: 'llm_retry_stopped',
194
339
  error,
195
340
  display,
341
+ recoveryAction,
196
342
  };
197
343
  }
198
344
  default:
@@ -200,7 +346,7 @@ function parseDialogInterruptionReason(value) {
200
346
  }
201
347
  }
202
348
  function resolveStoppedContinueEnabled(reason) {
203
- return reason.kind !== 'llm_retry_stopped';
349
+ return (0, dialog_interruption_1.isInterruptionReasonManualResumeEligible)(reason);
204
350
  }
205
351
  function serializeReminderSnapshot(reminder) {
206
352
  return {
@@ -948,6 +1094,67 @@ function isSubdialogResponseRecord(value) {
948
1094
  return false;
949
1095
  return true;
950
1096
  }
1097
+ function isReminderPriority(value) {
1098
+ return value === 'high' || value === 'medium' || value === 'low';
1099
+ }
1100
+ function isReminderScope(value) {
1101
+ return value === 'dialog' || value === 'personal' || value === 'agent_shared';
1102
+ }
1103
+ function isReminderStateFile(value) {
1104
+ if (!isRecord(value))
1105
+ return false;
1106
+ if (!Array.isArray(value.reminders))
1107
+ return false;
1108
+ if (typeof value.updatedAt !== 'string')
1109
+ return false;
1110
+ return value.reminders.every((entry) => {
1111
+ if (!isRecord(entry))
1112
+ return false;
1113
+ if (typeof entry.id !== 'string')
1114
+ return false;
1115
+ if (typeof entry.content !== 'string')
1116
+ return false;
1117
+ if (entry.ownerName !== undefined && typeof entry.ownerName !== 'string')
1118
+ return false;
1119
+ if (entry.echoback !== undefined && typeof entry.echoback !== 'boolean')
1120
+ return false;
1121
+ if (entry.scope !== undefined && !isReminderScope(entry.scope))
1122
+ return false;
1123
+ if (typeof entry.createdAt !== 'string')
1124
+ return false;
1125
+ if (!isReminderPriority(entry.priority))
1126
+ return false;
1127
+ return true;
1128
+ });
1129
+ }
1130
+ function isHumanQuestion(value) {
1131
+ if (!isRecord(value))
1132
+ return false;
1133
+ if (typeof value.id !== 'string')
1134
+ return false;
1135
+ if (typeof value.tellaskContent !== 'string')
1136
+ return false;
1137
+ if (typeof value.askedAt !== 'string')
1138
+ return false;
1139
+ if (typeof value.callId !== 'string')
1140
+ return false;
1141
+ if (!isRecord(value.callSiteRef))
1142
+ return false;
1143
+ if (typeof value.callSiteRef.course !== 'number')
1144
+ return false;
1145
+ if (typeof value.callSiteRef.messageIndex !== 'number')
1146
+ return false;
1147
+ return true;
1148
+ }
1149
+ function isQuestions4HumanFile(value) {
1150
+ if (!isRecord(value))
1151
+ return false;
1152
+ if (!Array.isArray(value.questions))
1153
+ return false;
1154
+ if (typeof value.updatedAt !== 'string')
1155
+ return false;
1156
+ return value.questions.every(isHumanQuestion);
1157
+ }
951
1158
  // Remove old type definitions - now using kernel/types/storage.ts
952
1159
  const id_1 = require("./utils/id");
953
1160
  function parseReplayTellaskCall(record) {
@@ -1115,7 +1322,6 @@ class DiskFileDialogStore extends dialog_1.DialogStore {
1115
1322
  },
1116
1323
  };
1117
1324
  await DialogPersistence.saveSubdialogMetadata(subdialogId, metadata);
1118
- await DialogPersistence.saveDialogMetadata(subdialogId, metadata);
1119
1325
  const rootAnchor = resolveRootGenerationAnchor(callerDialog);
1120
1326
  const parentCourse = callerDialog.activeGenCourseOrUndefined ?? callerDialog.currentCourse;
1121
1327
  const subdialogCreatedRecord = {
@@ -2876,6 +3082,15 @@ class DiskFileDialogStore extends dialog_1.DialogStore {
2876
3082
  }
2877
3083
  }
2878
3084
  exports.DiskFileDialogStore = DiskFileDialogStore;
3085
+ class DialogWriteBackCanceledError extends Error {
3086
+ constructor(token, phase) {
3087
+ super(`Dialog writeback canceled for ${token.rootDialogId} (${token.status}) during ${phase}`);
3088
+ this.name = 'DialogWriteBackCanceledError';
3089
+ }
3090
+ }
3091
+ function isDialogWriteBackCanceledError(error) {
3092
+ return error instanceof DialogWriteBackCanceledError;
3093
+ }
2879
3094
  /**
2880
3095
  * Utility class for managing dialog persistence
2881
3096
  */
@@ -2926,6 +3141,71 @@ class DialogPersistence {
2926
3141
  static getPendingSubdialogsWriteBackKey(rootDialogId, status) {
2927
3142
  return `${this.getDialogsRootDir()}|${status}|${rootDialogId.valueOf()}|pending-subdialogs`;
2928
3143
  }
3144
+ static getRootDialogWriteBackCancelScopeKey(rootDialogId, status) {
3145
+ return `${this.getDialogsRootDir()}|${status}|${rootDialogId.selfId}|writeback-cancel`;
3146
+ }
3147
+ static createRootDialogWriteBackCancellationToken(dialogId, status) {
3148
+ const rootDialogId = dialogId.rootId === dialogId.selfId ? dialogId : new dialog_1.DialogID(dialogId.rootId);
3149
+ const scopeKey = this.getRootDialogWriteBackCancelScopeKey(rootDialogId, status);
3150
+ return {
3151
+ scopeKey,
3152
+ generation: this.rootDialogWriteBackCancelGenerations.get(scopeKey) ?? 0,
3153
+ rootDialogId: rootDialogId.selfId,
3154
+ status,
3155
+ };
3156
+ }
3157
+ static assertRootDialogWriteBackNotCanceled(token, phase) {
3158
+ if (this.quarantinedRootDialogScopes.has(token.scopeKey)) {
3159
+ throw new DialogWriteBackCanceledError(token, phase);
3160
+ }
3161
+ const currentGeneration = this.rootDialogWriteBackCancelGenerations.get(token.scopeKey) ?? 0;
3162
+ if (currentGeneration !== token.generation) {
3163
+ throw new DialogWriteBackCanceledError(token, phase);
3164
+ }
3165
+ }
3166
+ static async rethrowWriteBackPathMissingAsCanceled(error, dialogPath, cancellationToken, phase) {
3167
+ if (getErrorCode(error) !== 'ENOENT') {
3168
+ throw error;
3169
+ }
3170
+ if (cancellationToken) {
3171
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, phase);
3172
+ if (!(await this.pathExists(dialogPath))) {
3173
+ throw new DialogWriteBackCanceledError(cancellationToken, `${phase}:dialog-path-missing`);
3174
+ }
3175
+ }
3176
+ throw error;
3177
+ }
3178
+ static cancelRootDialogWriteBacks(rootDialogId, status) {
3179
+ const scopeKey = this.getRootDialogWriteBackCancelScopeKey(rootDialogId, status);
3180
+ const nextGeneration = (this.rootDialogWriteBackCancelGenerations.get(scopeKey) ?? 0) + 1;
3181
+ this.rootDialogWriteBackCancelGenerations.set(scopeKey, nextGeneration);
3182
+ this.clearWriteBackEntriesForRootDialog(rootDialogId, status);
3183
+ }
3184
+ static getDialogMetadataPath(dialogId, status) {
3185
+ const dialogPath = dialogId.rootId === dialogId.selfId
3186
+ ? this.getRootDialogPath(dialogId, status)
3187
+ : this.getSubdialogPath(dialogId, status);
3188
+ return path.join(dialogPath, 'dialog.yaml');
3189
+ }
3190
+ static async assertDialogMetadataExistsForAppend(dialogId, status, cancellationToken, phase) {
3191
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, phase);
3192
+ const metadataPath = this.getDialogMetadataPath(dialogId, status);
3193
+ try {
3194
+ await fs.promises.access(metadataPath);
3195
+ }
3196
+ catch (error) {
3197
+ if (getErrorCode(error) === 'ENOENT') {
3198
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, `${phase}:metadata-missing`);
3199
+ throw new Error(`Refusing to append events for dialog ${dialogId.valueOf()}: missing dialog metadata at ${metadataPath}`);
3200
+ }
3201
+ throw error;
3202
+ }
3203
+ }
3204
+ static async cleanupCanceledAppendPlaceholder(dialogId, status) {
3205
+ const rootDialogId = dialogId.rootId === dialogId.selfId ? dialogId : new dialog_1.DialogID(dialogId.rootId);
3206
+ const rootPath = this.getRootDialogPath(rootDialogId, status);
3207
+ await fs.promises.rm(rootPath, { recursive: true, force: true });
3208
+ }
2929
3209
  static clonePendingSubdialogRecords(records) {
2930
3210
  return records.map((record) => ({
2931
3211
  ...record,
@@ -3000,58 +3280,6 @@ class DialogPersistence {
3000
3280
  static getDialogsRootDir() {
3001
3281
  return path.join(process.cwd(), this.DIALOGS_DIR);
3002
3282
  }
3003
- /**
3004
- * Save dialog state to JSON file for persistence (internal use only)
3005
- */
3006
- static async saveDialogState(state) {
3007
- try {
3008
- const dialogPath = await this.ensureRootDialogDirectory(new dialog_1.DialogID(state.metadata.id));
3009
- // Save state as JSON file
3010
- const stateFile = path.join(dialogPath, 'state.json');
3011
- await fs.promises.writeFile(stateFile, JSON.stringify({
3012
- metadata: state.metadata,
3013
- currentCourse: state.currentCourse,
3014
- messages: state.messages,
3015
- reminders: state.reminders,
3016
- savedAt: (0, time_1.formatUnifiedTimestamp)(new Date()),
3017
- }, null, 2), 'utf-8');
3018
- }
3019
- catch (error) {
3020
- log_1.log.error(`Failed to save dialog state for ${state.metadata.id}:`, error);
3021
- throw error;
3022
- }
3023
- }
3024
- /**
3025
- * Load dialog state from JSON file
3026
- */
3027
- static async loadDialogState(dialogId) {
3028
- try {
3029
- const dialogPath = this.getRootDialogPath(dialogId, 'running');
3030
- const stateFile = path.join(dialogPath, 'state.json');
3031
- // Check if state file exists
3032
- try {
3033
- await fs.promises.access(stateFile);
3034
- }
3035
- catch {
3036
- log_1.log.warn(`No state file found for dialog ${dialogId.selfId}, returning null`);
3037
- return null;
3038
- }
3039
- const stateData = JSON.parse(await fs.promises.readFile(stateFile, 'utf-8'));
3040
- const currentCourse = typeof stateData.currentCourse === 'number'
3041
- ? stateData.currentCourse
3042
- : 1;
3043
- return {
3044
- metadata: stateData.metadata,
3045
- currentCourse,
3046
- messages: stateData.messages,
3047
- reminders: stateData.reminders || [],
3048
- };
3049
- }
3050
- catch (error) {
3051
- log_1.log.error(`Failed to load dialog state for root ${dialogId.selfId}:`, error);
3052
- return null;
3053
- }
3054
- }
3055
3283
  /**
3056
3284
  * Get the full path for a dialog directory
3057
3285
  */
@@ -3059,16 +3287,7 @@ class DialogPersistence {
3059
3287
  if (dialogId.rootId !== dialogId.selfId) {
3060
3288
  throw new Error('Expected root dialog id');
3061
3289
  }
3062
- let statusDir;
3063
- if (status === 'running') {
3064
- statusDir = this.RUN_DIR;
3065
- }
3066
- else if (status === 'completed') {
3067
- statusDir = this.DONE_DIR;
3068
- }
3069
- else {
3070
- statusDir = this.ARCHIVE_DIR;
3071
- }
3290
+ const statusDir = getPersistableStatusDirName(status, 'DialogPersistence.getRootDialogPath');
3072
3291
  return path.join(this.getDialogsRootDir(), statusDir, dialogId.selfId);
3073
3292
  }
3074
3293
  /**
@@ -3092,11 +3311,6 @@ class DialogPersistence {
3092
3311
  const rootPath = this.getRootDialogPath(new dialog_1.DialogID(dialogId.rootId), status);
3093
3312
  return path.join(rootPath, this.SUBDIALOGS_DIR, dialogId.selfId);
3094
3313
  }
3095
- static isMalformedDialogLoadError(error) {
3096
- return (error instanceof Error &&
3097
- (error.message.startsWith('Invalid latest.yaml in ') ||
3098
- error.message.startsWith('Invalid dialog metadata in ')));
3099
- }
3100
3314
  static getMalformedRootDialogPath(dialogId, status) {
3101
3315
  if (dialogId.rootId !== dialogId.selfId) {
3102
3316
  throw new Error('Expected root dialog id');
@@ -3104,6 +3318,82 @@ class DialogPersistence {
3104
3318
  void status;
3105
3319
  return path.join(this.getDialogsRootDir(), this.MALFORMED_DIR, dialogId.selfId);
3106
3320
  }
3321
+ static inferRootDialogIdFromMetadataRelativeDir(relativeDir) {
3322
+ const dir = relativeDir.trim();
3323
+ if (dir === '' || dir === '.' || dir === path.sep) {
3324
+ return null;
3325
+ }
3326
+ const segments = dir.split(path.sep).filter((seg) => seg.length > 0 && seg !== '.');
3327
+ if (segments.length === 0) {
3328
+ return null;
3329
+ }
3330
+ const subdialogsIndex = segments.indexOf(this.SUBDIALOGS_DIR);
3331
+ const rootSegments = subdialogsIndex === -1 ? segments : segments.slice(0, subdialogsIndex);
3332
+ if (rootSegments.length === 0) {
3333
+ return null;
3334
+ }
3335
+ return new dialog_1.DialogID(rootSegments.join('/'));
3336
+ }
3337
+ static inferExpectedDialogIdFromMetadataRelativeDir(relativeDir) {
3338
+ const dir = relativeDir.trim();
3339
+ if (dir === '' || dir === '.' || dir === path.sep) {
3340
+ return null;
3341
+ }
3342
+ const segments = dir.split(path.sep).filter((seg) => seg.length > 0 && seg !== '.');
3343
+ if (segments.length === 0) {
3344
+ return null;
3345
+ }
3346
+ const subdialogsIndex = segments.indexOf(this.SUBDIALOGS_DIR);
3347
+ if (subdialogsIndex === -1) {
3348
+ return segments.join('/');
3349
+ }
3350
+ const subdialogSegments = segments.slice(subdialogsIndex + 1);
3351
+ if (subdialogSegments.length === 0) {
3352
+ return null;
3353
+ }
3354
+ return subdialogSegments.join('/');
3355
+ }
3356
+ static async listSubdialogIdsUnderRoot(rootDialogId, status) {
3357
+ const subdialogsPath = path.join(this.getRootDialogPath(rootDialogId, status), this.SUBDIALOGS_DIR);
3358
+ const subdialogIds = new Set();
3359
+ const visit = async (dirPath, relativePath = '') => {
3360
+ let entries;
3361
+ try {
3362
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
3363
+ }
3364
+ catch (error) {
3365
+ if (getErrorCode(error) === 'ENOENT') {
3366
+ return;
3367
+ }
3368
+ throw error;
3369
+ }
3370
+ for (const entry of entries) {
3371
+ if (!entry.isDirectory()) {
3372
+ continue;
3373
+ }
3374
+ const fullPath = path.join(dirPath, entry.name);
3375
+ const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
3376
+ const dialogYamlPath = path.join(fullPath, 'dialog.yaml');
3377
+ try {
3378
+ await fs.promises.access(dialogYamlPath);
3379
+ const inferredId = this.inferExpectedDialogIdFromMetadataRelativeDir(path.join(this.SUBDIALOGS_DIR, entryRelativePath));
3380
+ if (!inferredId) {
3381
+ throw new Error(`Failed to infer subdialog id from relative path ${entryRelativePath} under root ${rootDialogId.selfId}`);
3382
+ }
3383
+ subdialogIds.add(inferredId);
3384
+ continue;
3385
+ }
3386
+ catch (error) {
3387
+ if (getErrorCode(error) !== 'ENOENT') {
3388
+ throw error;
3389
+ }
3390
+ }
3391
+ await visit(fullPath, entryRelativePath);
3392
+ }
3393
+ };
3394
+ await visit(subdialogsPath);
3395
+ return [...subdialogIds];
3396
+ }
3107
3397
  static async pathExists(targetPath) {
3108
3398
  try {
3109
3399
  await fs.promises.access(targetPath);
@@ -3125,40 +3415,139 @@ class DialogPersistence {
3125
3415
  this.latestWriteBack.delete(key);
3126
3416
  this.latestWriteBackMutexes.delete(key);
3127
3417
  }
3128
- static async quarantineMalformedRootDialog(dialogId, status, reason, error) {
3129
- if (dialogId.rootId !== dialogId.selfId) {
3130
- throw error;
3418
+ static clearWriteBackEntriesForRootDialog(rootDialogId, status) {
3419
+ const basePrefix = `${this.getDialogsRootDir()}|${status}|${rootDialogId.selfId}`;
3420
+ const matchesRootDialogKey = (key) => key === basePrefix || key.startsWith(`${basePrefix}#`) || key.startsWith(`${basePrefix}|`);
3421
+ for (const [key, entry] of this.latestWriteBack.entries()) {
3422
+ if (!matchesRootDialogKey(key))
3423
+ continue;
3424
+ if (entry.kind === 'scheduled') {
3425
+ clearTimeout(entry.timer);
3426
+ }
3427
+ this.latestWriteBack.delete(key);
3131
3428
  }
3132
- const sourcePath = this.getRootDialogPath(dialogId, status);
3133
- if (!(await this.pathExists(sourcePath))) {
3134
- return;
3429
+ for (const key of this.latestWriteBackMutexes.keys()) {
3430
+ if (matchesRootDialogKey(key)) {
3431
+ this.latestWriteBackMutexes.delete(key);
3432
+ }
3135
3433
  }
3136
- let destinationPath = this.getMalformedRootDialogPath(dialogId, status);
3137
- if (await this.pathExists(destinationPath)) {
3138
- destinationPath = path.join(this.getDialogsRootDir(), this.MALFORMED_DIR, `${dialogId.selfId}__${(0, node_crypto_1.randomUUID)()}`);
3434
+ for (const [key, entry] of this.q4hWriteBack.entries()) {
3435
+ if (!matchesRootDialogKey(key))
3436
+ continue;
3437
+ if (entry.kind === 'scheduled') {
3438
+ clearTimeout(entry.timer);
3439
+ }
3440
+ this.q4hWriteBack.delete(key);
3441
+ }
3442
+ for (const key of this.q4hWriteBackMutexes.keys()) {
3443
+ if (matchesRootDialogKey(key)) {
3444
+ this.q4hWriteBackMutexes.delete(key);
3445
+ }
3446
+ }
3447
+ for (const [key, entry] of this.pendingSubdialogsWriteBack.entries()) {
3448
+ if (!matchesRootDialogKey(key))
3449
+ continue;
3450
+ if (entry.kind === 'scheduled') {
3451
+ clearTimeout(entry.timer);
3452
+ }
3453
+ this.pendingSubdialogsWriteBack.delete(key);
3454
+ }
3455
+ for (const key of this.pendingSubdialogsWriteBackMutexes.keys()) {
3456
+ if (matchesRootDialogKey(key)) {
3457
+ this.pendingSubdialogsWriteBackMutexes.delete(key);
3458
+ }
3459
+ }
3460
+ for (const key of this.courseAppendMutexes.keys()) {
3461
+ if (matchesRootDialogKey(key)) {
3462
+ this.courseAppendMutexes.delete(key);
3463
+ }
3139
3464
  }
3140
- await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
3141
- await fs.promises.rename(sourcePath, destinationPath);
3142
- this.clearLatestWriteBackState(dialogId, status);
3143
- log_1.log.warn(`Quarantined malformed root dialog ${dialogId.selfId}`, undefined, {
3144
- status,
3145
- reason,
3146
- sourcePath,
3147
- destinationPath,
3148
- errorMessage: error.message,
3149
- });
3150
3465
  }
3151
- static parseDialogLatestYaml(content, latestFilePath) {
3152
- let parsed;
3466
+ static async quarantineMalformedDialog(dialogId, status, reason, error) {
3467
+ const rootDialogId = dialogId.rootId === dialogId.selfId ? dialogId : new dialog_1.DialogID(dialogId.rootId);
3468
+ const quarantineKey = `${status}|${rootDialogId.selfId}`;
3469
+ if (quarantiningRootDialogs.has(quarantineKey)) {
3470
+ return;
3471
+ }
3472
+ quarantiningRootDialogs.add(quarantineKey);
3473
+ let quarantined = false;
3153
3474
  try {
3154
- parsed = yaml.parse(content);
3475
+ await prepareDialogQuarantineHook?.({
3476
+ dialogId,
3477
+ rootDialogId,
3478
+ status,
3479
+ reason,
3480
+ error,
3481
+ });
3482
+ this.quarantinedRootDialogScopes.add(this.getRootDialogWriteBackCancelScopeKey(rootDialogId, status));
3483
+ this.cancelRootDialogWriteBacks(rootDialogId, status);
3484
+ const sourcePath = this.getRootDialogPath(rootDialogId, status);
3485
+ if (!(await this.pathExists(sourcePath))) {
3486
+ return;
3487
+ }
3488
+ let destinationPath = this.getMalformedRootDialogPath(rootDialogId, status);
3489
+ if (await this.pathExists(destinationPath)) {
3490
+ destinationPath = path.join(this.getDialogsRootDir(), this.MALFORMED_DIR, `${rootDialogId.selfId}__${(0, node_crypto_1.randomUUID)()}`);
3491
+ }
3492
+ await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
3493
+ await fs.promises.rename(sourcePath, destinationPath);
3494
+ quarantined = true;
3495
+ log_1.log.warn(`Quarantined malformed dialog ${rootDialogId.selfId}`, undefined, {
3496
+ status,
3497
+ reason,
3498
+ sourcePath,
3499
+ destinationPath,
3500
+ errorMessage: error.message,
3501
+ dialogId: dialogId.valueOf(),
3502
+ rootDialogId: rootDialogId.valueOf(),
3503
+ });
3504
+ dialogsQuarantinedBroadcaster?.({
3505
+ type: 'dialogs_quarantined',
3506
+ status: 'quarantining',
3507
+ fromStatus: assertPersistableDialogStatus(status, 'DialogPersistence.quarantineMalformedDialog(fromStatus)'),
3508
+ rootId: rootDialogId.selfId,
3509
+ dialogId: dialogId.selfId,
3510
+ reason,
3511
+ timestamp: (0, time_1.formatUnifiedTimestamp)(new Date()),
3512
+ });
3155
3513
  }
3156
- catch {
3157
- throw new Error(`Invalid latest.yaml in ${latestFilePath}`);
3514
+ finally {
3515
+ try {
3516
+ await finalizeDialogQuarantineHook?.({
3517
+ dialogId,
3518
+ rootDialogId,
3519
+ status,
3520
+ reason,
3521
+ error,
3522
+ quarantined,
3523
+ });
3524
+ }
3525
+ finally {
3526
+ quarantiningRootDialogs.delete(quarantineKey);
3527
+ }
3528
+ }
3529
+ }
3530
+ static async rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, reason, error) {
3531
+ const persistenceError = (0, persistence_errors_1.findDomindsPersistenceFileError)(error);
3532
+ if (persistenceError) {
3533
+ await this.quarantineMalformedDialog(dialogId, status, reason, persistenceError);
3534
+ throw persistenceError;
3158
3535
  }
3536
+ throw error;
3537
+ }
3538
+ static parseDialogLatestYaml(content, latestFilePath) {
3539
+ const parsed = parsePersistenceYaml({
3540
+ content,
3541
+ filePath: latestFilePath,
3542
+ source: 'dialog_latest',
3543
+ });
3159
3544
  const latest = parseDialogLatestFile(parsed);
3160
3545
  if (!latest) {
3161
- throw new Error(`Invalid latest.yaml in ${latestFilePath}`);
3546
+ throw buildInvalidPersistenceFileError({
3547
+ source: 'dialog_latest',
3548
+ format: 'yaml',
3549
+ filePath: latestFilePath,
3550
+ });
3162
3551
  }
3163
3552
  return latest;
3164
3553
  }
@@ -3212,16 +3601,17 @@ class DialogPersistence {
3212
3601
  }
3213
3602
  }
3214
3603
  /**
3215
- * List all dialog IDs by scanning for dialog.yaml files and validating their IDs
3604
+ * List candidate root dialog IDs by scanning `dialog.yaml`.
3605
+ *
3606
+ * This scanner intentionally stays lightweight: it only validates the path<->id identity needed
3607
+ * for safe enumeration, and leaves full metadata shape validation to the subsequent lazy-load
3608
+ * step. Callers iterating these candidate IDs must therefore tolerate per-dialog load failures
3609
+ * and continue after a malformed dialog is quarantined.
3216
3610
  */
3217
3611
  static async listDialogs(status = 'running') {
3218
3612
  try {
3219
3613
  const statusDir = this.getDialogsRootDir();
3220
- const specificDir = path.join(statusDir, status === 'running'
3221
- ? this.RUN_DIR
3222
- : status === 'completed'
3223
- ? this.DONE_DIR
3224
- : this.ARCHIVE_DIR);
3614
+ const specificDir = path.join(statusDir, getPersistableStatusDirName(status, 'DialogPersistence.listDialogs'));
3225
3615
  const validDialogIds = [];
3226
3616
  // Recursively find all dialog.yaml files
3227
3617
  const findDialogYamls = async (dirPath, relativePath = '') => {
@@ -3235,36 +3625,75 @@ class DialogPersistence {
3235
3625
  await findDialogYamls(fullPath, entryRelativePath);
3236
3626
  }
3237
3627
  else if (entry.name === 'dialog.yaml') {
3238
- // Found a dialog.yaml file, record its ID regardless of nesting structure
3628
+ // Found a dialog.yaml file. We only validate path<->id identity here; full metadata
3629
+ // shape remains lazy-loaded by the caller-specific read path.
3239
3630
  try {
3240
- const content = await fs.promises.readFile(fullPath, 'utf-8');
3241
- const parsed = yaml.parse(content);
3242
- if (parsed?.id && typeof parsed.id === 'string') {
3243
- validDialogIds.push(parsed.id);
3631
+ const content = await readPersistenceTextFile({
3632
+ filePath: fullPath,
3633
+ source: 'dialog_metadata',
3634
+ format: 'yaml',
3635
+ });
3636
+ const parsed = parsePersistenceYaml({
3637
+ content,
3638
+ filePath: fullPath,
3639
+ source: 'dialog_metadata',
3640
+ });
3641
+ if (!isRecord(parsed) || typeof parsed.id !== 'string' || parsed.id.trim() === '') {
3642
+ throw buildInvalidPersistenceFileError({
3643
+ source: 'dialog_metadata',
3644
+ format: 'yaml',
3645
+ filePath: fullPath,
3646
+ });
3244
3647
  }
3648
+ const expectedDialogId = this.inferExpectedDialogIdFromMetadataRelativeDir(path.dirname(entryRelativePath));
3649
+ if (expectedDialogId === null || parsed.id !== expectedDialogId) {
3650
+ throw buildInvalidPersistenceFileError({
3651
+ source: 'dialog_metadata',
3652
+ format: 'yaml',
3653
+ filePath: fullPath,
3654
+ });
3655
+ }
3656
+ validDialogIds.push(parsed.id);
3245
3657
  }
3246
3658
  catch (yamlError) {
3659
+ const persistenceError = (0, persistence_errors_1.findDomindsPersistenceFileError)(yamlError);
3660
+ if (persistenceError) {
3661
+ const rootDialogId = this.inferRootDialogIdFromMetadataRelativeDir(path.dirname(entryRelativePath));
3662
+ if (rootDialogId) {
3663
+ await this.quarantineMalformedDialog(rootDialogId, status, 'listDialogs', persistenceError);
3664
+ }
3665
+ }
3247
3666
  log_1.log.warn(`🔍 listDialogs: Failed to parse dialog.yaml at ${fullPath}:`, yamlError);
3248
3667
  }
3249
3668
  }
3250
3669
  }
3251
3670
  }
3252
3671
  catch (error) {
3672
+ // Directory enumeration failures are filesystem-level access/I/O problems, not evidence
3673
+ // that a specific dialog record is malformed. If we cannot even read this directory,
3674
+ // attempting to quarantine a child dialog via move/rename is unlikely to be reliable.
3253
3675
  log_1.log.warn(`🔍 listDialogs: Error reading directory ${dirPath}:`, error);
3254
3676
  }
3255
3677
  };
3256
3678
  try {
3257
- // Check if directory exists before trying to read it
3258
- const dirExists = await fs.promises
3259
- .stat(specificDir)
3260
- .then(() => true)
3261
- .catch(() => false);
3262
- if (dirExists) {
3263
- await findDialogYamls(specificDir);
3679
+ // Only ENOENT means "status directory absent". Other stat failures are loud environment
3680
+ // errors and must flow into the warning path below instead of being silently downgraded.
3681
+ try {
3682
+ await fs.promises.stat(specificDir);
3683
+ }
3684
+ catch (error) {
3685
+ if (getErrorCode(error) === 'ENOENT') {
3686
+ return validDialogIds;
3687
+ }
3688
+ throw error;
3264
3689
  }
3690
+ await findDialogYamls(specificDir);
3265
3691
  return validDialogIds;
3266
3692
  }
3267
3693
  catch (error) {
3694
+ // Same rationale as the inner readdir catch above: keep the failure loud in logs, but do
3695
+ // not pretend we have actionable malformed-dialog evidence when the status directory itself
3696
+ // is not readable.
3268
3697
  log_1.log.warn(`🔍 listDialogs: Error processing directory ${specificDir}:`, error);
3269
3698
  return [];
3270
3699
  }
@@ -3278,14 +3707,14 @@ class DialogPersistence {
3278
3707
  * List all dialog IDs (root + subdialogs) together with their root IDs.
3279
3708
  * This is the only safe way to enumerate subdialogs because their directory names
3280
3709
  * are not guaranteed to be their selfId.
3710
+ *
3711
+ * Like `listDialogs()`, this is a candidate scanner rather than a full metadata validator.
3712
+ * Callers must treat later metadata/latest loads as lazy, per-dialog operations that can still
3713
+ * quarantine one dialog without invalidating the rest of the enumeration.
3281
3714
  */
3282
3715
  static async listAllDialogIds(status = 'running') {
3283
3716
  const statusDir = this.getDialogsRootDir();
3284
- const specificDir = path.join(statusDir, status === 'running'
3285
- ? this.RUN_DIR
3286
- : status === 'completed'
3287
- ? this.DONE_DIR
3288
- : this.ARCHIVE_DIR);
3717
+ const specificDir = path.join(statusDir, getPersistableStatusDirName(status, 'DialogPersistence.listAllDialogIds'));
3289
3718
  const result = [];
3290
3719
  const rootDialogIdByDialogYamlPath = new Map();
3291
3720
  const readDialogYamlId = async (dialogYamlPath) => {
@@ -3293,22 +3722,44 @@ class DialogPersistence {
3293
3722
  if (cached !== undefined)
3294
3723
  return cached;
3295
3724
  try {
3296
- const content = await fs.promises.readFile(dialogYamlPath, 'utf-8');
3297
- const parsed = yaml.parse(content);
3725
+ const content = await readPersistenceTextFile({
3726
+ filePath: dialogYamlPath,
3727
+ source: 'dialog_metadata',
3728
+ format: 'yaml',
3729
+ });
3730
+ const parsed = parsePersistenceYaml({
3731
+ content,
3732
+ filePath: dialogYamlPath,
3733
+ source: 'dialog_metadata',
3734
+ });
3298
3735
  if (typeof parsed !== 'object' || parsed === null) {
3299
- rootDialogIdByDialogYamlPath.set(dialogYamlPath, null);
3300
- return null;
3736
+ throw buildInvalidPersistenceFileError({
3737
+ source: 'dialog_metadata',
3738
+ format: 'yaml',
3739
+ filePath: dialogYamlPath,
3740
+ });
3301
3741
  }
3302
3742
  const idValue = parsed.id;
3303
3743
  if (typeof idValue !== 'string' || idValue.trim() === '') {
3304
- rootDialogIdByDialogYamlPath.set(dialogYamlPath, null);
3305
- return null;
3744
+ throw buildInvalidPersistenceFileError({
3745
+ source: 'dialog_metadata',
3746
+ format: 'yaml',
3747
+ filePath: dialogYamlPath,
3748
+ });
3306
3749
  }
3307
3750
  const normalized = idValue.trim();
3308
3751
  rootDialogIdByDialogYamlPath.set(dialogYamlPath, normalized);
3309
3752
  return normalized;
3310
3753
  }
3311
- catch {
3754
+ catch (error) {
3755
+ const persistenceError = (0, persistence_errors_1.findDomindsPersistenceFileError)(error);
3756
+ if (persistenceError) {
3757
+ const relativeDir = path.relative(specificDir, path.dirname(dialogYamlPath));
3758
+ const rootDialogId = this.inferRootDialogIdFromMetadataRelativeDir(relativeDir);
3759
+ if (rootDialogId) {
3760
+ await this.quarantineMalformedDialog(rootDialogId, status, 'listAllDialogIds:readDialogYamlId', persistenceError);
3761
+ }
3762
+ }
3312
3763
  rootDialogIdByDialogYamlPath.set(dialogYamlPath, null);
3313
3764
  return null;
3314
3765
  }
@@ -3343,6 +3794,9 @@ class DialogPersistence {
3343
3794
  entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
3344
3795
  }
3345
3796
  catch (err) {
3797
+ // This is an environment/filesystem failure rather than confirmed dialog corruption. We log
3798
+ // loudly and keep enumeration partial instead of fabricating a quarantine target that may
3799
+ // not even be reachable through the same broken directory path.
3346
3800
  log_1.log.warn(`🔍 listAllDialogIds: Error reading directory ${dirPath}:`, err);
3347
3801
  return;
3348
3802
  }
@@ -3360,26 +3814,64 @@ class DialogPersistence {
3360
3814
  if (!rootId)
3361
3815
  continue;
3362
3816
  try {
3363
- const content = await fs.promises.readFile(fullPath, 'utf-8');
3364
- const parsed = yaml.parse(content);
3365
- if (typeof parsed !== 'object' || parsed === null)
3366
- continue;
3817
+ const content = await readPersistenceTextFile({
3818
+ filePath: fullPath,
3819
+ source: 'dialog_metadata',
3820
+ format: 'yaml',
3821
+ });
3822
+ const parsed = parsePersistenceYaml({
3823
+ content,
3824
+ filePath: fullPath,
3825
+ source: 'dialog_metadata',
3826
+ });
3827
+ if (typeof parsed !== 'object' || parsed === null) {
3828
+ throw buildInvalidPersistenceFileError({
3829
+ source: 'dialog_metadata',
3830
+ format: 'yaml',
3831
+ filePath: fullPath,
3832
+ });
3833
+ }
3367
3834
  const idValue = parsed.id;
3368
- if (typeof idValue !== 'string' || idValue.trim() === '')
3369
- continue;
3835
+ if (typeof idValue !== 'string' || idValue.trim() === '') {
3836
+ throw buildInvalidPersistenceFileError({
3837
+ source: 'dialog_metadata',
3838
+ format: 'yaml',
3839
+ filePath: fullPath,
3840
+ });
3841
+ }
3842
+ const expectedDialogId = this.inferExpectedDialogIdFromMetadataRelativeDir(relDir);
3843
+ if (expectedDialogId === null || idValue !== expectedDialogId) {
3844
+ throw buildInvalidPersistenceFileError({
3845
+ source: 'dialog_metadata',
3846
+ format: 'yaml',
3847
+ filePath: fullPath,
3848
+ });
3849
+ }
3370
3850
  result.push(new dialog_1.DialogID(idValue, rootId));
3371
3851
  }
3372
3852
  catch (yamlError) {
3853
+ const persistenceError = (0, persistence_errors_1.findDomindsPersistenceFileError)(yamlError);
3854
+ if (persistenceError) {
3855
+ const rootDialogId = this.inferRootDialogIdFromMetadataRelativeDir(relDir);
3856
+ if (rootDialogId) {
3857
+ await this.quarantineMalformedDialog(rootDialogId, status, 'listAllDialogIds', persistenceError);
3858
+ }
3859
+ }
3373
3860
  log_1.log.warn(`🔍 listAllDialogIds: Failed to parse dialog.yaml at ${fullPath}:`, yamlError);
3374
3861
  }
3375
3862
  }
3376
3863
  };
3377
- const dirExists = await fs.promises
3378
- .stat(specificDir)
3379
- .then(() => true)
3380
- .catch(() => false);
3381
- if (!dirExists)
3382
- return [];
3864
+ try {
3865
+ // Only ENOENT is the benign "no dialogs under this status" case. Permission/I/O failures
3866
+ // must stay loud so operators can tell the difference between "missing" and "inaccessible".
3867
+ await fs.promises.stat(specificDir);
3868
+ }
3869
+ catch (error) {
3870
+ if (getErrorCode(error) === 'ENOENT') {
3871
+ return [];
3872
+ }
3873
+ throw error;
3874
+ }
3383
3875
  await findDialogYamls(specificDir);
3384
3876
  return result;
3385
3877
  }
@@ -3390,20 +3882,27 @@ class DialogPersistence {
3390
3882
  static async appendEvents(dialogId, course, events, status = 'running') {
3391
3883
  const appendMutexKey = this.getCourseAppendMutexKey(dialogId, course, status);
3392
3884
  const release = await this.getCourseAppendMutex(appendMutexKey).acquire();
3885
+ const cancellationToken = this.createRootDialogWriteBackCancellationToken(dialogId, status);
3393
3886
  try {
3394
3887
  if (events.length === 0) {
3395
3888
  return;
3396
3889
  }
3890
+ await this.assertDialogMetadataExistsForAppend(dialogId, status, cancellationToken, 'appendEvents:start');
3397
3891
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3398
3892
  const courseFilename = this.getCourseFilename(course);
3399
3893
  const courseFilePath = path.join(dialogPath, courseFilename);
3400
- // Ensure directory exists
3401
- await fs.promises.mkdir(dialogPath, { recursive: true });
3402
3894
  // Serialize appends per dialog+course file. Concurrent `appendFile` calls can interleave and
3403
3895
  // corrupt JSONL lines (e.g. tool results appended in parallel), which later manifests as
3404
3896
  // `Unterminated string in JSON ...` during resume.
3405
3897
  const serialized = events.map((event) => JSON.stringify(event)).join('\n') + '\n';
3406
- await fs.promises.appendFile(courseFilePath, serialized, 'utf-8');
3898
+ try {
3899
+ await fs.promises.appendFile(courseFilePath, serialized, 'utf-8');
3900
+ }
3901
+ catch (error) {
3902
+ await this.rethrowWriteBackPathMissingAsCanceled(error, dialogPath, cancellationToken, 'appendEvents:append-file');
3903
+ throw error;
3904
+ }
3905
+ await this.assertDialogMetadataExistsForAppend(dialogId, status, cancellationToken, 'appendEvents:after-append');
3407
3906
  // Update latest.yaml with new lastModified timestamp
3408
3907
  await this.mutateDialogLatest(dialogId, () => ({
3409
3908
  kind: 'patch',
@@ -3411,9 +3910,13 @@ class DialogPersistence {
3411
3910
  lastModified: (0, time_1.formatUnifiedTimestamp)(new Date()),
3412
3911
  currentCourse: course,
3413
3912
  },
3414
- }), status);
3913
+ }), status, cancellationToken);
3415
3914
  }
3416
3915
  catch (error) {
3916
+ if (isDialogWriteBackCanceledError(error)) {
3917
+ await this.cleanupCanceledAppendPlaceholder(dialogId, status);
3918
+ return;
3919
+ }
3417
3920
  log_1.log.error(`Failed to append events to dialog ${dialogId} course ${course}:`, error);
3418
3921
  throw error;
3419
3922
  }
@@ -3518,7 +4021,11 @@ class DialogPersistence {
3518
4021
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3519
4022
  const courseFilePath = path.join(dialogPath, this.getCourseFilename(course));
3520
4023
  try {
3521
- const content = await fs.promises.readFile(courseFilePath, 'utf-8');
4024
+ const content = await readPersistenceTextFile({
4025
+ filePath: courseFilePath,
4026
+ source: 'dialog_course_events',
4027
+ format: 'jsonl',
4028
+ });
3522
4029
  const events = [];
3523
4030
  const lines = content.split('\n');
3524
4031
  for (let i = 0; i < lines.length; i++) {
@@ -3526,7 +4033,12 @@ class DialogPersistence {
3526
4033
  if (!line.trim())
3527
4034
  continue;
3528
4035
  try {
3529
- events.push(JSON.parse(line));
4036
+ events.push(parsePersistenceJson({
4037
+ content: line,
4038
+ filePath: courseFilePath,
4039
+ source: 'dialog_course_events',
4040
+ lineNumber: i + 1,
4041
+ }));
3530
4042
  }
3531
4043
  catch (err) {
3532
4044
  const isLastNonEmptyLine = (() => {
@@ -3536,16 +4048,27 @@ class DialogPersistence {
3536
4048
  }
3537
4049
  return true;
3538
4050
  })();
4051
+ const persistenceFileError = err instanceof persistence_errors_1.DomindsPersistenceFileError ? err : undefined;
3539
4052
  const msg = err instanceof Error ? err.message : String(err);
3540
4053
  // If the last JSONL line was truncated (e.g. process crash mid-append), ignore it so
3541
4054
  // dialogs remain resumable. Do not mask corruption in the middle of the file.
3542
4055
  if (isLastNonEmptyLine &&
3543
- (msg.includes('Unterminated string in JSON') ||
4056
+ (persistenceFileError?.eofLike === true ||
4057
+ msg.includes('Unterminated string in JSON') ||
3544
4058
  msg.includes('Unexpected end of JSON input'))) {
3545
4059
  log_1.log.warn(`Ignoring truncated JSONL tail for dialog ${dialogId} course ${course} at line ${i + 1}: ${msg}`);
3546
4060
  break;
3547
4061
  }
3548
- throw err;
4062
+ if (persistenceFileError) {
4063
+ throw persistenceFileError;
4064
+ }
4065
+ throw buildInvalidPersistenceFileError({
4066
+ source: 'dialog_course_events',
4067
+ format: 'jsonl',
4068
+ filePath: courseFilePath,
4069
+ lineNumber: i + 1,
4070
+ cause: err,
4071
+ });
3549
4072
  }
3550
4073
  }
3551
4074
  return events;
@@ -3596,7 +4119,6 @@ class DialogPersistence {
3596
4119
  static async _saveReminderState(dialogId, reminders, status = 'running') {
3597
4120
  try {
3598
4121
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3599
- await fs.promises.mkdir(dialogPath, { recursive: true });
3600
4122
  const remindersFilePath = path.join(dialogPath, 'reminders.json');
3601
4123
  const reminderState = {
3602
4124
  reminders: reminders.map((r) => ({
@@ -3615,7 +4137,7 @@ class DialogPersistence {
3615
4137
  const jsonContent = JSON.stringify(reminderState, null, 2);
3616
4138
  const tempFile = path.join(dialogPath, `.${path.basename(remindersFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
3617
4139
  await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
3618
- await this.renameWithRetry(tempFile, remindersFilePath, jsonContent);
4140
+ await this.renameWithRetry(tempFile, remindersFilePath);
3619
4141
  }
3620
4142
  catch (error) {
3621
4143
  log_1.log.error(`Failed to save reminder state for dialog ${dialogId}:`, error);
@@ -3630,8 +4152,23 @@ class DialogPersistence {
3630
4152
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3631
4153
  const remindersFilePath = path.join(dialogPath, 'reminders.json');
3632
4154
  try {
3633
- const content = await fs.promises.readFile(remindersFilePath, 'utf-8');
3634
- const reminderState = JSON.parse(content);
4155
+ const content = await readPersistenceTextFile({
4156
+ filePath: remindersFilePath,
4157
+ source: 'reminder_state',
4158
+ format: 'json',
4159
+ });
4160
+ const reminderState = parsePersistenceJson({
4161
+ content,
4162
+ filePath: remindersFilePath,
4163
+ source: 'reminder_state',
4164
+ });
4165
+ if (!isReminderStateFile(reminderState)) {
4166
+ throw buildInvalidPersistenceFileError({
4167
+ source: 'reminder_state',
4168
+ format: 'json',
4169
+ filePath: remindersFilePath,
4170
+ });
4171
+ }
3635
4172
  return reminderState.reminders.map((r) => {
3636
4173
  const ownerNameFromFile = typeof r.ownerName === 'string' ? r.ownerName : undefined;
3637
4174
  // Reminder metadata is owner-private. Rebind strictly through persisted ownerName.
@@ -3657,8 +4194,8 @@ class DialogPersistence {
3657
4194
  }
3658
4195
  }
3659
4196
  catch (error) {
3660
- log_1.log.error(`Failed to load reminder state for dialog ${dialogId}:`, error);
3661
- return [];
4197
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadReminderState', error);
4198
+ throw new Error('unreachable after loadReminderState persistence rethrow');
3662
4199
  }
3663
4200
  }
3664
4201
  /**
@@ -3683,82 +4220,32 @@ class DialogPersistence {
3683
4220
  return await this.loadQuestions4HumanStateFromDisk(dialogId, status);
3684
4221
  }
3685
4222
  catch (error) {
3686
- log_1.log.error(`Failed to load q4h.yaml for dialog ${dialogId}:`, error);
3687
- return [];
4223
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadQuestions4HumanState', error);
4224
+ throw new Error('unreachable after loadQuestions4HumanState persistence rethrow');
3688
4225
  }
3689
4226
  }
3690
4227
  static async loadQuestions4HumanStateFromDisk(dialogId, status) {
3691
4228
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3692
4229
  const questionsFilePath = path.join(dialogPath, 'q4h.yaml');
3693
4230
  try {
3694
- const content = await fs.promises.readFile(questionsFilePath, 'utf-8');
3695
- try {
3696
- const parsed = yaml.parse(content);
3697
- if (typeof parsed === 'object' &&
3698
- parsed !== null &&
3699
- 'questions' in parsed &&
3700
- Array.isArray(parsed.questions)) {
3701
- return parsed.questions;
3702
- }
3703
- log_1.log.warn(`q4h.yaml has unexpected shape for dialog ${dialogId}`, undefined, {
4231
+ const content = await readPersistenceTextFile({
4232
+ filePath: questionsFilePath,
4233
+ source: 'questions4human_state',
4234
+ format: 'yaml',
4235
+ });
4236
+ const parsed = parsePersistenceYaml({
4237
+ content,
4238
+ filePath: questionsFilePath,
4239
+ source: 'questions4human_state',
4240
+ });
4241
+ if (!isQuestions4HumanFile(parsed)) {
4242
+ throw buildInvalidPersistenceFileError({
4243
+ source: 'questions4human_state',
4244
+ format: 'yaml',
3704
4245
  filePath: questionsFilePath,
3705
4246
  });
3706
- return [];
3707
- }
3708
- catch (parseError) {
3709
- // Attempt to auto-repair the common corruption pattern where extra trailing lines are appended
3710
- // due to concurrent writers clobbering a shared temp file.
3711
- const lines = content.split(/\r?\n/);
3712
- let repairedQuestions = null;
3713
- for (let cut = lines.length - 1; cut > 0 && lines.length - cut <= 12; cut--) {
3714
- const candidate = lines.slice(0, cut).join('\n');
3715
- if (candidate.trim() === '')
3716
- continue;
3717
- try {
3718
- const candidateState = yaml.parse(candidate);
3719
- if (typeof candidateState === 'object' &&
3720
- candidateState !== null &&
3721
- 'questions' in candidateState &&
3722
- Array.isArray(candidateState.questions)) {
3723
- repairedQuestions = candidateState.questions;
3724
- break;
3725
- }
3726
- }
3727
- catch {
3728
- // keep trimming
3729
- }
3730
- }
3731
- if (repairedQuestions) {
3732
- log_1.log.warn(`Repaired corrupted q4h.yaml for dialog ${dialogId}`, undefined, {
3733
- filePath: questionsFilePath,
3734
- });
3735
- const repairedFile = {
3736
- questions: repairedQuestions,
3737
- updatedAt: (0, time_1.formatUnifiedTimestamp)(new Date()),
3738
- };
3739
- try {
3740
- await this.writeQ4HStateToDisk(dialogId, { kind: 'file', file: repairedFile }, status);
3741
- }
3742
- catch (repairSaveError) {
3743
- log_1.log.warn(`Failed to persist repaired q4h.yaml for dialog ${dialogId}`, repairSaveError);
3744
- }
3745
- return repairedQuestions;
3746
- }
3747
- // Quarantine the bad file to avoid repeated parse errors, then treat as "no questions".
3748
- try {
3749
- const quarantinePath = `${questionsFilePath}.corrupt-${(0, node_crypto_1.randomUUID)()}`;
3750
- await fs.promises.rename(questionsFilePath, quarantinePath);
3751
- log_1.log.warn(`Quarantined corrupted q4h.yaml for dialog ${dialogId}`, undefined, {
3752
- filePath: questionsFilePath,
3753
- quarantinePath,
3754
- });
3755
- }
3756
- catch (quarantineError) {
3757
- log_1.log.warn(`Failed to quarantine corrupted q4h.yaml for dialog ${dialogId}`, quarantineError);
3758
- }
3759
- log_1.log.warn(`Failed to parse q4h.yaml for dialog ${dialogId}`, parseError);
3760
- return [];
3761
4247
  }
4248
+ return parsed.questions;
3762
4249
  }
3763
4250
  catch (error) {
3764
4251
  if (getErrorCode(error) === 'ENOENT')
@@ -3886,7 +4373,8 @@ class DialogPersistence {
3886
4373
  if (entry.kind !== 'scheduled')
3887
4374
  return;
3888
4375
  clearTimeout(entry.timer);
3889
- const inFlight = this.writeQ4HStateToDisk(entry.dialogId, entry.state, entry.status);
4376
+ const cancellationToken = this.createRootDialogWriteBackCancellationToken(entry.dialogId, entry.status);
4377
+ const inFlight = this.writeQ4HStateToDisk(entry.dialogId, entry.state, entry.status, cancellationToken);
3890
4378
  captured = {
3891
4379
  dialogId: entry.dialogId,
3892
4380
  status: entry.status,
@@ -3921,6 +4409,10 @@ class DialogPersistence {
3921
4409
  return;
3922
4410
  if (entry.inFlight !== captured.inFlight)
3923
4411
  return;
4412
+ if (isDialogWriteBackCanceledError(error)) {
4413
+ this.q4hWriteBack.delete(key);
4414
+ return;
4415
+ }
3924
4416
  const timer = setTimeout(() => {
3925
4417
  void this.flushQ4HWriteBack(key);
3926
4418
  }, this.Q4H_WRITEBACK_WINDOW_MS);
@@ -3965,18 +4457,26 @@ class DialogPersistence {
3965
4457
  release();
3966
4458
  }
3967
4459
  }
3968
- static async writeQ4HStateToDisk(dialogId, state, status) {
4460
+ static async writeQ4HStateToDisk(dialogId, state, status, cancellationToken) {
4461
+ if (cancellationToken) {
4462
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, 'writeQ4HStateToDisk:start');
4463
+ }
3969
4464
  const dialogPath = this.getDialogEventsPath(dialogId, status);
3970
4465
  const questionsFilePath = path.join(dialogPath, 'q4h.yaml');
3971
- await fs.promises.mkdir(dialogPath, { recursive: true });
3972
4466
  if (state.kind === 'deleted') {
3973
4467
  await fs.promises.rm(questionsFilePath, { force: true });
3974
4468
  return;
3975
4469
  }
3976
4470
  const yamlContent = yaml.stringify(state.file);
3977
4471
  const tempFile = path.join(dialogPath, `.${path.basename(questionsFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
3978
- await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
3979
- await this.renameWithRetry(tempFile, questionsFilePath, yamlContent);
4472
+ try {
4473
+ await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
4474
+ }
4475
+ catch (error) {
4476
+ await this.rethrowWriteBackPathMissingAsCanceled(error, dialogPath, cancellationToken, 'writeQ4HStateToDisk:write-temp');
4477
+ throw error;
4478
+ }
4479
+ await this.renameWithRetry(tempFile, questionsFilePath, 5, cancellationToken);
3980
4480
  }
3981
4481
  /**
3982
4482
  * Load all Q4H questions from all running dialogs (for global Q4H display)
@@ -4057,8 +4557,8 @@ class DialogPersistence {
4057
4557
  return await this.loadPendingSubdialogsFromDisk(rootDialogId, status);
4058
4558
  }
4059
4559
  catch (error) {
4060
- log_1.log.error(`Failed to load pending subdialogs for dialog ${rootDialogId}:`, error);
4061
- return [];
4560
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(rootDialogId, status, 'loadPendingSubdialogs', error);
4561
+ throw new Error('unreachable after loadPendingSubdialogs persistence rethrow');
4062
4562
  }
4063
4563
  }
4064
4564
  static isPendingSubdialogRecord(value) {
@@ -4118,11 +4618,24 @@ class DialogPersistence {
4118
4618
  const dialogPath = this.getDialogResponsesPath(rootDialogId, status);
4119
4619
  const filePath = path.join(dialogPath, 'pending-subdialogs.json');
4120
4620
  try {
4121
- const content = await fs.promises.readFile(filePath, 'utf-8');
4122
- const parsed = JSON.parse(content);
4123
- if (!Array.isArray(parsed))
4124
- return [];
4125
- return parsed.filter(this.isPendingSubdialogRecord);
4621
+ const content = await readPersistenceTextFile({
4622
+ filePath,
4623
+ source: 'pending_subdialogs',
4624
+ format: 'json',
4625
+ });
4626
+ const parsed = parsePersistenceJson({
4627
+ content,
4628
+ filePath,
4629
+ source: 'pending_subdialogs',
4630
+ });
4631
+ if (!Array.isArray(parsed) || !parsed.every((item) => this.isPendingSubdialogRecord(item))) {
4632
+ throw buildInvalidPersistenceFileError({
4633
+ source: 'pending_subdialogs',
4634
+ format: 'json',
4635
+ filePath,
4636
+ });
4637
+ }
4638
+ return parsed;
4126
4639
  }
4127
4640
  catch (error) {
4128
4641
  if (getErrorCode(error) === 'ENOENT')
@@ -4227,7 +4740,8 @@ class DialogPersistence {
4227
4740
  if (entry.kind !== 'scheduled')
4228
4741
  return;
4229
4742
  clearTimeout(entry.timer);
4230
- const inFlight = this.writePendingSubdialogsToDisk(entry.dialogId, entry.state, entry.status);
4743
+ const cancellationToken = this.createRootDialogWriteBackCancellationToken(entry.dialogId, entry.status);
4744
+ const inFlight = this.writePendingSubdialogsToDisk(entry.dialogId, entry.state, entry.status, cancellationToken);
4231
4745
  captured = {
4232
4746
  dialogId: entry.dialogId,
4233
4747
  status: entry.status,
@@ -4252,7 +4766,7 @@ class DialogPersistence {
4252
4766
  try {
4253
4767
  await captured.inFlight;
4254
4768
  }
4255
- catch {
4769
+ catch (error) {
4256
4770
  const release = await mutex.acquire();
4257
4771
  try {
4258
4772
  const entry = this.pendingSubdialogsWriteBack.get(key);
@@ -4262,6 +4776,10 @@ class DialogPersistence {
4262
4776
  return;
4263
4777
  if (entry.inFlight !== captured.inFlight)
4264
4778
  return;
4779
+ if (isDialogWriteBackCanceledError(error)) {
4780
+ this.pendingSubdialogsWriteBack.delete(key);
4781
+ return;
4782
+ }
4265
4783
  const timer = setTimeout(() => {
4266
4784
  void this.flushPendingSubdialogsWriteBack(key);
4267
4785
  }, this.PENDING_SUBDIALOGS_WRITEBACK_WINDOW_MS);
@@ -4306,9 +4824,11 @@ class DialogPersistence {
4306
4824
  release();
4307
4825
  }
4308
4826
  }
4309
- static async writePendingSubdialogsToDisk(rootDialogId, state, status) {
4827
+ static async writePendingSubdialogsToDisk(rootDialogId, state, status, cancellationToken) {
4828
+ if (cancellationToken) {
4829
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, 'writePendingSubdialogsToDisk:start');
4830
+ }
4310
4831
  const dialogPath = this.getDialogResponsesPath(rootDialogId, status);
4311
- await fs.promises.mkdir(dialogPath, { recursive: true });
4312
4832
  const filePath = path.join(dialogPath, 'pending-subdialogs.json');
4313
4833
  if (state.kind === 'deleted') {
4314
4834
  await fs.promises.rm(filePath, { force: true });
@@ -4316,8 +4836,14 @@ class DialogPersistence {
4316
4836
  }
4317
4837
  const jsonContent = JSON.stringify(state.records, null, 2);
4318
4838
  const tempFile = path.join(dialogPath, `.${path.basename(filePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4319
- await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
4320
- await this.renameWithRetry(tempFile, filePath, jsonContent);
4839
+ try {
4840
+ await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
4841
+ }
4842
+ catch (error) {
4843
+ await this.rethrowWriteBackPathMissingAsCanceled(error, dialogPath, cancellationToken, 'writePendingSubdialogsToDisk:write-temp');
4844
+ throw error;
4845
+ }
4846
+ await this.renameWithRetry(tempFile, filePath, 5, cancellationToken);
4321
4847
  }
4322
4848
  /**
4323
4849
  * Get the path for storing subdialog responses (supports both root and subdialog parents).
@@ -4343,13 +4869,12 @@ class DialogPersistence {
4343
4869
  static async saveSubdialogResponses(rootDialogId, responses, rootAnchor, status = 'running') {
4344
4870
  try {
4345
4871
  const dialogPath = this.getDialogResponsesPath(rootDialogId, status);
4346
- await fs.promises.mkdir(dialogPath, { recursive: true });
4347
4872
  const filePath = path.join(dialogPath, 'subdialog-responses.json');
4348
4873
  // Atomic write operation
4349
4874
  const jsonContent = JSON.stringify(responses, null, 2);
4350
4875
  const tempFile = path.join(dialogPath, `.${path.basename(filePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4351
4876
  await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
4352
- await this.renameWithRetry(tempFile, filePath, jsonContent);
4877
+ await this.renameWithRetry(tempFile, filePath);
4353
4878
  if (rootAnchor) {
4354
4879
  await this.appendSubdialogResponsesReconciledRecord(rootDialogId, responses, rootAnchorWriteTarget(rootAnchor), status);
4355
4880
  }
@@ -4371,9 +4896,24 @@ class DialogPersistence {
4371
4896
  const results = [];
4372
4897
  const tryReadArray = async (p) => {
4373
4898
  try {
4374
- const content = await fs.promises.readFile(p, 'utf-8');
4375
- const parsed = JSON.parse(content);
4376
- return Array.isArray(parsed) ? parsed : [];
4899
+ const content = await readPersistenceTextFile({
4900
+ filePath: p,
4901
+ source: 'subdialog_responses',
4902
+ format: 'json',
4903
+ });
4904
+ const parsed = parsePersistenceJson({
4905
+ content,
4906
+ filePath: p,
4907
+ source: 'subdialog_responses',
4908
+ });
4909
+ if (!Array.isArray(parsed)) {
4910
+ throw buildInvalidPersistenceFileError({
4911
+ source: 'subdialog_responses',
4912
+ format: 'json',
4913
+ filePath: p,
4914
+ });
4915
+ }
4916
+ return parsed;
4377
4917
  }
4378
4918
  catch (error) {
4379
4919
  if (getErrorCode(error) === 'ENOENT') {
@@ -4385,9 +4925,14 @@ class DialogPersistence {
4385
4925
  const primary = await tryReadArray(filePath);
4386
4926
  const inflight = await tryReadArray(inflightPath);
4387
4927
  for (const item of [...primary, ...inflight]) {
4388
- if (isSubdialogResponseRecord(item)) {
4389
- results.push(item);
4928
+ if (!isSubdialogResponseRecord(item)) {
4929
+ throw buildInvalidPersistenceFileError({
4930
+ source: 'subdialog_responses',
4931
+ format: 'json',
4932
+ filePath,
4933
+ });
4390
4934
  }
4935
+ results.push(item);
4391
4936
  }
4392
4937
  // Deduplicate by responseId (primary wins over inflight order is irrelevant)
4393
4938
  const byId = new Map();
@@ -4404,26 +4949,39 @@ class DialogPersistence {
4404
4949
  }
4405
4950
  }
4406
4951
  catch (error) {
4407
- log_1.log.error(`Failed to load subdialog responses for dialog ${rootDialogId}:`, error);
4408
- return [];
4952
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(rootDialogId, status, 'loadSubdialogResponses', error);
4953
+ throw new Error('unreachable after loadSubdialogResponses persistence rethrow');
4409
4954
  }
4410
4955
  }
4411
4956
  static async loadSubdialogResponsesQueue(dialogId, status = 'running') {
4412
4957
  try {
4413
4958
  const dialogPath = this.getDialogResponsesPath(dialogId, status);
4414
4959
  const filePath = path.join(dialogPath, 'subdialog-responses.json');
4415
- const content = await fs.promises.readFile(filePath, 'utf-8');
4416
- const parsed = JSON.parse(content);
4417
- if (!Array.isArray(parsed)) {
4418
- return [];
4960
+ const content = await readPersistenceTextFile({
4961
+ filePath,
4962
+ source: 'subdialog_responses',
4963
+ format: 'json',
4964
+ });
4965
+ const parsed = parsePersistenceJson({
4966
+ content,
4967
+ filePath,
4968
+ source: 'subdialog_responses',
4969
+ });
4970
+ if (!Array.isArray(parsed) || !parsed.every((item) => isSubdialogResponseRecord(item))) {
4971
+ throw buildInvalidPersistenceFileError({
4972
+ source: 'subdialog_responses',
4973
+ format: 'json',
4974
+ filePath,
4975
+ });
4419
4976
  }
4420
- return parsed.filter(isSubdialogResponseRecord);
4977
+ return parsed;
4421
4978
  }
4422
4979
  catch (error) {
4423
4980
  if (getErrorCode(error) === 'ENOENT') {
4424
4981
  return [];
4425
4982
  }
4426
- throw error;
4983
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadSubdialogResponsesQueue', error);
4984
+ throw new Error('unreachable after loadSubdialogResponsesQueue persistence rethrow');
4427
4985
  }
4428
4986
  }
4429
4987
  static async appendSubdialogResponse(dialogId, response, rootAnchor, status = 'running') {
@@ -4432,40 +4990,56 @@ class DialogPersistence {
4432
4990
  await this.saveSubdialogResponses(dialogId, existing, rootAnchor, status);
4433
4991
  }
4434
4992
  static async takeSubdialogResponses(dialogId, status = 'running') {
4435
- const dialogPath = this.getDialogResponsesPath(dialogId, status);
4436
- await fs.promises.mkdir(dialogPath, { recursive: true });
4437
- const filePath = path.join(dialogPath, 'subdialog-responses.json');
4438
- const inflightPath = path.join(dialogPath, 'subdialog-responses.processing.json');
4439
- // If a previous processing file exists, merge it back so it will be re-processed.
4440
- try {
4441
- await fs.promises.access(inflightPath);
4442
- await this.rollbackTakenSubdialogResponses(dialogId, status);
4443
- }
4444
- catch {
4445
- // no-op
4446
- }
4447
4993
  try {
4448
- await fs.promises.rename(filePath, inflightPath);
4449
- }
4450
- catch (error) {
4451
- if (getErrorCode(error) === 'ENOENT') {
4452
- return [];
4994
+ const dialogPath = this.getDialogResponsesPath(dialogId, status);
4995
+ const filePath = path.join(dialogPath, 'subdialog-responses.json');
4996
+ const inflightPath = path.join(dialogPath, 'subdialog-responses.processing.json');
4997
+ // If a previous processing file exists, merge it back so it will be re-processed.
4998
+ try {
4999
+ await fs.promises.access(inflightPath);
4453
5000
  }
4454
- throw error;
4455
- }
4456
- try {
4457
- const raw = await fs.promises.readFile(inflightPath, 'utf-8');
4458
- const parsed = JSON.parse(raw);
4459
- if (!Array.isArray(parsed)) {
4460
- return [];
5001
+ catch (error) {
5002
+ if (getErrorCode(error) !== 'ENOENT') {
5003
+ throw error;
5004
+ }
4461
5005
  }
4462
- return parsed.filter(isSubdialogResponseRecord);
5006
+ if (await this.pathExists(inflightPath)) {
5007
+ await this.rollbackTakenSubdialogResponses(dialogId, status);
5008
+ }
5009
+ try {
5010
+ await fs.promises.rename(filePath, inflightPath);
5011
+ }
5012
+ catch (error) {
5013
+ if (getErrorCode(error) === 'ENOENT') {
5014
+ return [];
5015
+ }
5016
+ throw error;
5017
+ }
5018
+ const raw = await readPersistenceTextFile({
5019
+ filePath: inflightPath,
5020
+ source: 'subdialog_responses',
5021
+ format: 'json',
5022
+ });
5023
+ const parsed = parsePersistenceJson({
5024
+ content: raw,
5025
+ filePath: inflightPath,
5026
+ source: 'subdialog_responses',
5027
+ });
5028
+ if (!Array.isArray(parsed) || !parsed.every((item) => isSubdialogResponseRecord(item))) {
5029
+ throw buildInvalidPersistenceFileError({
5030
+ source: 'subdialog_responses',
5031
+ format: 'json',
5032
+ filePath: inflightPath,
5033
+ });
5034
+ }
5035
+ return parsed;
4463
5036
  }
4464
5037
  catch (error) {
4465
5038
  if (getErrorCode(error) === 'ENOENT') {
4466
5039
  return [];
4467
5040
  }
4468
- throw error;
5041
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'takeSubdialogResponses', error);
5042
+ throw new Error('unreachable after takeSubdialogResponses persistence rethrow');
4469
5043
  }
4470
5044
  }
4471
5045
  static async commitTakenSubdialogResponses(dialogId, status = 'running') {
@@ -4474,44 +5048,79 @@ class DialogPersistence {
4474
5048
  await fs.promises.rm(inflightPath, { force: true });
4475
5049
  }
4476
5050
  static async rollbackTakenSubdialogResponses(dialogId, status = 'running') {
4477
- const dialogPath = this.getDialogResponsesPath(dialogId, status);
4478
- await fs.promises.mkdir(dialogPath, { recursive: true });
4479
- const filePath = path.join(dialogPath, 'subdialog-responses.json');
4480
- const inflightPath = path.join(dialogPath, 'subdialog-responses.processing.json');
4481
- let inflight = [];
4482
5051
  try {
4483
- const raw = await fs.promises.readFile(inflightPath, 'utf-8');
4484
- const parsed = JSON.parse(raw);
4485
- inflight = Array.isArray(parsed) ? parsed : [];
4486
- }
4487
- catch (error) {
4488
- if (getErrorCode(error) === 'ENOENT') {
4489
- return;
5052
+ const dialogPath = this.getDialogResponsesPath(dialogId, status);
5053
+ const filePath = path.join(dialogPath, 'subdialog-responses.json');
5054
+ const inflightPath = path.join(dialogPath, 'subdialog-responses.processing.json');
5055
+ let inflight = [];
5056
+ try {
5057
+ const raw = await readPersistenceTextFile({
5058
+ filePath: inflightPath,
5059
+ source: 'subdialog_responses',
5060
+ format: 'json',
5061
+ });
5062
+ const parsed = parsePersistenceJson({
5063
+ content: raw,
5064
+ filePath: inflightPath,
5065
+ source: 'subdialog_responses',
5066
+ });
5067
+ if (!Array.isArray(parsed) || !parsed.every((item) => isSubdialogResponseRecord(item))) {
5068
+ throw buildInvalidPersistenceFileError({
5069
+ source: 'subdialog_responses',
5070
+ format: 'json',
5071
+ filePath: inflightPath,
5072
+ });
5073
+ }
5074
+ inflight = parsed;
4490
5075
  }
4491
- throw error;
4492
- }
4493
- let primary = [];
4494
- try {
4495
- const raw = await fs.promises.readFile(filePath, 'utf-8');
4496
- const parsed = JSON.parse(raw);
4497
- primary = Array.isArray(parsed) ? parsed : [];
4498
- }
4499
- catch (error) {
4500
- if (getErrorCode(error) !== 'ENOENT') {
5076
+ catch (error) {
5077
+ if (getErrorCode(error) === 'ENOENT') {
5078
+ return;
5079
+ }
4501
5080
  throw error;
4502
5081
  }
5082
+ let primary = [];
5083
+ try {
5084
+ const raw = await readPersistenceTextFile({
5085
+ filePath,
5086
+ source: 'subdialog_responses',
5087
+ format: 'json',
5088
+ });
5089
+ const parsed = parsePersistenceJson({
5090
+ content: raw,
5091
+ filePath,
5092
+ source: 'subdialog_responses',
5093
+ });
5094
+ if (!Array.isArray(parsed) || !parsed.every((item) => isSubdialogResponseRecord(item))) {
5095
+ throw buildInvalidPersistenceFileError({
5096
+ source: 'subdialog_responses',
5097
+ format: 'json',
5098
+ filePath,
5099
+ });
5100
+ }
5101
+ primary = parsed;
5102
+ }
5103
+ catch (error) {
5104
+ if (getErrorCode(error) !== 'ENOENT') {
5105
+ throw error;
5106
+ }
5107
+ }
5108
+ const merged = [...inflight, ...primary];
5109
+ const byId = new Map();
5110
+ for (const r of merged) {
5111
+ byId.set(r.responseId, r);
5112
+ }
5113
+ const result = Array.from(byId.values());
5114
+ const jsonContent = JSON.stringify(result, null, 2);
5115
+ const tempFile = path.join(dialogPath, `.${path.basename(filePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
5116
+ await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
5117
+ await this.renameWithRetry(tempFile, filePath);
5118
+ await fs.promises.rm(inflightPath, { force: true });
4503
5119
  }
4504
- const merged = [...inflight, ...primary].filter(isSubdialogResponseRecord);
4505
- const byId = new Map();
4506
- for (const r of merged) {
4507
- byId.set(r.responseId, r);
5120
+ catch (error) {
5121
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'rollbackTakenSubdialogResponses', error);
5122
+ throw new Error('unreachable after rollbackTakenSubdialogResponses persistence rethrow');
4508
5123
  }
4509
- const result = Array.from(byId.values());
4510
- const jsonContent = JSON.stringify(result, null, 2);
4511
- const tempFile = path.join(dialogPath, `.${path.basename(filePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4512
- await fs.promises.writeFile(tempFile, jsonContent, 'utf-8');
4513
- await this.renameWithRetry(tempFile, filePath, jsonContent);
4514
- await fs.promises.rm(inflightPath, { force: true });
4515
5124
  }
4516
5125
  /**
4517
5126
  * Save root dialog metadata (write-once pattern)
@@ -4526,7 +5135,7 @@ class DialogPersistence {
4526
5135
  const yamlContent = yaml.stringify(metadata);
4527
5136
  const tempFile = path.join(dialogPath, `.${path.basename(metadataFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4528
5137
  await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
4529
- await this.renameWithRetry(tempFile, metadataFilePath, yamlContent);
5138
+ await this.renameWithRetry(tempFile, metadataFilePath);
4530
5139
  }
4531
5140
  catch (error) {
4532
5141
  log_1.log.error(`Failed to save dialog YAML for dialog ${dialogId}:`, error);
@@ -4563,11 +5172,12 @@ class DialogPersistence {
4563
5172
  try {
4564
5173
  const subPath = this.getSubdialogPath(dialogId, status);
4565
5174
  const metadataFilePath = path.join(subPath, 'dialog.yaml');
4566
- await fs.promises.mkdir(subPath, { recursive: true });
5175
+ // Creation sites must ensure the directory exists first. Update paths intentionally do not
5176
+ // recreate missing directories so quarantine can use "directory disappeared" as cancellation.
4567
5177
  const yamlContent = yaml.stringify(metadata);
4568
5178
  const tempFile = path.join(subPath, `.${path.basename(metadataFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4569
5179
  await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
4570
- await this.renameWithRetry(tempFile, metadataFilePath, yamlContent);
5180
+ await this.renameWithRetry(tempFile, metadataFilePath);
4571
5181
  }
4572
5182
  catch (error) {
4573
5183
  log_1.log.error(`Failed to save subdialog YAML for ${dialogId.selfId} under root dialog ${dialogId.rootId}:`, error);
@@ -4576,7 +5186,6 @@ class DialogPersistence {
4576
5186
  }
4577
5187
  /**
4578
5188
  * Update assignmentFromSup for an existing subdialog.
4579
- * Persists both subdialog metadata locations for consistency.
4580
5189
  */
4581
5190
  static async updateSubdialogAssignment(dialogId, assignment, status = 'running') {
4582
5191
  if (dialogId.rootId === dialogId.selfId) {
@@ -4591,7 +5200,6 @@ class DialogPersistence {
4591
5200
  assignmentFromSup: assignment,
4592
5201
  };
4593
5202
  await this.saveSubdialogMetadata(dialogId, next, status);
4594
- await this.saveDialogMetadata(dialogId, next, status);
4595
5203
  }
4596
5204
  /**
4597
5205
  * Load root dialog metadata
@@ -4601,21 +5209,30 @@ class DialogPersistence {
4601
5209
  const dialogPath = this.getRootDialogPath(dialogId, status);
4602
5210
  const metadataFilePath = path.join(dialogPath, 'dialog.yaml');
4603
5211
  try {
4604
- const content = await fs.promises.readFile(metadataFilePath, 'utf-8');
4605
- let parsed;
4606
- try {
4607
- parsed = yaml.parse(content);
4608
- }
4609
- catch {
4610
- throw new Error(`Invalid dialog metadata in ${metadataFilePath}`);
4611
- }
5212
+ const content = await readPersistenceTextFile({
5213
+ filePath: metadataFilePath,
5214
+ source: 'dialog_metadata',
5215
+ format: 'yaml',
5216
+ });
5217
+ const parsed = parsePersistenceYaml({
5218
+ content,
5219
+ filePath: metadataFilePath,
5220
+ source: 'dialog_metadata',
5221
+ });
4612
5222
  if (!isDialogMetadataFile(parsed)) {
4613
- throw new Error(`Invalid dialog metadata in ${metadataFilePath}`);
5223
+ throw buildInvalidPersistenceFileError({
5224
+ source: 'dialog_metadata',
5225
+ format: 'yaml',
5226
+ filePath: metadataFilePath,
5227
+ });
4614
5228
  }
4615
5229
  // Validate that the ID in the file matches the expected dialogId
4616
5230
  if (parsed.id !== dialogId.selfId) {
4617
- log_1.log.warn(`Dialog ID mismatch in ${metadataFilePath}: expected ${dialogId.selfId}, got ${parsed.id}`);
4618
- return null;
5231
+ throw buildInvalidPersistenceFileError({
5232
+ source: 'dialog_metadata',
5233
+ format: 'yaml',
5234
+ filePath: metadataFilePath,
5235
+ });
4619
5236
  }
4620
5237
  return parsed;
4621
5238
  }
@@ -4623,16 +5240,13 @@ class DialogPersistence {
4623
5240
  if (getErrorCode(error) === 'ENOENT') {
4624
5241
  return null;
4625
5242
  }
4626
- if (this.isMalformedDialogLoadError(error)) {
4627
- await this.quarantineMalformedRootDialog(dialogId, status, 'loadRootDialogMetadata', error);
4628
- return null;
4629
- }
4630
- throw error;
5243
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadRootDialogMetadata', error);
5244
+ throw new Error('unreachable after loadRootDialogMetadata persistence rethrow');
4631
5245
  }
4632
5246
  }
4633
5247
  catch (error) {
4634
5248
  log_1.log.error(`Failed to load dialog YAML for dialog ${dialogId.selfId}:`, error);
4635
- return null;
5249
+ throw error;
4636
5250
  }
4637
5251
  }
4638
5252
  /**
@@ -4648,10 +5262,22 @@ class DialogPersistence {
4648
5262
  const subdialogPath = this.getSubdialogPath(dialogId, status);
4649
5263
  const metadataFilePath = path.join(subdialogPath, 'dialog.yaml');
4650
5264
  try {
4651
- const content = await fs.promises.readFile(metadataFilePath, 'utf-8');
4652
- const parsed = yaml.parse(content);
5265
+ const content = await readPersistenceTextFile({
5266
+ filePath: metadataFilePath,
5267
+ source: 'dialog_metadata',
5268
+ format: 'yaml',
5269
+ });
5270
+ const parsed = parsePersistenceYaml({
5271
+ content,
5272
+ filePath: metadataFilePath,
5273
+ source: 'dialog_metadata',
5274
+ });
4653
5275
  if (!isDialogMetadataFile(parsed)) {
4654
- throw new Error(`Invalid dialog metadata in ${metadataFilePath}`);
5276
+ throw buildInvalidPersistenceFileError({
5277
+ source: 'dialog_metadata',
5278
+ format: 'yaml',
5279
+ filePath: metadataFilePath,
5280
+ });
4655
5281
  }
4656
5282
  return parsed;
4657
5283
  }
@@ -4659,28 +5285,39 @@ class DialogPersistence {
4659
5285
  if (getErrorCode(error) === 'ENOENT') {
4660
5286
  return null;
4661
5287
  }
4662
- throw error;
5288
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadDialogMetadata', error);
5289
+ throw new Error('unreachable after loadDialogMetadata persistence rethrow');
4663
5290
  }
4664
5291
  }
4665
5292
  /**
4666
5293
  * Save latest.yaml with current course and lastModified info
4667
5294
  */
4668
- static async writeDialogLatestToDisk(dialogId, latest, status = 'running') {
5295
+ static async writeDialogLatestToDisk(dialogId, latest, status = 'running', cancellationToken) {
4669
5296
  try {
5297
+ if (cancellationToken) {
5298
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, 'writeDialogLatestToDisk:start');
5299
+ }
4670
5300
  const dialogPath = this.getDialogEventsPath(dialogId, status);
4671
5301
  const latestFilePath = path.join(dialogPath, 'latest.yaml');
4672
- // Ensure directory exists before writing (handles race conditions and new dialogs)
4673
- await fs.promises.mkdir(dialogPath, { recursive: true });
4674
5302
  // NOTE: Use a unique temp file name to avoid collisions when multiple updates
4675
5303
  // happen concurrently for the same dialog (e.g., parallel tool responses).
4676
5304
  const tempFile = path.join(dialogPath, `.${path.basename(latestFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
4677
5305
  const yamlContent = yaml.stringify(latest);
4678
- await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
5306
+ try {
5307
+ await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
5308
+ }
5309
+ catch (error) {
5310
+ await this.rethrowWriteBackPathMissingAsCanceled(error, dialogPath, cancellationToken, 'writeDialogLatestToDisk:write-temp');
5311
+ throw error;
5312
+ }
4679
5313
  // Rename with retry logic for filesystem sync issues
4680
- await this.renameWithRetry(tempFile, latestFilePath, yamlContent);
5314
+ await this.renameWithRetry(tempFile, latestFilePath, 5, cancellationToken);
4681
5315
  // todo: publish CourseEvent here or where more suitable?
4682
5316
  }
4683
5317
  catch (error) {
5318
+ if (isDialogWriteBackCanceledError(error)) {
5319
+ throw error;
5320
+ }
4684
5321
  log_1.log.error(`Failed to save latest.yaml for dialog ${dialogId.selfId}:`, error);
4685
5322
  throw error;
4686
5323
  }
@@ -4688,26 +5325,21 @@ class DialogPersistence {
4688
5325
  /**
4689
5326
  * Rename with retry logic to handle filesystem sync issues
4690
5327
  */
4691
- static async renameWithRetry(source, destination, yamlContent, maxRetries = 5) {
5328
+ static async renameWithRetry(source, destination, maxRetries = 5, cancellationToken) {
4692
5329
  let lastError;
4693
- const destinationDir = path.dirname(destination);
4694
5330
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
4695
5331
  try {
4696
- // Ensure directory exists (handles race conditions)
4697
- await fs.promises.mkdir(destinationDir, { recursive: true });
4698
- // Check if source file exists, re-create if missing
4699
- try {
4700
- await fs.promises.access(source);
4701
- }
4702
- catch {
4703
- // Source file missing - re-create it
4704
- await fs.promises.writeFile(source, yamlContent, 'utf-8');
5332
+ if (cancellationToken) {
5333
+ this.assertRootDialogWriteBackNotCanceled(cancellationToken, `renameWithRetry:${path.basename(destination)}:before-rename`);
4705
5334
  }
4706
5335
  await fs.promises.rename(source, destination);
4707
5336
  return;
4708
5337
  }
4709
5338
  catch (error) {
4710
- lastError = error;
5339
+ if (isDialogWriteBackCanceledError(error)) {
5340
+ throw error;
5341
+ }
5342
+ lastError = error instanceof Error ? error : new Error(String(error));
4711
5343
  if (getErrorCode(error) !== 'ENOENT' || attempt === maxRetries) {
4712
5344
  throw error;
4713
5345
  }
@@ -4729,18 +5361,19 @@ class DialogPersistence {
4729
5361
  }
4730
5362
  const dialogPath = this.getDialogEventsPath(dialogId, status);
4731
5363
  const latestFilePath = path.join(dialogPath, 'latest.yaml');
4732
- const content = await fs.promises.readFile(latestFilePath, 'utf-8');
5364
+ const content = await readPersistenceTextFile({
5365
+ filePath: latestFilePath,
5366
+ source: 'dialog_latest',
5367
+ format: 'yaml',
5368
+ });
4733
5369
  return this.parseDialogLatestYaml(content, latestFilePath);
4734
5370
  }
4735
5371
  catch (error) {
4736
5372
  if (getErrorCode(error) === 'ENOENT') {
4737
5373
  return null;
4738
5374
  }
4739
- if (dialogId.rootId === dialogId.selfId && this.isMalformedDialogLoadError(error)) {
4740
- await this.quarantineMalformedRootDialog(dialogId, status, 'loadDialogLatest', error);
4741
- return null;
4742
- }
4743
- throw error;
5375
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadDialogLatest', error);
5376
+ throw new Error('unreachable after loadDialogLatest persistence rethrow');
4744
5377
  }
4745
5378
  }
4746
5379
  /**
@@ -4750,11 +5383,13 @@ class DialogPersistence {
4750
5383
  * staged state (or disk fallback). This avoids read-modify-write races in user code
4751
5384
  * and allows lock-free-ish coalescing to disk via the write-back buffer.
4752
5385
  */
4753
- static async mutateDialogLatest(dialogId, mutator, status = 'running') {
5386
+ static async mutateDialogLatest(dialogId, mutator, status = 'running', cancellationToken) {
4754
5387
  const key = this.getLatestWriteBackKey(dialogId, status);
4755
5388
  const mutex = this.getLatestWriteBackMutex(key);
5389
+ const effectiveCancellationToken = cancellationToken ?? this.createRootDialogWriteBackCancellationToken(dialogId, status);
4756
5390
  const release = await mutex.acquire();
4757
5391
  try {
5392
+ this.assertRootDialogWriteBackNotCanceled(effectiveCancellationToken, 'mutateDialogLatest');
4758
5393
  const staged = this.latestWriteBack.get(key);
4759
5394
  const existing = (staged
4760
5395
  ? staged.latest
@@ -4785,6 +5420,7 @@ class DialogPersistence {
4785
5420
  const _exhaustive = mutation;
4786
5421
  throw new Error(`Unhandled dialog latest mutation: ${String(_exhaustive)}`);
4787
5422
  }
5423
+ this.assertRootDialogWriteBackNotCanceled(effectiveCancellationToken, 'mutateDialogLatest:before-stage');
4788
5424
  const pending = this.latestWriteBack.get(key);
4789
5425
  if (!pending) {
4790
5426
  const timer = setTimeout(() => {
@@ -4814,18 +5450,19 @@ class DialogPersistence {
4814
5450
  try {
4815
5451
  const dialogPath = this.getDialogEventsPath(dialogId, status);
4816
5452
  const latestFilePath = path.join(dialogPath, 'latest.yaml');
4817
- const content = await fs.promises.readFile(latestFilePath, 'utf-8');
5453
+ const content = await readPersistenceTextFile({
5454
+ filePath: latestFilePath,
5455
+ source: 'dialog_latest',
5456
+ format: 'yaml',
5457
+ });
4818
5458
  return this.parseDialogLatestYaml(content, latestFilePath);
4819
5459
  }
4820
5460
  catch (error) {
4821
5461
  if (getErrorCode(error) === 'ENOENT') {
4822
5462
  return null;
4823
5463
  }
4824
- if (dialogId.rootId === dialogId.selfId && this.isMalformedDialogLoadError(error)) {
4825
- await this.quarantineMalformedRootDialog(dialogId, status, 'loadDialogLatestFromDisk', error);
4826
- return null;
4827
- }
4828
- throw error;
5464
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(dialogId, status, 'loadDialogLatestFromDisk', error);
5465
+ throw new Error('unreachable after loadDialogLatestFromDisk persistence rethrow');
4829
5466
  }
4830
5467
  }
4831
5468
  static async flushLatestWriteBack(key) {
@@ -4843,7 +5480,8 @@ class DialogPersistence {
4843
5480
  return;
4844
5481
  clearTimeout(entry.timer);
4845
5482
  const latestToWrite = entry.latest;
4846
- const inFlight = this.writeDialogLatestToDisk(entry.dialogId, latestToWrite, entry.status);
5483
+ const cancellationToken = this.createRootDialogWriteBackCancellationToken(entry.dialogId, entry.status);
5484
+ const inFlight = this.writeDialogLatestToDisk(entry.dialogId, latestToWrite, entry.status, cancellationToken);
4847
5485
  captured = {
4848
5486
  dialogId: entry.dialogId,
4849
5487
  status: entry.status,
@@ -4878,6 +5516,10 @@ class DialogPersistence {
4878
5516
  return;
4879
5517
  if (entry.inFlight !== captured.inFlight)
4880
5518
  return;
5519
+ if (isDialogWriteBackCanceledError(error)) {
5520
+ this.latestWriteBack.delete(key);
5521
+ return;
5522
+ }
4881
5523
  const timer = setTimeout(() => {
4882
5524
  void this.flushLatestWriteBack(key);
4883
5525
  }, this.LATEST_WRITEBACK_WINDOW_MS);
@@ -4980,18 +5622,8 @@ class DialogPersistence {
4980
5622
  */
4981
5623
  static async countAllSubdialogsUnderRoot(rootDialogId, status = 'running') {
4982
5624
  try {
4983
- const rootPath = this.getRootDialogPath(rootDialogId, status);
4984
- const subdialogsPath = path.join(rootPath, this.SUBDIALOGS_DIR);
4985
- try {
4986
- const entries = await fs.promises.readdir(subdialogsPath, { withFileTypes: true });
4987
- return entries.filter((entry) => entry.isDirectory()).length;
4988
- }
4989
- catch (error) {
4990
- if (getErrorCode(error) === 'ENOENT') {
4991
- return 0;
4992
- }
4993
- throw error;
4994
- }
5625
+ const subdialogIds = await this.listSubdialogIdsUnderRoot(rootDialogId, status);
5626
+ return subdialogIds.length;
4995
5627
  }
4996
5628
  catch (error) {
4997
5629
  log_1.log.error(`Failed to count all subdialogs under root ${rootDialogId.selfId}:`, error);
@@ -5010,18 +5642,7 @@ class DialogPersistence {
5010
5642
  return null;
5011
5643
  }
5012
5644
  // Recursively restore subdialogs
5013
- const rootPath = this.getRootDialogPath(rootDialogId, status);
5014
- const subdialogsPath = path.join(rootPath, this.SUBDIALOGS_DIR);
5015
- let subdialogIds = [];
5016
- try {
5017
- const entries = await fs.promises.readdir(subdialogsPath, { withFileTypes: true });
5018
- subdialogIds = entries.filter((e) => e.isDirectory()).map((e) => e.name);
5019
- }
5020
- catch (err) {
5021
- if (getErrorCode(err) !== 'ENOENT') {
5022
- throw err;
5023
- }
5024
- }
5645
+ const subdialogIds = await this.listSubdialogIdsUnderRoot(rootDialogId, status);
5025
5646
  for (const subdialogId of subdialogIds) {
5026
5647
  await this.restoreDialogTree(new dialog_1.DialogID(subdialogId, rootDialogId.rootId), status);
5027
5648
  }
@@ -5278,27 +5899,19 @@ class DialogPersistence {
5278
5899
  */
5279
5900
  static async moveDialogStatus(dialogId, fromStatus, toStatus) {
5280
5901
  try {
5281
- const fromPath = path.join(this.getDialogsRootDir(), fromStatus === 'running'
5282
- ? this.RUN_DIR
5283
- : fromStatus === 'completed'
5284
- ? this.DONE_DIR
5285
- : this.ARCHIVE_DIR, dialogId.selfId);
5286
- const toPath = path.join(this.getDialogsRootDir(), toStatus === 'running'
5287
- ? this.RUN_DIR
5288
- : toStatus === 'completed'
5289
- ? this.DONE_DIR
5290
- : this.ARCHIVE_DIR, dialogId.selfId);
5291
- // Ensure destination directory exists
5292
- await fs.promises.mkdir(toPath, { recursive: true });
5293
- // Move all files and directories
5294
- const entries = await fs.promises.readdir(fromPath, { withFileTypes: true });
5295
- for (const entry of entries) {
5296
- const srcPath = path.join(fromPath, entry.name);
5297
- const destPath = path.join(toPath, entry.name);
5298
- await fs.promises.rename(srcPath, destPath);
5902
+ const fromPath = path.join(this.getDialogsRootDir(), getPersistableStatusDirName(fromStatus, 'DialogPersistence.moveDialogStatus(fromStatus)'), dialogId.selfId);
5903
+ const toPath = path.join(this.getDialogsRootDir(), getPersistableStatusDirName(toStatus, 'DialogPersistence.moveDialogStatus(toStatus)'), dialogId.selfId);
5904
+ await fs.promises.mkdir(path.dirname(toPath), { recursive: true });
5905
+ try {
5906
+ await fs.promises.access(toPath);
5907
+ throw new Error(`Refusing to move dialog ${dialogId.valueOf()} from ${fromStatus} to ${toStatus}: destination already exists at ${toPath}`);
5908
+ }
5909
+ catch (error) {
5910
+ if (getErrorCode(error) !== 'ENOENT') {
5911
+ throw error;
5912
+ }
5299
5913
  }
5300
- // Remove the (now-empty) source directory so the dialog is not detected under both statuses.
5301
- await fs.promises.rm(fromPath, { recursive: true, force: true });
5914
+ await fs.promises.rename(fromPath, toPath);
5302
5915
  }
5303
5916
  catch (error) {
5304
5917
  log_1.log.error(`Failed to move dialog ${dialogId} from ${fromStatus} to ${toStatus}:`, error);
@@ -5318,12 +5931,8 @@ class DialogPersistence {
5318
5931
  return false;
5319
5932
  // Best-effort cleanup: remove the dialog from all status directories to avoid leaving behind
5320
5933
  // orphaned placeholder paths (e.g. `run/<id>/latest.yaml`) after a delete.
5321
- const allStatuses = [
5322
- 'running',
5323
- 'completed',
5324
- 'archived',
5325
- ];
5326
- for (const candidate of allStatuses) {
5934
+ for (const candidate of PERSISTABLE_DIALOG_STATUSES) {
5935
+ this.cancelRootDialogWriteBacks(rootDialogId, candidate);
5327
5936
  const candidatePath = this.getRootDialogPath(rootDialogId, candidate);
5328
5937
  await fs.promises.rm(candidatePath, { recursive: true, force: true });
5329
5938
  }
@@ -5337,7 +5946,6 @@ class DialogPersistence {
5337
5946
  try {
5338
5947
  const dialogPath = this.getDialogResponsesPath(rootDialogId, status);
5339
5948
  const registryFilePath = path.join(dialogPath, 'registry.yaml');
5340
- await fs.promises.mkdir(dialogPath, { recursive: true });
5341
5949
  const serializableEntries = entries.map((entry) => ({
5342
5950
  key: entry.key,
5343
5951
  subdialogId: entry.subdialogId.selfId,
@@ -5347,7 +5955,7 @@ class DialogPersistence {
5347
5955
  const yamlContent = yaml.stringify({ entries: serializableEntries });
5348
5956
  const tempFile = path.join(dialogPath, `.${path.basename(registryFilePath)}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`);
5349
5957
  await fs.promises.writeFile(tempFile, yamlContent, 'utf-8');
5350
- await this.renameWithRetry(tempFile, registryFilePath, yamlContent);
5958
+ await this.renameWithRetry(tempFile, registryFilePath);
5351
5959
  }
5352
5960
  catch (error) {
5353
5961
  log_1.log.error(`Failed to save subdialog registry for dialog ${rootDialogId}:`, error);
@@ -5361,15 +5969,34 @@ class DialogPersistence {
5361
5969
  try {
5362
5970
  const dialogPath = this.getDialogResponsesPath(rootDialogId, status);
5363
5971
  const registryFilePath = path.join(dialogPath, 'registry.yaml');
5364
- const content = await fs.promises.readFile(registryFilePath, 'utf-8');
5365
- const parsed = yaml.parse(content);
5972
+ const content = await readPersistenceTextFile({
5973
+ filePath: registryFilePath,
5974
+ source: 'subdialog_registry',
5975
+ format: 'yaml',
5976
+ });
5977
+ const parsed = parsePersistenceYaml({
5978
+ content,
5979
+ filePath: registryFilePath,
5980
+ source: 'subdialog_registry',
5981
+ });
5366
5982
  if (!isRecord(parsed) || !Array.isArray(parsed.entries)) {
5367
- log_1.log.warn(`Invalid registry.yaml format for dialog ${rootDialogId}`);
5368
- return [];
5983
+ throw buildInvalidPersistenceFileError({
5984
+ source: 'subdialog_registry',
5985
+ format: 'yaml',
5986
+ filePath: registryFilePath,
5987
+ });
5369
5988
  }
5370
5989
  const entries = parsed.entries.map((entry) => {
5371
- if (!isRecord(entry)) {
5372
- throw new Error('Invalid registry entry');
5990
+ if (!isRecord(entry) ||
5991
+ typeof entry.key !== 'string' ||
5992
+ typeof entry.subdialogId !== 'string' ||
5993
+ typeof entry.agentId !== 'string' ||
5994
+ (entry.sessionSlug !== undefined && typeof entry.sessionSlug !== 'string')) {
5995
+ throw buildInvalidPersistenceFileError({
5996
+ source: 'subdialog_registry',
5997
+ format: 'yaml',
5998
+ filePath: registryFilePath,
5999
+ });
5373
6000
  }
5374
6001
  return {
5375
6002
  key: entry.key,
@@ -5384,8 +6011,8 @@ class DialogPersistence {
5384
6011
  if (getErrorCode(error) === 'ENOENT') {
5385
6012
  return [];
5386
6013
  }
5387
- log_1.log.error(`Failed to load subdialog registry for dialog ${rootDialogId}:`, error);
5388
- return [];
6014
+ await this.rethrowAfterQuarantiningDialogPersistenceProblem(rootDialogId, status, 'loadSubdialogRegistry', error);
6015
+ throw new Error('unreachable after loadSubdialogRegistry persistence rethrow');
5389
6016
  }
5390
6017
  }
5391
6018
  }
@@ -5396,6 +6023,7 @@ DialogPersistence.RUN_DIR = 'run';
5396
6023
  DialogPersistence.DONE_DIR = 'done';
5397
6024
  DialogPersistence.ARCHIVE_DIR = 'archive';
5398
6025
  DialogPersistence.SUBDIALOGS_DIR = 'subdialogs';
6026
+ DialogPersistence.quarantinedRootDialogScopes = new Set();
5399
6027
  DialogPersistence.LATEST_WRITEBACK_WINDOW_MS = 300;
5400
6028
  DialogPersistence.Q4H_WRITEBACK_WINDOW_MS = 300;
5401
6029
  DialogPersistence.PENDING_SUBDIALOGS_WRITEBACK_WINDOW_MS = 300;
@@ -5406,3 +6034,4 @@ DialogPersistence.q4hWriteBack = new Map();
5406
6034
  DialogPersistence.pendingSubdialogsWriteBackMutexes = new Map();
5407
6035
  DialogPersistence.pendingSubdialogsWriteBack = new Map();
5408
6036
  DialogPersistence.courseAppendMutexes = new Map();
6037
+ DialogPersistence.rootDialogWriteBackCancelGenerations = new Map();