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.
@@ -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.30",
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",
@@ -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 sessionId = scheduler.runNow(id, override);
473
- if (!sessionId) return res.status(404).json({ error: 'Not found' });
474
- res.json({ sessionId });
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
+ }
@@ -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({ ok: true, id });
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 < prev) {
82
- const marker = readCompletionMarker(s.id);
83
- if (marker && marker.completedAt >= prev) {
84
- console.log(`[scheduler] skipping catch-up for ${s.id} already completed at ${new Date(marker.completedAt).toISOString()} by ${marker.triggeredBy}`);
85
- continue;
86
- }
87
- const running = readRunningMarker(s.id);
88
- if (running && isProcessAlive(running.pid)) {
89
- console.log(`[scheduler] skipping catch-up for ${s.id} still running (pid ${running.pid}, started ${new Date(running.startedAt).toISOString()})`);
90
- continue;
91
- }
92
- catchUps.push(s.id);
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 10s for MCP init)`);
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
- console.log(`[scheduler] catch-up: firing ${id}`);
102
- runNow(id);
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
- }, 10_000);
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): string | null {
227
+ export function runNow(id: string, override?: string): RunOutcome {
184
228
  const s = getSchedule(id);
185
- if (!s) return null;
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): string | null {
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 null;
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 null;
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): string | null {
340
- // Skip if this cron period was already completed or still running from a prior server instance.
341
- // Manual runs (runNow from UI/API) always proceed.
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
- console.log(`[scheduler] skipping fire for ${s.id} — already completed this period (at ${new Date(marker.completedAt).toISOString()})`);
348
- return null;
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
- const running = readRunningMarker(s.id);
351
- if (running && isProcessAlive(running.pid)) {
352
- console.log(`[scheduler] skipping fire for ${s.id} — still running (pid ${running.pid})`);
353
- return null;
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
- function startRun(s: Schedule, _firedAt: number, override?: string, resume?: ResumeOptions, eventCtx?: EventContext): string | null {
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 { writeRunningMarker, clearRunningMarker } from './storage.ts';
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
- // Mark this period as running with the live pid so startup catch-up won't double-fire it.
118
- writeRunningMarker({ pid: process.pid, startedAt: now, scheduleId: schedule.id });
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
- if (child.pid) {
418
- writeRunningMarker({ pid: child.pid, startedAt: Date.now(), scheduleId });
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;
@@ -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));