diff --git a/src/peer_channel.zig b/src/peer_channel.zig index ebdc6279..2fd7fba6 100644 --- a/src/peer_channel.zig +++ b/src/peer_channel.zig @@ -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 @@ -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 .{ @@ -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, diff --git a/src/peer_idle.zig b/src/peer_idle.zig index c21cfc4d..dd5467cd 100644 --- a/src/peer_idle.zig +++ b/src/peer_idle.zig @@ -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; @@ -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(); diff --git a/src/peer_inbox.zig b/src/peer_inbox.zig index 8c9e95d6..2d0f0411 100644 --- a/src/peer_inbox.zig +++ b/src/peer_inbox.zig @@ -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) { @@ -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"; @@ -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; diff --git a/src/peer_inbox_sequence_test.zig b/src/peer_inbox_sequence_test.zig index be383603..e7e921f1 100644 --- a/src/peer_inbox_sequence_test.zig +++ b/src/peer_inbox_sequence_test.zig @@ -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();