Skip to content

Commit 2b98e58

Browse files
committed
fix: relay deliverToSandbox writes to mail/new not mail/inbox/new
relay.ts:deliverToSandbox() was constructing the delivery path as: <branchRoot>/inbox/new/ but tps mail check reads from: <branchRoot>/new/ (via getInbox().fresh) This meant delivered messages were silently dropped — the inbox check never found them. Fix: derive the delivery directory from branchRoot() directly and append /new/, consistent with resolveDeliveryPath() which already returns the mail root. One-time migration moves any orphaned messages from the old inbox/new/ dir into new/ on next delivery. Also exports resolveAgentMailRoot() so external callers (bootstrap) can derive the same path without reimplementing the lookup logic. Changes: - relay.ts: deliverToSandbox uses branchRoot()+/new; migrate inbox/new - relay.ts: export resolveAgentMailRoot() - bootstrap.ts: healthMail/sendIntroduction use resolveAgentMailRoot - Tests updated to assert the canonical mail/new path
1 parent 0b8120a commit 2b98e58

5 files changed

Lines changed: 67 additions & 26 deletions

File tree

packages/cli/src/commands/bootstrap.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
55
import { sanitizeIdentifier, sanitizeFreeText, sanitizeModelIdentifier } from "../schema/sanitizer.js";
66
import { workspacePath as resolveWorkspacePath, resolveTeamId, branchRoot as workspaceRoot } from "../utils/workspace.js";
77
import { runCommandUnderNono } from "../utils/nono.js";
8-
import { deliverToSandbox } from "../utils/relay.js";
8+
import { deliverToSandbox, resolveAgentMailRoot } from "../utils/relay.js";
99
import { readOpenClawConfig, findOpenClawConfig, type OpenClawConfig } from "../utils/config.js";
1010

1111
export interface BootstrapArgs {
@@ -168,10 +168,12 @@ function healthGateway(): boolean {
168168
return runCommandUnderNono("tps-bootstrap", {}, ["openclaw", "gateway", "status"]) === 0;
169169
}
170170

171-
function healthMail(teamWorkspace: string, teamId: string): boolean {
172-
const inbox = join(teamWorkspace, "mail", "inbox", "new");
173-
mkdirSync(inbox, { recursive: true });
174-
const before = readdirSync(inbox).filter((f) => f.endsWith(".json")).length;
171+
function healthMail(_teamWorkspace: string, teamId: string): boolean {
172+
// Use resolveAgentMailRoot() — same path derivation as deliverToSandbox()
173+
const mailRoot = resolveAgentMailRoot(teamId);
174+
const freshDir = join(mailRoot, "new");
175+
mkdirSync(freshDir, { recursive: true });
176+
const before = readdirSync(freshDir).filter((f) => f.endsWith(".json")).length;
175177

176178
const msg = {
177179
id: randomUUID(),
@@ -187,35 +189,33 @@ function healthMail(teamWorkspace: string, teamId: string): boolean {
187189
return false;
188190
}
189191

190-
const after = readdirSync(inbox).filter((f) => f.endsWith(".json")).length;
192+
const after = readdirSync(freshDir).filter((f) => f.endsWith(".json")).length;
191193

192194
if (after <= before) {
193195
return false;
194196
}
195197

196-
// mark probe as received by attempting to move one file to cur (read path) and parse
198+
// mark probe as received by moving one file to cur and parsing
197199
try {
198-
const files = readdirSync(inbox)
200+
const curDir = join(mailRoot, "cur");
201+
mkdirSync(curDir, { recursive: true });
202+
const files = readdirSync(freshDir)
199203
.filter((f) => f.endsWith(".json"))
200204
.sort()
201205
.reverse();
202-
const probe = join(inbox, files[0]!);
206+
const probe = join(freshDir, files[0]!);
203207
const raw = readFileSync(probe, "utf-8");
204208
const parsed = JSON.parse(raw);
205209
if (!parsed.from?.startsWith("system:")) return false;
206-
mkdirSync(join(teamWorkspace, "mail", "inbox", "cur"), { recursive: true });
207-
renameSync(probe, join(teamWorkspace, "mail", "inbox", "cur", files[0]!));
210+
renameSync(probe, join(curDir, files[0]!));
208211
} catch {
209212
return false;
210213
}
211214

212215
return true;
213216
}
214217

215-
function sendIntroduction(teamId: string, teamWorkspace: string, body: string): void {
216-
const inbox = join(teamWorkspace, "mail", "inbox", "new");
217-
mkdirSync(inbox, { recursive: true });
218-
218+
function sendIntroduction(teamId: string, _teamWorkspace: string, body: string): void {
219219
deliverToSandbox(teamId, {
220220
id: randomUUID(),
221221
from: "system:bootstrap",

packages/cli/src/utils/relay.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface RelayMessage {
2929
error?: string;
3030
}
3131

32+
/** Returns the mail root dir for an agent (branch-office or team workspace). Used by deliverToSandbox and external callers that need to read delivered messages. */
33+
export function resolveAgentMailRoot(agentId: string): string {
34+
return resolveDeliveryPath(agentId);
35+
}
36+
3237
function resolveDeliveryPath(agentId: string): string {
3338
const branchDir = join(process.env.HOME || homedir(), ".tps", "branch-office");
3439
if (!existsSync(branchDir)) return join(branchDir, agentId, "mail");
@@ -605,11 +610,40 @@ export async function connectAndKeepAlive(
605610
};
606611
}
607612

613+
/**
614+
* One-time migration: move any orphaned messages from the old
615+
* `mail/inbox/new/` path into the canonical `mail/new/` path.
616+
* Safe to call on every delivery — exits early if nothing to migrate.
617+
*/
618+
function migrateOrphanedInboxMessages(mailRoot: string): void {
619+
const legacyDir = join(mailRoot, "inbox", "new");
620+
if (!existsSync(legacyDir)) return;
621+
const freshDir = join(mailRoot, "new");
622+
try {
623+
const files = readdirSync(legacyDir).filter((f) => f.endsWith(".json"));
624+
for (const f of files) {
625+
const src = join(legacyDir, f);
626+
const dst = join(freshDir, f);
627+
if (!existsSync(dst)) {
628+
renameSync(src, dst);
629+
}
630+
}
631+
} catch {
632+
// Non-fatal — best-effort migration
633+
}
634+
}
635+
608636
export function deliverToSandbox(agentId: string, message: RelayMessage): void {
609637
assertAgent(agentId);
610-
const root = branchRoot(agentId);
611-
const inboxNew = join(root, "inbox", "new");
612-
ensureDir(inboxNew);
638+
639+
// branchRoot() returns the mail root (e.g. ~/.tps/branch-office/<agent>/mail or team workspace/mail)
640+
// Messages always go to <mailRoot>/new/ — this matches where tps mail check reads from.
641+
const mailRoot = branchRoot(agentId);
642+
const freshDir = join(mailRoot, "new");
643+
ensureDir(freshDir);
644+
645+
// Migrate any orphaned messages from old inbox/new/ path on first delivery
646+
migrateOrphanedInboxMessages(mailRoot);
613647

614648
const payload = {
615649
id: message.id || randomUUID(),
@@ -622,5 +656,5 @@ export function deliverToSandbox(agentId: string, message: RelayMessage): void {
622656
};
623657

624658
const filename = `${timestampPrefix()}-${randomUUID()}.json`;
625-
atomicWriteJson(join(inboxNew, filename), payload);
659+
atomicWriteJson(join(freshDir, filename), payload);
626660
}

packages/cli/test/bootstrap.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ exit 0
103103

104104
expect(existsSync(join(tempRoot, ".tps", "bootstrap-state", agentId, ".bootstrap-complete"))).toBe(true);
105105

106-
const mailDir = join(workspace, "mail", "inbox", "new");
106+
// Canonical path: branch-office/<agent>/mail/new/ (matches getInbox().fresh)
107+
const mailDir = join(workspace, "mail", "new");
107108
const mailFiles = readdirSync(mailDir).filter((f) => f.endsWith(".json"));
108109
expect(mailFiles.length).toBeGreaterThanOrEqual(1);
109110

packages/cli/test/relay-remote.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ describe("handleIncomingMail", () => {
8181
});
8282

8383
test("delivers valid mail to local inbox", async () => {
84-
const inbox = join(tmpDir, ".tps", "branch-office", "local-agent", "mail", "inbox", "new");
84+
// Canonical path: branch-office/<agent>/mail/new/ (matches getInbox().fresh)
85+
const inbox = join(tmpDir, ".tps", "branch-office", "local-agent", "mail", "new");
8586
mkdirSync(inbox, { recursive: true });
8687

8788
const { handleIncomingMail } = await import("../src/utils/relay.js");

packages/cli/test/relay.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,19 @@ describe("relay utils", () => {
104104
expect(curFiles.length).toBe(1);
105105
});
106106

107-
test("deliverToSandbox writes to inbox/new atomically", async () => {
107+
test("deliverToSandbox writes to mail/new (canonical inbox path)", async () => {
108+
// Pre-create the branch-office mail root so getInbox() treats brancha as a branch agent
109+
const branchMail = join(root, ".tps", "branch-office", "brancha", "mail");
110+
mkdirSync(branchMail, { recursive: true });
111+
108112
deliverToSandbox("brancha", {
109113
to: "brancha",
110114
from: "flint",
111115
body: "hi from host",
112116
});
113117

114-
const inbox = join(root, ".tps", "branch-office", "brancha", "mail", "inbox", "new");
118+
// Canonical path: branch-office/<agent>/mail/new/ (matches getInbox().fresh)
119+
const inbox = join(branchMail, "new");
115120
const files = readdirSync(inbox).filter((f) => f.endsWith(".json"));
116121
expect(files.length).toBe(1);
117122
const payload = JSON.parse(readFileSync(join(inbox, files[0]!), "utf-8"));
@@ -146,7 +151,7 @@ describe("relay utils", () => {
146151
};
147152

148153
mkdirSync(teamDir, { recursive: true });
149-
mkdirSync(join(workspaceMail, "inbox", "new"), { recursive: true });
154+
mkdirSync(join(workspaceMail, "new"), { recursive: true });
150155
writeJson(join(teamDir, "team.json"), teamSidecar);
151156

152157
// Test delivery to member
@@ -156,8 +161,8 @@ describe("relay utils", () => {
156161
body: "hello team member",
157162
});
158163

159-
// Verify it landed in team workspace inbox
160-
const inbox = join(workspaceMail, "inbox", "new");
164+
// Canonical path: workspaceMail/new/ (matches getInbox().fresh)
165+
const inbox = join(workspaceMail, "new");
161166
const files = readdirSync(inbox).filter((f) => f.endsWith(".json"));
162167
expect(files.length).toBe(1);
163168
const msg = JSON.parse(readFileSync(join(inbox, files[0]!), "utf-8"));

0 commit comments

Comments
 (0)