@sjawhar/opencode-legion-envoy 0.3.0 → 0.3.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/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.1",
4
4
  "type": "module",
5
5
  "main": "src/server.ts",
6
6
  "exports": {
@@ -655,3 +655,207 @@ describe("tool.execute.after auto-subscribes the caller to dispatch threads (AC#
655
655
  expect(subscribed.length).toBe(0);
656
656
  });
657
657
  });
658
+
659
+ // Several live processes can hold the same session (opencode session state is on
660
+ // shared disk). Envoy arbitrates competing route claims by whether the claiming
661
+ // process is DRIVING the session, so the plugin must report that honestly:
662
+ // sessions that have run in this process are driven; siblings re-adopted after a
663
+ // serve restart are recovery claims that must not displace a live driver.
664
+ describe("claims report whether this process drives the session", () => {
665
+ it("marks sessions that have been busy in this process as driving", async () => {
666
+ const originalEnvoyUrl = process.env.ENVOY_URL;
667
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
668
+
669
+ const claims: { id: string; driving: unknown }[] = [];
670
+ const originalFetch = globalThis.fetch;
671
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
672
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
673
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
674
+ const body = JSON.parse(init.body as string);
675
+ claims.push({ id: body.session_id, driving: body.driving });
676
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
677
+ status: 200,
678
+ headers: { "Content-Type": "application/json" },
679
+ });
680
+ }
681
+ if (url.includes("/v1/sessions")) {
682
+ return new Response(JSON.stringify([]), {
683
+ status: 200,
684
+ headers: { "Content-Type": "application/json" },
685
+ });
686
+ }
687
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
688
+ throw new Error("connection refused");
689
+ }) as typeof fetch;
690
+
691
+ let dispose: (() => void) | undefined;
692
+ try {
693
+ const pluginModule = await import("../server");
694
+ const hooks = await pluginModule.default({
695
+ serverUrl: new URL("http://127.0.0.1:13381/"),
696
+ } as never);
697
+ dispose = (hooks as { dispose?: () => void }).dispose;
698
+
699
+ await hooks.event({
700
+ event: {
701
+ type: "session.status",
702
+ properties: { sessionID: "ses_driven", status: { type: "busy" } },
703
+ },
704
+ });
705
+ await new Promise((r) => setTimeout(r, 30));
706
+
707
+ const own = claims.filter((c) => c.id === "ses_driven");
708
+ expect(own.length).toBeGreaterThan(0);
709
+ expect(own.every((c) => c.driving === true)).toBe(true);
710
+ } finally {
711
+ dispose?.();
712
+ globalThis.fetch = originalFetch;
713
+ process.env.ENVOY_URL = originalEnvoyUrl;
714
+ }
715
+ });
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
+ });
773
+
774
+ // Serve-restart recovery must not hijack sessions that a LIVE process still
775
+ // 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", () => {
782
+ const runReadopt = async (siblingPortAlive: boolean) => {
783
+ const originalEnvoyUrl = process.env.ENVOY_URL;
784
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
785
+ const siblingPort = 34751;
786
+
787
+ const subscribed: string[] = [];
788
+ const originalFetch = globalThis.fetch;
789
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
790
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
791
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
792
+ const body = JSON.parse(init.body as string);
793
+ subscribed.push(body.session_id);
794
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
795
+ status: 200,
796
+ headers: { "Content-Type": "application/json" },
797
+ });
798
+ }
799
+ if (url.includes("/v1/sessions")) {
800
+ return new Response(
801
+ JSON.stringify([
802
+ { session_id: "ses_self", machine_id: "m", dir: process.cwd(), port: 42145 },
803
+ {
804
+ session_id: "ses_sibling",
805
+ machine_id: "m",
806
+ dir: process.cwd(),
807
+ port: siblingPort,
808
+ },
809
+ ]),
810
+ { status: 200, headers: { "Content-Type": "application/json" } }
811
+ );
812
+ }
813
+ // Liveness probe against the sibling's registered port.
814
+ if (url.includes(`:${siblingPort}/`)) {
815
+ if (siblingPortAlive) {
816
+ return new Response(JSON.stringify({ healthy: true }), {
817
+ status: 200,
818
+ headers: { "Content-Type": "application/json" },
819
+ });
820
+ }
821
+ throw new Error("connection refused");
822
+ }
823
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
824
+ throw new Error("connection refused");
825
+ }) as typeof fetch;
826
+
827
+ let dispose: (() => void) | undefined;
828
+ try {
829
+ const pluginModule = await import("../server");
830
+ const hooks = await pluginModule.default({
831
+ serverUrl: new URL("http://127.0.0.1:13381/"),
832
+ } as never);
833
+ dispose = (hooks as { dispose?: () => void }).dispose;
834
+ await hooks.event({
835
+ event: {
836
+ type: "session.status",
837
+ properties: { sessionID: "ses_self", status: { type: "busy" } },
838
+ },
839
+ });
840
+ await new Promise((r) => setTimeout(r, 80));
841
+ return subscribed;
842
+ } finally {
843
+ dispose?.();
844
+ globalThis.fetch = originalFetch;
845
+ process.env.ENVOY_URL = originalEnvoyUrl;
846
+ }
847
+ };
848
+
849
+ it("does not adopt a sibling whose registered port is still serving", async () => {
850
+ const subscribed = await runReadopt(true);
851
+
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");
860
+ });
861
+ });
package/src/server.ts CHANGED
@@ -29,7 +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 }>();
32
+ const trackedSessions = new Map<string, { title: string | null; driving: boolean }>();
33
33
  // Guard so sibling re-adoption (after a serve restart) runs at most once.
34
34
  let readoptDone = false;
35
35
  /** Cached port — resolved asynchronously, null until first successful resolution. */
@@ -84,7 +84,16 @@ export default async (input: { serverUrl: URL }) => {
84
84
  // Fire an immediate async attempt (non-blocking)
85
85
  syncPort().catch(() => {});
86
86
 
87
- const subscribeSession = (sessionID: string, title: string | null, port: number) =>
87
+ // driving = this process is the one running the session (it has gone busy
88
+ // here), as opposed to a sibling re-adopted from shared on-disk state after a
89
+ // serve restart. Envoy uses it to keep a live driver's route from being stolen
90
+ // by another process that merely holds the same session.
91
+ const subscribeSession = (
92
+ sessionID: string,
93
+ title: string | null,
94
+ port: number,
95
+ driving: boolean
96
+ ) =>
88
97
  call("/v1/interests/subscribe", {
89
98
  method: "POST",
90
99
  headers: { "Content-Type": "application/json" },
@@ -94,14 +103,43 @@ export default async (input: { serverUrl: URL }) => {
94
103
  topics: [`notifications.agent.${sessionID}`],
95
104
  port,
96
105
  title: title ?? "",
106
+ driving,
97
107
  }),
98
108
  }).catch(() => {});
99
109
 
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
+
100
130
  // After a serve restart, sessions that were live in the previous serve instance
101
131
  // do NOT re-register on their own (registration is gated on a session going
102
132
  // busy), so an idle session waiting to RECEIVE a message silently falls out of
103
133
  // the registry. Recover them once, on first activity: read the live registry and
104
134
  // re-subscribe siblings that share this serve's machine + dir at the new port.
135
+ //
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.
105
143
  const readoptSiblings = async (selfSessionID: string) => {
106
144
  if (readoptDone) return;
107
145
  const port = currentPort();
@@ -113,6 +151,7 @@ export default async (input: { serverUrl: URL }) => {
113
151
  machine_id: string;
114
152
  dir: string;
115
153
  title?: string;
154
+ port?: number;
116
155
  }>;
117
156
  // Authoritative machine id for this serve = the listener-stamped machine of
118
157
  // our own active session. Only adopt siblings that match it (and our dir) to
@@ -124,8 +163,9 @@ export default async (input: { serverUrl: URL }) => {
124
163
  if (s.machine_id !== self.machine_id) continue;
125
164
  if (s.dir !== cwd) continue;
126
165
  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);
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);
129
169
  }
130
170
  } catch {}
131
171
  };
@@ -142,7 +182,7 @@ export default async (input: { serverUrl: URL }) => {
142
182
  const port = currentPort();
143
183
  if (!port) return;
144
184
  for (const [sessionID, info] of trackedSessions) {
145
- subscribeSession(sessionID, info.title, port);
185
+ subscribeSession(sessionID, info.title, port, info.driving);
146
186
  }
147
187
  // Retry sibling re-adoption until the registry shows our own session.
148
188
  if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
@@ -187,7 +227,7 @@ export default async (input: { serverUrl: URL }) => {
187
227
  activeSessionID = sessionID;
188
228
  activeSessionTitle = null;
189
229
  if (!trackedSessions.has(sessionID)) {
190
- trackedSessions.set(sessionID, { title: null });
230
+ trackedSessions.set(sessionID, { title: null, driving: true });
191
231
  }
192
232
  await syncPort();
193
233
  const port = currentPort();
@@ -201,15 +241,16 @@ export default async (input: { serverUrl: URL }) => {
201
241
  // Await so our own session is persisted in the registry before
202
242
  // readoptSiblings reads it back (otherwise self may be absent and
203
243
  // re-adoption would be skipped).
204
- await subscribeSession(sessionID, activeSessionTitle, port);
244
+ await subscribeSession(sessionID, activeSessionTitle, port, true);
205
245
  // After the title arrives, send one follow-up subscribe with it.
206
246
  titlePromise.then((title) => {
207
247
  if (!title) return;
208
248
  // Update tracked metadata even if this session is no longer the
209
249
  // active one (another session may have become busy meanwhile).
210
250
  if (trackedSessions.has(sessionID)) {
211
- trackedSessions.set(sessionID, { title });
212
- subscribeSession(sessionID, title, currentPort() ?? 0);
251
+ const driving = trackedSessions.get(sessionID)?.driving ?? true;
252
+ trackedSessions.set(sessionID, { title, driving });
253
+ subscribeSession(sessionID, title, currentPort() ?? 0, driving);
213
254
  }
214
255
  if (activeSessionID === sessionID) activeSessionTitle = title;
215
256
  });
@@ -263,6 +304,8 @@ export default async (input: { serverUrl: URL }) => {
263
304
  topics: [topic],
264
305
  port: currentPort() ?? 0,
265
306
  title: activeSessionTitle ?? "",
307
+ // A tool call runs in this process, so it is the driving holder.
308
+ driving: true,
266
309
  }),
267
310
  });
268
311
  } catch (err) {
@@ -298,6 +341,7 @@ export default async (input: { serverUrl: URL }) => {
298
341
  topics: args.topics,
299
342
  port: currentPort() ?? 0,
300
343
  title: activeSessionTitle ?? "",
344
+ driving: true,
301
345
  }),
302
346
  });
303
347
  },