shraga 0.1.38 → 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';
|
|
@@ -94,7 +94,13 @@ export function start(broadcast: Broadcast): void {
|
|
|
94
94
|
if (prev === null) continue;
|
|
95
95
|
const lastAt = s.lastRun?.at;
|
|
96
96
|
if (lastAt === undefined) continue; // never ran — nothing to catch up
|
|
97
|
-
|
|
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;
|
|
98
104
|
|
|
99
105
|
const marker = readCompletionMarker(s.id);
|
|
100
106
|
if (marker && marker.completedAt >= prev) {
|
|
@@ -438,8 +444,14 @@ function judgeMissed(s: Schedule, window: number, now: number = Date.now()): Mis
|
|
|
438
444
|
/** Backoff between re-arms of a side-effect-free failed window. Deliberately minutes, not the
|
|
439
445
|
* sub-second ladder the in-process retry uses: that one exists for a flaky engine, this one for an
|
|
440
446
|
* 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
|
-
|
|
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
|
+
};
|
|
443
455
|
|
|
444
456
|
/** Cancel any pending re-arm for a schedule (disabled, deleted, or superseded by a newer window). */
|
|
445
457
|
function clearRearm(id: string): void {
|
|
@@ -468,7 +480,8 @@ function rearmWindow(s: Schedule, window: number): boolean {
|
|
|
468
480
|
const attempt = pending?.window === window ? pending.attempt + 1 : 1;
|
|
469
481
|
clearRearm(s.id);
|
|
470
482
|
|
|
471
|
-
const
|
|
483
|
+
const ladder = rearmBackoffMs();
|
|
484
|
+
const delay = ladder[Math.min(attempt - 1, ladder.length - 1)]!;
|
|
472
485
|
// Judge the window as of WHEN THE RETRY WOULD FIRE, not now — arming a timer that is already
|
|
473
486
|
// doomed to be refused just burns the ceiling in silence.
|
|
474
487
|
const verdict = judgeMissed(live, window, Date.now() + delay);
|
|
@@ -478,9 +491,15 @@ function rearmWindow(s: Schedule, window: number): boolean {
|
|
|
478
491
|
return false;
|
|
479
492
|
}
|
|
480
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);
|
|
481
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})`);
|
|
482
498
|
const timer = setTimeout(() => {
|
|
483
|
-
|
|
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.
|
|
484
503
|
const cur = getSchedule(s.id);
|
|
485
504
|
if (!cur?.enabled) return;
|
|
486
505
|
console.log(`[scheduler] retrying window ${new Date(window).toISOString()} for ${s.id}`);
|
|
@@ -671,6 +690,8 @@ function startRun(s: Schedule, firedAt: number, override?: string, resume?: Resu
|
|
|
671
690
|
recordAttemptOutcome(s.id, firedAt, summary.at, summary.status === 'running' ? 'started' : summary.status);
|
|
672
691
|
}
|
|
673
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);
|
|
674
695
|
writeCompletionMarker({ completedAt: summary.at, triggeredBy: 'scheduler', scheduleId: s.id, lastAttemptAt: summary.at, attemptWindow: firedAt, status: 'ok' });
|
|
675
696
|
if (live && live.trigger.kind === 'once') {
|
|
676
697
|
console.log(`[scheduler] auto-deleting completed once-schedule ${s.id}`);
|
|
@@ -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
|
+
}
|