shraga 0.1.37 → 0.1.39
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.
|
@@ -150,6 +150,11 @@ with the process frozen), the scheduler decides whether to replay it. `onMissed`
|
|
|
150
150
|
| `skip` | Never replay. The window is simply lost. |
|
|
151
151
|
| `offer` | Don't auto-fire, but **record** it so a human/agent can run it on demand. |
|
|
152
152
|
|
|
153
|
+
`skip`/`offer` suppress *unattended replay* generally — including the failed-run re-arm below, not
|
|
154
|
+
just a window missed while the process was down. A schedule set to `offer` will not retry itself
|
|
155
|
+
after an outage; that is the point of the setting, but it is also how a daily report goes quietly
|
|
156
|
+
missing for a day.
|
|
157
|
+
|
|
153
158
|
```bash
|
|
154
159
|
curl -s -X PUT -H "Content-Type: application/json" -H "x-internal-token: $INTERNAL_API_TOKEN" \
|
|
155
160
|
http://localhost:$PORT/api/schedules/{id} -d '{ "onMissed": "offer" }' | jq .
|
|
@@ -158,8 +163,16 @@ curl -s -X PUT -H "Content-Type: application/json" -H "x-internal-token: $INTERN
|
|
|
158
163
|
- **A staleness ceiling overrides every policy, `run` included**: a window more than
|
|
159
164
|
`SCHEDULER_MAX_MISSED_AGE_MS` (default **6h**) late is never replayed. Finishing an 08:00 report
|
|
160
165
|
at 22:00 is not the job the schedule describes.
|
|
161
|
-
- One decision (`judgeMissed` in `scheduler/engine.ts`) governs **all
|
|
166
|
+
- One decision (`judgeMissed` in `scheduler/engine.ts`) governs **all four** paths that could
|
|
162
167
|
replay a window:
|
|
168
|
+
0. **Failed-run re-arm** — the run fired on time but errored *before producing anything* (no
|
|
169
|
+
`tool_use`, no text, no thinking: `sideEffectFree` on the run summary). The work provably
|
|
170
|
+
never started, so the window has not been spent. The engine retries the **same** window on a
|
|
171
|
+
5/15/30/60m backoff — the in-process ladder in `runner.ts` is sub-second, tuned for a flaky
|
|
172
|
+
engine, and an outage (a capped API key, a dead upstream) outlives it. A newer window
|
|
173
|
+
supersedes a pending retry; disable/delete cancels it; success clears it. The re-arm timer is
|
|
174
|
+
in-memory, so boot catch-up re-derives the same condition from `lastRun` and picks it up
|
|
175
|
+
across a restart.
|
|
163
176
|
1. **Boot catch-up** (cron only) — the process was down when the window passed.
|
|
164
177
|
2. **In-place resume** of a run interrupted mid-flight — every trigger kind
|
|
165
178
|
(`interval`/`once`/`event` have no catch-up, so resume is their only gate; their window is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
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",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadSchedules, saveSchedules, readCompletionMarker, writeCompletionMarker, readRunningMarker, isProcessAlive, loadThrottleState, saveThrottleState, acquireRunLock, clearRunningMarker, markRunStarted } from './storage.ts';
|
|
1
|
+
import { loadSchedules, saveSchedules, readCompletionMarker, writeCompletionMarker, readRunningMarker, isProcessAlive, loadThrottleState, saveThrottleState, acquireRunLock, clearRunningMarker, markRunStarted, releaseAttemptWindow } from './storage.ts';
|
|
2
2
|
import { computeNextRun, computePrevRun, validateTrigger } from './timing.ts';
|
|
3
3
|
import { runSchedule, type ResumeOptions, type EventContext } from './runner.ts';
|
|
4
4
|
import { backfillScope, ensureBuiltinSchedules } from './builtins.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
|
|
|
@@ -90,7 +94,13 @@ export function start(broadcast: Broadcast): void {
|
|
|
90
94
|
if (prev === null) continue;
|
|
91
95
|
const lastAt = s.lastRun?.at;
|
|
92
96
|
if (lastAt === undefined) continue; // never ran — nothing to catch up
|
|
93
|
-
|
|
97
|
+
// A run that errored WITHOUT producing anything did not spend its window (see rearmWindow):
|
|
98
|
+
// its work provably never started, so the window is still owed even though `lastRun.at` is
|
|
99
|
+
// newer than it. The in-memory re-arm timer does NOT survive a restart — and on a box whose
|
|
100
|
+
// watchdog restarts the service, that is the common case, not the exotic one — so boot must
|
|
101
|
+
// recognise the same condition or the retry silently dies with the process.
|
|
102
|
+
const owedByFailure = s.lastRun?.status === 'error' && s.lastRun.sideEffectFree === true;
|
|
103
|
+
if (lastAt >= prev && !owedByFailure) continue;
|
|
94
104
|
|
|
95
105
|
const marker = readCompletionMarker(s.id);
|
|
96
106
|
if (marker && marker.completedAt >= prev) {
|
|
@@ -182,6 +192,7 @@ export function deleteSchedule(id: string): boolean {
|
|
|
182
192
|
if (idx < 0) return false;
|
|
183
193
|
state.schedules.splice(idx, 1);
|
|
184
194
|
state.queues.delete(id);
|
|
195
|
+
clearRearm(id);
|
|
185
196
|
const ac = state.running.get(id);
|
|
186
197
|
if (ac) ac.abort();
|
|
187
198
|
saveSchedules(state.schedules);
|
|
@@ -205,6 +216,8 @@ export function toggleSchedule(id: string, enabled: boolean): Schedule | null {
|
|
|
205
216
|
}
|
|
206
217
|
} else {
|
|
207
218
|
s.nextRun = undefined;
|
|
219
|
+
// A disabled schedule must not come back to life via a pending retry.
|
|
220
|
+
clearRearm(id);
|
|
208
221
|
}
|
|
209
222
|
saveSchedules(state.schedules);
|
|
210
223
|
replan();
|
|
@@ -428,6 +441,75 @@ function judgeMissed(s: Schedule, window: number, now: number = Date.now()): Mis
|
|
|
428
441
|
return { replay: true };
|
|
429
442
|
}
|
|
430
443
|
|
|
444
|
+
/** Backoff between re-arms of a side-effect-free failed window. Deliberately minutes, not the
|
|
445
|
+
* sub-second ladder the in-process retry uses: that one exists for a flaky engine, this one for an
|
|
446
|
+
* outage — a capped API key or a dead upstream is not coming back in 2 seconds. The last entry
|
|
447
|
+
* repeats; the real bound is the staleness ceiling, which stops replay well before the ladder does.
|
|
448
|
+
* Read per call (like `catchupDelayMs`) so tests — and an operator — can shorten it. */
|
|
449
|
+
const rearmBackoffMs = (): number[] => {
|
|
450
|
+
const override = process.env.SCHEDULER_REARM_BACKOFF_MS;
|
|
451
|
+
if (!override) return [5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000];
|
|
452
|
+
const parsed = override.split(',').map((v) => Number(v.trim())).filter((v) => Number.isFinite(v) && v >= 0);
|
|
453
|
+
return parsed.length ? parsed : [5 * 60_000];
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** Cancel any pending re-arm for a schedule (disabled, deleted, or superseded by a newer window). */
|
|
457
|
+
function clearRearm(id: string): void {
|
|
458
|
+
const pending = state.retries.get(id);
|
|
459
|
+
if (!pending) return;
|
|
460
|
+
clearTimeout(pending.timer);
|
|
461
|
+
state.retries.delete(id);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Re-arm a window whose run failed before producing any output. Returns true when a retry is
|
|
466
|
+
* pending — the caller must then NOT record an attempt outcome, because an attempt marker is
|
|
467
|
+
* exactly what makes the window un-replayable.
|
|
468
|
+
*
|
|
469
|
+
* Refuses (returns false, caller falls through to the normal failure path) when the retry would
|
|
470
|
+
* land outside what `judgeMissed` permits: past the staleness ceiling, or against an explicit
|
|
471
|
+
* `onMissed` of `skip`/`offer`. Those are standing instructions about unattended replay and this
|
|
472
|
+
* is unattended replay, so it answers to them rather than routing around them.
|
|
473
|
+
*/
|
|
474
|
+
function rearmWindow(s: Schedule, window: number): boolean {
|
|
475
|
+
if (!schedulerActive) return false;
|
|
476
|
+
const live = getSchedule(s.id);
|
|
477
|
+
if (!live?.enabled) return false;
|
|
478
|
+
|
|
479
|
+
const pending = state.retries.get(s.id);
|
|
480
|
+
const attempt = pending?.window === window ? pending.attempt + 1 : 1;
|
|
481
|
+
clearRearm(s.id);
|
|
482
|
+
|
|
483
|
+
const ladder = rearmBackoffMs();
|
|
484
|
+
const delay = ladder[Math.min(attempt - 1, ladder.length - 1)]!;
|
|
485
|
+
// Judge the window as of WHEN THE RETRY WOULD FIRE, not now — arming a timer that is already
|
|
486
|
+
// doomed to be refused just burns the ceiling in silence.
|
|
487
|
+
const verdict = judgeMissed(live, window, Date.now() + delay);
|
|
488
|
+
if (!verdict.replay) {
|
|
489
|
+
console.log(`[scheduler] not re-arming ${s.id} — ${verdict.message}`);
|
|
490
|
+
if (verdict.reason !== 'skip') noteMissed(live, window, verdict.reason);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The at-start marker already stamped this window as attempted; that stamp is what would make
|
|
495
|
+
// the replay a no-op, so drop it. Nothing else about the marker moves.
|
|
496
|
+
releaseAttemptWindow(s.id, window);
|
|
497
|
+
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})`);
|
|
498
|
+
const timer = setTimeout(() => {
|
|
499
|
+
// Deliberately NOT deleting the entry: it carries the attempt count, and the retry we are
|
|
500
|
+
// about to fire may fail again. Dropping it here would restart the ladder at 5m on every
|
|
501
|
+
// round — a fixed 5m poll wearing the shape of a backoff. It is cleared on success, on a
|
|
502
|
+
// superseding window, and on disable/delete.
|
|
503
|
+
const cur = getSchedule(s.id);
|
|
504
|
+
if (!cur?.enabled) return;
|
|
505
|
+
console.log(`[scheduler] retrying window ${new Date(window).toISOString()} for ${s.id}`);
|
|
506
|
+
enqueueFire(cur, window);
|
|
507
|
+
}, delay);
|
|
508
|
+
timer.unref?.();
|
|
509
|
+
state.retries.set(s.id, { timer, window, attempt });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
431
513
|
function noteMissed(s: Schedule, at: number, reason: 'skip' | 'offer' | 'stale'): void {
|
|
432
514
|
s.missedRun = { at, reason, noticedAt: Date.now() };
|
|
433
515
|
saveSchedules(state.schedules);
|
|
@@ -497,6 +579,13 @@ function fireDue(): void {
|
|
|
497
579
|
}
|
|
498
580
|
|
|
499
581
|
function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = false, eventCtx?: EventContext): RunOutcome {
|
|
582
|
+
// A newer window supersedes a pending retry of an older one: today's 08:00 report is the job,
|
|
583
|
+
// not yesterday's. Retrying both would double-post.
|
|
584
|
+
const pendingRetry = state.retries.get(s.id);
|
|
585
|
+
if (pendingRetry && firedAt > pendingRetry.window) {
|
|
586
|
+
console.log(`[scheduler] dropping pending retry of ${new Date(pendingRetry.window).toISOString()} for ${s.id} — superseded by a newer window`);
|
|
587
|
+
clearRearm(s.id);
|
|
588
|
+
}
|
|
500
589
|
// Skip if this cron period was already completed/attempted. Manual runs (runNow from UI/API)
|
|
501
590
|
// always proceed past the period guard — but never past the run lock in startRun().
|
|
502
591
|
if (!manual && s.trigger.kind === 'cron') {
|
|
@@ -588,12 +677,21 @@ function startRun(s: Schedule, firedAt: number, override?: string, resume?: Resu
|
|
|
588
677
|
saveSchedules(state.schedules);
|
|
589
678
|
state.broadcast({ type: 'schedule:updated', schedule: live });
|
|
590
679
|
}
|
|
680
|
+
// A run that failed WITHOUT producing anything has not spent its window: the work provably
|
|
681
|
+
// never started, so re-running cannot double-apply a side effect. Re-arm instead of burning
|
|
682
|
+
// the window — otherwise a multi-hour upstream outage (an API spend cap, a dead engine)
|
|
683
|
+
// silently costs the day's run, which is exactly how a morning report goes missing with
|
|
684
|
+
// every marker reading "attempted". Recording the attempt is deferred to the give-up path,
|
|
685
|
+
// because an attempt marker is precisely what blocks the replay we want.
|
|
686
|
+
if (summary.status === 'error' && summary.sideEffectFree && rearmWindow(s, firedAt)) return;
|
|
591
687
|
if (summary.status !== 'ok') {
|
|
592
688
|
// Keep the attempt on record with its real outcome — the next boot must see that this
|
|
593
689
|
// window was tried and failed, not that it never ran.
|
|
594
690
|
recordAttemptOutcome(s.id, firedAt, summary.at, summary.status === 'running' ? 'started' : summary.status);
|
|
595
691
|
}
|
|
596
692
|
if (summary.status === 'ok') {
|
|
693
|
+
// The window landed — drop any retry bookkeeping so the next failure starts a fresh ladder.
|
|
694
|
+
clearRearm(s.id);
|
|
597
695
|
writeCompletionMarker({ completedAt: summary.at, triggeredBy: 'scheduler', scheduleId: s.id, lastAttemptAt: summary.at, attemptWindow: firedAt, status: 'ok' });
|
|
598
696
|
if (live && live.trigger.kind === 'once') {
|
|
599
697
|
console.log(`[scheduler] auto-deleting completed once-schedule ${s.id}`);
|
|
@@ -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
|
}
|
|
@@ -170,3 +170,21 @@ export function markRunStarted(scheduleId: string, window: number, triggeredBy:
|
|
|
170
170
|
status: 'started',
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Undo the at-start attempt stamp for a window whose run turned out to have done nothing.
|
|
175
|
+
* `markRunStarted` records `attemptWindow` BEFORE the work happens — right for a crash (the run
|
|
176
|
+
* had its shot), wrong for a run that provably never started, because that stamp is precisely
|
|
177
|
+
* what makes a window un-replayable. `completedAt` is untouched: a period that genuinely
|
|
178
|
+
* completed must stay guarded no matter what a later attempt does. */
|
|
179
|
+
export function releaseAttemptWindow(scheduleId: string, window: number): void {
|
|
180
|
+
const prev = readCompletionMarker(scheduleId);
|
|
181
|
+
if (!prev || prev.attemptWindow !== window) return;
|
|
182
|
+
writeCompletionMarker({
|
|
183
|
+
completedAt: prev.completedAt,
|
|
184
|
+
triggeredBy: prev.triggeredBy,
|
|
185
|
+
scheduleId,
|
|
186
|
+
lastAttemptAt: prev.lastAttemptAt,
|
|
187
|
+
attemptWindow: undefined,
|
|
188
|
+
status: prev.status,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -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.
|