diff --git a/docs/gentle-agents-activity.md b/docs/gentle-agents-activity.md index d5991bbc0..b080eb085 100644 --- a/docs/gentle-agents-activity.md +++ b/docs/gentle-agents-activity.md @@ -46,10 +46,37 @@ Presence reads the current canonical Pi name on its existing five-second heartbe including idle `/name` or session-picker renames; declaration refreshes it immediately. An unnamed session retains its workspace-basename display fallback. Session replacement or shutdown disposes the previous publisher; a stale name source stops publication. -Headers and optional metadata sidecar contracts remain unchanged. - -Repository identity and extended work scope remain for a later stacked slice. -Recorded launch directories do not prove repository identity or freedom from shared +Headers remain unchanged; the optional sidecar now also carries `scope`. + +### Recorded repository scope + +Scope reuses `resolveSessionWorktree`: canonical Git root plus a SHA-256 hash of +canonical common-directory identity, with ambient `GIT_*` routing excluded. Sibling +worktrees share a clone hash, not a root; separate clones differ. No remote URL or +credential is read, and neither names nor scope grants authority. Non-Git, missing, +or unsafe paths are unknown, never guessed or shortened (scope paths: 256 bytes). +Literal scope paths containing NBSP, U+2000–200A, U+202F, U+205F or U+3000 are +unknown: the shared spelling resolver maps them to ASCII space and could otherwise +select a different existing repository. Input, resolved-root output and sidecar +readback all reject them. Ordinary spaces and Unicode letters remain supported. + +`scope.host`, child `repository` facts keyed by task ID, and up to eight `registered` +facts carry `source: recorded-workspace/git` and `resolvedAt` (resolution attempt +time, not heartbeat age). Registered roots come from the existing session registry's +durable entries, not another registry. Missing/pruned registrations resolve unknown. +One bounded derived snapshot caches successful and unknown resolutions by actual Pi +cwd, admitted launch cwd/membership, and registered roots. Lifecycle changes and +session replacement invalidate it; stable token updates/heartbeats do not probe Git. + +`omittedTasks`, `omittedRegistered`, and `complete` describe listing bounds. If the +sidecar byte budget cannot fit scope lists, both lists are withheld with exact +omission counts while retaining host context. Full registered-scope continuation +and interactive live-profile verification remain follow-ups; #1701 stays open. +New readers accept legacy sidecars without scope. Old strict optional-sidecar +readers may show unknown discovery context; activity visibility is unchanged. +Malformed scope alone falls back to unknown repository facts without hiding IDs. + +Recorded launch directories do not prove current child cwd or freedom from shared artifacts. A shell `cd` does not change Pi's session cwd. Omission counts mean the child list is incomplete, not an exhaustive writer inventory. This metadata cannot answer arbitrary reasoning questions (#1702). diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index b9f77e24b..2504a70b7 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { recordReviewMutation } from "../lib/review-reminder-receipt.ts"; import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts"; import { publishForeignSessionChange } from "../lib/session-change-capture.ts"; -import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts"; +import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts"; import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts"; import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts"; import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -35,6 +35,7 @@ import { AgentsView } from "../lib/agents-view.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { PresencePublisher, sanitizeDisplayLabel } from "../lib/orchestrator-presence.ts"; import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts"; +import { OrchestratorScopeCache, type RepositoryFact } from "../lib/orchestrator-scope.ts"; import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts"; import { isInteractiveRpcHost } from "../lib/rpc-host.ts"; import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts"; @@ -387,6 +388,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let stopVisualUpdates: (() => void) | undefined; let sessions: ExtensionContext["sessionManager"] | undefined; let presence: PresencePublisher | undefined; + const scopeCache = new OrchestratorScopeCache(deps.resolveWorktree); let rpcActivityPublisher: RpcActivityPublisher | undefined; // Messages already surfaced to the user this session through the RPC // activity publisher's `onError`, so a recurring push failure (the @@ -395,6 +397,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let notifiedRpcActivityErrors: Set | undefined; const overlays = new Set(); const startPresence = (manager: ExtensionContext["sessionManager"]) => { + scopeCache.clear(); const sessionId = manager.getSessionId() ?? ""; const labelSource = () => { if (sessions !== manager || (manager.getSessionId() ?? "") !== sessionId) throw new Error("stale-session"); @@ -413,10 +416,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = presence?.update(tasks.map((task) => ({ task, thread: store.thread(task.id) }))); const transport = activeSessionTransport; if (transport?.sessionManager === sessions && transport.sessionId === activeSessionId() && transport.listener.record) { - presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks }); + // Read the existing durable registry, without roots()'s repeated Git validation. + // These are recorded contexts, never authority for admission or messaging. + const registered = sessions.getEntries().flatMap(entry => { + if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY) return []; + const data = entry.data as { sessionId?: string; root?: string; evidence?: string } | undefined; + return data?.sessionId === activeSessionId() && typeof data.root === "string" && typeof data.evidence === "string" ? [data.root] : []; + }); + presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks, + scope: scopeCache.project(sessions.getCwd(), tasks, registered) }); } } catch { presence?.dispose(); presence = undefined; } }; + const unsubscribeScope = pi.events.on(SESSION_WORKTREE_CHANGED, (event) => { + if ((event as { sessionId?: string } | undefined)?.sessionId !== sessions?.getSessionId()) return; + scopeCache.clear(); + publishActivity(); + }); let worktrees: SessionWorktreeRegistry | undefined; let worktreeManager: ExtensionContext["sessionManager"] | undefined; let worktreeAuthority: (() => boolean) | undefined; @@ -461,6 +477,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pi.on("session_shutdown", () => { clearTaskMetrics(); unsubscribeMetrics(); + unsubscribeScope(); + scopeCache.clear(); }); let stopAllConfirmation: Promise | undefined; @@ -1552,10 +1570,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const activations = await transport.listener.registry.listActivations(transport.sessionId); if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" }); const peers = discoverOrchestrators(agentHome, activations); + const repository = (fact?: RepositoryFact) => fact?.root + ? `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.label || "unnamed"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown"; - const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"}`).join("") ?? ""; - return `- ${peer.sessionId}${context} · metadata: ${peer.freshness}${tasks}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`; + 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)` : ""; + return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${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 d04f53152..86a6759a5 100644 --- a/lib/orchestrator-discovery.ts +++ b/lib/orchestrator-discovery.ts @@ -9,6 +9,7 @@ export interface OrchestratorCandidate { workspace?: string; tasks?: DiscoveryMetadata["tasks"]; omitted?: number; + scope?: DiscoveryMetadata["scope"]; } /** One bounded metadata page, no thread reads or transport probes. The registry @@ -29,6 +30,6 @@ export function discoverOrchestrators(profile: string, peers: readonly PresenceR if (metadata?.activation !== activationHash(activations[0])) return unknown; if (!header.recent) return { ...unknown, freshness: "stale" }; return { ...unknown, freshness: "recent", label: header.label, workspace: metadata.workspace, - tasks: metadata.tasks, omitted: metadata.omitted }; + tasks: metadata.tasks, omitted: metadata.omitted, ...(metadata.scope ? { scope: metadata.scope } : {}) }; }); } diff --git a/lib/orchestrator-presence.ts b/lib/orchestrator-presence.ts index 6611c08f8..452418984 100644 --- a/lib/orchestrator-presence.ts +++ b/lib/orchestrator-presence.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import * as fs from "node:fs"; import { dirname, join, resolve } from "node:path"; import { stripVTControlCharacters } from "node:util"; +import { validRecordedScope, type RecordedScope } from "./orchestrator-scope.ts"; // Same-profile OS-user trust boundary, not an authorization channel. POSIX modes // restrict newly created storage; Windows deployments must supply their own ACLs. @@ -28,6 +29,7 @@ export interface Activity { tasks: { summary: ActivityInput["task"]; thread: { } }[] } export interface Target { sessionHash: string; incarnation: string } export interface DiscoveryMetadata { + scope?: RecordedScope; activation: string; workspace: string; tasks: { id: string; label: string; status: string; workspace: string }[]; @@ -90,7 +92,7 @@ function validTarget(value: Target) { && typeof value.incarnation === "string" && UUID.test(value.incarnation); } function validDiscovery(d: unknown): d is DiscoveryMetadata { - return object(d) && keys(d, ["activation", "workspace", "tasks", "omitted"]) + return object(d) && keys(d, Object.hasOwn(d, "scope") ? ["activation", "workspace", "tasks", "omitted", "scope"] : ["activation", "workspace", "tasks", "omitted"]) && typeof d.activation === "string" && HASH.test(d.activation) && typeof d.workspace === "string" && d.workspace === label(d.workspace) && integer(d.omitted) && Array.isArray(d.tasks) && d.tasks.length <= 8 && d.tasks.every((t: unknown) => object(t) @@ -263,7 +265,8 @@ export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | u if (!object(value) || !keys(value, ["schema", "sessionHash", "incarnation", "generation", "metadata"]) || value.schema !== 1 || value.sessionHash !== h.sessionHash || value.incarnation !== h.incarnation || value.generation !== h.generation || !validDiscovery(value.metadata)) return undefined; - return value.metadata; + const { scope, ...legacy } = value.metadata; + return validRecordedScope(scope) ? { ...legacy, scope } : legacy; } catch { return undefined; } } @@ -308,15 +311,25 @@ export class PresencePublisher { /** Metadata-only projection; never reads task prompts, results, or threads. * Binding to the listener activation prevents reused session IDs from joining. */ updateDiscovery(peer: { sessionId: string; endpoint: string; createdAt: number }, input: { - workspace: string; tasks: readonly { id: string; label: string; status: string; cwd: string }[]; + workspace: string; tasks: readonly { id: string; label: string; status: string; cwd: string }[]; scope?: RecordedScope; }) { if (this.disposed) throw new Error("disposed"); if (sessionHash(peer.sessionId) !== this.target.sessionHash) throw new Error("malformed-discovery"); // A shortened path could denote a different workspace; do not advertise it. const workspace = (path: string) => Array.from(path).length <= 120 && label(path) === path ? path : ""; - const discovery = { activation: activationHash(peer), workspace: workspace(input.workspace), + const discovery: DiscoveryMetadata = { activation: activationHash(peer), workspace: workspace(input.workspace), tasks: input.tasks.slice(0, 8).map(t => ({ id: label(t.id), label: label(t.label), status: t.status, workspace: workspace(t.cwd) })), omitted: Math.max(0, input.tasks.length - 8) }; + if (input.scope && validRecordedScope(input.scope)) { + discovery.scope = structuredClone(input.scope); + // Leave explicit gaps instead of publishing a sidecar readers cannot fit. + if (Buffer.byteLength(JSON.stringify(discovery)) > HEADER_LIMIT - 1024) { + const scope = discovery.scope; + discovery.scope = { ...scope, tasks: [], registered: [], + omittedTasks: scope.tasks.length + scope.omittedTasks, + omittedRegistered: scope.registered.length + scope.omittedRegistered, complete: false }; + } + } if (!validDiscovery(discovery)) throw new Error("malformed-discovery"); if (JSON.stringify(discovery) === JSON.stringify(this.discovery)) return; this.discovery = discovery; diff --git a/lib/orchestrator-scope.ts b/lib/orchestrator-scope.ts new file mode 100644 index 000000000..0cdfc78e8 --- /dev/null +++ b/lib/orchestrator-scope.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts"; + +export interface RepositoryFact { + root: string | null; + cloneHash: string | null; + resolvedAt: number; + source: "recorded-workspace/git"; +} +export interface RecordedScope { + host: RepositoryFact; + tasks: { id: string; repository: RepositoryFact }[]; + registered: RepositoryFact[]; + omittedTasks: number; + omittedRegistered: number; + complete: boolean; +} +// Recorded cwd/root is literal, not a user spelling. The shared resolver maps +// these Unicode separators to ASCII space; reject them before resolving and on +// output/readback rather than aliasing a distinct directory or truncating a root. +const safeRoot = (root: string) => isAbsolute(root) && Buffer.byteLength(root) <= 256 + && !/[\p{Cc}\p{Cf}\u00a0\u2000-\u200a\u202f\u205f\u3000]/u.test(root); +const object = (v: any) => v && typeof v === "object" && !Array.isArray(v); +const keys = (v: object, names: string[]) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k)); +const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0; +export function validRecordedScope(value: any): value is RecordedScope { + const fact = (v: any) => object(v) && keys(v, ["cloneHash", "resolvedAt", "root", "source"]) + && v.source === "recorded-workspace/git" && count(v.resolvedAt) + && ((v.root === null && v.cloneHash === null) || (typeof v.root === "string" && safeRoot(v.root) + && typeof v.cloneHash === "string" && /^[a-f0-9]{64}$/.test(v.cloneHash))); + return object(value) && keys(value, ["complete", "host", "omittedRegistered", "omittedTasks", "registered", "tasks"]) + && fact(value.host) && Array.isArray(value.tasks) && value.tasks.length <= 8 + && value.tasks.every((t: any) => object(t) && keys(t, ["id", "repository"]) + && typeof t.id === "string" && Buffer.byteLength(t.id) <= 512 && fact(t.repository)) + && Array.isArray(value.registered) && value.registered.length <= 8 && value.registered.every(fact) + && count(value.omittedTasks) && count(value.omittedRegistered) + && value.complete === (value.omittedTasks === 0 && value.omittedRegistered === 0); +} + +/** One bounded derivative snapshot, not a registry or authority. The caller supplies + * only admitted, unfinished, non-restored owned children and durable registry roots. + * Membership/cwd changes invalidate even unknown facts; session replacement clears. + * No resolution runs on a stable heartbeat/token update. */ +export class OrchestratorScopeCache { + private key = ""; + private snapshot?: RecordedScope; + private resolver: WorktreeResolver; + private now: () => number; + constructor(resolver = resolveSessionWorktree, now = Date.now) { + this.resolver = resolver; + this.now = now; + } + clear() { + this.key = ""; + this.snapshot = undefined; + } + project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope { + const registered = [...new Set(roots)]; + const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex"); + if (key === this.key && this.snapshot) return this.snapshot; + const resolvedAt = this.now(); + const facts = new Map(); + const fact = (path: string) => { + if (facts.has(path)) return facts.get(path)!; + const unknown: RepositoryFact = { root: null, cloneHash: null, resolvedAt, source: "recorded-workspace/git" }; + let identity; + try { identity = safeRoot(path) ? this.resolver(path, path) : undefined; } catch { /* Unavailable is unknown. */ } + const result = identity && safeRoot(identity.root) ? { ...unknown, root: identity.root, + cloneHash: createHash("sha256").update(identity.commonDir).digest("hex") } : unknown; + facts.set(path, result); + return result; + }; + this.snapshot = { host: fact(cwd), tasks: tasks.slice(0, 8).map(t => ({ id: t.id, repository: fact(t.cwd) })), + registered: registered.slice(0, 8).map(fact), omittedTasks: Math.max(0, tasks.length - 8), + omittedRegistered: Math.max(0, registered.length - 8), complete: tasks.length <= 8 && registered.length <= 8 }; + this.key = key; + return this.snapshot; + } +} diff --git a/odd/tasks/agent-coordination.md b/odd/tasks/agent-coordination.md index 2b24b07fb..5337f1724 100644 --- a/odd/tasks/agent-coordination.md +++ b/odd/tasks/agent-coordination.md @@ -84,9 +84,39 @@ and association tests rather than compressing two work units into the review bud presence, shipped subject instruction, and associated tests/docs; keep the previous sidecar, transport, and routing behaviors. +## Unit 3: authoritative recorded repository scope (complete, `922ee7336559892f8b5f8a4a3188ab723435021b`) + +Previous PR: #1715; current branch: `feat/1701-repository-scope`, based on `20d265aa`. +Reuse canonical Git root/common-directory resolution for the host, launch-owned children and registered worktrees; correlate clone identity without parsing remote URLs. Keep non-Git/unresolvable facts unknown, bound projections and refresh on real lifecycle changes, not shell `cd`. + +- Added optional scope facts with canonical roots, clone hashes, resolution-attempt time/source; no raw common-directory paths. Reuses the existing resolver and durable session registry entries. Existing owned/unfinished/non-restored task gating and exact schema-1 headers remain intact. +- One derived snapshot caches successful/unknown resolutions; host/launch cwd, admission membership, registered membership/events, and session replacement invalidate it. At most 17 unique paths are resolved per snapshot; stable publication/heartbeat does not probe Git. +- Eight child/eight registered facts maximum; roots requiring normalization or exceeding 256 bytes are unknown, never shortened. Byte overflow withholds both scope lists with exact omission counts. This is not complete registered-root continuation or a global writer/isolation inventory. +- RED: `node --experimental-strip-types --test tests/orchestrator-scope.test.ts` first failed missing module, then the temporary unknown-scope implementation produced the meaningful host assertion `null !== `. `node --experimental-strip-types --test --test-name-pattern='registered orchestrator_list' tests/gentle-agents.test.ts` failed because candidate scope/host was absent. +- GREEN: `node --experimental-strip-types --test --test-name-pattern='recorded host|registered orchestrator_list' tests/orchestrator-scope.test.ts tests/gentle-agents.test.ts`: 2 passed. Real owned Git fixtures exercise host/child association, sibling versus separate clones, ambient Git routing, non-Git/unavailable, replacement/cwd/membership changes and bounded unknown caching. These alternates were observed GREEN, not separate RED runs. +- Final: `node --experimental-strip-types --test tests/orchestrator-presence.test.ts tests/orchestrator-discovery.test.ts tests/gentle-agents.test.ts tests/orchestrator-scope.test.ts`: 196 passed, 0 failed. Existing fixture probes emit non-Git/missing-cwd warnings. An earlier run exposed a new bus-subscription leak and two incorrect fixture expectations; fixed and rerun successfully. +- `node scripts/check-types.mjs`: 186 recorded diagnostics, no regressions; 12 file/code pairs improved. `node scripts/build-runtime-modules.mjs --check`: 8 modules match; metrics sources validated. +- Registered-tool harness reads peer host/child scope and legacy unknown fallback; host publication harness checks own root, registered-root event refresh, unchanged resolution age on heartbeat and session replacement. Discovery-only harnesses launch zero children and send zero messages; existing admitted-child harness retains its original launches. No new tools/model calls/messages. +- Optional old readers may reject new sidecars as unknown, but activity remains visible. New readers accept legacy scope-free sidecars; malformed scope alone preserves recognizable metadata and stable ID with unknown repository facts. +- CodeGraph query failed (uninitialized dedicated worktree); scoped reads used, no out-of-scope index/dependency writes. Test teardown removes only owned fixture roots. No interactive live profile or native Windows execution claimed. +- Rollback: this unit's scope helper, optional sidecar scope, extension projection/join, tests and documentation only; retain earlier labels/rename/activity/transport behavior. Parent owns task mirror, commit and delivery; Refs #1701 only. + +### Pre-commit correction: literal Unicode root safety + +- Independent static finding reproduced: two existing Git directories `repo\u00a0name` and `repo name` are distinct, but the shared spelling resolver normalizes the former to the latter. Scope now rejects every normalized separator on input, resolved-root output and sidecar readback; shared resolver semantics are unchanged. This is honest unknown, not faithful resolution of those literal paths. +- RED/GREEN: `node --experimental-strip-types --test tests/orchestrator-scope.test.ts tests/orchestrator-presence.test.ts tests/orchestrator-discovery.test.ts tests/gentle-agents.test.ts`: before the guard, 196 passed / 1 failed with the ASCII twin root and clone hash instead of null; after, 197 passed / 0 failed. Alternates cover all 15 normalized separators, zero resolver probes for rejected inputs, ordinary spaces, Unicode letters, and malformed sidecar fallback retaining the header/recorded workspace. +- Known and unknown facts retain `recorded-workspace/git` source and resolution-attempt time, not heartbeat freshness. Fixtures and private profile storage are confined to the owned temporary root and cleaned in teardown. +- `node scripts/check-types.mjs`: 186 recorded diagnostics, no regressions; 12 file/code pairs improved. `node scripts/build-runtime-modules.mjs --check`: 8 modules match; metrics sources validated. No interactive live-profile or native Windows proof; no new tools/messages/model calls. Parent mirror/review/delivery remain pending; Refs #1701 only. + +### Independent actual SDK verification + +197 focused tests passed after the correction. Two real Pi SDK sessions in an isolated profile, driven by a local deterministic provider, verified aliases, distinct host clone hashes, child scope through one in-memory adapter, heartbeat rename and session replacement. Measured 1,000 stable projections added zero resolver/Git calls. All 15 normalized separators were independently checked; temporary runtimes/fixtures were disposed and removed. No outbound/paid calls, OS child launches, interactive TUI or native Windows proof. + ## Next stacked units / remaining acceptance -#1701 remains open: authoritative recorded repository/worktree context (reuse `resolveSessionWorktree`, exclude ambient Git env, hash common-directory identity, cache by actual host/child launch cwd with lifecycle invalidation, retain unknown non-Git context); registered authoritative roots if bounded; scalable bounded continuation; interactive live readback. Subject declaration and canonical rename refresh are delivered by Unit 2. Finished/restored children remain excluded from active writer metadata. First-page overflow conservatively leaves context unknown; older publishers remain visible but have unknown discovery metadata. +#1701 remains open: recorded repository/worktree context and bounded registered-root facts are implemented in Unit 3; full registered-root coverage beyond eight facts/byte limits and scalable bounded continuation remain separate acceptance work. Isolated actual SDK profile readback passed; interactive TUI was not exercised. Subject declaration and canonical rename refresh are delivered by Unit 2. Finished/restored children remain excluded from active writer metadata. First-page overflow conservatively leaves context unknown; older publishers remain visible but have unknown discovery metadata. #1702 remains separate: metadata alone cannot answer arbitrary cross-session reasoning questions. +Pending product decision before its model-backed lane: choose permitted context (published status/summaries only, bounded recent conversation, or separately authorized read-only declared files). No auxiliary may forge an owner decision. Metadata-only queries and bounded scope continuation are still pending; do not report the complete feature as delivered. + Rollback boundary: remove this unit's optional sidecar/projection/join and restore raw-ID-only `orchestrator_list`, together with its tests/docs; do not touch transport messaging, existing activity threads, dependency artifacts, or unrelated work. diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 2da27408a..bdb64f0d8 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -22,6 +22,7 @@ import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts"; import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts"; import { NativePointerScope } from "../lib/native-pointer-region.ts"; import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts"; +import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts"; import { stripAnsi } from "../lib/terminal-theme.ts"; import { fakeChild, type FakeChild } from "./agents-fake-child.ts"; import { AgentRunner } from "../lib/agents-runner.ts"; @@ -1301,6 +1302,9 @@ test("live-only extension instances discover same-profile peers across cwd bound await run(local, "local"); const peerId = await run(peer, "peer"); await eventually(() => listPresence(profile).entries.some(header => readDiscovery(profile, header)?.tasks.some(task => task.id === peerId)), "admitted runtime-owned tasks publish without subsequent child events"); + const scope = listPresence(profile).entries.map(header => readDiscovery(profile, header)?.scope).find(scope => scope?.tasks.some(task => task.id === peerId)); + assert.equal(scope?.tasks.find(task => task.id === peerId)?.repository.root, peer.ctx.sessionManager.getCwd()); + assert.equal(scope?.host.cloneHash, scope?.tasks.find(task => task.id === peerId)?.repository.cloneHash); await tick(); peer.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "peer streamed text" } }); await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.summary.id === peerId && row.thread.items.some((item) => item.text === "peer streamed text"))), "task deltas, not just status changes, must publish peer activity"); @@ -3873,7 +3877,8 @@ test("registered session identity declares subjects and refreshes canonical idle Object.assign(h.pi, { setSessionName: (value: string) => { name = value; names.push(value); } }); gentleAgents(h.pi, {}, runtime.deps); const { ctx } = fakeContext(); - Object.assign(ctx.sessionManager, { getSessionName: () => name }); + const registrations = [{ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId: "s1", root: cwd, evidence: "fixture" } }]; + Object.assign(ctx.sessionManager, { getSessionName: () => name, getEntries: () => registrations }); await h.fire("session_start", ctx); await eventually(() => ready, "subject transport ready"); const tool = h.tools.get("orchestrator_session_id")!; @@ -3885,12 +3890,18 @@ test("registered session identity declares subjects and refreshes canonical idle const before = listPresence(profile).entries[0]!; assert.equal(before.label, "Fix auth"); assert.equal(readDiscovery(profile, before)?.workspace, cwd); + assert.equal(readDiscovery(profile, before)?.scope?.host.root, cwd); + assert.equal(readDiscovery(profile, before)?.scope?.registered[0]?.root, cwd); name = "Human rename"; heartbeats[0](); // Existing publisher heartbeat, without any task/model activity. const renamed = listPresence(profile).entries[0]!; assert.equal(renamed.label, name); assert.equal(renamed.generation, before.generation); assert.deepEqual(readActivity(profile, renamed).activity?.tasks, []); + assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt); + registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } }); + h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" }); + assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]); await declare("Do not overwrite"); assert.deepEqual(names, ["Fix auth"]); assert.match((await declare()).content[0].text, /Human rename/); @@ -3905,6 +3916,7 @@ test("registered session identity declares subjects and refreshes canonical idle heartbeats[0](); assert.equal(listPresence(profile).entries.length, 1); assert.equal(listPresence(profile).entries[0].label, "Replacement"); + assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.host.root, cwd); const stale = await declare("Stale subject", ctx); assert.match(stale.content[0].text, /not ready/); assert.equal(names.length, 2); @@ -3920,7 +3932,8 @@ test("registered orchestrator_list joins peer metadata without child launches or const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 }; const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Auth review", activity: [] }); try { - publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [{ id: "child", label: "Check auth", status: "waiting", cwd: "/repo-child" }] }); + const scope = new OrchestratorScopeCache(path => ({ root: path, commonDir: "/clone" })).project("/repo", [{ id: "child", cwd: "/repo-child" }], ["/repo"]); + publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [{ id: "child", label: "Check auth", status: "waiting", cwd: "/repo-child" }], scope } as never); let ready = false; const registry = { list: async () => [], listActivations: async () => [peer] }; runtime.deps.agentHome = profile; @@ -3937,6 +3950,13 @@ test("registered orchestrator_list joins peer metadata without child launches or assert.match(result.content[0].text, /peer.*Auth review.*recorded workspace: \/repo/); assert.match(result.content[0].text, /Check auth \[waiting\].*launch workspace: \/repo-child/); assert.match(result.content[0].text, /reachability is unknown/); + assert.equal((result.details.gentleAgents as any).candidates[0].scope.host.root, "/repo"); + assert.equal((result.details.gentleAgents as any).candidates[0].scope.tasks[0].repository.root, "/repo-child"); + assert.match(result.content[0].text, /clone: [a-f0-9]{64}/); + publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [] }); + const legacy = await h.tools.get("orchestrator_list")!.execute("legacy", {}, undefined, undefined, ctx); + assert.match(legacy.content[0].text, /repository: unknown/); + assert.equal((legacy.details.gentleAgents as any).candidates[0].sessionId, "peer"); assert.equal(runtime.spawned.length, 0); assert.equal(h.sent.length, 0); await h.fire("session_shutdown", ctx); diff --git a/tests/orchestrator-discovery.test.ts b/tests/orchestrator-discovery.test.ts index 08cab1d6f..681aeefe2 100644 --- a/tests/orchestrator-discovery.test.ts +++ b/tests/orchestrator-discovery.test.ts @@ -7,6 +7,7 @@ import test, { type TestContext } from "node:test"; import { PresencePublisher, listPresence } from "../lib/orchestrator-presence.ts"; import { ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts"; import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts"; +import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts"; function fixture(t: TestContext) { const profile = realpathSync(mkdtempSync(join(tmpdir(), "discovery-"))); @@ -113,6 +114,43 @@ test("real POSIX registry selects one routing activation; another activation's m assert.equal(discoverOrchestrators(profile, selected)[0].reachability, "unknown"); }); +test("malformed scope alone falls back unknown; heartbeat retains Git resolution age", (t) => { + const profile = fixture(t); + const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Repo", activity: [] }); + t.after(() => publisher.dispose()); + const scope = new OrchestratorScopeCache(path => ({ root: path, commonDir: "/clone" }), () => 1).project("/repo", [], ["/repo"]); + publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], scope }); + publisher.refreshLabel(); + assert.equal(discoverOrchestrators(profile, [peer])[0].scope?.host.resolvedAt, 1); + const name = `${publisher.target.sessionHash}.${publisher.target.incarnation}.discovery.json`; + const path = join(profile, "gentle-agents", "presence", name); + const value = JSON.parse(readFileSync(path, "utf8")); + value.metadata.scope.host.root = "/repo\nother"; + writeFileSync(path, JSON.stringify(value)); + const candidate = discoverOrchestrators(profile, [peer])[0]; + assert.equal(candidate.sessionId, "peer"); + assert.equal(candidate.workspace, "/repo"); + assert.equal(candidate.scope, undefined); +}); + +test("sidecar byte overflow reports exact scope gaps rather than truncating roots", (t) => { + const profile = fixture(t); + const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Repo", activity: [] }); + t.after(() => publisher.dispose()); + const tasks = Array.from({ length: 8 }, (_, i) => ({ id: "😀".repeat(119) + i, cwd: "/" + "r".repeat(250) + i, + label: "😀".repeat(120), status: "running" })); + const scope = new OrchestratorScopeCache(path => ({ root: path, commonDir: "/clone" })).project("/repo", tasks, tasks.map(t => t.cwd)); + publisher.updateDiscovery(peer, { workspace: "/repo", tasks, scope }); + scope.host.root = "/caller-mutated"; + publisher.refreshLabel(); + const result = discoverOrchestrators(profile, [peer])[0].scope!; + assert.equal(result.host.root, "/repo"); + assert.equal(result.complete, false); + assert.equal(result.omittedTasks, 8); + assert.equal(result.omittedRegistered, 8); + assert.deepEqual(result.registered, []); +}); + test("duplicate display names do not become routing identities", (t) => { const profile = fixture(t); const peers = [peer, { ...peer, sessionId: "other", endpoint: "/socket/other" }]; diff --git a/tests/orchestrator-scope.test.ts b/tests/orchestrator-scope.test.ts new file mode 100644 index 000000000..d5f5b2709 --- /dev/null +++ b/tests/orchestrator-scope.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { OrchestratorScopeCache, validRecordedScope } from "../lib/orchestrator-scope.ts"; +import { PresencePublisher, listPresence, readDiscovery } from "../lib/orchestrator-presence.ts"; +import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts"; + +test("recorded host/owned child/registered scope correlates clones, isolates ambient Git and caches lifecycle", (t) => { + const base = realpathSync(mkdtempSync(join(tmpdir(), "orchestrator-scope-"))); + t.after(() => rmSync(base, { recursive: true, force: true })); + const repo = join(base, "repo"), sibling = join(base, "sibling"), other = join(base, "other"), plain = join(base, "plain"); + const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, ...args], { stdio: "pipe", env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))) }); + for (const path of [repo, other, plain]) mkdirSync(path); + for (const path of [repo, other]) { git(path, "init"); git(path, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-m", "fixture"); } + git(repo, "worktree", "add", "-b", "sibling", sibling); + const old = process.env.GIT_DIR; + process.env.GIT_DIR = join(other, ".git"); + t.after(() => { if (old === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = old; }); + let calls = 0, now = 1; + const cache = new OrchestratorScopeCache((path, cwd) => { calls++; return resolveSessionWorktree(path, cwd); }, () => now++); + const tasks = [{ id: "child", cwd: sibling }, { id: "foreign", cwd: other }, { id: "plain", cwd: plain }]; + const first = cache.project(repo, tasks, [repo, sibling]); + assert.equal(first.host.root, repo); + assert.equal(first.host.cloneHash, first.tasks[0].repository.cloneHash); + assert.notEqual(first.host.root, first.tasks[0].repository.root); + assert.notEqual(first.host.cloneHash, first.tasks[1].repository.cloneHash); + assert.equal(first.tasks[2].repository.root, null); + assert.equal(first.host.source, "recorded-workspace/git"); + assert.doesNotMatch(JSON.stringify(first), /commonDir|\.git/); + assert.deepEqual(first.registered.map(r => r.root), [repo, sibling]); + const count = calls; + assert.deepEqual(cache.project(repo, tasks, [repo, sibling]), first); + assert.equal(calls, count, "token/heartbeat-equivalent projection does not probe Git"); + const admitted = cache.project(repo, [...tasks, { id: "new", cwd: repo }], [repo, sibling]); + assert.ok(admitted.host.resolvedAt > first.host.resolvedAt); + const completed = cache.project(repo, [], [repo]); + assert.deepEqual(completed.tasks, []); + assert.equal(completed.registered.length, 1); + assert.equal(cache.project(other, [], []).host.root, other); + cache.clear(); + assert.ok(cache.project(repo, tasks, [repo, sibling]).host.resolvedAt > admitted.host.resolvedAt); + const bounded = cache.project(repo, Array.from({ length: 10 }, (_, i) => ({ id: String(i), cwd: repo })), Array.from({ length: 10 }, (_, i) => join(base, String(i)))); + assert.equal(bounded.tasks.length, 8); + assert.equal(bounded.omittedTasks, 2); + assert.equal(bounded.omittedRegistered, 2); + assert.equal(bounded.complete, false); + assert.equal(cache.project("/" + "x".repeat(600), [], []).host.root, null); + let probes = 0; + const unavailable = new OrchestratorScopeCache(() => { probes++; throw new Error("unavailable"); }); + const many = Array.from({ length: 1000 }, (_, i) => ({ id: String(i), cwd: join(base, String(i)) })); + const unknown = unavailable.project(repo, many, many.map(t => t.cwd)); + assert.equal(probes, 9, "deduplicated bounded resolution, including unknowns"); + assert.equal(unknown.omittedRegistered, 992); + unavailable.project(repo, many, many.map(t => t.cwd)); + assert.equal(probes, 9); + assert.equal(new OrchestratorScopeCache(() => ({ root: "/repo\nother", commonDir: "/clone" })).project(repo, [], []).host.root, null); +}); + +test("literal recorded roots never alias Unicode separators to another real repository", (t) => { + const base = realpathSync(mkdtempSync(join(tmpdir(), "scope-literal-"))); + t.after(() => rmSync(base, { recursive: true, force: true })); + const ascii = join(base, "repo name"), nbsp = join(base, "repo\u00a0name"), letters = join(base, "répertoire"); + for (const path of [ascii, nbsp, letters]) { + mkdirSync(path); + execFileSync("git", ["-C", path, "init"], { stdio: "pipe", env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith("GIT_"))) }); + } + assert.notEqual(realpathSync(nbsp), realpathSync(ascii)); + assert.equal(resolveSessionWorktree(nbsp, nbsp)?.root, ascii, "shared spelling resolver normalizes NBSP; keep its global semantics"); + const cache = new OrchestratorScopeCache(resolveSessionWorktree, () => 7); + const unknown = cache.project(nbsp, [{ id: "child", cwd: nbsp }], [nbsp]); + assert.deepEqual(unknown.host, { root: null, cloneHash: null, resolvedAt: 7, source: "recorded-workspace/git" }); + assert.deepEqual(unknown.tasks[0].repository, unknown.host); + assert.deepEqual(unknown.registered[0], unknown.host); + for (const path of [ascii, letters]) { + const known = cache.project(path, [], []); + assert.equal(known.host.root, path); + assert.match(known.host.cloneHash!, /^[a-f0-9]{64}$/); + assert.equal(known.host.source, "recorded-workspace/git"); + } + const separators = ["\u00a0", ...Array.from({ length: 11 }, (_, i) => String.fromCharCode(0x2000 + i)), "\u202f", "\u205f", "\u3000"]; + for (const separator of separators) { + const path = join(base, `repo${separator}name`); + let probes = 0; + const guarded = new OrchestratorScopeCache(() => { probes++; return { root: ascii, commonDir: "/clone" }; }); + assert.equal(guarded.project(path, [], []).host.cloneHash, null); + assert.equal(probes, 0, "normalizing spellings must not reach the resolver"); + const output = new OrchestratorScopeCache(() => ({ root: path, commonDir: "/clone" })).project(ascii, [], []); + assert.equal(output.host.root, null, "resolver output must pass the same literal-root guard"); + const malformed = structuredClone(cache.project(ascii, [], [])); + malformed.host.root = path; + assert.equal(validRecordedScope(malformed), false); + } + const publisher = PresencePublisher.start({ profile: base, sessionId: "literal", label: "Literal", activity: [] }); + t.after(() => publisher.dispose()); + publisher.updateDiscovery({ sessionId: "literal", endpoint: "/fixture", createdAt: 1 }, { workspace: ascii, tasks: [], scope: cache.project(ascii, [], []) }); + const header = listPresence(base).entries[0]; + const sidecar = join(base, "gentle-agents", "presence", `${header.sessionHash}.${header.incarnation}.discovery.json`); + const value = JSON.parse(readFileSync(sidecar, "utf8")); + value.metadata.scope.host.root = nbsp; + writeFileSync(sidecar, JSON.stringify(value)); + assert.equal(readDiscovery(base, header)?.scope, undefined); + assert.equal(readDiscovery(base, header)?.workspace, ascii); + assert.equal(listPresence(base).entries[0].sessionHash, header.sessionHash); +});