pi-onlyne 1.1.0 → 1.1.2
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 +17 -13
- package/README.zh.md +13 -9
- 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/src/index.ts +4 -4
- package/src/socket.mjs +54 -0
- package/src/socket.test.mjs +79 -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`,
|
|
@@ -215,10 +215,10 @@ workspace, `relay.toml` in a manual installation.
|
|
|
215
215
|
|
|
216
216
|
```toml
|
|
217
217
|
relay_required = ["writer"] # these roles must have received a handoff
|
|
218
|
-
relay_required_count = 2 #
|
|
218
|
+
relay_required_count = 2 # legacy alias of relay_count: this many distinct downstream roles
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
`relay_required` wins when both keys are present.
|
|
221
|
+
`relay_count` is the canonical count key. `relay_required_count` is its legacy alias, the spelling `relay.toml` itself uses. `relay_required` wins when both the list and the count keys are present.
|
|
222
222
|
|
|
223
223
|
The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
|
|
224
224
|
rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
|
|
@@ -228,8 +228,8 @@ every session process it spawns:
|
|
|
228
228
|
```toml
|
|
229
229
|
[[client]]
|
|
230
230
|
role = "planner"
|
|
231
|
+
relay_count = 2 # this many distinct downstream roles
|
|
231
232
|
relay_required = ["writer"] # these roles must have received a handoff
|
|
232
|
-
relay_count = 2 # ... or this many distinct downstream roles
|
|
233
233
|
```
|
|
234
234
|
|
|
235
235
|
The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
|
|
@@ -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`
|
|
@@ -327,7 +329,7 @@ the shipped client.
|
|
|
327
329
|
| `ONLYNE_ROLE` | yes | the mount role |
|
|
328
330
|
| `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
|
|
329
331
|
| `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
|
|
330
|
-
| `ONLYNE_SOCKET` | no |
|
|
332
|
+
| `ONLYNE_SOCKET` | no | the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin reads the marker `<cwd>/.onlyne/run/socket` for the path the daemon published, and falls back to `<cwd>/.onlyne/run/s` |
|
|
331
333
|
| `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
|
|
332
334
|
| `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
|
|
333
335
|
| `ORCA_PANE_KEY` | no | where this process runs (`<tab_id>:<leaf_id>`), reported on every heartbeat as `observed.host.orca.pane_key`; unset outside an Orca pane, which is why the field is then absent |
|
|
@@ -337,9 +339,10 @@ the shipped client.
|
|
|
337
339
|
Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
|
|
338
340
|
allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
|
|
339
341
|
|
|
340
|
-
The plugin reads
|
|
342
|
+
The plugin reads three files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1),
|
|
341
343
|
`relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
|
|
342
|
-
client injected none, §5)
|
|
344
|
+
client injected none, §5), and `<cwd>/.onlyne/run/socket` (the marker naming the socket
|
|
345
|
+
path the client's daemon bound, read when the environment carried none, §8).
|
|
343
346
|
|
|
344
347
|
## 8. Troubleshooting
|
|
345
348
|
|
|
@@ -347,6 +350,7 @@ client injected none, §5).
|
|
|
347
350
|
| --- | --- | --- |
|
|
348
351
|
| `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
|
|
349
352
|
| `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
|
|
353
|
+
| `socket error: connect EINVAL …/.onlyne/run/s` on a deep workspace | macOS gives `sun_path` 104 bytes, so a socket path past 103 is refused; a generated role workspace nests three levels under its server root and a long root carries the canonical spelling over the bound. The client serves such a workspace from a short path under the temporary directory and publishes it in `<workspace>/.onlyne/run/socket` | `onlyne-client status` for the line `onlyne: client running … socket <path>`, which names the served path, plus the client log line carrying `socket = <path>`; `cat <workspace>/.onlyne/run/socket` holds that same path, and the plugin dials it when the environment injected nothing |
|
|
350
354
|
| `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
|
|
351
355
|
| `ready refused: internal: unknown session for …` | the plugin mounted and reported for a task the client never staged (normal when pi is started by hand outside a task) | start pi under the client, not by hand |
|
|
352
356
|
| `assign` never arrives | the client's `session_command` did not spawn pi, or `inject` was dropped | the client log for the spawn line; `/onlyne status` for the capability set |
|
|
@@ -366,7 +370,7 @@ client injected none, §5).
|
|
|
366
370
|
|
|
367
371
|
```bash
|
|
368
372
|
cd plugins/onlyne-agent-pi
|
|
369
|
-
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
|
|
373
|
+
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
|
|
370
374
|
```
|
|
371
375
|
|
|
372
376
|
`src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
|
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`),环境
|
|
@@ -283,7 +284,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
283
284
|
| `ONLYNE_ROLE` | 是 | 挂载的 role |
|
|
284
285
|
| `ONLYNE_SESSION_ID` | 是 | 挂载的 session id;当前 client 中 session_id 等于 task_id |
|
|
285
286
|
| `ONLYNE_TASK_ID` | 是 | 本进程服务的任务;驱动 `session_register` 与首条 `ready` |
|
|
286
|
-
| `ONLYNE_SOCKET` | 否 |
|
|
287
|
+
| `ONLYNE_SOCKET` | 否 | client 为该工作区实际服务的 socket 路径;凡 client 拉起的会话进程都会带上。变量未设置时,插件读标记文件 `<cwd>/.onlyne/run/socket`,取守护进程发布的那个路径,随后落到 `<cwd>/.onlyne/run/s` |
|
|
287
288
|
| `ONLYNE_RELAY_REQUIRED` | 否 | 该 role 在 spec 里的 `relay_required`,逗号分隔:守卫的名单模式(§5) |
|
|
288
289
|
| `ONLYNE_RELAY_COUNT` | 否 | 该 role 在 spec 里的 `relay_count`:守卫的 count 模式,只在名单为空时起作用(§5) |
|
|
289
290
|
| `ORCA_PANE_KEY` | 否 | 本进程跑在哪(`<tab_id>:<leaf_id>`),每个 heartbeat 以 `observed.host.orca.pane_key` 上报;不在 Orca pane 里时未设置,这也是该字段缺席的原因 |
|
|
@@ -293,8 +294,10 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
293
294
|
值得记住的常量:插件每 10 秒发一次心跳(`heartbeat_timeout_ms` 是 30 秒),`hello` 最多等
|
|
294
295
|
5 秒,单次请求超时 30 秒,重连按 1/2/4/8/16/30 秒阶梯退避。
|
|
295
296
|
|
|
296
|
-
|
|
297
|
-
`relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5
|
|
297
|
+
插件自己读三个文件:`<cwd>/.pi/onlyne.json`(开关,§1)、`package.json` 旁边的
|
|
298
|
+
`relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5)、
|
|
299
|
+
`<cwd>/.onlyne/run/socket`(标记文件,写明 client 守护进程绑定的 socket 路径,只在环境里
|
|
300
|
+
没带路径时才读,§8)。
|
|
298
301
|
|
|
299
302
|
## 8. 故障排查
|
|
300
303
|
|
|
@@ -302,6 +305,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
302
305
|
| --- | --- | --- |
|
|
303
306
|
| 看不到 `[pi-onlyne] session …` | 三个环境变量缺一,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
|
|
304
307
|
| `socket error: connect ENOENT …/.onlyne/run/s` | 该工作区没有 `onlyne-client run` | 起 client,或 `onlyne-client status` |
|
|
308
|
+
| 深层工作区里 `socket error: connect EINVAL …/.onlyne/run/s` | macOS 的 `sun_path` 只有 104 字节,超过 103 的 socket 路径会被内核拒绝;生成的 role 工作区在 server root 下再套三层,root 一长,规范写法就越过这个上界。client 面对这种工作区会把 socket 放到临时目录下的短路径上服务,并把选中的路径发布进 `<workspace>/.onlyne/run/socket` | 看 `onlyne-client status` 打印的 `onlyne: client running … socket <路径>`,那一行点出实际服务的路径,再看 client 日志里带 `socket = <路径>` 的那行;`cat <workspace>/.onlyne/run/socket` 得到同一个路径——环境里没带变量时,插件拨的就是它 |
|
|
305
309
|
| 反复 `reconnecting in 4000ms` | client 已停或 socket 被替换 | `onlyne --server-root … roles` |
|
|
306
310
|
| `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
|
|
307
311
|
| `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
|
|
@@ -321,7 +325,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
321
325
|
|
|
322
326
|
```bash
|
|
323
327
|
cd plugins/onlyne-agent-pi
|
|
324
|
-
node --test src/*.test.mjs # 帧编解码、协议词汇、agent
|
|
328
|
+
node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫、socket 路径
|
|
325
329
|
```
|
|
326
330
|
|
|
327
331
|
`src/agent.live.test.mjs` 只在 `target/debug/onlyne-client` 与 `onlyne-server` 存在时运行。
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-onlyne",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { Type } from "typebox";
|
|
|
20
20
|
import { OnlyneAgent } from "./agent.mjs";
|
|
21
21
|
import { loadConfig, sessionIdentity } from "./config.mjs";
|
|
22
22
|
import { loadRelay, relayEnabled } from "./relay.mjs";
|
|
23
|
+
import { resolveSocketPath } from "./socket.mjs";
|
|
23
24
|
import { createSurface } from "./pi-surface.mjs";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -32,9 +33,6 @@ declare const process: {
|
|
|
32
33
|
stderr: { write(chunk: string): void };
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
/** Socket every role workspace serves; `crates/onlyne-client/src/adapter_socket.rs`. */
|
|
36
|
-
const SOCKET_RELATIVE_PATH = ".onlyne/run/s";
|
|
37
|
-
|
|
38
36
|
/** One image part handed to the pi message surface. */
|
|
39
37
|
interface ImagePartInput {
|
|
40
38
|
mime: string;
|
|
@@ -235,7 +233,9 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
235
233
|
log(`disabled by ${config.path}`);
|
|
236
234
|
return;
|
|
237
235
|
}
|
|
238
|
-
|
|
236
|
+
// Environment first (the client injects the path it serves), then the
|
|
237
|
+
// marker the daemon publishes, then the canonical `run/s` (socket.mjs).
|
|
238
|
+
const socketPath = resolveSocketPath(env, ctx.cwd);
|
|
239
239
|
// The guard's policy comes from the spec through the client's environment;
|
|
240
240
|
// a hand-written `relay.toml` beside the package is the fallback a manual
|
|
241
241
|
// installation still has (relay.mjs).
|
package/src/socket.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The adapter socket a pi session dials.
|
|
2
|
+
//
|
|
3
|
+
// macOS gives `sun_path` 104 bytes, so the kernel refuses a unix socket path
|
|
4
|
+
// past 103 (`UNIX_SOCKET_PATH_MAX` in `crates/onlyne-layout/src/lib.rs`). A
|
|
5
|
+
// generated role workspace nests three levels below its server root
|
|
6
|
+
// (`<root>/.onlyne/ws/<topology>/<role>/.onlyne/run/s`), so a deep root carries
|
|
7
|
+
// the canonical spelling past that bound. The client answers by serving such a
|
|
8
|
+
// workspace from a short path under the temporary directory
|
|
9
|
+
// (`<temp>/onlyne-<16hex>/s`) and publishing the choice it bound in the marker
|
|
10
|
+
// file `<workspace>/.onlyne/run/socket`, one line holding the absolute served
|
|
11
|
+
// path. `onlyne-layout::SocketEndpoint::publish` writes that file; every reader
|
|
12
|
+
// in the product reaches one live socket through it.
|
|
13
|
+
//
|
|
14
|
+
// So the plugin has three answers in order, cheapest first: the path the client
|
|
15
|
+
// injected when it spawned this process (`ONLYNE_SOCKET`), the path the running
|
|
16
|
+
// daemon published in the marker, and the canonical spelling. The third one is
|
|
17
|
+
// a complete answer for every workspace short enough to serve from `run/s`,
|
|
18
|
+
// because `bind_socket` writes the marker at every start and such a tree
|
|
19
|
+
// publishes `run/s` in it; the two readings give one path. A marker that is
|
|
20
|
+
// missing, unreadable, or blank holds no published override, so resolution
|
|
21
|
+
// falls through silently. That keeps a hand-started pi in a workspace with a
|
|
22
|
+
// running daemon on the right socket with no environment at all.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { isAbsolute, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** The canonical socket leaf every role workspace names; `SOCKET_FILE_NAME` in `crates/onlyne-layout/src/lib.rs`. */
|
|
28
|
+
export const SOCKET_RELATIVE_PATH = join(".onlyne", "run", "s");
|
|
29
|
+
|
|
30
|
+
/** Marker beside it naming the path the daemon actually serves. */
|
|
31
|
+
export const SOCKET_MARKER_RELATIVE_PATH = join(".onlyne", "run", "socket");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The socket path to dial for the workspace at `cwd`.
|
|
35
|
+
*
|
|
36
|
+
* @param {Record<string, string | undefined>} env the process environment
|
|
37
|
+
* @param {string} cwd the pi working directory, the role workspace itself
|
|
38
|
+
* @param {{ readFile?: (path: string) => string }} [options]
|
|
39
|
+
* @returns {string} an absolute path: the injected one, the published one, or `run/s`
|
|
40
|
+
*/
|
|
41
|
+
export function resolveSocketPath(env, cwd, options = {}) {
|
|
42
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
43
|
+
const injected = typeof env.ONLYNE_SOCKET === "string" ? env.ONLYNE_SOCKET.trim() : "";
|
|
44
|
+
if (injected) return injected;
|
|
45
|
+
const marker = join(cwd, SOCKET_MARKER_RELATIVE_PATH);
|
|
46
|
+
let published = "";
|
|
47
|
+
try {
|
|
48
|
+
published = readFile(marker).trim();
|
|
49
|
+
} catch {
|
|
50
|
+
published = "";
|
|
51
|
+
}
|
|
52
|
+
if (published && isAbsolute(published)) return published;
|
|
53
|
+
return join(cwd, SOCKET_RELATIVE_PATH);
|
|
54
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Which socket path a pi session dials.
|
|
2
|
+
//
|
|
3
|
+
// The three answers have to stay in one order: what the client injected, what
|
|
4
|
+
// the daemon published in `<run>/socket`, and the canonical `<run>/s`. A
|
|
5
|
+
// workspace deep enough that the canonical spelling passes macOS' 103-byte
|
|
6
|
+
// `sun_path` bound is served from a short path under the temporary directory,
|
|
7
|
+
// and the marker is the only place inside the tree that names it.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, test } from "node:test";
|
|
14
|
+
|
|
15
|
+
import { SOCKET_MARKER_RELATIVE_PATH, SOCKET_RELATIVE_PATH, resolveSocketPath } from "./socket.mjs";
|
|
16
|
+
|
|
17
|
+
const cleanups = [];
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
while (cleanups.length > 0) cleanups.pop()();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** One temp workspace whose `run/` directory exists, optionally with a marker body. */
|
|
23
|
+
function workspace(markerBody) {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
|
|
25
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
+
mkdirSync(join(dir, ".onlyne", "run"), { recursive: true });
|
|
27
|
+
if (markerBody !== undefined) writeFileSync(join(dir, SOCKET_MARKER_RELATIVE_PATH), markerBody);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The short path a daemon serves an over-long workspace from. */
|
|
32
|
+
const SERVED = join(tmpdir(), "onlyne-0123456789abcdef", "s");
|
|
33
|
+
|
|
34
|
+
test("the injected environment variable decides first", () => {
|
|
35
|
+
const dir = workspace(`${SERVED}\n`);
|
|
36
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: SERVED }, dir), SERVED);
|
|
37
|
+
// Any other published answer loses to the environment.
|
|
38
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: join(dir, SOCKET_RELATIVE_PATH) }, dir), join(dir, SOCKET_RELATIVE_PATH));
|
|
39
|
+
// A value wrapped in space names the same socket.
|
|
40
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: ` ${SERVED} ` }, dir), SERVED);
|
|
41
|
+
|
|
42
|
+
// A variable holding nothing answers nothing, so the tree decides.
|
|
43
|
+
const bare = workspace();
|
|
44
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: " " }, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
45
|
+
assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a published marker moves the session onto the served path", () => {
|
|
49
|
+
const dir = workspace(`${SERVED}\n`);
|
|
50
|
+
const resolved = resolveSocketPath({}, dir);
|
|
51
|
+
assert.equal(resolved, SERVED);
|
|
52
|
+
// The moved path lives outside the workspace, which is the whole point of the
|
|
53
|
+
// marker: the tree's own `run/s` would be refused by the kernel here.
|
|
54
|
+
assert.ok(!resolved.startsWith(dir));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a workspace with no marker keeps the canonical spelling", () => {
|
|
58
|
+
const dir = workspace();
|
|
59
|
+
assert.equal(resolveSocketPath({}, dir), join(dir, ".onlyne", "run", "s"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a marker that is empty, relative, or unreadable falls through silently", () => {
|
|
63
|
+
const empty = workspace("");
|
|
64
|
+
assert.equal(resolveSocketPath({}, empty), join(empty, SOCKET_RELATIVE_PATH));
|
|
65
|
+
|
|
66
|
+
const relative = workspace("run/s");
|
|
67
|
+
assert.equal(resolveSocketPath({}, relative), join(relative, SOCKET_RELATIVE_PATH));
|
|
68
|
+
|
|
69
|
+
// A `socket` leaf holding a directory makes the read itself fail.
|
|
70
|
+
const unreadable = workspace();
|
|
71
|
+
rmSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH), { force: true });
|
|
72
|
+
mkdirSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH));
|
|
73
|
+
assert.equal(resolveSocketPath({}, unreadable), join(unreadable, SOCKET_RELATIVE_PATH));
|
|
74
|
+
|
|
75
|
+
// A workspace with no `run/` at all still names one canonical path.
|
|
76
|
+
const bare = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
|
|
77
|
+
cleanups.push(() => rmSync(bare, { recursive: true, force: true }));
|
|
78
|
+
assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
79
|
+
});
|