@sigrun/ext-curator 0.0.0-stage → 0.1.0
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/backoff.d.ts +22 -0
- package/dist/backoff.js +39 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +143 -0
- package/dist/learn.d.ts +60 -0
- package/dist/learn.js +97 -0
- package/dist/loop1.d.ts +77 -0
- package/dist/loop1.js +250 -0
- package/dist/loop2.d.ts +108 -0
- package/dist/loop2.js +323 -0
- package/dist/notices.d.ts +54 -0
- package/dist/notices.js +127 -0
- package/dist/prompts.d.ts +45 -0
- package/dist/prompts.js +57 -0
- package/dist/queue.d.ts +111 -0
- package/dist/queue.js +237 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +56 -0
- package/dist/steps/archive.d.ts +40 -0
- package/dist/steps/archive.js +88 -0
- package/dist/steps/diet.d.ts +29 -0
- package/dist/steps/diet.js +131 -0
- package/dist/steps/digests.d.ts +55 -0
- package/dist/steps/digests.js +243 -0
- package/dist/steps/merge.d.ts +17 -0
- package/dist/steps/merge.js +81 -0
- package/dist/steps/overlays.d.ts +88 -0
- package/dist/steps/overlays.js +358 -0
- package/dist/steps/run.d.ts +77 -0
- package/dist/steps/run.js +76 -0
- package/dist/steps/targets.d.ts +16 -0
- package/dist/steps/targets.js +42 -0
- package/dist/view.d.ts +92 -0
- package/dist/view.js +405 -0
- package/package.json +55 -5
package/dist/loop1.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop 1 — the post-run fork review (`src/loop1.ts`, Task B2, spec
|
|
3
|
+
* §6.2).
|
|
4
|
+
*
|
|
5
|
+
* `onRunSettled` DECIDES and ENQUEUES, then returns: runSettled
|
|
6
|
+
* handlers are abandoned at `SIGRUN_HOOK_DEADLINE` (30 s), so the
|
|
7
|
+
* review body NEVER runs on the handler's stack — it runs through the
|
|
8
|
+
* per-user `ReviewQueue`.
|
|
9
|
+
*
|
|
10
|
+
* The decision tree (the brief, verbatim authority):
|
|
11
|
+
*
|
|
12
|
+
* 1. owner present, settings enabled and not paused, not backed off —
|
|
13
|
+
* else return (the hints are left pending, never consumed by a
|
|
14
|
+
* review that will not run);
|
|
15
|
+
* 2. an internal conversation returns (the bus already withholds
|
|
16
|
+
* internal settles; this read-side gate is the belt);
|
|
17
|
+
* 3. `eligibility.ownerOnly && unattributed === 0` → the fork path:
|
|
18
|
+
* take the hints (read-and-clear) merged with any refunded batch
|
|
19
|
+
* (`pending-hints.json` — hints a previous review consumed but
|
|
20
|
+
* never reviewed), count `modelCallsSince(id, lastReviewedEntry)`,
|
|
21
|
+
* and review when hints are pending (answered or not — an aborted
|
|
22
|
+
* run's hints stay pending until the next settle) or the run
|
|
23
|
+
* answered at/over the threshold;
|
|
24
|
+
* 4. anything else (mixed authorship, unattributed entries) → the
|
|
25
|
+
* threshold trigger alone queues a `queueDigest` entry: never
|
|
26
|
+
* forked, handed to loop 2 (Task B4). (Hints ride in that durable
|
|
27
|
+
* entry — taken read-and-clear and folded into the digest run's
|
|
28
|
+
* instruction by Task B4 — and the refund file drains into it, so
|
|
29
|
+
* no hint can wait on both.)
|
|
30
|
+
*
|
|
31
|
+
* The review itself: the allow-list is `skill_manage` + the served
|
|
32
|
+
* targets' tools + `read` when the parent's selection carries it; the
|
|
33
|
+
* bounds are the spec's (8 tool calls, 2048 output tokens, 2 minutes);
|
|
34
|
+
* a watch on the parent aborts the run's signal the moment the parent
|
|
35
|
+
* moves. Success anchors the per-conversation cursor
|
|
36
|
+
* (`cursors.json`) at the eligibility snapshot's newest entry; a
|
|
37
|
+
* failed run steps that user's backoff ladder; any NON-completed
|
|
38
|
+
* outcome (failed, cancelled, deadline — or a thrown review) refunds
|
|
39
|
+
* its hints to `pending-hints.json`, so a consumed-but-unreviewed
|
|
40
|
+
* hint survives until some review completes with it in hand.
|
|
41
|
+
*/
|
|
42
|
+
import { randomUUID } from "node:crypto";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { notify } from "./notices.js";
|
|
45
|
+
import { enqueueLearn } from "./learn.js";
|
|
46
|
+
import { reviewInstruction } from "./prompts.js";
|
|
47
|
+
import { clearPendingHints, mergeHints, pendingHintsFor, queueDigest, readStateJson, refundHints, writeStateJson, } from "./queue.js";
|
|
48
|
+
import { readSettings } from "./settings.js";
|
|
49
|
+
/** The per-conversation cursor file under the user's state dir. */
|
|
50
|
+
export const CURSORS_FILE = "cursors.json";
|
|
51
|
+
/** The fork-review bounds (spec §6.2: a handful of calls, small output, ~2 minutes). */
|
|
52
|
+
export const LOOP1_MAX_TOOL_CALLS = 8;
|
|
53
|
+
export const LOOP1_MAX_OUTPUT_TOKENS = 2048;
|
|
54
|
+
export const LOOP1_DEADLINE_MS = 120_000;
|
|
55
|
+
/**
|
|
56
|
+
* Create loop 1: the `runSettled` handler the factory wires, and the
|
|
57
|
+
* `learnNow` seam `/learn` runs the same review path through. The
|
|
58
|
+
* handler owns only the decision; the queue owns the review.
|
|
59
|
+
*/
|
|
60
|
+
export function createLoop1(deps) {
|
|
61
|
+
const now = deps.now ?? (() => new Date());
|
|
62
|
+
const log = deps.log ?? ((message) => console.error(`curator: ${message}`));
|
|
63
|
+
const onRunSettled = async (event) => {
|
|
64
|
+
const owner = event.owner;
|
|
65
|
+
if (owner === undefined)
|
|
66
|
+
return;
|
|
67
|
+
const id = event.conversationId;
|
|
68
|
+
const settings = await readSettings(deps.host, owner);
|
|
69
|
+
if (!settings.enabled || settings.paused)
|
|
70
|
+
return;
|
|
71
|
+
if (deps.backoff.blocked(owner, now()))
|
|
72
|
+
return;
|
|
73
|
+
const eligibility = await deps.host.curation.eligibility(id);
|
|
74
|
+
if (eligibility.internal)
|
|
75
|
+
return;
|
|
76
|
+
if (eligibility.ownerOnly && eligibility.unattributed === 0) {
|
|
77
|
+
await enqueueForkReview(owner, id, event, eligibility.newestEntry, settings.threshold);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Mixed authorship: never forked, whatever triggered us — loop
|
|
81
|
+
// 2's digest step (Task B4) reviews only the owner's turns.
|
|
82
|
+
// The same trigger gates both routes: hints (taken read-and-clear,
|
|
83
|
+
// merged into the queued entry so the digest run folds them in)
|
|
84
|
+
// or an answered run at/over the threshold (the cursor never
|
|
85
|
+
// advances on this branch, so that is "ever" — what a backlog
|
|
86
|
+
// wants).
|
|
87
|
+
const hints = mergeHints(await pendingHintsFor(deps.host, owner, id), await deps.host.curation.takeHints(id));
|
|
88
|
+
if (hints.length === 0) {
|
|
89
|
+
if (event.outcome.status !== "answered")
|
|
90
|
+
return;
|
|
91
|
+
const cursors = await readCursors(deps.host, owner);
|
|
92
|
+
const calls = await deps.host.curation.modelCallsSince(id, cursors.lastReviewedEntry[String(id)]);
|
|
93
|
+
if (calls < settings.threshold)
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await queueDigest(deps.host, owner, id, hints);
|
|
97
|
+
// The digest entry is durable and owns these ids now — drain the
|
|
98
|
+
// refund file so a later owner-only settle cannot re-fire them.
|
|
99
|
+
// After the write, never before: a crash in between duplicates a
|
|
100
|
+
// hint into two durable places (queueDigest dedups by id), it
|
|
101
|
+
// never drops one.
|
|
102
|
+
await clearPendingHints(deps.host, owner, id, hints);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Trigger evaluation for one owner-only conversation: hints always
|
|
107
|
+
* fire; otherwise the run must have answered at/over the threshold.
|
|
108
|
+
*/
|
|
109
|
+
async function enqueueForkReview(owner, id, event, newestEntry, threshold) {
|
|
110
|
+
// Hints first (read-and-clear), merged with anything a previous
|
|
111
|
+
// non-completed review refunded for this conversation (dedup by
|
|
112
|
+
// hint id): a hint stays pending across failures until some
|
|
113
|
+
// review actually completes with it in hand. When the merged set
|
|
114
|
+
// is empty, only the threshold can fire and nothing was consumed.
|
|
115
|
+
const hints = mergeHints(await pendingHintsFor(deps.host, owner, id), await deps.host.curation.takeHints(id));
|
|
116
|
+
if (hints.length === 0) {
|
|
117
|
+
if (event.outcome.status !== "answered")
|
|
118
|
+
return;
|
|
119
|
+
const cursors = await readCursors(deps.host, owner);
|
|
120
|
+
const calls = await deps.host.curation.modelCallsSince(id, cursors.lastReviewedEntry[String(id)]);
|
|
121
|
+
if (calls < threshold)
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
deps.queue.enqueue({
|
|
125
|
+
user: owner,
|
|
126
|
+
work: () => runForkReview(owner, id, hints, newestEntry, undefined, `review-${randomUUID()}`).catch((error) => {
|
|
127
|
+
log(`loop 1: review of conversation ${String(id)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** The review body (queue-owned; never on the handler's stack). */
|
|
132
|
+
async function runForkReview(owner, id, hints, newestEntry, focus, reviewId) {
|
|
133
|
+
try {
|
|
134
|
+
await reviewBody(owner, id, hints, newestEntry, focus, reviewId);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// The body threw before any outcome was recorded (a host read,
|
|
138
|
+
// the watch, the run itself): the hints were no more consumed
|
|
139
|
+
// than the run was — refund them, then let the queue's logger
|
|
140
|
+
// have the error. A refund that itself fails must not mask the
|
|
141
|
+
// original error.
|
|
142
|
+
await refundHints(deps.host, owner, id, hints).catch(() => undefined);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The review proper, factored out of {@link runForkReview}'s refund guard. */
|
|
147
|
+
async function reviewBody(owner, id, hints, newestEntry, focus, reviewId) {
|
|
148
|
+
const targets = await deps.host.curation.targets(owner, id);
|
|
149
|
+
const selection = await deps.host.curation.tools(id);
|
|
150
|
+
const allow = [
|
|
151
|
+
...new Set([
|
|
152
|
+
"skill_manage",
|
|
153
|
+
...targets.flatMap((target) => [...target.tools]),
|
|
154
|
+
...(selection.includes("read") ? ["read"] : []),
|
|
155
|
+
]),
|
|
156
|
+
];
|
|
157
|
+
const instruction = reviewInstruction({
|
|
158
|
+
hints,
|
|
159
|
+
targets: targets.map((target) => ({
|
|
160
|
+
id: target.id,
|
|
161
|
+
guidance: [...target.guidance],
|
|
162
|
+
})),
|
|
163
|
+
allow,
|
|
164
|
+
...(focus === undefined ? {} : { focus }),
|
|
165
|
+
});
|
|
166
|
+
// The parent abort: any activity on the parent while the review
|
|
167
|
+
// runs fires the signal — the review reviews the conversation it
|
|
168
|
+
// saw (the host-side driver re-checks the same state itself).
|
|
169
|
+
const abort = new AbortController();
|
|
170
|
+
const stopWatch = await deps.host.conversations.watch(id, () => {
|
|
171
|
+
abort.abort();
|
|
172
|
+
});
|
|
173
|
+
let result;
|
|
174
|
+
try {
|
|
175
|
+
result = await deps.host.curation.forkReview(id, {
|
|
176
|
+
instruction,
|
|
177
|
+
allow,
|
|
178
|
+
maxToolCalls: LOOP1_MAX_TOOL_CALLS,
|
|
179
|
+
maxOutputTokens: LOOP1_MAX_OUTPUT_TOKENS,
|
|
180
|
+
deadlineMs: LOOP1_DEADLINE_MS,
|
|
181
|
+
reviewId,
|
|
182
|
+
signal: abort.signal,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await stopWatch();
|
|
187
|
+
}
|
|
188
|
+
if (result.status === "completed") {
|
|
189
|
+
deps.backoff.success(owner);
|
|
190
|
+
// Anchor the next count at what this review covered. An absent
|
|
191
|
+
// newestEntry cannot anchor anything — the next review counts
|
|
192
|
+
// everything, an extra review at worst.
|
|
193
|
+
if (newestEntry !== undefined) {
|
|
194
|
+
await writeCursor(deps.host, owner, id, newestEntry);
|
|
195
|
+
}
|
|
196
|
+
// The review consumed these hints for good — clear their
|
|
197
|
+
// refunded ids so a later settle cannot re-fire on them.
|
|
198
|
+
await clearPendingHints(deps.host, owner, id, hints);
|
|
199
|
+
// Task B5: announce the changes (no changes → no notice). The
|
|
200
|
+
// notice is isolated: a failing setAmbient or ledger write
|
|
201
|
+
// degrades to a missing notice — never a thrown body, which the
|
|
202
|
+
// refund guard would answer with a phantom refund of an
|
|
203
|
+
// already-applied review (re-reviewing learned material).
|
|
204
|
+
await notify(deps.host, id, owner, { ...result, reviewId }).catch((error) => {
|
|
205
|
+
log(`loop 1: notice after the review of conversation ${String(id)} could not be set: ${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
if (result.status === "failed")
|
|
210
|
+
deps.backoff.failure(owner, now());
|
|
211
|
+
// No review happened for these hints (provider failure, the
|
|
212
|
+
// parent moving, or the deadline): refund them — the host was
|
|
213
|
+
// cleared at decision time, so the refund file is the only
|
|
214
|
+
// place they survive; the next settle merges them back in.
|
|
215
|
+
await refundHints(deps.host, owner, id, hints);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** `/learn`'s seam (Task B5): the same review path minus the
|
|
219
|
+
* threshold gate — eligibility still decides, and a mixed
|
|
220
|
+
* conversation goes to the digest queue with the mixed-conversation
|
|
221
|
+
* notice. The paused/enabled gates deliberately do NOT apply: the
|
|
222
|
+
* vacation switch stops the loops, not the user's explicit ask. */
|
|
223
|
+
const learnNow = (conversationId, user, focus) => enqueueLearn(deps.host, deps.queue, conversationId, user, focus, (owner, id, hints, newestEntry, reviewFocus) => runForkReview(owner, id, hints, newestEntry, reviewFocus, `learn-${randomUUID()}`).catch((error) => {
|
|
224
|
+
log(`loop 1: /learn review of conversation ${String(id)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
}));
|
|
226
|
+
return { onRunSettled, learnNow };
|
|
227
|
+
}
|
|
228
|
+
/** Read the per-user cursors file (absent or malformed → empty). */
|
|
229
|
+
async function readCursors(host, user) {
|
|
230
|
+
const raw = await readStateJson(join(host.stores.user(user).paths.state, CURSORS_FILE), { lastReviewedEntry: {} });
|
|
231
|
+
// Harden the shape before anyone writes through it: only a plain
|
|
232
|
+
// object of conversation-id keys → integer entries is a cursor map.
|
|
233
|
+
const entries = raw?.lastReviewedEntry;
|
|
234
|
+
if (entries === null || typeof entries !== "object" || Array.isArray(entries)) {
|
|
235
|
+
return { lastReviewedEntry: {} };
|
|
236
|
+
}
|
|
237
|
+
const lastReviewedEntry = {};
|
|
238
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
239
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
240
|
+
lastReviewedEntry[key] = value;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { lastReviewedEntry };
|
|
244
|
+
}
|
|
245
|
+
/** Write one conversation's anchor into the cursors file. */
|
|
246
|
+
async function writeCursor(host, user, id, entry) {
|
|
247
|
+
const cursors = await readCursors(host, user);
|
|
248
|
+
cursors.lastReviewedEntry[String(id)] = entry;
|
|
249
|
+
await writeStateJson(host.stores.user(user).paths.state, CURSORS_FILE, cursors);
|
|
250
|
+
}
|
package/dist/loop2.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop 2 — the scheduled idle maintenance pass (`src/loop2.ts`, Task
|
|
3
|
+
* B3, spec §6.2).
|
|
4
|
+
*
|
|
5
|
+
* Clocked by the `curator-tick` job (the only time-based scheduler);
|
|
6
|
+
* each fired task runs {@link tick} once, and a tick does AT MOST one
|
|
7
|
+
* pass-fragment: every user's pass is a small resumable queue of
|
|
8
|
+
* steps with a durable cursor (`pass.json` under the user's state
|
|
9
|
+
* dir), so a tick interrupted at its self-imposed 5-minute deadline
|
|
10
|
+
* leaves the cursor pointing at the next step and the NEXT tick
|
|
11
|
+
* resumes exactly there. Nothing depends on finishing in one go.
|
|
12
|
+
*
|
|
13
|
+
* The eligibility gate ({@link eligibleUsers}, spec): a user is due
|
|
14
|
+
* when they are QUIET — the newest `lastActivityAt` across their
|
|
15
|
+
* conversations is older than the idle threshold. "No live run" rides
|
|
16
|
+
* the same clock: activity is stamped at submission admission AND at
|
|
17
|
+
* run settlement (core's activity seam), so a user with a live run is
|
|
18
|
+
* never past the idle threshold — the recency check observes it. A
|
|
19
|
+
* user with no activity at all (never used, or quiet since before the
|
|
20
|
+
* seam existed) is quiet too. Plus: settings enabled and not paused,
|
|
21
|
+
* no full pass in the last 24h, and past the install's first-run
|
|
22
|
+
* grace (the FIRST tick instant + 24h, recorded once in the
|
|
23
|
+
* extension's own state — the grace is install-wide, per spec: "no
|
|
24
|
+
* pass until one full interval after install").
|
|
25
|
+
*
|
|
26
|
+
* The cursor (`PassCursor`): `steps` is the pass's queue, `done` how
|
|
27
|
+
* many steps finished (persisted after EVERY step — that is the
|
|
28
|
+
* resume point), `dailyRunsUsed`/`day` the per-user model-run budget
|
|
29
|
+
* Task B4's model steps spend (a calendar day rollover re-arms it).
|
|
30
|
+
*
|
|
31
|
+
* The step table: B3 owns the two deterministic steps (`archive`,
|
|
32
|
+
* `overlay-detect`); the five model steps are declared in the queue
|
|
33
|
+
* and run as no-ops here — Task B4 fills them in.
|
|
34
|
+
*/
|
|
35
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
36
|
+
/**
|
|
37
|
+
* The idle threshold (spec §5 pins SIGRUN_IDLE_THRESHOLD's default at
|
|
38
|
+
* 10 minutes; the extension reads the semantics, not the env — the
|
|
39
|
+
* host's activity seam already carries it).
|
|
40
|
+
*/
|
|
41
|
+
export declare const IDLE_THRESHOLD_MS = 600000;
|
|
42
|
+
/**
|
|
43
|
+
* The tick's self-imposed deadline: 5 minutes, well under the worker/
|
|
44
|
+
* job deadline, so a pass fragment always outlives its own tick.
|
|
45
|
+
*/
|
|
46
|
+
export declare const LOOP2_TICK_DEADLINE_MS = 300000;
|
|
47
|
+
/** The first-run grace: no pass until 24h after the first tick ever. */
|
|
48
|
+
export declare const FIRST_RUN_GRACE_MS: number;
|
|
49
|
+
/** How long a completed pass keeps its user ineligible. */
|
|
50
|
+
export declare const FULL_PASS_INTERVAL_MS: number;
|
|
51
|
+
/** The per-user pass-cursor file name (under the user's state dir). */
|
|
52
|
+
export declare const PASS_FILE = "pass.json";
|
|
53
|
+
/**
|
|
54
|
+
* The extension-scope state file holding the install-wide first-tick
|
|
55
|
+
* instant (the grace anchor). Chosen name; recorded here as the seam.
|
|
56
|
+
*/
|
|
57
|
+
export declare const FIRST_TICK_FILE = "loop2-first-tick.json";
|
|
58
|
+
/** One pass's step kinds, in run order (deterministic steps first). */
|
|
59
|
+
export declare const PASS_STEPS: readonly ["archive", "overlay-detect", "overlay-recheck", "digests", "diet", "merge", "targets"];
|
|
60
|
+
/** A pass step's kind (the cursor's queue entries). */
|
|
61
|
+
export type StepId = (typeof PASS_STEPS)[number];
|
|
62
|
+
/** The durable per-user pass cursor (the resume point). */
|
|
63
|
+
export interface PassCursor {
|
|
64
|
+
/** When this pass started (ISO); the 24h window anchors here. */
|
|
65
|
+
readonly startedAt: string;
|
|
66
|
+
/** The step queue, in run order. */
|
|
67
|
+
readonly steps: readonly StepId[];
|
|
68
|
+
/** How many steps have finished — the next tick resumes at `steps[done]`. */
|
|
69
|
+
readonly done: number;
|
|
70
|
+
/** Model runs spent today (Task B4 decrements the budget). */
|
|
71
|
+
readonly dailyRunsUsed: number;
|
|
72
|
+
/** The UTC day the budget counts against (`YYYY-MM-DD`). */
|
|
73
|
+
readonly day: string;
|
|
74
|
+
}
|
|
75
|
+
/** What {@link tick} accepts (all optional). */
|
|
76
|
+
export interface TickOptions {
|
|
77
|
+
/** One line per unexpected error; defaults to `console.error`. */
|
|
78
|
+
readonly log?: (message: string) => void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The users due for a pass (spec §6.2): quiet, settings on, past the
|
|
82
|
+
* first-run grace, and without a completed pass in the last 24h. An
|
|
83
|
+
* INCOMPLETE pass does not block — the next tick resumes it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function eligibleUsers(host: SigrunHost, now: () => Date): Promise<readonly string[]>;
|
|
86
|
+
/** What {@link createLoop2} needs (all but `host` are optional). */
|
|
87
|
+
export interface Loop2Deps {
|
|
88
|
+
/** The host the pass reads. */
|
|
89
|
+
readonly host: SigrunHost;
|
|
90
|
+
/** The clock every gate and step reads (tests fake it). */
|
|
91
|
+
readonly now?: () => Date;
|
|
92
|
+
/** One line per unexpected error; defaults to `console.error`. */
|
|
93
|
+
readonly log?: (message: string) => void;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Create loop 2: the tick the factory's task calls (a thin binding of
|
|
97
|
+
* {@link tick} to its deps). The durable cadence stays the job's —
|
|
98
|
+
* this creates no timers.
|
|
99
|
+
*/
|
|
100
|
+
export declare function createLoop2(deps: Loop2Deps): {
|
|
101
|
+
readonly tick: () => Promise<void>;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* One tick: as many pass fragments as fit before the 5-minute
|
|
105
|
+
* self-deadline. Per-user failures are logged and never break the
|
|
106
|
+
* tick — the cursor makes every user resumable.
|
|
107
|
+
*/
|
|
108
|
+
export declare function tick(host: SigrunHost, now: () => Date, opts?: TickOptions): Promise<void>;
|
package/dist/loop2.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop 2 — the scheduled idle maintenance pass (`src/loop2.ts`, Task
|
|
3
|
+
* B3, spec §6.2).
|
|
4
|
+
*
|
|
5
|
+
* Clocked by the `curator-tick` job (the only time-based scheduler);
|
|
6
|
+
* each fired task runs {@link tick} once, and a tick does AT MOST one
|
|
7
|
+
* pass-fragment: every user's pass is a small resumable queue of
|
|
8
|
+
* steps with a durable cursor (`pass.json` under the user's state
|
|
9
|
+
* dir), so a tick interrupted at its self-imposed 5-minute deadline
|
|
10
|
+
* leaves the cursor pointing at the next step and the NEXT tick
|
|
11
|
+
* resumes exactly there. Nothing depends on finishing in one go.
|
|
12
|
+
*
|
|
13
|
+
* The eligibility gate ({@link eligibleUsers}, spec): a user is due
|
|
14
|
+
* when they are QUIET — the newest `lastActivityAt` across their
|
|
15
|
+
* conversations is older than the idle threshold. "No live run" rides
|
|
16
|
+
* the same clock: activity is stamped at submission admission AND at
|
|
17
|
+
* run settlement (core's activity seam), so a user with a live run is
|
|
18
|
+
* never past the idle threshold — the recency check observes it. A
|
|
19
|
+
* user with no activity at all (never used, or quiet since before the
|
|
20
|
+
* seam existed) is quiet too. Plus: settings enabled and not paused,
|
|
21
|
+
* no full pass in the last 24h, and past the install's first-run
|
|
22
|
+
* grace (the FIRST tick instant + 24h, recorded once in the
|
|
23
|
+
* extension's own state — the grace is install-wide, per spec: "no
|
|
24
|
+
* pass until one full interval after install").
|
|
25
|
+
*
|
|
26
|
+
* The cursor (`PassCursor`): `steps` is the pass's queue, `done` how
|
|
27
|
+
* many steps finished (persisted after EVERY step — that is the
|
|
28
|
+
* resume point), `dailyRunsUsed`/`day` the per-user model-run budget
|
|
29
|
+
* Task B4's model steps spend (a calendar day rollover re-arms it).
|
|
30
|
+
*
|
|
31
|
+
* The step table: B3 owns the two deterministic steps (`archive`,
|
|
32
|
+
* `overlay-detect`); the five model steps are declared in the queue
|
|
33
|
+
* and run as no-ops here — Task B4 fills them in.
|
|
34
|
+
*/
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { readStateJson, writeStateJson } from "./queue.js";
|
|
37
|
+
import { readSettings } from "./settings.js";
|
|
38
|
+
import { archiveUnused } from "./steps/archive.js";
|
|
39
|
+
import { detectOverlayDrift, recheckOverlays } from "./steps/overlays.js";
|
|
40
|
+
import { digestsStep } from "./steps/digests.js";
|
|
41
|
+
import { dietStep } from "./steps/diet.js";
|
|
42
|
+
import { mergeStep } from "./steps/merge.js";
|
|
43
|
+
import { maintainTargets } from "./steps/targets.js";
|
|
44
|
+
/**
|
|
45
|
+
* The idle threshold (spec §5 pins SIGRUN_IDLE_THRESHOLD's default at
|
|
46
|
+
* 10 minutes; the extension reads the semantics, not the env — the
|
|
47
|
+
* host's activity seam already carries it).
|
|
48
|
+
*/
|
|
49
|
+
export const IDLE_THRESHOLD_MS = 600_000;
|
|
50
|
+
/**
|
|
51
|
+
* The tick's self-imposed deadline: 5 minutes, well under the worker/
|
|
52
|
+
* job deadline, so a pass fragment always outlives its own tick.
|
|
53
|
+
*/
|
|
54
|
+
export const LOOP2_TICK_DEADLINE_MS = 300_000;
|
|
55
|
+
/** The first-run grace: no pass until 24h after the first tick ever. */
|
|
56
|
+
export const FIRST_RUN_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
57
|
+
/** How long a completed pass keeps its user ineligible. */
|
|
58
|
+
export const FULL_PASS_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
59
|
+
/** The per-user pass-cursor file name (under the user's state dir). */
|
|
60
|
+
export const PASS_FILE = "pass.json";
|
|
61
|
+
/**
|
|
62
|
+
* The extension-scope state file holding the install-wide first-tick
|
|
63
|
+
* instant (the grace anchor). Chosen name; recorded here as the seam.
|
|
64
|
+
*/
|
|
65
|
+
export const FIRST_TICK_FILE = "loop2-first-tick.json";
|
|
66
|
+
/** One pass's step kinds, in run order (deterministic steps first). */
|
|
67
|
+
export const PASS_STEPS = [
|
|
68
|
+
"archive",
|
|
69
|
+
"overlay-detect",
|
|
70
|
+
"overlay-recheck",
|
|
71
|
+
"digests",
|
|
72
|
+
"diet",
|
|
73
|
+
"merge",
|
|
74
|
+
"targets",
|
|
75
|
+
];
|
|
76
|
+
/** The step-queue kinds Task B4 owns (the five model steps). */
|
|
77
|
+
const MODEL_STEPS = new Set([
|
|
78
|
+
"overlay-recheck",
|
|
79
|
+
"digests",
|
|
80
|
+
"diet",
|
|
81
|
+
"merge",
|
|
82
|
+
"targets",
|
|
83
|
+
]);
|
|
84
|
+
/** The UTC calendar day of `at` (budget days are UTC). */
|
|
85
|
+
function dayOf(at) {
|
|
86
|
+
return at.toISOString().slice(0, 10);
|
|
87
|
+
}
|
|
88
|
+
/** Read the install-wide first-tick instant; absent/malformed → none. */
|
|
89
|
+
async function readFirstTick(host) {
|
|
90
|
+
const parsed = await readStateJson(join(host.stores.paths.state, FIRST_TICK_FILE), {});
|
|
91
|
+
if (typeof parsed.firstTickAt !== "string")
|
|
92
|
+
return undefined;
|
|
93
|
+
const at = Date.parse(parsed.firstTickAt);
|
|
94
|
+
return Number.isFinite(at) ? new Date(at) : undefined;
|
|
95
|
+
}
|
|
96
|
+
/** Record the install's first-tick instant (once — the grace anchor). */
|
|
97
|
+
async function writeFirstTick(host, at) {
|
|
98
|
+
await writeStateJson(host.stores.paths.state, FIRST_TICK_FILE, {
|
|
99
|
+
firstTickAt: at.toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Read one user's pass cursor; absent/malformed → none (a fresh pass). */
|
|
103
|
+
async function readPass(host, user) {
|
|
104
|
+
const parsed = await readStateJson(join(host.stores.user(user).paths.state, PASS_FILE), {});
|
|
105
|
+
const known = new Set(PASS_STEPS);
|
|
106
|
+
if (typeof parsed.startedAt !== "string")
|
|
107
|
+
return undefined;
|
|
108
|
+
if (!Array.isArray(parsed.steps) || parsed.steps.length === 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (!parsed.steps.every((step) => typeof step === "string" && known.has(step))) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
if (typeof parsed.done !== "number" ||
|
|
114
|
+
!Number.isInteger(parsed.done) ||
|
|
115
|
+
parsed.done < 0 ||
|
|
116
|
+
parsed.done > parsed.steps.length) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (typeof parsed.dailyRunsUsed !== "number" ||
|
|
120
|
+
!Number.isInteger(parsed.dailyRunsUsed) ||
|
|
121
|
+
parsed.dailyRunsUsed < 0) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (typeof parsed.day !== "string")
|
|
125
|
+
return undefined;
|
|
126
|
+
return {
|
|
127
|
+
startedAt: parsed.startedAt,
|
|
128
|
+
steps: parsed.steps,
|
|
129
|
+
done: parsed.done,
|
|
130
|
+
dailyRunsUsed: parsed.dailyRunsUsed,
|
|
131
|
+
day: parsed.day,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Persist one user's pass cursor (after every step — the resume point). */
|
|
135
|
+
async function writePass(host, user, cursor) {
|
|
136
|
+
await writeStateJson(host.stores.user(user).paths.state, PASS_FILE, cursor);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether the user is QUIET: their newest conversation activity is
|
|
140
|
+
* older than the idle threshold. No activity at all is quiet. A live
|
|
141
|
+
* run stamps activity at admission, so it is never quiet mid-run.
|
|
142
|
+
*/
|
|
143
|
+
async function isQuiet(host, user, now) {
|
|
144
|
+
const conversations = await host.conversations.list({ owner: user });
|
|
145
|
+
let newest;
|
|
146
|
+
for (const conversation of conversations) {
|
|
147
|
+
if (conversation.lastActivityAt === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
const at = Date.parse(conversation.lastActivityAt);
|
|
150
|
+
if (Number.isFinite(at) && (newest === undefined || at > newest)) {
|
|
151
|
+
newest = at;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return newest === undefined || now.getTime() - newest >= IDLE_THRESHOLD_MS;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The users due for a pass (spec §6.2): quiet, settings on, past the
|
|
158
|
+
* first-run grace, and without a completed pass in the last 24h. An
|
|
159
|
+
* INCOMPLETE pass does not block — the next tick resumes it.
|
|
160
|
+
*/
|
|
161
|
+
export async function eligibleUsers(host, now) {
|
|
162
|
+
const firstTick = await readFirstTick(host);
|
|
163
|
+
if (firstTick === undefined)
|
|
164
|
+
return []; // the grace never started
|
|
165
|
+
const at = now();
|
|
166
|
+
if (at.getTime() - firstTick.getTime() < FIRST_RUN_GRACE_MS)
|
|
167
|
+
return [];
|
|
168
|
+
const due = [];
|
|
169
|
+
for (const row of await host.users.list()) {
|
|
170
|
+
const settings = await readSettings(host, row.user);
|
|
171
|
+
if (!settings.enabled || settings.paused)
|
|
172
|
+
continue;
|
|
173
|
+
if (!(await isQuiet(host, row.user, at)))
|
|
174
|
+
continue;
|
|
175
|
+
const pass = await readPass(host, row.user);
|
|
176
|
+
if (pass !== undefined && pass.done >= pass.steps.length) {
|
|
177
|
+
// A completed pass: due again only past its 24h window.
|
|
178
|
+
if (at.getTime() - Date.parse(pass.startedAt) < FULL_PASS_INTERVAL_MS) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
due.push(row.user);
|
|
183
|
+
}
|
|
184
|
+
return due;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Create loop 2: the tick the factory's task calls (a thin binding of
|
|
188
|
+
* {@link tick} to its deps). The durable cadence stays the job's —
|
|
189
|
+
* this creates no timers.
|
|
190
|
+
*/
|
|
191
|
+
export function createLoop2(deps) {
|
|
192
|
+
const now = deps.now ?? (() => new Date());
|
|
193
|
+
return {
|
|
194
|
+
tick: () => tick(deps.host, now, { log: deps.log }),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One tick: as many pass fragments as fit before the 5-minute
|
|
199
|
+
* self-deadline. Per-user failures are logged and never break the
|
|
200
|
+
* tick — the cursor makes every user resumable.
|
|
201
|
+
*/
|
|
202
|
+
export async function tick(host, now, opts = {}) {
|
|
203
|
+
const log = opts.log ?? ((message) => console.error(`curator: ${message}`));
|
|
204
|
+
const startedAt = now();
|
|
205
|
+
const firstTick = await readFirstTick(host);
|
|
206
|
+
if (firstTick === undefined) {
|
|
207
|
+
// The install instant: the grace window opens now, and no pass
|
|
208
|
+
// runs before it ends (24h of quiet install first).
|
|
209
|
+
await writeFirstTick(host, startedAt);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (startedAt.getTime() - firstTick.getTime() < FIRST_RUN_GRACE_MS)
|
|
213
|
+
return;
|
|
214
|
+
const deadlineAt = startedAt.getTime() + LOOP2_TICK_DEADLINE_MS;
|
|
215
|
+
for (const user of await eligibleUsers(host, now)) {
|
|
216
|
+
if (now().getTime() >= deadlineAt)
|
|
217
|
+
break;
|
|
218
|
+
await runPass(host, user, now, deadlineAt, log).catch((error) => {
|
|
219
|
+
log(`loop 2: pass for ${user} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** The step table: B3's deterministic steps and B4's model steps. */
|
|
224
|
+
const STEP_RUNNERS = {
|
|
225
|
+
archive: archiveUnused,
|
|
226
|
+
"overlay-detect": detectOverlayDrift,
|
|
227
|
+
"overlay-recheck": recheckOverlays,
|
|
228
|
+
digests: digestsStep,
|
|
229
|
+
diet: dietStep,
|
|
230
|
+
merge: mergeStep,
|
|
231
|
+
targets: maintainTargets,
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Walk one user's pass from its cursor: run steps while the tick's
|
|
235
|
+
* deadline holds, persisting `done` after every step. A MODEL step that
|
|
236
|
+
* answers false (the daily budget is spent) STOPS the pass for the day:
|
|
237
|
+
* the cursor keeps pointing at that step, and the next tick — or the
|
|
238
|
+
* next day's re-armed budget — resumes exactly there.
|
|
239
|
+
*/
|
|
240
|
+
async function runPass(host, user, now, deadlineAt, log) {
|
|
241
|
+
const today = dayOf(now());
|
|
242
|
+
const read = await readPass(host, user);
|
|
243
|
+
let fresh;
|
|
244
|
+
if (read === undefined || read.done >= read.steps.length) {
|
|
245
|
+
// No pass yet, or the previous pass completed (its 24h window is
|
|
246
|
+
// why the user is eligible again): a fresh cursor from step one.
|
|
247
|
+
fresh = {
|
|
248
|
+
startedAt: now().toISOString(),
|
|
249
|
+
steps: [...PASS_STEPS],
|
|
250
|
+
done: 0,
|
|
251
|
+
dailyRunsUsed: 0,
|
|
252
|
+
day: today,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
else if (read.day !== today) {
|
|
256
|
+
// A new day re-arms the model-run budget mid-pass.
|
|
257
|
+
fresh = { ...read, day: today, dailyRunsUsed: 0 };
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
fresh = read;
|
|
261
|
+
}
|
|
262
|
+
// The cursor holder — one mutable slot every writer goes through, so
|
|
263
|
+
// the budget closure always sees the live cursor.
|
|
264
|
+
const state = { cursor: fresh };
|
|
265
|
+
await writePass(host, user, state.cursor);
|
|
266
|
+
// The daily model-run budget (Task B4): `dailyRuns` minus what today
|
|
267
|
+
// already spent. Every spend persists the cursor BEFORE its run —
|
|
268
|
+
// a crash may waste a run, it never overspends the day.
|
|
269
|
+
const settings = await readSettings(host, user);
|
|
270
|
+
const budget = {
|
|
271
|
+
left: () => Math.max(0, settings.dailyRuns - state.cursor.dailyRunsUsed),
|
|
272
|
+
spend: async () => {
|
|
273
|
+
if (state.cursor.dailyRunsUsed >= settings.dailyRuns)
|
|
274
|
+
return false;
|
|
275
|
+
state.cursor = {
|
|
276
|
+
...state.cursor,
|
|
277
|
+
dailyRunsUsed: state.cursor.dailyRunsUsed + 1,
|
|
278
|
+
};
|
|
279
|
+
await writePass(host, user, state.cursor);
|
|
280
|
+
return true;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
while (state.cursor.done < state.cursor.steps.length) {
|
|
284
|
+
const cursor = state.cursor;
|
|
285
|
+
// Out of tick budget: stop BEFORE the next step — the cursor is
|
|
286
|
+
// the resume point the next tick picks up.
|
|
287
|
+
if (now().getTime() >= deadlineAt)
|
|
288
|
+
return;
|
|
289
|
+
const step = cursor.steps[cursor.done];
|
|
290
|
+
const runner = STEP_RUNNERS[step];
|
|
291
|
+
const deps = {
|
|
292
|
+
host,
|
|
293
|
+
user,
|
|
294
|
+
now,
|
|
295
|
+
deadlineAt,
|
|
296
|
+
budget,
|
|
297
|
+
log,
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
if (MODEL_STEPS.has(step)) {
|
|
301
|
+
const proceed = await runner(deps);
|
|
302
|
+
if (!proceed) {
|
|
303
|
+
// The daily cap (or the tick deadline) stopped the pass: the
|
|
304
|
+
// cursor stays at this step — the next tick resumes it, and a
|
|
305
|
+
// new day re-arms the budget.
|
|
306
|
+
log(`loop 2: ${user} stopped at step "${step}" for the day`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
await runner(host, user, now);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
// The step failed; the pass moves on — a deterministic step
|
|
316
|
+
// wedging the cursor forever would be worse than skipping it
|
|
317
|
+
// this pass (idempotent steps make a later pass safe).
|
|
318
|
+
log(`loop 2: ${user} step "${step}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
319
|
+
}
|
|
320
|
+
state.cursor = { ...state.cursor, done: state.cursor.done + 1 };
|
|
321
|
+
await writePass(host, user, state.cursor);
|
|
322
|
+
}
|
|
323
|
+
}
|