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.
|
|
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.
|