@sigrun/ext-curator 0.0.0-stage → 0.2.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 +44 -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 +262 -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 +92 -0
- package/dist/steps/overlays.js +476 -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 +105 -0
- package/dist/view.js +412 -0
- package/package.json +55 -5
package/dist/loop1.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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: CHURN on the parent while the review runs
|
|
167
|
+
// fires the signal — the review reviews the conversation it saw
|
|
168
|
+
// (the host-side driver re-checks the same state itself). The
|
|
169
|
+
// watch's first frame is the acquisition snapshot (the baseline),
|
|
170
|
+
// never churn; only a frame carrying MORE model entries than the
|
|
171
|
+
// baseline means the parent actually moved. A re-rendered view
|
|
172
|
+
// (a status refresh at the same entry count) is not churn and
|
|
173
|
+
// must not kill an in-flight review (the coarse-abort ruling).
|
|
174
|
+
const abort = new AbortController();
|
|
175
|
+
let baseline;
|
|
176
|
+
const stopWatch = await deps.host.conversations.watch(id, (view) => {
|
|
177
|
+
const count = view.entries.length;
|
|
178
|
+
if (baseline === undefined) {
|
|
179
|
+
baseline = count; // the snapshot is the baseline, never churn
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (count > baseline)
|
|
183
|
+
abort.abort();
|
|
184
|
+
});
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await deps.host.curation.forkReview(id, {
|
|
188
|
+
instruction,
|
|
189
|
+
allow,
|
|
190
|
+
maxToolCalls: LOOP1_MAX_TOOL_CALLS,
|
|
191
|
+
maxOutputTokens: LOOP1_MAX_OUTPUT_TOKENS,
|
|
192
|
+
deadlineMs: LOOP1_DEADLINE_MS,
|
|
193
|
+
reviewId,
|
|
194
|
+
signal: abort.signal,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await stopWatch();
|
|
199
|
+
}
|
|
200
|
+
if (result.status === "completed") {
|
|
201
|
+
deps.backoff.success(owner);
|
|
202
|
+
// Anchor the next count at what this review covered. An absent
|
|
203
|
+
// newestEntry cannot anchor anything — the next review counts
|
|
204
|
+
// everything, an extra review at worst.
|
|
205
|
+
if (newestEntry !== undefined) {
|
|
206
|
+
await writeCursor(deps.host, owner, id, newestEntry);
|
|
207
|
+
}
|
|
208
|
+
// The review consumed these hints for good — clear their
|
|
209
|
+
// refunded ids so a later settle cannot re-fire on them.
|
|
210
|
+
await clearPendingHints(deps.host, owner, id, hints);
|
|
211
|
+
// Task B5: announce the changes (no changes → no notice). The
|
|
212
|
+
// notice is isolated: a failing setAmbient or ledger write
|
|
213
|
+
// degrades to a missing notice — never a thrown body, which the
|
|
214
|
+
// refund guard would answer with a phantom refund of an
|
|
215
|
+
// already-applied review (re-reviewing learned material).
|
|
216
|
+
await notify(deps.host, id, owner, { ...result, reviewId }).catch((error) => {
|
|
217
|
+
log(`loop 1: notice after the review of conversation ${String(id)} could not be set: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
if (result.status === "failed")
|
|
222
|
+
deps.backoff.failure(owner, now());
|
|
223
|
+
// No review happened for these hints (provider failure, the
|
|
224
|
+
// parent moving, or the deadline): refund them — the host was
|
|
225
|
+
// cleared at decision time, so the refund file is the only
|
|
226
|
+
// place they survive; the next settle merges them back in.
|
|
227
|
+
await refundHints(deps.host, owner, id, hints);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** `/learn`'s seam (Task B5): the same review path minus the
|
|
231
|
+
* threshold gate — eligibility still decides, and a mixed
|
|
232
|
+
* conversation goes to the digest queue with the mixed-conversation
|
|
233
|
+
* notice. The paused/enabled gates deliberately do NOT apply: the
|
|
234
|
+
* vacation switch stops the loops, not the user's explicit ask. */
|
|
235
|
+
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) => {
|
|
236
|
+
log(`loop 1: /learn review of conversation ${String(id)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
237
|
+
}));
|
|
238
|
+
return { onRunSettled, learnNow };
|
|
239
|
+
}
|
|
240
|
+
/** Read the per-user cursors file (absent or malformed → empty). */
|
|
241
|
+
async function readCursors(host, user) {
|
|
242
|
+
const raw = await readStateJson(join(host.stores.user(user).paths.state, CURSORS_FILE), { lastReviewedEntry: {} });
|
|
243
|
+
// Harden the shape before anyone writes through it: only a plain
|
|
244
|
+
// object of conversation-id keys → integer entries is a cursor map.
|
|
245
|
+
const entries = raw?.lastReviewedEntry;
|
|
246
|
+
if (entries === null || typeof entries !== "object" || Array.isArray(entries)) {
|
|
247
|
+
return { lastReviewedEntry: {} };
|
|
248
|
+
}
|
|
249
|
+
const lastReviewedEntry = {};
|
|
250
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
251
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
252
|
+
lastReviewedEntry[key] = value;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { lastReviewedEntry };
|
|
256
|
+
}
|
|
257
|
+
/** Write one conversation's anchor into the cursors file. */
|
|
258
|
+
async function writeCursor(host, user, id, entry) {
|
|
259
|
+
const cursors = await readCursors(host, user);
|
|
260
|
+
cursors.lastReviewedEntry[String(id)] = entry;
|
|
261
|
+
await writeStateJson(host.stores.user(user).paths.state, CURSORS_FILE, cursors);
|
|
262
|
+
}
|
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>;
|