pi-onlyne 1.2.1 → 2.0.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/README.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
package/src/agent.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// The agent side of the onlyne adapter protocol: one connection to
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The agent side of the onlyne adapter protocol: one connection to the role
|
|
2
|
+
// workspace's client socket — v2 binds it in the machine-level runtime
|
|
3
|
+
// directory as `<digest>.sock`, not inside the tree (`socket.mjs`) — the
|
|
4
|
+
// hello/welcome handshake, assign delivery, turn-state reports, the host's
|
|
5
|
+
// nudge, the completion exit, probe, recycle and detach — plus reconnect when
|
|
6
|
+
// the client restarts under it.
|
|
6
7
|
//
|
|
7
8
|
// Everything pi-specific lives behind `surface` (see pi-surface.mjs): this
|
|
8
9
|
// module decides *what* the protocol says and hands the *effects* to the
|
|
@@ -15,12 +16,10 @@
|
|
|
15
16
|
// refused report, a socket error, a timeout, a framing fault — because those
|
|
16
17
|
// matter to a host with no panel at all.
|
|
17
18
|
|
|
18
|
-
import {
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
19
20
|
import { createConnection } from "node:net";
|
|
20
|
-
import { basename, join } from "node:path";
|
|
21
21
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
22
22
|
import { createActivity } from "./activity.mjs";
|
|
23
|
-
import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
|
|
24
23
|
import {
|
|
25
24
|
DEFAULT_HEARTBEAT_MS,
|
|
26
25
|
assignAckArgs,
|
|
@@ -32,7 +31,6 @@ import {
|
|
|
32
31
|
headOf,
|
|
33
32
|
hostBinding,
|
|
34
33
|
imagePart,
|
|
35
|
-
injectionText,
|
|
36
34
|
normalizeOutcome,
|
|
37
35
|
readyReport,
|
|
38
36
|
sendEnvelope,
|
|
@@ -41,7 +39,6 @@ import {
|
|
|
41
39
|
stdinTaskText,
|
|
42
40
|
welcomeFrom,
|
|
43
41
|
} from "./protocol.mjs";
|
|
44
|
-
import { DEFAULT_RELAY, FORCED_PREFIX, relayEnabled, relayRefusal } from "./relay.mjs";
|
|
45
42
|
|
|
46
43
|
/** Reconnect ladder in milliseconds, capped like the client's own. */
|
|
47
44
|
export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
@@ -49,33 +46,15 @@ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
|
49
46
|
export const HELLO_TIMEOUT_MS = 5_000;
|
|
50
47
|
/** Default bound on one request round trip. */
|
|
51
48
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
52
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* How long after an errored turn the plugin waits for `agent_settled` before it
|
|
51
|
+
* reports the failure it witnessed itself.
|
|
52
|
+
*/
|
|
53
53
|
export const SETTLE_FALLBACK_MS = 2_000;
|
|
54
54
|
|
|
55
55
|
/** Capabilities this plugin implements on the wire. */
|
|
56
56
|
export const CAPABILITIES = ["register", "report", "inject", "recycle"];
|
|
57
57
|
|
|
58
|
-
/** Mime guess for an attachment the host did not name. */
|
|
59
|
-
function extensionForMime(mime) {
|
|
60
|
-
switch (mime) {
|
|
61
|
-
case "image/png":
|
|
62
|
-
return "png";
|
|
63
|
-
case "image/jpeg":
|
|
64
|
-
return "jpg";
|
|
65
|
-
case "image/gif":
|
|
66
|
-
return "gif";
|
|
67
|
-
case "image/webp":
|
|
68
|
-
return "webp";
|
|
69
|
-
default:
|
|
70
|
-
return "bin";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Ids the host mints are uuids; anything else is flattened before it names a file. */
|
|
75
|
-
function safeSegment(value) {
|
|
76
|
-
return String(value ?? "unknown").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
58
|
export class OnlyneAgent {
|
|
80
59
|
/**
|
|
81
60
|
* @param {{
|
|
@@ -87,14 +66,12 @@ export class OnlyneAgent {
|
|
|
87
66
|
* surface: any,
|
|
88
67
|
* log?: (line: string, data?: unknown) => void,
|
|
89
68
|
* activity?: { note: (kind: string, text: string) => any, set: (patch: any) => any, lines: () => string[], events: any[] },
|
|
90
|
-
* relay?: { required?: string[], count?: number | null },
|
|
91
69
|
* capabilities?: string[],
|
|
92
70
|
* heartbeatMs?: number,
|
|
93
71
|
* ladder?: number[],
|
|
94
72
|
* requestTimeoutMs?: number,
|
|
95
73
|
* helloTimeoutMs?: number,
|
|
96
74
|
* settleFallbackMs?: number,
|
|
97
|
-
* idleReminders?: number,
|
|
98
75
|
* createConnection?: (path: string) => any,
|
|
99
76
|
* timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
|
|
100
77
|
* }} options
|
|
@@ -110,20 +87,12 @@ export class OnlyneAgent {
|
|
|
110
87
|
this.activity = options.activity ?? createActivity();
|
|
111
88
|
this.activity.set({ role: options.role });
|
|
112
89
|
this.log = options.log ?? (() => {});
|
|
113
|
-
/** The relay guard's policy; the default guards nothing (`relay.mjs`). */
|
|
114
|
-
this.relay = options.relay ?? DEFAULT_RELAY;
|
|
115
90
|
this.capabilities = options.capabilities ?? CAPABILITIES;
|
|
116
91
|
this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
117
92
|
this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
|
|
118
93
|
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
119
94
|
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
|
120
95
|
this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
|
|
121
|
-
/**
|
|
122
|
-
* How many idle reminders one task may collect: the bound that turns the
|
|
123
|
-
* third idle without a completion into a failure (`settleNow`). It comes
|
|
124
|
-
* from the workspace's `.pi/onlyne.json` (`config.mjs`).
|
|
125
|
-
*/
|
|
126
|
-
this.idleReminders = options.idleReminders ?? DEFAULT_IDLE_REMINDERS;
|
|
127
96
|
this.createConnection = options.createConnection ?? ((path) => createConnection(path));
|
|
128
97
|
// The pane this process was spawned in, reported on every heartbeat so the
|
|
129
98
|
// supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
|
|
@@ -139,6 +108,12 @@ export class OnlyneAgent {
|
|
|
139
108
|
this.connected = false;
|
|
140
109
|
this.socket = null;
|
|
141
110
|
this.welcome = null;
|
|
111
|
+
/**
|
|
112
|
+
* The generation for a beat whose task carries no record of its own (the
|
|
113
|
+
* session's spawn task, before any assign wrote one). The generation the host
|
|
114
|
+
* names in an `assign` lives on that task's record instead, where a second
|
|
115
|
+
* concurrent assign cannot rewrite the first one's.
|
|
116
|
+
*/
|
|
142
117
|
this.generation = 1;
|
|
143
118
|
this.seq = SEQ_BASE;
|
|
144
119
|
this.nextId = 1;
|
|
@@ -148,18 +123,27 @@ export class OnlyneAgent {
|
|
|
148
123
|
this.reconnectHandle = null;
|
|
149
124
|
this.heartbeatHandle = null;
|
|
150
125
|
this.settleHandle = null;
|
|
126
|
+
/**
|
|
127
|
+
* The heartbeat round in flight, and what the next pass of it should say.
|
|
128
|
+
* `heartbeat` never lets two rounds overlap, so `beatRound` doubles as the
|
|
129
|
+
* lock; `beatAgain` records that a beat was asked for while the round was
|
|
130
|
+
* writing, and `beatPhase` what the newest of those asks wanted said — an
|
|
131
|
+
* explicit phase, or null for "ask pi again". See `heartbeat`.
|
|
132
|
+
*/
|
|
133
|
+
this.beatRound = null;
|
|
134
|
+
this.beatAgain = false;
|
|
135
|
+
this.beatPhase = null;
|
|
151
136
|
/** @type {Map<string, any>} */
|
|
152
137
|
this.tasks = new Map();
|
|
153
138
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* sent — the guard judges this session's own deliveries, not history.
|
|
139
|
+
* Completions the socket could not carry: one entry per `complete` call
|
|
140
|
+
* taken while the connection was down, kept in the order they were called
|
|
141
|
+
* and flushed on the next hello. A single slot would let a second
|
|
142
|
+
* completion overwrite the first, so an outcome nobody had received would
|
|
143
|
+
* vanish while a reconnect was already in flight.
|
|
144
|
+
* @type {{ taskId: string, report: any, outcome: string, exitProcess: boolean }[]}
|
|
161
145
|
*/
|
|
162
|
-
this.
|
|
146
|
+
this.pendingCompletions = [];
|
|
163
147
|
/**
|
|
164
148
|
* The deliveries this process has already handed to the model, keyed by
|
|
165
149
|
* envelope id (a delivery's own identity). Keying it by task id would swallow
|
|
@@ -175,6 +159,11 @@ export class OnlyneAgent {
|
|
|
175
159
|
this.lastError = null;
|
|
176
160
|
/** Whether pi has already been asked to end this process (`exitSession`). */
|
|
177
161
|
this.exitRequested = false;
|
|
162
|
+
/**
|
|
163
|
+
* The role's `[client.session] scope` as the last assignment carried it.
|
|
164
|
+
* @type {string | null}
|
|
165
|
+
*/
|
|
166
|
+
this.scope = null;
|
|
178
167
|
this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
|
|
179
168
|
}
|
|
180
169
|
|
|
@@ -218,8 +207,15 @@ export class OnlyneAgent {
|
|
|
218
207
|
sessionId: this.sessionId,
|
|
219
208
|
generation: this.generation,
|
|
220
209
|
agentState: this.agentState,
|
|
210
|
+
// The plugin's own report counter, and the seq each open task last
|
|
211
|
+
// carried. A supervisor chasing a beat the host dropped as stale reads
|
|
212
|
+
// these against the row's watermark instead of guessing from the log.
|
|
213
|
+
seq: this.seq,
|
|
214
|
+
taskSeqs: Object.fromEntries(
|
|
215
|
+
[...this.tasks.values()].map((task) => [task.taskId, task.lastSeq ?? null]),
|
|
216
|
+
),
|
|
221
217
|
tasks: this.activeTasks().map((task) => task.taskId),
|
|
222
|
-
|
|
218
|
+
pendingCompletions: this.pendingCompletions.map((pending) => pending.taskId),
|
|
223
219
|
lastError: this.lastError ? String(this.lastError.message ?? this.lastError) : null,
|
|
224
220
|
activity: this.activity.events.slice(0, 5),
|
|
225
221
|
stats: { ...this.stats },
|
|
@@ -323,6 +319,18 @@ export class OnlyneAgent {
|
|
|
323
319
|
}
|
|
324
320
|
this.welcome = welcome;
|
|
325
321
|
this.connected = true;
|
|
322
|
+
// The client names every delivery it already handed to this session, so a
|
|
323
|
+
// process that restarts under a live client does not re-inject work its
|
|
324
|
+
// predecessor read: the delivery guard is process memory, and this is how it
|
|
325
|
+
// survives one. Seeded before any push drains, so the first `assign` the new
|
|
326
|
+
// connection sees is already deduped against it. Both spellings go in,
|
|
327
|
+
// because the guard reads `envelope.id ?? task:<task_id>` (`onAssign`) and a
|
|
328
|
+
// host may name either the delivery's own id or the task it belongs to here.
|
|
329
|
+
const alreadyDelivered = Array.isArray(welcome.deliveredTasks) ? welcome.deliveredTasks : [];
|
|
330
|
+
for (const taskId of alreadyDelivered) {
|
|
331
|
+
this.injectedDeliveries.add(taskId);
|
|
332
|
+
this.injectedDeliveries.add(`task:${taskId}`);
|
|
333
|
+
}
|
|
326
334
|
// A fresh connection has reported nothing, so the phase is a question, not
|
|
327
335
|
// an answer: the first beat below re-derives it from pi. A taskless session
|
|
328
336
|
// is the ready pool, and ready is its own state.
|
|
@@ -340,7 +348,7 @@ export class OnlyneAgent {
|
|
|
340
348
|
const prose = welcome.prose.trim();
|
|
341
349
|
if (prose && !this.deliveredProse.has(prose)) {
|
|
342
350
|
this.deliveredProse.add(prose);
|
|
343
|
-
this.surface.
|
|
351
|
+
this.surface.roleProse?.(prose);
|
|
344
352
|
}
|
|
345
353
|
|
|
346
354
|
if (this.envTaskId) {
|
|
@@ -353,9 +361,10 @@ export class OnlyneAgent {
|
|
|
353
361
|
})).catch((error) => this.log(`session_register refused: ${error.message}`));
|
|
354
362
|
}
|
|
355
363
|
await this.reportReady();
|
|
356
|
-
|
|
364
|
+
await this.flushPendingCompletions();
|
|
357
365
|
// The frames that waited for the handshake: the welcome is adopted by now,
|
|
358
|
-
// so the role prose is
|
|
366
|
+
// so the role prose is in the instruction layer before any assignment opens
|
|
367
|
+
// a turn.
|
|
359
368
|
this.handshaking = false;
|
|
360
369
|
this.drainDeferred();
|
|
361
370
|
if (this.tasks.size > 0) await this.heartbeat().catch(() => {});
|
|
@@ -437,6 +446,7 @@ export class OnlyneAgent {
|
|
|
437
446
|
const op = frame.op;
|
|
438
447
|
const args = frame.args ?? {};
|
|
439
448
|
if (op === "assign") void this.onAssign(args);
|
|
449
|
+
else if (op === "nudge") this.onNudge(frame, args);
|
|
440
450
|
else if (op === "probe") void this.probe();
|
|
441
451
|
else if (op === "recycle") void this.onRecycle(args);
|
|
442
452
|
else if (op === "config_get") void this.onConfigGet(args);
|
|
@@ -483,13 +493,17 @@ export class OnlyneAgent {
|
|
|
483
493
|
async reportReady() {
|
|
484
494
|
const taskId = this.envTaskId;
|
|
485
495
|
if (!taskId || !this.connected) return;
|
|
486
|
-
|
|
496
|
+
// The ready travels the one counter and moves the same row's watermark a
|
|
497
|
+
// beat reads, so the task's record takes the clamp where one exists: a
|
|
498
|
+
// ready landing between two of that task's beats cannot hand the row a seq
|
|
499
|
+
// it has already seen.
|
|
500
|
+
const seq = this.nextSeq(this.tasks.get(taskId) ?? null);
|
|
487
501
|
try {
|
|
488
502
|
await this.request("report", readyReport({
|
|
489
503
|
taskId,
|
|
490
504
|
sessionId: this.sessionId,
|
|
491
505
|
generation: this.generation,
|
|
492
|
-
seq
|
|
506
|
+
seq,
|
|
493
507
|
}));
|
|
494
508
|
this.stats.reports += 1;
|
|
495
509
|
this.notice("state", `ready ${taskId.slice(0, 8)}`);
|
|
@@ -499,10 +513,114 @@ export class OnlyneAgent {
|
|
|
499
513
|
}
|
|
500
514
|
|
|
501
515
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
516
|
+
* Allocate the next report sequence, for one task when the report names one.
|
|
517
|
+
*
|
|
518
|
+
* One writer gets one counter: the protocol asks for a sequence strictly
|
|
519
|
+
* increasing for the life of a generation (`crates/onlyne-adapter/PROTOCOL.md`,
|
|
520
|
+
* "Report sequencing and the ready barrier"), and the host's own events for a
|
|
521
|
+
* row take `row.seq + 1` beside it (`onlyne-session`'s `reconcile/feed.rs`,
|
|
522
|
+
* `reconcile/bridge.rs`). A per-task counter was the other option, and it is
|
|
523
|
+
* the worse one: a task's row would then move exactly one per round, tying
|
|
524
|
+
* with the host's interleaved writes to that row, and the reducer reads a tie
|
|
525
|
+
* as `StaleOrDuplicateSeq` and throws the liveness fact away. Beating every
|
|
526
|
+
* task off the one counter is what keeps each row ahead of the host.
|
|
527
|
+
*
|
|
528
|
+
* The gate itself is per task row, so the invariant a *task* owes is "my next
|
|
529
|
+
* beat carries a higher seq than my last one". `task.lastSeq` makes that a
|
|
530
|
+
* checked property rather than a side effect of write order: an allocation for
|
|
531
|
+
* a task that has reported before is clamped above its own last report, so no
|
|
532
|
+
* path — a ready between two beats, a second round folded into the first, a
|
|
533
|
+
* follow-up envelope arriving mid-round — can hand a task back a seq its row
|
|
534
|
+
* has already accepted.
|
|
535
|
+
*
|
|
536
|
+
* @param {any} [task] the task record the report speaks for. A report naming
|
|
537
|
+
* no record (the task this process was spawned with, before any `assign` wrote
|
|
538
|
+
* one) takes the bare counter, which is above everything it has ever sent.
|
|
539
|
+
*/
|
|
540
|
+
nextSeq(task = null) {
|
|
541
|
+
this.seq += 1;
|
|
542
|
+
if (!task) return this.seq;
|
|
543
|
+
if (typeof task.lastSeq === "number" && task.lastSeq >= this.seq) {
|
|
544
|
+
this.seq = task.lastSeq + 1;
|
|
545
|
+
}
|
|
546
|
+
task.lastSeq = this.seq;
|
|
547
|
+
return this.seq;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One heartbeat round, or the round already running if one is in flight.
|
|
552
|
+
*
|
|
553
|
+
* Rounds never overlap. A beat is a snapshot of the session, so a second round
|
|
554
|
+
* begun while the first is still writing holds no newer evidence than the one
|
|
555
|
+
* in flight — it only splits one tick's facts across two sequences and doubles
|
|
556
|
+
* the frames every task's row has to clear. The timer, a turn hook and a
|
|
557
|
+
* `probe` can each ask for a beat in the same instant: the first starts the
|
|
558
|
+
* round, the rest fold into it, and the round takes one more pass so the
|
|
559
|
+
* newest ask is answered. A folded caller shares the running round's outcome,
|
|
560
|
+
* rejection included, because a plugin reads a failed report as a link that
|
|
561
|
+
* died, and the reconnect ladder — not a queue of beats behind a socket that
|
|
562
|
+
* cannot carry them — is the answer to that.
|
|
563
|
+
*
|
|
564
|
+
* The phase is the one the phase rule names: `idle` only while the session
|
|
565
|
+
* waits for user input, `running` for everything else, re-derived from pi once
|
|
566
|
+
* per round so a phase that went stale when a run started again cannot survive
|
|
567
|
+
* a tick — the answer is about the session, so a task does not get its own
|
|
568
|
+
* view of it.
|
|
569
|
+
*
|
|
570
|
+
* @param {"idle" | "running" | null} [agent] the phase to state; absent means
|
|
571
|
+
* ask pi.
|
|
572
|
+
*/
|
|
573
|
+
heartbeat(agent = null) {
|
|
574
|
+
if (this.beatRound) {
|
|
575
|
+
// The newest ask wins the next pass, whatever it is. A bare `heartbeat()`
|
|
576
|
+
// means "ask pi again" and so clears an earlier explicit phase: a
|
|
577
|
+
// turn-start beat that says `running` must not pin the turn-end beat that
|
|
578
|
+
// folded into it, or the idle a waiting session reached never gets
|
|
579
|
+
// reported, and the client's own rule reads a working agent forever.
|
|
580
|
+
this.beatAgain = true;
|
|
581
|
+
this.beatPhase = agent;
|
|
582
|
+
return this.beatRound;
|
|
583
|
+
}
|
|
584
|
+
this.beatPhase = agent ?? null;
|
|
585
|
+
this.beatAgain = false;
|
|
586
|
+
this.beatRound = this.runBeatRound();
|
|
587
|
+
return this.beatRound;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The serialized driver behind `heartbeat`; see there for the why. */
|
|
591
|
+
async runBeatRound() {
|
|
592
|
+
let failure = null;
|
|
593
|
+
try {
|
|
594
|
+
for (;;) {
|
|
595
|
+
this.beatAgain = false;
|
|
596
|
+
try {
|
|
597
|
+
await this.beatOnce(this.beatPhase);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
// The link stopped answering mid-round. Later beats would only pile
|
|
600
|
+
// behind frames this socket cannot carry; `dropSocket` rejects them
|
|
601
|
+
// all and the reconnect path re-derives the phase anyway.
|
|
602
|
+
failure = error;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
if (!this.beatAgain) break;
|
|
606
|
+
}
|
|
607
|
+
} finally {
|
|
608
|
+
this.beatRound = null;
|
|
609
|
+
this.beatAgain = false;
|
|
610
|
+
}
|
|
611
|
+
if (failure) throw failure;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* One pass of the round: one beat for every task this session still holds.
|
|
616
|
+
*
|
|
617
|
+
* A session can hold two open tasks at once (the one it is working and the
|
|
618
|
+
* next one already handed over), and a beat is the only liveness evidence
|
|
619
|
+
* either row has: beating the head of the list alone let a second task sit
|
|
620
|
+
* until the client's heartbeat timeout took it. Each beat carries its own
|
|
621
|
+
* task's generation and a sequence strictly above the last one that task
|
|
622
|
+
* sent, so an alternating pair of tasks can never be told stale by its own
|
|
623
|
+
* earlier frame.
|
|
506
624
|
*
|
|
507
625
|
* A task this plugin already completed gets none: the beat is a full
|
|
508
626
|
* snapshot of what the plugin can see, and after the completion the agent's
|
|
@@ -514,22 +632,31 @@ export class OnlyneAgent {
|
|
|
514
632
|
* heartbeat left over from the finishing turn landed three milliseconds
|
|
515
633
|
* behind the completion.
|
|
516
634
|
*/
|
|
517
|
-
async
|
|
635
|
+
async beatOnce(agent) {
|
|
518
636
|
if (!this.connected) return;
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
637
|
+
let tasks = this.activeTasks();
|
|
638
|
+
if (tasks.length === 0) {
|
|
639
|
+
// Nothing is open: the beat still belongs to the task this process was
|
|
640
|
+
// spawned with, unless that task is one this plugin already completed.
|
|
641
|
+
const taskId = this.envTaskId;
|
|
642
|
+
if (!taskId || this.tasks.get(taskId)?.completed) return;
|
|
643
|
+
tasks = [{ taskId }];
|
|
644
|
+
}
|
|
522
645
|
const phase = agent ?? (await this.derivedPhase());
|
|
523
646
|
this.agentState = phase;
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
647
|
+
for (const task of tasks) {
|
|
648
|
+
// The clamp is stamped on the record, where the next round can read it;
|
|
649
|
+
// the synthetic entry above has none, and the bare counter covers it.
|
|
650
|
+
const seq = this.nextSeq(this.tasks.get(task.taskId) ?? null);
|
|
651
|
+
await this.request("report", heartbeatReport({
|
|
652
|
+
taskId: task.taskId,
|
|
653
|
+
generation: task.generation ?? this.generation,
|
|
654
|
+
seq,
|
|
655
|
+
agent: phase,
|
|
656
|
+
host: this.host,
|
|
657
|
+
}));
|
|
658
|
+
this.stats.reports += 1;
|
|
659
|
+
}
|
|
533
660
|
}
|
|
534
661
|
|
|
535
662
|
/**
|
|
@@ -592,7 +719,24 @@ export class OnlyneAgent {
|
|
|
592
719
|
|
|
593
720
|
// ------------------------------------------------------------ host → plugin
|
|
594
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Whether this role's scope keeps a session past the delivery it served.
|
|
724
|
+
*
|
|
725
|
+
* `oneshot` is finished and this process should end — which is also the only
|
|
726
|
+
* way this runtime's own store gets flushed, because the teardown that writes
|
|
727
|
+
* it runs at `agent_settled`. `task` and `role` want the opposite: the session
|
|
728
|
+
* outlives the delivery, the process stays, and the conversation is what the
|
|
729
|
+
* next delivery lands in.
|
|
730
|
+
*
|
|
731
|
+
* A frame from before the field carries no scope, and is read as `oneshot`:
|
|
732
|
+
* that is what every such frame meant.
|
|
733
|
+
*/
|
|
734
|
+
keepsSession() {
|
|
735
|
+
return this.scope === "task" || this.scope === "role";
|
|
736
|
+
}
|
|
737
|
+
|
|
595
738
|
async onAssign(args) {
|
|
739
|
+
if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
|
|
596
740
|
const envelope = args.envelope ?? {};
|
|
597
741
|
const taskId = args.task_id ?? envelope.causality?.task ?? null;
|
|
598
742
|
if (!taskId) {
|
|
@@ -610,70 +754,65 @@ export class OnlyneAgent {
|
|
|
610
754
|
}
|
|
611
755
|
this.injectedDeliveries.add(deliveryId);
|
|
612
756
|
this.stats.assigns += 1;
|
|
613
|
-
if (typeof args.generation === "number") this.generation = args.generation;
|
|
614
757
|
|
|
615
|
-
|
|
616
|
-
|
|
758
|
+
// The delivery text arrives already rendered: the client's one template
|
|
759
|
+
// built it from the sender, the body and the files it wrote itself. This
|
|
760
|
+
// plugin injects those bytes and nothing else — it composes no wording of
|
|
761
|
+
// its own around a delivery, and its other addition to what the model reads
|
|
762
|
+
// is the role prose, which is a system-prompt section rather than a message.
|
|
763
|
+
const text = typeof args.text === "string" ? args.text : "";
|
|
764
|
+
const attachmentPaths = Array.isArray(args.attachments) ? args.attachments : [];
|
|
617
765
|
const prose = typeof args.prose === "string" ? args.prose.trim() : "";
|
|
618
766
|
const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
|
|
619
|
-
if (proseIsNew)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
// the attachments this call already wrote.
|
|
624
|
-
const assignment = { ...args, task_id: taskId };
|
|
625
|
-
const text = injectionText({ assign: assignment, proseIsNew, attachmentPaths });
|
|
767
|
+
if (proseIsNew) {
|
|
768
|
+
this.deliveredProse.add(prose);
|
|
769
|
+
this.surface.roleProse?.(prose);
|
|
770
|
+
}
|
|
626
771
|
|
|
627
772
|
const held = this.tasks.get(taskId);
|
|
628
773
|
// A completed record is not a live one: a new envelope for a task that
|
|
629
774
|
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
630
775
|
if (held && !held.completed) {
|
|
631
776
|
// A new envelope for a task this session already holds — a follow-up, a
|
|
632
|
-
// redirect, a bounce back through a
|
|
633
|
-
// is
|
|
634
|
-
//
|
|
635
|
-
// moves, so the settled-without-completing watchdog measures the newest one.
|
|
636
|
-
// The ladder restarts with it: the reminder count, the idle episode it was
|
|
637
|
-
// charged to (`remindedAt`), and the failure an errored turn proved all
|
|
638
|
-
// belong to the instruction being replaced.
|
|
639
|
-
held.turnsSinceAssign = 0;
|
|
640
|
-
held.reminders = 0;
|
|
641
|
-
held.remindedAt = 0;
|
|
642
|
-
held.reminderWokeTurn = false;
|
|
777
|
+
// redirect, a bounce back through a handoff. The work record stays where
|
|
778
|
+
// it is, and the failure an errored turn proved belonged to the
|
|
779
|
+
// instruction being replaced, so it is cleared with that instruction.
|
|
643
780
|
held.errored = false;
|
|
644
781
|
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
645
|
-
|
|
646
|
-
|
|
782
|
+
// `lastSeq` deliberately does not reset here. It is the floor the next
|
|
783
|
+
// allocation for this task must clear, and the host's row for the task
|
|
784
|
+
// still holds the seq of the last beat it accepted; a follow-up envelope
|
|
785
|
+
// opens no new row, so forgetting the floor would let a later round
|
|
786
|
+
// re-issue a seq the reducer has already seen.
|
|
787
|
+
// The host's newest word on this task's generation, which its next beat
|
|
788
|
+
// must carry; a follow-up can move it.
|
|
789
|
+
if (typeof args.generation === "number") held.generation = args.generation;
|
|
647
790
|
} else {
|
|
648
791
|
this.tasks.set(taskId, {
|
|
649
792
|
taskId,
|
|
650
793
|
envelopeId: envelope.id ?? null,
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
794
|
+
/** The generation the host names for this task; the beat reports it. */
|
|
795
|
+
generation: typeof args.generation === "number" ? args.generation : 1,
|
|
796
|
+
/**
|
|
797
|
+
* The seq of the last report this plugin sent for this task, stamped by
|
|
798
|
+
* `nextSeq`. The host gates one task's row on its own watermark, so this
|
|
799
|
+
* is what keeps a beat ahead of the frame before it however many tasks
|
|
800
|
+
* share the plugin's counter. Null until the first report.
|
|
801
|
+
*/
|
|
802
|
+
lastSeq: null,
|
|
656
803
|
errored: false,
|
|
657
804
|
head: "",
|
|
658
|
-
assignment,
|
|
659
|
-
attachmentPaths,
|
|
660
|
-
/** Idle reminders sent for this task; the bound is `idleReminders`. */
|
|
661
|
-
reminders: 0,
|
|
662
|
-
/** The `turnsSinceAssign` the last reminder was charged to. */
|
|
663
|
-
remindedAt: 0,
|
|
664
|
-
/** Whether the next turn is the one this plugin's own reminder woke. */
|
|
665
|
-
reminderWokeTurn: false,
|
|
666
805
|
});
|
|
667
806
|
}
|
|
668
807
|
this.agentState = "running";
|
|
669
|
-
this.surface.wakeUser?.(text,
|
|
808
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope));
|
|
670
809
|
this.surface.customEntry?.("onlyne-assign", {
|
|
671
810
|
taskId,
|
|
672
811
|
envelopeId: envelope.id ?? null,
|
|
673
812
|
kind: envelope.kind ?? "task",
|
|
674
813
|
proseInjected: proseIsNew,
|
|
675
814
|
prose,
|
|
676
|
-
attachments:
|
|
815
|
+
attachments: attachmentPaths,
|
|
677
816
|
});
|
|
678
817
|
this.activity.set({ taskId, phase: "running" });
|
|
679
818
|
this.notice("in", `task ${taskId.slice(0, 8)} from ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
|
|
@@ -689,6 +828,41 @@ export class OnlyneAgent {
|
|
|
689
828
|
}
|
|
690
829
|
}
|
|
691
830
|
|
|
831
|
+
/**
|
|
832
|
+
* The host's turn-end nudge (`HostOp::Nudge`): the client owns the rule
|
|
833
|
+
* (`docs/v2-CONTRACT.md` §3c) and the sentence it sends is the whole of what
|
|
834
|
+
* the model gets.
|
|
835
|
+
*
|
|
836
|
+
* Handed over exactly as it arrived — no prefix, no count, no task id, no role
|
|
837
|
+
* — which is what makes this a nudge rather than v1's ladder, whose reminder
|
|
838
|
+
* re-injected the assignment and counted its rungs. Nothing in the session's
|
|
839
|
+
* turn state is reset by it, and the plugin keeps no copy of the text.
|
|
840
|
+
*
|
|
841
|
+
* The answer claims only that the sentence was handed over, because the client
|
|
842
|
+
* settles a delivery on it: `ok` when pi took the text, and a refusal when pi
|
|
843
|
+
* would not. A frame carrying no `id` is a notification and gets no answer.
|
|
844
|
+
*
|
|
845
|
+
* @param {{ id?: number | null }} frame
|
|
846
|
+
* @param {{ task_id?: string, text?: string }} args
|
|
847
|
+
*/
|
|
848
|
+
onNudge(frame, args) {
|
|
849
|
+
const text = typeof args.text === "string" ? args.text : "";
|
|
850
|
+
const taskId = typeof args.task_id === "string" && args.task_id ? args.task_id.slice(0, 8) : "?";
|
|
851
|
+
const handedOver = text.length > 0 && this.surface.wakeUser?.(text, []) === true;
|
|
852
|
+
if (handedOver) this.notice("in", `nudge for task ${taskId}`);
|
|
853
|
+
else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
|
|
854
|
+
if (frame?.id === undefined || frame?.id === null) return;
|
|
855
|
+
if (handedOver) {
|
|
856
|
+
this.write({ reply_to: frame.id, ok: true });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.write({
|
|
860
|
+
reply_to: frame.id,
|
|
861
|
+
ok: false,
|
|
862
|
+
error: { code: "internal", message: "pi did not take the nudge text" },
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
692
866
|
/** The host asked for a fresh observation: one heartbeat is the answer. */
|
|
693
867
|
async probe() {
|
|
694
868
|
try {
|
|
@@ -703,8 +877,10 @@ export class OnlyneAgent {
|
|
|
703
877
|
const task = stdinTaskText(args);
|
|
704
878
|
if (task) {
|
|
705
879
|
// The no-`inject` route: the host hands the payload over as a config key.
|
|
880
|
+
// The key carries the same rendered delivery text an `assign` would, so
|
|
881
|
+
// it is injected as it stands — this route composes no wording either.
|
|
706
882
|
this.notice("in", `stdin task: ${headOf(task.text)}`);
|
|
707
|
-
this.surface.wakeUser?.(
|
|
883
|
+
this.surface.wakeUser?.(task.text, []);
|
|
708
884
|
return;
|
|
709
885
|
}
|
|
710
886
|
this.log(`config_get ${args?.key ?? "?"} is not implemented by this plugin`);
|
|
@@ -726,56 +902,45 @@ export class OnlyneAgent {
|
|
|
726
902
|
// ------------------------------------------------------------ pi → plugin
|
|
727
903
|
|
|
728
904
|
/**
|
|
729
|
-
* A turn started: the plugin's own agent fact is `running
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
905
|
+
* A turn started: the plugin's own agent fact is `running`.
|
|
906
|
+
*
|
|
907
|
+
* How a turn end settles the work is the client's rule
|
|
908
|
+
* (`docs/v2-CONTRACT.md` §3c): the plugin counts nothing, bounds nothing, and
|
|
909
|
+
* reports only the facts it alone can see — its own phase, and an errored
|
|
910
|
+
* turn.
|
|
733
911
|
*/
|
|
734
912
|
onTurnStart() {
|
|
735
|
-
for (const task of this.tasks.values()) {
|
|
736
|
-
if (task.completed) continue;
|
|
737
|
-
task.turns += 1;
|
|
738
|
-
if (task.reminderWokeTurn) {
|
|
739
|
-
task.reminderWokeTurn = false;
|
|
740
|
-
continue;
|
|
741
|
-
}
|
|
742
|
-
task.turnsSinceAssign = 0;
|
|
743
|
-
task.reminders = 0;
|
|
744
|
-
task.remindedAt = 0;
|
|
745
|
-
}
|
|
746
913
|
void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
747
914
|
}
|
|
748
915
|
|
|
749
916
|
/**
|
|
750
|
-
* A turn ended: one turn of a run that may still have more
|
|
751
|
-
*
|
|
752
|
-
*
|
|
917
|
+
* A turn ended: one turn of a run that may still have more. The phase is
|
|
918
|
+
* re-derived, so the beat says `running` while pi keeps working and says
|
|
919
|
+
* `idle` only once the session waits for input. A clean turn end settles
|
|
920
|
+
* nothing: the client owns that decision, and it reaches the session as its
|
|
921
|
+
* own frame.
|
|
753
922
|
*/
|
|
754
923
|
onTurnEnd() {
|
|
755
|
-
for (const task of this.tasks.values()) {
|
|
756
|
-
if (!task.completed) task.turnsSinceAssign += 1;
|
|
757
|
-
}
|
|
758
924
|
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
759
|
-
this.armSettleFallback();
|
|
760
925
|
}
|
|
761
926
|
|
|
762
927
|
/**
|
|
763
|
-
* pi has settled:
|
|
764
|
-
*
|
|
928
|
+
* pi has settled: the session is waiting for input, which is the moment an
|
|
929
|
+
* errored turn is reported from.
|
|
765
930
|
*/
|
|
766
931
|
onSettled() {
|
|
767
932
|
this.clearSettleFallback();
|
|
768
933
|
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
769
|
-
void this.trySettle().catch((error) => this.log(`
|
|
934
|
+
void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
770
935
|
}
|
|
771
936
|
|
|
772
937
|
/**
|
|
773
|
-
*
|
|
774
|
-
* retrying, compacting, or holding a queued continuation, and
|
|
775
|
-
* keeps work running after pi itself has settled, so a
|
|
776
|
-
* arrives during any of those waits re-arms instead of
|
|
777
|
-
* that is still busy. The fallback timer and `agent_settled`
|
|
778
|
-
* and
|
|
938
|
+
* Report a turn that failed, once pi is quiet. pi keeps `isIdle()` false while
|
|
939
|
+
* it is running, retrying, compacting, or holding a queued continuation, and
|
|
940
|
+
* a background task keeps work running after pi itself has settled, so a
|
|
941
|
+
* signal that arrives during any of those waits re-arms instead of reporting
|
|
942
|
+
* from a session that is still busy. The fallback timer and `agent_settled`
|
|
943
|
+
* both land here, and a task is reported once because the report completes it.
|
|
779
944
|
*/
|
|
780
945
|
async trySettle() {
|
|
781
946
|
if (this.closed || this.activeTasks().length === 0) return;
|
|
@@ -783,7 +948,7 @@ export class OnlyneAgent {
|
|
|
783
948
|
this.armSettleFallback();
|
|
784
949
|
return;
|
|
785
950
|
}
|
|
786
|
-
await this.
|
|
951
|
+
await this.reportFailedTurns().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
787
952
|
}
|
|
788
953
|
|
|
789
954
|
/** A failed turn: the task's outcome is `failed`, with the error as its head. */
|
|
@@ -793,19 +958,18 @@ export class OnlyneAgent {
|
|
|
793
958
|
task.errored = true;
|
|
794
959
|
if (text) task.head = headOf(text);
|
|
795
960
|
}
|
|
796
|
-
void this.trySettle().catch((error) => this.log(`
|
|
961
|
+
void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
797
962
|
}
|
|
798
963
|
|
|
799
964
|
/**
|
|
800
|
-
* The last assistant text seen, kept as the
|
|
801
|
-
*
|
|
965
|
+
* The last assistant text seen, kept as the ledger head for a task whose
|
|
966
|
+
* `onlyne_complete` call carries an empty `summary`.
|
|
802
967
|
*
|
|
803
|
-
* This is the fallback, never the deliverable: `onlyne_complete`'s `
|
|
804
|
-
* reported byte for byte by `completeFromTool` and is never written back
|
|
805
|
-
* here, so the sentence a turn happened to end on cannot stand in for
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* a turn nobody completed.
|
|
968
|
+
* This is the fallback, never the deliverable: `onlyne_complete`'s `summary`
|
|
969
|
+
* is reported byte for byte by `completeFromTool` and is never written back
|
|
970
|
+
* here, so the sentence a turn happened to end on cannot stand in for the
|
|
971
|
+
* summary the tool call carried. Nothing else reads it — and the full result
|
|
972
|
+
* travels in `details`, which this never touches.
|
|
809
973
|
*/
|
|
810
974
|
noteAssistantText(text) {
|
|
811
975
|
const flat = headOf(text);
|
|
@@ -820,7 +984,7 @@ export class OnlyneAgent {
|
|
|
820
984
|
if (this.closed || this.activeTasks().length === 0) return;
|
|
821
985
|
this.settleHandle = this.timer.set(() => {
|
|
822
986
|
this.settleHandle = null;
|
|
823
|
-
void this.trySettle().catch((error) => this.log(`
|
|
987
|
+
void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
824
988
|
}, this.settleFallbackMs);
|
|
825
989
|
}
|
|
826
990
|
|
|
@@ -831,121 +995,52 @@ export class OnlyneAgent {
|
|
|
831
995
|
}
|
|
832
996
|
|
|
833
997
|
/**
|
|
834
|
-
*
|
|
835
|
-
*
|
|
998
|
+
* Report the failures this session witnessed itself, for every task it still
|
|
999
|
+
* holds.
|
|
836
1000
|
*
|
|
837
1001
|
* An errored turn is proof on its own — pi may skip the clean turn end — so
|
|
838
|
-
* its task is reported `failed` at once, with the error as its head.
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
* `onlyne_complete` is the only path to `done`. A turn that ends without it
|
|
844
|
-
* leaves the task `idle_waiting` (the design's own word for it), and the
|
|
845
|
-
* answer is the reinforcing prompt the model gets instead of a completion it
|
|
846
|
-
* never claimed.
|
|
847
|
-
*
|
|
848
|
-
* A task whose injected message has not run a turn is left alone at every
|
|
849
|
-
* rung: the reminder would re-send an assignment the model may not have read
|
|
850
|
-
* yet, and failing now would claim work that never happened.
|
|
851
|
-
*
|
|
852
|
-
* A rung belongs to an idle episode, not to a settle signal: `remindedAt`
|
|
853
|
-
* records the `turnsSinceAssign` the last reminder was charged to, so the
|
|
854
|
-
* fallback timer and an `agent_settled` answering the same turn end spend one
|
|
855
|
-
* rung between them.
|
|
1002
|
+
* its task is reported `failed` at once, with the error as its head. Nothing
|
|
1003
|
+
* else is decided here: a turn that ends without a completion is the client's
|
|
1004
|
+
* case, and it reaches the session as the host's nudge (`onNudge`), so a
|
|
1005
|
+
* plugin that also decided it would be the second owner of the rule.
|
|
856
1006
|
*/
|
|
857
|
-
async
|
|
1007
|
+
async reportFailedTurns() {
|
|
858
1008
|
for (const task of [...this.tasks.values()]) {
|
|
859
|
-
if (task.completed) continue;
|
|
860
|
-
|
|
861
|
-
await this.complete(task.taskId, "failed", task.head);
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
if (task.turnsSinceAssign === 0 || task.remindedAt === task.turnsSinceAssign) continue;
|
|
865
|
-
task.remindedAt = task.turnsSinceAssign;
|
|
866
|
-
if (task.reminders < this.idleReminders) {
|
|
867
|
-
this.remind(task);
|
|
868
|
-
continue;
|
|
869
|
-
}
|
|
870
|
-
const rungs = `${task.reminders} idle reminder${task.reminders === 1 ? "" : "s"}`;
|
|
871
|
-
await this.complete(task.taskId, "failed", `no completion after ${rungs}`);
|
|
1009
|
+
if (task.completed || !task.errored) continue;
|
|
1010
|
+
await this.complete(task.taskId, "failed", task.head);
|
|
872
1011
|
}
|
|
873
1012
|
}
|
|
874
1013
|
|
|
875
|
-
/**
|
|
876
|
-
* One rung of the idle ladder: hand the assignment back to the model.
|
|
877
|
-
*
|
|
878
|
-
* The reminder is the injection's own text — `injectionText` with the prose
|
|
879
|
-
* flag off, so the role prose already in the session's context is not
|
|
880
|
-
* repeated — under one line saying why it is back. The task text is where a
|
|
881
|
-
* relay carries its handoff lines, so a handoff the model still owes comes
|
|
882
|
-
* back with it.
|
|
883
|
-
*
|
|
884
|
-
* The attachments travel as the paths the injection wrote, never as parts:
|
|
885
|
-
* the images were handed to pi once, and re-attaching them would put the same
|
|
886
|
-
* bytes into the context a second time.
|
|
887
|
-
*
|
|
888
|
-
* @param {any} task a record with `assignment` and `attachmentPaths` retained
|
|
889
|
-
* from `onAssign`
|
|
890
|
-
*/
|
|
891
|
-
remind(task) {
|
|
892
|
-
task.reminders += 1;
|
|
893
|
-
// The turn this reminder is about to wake belongs to the same episode, so
|
|
894
|
-
// `onTurnStart` spends no reset on it and the bound stays reachable.
|
|
895
|
-
task.reminderWokeTurn = true;
|
|
896
|
-
const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
|
|
897
|
-
const text = [
|
|
898
|
-
`[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
|
|
899
|
-
"",
|
|
900
|
-
injectionText({ assign: task.assignment, proseIsNew: false, attachmentPaths: task.attachmentPaths }),
|
|
901
|
-
].join("\n");
|
|
902
|
-
this.surface.wakeUser?.(text, []);
|
|
903
|
-
this.notice("out", `${rung} for task ${task.taskId.slice(0, 8)}`);
|
|
904
|
-
}
|
|
905
|
-
|
|
906
1014
|
/**
|
|
907
1015
|
* `onlyne_complete`: the model's explicit outcome, and the only path to
|
|
908
1016
|
* `done`.
|
|
909
1017
|
*
|
|
910
|
-
*
|
|
911
|
-
* over, reported
|
|
912
|
-
* assistant text never stands in for it. An
|
|
913
|
-
*
|
|
1018
|
+
* `summary` is the display line the ledger keeps: it is what the model handed
|
|
1019
|
+
* over, flattened to one line and reported as the `head`, so the last
|
|
1020
|
+
* assistant text never stands in for it. An empty `summary` carries no
|
|
1021
|
+
* display line at all and falls back to that assistant text.
|
|
1022
|
+
*
|
|
1023
|
+
* `details` is the full result and `files` the absolute paths it names. Both
|
|
1024
|
+
* travel on unchanged, and both are what the next hop and the originator
|
|
1025
|
+
* receive: the client owns the shape and the ceiling, and refuses an oversize
|
|
1026
|
+
* body with its own sentence (`docs/v2-CONTRACT.md` §3c).
|
|
914
1027
|
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
* written, queued or detached — no completion report, no exit, no state on
|
|
919
|
-
* the task — so the session stays live and the same call lands once the
|
|
920
|
-
* handoff has gone out. `force: true` with a non-empty `reason` waives the
|
|
921
|
-
* guard and stamps the head with `relay-guard-forced: <reason>`, which is the
|
|
922
|
-
* ledger's audit trail for a completion that skipped the guard.
|
|
1028
|
+
* No policy of this plugin's runs before them. The completion's shape, the
|
|
1029
|
+
* details ceiling and the relay requirement are the client's checks, so its
|
|
1030
|
+
* refusal is raised out of here exactly as it arrived.
|
|
923
1031
|
*
|
|
924
|
-
* @param {{ outcome?: string,
|
|
1032
|
+
* @param {{ outcome?: string, summary?: string, details?: string, files?: string[] }} input
|
|
925
1033
|
*/
|
|
926
1034
|
async completeFromTool(input = {}) {
|
|
927
1035
|
const task = [...this.tasks.values()].find((item) => !item.completed);
|
|
928
1036
|
const taskId = task?.taskId ?? this.envTaskId;
|
|
929
1037
|
if (!taskId) throw new Error("onlyne: no task is assigned to this session");
|
|
930
|
-
const explicit = headOf(input.
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
});
|
|
937
|
-
if (refusal) {
|
|
938
|
-
const reason = headOf(input.reason);
|
|
939
|
-
if (input.force !== true || !reason) {
|
|
940
|
-
this.log(refusal);
|
|
941
|
-
this.notice("warn", refusal);
|
|
942
|
-
throw new Error(`onlyne: ${refusal}`);
|
|
943
|
-
}
|
|
944
|
-
head = headOf(`${FORCED_PREFIX}${reason}${explicit ? ` | ${explicit}` : ""}`);
|
|
945
|
-
this.notice("warn", `relay guard waived: ${reason}`);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
return this.complete(taskId, normalizeOutcome(input.outcome), head);
|
|
1038
|
+
const explicit = headOf(input.summary);
|
|
1039
|
+
const head = explicit || task?.head || "";
|
|
1040
|
+
return this.complete(taskId, normalizeOutcome(input.outcome), head, {
|
|
1041
|
+
details: typeof input.details === "string" ? input.details : null,
|
|
1042
|
+
files: Array.isArray(input.files) ? input.files : [],
|
|
1043
|
+
});
|
|
949
1044
|
}
|
|
950
1045
|
|
|
951
1046
|
/**
|
|
@@ -961,24 +1056,56 @@ export class OnlyneAgent {
|
|
|
961
1056
|
* therefore means this process can leave without losing the outcome, and a
|
|
962
1057
|
* rejected or queued report must never exit.
|
|
963
1058
|
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
1059
|
+
* `details` and `files` ride on the report itself; `head` stays the display
|
|
1060
|
+
* line the ledger keeps.
|
|
1061
|
+
*
|
|
1062
|
+
* @param {{ exitProcess?: boolean, details?: string | null, files?: string[] }} [options]
|
|
1063
|
+
* `exitProcess: true` is the recycle path, which ends the process after its
|
|
1064
|
+
* own detach frame instead.
|
|
966
1065
|
*/
|
|
967
1066
|
async complete(taskId, outcome, head, options = {}) {
|
|
968
|
-
|
|
1067
|
+
// A delivery ends this process when the role's scope says the session is
|
|
1068
|
+
// finished, and not otherwise. Both halves of that are load-bearing:
|
|
1069
|
+
//
|
|
1070
|
+
// Leaving when the scope keeps the session empties the pool — a `role` role
|
|
1071
|
+
// then opens one session per delivery, which is what `oneshot` means and the
|
|
1072
|
+
// opposite of what the operator asked for.
|
|
1073
|
+
//
|
|
1074
|
+
// Staying when the scope does not keep it leaks the host resource. The
|
|
1075
|
+
// client retires a session while its agent is still reachable — that is how
|
|
1076
|
+
// a pool member survives — so a resident runtime holds its pane open
|
|
1077
|
+
// forever. Its own comment says the exemption belongs to a kept session, and
|
|
1078
|
+
// the scope is what says which one this is.
|
|
1079
|
+
//
|
|
1080
|
+
// And the leave has to be this process's own. The client's teardown is a pane
|
|
1081
|
+
// kill, and a runtime killed mid-teardown loses whatever it had not yet
|
|
1082
|
+
// written: pi flushes its session file as it shuts down, so the graceful exit
|
|
1083
|
+
// is the only one that keeps the completion entry.
|
|
1084
|
+
const exitProcess = options.exitProcess ?? !this.keepsSession();
|
|
1085
|
+
const details = typeof options.details === "string" && options.details.length > 0 ? options.details : null;
|
|
1086
|
+
const files = Array.isArray(options.files) ? options.files : [];
|
|
969
1087
|
const normalized = normalizeOutcome(outcome);
|
|
970
1088
|
const summary = headOf(head);
|
|
971
1089
|
const task = this.tasks.get(taskId);
|
|
972
1090
|
if (task?.completed) return { taskId, outcome: normalized, head: summary, duplicate: true };
|
|
973
1091
|
if (task) task.completed = true;
|
|
974
|
-
const report = completeReport({ taskId, outcome: normalized, head: summary });
|
|
1092
|
+
const report = completeReport({ taskId, outcome: normalized, head: summary, details, files });
|
|
975
1093
|
if (!this.connected) {
|
|
976
|
-
this.
|
|
1094
|
+
this.pendingCompletions.push({ taskId, report, outcome: normalized, exitProcess });
|
|
977
1095
|
this.activity.set({ taskId, phase: `${normalized} queued` });
|
|
978
1096
|
this.notice("warn", `complete ${taskId.slice(0, 8)} ${normalized} queued: socket down`);
|
|
979
1097
|
return { taskId, outcome: normalized, head: summary, queued: true };
|
|
980
1098
|
}
|
|
981
|
-
|
|
1099
|
+
// The claim above only fences a second call while this report is in
|
|
1100
|
+
// flight. A refused or broken request handed nothing over, so the task
|
|
1101
|
+
// goes back to open and the caller sees why: a retry reports again, and
|
|
1102
|
+
// `completeFromTool` still finds the task.
|
|
1103
|
+
try {
|
|
1104
|
+
await this.request("report", report);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
if (task) task.completed = false;
|
|
1107
|
+
throw error;
|
|
1108
|
+
}
|
|
982
1109
|
this.stats.completions += 1;
|
|
983
1110
|
this.surface.customEntry?.("onlyne-complete", { taskId, outcome: normalized, head: summary });
|
|
984
1111
|
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
|
|
@@ -1005,21 +1132,29 @@ export class OnlyneAgent {
|
|
|
1005
1132
|
this.surface.exit?.(reason);
|
|
1006
1133
|
}
|
|
1007
1134
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
this.
|
|
1017
|
-
|
|
1018
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* Hand over every completion the dead socket could not carry, in the order the
|
|
1137
|
+
* calls came. The drain stops at the first report the new connection refuses:
|
|
1138
|
+
* that one goes back on the head of the queue, and the rest wait for the next
|
|
1139
|
+
* hello with it, so a client that dies mid-flush loses nothing.
|
|
1140
|
+
*/
|
|
1141
|
+
async flushPendingCompletions() {
|
|
1142
|
+
while (this.pendingCompletions.length > 0) {
|
|
1143
|
+
if (!this.connected) return;
|
|
1144
|
+
const pending = this.pendingCompletions.shift();
|
|
1145
|
+
try {
|
|
1146
|
+
await this.request("report", pending.report);
|
|
1147
|
+
this.stats.completions += 1;
|
|
1148
|
+
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
1149
|
+
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
1150
|
+
if (pending.exitProcess && this.activeTasks().length === 0) {
|
|
1151
|
+
this.exitSession(pending.outcome);
|
|
1152
|
+
}
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
this.pendingCompletions.unshift(pending);
|
|
1155
|
+
this.log(`queued completion still refused: ${error.message}`);
|
|
1156
|
+
break;
|
|
1019
1157
|
}
|
|
1020
|
-
} catch (error) {
|
|
1021
|
-
this.pendingCompletion = pending;
|
|
1022
|
-
this.log(`queued completion still refused: ${error.message}`);
|
|
1023
1158
|
}
|
|
1024
1159
|
}
|
|
1025
1160
|
|
|
@@ -1048,10 +1183,6 @@ export class OnlyneAgent {
|
|
|
1048
1183
|
const image = this.imageFromPath(input.imagePath);
|
|
1049
1184
|
const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
|
|
1050
1185
|
const data = await this.request("send", envelope);
|
|
1051
|
-
// Recorded only after the client answered the `send`: a refused envelope was
|
|
1052
|
-
// never a handoff, and the relay guard must not read one as delivered. Any
|
|
1053
|
-
// kind counts — `note` and `task` are both the session reaching that role.
|
|
1054
|
-
this.deliveredTo.add(String(input.to));
|
|
1055
1186
|
return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
|
|
1056
1187
|
}
|
|
1057
1188
|
|
|
@@ -1088,26 +1219,17 @@ export class OnlyneAgent {
|
|
|
1088
1219
|
// ------------------------------------------------------------ attachments
|
|
1089
1220
|
|
|
1090
1221
|
/**
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1222
|
+
* The pi image parts for one delivery, from the envelope's inline image. The
|
|
1223
|
+
* client writes the files itself and names every path in `assign.args
|
|
1224
|
+
* .attachments`, so this plugin only hands pi the bytes it was given; the
|
|
1225
|
+
* model addresses the files through the paths the delivery text names.
|
|
1093
1226
|
*/
|
|
1094
|
-
|
|
1227
|
+
imageParts(envelope) {
|
|
1095
1228
|
const image = envelope?.body?.image;
|
|
1096
1229
|
if (!image || typeof image.data_base64 !== "string") return [];
|
|
1097
1230
|
const mime = typeof image.mime === "string" && image.mime ? image.mime : "image/png";
|
|
1098
|
-
const name =
|
|
1099
|
-
|
|
1100
|
-
const path = join(dir, `${safeSegment(taskId)}-${safeSegment(envelope.id)}-${name}`);
|
|
1101
|
-
try {
|
|
1102
|
-
const bytes = Buffer.from(image.data_base64, "base64");
|
|
1103
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1104
|
-
writeFileSync(path, bytes, { mode: 0o600 });
|
|
1105
|
-
this.notice("state", `attachment ${basename(path)}`);
|
|
1106
|
-
return [{ path, part: { type: "image", mime, data: image.data_base64, name } }];
|
|
1107
|
-
} catch (error) {
|
|
1108
|
-
this.log(`attachment write failed: ${error.message}`);
|
|
1109
|
-
return [];
|
|
1110
|
-
}
|
|
1231
|
+
const name = typeof image.name === "string" ? image.name : null;
|
|
1232
|
+
return [{ type: "image", mime, data: image.data_base64, name }];
|
|
1111
1233
|
}
|
|
1112
1234
|
}
|
|
1113
1235
|
|