pi-onlyne 1.1.0 → 1.1.1
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 +8 -6
- package/README.zh.md +6 -5
- package/package.json +3 -6
- package/relay.toml.example +15 -10
- package/src/agent.mjs +39 -16
- package/src/agent.test.mjs +44 -0
package/README.md
CHANGED
|
@@ -79,10 +79,10 @@ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings
|
|
|
79
79
|
### From npm
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
pi install pi-onlyne
|
|
82
|
+
pi install npm:pi-onlyne # user-level: every pi process on this box loads it
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The published package is `pi-onlyne` on npm; `pi install pi-onlyne@<version>` pins
|
|
85
|
+
The published package is `pi-onlyne` on npm; `pi install npm:pi-onlyne@<version>` pins
|
|
86
86
|
one. This route reaches ordinary interactive sessions too, and there the extension
|
|
87
87
|
stays inert (no `ONLYNE_ROLE`, so no adapter). A role workspace needs no global
|
|
88
88
|
install to get a panel: the file-level copy above, or `onlyne server generate`,
|
|
@@ -298,10 +298,12 @@ the shipped client.
|
|
|
298
298
|
written, and a framing fault closes the connection and reconnects. Framing cannot
|
|
299
299
|
resynchronise after a corrupt body, which is the same conclusion
|
|
300
300
|
`crates/onlyne-frame/src/lib.rs` reaches.
|
|
301
|
-
- **
|
|
302
|
-
same
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
- **Deliveries are idempotent; tasks are not.** The dedup key is the envelope id. The
|
|
302
|
+
same delivery twice gets one injection and an ack with `reason: "duplicate"`, and a
|
|
303
|
+
new envelope for a task that is already running reaches that session as another
|
|
304
|
+
message — the work record keeps its counters and its relay ledger, and only its
|
|
305
|
+
"turns since this instruction" watchdog restarts. The client mints a fresh uuid per
|
|
306
|
+
envelope, so `duplicate` fires on a genuine re-offer and on nothing else.
|
|
305
307
|
|
|
306
308
|
- **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
|
|
307
309
|
heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
|
package/README.zh.md
CHANGED
|
@@ -73,10 +73,10 @@ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings
|
|
|
73
73
|
### 从 npm 装
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
pi install pi-onlyne
|
|
76
|
+
pi install npm:pi-onlyne # 用户级:这台机器上每个 pi 进程都会加载
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
发布名是 npm 上的 `pi-onlyne`,`pi install pi-onlyne@<version>` 钉住某一版。这条路径会覆盖
|
|
79
|
+
发布名是 npm 上的 `pi-onlyne`,`pi install npm:pi-onlyne@<version>` 钉住某一版。这条路径会覆盖
|
|
80
80
|
普通交互会话,那里没有 `ONLYNE_ROLE`,扩展保持静默(见 §1 的身份门)。role workspace 想要面
|
|
81
81
|
板,不必装到全局:上面那份文件级复制、或者 `onlyne server generate`,都把插件限定在服务这个
|
|
82
82
|
role 的 workspace 里。
|
|
@@ -259,9 +259,10 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
259
259
|
`inject` 插件记录的重载。其他键记日志后忽略,绝不误读。
|
|
260
260
|
- **`frame_too_large` / `bad_frame`**:超限正文在写出任何字节之前就被拒;帧错误关闭连接并重
|
|
261
261
|
连。帧一旦损坏无法重新同步,这与 `crates/onlyne-frame/src/lib.rs` 的结论一致。
|
|
262
|
-
-
|
|
263
|
-
`reason: "duplicate"
|
|
264
|
-
|
|
262
|
+
- **投递按 envelope id 幂等,任务不按 id 一次性使用**:去重键是 envelope id。同一条投递重复
|
|
263
|
+
到达只注入一次,ack 带 `reason: "duplicate"`;正在运行的任务收到新 envelope,会作为新消息
|
|
264
|
+
注入同一个会话,工作记录保留自己的计数与转发账本,只把"自这条指令以来的轮数"看门狗归零。
|
|
265
|
+
client 每条 envelope 都发新 uuid,所以 `duplicate` 只在真正的重投上生效。
|
|
265
266
|
|
|
266
267
|
- **pane 绑定(Orca tab)。** 在 Orca pane 里,插件在每个 heartbeat 上报自己跑在哪:报告
|
|
267
268
|
`Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-session/src/host.rs`),环境
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-onlyne",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./src/index.ts",
|
|
7
6
|
"license": "MIT",
|
|
8
7
|
"repository": {
|
|
9
8
|
"type": "git",
|
|
@@ -32,12 +31,10 @@
|
|
|
32
31
|
]
|
|
33
32
|
},
|
|
34
33
|
"scripts": {
|
|
35
|
-
"test": "node --test src/*.test.mjs"
|
|
36
|
-
"test:live": "node --test src/agent.live.test.mjs"
|
|
34
|
+
"test": "node --test src/*.test.mjs"
|
|
37
35
|
},
|
|
38
36
|
"peerDependencies": {
|
|
39
37
|
"@earendil-works/pi-coding-agent": "*",
|
|
40
38
|
"typebox": "*"
|
|
41
|
-
}
|
|
42
|
-
"peerDevDependencies": {}
|
|
39
|
+
}
|
|
43
40
|
}
|
package/relay.toml.example
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Manual-installation escape hatch for the relay guard.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# A generated workspace gets its guard policy from spec.toml ([[client]] rows
|
|
4
|
+
# `relay_required = ["writer"]`, `relay_count = 2` — `relay_required_count` is
|
|
5
|
+
# accepted as the guard file's own spelling), which the client injects into
|
|
6
|
+
# every session it spawns. This file is for installations that manage their
|
|
7
|
+
# own workspace: drop it beside package.json and the guard reads it when the
|
|
8
|
+
# environment carries no policy. Environment wins over this file; no policy in
|
|
9
|
+
# either place leaves the guard off.
|
|
7
10
|
#
|
|
8
|
-
#
|
|
9
|
-
# double-quoted strings, `#` comments. Anything outside that warns on stderr and
|
|
10
|
-
# is ignored. `relay_required` wins when both keys are present.
|
|
11
|
+
# relay_required wins over relay_count when both are present.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
# Downstream roles one of this role's sessions must have handed work to before
|
|
14
|
+
# it may report a terminal outcome:
|
|
15
|
+
# relay_required = ["writer", "auditor"]
|
|
16
|
+
|
|
17
|
+
# ... or this many distinct downstream roles:
|
|
18
|
+
# relay_required_count = 2
|
package/src/agent.mjs
CHANGED
|
@@ -152,7 +152,13 @@ export class OnlyneAgent {
|
|
|
152
152
|
* sent — the guard judges this session's own deliveries, not history.
|
|
153
153
|
*/
|
|
154
154
|
this.deliveredTo = new Set();
|
|
155
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The deliveries this process has already handed to the model, keyed by
|
|
157
|
+
* envelope id (a delivery's own identity). Keying it by task id would swallow
|
|
158
|
+
* every later envelope for a live task — the follow-up that never arrives —
|
|
159
|
+
* and the guard below answers a true re-delivery with `duplicate`.
|
|
160
|
+
*/
|
|
161
|
+
this.injectedDeliveries = new Set();
|
|
156
162
|
this.deliveredProse = new Set();
|
|
157
163
|
/** Pushes that arrived before the handshake finished; see `onFrame`. */
|
|
158
164
|
this.handshaking = false;
|
|
@@ -564,13 +570,16 @@ export class OnlyneAgent {
|
|
|
564
570
|
this.log("assign carried no task id; ignored");
|
|
565
571
|
return;
|
|
566
572
|
}
|
|
567
|
-
|
|
573
|
+
// The delivery's own identity, so a re-offer of the same message is caught
|
|
574
|
+
// and a genuinely new message for a running task gets through.
|
|
575
|
+
const deliveryId = envelope.id ?? `task:${taskId}`;
|
|
576
|
+
if (this.injectedDeliveries.has(deliveryId)) {
|
|
568
577
|
this.stats.duplicates += 1;
|
|
569
|
-
this.notice("dup",
|
|
578
|
+
this.notice("dup", `~~ task ${taskId.slice(0, 8)} re-delivered, already injected`);
|
|
570
579
|
await this.ack(taskId, true, "duplicate");
|
|
571
580
|
return;
|
|
572
581
|
}
|
|
573
|
-
this.
|
|
582
|
+
this.injectedDeliveries.add(deliveryId);
|
|
574
583
|
this.stats.assigns += 1;
|
|
575
584
|
if (typeof args.generation === "number") this.generation = args.generation;
|
|
576
585
|
|
|
@@ -580,18 +589,32 @@ export class OnlyneAgent {
|
|
|
580
589
|
if (proseIsNew) this.deliveredProse.add(prose);
|
|
581
590
|
const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
|
|
582
591
|
|
|
583
|
-
this.tasks.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// a
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
592
|
+
const held = this.tasks.get(taskId);
|
|
593
|
+
// A completed record is not a live one: a new envelope for a task that
|
|
594
|
+
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
595
|
+
if (held && !held.completed) {
|
|
596
|
+
// A new envelope for a task this session already holds — a follow-up, a
|
|
597
|
+
// redirect, a bounce back through a relay. The work record stays where it
|
|
598
|
+
// is: its delivered set keeps the relay guard's count, and its completion
|
|
599
|
+
// state still settles the task. Only the "since this instruction" counter
|
|
600
|
+
// moves, so the settled-without-completing watchdog measures the newest one.
|
|
601
|
+
held.turnsSinceAssign = 0;
|
|
602
|
+
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
603
|
+
if (held.failed) held.failed = false;
|
|
604
|
+
} else {
|
|
605
|
+
this.tasks.set(taskId, {
|
|
606
|
+
taskId,
|
|
607
|
+
envelopeId: envelope.id ?? null,
|
|
608
|
+
// Who handed this task over: the relay guard's count mode does not count
|
|
609
|
+
// a send straight back to it (`relay.mjs`).
|
|
610
|
+
upstream: envelope.from?.role?.role ?? null,
|
|
611
|
+
turnsSinceAssign: 0,
|
|
612
|
+
turns: 0,
|
|
613
|
+
errored: false,
|
|
614
|
+
head: "",
|
|
615
|
+
failed: false,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
595
618
|
this.agentState = "running";
|
|
596
619
|
this.surface.wakeUser?.(text, attachments.map((item) => item.part));
|
|
597
620
|
this.surface.customEntry?.("onlyne-assign", {
|
package/src/agent.test.mjs
CHANGED
|
@@ -458,6 +458,50 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
|
|
|
458
458
|
assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
459
459
|
});
|
|
460
460
|
|
|
461
|
+
// A follow-up arrives as its own envelope under the task id the session is
|
|
462
|
+
// already running. Keying the injection guard on the task swallowed it: the
|
|
463
|
+
// panel said `already injected`, nothing reached the model, and the row the
|
|
464
|
+
// server kept re-offering never settled.
|
|
465
|
+
test("a new envelope for a running task reaches the model and keeps its record", async () => {
|
|
466
|
+
const { agent, host, surface } = await startAgent();
|
|
467
|
+
agent.start();
|
|
468
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
469
|
+
|
|
470
|
+
host.notify("assign", assignArgs());
|
|
471
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
472
|
+
const record = agent.tasks.get(TASK_ID);
|
|
473
|
+
// Turns already run under this task, without driving the turn hooks: a real
|
|
474
|
+
// turn end also arms the settle fallback, which would complete the task.
|
|
475
|
+
record.turns = 3;
|
|
476
|
+
record.turnsSinceAssign = 2;
|
|
477
|
+
|
|
478
|
+
const base = assignArgs();
|
|
479
|
+
const follow = {
|
|
480
|
+
...base,
|
|
481
|
+
envelope: {
|
|
482
|
+
...base.envelope,
|
|
483
|
+
id: "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172",
|
|
484
|
+
body: { text: "actually, use the q8 variant" },
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
host.notify("assign", follow);
|
|
488
|
+
await waitFor(() => (surface.calls.wakeUser.length === 2 ? true : null));
|
|
489
|
+
assert.match(surface.calls.wakeUser[1].text, /q8 variant/);
|
|
490
|
+
const acks = await waitFor(() => (host.of("assign_ack").length === 2 ? host.of("assign_ack") : null));
|
|
491
|
+
assert.deepEqual(acks[1], { task_id: TASK_ID, accepted: true });
|
|
492
|
+
|
|
493
|
+
assert.equal(agent.tasks.get(TASK_ID), record, "the running work record survives");
|
|
494
|
+
assert.equal(record.turns, 3, "what the session already did under this task is not erased");
|
|
495
|
+
assert.equal(record.turnsSinceAssign, 0, "the watchdog counts from the newest instruction");
|
|
496
|
+
assert.equal(record.envelopeId, "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172");
|
|
497
|
+
|
|
498
|
+
// The same envelope again is the true duplicate, and it changes nothing.
|
|
499
|
+
host.notify("assign", follow);
|
|
500
|
+
const third = await waitFor(() => (host.of("assign_ack").length === 3 ? host.of("assign_ack") : null));
|
|
501
|
+
assert.equal(surface.calls.wakeUser.length, 2);
|
|
502
|
+
assert.deepEqual(third[2], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
503
|
+
});
|
|
504
|
+
|
|
461
505
|
// The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
|
|
462
506
|
// hands over a staged session by writing the hello reply and the first assign
|
|
463
507
|
// together, so both frames arrive in one read. The assignment must not be
|