From ec6385fac250c61e954e18a1615b249ac826dcfe Mon Sep 17 00:00:00 2001 From: Flint <263629284+tps-flint@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:11:05 -0700 Subject: [PATCH] =?UTF-8?q?fix(relay):=20route=20branch=E2=86=92branch=20m?= =?UTF-8?q?ail=20to=20the=20recipient=20branch's=20workspace=20inbox=20(op?= =?UTF-8?q?s-16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a sandboxed/branch agent relays mail to another LOCAL branch agent, the outbox relay (processOutboxOnce → resolveTransport → FileSystemTransport) wrote to the flat host mail path (~/.tps/mail//new) regardless of recipient. But a branch agent reads its WORKSPACE inbox (~/.tps/branch-office/.../mail), so branch→branch messages landed somewhere the recipient never looks. FileSystemTransport hardcoded join(hostMailRoot(), to, "new"). deliverToSandbox was already branch-aware (via resolveDeliveryPath), but the transport relay path wasn't. Fix (dependency injection — no circular import, relay.ts owns the default transport): - FileSystemTransport takes an optional resolveRecipientDir hook; deliver() routes to /new when it returns a path, else the host path (unchanged). - relay.ts injects resolveBranchRecipientDir: a local branch/team recipient (has a branch-office presence) → resolveDeliveryPath(recipient) (its workspace inbox); a host agent → null → host path, behavior preserved. Tests: branch→branch and branch→team-member land in the workspace inbox (not the host path); a non-branch (host) recipient still uses the host path (regression guard). Note: the inbox-full check in processOutboxOnce still counts the host path for branch recipients — a separate minor inconsistency, flagged for follow-up. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/utils/relay.ts | 27 ++++++++++++- packages/cli/src/utils/transport.ts | 14 ++++++- packages/cli/test/relay.test.ts | 61 ++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/relay.ts b/packages/cli/src/utils/relay.ts index 0c0b544..abe2c0b 100644 --- a/packages/cli/src/utils/relay.ts +++ b/packages/cli/src/utils/relay.ts @@ -74,6 +74,31 @@ function branchRoot(agentId: string): string { return resolveDeliveryPath(agentId); } +/** True if `recipient` is a local branch agent or team member (has a branch-office presence). */ +function isBranchRecipient(recipient: string): boolean { + const branchDir = join(process.env.HOME || homedir(), ".tps", "branch-office"); + if (!existsSync(branchDir)) return false; + if (existsSync(join(branchDir, recipient))) return true; // registered branch / team root + try { + for (const d of readdirSync(branchDir)) { + const sidecar = join(branchDir, d, "team.json"); + if (!existsSync(sidecar)) continue; + const s = JSON.parse(readFileSync(sidecar, "utf-8")); + if (Array.isArray(s.members) && s.members.includes(recipient)) return true; + } + } catch {} + return false; +} + +/** + * Delivery hook for FileSystemTransport (ops-16): a local branch/team recipient + * reads its workspace inbox, not the flat host mail path, so route relayed mail + * there. Returns null for host agents so they keep the host path unchanged. + */ +function resolveBranchRecipientDir(recipient: string): string | null { + return isBranchRecipient(recipient) ? resolveDeliveryPath(recipient) : null; +} + function assertAgent(agentId: string): void { const safe = sanitizeIdentifier(agentId); if (!agentId || safe !== agentId) { @@ -92,7 +117,7 @@ function atomicWriteJson(targetPath: string, data: unknown): void { } const detectors = new Map(); -const transportRegistry = new TransportRegistry(new FileSystemTransport()); +const transportRegistry = new TransportRegistry(new FileSystemTransport(resolveBranchRecipientDir)); function getDetector(agentId: string): LoopDetector { if (!detectors.has(agentId)) { diff --git a/packages/cli/src/utils/transport.ts b/packages/cli/src/utils/transport.ts index f4e4ae4..8380614 100644 --- a/packages/cli/src/utils/transport.ts +++ b/packages/cli/src/utils/transport.ts @@ -107,13 +107,25 @@ function hostMailRoot(): string { } export class FileSystemTransport implements DeliveryTransport { + /** + * @param resolveRecipientDir Optional hook: given a recipient id, return the mail + * root to deliver into (its `/new` is used), or null to use the flat host + * mail path. relay.ts injects a branch-aware resolver so a message to a local + * branch agent lands in that branch's workspace inbox (where it actually reads), + * not the host path (ops-16). With no hook, host delivery is unchanged. + */ + constructor(private readonly resolveRecipientDir?: (recipient: string) => string | null) {} + name(): string { return "filesystem"; } async deliver(envelope: MailEnvelope): Promise { try { - const recipientNew = join(hostMailRoot(), envelope.to, "new"); + const branchRoot = this.resolveRecipientDir?.(envelope.to) ?? null; + const recipientNew = branchRoot + ? join(branchRoot, "new") + : join(hostMailRoot(), envelope.to, "new"); ensureDir(recipientNew); const payload = { diff --git a/packages/cli/test/relay.test.ts b/packages/cli/test/relay.test.ts index ef6ce30..a536e64 100644 --- a/packages/cli/test/relay.test.ts +++ b/packages/cli/test/relay.test.ts @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { deliverToSandbox, processOutboxOnce } from "../src/utils/relay.js"; @@ -170,6 +170,65 @@ describe("relay utils", () => { expect(msg.body).toBe("hello team member"); }); + test("relays branch→branch mail to the recipient's workspace inbox, not the host path (ops-16)", async () => { + const out = outboxNew("brancha"); + // branchb is a registered branch (has a branch-office dir) → reads its workspace inbox. + const recipBranch = join(root, ".tps", "branch-office", "branchb"); + mkdirSync(recipBranch, { recursive: true }); + writeJson(join(out, "m1.json"), { + id: "msg-bb", from: "brancha", to: "branchb", + body: "hello branch b", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + // Lands in branchb's workspace inbox (branch-office/branchb/mail/new)... + const wsInbox = join(recipBranch, "mail", "new"); + expect(readdirSync(wsInbox).filter((f) => f.endsWith(".json")).length).toBe(1); + // ...not the flat host path (~/.tps/mail/branchb/new). + const hostPath = join(root, ".tps", "mail", "branchb", "new"); + const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0; + expect(hostCount).toBe(0); + }); + + test("relays to a team member's workspace inbox, not the host path (ops-16)", async () => { + const out = outboxNew("brancha"); + const teamDir = join(root, ".tps", "branch-office", "team1"); + const workspaceMail = join(teamDir, "workspace", "mail"); + mkdirSync(teamDir, { recursive: true }); + writeJson(join(teamDir, "team.json"), { + teamId: "team1", members: ["member1"], workspaceMail, createdAt: new Date().toISOString(), + }); + writeJson(join(out, "m1.json"), { + id: "msg-m1", from: "brancha", to: "member1", + body: "hello member", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + expect(readdirSync(join(workspaceMail, "new")).filter((f) => f.endsWith(".json")).length).toBe(1); + const hostPath = join(root, ".tps", "mail", "member1", "new"); + const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0; + expect(hostCount).toBe(0); + }); + + test("relays to a non-branch (host) recipient via the host mail path (ops-16 regression guard)", async () => { + const out = outboxNew("brancha"); + writeJson(join(out, "m1.json"), { + id: "msg-host", from: "brancha", to: "kern", + body: "hello host agent", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + // kern has no branch-office presence → host path, unchanged. + const hostInbox = join(root, ".tps", "mail", "kern", "new"); + expect(readdirSync(hostInbox).filter((f) => f.endsWith(".json")).length).toBe(1); + }); + test("relay pauses duplicate messages (loop detection)", async () => { const agentId = "brancha"; const out = outboxNew(agentId);