@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.
- package/assets/helps/error-recovery.md +90 -0
- package/assets/helps/index.md +1 -0
- package/assets/helps/remote-host.md +122 -0
- package/dist/scheduler/adapter.d.ts +24 -1
- package/dist/scheduler/index.cjs +176 -75
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.d.ts +2 -1
- package/dist/scheduler/index.js +172 -79
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +19 -6
- package/dist/whisper/client-helpers.d.ts +22 -0
- package/dist/whisper/client.cjs +227 -104
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.d.ts +50 -0
- package/dist/whisper/client.js +225 -105
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +188 -132
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +188 -132
- package/dist/whisper/index.js.map +1 -1
- package/dist/whisper/models.d.ts +6 -0
- package/dist/whisper/sidecar-helpers.d.ts +7 -0
- package/dist/whisper/sidecar.d.ts +24 -0
- package/package.json +2 -2
|
@@ -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`.
|
package/assets/helps/index.md
CHANGED
|
@@ -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;
|
package/dist/scheduler/index.cjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
+
await runAndTrack(def, currentTime, succeeded, log);
|
|
81
|
+
progress = true;
|
|
80
82
|
}
|
|
83
|
+
remaining = next;
|
|
81
84
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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(
|
|
363
|
+
const currentState = stateMap.get(meta.id);
|
|
276
364
|
try {
|
|
277
|
-
await (0, _receptron_task_scheduler.updateAndSave)(stateFilePath(), stateMap,
|
|
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:
|
|
372
|
+
nextScheduledAt: computeNextScheduledFor(meta.schedule)
|
|
285
373
|
}, stateDeps());
|
|
286
374
|
} catch (err) {
|
|
287
375
|
logger().warn("state persistence failed", {
|
|
288
|
-
taskId:
|
|
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:
|
|
295
|
-
taskName:
|
|
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:
|
|
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
|
|
333
|
-
const next = (0, _receptron_task_scheduler.nextWindowAfter)(toCoreSchedule(
|
|
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
|