@sjawhar/opencode-legion-envoy 0.3.1 → 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.1",
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;
@@ -713,72 +541,21 @@ describe("claims report whether this process drives the session", () => {
713
541
  process.env.ENVOY_URL = originalEnvoyUrl;
714
542
  }
715
543
  });
716
-
717
- it("marks siblings re-adopted after a serve restart as not driving", async () => {
718
- const originalEnvoyUrl = process.env.ENVOY_URL;
719
- process.env.ENVOY_URL = "http://127.0.0.1:59999";
720
-
721
- const claims: { id: string; driving: unknown }[] = [];
722
- const originalFetch = globalThis.fetch;
723
- globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
724
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
725
- if (url.includes("/v1/interests/subscribe") && init?.body) {
726
- const body = JSON.parse(init.body as string);
727
- claims.push({ id: body.session_id, driving: body.driving });
728
- return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
729
- status: 200,
730
- headers: { "Content-Type": "application/json" },
731
- });
732
- }
733
- if (url.includes("/v1/sessions")) {
734
- // A sibling session in the same dir, held by some other process.
735
- return new Response(
736
- JSON.stringify([
737
- { session_id: "ses_sibling", machine_id: "", dir: process.cwd(), port: 34751 },
738
- { session_id: "ses_driven", machine_id: "", dir: process.cwd(), port: 42145 },
739
- ]),
740
- { status: 200, headers: { "Content-Type": "application/json" } }
741
- );
742
- }
743
- if (url.includes("/session/")) return new Response("not found", { status: 404 });
744
- throw new Error("connection refused");
745
- }) as typeof fetch;
746
-
747
- let dispose: (() => void) | undefined;
748
- try {
749
- const pluginModule = await import("../server");
750
- const hooks = await pluginModule.default({
751
- serverUrl: new URL("http://127.0.0.1:13381/"),
752
- } as never);
753
- dispose = (hooks as { dispose?: () => void }).dispose;
754
-
755
- await hooks.event({
756
- event: {
757
- type: "session.status",
758
- properties: { sessionID: "ses_driven", status: { type: "busy" } },
759
- },
760
- });
761
- await new Promise((r) => setTimeout(r, 60));
762
-
763
- const sibling = claims.filter((c) => c.id === "ses_sibling");
764
- expect(sibling.length).toBeGreaterThan(0);
765
- expect(sibling.every((c) => c.driving !== true)).toBe(true);
766
- } finally {
767
- dispose?.();
768
- globalThis.fetch = originalFetch;
769
- process.env.ENVOY_URL = originalEnvoyUrl;
770
- }
771
- });
772
544
  });
773
545
 
774
546
  // Serve-restart recovery must not hijack sessions that a LIVE process still
775
547
  // serves. Because opencode session state is on shared disk and every `oc -s`
776
- // launch is its own process, a new process in a shared directory used to
777
- // re-point every sibling session's route at itself (observed: 231 sessions
778
- // claimed by one process in a single burst, then refreshed every 2 minutes).
779
- // Envoy then delivers there, and that process starts its own model loop on a
780
- // session another process owns — two loops, one transcript.
781
- describe("re-adoption only rescues sessions whose serve is gone", () => {
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", () => {
782
559
  const runReadopt = async (siblingPortAlive: boolean) => {
783
560
  const originalEnvoyUrl = process.env.ENVOY_URL;
784
561
  process.env.ENVOY_URL = "http://127.0.0.1:59999";
@@ -810,7 +587,7 @@ describe("re-adoption only rescues sessions whose serve is gone", () => {
810
587
  { status: 200, headers: { "Content-Type": "application/json" } }
811
588
  );
812
589
  }
813
- // Liveness probe against the sibling's registered port.
590
+ // Any liveness probe at all means readopt is still trying to adopt.
814
591
  if (url.includes(`:${siblingPort}/`)) {
815
592
  if (siblingPortAlive) {
816
593
  return new Response(JSON.stringify({ healthy: true }), {
@@ -846,16 +623,12 @@ describe("re-adoption only rescues sessions whose serve is gone", () => {
846
623
  }
847
624
  };
848
625
 
849
- it("does not adopt a sibling whose registered port is still serving", async () => {
850
- const subscribed = await runReadopt(true);
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);
851
629
 
852
- expect(subscribed).toContain("ses_self");
853
- expect(subscribed).not.toContain("ses_sibling");
854
- });
855
-
856
- it("adopts a sibling whose registered port is gone", async () => {
857
- const subscribed = await runReadopt(false);
858
-
859
- expect(subscribed).toContain("ses_sibling");
630
+ expect(subscribed).toContain("ses_self");
631
+ expect(subscribed).not.toContain("ses_sibling");
632
+ }
860
633
  });
861
634
  });
package/src/server.ts CHANGED
@@ -30,8 +30,6 @@ export default async (input: { serverUrl: URL }) => {
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
32
  const trackedSessions = new Map<string, { title: string | null; driving: boolean }>();
33
- // Guard so sibling re-adoption (after a serve restart) runs at most once.
34
- let readoptDone = false;
35
33
  /** Cached port — resolved asynchronously, null until first successful resolution. */
36
34
  let resolvedPort: number | null = null;
37
35
 
@@ -107,68 +105,16 @@ export default async (input: { serverUrl: URL }) => {
107
105
  }),
108
106
  }).catch(() => {});
109
107
 
110
- // Does a process still answer on this port? Used to tell an orphaned session
111
- // (previous serve gone: connection refused) from one a live process is still
112
- // serving. Unknown port => treat as alive, i.e. do not adopt.
113
- const serveAlive = async (port: number | undefined): Promise<boolean> => {
114
- if (!port) return true;
115
- if (port === currentPort()) return false;
116
- try {
117
- const controller = new AbortController();
118
- const timeout = setTimeout(() => controller.abort(), 1000);
119
- try {
120
- await fetch(`http://127.0.0.1:${port}/global/health`, { signal: controller.signal });
121
- return true;
122
- } finally {
123
- clearTimeout(timeout);
124
- }
125
- } catch {
126
- return false;
127
- }
128
- };
129
-
130
- // After a serve restart, sessions that were live in the previous serve instance
131
- // do NOT re-register on their own (registration is gated on a session going
132
- // busy), so an idle session waiting to RECEIVE a message silently falls out of
133
- // the registry. Recover them once, on first activity: read the live registry and
134
- // re-subscribe siblings that share this serve's machine + dir at the new port.
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.
135
113
  //
136
- // Only sessions whose registered serve is GONE may be adopted. Session state
137
- // lives on shared disk and every `oc -s` launch is its own process, so a
138
- // same-dir sibling is usually owned by another LIVE process. Adopting those
139
- // re-points their route here; envoy then delivers here, and this process
140
- // starts its own model loop on a session someone else is driving — two loops
141
- // interleaving one transcript. A refused connection on the registered port is
142
- // the "previous serve is gone" signal this recovery was written for.
143
- const readoptSiblings = async (selfSessionID: string) => {
144
- if (readoptDone) return;
145
- const port = currentPort();
146
- if (!port) return;
147
- try {
148
- const res = await call("/v1/sessions");
149
- const sessions = JSON.parse(res) as Array<{
150
- session_id: string;
151
- machine_id: string;
152
- dir: string;
153
- title?: string;
154
- port?: number;
155
- }>;
156
- // Authoritative machine id for this serve = the listener-stamped machine of
157
- // our own active session. Only adopt siblings that match it (and our dir) to
158
- // avoid hijacking a same-path session that lives on another machine.
159
- const self = sessions.find((s) => s.session_id === selfSessionID);
160
- if (!self) return;
161
- readoptDone = true;
162
- for (const s of sessions) {
163
- if (s.machine_id !== self.machine_id) continue;
164
- if (s.dir !== cwd) continue;
165
- if (trackedSessions.has(s.session_id)) continue;
166
- if (await serveAlive(s.port)) continue;
167
- trackedSessions.set(s.session_id, { title: s.title ?? null, driving: false });
168
- subscribeSession(s.session_id, s.title ?? null, port, false);
169
- }
170
- } catch {}
171
- };
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.
172
118
 
173
119
  // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
174
120
  // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
@@ -184,8 +130,6 @@ export default async (input: { serverUrl: URL }) => {
184
130
  for (const [sessionID, info] of trackedSessions) {
185
131
  subscribeSession(sessionID, info.title, port, info.driving);
186
132
  }
187
- // Retry sibling re-adoption until the registry shows our own session.
188
- if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
189
133
  }, heartbeatMs);
190
134
  heartbeatInterval.unref?.();
191
135
 
@@ -238,9 +182,6 @@ export default async (input: { serverUrl: URL }) => {
238
182
  return t;
239
183
  });
240
184
  if (port) {
241
- // Await so our own session is persisted in the registry before
242
- // readoptSiblings reads it back (otherwise self may be absent and
243
- // re-adoption would be skipped).
244
185
  await subscribeSession(sessionID, activeSessionTitle, port, true);
245
186
  // After the title arrives, send one follow-up subscribe with it.
246
187
  titlePromise.then((title) => {
@@ -254,9 +195,6 @@ export default async (input: { serverUrl: URL }) => {
254
195
  }
255
196
  if (activeSessionID === sessionID) activeSessionTitle = title;
256
197
  });
257
- // Recover idle siblings orphaned by a serve restart (retries from the
258
- // heartbeat until the registry shows our own session).
259
- readoptSiblings(sessionID).catch(() => {});
260
198
  }
261
199
  }
262
200
  }