shraga 0.1.30 → 0.1.32
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/defaults/skills/mcp-server.md +1 -0
- package/defaults/skills/scheduler.md +70 -1
- package/defaults/skills/self-aware.md +10 -1
- package/package.json +1 -1
- package/src/server/boot.ts +51 -4
- package/src/server/downtime.ts +366 -0
- package/src/server/mcp-server.ts +19 -2
- package/src/server/scheduler/engine.ts +233 -41
- package/src/server/scheduler/runner.ts +6 -6
- package/src/server/scheduler/storage.ts +74 -0
- package/src/server/scheduler/types.ts +65 -0
- package/src/server/slack/bot.ts +5 -0
|
@@ -56,6 +56,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (uses s
|
|
|
56
56
|
| `put_skills_write` | Create/update a skill |
|
|
57
57
|
| `get_schedules` | List scheduled jobs |
|
|
58
58
|
| `post_schedules_run` | Trigger a schedule run |
|
|
59
|
+
| `get_downtime` | "What did I miss?" — outage ranges + missed schedule windows + Slack backfill (reports/proposes only, fires nothing) |
|
|
59
60
|
| `post_chat` | Talk to the agent — conversational, multi-turn via sessionId |
|
|
60
61
|
| `get_config` | Read agent configuration |
|
|
61
62
|
|
|
@@ -134,9 +134,78 @@ Task:
|
|
|
134
134
|
{ kind: "prompt", prompt: "<text>" }
|
|
135
135
|
{ kind: "bash", command: "<cmd>" }
|
|
136
136
|
|
|
137
|
-
Schedule: { id, name, enabled, trigger, task, scope, createdBy, nextRun?, lastRun?, runCount
|
|
137
|
+
Schedule: { id, name, enabled, trigger, task, scope, createdBy, nextRun?, lastRun?, runCount,
|
|
138
|
+
onMissed?, missedRun? }
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
## Missed windows (`onMissed` / `missedRun`)
|
|
142
|
+
|
|
143
|
+
If the process is down when a window should have fired (deploy, crash, power cut), the scheduler
|
|
144
|
+
decides at the next boot whether to replay it. `onMissed` is settable on `POST`/`PUT`:
|
|
145
|
+
|
|
146
|
+
| `onMissed` | Behaviour |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `run` (default) | Replay the missed window — the pre-existing behaviour. |
|
|
149
|
+
| `skip` | Never replay. The window is simply lost. |
|
|
150
|
+
| `offer` | Don't auto-fire, but **record** it so a human/agent can run it on demand. |
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
curl -s -X PUT -H "Content-Type: application/json" -H "x-internal-token: $INTERNAL_API_TOKEN" \
|
|
154
|
+
http://localhost:$PORT/api/schedules/{id} -d '{ "onMissed": "offer" }' | jq .
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- **A staleness ceiling overrides every policy, `run` included**: a window more than
|
|
158
|
+
`SCHEDULER_MAX_MISSED_AGE_MS` (default **6h**) late is never replayed. Finishing an 08:00 report
|
|
159
|
+
at 22:00 is not the job the schedule describes.
|
|
160
|
+
- This applies to **both** paths that could replay a window: the boot catch-up (cron), and the
|
|
161
|
+
in-place resume of a run interrupted mid-flight (every trigger kind — `interval`/`once`/`event`
|
|
162
|
+
have no catch-up, so resume is their only gate; their window is the one the interrupted run
|
|
163
|
+
recorded when it started).
|
|
164
|
+
- Whenever a window is not replayed, the schedule gets
|
|
165
|
+
`missedRun: { at, reason: "skip"|"offer"|"stale", noticedAt }` — visible on
|
|
166
|
+
`GET /api/schedules` and `GET /api/schedules/{id}`. **That is the `offer` affordance**: read it,
|
|
167
|
+
then `POST /api/schedules/{id}/run` to run the missed work on demand. It clears the moment the
|
|
168
|
+
schedule next starts a run.
|
|
169
|
+
- `POST /api/schedules/{id}/run` returns **409** `{ error, reason }` when a run can't start
|
|
170
|
+
(`locked` — a run is already in flight, incl. one held across a restart; `already-completed` /
|
|
171
|
+
`already-attempted`). A `404` there really does mean the schedule doesn't exist.
|
|
172
|
+
|
|
173
|
+
## "What did I miss?" — downtime recovery (`GET /api/downtime`)
|
|
174
|
+
|
|
175
|
+
This box is deliberately not always-on. The server heartbeats to `data/state/heartbeat.json` every
|
|
176
|
+
`HEARTBEAT_INTERVAL_MS` (default **60s**); at boot, a gap larger than `DOWNTIME_THRESHOLD_MS`
|
|
177
|
+
(default **3 intervals = 3m**) is appended to `data/state/downtime.json` as
|
|
178
|
+
`{ from, to, ms, slackCursors }` (last **20** kept). A clean restart records nothing.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
curl -s -H "x-internal-token: $INTERNAL_API_TOKEN" "http://localhost:$PORT/api/downtime" | jq .
|
|
182
|
+
curl -s -H "x-internal-token: $INTERNAL_API_TOKEN" "http://localhost:$PORT/api/downtime?slack=0" | jq . # skip the Slack read
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Returns `{ heartbeat, downtime[], lastDowntime, missedSchedules[], slack }`:
|
|
186
|
+
|
|
187
|
+
- **`missedSchedules[]`** — the `missedRun` records above, joined to the outage that covers them,
|
|
188
|
+
each with a `proposal` string. It does **not** re-derive which windows were missed; the
|
|
189
|
+
scheduler owns that.
|
|
190
|
+
- **`slack`** — messages that arrived during the last outage, fetched **on demand only** (never at
|
|
191
|
+
boot) via `conversations.history`, deduped by `client_msg_id`. History, not event replay: Slack's
|
|
192
|
+
Events API retries a dropped delivery for only ~30 minutes then discards it forever, while
|
|
193
|
+
`conversations.history` stays readable for days.
|
|
194
|
+
- **Where it starts:** the outage's `from`, raised only by the last-seen cursor **as
|
|
195
|
+
snapshotted into the downtime entry at boot** (`slackCursors`). The live cursors in
|
|
196
|
+
`data/state/slack-cursors.json` are a tail pointer — one message after recovery pushes them
|
|
197
|
+
past the whole backlog — so they are never used as the floor.
|
|
198
|
+
- **Which channels:** every channel with a cursor (at the time of the outage or since), plus
|
|
199
|
+
`SLACK_AGENT_CHANNEL` — so a first deploy, or a channel quiet before the outage, is not
|
|
200
|
+
invisible.
|
|
201
|
+
- **`channels[].truncated: true`** means the page cap (5 × 200 per channel) was hit with more
|
|
202
|
+
still waiting. `conversations.history` returns newest-first, so what's missing is the
|
|
203
|
+
**start** of the outage. The report's `note` also says `INCOMPLETE` — treat it as such.
|
|
204
|
+
|
|
205
|
+
**It reports and proposes — nothing auto-fires.** Acting on an item is always an explicit second
|
|
206
|
+
call: `POST /api/schedules/{id}/run` (409 + `reason` if it can't start). Same data is exposed as
|
|
207
|
+
the MCP `get_downtime` tool when `MCP_ALL_TOOLS=true`.
|
|
208
|
+
|
|
140
209
|
## Emitting events (to fire event-triggered schedules)
|
|
141
210
|
|
|
142
211
|
Any caller that can present shraga auth can push an event onto the bus:
|
|
@@ -123,7 +123,16 @@ Schedules sync across all envs but only **execute** where `DATA_SYNC_SCHEDULER_A
|
|
|
123
123
|
Two layers cause you to run — know both, and which to reach for:
|
|
124
124
|
|
|
125
125
|
**Schedules (`schedules.json`)** — a schedule is `trigger` + `task` (`prompt`/`bash`/`job`). One execution path, two trigger families:
|
|
126
|
-
- **Time**: `cron` / `interval` / `once`.
|
|
126
|
+
- **Time**: `cron` / `interval` / `once`. A window missed while you were down obeys `onMissed`
|
|
127
|
+
(`run` default / `skip` / `offer`) plus a hard 6h staleness ceiling that overrides *every*
|
|
128
|
+
policy — so an 08:00 job never silently replays at 22:00. Anything not replayed is recorded as
|
|
129
|
+
`missedRun` on the schedule (visible in `GET /api/schedules`) for an on-demand run. See the
|
|
130
|
+
**scheduler** skill.
|
|
131
|
+
- **Asked "what did I miss?"** (after a power cut / long downtime): `GET /api/downtime`. It
|
|
132
|
+
joins the recorded outage ranges to those `missedRun` windows and to the Slack messages that
|
|
133
|
+
arrived while you were down, and it **reports and proposes only** — run a missed window with
|
|
134
|
+
an explicit `POST /api/schedules/{id}/run`. Never replay everything you missed; a 14h-late
|
|
135
|
+
08:00 job is the incident this exists to prevent. Details: the **scheduler** skill.
|
|
127
136
|
- **Event** (`{ kind:'event', source, match? }`) — fires when a matching event hits the event bus. `match` is an AND-filter of payload dot-paths → values. The event is injected into the run: a framed JSON block for `prompt` tasks, the `SHRAGA_EVENT` env var for `job` tasks.
|
|
128
137
|
- Events arrive via `POST /api/events/:source` (auth-gated) or `ctx.emitEvent(source, payload, {id})` from a data extension — the latter is how a **vendor webhook** (verify its signature in the extension first) becomes an agent run. Bus + dispatcher: `src/server/events/`; fire path: `scheduler/engine.ts` `fireEvent()`. Full how-to (create / match / emit): the **scheduler** skill.
|
|
129
138
|
- **Built-in lifecycle source**: the system auto-emits `schedule.finished` (`{ scheduleId, name, status, sessionId, sessionUrl?, error? }`) when a time/manual run completes — react to your own runs (e.g. `match: { status: "error" }` → notify). Event-triggered runs don't emit it (loop guard). More internal sources can be added with one `emitEvent()` at the milestone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/boot.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { registerModuleRoutes, reconcileInstalledModules } from './modules/index
|
|
|
41
41
|
import { hydrateSlackUserToken } from './slack/oauth.ts';
|
|
42
42
|
import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
|
|
43
43
|
import { registerEventRoutes } from './events/routes.ts';
|
|
44
|
+
import { startHeartbeat, recordBootGap, buildReport } from './downtime.ts';
|
|
44
45
|
import { registerWebhook } from './events/webhook.ts';
|
|
45
46
|
import { startEventDispatcher } from './events/dispatcher.ts';
|
|
46
47
|
import { seedOperators } from './contacts.ts';
|
|
@@ -390,6 +391,20 @@ function scheduleIfVisible(id: string, uid: string, isOwner = false): Schedule |
|
|
|
390
391
|
return undefined;
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
// "What did I miss?" — the on-demand downtime report. Read-only and INERT: it returns outage
|
|
395
|
+
// ranges, the windows phase 1 deliberately did not replay (`missedRun`), and the Slack messages
|
|
396
|
+
// that arrived while we were down, each with a PROPOSAL. Running any of it is a separate,
|
|
397
|
+
// explicit call (POST /api/schedules/:id/run). `?slack=0` skips the Slack read.
|
|
398
|
+
app.get('/api/downtime', requireAuth, async (req, res) => {
|
|
399
|
+
try {
|
|
400
|
+
const slack = String(req.query.slack ?? '1') !== '0';
|
|
401
|
+
res.json(await buildReport({ slack }));
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error('[downtime] report failed:', err);
|
|
404
|
+
res.status(500).json({ error: (err as Error).message });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
393
408
|
app.get('/api/schedules', requireAuth, (req, res) => {
|
|
394
409
|
const user = (req as any).user;
|
|
395
410
|
const schedules = scheduler.listSchedules().filter((s) => user.isOwner || s.scope === 'system' || s.createdBy.uid === user.uid);
|
|
@@ -414,6 +429,7 @@ app.post('/api/schedules', requireAuth, (req, res) => {
|
|
|
414
429
|
enabled: body.enabled ?? true,
|
|
415
430
|
trigger: body.trigger as Schedule['trigger'],
|
|
416
431
|
task: body.task as Schedule['task'],
|
|
432
|
+
onMissed: body.onMissed,
|
|
417
433
|
scope: 'user',
|
|
418
434
|
createdBy: { uid: user.uid, email: user.email },
|
|
419
435
|
createdAt: now,
|
|
@@ -438,6 +454,7 @@ app.put('/api/schedules/:id', requireAuth, (req, res) => {
|
|
|
438
454
|
enabled: body.enabled ?? existing.enabled,
|
|
439
455
|
trigger: (body.trigger ?? existing.trigger) as Schedule['trigger'],
|
|
440
456
|
task: (body.task ?? existing.task) as Schedule['task'],
|
|
457
|
+
onMissed: body.onMissed ?? existing.onMissed,
|
|
441
458
|
};
|
|
442
459
|
const result = scheduler.upsertSchedule(updated);
|
|
443
460
|
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
@@ -469,9 +486,15 @@ app.post('/api/schedules/:id/run', requireAuth, (req, res) => {
|
|
|
469
486
|
const id = String(req.params.id);
|
|
470
487
|
if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
|
|
471
488
|
const override = typeof req.body?.override === 'string' ? req.body.override.trim() || undefined : undefined;
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
489
|
+
const outcome = scheduler.runNow(id, override);
|
|
490
|
+
// A refusal here is a CONFLICT with the schedule's current state (a run already holds the
|
|
491
|
+
// cross-restart lock, the period is already done), not a missing resource — 404 sent operators
|
|
492
|
+
// hunting for a schedule that plainly exists.
|
|
493
|
+
if (!outcome.ok) {
|
|
494
|
+
if (outcome.reason === 'unknown-schedule') return res.status(404).json({ error: 'Not found' });
|
|
495
|
+
return res.status(409).json({ error: outcome.message, reason: outcome.reason });
|
|
496
|
+
}
|
|
497
|
+
res.json({ sessionId: outcome.sessionId, queued: outcome.queued ?? false });
|
|
475
498
|
});
|
|
476
499
|
|
|
477
500
|
app.post('/api/schedules/:id/cancel', requireAuth, (req, res) => {
|
|
@@ -1042,6 +1065,13 @@ setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on'
|
|
|
1042
1065
|
ensureWorkspaceDir();
|
|
1043
1066
|
watchWorkspace((event) => broadcast({ type: 'workspace_change', ...event }));
|
|
1044
1067
|
if (!PASSIVE) {
|
|
1068
|
+
// BEFORE anything that could take time: the gap is measured as (now - last heartbeat), so it must
|
|
1069
|
+
// be read and re-stamped while "now" still means boot — a slow start would otherwise inflate the
|
|
1070
|
+
// gap, or (once startHeartbeat is running) erase it. Order vs the scheduler's catch-up doesn't
|
|
1071
|
+
// matter for the join: missedSchedules() reads the two files independently, at report time.
|
|
1072
|
+
// Passive twins never write here — single-active-writer, same rule as the scheduler.
|
|
1073
|
+
recordBootGap();
|
|
1074
|
+
startHeartbeat();
|
|
1045
1075
|
scheduler.start(broadcast);
|
|
1046
1076
|
startEventDispatcher();
|
|
1047
1077
|
// Modules reconcile MUST follow scheduler.start(): upsertSchedule mutates the engine's
|
|
@@ -1102,6 +1132,8 @@ async function activateConsumers() {
|
|
|
1102
1132
|
console.log('[server] ACTIVATING — starting consumers and background writers');
|
|
1103
1133
|
await dataSync.init();
|
|
1104
1134
|
syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
|
|
1135
|
+
recordBootGap();
|
|
1136
|
+
startHeartbeat();
|
|
1105
1137
|
scheduler.start(broadcast);
|
|
1106
1138
|
startEventDispatcher();
|
|
1107
1139
|
mountFeatures({ app, requireAuth, broadcast, passive: false });
|
|
@@ -1806,7 +1838,22 @@ async function recoverInterruptedSessions() {
|
|
|
1806
1838
|
});
|
|
1807
1839
|
const resumePrompt = 'Your previous response was cut off by a server restart. The partial response has been preserved above. Continue from where you left off, and avoid repeating any side-effects (e.g. messages already sent) that may have completed before the interruption.';
|
|
1808
1840
|
setRunStatus(s.sessionId, 'idle');
|
|
1809
|
-
scheduler.resumeRun(s.scheduleId!, s.sessionId, resumePrompt);
|
|
1841
|
+
const outcome = scheduler.resumeRun(s.scheduleId!, s.sessionId, resumePrompt);
|
|
1842
|
+
if (!outcome.ok) {
|
|
1843
|
+
// The refusal is now the EXPECTED outcome after a long outage (stale window / onMissed).
|
|
1844
|
+
// Close the session out exactly like the not-resumable branch above — otherwise
|
|
1845
|
+
// scheduleRunStatus stays 'running', the sidebar shows it busy forever, and it can never
|
|
1846
|
+
// self-heal because getRunningSessions() filters on runStatus, which is already idle.
|
|
1847
|
+
appendMessage(s.sessionId, {
|
|
1848
|
+
id: crypto.randomUUID(),
|
|
1849
|
+
role: 'assistant',
|
|
1850
|
+
blocks: [{ type: 'error', text: `Not resumed after the server restart — ${outcome.message} (${outcome.reason}).` }],
|
|
1851
|
+
});
|
|
1852
|
+
updateScheduledSessionStatus(s.sessionId, 'error');
|
|
1853
|
+
scheduler.clearRunningMarker(s.scheduleId!);
|
|
1854
|
+
console.log(`[recovery] scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId}) — resume refused (${outcome.reason}): ${outcome.message}`);
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1810
1857
|
console.log(`[recovery] resuming scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId}) in-place`);
|
|
1811
1858
|
continue;
|
|
1812
1859
|
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downtime recovery — "what happened while I was down?"
|
|
3
|
+
*
|
|
4
|
+
* This box is deliberately NOT always-on. When it comes back, the honest question is not "what
|
|
5
|
+
* should I replay?" but "what did I miss, and what do you want me to do about it?". Phase 1
|
|
6
|
+
* (scheduler run lock + `onMissed`) already stopped the blind replay that finished an 08:00 job at
|
|
7
|
+
* 22:00; this module supplies the other half — the ledger and the on-demand report.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately inert: nothing here starts a run, answers a Slack message, or mutates a schedule.
|
|
10
|
+
* The only writers are the heartbeat, the boot gap record, and the Slack last-seen cursor.
|
|
11
|
+
* Acting on a finding is always an explicit follow-up (`POST /api/schedules/:id/run`).
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
14
|
+
import { dataPath } from './paths.ts';
|
|
15
|
+
import { loadSchedules } from './scheduler/storage.ts';
|
|
16
|
+
import type { MissedRun } from './scheduler/types.ts';
|
|
17
|
+
|
|
18
|
+
const HEARTBEAT_FILE = dataPath('state/heartbeat.json');
|
|
19
|
+
const DOWNTIME_FILE = dataPath('state/downtime.json');
|
|
20
|
+
const SLACK_CURSORS_FILE = dataPath('state/slack-cursors.json');
|
|
21
|
+
|
|
22
|
+
/** How often liveness is stamped to disk. */
|
|
23
|
+
export const HEARTBEAT_INTERVAL_MS = Number(process.env.HEARTBEAT_INTERVAL_MS ?? 60_000);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gap above which an absence counts as downtime — 3 heartbeat intervals (3m by default).
|
|
27
|
+
*
|
|
28
|
+
* The worst case for a CLEAN restart is: the last heartbeat landed a tick before shutdown (up to
|
|
29
|
+
* 1 interval stale) + the process restart itself. One interval is therefore already "normal", and
|
|
30
|
+
* two leaves no margin for a slow boot, a loaded box, or clock/write skew — either would log
|
|
31
|
+
* phantom downtime on every deploy, which is worse than useless (it would drown the real outage).
|
|
32
|
+
* Three intervals is comfortably above every clean-restart case and still far below anything a
|
|
33
|
+
* human would call an outage: a real power cut is minutes-to-hours, not 3 minutes.
|
|
34
|
+
*/
|
|
35
|
+
export const DOWNTIME_THRESHOLD_MS = Number(process.env.DOWNTIME_THRESHOLD_MS ?? 3 * HEARTBEAT_INTERVAL_MS);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bounded history: the last 20 outages. This is a "what did I miss" aid, not an uptime archive —
|
|
39
|
+
* the report only ever reads the recent tail, and the joins that make an entry actionable
|
|
40
|
+
* (`missedRun`, Slack history) age out long before 20 outages do. 20 keeps the file trivially
|
|
41
|
+
* small while still covering many months on a box that is off occasionally.
|
|
42
|
+
*/
|
|
43
|
+
export const DOWNTIME_HISTORY_MAX = 20;
|
|
44
|
+
|
|
45
|
+
export interface DowntimeEntry {
|
|
46
|
+
/** Last proven-alive moment (the final heartbeat before the gap). */
|
|
47
|
+
from: number;
|
|
48
|
+
/** When the process came back. */
|
|
49
|
+
to: number;
|
|
50
|
+
ms: number;
|
|
51
|
+
/**
|
|
52
|
+
* channelId → last Slack ts we had seen when this outage was recorded, snapshotted at boot
|
|
53
|
+
* BEFORE any live traffic can move the cursors. The live cursor is a tail pointer: one normal
|
|
54
|
+
* message after recovery pushes it past the entire backlog. This frozen copy is the only thing
|
|
55
|
+
* that still knows where the outage's unseen history begins.
|
|
56
|
+
*/
|
|
57
|
+
slackCursors?: Record<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DowntimeFile { entries: DowntimeEntry[] }
|
|
61
|
+
|
|
62
|
+
// ── storage (same conventions as scheduler/storage.ts: tmp file + rename) ──────────────────────
|
|
63
|
+
|
|
64
|
+
function readJson<T>(file: string, fallback: T): T {
|
|
65
|
+
if (!existsSync(file)) return fallback;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
68
|
+
return parsed && typeof parsed === 'object' ? (parsed as T) : fallback;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`[downtime] failed to parse ${file}, starting fresh:`, err);
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeJsonAtomic(file: string, value: unknown): void {
|
|
76
|
+
mkdirSync(dataPath('state'), { recursive: true });
|
|
77
|
+
const tmp = `${file}.tmp`;
|
|
78
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
79
|
+
renameSync(tmp, file);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── heartbeat ─────────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function readHeartbeat(): number | null {
|
|
85
|
+
const hb = readJson<{ at?: number }>(HEARTBEAT_FILE, {});
|
|
86
|
+
return Number.isFinite(hb.at) ? (hb.at as number) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function writeHeartbeat(at = Date.now()): void {
|
|
90
|
+
writeJsonAtomic(HEARTBEAT_FILE, { at, pid: process.pid });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stamp liveness every interval. Unref'd — a heartbeat must never hold the process open. */
|
|
94
|
+
export function startHeartbeat(intervalMs = HEARTBEAT_INTERVAL_MS): () => void {
|
|
95
|
+
writeHeartbeat();
|
|
96
|
+
const timer = setInterval(() => {
|
|
97
|
+
try { writeHeartbeat(); } catch (err) { console.error('[downtime] heartbeat write failed:', err); }
|
|
98
|
+
}, intervalMs);
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
return () => clearInterval(timer);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── downtime ledger ───────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export function listDowntime(): DowntimeEntry[] {
|
|
106
|
+
return readJson<DowntimeFile>(DOWNTIME_FILE, { entries: [] }).entries ?? [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare the last heartbeat against boot time and, if the gap is real, record it. Returns the
|
|
111
|
+
* entry it recorded, or null — a clean restart (and a first-ever boot, which has no heartbeat to
|
|
112
|
+
* measure from) records NOTHING, so the ledger only ever contains genuine outages.
|
|
113
|
+
*/
|
|
114
|
+
export function recordBootGap(bootTime = Date.now()): DowntimeEntry | null {
|
|
115
|
+
const last = readHeartbeat();
|
|
116
|
+
// Write the new heartbeat regardless: whatever we conclude, this process is alive now.
|
|
117
|
+
writeHeartbeat(bootTime);
|
|
118
|
+
if (last === null) return null;
|
|
119
|
+
const ms = bootTime - last;
|
|
120
|
+
if (ms <= DOWNTIME_THRESHOLD_MS) return null;
|
|
121
|
+
|
|
122
|
+
// Freeze the Slack cursors into the entry now, at boot, before the first inbound message can
|
|
123
|
+
// advance them past the backlog this outage left behind.
|
|
124
|
+
const snapshot: Record<string, string> = {};
|
|
125
|
+
for (const [channel, cursor] of Object.entries(listSlackCursors())) snapshot[channel] = cursor.ts;
|
|
126
|
+
|
|
127
|
+
const entry: DowntimeEntry = { from: last, to: bootTime, ms, ...(Object.keys(snapshot).length ? { slackCursors: snapshot } : {}) };
|
|
128
|
+
const entries = [...listDowntime(), entry].slice(-DOWNTIME_HISTORY_MAX);
|
|
129
|
+
writeJsonAtomic(DOWNTIME_FILE, { entries } satisfies DowntimeFile);
|
|
130
|
+
console.log(`[downtime] gap of ${Math.round(ms / 60_000)}m recorded — down from ${new Date(last).toISOString()} to ${new Date(bootTime).toISOString()}`);
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Slack last-seen cursors ───────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** channelId → { ts: last message ts we observed, at: when we observed it }. */
|
|
137
|
+
export interface SlackCursor { ts: string; at: number }
|
|
138
|
+
type SlackCursors = Record<string, SlackCursor>;
|
|
139
|
+
|
|
140
|
+
export function listSlackCursors(): SlackCursors {
|
|
141
|
+
return readJson<SlackCursors>(SLACK_CURSORS_FILE, {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Record that we saw `ts` in `channel`. Called for every inbound Slack message the ingress hands
|
|
146
|
+
* us (whether or not the agent chose to answer it) — it is a LIVE TAIL pointer: it says where the
|
|
147
|
+
* stream is now, not what was handled, and after a recovery it runs ahead of the outage backlog
|
|
148
|
+
* within one message (which is why an outage snapshots it, see `DowntimeEntry.slackCursors`).
|
|
149
|
+
* Monotonic: an out-of-order event can't rewind the cursor and cause a re-fetch of already-seen
|
|
150
|
+
* history. Slack ts values carry 16 significant digits, so they are compared as STRINGS — `Number`
|
|
151
|
+
* rounds them to a double and makes same-second messages compare equal.
|
|
152
|
+
*/
|
|
153
|
+
export function noteSlackSeen(channel: string, ts: string): void {
|
|
154
|
+
if (!channel || !ts) return;
|
|
155
|
+
try {
|
|
156
|
+
const cursors = listSlackCursors();
|
|
157
|
+
const prev = cursors[channel];
|
|
158
|
+
if (prev && prev.ts >= ts) return;
|
|
159
|
+
cursors[channel] = { ts, at: Date.now() };
|
|
160
|
+
writeJsonAtomic(SLACK_CURSORS_FILE, cursors);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error('[downtime] failed to record Slack cursor:', err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface BackfilledMessage {
|
|
167
|
+
channel: string;
|
|
168
|
+
ts: string;
|
|
169
|
+
user?: string;
|
|
170
|
+
botId?: string;
|
|
171
|
+
text: string;
|
|
172
|
+
clientMsgId?: string;
|
|
173
|
+
/** True when the text mentions the agent (bot or user id) — the subset most likely to need action. */
|
|
174
|
+
mentionsAgent: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface SlackBackfill {
|
|
178
|
+
/** `truncated` = the page cap was hit with more still waiting, so the OLDEST part of the outage is missing. */
|
|
179
|
+
channels: { channel: string; from: string; fetched: number; truncated?: boolean; error?: string }[];
|
|
180
|
+
messages: BackfilledMessage[];
|
|
181
|
+
skipped?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Injected so tests (and any future caller) can drive the join without a live Slack workspace. */
|
|
185
|
+
export type SlackHistoryFn = (method: string, body: Record<string, unknown>) => Promise<any>;
|
|
186
|
+
|
|
187
|
+
export interface BackfillOptions {
|
|
188
|
+
/** Defaults to the shared mcp-slack-use client (`slackPost`) — the app's ONE Slack seam. */
|
|
189
|
+
history?: SlackHistoryFn;
|
|
190
|
+
/** Agent ids to flag mentions against. */
|
|
191
|
+
agentIds?: string[];
|
|
192
|
+
/** Safety valve on a very long outage: pages of 200 per channel. */
|
|
193
|
+
maxPages?: number;
|
|
194
|
+
/**
|
|
195
|
+
* channelId → last-seen ts AS OF the outage (`DowntimeEntry.slackCursors`). The live cursors are
|
|
196
|
+
* deliberately NOT used as a floor: they advance with the stream and would skip the backlog.
|
|
197
|
+
*/
|
|
198
|
+
cursors?: Record<string, string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch what arrived during `range` from each channel we know about, on demand.
|
|
203
|
+
*
|
|
204
|
+
* Why history and not event replay: Slack's Events API retries a failed delivery for only ~30
|
|
205
|
+
* minutes and then drops the event PERMANENTLY — after a multi-hour outage there is nothing left
|
|
206
|
+
* to redeliver. `conversations.history` keeps the same messages readable for days (retention), so
|
|
207
|
+
* it, not the event stream, is the durable recovery source. This is also why it is on-demand only
|
|
208
|
+
* and never runs at boot: it is a paid, rate-limited read of someone else's system, and its result
|
|
209
|
+
* is a REPORT, not a work queue.
|
|
210
|
+
*/
|
|
211
|
+
export async function fetchSlackBackfill(range: { from: number; to?: number }, opts: BackfillOptions = {}): Promise<SlackBackfill> {
|
|
212
|
+
// Which channels to ask about: the ones seen at the time of the outage, the ones seen since
|
|
213
|
+
// (a channel that only became active during/after the outage still has missed history), and the
|
|
214
|
+
// agent's own channel — which must be readable on a first deploy, before any traffic at all.
|
|
215
|
+
const snapshot = opts.cursors ?? {};
|
|
216
|
+
const channels = [...new Set([
|
|
217
|
+
...Object.keys(snapshot),
|
|
218
|
+
...Object.keys(listSlackCursors()),
|
|
219
|
+
...(process.env.SLACK_AGENT_CHANNEL ? [process.env.SLACK_AGENT_CHANNEL] : []),
|
|
220
|
+
])];
|
|
221
|
+
if (!channels.length) return { channels: [], messages: [], skipped: 'no channels seen yet (no cursor recorded, no SLACK_AGENT_CHANNEL)' };
|
|
222
|
+
|
|
223
|
+
let history = opts.history;
|
|
224
|
+
let agentIds = (opts.agentIds ?? []).filter(Boolean);
|
|
225
|
+
if (!history) {
|
|
226
|
+
try {
|
|
227
|
+
// The app's ONE Slack seam (slack/api.ts re-exports the mcp-slack-use client). No new HTTP
|
|
228
|
+
// client, no second token-resolution path.
|
|
229
|
+
const api = await import('./slack/api.ts');
|
|
230
|
+
history = (method, body) => api.slackPost(method, body);
|
|
231
|
+
if (!agentIds.length) {
|
|
232
|
+
const ids = await Promise.all([api.getBotUserId().catch(() => null), api.getAgentUserId().catch(() => null)]);
|
|
233
|
+
agentIds = ids.filter((id): id is string => !!id);
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return { channels: [], messages: [], skipped: `Slack client unavailable: ${(err as Error).message}` };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const maxPages = opts.maxPages ?? 5;
|
|
241
|
+
const out: SlackBackfill = { channels: [], messages: [] };
|
|
242
|
+
const seen = new Set<string>();
|
|
243
|
+
|
|
244
|
+
for (const channel of channels) {
|
|
245
|
+
// Start at the outage start, raised only by a SNAPSHOT cursor that is later still (a channel
|
|
246
|
+
// whose last-seen message post-dates `range.from` — we already saw those). Never raised by the
|
|
247
|
+
// live cursor: that one has moved on with the stream and would hide the whole backlog.
|
|
248
|
+
const oldest = Math.max(Number(snapshot[channel]) || 0, range.from / 1000);
|
|
249
|
+
let fetched = 0;
|
|
250
|
+
let pageCursor: string | undefined;
|
|
251
|
+
let error: string | undefined;
|
|
252
|
+
let truncated = false;
|
|
253
|
+
try {
|
|
254
|
+
for (let page = 0; page < maxPages; page++) {
|
|
255
|
+
const body: Record<string, unknown> = { channel, oldest: String(oldest), limit: 200 };
|
|
256
|
+
if (range.to) body.latest = String(range.to / 1000);
|
|
257
|
+
if (pageCursor) body.cursor = pageCursor;
|
|
258
|
+
const res = await history('conversations.history', body);
|
|
259
|
+
if (!res?.ok) { error = String(res?.error ?? 'unknown Slack error'); break; }
|
|
260
|
+
for (const m of (res.messages ?? []) as any[]) {
|
|
261
|
+
// Dedupe on client_msg_id (Slack's own idempotency key), falling back to channel+ts for
|
|
262
|
+
// messages that carry none (bot posts, joins). Paging overlap and a re-run of this
|
|
263
|
+
// report must not double-report the same message.
|
|
264
|
+
const key = m.client_msg_id ?? `${channel}:${m.ts}`;
|
|
265
|
+
if (seen.has(key)) continue;
|
|
266
|
+
seen.add(key);
|
|
267
|
+
const text = String(m.text ?? '');
|
|
268
|
+
out.messages.push({
|
|
269
|
+
channel,
|
|
270
|
+
ts: String(m.ts),
|
|
271
|
+
user: m.user,
|
|
272
|
+
botId: m.bot_id,
|
|
273
|
+
text,
|
|
274
|
+
clientMsgId: m.client_msg_id,
|
|
275
|
+
mentionsAgent: agentIds.some(id => text.includes(id)),
|
|
276
|
+
});
|
|
277
|
+
fetched++;
|
|
278
|
+
}
|
|
279
|
+
pageCursor = res.response_metadata?.next_cursor || undefined;
|
|
280
|
+
if (!res.has_more || !pageCursor) break;
|
|
281
|
+
// Still more waiting when the cap is reached: say so. conversations.history returns
|
|
282
|
+
// NEWEST-first, so what we dropped is the START of the outage — the oldest and most likely
|
|
283
|
+
// to have been missed. A silently short report would read as "that's everything".
|
|
284
|
+
if (page === maxPages - 1) truncated = true;
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
error = (err as Error).message;
|
|
288
|
+
}
|
|
289
|
+
out.channels.push({ channel, from: String(oldest), fetched, ...(truncated ? { truncated } : {}), ...(error ? { error } : {}) });
|
|
290
|
+
}
|
|
291
|
+
out.messages.sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── the on-demand report ──────────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
export interface MissedScheduleReport {
|
|
298
|
+
id: string;
|
|
299
|
+
name: string;
|
|
300
|
+
missedRun: MissedRun;
|
|
301
|
+
/** The outage this window falls inside, if any. */
|
|
302
|
+
downtime: DowntimeEntry | null;
|
|
303
|
+
/** What the USER can choose to do. Nothing here runs it. */
|
|
304
|
+
proposal: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface DowntimeReport {
|
|
308
|
+
now: number;
|
|
309
|
+
heartbeat: { at: number; ageMs: number } | null;
|
|
310
|
+
downtime: DowntimeEntry[];
|
|
311
|
+
lastDowntime: DowntimeEntry | null;
|
|
312
|
+
missedSchedules: MissedScheduleReport[];
|
|
313
|
+
slack?: SlackBackfill;
|
|
314
|
+
note: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Join phase 1's `missedRun` records to the outage that covers them.
|
|
319
|
+
*
|
|
320
|
+
* It does NOT recompute which windows were missed — the scheduler owns that decision (policy +
|
|
321
|
+
* staleness ceiling) and re-deriving it here would be a second, silently-diverging opinion.
|
|
322
|
+
*
|
|
323
|
+
* Read from schedules.json rather than the engine's in-memory list: the engine `saveSchedules()`
|
|
324
|
+
* on every mutation (incl. `noteMissed`), so disk is current, and reading it keeps this module
|
|
325
|
+
* independent of whether the engine has been started — which matters because a passive twin and
|
|
326
|
+
* this report both need the answer without owning the scheduler.
|
|
327
|
+
*/
|
|
328
|
+
export function missedSchedules(entries = listDowntime()): MissedScheduleReport[] {
|
|
329
|
+
return loadSchedules()
|
|
330
|
+
.filter((s): s is typeof s & { missedRun: MissedRun } => !!s.missedRun)
|
|
331
|
+
.map((s) => ({
|
|
332
|
+
id: s.id,
|
|
333
|
+
name: s.name,
|
|
334
|
+
missedRun: s.missedRun,
|
|
335
|
+
downtime: entries.find(e => s.missedRun.at >= e.from && s.missedRun.at <= e.to) ?? null,
|
|
336
|
+
proposal: `POST /api/schedules/${s.id}/run to run this window now (nothing has run it)`,
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the "what did I miss?" answer. Read-only apart from the Slack fetch, which is a read of
|
|
342
|
+
* Slack. Callers pass `slack: false` to skip the network entirely.
|
|
343
|
+
*/
|
|
344
|
+
export async function buildReport(opts: { slack?: boolean; backfill?: BackfillOptions } = {}): Promise<DowntimeReport> {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
const entries = listDowntime();
|
|
347
|
+
const last = entries[entries.length - 1] ?? null;
|
|
348
|
+
const hb = readHeartbeat();
|
|
349
|
+
const report: DowntimeReport = {
|
|
350
|
+
now,
|
|
351
|
+
heartbeat: hb === null ? null : { at: hb, ageMs: now - hb },
|
|
352
|
+
downtime: entries,
|
|
353
|
+
lastDowntime: last,
|
|
354
|
+
missedSchedules: missedSchedules(entries),
|
|
355
|
+
note: 'Report only — nothing here has been run, answered, or replayed. Act on an item explicitly.',
|
|
356
|
+
};
|
|
357
|
+
if (opts.slack !== false) {
|
|
358
|
+
report.slack = last
|
|
359
|
+
// The snapshot frozen at boot, not the live cursors — see DowntimeEntry.slackCursors.
|
|
360
|
+
? await fetchSlackBackfill({ from: last.from }, { cursors: last.slackCursors, ...opts.backfill })
|
|
361
|
+
: { channels: [], messages: [], skipped: 'no recorded downtime to backfill' };
|
|
362
|
+
const cut = report.slack.channels.filter(c => c.truncated).map(c => c.channel);
|
|
363
|
+
if (cut.length) report.note += ` INCOMPLETE: hit the page cap on ${cut.join(', ')} — the OLDEST part of the outage is missing from this report.`;
|
|
364
|
+
}
|
|
365
|
+
return report;
|
|
366
|
+
}
|
package/src/server/mcp-server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { listWorkspaceTree, readWorkspaceFile, safeResolve, searchWorkspace } fr
|
|
|
9
9
|
import { listSkills, getSkill, saveSkill } from './skills.ts';
|
|
10
10
|
import { getAllSessions, loadConversation, isSessionLocked } from './sessions.ts';
|
|
11
11
|
import * as scheduler from './scheduler/index.ts';
|
|
12
|
+
import { buildReport } from './downtime.ts';
|
|
12
13
|
import { getAgentConfig } from './claude.ts';
|
|
13
14
|
import { validateApiKey } from './api-keys.ts';
|
|
14
15
|
import { verifyMcpToken } from './auth.ts';
|
|
@@ -247,8 +248,24 @@ export function createShragaMcp(deps: McpServerDeps) {
|
|
|
247
248
|
if (!id) return json({ error: 'id required' }, { status: 400 });
|
|
248
249
|
const schedule = scheduler.getSchedule(id);
|
|
249
250
|
if (!schedule) return json({ error: 'Schedule not found' }, { status: 404 });
|
|
250
|
-
scheduler.runNow(id);
|
|
251
|
-
return json({
|
|
251
|
+
const outcome = scheduler.runNow(id);
|
|
252
|
+
if (!outcome.ok) return json({ error: outcome.message, reason: outcome.reason }, { status: 409 });
|
|
253
|
+
return json({ ok: true, id, sessionId: outcome.sessionId, queued: outcome.queued ?? false });
|
|
254
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── Downtime ─────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
base.describeMCP('/downtime', 'GET', {
|
|
260
|
+
description: 'Answer "what did I miss while I was down?". Returns recorded downtime ranges, the schedule windows that were deliberately NOT replayed (missedRun), and the Slack messages that arrived during the last outage. Reports and proposes only — it never runs, replays, or answers anything; act on an item with the schedules/run tool.',
|
|
261
|
+
params: {
|
|
262
|
+
slack: { description: 'Set "0" to skip the Slack history read (no network).', type: 'string' },
|
|
263
|
+
},
|
|
264
|
+
annotations: { readOnlyHint: true },
|
|
265
|
+
});
|
|
266
|
+
router.get('/downtime', async (req: any) => {
|
|
267
|
+
try {
|
|
268
|
+
return json(await buildReport({ slack: str(req.query.slack) !== '0' }));
|
|
252
269
|
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
253
270
|
});
|
|
254
271
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { loadSchedules, saveSchedules, readCompletionMarker, writeCompletionMarker, readRunningMarker, isProcessAlive, loadThrottleState, saveThrottleState } from './storage.ts';
|
|
1
|
+
import { loadSchedules, saveSchedules, readCompletionMarker, writeCompletionMarker, readRunningMarker, isProcessAlive, loadThrottleState, saveThrottleState, acquireRunLock, clearRunningMarker, markRunStarted } from './storage.ts';
|
|
2
2
|
import { computeNextRun, computePrevRun, validateTrigger } from './timing.ts';
|
|
3
3
|
import { runSchedule, type ResumeOptions, type EventContext } from './runner.ts';
|
|
4
4
|
import { backfillScope, ensureBuiltinSchedules } from './builtins.ts';
|
|
5
5
|
import { emitEvent } from '../events/bus.ts';
|
|
6
6
|
import { getSessionUrl } from '../shraga-config.ts';
|
|
7
|
-
import type { Schedule } from './types.ts';
|
|
7
|
+
import type { Schedule, MissedPolicy, RunOutcome, RunRefusal } from './types.ts';
|
|
8
8
|
|
|
9
9
|
type Broadcast = (data: object) => void;
|
|
10
10
|
|
|
@@ -26,6 +26,18 @@ interface RuntimeState {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const QUEUE_CAP = 5;
|
|
29
|
+
/** Catch-up fires are delayed so MCP servers finish initializing first. Read per call so tests
|
|
30
|
+
* (and an operator) can shorten it. */
|
|
31
|
+
const catchupDelayMs = () => Number(process.env.SCHEDULER_CATCHUP_DELAY_MS ?? 10_000);
|
|
32
|
+
/** Hard ceiling on how late a missed window may still be replayed, regardless of `onMissed`.
|
|
33
|
+
* A missed window is never more than one period old, so a period-relative bound alone can never
|
|
34
|
+
* suppress anything — yet replaying an 08:00 report at 22:00 is not the job the schedule
|
|
35
|
+
* describes. This absolute cap is therefore the entire rule.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately NOT `min(period, cap)`: a cron missed window comes from `computePrevRun`, so its
|
|
38
|
+
* age is always strictly less than one period — a period-relative term can never suppress
|
|
39
|
+
* anything, and `min(period, cap)` is provably identical to `cap`. */
|
|
40
|
+
const maxMissedAgeMs = () => Number(process.env.SCHEDULER_MAX_MISSED_AGE_MS ?? 6 * 60 * 60 * 1000);
|
|
29
41
|
/** Max setTimeout delay (2^31-1 ms ≈ 24.8 days); longer delays overflow and fire immediately. */
|
|
30
42
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
31
43
|
/** Only the designated instance fires schedules (DATA_SYNC_SCHEDULER_ACTIVE=true).
|
|
@@ -71,38 +83,67 @@ export function start(broadcast: Broadcast): void {
|
|
|
71
83
|
}
|
|
72
84
|
}
|
|
73
85
|
// Catch up missed cron fires (e.g. process was down when cron should have fired)
|
|
74
|
-
const catchUps: string[] = [];
|
|
86
|
+
const catchUps: { id: string; window: number }[] = [];
|
|
75
87
|
for (const s of state.schedules) {
|
|
76
88
|
if (!s.enabled || s.trigger.kind !== 'cron') continue;
|
|
77
89
|
const prev = computePrevRun(s.trigger);
|
|
78
90
|
if (prev === null) continue;
|
|
79
91
|
const lastAt = s.lastRun?.at;
|
|
80
92
|
if (lastAt === undefined) continue; // never ran — nothing to catch up
|
|
81
|
-
if (lastAt
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
+
if (lastAt >= prev) continue;
|
|
94
|
+
|
|
95
|
+
const marker = readCompletionMarker(s.id);
|
|
96
|
+
if (marker && marker.completedAt >= prev) {
|
|
97
|
+
console.log(`[scheduler] skipping catch-up for ${s.id} — already completed at ${new Date(marker.completedAt).toISOString()} by ${marker.triggeredBy}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// An ATTEMPT on this window counts too: a run that errored or was killed already had its
|
|
101
|
+
// shot. Replaying it on the next boot is exactly the duplicate-fire this guards.
|
|
102
|
+
if (marker?.attemptWindow !== undefined && marker.attemptWindow >= prev) {
|
|
103
|
+
console.log(`[scheduler] skipping catch-up for ${s.id} — window ${new Date(prev).toISOString()} already attempted (${marker.status ?? 'started'})`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Someone is already on it — either another instance, or (far more common) the recovery
|
|
107
|
+
// path that resumed the interrupted run in-place moments ago.
|
|
108
|
+
const running = readRunningMarker(s.id);
|
|
109
|
+
if (running && isProcessAlive(running.pid)) {
|
|
110
|
+
console.log(`[scheduler] skipping catch-up for ${s.id} — still running (pid ${running.pid}, started ${new Date(running.startedAt).toISOString()})`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const policy = missedPolicy(s);
|
|
115
|
+
if (policy === 'skip') {
|
|
116
|
+
console.log(`[scheduler] missed window ${new Date(prev).toISOString()} for ${s.id} — onMissed=skip, not replaying`);
|
|
117
|
+
noteMissed(s, prev, 'skip');
|
|
118
|
+
continue;
|
|
93
119
|
}
|
|
120
|
+
if (policy === 'offer') {
|
|
121
|
+
console.log(`[scheduler] missed window ${new Date(prev).toISOString()} for ${s.id} — onMissed=offer, recorded for on-demand run`);
|
|
122
|
+
noteMissed(s, prev, 'offer');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const age = Date.now() - prev;
|
|
126
|
+
const grace = maxMissedAgeMs();
|
|
127
|
+
if (age > grace) {
|
|
128
|
+
console.log(`[scheduler] missed window ${new Date(prev).toISOString()} for ${s.id} is ${Math.round(age / 60_000)}m stale (grace ${Math.round(grace / 60_000)}m) — not replaying`);
|
|
129
|
+
noteMissed(s, prev, 'stale');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
catchUps.push({ id: s.id, window: prev });
|
|
94
133
|
}
|
|
95
134
|
if (catchUps.length) {
|
|
96
|
-
console.log(`[scheduler] catch-up: ${catchUps.join(', ')} (delayed
|
|
135
|
+
console.log(`[scheduler] catch-up: ${catchUps.map((c) => c.id).join(', ')} (delayed ${catchupDelayMs()}ms for MCP init)`);
|
|
97
136
|
setTimeout(() => {
|
|
98
|
-
for (const id of catchUps) {
|
|
137
|
+
for (const { id, window } of catchUps) {
|
|
99
138
|
const s = getSchedule(id);
|
|
100
|
-
if (s?.enabled)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
139
|
+
if (!s?.enabled) continue;
|
|
140
|
+
console.log(`[scheduler] catch-up: firing ${id}`);
|
|
141
|
+
// NOT runNow(): a catch-up is not a manual run. It must go through the same
|
|
142
|
+
// completion/attempt/lock guards as any timer fire, so that whatever claimed this
|
|
143
|
+
// window first (typically the in-place resume) makes this a no-op.
|
|
144
|
+
enqueueFire(s, window);
|
|
104
145
|
}
|
|
105
|
-
},
|
|
146
|
+
}, catchupDelayMs());
|
|
106
147
|
}
|
|
107
148
|
|
|
108
149
|
saveSchedules(state.schedules);
|
|
@@ -125,6 +166,9 @@ export function getSchedule(id: string): Schedule | undefined {
|
|
|
125
166
|
export function upsertSchedule(s: Schedule): { ok: true; schedule: Schedule } | { ok: false; error: string } {
|
|
126
167
|
const err = validateTrigger(s.trigger);
|
|
127
168
|
if (err) return { ok: false, error: err };
|
|
169
|
+
if (s.onMissed !== undefined && !MISSED_POLICIES.includes(s.onMissed)) {
|
|
170
|
+
return { ok: false, error: `Invalid onMissed "${s.onMissed}" (expected ${MISSED_POLICIES.join(' | ')})` };
|
|
171
|
+
}
|
|
128
172
|
|
|
129
173
|
s.updatedAt = Date.now();
|
|
130
174
|
if (s.enabled) {
|
|
@@ -180,9 +224,9 @@ export function toggleSchedule(id: string, enabled: boolean): Schedule | null {
|
|
|
180
224
|
return s;
|
|
181
225
|
}
|
|
182
226
|
|
|
183
|
-
export function runNow(id: string, override?: string):
|
|
227
|
+
export function runNow(id: string, override?: string): RunOutcome {
|
|
184
228
|
const s = getSchedule(id);
|
|
185
|
-
if (!s) return
|
|
229
|
+
if (!s) return refuse('unknown-schedule', `No schedule ${id}`);
|
|
186
230
|
return enqueueFire(s, Date.now(), override, true);
|
|
187
231
|
}
|
|
188
232
|
|
|
@@ -275,22 +319,116 @@ export function cancelRun(id: string): boolean {
|
|
|
275
319
|
* side-effects, e.g. a second Slack post). Mirrors the web/slack restart-resume path.
|
|
276
320
|
* No-op if the schedule is unknown or already running.
|
|
277
321
|
*/
|
|
278
|
-
export function resumeRun(scheduleId: string, sessionId: string, prompt: string):
|
|
322
|
+
export function resumeRun(scheduleId: string, sessionId: string, prompt: string): RunOutcome {
|
|
279
323
|
const s = getSchedule(scheduleId);
|
|
280
324
|
if (!s) {
|
|
281
325
|
console.warn(`[scheduler] resumeRun: unknown schedule ${scheduleId}`);
|
|
282
|
-
return
|
|
326
|
+
return refuse('unknown-schedule', `No schedule ${scheduleId}`);
|
|
283
327
|
}
|
|
284
328
|
if (state.running.has(scheduleId)) {
|
|
285
329
|
console.log(`[scheduler] resumeRun: ${scheduleId} already running — skipping resume`);
|
|
286
|
-
return
|
|
330
|
+
return refuse('already-running', `${s.name} is already running`);
|
|
331
|
+
}
|
|
332
|
+
const window = interruptedWindow(s);
|
|
333
|
+
// A resume continues an existing conversation rather than starting a fresh one, so it dodges
|
|
334
|
+
// the duplicate-side-effect problem — but it does NOT dodge the "is this work still the work
|
|
335
|
+
// the schedule asked for" problem. Finishing the 08:00 report at 22:00 is the incident. So the
|
|
336
|
+
// resume path answers to the same onMissed policy and the same staleness ceiling as catch-up —
|
|
337
|
+
// for EVERY trigger kind, not just cron (interval/once/event have no catch-up path at all, so
|
|
338
|
+
// resume is their only gate).
|
|
339
|
+
if (window !== null) {
|
|
340
|
+
// Someone already finished this window — typically a catch-up that won the boot race and has
|
|
341
|
+
// since completed, so the run lock it held is gone. Resuming now would redo work that is
|
|
342
|
+
// already done: the duplicate fire, one step later. (An *attempt* on this window is NOT a
|
|
343
|
+
// refusal — the interrupted run we are resuming recorded one itself.)
|
|
344
|
+
const marker = readCompletionMarker(scheduleId);
|
|
345
|
+
if (marker && marker.completedAt >= window) {
|
|
346
|
+
const msg = `window ${new Date(window).toISOString()} already completed at ${new Date(marker.completedAt).toISOString()} by ${marker.triggeredBy}`;
|
|
347
|
+
console.log(`[scheduler] not resuming ${scheduleId} — ${msg}`);
|
|
348
|
+
return refuse('already-completed', msg);
|
|
349
|
+
}
|
|
350
|
+
const policy = missedPolicy(s);
|
|
351
|
+
if (policy !== 'run') {
|
|
352
|
+
const msg = `window ${new Date(window).toISOString()} was missed and onMissed=${policy}`;
|
|
353
|
+
console.log(`[scheduler] not resuming ${scheduleId} — ${msg}`);
|
|
354
|
+
noteMissed(s, window, policy);
|
|
355
|
+
return refuse(policy === 'skip' ? 'policy-skip' : 'policy-offer', msg);
|
|
356
|
+
}
|
|
357
|
+
const age = Date.now() - window;
|
|
358
|
+
const grace = maxMissedAgeMs();
|
|
359
|
+
if (age > grace) {
|
|
360
|
+
const msg = `window ${new Date(window).toISOString()} is ${Math.round(age / 60_000)}m stale (ceiling ${Math.round(grace / 60_000)}m)`;
|
|
361
|
+
console.log(`[scheduler] not resuming ${scheduleId} — ${msg}`);
|
|
362
|
+
noteMissed(s, window, 'stale');
|
|
363
|
+
return refuse('stale', msg);
|
|
364
|
+
}
|
|
287
365
|
}
|
|
288
366
|
console.log(`[scheduler] resuming ${scheduleId} in-place on session ${sessionId.slice(0, 30)}…`);
|
|
289
|
-
return startRun(s, Date.now(), undefined, { sessionId, prompt });
|
|
367
|
+
return startRun(s, window ?? Date.now(), undefined, { sessionId, prompt });
|
|
290
368
|
}
|
|
291
369
|
|
|
292
370
|
// ── Internals ───────────────────────────────────────────────────────────────
|
|
293
371
|
|
|
372
|
+
const MISSED_POLICIES: MissedPolicy[] = ['run', 'skip', 'offer'];
|
|
373
|
+
|
|
374
|
+
function refuse(reason: RunRefusal, message: string): RunOutcome {
|
|
375
|
+
return { ok: false, reason, message };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The window the interrupted run was covering — what "how late is this?" is measured against.
|
|
380
|
+
*
|
|
381
|
+
* For `cron` the window is derivable from the expression itself (`computePrevRun`), and that is
|
|
382
|
+
* preferred: it is correct even for legacy state written before markers recorded a window.
|
|
383
|
+
*
|
|
384
|
+
* `interval`/`once`/`event` have no schedule-derivable grid — but they are not therefore timeless.
|
|
385
|
+
* An interval run interrupted 14h ago is exactly as stale as a cron one, and it has no catch-up
|
|
386
|
+
* path to be caught by. So for those the window is the one the interrupted run ITSELF claimed,
|
|
387
|
+
* read back off the markers already on disk: the run lock's `window` (stamped by `acquireRunLock`
|
|
388
|
+
* at fire time), else its `startedAt`, else the attempt ledger, else the at-start `lastRun.at`.
|
|
389
|
+
* Only a schedule with no trace of ever having started has no window — and nothing to resume.
|
|
390
|
+
*/
|
|
391
|
+
function interruptedWindow(s: Schedule): number | null {
|
|
392
|
+
const prev = computePrevRun(s.trigger);
|
|
393
|
+
if (prev !== null) return prev;
|
|
394
|
+
const running = readRunningMarker(s.id);
|
|
395
|
+
if (running?.window !== undefined) return running.window;
|
|
396
|
+
if (running?.startedAt) return running.startedAt;
|
|
397
|
+
const marker = readCompletionMarker(s.id);
|
|
398
|
+
if (marker?.attemptWindow !== undefined) return marker.attemptWindow;
|
|
399
|
+
if (marker?.lastAttemptAt) return marker.lastAttemptAt;
|
|
400
|
+
return s.lastRun?.at ?? null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Persist the terminal outcome of an attempt without touching the last SUCCESSFUL completion.
|
|
404
|
+
* Every non-ok exit — reported failure or unexpected throw — must land here, else the marker
|
|
405
|
+
* stays `started` forever and a crash is indistinguishable from a throw. */
|
|
406
|
+
function recordAttemptOutcome(scheduleId: string, firedAt: number, at: number, status: 'error' | 'aborted' | 'started'): void {
|
|
407
|
+
const prev = readCompletionMarker(scheduleId);
|
|
408
|
+
writeCompletionMarker({
|
|
409
|
+
completedAt: prev?.completedAt ?? 0,
|
|
410
|
+
triggeredBy: 'scheduler',
|
|
411
|
+
scheduleId,
|
|
412
|
+
lastAttemptAt: prev?.lastAttemptAt ?? at,
|
|
413
|
+
attemptWindow: prev?.attemptWindow ?? firedAt,
|
|
414
|
+
status,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Default is `run`: it is what every existing schedule already does, and schedules.json has no
|
|
419
|
+
* `onMissed` field on any of them — defaulting to anything else would silently change the
|
|
420
|
+
* behaviour of live automations on upgrade. The unbounded-replay hazard that made `run`
|
|
421
|
+
* dangerous is fixed by the staleness ceiling below, which applies to `run` too. */
|
|
422
|
+
function missedPolicy(s: Schedule): MissedPolicy {
|
|
423
|
+
return s.onMissed ?? 'run';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function noteMissed(s: Schedule, at: number, reason: 'skip' | 'offer' | 'stale'): void {
|
|
427
|
+
s.missedRun = { at, reason, noticedAt: Date.now() };
|
|
428
|
+
saveSchedules(state.schedules);
|
|
429
|
+
state.broadcast({ type: 'schedule:updated', schedule: s });
|
|
430
|
+
}
|
|
431
|
+
|
|
294
432
|
function replan(): void {
|
|
295
433
|
if (!schedulerActive) return;
|
|
296
434
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
@@ -336,21 +474,22 @@ function fireDue(): void {
|
|
|
336
474
|
replan();
|
|
337
475
|
}
|
|
338
476
|
|
|
339
|
-
function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = false, eventCtx?: EventContext):
|
|
340
|
-
// Skip if this cron period was already completed
|
|
341
|
-
//
|
|
477
|
+
function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = false, eventCtx?: EventContext): RunOutcome {
|
|
478
|
+
// Skip if this cron period was already completed/attempted. Manual runs (runNow from UI/API)
|
|
479
|
+
// always proceed past the period guard — but never past the run lock in startRun().
|
|
342
480
|
if (!manual && s.trigger.kind === 'cron') {
|
|
343
481
|
const prev = computePrevRun(s.trigger, firedAt + 1);
|
|
344
482
|
if (prev !== null) {
|
|
345
483
|
const marker = readCompletionMarker(s.id);
|
|
346
484
|
if (marker && marker.completedAt >= prev) {
|
|
347
|
-
|
|
348
|
-
|
|
485
|
+
const msg = `already completed this period (at ${new Date(marker.completedAt).toISOString()})`;
|
|
486
|
+
console.log(`[scheduler] skipping fire for ${s.id} — ${msg}`);
|
|
487
|
+
return refuse('already-completed', msg);
|
|
349
488
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
console.log(`[scheduler] skipping fire for ${s.id} —
|
|
353
|
-
return
|
|
489
|
+
if (marker?.attemptWindow !== undefined && marker.attemptWindow >= prev) {
|
|
490
|
+
const msg = `this period was already attempted (${marker.status ?? 'started'})`;
|
|
491
|
+
console.log(`[scheduler] skipping fire for ${s.id} — ${msg}`);
|
|
492
|
+
return refuse('already-attempted', msg);
|
|
354
493
|
}
|
|
355
494
|
}
|
|
356
495
|
}
|
|
@@ -369,10 +508,36 @@ function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = f
|
|
|
369
508
|
console.warn(`[scheduler] queue overflow for ${s.id} (cap=${QUEUE_CAP}), dropped fire @ ${dropped?.firedAt}`);
|
|
370
509
|
}
|
|
371
510
|
state.queues.set(s.id, q);
|
|
372
|
-
return null;
|
|
511
|
+
return { ok: true, sessionId: null, queued: true };
|
|
373
512
|
}
|
|
374
513
|
|
|
375
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Start one run, holding the cross-restart run lock for its whole life.
|
|
516
|
+
*
|
|
517
|
+
* Every path that can start a run funnels through here — timer fire, catch-up, event, manual
|
|
518
|
+
* runNow, and the in-place resume from crash recovery — so the lock is the single place that
|
|
519
|
+
* enforces "one live run per schedule". It is also what makes catch-up and recovery mutually
|
|
520
|
+
* exclusive: whichever reaches this first holds a live pid, and the other's acquire fails.
|
|
521
|
+
*/
|
|
522
|
+
function startRun(s: Schedule, firedAt: number, override?: string, resume?: ResumeOptions, eventCtx?: EventContext): RunOutcome {
|
|
523
|
+
const lock = acquireRunLock(s.id, firedAt);
|
|
524
|
+
if (!lock) {
|
|
525
|
+
const held = readRunningMarker(s.id);
|
|
526
|
+
const msg = `run lock held by pid ${held?.pid} since ${new Date(held?.startedAt ?? 0).toISOString()}`;
|
|
527
|
+
console.log(`[scheduler] not starting ${s.id} — ${msg}`);
|
|
528
|
+
return refuse('locked', msg);
|
|
529
|
+
}
|
|
530
|
+
// Record the attempt BEFORE running: a crash from here on must not look like "never tried".
|
|
531
|
+
markRunStarted(s.id, firedAt);
|
|
532
|
+
const pre = getSchedule(s.id);
|
|
533
|
+
if (pre) {
|
|
534
|
+
// Advance lastRun at start, not only on success. start() turns a leftover 'running' into
|
|
535
|
+
// 'error' on the next boot, so the distinction survives while the timestamp still blocks a
|
|
536
|
+
// replay of this window.
|
|
537
|
+
pre.lastRun = { at: Date.now(), sessionId: resume?.sessionId ?? '', status: 'running' };
|
|
538
|
+
pre.missedRun = undefined;
|
|
539
|
+
saveSchedules(state.schedules);
|
|
540
|
+
}
|
|
376
541
|
// Deep-copy task so edits mid-run don't affect the in-flight execution
|
|
377
542
|
const snapshot: Schedule = JSON.parse(JSON.stringify(s));
|
|
378
543
|
let sessionId: string | null = null;
|
|
@@ -380,6 +545,14 @@ function startRun(s: Schedule, _firedAt: number, override?: string, resume?: Res
|
|
|
380
545
|
const register = (sid: string, ac: AbortController) => {
|
|
381
546
|
sessionId = sid;
|
|
382
547
|
state.running.set(s.id, ac);
|
|
548
|
+
// Backfill the session link onto the at-start lastRun. Without this a crash mid-run persists
|
|
549
|
+
// an errored run with sessionId '' — no way back to the conversation that was interrupted,
|
|
550
|
+
// which is exactly what the recovery path needs.
|
|
551
|
+
const live = getSchedule(s.id);
|
|
552
|
+
if (live?.lastRun && live.lastRun.status === 'running' && !live.lastRun.sessionId) {
|
|
553
|
+
live.lastRun.sessionId = sid;
|
|
554
|
+
saveSchedules(state.schedules);
|
|
555
|
+
}
|
|
383
556
|
};
|
|
384
557
|
|
|
385
558
|
state.broadcast({ type: 'schedule:fired', scheduleId: s.id });
|
|
@@ -393,8 +566,13 @@ function startRun(s: Schedule, _firedAt: number, override?: string, resume?: Res
|
|
|
393
566
|
saveSchedules(state.schedules);
|
|
394
567
|
state.broadcast({ type: 'schedule:updated', schedule: live });
|
|
395
568
|
}
|
|
569
|
+
if (summary.status !== 'ok') {
|
|
570
|
+
// Keep the attempt on record with its real outcome — the next boot must see that this
|
|
571
|
+
// window was tried and failed, not that it never ran.
|
|
572
|
+
recordAttemptOutcome(s.id, firedAt, summary.at, summary.status === 'running' ? 'started' : summary.status);
|
|
573
|
+
}
|
|
396
574
|
if (summary.status === 'ok') {
|
|
397
|
-
writeCompletionMarker({ completedAt: summary.at, triggeredBy: 'scheduler', scheduleId: s.id });
|
|
575
|
+
writeCompletionMarker({ completedAt: summary.at, triggeredBy: 'scheduler', scheduleId: s.id, lastAttemptAt: summary.at, attemptWindow: firedAt, status: 'ok' });
|
|
398
576
|
if (live && live.trigger.kind === 'once') {
|
|
399
577
|
console.log(`[scheduler] auto-deleting completed once-schedule ${s.id}`);
|
|
400
578
|
deleteSchedule(s.id);
|
|
@@ -422,9 +600,23 @@ function startRun(s: Schedule, _firedAt: number, override?: string, resume?: Res
|
|
|
422
600
|
})
|
|
423
601
|
.catch((err) => {
|
|
424
602
|
console.error(`[scheduler] unexpected run failure for ${s.id}:`, err);
|
|
603
|
+
// A rejection never reaches the .then above, so without this the attempt marker stays
|
|
604
|
+
// 'started' forever and an unexpected throw is indistinguishable from a power cut.
|
|
605
|
+
const at = Date.now();
|
|
606
|
+
recordAttemptOutcome(s.id, firedAt, at, 'error');
|
|
607
|
+
const live = getSchedule(s.id);
|
|
608
|
+
if (live) {
|
|
609
|
+
live.lastRun = { at, sessionId: live.lastRun?.sessionId ?? '', status: 'error', error: `unexpected run failure: ${err?.message ?? String(err)}` };
|
|
610
|
+
saveSchedules(state.schedules);
|
|
611
|
+
state.broadcast({ type: 'schedule:updated', schedule: live });
|
|
612
|
+
}
|
|
425
613
|
})
|
|
426
614
|
.finally(() => {
|
|
427
615
|
state.running.delete(s.id);
|
|
616
|
+
// Release the lock on EVERY exit path — ok, error, abort, or an unexpected throw. The
|
|
617
|
+
// runner already clears it on its own terminal states; this is idempotent and covers the
|
|
618
|
+
// paths that never reach the runner's finally.
|
|
619
|
+
clearRunningMarker(s.id);
|
|
428
620
|
const q = state.queues.get(s.id);
|
|
429
621
|
if (q && q.length > 0) {
|
|
430
622
|
const next = q.shift()!;
|
|
@@ -434,5 +626,5 @@ function startRun(s: Schedule, _firedAt: number, override?: string, resume?: Res
|
|
|
434
626
|
}
|
|
435
627
|
});
|
|
436
628
|
|
|
437
|
-
return sessionId;
|
|
629
|
+
return { ok: true, sessionId };
|
|
438
630
|
}
|
|
@@ -6,7 +6,7 @@ import { streamChat, type PermissionHandler } from '../claude.ts';
|
|
|
6
6
|
import { getMcpConfig } from '../mcp.ts';
|
|
7
7
|
import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
|
|
8
8
|
import type { Schedule, ScheduleRunSummary } from './types.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { updateRunLockPid, clearRunningMarker } from './storage.ts';
|
|
10
10
|
import { addUnread } from '../unread.ts';
|
|
11
11
|
|
|
12
12
|
export interface RunContext {
|
|
@@ -114,8 +114,8 @@ export async function runSchedule(
|
|
|
114
114
|
acquireSessionLock(sessionId, 'scheduler', abortController);
|
|
115
115
|
setRunStatus(sessionId, 'running', 'scheduler');
|
|
116
116
|
onEvent({ type: 'session_busy', sessionId, busy: true });
|
|
117
|
-
//
|
|
118
|
-
|
|
117
|
+
// The run lock (running marker) is acquired by the engine BEFORE this point — see
|
|
118
|
+
// engine.startRun. Writing it here too would let a direct runSchedule() call bypass the lock.
|
|
119
119
|
|
|
120
120
|
const task = schedule.task;
|
|
121
121
|
if (task.kind === 'job') {
|
|
@@ -414,9 +414,9 @@ function runCommandWithMarker(command: string, abortController: AbortController,
|
|
|
414
414
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
417
|
+
// Re-point the held lock at the child: the job outlives nothing here, but if the server dies
|
|
418
|
+
// the child may still be alive, and a live pid must keep the schedule locked.
|
|
419
|
+
if (child.pid) updateRunLockPid(scheduleId, child.pid);
|
|
420
420
|
|
|
421
421
|
let output = '';
|
|
422
422
|
const handleData = (chunk: Buffer) => {
|
|
@@ -96,3 +96,77 @@ export function clearRunningMarker(scheduleId: string): void {
|
|
|
96
96
|
export function isProcessAlive(pid: number): boolean {
|
|
97
97
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/** How long a held run lock is believed, before it is treated as abandoned regardless of whether
|
|
101
|
+
* its pid answers. Bounds the blast radius of pid reuse: after a power cut the OS restarts pid
|
|
102
|
+
* allocation low, so a persisted pid can plausibly be live again under the same uid — and a
|
|
103
|
+
* liveness check alone would then wedge the schedule forever. Generous vs any real run (agent
|
|
104
|
+
* runs are minutes, not hours) while capping the wedge at one window's worth of a daily job. */
|
|
105
|
+
const runLockMaxAgeMs = () => Number(process.env.SCHEDULER_RUN_LOCK_MAX_AGE_MS ?? 6 * 60 * 60 * 1000);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Claim the single live-run slot for a schedule.
|
|
109
|
+
*
|
|
110
|
+
* The lock IS the running marker — same file, same conventions — so it survives a process
|
|
111
|
+
* restart: after a crash the marker is still on disk but its pid is dead, and a dead pid is
|
|
112
|
+
* reclaimable (otherwise a power cut would wedge the schedule forever). A LIVE pid, ours or
|
|
113
|
+
* another instance's, means someone is already running this schedule: the caller must back off —
|
|
114
|
+
* UNLESS the claim is older than `runLockMaxAgeMs`, which is the escape hatch for a lock wedged
|
|
115
|
+
* by pid reuse. A live, in-ceiling claim is never stolen, not even by a manual run: on this
|
|
116
|
+
* single-active-instance design that pid is a real run (an agent session, or a spawned job the
|
|
117
|
+
* lock was re-pointed at), and starting a second one is the duplicate-fire we are preventing.
|
|
118
|
+
*
|
|
119
|
+
* Single-writer by design (only the DATA_SYNC_SCHEDULER_ACTIVE instance fires), so this is a
|
|
120
|
+
* read-then-write, not an atomic CAS.
|
|
121
|
+
*/
|
|
122
|
+
export function acquireRunLock(scheduleId: string, window: number): RunningMarker | null {
|
|
123
|
+
const existing = readRunningMarker(scheduleId);
|
|
124
|
+
if (existing) {
|
|
125
|
+
const alive = isProcessAlive(existing.pid);
|
|
126
|
+
const age = Date.now() - (Number.isFinite(existing.startedAt) ? existing.startedAt : 0);
|
|
127
|
+
const maxAge = runLockMaxAgeMs();
|
|
128
|
+
if (alive && age <= maxAge) return null;
|
|
129
|
+
const why = !alive
|
|
130
|
+
? `dead pid ${existing.pid}`
|
|
131
|
+
: `held ${Math.round(age / 60_000)}m by live pid ${existing.pid}, past the ${Math.round(maxAge / 60_000)}m lock ceiling — assuming pid reuse`;
|
|
132
|
+
console.log(`[scheduler] reclaiming run lock for ${scheduleId} — ${why} (window ${new Date(existing.window ?? existing.startedAt).toISOString()})`);
|
|
133
|
+
}
|
|
134
|
+
const marker: RunningMarker = { pid: process.pid, startedAt: Date.now(), scheduleId, window };
|
|
135
|
+
writeRunningMarker(marker);
|
|
136
|
+
return marker;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Re-point a held lock at a spawned child process. Only ever touches a lock THIS process holds,
|
|
140
|
+
* and preserves `startedAt` so re-pointing can't refresh the age ceiling above. */
|
|
141
|
+
export function updateRunLockPid(scheduleId: string, pid: number): void {
|
|
142
|
+
const existing = readRunningMarker(scheduleId);
|
|
143
|
+
if (!existing) {
|
|
144
|
+
console.warn(`[scheduler] updateRunLockPid(${scheduleId}): no lock held — not creating one`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (existing.pid !== process.pid) {
|
|
148
|
+
console.warn(`[scheduler] updateRunLockPid(${scheduleId}): lock is held by pid ${existing.pid}, not us — leaving it alone`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
writeRunningMarker({ ...existing, scheduleId, pid });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Record that a run STARTED for `window`, before it can succeed or fail.
|
|
156
|
+
*
|
|
157
|
+
* Without this a run that errors or is killed leaves no trace on disk, so the next boot sees an
|
|
158
|
+
* un-completed window and replays it — the double-fire this ledger exists to prevent. The
|
|
159
|
+
* successful-completion timestamp is preserved untouched so "started" stays distinguishable
|
|
160
|
+
* from "completed ok".
|
|
161
|
+
*/
|
|
162
|
+
export function markRunStarted(scheduleId: string, window: number, triggeredBy: CompletionMarker['triggeredBy'] = 'scheduler'): void {
|
|
163
|
+
const prev = readCompletionMarker(scheduleId);
|
|
164
|
+
writeCompletionMarker({
|
|
165
|
+
completedAt: prev?.completedAt ?? 0,
|
|
166
|
+
triggeredBy,
|
|
167
|
+
scheduleId,
|
|
168
|
+
lastAttemptAt: Date.now(),
|
|
169
|
+
attemptWindow: window,
|
|
170
|
+
status: 'started',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -34,16 +34,77 @@ export interface ScheduleRunSummary {
|
|
|
34
34
|
error?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** What the scheduler did about a window it found already elapsed at boot.
|
|
38
|
+
* - `run` replay it (today's behaviour, and the default — see engine.ts).
|
|
39
|
+
* - `skip` never replay; the window is simply lost.
|
|
40
|
+
* - `offer` don't auto-fire, but record it on the schedule (`missedRun`) so a human can
|
|
41
|
+
* see it was missed and run it on demand. */
|
|
42
|
+
export type MissedPolicy = 'run' | 'skip' | 'offer';
|
|
43
|
+
|
|
44
|
+
/** A window that elapsed while nothing was running and was NOT replayed.
|
|
45
|
+
* Persisted on the schedule and returned verbatim by `GET /api/schedules[/:id]` — that read
|
|
46
|
+
* path IS the affordance `offer` promises: a human (or the agent, via the scheduler skill) sees
|
|
47
|
+
* the missed window and can `POST /api/schedules/:id/run` it on demand. */
|
|
48
|
+
export interface MissedRun {
|
|
49
|
+
/** The window (fire time) that was missed. */
|
|
50
|
+
at: number;
|
|
51
|
+
reason: 'skip' | 'offer' | 'stale';
|
|
52
|
+
/** When the scheduler noticed — i.e. boot time. Read by whoever acts on the miss: `at` alone
|
|
53
|
+
* can't tell "missed 20m ago, still worth running" from "found on a boot two days later". */
|
|
54
|
+
noticedAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Why a start request did not start a run. */
|
|
58
|
+
export type RunRefusal =
|
|
59
|
+
| 'unknown-schedule'
|
|
60
|
+
| 'already-running'
|
|
61
|
+
| 'already-completed'
|
|
62
|
+
| 'already-attempted'
|
|
63
|
+
| 'policy-skip'
|
|
64
|
+
| 'policy-offer'
|
|
65
|
+
| 'stale'
|
|
66
|
+
| 'locked';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Result of asking the engine to start a run (timer fire, catch-up, manual, event, resume).
|
|
70
|
+
* A refusal carries WHY, so the caller can record a truthful error and map a real HTTP status
|
|
71
|
+
* instead of collapsing every outcome into "null".
|
|
72
|
+
*/
|
|
73
|
+
export type RunOutcome =
|
|
74
|
+
| { ok: true; sessionId: string | null; queued?: boolean }
|
|
75
|
+
| { ok: false; reason: RunRefusal; message: string };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Per-schedule attempt/completion ledger. `completedAt` records the last SUCCESSFUL run;
|
|
79
|
+
* `lastAttemptAt`/`attemptWindow` record the last run that STARTED, whether or not it finished.
|
|
80
|
+
* Both are needed: completion drives "this period is done", attempt drives "this period was
|
|
81
|
+
* already tried, don't replay it after a crash".
|
|
82
|
+
*/
|
|
37
83
|
export interface CompletionMarker {
|
|
84
|
+
/** Last successful completion; 0 when the schedule has never completed. */
|
|
38
85
|
completedAt: number;
|
|
39
86
|
triggeredBy: 'scheduler' | 'ssh' | 'api' | 'manual';
|
|
40
87
|
scheduleId: string;
|
|
88
|
+
/** Wall-clock time the last attempt began. */
|
|
89
|
+
lastAttemptAt?: number;
|
|
90
|
+
/** The window (fire time) that attempt was covering. */
|
|
91
|
+
attemptWindow?: number;
|
|
92
|
+
/** Terminal state of the last attempt; `started` means it never reported back (crash). */
|
|
93
|
+
status?: 'started' | 'ok' | 'error' | 'aborted';
|
|
41
94
|
}
|
|
42
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The run lock: at most one live run per scheduleId, across process restarts.
|
|
98
|
+
* Written by `acquireRunLock` before a run starts, removed when it reaches a terminal state.
|
|
99
|
+
* A lock whose `pid` is dead (crash / power loss) is reclaimable — see `acquireRunLock`.
|
|
100
|
+
*/
|
|
43
101
|
export interface RunningMarker {
|
|
44
102
|
pid: number;
|
|
45
103
|
startedAt: number;
|
|
46
104
|
scheduleId: string;
|
|
105
|
+
/** The window (fire time) this run covers — lets a claimer see WHAT is held, not just that
|
|
106
|
+
* something is. */
|
|
107
|
+
window?: number;
|
|
47
108
|
}
|
|
48
109
|
|
|
49
110
|
export interface Schedule {
|
|
@@ -59,6 +120,10 @@ export interface Schedule {
|
|
|
59
120
|
nextRun?: number;
|
|
60
121
|
lastRun?: ScheduleRunSummary;
|
|
61
122
|
runCount: number;
|
|
123
|
+
/** What to do with a window that elapsed while the process was down. Absent ⇒ 'run'. */
|
|
124
|
+
onMissed?: MissedPolicy;
|
|
125
|
+
/** Last window that elapsed and was deliberately not replayed (policy or staleness). */
|
|
126
|
+
missedRun?: MissedRun;
|
|
62
127
|
/** Set when a data-plane module owns this schedule (module name). Module reconcile
|
|
63
128
|
* updates trigger/task; enable/disable snapshots live in the module's state entry. */
|
|
64
129
|
managedBy?: string;
|
package/src/server/slack/bot.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { pipeAgentReply, type AgentEvent, type IngressMessage } from 'mcp-slack-
|
|
|
21
21
|
import { makeSlackQuestionHandler } from './questions.ts';
|
|
22
22
|
import * as contacts from '../contacts.ts';
|
|
23
23
|
import { getChannelContext, invalidateChannelContext } from './context-cache.ts';
|
|
24
|
+
import { noteSlackSeen } from '../downtime.ts';
|
|
24
25
|
import { getOrCreateSession, registerThreadAlias, setLastMessageTs, setUseUserToken, findSlackSessionBySessionId, getProactiveOrigin, hasSessionForThread, isSlackBotPlaceholderEmail } from './sessions.ts';
|
|
25
26
|
|
|
26
27
|
const MAX_DOWNLOAD_SIZE = 25 * 1024 * 1024;
|
|
@@ -45,6 +46,10 @@ const mdText = (b: ConvBlock): b is { type: 'text'; text: string } => b.type ===
|
|
|
45
46
|
// half: agent-channel summon rules + threads the agent already owns. Referenced app session state
|
|
46
47
|
// (proactive origins, known threads) is why it can't live in the package.
|
|
47
48
|
export function shouldRespond(msg: IngressMessage): boolean {
|
|
49
|
+
// Advance the per-channel last-seen cursor for EVERY message we're handed, answered or not:
|
|
50
|
+
// "seen" is the honest baseline for downtime backfill, and it is also how we learn which
|
|
51
|
+
// channels exist at all. Purely a bookkeeping write — it never affects the gate below.
|
|
52
|
+
noteSlackSeen(msg.channel, msg.ts);
|
|
48
53
|
const isAgentChannel = msg.channel === AGENT_CHANNEL;
|
|
49
54
|
const isAgentOriginatedThread = msg.isThreadReply && !!(msg.rawThreadTs && getProactiveOrigin(msg.channel, msg.rawThreadTs));
|
|
50
55
|
const isKnownAgentChannelThread = isAgentChannel && msg.isThreadReply && !!(msg.rawThreadTs && hasSessionForThread(msg.channel, msg.rawThreadTs));
|