Skip to content

Commit 341f37d

Browse files
authored
feat: add mail lifecycle phase 1 commands (#223)
* fix: reuse codex-created commits in autocommit * feat: add mail lifecycle phase 1 commands * test: update mail lifecycle and autocommit expectations
1 parent b1213ce commit 341f37d

6 files changed

Lines changed: 235 additions & 25 deletions

File tree

packages/cli/bin/tps.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -590,11 +590,11 @@ async function main() {
590590
break;
591591
}
592592
case "mail": {
593-
const action = rest[0] as "send" | "check" | "list" | "stats" | "log" | "read" | "watch" | "search" | "relay" | "topic" | "subscribe" | "unsubscribe" | "publish" | undefined;
594-
const validMailActions = ["send", "check", "list", "stats", "log", "read", "watch", "search", "relay", "topic", "subscribe", "unsubscribe", "publish"];
593+
const action = rest[0] as "send" | "check" | "list" | "stats" | "log" | "read" | "watch" | "search" | "relay" | "topic" | "subscribe" | "unsubscribe" | "publish" | "ack" | "nack" | "gc" | undefined;
594+
const validMailActions = ["send", "check", "list", "stats", "log", "read", "watch", "search", "relay", "topic", "subscribe", "unsubscribe", "publish", "ack", "nack", "gc"];
595595
if (cli.flags.help || !action || !validMailActions.includes(action)) {
596596
console.log(
597-
"Usage:\n tps mail send <agent> <message> Send mail to a local or remote agent\n tps mail check [agent] Read new messages (marks as read)\n tps mail watch [agent] Watch inbox for new messages\n tps mail list [agent] List all messages (read + unread)\n tps mail read <agent> <id> Show a specific message by ID (prefix ok)\n tps mail search <query> Search mail history using full-text search\n tps mail log [agent] Show audit log [--since YYYY-MM-DD] [--limit N]\n tps mail relay [start|stop|status] Mail relay daemon\n tps mail topic create <name> Create a topic [--desc \"...\"]\n tps mail topic list List all topics\n tps mail subscribe <topic> Subscribe to a topic [--id <agentId>] [--from-beginning]\n tps mail unsubscribe <topic> Unsubscribe from a topic [--id <agentId>]\n tps mail publish <topic> <message> Publish to a topic [--from <agentId>]"
597+
"Usage:\n tps mail send <agent> <message> Send mail to a local or remote agent\n tps mail check [agent] Read available messages (leases processing)\n tps mail ack <id> [agent] Mark a message as done\n tps mail nack <id> --reason <txt> Mark a message as failed\n tps mail gc [--agent <id>] Garbage collect done/expired mail\n tps mail watch [agent] Watch inbox for new messages\n tps mail list [agent] List all messages (read + unread)\n tps mail read <agent> <id> Show a specific message by ID (prefix ok)\n tps mail search <query> Search mail history using full-text search\n tps mail log [agent] Show audit log [--since YYYY-MM-DD] [--limit N]\n tps mail relay [start|stop|status] Mail relay daemon\n tps mail topic create <name> Create a topic [--desc \"...\"]\n tps mail topic list List all topics\n tps mail subscribe <topic> Subscribe to a topic [--id <agentId>] [--from-beginning]\n tps mail unsubscribe <topic> Unsubscribe from a topic [--id <agentId>]\n tps mail publish <topic> <message> Publish to a topic [--from <agentId>]"
598598
);
599599
process.exit(cli.flags.help ? 0 : 1);
600600
}
@@ -640,13 +640,19 @@ async function main() {
640640
} else {
641641
await runMail({
642642
action,
643-
agent: rest[1],
644-
message: action === "relay" ? rest[2] : (action === "send" ? rest.slice(2).join(" ") : undefined),
643+
agent: action === "ack" || action === "nack" || action === "gc" ? getFlag("agent") : rest[1],
644+
message: action === "relay" ? rest[2] : (action === "send" ? rest.slice(2).join(" ") : (action === "ack" || action === "nack" ? rest[1] : undefined)),
645645
messageId: action === "read" ? rest[2] : undefined,
646646
json: cli.flags.json,
647647
count: cli.flags.count,
648648
since: cli.flags.since,
649649
limit: cli.flags.limit ? Number(cli.flags.limit) : undefined,
650+
reason: getFlag("reason"),
651+
type: getFlag("type") as any,
652+
retryAfter: getFlag("retry-after"),
653+
maxAge: getFlag("max-age"),
654+
pr: getFlag("pr") ? Number(getFlag("pr")) : undefined,
655+
status: getFlag("status") as any,
650656
});
651657
}
652658
break;

packages/cli/src/commands/mail.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { assertValidBody, checkMessages, getInbox, listMessages, sendMessage, type MailMessage } from "../utils/mail.js";
1+
import { ackMessage, assertValidBody, checkMessages, gcMessages, getInbox, listMessages, nackMessage, sendMessage, type MailMessage } from "../utils/mail.js";
22
import { deliverToSandbox, deliverToRemoteBranch } from "../utils/relay.js";
33
import { sanitizeIdentifier } from "../schema/sanitizer.js";
44
import { queryArchive } from "../utils/archive.js";
@@ -9,7 +9,7 @@ import { loadHostIdentityId } from "../utils/identity.js";
99
import { queueOutboxMessage } from "../utils/outbox.js";
1010

1111
interface MailArgs {
12-
action: "send" | "check" | "list" | "stats" | "log" | "read" | "watch" | "search" | "relay" | "topic" | "subscribe" | "unsubscribe" | "publish";
12+
action: "send" | "check" | "list" | "stats" | "log" | "read" | "watch" | "search" | "relay" | "topic" | "subscribe" | "unsubscribe" | "publish" | "ack" | "nack" | "gc";
1313
agent?: string;
1414
message?: string;
1515
messageId?: string;
@@ -21,6 +21,12 @@ interface MailArgs {
2121
from?: string;
2222
fromBeginning?: boolean;
2323
topicAction?: string;
24+
reason?: string;
25+
type?: "transient" | "agent" | "permanent";
26+
retryAfter?: string;
27+
maxAge?: string;
28+
pr?: number;
29+
status?: "new" | "processing" | "done" | "failed" | "all";
2430
}
2531

2632
async function resolveAgentId(override?: string): Promise<string> {
@@ -164,8 +170,17 @@ export async function runMail(args: MailArgs): Promise<void> {
164170

165171
case "list": {
166172
const agent = await resolveAgentId(args.agent);
167-
const messages = listMessages(agent)
173+
let messages = listMessages(agent)
168174
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
175+
if (args.status && args.status !== "all") {
176+
messages = messages.filter((m) => {
177+
if (args.status === "new") return !m.read && !m.checkedOutAt && !m.nackedAt;
178+
if (args.status === "processing") return !m.read && !!m.checkedOutAt && !m.nackedAt;
179+
if (args.status === "done") return !!m.ackedAt;
180+
if (args.status === "failed") return !!m.nackedAt;
181+
return true;
182+
});
183+
}
169184
if (args.count) {
170185
console.log(messages.length);
171186
} else if (args.json) {
@@ -359,6 +374,44 @@ export async function runMail(args: MailArgs): Promise<void> {
359374
break;
360375
}
361376

377+
case "ack": {
378+
const agent = await resolveAgentId(args.agent);
379+
const id = args.messageId ?? args.message;
380+
if (!id) {
381+
console.error("Usage: tps mail ack <id> [agent]");
382+
process.exit(1);
383+
}
384+
const msg = ackMessage(agent, id);
385+
if (!msg) {
386+
console.warn(`Message already gone or not found: ${id}`);
387+
return;
388+
}
389+
console.log(`Acked ${msg.id}`);
390+
return;
391+
}
392+
393+
case "nack": {
394+
const agent = await resolveAgentId(args.agent);
395+
const id = args.messageId ?? args.message;
396+
if (!id || !args.reason) {
397+
console.error("Usage: tps mail nack <id> --reason <text> [--type transient|agent|permanent] [--retry-after <duration>]");
398+
process.exit(1);
399+
}
400+
const msg = nackMessage(agent, id, args.reason, args.type ?? "transient", args.retryAfter);
401+
if (!msg) {
402+
console.warn(`Message already gone or not found: ${id}`);
403+
return;
404+
}
405+
console.log(`Nacked ${msg.id} (${msg.nackType})`);
406+
return;
407+
}
408+
409+
case "gc": {
410+
const removed = gcMessages(args.agent, args.maxAge, args.pr);
411+
console.log(`GC removed ${removed} message(s)`);
412+
return;
413+
}
414+
362415
case "search": {
363416
if (!args.agent) {
364417
console.error("Usage: tps mail search <query>");

packages/cli/src/utils/codex-runtime.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,9 +530,9 @@ export async function runAutoCommit(
530530
if (!hasWorkingTreeChanges && hasExistingCommits) {
531531
console.log(`[autoCommit] reusing existing commits on ${branchName}`);
532532
if (push) {
533-
const checkoutResult = spawnSync(GIT_BIN, ["checkout", "-b", branchName], { cwd: repo, encoding: "utf-8" });
534-
if (checkoutResult.status !== 0) {
535-
spawnSync(GIT_BIN, ["checkout", branchName], { cwd: repo, encoding: "utf-8" });
533+
const checkoutResult = runSync(GIT_BIN, ["checkout", "-b", branchName], { cwd: repo, encoding: "utf-8" });
534+
if ((checkoutResult.status ?? 1) !== 0) {
535+
runSync(GIT_BIN, ["checkout", branchName], { cwd: repo, encoding: "utf-8" });
536536
}
537537
const pushResult = runSync(GIT_BIN, ["push", "-u", "origin", branchName], { cwd: repo, encoding: "utf-8" });
538538
const pushStdout = typeof pushResult.stdout === "string" ? pushResult.stdout.trim() : "";

packages/cli/src/utils/mail.ts

Lines changed: 154 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { homedir } from "node:os";
44
import { randomUUID } from "node:crypto";
@@ -12,11 +12,26 @@ export interface MailMessage {
1212
body: string;
1313
timestamp: string;
1414
read: boolean;
15+
ackedAt?: string;
16+
nackedAt?: string;
17+
nackReason?: string;
18+
nackType?: "transient" | "agent" | "permanent";
19+
checkedOutAt?: string;
20+
checkedOutBy?: string;
21+
deliveryAttempts?: number;
22+
retryAfter?: string;
23+
prNumber?: number;
1524
headers?: Record<string, string>;
1625
}
1726

1827
const MAX_BODY_BYTES = 64 * 1024;
1928
export const MAX_INBOX_MESSAGES = 100;
29+
const LEASE_TIMEOUT_MS = 30 * 60 * 1000;
30+
31+
const VALID_ID = /^[a-zA-Z0-9._-]+$/;
32+
export function validateMessageId(id: string): void {
33+
if (!VALID_ID.test(id)) throw new Error(`Invalid message ID: ${id}`);
34+
}
2035

2136
function assertValidAgentId(agent: string): void {
2237
const safe = sanitizeIdentifier(agent);
@@ -41,16 +56,18 @@ export function getMailDir(): string {
4156
return dir;
4257
}
4358

44-
export function getInbox(agent: string): { root: string; tmp: string; fresh: string; cur: string } {
59+
export function getInbox(agent: string): { root: string; tmp: string; fresh: string; cur: string; dlq: string } {
4560
assertValidAgentId(agent);
4661
const root = join(getMailDir(), agent);
4762
const tmp = join(root, "tmp");
4863
const fresh = join(root, "new");
4964
const cur = join(root, "cur");
65+
const dlq = join(root, "dlq");
5066
mkdirSync(tmp, { recursive: true });
5167
mkdirSync(fresh, { recursive: true });
5268
mkdirSync(cur, { recursive: true });
53-
return { root, tmp, fresh, cur };
69+
mkdirSync(dlq, { recursive: true });
70+
return { root, tmp, fresh, cur, dlq };
5471
}
5572

5673
function readMessagesFromDir(dir: string, read: boolean): MailMessage[] {
@@ -66,6 +83,46 @@ function readMessagesFromDir(dir: string, read: boolean): MailMessage[] {
6683
.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
6784
}
6885

86+
function listMessageFiles(dir: string): string[] {
87+
if (!existsSync(dir)) return [];
88+
return readdirSync(dir).filter((f) => f.endsWith(".json"));
89+
}
90+
91+
function readMessageFile(path: string): MailMessage {
92+
return JSON.parse(readFileSync(path, "utf-8")) as MailMessage;
93+
}
94+
95+
function writeMessageFile(path: string, msg: MailMessage): void {
96+
writeFileSync(path, JSON.stringify(msg, null, 2), "utf-8");
97+
}
98+
99+
function isLeaseExpired(msg: MailMessage, now = Date.now()): boolean {
100+
if (!msg.checkedOutAt) return true;
101+
return (now - Date.parse(msg.checkedOutAt)) > LEASE_TIMEOUT_MS;
102+
}
103+
104+
function parseDurationMs(raw?: string, fallbackMs = 24 * 60 * 60 * 1000): number {
105+
if (!raw) return fallbackMs;
106+
const m = raw.match(/^(\d+)(ms|s|m|h|d)$/);
107+
if (!m) return fallbackMs;
108+
const n = Number(m[1]);
109+
const unit = m[2];
110+
return n * (unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000);
111+
}
112+
113+
function messagePathById(agent: string, id: string): string | null {
114+
validateMessageId(id);
115+
const inbox = getInbox(agent);
116+
for (const dir of [inbox.fresh, inbox.cur, inbox.dlq]) {
117+
for (const file of listMessageFiles(dir)) {
118+
const full = join(dir, file);
119+
const msg = readMessageFile(full);
120+
if (msg.id === id || msg.id.startsWith(id)) return full;
121+
}
122+
}
123+
return null;
124+
}
125+
69126
export function countInboxMessages(agent: string): number {
70127
const inbox = getInbox(agent);
71128
return readdirSync(inbox.fresh).filter((f) => f.endsWith(".json")).length +
@@ -108,30 +165,116 @@ export function sendMessage(to: string, body: string, from?: string): MailMessag
108165
return { ...message, filePath: newPath };
109166
}
110167

111-
export function checkMessages(agent: string): MailMessage[] {
168+
export function checkMessages(agent: string, checkedOutBy = agent): MailMessage[] {
112169
assertValidAgentId(agent);
170+
assertValidAgentId(checkedOutBy);
113171
const inbox = getInbox(agent);
114-
const files = readdirSync(inbox.fresh).filter((f) => f.endsWith(".json"));
115172
const messages: MailMessage[] = [];
173+
const nowIso = new Date().toISOString();
174+
const nowMs = Date.now();
116175

117-
for (const f of files) {
176+
for (const f of listMessageFiles(inbox.fresh)) {
118177
const from = join(inbox.fresh, f);
119178
const to = join(inbox.cur, f);
120179
renameSync(from, to);
121-
const raw = readFileSync(to, "utf-8");
122-
const msg = JSON.parse(raw) as MailMessage;
123-
msg.read = true;
180+
const msg = readMessageFile(to);
181+
msg.read = false;
182+
msg.checkedOutAt = nowIso;
183+
msg.checkedOutBy = checkedOutBy;
184+
msg.deliveryAttempts = (msg.deliveryAttempts ?? 0) + 1;
185+
writeMessageFile(to, msg);
124186
messages.push(msg);
125187
logEvent({ event: "read", from: msg.from, to: agent, messageId: msg.id }, msg.body);
126188
}
127189

190+
for (const f of listMessageFiles(inbox.cur)) {
191+
const full = join(inbox.cur, f);
192+
const msg = readMessageFile(full);
193+
if (msg.read || msg.nackedAt) continue;
194+
if (msg.retryAfter && Date.parse(msg.retryAfter) > nowMs) continue;
195+
if (msg.checkedOutBy && !isLeaseExpired(msg, nowMs)) continue;
196+
msg.checkedOutAt = nowIso;
197+
msg.checkedOutBy = checkedOutBy;
198+
writeMessageFile(full, msg);
199+
messages.push(msg);
200+
}
201+
128202
return messages.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
129203
}
130204

131205
export function listMessages(agent: string): MailMessage[] {
132206
assertValidAgentId(agent);
133207
const inbox = getInbox(agent);
134208
const unread = readMessagesFromDir(inbox.fresh, false);
135-
const read = readMessagesFromDir(inbox.cur, true);
136-
return [...unread, ...read].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
209+
const cur = readMessagesFromDir(inbox.cur, true);
210+
const dlq = readMessagesFromDir(inbox.dlq, true);
211+
return [...unread, ...cur, ...dlq].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
212+
}
213+
214+
export function ackMessage(agent: string, id: string): MailMessage | null {
215+
const path = messagePathById(agent, id);
216+
if (!path) return null;
217+
const msg = readMessageFile(path);
218+
msg.read = true;
219+
msg.ackedAt = new Date().toISOString();
220+
delete msg.nackedAt;
221+
delete msg.nackReason;
222+
delete msg.nackType;
223+
delete msg.checkedOutAt;
224+
delete msg.checkedOutBy;
225+
delete msg.retryAfter;
226+
writeMessageFile(path, msg);
227+
return msg;
228+
}
229+
230+
export function nackMessage(agent: string, id: string, reason: string, type: "transient" | "agent" | "permanent" = "transient", retryAfter?: string): MailMessage | null {
231+
const path = messagePathById(agent, id);
232+
if (!path) return null;
233+
const msg = readMessageFile(path);
234+
msg.read = false;
235+
msg.nackedAt = new Date().toISOString();
236+
msg.nackReason = reason;
237+
msg.nackType = type;
238+
msg.checkedOutAt = undefined;
239+
msg.checkedOutBy = undefined;
240+
if (type === "transient" && retryAfter) {
241+
msg.retryAfter = new Date(Date.now() + parseDurationMs(retryAfter, 60_000)).toISOString();
242+
} else {
243+
delete msg.retryAfter;
244+
}
245+
if (type === "permanent") {
246+
const inbox = getInbox(agent);
247+
const target = join(inbox.dlq, path.split("/").pop()!);
248+
writeMessageFile(path, msg);
249+
renameSync(path, target);
250+
return msg;
251+
}
252+
writeMessageFile(path, msg);
253+
return msg;
254+
}
255+
256+
export function gcMessages(agent?: string, maxAge = "24h", prNumber?: number, hardTtl = "48h"): number {
257+
const agents = agent ? [agent] : (existsSync(getMailDir()) ? readdirSync(getMailDir()).filter((d) => existsSync(join(getMailDir(), d, "cur"))) : []);
258+
let removed = 0;
259+
const doneCutoff = Date.now() - parseDurationMs(maxAge, 24 * 60 * 60 * 1000);
260+
const hardCutoff = Date.now() - parseDurationMs(hardTtl, 48 * 60 * 60 * 1000);
261+
for (const a of agents) {
262+
const inbox = getInbox(a);
263+
for (const dir of [inbox.fresh, inbox.cur, inbox.dlq]) {
264+
for (const file of listMessageFiles(dir)) {
265+
const full = join(dir, file);
266+
const msg = readMessageFile(full);
267+
const ts = Date.parse(msg.ackedAt ?? msg.timestamp);
268+
const hardTs = Date.parse(msg.timestamp);
269+
const done = msg.read && !!msg.ackedAt;
270+
const prMatch = prNumber == null || msg.prNumber === prNumber || msg.body.includes(`#${prNumber}`) || msg.body.includes(`PR #${prNumber}`);
271+
if (!prMatch) continue;
272+
if ((done && ts < doneCutoff) || hardTs < hardCutoff) {
273+
rmSync(full, { force: true });
274+
removed++;
275+
}
276+
}
277+
}
278+
}
279+
return removed;
137280
}

packages/cli/test/auto-commit.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ describe("runAutoCommit", () => {
203203
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-list --count origin/feat/task-789..HEAD") {
204204
return { status: 0, stdout: "1\n", stderr: "" };
205205
}
206+
if (cmd === "/usr/bin/git" && args.join(" ") === "checkout -b feat/task-789") {
207+
return { status: 0, stdout: "", stderr: "" };
208+
}
206209
if (cmd === "/usr/bin/git" && args.join(" ") === "push -u origin feat/task-789") {
207210
return { status: 0, stdout: "pushed\n", stderr: "" };
208211
}
@@ -238,6 +241,7 @@ describe("runAutoCommit", () => {
238241
{ cmd: "/usr/bin/git", args: ["status", "--porcelain"] },
239242
{ cmd: "/usr/bin/git", args: ["rev-parse", "--verify", "refs/remotes/origin/feat/task-789"] },
240243
{ cmd: "/usr/bin/git", args: ["rev-list", "--count", "origin/feat/task-789..HEAD"] },
244+
{ cmd: "/usr/bin/git", args: ["checkout", "-b", "feat/task-789"] },
241245
{ cmd: "/usr/bin/git", args: ["push", "-u", "origin", "feat/task-789"] },
242246
{
243247
cmd: "gh-as",

0 commit comments

Comments
 (0)