shraga 0.1.65 → 0.1.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
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",
@@ -0,0 +1,511 @@
1
+ // Background shell jobs that OUTLIVE the turn that started them.
2
+ //
3
+ // The bug this exists for: an agent dispatches long work, promises "I'll report back", and the turn
4
+ // is then cut at the engine's wall clock. The work finishes fine — and nobody is left to say so.
5
+ // Any dispatch-and-poll pattern loses that race whenever the work outlasts the remaining budget, so
6
+ // polling is not the fix; something has to WAKE the session when the process exits.
7
+ //
8
+ // Shape:
9
+ // • The registry is owned by the SERVER (module singleton), not by the turn. agentx builds a fresh
10
+ // `Agent` per turn, so a registry created there would die with the turn — the very failure above.
11
+ // Hosts hand each turn a thin per-session VIEW (`sessionJobRegistry`) over this one store; the
12
+ // view is duck-compatible with agentx's `ShellJobRegistry`, so `Shell({background:true})` and the
13
+ // ShellOutput/ShellStatus/ShellKill tools light up with no folklore `nohup`.
14
+ // • On exit we report through wake.ts — the same "close then report" path polls.ts already uses in
15
+ // production, so a job outcome lands wherever the session speaks (Slack thread / web UI).
16
+ // • No double-report: reading a finished job's OUTPUT from inside a turn marks it `observed` — the
17
+ // model had the result in hand and can speak for itself. Only that. `status`/`list` return no
18
+ // output, so seeing "exited" there is not holding the result; marking those observed suppressed
19
+ // the follow-up and lost the outcome entirely. We wake for every job whose output nobody read.
20
+ // (Known narrow gap: a turn that reads the output and is then cut before it speaks gets no
21
+ // follow-up. Much smaller window than the one being fixed.)
22
+ // • No collision with a live turn: the report defers while `isSessionLocked` — with a cap, past
23
+ // which it is delivered as plain text rather than never.
24
+ //
25
+ // Restart survival is PARTIAL and deliberately visible. Children are detached, so they keep running
26
+ // across a server restart and their output keeps landing in the job's log file; boot re-adopts them
27
+ // by pid and reports on exit as usual. What cannot survive is the exit CODE of a job that finished
28
+ // while we were down — nothing was waiting on the process. Those are reported with an explicit
29
+ // "exit code unknown (server restarted)" instead of a fabricated success.
30
+ import { spawn, execFileSync } from 'node:child_process';
31
+ import { mkdirSync, openSync, closeSync, readSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'node:fs';
32
+ import path from 'node:path';
33
+ import { dataPath } from './paths.ts';
34
+ import { isSessionLocked, getSession } from './sessions.ts';
35
+ import { wakeSession, deliverToSession, wakeReady } from './wake.ts';
36
+
37
+ const PREFIX = '[jobs]';
38
+
39
+ // ── Bounds. Every one of these caps a blast radius the incident's `nohup` had none of. ──
40
+ /** Hard cap on a job's log, enforced by `head -c` inside the job's own pipeline (see buildShell).
41
+ * Polling a size and killing late cannot bound anything: at a 5s cadence an 8MB cap measured 830MB
42
+ * on disk. `head` closes the pipe at exactly this many bytes instead — the writer then dies of
43
+ * SIGPIPE, so the bound is the kernel's, not a timer's. */
44
+ const MAX_LOG_BYTES = 8 * 1024 * 1024;
45
+ /** How much of the tail we ever read back into memory (for a tool result or a report). */
46
+ const TAIL_BYTES = 16 * 1024;
47
+ /** How much of that tail goes into the wake prompt (the rest stays readable via ShellOutput). */
48
+ const REPORT_CHARS = 4_000;
49
+ const MAX_RUNNING_PER_SESSION = 4;
50
+ const MAX_RUNNING_GLOBAL = 16;
51
+ /** Grace before reporting: lets a still-live turn poll the just-finished job and own the telling. */
52
+ const GRACE_MS = 8_000;
53
+ /** Re-check cadence while the session is busy, or while an adopted (post-restart) job still runs. */
54
+ const POLL_MS = 5_000;
55
+ /** Give up deferring behind a busy session after this long and deliver the outcome as plain text. */
56
+ const MAX_DEFER_MS = 30 * 60_000;
57
+ /** Delete finished job records + logs after this long. */
58
+ const PRUNE_AFTER_MS = 24 * 60 * 60_000;
59
+
60
+ export type JobStatus = 'running' | 'exited' | 'killed' | 'error';
61
+
62
+ export interface JobOwner {
63
+ sessionId: string;
64
+ uid: string;
65
+ userEmail?: string;
66
+ /** Working directory for the job's shell (the deployment/project root). */
67
+ cwd: string;
68
+ /** Extra env merged over the server's own for the child. */
69
+ env?: Record<string, string>;
70
+ }
71
+
72
+ interface JobRecord {
73
+ id: string;
74
+ sessionId: string;
75
+ uid: string;
76
+ userEmail?: string;
77
+ command: string;
78
+ cwd: string;
79
+ pid?: number;
80
+ startedAt: number;
81
+ status: JobStatus;
82
+ exitCode?: number;
83
+ endedAt?: number;
84
+ /** A turn read this job's terminal state — the model can report it itself, so we must not. */
85
+ observed?: boolean;
86
+ /** Terminal outcome of the follow-up delivery (absent = not attempted yet). */
87
+ reported?: 'woke' | 'raw' | 'skipped-observed' | 'no-session' | 'failed';
88
+ /** The exit code is unknown because the server restarted while this job ran. */
89
+ orphaned?: boolean;
90
+ /** The job hit MAX_LOG_BYTES: its output was cut off and the command was killed by SIGPIPE. */
91
+ truncated?: boolean;
92
+ }
93
+
94
+ // ── Storage ────────────────────────────────────────────────────────────────────
95
+ const dir = (): string => { const d = dataPath('jobs'); mkdirSync(d, { recursive: true }); return d; };
96
+ const recFile = (id: string): string => path.join(dir(), `${id}.json`);
97
+ const logFile = (id: string): string => path.join(dir(), `${id}.log`);
98
+ /** The command's REAL exit status, written by the job's own shell (see buildShell). */
99
+ const statusFile = (id: string): string => path.join(dir(), `${id}.status`);
100
+
101
+ /** The command's exit code as its shell recorded it, or null if it never got that far. */
102
+ function readStatus(id: string): number | null {
103
+ try {
104
+ const n = Number(readFileSync(statusFile(id), 'utf-8').trim());
105
+ return Number.isInteger(n) ? n : null;
106
+ } catch { return null; }
107
+ }
108
+
109
+ /** Current size of a job's log, 0 if it does not exist yet. */
110
+ function logSize(id: string): number {
111
+ try { return statSync(logFile(id)).size; } catch { return 0; }
112
+ }
113
+
114
+ /**
115
+ * Wrap the user's command so the log is bounded and the real exit code survives.
116
+ *
117
+ * `{ ( cmd ); echo $? > status; } 2>&1 | head -c N`, and every piece of that shape is load-bearing:
118
+ * • `head -c N` — the actual disk bound. It closes the pipe at exactly N bytes; the writer then
119
+ * takes SIGPIPE. Chosen over `ulimit -f`, which is also kernel-enforced but caps EVERY file the
120
+ * job writes — that would kill an ordinary build the moment it emitted a >8MB artifact.
121
+ * • the inner `( … )` subshell — a command ending in `exit 3` (agents write those) would otherwise
122
+ * exit the group before the status line ever ran, and we'd lose the code. Verified.
123
+ * • the status file — the pipeline's own exit code is `head`'s (always 0), so without this every
124
+ * job would report success. The shell is not the process writing to the pipe, so it survives the
125
+ * SIGPIPE that kills the command and still records the code.
126
+ * It also outlives us: the whole pipeline is in the job's detached process group, so a job adopted
127
+ * after a server restart can be given its true exit code instead of "unknown".
128
+ */
129
+ function buildShell(id: string, cmd: string): string {
130
+ return `{\n(\n${cmd}\n)\n__st=$?\necho $__st > '${statusFile(id)}'\n} 2>&1 | head -c ${MAX_LOG_BYTES}`;
131
+ }
132
+
133
+ /** In-memory mirror of the on-disk records (disk is the restart-durable copy; this is the hot path). */
134
+ const records = new Map<string, JobRecord>();
135
+ /** Live timers per job, so nothing is left ticking after a job is reported or the record pruned. */
136
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
137
+
138
+ function save(j: JobRecord): void {
139
+ records.set(j.id, j);
140
+ try { writeFileSync(recFile(j.id), JSON.stringify(j, null, 2)); }
141
+ catch (e) { console.error(`${PREFIX} persist ${j.id} failed:`, (e as Error).message); }
142
+ }
143
+
144
+ function setTimer(id: string, ms: number, fn: () => void): void {
145
+ clearTimer(id);
146
+ timers.set(id, setTimeout(() => { timers.delete(id); fn(); }, ms));
147
+ }
148
+ function clearTimer(id: string): void {
149
+ const t = timers.get(id);
150
+ if (t) { clearTimeout(t); timers.delete(id); }
151
+ }
152
+
153
+ /** Last `TAIL_BYTES` of a job's log, decoded as utf-8. Never loads the whole file. */
154
+ function readTail(id: string): string {
155
+ let fd: number | undefined;
156
+ try {
157
+ const size = statSync(logFile(id)).size;
158
+ const start = Math.max(0, size - TAIL_BYTES);
159
+ const len = size - start;
160
+ if (len <= 0) return '';
161
+ const buf = Buffer.allocUnsafe(len);
162
+ fd = openSync(logFile(id), 'r');
163
+ readSync(fd, buf, 0, len, start);
164
+ // A head-truncated read can split a multi-byte char — the replacement char is cosmetic, not corruption.
165
+ return (start > 0 ? `…(truncated, showing last ${TAIL_BYTES >> 10}KB)\n` : '') + buf.toString('utf-8');
166
+ } catch { return ''; }
167
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ } }
168
+ }
169
+
170
+ const runningCount = (sessionId?: string): number =>
171
+ [...records.values()].filter((j) => j.status === 'running' && (!sessionId || j.sessionId === sessionId)).length;
172
+
173
+ /**
174
+ * Seconds this pid has been alive, or null if there is no such process.
175
+ *
176
+ * `etime` (formatted `[[DD-]HH:]MM:SS`), NOT `etimes` (raw seconds): etimes is a procps/Linux
177
+ * extension and BSD ps — macOS, which is what the live box runs — rejects it outright, so the whole
178
+ * adoption check silently degraded to "process gone" and every job that survived a restart was
179
+ * reported as `exit code unknown`. etime is in POSIX and works on both.
180
+ */
181
+ function pidAgeSeconds(pid: number): number | null {
182
+ let out: string;
183
+ try {
184
+ // stderr piped: a missing pid is an expected outcome here, not something to print.
185
+ out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
186
+ } catch { return null; /* no such process */ }
187
+ const m = out.match(/^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/);
188
+ if (!m) return null;
189
+ const [, d, h, mi, sec] = m;
190
+ return Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(mi) * 60 + Number(sec);
191
+ }
192
+
193
+ /**
194
+ * Env for a job's shell: the server's own, minus anything that looks like a credential.
195
+ *
196
+ * agentx's foreground `Bash` redacts these by default (RealShellOptions.redactEnv). Backgrounding a
197
+ * command must not be a way around that — otherwise `Bash({background:true})` becomes a strictly
198
+ * weaker sandbox than `Bash({})`, and `echo $ANTHROPIC_API_KEY` lands in a job log we then feed back
199
+ * into the model. Mirrors agentx's SECRET_ENV_RE; a job that genuinely needs a credential gets it
200
+ * explicitly via `JobOwner.env`.
201
+ */
202
+ const SECRET_ENV_RE = /(API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$|^SLACK_)/i;
203
+ function childEnv(): Record<string, string | undefined> {
204
+ const out: Record<string, string | undefined> = {};
205
+ for (const [k, v] of Object.entries(process.env)) if (!SECRET_ENV_RE.test(k)) out[k] = v;
206
+ return out;
207
+ }
208
+
209
+ // ── Lifecycle ──────────────────────────────────────────────────────────────────
210
+
211
+ /** Start `command` detached, in its own process group, with stdout+stderr appended to the job log.
212
+ * Detached (setsid) for two reasons: the group is killable as a subtree, and the child is NOT torn
213
+ * down with the server — which is what makes restart adoption possible at all. */
214
+ export async function startJob(owner: JobOwner, command: string): Promise<string> {
215
+ const cmd = command.trim();
216
+ if (!cmd) throw new Error('empty command');
217
+ if (runningCount(owner.sessionId) >= MAX_RUNNING_PER_SESSION)
218
+ throw new Error(`too many background jobs in this session (max ${MAX_RUNNING_PER_SESSION}) — wait for one to finish or ShellKill it`);
219
+ if (runningCount() >= MAX_RUNNING_GLOBAL)
220
+ throw new Error(`too many background jobs on this server (max ${MAX_RUNNING_GLOBAL})`);
221
+
222
+ const id = `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
223
+ const j: JobRecord = {
224
+ id, sessionId: owner.sessionId, uid: owner.uid, userEmail: owner.userEmail,
225
+ command: cmd, cwd: owner.cwd, startedAt: Date.now(), status: 'running',
226
+ };
227
+ let fd: number;
228
+ try { fd = openSync(logFile(id), 'a'); }
229
+ catch (e) { throw new Error(`cannot open job log: ${(e as Error).message}`); }
230
+ try {
231
+ // stdin is /dev/null so a child can never block on (or steal) input; stdout+stderr go straight
232
+ // to the fd — nothing is buffered in this process, so a chatty job costs disk, not memory.
233
+ const proc = spawn('/bin/sh', ['-c', buildShell(id, cmd)], {
234
+ cwd: owner.cwd, env: { ...childEnv(), ...owner.env },
235
+ detached: true, stdio: ['ignore', fd, fd],
236
+ });
237
+ j.pid = proc.pid;
238
+ proc.on('error', (err) => finish(id, 'error', undefined, `spawn error: ${err.message}`));
239
+ // The pipeline's own code is `head`'s; the command's real one is in the status file.
240
+ proc.on('close', (code) => finish(id, 'exited', readStatus(id) ?? code ?? undefined));
241
+ proc.unref(); // never hold the event loop open for a background job
242
+ } catch (e) {
243
+ j.status = 'error';
244
+ save(j);
245
+ closeSync(fd);
246
+ throw new Error(`failed to spawn: ${(e as Error).message}`);
247
+ }
248
+ closeSync(fd); // the child holds its own dup of the fd
249
+ save(j);
250
+ console.log(`${PREFIX} started ${id} pid=${j.pid} session=${j.sessionId.slice(0, 8)} cmd=${cmd.slice(0, 120)}`);
251
+ return id;
252
+ }
253
+
254
+ /** Record a terminal state exactly once, then schedule the follow-up. */
255
+ function finish(id: string, status: JobStatus, exitCode?: number, note?: string): void {
256
+ const j = records.get(id);
257
+ if (!j || j.status !== 'running') return; // already terminal (e.g. killed, then close fires)
258
+ j.status = status;
259
+ j.exitCode = exitCode;
260
+ j.endedAt = Date.now();
261
+ if (logSize(id) >= MAX_LOG_BYTES) {
262
+ // Say so IN the log: every path the model can read the result through goes via readTail, so the
263
+ // notice reaches it whether it polls ShellOutput or gets the wake report.
264
+ j.truncated = true;
265
+ try { writeFileSync(logFile(id), `\n[output limit: ${MAX_LOG_BYTES >> 20}MB reached — output was cut off here and the command was terminated]\n`, { flag: 'a' }); }
266
+ catch { /* nothing more we can do about the log */ }
267
+ console.warn(`${PREFIX} ${id} hit the ${MAX_LOG_BYTES >> 20}MB log cap — output truncated`);
268
+ }
269
+ save(j);
270
+ console.log(`${PREFIX} ${id} ${status}${exitCode != null ? ` exit=${exitCode}` : ''} in ${Math.round((j.endedAt - j.startedAt) / 1000)}s${note ? ` (${note})` : ''}`);
271
+ // Grace: a turn that is still alive gets first refusal on telling the user (see `observed`).
272
+ setTimer(id, GRACE_MS, () => { void report(id, Date.now()); });
273
+ }
274
+
275
+ export function killJob(id: string): boolean {
276
+ const j = records.get(id);
277
+ if (!j) return false;
278
+ if (j.status === 'running') {
279
+ // Group-kill: the child is its own group leader, so signalling `-pid` reaps the whole subtree —
280
+ // `kill(pid)` alone would hit /bin/sh and orphan whatever it forked.
281
+ if (j.pid) { try { process.kill(-j.pid, 'SIGTERM'); } catch { try { process.kill(j.pid, 'SIGTERM'); } catch { /* already gone */ } } }
282
+ finish(id, 'killed');
283
+ }
284
+ return true;
285
+ }
286
+
287
+ /** A job the model explicitly killed inside a turn needs no follow-up — it already knows. */
288
+ function markObservedIfTerminal(j: JobRecord): void {
289
+ if (j.status !== 'running' && !j.observed) { j.observed = true; save(j); }
290
+ }
291
+
292
+ // ── Follow-up delivery ─────────────────────────────────────────────────────────
293
+
294
+ function reportPrompt(j: JobRecord): string {
295
+ const dur = Math.round(((j.endedAt ?? Date.now()) - j.startedAt) / 1000);
296
+ const result = (j.orphaned
297
+ ? 'ended while the server was restarting — exit code unknown'
298
+ : j.status === 'killed' ? 'killed'
299
+ : j.status === 'error' ? 'failed to run'
300
+ : `exit ${j.exitCode ?? 0}`)
301
+ + (j.truncated ? `, cut off at the ${MAX_LOG_BYTES >> 20}MB output limit` : '');
302
+ const tail = readTail(j.id).slice(-REPORT_CHARS).trim();
303
+ return [
304
+ `[Background job finished] A command you started in an earlier turn has completed, after that turn ended.`,
305
+ `Command: \`${j.command}\``,
306
+ `Result: ${result} (after ${dur}s)`,
307
+ ``,
308
+ `Output (tail):`,
309
+ tail || '(no output)',
310
+ ``,
311
+ `Report this outcome to the user now — that turn promised a follow-up and this is it.`,
312
+ `Be brief and concrete. Do NOT re-run the command.`,
313
+ ].join('\n');
314
+ }
315
+
316
+ /** Plain-text form, for when we cannot run a turn (no runner, or the session never went idle). */
317
+ function rawReport(j: JobRecord): string {
318
+ const dur = Math.round(((j.endedAt ?? Date.now()) - j.startedAt) / 1000);
319
+ const result = (j.orphaned ? 'exit code unknown (server restarted)' : j.status === 'exited' ? `exit ${j.exitCode ?? 0}` : j.status)
320
+ + (j.truncated ? `, cut off at the ${MAX_LOG_BYTES >> 20}MB output limit` : '');
321
+ const tail = readTail(j.id).slice(-REPORT_CHARS).trim();
322
+ return `Background job finished — \`${j.command}\`\nResult: ${result} (after ${dur}s)\n\n\`\`\`\n${tail || '(no output)'}\n\`\`\``;
323
+ }
324
+
325
+ /**
326
+ * Deliver a finished job's outcome, once.
327
+ *
328
+ * Order matters: `observed` is checked AFTER the grace window, so a turn that was still alive when
329
+ * the job exited has had its chance to poll and own the telling. `firstAttemptAt` bounds how long
330
+ * we defer behind a busy session.
331
+ */
332
+ const report = (id: string, firstAttemptAt: number): Promise<void> =>
333
+ tryReport(id, firstAttemptAt).catch((e) => console.error(`${PREFIX} ${id} report threw:`, (e as Error).message));
334
+
335
+ async function tryReport(id: string, firstAttemptAt: number): Promise<void> {
336
+ const j = records.get(id);
337
+ if (!j || j.reported) return;
338
+
339
+ if (j.observed) {
340
+ j.reported = 'skipped-observed';
341
+ save(j);
342
+ console.log(`${PREFIX} ${id} observed inside a turn — no follow-up needed`);
343
+ return;
344
+ }
345
+ if (!getSession(j.sessionId)) {
346
+ j.reported = 'no-session';
347
+ save(j);
348
+ console.warn(`${PREFIX} ${id} session ${j.sessionId} is gone — dropping follow-up`);
349
+ return;
350
+ }
351
+ // Never start a turn on top of a live one — they would interleave into one transcript.
352
+ if (isSessionLocked(j.sessionId)) {
353
+ if (Date.now() - firstAttemptAt < MAX_DEFER_MS) {
354
+ setTimer(id, POLL_MS, () => { void report(id, firstAttemptAt); });
355
+ return;
356
+ }
357
+ console.warn(`${PREFIX} ${id} session busy for ${Math.round(MAX_DEFER_MS / 60_000)}min — delivering raw`);
358
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' })
359
+ .catch((e) => console.error(`${PREFIX} raw deliver failed:`, (e as Error).message));
360
+ j.reported = 'raw';
361
+ save(j);
362
+ return;
363
+ }
364
+ try {
365
+ if (!wakeReady()) {
366
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' });
367
+ j.reported = 'raw';
368
+ } else {
369
+ const outcome = await wakeSession({
370
+ sessionId: j.sessionId, uid: j.uid, userEmail: j.userEmail,
371
+ prompt: reportPrompt(j), channel: 'job', title: 'Background job',
372
+ });
373
+ if (outcome === 'woke') j.reported = 'woke';
374
+ else {
375
+ // The wake ran but said nothing (or the session vanished mid-flight). Falling back to raw
376
+ // keeps the promise: the user sees the result rather than nothing at all.
377
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' });
378
+ j.reported = 'raw';
379
+ console.warn(`${PREFIX} ${id} wake returned '${outcome}' — delivered raw instead`);
380
+ }
381
+ }
382
+ console.log(`${PREFIX} ${id} follow-up delivered (${j.reported})`);
383
+ } catch (e) {
384
+ j.reported = 'failed';
385
+ console.error(`${PREFIX} ${id} follow-up FAILED:`, (e as Error).message);
386
+ }
387
+ save(j);
388
+ }
389
+
390
+ // ── Boot: adopt what survived, prune what is stale ─────────────────────────────
391
+
392
+ /**
393
+ * Load persisted records and reconcile them with reality.
394
+ *
395
+ * `running` records are jobs we lost the `close` listener for when the process ended. Their children
396
+ * are detached, so many are genuinely still running — re-adopt those by polling the pid. The rest
397
+ * finished while we were down: we know THAT they ended but not with what code, which is reported
398
+ * honestly rather than guessed.
399
+ */
400
+ export function initBackgroundJobs(): void {
401
+ let files: string[] = [];
402
+ try { files = readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return; }
403
+ let adopted = 0, orphaned = 0;
404
+ for (const f of files) {
405
+ let j: JobRecord;
406
+ try { j = JSON.parse(readFileSync(path.join(dir(), f), 'utf-8')) as JobRecord; } catch { continue; }
407
+ if (!j?.id) continue;
408
+ records.set(j.id, j);
409
+
410
+ if (j.status !== 'running') {
411
+ // A terminal job whose follow-up never went out (we died between finish and report) still owes one.
412
+ if (!j.reported) setTimer(j.id, GRACE_MS, () => { void report(j.id, Date.now()); });
413
+ continue;
414
+ }
415
+ // pid liveness, guarded against pid REUSE. Our process started when the job did, so a pid that
416
+ // is still ours is AT LEAST as old as the job. A recycled pid belongs to a process the OS started
417
+ // later — i.e. YOUNGER than the job — which is exactly what this rejects. (Getting the comparison
418
+ // backwards adopts the stranger: we would report its exit as the job's, and ShellKill would
419
+ // `kill(-pid)` an unrelated process tree.) The 5s slack absorbs `etime`'s 1s granularity.
420
+ const age = j.pid ? pidAgeSeconds(j.pid) : null;
421
+ const jobAgeSec = (Date.now() - j.startedAt) / 1000;
422
+ if (age != null && age >= jobAgeSec - 5) { adoptRunning(j.id); adopted++; }
423
+ else {
424
+ if (age != null) console.warn(`${PREFIX} ${j.id} pid ${j.pid} is ${Math.round(age)}s old but the job is ${Math.round(jobAgeSec)}s old — pid was reused, not adopting`);
425
+ // Its own shell may still have recorded the real code before we went down.
426
+ const st = readStatus(j.id);
427
+ if (st != null) j.exitCode = st; else j.orphaned = true;
428
+ j.status = 'exited';
429
+ j.endedAt = Date.now();
430
+ save(j);
431
+ orphaned++;
432
+ setTimer(j.id, GRACE_MS, () => { void report(j.id, Date.now()); });
433
+ }
434
+ }
435
+ pruneOld();
436
+ setInterval(pruneOld, 60 * 60_000).unref?.();
437
+ if (adopted || orphaned) console.log(`${PREFIX} boot: adopted ${adopted} running job(s), ${orphaned} ended while down`);
438
+ }
439
+
440
+ /** Watch a job we no longer have a child handle for; report when its pid disappears. */
441
+ function adoptRunning(id: string): void {
442
+ const j = records.get(id);
443
+ if (!j || j.status !== 'running' || !j.pid) return;
444
+ if (pidAgeSeconds(j.pid) == null) {
445
+ // `wait` only works for your own child, so the pid vanishing tells us THAT it ended, not how.
446
+ // Its own shell wrote the code down before exiting, though — prefer that over guessing.
447
+ const st = readStatus(id);
448
+ if (st == null) j.orphaned = true;
449
+ finish(id, 'exited', st ?? undefined);
450
+ return;
451
+ }
452
+ setTimer(id, POLL_MS, () => adoptRunning(id));
453
+ }
454
+
455
+ function pruneOld(): void {
456
+ const now = Date.now();
457
+ for (const j of [...records.values()]) {
458
+ if (j.status === 'running' || !j.endedAt || now - j.endedAt < PRUNE_AFTER_MS) continue;
459
+ clearTimer(j.id);
460
+ records.delete(j.id);
461
+ for (const p of [recFile(j.id), logFile(j.id), statusFile(j.id)]) try { rmSync(p); } catch { /* already gone */ }
462
+ }
463
+ }
464
+
465
+ // ── The per-turn view handed to the agent engine ───────────────────────────────
466
+
467
+ /**
468
+ * A session-scoped facade over the process-wide store, duck-compatible with agentx's
469
+ * `ShellJobRegistry` — pass it as `makeRealShellTool({ registry })` and to `makeShellJobTools`.
470
+ *
471
+ * Cheap to build per turn (it holds no state); the jobs it starts belong to the server, so they
472
+ * outlive the turn that made it. Every lookup is session-scoped: one session's agent can neither
473
+ * read nor kill another's jobs.
474
+ */
475
+ export function sessionJobRegistry(owner: JobOwner) {
476
+ const mine = (id: string): JobRecord | undefined => {
477
+ const j = records.get(id);
478
+ return j && j.sessionId === owner.sessionId ? j : undefined;
479
+ };
480
+ return {
481
+ start: (command: string) => startJob(owner, command),
482
+ output(id: string): string | null {
483
+ const j = mine(id);
484
+ if (!j) return null;
485
+ markObservedIfTerminal(j);
486
+ return readTail(id);
487
+ },
488
+ // NB: status() and list() deliberately do NOT mark the job observed — they return no output, so
489
+ // the model has not got the result and still needs the follow-up. Only output() may.
490
+ status(id: string): { status: JobStatus; exitCode?: number; bytes: number } | null {
491
+ const j = mine(id);
492
+ if (!j) return null;
493
+ return { status: j.status, exitCode: j.exitCode, bytes: logSize(id) };
494
+ },
495
+ list(): Array<{ id: string; command: string; status: JobStatus }> {
496
+ return [...records.values()]
497
+ .filter((j) => j.sessionId === owner.sessionId)
498
+ .map((j) => ({ id: j.id, command: j.command, status: j.status }));
499
+ },
500
+ kill(id: string): boolean {
501
+ const j = mine(id);
502
+ if (!j) return false;
503
+ const ok = killJob(id);
504
+ markObservedIfTerminal(j); // the model asked for this end — it does not need telling about it
505
+ return ok;
506
+ },
507
+ };
508
+ }
509
+
510
+ /** Test/introspection seam: the current record for a job (undefined once pruned). */
511
+ export function getJob(id: string): Readonly<JobRecord> | undefined { return records.get(id); }
@@ -30,6 +30,7 @@ import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMes
30
30
  import { setBroadcaster } from './session-bus.ts';
31
31
  import * as scheduler from './scheduler/index.ts';
32
32
  import { initPolls } from './polls.ts';
33
+ import { initBackgroundJobs } from './background-jobs.ts';
33
34
  import { pushEnabled } from './push/push.ts';
34
35
  import { upsertToken, removeToken } from './push/store.ts';
35
36
  import { initPushTriggers, pushTurnDone, pushQuestion } from './push/triggers.ts';
@@ -1133,11 +1134,47 @@ if (!PASSIVE) {
1133
1134
  // runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
1134
1135
  statsSampler.start(broadcast);
1135
1136
  registerEventRoutes(app, requireAuth);
1137
+ /** How long an out-of-band wake turn waits for a busy session before giving up (see runTurn). */
1138
+ const WAKE_LOCK_WAIT_MS = 5 * 60_000;
1136
1139
  initPolls({
1137
1140
  broadcast,
1138
- runTurn: ({ prompt, sessionId, uid, userEmail }) =>
1139
- consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController: new AbortController(), onPermissionRequest: async () => ({ allow: true }) })),
1141
+ // The out-of-band turn runner (polls + background-job follow-ups). It TAKES THE SESSION LOCK, and
1142
+ // that is load-bearing: `streamChat` never acquires one itself, so before this every guard written
1143
+ // against `isSessionLocked` — including background-jobs.ts's "never start a turn on top of a live
1144
+ // one" — was reading a lock that this path never took. Two jobs finishing minutes apart then ran
1145
+ // two concurrent wake turns in one session, each free to dispatch the next leg of the same
1146
+ // workflow: two writers on one browser instance, which is the failure these workflows are built to
1147
+ // prevent. Waiting (bounded) rather than failing fast, because the whole point of a wake is that
1148
+ // the outcome gets told: a busy session usually means another wake is mid-turn and will be done in
1149
+ // seconds.
1150
+ runTurn: async ({ prompt, sessionId, uid, userEmail }) => {
1151
+ const abortController = new AbortController();
1152
+ const deadline = Date.now() + WAKE_LOCK_WAIT_MS;
1153
+ while (!acquireSessionLock(sessionId, 'api', abortController)) {
1154
+ if (Date.now() >= deadline) {
1155
+ // Give up by returning NOTHING, never by throwing. A throw propagates out of wake.ts's
1156
+ // unguarded `await runTurn` into background-jobs' catch, which records `reported: 'failed'`
1157
+ // — the one delivery path with no raw fallback, so the job's outcome would reach the user in
1158
+ // no form at all, after wake.ts had already appended the trigger prompt (a question with no
1159
+ // answer in the transcript). Empty blocks are the 'no-output' contract callers already
1160
+ // handle: the job store then delivers its raw report instead. Degraded, but never silent.
1161
+ console.warn(`[wake] session ${sessionId} stayed busy for ${Math.round(WAKE_LOCK_WAIT_MS / 1000)}s — skipping the turn; the caller falls back to a raw report`);
1162
+ return [];
1163
+ }
1164
+ await new Promise((r) => setTimeout(r, 2_000));
1165
+ }
1166
+ try {
1167
+ return await consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }));
1168
+ } finally {
1169
+ if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
1170
+ }
1171
+ },
1140
1172
  });
1173
+ // Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
1174
+ // initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
1175
+ // while we were down, and that report runs a turn. Skipped when passive: a standby twin must not
1176
+ // adopt the active instance's children or double-report them.
1177
+ if (!PASSIVE) initBackgroundJobs();
1141
1178
  // Remote-push triggers: subscribe to schedule.finished and expose turn-done/question
1142
1179
  // hooks. isForeground reuses the existing presence tracking (see isUserViewingSession).
1143
1180
  initPushTriggers({
@@ -7,10 +7,9 @@
7
7
  import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { dataPath } from './paths.ts';
10
- import { appendMessage, getSession, type ConvBlock } from './sessions.ts';
11
- import { addUnread } from './unread.ts';
12
- import { postMessage, slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
13
- import { findSlackSessionBySessionId } from './slack/sessions.ts';
10
+ import { getSession } from './sessions.ts';
11
+ import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
12
+ import { initWake, wakeSession, deliverToSession, type TurnRunner } from './wake.ts';
14
13
 
15
14
  const PREFIX = '[polls]';
16
15
 
@@ -28,14 +27,10 @@ export interface PollRecord extends PollSpec {
28
27
  createdAt: number;
29
28
  }
30
29
 
31
- // ── IoC: injected by index.ts at startup to avoid a claude.ts <-> polls.ts cycle ──
32
- type TurnRunner = (args: { prompt: string; sessionId: string; uid: string; userEmail?: string }) => Promise<ConvBlock[]>;
33
- let runTurn: TurnRunner | null = null;
34
- let broadcastFn: ((ev: object) => void) | null = null;
35
-
30
+ // ── IoC: injected by boot.ts at startup to avoid a claude.ts <-> polls.ts cycle. The runners live
31
+ // in wake.ts now every subsystem that reports back after its turn ended shares them. ──
36
32
  export function initPolls(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
37
- runTurn = deps.runTurn;
38
- broadcastFn = deps.broadcast;
33
+ initWake(deps);
39
34
  setInterval(sweep, 60_000);
40
35
  console.log(`${PREFIX} sweeper started`);
41
36
  }
@@ -140,7 +135,6 @@ async function closePoll(pollId: string, reason: 'deadline' | 'quorum' | 'manual
140
135
 
141
136
  // ── Close then report: wake the originating session once with the tally ─────────
142
137
  async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
143
- if (!runTurn) { console.warn(`${PREFIX} no turn runner; skipping wake for ${p.pollId}`); return; }
144
138
  if (!getSession(p.sessionId)) { console.warn(`${PREFIX} session ${p.sessionId} gone; skipping wake`); return; }
145
139
 
146
140
  // Per-option voters, with Slack user ids resolved to display names (cached).
@@ -161,15 +155,18 @@ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
161
155
  const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
162
156
  const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
163
157
 
164
- appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'poll' });
165
- broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
166
-
167
- const blocks = await runTurn({ prompt, sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail });
168
- if (!blocks.length) return;
169
- appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
170
- broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
171
- const text = blocks.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text).join('\n\n').trim();
172
- addUnread(p.uid, p.sessionId, text.slice(0, 120) || 'Poll closed', 'proactive', p.title);
173
- const slack = findSlackSessionBySessionId(p.sessionId);
174
- if (slack && text) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken).catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
158
+ // The return value is NOT decoration: a wake that could not run a turn ('no-output' — no runner
159
+ // wired, or the session stayed busy past the wake lock's wait) leaves this poll CLOSED, already
160
+ // re-rendered as closed in Slack, and the transcript holding a `[Poll result]` prompt with no
161
+ // answer. The tally would then be lost for good (the record is pruned after 7 days). So when no
162
+ // turn ran, deliver the lines we already built — the same raw-report fallback the background-job
163
+ // caller makes. `unreadFallback` above cannot cover this: wake.ts returns before it consults it.
164
+ const outcome = await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
165
+ if (outcome !== 'woke') {
166
+ console.warn(`${PREFIX} wake for ${p.pollId} returned '${outcome}' delivering the tally as plain text instead`);
167
+ await deliverToSession({
168
+ sessionId: p.sessionId, uid: p.uid, title: p.title,
169
+ text: `${headline} — "${p.title}" (${voterCount(p)} participant(s)).\n${lines}`,
170
+ }).catch((e) => console.error(`${PREFIX} raw tally deliver failed:`, (e as Error)?.message));
171
+ }
175
172
  }
@@ -0,0 +1,92 @@
1
+ // A scheduled run's DECLARED outcome — the run saying what actually happened, instead of the
2
+ // scheduler inferring success from "the agent's turn returned".
3
+ //
4
+ // The bug this exists for: a prompt run whose real work failed (or never started) still records
5
+ // `ok`, because the only thing measured was that the turn came back. On 2026-08-28 the 15:30 social
6
+ // run's scout died, nothing was delivered, the run stored `ok`, and the (enabled) failure notifier
7
+ // stayed silent all day — it is event-driven on `status: 'error'` and was never given one.
8
+ //
9
+ // It gets worse with background jobs (server/background-jobs.ts): there, ending the turn early is
10
+ // the CORRECT behaviour — the work outlives it and the job store wakes the session when it exits.
11
+ // So "the turn returned" stops being even a weak proxy for the run's outcome.
12
+ //
13
+ // Shape: one JSON file per run session, written by the run itself (any tool that can write a file —
14
+ // no new tool surface, nothing to plumb through the engine), read by runner.ts when the turn ends.
15
+ // { "status": "ok" } → the run delivered
16
+ // { "status": "error", "error": "…" } → it did not; this fires the notifier
17
+ // { "status": "pending", "deadline": <epoch ms|ISO> } → work is still in flight; the run stays
18
+ // open until a terminal declaration lands,
19
+ // and FAILS if the deadline passes first
20
+ // Absent file ⇒ unchanged legacy behaviour (turn returned = ok), so no existing schedule changes.
21
+ // Deliberately domain-free: it knows nothing about what the run was doing.
22
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { dataPath } from '../paths.ts';
25
+
26
+ /** Cap on how long a `pending` run may hold the window open, whatever deadline it asked for. */
27
+ export const MAX_PENDING_MS = 6 * 60 * 60_000;
28
+ /** Used when a `pending` declaration names no deadline. */
29
+ export const DEFAULT_PENDING_MS = 60 * 60_000;
30
+
31
+ export interface DeclaredOutcome {
32
+ status: 'ok' | 'error' | 'pending';
33
+ error?: string;
34
+ /** For `pending`: when the run gives up and is recorded as failed. Epoch ms or ISO-8601. */
35
+ deadline?: number | string;
36
+ }
37
+
38
+ const dir = (): string => { const d = dataPath('scheduler', 'outcomes'); mkdirSync(d, { recursive: true }); return d; };
39
+ /** Session ids are server-minted (`sched-<id>-<ts>`), but this value reaches `path.join` and `rmSync`
40
+ * — so it is validated rather than trusted. A `../` in there would delete outside the outcomes dir. */
41
+ const safeId = (sessionId: string): string => {
42
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.startsWith('.')) throw new Error(`unsafe session id for an outcome file: ${sessionId}`);
43
+ return sessionId;
44
+ };
45
+ export const outcomeFile = (sessionId: string): string => path.join(dir(), `${safeId(sessionId)}.json`);
46
+
47
+ export function readOutcome(sessionId: string): DeclaredOutcome | null {
48
+ let raw: string;
49
+ try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); } catch { return null; }
50
+ let o: DeclaredOutcome;
51
+ // A malformed declaration is not "no declaration": the run tried to say something. Surfacing it
52
+ // as an error beats silently falling back to the optimistic default this module exists to remove.
53
+ // But a plain `Write` is not atomic, so a read can also land MID-write — that is a torn read, not
54
+ // a malformed declaration, and failing a run for it would be the same class of lie in reverse.
55
+ // Re-read once after a beat before believing it (the prompt also asks for write-temp-then-rename).
56
+ try { o = JSON.parse(raw) as DeclaredOutcome; }
57
+ catch {
58
+ try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); o = JSON.parse(raw) as DeclaredOutcome; }
59
+ catch { return { status: 'error', error: `run outcome file is not valid JSON: ${raw.slice(0, 200)}` }; }
60
+ }
61
+ if (o?.status !== 'ok' && o?.status !== 'error' && o?.status !== 'pending')
62
+ return { status: 'error', error: `run outcome file has an invalid status: ${JSON.stringify(o?.status)}` };
63
+ return o;
64
+ }
65
+
66
+ export function clearOutcome(sessionId: string): void {
67
+ try { rmSync(outcomeFile(sessionId)); } catch { /* nothing to clear */ }
68
+ }
69
+
70
+ /** Test/host seam — writes a declaration the way a run's own file write would. */
71
+ export function writeOutcome(sessionId: string, o: DeclaredOutcome): void {
72
+ writeFileSync(outcomeFile(sessionId), JSON.stringify(o));
73
+ }
74
+
75
+ /** Absolute epoch ms a `pending` run expires at, clamped to MAX_PENDING_MS. */
76
+ export function pendingDeadline(o: DeclaredOutcome, now: number): number {
77
+ const raw = typeof o.deadline === 'string' ? Date.parse(o.deadline) : o.deadline;
78
+ const asked = Number.isFinite(raw) ? (raw as number) : now + DEFAULT_PENDING_MS;
79
+ return Math.min(Math.max(asked, now), now + MAX_PENDING_MS);
80
+ }
81
+
82
+ /** The contract, appended to a scheduled prompt run so a run can report itself truthfully. */
83
+ export function outcomePrompt(sessionId: string): string {
84
+ return `# Reporting this run's outcome
85
+ This is a scheduled run. Unless you say otherwise, it is recorded as SUCCESSFUL the moment your turn returns — which is a lie whenever the work failed, was skipped, or is still in flight. Correct that by writing this file:
86
+ \`${outcomeFile(sessionId)}\`
87
+ - \`{"status":"ok"}\` — the run delivered what it was for.
88
+ - \`{"status":"error","error":"<what went wrong>"}\` — it did not. This is what raises the failure alert; write it for a leg that never ran, a worker that died, or work you could not finish.
89
+ - \`{"status":"pending","deadline":"<ISO-8601>"}\` — work you started outlives this turn (e.g. a background job). The run stays open and NOT successful until you write a terminal status from a later turn; if the deadline passes with no terminal status, the run is recorded as failed automatically.
90
+ Write it atomically — write a temp file next to it and \`mv\` it into place — so a reader can never catch it half-written.
91
+ Declare \`pending\` BEFORE you end a turn that leaves work running, and re-declare \`ok\`/\`error\` from the turn that sees it finish. Never declare \`ok\` for a run that did not deliver.`;
92
+ }
@@ -6,7 +6,8 @@ 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 { updateRunLockPid, clearRunningMarker } from './storage.ts';
9
+ import { updateRunLockPid, clearRunningMarker, loadSchedules } from './storage.ts';
10
+ import { readOutcome, clearOutcome, pendingDeadline, outcomePrompt, MAX_PENDING_MS } from './outcome.ts';
10
11
  import { addUnread } from '../unread.ts';
11
12
 
12
13
  export interface RunContext {
@@ -87,6 +88,70 @@ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve)
87
88
  signal?.addEventListener('abort', done, { once: true });
88
89
  });
89
90
 
91
+ /** How often a `pending` run is re-checked while it waits for its terminal declaration. */
92
+ const OUTCOME_POLL_MS = 10_000;
93
+
94
+ /**
95
+ * Resolve what the run itself declared, once its turn has ended.
96
+ *
97
+ * Returns null when the run declared nothing — that is the legacy path and stays exactly as it was
98
+ * (turn returned ⇒ ok). A `pending` declaration keeps the run open until a terminal one lands; each
99
+ * fresh `pending` extends the wait (leg 2 re-declaring after leg 1 finished), bounded absolutely by
100
+ * MAX_PENDING_MS from the first one so an agent cannot extend forever. Silence past the deadline is
101
+ * a FAILURE — that is the whole point: a run that never came back must alert, not read as success.
102
+ */
103
+ /**
104
+ * The schedule's CURRENT next fire time, read live from disk each time it is needed.
105
+ *
106
+ * Deliberately not the caller's snapshot: `fireDue()` starts the run in its first loop and only
107
+ * advances `nextRun` in its second, and `startRun` deep-copies the schedule BEFORE that advance —
108
+ * so the snapshot this run was handed still carries the window it is running FOR (already in the
109
+ * past), which would make the ceiling below `Infinity` and inert. The engine persists the advanced
110
+ * value (`saveSchedules` at the end of `fireDue`), so disk is the source of truth here. Reading it
111
+ * per poll also picks up an edit made while the run waits.
112
+ */
113
+ function liveNextWindow(scheduleId: string): number | undefined {
114
+ try { return loadSchedules().find((s) => s.id === scheduleId)?.nextRun; }
115
+ catch { return undefined; }
116
+ }
117
+
118
+ async function resolveDeclaredOutcome(
119
+ sessionId: string,
120
+ ac: AbortController,
121
+ /** Schedule whose next window caps the wait — looked up live, never from the run's snapshot. */
122
+ scheduleId: string,
123
+ ): Promise<{ status: Exclude<ScheduleRunSummary['status'], 'running'>; error?: string } | null> {
124
+ let declared = readOutcome(sessionId);
125
+ if (!declared) return null;
126
+ // Waiting happens INSIDE the run promise, and engine.startRun only does `state.running.delete()`
127
+ // when that promise settles — so a pending run keeps its schedule marked running, and the next
128
+ // fire of the same schedule is QUEUED behind it rather than run on time. Verified in
129
+ // engine.ts (`state.running.delete` sits in `.finally`, and `fire()` queues when `state.running`
130
+ // has the id). For a 3×-daily schedule a multi-hour pending wait would therefore eat the next
131
+ // slot. So the wait ends at the next window at the latest: the run is then recorded as failed
132
+ // (the notifier fires) and the new window starts clean and on time.
133
+ const windowStop = (): number => {
134
+ const next = liveNextWindow(scheduleId);
135
+ return next && next > Date.now() ? next - 60_000 : Infinity;
136
+ };
137
+ const absoluteStop = Date.now() + MAX_PENDING_MS;
138
+ while (declared?.status === 'pending' && !ac.signal.aborted) {
139
+ const stop = windowStop();
140
+ const deadline = Math.min(pendingDeadline(declared, Date.now()), absoluteStop, stop);
141
+ if (Date.now() >= deadline) {
142
+ const why = deadline === stop ? 'its next scheduled window arrived first' : `deadline ${new Date(deadline).toISOString()}`;
143
+ return { status: 'error', error: `Run declared itself still in flight and never reported a terminal outcome (${why}).` };
144
+ }
145
+ await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()), ac.signal);
146
+ declared = readOutcome(sessionId);
147
+ }
148
+ if (ac.signal.aborted) return { status: 'aborted' };
149
+ if (!declared) return { status: 'error', error: 'Run outcome declaration disappeared before it reported a terminal state.' };
150
+ return declared.status === 'error'
151
+ ? { status: 'error', error: declared.error ? `Run reported failure: ${declared.error}` : 'Run reported failure with no detail.' }
152
+ : { status: 'ok' };
153
+ }
154
+
90
155
  function formatEventBlock(e: EventContext): string {
91
156
  let body: string;
92
157
  try { body = JSON.stringify(e.payload, null, 2); } catch { body = String(e.payload); }
@@ -140,6 +205,17 @@ export async function runSchedule(
140
205
  if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
141
206
  prompt = base;
142
207
  }
208
+ // Tell the run how to report its own truthful outcome (scheduler/outcome.ts). Prompt tasks only:
209
+ // a `bash` task's permission handler allows nothing but the task's own command, so such a run
210
+ // could not write the file even if it wanted to — its exit code is already the truth there.
211
+ if (task.kind === 'prompt') {
212
+ // Cleared on RESUME too: a resume reuses the interrupted run's session id, so a declaration left
213
+ // by the attempt that crashed would be adopted as this attempt's verdict. The contract is
214
+ // re-stated for the same reason — the resumed turn must be able to declare for itself.
215
+ clearOutcome(sessionId);
216
+ prompt = `${prompt}\n\n---\n${outcomePrompt(sessionId)}`;
217
+ }
218
+
143
219
  // task.engine/task.model ride the same prompt-directive channel users type by hand —
144
220
  // parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
145
221
  // choice into the saved prompt, so the session UI shows what the schedule actually requested.
@@ -322,6 +398,27 @@ export async function runSchedule(
322
398
  updateScheduledSessionStatus(sessionId, status);
323
399
  }
324
400
 
401
+ // The run's OWN verdict beats "the turn returned" — see scheduler/outcome.ts. Deliberately after
402
+ // the finally: the session lock is released by now, so a run that declared `pending` can be closed
403
+ // by a later turn in this session (a background job's wake, a follow-up message) while we wait.
404
+ if (status === 'ok' && task.kind === 'prompt') {
405
+ const declared = await resolveDeclaredOutcome(sessionId, abortController, schedule.id);
406
+ if (declared) {
407
+ status = declared.status;
408
+ error = declared.error;
409
+ updateScheduledSessionStatus(sessionId, status);
410
+ if (status !== 'ok') {
411
+ appendMessage(sessionId, {
412
+ id: crypto.randomUUID(),
413
+ role: 'assistant',
414
+ blocks: [{ type: 'error', text: error ?? `Run reported status ${status}` }],
415
+ });
416
+ onEvent({ type: 'session_messages_changed', sessionId });
417
+ }
418
+ }
419
+ clearOutcome(sessionId);
420
+ }
421
+
325
422
  const preview = assistantText.slice(0, 120) || (status === 'ok' ? 'Schedule completed' : `Schedule ${status}`);
326
423
  addUnread(schedule.createdBy.uid, sessionId, preview, 'schedule', schedule.name);
327
424
 
@@ -0,0 +1,95 @@
1
+ // Waking a session from out-of-band work — "something finished while nobody was in the turn,
2
+ // tell the user, wherever that session speaks".
3
+ //
4
+ // Every async subsystem that resolves after its turn is over needs the SAME tail: persist the
5
+ // trigger as a user-channel message, run one agent turn on it, persist + broadcast the reply,
6
+ // mark it unread, and relay to the Slack thread when the session has one. polls.ts grew it first
7
+ // ("close then report"); background-jobs.ts needs it verbatim, so it lives here instead of twice.
8
+ //
9
+ // Decoupled from claude.ts via injected runners (see initWake) — same IoC polls.ts used, for the
10
+ // same reason (claude.ts ← → subsystem import cycle).
11
+ import { appendMessage, getSession, type ConvBlock } from './sessions.ts';
12
+ import { addUnread } from './unread.ts';
13
+ import { postMessage } from './slack/api.ts';
14
+ import { findSlackSessionBySessionId } from './slack/sessions.ts';
15
+
16
+ const PREFIX = '[wake]';
17
+
18
+ export type TurnRunner = (args: { prompt: string; sessionId: string; uid: string; userEmail?: string }) => Promise<ConvBlock[]>;
19
+
20
+ let runTurn: TurnRunner | null = null;
21
+ let broadcastFn: ((ev: object) => void) | null = null;
22
+
23
+ export function initWake(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
24
+ runTurn = deps.runTurn;
25
+ broadcastFn = deps.broadcast;
26
+ }
27
+
28
+ /** True once boot has wired a turn runner. Callers that can degrade (deliver raw text instead of
29
+ * running a turn) check this rather than silently dropping their report. */
30
+ export function wakeReady(): boolean { return !!runTurn; }
31
+
32
+ export function broadcastSessionChanged(sessionId: string): void {
33
+ broadcastFn?.({ type: 'session_messages_changed', sessionId });
34
+ }
35
+
36
+ /**
37
+ * Put `text` into the session as an assistant message and push it to every surface that session
38
+ * speaks on (web stream + unread badge, and the Slack thread when one is mapped).
39
+ *
40
+ * This is the delivery half on its own: used directly when we must report WITHOUT running a turn
41
+ * (no runner wired, or the session stayed busy past the defer cap) so the outcome is still visible
42
+ * rather than silently dropped.
43
+ */
44
+ export async function deliverToSession(i: { sessionId: string; uid: string; text: string; title?: string }): Promise<void> {
45
+ const text = i.text.trim();
46
+ if (!text) return;
47
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks: [{ type: 'text', text }] });
48
+ broadcastSessionChanged(i.sessionId);
49
+ addUnread(i.uid, i.sessionId, text.slice(0, 120), 'proactive', i.title);
50
+ const slack = findSlackSessionBySessionId(i.sessionId);
51
+ if (slack) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken)
52
+ .catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
53
+ }
54
+
55
+ export type WakeOutcome = 'woke' | 'no-session' | 'not-ready' | 'no-output';
56
+
57
+ /**
58
+ * Run one agent turn on `prompt` in an existing session and deliver whatever it says.
59
+ *
60
+ * The caller is responsible for NOT calling this while a turn is already streaming in that
61
+ * session (see isSessionLocked) — two concurrent turns would interleave into one transcript.
62
+ */
63
+ export async function wakeSession(i: {
64
+ sessionId: string; uid: string; userEmail?: string;
65
+ prompt: string;
66
+ /** Message channel tag for the injected trigger (e.g. 'poll', 'job') — shows provenance in the thread. */
67
+ channel: string;
68
+ /** Unread-badge title. */
69
+ title?: string;
70
+ /** Badge text to use when the turn ran but produced no TEXT block. Without it such a turn is
71
+ * reported as 'no-output' and raises no badge — but a turn whose only block is `{type:'error'}`
72
+ * is precisely when the user most needs telling. Callers that can degrade some other way (jobs
73
+ * fall back to a raw report) leave this unset and handle 'no-output' themselves. */
74
+ unreadFallback?: string;
75
+ }): Promise<WakeOutcome> {
76
+ if (!runTurn) { console.warn(`${PREFIX} no turn runner; cannot wake ${i.sessionId}`); return 'not-ready'; }
77
+ if (!getSession(i.sessionId)) { console.warn(`${PREFIX} session ${i.sessionId} gone; skipping wake`); return 'no-session'; }
78
+
79
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: i.prompt }], channel: i.channel });
80
+ broadcastSessionChanged(i.sessionId);
81
+
82
+ const blocks = await runTurn({ prompt: i.prompt, sessionId: i.sessionId, uid: i.uid, userEmail: i.userEmail });
83
+ if (!blocks.length) return 'no-output';
84
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
85
+ broadcastSessionChanged(i.sessionId);
86
+
87
+ const text = blocks.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text).join('\n\n').trim();
88
+ if (!text && !i.unreadFallback) return 'no-output';
89
+ addUnread(i.uid, i.sessionId, text.slice(0, 120) || i.unreadFallback!, 'proactive', i.title);
90
+ const slack = findSlackSessionBySessionId(i.sessionId);
91
+ // Nothing to say out loud when there is no text — but the badge above still went up.
92
+ if (slack && text) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken)
93
+ .catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
94
+ return text ? 'woke' : 'no-output';
95
+ }