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.
@@ -0,0 +1,61 @@
1
+ // The rule that keeps a pi subagent from settling its parent's task, and the
2
+ // sentence a foreign caller reads. The live defect these pin: a `task` subagent
3
+ // runs a sidechain in the same pi process with the same inherited environment,
4
+ // so it saw the parent's `ONLYNE_SESSION_ID` and its `onlyne_complete` settled
5
+ // the parent's task (librarian cfa73386, 2026-08-11).
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+ import {
9
+ foreignSessionRefusal,
10
+ mountsSession,
11
+ ownsSession,
12
+ sessionIdOf,
13
+ } from "./session-scope.mjs";
14
+
15
+ const PARENT = "a379b47d-65b7-49c3-ad4b-b978cdf5a5d3";
16
+ const SIDECHAIN = "4f1cb760-c254-4ea1-9d55-6f2a1c8bb0e1";
17
+
18
+ test("the session id is read off the manager, and an unreadable one reads as unknown", () => {
19
+ assert.equal(sessionIdOf({ getSessionId: () => PARENT }), PARENT);
20
+ assert.equal(sessionIdOf({}), null, "no accessor, no id");
21
+ assert.equal(sessionIdOf(undefined), null);
22
+ assert.equal(sessionIdOf({ getSessionId: () => "" }), null, "an empty id names nothing");
23
+ assert.equal(sessionIdOf({ getSessionId: () => 7 }), null, "a non-string id is not one");
24
+ assert.equal(
25
+ sessionIdOf({
26
+ getSessionId: () => {
27
+ throw new Error("stale context");
28
+ },
29
+ }),
30
+ null,
31
+ "a context that throws is one this plugin cannot read",
32
+ );
33
+ });
34
+
35
+ test("only the session the client named mounts, and an unknown id does not stop the mount", () => {
36
+ assert.equal(mountsSession(PARENT, PARENT), true, "the spawned session is the one");
37
+ assert.equal(mountsSession(PARENT, SIDECHAIN), false, "a subagent sidechain is not");
38
+ assert.equal(
39
+ mountsSession(PARENT, null),
40
+ true,
41
+ "an older pi that cannot say which session this is keeps the env-bound mount",
42
+ );
43
+ });
44
+
45
+ test("a call is the bound session's unless pi names another session", () => {
46
+ assert.equal(ownsSession(PARENT, PARENT), true);
47
+ assert.equal(ownsSession(PARENT, SIDECHAIN), false, "the sidechain must not settle the parent");
48
+ assert.equal(ownsSession(PARENT, null), true, "a caller this pi cannot identify is allowed");
49
+ assert.equal(
50
+ ownsSession(null, SIDECHAIN),
51
+ false,
52
+ "a runner with no bound session has no session whose calls are its own",
53
+ );
54
+ });
55
+
56
+ test("the refusal names both sessions and why the call is refused", () => {
57
+ const sentence = foreignSessionRefusal(PARENT, SIDECHAIN);
58
+ assert.ok(sentence.includes(SIDECHAIN), sentence);
59
+ assert.ok(sentence.includes(PARENT), sentence);
60
+ assert.ok(sentence.includes("subagent"), sentence);
61
+ });
package/src/socket.mjs CHANGED
@@ -210,12 +210,57 @@ export function listClientRegistrations(env, options = {}) {
210
210
  path: join(dir, name),
211
211
  root: canonicalRoot(parsed.root, options),
212
212
  kind: parsed.kind,
213
+ role: typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null,
213
214
  runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
214
215
  });
215
216
  }
216
217
  return found;
217
218
  }
218
219
 
220
+ /**
221
+ * Resolve the socket path for a resident or pre-started pi session.
222
+ *
223
+ * Unlike [`resolveSocketPath`], when `onlyne-client` has not been started yet
224
+ * (so `<runtime_dir>/<digest>.json` does not exist), this falls back to the
225
+ * deterministic `<runtime_dir>/<workspaceDigest(workspaceRoot)>.sock` path so
226
+ * the agent can enter its background reconnect loop and handshake automatically
227
+ * the moment `onlyne-client` binds the socket. When a client registration for
228
+ * `role` appears in `<runtime_dir>`, it prefers that registration's socket.
229
+ *
230
+ * @param {Record<string, string | undefined>} env
231
+ * @param {string} workspaceRoot
232
+ * @param {string} role
233
+ * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string }} [options]
234
+ * @returns {string}
235
+ */
236
+ export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}) {
237
+ const injected = typeof env[SOCKET_ENV] === "string" ? env[SOCKET_ENV].trim() : "";
238
+ if (injected) return injected;
239
+
240
+ const dir = runtimeDir(env, options);
241
+ const here = canonicalRoot(workspaceRoot, options);
242
+ const candidates = listClientRegistrations(env, options);
243
+
244
+ const coveringRole = candidates.filter((entry) => covers(entry.root, here) && (!role || !entry.role || entry.role === role));
245
+ if (coveringRole.length > 0) {
246
+ const nearest = coveringRole.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
247
+ return join(dir, `${workspaceDigest(nearest.root, options)}${SOCKET_SUFFIX}`);
248
+ }
249
+
250
+ if (role) {
251
+ const byRole = candidates.filter((entry) => entry.role === role);
252
+ if (byRole.length === 1) {
253
+ return join(dir, `${workspaceDigest(byRole[0].root, options)}${SOCKET_SUFFIX}`);
254
+ }
255
+ }
256
+
257
+ try {
258
+ return resolveSocketPath(env, workspaceRoot, options);
259
+ } catch {
260
+ return socketPath(workspaceRoot, env, options);
261
+ }
262
+ }
263
+
219
264
  /**
220
265
  * The socket path to dial for the pi working directory `cwd`.
221
266
  *
@@ -17,6 +17,7 @@ import {
17
17
  canonicalRoot,
18
18
  listClientRegistrations,
19
19
  registrationPath,
20
+ resolveResidentSocketPath,
20
21
  resolveSocketPath,
21
22
  runtimeDir,
22
23
  socketPath,
@@ -235,3 +236,29 @@ test("a registration read through the injected filesystem names the same socket"
235
236
  // The env is what names the socket a client injected, and it is still first.
236
237
  assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir, [SOCKET_ENV]: "/tmp/served" }, workspace, options), "/tmp/served");
237
238
  });
239
+
240
+ test("resolveResidentSocketPath falls back to deterministic socket before client starts, then adopts registered role socket", () => {
241
+ const clusterRoot = scratch("pi-onlyne-cluster-");
242
+ const roleWs = join(clusterRoot, "supervisor");
243
+ mkdirSync(roleWs, { recursive: true });
244
+ const emptyDir = runtime([]);
245
+
246
+ // Before onlyne-client starts, no registration exists; returns deterministic socket path.
247
+ assert.equal(
248
+ resolveResidentSocketPath(envFor(emptyDir), roleWs, "supervisor"),
249
+ socketPath(roleWs, envFor(emptyDir)),
250
+ );
251
+
252
+ // Once onlyne-client starts and registers the role in the runtime directory, even if
253
+ // omp was started in the parent clusterRoot, it resolves the registered role's socket.
254
+ const liveDir = runtime([
255
+ {
256
+ name: registrationName(roleWs),
257
+ body: { kind: "client", role: "supervisor", root: canonicalRoot(roleWs), pid: 9001, version: "2.3.0", runtime: "pi" },
258
+ },
259
+ ]);
260
+ assert.equal(
261
+ resolveResidentSocketPath(envFor(liveDir), clusterRoot, "supervisor"),
262
+ socketPath(roleWs, envFor(liveDir)),
263
+ );
264
+ });