shraga 0.1.37 → 0.1.38

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.37",
3
+ "version": "0.1.38",
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",
@@ -22,6 +22,9 @@ interface RuntimeState {
22
22
  queues: Map<string, QueuedFire[]>;
23
23
  /** Schedules currently running (id → AbortController for the live run). */
24
24
  running: Map<string, AbortController>;
25
+ /** Pending re-arms of a window whose run failed before producing anything (id → timer). One
26
+ * per schedule: a schedule can only be retrying one window at a time. */
27
+ retries: Map<string, { timer: ReturnType<typeof setTimeout>; window: number; attempt: number }>;
25
28
  broadcast: Broadcast;
26
29
  }
27
30
 
@@ -50,6 +53,7 @@ const state: RuntimeState = {
50
53
  timer: null,
51
54
  queues: new Map(),
52
55
  running: new Map(),
56
+ retries: new Map(),
53
57
  broadcast: () => {},
54
58
  };
55
59
 
@@ -182,6 +186,7 @@ export function deleteSchedule(id: string): boolean {
182
186
  if (idx < 0) return false;
183
187
  state.schedules.splice(idx, 1);
184
188
  state.queues.delete(id);
189
+ clearRearm(id);
185
190
  const ac = state.running.get(id);
186
191
  if (ac) ac.abort();
187
192
  saveSchedules(state.schedules);
@@ -205,6 +210,8 @@ export function toggleSchedule(id: string, enabled: boolean): Schedule | null {
205
210
  }
206
211
  } else {
207
212
  s.nextRun = undefined;
213
+ // A disabled schedule must not come back to life via a pending retry.
214
+ clearRearm(id);
208
215
  }
209
216
  saveSchedules(state.schedules);
210
217
  replan();
@@ -428,6 +435,62 @@ function judgeMissed(s: Schedule, window: number, now: number = Date.now()): Mis
428
435
  return { replay: true };
429
436
  }
430
437
 
438
+ /** Backoff between re-arms of a side-effect-free failed window. Deliberately minutes, not the
439
+ * sub-second ladder the in-process retry uses: that one exists for a flaky engine, this one for an
440
+ * outage — a capped API key or a dead upstream is not coming back in 2 seconds. The last entry
441
+ * repeats; the real bound is the staleness ceiling, which stops replay well before the ladder does. */
442
+ const REARM_BACKOFF_MS = [5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000];
443
+
444
+ /** Cancel any pending re-arm for a schedule (disabled, deleted, or superseded by a newer window). */
445
+ function clearRearm(id: string): void {
446
+ const pending = state.retries.get(id);
447
+ if (!pending) return;
448
+ clearTimeout(pending.timer);
449
+ state.retries.delete(id);
450
+ }
451
+
452
+ /**
453
+ * Re-arm a window whose run failed before producing any output. Returns true when a retry is
454
+ * pending — the caller must then NOT record an attempt outcome, because an attempt marker is
455
+ * exactly what makes the window un-replayable.
456
+ *
457
+ * Refuses (returns false, caller falls through to the normal failure path) when the retry would
458
+ * land outside what `judgeMissed` permits: past the staleness ceiling, or against an explicit
459
+ * `onMissed` of `skip`/`offer`. Those are standing instructions about unattended replay and this
460
+ * is unattended replay, so it answers to them rather than routing around them.
461
+ */
462
+ function rearmWindow(s: Schedule, window: number): boolean {
463
+ if (!schedulerActive) return false;
464
+ const live = getSchedule(s.id);
465
+ if (!live?.enabled) return false;
466
+
467
+ const pending = state.retries.get(s.id);
468
+ const attempt = pending?.window === window ? pending.attempt + 1 : 1;
469
+ clearRearm(s.id);
470
+
471
+ const delay = REARM_BACKOFF_MS[Math.min(attempt - 1, REARM_BACKOFF_MS.length - 1)]!;
472
+ // Judge the window as of WHEN THE RETRY WOULD FIRE, not now — arming a timer that is already
473
+ // doomed to be refused just burns the ceiling in silence.
474
+ const verdict = judgeMissed(live, window, Date.now() + delay);
475
+ if (!verdict.replay) {
476
+ console.log(`[scheduler] not re-arming ${s.id} — ${verdict.message}`);
477
+ if (verdict.reason !== 'skip') noteMissed(live, window, verdict.reason);
478
+ return false;
479
+ }
480
+
481
+ console.warn(`[scheduler] ${s.id} failed before producing any output — re-arming window ${new Date(window).toISOString()} in ${Math.round(delay / 60_000)}m (retry ${attempt})`);
482
+ const timer = setTimeout(() => {
483
+ state.retries.delete(s.id);
484
+ const cur = getSchedule(s.id);
485
+ if (!cur?.enabled) return;
486
+ console.log(`[scheduler] retrying window ${new Date(window).toISOString()} for ${s.id}`);
487
+ enqueueFire(cur, window);
488
+ }, delay);
489
+ timer.unref?.();
490
+ state.retries.set(s.id, { timer, window, attempt });
491
+ return true;
492
+ }
493
+
431
494
  function noteMissed(s: Schedule, at: number, reason: 'skip' | 'offer' | 'stale'): void {
432
495
  s.missedRun = { at, reason, noticedAt: Date.now() };
433
496
  saveSchedules(state.schedules);
@@ -497,6 +560,13 @@ function fireDue(): void {
497
560
  }
498
561
 
499
562
  function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = false, eventCtx?: EventContext): RunOutcome {
563
+ // A newer window supersedes a pending retry of an older one: today's 08:00 report is the job,
564
+ // not yesterday's. Retrying both would double-post.
565
+ const pendingRetry = state.retries.get(s.id);
566
+ if (pendingRetry && firedAt > pendingRetry.window) {
567
+ console.log(`[scheduler] dropping pending retry of ${new Date(pendingRetry.window).toISOString()} for ${s.id} — superseded by a newer window`);
568
+ clearRearm(s.id);
569
+ }
500
570
  // Skip if this cron period was already completed/attempted. Manual runs (runNow from UI/API)
501
571
  // always proceed past the period guard — but never past the run lock in startRun().
502
572
  if (!manual && s.trigger.kind === 'cron') {
@@ -588,6 +658,13 @@ function startRun(s: Schedule, firedAt: number, override?: string, resume?: Resu
588
658
  saveSchedules(state.schedules);
589
659
  state.broadcast({ type: 'schedule:updated', schedule: live });
590
660
  }
661
+ // A run that failed WITHOUT producing anything has not spent its window: the work provably
662
+ // never started, so re-running cannot double-apply a side effect. Re-arm instead of burning
663
+ // the window — otherwise a multi-hour upstream outage (an API spend cap, a dead engine)
664
+ // silently costs the day's run, which is exactly how a morning report goes missing with
665
+ // every marker reading "attempted". Recording the attempt is deferred to the give-up path,
666
+ // because an attempt marker is precisely what blocks the replay we want.
667
+ if (summary.status === 'error' && summary.sideEffectFree && rearmWindow(s, firedAt)) return;
591
668
  if (summary.status !== 'ok') {
592
669
  // Keep the attempt on record with its real outcome — the next boot must see that this
593
670
  // window was tried and failed, not that it never ran.
@@ -193,6 +193,7 @@ export async function runSchedule(
193
193
 
194
194
  let status: ScheduleRunSummary['status'] = 'ok';
195
195
  let error: string | undefined;
196
+ let sideEffectFree = false;
196
197
 
197
198
  // A `bash` task is not exec'd — it's handed to the agent as a prompt, so the command's exit code
198
199
  // reaches nobody: the agent reports the failure in prose, its own turn succeeds, the run is stored
@@ -280,6 +281,10 @@ export async function runSchedule(
280
281
  // `tool_use_input`, `tool_result_image`, `permission_request` and `question_request` need no
281
282
  // separate flag: each is necessarily preceded by the `tool_use` that already set the boundary.
282
283
  const producedOutput = assistantBlocks.length > 0 || assistantText.length > 0 || producedThinking;
284
+ // Carry the boundary out to the summary. The in-process ladder is tuned for a flaky engine
285
+ // (sub-second backoff); an outage measured in hours outlives it, and the engine needs to know
286
+ // whether the window is still safely replayable once this run gives up.
287
+ sideEffectFree = !producedOutput;
283
288
  if (producedOutput || abortController.signal.aborted || attempt >= MAX_ATTEMPTS) {
284
289
  // Nobody watches stderr on a scheduled run — record the failure in the transcript.
285
290
  if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
@@ -317,7 +322,7 @@ export async function runSchedule(
317
322
  const preview = assistantText.slice(0, 120) || (status === 'ok' ? 'Schedule completed' : `Schedule ${status}`);
318
323
  addUnread(schedule.createdBy.uid, sessionId, preview, 'schedule', schedule.name);
319
324
 
320
- const summary: ScheduleRunSummary = { at: now, sessionId, status, error };
325
+ const summary: ScheduleRunSummary = { at: now, sessionId, status, error, sideEffectFree: status === 'error' ? sideEffectFree : undefined };
321
326
  onEvent({ type: 'schedule:run_finished', scheduleId: schedule.id, sessionId, summary });
322
327
  return summary;
323
328
  }
@@ -32,6 +32,11 @@ export interface ScheduleRunSummary {
32
32
  sessionId: string;
33
33
  status: 'running' | 'ok' | 'error' | 'aborted';
34
34
  error?: string;
35
+ /** Set on an errored run that produced NO output at all (no tool_use, no text, no thinking) —
36
+ * the same side-effect boundary the in-process retry uses. It means the window's work provably
37
+ * did not start, so the window has not really been spent and re-running cannot double-apply
38
+ * anything. The engine uses it to re-arm the window instead of burning it. */
39
+ sideEffectFree?: boolean;
35
40
  }
36
41
 
37
42
  /** What the scheduler did about a window it found already elapsed at boot.