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
|
-
|
|
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
|
-
|
|
171
|
+
**If not autoApply:** apply only when the user replies with numbers (e.g. "1,3") or "all".
|
|
161
172
|
|
|
162
|
-
|
|
173
|
+
Either way:
|
|
163
174
|
|
|
164
175
|
1. Load the report from `data/workspace/garden-reports/{date}.md`
|
|
165
|
-
2. Apply
|
|
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
|
|
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.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
|
-
|
|
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
|
+
}
|