pulseed 0.4.14 → 0.4.15

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 (165) hide show
  1. package/assets/seedy.png +0 -0
  2. package/dist/adapters/types/mcp.d.ts +6 -6
  3. package/dist/base/config/global-config.d.ts.map +1 -1
  4. package/dist/base/config/global-config.js +2 -2
  5. package/dist/base/config/global-config.js.map +1 -1
  6. package/dist/base/llm/provider-config.d.ts +6 -1
  7. package/dist/base/llm/provider-config.d.ts.map +1 -1
  8. package/dist/base/llm/provider-config.js +148 -94
  9. package/dist/base/llm/provider-config.js.map +1 -1
  10. package/dist/base/utils/json-io.d.ts +6 -1
  11. package/dist/base/utils/json-io.d.ts.map +1 -1
  12. package/dist/base/utils/json-io.js +16 -3
  13. package/dist/base/utils/json-io.js.map +1 -1
  14. package/dist/interface/chat/chat-history.d.ts +6 -1
  15. package/dist/interface/chat/chat-history.d.ts.map +1 -1
  16. package/dist/interface/chat/chat-history.js +16 -2
  17. package/dist/interface/chat/chat-history.js.map +1 -1
  18. package/dist/interface/chat/chat-runner.d.ts +27 -1
  19. package/dist/interface/chat/chat-runner.d.ts.map +1 -1
  20. package/dist/interface/chat/chat-runner.js +412 -5
  21. package/dist/interface/chat/chat-runner.js.map +1 -1
  22. package/dist/interface/chat/grounding.d.ts.map +1 -1
  23. package/dist/interface/chat/grounding.js +2 -1
  24. package/dist/interface/chat/grounding.js.map +1 -1
  25. package/dist/interface/chat/mutation-tool-defs.d.ts +0 -21
  26. package/dist/interface/chat/mutation-tool-defs.d.ts.map +1 -1
  27. package/dist/interface/chat/mutation-tool-defs.js +1 -164
  28. package/dist/interface/chat/mutation-tool-defs.js.map +1 -1
  29. package/dist/interface/cli/commands/daemon.d.ts.map +1 -1
  30. package/dist/interface/cli/commands/daemon.js +4 -14
  31. package/dist/interface/cli/commands/daemon.js.map +1 -1
  32. package/dist/interface/cli/commands/setup/import/apply.d.ts.map +1 -1
  33. package/dist/interface/cli/commands/setup/import/apply.js +4 -0
  34. package/dist/interface/cli/commands/setup/import/apply.js.map +1 -1
  35. package/dist/interface/cli/commands/setup/import/discovery.d.ts.map +1 -1
  36. package/dist/interface/cli/commands/setup/import/discovery.js +10 -2
  37. package/dist/interface/cli/commands/setup/import/discovery.js.map +1 -1
  38. package/dist/interface/cli/commands/setup/import/flow.d.ts.map +1 -1
  39. package/dist/interface/cli/commands/setup/import/flow.js +11 -1
  40. package/dist/interface/cli/commands/setup/import/flow.js.map +1 -1
  41. package/dist/interface/cli/commands/setup/import/types.d.ts +3 -0
  42. package/dist/interface/cli/commands/setup/import/types.d.ts.map +1 -1
  43. package/dist/interface/cli/commands/setup-wizard.d.ts.map +1 -1
  44. package/dist/interface/cli/commands/setup-wizard.js +14 -1
  45. package/dist/interface/cli/commands/setup-wizard.js.map +1 -1
  46. package/dist/interface/cli/setup.d.ts.map +1 -1
  47. package/dist/interface/cli/setup.js +6 -5
  48. package/dist/interface/cli/setup.js.map +1 -1
  49. package/dist/interface/mcp-server/index.d.ts.map +1 -1
  50. package/dist/interface/mcp-server/index.js +2 -1
  51. package/dist/interface/mcp-server/index.js.map +1 -1
  52. package/dist/platform/observation/observation-tools.d.ts.map +1 -1
  53. package/dist/platform/observation/observation-tools.js +4 -1
  54. package/dist/platform/observation/observation-tools.js.map +1 -1
  55. package/dist/platform/soil/config.d.ts.map +1 -1
  56. package/dist/platform/soil/config.js +1 -2
  57. package/dist/platform/soil/config.js.map +1 -1
  58. package/dist/platform/soil/display/index.d.ts +4 -0
  59. package/dist/platform/soil/display/index.d.ts.map +1 -0
  60. package/dist/platform/soil/display/index.js +4 -0
  61. package/dist/platform/soil/display/index.js.map +1 -0
  62. package/dist/platform/soil/display/materialize.d.ts +3 -0
  63. package/dist/platform/soil/display/materialize.d.ts.map +1 -0
  64. package/dist/platform/soil/display/materialize.js +381 -0
  65. package/dist/platform/soil/display/materialize.js.map +1 -0
  66. package/dist/platform/soil/display/registry.d.ts +4 -0
  67. package/dist/platform/soil/display/registry.d.ts.map +1 -0
  68. package/dist/platform/soil/display/registry.js +19 -0
  69. package/dist/platform/soil/display/registry.js.map +1 -0
  70. package/dist/platform/soil/display/types.d.ts +28 -0
  71. package/dist/platform/soil/display/types.d.ts.map +1 -0
  72. package/dist/platform/soil/display/types.js +2 -0
  73. package/dist/platform/soil/display/types.js.map +1 -0
  74. package/dist/platform/soil/index.d.ts +1 -0
  75. package/dist/platform/soil/index.d.ts.map +1 -1
  76. package/dist/platform/soil/index.js +1 -0
  77. package/dist/platform/soil/index.js.map +1 -1
  78. package/dist/platform/soil/open.d.ts.map +1 -1
  79. package/dist/platform/soil/open.js +2 -0
  80. package/dist/platform/soil/open.js.map +1 -1
  81. package/dist/platform/soil/publish/publisher.d.ts.map +1 -1
  82. package/dist/platform/soil/publish/publisher.js +2 -0
  83. package/dist/platform/soil/publish/publisher.js.map +1 -1
  84. package/dist/reflection/evening-catchup.d.ts.map +1 -1
  85. package/dist/reflection/evening-catchup.js +6 -41
  86. package/dist/reflection/evening-catchup.js.map +1 -1
  87. package/dist/reflection/morning-planning.d.ts.map +1 -1
  88. package/dist/reflection/morning-planning.js +6 -46
  89. package/dist/reflection/morning-planning.js.map +1 -1
  90. package/dist/reflection/reflection-utils.d.ts +16 -0
  91. package/dist/reflection/reflection-utils.d.ts.map +1 -0
  92. package/dist/reflection/reflection-utils.js +56 -0
  93. package/dist/reflection/reflection-utils.js.map +1 -0
  94. package/dist/reflection/weekly-review.d.ts.map +1 -1
  95. package/dist/reflection/weekly-review.js +3 -12
  96. package/dist/reflection/weekly-review.js.map +1 -1
  97. package/dist/reporting/report-formatters.d.ts +7 -0
  98. package/dist/reporting/report-formatters.d.ts.map +1 -1
  99. package/dist/reporting/report-formatters.js +22 -33
  100. package/dist/reporting/report-formatters.js.map +1 -1
  101. package/dist/reporting/reporting-engine.d.ts.map +1 -1
  102. package/dist/reporting/reporting-engine.js +34 -49
  103. package/dist/reporting/reporting-engine.js.map +1 -1
  104. package/dist/runtime/builtin-integrations.d.ts +4 -0
  105. package/dist/runtime/builtin-integrations.d.ts.map +1 -0
  106. package/dist/runtime/builtin-integrations.js +45 -0
  107. package/dist/runtime/builtin-integrations.js.map +1 -0
  108. package/dist/runtime/event/server.d.ts +1 -1
  109. package/dist/runtime/event/server.d.ts.map +1 -1
  110. package/dist/runtime/event/server.js +5 -27
  111. package/dist/runtime/event/server.js.map +1 -1
  112. package/dist/runtime/executor/loop-supervisor.d.ts.map +1 -1
  113. package/dist/runtime/executor/loop-supervisor.js +2 -2
  114. package/dist/runtime/executor/loop-supervisor.js.map +1 -1
  115. package/dist/runtime/foreign-plugins/compatibility.d.ts +7 -0
  116. package/dist/runtime/foreign-plugins/compatibility.d.ts.map +1 -0
  117. package/dist/runtime/foreign-plugins/compatibility.js +175 -0
  118. package/dist/runtime/foreign-plugins/compatibility.js.map +1 -0
  119. package/dist/runtime/foreign-plugins/index.d.ts +3 -0
  120. package/dist/runtime/foreign-plugins/index.d.ts.map +1 -0
  121. package/dist/runtime/foreign-plugins/index.js +2 -0
  122. package/dist/runtime/foreign-plugins/index.js.map +1 -0
  123. package/dist/runtime/foreign-plugins/types.d.ts +25 -0
  124. package/dist/runtime/foreign-plugins/types.d.ts.map +1 -0
  125. package/dist/runtime/foreign-plugins/types.js +2 -0
  126. package/dist/runtime/foreign-plugins/types.js.map +1 -0
  127. package/dist/runtime/schedule/engine.d.ts +12 -0
  128. package/dist/runtime/schedule/engine.d.ts.map +1 -1
  129. package/dist/runtime/schedule/engine.js +402 -243
  130. package/dist/runtime/schedule/engine.js.map +1 -1
  131. package/dist/runtime/types/builtin-integration.d.ts +13 -0
  132. package/dist/runtime/types/builtin-integration.d.ts.map +1 -0
  133. package/dist/runtime/types/builtin-integration.js +2 -0
  134. package/dist/runtime/types/builtin-integration.js.map +1 -0
  135. package/dist/tools/executor.js +2 -2
  136. package/dist/tools/executor.js.map +1 -1
  137. package/dist/tools/fs/ReadPulseedFileTool/ReadPulseedFileTool.d.ts.map +1 -1
  138. package/dist/tools/fs/ReadPulseedFileTool/ReadPulseedFileTool.js +21 -5
  139. package/dist/tools/fs/ReadPulseedFileTool/ReadPulseedFileTool.js.map +1 -1
  140. package/dist/tools/fs/WritePulseedFileTool/WritePulseedFileTool.d.ts.map +1 -1
  141. package/dist/tools/fs/WritePulseedFileTool/WritePulseedFileTool.js +61 -6
  142. package/dist/tools/fs/WritePulseedFileTool/WritePulseedFileTool.js.map +1 -1
  143. package/dist/tools/interaction/plan-utils.js +2 -2
  144. package/dist/tools/interaction/plan-utils.js.map +1 -1
  145. package/dist/tools/network/McpStdioTool/McpStdioTool.d.ts +8 -8
  146. package/dist/tools/query/ConfigTool/ConfigTool.d.ts.map +1 -1
  147. package/dist/tools/query/ConfigTool/ConfigTool.js +4 -3
  148. package/dist/tools/query/ConfigTool/ConfigTool.js.map +1 -1
  149. package/dist/tools/query/PluginStateTool/PluginStateTool.d.ts.map +1 -1
  150. package/dist/tools/query/PluginStateTool/PluginStateTool.js +13 -2
  151. package/dist/tools/query/PluginStateTool/PluginStateTool.js.map +1 -1
  152. package/dist/tools/system/ProcessSessionTool/ProcessSessionTool.d.ts +4 -4
  153. package/package.json +2 -1
  154. package/dist/interface/chat/self-knowledge-mutation-tools.d.ts +0 -12
  155. package/dist/interface/chat/self-knowledge-mutation-tools.d.ts.map +0 -1
  156. package/dist/interface/chat/self-knowledge-mutation-tools.js +0 -261
  157. package/dist/interface/chat/self-knowledge-mutation-tools.js.map +0 -1
  158. package/dist/interface/chat/self-knowledge-tools.d.ts +0 -30
  159. package/dist/interface/chat/self-knowledge-tools.d.ts.map +0 -1
  160. package/dist/interface/chat/self-knowledge-tools.js +0 -248
  161. package/dist/interface/chat/self-knowledge-tools.js.map +0 -1
  162. package/dist/prompt/index.d.ts +0 -12
  163. package/dist/prompt/index.d.ts.map +0 -1
  164. package/dist/prompt/index.js +0 -9
  165. package/dist/prompt/index.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { CronExpressionParser } from "cron-parser";
2
2
  import * as path from "node:path";
3
3
  import * as net from "node:net";
4
+ import * as fsp from "node:fs/promises";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { exec } from "node:child_process";
6
7
  import { writeJsonFileAtomic, readJsonFileOrNull } from "../../base/utils/json-io.js";
@@ -13,6 +14,10 @@ import { hasConfiguredSoilPublishProvider } from "../../platform/soil/publish/in
13
14
  import { buildSchedulePresetEntry } from "./presets.js";
14
15
  import { ExternalScheduleEntrySchema } from "./source.js";
15
16
  const SCHEDULES_FILE = "schedules.json";
17
+ const SCHEDULE_LOCK_DIR = `${SCHEDULES_FILE}.lock`;
18
+ const SCHEDULE_LOCK_TIMEOUT_MS = 5000;
19
+ const SCHEDULE_LOCK_STALE_MS = 30_000;
20
+ const SCHEDULE_LOCK_RETRY_MS = 25;
16
21
  const DEFAULT_RETRY_POLICY = {
17
22
  enabled: true,
18
23
  initial_delay_ms: 30_000,
@@ -43,9 +48,12 @@ export class ScheduleEngine {
43
48
  memoryLifecycle;
44
49
  knowledgeManager;
45
50
  historyStore;
51
+ schedulesLockPath;
52
+ scheduleLockDepth = 0;
46
53
  constructor(deps) {
47
54
  this.baseDir = deps.baseDir;
48
55
  this.schedulesPath = path.join(deps.baseDir, SCHEDULES_FILE);
56
+ this.schedulesLockPath = path.join(deps.baseDir, SCHEDULE_LOCK_DIR);
49
57
  this.logger = deps.logger ?? noopLogger;
50
58
  this.dataSourceRegistry = deps.dataSourceRegistry;
51
59
  this.llmClient = deps.llmClient;
@@ -60,31 +68,136 @@ export class ScheduleEngine {
60
68
  }
61
69
  // ─── Persistence ───
62
70
  async loadEntries() {
71
+ this.entries = await this.readEntriesFromDisk();
72
+ await this.projectCurrentSchedulesToSoil();
73
+ return this.entries;
74
+ }
75
+ async readEntriesFromDisk() {
63
76
  const raw = await readJsonFileOrNull(this.schedulesPath);
64
77
  if (raw === null) {
65
- this.entries = [];
66
78
  return [];
67
79
  }
68
80
  const result = ScheduleEntryListSchema.safeParse(raw);
69
- this.entries = result.success ? result.data : [];
70
- await this.projectCurrentSchedulesToSoil();
71
- return this.entries;
81
+ return result.success ? result.data : [];
72
82
  }
73
83
  async saveEntries() {
84
+ await this.withScheduleFileLock(async () => {
85
+ await this.writeEntriesAndProject();
86
+ });
87
+ }
88
+ async writeEntriesAndProject() {
74
89
  await writeJsonFileAtomic(this.schedulesPath, this.entries);
75
90
  await this.projectCurrentSchedulesToSoil();
76
91
  }
77
- async ensureSoilPublishSchedule() {
78
- const configured = await hasConfiguredSoilPublishProvider({ baseDir: this.baseDir });
79
- if (!configured) {
80
- return null;
92
+ async refreshEntriesForMutation() {
93
+ this.entries = await this.readEntriesFromDisk();
94
+ }
95
+ captureExecutionSideEffects(entryId) {
96
+ const entry = this.entries.find((candidate) => candidate.id === entryId);
97
+ return entry ? { baseline_results: entry.baseline_results } : null;
98
+ }
99
+ applyExecutionSideEffects(entryId, sideEffects) {
100
+ if (!sideEffects)
101
+ return;
102
+ const idx = this.entries.findIndex((candidate) => candidate.id === entryId);
103
+ if (idx === -1)
104
+ return;
105
+ this.entries[idx] = {
106
+ ...this.entries[idx],
107
+ baseline_results: sideEffects.baseline_results,
108
+ };
109
+ }
110
+ async withScheduleMutation(mutate) {
111
+ return this.withScheduleFileLock(async () => {
112
+ const previousEntries = this.entries;
113
+ await this.refreshEntriesForMutation();
114
+ try {
115
+ const result = await mutate();
116
+ await this.saveEntries();
117
+ return result;
118
+ }
119
+ catch (error) {
120
+ this.entries = previousEntries;
121
+ throw error;
122
+ }
123
+ });
124
+ }
125
+ async withScheduleFileLock(work) {
126
+ if (this.scheduleLockDepth > 0) {
127
+ return work();
128
+ }
129
+ const release = await this.acquireScheduleFileLock();
130
+ this.scheduleLockDepth++;
131
+ try {
132
+ return await work();
81
133
  }
82
- const existing = this.entries.find((entry) => entry.layer === "cron" &&
83
- (entry.cron?.job_kind === "soil_publish" || entry.metadata?.preset_key === "soil_publish"));
84
- if (existing) {
85
- return existing;
134
+ finally {
135
+ this.scheduleLockDepth--;
136
+ await release();
86
137
  }
87
- return this.addEntry(buildSchedulePresetEntry({ preset: "soil_publish" }));
138
+ }
139
+ async acquireScheduleFileLock() {
140
+ await fsp.mkdir(this.baseDir, { recursive: true });
141
+ const startedAt = Date.now();
142
+ while (true) {
143
+ try {
144
+ await fsp.mkdir(this.schedulesLockPath);
145
+ await fsp.writeFile(path.join(this.schedulesLockPath, "owner.json"), JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }), "utf-8");
146
+ return async () => {
147
+ await fsp.rm(this.schedulesLockPath, { recursive: true, force: true });
148
+ };
149
+ }
150
+ catch (error) {
151
+ const code = error.code;
152
+ if (code !== "EEXIST") {
153
+ throw error;
154
+ }
155
+ await this.removeStaleScheduleLock().catch(() => undefined);
156
+ if (Date.now() - startedAt >= SCHEDULE_LOCK_TIMEOUT_MS) {
157
+ throw new Error(`Timed out waiting for schedule file lock at ${this.schedulesLockPath}`);
158
+ }
159
+ await new Promise((resolve) => setTimeout(resolve, SCHEDULE_LOCK_RETRY_MS));
160
+ }
161
+ }
162
+ }
163
+ async removeStaleScheduleLock() {
164
+ const stat = await fsp.stat(this.schedulesLockPath);
165
+ if (Date.now() - stat.mtimeMs <= SCHEDULE_LOCK_STALE_MS) {
166
+ return;
167
+ }
168
+ try {
169
+ const raw = await fsp.readFile(path.join(this.schedulesLockPath, "owner.json"), "utf-8");
170
+ const owner = JSON.parse(raw);
171
+ if (typeof owner.pid === "number") {
172
+ try {
173
+ process.kill(owner.pid, 0);
174
+ return;
175
+ }
176
+ catch {
177
+ // Owner is gone; stale lock can be removed.
178
+ }
179
+ }
180
+ }
181
+ catch {
182
+ // Missing or malformed owner data is treated as stale after the age threshold.
183
+ }
184
+ if (Date.now() - stat.mtimeMs > SCHEDULE_LOCK_STALE_MS) {
185
+ await fsp.rm(this.schedulesLockPath, { recursive: true, force: true });
186
+ }
187
+ }
188
+ async ensureSoilPublishSchedule() {
189
+ return this.withScheduleMutation(async () => {
190
+ const configured = await hasConfiguredSoilPublishProvider({ baseDir: this.baseDir });
191
+ if (!configured) {
192
+ return null;
193
+ }
194
+ const existing = this.entries.find((entry) => entry.layer === "cron" &&
195
+ (entry.cron?.job_kind === "soil_publish" || entry.metadata?.preset_key === "soil_publish"));
196
+ if (existing) {
197
+ return existing;
198
+ }
199
+ return this.addEntryInMemory(buildSchedulePresetEntry({ preset: "soil_publish" }));
200
+ });
88
201
  }
89
202
  async projectCurrentSchedulesToSoil() {
90
203
  try {
@@ -104,116 +217,121 @@ export class ScheduleEngine {
104
217
  return this.baseDir;
105
218
  }
106
219
  async syncExternalSources(sources) {
107
- const seenKeys = new Set();
108
- const reconciledSourceIds = new Set();
109
- const errors = [];
110
- let added = 0;
111
- let updated = 0;
112
- let skipped = 0;
113
- for (const source of sources) {
114
- try {
115
- const health = await source.healthCheck();
116
- if (!health.healthy) {
117
- errors.push({ source_id: source.id, message: health.error ?? "source is unhealthy" });
118
- continue;
119
- }
120
- const rawEntries = await source.fetchEntries();
121
- const sourceIdsFromFetchedEntries = new Set([source.id]);
122
- let sourceHadEntryErrors = false;
123
- for (const raw of rawEntries) {
124
- const parsed = ExternalScheduleEntrySchema.safeParse(raw);
125
- if (!parsed.success) {
126
- sourceHadEntryErrors = true;
127
- skipped++;
128
- errors.push({ source_id: source.id, message: parsed.error.message });
129
- continue;
130
- }
131
- const external = parsed.data;
132
- sourceIdsFromFetchedEntries.add(external.source_id);
133
- const entryInput = this.buildExternalScheduleEntryInput(external);
134
- if (!entryInput) {
135
- sourceHadEntryErrors = true;
136
- skipped++;
137
- errors.push({ source_id: external.source_id, message: `missing ${external.layer} config for ${external.external_id}` });
220
+ return this.withScheduleMutation(async () => {
221
+ const seenKeys = new Set();
222
+ const reconciledSourceIds = new Set();
223
+ const errors = [];
224
+ let added = 0;
225
+ let updated = 0;
226
+ let skipped = 0;
227
+ for (const source of sources) {
228
+ try {
229
+ const health = await source.healthCheck();
230
+ if (!health.healthy) {
231
+ errors.push({ source_id: source.id, message: health.error ?? "source is unhealthy" });
138
232
  continue;
139
233
  }
140
- const key = this.externalEntryKey(external.source_id, external.external_id);
141
- seenKeys.add(key);
142
- const existingIndex = this.entries.findIndex((entry) => entry.metadata?.source === "external" &&
143
- entry.metadata.external_source_id === external.source_id &&
144
- entry.metadata.external_id === external.external_id);
145
- if (existingIndex === -1) {
146
- this.entries.push(ScheduleEntrySchema.parse({
234
+ const rawEntries = await source.fetchEntries();
235
+ const sourceIdsFromFetchedEntries = new Set([source.id]);
236
+ let sourceHadEntryErrors = false;
237
+ for (const raw of rawEntries) {
238
+ const parsed = ExternalScheduleEntrySchema.safeParse(raw);
239
+ if (!parsed.success) {
240
+ sourceHadEntryErrors = true;
241
+ skipped++;
242
+ errors.push({ source_id: source.id, message: parsed.error.message });
243
+ continue;
244
+ }
245
+ const external = parsed.data;
246
+ sourceIdsFromFetchedEntries.add(external.source_id);
247
+ const entryInput = this.buildExternalScheduleEntryInput(external);
248
+ if (!entryInput) {
249
+ sourceHadEntryErrors = true;
250
+ skipped++;
251
+ errors.push({ source_id: external.source_id, message: `missing ${external.layer} config for ${external.external_id}` });
252
+ continue;
253
+ }
254
+ const key = this.externalEntryKey(external.source_id, external.external_id);
255
+ seenKeys.add(key);
256
+ const existingIndex = this.entries.findIndex((entry) => entry.metadata?.source === "external" &&
257
+ entry.metadata.external_source_id === external.source_id &&
258
+ entry.metadata.external_id === external.external_id);
259
+ if (existingIndex === -1) {
260
+ this.entries.push(ScheduleEntrySchema.parse({
261
+ ...entryInput,
262
+ id: randomUUID(),
263
+ created_at: new Date().toISOString(),
264
+ updated_at: new Date().toISOString(),
265
+ last_fired_at: null,
266
+ next_fire_at: this.computeNextFireAt(entryInput.trigger),
267
+ consecutive_failures: 0,
268
+ last_escalation_at: null,
269
+ baseline_results: [],
270
+ total_executions: 0,
271
+ total_tokens_used: 0,
272
+ }));
273
+ added++;
274
+ continue;
275
+ }
276
+ const existing = this.entries[existingIndex];
277
+ const candidate = ScheduleEntrySchema.parse({
278
+ ...existing,
147
279
  ...entryInput,
148
- id: randomUUID(),
149
- created_at: new Date().toISOString(),
150
- updated_at: new Date().toISOString(),
151
- last_fired_at: null,
152
- next_fire_at: this.computeNextFireAt(entryInput.trigger),
153
- consecutive_failures: 0,
154
- last_escalation_at: null,
155
- baseline_results: [],
156
- total_executions: 0,
157
- total_tokens_used: 0,
158
- }));
159
- added++;
160
- continue;
161
- }
162
- const existing = this.entries[existingIndex];
163
- const candidate = ScheduleEntrySchema.parse({
164
- ...existing,
165
- ...entryInput,
166
- id: existing.id,
167
- created_at: existing.created_at,
168
- updated_at: existing.updated_at,
169
- next_fire_at: JSON.stringify(existing.trigger) === JSON.stringify(entryInput.trigger)
170
- ? existing.next_fire_at
171
- : this.computeNextFireAt(entryInput.trigger),
172
- });
173
- if (JSON.stringify(existing) !== JSON.stringify(candidate)) {
174
- this.entries[existingIndex] = ScheduleEntrySchema.parse({
175
- ...candidate,
176
- updated_at: new Date().toISOString(),
280
+ id: existing.id,
281
+ created_at: existing.created_at,
282
+ updated_at: existing.updated_at,
283
+ next_fire_at: JSON.stringify(existing.trigger) === JSON.stringify(entryInput.trigger)
284
+ ? existing.next_fire_at
285
+ : this.computeNextFireAt(entryInput.trigger),
177
286
  });
178
- updated++;
287
+ if (JSON.stringify(existing) !== JSON.stringify(candidate)) {
288
+ this.entries[existingIndex] = ScheduleEntrySchema.parse({
289
+ ...candidate,
290
+ updated_at: new Date().toISOString(),
291
+ });
292
+ updated++;
293
+ }
179
294
  }
180
- }
181
- if (!sourceHadEntryErrors) {
182
- for (const sourceId of sourceIdsFromFetchedEntries) {
183
- reconciledSourceIds.add(sourceId);
295
+ if (!sourceHadEntryErrors) {
296
+ for (const sourceId of sourceIdsFromFetchedEntries) {
297
+ reconciledSourceIds.add(sourceId);
298
+ }
184
299
  }
185
300
  }
301
+ catch (error) {
302
+ errors.push({ source_id: source.id, message: error instanceof Error ? error.message : String(error) });
303
+ }
186
304
  }
187
- catch (error) {
188
- errors.push({ source_id: source.id, message: error instanceof Error ? error.message : String(error) });
189
- }
190
- }
191
- let disabled = 0;
192
- this.entries = this.entries.map((entry) => {
193
- if (entry.metadata?.source !== "external" ||
194
- !entry.enabled ||
195
- !entry.metadata.external_source_id ||
196
- !reconciledSourceIds.has(entry.metadata.external_source_id)) {
197
- return entry;
198
- }
199
- const key = this.externalEntryKey(entry.metadata.external_source_id, entry.metadata.external_id ?? "");
200
- if (seenKeys.has(key)) {
201
- return entry;
202
- }
203
- disabled++;
204
- return ScheduleEntrySchema.parse({
205
- ...entry,
206
- enabled: false,
207
- updated_at: new Date().toISOString(),
305
+ let disabled = 0;
306
+ this.entries = this.entries.map((entry) => {
307
+ if (entry.metadata?.source !== "external" ||
308
+ !entry.enabled ||
309
+ !entry.metadata.external_source_id ||
310
+ !reconciledSourceIds.has(entry.metadata.external_source_id)) {
311
+ return entry;
312
+ }
313
+ const key = this.externalEntryKey(entry.metadata.external_source_id, entry.metadata.external_id ?? "");
314
+ if (seenKeys.has(key)) {
315
+ return entry;
316
+ }
317
+ disabled++;
318
+ return ScheduleEntrySchema.parse({
319
+ ...entry,
320
+ enabled: false,
321
+ updated_at: new Date().toISOString(),
322
+ });
208
323
  });
324
+ if (added > 0 || updated > 0 || disabled > 0) {
325
+ await this.saveEntries();
326
+ }
327
+ return { added, updated, disabled, skipped, errors };
209
328
  });
210
- if (added > 0 || updated > 0 || disabled > 0) {
211
- await this.saveEntries();
212
- }
213
- return { added, updated, disabled, skipped, errors };
214
329
  }
215
330
  // ─── Entry management ───
216
331
  async addEntry(input) {
332
+ return this.withScheduleMutation(async () => this.addEntryInMemory(input));
333
+ }
334
+ addEntryInMemory(input) {
217
335
  const now = new Date().toISOString();
218
336
  const entry = ScheduleEntrySchema.parse({
219
337
  ...input,
@@ -229,90 +347,84 @@ export class ScheduleEngine {
229
347
  total_tokens_used: 0,
230
348
  });
231
349
  this.entries.push(entry);
232
- await this.saveEntries();
233
350
  return entry;
234
351
  }
235
352
  async removeEntry(id) {
236
- const before = this.entries.length;
237
- this.entries = this.entries.filter((e) => e.id !== id);
238
- if (this.entries.length === before)
239
- return false;
240
- await this.saveEntries();
241
- return true;
353
+ return this.withScheduleMutation(async () => {
354
+ const before = this.entries.length;
355
+ this.entries = this.entries.filter((e) => e.id !== id);
356
+ if (this.entries.length === before)
357
+ return false;
358
+ return true;
359
+ });
242
360
  }
243
361
  async updateEntry(id, patch) {
244
- const idx = this.entries.findIndex((entry) => entry.id === id);
245
- if (idx === -1)
246
- return null;
247
- const hasUpdatableFields = patch.name !== undefined ||
248
- patch.enabled !== undefined ||
249
- patch.trigger !== undefined ||
250
- patch.heartbeat !== undefined ||
251
- patch.probe !== undefined ||
252
- patch.cron !== undefined ||
253
- patch.goal_trigger !== undefined ||
254
- patch.escalation !== undefined ||
255
- patch.retry_policy !== undefined;
256
- if (!hasUpdatableFields) {
257
- throw new Error("No updatable fields provided");
258
- }
259
- const current = this.entries[idx];
260
- const layerConfigFields = [
261
- ["heartbeat", patch.heartbeat],
262
- ["probe", patch.probe],
263
- ["cron", patch.cron],
264
- ["goal_trigger", patch.goal_trigger],
265
- ];
266
- for (const [field, value] of layerConfigFields) {
267
- if (value === undefined)
268
- continue;
269
- if (current.layer !== field) {
270
- throw new Error(`Cannot update ${field} config for ${current.layer} entry`);
362
+ return this.withScheduleMutation(async () => {
363
+ const idx = this.entries.findIndex((entry) => entry.id === id);
364
+ if (idx === -1)
365
+ return null;
366
+ const hasUpdatableFields = patch.name !== undefined ||
367
+ patch.enabled !== undefined ||
368
+ patch.trigger !== undefined ||
369
+ patch.heartbeat !== undefined ||
370
+ patch.probe !== undefined ||
371
+ patch.cron !== undefined ||
372
+ patch.goal_trigger !== undefined ||
373
+ patch.escalation !== undefined ||
374
+ patch.retry_policy !== undefined;
375
+ if (!hasUpdatableFields) {
376
+ throw new Error("No updatable fields provided");
271
377
  }
272
- }
273
- const nextEntry = { ...current };
274
- if (patch.name !== undefined)
275
- nextEntry.name = patch.name;
276
- if (patch.enabled !== undefined)
277
- nextEntry.enabled = patch.enabled;
278
- if (patch.trigger !== undefined)
279
- nextEntry.trigger = patch.trigger;
280
- if (patch.heartbeat !== undefined)
281
- nextEntry.heartbeat = patch.heartbeat;
282
- if (patch.probe !== undefined)
283
- nextEntry.probe = patch.probe;
284
- if (patch.cron !== undefined)
285
- nextEntry.cron = patch.cron;
286
- if (patch.goal_trigger !== undefined)
287
- nextEntry.goal_trigger = patch.goal_trigger;
288
- if (patch.retry_policy !== undefined)
289
- nextEntry.retry_policy = patch.retry_policy;
290
- if (patch.escalation !== undefined) {
291
- if (patch.escalation === null) {
292
- delete nextEntry.escalation;
378
+ const current = this.entries[idx];
379
+ const layerConfigFields = [
380
+ ["heartbeat", patch.heartbeat],
381
+ ["probe", patch.probe],
382
+ ["cron", patch.cron],
383
+ ["goal_trigger", patch.goal_trigger],
384
+ ];
385
+ for (const [field, value] of layerConfigFields) {
386
+ if (value === undefined)
387
+ continue;
388
+ if (current.layer !== field) {
389
+ throw new Error(`Cannot update ${field} config for ${current.layer} entry`);
390
+ }
391
+ }
392
+ const nextEntry = { ...current };
393
+ if (patch.name !== undefined)
394
+ nextEntry.name = patch.name;
395
+ if (patch.enabled !== undefined)
396
+ nextEntry.enabled = patch.enabled;
397
+ if (patch.trigger !== undefined)
398
+ nextEntry.trigger = patch.trigger;
399
+ if (patch.heartbeat !== undefined)
400
+ nextEntry.heartbeat = patch.heartbeat;
401
+ if (patch.probe !== undefined)
402
+ nextEntry.probe = patch.probe;
403
+ if (patch.cron !== undefined)
404
+ nextEntry.cron = patch.cron;
405
+ if (patch.goal_trigger !== undefined)
406
+ nextEntry.goal_trigger = patch.goal_trigger;
407
+ if (patch.retry_policy !== undefined)
408
+ nextEntry.retry_policy = patch.retry_policy;
409
+ if (patch.escalation !== undefined) {
410
+ if (patch.escalation === null) {
411
+ delete nextEntry.escalation;
412
+ }
413
+ else {
414
+ nextEntry.escalation = patch.escalation;
415
+ }
293
416
  }
294
- else {
295
- nextEntry.escalation = patch.escalation;
417
+ if (patch.trigger !== undefined || (current.enabled === false && patch.enabled === true)) {
418
+ nextEntry.next_fire_at = this.computeNextFireAt(nextEntry.trigger);
419
+ nextEntry.retry_state = null;
296
420
  }
297
- }
298
- if (patch.trigger !== undefined || (current.enabled === false && patch.enabled === true)) {
299
- nextEntry.next_fire_at = this.computeNextFireAt(nextEntry.trigger);
300
- nextEntry.retry_state = null;
301
- }
302
- nextEntry.updated_at = new Date().toISOString();
303
- const parsedEntry = ScheduleEntrySchema.parse(nextEntry);
304
- const previousEntries = this.entries;
305
- const nextEntries = [...this.entries];
306
- nextEntries[idx] = parsedEntry;
307
- this.entries = nextEntries;
308
- try {
309
- await this.saveEntries();
310
- }
311
- catch (error) {
312
- this.entries = previousEntries;
313
- throw error;
314
- }
315
- return parsedEntry;
421
+ nextEntry.updated_at = new Date().toISOString();
422
+ const parsedEntry = ScheduleEntrySchema.parse(nextEntry);
423
+ const nextEntries = [...this.entries];
424
+ nextEntries[idx] = parsedEntry;
425
+ this.entries = nextEntries;
426
+ return parsedEntry;
427
+ });
316
428
  }
317
429
  // ─── Scheduling ───
318
430
  async getDueEntries() {
@@ -324,7 +436,10 @@ export class ScheduleEngine {
324
436
  return filtered.slice(-limit);
325
437
  }
326
438
  async runEntryNow(entryId, options = {}) {
327
- const entry = this.entries.find((candidate) => candidate.id === entryId);
439
+ const entry = await this.withScheduleFileLock(async () => {
440
+ await this.refreshEntriesForMutation();
441
+ return this.entries.find((candidate) => candidate.id === entryId) ?? null;
442
+ });
328
443
  if (!entry) {
329
444
  return null;
330
445
  }
@@ -335,7 +450,18 @@ export class ScheduleEngine {
335
450
  next_fire_at: scheduledFor,
336
451
  };
337
452
  const executedResult = await this.executeEntry(immediateEntry);
338
- const applied = await this.applyExecutionOutcome(entry.id, executedResult, "manual_run", scheduledFor, { preserveEnabled: options.preserveEnabled ?? true });
453
+ const sideEffects = entry.layer === "probe"
454
+ ? this.captureExecutionSideEffects(entry.id)
455
+ : null;
456
+ const applied = await this.withScheduleFileLock(async () => {
457
+ await this.refreshEntriesForMutation();
458
+ const outcome = await this.applyExecutionOutcome(entry.id, executedResult, "manual_run", scheduledFor, { preserveEnabled: options.preserveEnabled ?? true });
459
+ if (outcome) {
460
+ this.applyExecutionSideEffects(entry.id, sideEffects);
461
+ await this.writeEntriesAndProject();
462
+ }
463
+ return outcome;
464
+ });
339
465
  let finalResult = executedResult;
340
466
  if (options.allowEscalation && applied?.entry) {
341
467
  const escalationResult = await this.checkEscalation(applied.entry, executedResult);
@@ -344,7 +470,6 @@ export class ScheduleEngine {
344
470
  }
345
471
  }
346
472
  if (applied) {
347
- await this.saveEntries();
348
473
  await this.recordHistory({
349
474
  entry_id: applied.entry?.id ?? entry.id,
350
475
  entry_name: applied.entry?.name ?? entry.name,
@@ -386,28 +511,44 @@ export class ScheduleEngine {
386
511
  });
387
512
  }
388
513
  async tick() {
389
- // Reset daily budget for entries whose budget_reset_at is null or in the past
390
- const nowMs = Date.now();
391
- let budgetReset = false;
392
- for (let i = 0; i < this.entries.length; i++) {
393
- const e = this.entries[i];
394
- if (!e.budget_reset_at || new Date(e.budget_reset_at).getTime() <= nowMs) {
395
- this.entries[i] = {
396
- ...e,
397
- tokens_used_today: 0,
398
- budget_reset_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString(),
399
- };
400
- budgetReset = true;
514
+ const due = await this.withScheduleFileLock(async () => {
515
+ await this.refreshEntriesForMutation();
516
+ // Reset daily budget for entries whose budget_reset_at is null or in the past
517
+ const nowMs = Date.now();
518
+ let budgetReset = false;
519
+ for (let i = 0; i < this.entries.length; i++) {
520
+ const e = this.entries[i];
521
+ if (!e.budget_reset_at || new Date(e.budget_reset_at).getTime() <= nowMs) {
522
+ this.entries[i] = {
523
+ ...e,
524
+ tokens_used_today: 0,
525
+ budget_reset_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString(),
526
+ };
527
+ budgetReset = true;
528
+ }
401
529
  }
402
- }
403
- if (budgetReset) {
404
- await this.saveEntries();
405
- }
406
- const due = await this.getDueEntryDescriptors();
530
+ if (budgetReset) {
531
+ await this.writeEntriesAndProject();
532
+ }
533
+ return this.getDueEntryDescriptors();
534
+ });
407
535
  const results = [];
408
536
  for (const descriptor of due) {
409
537
  const executedResult = await this.executeEntry(descriptor.entry);
410
- const applied = await this.applyExecutionOutcome(descriptor.entry.id, executedResult, descriptor.reason, descriptor.scheduledFor);
538
+ const sideEffects = descriptor.entry.layer === "probe"
539
+ ? this.captureExecutionSideEffects(descriptor.entry.id)
540
+ : null;
541
+ const applied = await this.withScheduleFileLock(async () => {
542
+ await this.refreshEntriesForMutation();
543
+ const outcome = await this.applyExecutionOutcome(descriptor.entry.id, executedResult, descriptor.reason, descriptor.scheduledFor);
544
+ if (outcome) {
545
+ this.applyExecutionSideEffects(descriptor.entry.id, sideEffects);
546
+ // Persist cadence/retry advancement before history side effects so a crash
547
+ // cannot replay an already-fired entry from stale schedule state.
548
+ await this.writeEntriesAndProject();
549
+ }
550
+ return outcome;
551
+ });
411
552
  let finalResult = executedResult;
412
553
  if (applied?.entry) {
413
554
  const escalationResult = await this.checkEscalation(applied.entry, executedResult);
@@ -416,9 +557,6 @@ export class ScheduleEngine {
416
557
  }
417
558
  }
418
559
  if (applied) {
419
- // Persist cadence/retry advancement before history side effects so a crash
420
- // cannot replay an already-fired entry from stale schedule state.
421
- await this.saveEntries();
422
560
  await this.recordHistory({
423
561
  entry_id: applied.entry?.id ?? descriptor.entry.id,
424
562
  entry_name: applied.entry?.name ?? descriptor.entry.name,
@@ -681,7 +819,10 @@ export class ScheduleEngine {
681
819
  });
682
820
  }
683
821
  async executeEscalationTargetEntry(targetEntryId) {
684
- const targetEntry = this.entries.find((candidate) => candidate.id === targetEntryId);
822
+ const targetEntry = await this.withScheduleFileLock(async () => {
823
+ await this.refreshEntriesForMutation();
824
+ return this.entries.find((candidate) => candidate.id === targetEntryId) ?? null;
825
+ });
685
826
  if (!targetEntry) {
686
827
  this.logger.warn(`Escalation target entry not found: ${targetEntryId}`);
687
828
  return null;
@@ -692,9 +833,19 @@ export class ScheduleEngine {
692
833
  next_fire_at: new Date().toISOString(),
693
834
  };
694
835
  const result = await this.executeEntry(immediateEntry);
695
- const applied = await this.applyExecutionOutcome(targetEntryId, result, "escalation_target", immediateEntry.next_fire_at);
836
+ const sideEffects = targetEntry.layer === "probe"
837
+ ? this.captureExecutionSideEffects(targetEntry.id)
838
+ : null;
839
+ const applied = await this.withScheduleFileLock(async () => {
840
+ await this.refreshEntriesForMutation();
841
+ const outcome = await this.applyExecutionOutcome(targetEntryId, result, "escalation_target", immediateEntry.next_fire_at);
842
+ if (outcome) {
843
+ this.applyExecutionSideEffects(targetEntryId, sideEffects);
844
+ await this.writeEntriesAndProject();
845
+ }
846
+ return outcome;
847
+ });
696
848
  if (applied) {
697
- await this.saveEntries();
698
849
  await this.recordHistory({
699
850
  entry_id: targetEntry.id,
700
851
  entry_name: targetEntry.name,
@@ -755,55 +906,63 @@ export class ScheduleEngine {
755
906
  }
756
907
  // ─── Escalation logic ───
757
908
  async checkEscalation(entry, result) {
758
- const esc = entry.escalation;
759
- if (!esc?.enabled)
760
- return null;
761
909
  const isFailure = result.status === "error" || result.status === "down";
762
910
  if (!isFailure)
763
911
  return null;
764
- const now = Date.now();
765
- // Check cooldown
766
- if (entry.last_escalation_at) {
767
- const lastEsc = new Date(entry.last_escalation_at).getTime();
768
- if (now - lastEsc < esc.cooldown_minutes * 60 * 1000) {
769
- this.logger.info(`Escalation for "${entry.name}" suppressed (cooldown)`);
912
+ const escalationEntry = await this.withScheduleFileLock(async () => {
913
+ await this.refreshEntriesForMutation();
914
+ const idx = this.entries.findIndex((e) => e.id === entry.id);
915
+ if (idx === -1) {
770
916
  return null;
771
917
  }
772
- }
773
- // Rolling-window rate-limit: check escalation_timestamps within the last hour
774
- const hourAgo = now - 60 * 60 * 1000;
775
- const recentTimestamps = (entry.escalation_timestamps ?? []).filter((ts) => new Date(ts).getTime() > hourAgo);
776
- if (recentTimestamps.length >= esc.max_per_hour) {
777
- this.logger.info(`Escalation for "${entry.name}" suppressed (max_per_hour=${esc.max_per_hour} reached)`);
778
- return null;
779
- }
780
- // Update last_escalation_at and rolling-window escalation_timestamps
781
- const nowIso = new Date(now).toISOString();
782
- const hourAgoForPrune = now - 60 * 60 * 1000;
783
- const idx = this.entries.findIndex((e) => e.id === entry.id);
784
- if (idx !== -1) {
918
+ const current = this.entries[idx];
919
+ const esc = current.escalation;
920
+ if (!esc?.enabled)
921
+ return null;
922
+ const now = Date.now();
923
+ // Check cooldown
924
+ if (current.last_escalation_at) {
925
+ const lastEsc = new Date(current.last_escalation_at).getTime();
926
+ if (now - lastEsc < esc.cooldown_minutes * 60 * 1000) {
927
+ this.logger.info(`Escalation for "${current.name}" suppressed (cooldown)`);
928
+ return null;
929
+ }
930
+ }
931
+ // Rolling-window rate-limit: check escalation_timestamps within the last hour
932
+ const hourAgo = now - 60 * 60 * 1000;
933
+ const recentTimestamps = (current.escalation_timestamps ?? []).filter((ts) => new Date(ts).getTime() > hourAgo);
934
+ if (recentTimestamps.length >= esc.max_per_hour) {
935
+ this.logger.info(`Escalation for "${current.name}" suppressed (max_per_hour=${esc.max_per_hour} reached)`);
936
+ return null;
937
+ }
938
+ // Update last_escalation_at and rolling-window escalation_timestamps.
939
+ const nowIso = new Date(now).toISOString();
785
940
  const prunedTimestamps = [
786
- ...(this.entries[idx].escalation_timestamps ?? []).filter((ts) => new Date(ts).getTime() > hourAgoForPrune),
941
+ ...(current.escalation_timestamps ?? []).filter((ts) => new Date(ts).getTime() > hourAgo),
787
942
  nowIso,
788
943
  ];
789
944
  this.entries[idx] = {
790
- ...this.entries[idx],
945
+ ...current,
791
946
  last_escalation_at: nowIso,
792
947
  escalation_timestamps: prunedTimestamps,
793
948
  };
794
- await this.saveEntries();
795
- }
949
+ await this.writeEntriesAndProject();
950
+ return this.entries[idx];
951
+ });
952
+ if (!escalationEntry?.escalation)
953
+ return null;
954
+ const esc = escalationEntry.escalation;
796
955
  // Dispatch escalation notification
797
956
  await this.dispatchNotification({
798
957
  report_type: "schedule_escalation",
799
- entry_id: entry.id,
800
- entry_name: entry.name,
958
+ entry_id: escalationEntry.id,
959
+ entry_name: escalationEntry.name,
801
960
  target_layer: esc.target_layer,
802
961
  target_entry_id: esc.target_entry_id,
803
962
  target_goal_id: esc.target_goal_id,
804
- consecutive_failures: entry.consecutive_failures,
963
+ consecutive_failures: escalationEntry.consecutive_failures,
805
964
  });
806
- this.logger.warn(`Escalating "${entry.name}" to ${esc.target_layer ?? "unknown"} (failures=${entry.consecutive_failures})`);
965
+ this.logger.warn(`Escalating "${escalationEntry.name}" to ${esc.target_layer ?? "unknown"} (failures=${escalationEntry.consecutive_failures})`);
807
966
  // Execute target goal or target entry immediately so escalations take effect in the same tick.
808
967
  if (esc.target_goal_id) {
809
968
  await this.executeEscalationTargetGoal(esc.target_goal_id);