@sjawhar/opencode-legion-envoy 0.3.1 → 0.4.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.
- package/package.json +3 -1
- package/src/__tests__/index.test.ts +18 -245
- package/src/server.ts +81 -221
- package/tsconfig.json +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/opencode-legion-envoy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/server.ts",
|
|
6
6
|
"exports": {
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"lint": "bunx biome check src/"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@legion/contracts": "workspace:*",
|
|
26
|
+
"@legion/envoy-client": "workspace:*",
|
|
25
27
|
"@opencode-ai/plugin": "~1.14.46"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
@@ -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
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
|
|
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
|
-
//
|
|
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("
|
|
850
|
-
const
|
|
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
|
-
|
|
853
|
-
|
|
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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { agentSubject } from "@legion/contracts";
|
|
2
|
+
import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
|
|
3
|
+
import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
|
|
4
|
+
import { createEnvoyClient } from "@legion/envoy-client/transport";
|
|
1
5
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
6
|
import { loadEnvoyConfig } from "./config";
|
|
3
7
|
import { buildDispatchMcpEntry, injectEnvoyMcp } from "./dispatch-mcp";
|
|
@@ -5,24 +9,22 @@ import { dispatchSubscriptionTopic } from "./dispatch-subscribe";
|
|
|
5
9
|
import { logger } from "./log";
|
|
6
10
|
import { resolvePort } from "./port";
|
|
7
11
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const text = await res.text();
|
|
19
|
-
if (!res.ok) throw new Error(text || `${res.status}`);
|
|
20
|
-
return text;
|
|
21
|
-
}
|
|
12
|
+
const [
|
|
13
|
+
subscribeSpec,
|
|
14
|
+
unsubscribeSpec,
|
|
15
|
+
listSpec,
|
|
16
|
+
sendSpec,
|
|
17
|
+
publishSpec,
|
|
18
|
+
roleSetSpec,
|
|
19
|
+
whoamiSpec,
|
|
20
|
+
sessionsSpec,
|
|
21
|
+
] = envoyToolSpecs;
|
|
22
22
|
|
|
23
23
|
export default async (input: { serverUrl: URL }) => {
|
|
24
24
|
const cwd = process.cwd();
|
|
25
25
|
const config = await loadEnvoyConfig(cwd);
|
|
26
|
+
const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
|
|
27
|
+
const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
|
|
26
28
|
let activeSessionID: string | null = null;
|
|
27
29
|
let activeSessionTitle: string | null = null;
|
|
28
30
|
// All sessions that have become busy in this serve instance. The heartbeat
|
|
@@ -30,8 +32,6 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
30
32
|
// sessions, so tracking only the most-recently-active one lets idle siblings
|
|
31
33
|
// expire out of the registry and become undeliverable.
|
|
32
34
|
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
35
|
/** Cached port — resolved asynchronously, null until first successful resolution. */
|
|
36
36
|
let resolvedPort: number | null = null;
|
|
37
37
|
|
|
@@ -63,7 +63,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
63
63
|
const fetchTitle = async (sessionID: string): Promise<string | null> => {
|
|
64
64
|
try {
|
|
65
65
|
const res = await fetch(`${input.serverUrl.href}session/${sessionID}`, {
|
|
66
|
-
signal: AbortSignal.timeout(
|
|
66
|
+
signal: AbortSignal.timeout(5_000),
|
|
67
67
|
});
|
|
68
68
|
if (!res.ok) return null;
|
|
69
69
|
const data = (await res.json()) as { title?: string };
|
|
@@ -94,98 +94,38 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
94
94
|
port: number,
|
|
95
95
|
driving: boolean
|
|
96
96
|
) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
dir: cwd,
|
|
103
|
-
topics: [`notifications.agent.${sessionID}`],
|
|
97
|
+
envoy
|
|
98
|
+
.subscribe({
|
|
99
|
+
sessionID,
|
|
100
|
+
directory: cwd,
|
|
101
|
+
topics: [agentSubject(sessionID)],
|
|
104
102
|
port,
|
|
105
103
|
title: title ?? "",
|
|
106
104
|
driving,
|
|
107
|
-
})
|
|
108
|
-
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {});
|
|
109
107
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
|
175
121
|
// just the most recently active one. Interval is env-tunable for tests/tuning.
|
|
176
|
-
const
|
|
177
|
-
const heartbeatMs =
|
|
178
|
-
Number.isFinite(rawHeartbeatMs) && rawHeartbeatMs > 0
|
|
179
|
-
? Math.max(rawHeartbeatMs, 25)
|
|
180
|
-
: 2 * 60 * 1000;
|
|
122
|
+
const heartbeatMs = envoyDefaults.heartbeatMs;
|
|
181
123
|
const heartbeatInterval = setInterval(() => {
|
|
182
124
|
const port = currentPort();
|
|
183
125
|
if (!port) return;
|
|
184
126
|
for (const [sessionID, info] of trackedSessions) {
|
|
185
127
|
subscribeSession(sessionID, info.title, port, info.driving);
|
|
186
128
|
}
|
|
187
|
-
// Retry sibling re-adoption until the registry shows our own session.
|
|
188
|
-
if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
|
|
189
129
|
}, heartbeatMs);
|
|
190
130
|
heartbeatInterval.unref?.();
|
|
191
131
|
|
|
@@ -238,9 +178,6 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
238
178
|
return t;
|
|
239
179
|
});
|
|
240
180
|
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
181
|
await subscribeSession(sessionID, activeSessionTitle, port, true);
|
|
245
182
|
// After the title arrives, send one follow-up subscribe with it.
|
|
246
183
|
titlePromise.then((title) => {
|
|
@@ -254,9 +191,6 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
254
191
|
}
|
|
255
192
|
if (activeSessionID === sessionID) activeSessionTitle = title;
|
|
256
193
|
});
|
|
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
194
|
}
|
|
261
195
|
}
|
|
262
196
|
}
|
|
@@ -276,11 +210,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
276
210
|
activeSessionTitle = null;
|
|
277
211
|
}
|
|
278
212
|
// Best-effort: drop the deleted session's interests so routing stops.
|
|
279
|
-
|
|
280
|
-
method: "POST",
|
|
281
|
-
headers: { "Content-Type": "application/json" },
|
|
282
|
-
body: JSON.stringify({ session_id: deletedID, topics: [] }),
|
|
283
|
-
}).catch(() => {});
|
|
213
|
+
envoy.unsubscribe({ sessionID: deletedID, topics: [] }).catch(() => {});
|
|
284
214
|
}
|
|
285
215
|
}
|
|
286
216
|
},
|
|
@@ -295,18 +225,13 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
295
225
|
const topic = dispatchSubscriptionTopic(input.tool, output.output);
|
|
296
226
|
if (!topic) return;
|
|
297
227
|
try {
|
|
298
|
-
await
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
port: currentPort() ?? 0,
|
|
306
|
-
title: activeSessionTitle ?? "",
|
|
307
|
-
// A tool call runs in this process, so it is the driving holder.
|
|
308
|
-
driving: true,
|
|
309
|
-
}),
|
|
228
|
+
await envoy.subscribe({
|
|
229
|
+
sessionID: input.sessionID,
|
|
230
|
+
directory: cwd,
|
|
231
|
+
topics: [topic],
|
|
232
|
+
port: currentPort() ?? 0,
|
|
233
|
+
title: activeSessionTitle ?? "",
|
|
234
|
+
driving: true,
|
|
310
235
|
});
|
|
311
236
|
} catch (err) {
|
|
312
237
|
logger.warn(
|
|
@@ -321,132 +246,77 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
321
246
|
},
|
|
322
247
|
tool: {
|
|
323
248
|
envoy_subscribe: tool({
|
|
324
|
-
description:
|
|
325
|
-
|
|
326
|
-
args: {
|
|
327
|
-
topics: tool.schema
|
|
328
|
-
.array(tool.schema.string())
|
|
329
|
-
.describe(
|
|
330
|
-
"NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
|
|
331
|
-
),
|
|
332
|
-
},
|
|
249
|
+
description: subscribeSpec.description,
|
|
250
|
+
args: { topics: tool.schema.array(tool.schema.string()) },
|
|
333
251
|
async execute(args, ctx) {
|
|
334
252
|
ctx.metadata({ title: "Envoy subscribe" });
|
|
335
|
-
return
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
session_id: ctx.sessionID,
|
|
340
|
-
dir: ctx.directory,
|
|
253
|
+
return JSON.stringify(
|
|
254
|
+
await envoy.subscribe({
|
|
255
|
+
sessionID: ctx.sessionID,
|
|
256
|
+
directory: ctx.directory,
|
|
341
257
|
topics: args.topics,
|
|
342
258
|
port: currentPort() ?? 0,
|
|
343
259
|
title: activeSessionTitle ?? "",
|
|
344
260
|
driving: true,
|
|
345
|
-
})
|
|
346
|
-
|
|
261
|
+
})
|
|
262
|
+
);
|
|
347
263
|
},
|
|
348
264
|
}),
|
|
349
265
|
envoy_unsubscribe: tool({
|
|
350
|
-
description:
|
|
351
|
-
|
|
352
|
-
args: {
|
|
353
|
-
topics: tool.schema
|
|
354
|
-
.array(tool.schema.string())
|
|
355
|
-
.optional()
|
|
356
|
-
.describe("Topics to remove, or omit to remove all"),
|
|
357
|
-
},
|
|
266
|
+
description: unsubscribeSpec.description,
|
|
267
|
+
args: { topics: tool.schema.array(tool.schema.string()).optional() },
|
|
358
268
|
async execute(args, ctx) {
|
|
359
269
|
ctx.metadata({ title: "Envoy unsubscribe" });
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
headers: { "Content-Type": "application/json" },
|
|
363
|
-
body: JSON.stringify({
|
|
364
|
-
session_id: ctx.sessionID,
|
|
365
|
-
topics: args.topics ?? [],
|
|
366
|
-
}),
|
|
367
|
-
});
|
|
270
|
+
await envoy.unsubscribe({ sessionID: ctx.sessionID, topics: args.topics ?? [] });
|
|
271
|
+
return "ok";
|
|
368
272
|
},
|
|
369
273
|
}),
|
|
370
274
|
envoy_list: tool({
|
|
371
|
-
description:
|
|
372
|
-
"List the current Envoy topic subscriptions for this session so you can confirm the exact topic shapes that are active.",
|
|
275
|
+
description: listSpec.description,
|
|
373
276
|
args: {},
|
|
374
277
|
async execute(_args, ctx) {
|
|
375
278
|
ctx.metadata({ title: "Envoy list" });
|
|
376
|
-
return
|
|
279
|
+
return JSON.stringify(await envoy.getInterest(ctx.sessionID));
|
|
377
280
|
},
|
|
378
281
|
}),
|
|
379
282
|
envoy_send: tool({
|
|
380
|
-
description:
|
|
381
|
-
|
|
382
|
-
args: {
|
|
383
|
-
target_session: tool.schema
|
|
384
|
-
.string()
|
|
385
|
-
.describe("Target OpenCode session ID, e.g. ses_2e6ca3034ffejVikSZ8mDwk0mR"),
|
|
386
|
-
message: tool.schema
|
|
387
|
-
.string()
|
|
388
|
-
.describe("Message body to deliver to that session as a new user turn/notification"),
|
|
389
|
-
},
|
|
283
|
+
description: sendSpec.description,
|
|
284
|
+
args: { target_session: tool.schema.string(), message: tool.schema.string() },
|
|
390
285
|
async execute(args, ctx) {
|
|
391
286
|
ctx.metadata({ title: "Envoy send" });
|
|
392
|
-
return
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
source_session: ctx.sessionID,
|
|
397
|
-
target_session: args.target_session,
|
|
287
|
+
return JSON.stringify(
|
|
288
|
+
await envoy.send({
|
|
289
|
+
sourceSessionID: ctx.sessionID,
|
|
290
|
+
targetSessionID: args.target_session,
|
|
398
291
|
message: args.message,
|
|
399
|
-
})
|
|
400
|
-
|
|
292
|
+
})
|
|
293
|
+
);
|
|
401
294
|
},
|
|
402
295
|
}),
|
|
403
296
|
envoy_publish: tool({
|
|
404
|
-
description:
|
|
405
|
-
|
|
406
|
-
args: {
|
|
407
|
-
topic: tool.schema
|
|
408
|
-
.string()
|
|
409
|
-
.describe("NATS-style topic to publish to, e.g. notifications.role.legion-controller"),
|
|
410
|
-
message: tool.schema.string().describe("Message body to broadcast"),
|
|
411
|
-
},
|
|
297
|
+
description: publishSpec.description,
|
|
298
|
+
args: { topic: tool.schema.string(), message: tool.schema.string() },
|
|
412
299
|
async execute(args, ctx) {
|
|
413
300
|
ctx.metadata({ title: "Envoy publish" });
|
|
414
|
-
return
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
body: JSON.stringify({
|
|
418
|
-
source_session: ctx.sessionID,
|
|
301
|
+
return JSON.stringify(
|
|
302
|
+
await envoy.publish({
|
|
303
|
+
sourceSessionID: ctx.sessionID,
|
|
419
304
|
topic: args.topic,
|
|
420
305
|
message: args.message,
|
|
421
|
-
})
|
|
422
|
-
|
|
306
|
+
})
|
|
307
|
+
);
|
|
423
308
|
},
|
|
424
309
|
}),
|
|
425
310
|
envoy_role_set: tool({
|
|
426
|
-
description:
|
|
427
|
-
|
|
428
|
-
args: {
|
|
429
|
-
role: tool.schema
|
|
430
|
-
.string()
|
|
431
|
-
.describe(
|
|
432
|
-
"Role name to claim (lowercase alphanumeric, hyphens, underscores). E.g. opencode-dev, legion-controller, legion-po"
|
|
433
|
-
),
|
|
434
|
-
},
|
|
311
|
+
description: roleSetSpec.description,
|
|
312
|
+
args: { role: tool.schema.string() },
|
|
435
313
|
async execute(args, ctx) {
|
|
436
314
|
ctx.metadata({ title: "Set Envoy role" });
|
|
437
|
-
return
|
|
438
|
-
method: "POST",
|
|
439
|
-
headers: { "Content-Type": "application/json" },
|
|
440
|
-
body: JSON.stringify({
|
|
441
|
-
session_id: ctx.sessionID,
|
|
442
|
-
role: args.role,
|
|
443
|
-
}),
|
|
444
|
-
});
|
|
315
|
+
return JSON.stringify(await envoy.setRole({ sessionID: ctx.sessionID, role: args.role }));
|
|
445
316
|
},
|
|
446
317
|
}),
|
|
447
318
|
envoy_whoami: tool({
|
|
448
|
-
description:
|
|
449
|
-
"Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
|
|
319
|
+
description: whoamiSpec.description,
|
|
450
320
|
args: {},
|
|
451
321
|
async execute(_args, ctx) {
|
|
452
322
|
ctx.metadata({ title: "Envoy whoami" });
|
|
@@ -465,25 +335,15 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
465
335
|
},
|
|
466
336
|
}),
|
|
467
337
|
envoy_sessions: tool({
|
|
468
|
-
description:
|
|
469
|
-
|
|
470
|
-
args: {
|
|
471
|
-
machine: tool.schema
|
|
472
|
-
.string()
|
|
473
|
-
.optional()
|
|
474
|
-
.describe(
|
|
475
|
-
"Filter to sessions on this machine ID (e.g. hostname). Omit to list all machines."
|
|
476
|
-
),
|
|
477
|
-
},
|
|
338
|
+
description: sessionsSpec.description,
|
|
339
|
+
args: { machine: tool.schema.string().optional() },
|
|
478
340
|
async execute(args, ctx) {
|
|
479
341
|
ctx.metadata({ title: "Envoy sessions" });
|
|
480
|
-
const
|
|
481
|
-
if (!args.machine) return res;
|
|
482
|
-
const sessions = JSON.parse(res) as Array<{
|
|
483
|
-
machine_id: string;
|
|
484
|
-
}>;
|
|
342
|
+
const sessions = await envoy.listSessions();
|
|
485
343
|
return JSON.stringify(
|
|
486
|
-
|
|
344
|
+
args.machine
|
|
345
|
+
? sessions.filter((session) => session.machine_id === args.machine)
|
|
346
|
+
: sessions,
|
|
487
347
|
null,
|
|
488
348
|
2
|
|
489
349
|
);
|
package/tsconfig.json
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
"target": "ES2022",
|
|
4
4
|
"module": "ESNext",
|
|
5
5
|
"moduleResolution": "Bundler",
|
|
6
|
+
"baseUrl": ".",
|
|
7
|
+
"paths": {
|
|
8
|
+
"@legion/contracts": ["../contracts/src/index.ts"],
|
|
9
|
+
"@legion/envoy-client/defaults": ["../envoy-client/src/defaults.ts"],
|
|
10
|
+
"@legion/envoy-client/tool-contract": ["../envoy-client/src/tool-contract.ts"],
|
|
11
|
+
"@legion/envoy-client/transport": ["../envoy-client/src/transport.ts"]
|
|
12
|
+
},
|
|
6
13
|
"strict": true,
|
|
7
14
|
"noEmit": true,
|
|
8
15
|
"skipLibCheck": true,
|