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 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 # user-level: every pi process on this box loads it
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
- - **Task ids here are single-use.** The plugin acks duplicate `assign` deliveries for the
302
- same task (`reason: "duplicate"`) without a second injection, and remembers the id for
303
- the life of the connection. The client today mints a fresh uuid per task, so this only
304
- ever fires on a genuine redelivery.
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 # 用户级:这台机器上每个 pi 进程都会加载
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
- - **任务 id 在连接生命周期内一次性使用**:同一任务的重复 `assign` 插件只回
263
- `reason: "duplicate"` 的 ack,不重复注入,并记住这个 id 直到连接结束。当今 client 每个任务
264
- 都是新 uuid,所以这条只在真正的重投上生效。
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.0",
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
  }
@@ -1,13 +1,18 @@
1
- # Onlyne relay guard policy — copy to `relay.toml` next to this package's `package.json`.
1
+ # Manual-installation escape hatch for the relay guard.
2
2
  #
3
- # Manual installations only. A generated workspace states the same policy in the
4
- # server spec's `[[client]]` entry (`relay_required` / `relay_count`), which the
5
- # client injects as `ONLYNE_RELAY_REQUIRED` / `ONLYNE_RELAY_COUNT`. Those
6
- # environment variables win, and a file they shadow is ignored outright.
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
- # Closed subset: flat `key = value` lines, the two keys below, one-line arrays of
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
- relay_required = ["writer"] # these roles must have received a handoff
13
- relay_required_count = 2 # ... or this many distinct downstream roles
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
- this.injectedTasks = new Set();
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
- if (this.injectedTasks.has(taskId)) {
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", `task ${taskId.slice(0, 8)} already injected`);
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.injectedTasks.add(taskId);
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.set(taskId, {
584
- taskId,
585
- envelopeId: envelope.id ?? null,
586
- // Who handed this task over: the relay guard's count mode does not count
587
- // a send straight back to it (`relay.mjs`).
588
- upstream: envelope.from?.role?.role ?? null,
589
- turnsSinceAssign: 0,
590
- turns: 0,
591
- errored: false,
592
- head: "",
593
- failed: false,
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", {
@@ -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