@mulmoclaude/core 0.12.0 → 0.12.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.
@@ -177,3 +177,93 @@ yourself — additions to `config/helps/error-recovery.md` are managed
177
177
  by the project maintainers so the canonical copy in
178
178
  `packages/core/assets/helps/error-recovery.md` and the installed copy
179
179
  stay in sync.
180
+
181
+ ## Sandbox MCP server dies at load — `Cannot find module '@…/…'`
182
+
183
+ ### Symptoms
184
+
185
+ - Chatting fails before any tool runs, with:
186
+ `Error: MCP tool mcp__mulmoclaude__handlePermission (passed via --permission-prompt-tool) not found.`
187
+ - Or, in the server log: `[agent-stderr] Error: Cannot find module '@mulmobridge/protocol'`
188
+ (or `@mulmoclaude/chart-plugin`, `@gui-chat-plugin/camera`, …) followed by a `Require stack:`
189
+ listing `/app/server/agent/mcp-server.ts`.
190
+ - Sandbox (Docker) mode only. The broker dies **permanently** — it fails on every manual retry
191
+ too (contrast the transient scheduler race below, which succeeds on a manual re-run).
192
+
193
+ ### Cause
194
+
195
+ The MCP child resolves its imports against `/app/node_modules` plus the `/app/pkg_modules`
196
+ fallback on `NODE_PATH`. It dies at load when a package it imports is reachable from neither. Two
197
+ layouts cause that:
198
+
199
+ 1. **Windows source checkout.** `yarn` links workspace packages into `node_modules/` as **NTFS
200
+ junctions**; their absolute Windows target (`C:\Users\…`) does not exist in the Linux container,
201
+ so every junction dangles. `server/agent/config.ts` bind-mounts a junction-free copy of each
202
+ workspace package at `/app/pkg_modules/<name>`; a package missing from that list is invisible.
203
+ 2. **npx install with nested `node_modules`.** npm sometimes places a dep in the nested
204
+ `<packageRoot>/node_modules` (a version conflict, or a half-deduped npx cache from repeated
205
+ overwrite-updates) instead of hoisting it to `<projectRoot>/node_modules`. Only the latter is
206
+ mounted to `/app/node_modules`, so the nested dep is invisible.
207
+
208
+ Either way the child dies before the MCP handshake, so the agent loses **every** MCP tool at once —
209
+ `handlePermission` included, which is why the CLI complains about the permission-prompt tool rather
210
+ than about the missing module.
211
+
212
+ ### Fix
213
+
214
+ Both layouts are handled automatically: the Windows case mounts every workspace scope, and the npx
215
+ case mounts the nested `<packageRoot>/node_modules` onto `/app/pkg_modules`. If you see this anyway:
216
+
217
+ ```bash
218
+ # Check the SHIPPED mount list, not a hand-mounted copy: does the package the
219
+ # child failed on appear in what workspaceModuleMounts() actually produces?
220
+ node_modules/.bin/tsx test/sandbox-repro/print-mcp-container-spec.ts \
221
+ | grep pkg_modules/<name> # <name> e.g. @mulmobridge/protocol
222
+
223
+ # The workspace dists must exist — production ships built output.
224
+ yarn build:packages:dev
225
+ ```
226
+
227
+ A stale `dist/` looks identical from the outside: the mount is there, but its `exports` target is
228
+ absent. Rebuild before suspecting the mounts.
229
+
230
+ For an **npx** install specifically, the quickest user-side unblock is to clear the npx cache so
231
+ the next launch installs a clean, fully-hoisted tree:
232
+
233
+ ```bash
234
+ rm -rf ~/.npm/_npx # then re-run:
235
+ npx mulmoclaude@latest
236
+ ```
237
+
238
+ ## Scheduled run fails once with `handlePermission not found`, but works on a manual retry
239
+
240
+ ### Symptoms
241
+
242
+ - A scheduled skill / user task fails with the SAME
243
+ `MCP tool mcp__mulmoclaude__handlePermission ... not found` message — but running the identical
244
+ skill by hand immediately afterwards succeeds.
245
+ - More frequent when several tasks are scheduled for the same minute (e.g. multiple 20:00 UTC
246
+ jobs). The failed run's transcript is tiny (a few hundred bytes to a few KB) and its recorded
247
+ duration is only milliseconds.
248
+
249
+ ### Cause
250
+
251
+ This is a transient STARTUP RACE, not the permanent load failure above. Each scheduled chat spawns
252
+ its own `mulmoclaude` MCP broker; when many chats launch in the same instant the broker boots under
253
+ contention and can connect a moment after the agent's first tool call, so the permission-prompt
254
+ tool is briefly absent. The broker connects seconds later — which is why a manual re-run works.
255
+
256
+ The scheduler now staggers same-minute firings by a second each to reduce this contention (#2057),
257
+ so it should be rare. It is NOT a module-resolution problem — the mounts / `dist` are fine.
258
+
259
+ ### Fix
260
+
261
+ Just re-run the task. If it recurs often on a busy schedule, spread the tasks across different
262
+ minutes rather than stacking them on the same one.
263
+
264
+ ### Regression coverage
265
+
266
+ `.github/workflows/docker_sandbox_windows.yaml` boots the real `mcp-server.ts` inside a Linux
267
+ container from a Windows host (WSL2 + native `dockerd`), with the same mounts / env / argv the
268
+ shipped builders produce, and asserts `handlePermission` comes back over the MCP handshake. See
269
+ `docs/windows-docker-ci.md`.
@@ -69,6 +69,7 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
69
69
  - [Sandbox](config/helps/sandbox.md) — how the Docker sandbox isolates the agent, what it can access, and how to disable it
70
70
  - [Error recovery](config/helps/error-recovery.md) — the lookup the agent reads on tool failures (gh/git/SSH inside the sandbox, Marp PDF, registry import, build/workspace, plugin runtime) before asking the user
71
71
  - [Telegram Bridge](config/helps/telegram.md) — how to talk to MulmoClaude from the Telegram app: creating a bot, starting the bridge, allowlisting chat IDs, commands, and troubleshooting
72
+ - [Remote host](config/helps/remote-host.md) — drive MulmoClaude from a phone at mulmoserver.web.app: Google sign-in connect, host online vs. offline (queued chats, 7-day expiry), photo attachments, and the security model
72
73
  - [Feeds](config/helps/feeds.md) — register a self-refreshing data feed (RSS/Atom/JSON) by authoring `feeds/<slug>/schema.json`: schema shape, the `ingest` block, raw-item field mapping, and `maxItems` retention
73
74
  - [GitHub repositories in the workspace](config/helps/github.md) — clone-destination rules under `github/<name>/` and how to handle existing directories with matching or different remotes
74
75
  - [Collection skills](config/helps/collection-skills.md) — build a data app (model + UI + relations + computed fields + action buttons) by authoring a `schema.json` collection skill: the DSL, field types, derived formulas, actions, records
@@ -0,0 +1,122 @@
1
+ # Remote Host — Your MulmoClaude from Your Phone
2
+
3
+ The remote host feature lets you use your MulmoClaude from a phone browser at
4
+ **https://mulmoserver.web.app** — browse your collections, open mobile custom
5
+ views, edit records, and start chats with photos taken on the spot — while the
6
+ server keeps running privately on your own computer.
7
+
8
+ Nothing on your computer is exposed to the internet: no open ports, no public
9
+ URL, no tunnels. Phone and server talk through a **cloud message queue**
10
+ (Google Firestore): the phone drops a request into your private queue, your
11
+ server picks it up, does the work, and drops the answer back. Both directions
12
+ happen in real time.
13
+
14
+ ## Connecting
15
+
16
+ 1. On your computer, click the **phone icon** (`phonelink`) in the MulmoClaude
17
+ toolbar and press **Connect**. A Google sign-in popup opens — sign in with
18
+ your Google account. The icon turns green when the host is connected.
19
+ 2. On your phone, open **https://mulmoserver.web.app** and sign in with the
20
+ **same Google account**. The app shows your host as online and lists what it
21
+ can do.
22
+
23
+ The two sides find each other purely through the shared Google account — there
24
+ is no pairing code and no IP address to type.
25
+
26
+ The connection lives only as long as the server process: restarting MulmoClaude
27
+ drops it, and you reconnect with the same one click. **Disconnect** in the same
28
+ popover stops it immediately.
29
+
30
+ ## What You Can Do from the Phone
31
+
32
+ - **Browse collections** — list your collections and page through their records.
33
+ - **Mobile custom views** — open views authored for the phone
34
+ (`target: "mobile"` in a collection's `views[]`; see
35
+ [Remote custom views](config/helps/custom-view-remote.md)). Views can show
36
+ image thumbnails and, where the view allows it, **update or delete records**.
37
+ - **Start a chat** — send a message (optionally with a role and **photo
38
+ attachments**) that opens a visible chat session on your computer, as if you
39
+ had typed it there.
40
+ - **Browse feeds, shortcuts, and skills** — read-only listings of what the
41
+ workspace offers.
42
+
43
+ The phone app only shows actions your host actually supports — an older host
44
+ simply advertises a shorter menu, so you never tap a button that can't work.
45
+
46
+ ## When the Host Is Online
47
+
48
+ Everything above works live. The host announces itself once a minute, so the
49
+ phone reliably shows it as online and results come back within moments.
50
+
51
+ ## When the Host Is Offline
52
+
53
+ If your computer is asleep or MulmoClaude isn't connected, the phone shows the
54
+ host as **offline**. Browsing (collections, views, feeds) needs a live host and
55
+ is unavailable — but **starting a chat still works**:
56
+
57
+ - The message (and any attached photos) is **queued** in the cloud. The moment
58
+ your host reconnects, it drains the queue — oldest first — and starts the
59
+ chats as if they had just arrived.
60
+ - Queued messages **expire after 7 days**. An expired message is deleted
61
+ outright, along with its uploaded photos — it will not surprise you by
62
+ starting a week-old chat.
63
+ - The phone lists your pending messages, and you can delete one before the
64
+ host picks it up.
65
+
66
+ This makes the chat queue a fire-and-forget inbox: jot an idea or snap a
67
+ receipt on the go, and it is waiting for your MulmoClaude the next time it
68
+ comes online.
69
+
70
+ ## Photos and Attachments
71
+
72
+ Photos attached to a chat are uploaded to a private staging area (Firebase
73
+ Storage) under your account, downloaded into your workspace when the host
74
+ handles the message, and then **deleted from the cloud**. Photos belonging to
75
+ an expired queued message are deleted too. As a backstop, anything somehow left
76
+ behind is automatically swept after 14 days.
77
+
78
+ ## How Secure Is It?
79
+
80
+ **Your server stays private.** It only makes *outbound* connections to Google's
81
+ Firestore — it never listens on a public address, so there is nothing on your
82
+ machine for an attacker to scan or reach.
83
+
84
+ **Everything is scoped to your Google account.** Both the phone and the host
85
+ sign in as *you*, and the cloud's security rules restrict each signed-in user
86
+ to their own private area — no other user can read your data or send commands
87
+ to your host, and your host cannot touch anyone else's data.
88
+
89
+ **Sign-in is one-shot and in-memory.** Connect passes a short-lived Google
90
+ sign-in token from your browser to the server over `localhost` only; it is
91
+ used once, never written to a log or to disk. The resulting session lives in
92
+ server memory — restarting the server forgets it, and Disconnect ends it on
93
+ the spot.
94
+
95
+ **Mobile views are sandboxed.** A custom view rendered on the phone runs in a
96
+ locked-down frame with **all network access disabled** — data reaches it only
97
+ through a controlled message channel, so a view (which may be LLM-authored)
98
+ cannot leak your data to the outside. Record edits made from a view are
99
+ validated and policy-checked by *your host*, not trusted from the phone.
100
+
101
+ **What passes through the cloud.** Requests, results (record pages, view HTML,
102
+ thumbnails), and staged photo uploads transit — and are temporarily stored in —
103
+ your private area of a Google Firebase project while in flight. Access is
104
+ limited to your account, and the data is deleted as soon as it has been
105
+ delivered — the cloud is a relay, not a copy of your workspace.
106
+
107
+ **The one thing to protect is your Google account.** Anyone who can sign in to
108
+ it can command your host with the full capability list above (though never
109
+ beyond it — the host only executes its fixed, built-in set of operations).
110
+ Use a strong password and two-factor authentication, and Disconnect the host
111
+ when you don't need remote access.
112
+
113
+ ## Good to Know
114
+
115
+ - **Host shows offline right after a restart** — reconnecting is manual by
116
+ design (the sign-in lives in memory). Click the phone icon → Connect.
117
+ - **A queued chat used a role that no longer exists** — the message fails with
118
+ an error the phone can display, rather than silently starting the wrong chat.
119
+ - **Not the same thing as the MulmoBridge messenger bridges** — Telegram / LINE
120
+ bridges relay *conversations* through a bot; the remote host is a *control
121
+ channel* for collections, views, and chat-starting from the dedicated phone
122
+ app.
@@ -1,4 +1,4 @@
1
- import { TaskExecutionState, TaskLogEntry, MISSED_RUN_POLICIES } from '@receptron/task-scheduler';
1
+ import { TaskExecutionState, TaskLogEntry, TaskTrigger, MISSED_RUN_POLICIES } from '@receptron/task-scheduler';
2
2
  import { ITaskManager, TaskDefinition, SchedulerLogger } from './task-manager.js';
3
3
  export interface SchedulerConfig {
4
4
  /** Absolute workspace root — state.json + logs hang off it. */
@@ -44,5 +44,28 @@ export declare function getSchedulerTasks(): {
44
44
  missedRunPolicy: string;
45
45
  state: TaskExecutionState;
46
46
  }[];
47
+ /** Read the persisted execution state for any task id (system, user, or
48
+ * skill). Returns an empty state when the id has never run — used by the API
49
+ * route to attach last-run / next-run / history state to user + skill tasks
50
+ * it lists from other sources. */
51
+ export declare function getSchedulerTaskState(taskId: string): TaskExecutionState;
52
+ /** Record a run of an EXTERNAL (skill / user) task — one registered directly
53
+ * on the task-manager rather than through `initScheduler`. Persists a log
54
+ * entry + updates state so these tasks get the same history / last-run /
55
+ * next-run as system tasks. Because they are fire-and-forget (`startChat`
56
+ * spawns an async chat), `errorMessage` reflects whether the *dispatch*
57
+ * succeeded, not the chat's eventual outcome; `chatSessionId` links the run
58
+ * to the spawned session. */
59
+ export declare function recordExternalRun(params: {
60
+ id: string;
61
+ name: string;
62
+ schedule: TaskDefinition["schedule"];
63
+ scheduledFor: string;
64
+ startedAt: string;
65
+ durationMs: number;
66
+ trigger: TaskTrigger;
67
+ errorMessage: string | null;
68
+ chatSessionId?: string;
69
+ }): Promise<void>;
47
70
  /** Test-only: clear config + in-memory state. */
48
71
  export declare function resetSchedulerForTesting(): void;
@@ -9,6 +9,14 @@ let _receptron_task_scheduler = require("@receptron/task-scheduler");
9
9
  var ONE_SECOND_MS$1 = 1e3;
10
10
  var ONE_MINUTE_MS = 60 * ONE_SECOND_MS$1;
11
11
  var ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
12
+ var DEFAULT_FIRING_STAGGER_MS = ONE_SECOND_MS$1;
13
+ var MAX_STAGGER_FRACTION_OF_TICK = .5;
14
+ var realSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
15
+ function staggerStepMs(staggerMs, tickMs, count) {
16
+ if (staggerMs <= 0 || count <= 1) return 0;
17
+ const maxStepMs = tickMs * MAX_STAGGER_FRACTION_OF_TICK / (count - 1);
18
+ return Math.min(staggerMs, maxStepMs);
19
+ }
12
20
  var NOOP_LOG$1 = {
13
21
  info: () => {},
14
22
  warn: () => {},
@@ -27,65 +35,104 @@ function isDue(now, schedule, tickMs) {
27
35
  }
28
36
  return false;
29
37
  }
30
- function createTaskManager(options) {
31
- const tickMs = options?.tickMs ?? ONE_MINUTE_MS;
32
- const now = options?.now ?? (() => /* @__PURE__ */ new Date());
33
- const log = options?.log ?? NOOP_LOG$1;
34
- const registry = /* @__PURE__ */ new Map();
35
- let timer = null;
36
- function collectDueTasks(currentTime) {
37
- const independent = [];
38
- const dependent = [];
39
- for (const def of registry.values()) {
40
- if (def.enabled === false) continue;
41
- if (!isDue(currentTime, def.schedule, tickMs)) continue;
42
- if (def.dependsOn) dependent.push(def);
43
- else independent.push(def);
44
- }
45
- return {
46
- independent,
47
- dependent
48
- };
38
+ /** Split the due tasks into those that may run immediately and those gated
39
+ * behind a `dependsOn` edge (resolved later in the same tick cycle). */
40
+ function collectDueTasks(currentTime, registry, tickMs) {
41
+ const independent = [];
42
+ const dependent = [];
43
+ for (const def of registry.values()) {
44
+ if (def.enabled === false) continue;
45
+ if (!isDue(currentTime, def.schedule, tickMs)) continue;
46
+ if (def.dependsOn) dependent.push(def);
47
+ else independent.push(def);
49
48
  }
50
- async function runAndTrack(def, currentTime, succeeded) {
51
- try {
52
- await def.run({
53
- taskId: def.id,
54
- now: currentTime
55
- });
56
- succeeded.add(def.id);
57
- } catch (err) {
58
- log.error("task failed", {
59
- id: def.id,
60
- error: String(err)
61
- });
62
- }
49
+ return {
50
+ independent,
51
+ dependent
52
+ };
53
+ }
54
+ async function runAndTrack(def, currentTime, succeeded, log) {
55
+ try {
56
+ await def.run({
57
+ taskId: def.id,
58
+ now: currentTime
59
+ });
60
+ succeeded.add(def.id);
61
+ } catch (err) {
62
+ log.error("task failed", {
63
+ id: def.id,
64
+ error: String(err)
65
+ });
63
66
  }
64
- async function runDependentChain(dependent, currentTime, succeeded) {
65
- let remaining = [...dependent];
66
- let progress = true;
67
- while (remaining.length > 0 && progress) {
68
- progress = false;
69
- const next = [];
70
- for (const def of remaining) {
71
- const dep = def.dependsOn;
72
- if (!dep || !succeeded.has(dep)) {
73
- next.push(def);
74
- continue;
75
- }
76
- await runAndTrack(def, currentTime, succeeded);
77
- progress = true;
67
+ }
68
+ async function runDependentChain(dependent, currentTime, succeeded, log) {
69
+ let remaining = [...dependent];
70
+ let progress = true;
71
+ while (remaining.length > 0 && progress) {
72
+ progress = false;
73
+ const next = [];
74
+ for (const def of remaining) {
75
+ const dep = def.dependsOn;
76
+ if (!dep || !succeeded.has(dep)) {
77
+ next.push(def);
78
+ continue;
78
79
  }
79
- remaining = next;
80
+ await runAndTrack(def, currentTime, succeeded, log);
81
+ progress = true;
80
82
  }
83
+ remaining = next;
81
84
  }
82
- async function onTick() {
83
- const currentTime = now();
84
- const { independent, dependent } = collectDueTasks(currentTime);
85
- const succeeded = /* @__PURE__ */ new Set();
86
- await Promise.all(independent.map((def) => runAndTrack(def, currentTime, succeeded)));
87
- await runDependentChain(dependent, currentTime, succeeded);
88
- }
85
+ }
86
+ async function runTick(now, registry, cfg) {
87
+ const currentTime = now();
88
+ const { independent, dependent } = collectDueTasks(currentTime, registry, cfg.tickMs);
89
+ const succeeded = /* @__PURE__ */ new Set();
90
+ const stepMs = staggerStepMs(cfg.staggerMs, cfg.tickMs, independent.length);
91
+ await Promise.all(independent.map(async (def, index) => {
92
+ if (stepMs > 0 && index > 0) await cfg.sleep(index * stepMs);
93
+ await runAndTrack(def, currentTime, succeeded, cfg.log);
94
+ }));
95
+ await runDependentChain(dependent, currentTime, succeeded, cfg.log);
96
+ }
97
+ function listTaskSummaries(registry) {
98
+ return [...registry.values()].map((taskDef) => ({
99
+ id: taskDef.id,
100
+ description: taskDef.description,
101
+ schedule: taskDef.schedule,
102
+ dependsOn: taskDef.dependsOn
103
+ }));
104
+ }
105
+ function makeGuardedTick(now, registry, cfg) {
106
+ let ticking = false;
107
+ return async () => {
108
+ if (ticking) return;
109
+ ticking = true;
110
+ try {
111
+ await runTick(now, registry, cfg);
112
+ } finally {
113
+ ticking = false;
114
+ }
115
+ };
116
+ }
117
+ function resolveTickConfig(options) {
118
+ const tickMs = options?.tickMs ?? ONE_MINUTE_MS;
119
+ return {
120
+ tickMs,
121
+ now: options?.now ?? (() => /* @__PURE__ */ new Date()),
122
+ cfg: {
123
+ tickMs,
124
+ staggerMs: options?.firingStaggerMs ?? DEFAULT_FIRING_STAGGER_MS,
125
+ sleep: options?.sleep ?? realSleep,
126
+ log: options?.log ?? NOOP_LOG$1
127
+ }
128
+ };
129
+ }
130
+ function createTaskManager(options) {
131
+ const { tickMs, now, cfg } = resolveTickConfig(options);
132
+ const { log } = cfg;
133
+ const registry = /* @__PURE__ */ new Map();
134
+ let timer = null;
135
+ const onTick = makeGuardedTick(now, registry, cfg);
89
136
  return {
90
137
  async tick() {
91
138
  await onTick();
@@ -118,12 +165,7 @@ function createTaskManager(options) {
118
165
  }
119
166
  },
120
167
  listTasks() {
121
- return [...registry.values()].map((taskDef) => ({
122
- id: taskDef.id,
123
- description: taskDef.description,
124
- schedule: taskDef.schedule,
125
- dependsOn: taskDef.dependsOn
126
- }));
168
+ return listTaskSummaries(registry);
127
169
  }
128
170
  };
129
171
  }
@@ -228,7 +270,7 @@ async function applyScheduleOverride(taskId, schedule) {
228
270
  if (!task || !taskManagerRef) return false;
229
271
  if (!taskManagerRef.updateSchedule(taskId, schedule)) return false;
230
272
  task.schedule = schedule;
231
- await safeUpdateState(taskId, { nextScheduledAt: computeNextScheduled(task) });
273
+ await safeUpdateState(taskId, { nextScheduledAt: computeNextScheduledFor(task.schedule) });
232
274
  return true;
233
275
  }
234
276
  /** Query execution logs — used by API routes. */
@@ -246,6 +288,35 @@ function getSchedulerTasks() {
246
288
  state: stateMap.get(taskDef.id) ?? (0, _receptron_task_scheduler.emptyState)(taskDef.id)
247
289
  }));
248
290
  }
291
+ /** Read the persisted execution state for any task id (system, user, or
292
+ * skill). Returns an empty state when the id has never run — used by the API
293
+ * route to attach last-run / next-run / history state to user + skill tasks
294
+ * it lists from other sources. */
295
+ function getSchedulerTaskState(taskId) {
296
+ return stateMap.get(taskId) ?? (0, _receptron_task_scheduler.emptyState)(taskId);
297
+ }
298
+ /** Record a run of an EXTERNAL (skill / user) task — one registered directly
299
+ * on the task-manager rather than through `initScheduler`. Persists a log
300
+ * entry + updates state so these tasks get the same history / last-run /
301
+ * next-run as system tasks. Because they are fire-and-forget (`startChat`
302
+ * spawns an async chat), `errorMessage` reflects whether the *dispatch*
303
+ * succeeded, not the chat's eventual outcome; `chatSessionId` links the run
304
+ * to the spawned session. */
305
+ async function recordExternalRun(params) {
306
+ await persistRun({
307
+ meta: {
308
+ id: params.id,
309
+ name: params.name,
310
+ schedule: params.schedule
311
+ },
312
+ scheduledFor: params.scheduledFor,
313
+ startedAt: params.startedAt,
314
+ durationMs: params.durationMs,
315
+ trigger: params.trigger,
316
+ errMsg: params.errorMessage,
317
+ chatSessionId: params.chatSessionId
318
+ });
319
+ }
249
320
  /** Test-only: clear config + in-memory state. */
250
321
  function resetSchedulerForTesting() {
251
322
  config = null;
@@ -266,44 +337,66 @@ async function executeAndLog(task, scheduledFor, trigger) {
266
337
  error: errMsg
267
338
  });
268
339
  }
269
- await safePersist(task, scheduledFor, startedAt, Date.now() - startMs, trigger, errMsg);
340
+ const durationMs = Date.now() - startMs;
341
+ await persistRun({
342
+ meta: {
343
+ id: task.id,
344
+ name: task.name,
345
+ schedule: task.schedule
346
+ },
347
+ scheduledFor,
348
+ startedAt,
349
+ durationMs,
350
+ trigger,
351
+ errMsg
352
+ });
353
+ }
354
+ /** Best-effort persistence — state and log are independent, so one failing
355
+ * never blocks the other and neither propagates upward. */
356
+ async function persistRun(run) {
357
+ await writeRunState(run);
358
+ await writeRunLog(run);
270
359
  }
271
- /** Best-effort persistence — state and log are independent. A failure in
272
- * one does not block the other, and neither propagates upward. */
273
- async function safePersist(task, scheduledFor, startedAt, durationMs, trigger, errMsg) {
360
+ async function writeRunState(run) {
361
+ const { meta, scheduledFor, durationMs, errMsg } = run;
274
362
  const isSuccess = errMsg === null;
275
- const currentState = stateMap.get(task.id);
363
+ const currentState = stateMap.get(meta.id);
276
364
  try {
277
- await (0, _receptron_task_scheduler.updateAndSave)(stateFilePath(), stateMap, task.id, {
365
+ await (0, _receptron_task_scheduler.updateAndSave)(stateFilePath(), stateMap, meta.id, {
278
366
  lastRunAt: scheduledFor,
279
367
  lastRunResult: isSuccess ? _receptron_task_scheduler.TASK_RESULTS.success : _receptron_task_scheduler.TASK_RESULTS.error,
280
368
  lastRunDurationMs: durationMs,
281
369
  lastErrorMessage: errMsg,
282
370
  consecutiveFailures: isSuccess ? 0 : (currentState?.consecutiveFailures ?? 0) + 1,
283
371
  totalRuns: (currentState?.totalRuns ?? 0) + 1,
284
- nextScheduledAt: computeNextScheduled(task)
372
+ nextScheduledAt: computeNextScheduledFor(meta.schedule)
285
373
  }, stateDeps());
286
374
  } catch (err) {
287
375
  logger().warn("state persistence failed", {
288
- taskId: task.id,
376
+ taskId: meta.id,
289
377
  error: String(err)
290
378
  });
291
379
  }
380
+ }
381
+ async function writeRunLog(run) {
382
+ const { meta, scheduledFor, startedAt, durationMs, trigger, errMsg, chatSessionId } = run;
383
+ const isSuccess = errMsg === null;
292
384
  try {
293
385
  await (0, _receptron_task_scheduler.appendLogEntry)(logsDir(), {
294
- taskId: task.id,
295
- taskName: task.name,
386
+ taskId: meta.id,
387
+ taskName: meta.name,
296
388
  scheduledFor,
297
389
  startedAt,
298
390
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
299
391
  result: isSuccess ? _receptron_task_scheduler.TASK_RESULTS.success : _receptron_task_scheduler.TASK_RESULTS.error,
300
392
  durationMs,
301
393
  trigger,
302
- ...errMsg !== null && { errorMessage: errMsg }
394
+ ...errMsg !== null && { errorMessage: errMsg },
395
+ ...chatSessionId !== void 0 && { chatSessionId }
303
396
  }, logDeps);
304
397
  } catch (err) {
305
398
  logger().warn("log persistence failed", {
306
- taskId: task.id,
399
+ taskId: meta.id,
307
400
  error: String(err)
308
401
  });
309
402
  }
@@ -329,8 +422,8 @@ function computeCurrentWindow(task) {
329
422
  const windowMs = (0, _receptron_task_scheduler.nextWindowAfter)(coreSchedule, nowMs - (coreSchedule.type === _receptron_task_scheduler.SCHEDULE_TYPES.interval ? coreSchedule.intervalSec * ONE_SECOND_MS : 0));
330
423
  return windowMs !== null && windowMs <= nowMs ? new Date(windowMs).toISOString() : new Date(nowMs).toISOString();
331
424
  }
332
- function computeNextScheduled(task) {
333
- const next = (0, _receptron_task_scheduler.nextWindowAfter)(toCoreSchedule(task.schedule), Date.now() + 1);
425
+ function computeNextScheduledFor(schedule) {
426
+ const next = (0, _receptron_task_scheduler.nextWindowAfter)(toCoreSchedule(schedule), Date.now() + 1);
334
427
  return next !== null ? new Date(next).toISOString() : null;
335
428
  }
336
429
  function toCoreSchedule(schedule) {
@@ -341,12 +434,20 @@ function toCoreSchedule(schedule) {
341
434
  return schedule;
342
435
  }
343
436
  //#endregion
437
+ Object.defineProperty(exports, "TASK_TRIGGERS", {
438
+ enumerable: true,
439
+ get: function() {
440
+ return _receptron_task_scheduler.TASK_TRIGGERS;
441
+ }
442
+ });
344
443
  exports.applyScheduleOverride = applyScheduleOverride;
345
444
  exports.configureScheduler = configureScheduler;
346
445
  exports.createTaskManager = createTaskManager;
347
446
  exports.getSchedulerLogs = getSchedulerLogs;
447
+ exports.getSchedulerTaskState = getSchedulerTaskState;
348
448
  exports.getSchedulerTasks = getSchedulerTasks;
349
449
  exports.initScheduler = initScheduler;
450
+ exports.recordExternalRun = recordExternalRun;
350
451
  exports.resetSchedulerForTesting = resetSchedulerForTesting;
351
452
 
352
453
  //# sourceMappingURL=index.cjs.map