From 4c44f34a453a2843f3bc69c797a42bc29486eea2 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Thu, 15 Oct 2026 21:28:54 -0300 Subject: [PATCH 1/2] fix(agents): label live peers that predate discovery publication A session started before discovery publication (#1714/#1720) heartbeats the schema-1 presence header but never writes a discovery sidecar, so orchestrator_list rendered every field as unknown. When the header is recent and the sidecar is absent, show the header label with an explicit legacy marker. Invalid or foreign sidecars and stale heartbeats still fail closed. Closes #2305 --- extensions/gentle-agents.ts | 4 +++- lib/orchestrator-discovery.ts | 6 +++++- lib/orchestrator-presence.ts | 9 +++++++++ tests/orchestrator-discovery.test.ts | 29 ++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 341922d4b..88cc5abe5 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -2060,7 +2060,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = ? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})` : "repository: unknown"; const rows = peers.map(peer => { - const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown"; + const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : peer.legacyLabel !== undefined + ? ` · ${peer.legacyLabel || "unnamed"} · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)` + : " · context: unknown"; const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? ""; const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? ""; const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : ""; diff --git a/lib/orchestrator-discovery.ts b/lib/orchestrator-discovery.ts index 53563d01d..d07a513ad 100644 --- a/lib/orchestrator-discovery.ts +++ b/lib/orchestrator-discovery.ts @@ -1,13 +1,16 @@ import { readCatalog, type CatalogPage } from "./orchestrator-catalog.ts"; import type { PublishedWork } from "./orchestrator-work.ts"; import type { PresenceRecord } from "./agents-session-transport.ts"; -import { activationHash, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts"; +import { activationHash, discoveryAbsent, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts"; export interface OrchestratorCandidate { sessionId: string; reachability: "unknown"; freshness: "unknown" | "recent" | "stale"; label?: string; + /** Display-only label of a live peer whose code predates discovery publication: + * its schema-1 header heartbeats, but it never writes a discovery sidecar. */ + legacyLabel?: string; aliases?: DiscoveryMetadata["aliases"]; workspace?: string; tasks?: DiscoveryMetadata["tasks"]; @@ -36,6 +39,7 @@ export function discoverOrchestrators(profile: string, peers: readonly PresenceR if (matches.length !== 1) return unknown; const header = matches[0]; const metadata = readDiscovery(profile, header); + if (!metadata && header.recent && discoveryAbsent(profile, header)) return { ...unknown, legacyLabel: header.label }; if (metadata?.activation !== activationHash(activations[0])) return unknown; if (!header.recent) return { ...unknown, freshness: "stale" }; const catalog = readCatalog(profile, header, metadata.activation, selection?.recipientSessionId === sessionId ? selection.cursor : undefined); diff --git a/lib/orchestrator-presence.ts b/lib/orchestrator-presence.ts index b5f97e7e8..d49a4a053 100644 --- a/lib/orchestrator-presence.ts +++ b/lib/orchestrator-presence.ts @@ -263,6 +263,15 @@ export function readActivity(profile: string, selection: Header): { activity?: A } catch (error) { return { unavailable: reason(error) }; } } +/** True only when no discovery sidecar exists for this header: the publisher + * predates discovery publication. A present but unreadable sidecar is not absent. */ +export function discoveryAbsent(profile: string, h: Header): boolean { + try { + fs.lstatSync(join(rootFor(profile), filename(h, "discovery"))); + return false; + } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } +} + /** Optional derived sidecar; failures never invalidate the schema-1 header. */ export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | undefined { try { diff --git a/tests/orchestrator-discovery.test.ts b/tests/orchestrator-discovery.test.ts index 0c087112f..26cb2587a 100644 --- a/tests/orchestrator-discovery.test.ts +++ b/tests/orchestrator-discovery.test.ts @@ -57,6 +57,35 @@ test("published headers retain the frozen legacy schema-1 contract after metadat assert.equal(listPresence(profile).entries.length, 1); assert.equal(discoverOrchestrators(profile, [peer])[0].freshness, "unknown"); }); +test("a live peer that predates discovery publication keeps its legacy header label (#2305)", (t) => { + const profile = fixture(t); + // A long-lived peer started before discovery publication shipped heartbeats + // the schema-1 header only and never writes a discovery sidecar. + const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] }); + t.after(() => publisher.dispose()); + const [candidate] = discoverOrchestrators(profile, [peer]); + assert.equal(candidate.freshness, "unknown"); + assert.equal(candidate.legacyLabel, "gentle-shell"); + assert.equal(candidate.workspace, undefined); + assert.equal(candidate.publication, undefined); + // An old heartbeat is not a live legacy peer. + assert.equal(discoverOrchestrators(profile, [peer], Date.now() + 20_000)[0].legacyLabel, undefined); +}); +test("a present but invalid or foreign discovery sidecar never yields a legacy label", (t) => { + const profile = fixture(t); + const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] }); + t.after(() => publisher.dispose()); + publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [] }); + // Metadata bound to another activation fails closed, without a label. + const foreign = discoverOrchestrators(profile, [{ ...peer, endpoint: "/socket/replacement" }])[0]; + assert.equal(foreign.freshness, "unknown"); + assert.equal(foreign.legacyLabel, undefined); + const sidecar = join(profile, "gentle-agents", "presence", `${publisher.target.sessionHash}.${publisher.target.incarnation}.discovery.json`); + writeFileSync(sidecar, "{broken"); + const broken = discoverOrchestrators(profile, [peer])[0]; + assert.equal(broken.freshness, "unknown"); + assert.equal(broken.legacyLabel, undefined); +}); test("joins activation-bound metadata without exporting prompts or thread output", (t) => { const profile = fixture(t); const publisher = PresencePublisher.start({ profile, sessionId: peer.sessionId, label: "Review auth", activity: [] }); From c7bdfb90cbb7de9ea2f65d95bd079cd2507ddf0e Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Thu, 15 Oct 2026 21:38:59 -0300 Subject: [PATCH 2/2] fix(agents): mark legacy peer scope as not published in orchestrator_list A peer that predates discovery publication never publishes its repository scope, so rendering repository and metadata as unknown contradicted the legacy marker on the same row. Render them as not published and legacy, and pin the full row through the registered tool. --- extensions/gentle-agents.ts | 4 +++- tests/gentle-agents.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 88cc5abe5..b2afd3b9a 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -2068,7 +2068,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : ""; const catalog = peer.catalog ? `\n recorded catalog (not Git identity): ${JSON.stringify(peer.catalog)}` : "\n recorded catalog: unknown"; const note = params.recipient_session_id ? `\n owner-curated recorded state (not consent or owner reply; authority none): ${peer.state ? JSON.stringify(peer.state) : "unknown"}` : ""; - return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`; + // A legacy peer never publishes its scope: say so instead of reporting an unknown. + const legacy = peer.legacyLabel !== undefined; + return `- ${peer.sessionId}${context} · ${legacy ? "repository: not published" : repository(peer.scope?.host)} · metadata: ${legacy ? "legacy" : peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`; }); return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } }); } catch { diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 95608d80c..9189da653 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -4728,6 +4728,39 @@ test("registered orchestrator_list joins peer metadata without child launches or } finally { publisher.dispose(); } }); +test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => { + const h = fakePi(); + const runtime = deps(); + const profile = realpathSync(mkdtempSync(join(root, "discovery-legacy-"))); + const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 }; + // A peer running pre-publication code heartbeats its schema-1 header only. + const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] }); + try { + let ready = false; + const registry = { list: async () => [], listActivations: async () => [peer] }; + runtime.deps.agentHome = profile; + runtime.deps.sessionTransport = { + createRegistry: async () => registry, + createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }), + createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }), + }; + gentleAgents(h.pi, {}, runtime.deps); + const { ctx } = fakeContext(); + await h.fire("session_start", ctx); + await eventually(() => ready, "discovery transport ready"); + const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx); + assert.equal(result.content[0].text, "Advertised sessions (reachability is unknown):\n" + + "- peer · gentle-shell · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)" + + " · repository: not published · metadata: legacy\n recorded catalog: unknown"); + // Consultation stays fail-closed: a legacy label is display-only. + const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx); + assert.equal(JSON.parse(consult.content[0].text).status, "unavailable"); + assert.equal(runtime.spawned.length, 0); + assert.equal(h.sent.length, 0); + await h.fire("session_shutdown", ctx); + } finally { publisher.dispose(); } +}); + test("registered reasoning and revocation use live SDK host and published source only", async () => { const h = fakePi(), runtime = deps(); const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));