shraga 0.1.66 → 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.66",
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",
@@ -1134,10 +1134,41 @@ if (!PASSIVE) {
1134
1134
  // runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
1135
1135
  statsSampler.start(broadcast);
1136
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;
1137
1139
  initPolls({
1138
1140
  broadcast,
1139
- runTurn: ({ prompt, sessionId, uid, userEmail }) =>
1140
- 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
+ },
1141
1172
  });
1142
1173
  // Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
1143
1174
  // initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import { dataPath } from './paths.ts';
10
10
  import { getSession } from './sessions.ts';
11
11
  import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
12
- import { initWake, wakeSession, type TurnRunner } from './wake.ts';
12
+ import { initWake, wakeSession, deliverToSession, type TurnRunner } from './wake.ts';
13
13
 
14
14
  const PREFIX = '[polls]';
15
15
 
@@ -155,5 +155,18 @@ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
155
155
  const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
156
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.`;
157
157
 
158
- await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
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
+ }
159
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