shraga 0.1.38 → 0.1.40

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.
@@ -131,9 +131,16 @@ Write to `data/workspace/garden-reports/{YYYY-MM-DD}.md`:
131
131
 
132
132
  The full report is the audit trail. Raw agent reports let you verify the diagnosis. Diff previews let you verify the prescription. Skipped findings let you verify the triage.
133
133
 
134
+ ### Check auto-apply preference
135
+
136
+ Read `data/workspace/preferences/garden.json` (if present). This is the owner's standing instruction and **overrides the default approval gate** — check it before deciding whether to stop and wait.
137
+
138
+ - `{"autoApply": true, ...}` → **Phase 4 applies automatically**, no waiting. Only proposals whose `Risk` is `none` or `low` auto-apply; anything `medium`/`high`, or involving conflicting sources / a judgment call, still goes to DM-and-wait regardless of the flag (per the file's own `note` on what still needs Elya).
139
+ - Missing file, or `autoApply` absent/false → default gate applies: propose, DM, and **STOP** (below).
140
+
134
141
  ### Send Slack DM to owners
135
142
 
136
- Send a DM to each owner (from `<known_contacts>` with Slack IDs) using `post_slack_message`:
143
+ Send a DM to each owner (from `<known_contacts>` with Slack IDs) using `post_slack_message`. Adjust wording based on the auto-apply check above:
137
144
 
138
145
  ```
139
146
  🌱 Knowledge Garden — {date}
@@ -152,17 +159,21 @@ Full audit trail: data/workspace/garden-reports/{date}.md
152
159
  Diff: -{removed summary} +{added summary}
153
160
 
154
161
  Skipped {N} lower-priority findings (see report).
155
- Reply with numbers to approve (e.g. "1,3") or "all".
156
162
  ```
157
163
 
158
- **STOP here. Do not apply changes. Wait for user approval.**
164
+ If autoApply: append `Auto-applying #1, #2 (risk: none/low). Nothing needs your review this run.` — or, if something is held back — `Applied #1 automatically. #2 needs your call (risk: medium / conflicting sources) — reply with the number to approve.`
165
+ If not autoApply: append `Reply with numbers to approve (e.g. "1,3") or "all".` and **STOP here. Do not apply changes. Wait for user approval.**
166
+
167
+ ## Phase 4: Apply
168
+
169
+ **If autoApply (per the check above):** apply every proposed change with `Risk: none` or `Risk: low` immediately, in the same run — no waiting. Hold back anything `medium`/`high` risk and DM as described above.
159
170
 
160
- ## Phase 4: Apply (only when user approves)
171
+ **If not autoApply:** apply only when the user replies with numbers (e.g. "1,3") or "all".
161
172
 
162
- When the user replies with numbers (e.g. "1,3") or "all":
173
+ Either way:
163
174
 
164
175
  1. Load the report from `data/workspace/garden-reports/{date}.md`
165
- 2. Apply only the approved changes using Edit tool
176
+ 2. Apply the approved/qualifying changes using Edit tool
166
177
  3. Commit:
167
178
 
168
179
  ```bash
@@ -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 three** paths that could
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.38",
3
+ "version": "0.1.40",
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
- if (lastAt >= prev) continue;
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
- const REARM_BACKOFF_MS = [5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000];
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 delay = REARM_BACKOFF_MS[Math.min(attempt - 1, REARM_BACKOFF_MS.length - 1)]!;
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
- state.retries.delete(s.id);
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
+ }