shraga 0.1.65 → 0.1.66
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 +1 -1
- package/src/server/background-jobs.ts +511 -0
- package/src/server/boot.ts +6 -0
- package/src/server/polls.ts +7 -23
- package/src/server/wake.ts +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.66",
|
|
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); }
|
package/src/server/boot.ts
CHANGED
|
@@ -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';
|
|
@@ -1138,6 +1139,11 @@ initPolls({
|
|
|
1138
1139
|
runTurn: ({ prompt, sessionId, uid, userEmail }) =>
|
|
1139
1140
|
consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController: new AbortController(), onPermissionRequest: async () => ({ allow: true }) })),
|
|
1140
1141
|
});
|
|
1142
|
+
// Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
|
|
1143
|
+
// initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
|
|
1144
|
+
// while we were down, and that report runs a turn. Skipped when passive: a standby twin must not
|
|
1145
|
+
// adopt the active instance's children or double-report them.
|
|
1146
|
+
if (!PASSIVE) initBackgroundJobs();
|
|
1141
1147
|
// Remote-push triggers: subscribe to schedule.finished and expose turn-done/question
|
|
1142
1148
|
// hooks. isForeground reuses the existing presence tracking (see isUserViewingSession).
|
|
1143
1149
|
initPushTriggers({
|
package/src/server/polls.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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, 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
|
|
32
|
-
|
|
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
|
-
|
|
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,5 @@ 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
|
-
|
|
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
|
+
await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
|
|
175
159
|
}
|
|
@@ -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
|
+
}
|