diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 341922d4b..b2afd3b9a 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -2060,13 +2060,17 @@ 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)` : ""; 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/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/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-"))); 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: [] });