@percepteye/agent-flywheel 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/src/turns.js ADDED
@@ -0,0 +1,437 @@
1
+ /**
2
+ * A captured production turn, on disk, in the format the Python SDK already
3
+ * writes and reads.
4
+ *
5
+ * ONE FORMAT, TWO IMPLEMENTATIONS. A customer deploys this package or the
6
+ * Python one, never both -- so the two must agree, and the thing that makes
7
+ * them agree cannot be a comment saying so. It is this: a turn is a DIRECTORY
8
+ * named for the turn id, with the shared core files below and two optional
9
+ * execution-identity files whose names and shapes also match Python:
10
+ *
11
+ * tool_calls.jsonl one JSON object per executed tool call (already ours:
12
+ * `trajectory.js` has written it since day one, and it is
13
+ * why nothing here reimplements it)
14
+ * turns.jsonl {kind:"task"|"answer", text, turn_id,
15
+ * conversation_id, task_id}
16
+ * conversation_id the conversation this turn belongs to, as bare text
17
+ * task_id caller-authored case/work-item correlation, as text
18
+ * task_id.conflict monotonic abstention when two task bindings disagree
19
+ * agent_fingerprint.json an exact, SDK-reconciled execution fingerprint
20
+ * agent_fingerprint.conflict permanent abstention if writers disagree
21
+ *
22
+ * The Python end is `agent_flywheel.outcomes` (`record_turn_text`,
23
+ * `read_turn_text`, `read_conversation_id`) and `production.Attachment._turn_wire`.
24
+ * The rules below are not restatements of that code; they are the same rules,
25
+ * and where one is subtle the reason is given so the two do not drift apart by
26
+ * someone "simplifying" one side.
27
+ *
28
+ * WHAT MAKES A TURN HERE. OpenClaw gives every agent run a `runId`, and it is
29
+ * on all three hooks this lane uses -- `message_received` (the end user's
30
+ * message), `after_tool_call` (event AND context), and `agent_end` (the
31
+ * answer). So the turn boundary is DECLARED by the host rather than inferred
32
+ * by us, which is the same standard the Python side holds: guessing where one
33
+ * turn ends and the next begins is how one user's tool calls end up in
34
+ * another's record.
35
+ */
36
+ import {
37
+ appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync,
38
+ writeFileSync,
39
+ } from "node:fs";
40
+ import { basename, join } from "node:path";
41
+
42
+ import { readTrajectory, TOOL_CALLS_FILENAME } from "./trajectory.js";
43
+ import { isLowerSha256 } from "./execution-identity.js";
44
+ import {
45
+ ContractError, productionIdentifier, productionTurnIdentifier,
46
+ } from "./wire.js";
47
+
48
+ export const TURNS_FILENAME = "turns.jsonl";
49
+ export const CONVERSATION_ID_FILENAME = "conversation_id";
50
+ export const TASK_ID_FILENAME = "task_id";
51
+ export const TASK_ID_CONFLICT_FILENAME = "task_id.conflict";
52
+ export const AGENT_FINGERPRINT_FILENAME = "agent_fingerprint.json";
53
+ export const AGENT_FINGERPRINT_CONFLICT_FILENAME = "agent_fingerprint.conflict";
54
+
55
+ /** The two halves of a turn. Anything else is a programming error. */
56
+ export const TURN_KINDS = ["task", "answer"];
57
+
58
+ /**
59
+ * Matches Python's `record_turn_text(max_chars=20_000)`.
60
+ *
61
+ * A cap rather than the whole message, because this is a customer's end user's
62
+ * text and a runaway paste should not put a megabyte per turn on their disk.
63
+ * The same number on both sides, so a turn captured by either package
64
+ * truncates at the same point and the two corpora are comparable.
65
+ *
66
+ * CHARS MEANS CODE POINTS, which is what `truncateChars` below exists to make
67
+ * true -- see it for why `slice` was not that.
68
+ */
69
+ export const MAX_TEXT_CHARS = 20_000;
70
+
71
+ /**
72
+ * `text[:MAX_TEXT_CHARS]` as PYTHON means it: code POINTS, not UTF-16 units.
73
+ *
74
+ * `String.prototype.slice` counts UTF-16 code units, so it truncated a
75
+ * mostly-emoji message at HALF the documented point -- the two corpora were
76
+ * not comparable, which is the one thing the shared constant is for. Worse, a
77
+ * cut landing between the halves of a surrogate pair leaves a LONE SURROGATE,
78
+ * which `JSON.stringify` writes into `turns.jsonl` and onto the wire; the
79
+ * intake cannot encode it to UTF-8, the chunk is never acknowledged, and
80
+ * `createTurnUploader.flush` leaves every marker in that chunk unwritten, so
81
+ * the poisoned turn is re-sent on every flush forever. Iterating the string
82
+ * yields whole code points, so neither is possible.
83
+ *
84
+ * The length guard is not an optimisation of the general case, it IS the
85
+ * common case: code units are never fewer than code points, so text under the
86
+ * cap in units is under it in points and never needs to be split at all.
87
+ */
88
+ function truncateChars(text) {
89
+ if (text.length <= MAX_TEXT_CHARS) return text;
90
+ return [...text].slice(0, MAX_TEXT_CHARS).join("");
91
+ }
92
+
93
+ /** One turn's directory. The directory name IS the turn id. */
94
+ export function turnDir(root, turnId) {
95
+ const exact = safeTurnId(turnId);
96
+ if (exact === null) {
97
+ throw new ContractError("turn id does not match the production identifier grammar");
98
+ }
99
+ return join(root, exact);
100
+ }
101
+
102
+ /**
103
+ * A turn id is host-supplied, and it becomes a path segment.
104
+ *
105
+ * Valid opaque ids are preserved byte-for-byte. Invalid values return null;
106
+ * the host adapter can then mint a fresh id for that run rather than silently
107
+ * cleaning two different caller ids into the same directory name.
108
+ */
109
+ export function safeTurnId(value) {
110
+ return productionTurnIdentifier(value);
111
+ }
112
+
113
+ /**
114
+ * Append one half of a turn. Returns whether anything was written.
115
+ *
116
+ * NEVER THROWS. This runs inside a customer's production request, and the
117
+ * standing rule is that a capture bug costs the observation and never the
118
+ * turn.
119
+ *
120
+ * EMPTY TEXT WRITES NOTHING, rather than writing an empty record: "the agent
121
+ * answered with silence" and "we were not given the answer" are different
122
+ * claims, and only the second is true when there is no text. Collapsing them
123
+ * would put an empty `final_text` on the wire, which the intake would accept
124
+ * as a real -- and terrible -- answer.
125
+ */
126
+ export function recordTurnText(dir, kind, text, ids = {}) {
127
+ if (!TURN_KINDS.includes(kind)) {
128
+ throw new Error(`turn kind must be one of ${TURN_KINDS}, got ${kind}`);
129
+ }
130
+ if (typeof text !== "string" || !text.trim()) return false;
131
+ const record = { kind, text: truncateChars(text.trim()) };
132
+ for (const [k, v] of Object.entries(ids)) {
133
+ if (typeof v === "string" && v) record[k] = v;
134
+ }
135
+ try {
136
+ mkdirSync(dir, { recursive: true });
137
+ appendFileSync(join(dir, TURNS_FILENAME),
138
+ JSON.stringify(record) + "\n", "utf8");
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** Record which conversation this turn belongs to. Idempotent, never throws. */
146
+ export function recordConversationId(dir, conversationId) {
147
+ const exact = productionIdentifier(conversationId);
148
+ if (exact === null) return false;
149
+ try {
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(join(dir, CONVERSATION_ID_FILENAME),
152
+ exact, "utf8");
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Persist one caller-authored task/case correlation without relabelling a
161
+ * mixed turn. This is neutral evidence only; it grants no training meaning.
162
+ */
163
+ export function recordTaskId(dir, taskId) {
164
+ const exact = productionIdentifier(taskId);
165
+ if (exact === null) return false;
166
+ const sidecar = join(dir, TASK_ID_FILENAME);
167
+ const conflict = join(dir, TASK_ID_CONFLICT_FILENAME);
168
+ try {
169
+ mkdirSync(dir, { recursive: true });
170
+ if (existsSync(conflict)) return false;
171
+ try {
172
+ writeFileSync(sidecar, exact, { encoding: "utf8", flag: "wx" });
173
+ return true;
174
+ } catch (err) {
175
+ if (err?.code !== "EEXIST") return false;
176
+ }
177
+ let current = null;
178
+ try {
179
+ current = readFileSync(sidecar, "utf8");
180
+ } catch {
181
+ current = null;
182
+ }
183
+ if (current === exact) return true;
184
+ try {
185
+ writeFileSync(conflict, "", { encoding: "utf8", flag: "wx" });
186
+ } catch (err) {
187
+ if (err?.code !== "EEXIST") return false;
188
+ }
189
+ } catch {
190
+ return false;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Persist an SDK-reconciled identity without letting a second writer relabel
197
+ * a mixed turn. A conflict marker is monotonic and makes every reader abstain.
198
+ */
199
+ export function recordExecutionFingerprint(dir, fingerprint) {
200
+ const digest = fingerprint?.execution_sha256;
201
+ if (!isLowerSha256(digest)) return false;
202
+ const expected = { execution_sha256: digest };
203
+ const sidecar = join(dir, AGENT_FINGERPRINT_FILENAME);
204
+ const conflict = join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME);
205
+ try {
206
+ mkdirSync(dir, { recursive: true });
207
+ if (existsSync(conflict)) return false;
208
+ try {
209
+ writeFileSync(sidecar, JSON.stringify(expected), { encoding: "utf8", flag: "wx" });
210
+ return true;
211
+ } catch (err) {
212
+ if (err?.code !== "EEXIST") return false;
213
+ }
214
+ let current = null;
215
+ try {
216
+ current = JSON.parse(readFileSync(sidecar, "utf8"));
217
+ } catch {
218
+ current = null;
219
+ }
220
+ if (
221
+ current && typeof current === "object" && !Array.isArray(current)
222
+ && Object.keys(current).length === 1
223
+ && current.execution_sha256 === digest
224
+ ) return true;
225
+ try {
226
+ writeFileSync(conflict, "", { encoding: "utf8", flag: "wx" });
227
+ } catch (err) {
228
+ if (err?.code !== "EEXIST") return false;
229
+ }
230
+ } catch {
231
+ return false;
232
+ }
233
+ return false;
234
+ }
235
+
236
+ /** Permanently invalidate a turn whose observations disagree. */
237
+ export function recordExecutionFingerprintConflict(dir) {
238
+ try {
239
+ mkdirSync(dir, { recursive: true });
240
+ try {
241
+ writeFileSync(join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME), "", {
242
+ encoding: "utf8", flag: "wx",
243
+ });
244
+ } catch (err) {
245
+ if (err?.code !== "EEXIST") return false;
246
+ }
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ /** Read only the exact sidecar shape this SDK authors. */
254
+ export function readExecutionFingerprint(dir) {
255
+ const conflict = join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME);
256
+ if (existsSync(conflict)) return null;
257
+ let value;
258
+ try {
259
+ value = JSON.parse(readFileSync(join(dir, AGENT_FINGERPRINT_FILENAME), "utf8"));
260
+ } catch {
261
+ return null;
262
+ }
263
+ if (
264
+ !value || typeof value !== "object" || Array.isArray(value)
265
+ || Object.keys(value).length !== 1
266
+ || !isLowerSha256(value.execution_sha256)
267
+ || existsSync(conflict)
268
+ ) return null;
269
+ return { execution_sha256: value.execution_sha256 };
270
+ }
271
+
272
+ /** The conversation this turn declared, or `null`. */
273
+ export function readConversationId(dir) {
274
+ try {
275
+ const raw = readFileSync(join(dir, CONVERSATION_ID_FILENAME), "utf8");
276
+ return productionIdentifier(raw) === raw ? raw : null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /** Read an exact, non-conflicting task binding, or null. */
283
+ export function readTaskId(dir) {
284
+ const conflict = join(dir, TASK_ID_CONFLICT_FILENAME);
285
+ if (existsSync(conflict)) return null;
286
+ let raw;
287
+ try {
288
+ raw = readFileSync(join(dir, TASK_ID_FILENAME), "utf8");
289
+ } catch {
290
+ return null;
291
+ }
292
+ return productionIdentifier(raw) === raw && !existsSync(conflict) ? raw : null;
293
+ }
294
+
295
+ /**
296
+ * The turn's task and answer, or `null` when nothing was recorded.
297
+ *
298
+ * THE LAST RECORD OF EACH KIND WINS, matching Python. A turn that was answered
299
+ * twice -- a retry, a correction -- ends with the answer that was actually
300
+ * returned, not the one that was superseded.
301
+ *
302
+ * `null` (no file) is not the same as `{}` (a file recording neither half),
303
+ * and neither is collapsed, for the same reason the tool-call tri-state is
304
+ * not.
305
+ */
306
+ export function readTurnText(dir) {
307
+ let raw;
308
+ try {
309
+ raw = readFileSync(join(dir, TURNS_FILENAME), "utf8");
310
+ } catch {
311
+ return null;
312
+ }
313
+ const out = {};
314
+ for (const line of raw.split("\n")) {
315
+ if (!line.trim()) continue;
316
+ try {
317
+ const rec = JSON.parse(line);
318
+ if (TURN_KINDS.includes(rec?.kind) && typeof rec.text === "string") {
319
+ out[rec.kind] = rec.text;
320
+ }
321
+ } catch {
322
+ // A truncated final write costs that record and nothing else -- the same
323
+ // per-line recovery the tool-call reader does.
324
+ }
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * One captured turn in the intake's wire shape, or `null` if it is empty.
331
+ *
332
+ * TURN-NATIVE, NOT CORPUS-SHAPED. What crosses the boundary is what the agent
333
+ * observed -- the user's message, the answer, the calls. Projecting into a
334
+ * training-row format happens on the control plane's side, where it can change
335
+ * without a customer redeploying.
336
+ *
337
+ * THE TOOL-CALL TRI-STATE is asked of the FILE, not of whether this plugin
338
+ * happens to be recording. Python had a bug here worth not repeating: it
339
+ * answered `[] if adapters_bound else None`, which reports on whether capture
340
+ * was configured at attach time and says nothing about whether THIS turn
341
+ * recorded anything -- so a turn whose trajectory was never written went up as
342
+ * a positive assertion that the agent made zero calls. An observation nobody
343
+ * made.
344
+ *
345
+ * rows present -> the calls
346
+ * file present, no rows -> [] (it ran and called nothing)
347
+ * file absent -> null (nothing was recorded for it)
348
+ */
349
+ export function turnWire(dir, { turnIndex = 0 } = {}) {
350
+ const turnId = productionTurnIdentifier(basename(dir));
351
+ if (turnId === null) {
352
+ throw new ContractError(
353
+ "captured turn id is not representable on the flywheel/1 wire",
354
+ );
355
+ }
356
+ const text = readTurnText(dir) || {};
357
+ const { calls, malformed } = readTrajectory(dir);
358
+ const answer = text.answer ?? null;
359
+ const task = text.task ?? null;
360
+ const taskId = readTaskId(dir);
361
+
362
+ let toolCalls;
363
+ if (calls && calls.length) toolCalls = calls;
364
+ else if (existsSync(join(dir, TOOL_CALLS_FILENAME))) toolCalls = [];
365
+ else toolCalls = null;
366
+
367
+ if (!task && !answer && !(toolCalls && toolCalls.length) && !taskId) return null;
368
+
369
+ const wire = {
370
+ // `basename`, not `split("/")`. The turn id is the DIRECTORY NAME, and on
371
+ // win32 `join` builds paths with `\`, so splitting on a hardcoded "/"
372
+ // returned the whole absolute path: an operator's home directory went onto
373
+ // the wire as the turn id, and it could never match the bare `d.name` that
374
+ // `createTurnUploader` writes into `.uploaded` -- so no turn was ever
375
+ // considered uploaded and every flush re-sent the entire capture root.
376
+ turn_id: turnId,
377
+ input_text: task,
378
+ final_text: answer,
379
+ tool_calls: toolCalls,
380
+ tool_calls_omitted_count: malformed,
381
+ };
382
+ const cid = readConversationId(dir);
383
+ if (cid) {
384
+ wire.conversation_id = cid;
385
+ wire.turn_index = turnIndex;
386
+ }
387
+ if (taskId) wire.task_id = taskId;
388
+ const fingerprint = readExecutionFingerprint(dir);
389
+ if (fingerprint !== null) wire.agent_fingerprint = fingerprint;
390
+ return wire;
391
+ }
392
+
393
+ /**
394
+ * Every turn directory under `root`, oldest first.
395
+ *
396
+ * ORDERED BY MTIME, which is the ordering Python derives `turn_index` from.
397
+ * Both ends must agree, because the control plane indexes on
398
+ * `(org, agent, conversation_id, turn_index)` and a conversation whose turns
399
+ * all claim index 0 cannot be ordered at all.
400
+ */
401
+ export function listTurnDirs(root) {
402
+ let names;
403
+ try {
404
+ names = readdirSync(root);
405
+ } catch {
406
+ return [];
407
+ }
408
+ const dirs = [];
409
+ for (const name of names) {
410
+ const path = join(root, name);
411
+ try {
412
+ const st = statSync(path);
413
+ if (st.isDirectory()) dirs.push({ path, name, mtime: st.mtimeMs });
414
+ } catch {
415
+ // Vanished between listing and stat: a concurrent prune. Not an error.
416
+ }
417
+ }
418
+ dirs.sort((a, b) => a.mtime - b.mtime || a.name.localeCompare(b.name));
419
+ return dirs;
420
+ }
421
+
422
+ /**
423
+ * This turn's position within its conversation.
424
+ *
425
+ * Computed over every turn of that conversation ON DISK, not just the ones
426
+ * being uploaded now, so a turn sent in a later batch keeps the position it
427
+ * actually had. A counter held in memory would restart at zero on every
428
+ * process restart -- and every turn landing at index 0 is precisely what the
429
+ * control plane's index exists to prevent.
430
+ */
431
+ export function turnIndexOf(root, dirName, conversationId) {
432
+ if (!conversationId) return 0;
433
+ const siblings = listTurnDirs(root)
434
+ .filter((d) => readConversationId(d.path) === conversationId);
435
+ const i = siblings.findIndex((d) => d.name === dirName);
436
+ return i < 0 ? 0 : i;
437
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * UNATTENDED ROLLOUTS: the plugin starts the turn itself.
3
+ *
4
+ * WHAT TRAINING MODE DOES. There is no switch of its own here, and there were
5
+ * two wrong answers before this one.
6
+ *
7
+ * First it was opt-in, on the argument that this reaches past a restriction
8
+ * the host states for its own scheduling API. That treated "the plugin runs
9
+ * the rollout" as something beyond what was asked for, and it is not:
10
+ * `PERCEPTEYE_AGENT_MODE=training` already means "this install claims rollouts
11
+ * and runs them", and in that mode the plugin already claims work and puts
12
+ * tasks into the agent's context. Starting the turn is how that promise is
13
+ * kept, so opt-in only meant the flywheel sat idle waiting to be enabled.
14
+ *
15
+ * Then it was default-on with its own kill switch, which was the same mistake
16
+ * one step smaller: `PERCEPTEYE_AGENT_MODE` already answers "does this install
17
+ * drive?", so a second flag was a second answer to a settled question. The one
18
+ * state it could have named -- claim rollouts but wait for a human to start
19
+ * each turn -- has no customer behind it. An operator who wants the agent left
20
+ * alone sets `production`, and gets a stronger guarantee than a flag: this
21
+ * module is never even reached.
22
+ *
23
+ * The real risk is handled below by DEGRADING, not by a switch: a flag never
24
+ * made the host seam more stable, it only meant somebody had to know about it.
25
+ *
26
+ * ── WHAT IT REACHES PAST, AND WHY THAT IS SURVIVABLE ──────────────────────
27
+ *
28
+ * OpenClaw's documented API for starting a turn from a plugin,
29
+ * `api.session.workflow.scheduleSessionTurn`, is restricted to plugins bundled
30
+ * with the host: `schedulePluginSessionTurn` returns immediately for any other
31
+ * origin (registry-B8eQDFB4.js:1186), silently.
32
+ *
33
+ * The capability underneath is not restricted. `gateway_start` and
34
+ * `cron_changed` hand every plugin the raw host cron service as
35
+ * `ctx.getCron()` (server-startup-post-attach-B3O9knW5.js:765,
36
+ * server-cron-Cwg2hJro.js:4384); `registerTypedHook` gates conversation hooks
37
+ * by origin and prompt-injection hooks by policy, and `gateway_start` is in
38
+ * neither set; and `server-cron` carries no origin check at all. A job with
39
+ * `payload.kind: "agentTurn"` reaches `runIsolatedAgentJob` (:2433) and
40
+ * `runCronIsolatedAgentTurn` (:4484), which runs the agent. The job built
41
+ * below is deliberately the same shape the bundled-only wrapper builds
42
+ * (registry-B8eQDFB4.js:1240-1254).
43
+ *
44
+ * So this reaches past a restriction the host states explicitly, by way of a
45
+ * service it hands out elsewhere. That may be an inconsistency rather than an
46
+ * intention, which means it can be closed in a patch release. Two consequences
47
+ * are designed in rather than discovered later:
48
+ *
49
+ * 1. It is confined to TRAINING mode, structurally. A process serving real
50
+ * users never reaches this module, whatever the setting says.
51
+ * 2. It DEGRADES to the default lane instead of failing. If `getCron()`
52
+ * returns nothing -- the seam closed, or cron is simply unavailable --
53
+ * the plugin says so once, STOPS POLLING, and leaves the `session_start`
54
+ * injection lane as the only claimer. A capability that disappears must
55
+ * not take rollouts down with it.
56
+ *
57
+ * STOPPING IS THE WHOLE OF THE DEGRADATION, and the first version of it
58
+ * left that part out: it announced the fallback, handed the rollout
59
+ * back, and then polled again `pollMs` later, forever. Mission Control's
60
+ * `claim` increments `attempt`, and its `abandon` requeues only while
61
+ * `attempt < max_attempts` -- so a runner that claims and hands back
62
+ * every 30 seconds walks the whole org's queue to ABANDONED at three
63
+ * attempts each, having run none of it, while reporting itself as having
64
+ * fallen back. A seam that is gone must make this lane QUIET, not busy.
65
+ *
66
+ * `start()` is the one thing that undoes it, because its only callers
67
+ * are `gateway_start` and `cron_changed` and both mean the host has just
68
+ * handed over a cron context. That keeps a LATER-ARRIVING cron usable
69
+ * (see `cronTurnStarter` below) without reopening the churn: a host with
70
+ * no cron service emits neither event, so nothing restarts the loop.
71
+ *
72
+ * ── ONE ROLLOUT AT A TIME, IN ITS OWN SESSION ─────────────────────────────
73
+ *
74
+ * Each rollout runs under `percepteye-rollout-<id>`, never a session a person
75
+ * is using. Commandeering a human's session to run training work would be
76
+ * indefensible, and an isolated session is also what makes `agent_end`
77
+ * attributable: the key the turn runs under is the key the report is looked up
78
+ * by.
79
+ *
80
+ * Serial by construction -- the runner claims only when nothing is in flight.
81
+ * A concurrency knob would be the second thing to get wrong here, and a
82
+ * customer's gateway is not the place to discover that.
83
+ */
84
+ import { taskText } from "./rollout.js";
85
+
86
+ export const JOB_PREFIX = "percepteye:rollout:";
87
+
88
+ /** How often to look for work when idle. */
89
+ export const DEFAULT_POLL_MS = 30_000;
90
+
91
+ /**
92
+ * Turn the raw cron service into "start a turn", or say why it cannot.
93
+ *
94
+ * `getCron` is a FUNCTION, not a service, because the context that carries it
95
+ * only exists during `gateway_start`/`cron_changed` and the service may not be
96
+ * up yet at that moment -- the bundled memory-core plugin has the same problem
97
+ * and defers the same way. Resolving it per call keeps a later-arriving cron
98
+ * usable instead of caching an `undefined` forever.
99
+ */
100
+ export function cronTurnStarter(getCron) {
101
+ return async function startTurn({ sessionKey, message, rolloutId }) {
102
+ const cron = typeof getCron === "function" ? getCron() : null;
103
+ if (!cron || typeof cron.add !== "function") {
104
+ return {
105
+ started: false,
106
+ degraded: true,
107
+ reason:
108
+ "the host cron service is unavailable to this plugin, so no turn " +
109
+ "can be started",
110
+ };
111
+ }
112
+ try {
113
+ await cron.add({
114
+ name: `${JOB_PREFIX}${rolloutId}`,
115
+ // Idempotent per rollout: a retried claim of the same work cannot
116
+ // queue a second turn for it.
117
+ declarationKey: `${JOB_PREFIX}${rolloutId}`,
118
+ enabled: true,
119
+ // One-shot, effectively now. `at` is what the host's own wrapper uses
120
+ // for a `delayMs`, clamped to at least 1ms.
121
+ schedule: { kind: "at", at: new Date(Date.now() + 1).toISOString() },
122
+ sessionTarget: `session:${sessionKey}`,
123
+ payload: { kind: "agentTurn", message },
124
+ deleteAfterRun: true,
125
+ wakeMode: "now",
126
+ // NOT "announce". Announcing would deliver the rollout's output into
127
+ // whatever chat the customer last used, which is training work
128
+ // appearing in a human's conversation.
129
+ delivery: { mode: "none" },
130
+ });
131
+ return { started: true, degraded: false, reason: null };
132
+ } catch (err) {
133
+ return {
134
+ started: false,
135
+ // NOT degraded: cron answered and refused. Falling back to the
136
+ // injection lane on a genuine rejection would hide a real error behind
137
+ // a quieter mode.
138
+ degraded: false,
139
+ reason: `the host refused the rollout turn: ${err?.message ?? err}`,
140
+ };
141
+ }
142
+ };
143
+ }
144
+
145
+ /** The session a rollout runs in. Never a session a person is using. */
146
+ export const sessionKeyFor = (rolloutId) => `percepteye-rollout-${rolloutId}`;
147
+
148
+ /**
149
+ * Claim, start, and keep going -- one rollout at a time.
150
+ *
151
+ * `driver` is the same one the attended lane uses, so reporting and typed
152
+ * abandon happen in exactly one place for both lanes.
153
+ */
154
+ export function createUnattendedRunner({
155
+ transport, driver, startTurn, logger = null, pollMs = DEFAULT_POLL_MS,
156
+ }) {
157
+ let timer = null;
158
+ let running = false;
159
+ /**
160
+ * The turn-starting seam is gone, so this lane claims nothing.
161
+ *
162
+ * ONE FLAG, NOT TWO. It used to be `degradedAnnounced`, gating only the
163
+ * warning, which meant the loop went on claiming and handing back -- see the
164
+ * header. "Have we said it" and "should we still claim" are the same
165
+ * question here: the announcement IS the fallback taking effect.
166
+ */
167
+ let degraded = false;
168
+
169
+ function stopTimer() {
170
+ if (timer) clearInterval(timer);
171
+ timer = null;
172
+ }
173
+
174
+ async function tick() {
175
+ // Never claim once the seam is known dead: a claim we cannot start still
176
+ // costs the rollout an attempt, and three of those retire it unrun.
177
+ // Checked HERE rather than in `start()` alone, so a directly driven tick
178
+ // cannot walk around it either.
179
+ if (degraded || running || driver.inFlight.size > 0) return null;
180
+ running = true;
181
+ try {
182
+ let claimed;
183
+ try {
184
+ claimed = await transport.claim(1);
185
+ } catch (err) {
186
+ logger?.warn?.(
187
+ `[agent-flywheel] could not claim a rollout: ${err?.message ?? err}`);
188
+ return null;
189
+ }
190
+ const rollout = claimed?.rollouts?.[0];
191
+ if (!rollout) return null;
192
+
193
+ const sessionKey = sessionKeyFor(rollout.rolloutId);
194
+ driver.track(sessionKey, rollout);
195
+
196
+ const result = await startTurn({
197
+ sessionKey, rolloutId: rollout.rolloutId, message: taskText(rollout),
198
+ });
199
+ if (result.started) {
200
+ logger?.info?.(
201
+ `[agent-flywheel] rollout ${rollout.rolloutId} started unattended in ` +
202
+ `session ${sessionKey}.`);
203
+ return rollout;
204
+ }
205
+
206
+ // The turn will never run, so the work goes back NOW rather than sitting
207
+ // until its lease lapses.
208
+ await driver.giveBack(
209
+ sessionKey, rollout, "entrypoint_error", result.reason);
210
+ if (result.degraded) {
211
+ degraded = true;
212
+ // The fallback, actually taken: stop looking for work. Anything else
213
+ // spends one rollout's attempt per poll on a turn that cannot start.
214
+ stopTimer();
215
+ logger?.warn?.(
216
+ `[agent-flywheel] unattended rollouts are configured but ${result.reason}. ` +
217
+ `Falling back to the default lane: this plugin has stopped polling ` +
218
+ `for rollouts and tasks are delivered into the next turn you start. ` +
219
+ `Nothing else changes.`);
220
+ }
221
+ return null;
222
+ } finally {
223
+ running = false;
224
+ }
225
+ }
226
+
227
+ return {
228
+ tick,
229
+ /**
230
+ * Is this lane still looking for work?
231
+ *
232
+ * The observable half of the degradation. `tick`'s own guard is what stops
233
+ * the claiming, and a test that only counts claims passes just as happily
234
+ * with `stopTimer()` deleted -- leaving a poll interval waking every 30
235
+ * seconds forever to return immediately, in a customer's gateway, in a
236
+ * lane the operator was told had gone quiet.
237
+ */
238
+ get polling() { return timer !== null; },
239
+ start() {
240
+ // Only `gateway_start` and `cron_changed` call this, and both mean the
241
+ // host has just handed over a cron context -- the one event that can
242
+ // undo a degradation, and the only thing that restarts the loop.
243
+ degraded = false;
244
+ if (timer) return;
245
+ timer = setInterval(() => { tick().catch(() => {}); }, pollMs);
246
+ // Never hold a customer's gateway open on our account.
247
+ timer.unref?.();
248
+ },
249
+ stop: stopTimer,
250
+ };
251
+ }