Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/peer_channel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const Owner = worktree_lease.Owner;
pub const tool_name = "peer_message";
pub const tool_desc = "Ping a co-resident session, list who is live, read parked inbound, direct one as supervisor, or transfer an artifact claim. action=list | inbox | send (default) | direct | claim | release | handoff | status. session is a DM (visible title, saved-session base, id/pid/name/goal); omit for this folder's room (send only). Presence is device-local. Not \"all\".";
pub const tool_schema =
\\{"type": "object", "properties": {"action": {"type": "string", "enum": ["send", "list", "inbox", "direct", "claim", "release", "handoff", "status"], "description": "send (default): ping a peer. list: who is live. inbox: read+clear parked inbound. direct: supervisor control of one named session. claim/release/handoff/status: repository-scoped artifact ownership (kind+key)."}, "session": {"type": "string", "description": "who to ping, direct, or hand a claim to: exact visible title, exact saved-session base, unique normalized title/slug, id, pid, unique name fragment, or unique goal fragment (omit = this folder's room for send). Named targets are DMs. Presence is device-local. Not \"all\"."}, "text": {"type": "string", "description": "one or two sentences of coordination intent (required for send and direct)"}, "kind": {"type": "string", "enum": ["branch", "issue", "commit", "pull_request", "publication"], "description": "artifact kind for claim/release/handoff/status"}, "key": {"type": "string", "description": "artifact key: branch name, issue number, commit SHA, or PR number"}, "repo": {"type": "string", "description": "GitHub repository for claim/release/handoff/status, as owner/name or host/owner/name; defaults to the workspace CLI repository. Use the same repository for release or handoff."}}}
\\{"type": "object", "properties": {"action": {"type": "string", "enum": ["send", "list", "inbox", "direct", "claim", "release", "handoff", "status"], "description": "send (default): ping a peer. list: who is live. inbox: read+clear parked inbound. direct: supervisor control of one named session. claim/release/handoff/status: repository-scoped artifact ownership (kind+key)."}, "session": {"type": "string", "description": "who to ping, direct, or hand a claim to: exact visible title, exact saved-session base, unique normalized title/slug, id, pid, unique name fragment, or unique goal fragment (omit = this folder's room for send). Named targets are DMs. Presence is device-local. Not \"all\"."}, "text": {"type": "string", "description": "one or two sentences of coordination intent (required for send and direct)"}, "fyi": {"type": "boolean", "description": "send: true for a status or scope update that needs no action or reply. Recipients read it on their next turn; it does not start one."}, "kind": {"type": "string", "enum": ["branch", "issue", "commit", "pull_request", "publication"], "description": "artifact kind for claim/release/handoff/status"}, "key": {"type": "string", "description": "artifact key: branch name, issue number, commit SHA, or PR number"}, "repo": {"type": "string", "description": "GitHub repository for claim/release/handoff/status, as owner/name or host/owner/name; defaults to the workspace CLI repository. Use the same repository for release or handoff."}}}
;

/// Whether the named session sits in MY worktree (routes the post: worktree
Expand Down Expand Up @@ -99,8 +99,11 @@ pub fn handleMessage(self: *Agent, call: ToolCall) !ExecResult {
.text = "peer_message action must be send, list, inbox, direct, claim, release, handoff, or status",
.is_error = true,
};
const text = tools_mod.json_args.str(obj, "text") orelse "";
if (text.len == 0) return .{ .text = "peer_message: empty text — say what you want the peer to know", .is_error = true };
const said = tools_mod.json_args.str(obj, "text") orelse "";
if (said.len == 0) return .{ .text = "peer_message: empty text — say what you want the peer to know", .is_error = true };
// #1137: an update that needs nothing from the peer must not wake it.
const fyi = tools_mod.json_args.flag(call.input, "fyi");
const text = if (fyi and !peer_inbox.isFyi(said)) try std.fmt.allocPrint(self.arena, "{s}{s}", .{ peer_inbox.fyi_marker, said }) else said;
const want = tools_mod.json_args.str(obj, "session") orelse "";
const local = presence.liveTreePeers(self.io, self.arena);
if (want.len == 0 and local.len == 0) return .{
Expand Down Expand Up @@ -133,6 +136,10 @@ pub fn handleMessage(self: *Agent, call: ToolCall) !ExecResult {
.text = "delivery failed — the presence registry is unavailable (this session may never have announced itself)",
.is_error = true,
};
if (fyi) return .{
.text = if (to.len == 0) "posted to this folder's room as fyi — sessions here read it on their next turn; it starts none." else "sent as fyi — the peer reads it on its next turn; it starts none.",
.is_error = false,
};
if (to.len == 0) return .{
.text = "posted to this folder's room — every live idle session here starts a turn. A busy one reads it when that turn ends. Set session to DM one peer instead.",
.is_error = false,
Expand Down
28 changes: 28 additions & 0 deletions src/peer_idle.zig
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub fn takeIdleWake(io: Io, buf: []u8) ?[]const u8 {
if (suppress_idle_wake) return null;
const n = peer_inbox.unread();
if (n == 0 or n <= last_woken_unread) return null;
// #1137: fyi-only news stays parked for the next turn; it starts none.
if (!peer_inbox.anyNeedsTurnFrom(last_woken_unread)) return null;
last_woken_unread = n;
const wake = peer_inbox.formatWake(arena);
if (wake.len == 0) return null;
Expand Down Expand Up @@ -240,6 +242,32 @@ test "#1001 more mail after a wake can fire again" {
try std.testing.expect(std.mem.indexOf(u8, again, "[peer]") != null);
}

test "#1137 fyi mail stays parked without a turn; a request after it wakes" {
peer_inbox.resetForTest();
resetForTest();
defer {
peer_inbox.resetForTest();
resetForTest();
}
const io = std.testing.io;
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var buf: [256]u8 = undefined;
const fyi: presence_chan.Message = .{ .from_pid = 2, .from_start = 1, .from_session = "s-a", .text = "[fyi] I only touch gui/src" };
const fyi2: presence_chan.Message = .{ .from_pid = 3, .from_start = 1, .from_session = "s-b", .text = " [fyi] docs done" };
_ = peer_inbox.parkHeard(&.{ fyi, fyi2 }, &.{});
try std.testing.expect(takeIdleWake(io, &buf) == null);
try std.testing.expectEqual(@as(usize, 2), peer_inbox.unread()); // still readable
const ask: presence_chan.Message = .{ .from_pid = 2, .from_start = 1, .from_session = "s-a", .text = "can you rerun the tests?" };
_ = peer_inbox.parkHeard(&.{ask}, &.{});
try std.testing.expect(takeIdleWake(io, &buf) != null);
// An fyi after the woken batch does not wake again.
_ = peer_inbox.parkHeard(&.{fyi}, &.{});
try std.testing.expect(takeIdleWake(io, &buf) == null);
const read = try peer_inbox.takeAll(arena_state.allocator());
try std.testing.expect(std.mem.indexOf(u8, read, "[fyi] updates need no reply") != null);
}

test "daddy idle wake fires after attempt_completion" {
peer_inbox.resetForTest();
resetForTest();
Expand Down
23 changes: 22 additions & 1 deletion src/peer_inbox.zig
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ pub fn pending() bool {
}

/// True when any parked body starts with `want` (daddy directives).
/// #1137: a sender marks a status or scope update that needs nothing from the
/// recipient. It stays readable but never starts a turn on its own.
pub const fyi_marker = "[fyi] ";

pub fn isFyi(text: []const u8) bool {
return std.mem.startsWith(u8, std.mem.trimStart(u8, text, " \t\r\n"), std.mem.trimEnd(u8, fyi_marker, " "));
}

/// Whether any message parked at or after index `from` asks for a turn.
pub fn anyNeedsTurnFrom(from: usize) bool {
var i = from;
while (i < g_len) : (i += 1) {
if (!isFyi(textSlice(itemAt(i)))) return true;
}
return false;
}

pub fn anyTextPrefixed(want: []const u8) bool {
var i: usize = 0;
while (i < g_len) : (i += 1) {
Expand Down Expand Up @@ -186,6 +203,10 @@ pub fn formatWake(arena: Allocator) []const u8 {
return std.fmt.allocPrint(arena, "[peer] {d} unread {s} — parked; peer_message action=inbox when relevant", .{ g_len, who }) catch "[peer] unread — peer_message action=inbox when relevant";
}

/// #1137: replying is the exception. An unconditional "reply" here produced
/// acknowledgement turns and restated answers after informational mail.
pub const inbox_footer = "(inbox cleared — reply with peer_message only to a message that asks you something; [fyi] updates need no reply. Omit session for the room, or name one peer to DM)";

/// Read+clear only after the complete tool result is allocated successfully.
pub fn takeAll(arena: Allocator) Allocator.Error![]const u8 {
if (!pending()) return "inbox empty";
Expand All @@ -197,7 +218,7 @@ pub fn takeAll(arena: Allocator) Allocator.Error![]const u8 {
const flag = if (p.device and p.dm) " · device DM" else if (p.device) " · device" else if (p.dm) " · DM" else "";
buf.writer.print("[peer message from {s}{s}]: {s}\n", .{ fromSlice(p), flag, textSlice(p) }) catch return error.OutOfMemory;
}
buf.writer.writeAll("(inbox cleared — reply with peer_message; omit session for the room, or name one peer to DM)") catch return error.OutOfMemory;
buf.writer.writeAll(inbox_footer) catch return error.OutOfMemory;
const result = try buf.toOwnedSlice();
clear();
return result;
Expand Down
2 changes: 1 addition & 1 deletion src/peer_inbox_sequence_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const Model = struct {
const flag: usize = @as(usize, @intFromBool(entry.dm)) + 2 * @as(usize, @intFromBool(entry.device));
try expected.writer.print("[peer message from {s}{s}]: {s}\n", .{ entry.from, flags[flag], entry.text });
}
try expected.writer.writeAll("(inbox cleared — reply with peer_message; omit session for the room, or name one peer to DM)");
try expected.writer.writeAll(inbox.inbox_footer);
}
try testing.expectEqualStrings(expected.writer.buffered(), try inbox.takeAll(a));
self.clear();
Expand Down