@sjawhar/opencode-legion-envoy 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "src/server.ts",
6
6
  "exports": {
@@ -302,96 +302,6 @@ describe("heartbeat refreshes all busy sessions (fix 1a)", () => {
302
302
  });
303
303
  });
304
304
 
305
- describe("re-adopts sibling sessions after serve restart (fix 1b)", () => {
306
- it("registers idle same-dir+machine siblings on first activity, ignoring other machines/dirs", async () => {
307
- const originalEnvoyUrl = process.env.ENVOY_URL;
308
- process.env.ENVOY_URL = "http://127.0.0.1:59999";
309
- const cwd = process.cwd();
310
-
311
- const subscribed: string[] = [];
312
- const originalFetch = globalThis.fetch;
313
- globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
314
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
315
- if (url.includes("/v1/interests/subscribe") && init?.body) {
316
- const body = JSON.parse(init.body as string);
317
- subscribed.push(body.session_id);
318
- return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
319
- status: 200,
320
- headers: { "Content-Type": "application/json" },
321
- });
322
- }
323
- if (url.includes("/v1/sessions")) {
324
- return new Response(
325
- JSON.stringify([
326
- {
327
- session_id: "ses_active",
328
- machine_id: "M",
329
- dir: cwd,
330
- port: 13381,
331
- title: "",
332
- topics: [],
333
- updated_at: Date.now(),
334
- },
335
- {
336
- session_id: "ses_idle",
337
- machine_id: "M",
338
- dir: cwd,
339
- port: 9,
340
- title: "Idle",
341
- topics: [],
342
- updated_at: Date.now(),
343
- },
344
- {
345
- session_id: "ses_foreign",
346
- machine_id: "OTHER",
347
- dir: cwd,
348
- port: 7,
349
- title: "",
350
- topics: [],
351
- updated_at: Date.now(),
352
- },
353
- {
354
- session_id: "ses_otherdir",
355
- machine_id: "M",
356
- dir: "/somewhere/else",
357
- port: 8,
358
- title: "",
359
- topics: [],
360
- updated_at: Date.now(),
361
- },
362
- ]),
363
- { status: 200, headers: { "Content-Type": "application/json" } }
364
- );
365
- }
366
- if (url.includes("/session/")) return new Response("not found", { status: 404 });
367
- throw new Error("connection refused");
368
- }) as typeof fetch;
369
-
370
- try {
371
- const pluginModule = await import("../server");
372
- const hooks = await pluginModule.default({
373
- serverUrl: new URL("http://127.0.0.1:13381/"),
374
- } as never);
375
-
376
- await hooks.event({
377
- event: {
378
- type: "session.status",
379
- properties: { sessionID: "ses_active", status: { type: "busy" } },
380
- },
381
- });
382
- await new Promise((r) => setTimeout(r, 100));
383
-
384
- expect(subscribed).toContain("ses_active");
385
- expect(subscribed).toContain("ses_idle");
386
- expect(subscribed).not.toContain("ses_foreign");
387
- expect(subscribed).not.toContain("ses_otherdir");
388
- } finally {
389
- globalThis.fetch = originalFetch;
390
- process.env.ENVOY_URL = originalEnvoyUrl;
391
- }
392
- });
393
- });
394
-
395
305
  describe("prunes deleted sessions from the heartbeat (fix 2)", () => {
396
306
  it("stops re-subscribing a session after session.deleted", async () => {
397
307
  const originalEnvoyUrl = process.env.ENVOY_URL;
@@ -462,88 +372,6 @@ describe("prunes deleted sessions from the heartbeat (fix 2)", () => {
462
372
  });
463
373
  });
464
374
 
465
- describe("re-adoption retries until the registry shows our own session (fix 3)", () => {
466
- it("adopts an idle sibling once /v1/sessions includes self on a later poll", async () => {
467
- const originalEnvoyUrl = process.env.ENVOY_URL;
468
- const originalHb = process.env.ENVOY_HEARTBEAT_MS;
469
- process.env.ENVOY_URL = "http://127.0.0.1:59999";
470
- process.env.ENVOY_HEARTBEAT_MS = "40";
471
- const cwd = process.cwd();
472
-
473
- let sessionsCalls = 0;
474
- const subscribed: string[] = [];
475
- const originalFetch = globalThis.fetch;
476
- globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
477
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
478
- if (url.includes("/v1/interests/subscribe") && init?.body) {
479
- const body = JSON.parse(init.body as string);
480
- subscribed.push(body.session_id);
481
- return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
482
- status: 200,
483
- headers: { "Content-Type": "application/json" },
484
- });
485
- }
486
- if (url.includes("/v1/sessions")) {
487
- sessionsCalls += 1;
488
- // First poll: self not persisted yet. Later polls: self + idle sibling present.
489
- const body =
490
- sessionsCalls <= 1
491
- ? []
492
- : [
493
- {
494
- session_id: "ses_active",
495
- machine_id: "M",
496
- dir: cwd,
497
- port: 13381,
498
- title: "",
499
- topics: [],
500
- updated_at: Date.now(),
501
- },
502
- {
503
- session_id: "ses_idle",
504
- machine_id: "M",
505
- dir: cwd,
506
- port: 9,
507
- title: "Idle",
508
- topics: [],
509
- updated_at: Date.now(),
510
- },
511
- ];
512
- return new Response(JSON.stringify(body), {
513
- status: 200,
514
- headers: { "Content-Type": "application/json" },
515
- });
516
- }
517
- if (url.includes("/session/")) return new Response("not found", { status: 404 });
518
- throw new Error("connection refused");
519
- }) as typeof fetch;
520
-
521
- let dispose: (() => void) | undefined;
522
- try {
523
- const pluginModule = await import("../server");
524
- const hooks = await pluginModule.default({
525
- serverUrl: new URL("http://127.0.0.1:13381/"),
526
- } as never);
527
- dispose = (hooks as { dispose?: () => void }).dispose;
528
- await hooks.event({
529
- event: {
530
- type: "session.status",
531
- properties: { sessionID: "ses_active", status: { type: "busy" } },
532
- },
533
- });
534
- await new Promise((r) => setTimeout(r, 200));
535
-
536
- expect(subscribed).toContain("ses_idle");
537
- } finally {
538
- dispose?.();
539
- globalThis.fetch = originalFetch;
540
- process.env.ENVOY_URL = originalEnvoyUrl;
541
- if (originalHb === undefined) delete process.env.ENVOY_HEARTBEAT_MS;
542
- else process.env.ENVOY_HEARTBEAT_MS = originalHb;
543
- }
544
- });
545
- });
546
-
547
375
  describe("invalid ENVOY_HEARTBEAT_MS falls back to the default (fix 6)", () => {
548
376
  it("does not hammer subscribe when the env value is negative", async () => {
549
377
  const originalEnvoyUrl = process.env.ENVOY_URL;
@@ -655,3 +483,152 @@ describe("tool.execute.after auto-subscribes the caller to dispatch threads (AC#
655
483
  expect(subscribed.length).toBe(0);
656
484
  });
657
485
  });
486
+
487
+ // Several live processes can hold the same session (opencode session state is on
488
+ // shared disk). Envoy arbitrates competing route claims by whether the claiming
489
+ // process is DRIVING the session, so the plugin must report that honestly:
490
+ // sessions that have run in this process are driven; siblings re-adopted after a
491
+ // serve restart are recovery claims that must not displace a live driver.
492
+ describe("claims report whether this process drives the session", () => {
493
+ it("marks sessions that have been busy in this process as driving", async () => {
494
+ const originalEnvoyUrl = process.env.ENVOY_URL;
495
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
496
+
497
+ const claims: { id: string; driving: unknown }[] = [];
498
+ const originalFetch = globalThis.fetch;
499
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
500
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
501
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
502
+ const body = JSON.parse(init.body as string);
503
+ claims.push({ id: body.session_id, driving: body.driving });
504
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
505
+ status: 200,
506
+ headers: { "Content-Type": "application/json" },
507
+ });
508
+ }
509
+ if (url.includes("/v1/sessions")) {
510
+ return new Response(JSON.stringify([]), {
511
+ status: 200,
512
+ headers: { "Content-Type": "application/json" },
513
+ });
514
+ }
515
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
516
+ throw new Error("connection refused");
517
+ }) as typeof fetch;
518
+
519
+ let dispose: (() => void) | undefined;
520
+ try {
521
+ const pluginModule = await import("../server");
522
+ const hooks = await pluginModule.default({
523
+ serverUrl: new URL("http://127.0.0.1:13381/"),
524
+ } as never);
525
+ dispose = (hooks as { dispose?: () => void }).dispose;
526
+
527
+ await hooks.event({
528
+ event: {
529
+ type: "session.status",
530
+ properties: { sessionID: "ses_driven", status: { type: "busy" } },
531
+ },
532
+ });
533
+ await new Promise((r) => setTimeout(r, 30));
534
+
535
+ const own = claims.filter((c) => c.id === "ses_driven");
536
+ expect(own.length).toBeGreaterThan(0);
537
+ expect(own.every((c) => c.driving === true)).toBe(true);
538
+ } finally {
539
+ dispose?.();
540
+ globalThis.fetch = originalFetch;
541
+ process.env.ENVOY_URL = originalEnvoyUrl;
542
+ }
543
+ });
544
+ });
545
+
546
+ // Serve-restart recovery must not hijack sessions that a LIVE process still
547
+ // serves. Because opencode session state is on shared disk and every `oc -s`
548
+ // launch is its own process, a new process in a shared directory re-pointed
549
+ // every sibling session's route at itself (observed: 231 sessions claimed by one
550
+ // process in a single burst, then refreshed every 2 minutes). Envoy then
551
+ // delivers there, and that process starts its own model loop on a session
552
+ // another process owns — two loops, one transcript.
553
+ //
554
+ // A process may therefore claim ONLY sessions it has actually run. Keeping
555
+ // idle-but-owned sessions reachable is the daemon's job (it knows the serve port
556
+ // and the session IDs it dispatched), not something a stranger process may
557
+ // arrange by adopting routes.
558
+ describe("a process claims only sessions it has run", () => {
559
+ const runReadopt = async (siblingPortAlive: boolean) => {
560
+ const originalEnvoyUrl = process.env.ENVOY_URL;
561
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
562
+ const siblingPort = 34751;
563
+
564
+ const subscribed: string[] = [];
565
+ const originalFetch = globalThis.fetch;
566
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
567
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
568
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
569
+ const body = JSON.parse(init.body as string);
570
+ subscribed.push(body.session_id);
571
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
572
+ status: 200,
573
+ headers: { "Content-Type": "application/json" },
574
+ });
575
+ }
576
+ if (url.includes("/v1/sessions")) {
577
+ return new Response(
578
+ JSON.stringify([
579
+ { session_id: "ses_self", machine_id: "m", dir: process.cwd(), port: 42145 },
580
+ {
581
+ session_id: "ses_sibling",
582
+ machine_id: "m",
583
+ dir: process.cwd(),
584
+ port: siblingPort,
585
+ },
586
+ ]),
587
+ { status: 200, headers: { "Content-Type": "application/json" } }
588
+ );
589
+ }
590
+ // Any liveness probe at all means readopt is still trying to adopt.
591
+ if (url.includes(`:${siblingPort}/`)) {
592
+ if (siblingPortAlive) {
593
+ return new Response(JSON.stringify({ healthy: true }), {
594
+ status: 200,
595
+ headers: { "Content-Type": "application/json" },
596
+ });
597
+ }
598
+ throw new Error("connection refused");
599
+ }
600
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
601
+ throw new Error("connection refused");
602
+ }) as typeof fetch;
603
+
604
+ let dispose: (() => void) | undefined;
605
+ try {
606
+ const pluginModule = await import("../server");
607
+ const hooks = await pluginModule.default({
608
+ serverUrl: new URL("http://127.0.0.1:13381/"),
609
+ } as never);
610
+ dispose = (hooks as { dispose?: () => void }).dispose;
611
+ await hooks.event({
612
+ event: {
613
+ type: "session.status",
614
+ properties: { sessionID: "ses_self", status: { type: "busy" } },
615
+ },
616
+ });
617
+ await new Promise((r) => setTimeout(r, 80));
618
+ return subscribed;
619
+ } finally {
620
+ dispose?.();
621
+ globalThis.fetch = originalFetch;
622
+ process.env.ENVOY_URL = originalEnvoyUrl;
623
+ }
624
+ };
625
+
626
+ it("never claims a sibling session, whether or not its serve is alive", async () => {
627
+ for (const siblingServeAlive of [true, false]) {
628
+ const subscribed = await runReadopt(siblingServeAlive);
629
+
630
+ expect(subscribed).toContain("ses_self");
631
+ expect(subscribed).not.toContain("ses_sibling");
632
+ }
633
+ });
634
+ });
package/src/server.ts CHANGED
@@ -29,9 +29,7 @@ export default async (input: { serverUrl: URL }) => {
29
29
  // refreshes the envoy_sessions TTL for ALL of them — a single serve hosts many
30
30
  // sessions, so tracking only the most-recently-active one lets idle siblings
31
31
  // expire out of the registry and become undeliverable.
32
- const trackedSessions = new Map<string, { title: string | null }>();
33
- // Guard so sibling re-adoption (after a serve restart) runs at most once.
34
- let readoptDone = false;
32
+ const trackedSessions = new Map<string, { title: string | null; driving: boolean }>();
35
33
  /** Cached port — resolved asynchronously, null until first successful resolution. */
36
34
  let resolvedPort: number | null = null;
37
35
 
@@ -84,7 +82,16 @@ export default async (input: { serverUrl: URL }) => {
84
82
  // Fire an immediate async attempt (non-blocking)
85
83
  syncPort().catch(() => {});
86
84
 
87
- const subscribeSession = (sessionID: string, title: string | null, port: number) =>
85
+ // driving = this process is the one running the session (it has gone busy
86
+ // here), as opposed to a sibling re-adopted from shared on-disk state after a
87
+ // serve restart. Envoy uses it to keep a live driver's route from being stolen
88
+ // by another process that merely holds the same session.
89
+ const subscribeSession = (
90
+ sessionID: string,
91
+ title: string | null,
92
+ port: number,
93
+ driving: boolean
94
+ ) =>
88
95
  call("/v1/interests/subscribe", {
89
96
  method: "POST",
90
97
  headers: { "Content-Type": "application/json" },
@@ -94,41 +101,20 @@ export default async (input: { serverUrl: URL }) => {
94
101
  topics: [`notifications.agent.${sessionID}`],
95
102
  port,
96
103
  title: title ?? "",
104
+ driving,
97
105
  }),
98
106
  }).catch(() => {});
99
107
 
100
- // After a serve restart, sessions that were live in the previous serve instance
101
- // do NOT re-register on their own (registration is gated on a session going
102
- // busy), so an idle session waiting to RECEIVE a message silently falls out of
103
- // the registry. Recover them once, on first activity: read the live registry and
104
- // re-subscribe siblings that share this serve's machine + dir at the new port.
105
- const readoptSiblings = async (selfSessionID: string) => {
106
- if (readoptDone) return;
107
- const port = currentPort();
108
- if (!port) return;
109
- try {
110
- const res = await call("/v1/sessions");
111
- const sessions = JSON.parse(res) as Array<{
112
- session_id: string;
113
- machine_id: string;
114
- dir: string;
115
- title?: string;
116
- }>;
117
- // Authoritative machine id for this serve = the listener-stamped machine of
118
- // our own active session. Only adopt siblings that match it (and our dir) to
119
- // avoid hijacking a same-path session that lives on another machine.
120
- const self = sessions.find((s) => s.session_id === selfSessionID);
121
- if (!self) return;
122
- readoptDone = true;
123
- for (const s of sessions) {
124
- if (s.machine_id !== self.machine_id) continue;
125
- if (s.dir !== cwd) continue;
126
- if (trackedSessions.has(s.session_id)) continue;
127
- trackedSessions.set(s.session_id, { title: s.title ?? null });
128
- subscribeSession(s.session_id, s.title ?? null, port);
129
- }
130
- } catch {}
131
- };
108
+ // A process registers ONLY the sessions it has actually run (see the busy
109
+ // handler below). It must never claim a route for a session it merely has
110
+ // loaded from shared on-disk state: doing so re-points that session's route
111
+ // here, envoy delivers here, and this process starts a second model loop on a
112
+ // session another process is driving.
113
+ //
114
+ // That means a session whose process is gone stays unreachable until it runs
115
+ // again. Keeping dispatched-but-idle workers reachable is the daemon's job — it
116
+ // knows the serve port and the session IDs it dispatched — not something a
117
+ // stranger process may arrange by adopting routes.
132
118
 
133
119
  // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
134
120
  // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
@@ -142,10 +128,8 @@ export default async (input: { serverUrl: URL }) => {
142
128
  const port = currentPort();
143
129
  if (!port) return;
144
130
  for (const [sessionID, info] of trackedSessions) {
145
- subscribeSession(sessionID, info.title, port);
131
+ subscribeSession(sessionID, info.title, port, info.driving);
146
132
  }
147
- // Retry sibling re-adoption until the registry shows our own session.
148
- if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
149
133
  }, heartbeatMs);
150
134
  heartbeatInterval.unref?.();
151
135
 
@@ -187,7 +171,7 @@ export default async (input: { serverUrl: URL }) => {
187
171
  activeSessionID = sessionID;
188
172
  activeSessionTitle = null;
189
173
  if (!trackedSessions.has(sessionID)) {
190
- trackedSessions.set(sessionID, { title: null });
174
+ trackedSessions.set(sessionID, { title: null, driving: true });
191
175
  }
192
176
  await syncPort();
193
177
  const port = currentPort();
@@ -198,24 +182,19 @@ export default async (input: { serverUrl: URL }) => {
198
182
  return t;
199
183
  });
200
184
  if (port) {
201
- // Await so our own session is persisted in the registry before
202
- // readoptSiblings reads it back (otherwise self may be absent and
203
- // re-adoption would be skipped).
204
- await subscribeSession(sessionID, activeSessionTitle, port);
185
+ await subscribeSession(sessionID, activeSessionTitle, port, true);
205
186
  // After the title arrives, send one follow-up subscribe with it.
206
187
  titlePromise.then((title) => {
207
188
  if (!title) return;
208
189
  // Update tracked metadata even if this session is no longer the
209
190
  // active one (another session may have become busy meanwhile).
210
191
  if (trackedSessions.has(sessionID)) {
211
- trackedSessions.set(sessionID, { title });
212
- subscribeSession(sessionID, title, currentPort() ?? 0);
192
+ const driving = trackedSessions.get(sessionID)?.driving ?? true;
193
+ trackedSessions.set(sessionID, { title, driving });
194
+ subscribeSession(sessionID, title, currentPort() ?? 0, driving);
213
195
  }
214
196
  if (activeSessionID === sessionID) activeSessionTitle = title;
215
197
  });
216
- // Recover idle siblings orphaned by a serve restart (retries from the
217
- // heartbeat until the registry shows our own session).
218
- readoptSiblings(sessionID).catch(() => {});
219
198
  }
220
199
  }
221
200
  }
@@ -263,6 +242,8 @@ export default async (input: { serverUrl: URL }) => {
263
242
  topics: [topic],
264
243
  port: currentPort() ?? 0,
265
244
  title: activeSessionTitle ?? "",
245
+ // A tool call runs in this process, so it is the driving holder.
246
+ driving: true,
266
247
  }),
267
248
  });
268
249
  } catch (err) {
@@ -298,6 +279,7 @@ export default async (input: { serverUrl: URL }) => {
298
279
  topics: args.topics,
299
280
  port: currentPort() ?? 0,
300
281
  title: activeSessionTitle ?? "",
282
+ driving: true,
301
283
  }),
302
284
  });
303
285
  },