pi-onlyne 2.1.0 → 2.3.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 +77 -35
- package/README.zh.md +48 -19
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +157 -84
- package/src/agent.test.mjs +97 -45
- package/src/config.mjs +173 -3
- package/src/config.test.mjs +37 -1
- package/src/index.ts +163 -18
- package/src/pi-surface.mjs +69 -8
- package/src/pi-surface.test.mjs +78 -4
- package/src/session-scope.mjs +90 -0
- package/src/session-scope.test.mjs +61 -0
- package/src/socket.mjs +45 -0
- package/src/socket.test.mjs +27 -0
package/src/agent.mjs
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
// surface, which is why the whole state machine is testable against a plain
|
|
11
11
|
// Node unix socket.
|
|
12
12
|
//
|
|
13
|
+
// One completion path, and it is the model's: an explicit `onlyne_complete`
|
|
14
|
+
// (`completeFromTool`) or the host's `recycle{outcome}`. A turn that ends
|
|
15
|
+
// without one — cleanly or with a provider error — decides nothing here: the
|
|
16
|
+
// client's own turn-end rule owns the ending (`docs/v2-CONTRACT.md` §3c: one
|
|
17
|
+
// nudge, then the delivery settles `blocked`) and its own sweeps own a session
|
|
18
|
+
// whose agent died. A failed turn only supplies the head fallback, because pi
|
|
19
|
+
// retries a retryable provider error inside the same run and the client's nudge
|
|
20
|
+
// can restart a session a moment after it settles — a report sent on the error
|
|
21
|
+
// would race work that is still going.
|
|
22
|
+
//
|
|
13
23
|
// What the human sees travels through one funnel, `notice` below: the pi widget
|
|
14
24
|
// panel when the host has `ctx.ui.setWidget`, the footer status line plus a
|
|
15
25
|
// `[pi-onlyne]` stderr line when it has not. `log` keeps the diagnostics — a
|
|
@@ -46,14 +56,24 @@ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
|
46
56
|
export const HELLO_TIMEOUT_MS = 5_000;
|
|
47
57
|
/** Default bound on one request round trip. */
|
|
48
58
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
59
|
+
|
|
49
60
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
61
|
+
* Capabilities this plugin implements on the wire.
|
|
62
|
+
*
|
|
63
|
+
* `resume` is the one that changes what a `task` scope does with this runtime.
|
|
64
|
+
* Pi keeps its conversation in its own store (`--session-dir`), so a session the
|
|
65
|
+
* client releases is one it can bring back by starting the role's `resume` argv
|
|
66
|
+
* — `pi --session <id>` — over the same store. Without the declaration the
|
|
67
|
+
* client keeps the process instead and the scope degrades to "process alive,
|
|
68
|
+
* session alive"; with it, an idle `task` session has its process released and
|
|
69
|
+
* the family's next delivery lands in the same conversation.
|
|
70
|
+
*
|
|
71
|
+
* The release is this plugin's own exit, not a kill: `onRecycle` stops and calls
|
|
72
|
+
* `exitSession`, and pi flushes its session file at `agent_settled`. The client
|
|
73
|
+
* asks first and closes the container only after a grace, which is what makes
|
|
74
|
+
* the ordering safe (`AGENTS.md` §10).
|
|
52
75
|
*/
|
|
53
|
-
export const
|
|
54
|
-
|
|
55
|
-
/** Capabilities this plugin implements on the wire. */
|
|
56
|
-
export const CAPABILITIES = ["register", "report", "inject", "recycle"];
|
|
76
|
+
export const CAPABILITIES = ["register", "report", "inject", "recycle", "resume"];
|
|
57
77
|
|
|
58
78
|
export class OnlyneAgent {
|
|
59
79
|
/**
|
|
@@ -71,17 +91,18 @@ export class OnlyneAgent {
|
|
|
71
91
|
* ladder?: number[],
|
|
72
92
|
* requestTimeoutMs?: number,
|
|
73
93
|
* helloTimeoutMs?: number,
|
|
74
|
-
* settleFallbackMs?: number,
|
|
75
94
|
* createConnection?: (path: string) => any,
|
|
76
95
|
* timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
|
|
77
96
|
* }} options
|
|
78
97
|
*/
|
|
79
98
|
constructor(options) {
|
|
80
99
|
this.socketPath = options.socketPath;
|
|
100
|
+
this.resolveSocket = typeof options.resolveSocket === "function" ? options.resolveSocket : null;
|
|
81
101
|
this.cwd = options.cwd;
|
|
82
102
|
this.role = options.role;
|
|
83
103
|
this.sessionId = options.sessionId;
|
|
84
104
|
this.envTaskId = options.taskId;
|
|
105
|
+
this.resident = Boolean(options.resident);
|
|
85
106
|
this.surface = options.surface;
|
|
86
107
|
/** The widget panel's model (`activity.mjs`); seeded with the local role. */
|
|
87
108
|
this.activity = options.activity ?? createActivity();
|
|
@@ -92,7 +113,6 @@ export class OnlyneAgent {
|
|
|
92
113
|
this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
|
|
93
114
|
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
94
115
|
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
|
95
|
-
this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
|
|
96
116
|
this.createConnection = options.createConnection ?? ((path) => createConnection(path));
|
|
97
117
|
// The pane this process was spawned in, reported on every heartbeat so the
|
|
98
118
|
// supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
|
|
@@ -122,7 +142,6 @@ export class OnlyneAgent {
|
|
|
122
142
|
this.attempt = 0;
|
|
123
143
|
this.reconnectHandle = null;
|
|
124
144
|
this.heartbeatHandle = null;
|
|
125
|
-
this.settleHandle = null;
|
|
126
145
|
/**
|
|
127
146
|
* The heartbeat round in flight, and what the next pass of it should say.
|
|
128
147
|
* `heartbeat` never lets two rounds overlap, so `beatRound` doubles as the
|
|
@@ -226,6 +245,16 @@ export class OnlyneAgent {
|
|
|
226
245
|
|
|
227
246
|
connect() {
|
|
228
247
|
if (this.closed || this.socket) return;
|
|
248
|
+
if (this.resolveSocket) {
|
|
249
|
+
try {
|
|
250
|
+
const refreshed = this.resolveSocket();
|
|
251
|
+
if (typeof refreshed === "string" && refreshed.length > 0) {
|
|
252
|
+
this.socketPath = refreshed;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
/* keep previous socketPath */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
229
258
|
let socket;
|
|
230
259
|
try {
|
|
231
260
|
socket = this.createConnection(this.socketPath);
|
|
@@ -269,6 +298,7 @@ export class OnlyneAgent {
|
|
|
269
298
|
/** Tear the current socket down and schedule the next attempt. */
|
|
270
299
|
dropSocket() {
|
|
271
300
|
const socket = this.socket;
|
|
301
|
+
const wasConnected = this.connected;
|
|
272
302
|
this.socket = null;
|
|
273
303
|
this.connected = false;
|
|
274
304
|
this.handshaking = false;
|
|
@@ -276,7 +306,13 @@ export class OnlyneAgent {
|
|
|
276
306
|
this.rejectAll("connection lost");
|
|
277
307
|
this.stopHeartbeat();
|
|
278
308
|
this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
|
|
279
|
-
|
|
309
|
+
if (!this.closed && (!this.resident || wasConnected)) {
|
|
310
|
+
this.notice("state", "reconnecting");
|
|
311
|
+
} else if (this.closed) {
|
|
312
|
+
this.notice("state", "detached");
|
|
313
|
+
} else {
|
|
314
|
+
this.redraw();
|
|
315
|
+
}
|
|
280
316
|
if (socket && !socket.destroyed) socket.destroy();
|
|
281
317
|
this.scheduleReconnect();
|
|
282
318
|
}
|
|
@@ -319,6 +355,11 @@ export class OnlyneAgent {
|
|
|
319
355
|
}
|
|
320
356
|
this.welcome = welcome;
|
|
321
357
|
this.connected = true;
|
|
358
|
+
const hadConnectFailure = this.lastError !== null;
|
|
359
|
+
this.lastError = null;
|
|
360
|
+
if (this.resident && hadConnectFailure) {
|
|
361
|
+
this.log(`connected role=${welcome.role} session=${this.sessionId ?? "resident"} socket=${this.socketPath}`);
|
|
362
|
+
}
|
|
322
363
|
// The client names every delivery it already handed to this session, so a
|
|
323
364
|
// process that restarts under a live client does not re-inject work its
|
|
324
365
|
// predecessor read: the delivery guard is process memory, and this is how it
|
|
@@ -465,7 +506,15 @@ export class OnlyneAgent {
|
|
|
465
506
|
}
|
|
466
507
|
|
|
467
508
|
noteFailure(error) {
|
|
509
|
+
const prevCode = this.lastError?.code ?? null;
|
|
468
510
|
this.lastError = error;
|
|
511
|
+
if (
|
|
512
|
+
this.resident &&
|
|
513
|
+
(error?.code === "ENOENT" || error?.code === "ECONNREFUSED") &&
|
|
514
|
+
prevCode === error.code
|
|
515
|
+
) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
469
518
|
this.log(`socket error: ${error?.message ?? error}`);
|
|
470
519
|
}
|
|
471
520
|
|
|
@@ -477,14 +526,20 @@ export class OnlyneAgent {
|
|
|
477
526
|
* @param {"in" | "dup" | "out" | "warn" | "state"} kind
|
|
478
527
|
* @param {string} text
|
|
479
528
|
*/
|
|
529
|
+
redraw() {
|
|
530
|
+
this.surface.status?.(this.activity.lines()[0]);
|
|
531
|
+
if (this.surface.available?.widget) {
|
|
532
|
+
this.surface.widget?.(this.activity.lines(), this.activity);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
480
536
|
notice(kind, text) {
|
|
481
537
|
this.activity.note(kind, text);
|
|
538
|
+
this.redraw();
|
|
482
539
|
if (this.surface.available?.widget) {
|
|
483
|
-
this.surface.widget?.(this.activity.lines());
|
|
484
540
|
return;
|
|
485
541
|
}
|
|
486
|
-
// No panel:
|
|
487
|
-
this.surface.status?.(this.activity.lines()[0]);
|
|
542
|
+
// No panel: stderr carries the event.
|
|
488
543
|
this.log(`${kind}: ${text}`);
|
|
489
544
|
}
|
|
490
545
|
|
|
@@ -710,10 +765,6 @@ export class OnlyneAgent {
|
|
|
710
765
|
this.timer.clear(this.reconnectHandle);
|
|
711
766
|
this.reconnectHandle = null;
|
|
712
767
|
}
|
|
713
|
-
if (this.settleHandle) {
|
|
714
|
-
this.timer.clear(this.settleHandle);
|
|
715
|
-
this.settleHandle = null;
|
|
716
|
-
}
|
|
717
768
|
this.rejectAll("agent stopped");
|
|
718
769
|
}
|
|
719
770
|
|
|
@@ -732,11 +783,14 @@ export class OnlyneAgent {
|
|
|
732
783
|
* that is what every such frame meant.
|
|
733
784
|
*/
|
|
734
785
|
keepsSession() {
|
|
735
|
-
return this.scope === "task" || this.scope === "role";
|
|
786
|
+
return this.resident || this.scope === "task" || this.scope === "role";
|
|
736
787
|
}
|
|
737
788
|
|
|
738
789
|
async onAssign(args) {
|
|
739
790
|
if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
|
|
791
|
+
if (this.resident && typeof args.session_id === "string" && args.session_id) {
|
|
792
|
+
this.sessionId = args.session_id;
|
|
793
|
+
}
|
|
740
794
|
const envelope = args.envelope ?? {};
|
|
741
795
|
const taskId = args.task_id ?? envelope.causality?.task ?? null;
|
|
742
796
|
if (!taskId) {
|
|
@@ -769,15 +823,42 @@ export class OnlyneAgent {
|
|
|
769
823
|
this.surface.roleProse?.(prose);
|
|
770
824
|
}
|
|
771
825
|
|
|
826
|
+
const kind = envelope.kind ?? "task";
|
|
772
827
|
const held = this.tasks.get(taskId);
|
|
828
|
+
const fromLabel = describePrincipal(envelope.from);
|
|
829
|
+
const wakeMeta = {
|
|
830
|
+
role: this.role,
|
|
831
|
+
taskId,
|
|
832
|
+
kind,
|
|
833
|
+
from: fromLabel,
|
|
834
|
+
envelopeId: envelope.id ?? null,
|
|
835
|
+
bodyText: envelope.body?.text ?? text,
|
|
836
|
+
};
|
|
837
|
+
// A note is a message, not work: it is injected and acknowledged, and it
|
|
838
|
+
// opens no task record, so nothing beats for it, no turn end owes it an
|
|
839
|
+
// outcome and `onlyne_complete` is never required of it. A note naming a
|
|
840
|
+
// task this session is still working on lands mid-task, below.
|
|
841
|
+
if (kind === "note" && (!held || held.completed)) {
|
|
842
|
+
if (this.tasks.size > 0) this.agentState = "running";
|
|
843
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
844
|
+
this.surface.customEntry?.("onlyne-assign", {
|
|
845
|
+
taskId,
|
|
846
|
+
envelopeId: envelope.id ?? null,
|
|
847
|
+
kind: "note",
|
|
848
|
+
proseInjected: proseIsNew,
|
|
849
|
+
prose,
|
|
850
|
+
attachments: attachmentPaths,
|
|
851
|
+
});
|
|
852
|
+
this.notice("in", `note ${taskId.slice(0, 8)} from ${fromLabel}: ${headOf(envelope.body?.text)}`);
|
|
853
|
+
await this.ack(taskId, true, null);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
773
856
|
// A completed record is not a live one: a new envelope for a task that
|
|
774
857
|
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
775
858
|
if (held && !held.completed) {
|
|
776
859
|
// A new envelope for a task this session already holds — a follow-up, a
|
|
777
860
|
// redirect, a bounce back through a handoff. The work record stays where
|
|
778
|
-
// it is
|
|
779
|
-
// instruction being replaced, so it is cleared with that instruction.
|
|
780
|
-
held.errored = false;
|
|
861
|
+
// it is; the newest instruction re-points it at the newest envelope.
|
|
781
862
|
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
782
863
|
// `lastSeq` deliberately does not reset here. It is the floor the next
|
|
783
864
|
// allocation for this task must clear, and the host's row for the task
|
|
@@ -800,22 +881,21 @@ export class OnlyneAgent {
|
|
|
800
881
|
* share the plugin's counter. Null until the first report.
|
|
801
882
|
*/
|
|
802
883
|
lastSeq: null,
|
|
803
|
-
errored: false,
|
|
804
884
|
head: "",
|
|
805
885
|
});
|
|
806
886
|
}
|
|
807
887
|
this.agentState = "running";
|
|
808
|
-
this.surface.wakeUser?.(text, this.imageParts(envelope));
|
|
888
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
809
889
|
this.surface.customEntry?.("onlyne-assign", {
|
|
810
890
|
taskId,
|
|
811
891
|
envelopeId: envelope.id ?? null,
|
|
812
|
-
kind
|
|
892
|
+
kind,
|
|
813
893
|
proseInjected: proseIsNew,
|
|
814
894
|
prose,
|
|
815
895
|
attachments: attachmentPaths,
|
|
816
896
|
});
|
|
817
897
|
this.activity.set({ taskId, phase: "running" });
|
|
818
|
-
this.notice("in", `task ${taskId.slice(0, 8)} from ${
|
|
898
|
+
this.notice("in", `task ${taskId.slice(0, 8)} from ${fromLabel} (${kind}): ${headOf(envelope.body?.text)}`);
|
|
819
899
|
this.startHeartbeat();
|
|
820
900
|
await this.ack(taskId, true, null);
|
|
821
901
|
}
|
|
@@ -848,7 +928,13 @@ export class OnlyneAgent {
|
|
|
848
928
|
onNudge(frame, args) {
|
|
849
929
|
const text = typeof args.text === "string" ? args.text : "";
|
|
850
930
|
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, []
|
|
931
|
+
const handedOver = text.length > 0 && this.surface.wakeUser?.(text, [], {
|
|
932
|
+
role: this.role,
|
|
933
|
+
taskId: args.task_id ?? null,
|
|
934
|
+
kind: "nudge",
|
|
935
|
+
from: "host",
|
|
936
|
+
bodyText: text,
|
|
937
|
+
}) === true;
|
|
852
938
|
if (handedOver) this.notice("in", `nudge for task ${taskId}`);
|
|
853
939
|
else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
|
|
854
940
|
if (frame?.id === undefined || frame?.id === null) return;
|
|
@@ -906,8 +992,10 @@ export class OnlyneAgent {
|
|
|
906
992
|
*
|
|
907
993
|
* How a turn end settles the work is the client's rule
|
|
908
994
|
* (`docs/v2-CONTRACT.md` §3c): the plugin counts nothing, bounds nothing, and
|
|
909
|
-
* reports only
|
|
910
|
-
*
|
|
995
|
+
* reports only its own phase and the completion the model asked for. A turn
|
|
996
|
+
* that ended without a completion is the client's case — its own one-nudge
|
|
997
|
+
* rule, and its own sweeps for a session whose agent died — so nothing here
|
|
998
|
+
* decides an outcome.
|
|
911
999
|
*/
|
|
912
1000
|
onTurnStart() {
|
|
913
1001
|
void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
@@ -925,40 +1013,29 @@ export class OnlyneAgent {
|
|
|
925
1013
|
}
|
|
926
1014
|
|
|
927
1015
|
/**
|
|
928
|
-
* pi has settled: the session is waiting for input
|
|
929
|
-
*
|
|
1016
|
+
* pi has settled: the session is waiting for input. The phase is the whole of
|
|
1017
|
+
* what this hook says — a settle is not a verdict, because the client's own
|
|
1018
|
+
* nudge can restart a session a moment later and pi can still be inside a
|
|
1019
|
+
* retry that settles it again.
|
|
930
1020
|
*/
|
|
931
1021
|
onSettled() {
|
|
932
|
-
this.clearSettleFallback();
|
|
933
1022
|
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
934
|
-
void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
935
1023
|
}
|
|
936
1024
|
|
|
937
1025
|
/**
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
1026
|
+
* A turn ended with an error (`stopReason: "error"`). The plugin sends
|
|
1027
|
+
* nothing: an error is not proof of a terminal outcome, because pi retries a
|
|
1028
|
+
* retryable provider error and stays inside the same run, and the client's
|
|
1029
|
+
* nudge can restart a session a moment after it settles — so a report sent
|
|
1030
|
+
* here would race work that is still going. What the error does supply is the
|
|
1031
|
+
* head fallback for a later `onlyne_complete` whose `summary` carries nothing.
|
|
944
1032
|
*/
|
|
945
|
-
async trySettle() {
|
|
946
|
-
if (this.closed || this.activeTasks().length === 0) return;
|
|
947
|
-
if (await this.derivedPhase() !== "idle") {
|
|
948
|
-
this.armSettleFallback();
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
await this.reportFailedTurns().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
/** A failed turn: the task's outcome is `failed`, with the error as its head. */
|
|
955
1033
|
onTurnError(text) {
|
|
956
1034
|
for (const task of this.tasks.values()) {
|
|
957
1035
|
if (task.completed) continue;
|
|
958
|
-
task.errored = true;
|
|
959
1036
|
if (text) task.head = headOf(text);
|
|
960
1037
|
}
|
|
961
|
-
|
|
1038
|
+
this.notice("warn", `turn ended with an error: ${headOf(text) || "no reason given"}`);
|
|
962
1039
|
}
|
|
963
1040
|
|
|
964
1041
|
/**
|
|
@@ -979,38 +1056,6 @@ export class OnlyneAgent {
|
|
|
979
1056
|
}
|
|
980
1057
|
}
|
|
981
1058
|
|
|
982
|
-
armSettleFallback() {
|
|
983
|
-
this.clearSettleFallback();
|
|
984
|
-
if (this.closed || this.activeTasks().length === 0) return;
|
|
985
|
-
this.settleHandle = this.timer.set(() => {
|
|
986
|
-
this.settleHandle = null;
|
|
987
|
-
void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
|
|
988
|
-
}, this.settleFallbackMs);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
clearSettleFallback() {
|
|
992
|
-
if (!this.settleHandle) return;
|
|
993
|
-
this.timer.clear(this.settleHandle);
|
|
994
|
-
this.settleHandle = null;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* Report the failures this session witnessed itself, for every task it still
|
|
999
|
-
* holds.
|
|
1000
|
-
*
|
|
1001
|
-
* An errored turn is proof on its own — pi may skip the clean turn end — so
|
|
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.
|
|
1006
|
-
*/
|
|
1007
|
-
async reportFailedTurns() {
|
|
1008
|
-
for (const task of [...this.tasks.values()]) {
|
|
1009
|
-
if (task.completed || !task.errored) continue;
|
|
1010
|
-
await this.complete(task.taskId, "failed", task.head);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
1059
|
/**
|
|
1015
1060
|
* `onlyne_complete`: the model's explicit outcome, and the only path to
|
|
1016
1061
|
* `done`.
|
|
@@ -1100,8 +1145,9 @@ export class OnlyneAgent {
|
|
|
1100
1145
|
// flight. A refused or broken request handed nothing over, so the task
|
|
1101
1146
|
// goes back to open and the caller sees why: a retry reports again, and
|
|
1102
1147
|
// `completeFromTool` still finds the task.
|
|
1148
|
+
let answer = null;
|
|
1103
1149
|
try {
|
|
1104
|
-
await this.request("report", report);
|
|
1150
|
+
answer = await this.request("report", report);
|
|
1105
1151
|
} catch (error) {
|
|
1106
1152
|
if (task) task.completed = false;
|
|
1107
1153
|
throw error;
|
|
@@ -1114,6 +1160,14 @@ export class OnlyneAgent {
|
|
|
1114
1160
|
this.stopHeartbeat();
|
|
1115
1161
|
if (exitProcess) this.exitSession(normalized);
|
|
1116
1162
|
}
|
|
1163
|
+
// A client that had already settled this task says so with its own record
|
|
1164
|
+
// of the terminal fact, and the caller hands that on rather than claiming
|
|
1165
|
+
// the completion it just sent. The task is still left settled here: the
|
|
1166
|
+
// client's row is the authority, and re-opening it would only ask the
|
|
1167
|
+
// model to report an outcome nobody is waiting for.
|
|
1168
|
+
if (isAlreadySettled(answer)) {
|
|
1169
|
+
return { taskId, outcome: normalized, head: summary, alreadySettled: answer.already_settled };
|
|
1170
|
+
}
|
|
1117
1171
|
return { taskId, outcome: normalized, head: summary };
|
|
1118
1172
|
}
|
|
1119
1173
|
|
|
@@ -1143,7 +1197,15 @@ export class OnlyneAgent {
|
|
|
1143
1197
|
if (!this.connected) return;
|
|
1144
1198
|
const pending = this.pendingCompletions.shift();
|
|
1145
1199
|
try {
|
|
1146
|
-
await this.request("report", pending.report);
|
|
1200
|
+
const answer = await this.request("report", pending.report);
|
|
1201
|
+
// A queued completion has no tool result to carry the client's record
|
|
1202
|
+
// back through, so the one thing worth saying is that the client had
|
|
1203
|
+
// already settled the task and this report changed nothing.
|
|
1204
|
+
if (isAlreadySettled(answer)) {
|
|
1205
|
+
this.log(
|
|
1206
|
+
`queued completion for ${pending.taskId.slice(0, 8)} was already settled by the client as ${answer.already_settled.state}`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1147
1209
|
this.stats.completions += 1;
|
|
1148
1210
|
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
1149
1211
|
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
@@ -1233,6 +1295,17 @@ export class OnlyneAgent {
|
|
|
1233
1295
|
}
|
|
1234
1296
|
}
|
|
1235
1297
|
|
|
1298
|
+
/**
|
|
1299
|
+
* Whether a client's answer to a `report.complete` says the task had already
|
|
1300
|
+
* settled. The shape is the client's (`{"already_settled": {state, settled_at,
|
|
1301
|
+
* head}}`); a normal accepted completion answers `data = null`, which is not an
|
|
1302
|
+
* object and reads as no.
|
|
1303
|
+
*/
|
|
1304
|
+
function isAlreadySettled(answer) {
|
|
1305
|
+
return Boolean(answer) && typeof answer === "object" && answer.already_settled !== null
|
|
1306
|
+
&& typeof answer.already_settled === "object";
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1236
1309
|
/** Mime type for one attachment path the model asked to send. */
|
|
1237
1310
|
export function mimeForPath(path) {
|
|
1238
1311
|
const lower = String(path).toLowerCase();
|