@sjawhar/opencode-legion-envoy 0.2.2 → 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 +1 -1
- package/src/__tests__/index.test.ts +204 -0
- package/src/server.ts +53 -9
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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.
|
|
212
|
-
|
|
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
|
},
|