diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs index 1b3476ee746..9938c4413e6 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs @@ -27,6 +27,7 @@ public AgentCommand() : base("agent") Subcommands.Add(new LoginAgentCommand()); Subcommands.Add(new RegisterAgentCommand()); Subcommands.Add(new ListAgentCommand()); + Subcommands.Add(new TakeoverAgentCommand()); Subcommands.Add(new HookCommand()); Subcommands.Add(new HooksCommand()); diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/ReadMailCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/ReadMailCommand.cs index 89f595689ea..e51cf6f7f4d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/ReadMailCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/ReadMailCommand.cs @@ -33,6 +33,7 @@ private static async Task ExecuteAsync( var console = services.GetRequiredService(); var store = services.GetRequiredService(); var registry = services.GetRequiredService(); + var ledger = services.GetRequiredService(); var actorResolver = services.GetRequiredService(); var resultHolder = services.GetRequiredService(); @@ -48,10 +49,13 @@ private static async Task ExecuteAsync( var messages = thread ? await MarkThreadReadAsync(store, message.ThreadId, actor, cancellationToken) : [await MarkMessageReadAsync(store, message, actor, cancellationToken)]; + var takeovers = await GetTakeoversAsync(ledger, messages, cancellationToken); if (!console.IsHumanReadable) { - var results = messages.Select(m => MailMessageDetailResult.Create(m, actor)).ToArray(); + var results = messages + .Select((message, index) => MailMessageDetailResult.Create(message, actor, takeovers[index])) + .ToArray(); resultHolder.SetResult( thread @@ -71,7 +75,7 @@ private static async Task ExecuteAsync( } var sender = await registry.GetAsync(messages[i].Sender, cancellationToken); - WriteMessage(console, messages[i], sender?.Role ?? ""); + WriteMessage(console, messages[i], sender?.Role ?? "", takeovers[i]); } return ExitCodes.Success; @@ -131,7 +135,29 @@ private static async Task> MarkThreadReadAsync( return await store.GetThreadMessagesAsync(threadId, cancellationToken); } - private static void WriteMessage(INitroConsole console, MailMessage message, string senderRole) + private static async Task[]> GetTakeoversAsync( + ITakeoverLedger ledger, + IReadOnlyList messages, + CancellationToken cancellationToken) + { + var takeovers = new IReadOnlyList[messages.Count]; + + for (var index = 0; index < messages.Count; index++) + { + var records = await ledger.QueryAsync( + new TakeoverFilter { MessageId = messages[index].Id }, + cancellationToken); + takeovers[index] = records.Select(TakeoverReferenceResult.FromRecord).ToArray(); + } + + return takeovers; + } + + private static void WriteMessage( + INitroConsole console, + MailMessage message, + string senderRole, + IReadOnlyList takeovers) { var to = message.Recipients .Where(r => r.Kind == MailRecipientKinds.To) @@ -159,6 +185,14 @@ private static void WriteMessage(INitroConsole console, MailMessage message, str console.WriteLine($"Date: {TaskDates.Format(message.CreatedAt)}"); console.WriteLine($"Subject: {message.Subject}"); console.WriteLine($"Thread: {message.ThreadId}"); + + foreach (var takeover in takeovers) + { + console.WriteLine( + $"Takeover: {takeover.From} -> {takeover.To} " + + $"({takeover.Id}, {takeover.CreatedAt.ToUniversalTime():yyyy-MM-dd})"); + } + console.WriteLine(); console.WriteLine(message.Body); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/WatchMailCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/WatchMailCommand.cs index 0a7e9b5fe96..3fdd8da2754 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/WatchMailCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/WatchMailCommand.cs @@ -109,7 +109,7 @@ private static async Task ExecuteAsync( { resultHolder.SetResult( new ListResult( - arrived.Select(m => MailMessageDetailResult.Create(m, actor)).ToArray())); + arrived.Select(m => MailMessageDetailResult.Create(m, actor, [])).ToArray())); return ExitCodes.Success; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/ForceActorTakeoverOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/ForceActorTakeoverOption.cs index c147e6b588a..00863e82f8c 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/ForceActorTakeoverOption.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/ForceActorTakeoverOption.cs @@ -4,6 +4,6 @@ internal sealed class ForceActorTakeoverOption : Option { public ForceActorTakeoverOption() : base("--force") { - Description = "Take the actor from another session and remove that session"; + Description = "Take over even when the source actor still has a live session"; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/RoleAgentOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/RoleAgentOption.cs index a63d2cf470d..db45733e588 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/RoleAgentOption.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/RoleAgentOption.cs @@ -4,7 +4,9 @@ internal sealed class RoleAgentOption : Option { public RoleAgentOption() : base("--role") { - Description = "The actor role, normalized lowercase"; + Description = + "The actor role, normalized lowercase. Known roles: orchestrator, planner, implementer, " + + "reviewer, researcher; any other value is accepted."; Required = false; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverActorOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverActorOption.cs new file mode 100644 index 00000000000..c9e8a64f450 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverActorOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options; + +internal sealed class TakeoverActorOption : Option +{ + public TakeoverActorOption() : base("--actor") + { + Description = "The actor taking over the mail and tasks"; + Required = true; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverFromActorOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverFromActorOption.cs new file mode 100644 index 00000000000..b555e08c7f7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverFromActorOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options; + +internal sealed class TakeoverFromActorOption : Option +{ + public TakeoverFromActorOption() : base("--from") + { + Description = "The actor whose mail and tasks to take over"; + Required = true; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryActorOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryActorOption.cs new file mode 100644 index 00000000000..08b1374184d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryActorOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options; + +internal sealed class TakeoverHistoryActorOption : Option +{ + public TakeoverHistoryActorOption() : base("--actor") + { + Description = "Filter to takeovers involving this actor"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryLimitOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryLimitOption.cs new file mode 100644 index 00000000000..8717d741fc7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverHistoryLimitOption.cs @@ -0,0 +1,19 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options; + +internal sealed class TakeoverHistoryLimitOption : Option +{ + public TakeoverHistoryLimitOption() : base("--limit") + { + Description = "The maximum number of takeovers to show"; + Required = false; + Validators.Add(result => + { + var limit = result.GetValue(this); + + if (limit is <= 0) + { + result.AddError("Option '--limit' must be a positive number."); + } + }); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverReasonOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverReasonOption.cs new file mode 100644 index 00000000000..f196bc64aad --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Options/TakeoverReasonOption.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options; + +internal sealed class TakeoverReasonOption : Option +{ + public TakeoverReasonOption() : base("--reason") + { + Description = "The reason recorded for the takeover"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/RegisterAgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/RegisterAgentCommand.cs index 84b5cbc1703..2c46bcc0c0e 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/RegisterAgentCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/RegisterAgentCommand.cs @@ -16,7 +16,7 @@ public RegisterAgentCommand() : base("register") Options.Add(Opt.Instance); Options.Add(Opt.Instance); - this.AddExamples("agent register --actor \"maya\"", "agent register --actor \"maya\" --role \"backend\""); + this.AddExamples("agent register --actor \"maya\"", "agent register --actor \"maya\" --role \"researcher\""); this.SetActionWithExceptionHandling(ExecuteAsync); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverAgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverAgentCommand.cs new file mode 100644 index 00000000000..c7c6d786a83 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverAgentCommand.cs @@ -0,0 +1,151 @@ +using ChilliCream.Nitro.CommandLine.Commands.Agent.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent; + +internal sealed class TakeoverAgentCommand : Command +{ + public TakeoverAgentCommand() : base("takeover") + { + Description = "Take over another actor's mail and tasks."; + + Subcommands.Add(new TakeoverHistoryAgentCommand()); + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent takeover --from \"maya\" --actor \"nora\"", + "agent takeover --from \"maya\" --actor \"nora\" --force --reason \"session ended\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + var agents = services.GetRequiredService(); + var sessions = services.GetRequiredService(); + var mail = services.GetRequiredService(); + var tasks = services.GetRequiredService(); + var ledger = services.GetRequiredService(); + + var from = MailAgentName.Normalize( + parseResult.GetValue(Opt.Instance) ?? string.Empty); + var to = MailAgentName.Normalize( + parseResult.GetValue(Opt.Instance) ?? string.Empty); + var force = parseResult.GetValue(Opt.Instance); + var reason = parseResult.GetValue(Opt.Instance); + + var source = await agents.GetAsync(from, cancellationToken) + ?? throw UnknownActor(from); + var target = await agents.GetAsync(to, cancellationToken) + ?? throw UnknownActor(to); + + if (from == to) + { + throw new ExitException("The source and target actors must be different."); + } + + if (!force + && (await sessions.FindLiveClaimedByAgentNameAsync(from, cancellationToken)).Count > 0) + { + throw new ExitException( + $"Actor '{from}' still has a live session; pass --force to take over anyway."); + } + + var role = target.Role; + if (role.Length == 0 && source.Role.Length > 0) + { + target = await agents.RegisterAsync(to, source.Role, target.Client, cancellationToken); + role = target.Role; + } + + var mailTransfer = await mail.TransferParticipationAsync(from, to, cancellationToken); + var taskIds = await tasks.ReassignAsync( + from, + to, + to, + $"Taken over from '{from}' by '{to}'.", + cancellationToken); + var items = CreateItems(mailTransfer, taskIds); + var takeover = await ledger.RecordAsync( + new TakeoverRecordCreation + { + FromActor = from, + ToActor = to, + Actor = to, + Forced = force, + Role = role.Length > 0 ? role : null, + Reason = reason + }, + items, + cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult( + new AgentTakeoverResult( + takeover.Id, + from, + to, + role, + mailTransfer.RecipientsMoved, + mailTransfer.SendersMoved, + taskIds))); + + return ExitCodes.Success; + } + + var taskSummary = taskIds.Count == 0 + ? "no tasks" + : $"{taskIds.Count} tasks ({string.Join(", ", taskIds.Select(id => id.EscapeMarkup()))})"; + console.OkLine( + $"'{to.EscapeMarkup()}' took over from '{from.EscapeMarkup()}': " + + $"role '{role.EscapeMarkup()}', " + + $"{mailTransfer.RecipientsMoved + mailTransfer.SendersMoved} messages, {taskSummary}."); + + return ExitCodes.Success; + } + + private static ExitException UnknownActor(string actor) + => new($"Unknown actor '{actor}'. Run `nitro agent list` to see the actors this workspace knows."); + + private static IReadOnlyList CreateItems( + MailTransferResult mailTransfer, + IReadOnlyList taskIds) + { + var items = new List( + mailTransfer.SenderMessageIds.Count + + mailTransfer.RecipientMessageIds.Count + + taskIds.Count); + items.AddRange(mailTransfer.SenderMessageIds.Select( + id => new TakeoverItem { Kind = TakeoverItemKinds.MessageSender, ItemId = id })); + items.AddRange(mailTransfer.RecipientMessageIds.Select( + id => new TakeoverItem { Kind = TakeoverItemKinds.MessageRecipient, ItemId = id })); + items.AddRange(taskIds.Select( + id => new TakeoverItem { Kind = TakeoverItemKinds.Task, ItemId = id })); + + return items; + } + + public sealed record AgentTakeoverResult( + string Id, + string From, + string To, + string Role, + int RecipientsMoved, + int SendersMoved, + IReadOnlyList Tasks); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverHistoryAgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverHistoryAgentCommand.cs new file mode 100644 index 00000000000..aff6228752a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/TakeoverHistoryAgentCommand.cs @@ -0,0 +1,98 @@ +using ChilliCream.Nitro.CommandLine.Commands.Agent.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent; + +internal sealed class TakeoverHistoryAgentCommand : Command +{ + public TakeoverHistoryAgentCommand() : base("history") + { + Description = "List actor takeover history, newest first."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent takeover history", + "agent takeover history --actor \"nora\" --limit 10"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + var ledger = services.GetRequiredService(); + + var actorValue = parseResult.GetValue(Opt.Instance); + var actor = actorValue is null ? null : MailAgentName.Normalize(actorValue); + var limit = parseResult.GetValue(Opt.Instance); + var records = await ledger.QueryAsync( + new TakeoverFilter { Actor = actor, Limit = limit }, + cancellationToken); + var results = records.Select(ToResult).ToArray(); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(results)); + return ExitCodes.Success; + } + + foreach (var result in results) + { + console.WriteLine( + $"{result.Id.EscapeMarkup()} {TaskDates.Format(result.CreatedAt)} " + + $"{result.From.EscapeMarkup()} -> {result.To.EscapeMarkup()} " + + $"by {result.Actor.EscapeMarkup()} " + + $"{result.MessageSenders + result.MessageRecipients} messages, " + + $"{result.Tasks.Count} tasks"); + } + + return ExitCodes.Success; + } + + private static AgentTakeoverHistoryResult ToResult(TakeoverRecord record) + => new( + record.Id, + record.FromActor, + record.ToActor, + record.Actor, + record.CreatedAt, + record.Forced, + record.Role, + record.Reason, + GetItemCount(record, TakeoverItemKinds.MessageSender), + GetItemCount(record, TakeoverItemKinds.MessageRecipient), + GetItemIds(record, TakeoverItemKinds.Task)); + + private static int GetItemCount(TakeoverRecord record, string kind) + => record.Items.Count(item => item.Kind == kind); + + private static IReadOnlyList GetItemIds(TakeoverRecord record, string kind) + => record.Items + .Where(item => item.Kind == kind) + .Select(item => item.ItemId) + .ToArray(); + + public sealed record AgentTakeoverHistoryResult( + string Id, + string From, + string To, + string Actor, + DateTimeOffset CreatedAt, + bool Forced, + string? Role, + string? Reason, + int MessageSenders, + int MessageRecipients, + IReadOnlyList Tasks); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ListTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ListTaskCommand.cs index 89ec0bfbb0b..26158adde94 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ListTaskCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ListTaskCommand.cs @@ -13,7 +13,7 @@ public ListTaskCommand() : base("list") Description = "List tasks."; Options.Add(Opt.Instance); - Options.Add(Opt.Instance); + Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); @@ -39,7 +39,7 @@ private static async Task ExecuteAsync( var resultHolder = services.GetRequiredService(); var statuses = parseResult.GetValue(Opt.Instance); - var type = parseResult.GetValue(Opt.Instance); + var type = parseResult.GetValue(Opt.Instance); var priority = parseResult.GetValue(Opt.Instance); var assignee = parseResult.GetValue(Opt.Instance); var labels = parseResult.GetValue(Opt.Instance); diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/Options/TaskTypeFilterOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/Options/TaskTypeFilterOption.cs new file mode 100644 index 00000000000..8e5d2b8128f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/Options/TaskTypeFilterOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks.Options; + +internal sealed class TaskTypeFilterOption : Option +{ + public TaskTypeFilterOption() : base("--type") + { + Description = + "Only show tasks of this type (task, bug, feature, epic, chore, docs, question, or custom)"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ReadyTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ReadyTaskCommand.cs index ccd3427be23..91033ee036a 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ReadyTaskCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ReadyTaskCommand.cs @@ -12,6 +12,7 @@ public ReadyTaskCommand() : base("ready") { Description = "List tasks that are ready to work on."; + Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); @@ -34,6 +35,7 @@ private static async Task ExecuteAsync( var timeProvider = services.GetRequiredService(); var resultHolder = services.GetRequiredService(); + var type = parseResult.GetValue(Opt.Instance); var priorityValue = parseResult.GetValue(Opt.Instance); var assignee = parseResult.GetValue(Opt.Instance); var labels = parseResult.GetValue(Opt.Instance); @@ -50,6 +52,7 @@ private static async Task ExecuteAsync( var filter = new TaskFilter { Statuses = [TaskStates.Open], + Type = type, PriorityMin = priorityRange?.Min, PriorityMax = priorityRange?.Max, Unassigned = unassigned, diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ShowTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ShowTaskCommand.cs index f1905424e21..189d14f5f70 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ShowTaskCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Tasks/ShowTaskCommand.cs @@ -3,6 +3,7 @@ using ChilliCream.Nitro.CommandLine.Results; using ChilliCream.Nitro.CommandLine.Services; using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks; @@ -28,6 +29,7 @@ private static async Task ExecuteAsync( var console = services.GetRequiredService(); var store = services.GetRequiredService(); var resultHolder = services.GetRequiredService(); + var ledger = services.GetRequiredService(); var id = parseResult.GetRequiredValue(Opt.Instance); @@ -37,6 +39,11 @@ private static async Task ExecuteAsync( var blocks = await store.GetDependentsAsync(task.Id, cancellationToken); var comments = await store.GetCommentsAsync(task.Id, cancellationToken); var blocked = await store.ComputeBlockedAsync(cancellationToken); + var takeovers = (await ledger.QueryAsync( + new TakeoverFilter { TaskId = task.Id }, + cancellationToken)) + .Select(TakeoverReferenceResult.FromRecord) + .ToArray(); if (!console.IsHumanReadable) { @@ -68,7 +75,8 @@ private static async Task ExecuteAsync( Blockers = blockers, Dependencies = dependencies, Dependents = blocks, - Comments = comments + Comments = comments, + Takeovers = takeovers })); return ExitCodes.Success; @@ -79,7 +87,7 @@ private static async Task ExecuteAsync( var separatorPending = false; - WriteSection(console, BuildHeader(task, labels, blocked), ref separatorPending); + WriteSection(console, BuildHeader(task, labels, blocked, takeovers), ref separatorPending); WriteSection(console, BuildTextBlock("Description:", task.Description), ref separatorPending); WriteSection(console, BuildTextBlock("Design:", task.Design), ref separatorPending); WriteSection( @@ -96,7 +104,8 @@ private static async Task ExecuteAsync( private static List BuildHeader( TaskItem task, IReadOnlyList labels, - IReadOnlyDictionary> blocked) + IReadOnlyDictionary> blocked, + IReadOnlyList takeovers) { var lines = new List { @@ -126,6 +135,13 @@ private static List BuildHeader( lines.Add($"Created: {TaskDates.Format(task.CreatedAt)} by {task.CreatedBy}"); lines.Add($"Updated: {TaskDates.Format(task.UpdatedAt)}"); + foreach (var takeover in takeovers) + { + lines.Add( + $"Takeover: {takeover.From} -> {takeover.To} " + + $"({takeover.Id}, {takeover.CreatedAt.ToUniversalTime():yyyy-MM-dd})"); + } + if (task.Status is TaskStates.Closed or TaskStates.Archived && task.ClosedAt is { } closedAt) { var closedLine = $"Closed: {TaskDates.Format(closedAt)}"; diff --git a/src/Nitro/CommandLine/src/CommandLine/Extensions/RootCommandExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Extensions/RootCommandExtensions.cs index d715be04261..5f06c73872d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Extensions/RootCommandExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Extensions/RootCommandExtensions.cs @@ -64,7 +64,7 @@ await services } else if (format is OutputFormat.Json && exitCode == 0) { - console.Out.WriteLine("{}"); + console.WriteRawLine("{}"); } return exitCode; diff --git a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs index 7822582c3cf..d875ac529fb 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs @@ -43,6 +43,7 @@ public static IServiceCollection AddNitroServices(this IServiceCollection servic services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Console/INitroConsole.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Console/INitroConsole.cs index 5011ff7e068..c3f70dbe528 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Console/INitroConsole.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Console/INitroConsole.cs @@ -14,6 +14,8 @@ internal interface INitroConsole : IAnsiConsole IAnsiConsole Error { get; } + void WriteRawLine(string value); + void SetOutputFormat(OutputFormat format); INitroConsoleActivity StartActivity(string title, string failureMessage); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs index ee25bde466e..5ad7b7c0e9b 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Console/NitroConsole.cs @@ -23,6 +23,11 @@ internal sealed class NitroConsole( public IAnsiConsole Error => errorConsole; + public void WriteRawLine(string value) + { + outConsole.Profile.Out.Writer.WriteLine(value); + } + public void SetOutputFormat(OutputFormat format) { _outputFormat = format; diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/AgentEffectiveRole.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/AgentEffectiveRole.cs new file mode 100644 index 00000000000..ea04390be86 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/AgentEffectiveRole.cs @@ -0,0 +1,35 @@ +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Services.Hook; + +internal static class AgentEffectiveRole +{ + public static async Task ResolveAsync( + string sessionRole, + string actor, + IAgentRegistry agentRegistry, + CancellationToken cancellationToken) + { + if (sessionRole.Length > 0) + { + return sessionRole; + } + + try + { + return (await agentRegistry.GetAsync(actor, cancellationToken))?.Role ?? string.Empty; + } + catch (OperationCanceledException) + { + throw; + } + catch (AgentWorkspaceSchemaMismatchException) + { + throw; + } + catch + { + return string.Empty; + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs index 8f79ebfb94d..10dba094f66 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs @@ -9,6 +9,7 @@ internal sealed class ClaudeHookHandler( IFileSystem fileSystem, TimeProvider timeProvider, IAgentSessionRegistry sessionRegistry, + IAgentRegistry agentRegistry, ISessionDeliveryLedger ledger, IMailStore mailStore, IClaudeSessionFileReader sessionFileReader, @@ -22,15 +23,13 @@ internal sealed class ClaudeHookHandler( /// public const int MaxBlocksPerTurn = 3; - /// - /// How many unread messages one nudge accounts for. - /// - public const int MaxDigestMessages = 10; - private static string BlockReason(string actor) => $"Unread nitro mail is waiting. Read it with `nitro agent mail inbox --actor {actor}` " + "before ending this turn, or ignore this once if it is not actionable right now."; + private const string BlockDigestPreamble = + "Unread nitro mail is waiting; handle it before ending this turn, or ignore this once if it is not actionable right now."; + public async Task HandleSessionStartAsync( ClaudeHookPayload payload, bool dryRun, CancellationToken cancellationToken) { @@ -61,9 +60,12 @@ await sessionRegistry.RecordHarnessVersionAsync( resolved.Generation, resolved.HarnessVersion, cancellationToken); } + var role = await AgentEffectiveRole.ResolveAsync( + session.Role, session.AgentName!, agentRegistry, cancellationToken); + return new ClaudeHookOutcome { - AdditionalContext = AgentActorContext.Format(session.AgentName!, session.Role) + AdditionalContext = AgentActorContext.Format(session.AgentName!, role) }; } @@ -110,11 +112,12 @@ public async Task HandleUserPromptSubmitAsync( // it on startup, resume, clear, compact, and fork, which covers every // point the session could have lost it. This event only speaks up // when there is unread mail to announce. - var digest = await BuildDigestAsync(resolved.Generation, row.AgentName, cancellationToken); + var digest = await BuildDigestAsync( + resolved.Generation, row.AgentName, AgentSessionChannel.Digest, cancellationToken); return digest is null ? ClaudeHookOutcome.Neutral - : new ClaudeHookOutcome { AdditionalContext = digest }; + : new ClaudeHookOutcome { AdditionalContext = digest.Text }; } public async Task HandleStopAsync( @@ -150,24 +153,10 @@ public async Task HandleStopAsync( return ClaudeHookOutcome.Neutral; } - var unread = await mailStore.QueryInboxAsync( - new MailInboxFilter { Actor = row.AgentName, UnreadOnly = true, Limit = MaxDigestMessages }, - cancellationToken); - - if (unread.Count == 0) - { - return ClaudeHookOutcome.Neutral; - } - - var reserved = await ledger.ReserveAsync( - resolved.Generation.Harness, - resolved.Generation.SessionId, - unread.Select(m => m.Id).ToList(), - AgentSessionChannel.Gate, - timeProvider.GetUtcNow(), - cancellationToken); + var digest = await BuildDigestAsync( + resolved.Generation, row.AgentName, AgentSessionChannel.Gate, cancellationToken); - if (reserved.Count == 0) + if (digest is null) { return ClaudeHookOutcome.Neutral; } @@ -181,7 +170,13 @@ public async Task HandleStopAsync( return ClaudeHookOutcome.Neutral; } - return new ClaudeHookOutcome { Block = true, BlockReason = BlockReason(row.AgentName) }; + return new ClaudeHookOutcome + { + Block = true, + BlockReason = digest.HasMessages + ? $"{BlockDigestPreamble}\n{digest.Text}" + : BlockReason(row.AgentName) + }; } public async Task HandleSessionEndAsync( @@ -198,17 +193,17 @@ public async Task HandleSessionEndAsync( } /// - /// The unread-mail nudge for this session, or null when nothing is - /// unread or every unread message was already announced to it. It names - /// the command that reads the mail; the mail itself stays in the inbox. + /// The unread-mail digest for this session and channel, or null when + /// nothing is unread or every message is already reserved on the channel. /// - private async Task BuildDigestAsync( + private async Task BuildDigestAsync( AgentSessionGeneration generation, string actor, + string channel, CancellationToken cancellationToken) { var unread = await mailStore.QueryInboxAsync( - new MailInboxFilter { Actor = actor, UnreadOnly = true, Limit = MaxDigestMessages }, + new MailInboxFilter { Actor = actor, UnreadOnly = true, Limit = MailDigestPolicy.MaxMessages }, cancellationToken); if (unread.Count == 0) @@ -216,11 +211,13 @@ public async Task HandleSessionEndAsync( return null; } + var messageIds = unread.Select(message => message.Id).ToList(); + var delivered = await ledger.FindDeliveredAsync(generation, messageIds, cancellationToken); var reserved = await ledger.ReserveAsync( generation.Harness, generation.SessionId, - unread.Select(m => m.Id).ToList(), - AgentSessionChannel.Digest, + messageIds, + channel, timeProvider.GetUtcNow(), cancellationToken); @@ -229,7 +226,16 @@ public async Task HandleSessionEndAsync( return null; } - return MailNudgeText.Format(actor, await mailStore.CountUnreadAsync(actor, cancellationToken)); + var reservedIds = reserved.ToHashSet(StringComparer.Ordinal); + var deliveredIds = delivered.ToHashSet(StringComparer.Ordinal); + var messages = unread + .Where(message => reservedIds.Contains(message.Id) && !deliveredIds.Contains(message.Id)) + .ToList(); + var unreadTotal = await mailStore.CountUnreadAsync(actor, cancellationToken); + + return new MailDigestResult( + MailDigest.Render(actor, messages, unreadTotal), + messages.Count > 0); } /// @@ -277,4 +283,6 @@ private sealed record ResolvedGeneration( string WorkspaceDirectory, string? EndpointName, string HarnessVersion); + + private sealed record MailDigestResult(string Text, bool HasMessages); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/CodexHookHandler.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/CodexHookHandler.cs index ea338470827..4af606c0041 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/CodexHookHandler.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/CodexHookHandler.cs @@ -8,6 +8,7 @@ internal sealed class CodexHookHandler( IFileSystem fileSystem, TimeProvider timeProvider, IAgentSessionRegistry sessionRegistry, + IAgentRegistry agentRegistry, ISessionDeliveryLedger ledger, IMailStore mailStore, ICodexHarnessVersionResolver harnessVersionResolver, @@ -19,6 +20,7 @@ public CodexHookHandler( IFileSystem fileSystem, TimeProvider timeProvider, IAgentSessionRegistry sessionRegistry, + IAgentRegistry agentRegistry, ISessionDeliveryLedger ledger, IMailStore mailStore, IEnvironmentVariableProvider environmentVariableProvider, @@ -30,6 +32,7 @@ public CodexHookHandler( fileSystem, timeProvider, sessionRegistry, + agentRegistry, ledger, mailStore, harnessVersionResolver, @@ -40,11 +43,6 @@ public CodexHookHandler( ArgumentNullException.ThrowIfNull(environmentVariableProvider); } - /// - /// How many unread messages one nudge accounts for. - /// - public const int MaxDigestMessages = 10; - public async Task HandleSessionStartAsync( CodexHookPayload payload, bool dryRun, CancellationToken cancellationToken) { @@ -78,9 +76,12 @@ public async Task HandleSessionStartAsync( await sessionRegistry.RecordHarnessVersionAsync(resolved.Generation, harnessVersion, cancellationToken); } + var role = await AgentEffectiveRole.ResolveAsync( + session.Role, session.AgentName!, agentRegistry, cancellationToken); + return new CodexHookOutcome { - AdditionalContext = AgentActorContext.Format(session.AgentName!, session.Role) + AdditionalContext = AgentActorContext.Format(session.AgentName!, role) }; } @@ -129,7 +130,7 @@ public async Task HandleUserPromptSubmitAsync( return digest is null ? CodexHookOutcome.Neutral - : new CodexHookOutcome { AdditionalContext = digest }; + : new CodexHookOutcome { AdditionalContext = digest.Text }; } public async Task HandleSessionEndAsync( @@ -189,25 +190,24 @@ public async Task HandleNotifyAsync( // digest on the gate channel from then on rather than retrying or // duplicating it - the message stays visible to a direct inbox read // and to the digest channel either way. - var queueResult = await queueClient.QueueAsync(payload.ThreadId, digest, cancellationToken); + var queueResult = await queueClient.QueueAsync(payload.ThreadId, digest.Text, cancellationToken); return new CodexNotifyOutcome { Queued = queueResult == CodexQueueResult.Ok }; } /// - /// The unread-mail nudge for this session on , - /// or null when nothing is unread or every unread message was already - /// announced there. It names the command that reads the mail; the mail - /// itself stays in the inbox. + /// The unread-mail digest for this session on , + /// or null when nothing is unread or every message is already reserved on + /// that channel. /// - private async Task BuildDigestAsync( + private async Task BuildDigestAsync( AgentSessionGeneration generation, string actor, string channel, CancellationToken cancellationToken) { var unread = await mailStore.QueryInboxAsync( - new MailInboxFilter { Actor = actor, UnreadOnly = true, Limit = MaxDigestMessages }, + new MailInboxFilter { Actor = actor, UnreadOnly = true, Limit = MailDigestPolicy.MaxMessages }, cancellationToken); if (unread.Count == 0) @@ -215,10 +215,12 @@ public async Task HandleNotifyAsync( return null; } + var messageIds = unread.Select(message => message.Id).ToList(); + var delivered = await ledger.FindDeliveredAsync(generation, messageIds, cancellationToken); var reserved = await ledger.ReserveAsync( generation.Harness, generation.SessionId, - unread.Select(m => m.Id).ToList(), + messageIds, channel, timeProvider.GetUtcNow(), cancellationToken); @@ -228,7 +230,15 @@ public async Task HandleNotifyAsync( return null; } - return MailNudgeText.Format(actor, await mailStore.CountUnreadAsync(actor, cancellationToken)); + var reservedIds = reserved.ToHashSet(StringComparer.Ordinal); + var deliveredIds = delivered.ToHashSet(StringComparer.Ordinal); + var messages = unread + .Where(message => reservedIds.Contains(message.Id) && !deliveredIds.Contains(message.Id)) + .ToList(); + var unreadTotal = await mailStore.CountUnreadAsync(actor, cancellationToken); + + return new MailDigestResult( + MailDigest.Render(actor, messages, unreadTotal)); } /// @@ -272,4 +282,6 @@ public async Task HandleNotifyAsync( } private sealed record ResolvedGeneration(AgentSessionGeneration Generation, string WorkspaceDirectory); + + private sealed record MailDigestResult(string Text); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Mail/IMailStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/IMailStore.cs index f16d58cf0ec..c6c3060c4b4 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Mail/IMailStore.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/IMailStore.cs @@ -76,6 +76,16 @@ Task ReplyMessageAsync( CancellationToken cancellationToken) => ReplyMessageAsync(inReplyToId, sender, body, MailWakePolicy.Skip, cancellationToken); + /// + /// Transfers mail participation from one agent to another. Recipient + /// conflicts preserve the target agent's recipient state. + /// + Task TransferParticipationAsync( + string from, + string to, + CancellationToken cancellationToken) + => Task.FromException(new NotSupportedException()); + /// /// Returns the message with the given ID, with its recipients embedded, /// or null. diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/MailMessageDetailResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailMessageDetailResult.cs similarity index 78% rename from src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/MailMessageDetailResult.cs rename to src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailMessageDetailResult.cs index 3929997eb02..6c3bd937c21 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Mail/MailMessageDetailResult.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailMessageDetailResult.cs @@ -1,6 +1,6 @@ -using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Workspace; -namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Mail; +namespace ChilliCream.Nitro.CommandLine.Services.Mail; /// /// A message's full detail, as returned by the structured (JSON) output of @@ -19,8 +19,12 @@ internal sealed record MailMessageDetailResult public required DateTimeOffset CreatedAt { get; init; } public required bool Read { get; init; } public required bool Archived { get; init; } + public required IReadOnlyList Takeovers { get; init; } - public static MailMessageDetailResult Create(MailMessage message, string actor) + public static MailMessageDetailResult Create( + MailMessage message, + string actor, + IReadOnlyList takeovers) { var recipient = message.Recipients.FirstOrDefault(r => r.Name == actor); @@ -44,7 +48,8 @@ public static MailMessageDetailResult Create(MailMessage message, string actor) Body = message.Body, CreatedAt = message.CreatedAt, Read = recipient?.ReadAt is not null, - Archived = recipient?.ArchivedAt is not null + Archived = recipient?.ArchivedAt is not null, + Takeovers = takeovers }; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailStore.cs index ec112da158d..d7ab7baa19a 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailStore.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailStore.cs @@ -250,6 +250,82 @@ INSERT INTO messages ( }; } + public async Task TransferParticipationAsync( + string from, + string to, + CancellationToken cancellationToken) + { + var source = MailAgentName.Normalize(from); + var target = MailAgentName.Normalize(to); + + if (source == target) + { + throw new ExitException("The source and target agents must be different."); + } + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + var targetExists = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM agents WHERE name = @target", + new { target, cancellationToken }, + transaction); + + if (targetExists == 0) + { + throw new ExitException($"Target agent '{target}' does not exist."); + } + + var senderMessageIds = (await connection.QueryAsync( + "SELECT id FROM messages WHERE sender = @source ORDER BY id", + new { source, cancellationToken }, + transaction)).ToArray(); + + var dropped = await connection.ExecuteAsync( + """ + DELETE FROM message_recipients AS source + WHERE source.recipient = @source + AND EXISTS ( + SELECT 1 + FROM message_recipients AS target + WHERE target.message_id = source.message_id + AND target.recipient = @target) + """, + new { source, target, cancellationToken }, + transaction); + + var recipientMessageIds = (await connection.QueryAsync( + "SELECT message_id FROM message_recipients WHERE recipient = @source ORDER BY message_id", + new { source, cancellationToken }, + transaction)).ToArray(); + + var recipientsMoved = await connection.ExecuteAsync( + """ + UPDATE message_recipients + SET recipient = @target + WHERE recipient = @source + """, + new { source, target, cancellationToken }, + transaction); + + var sendersMoved = await connection.ExecuteAsync( + """ + UPDATE messages + SET sender = @target + WHERE sender = @source + """, + new { source, target, cancellationToken }, + transaction); + + await transaction.CommitAsync(cancellationToken); + + return new MailTransferResult(recipientsMoved, sendersMoved, dropped) + { + SenderMessageIds = senderMessageIds, + RecipientMessageIds = recipientMessageIds + }; + } + /// /// Resolves this machine's Nitro instance id for a /// send or reply. Throws diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailTransferResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailTransferResult.cs new file mode 100644 index 00000000000..2306ed7117f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Mail/MailTransferResult.cs @@ -0,0 +1,11 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Mail; + +internal sealed record MailTransferResult( + int RecipientsMoved, + int SendersMoved, + int Dropped) +{ + public IReadOnlyList SenderMessageIds { get; init; } = []; + + public IReadOnlyList RecipientMessageIds { get; init; } = []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigest.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigest.cs new file mode 100644 index 00000000000..d08bf49a6e9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigest.cs @@ -0,0 +1,66 @@ +using System.Text; +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services.Mail; + +namespace ChilliCream.Nitro.CommandLine.Services.Notify; + +internal static class MailDigest +{ + public static string Render( + string actor, + IReadOnlyList messages, + int unreadTotal) + { + if (messages.Count == 0) + { + return MailNudgeText.Format(actor, unreadTotal); + } + + var candidates = messages + .OrderBy(message => message.CreatedAt) + .ThenBy(message => message.Id, StringComparer.Ordinal) + .Take(MailDigestPolicy.MaxMessages) + .Select(message => MailMessageDetailResult.Create(message, actor, [])) + .Select(message => TruncateBody(message, actor)) + .ToList(); + + while (candidates.Count > 0) + { + var digest = Render(actor, candidates, unreadTotal); + + if (Encoding.UTF8.GetByteCount(digest) <= MailDigestPolicy.MaxTotalBytes) + { + return digest; + } + + candidates.RemoveAt(candidates.Count - 1); + } + + return MailNudgeText.Format(actor, unreadTotal); + } + + private static string Render(string actor, IReadOnlyList messages, int unreadTotal) + => $"You have {unreadTotal} unread nitro message{(unreadTotal == 1 ? "" : "s")}; {messages.Count} shown below as " + + "`nitro agent mail read --thread --output json` prints them. " + + $"Reply with `nitro agent mail reply --message --actor {actor} --body \"...\"` " + + $"or ack with `nitro agent mail ack --message --actor {actor}`; anything not shown is in " + + $"`nitro agent mail inbox --unread --actor {actor}`.\n" + + JsonSerializer.Serialize( + new ListResult(messages), + JsonSourceGenerationContext.Default.ListResultMailMessageDetailResult); + + private static MailMessageDetailResult TruncateBody(MailMessageDetailResult message, string actor) + { + if (message.Body.Length <= MailDigestPolicy.MaxBodyChars) + { + return message; + } + + return message with + { + Body = message.Body[..MailDigestPolicy.MaxBodyChars] + + $"\n[body truncated: nitro agent mail read --message {message.Id} --actor {actor}]" + }; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigestPolicy.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigestPolicy.cs new file mode 100644 index 00000000000..153675dd0ca --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailDigestPolicy.cs @@ -0,0 +1,8 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Notify; + +internal static class MailDigestPolicy +{ + public const int MaxMessages = 10; + public const int MaxBodyChars = 4_000; + public const int MaxTotalBytes = 32_768; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs index 0dea0382811..55c6660cf77 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs @@ -7,10 +7,26 @@ namespace ChilliCream.Nitro.CommandLine.Services.Notify; internal sealed class MailNudge( IAgentSessionRegistry sessions, IMailStore mail, + ISessionDeliveryLedger ledger, IClaudePeerClient claudePeerClient, - ICodexQueueClient codexQueueClient) : IMailNudge + ICodexQueueClient codexQueueClient, + TimeProvider timeProvider) : IMailNudge { public async Task NudgeAsync(IReadOnlyList actors, CancellationToken cancellationToken) + { + try + { + await NudgeCoreAsync(actors, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Best effort: the recipients' next turns report the unread mail. + } + } + + private async Task NudgeCoreAsync( + IReadOnlyList actors, + CancellationToken cancellationToken) { if (actors.Count == 0) { @@ -32,18 +48,53 @@ public async Task NudgeAsync(IReadOnlyList actors, CancellationToken can continue; } - var unread = await mail.CountUnreadAsync(actor, cancellationToken); - - if (unread == 0) + foreach (var target in targets) { - continue; - } + try + { + var unread = await mail.QueryInboxAsync( + new MailInboxFilter + { + Actor = actor, + UnreadOnly = true, + Limit = MailDigestPolicy.MaxMessages + }, + cancellationToken); - var text = MailNudgeText.Format(actor, unread); + if (unread.Count == 0) + { + continue; + } - foreach (var target in targets) - { - await SendAsync(target.Session, text, cancellationToken); + var generation = new AgentSessionGeneration( + target.Session.Harness, + target.Session.SessionId, + target.Session.Host); + var messageIds = unread.Select(message => message.Id).ToList(); + var delivered = await ledger.FindDeliveredAsync( + generation, messageIds, cancellationToken); + var reserved = await ledger.ReserveAsync( + generation.Harness, + generation.SessionId, + messageIds, + AgentSessionChannel.Ping, + timeProvider.GetUtcNow(), + cancellationToken); + var reservedIds = reserved.ToHashSet(StringComparer.Ordinal); + var deliveredIds = delivered.ToHashSet(StringComparer.Ordinal); + var messages = unread + .Where(message => + reservedIds.Contains(message.Id) && !deliveredIds.Contains(message.Id)) + .ToList(); + var unreadTotal = await mail.CountUnreadAsync(actor, cancellationToken); + var text = MailDigest.Render(actor, messages, unreadTotal); + + await SendAsync(target.Session, text, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Best effort: the recipient's next turn reports the unread mail. + } } } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingPolicy.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingPolicy.cs index a51f006d7c7..e6f9cf36a26 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingPolicy.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingPolicy.cs @@ -15,12 +15,4 @@ internal static class PingPolicy public static readonly TimeSpan Cooldown = TimeSpan.FromSeconds(60); public static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30); public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(20); - - /// - /// Shared with the Claude and Codex turn-boundary digests - /// (CodexHookHandler.MaxDigestMessages): the digest envelope, its - /// per-call message cap, and its byte ceiling are harness-neutral by - /// design. - /// - public const int MaxDigestMessages = 10; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs index 963fb9d637e..f0c8a7b29c3 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs @@ -6,6 +6,7 @@ namespace ChilliCream.Nitro.CommandLine.Services.Notify; internal sealed class PingSessionExecutor( IMailStore mailStore, + ISessionDeliveryLedger ledger, ICodexQueueClient queueClient, IClaudePeerClient claudePeerClient, IAgentSessionRegistry sessionRegistry, @@ -89,7 +90,8 @@ private async Task ExecuteAsync( try { - digest = await BuildDigestAsync(actorName, linkedSource.Token); + digest = await BuildDigestAsync( + harness, sessionId, actorName, linkedSource.Token); } catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) { @@ -135,10 +137,14 @@ private async Task ExecuteAsync( } } - private async Task BuildDigestAsync(string actorName, CancellationToken cancellationToken) + private async Task BuildDigestAsync( + string harness, + string sessionId, + string actorName, + CancellationToken cancellationToken) { var unread = await mailStore.QueryInboxAsync( - new MailInboxFilter { Actor = actorName, UnreadOnly = true, Limit = PingPolicy.MaxDigestMessages }, + new MailInboxFilter { Actor = actorName, UnreadOnly = true, Limit = MailDigestPolicy.MaxMessages }, cancellationToken); if (unread.Count == 0) @@ -146,8 +152,35 @@ private async Task ExecuteAsync( return null; } - return MailNudgeText.Format( - actorName, await mailStore.CountUnreadAsync(actorName, cancellationToken)); + var session = (await sessionRegistry.FindLiveClaimedByAgentNameAsync(actorName, cancellationToken)) + .FirstOrDefault(candidate => + candidate.Harness == harness && candidate.SessionId == sessionId); + + if (session is null) + { + return null; + } + + var generation = new AgentSessionGeneration( + session.Harness, session.SessionId, session.Host); + var messageIds = unread.Select(message => message.Id).ToList(); + var delivered = await ledger.FindDeliveredAsync( + generation, messageIds, cancellationToken); + var reserved = await ledger.ReserveAsync( + generation.Harness, + generation.SessionId, + messageIds, + AgentSessionChannel.Ping, + timeProvider.GetUtcNow(), + cancellationToken); + var reservedIds = reserved.ToHashSet(StringComparer.Ordinal); + var deliveredIds = delivered.ToHashSet(StringComparer.Ordinal); + var messages = unread + .Where(message => reservedIds.Contains(message.Id) && !deliveredIds.Contains(message.Id)) + .ToList(); + var unreadTotal = await mailStore.CountUnreadAsync(actorName, cancellationToken); + + return MailDigest.Render(actorName, messages, unreadTotal); } /// diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonResultFormatter.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonResultFormatter.cs index 5809ed28667..516bfecda0a 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonResultFormatter.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonResultFormatter.cs @@ -22,6 +22,6 @@ private void Serialize(object value) { var serializedObj = JsonSerializer.Serialize(value, value.GetType(), JsonSourceGenerationContext.Default); - console.Out.WriteLine(serializedObj); + console.WriteRawLine(serializedObj); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs index 2fa6b1d990f..700b1973c57 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using ChilliCream.Nitro.CommandLine.Commands.Agent; +using ChilliCream.Nitro.CommandLine.Commands.Agent.Mail; using ChilliCream.Nitro.CommandLine.Commands.ApiKeys; using ChilliCream.Nitro.CommandLine.Commands.ApiKeys.Components; using ChilliCream.Nitro.CommandLine.Commands.Apis.Components; @@ -7,7 +8,7 @@ using ChilliCream.Nitro.CommandLine.Commands.Clients.Components; using ChilliCream.Nitro.CommandLine.Commands.Environments.Components; using ChilliCream.Nitro.CommandLine.Commands.Fusion.Publish; -using ChilliCream.Nitro.CommandLine.Commands.Agent.Mail; +using ChilliCream.Nitro.CommandLine.Services.Mail; using ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks.Config; using ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks.Dependency; using ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks.Label; @@ -21,6 +22,7 @@ using ChilliCream.Nitro.CommandLine.Commands.Workspaces.Components; using ChilliCream.Nitro.CommandLine.Services.Memory; using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Results; @@ -80,6 +82,9 @@ namespace ChilliCream.Nitro.CommandLine.Results; [JsonSerializable(typeof(ListResult))] [JsonSerializable(typeof(RegisterAgentCommand.AgentRegisterResult))] [JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(TakeoverAgentCommand.AgentTakeoverResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(TakeoverReferenceResult))] [JsonSerializable(typeof(MailMessageResult))] [JsonSerializable(typeof(MailSendResult))] [JsonSerializable(typeof(ListResult))] diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs index 21b010807dd..b18e7137e99 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs @@ -193,6 +193,45 @@ async Task> UpdateTasksAsync( return results; } + /// + /// Reassigns active tasks from one assignee to another and adds the given + /// comment to each reassigned task. Implementations should apply the + /// reassignment atomically. + /// + async Task> ReassignAsync( + string from, + string to, + string actor, + string comment, + CancellationToken cancellationToken) + { + if (from == to) + { + throw new ExitException("The source and target assignees must differ."); + } + + var tasks = await QueryTasksAsync( + new TaskFilter { Assignee = from, IncludeAll = true, IncludeArchived = true }, + cancellationToken); + var ids = tasks + .Where(task => task.Status is not ( + TaskStates.Closed or TaskStates.Tombstone or TaskStates.Archived)) + .Select(task => task.Id) + .Order(StringComparer.Ordinal) + .ToArray(); + + foreach (var id in ids) + { + await UpdateTaskAsync( + id, + new TaskUpdate { Actor = actor, Assignee = to, AssigneeGiven = true }, + cancellationToken); + await AddCommentAsync(id, comment, actor, cancellationToken); + } + + return ids; + } + /// /// Closes every given task and records a closed event for each. All /// tasks are validated before any is written: either every task closes diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs index 4b5220e65cd..fcd37f07168 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs @@ -1,3 +1,5 @@ +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Services.Tasks; /// @@ -30,4 +32,5 @@ internal sealed record TaskDetailResult public required IReadOnlyList Dependencies { get; init; } public required IReadOnlyList Dependents { get; init; } public required IReadOnlyList Comments { get; init; } + public required IReadOnlyList Takeovers { get; init; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs index eb987505fed..3370a3b4118 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs @@ -1019,6 +1019,62 @@ await WriteUpdateAsync( return changes.Select(change => change.Task).ToArray(); } + public async Task> ReassignAsync( + string from, + string to, + string actor, + string comment, + CancellationToken cancellationToken) + { + if (from == to) + { + throw new ExitException("The source and target assignees must differ."); + } + + if (string.IsNullOrWhiteSpace(comment)) + { + throw new ExitException("The comment text must not be empty."); + } + + var now = timeProvider.GetUtcNow(); + var update = new TaskUpdate { Actor = actor, Assignee = to, AssigneeGiven = true }; + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var tasks = (await connection.QueryAsync( + $""" + SELECT {TaskItem.Columns} + FROM tasks + WHERE assignee = @from + AND status NOT IN (@closed, @tombstone, @archived) + ORDER BY id ASC + """, + new + { + from, + closed = TaskStates.Closed, + tombstone = TaskStates.Tombstone, + archived = TaskStates.Archived, + cancellationToken + }, + transaction)).Select(row => row.ToTaskItem()).ToList(); + + foreach (var task in tasks) + { + var change = ApplyUpdate(task, update); + task.UpdatedAt = now; + + await WriteUpdateAsync( + connection, transaction, task, change, actor, now, cancellationToken); + await AddCommentAsync( + connection, transaction, task, comment, actor, now, cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + + return tasks.Select(task => task.Id).ToArray(); + } + /// /// Validates the given update against the task's current state and /// applies the resulting field changes to in @@ -1782,6 +1838,23 @@ public async Task AddCommentAsync( await using var transaction = await connection.BeginTransactionAsync(cancellationToken); var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + var comment = await AddCommentAsync( + connection, transaction, task, text, actor, now, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return comment; + } + + private async Task AddCommentAsync( + SqliteConnection connection, + DbTransaction transaction, + TaskItem task, + string text, + string actor, + DateTimeOffset now, + CancellationToken cancellationToken) + { var commentId = await connection.ExecuteScalarAsync( """ INSERT INTO comments ( @@ -1818,8 +1891,6 @@ await RecordEventAsync( cancellationToken, transaction); - await transaction.CommitAsync(cancellationToken); - return new TaskComment { Id = commentId, diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs index 0773517fa32..a2b9bad9c39 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs @@ -20,7 +20,7 @@ internal sealed class AgentDatabase /// database at a legacy path carrying either of those versions is /// migrated, not opened here. /// - public const int CurrentVersion = 11; + public const int CurrentVersion = 12; /// /// Schema versions upgraded in place by @@ -57,7 +57,9 @@ internal sealed class AgentDatabase /// one-session/one-actor invariant starts from a consistent state. The /// v10-to-v11 upgrade adds the memory tables and carries any markdown /// memory store found beside the workspace into them; see - /// . The + /// . The v11-to-v12 upgrade adds the + /// takeover audit ledger tables without changing existing workspace + /// state. The /// v9-to-v10 upgrade drops the pid and proc_start columns /// (and agent_sessions' process_scope and /// proc_start_legacy) from all three tables that carried them: @@ -67,7 +69,7 @@ internal sealed class AgentDatabase /// constraint also triggers on a surviving pid column and copies /// every row across without it. /// - private static readonly int[] s_upgradableVersions = [2, 3, 4, 5, 6, 7, 8, 9, 10]; + private static readonly int[] s_upgradableVersions = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; /// /// True for a schema version upgrades in @@ -105,14 +107,26 @@ public async Task InitializeAsync( AgentWorkspace.GetDatabasePath(workspaceDirectory), cancellationToken); - // Validated before anything else touches the file, including the - // constraint rebuild below: a database newer than this CLI - // understands must be rejected untouched, not partially rewritten - // by a rebuild built against this CLI's own idea of the table's - // shape. - var version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); + long version; - ValidateVersionForInitialize(version); + try + { + // Validated before anything else touches the file, including the + // constraint rebuild below: a database newer than this CLI + // understands must be rejected untouched, not partially rewritten + // by a rebuild built against this CLI's own idea of the table's + // shape. + version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); + + ValidateVersionForInitialize(version); + + await ConfigureAcceptedConnectionAsync(connection, cancellationToken); + } + catch + { + await connection.DisposeAsync(); + throw; + } // Must run before the main transaction below starts, and manages its // own: PRAGMA foreign_keys can only be toggled when there is no @@ -159,6 +173,11 @@ public async Task InitializeAsync( // upgradable version alike. await connection.ExecuteAsync(MemoryStoreSchema.Create, transaction: transaction); + // v12: the takeover audit ledger has no foreign keys to mutable + // agent, mail, or task state, so adding both tables is safe for a + // fresh database and every upgradable version. + await connection.ExecuteAsync(TakeoverLedgerSchema.Create, transaction: transaction); + // Runs against every version, not just v10: the markdown store is // detected by its own presence on disk, and the import skips ids the // database already carries, so a workspace that has already been @@ -605,11 +624,21 @@ public async Task ConnectAsync( AgentWorkspace.GetDatabasePath(workspaceDirectory), cancellationToken); - var version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); + try + { + var version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); - ValidateVersionForConnect(version); + ValidateVersionForConnect(version); - return connection; + await ConfigureAcceptedConnectionAsync(connection, cancellationToken); + + return connection; + } + catch + { + await connection.DisposeAsync(); + throw; + } } /// @@ -677,9 +706,14 @@ private static async Task OpenAsync( var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); await connection.OpenAsync(cancellationToken); - await connection.ExecuteAsync( - "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); - return connection; } + + private static Task ConfigureAcceptedConnectionAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + => connection.ExecuteAsync( + new CommandDefinition( + "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;", + cancellationToken: cancellationToken)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRole.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRole.cs index efceda7aa0d..e42bfcc7974 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRole.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRole.cs @@ -1,10 +1,26 @@ namespace ChilliCream.Nitro.CommandLine.Services.Workspace; /// -/// Normalizes agent role values used throughout the agent registry. +/// Defines the well-known agent roles and normalizes role values used throughout the agent registry. +/// Custom normalized roles are also accepted. /// internal static class AgentRole { + public const string Orchestrator = "orchestrator"; + public const string Planner = "planner"; + public const string Implementer = "implementer"; + public const string Reviewer = "reviewer"; + public const string Researcher = "researcher"; + + public static IReadOnlyList WellKnown { get; } = Array.AsReadOnly( + [ + Orchestrator, + Planner, + Implementer, + Reviewer, + Researcher + ]); + /// /// Trims and lowercases the given value. A null or whitespace-only value /// normalizes to the empty string; unlike an agent name, a role may be diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ISessionDeliveryLedger.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ISessionDeliveryLedger.cs index e86009e9603..39125470e05 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ISessionDeliveryLedger.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ISessionDeliveryLedger.cs @@ -10,6 +10,17 @@ namespace ChilliCream.Nitro.CommandLine.Services.Workspace; /// internal interface ISessionDeliveryLedger { + /// + /// Returns the message ids from that have + /// been delivered to , across all channels. + /// The returned ids retain the input order, and an empty input returns an empty result. + /// + Task> FindDeliveredAsync( + AgentSessionGeneration generation, + IReadOnlyList messageIds, + CancellationToken cancellationToken) + => Task.FromException>(new NotSupportedException()); + /// /// Atomically claims each of for /// on the given session. Returns the subset diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ITakeoverLedger.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ITakeoverLedger.cs new file mode 100644 index 00000000000..ffd97adcb26 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/ITakeoverLedger.cs @@ -0,0 +1,22 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// Records and queries the immutable audit trail for actor takeovers in an agent workspace. +/// +internal interface ITakeoverLedger +{ + /// + /// Records a takeover and its related items atomically. An empty item collection still records the takeover header. + /// + Task RecordAsync( + TakeoverRecordCreation creation, + IReadOnlyList items, + CancellationToken cancellationToken); + + /// + /// Returns takeover records matching every supplied filter, newest first, with their related items. + /// + Task> QueryAsync( + TakeoverFilter filter, + CancellationToken cancellationToken); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/SessionDeliveryLedger.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/SessionDeliveryLedger.cs index 07c4c9b2b7b..228f9aa69aa 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/SessionDeliveryLedger.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/SessionDeliveryLedger.cs @@ -4,6 +4,48 @@ namespace ChilliCream.Nitro.CommandLine.Services.Workspace; internal sealed class SessionDeliveryLedger(IFileSystem fileSystem, AgentDatabase database) : ISessionDeliveryLedger { + public async Task> FindDeliveredAsync( + AgentSessionGeneration generation, + IReadOnlyList messageIds, + CancellationToken cancellationToken) + { + if (messageIds.Count == 0) + { + return []; + } + + var workspaceDirectory = AgentWorkspace.Find(fileSystem, fileSystem.GetCurrentDirectory()) + ?? throw new ExitException("No agent workspace found. Run `nitro agent init` first."); + + await using var connection = await database.ConnectAsync(workspaceDirectory, cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT DISTINCT message_id + FROM session_deliveries + WHERE harness = @harness AND session_id = @sessionId AND message_id IN ( + """ + + string.Join(", ", messageIds.Select((_, index) => $"@messageId{index}")) + + ");"; + command.Parameters.AddWithValue("@harness", generation.Harness); + command.Parameters.AddWithValue("@sessionId", generation.SessionId); + + for (var i = 0; i < messageIds.Count; i++) + { + command.Parameters.AddWithValue($"@messageId{i}", messageIds[i]); + } + + var delivered = new HashSet(StringComparer.Ordinal); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + while (await reader.ReadAsync(cancellationToken)) + { + delivered.Add(reader.GetString(0)); + } + + return messageIds.Where(delivered.Contains).ToArray(); + } + public async Task> ReserveAsync( string harness, string sessionId, diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverFilter.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverFilter.cs new file mode 100644 index 00000000000..d596d79c5d4 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverFilter.cs @@ -0,0 +1,26 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// The optional criteria for querying takeover audit records. Every supplied criterion applies as an AND condition. +/// +internal sealed record TakeoverFilter +{ + private readonly int? _limit; + + public string? Actor { get; init; } + public string? MessageId { get; init; } + public string? TaskId { get; init; } + public int? Limit + { + get => _limit; + init + { + if (value < 0) + { + throw ThrowHelper.NegativeLimit(value.Value); + } + + _limit = value; + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItem.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItem.cs new file mode 100644 index 00000000000..2a42f204cf9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItem.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// A mail or task item related to an actor takeover. +/// +internal sealed record TakeoverItem +{ + public required string Kind { get; init; } + public required string ItemId { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItemKinds.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItemKinds.cs new file mode 100644 index 00000000000..28a86d71e38 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverItemKinds.cs @@ -0,0 +1,11 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// The supported kinds of items attached to a takeover audit record. +/// +internal static class TakeoverItemKinds +{ + public const string MessageSender = "message_sender"; + public const string MessageRecipient = "message_recipient"; + public const string Task = "task"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedger.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedger.cs new file mode 100644 index 00000000000..efb5e01dccb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedger.cs @@ -0,0 +1,264 @@ +using System.Data.Common; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +internal sealed class TakeoverLedger( + IFileSystem fileSystem, + TimeProvider timeProvider, + AgentDatabase database) : ITakeoverLedger +{ + private const string IdPrefix = "to-"; + private const string IdAlphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + private const int MinIdLength = 6; + private const int MaxIdAttempts = 10; + + public async Task RecordAsync( + TakeoverRecordCreation creation, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var workspaceDirectory = FindWorkspaceDirectory() + ?? throw new ExitException("No agent workspace found. Run `nitro agent init` first."); + var createdAt = timeProvider.GetUtcNow(); + + await using var connection = await database.ConnectAsync(workspaceDirectory, cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + var id = await CreateTakeoverIdAsync( + connection, + $"{creation.FromActor}|{creation.ToActor}|{creation.Actor}|{createdAt:O}", + cancellationToken, + transaction); + + var headerParameters = new DynamicParameters(); + headerParameters.Add("Id", id); + headerParameters.Add("FromActor", creation.FromActor); + headerParameters.Add("ToActor", creation.ToActor); + headerParameters.Add("Actor", creation.Actor); + headerParameters.Add("CreatedAt", createdAt); + headerParameters.Add("Forced", creation.Forced); + headerParameters.Add("Role", creation.Role); + headerParameters.Add("Reason", creation.Reason); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO agent_takeovers ( + id, from_actor, to_actor, actor, created_at, forced, role, reason + ) + VALUES ( + @Id, @FromActor, @ToActor, @Actor, @CreatedAt, @Forced, @Role, @Reason + ); + """, + headerParameters, + transaction, + cancellationToken: cancellationToken)); + + foreach (var item in items) + { + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO agent_takeover_items (takeover_id, kind, item_id) + VALUES (@TakeoverId, @Kind, @ItemId); + """, + new { TakeoverId = id, item.Kind, item.ItemId }, + transaction, + cancellationToken: cancellationToken)); + } + + await transaction.CommitAsync(cancellationToken); + + return new TakeoverRecord + { + Id = id, + FromActor = creation.FromActor, + ToActor = creation.ToActor, + Actor = creation.Actor, + CreatedAt = createdAt, + Forced = creation.Forced, + Role = creation.Role, + Reason = creation.Reason, + Items = items.ToArray() + }; + } + + public async Task> QueryAsync( + TakeoverFilter filter, + CancellationToken cancellationToken) + { + var workspaceDirectory = FindWorkspaceDirectory() + ?? throw new ExitException("No agent workspace found. Run `nitro agent init` first."); + + await using var connection = await database.ConnectAsync(workspaceDirectory, cancellationToken); + + var where = new List(); + var parameters = new DynamicParameters(); + + if (filter.Actor is not null) + { + where.Add("(t.from_actor = @Actor OR t.to_actor = @Actor)"); + parameters.Add("Actor", filter.Actor); + } + + if (filter.MessageId is not null) + { + where.Add( + """ + EXISTS ( + SELECT 1 + FROM agent_takeover_items AS i + WHERE i.takeover_id = t.id + AND i.item_id = @MessageId + AND i.kind IN ('message_sender', 'message_recipient') + ) + """); + parameters.Add("MessageId", filter.MessageId); + } + + if (filter.TaskId is not null) + { + where.Add( + """ + EXISTS ( + SELECT 1 + FROM agent_takeover_items AS i + WHERE i.takeover_id = t.id + AND i.kind = 'task' + AND i.item_id = @TaskId + ) + """); + parameters.Add("TaskId", filter.TaskId); + } + + parameters.Add("Limit", filter.Limit); + + var rows = (await connection.QueryAsync( + new CommandDefinition( + $""" + SELECT {TakeoverRecord.Columns} + FROM agent_takeovers AS t + {(where.Count == 0 ? string.Empty : $"WHERE {string.Join(" AND ", where)}")} + ORDER BY t.created_at DESC, t.id DESC + LIMIT COALESCE(@Limit, -1); + """, + parameters, + cancellationToken: cancellationToken))).ToArray(); + + if (rows.Length == 0) + { + return []; + } + + var itemParameters = new DynamicParameters(); + var itemNames = new string[rows.Length]; + + for (var index = 0; index < rows.Length; index++) + { + var name = $"TakeoverId{index}"; + itemNames[index] = $"@{name}"; + itemParameters.Add(name, rows[index].Id); + } + + var itemRows = await connection.QueryAsync( + new CommandDefinition( + $""" + SELECT takeover_id AS TakeoverId, kind AS Kind, item_id AS ItemId + FROM agent_takeover_items + WHERE takeover_id IN ({string.Join(", ", itemNames)}) + ORDER BY takeover_id, kind, item_id; + """, + itemParameters, + cancellationToken: cancellationToken)); + var itemsByTakeoverId = itemRows + .GroupBy(item => item.TakeoverId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group + .Select(item => new TakeoverItem { Kind = item.Kind, ItemId = item.ItemId }) + .ToArray(), StringComparer.Ordinal); + + return rows.Select(row => new TakeoverRecord + { + Id = row.Id, + FromActor = row.FromActor, + ToActor = row.ToActor, + Actor = row.Actor, + CreatedAt = DateTimeOffset.Parse(row.CreatedAt, CultureInfo.InvariantCulture), + Forced = row.Forced, + Role = row.Role, + Reason = row.Reason, + Items = itemsByTakeoverId.GetValueOrDefault(row.Id, []) + }).ToArray(); + } + + private string? FindWorkspaceDirectory() + => AgentWorkspace.Find(fileSystem, fileSystem.GetCurrentDirectory()); + + private static async Task CreateTakeoverIdAsync( + SqliteConnection connection, + string seed, + CancellationToken cancellationToken, + DbTransaction transaction) + { + var takeoverCount = await connection.ExecuteScalarAsync( + new CommandDefinition( + "SELECT COUNT(*) FROM agent_takeovers", + transaction: transaction, + cancellationToken: cancellationToken)); + + for (var attempt = 0; attempt < MaxIdAttempts; attempt++) + { + var id = IdPrefix + CreateIdSuffix(seed, takeoverCount, attempt); + var exists = await connection.ExecuteScalarAsync( + new CommandDefinition( + "SELECT COUNT(*) FROM agent_takeovers WHERE id = @Id", + new { Id = id }, + transaction, + cancellationToken: cancellationToken)); + + if (exists == 0) + { + return id; + } + } + + throw new ExitException("Could not allocate a unique takeover ID."); + } + + private static string CreateIdSuffix(string seed, long takeoverCount, int attempt) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{seed}|{takeoverCount}|{attempt}")); + var length = MinIdLength + attempt / 3; + var suffix = new char[length]; + + for (var index = 0; index < length; index++) + { + suffix[index] = IdAlphabet[hash[index] % IdAlphabet.Length]; + } + + return new string(suffix); + } + + private sealed class TakeoverItemRow + { + public required string TakeoverId { get; init; } + public required string Kind { get; init; } + public required string ItemId { get; init; } + } + + internal sealed class TakeoverRecordRow + { + public required string Id { get; init; } + public required string FromActor { get; init; } + public required string ToActor { get; init; } + public required string Actor { get; init; } + public required string CreatedAt { get; init; } + public required bool Forced { get; init; } + public string? Role { get; init; } + public string? Reason { get; init; } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedgerSchema.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedgerSchema.cs new file mode 100644 index 00000000000..4ea2c57de40 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverLedgerSchema.cs @@ -0,0 +1,36 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +internal static class TakeoverLedgerSchema +{ + /// + /// The complete schema for recorded actor takeovers. Statements are idempotent so applying them to an existing database is non-destructive. + /// + public const string Create = + """ + CREATE TABLE IF NOT EXISTS agent_takeovers ( + id TEXT PRIMARY KEY, + from_actor TEXT NOT NULL, + to_actor TEXT NOT NULL, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + forced INTEGER NOT NULL, + role TEXT NULL, + reason TEXT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_agent_takeovers_from_actor + ON agent_takeovers (from_actor); + CREATE INDEX IF NOT EXISTS idx_agent_takeovers_to_actor + ON agent_takeovers (to_actor); + + CREATE TABLE IF NOT EXISTS agent_takeover_items ( + takeover_id TEXT NOT NULL REFERENCES agent_takeovers (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('message_sender', 'message_recipient', 'task')), + item_id TEXT NOT NULL, + PRIMARY KEY (takeover_id, kind, item_id) + ); + + CREATE INDEX IF NOT EXISTS idx_agent_takeover_items_kind_item_id + ON agent_takeover_items (kind, item_id); + """; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecord.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecord.cs new file mode 100644 index 00000000000..40532e0ac64 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecord.cs @@ -0,0 +1,21 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// An immutable audit record for an actor takeover. Null role and reason values mean the takeover did not record those details. +/// +internal sealed class TakeoverRecord +{ + public const string Columns = + "id AS Id, from_actor AS FromActor, to_actor AS ToActor, actor AS Actor, " + + "created_at AS CreatedAt, forced AS Forced, role AS Role, reason AS Reason"; + + public required string Id { get; init; } + public required string FromActor { get; init; } + public required string ToActor { get; init; } + public required string Actor { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required bool Forced { get; init; } + public string? Role { get; init; } + public string? Reason { get; init; } + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecordCreation.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecordCreation.cs new file mode 100644 index 00000000000..7b96dd57a2e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverRecordCreation.cs @@ -0,0 +1,14 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// The details required to record an actor takeover. Null role and reason values omit those optional audit details. +/// +internal sealed record TakeoverRecordCreation +{ + public required string FromActor { get; init; } + public required string ToActor { get; init; } + public required string Actor { get; init; } + public required bool Forced { get; init; } + public string? Role { get; init; } + public string? Reason { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverReferenceResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverReferenceResult.cs new file mode 100644 index 00000000000..9dfb4d5007d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/TakeoverReferenceResult.cs @@ -0,0 +1,21 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// Identifies a takeover that moved a mail message or task. +/// +internal sealed record TakeoverReferenceResult +{ + public required string Id { get; init; } + public required string From { get; init; } + public required string To { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + + public static TakeoverReferenceResult FromRecord(TakeoverRecord record) + => new() + { + Id = record.Id, + From = record.FromActor, + To = record.ToActor, + CreatedAt = record.CreatedAt + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs b/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs index 68f5f9ea758..2ce325abe33 100644 --- a/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs +++ b/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs @@ -23,4 +23,7 @@ public static Exception CouldNotSelectEdges() public static ExitException MutationReturnedNoData() => Exit("The GraphQL mutation completed without errors, but the server did not return the expected data."); + + public static ArgumentOutOfRangeException NegativeLimit(int limit) + => new(nameof(limit), limit, "Limit must be zero or greater."); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/DefaultTheme.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/DefaultTheme.cs index 8219d37b1d6..270926d2bbe 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/DefaultTheme.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/DefaultTheme.cs @@ -51,6 +51,7 @@ internal static class DefaultTheme ["agents.list.role.planner"] = new Style(Color.Gold1), ["agents.list.role.implementer"] = new Style(Color.Green), ["agents.list.role.reviewer"] = new Style(Color.Orange1), + ["agents.list.role.researcher"] = new Style(Color.Blue), ["agents.list.age"] = new Style(Color.Grey58, decoration: Decoration.Dim), ["agents.list.implicit"] = new Style(decoration: Decoration.Dim), ["agents.list.presence"] = new Style(Color.Grey70), diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/AgentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/AgentCommandTests.cs index 81ad9709675..a4f8866cb2c 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/AgentCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/AgentCommandTests.cs @@ -40,6 +40,7 @@ memory Save and recall durable agent memory. login Allocate an actor name for a harness without a session-start hook. register Set the role of an actor allocated by `agent login` or a session-start hook. list List the actors this workspace knows, with their session when they have one. + takeover Take over another actor's mail and tasks. hooks Install, inspect, and remove Nitro's turn-boundary hook entries per harness. """); } @@ -71,6 +72,7 @@ memory Save and recall durable agent memory. login Allocate an actor name for a harness without a session-start hook. register Set the role of an actor allocated by `agent login` or a session-start hook. list List the actors this workspace knows, with their session when they have one. + takeover Take over another actor's mail and tasks. hooks Install, inspect, and remove Nitro's turn-boundary hook entries per harness. """); } @@ -105,6 +107,7 @@ memory Save and recall durable agent memory. login Allocate an actor name for a harness without a session-start hook. register Set the role of an actor allocated by `agent login` or a session-start hook. list List the actors this workspace knows, with their session when they have one. + takeover Take over another actor's mail and tasks. hooks Install, inspect, and remove Nitro's turn-boundary hook entries per harness. """); } @@ -139,6 +142,7 @@ memory Save and recall durable agent memory. login Allocate an actor name for a harness without a session-start hook. register Set the role of an actor allocated by `agent login` or a session-start hook. list List the actors this workspace knows, with their session when they have one. + takeover Take over another actor's mail and tasks. hooks Install, inspect, and remove Nitro's turn-boundary hook entries per harness. """); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ClaudeHookCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ClaudeHookCommandTests.cs index 56ade8a9f6b..ec15df03e58 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ClaudeHookCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ClaudeHookCommandTests.cs @@ -1,3 +1,4 @@ +using ChilliCream.Nitro.CommandLine.Services.Mail; using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Tests.Agents; @@ -73,9 +74,7 @@ public async Task UserPromptSubmit_Should_AppendTheMailDigest_When_TheActorHasUn // is bound to, sent by a second allocated actor. await InitWorkspaceAsync(); await InsertSessionIdentityAsync("maya", "session-1"); - await SeedAgentAsync("ada"); - await ExecuteCommandAsync( - "agent", "mail", "send", "--body", "All good.", "--to", "maya", "--subject", "Status", "--actor", "ada"); + var message = await SeedMailAsync(); SetupStandardInput( $$"""{"session_id":"session-1","cwd":{{System.Text.Json.JsonSerializer.Serialize(WorkingDirectory)}}}"""); @@ -84,8 +83,10 @@ await ExecuteCommandAsync( // assert Assert.Equal(0, result.ExitCode); - result.StdOut.Trim().MatchInlineSnapshot( - """{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"You have 1 unread nitro message. Run \u0060nitro agent mail inbox --actor maya\u0060."}}"""); + result.StdOut.Trim().Replace(message.Id, "").MatchInlineSnapshot( + """ + {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"You have 1 unread nitro message; 1 shown below as \u0060nitro agent mail read --thread --output json\u0060 prints them. Reply with \u0060nitro agent mail reply --message \u003Cid\u003E --actor maya --body \u0022...\u0022\u0060 or ack with \u0060nitro agent mail ack --message \u003Cid\u003E --actor maya\u0060; anything not shown is in \u0060nitro agent mail inbox --unread --actor maya\u0060.\n{\n \u0022items\u0022: [\n {\n \u0022id\u0022: \u0022\u0022,\n \u0022threadId\u0022: \u0022\u0022,\n \u0022inReplyTo\u0022: null,\n \u0022from\u0022: \u0022ada\u0022,\n \u0022to\u0022: [\n \u0022maya\u0022\n ],\n \u0022cc\u0022: [],\n \u0022subject\u0022: \u0022Status\u0022,\n \u0022body\u0022: \u0022All good.\u0022,\n \u0022createdAt\u0022: \u00222026-01-01T00:00:00\u002B00:00\u0022,\n \u0022read\u0022: false,\n \u0022archived\u0022: false,\n \u0022takeovers\u0022: []\n }\n ]\n}"}} + """); } [Fact] @@ -117,12 +118,10 @@ public async Task Stop_Should_BlockTheTurn_When_UnreadMailIsUndelivered() // message never yet delivered on the gate channel. await InitWorkspaceAsync(); await InsertSessionIdentityAsync("maya", "session-1"); - await SeedAgentAsync("ada"); + var message = await SeedMailAsync(); SetupStandardInput( $$"""{"session_id":"session-1","cwd":{{System.Text.Json.JsonSerializer.Serialize(WorkingDirectory)}}}"""); await ExecuteCommandAsync("agent", "hook", "claude", "session-start"); - await ExecuteCommandAsync( - "agent", "mail", "send", "--body", "All good.", "--to", "maya", "--subject", "Status", "--actor", "ada"); SetupStandardInput( $$"""{"session_id":"session-1","cwd":{{System.Text.Json.JsonSerializer.Serialize(WorkingDirectory)}}}"""); @@ -131,8 +130,10 @@ await ExecuteCommandAsync( // assert Assert.Equal(0, result.ExitCode); - result.StdOut.Trim().MatchInlineSnapshot( - """{"decision":"block","reason":"Unread nitro mail is waiting. Read it with \u0060nitro agent mail inbox --actor maya\u0060 before ending this turn, or ignore this once if it is not actionable right now."}"""); + result.StdOut.Trim().Replace(message.Id, "").MatchInlineSnapshot( + """ + {"decision":"block","reason":"Unread nitro mail is waiting; handle it before ending this turn, or ignore this once if it is not actionable right now.\nYou have 1 unread nitro message; 1 shown below as \u0060nitro agent mail read --thread --output json\u0060 prints them. Reply with \u0060nitro agent mail reply --message \u003Cid\u003E --actor maya --body \u0022...\u0022\u0060 or ack with \u0060nitro agent mail ack --message \u003Cid\u003E --actor maya\u0060; anything not shown is in \u0060nitro agent mail inbox --unread --actor maya\u0060.\n{\n \u0022items\u0022: [\n {\n \u0022id\u0022: \u0022\u0022,\n \u0022threadId\u0022: \u0022\u0022,\n \u0022inReplyTo\u0022: null,\n \u0022from\u0022: \u0022ada\u0022,\n \u0022to\u0022: [\n \u0022maya\u0022\n ],\n \u0022cc\u0022: [],\n \u0022subject\u0022: \u0022Status\u0022,\n \u0022body\u0022: \u0022All good.\u0022,\n \u0022createdAt\u0022: \u00222026-01-01T00:00:00\u002B00:00\u0022,\n \u0022read\u0022: false,\n \u0022archived\u0022: false,\n \u0022takeovers\u0022: []\n }\n ]\n}"} + """); } [Fact] @@ -245,4 +246,25 @@ nitro agent hook claude {eventName} [options] -?, -h, --help Show help and usage information """); } + + private async Task SeedMailAsync() + { + await SeedAgentAsync("ada"); + + var store = new MailStore( + new TestFileSystem(WorkingDirectory), + FakeTime, + new AgentDatabase(), + new AgentRegistry(new TestFileSystem(WorkingDirectory), FakeTime, new AgentDatabase())); + + return await store.SendMessageAsync( + new MailMessageCreation + { + Sender = "ada", + Subject = "Status", + Body = "All good.", + To = ["maya"] + }, + TestContext.Current.CancellationToken); + } } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/CodexHookCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/CodexHookCommandTests.cs index 83b526d3360..2e47bf9c88f 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/CodexHookCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/CodexHookCommandTests.cs @@ -148,7 +148,27 @@ public async Task Notify_Should_QueueTheDigest_When_TheThreadHasUnreadMail() Assert.Equal(SessionId, call.ThreadId); call.Message.Replace(message.Id, "").MatchInlineSnapshot( """ - You have 1 unread nitro message. Run `nitro agent mail inbox --actor maya`. + You have 1 unread nitro message; 1 shown below as `nitro agent mail read --thread --output json` prints them. Reply with `nitro agent mail reply --message --actor maya --body "..."` or ack with `nitro agent mail ack --message --actor maya`; anything not shown is in `nitro agent mail inbox --unread --actor maya`. + { + "items": [ + { + "id": "", + "threadId": "", + "inReplyTo": null, + "from": "bob", + "to": [ + "maya" + ], + "cc": [], + "subject": "status", + "body": "please check", + "createdAt": "2026-01-01T00:00:00+00:00", + "read": false, + "archived": false, + "takeovers": [] + } + ] + } """); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ListAgentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ListAgentCommandTests.cs index d54a5d89053..3bfa5227c0b 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ListAgentCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/ListAgentCommandTests.cs @@ -25,7 +25,7 @@ public async Task Help_ReturnsSuccess() nitro agent list [options] Options: - --role The actor role, normalized lowercase + --role The actor role, normalized lowercase. Known roles: orchestrator, planner, implementer, reviewer, researcher; any other value is accepted. --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] -?, -h, --help Show help and usage information diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/BroadcastMailCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/BroadcastMailCommandTests.cs index f1a6190092d..483bc169a9e 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/BroadcastMailCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/BroadcastMailCommandTests.cs @@ -26,7 +26,7 @@ nitro agent mail broadcast [options] --subject (REQUIRED) The message subject --body The message body; use --body-file to read it from a file instead --body-file A file to read the message body from; use it instead of --body - --role The actor role, normalized lowercase + --role The actor role, normalized lowercase. Known roles: orchestrator, planner, implementer, reviewer, researcher; any other value is accepted. --actor (REQUIRED) The actor performing this command; allocate one with `nitro agent login` --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] -?, -h, --help Show help and usage information @@ -383,7 +383,7 @@ await SeedAliveSessionAsync( ✓ Sent '{id}' to zeta. """); var call = Assert.Single(queueClient.Calls); - Assert.Equal("thread-zeta", call.ThreadId); + Assert.Equal(("thread-zeta", id, "Deploying."), ReadDigestCall(call)); } [Fact] diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/MailCommandTestBase.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/MailCommandTestBase.cs index 60f61edab69..2a057da2386 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/MailCommandTestBase.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/MailCommandTestBase.cs @@ -1,9 +1,12 @@ using System.Diagnostics; using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Notify; using ChilliCream.Nitro.CommandLine.Services.Workspace; +using ChilliCream.Nitro.CommandLine.Tests.Agents; using ChilliCream.Nitro.CommandLine.Tests.Commands; using ChilliCream.Nitro.CommandLine.Tests.Hook; using Microsoft.Data.Sqlite; +using TestFileSystem = ChilliCream.Nitro.CommandLine.Tests.Hook.TestFileSystem; namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Agent.Mail; @@ -127,10 +130,13 @@ private protected Task SeedAliveCodexThreadSessionAsync(string agentName, string /// Use this in command tests whose primary concern requires a successful /// send but is unrelated to the wake transport itself. /// - private protected async Task SetupSuccessfulWakeAsync(string host, params string[] agentNames) + private protected async Task SetupSuccessfulWakeAsync( + string host, + params string[] agentNames) { SetupInstanceId(host); - SetupCodexQueueClient(new FakeCodexQueueClient()); + var queueClient = new FakeCodexQueueClient(); + SetupCodexQueueClient(queueClient); foreach (var agentName in agentNames) { @@ -139,6 +145,35 @@ await SeedAliveSessionAsync( endpointKind: AgentSessionEndpointKind.CodexThread, endpointAddr: $"thread-{agentName}"); } + + return queueClient; + } + + private protected MailNudge CreateMailNudge(string host, FakeCodexQueueClient queueClient) + { + var fileSystem = new TestFileSystem(WorkingDirectory); + var database = new AgentDatabase(); + + return new MailNudge( + CreateSessions(host), + CreateStore(), + new SessionDeliveryLedger(fileSystem, database), + new FakeClaudePeerClient(), + queueClient, + FakeTime); + } + + private protected static (string ThreadId, string Id, string Body) ReadDigestCall( + (string ThreadId, string Message) call) + { + using var document = System.Text.Json.JsonDocument.Parse( + call.Message[(call.Message.IndexOf('\n') + 1)..]); + var item = document.RootElement.GetProperty("items")[0]; + + return ( + call.ThreadId, + item.GetProperty("id").GetString()!, + item.GetProperty("body").GetString()!); } /// diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReadMailCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReadMailCommandTests.cs index b8ef79f4ad2..a67e66432b5 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReadMailCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReadMailCommandTests.cs @@ -217,15 +217,96 @@ public async Task JsonOutput_ReturnsMessageDetail() var result = await ExecuteCommandAsync("agent", "mail", "read", "--message", message.Id); // assert - using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); - var root = document.RootElement; + result.AssertSuccess( + $$""" + { + "id": "{{message.Id}}", + "threadId": "{{message.Id}}", + "inReplyTo": null, + "from": "bob", + "to": [ + "test-agent" + ], + "cc": [], + "subject": "Status", + "body": "All good.", + "createdAt": "2026-01-01T00:00:00+00:00", + "read": true, + "archived": false, + "takeovers": [] + } + """); + } - Assert.Equal(0, result.ExitCode); - Assert.Equal(message.Id, root.GetProperty("id").GetString()); - Assert.Equal("All good.", root.GetProperty("body").GetString()); - Assert.Equal(["test-agent"], root.GetProperty("to").EnumerateArray().Select(e => e.GetString()!).ToArray()); - Assert.True(root.GetProperty("read").GetBoolean()); - Assert.False(root.GetProperty("archived").GetBoolean()); + [Fact] + public async Task Read_Should_PrintTwoTakeoverHopsNewestFirst() + { + // arrange + var history = await SeedTakeoverHistoryAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "mail", "read", "--message", history.Message.Id, "--actor", "zoe"); + + // assert + result.AssertSuccess( + $""" + From: bob + To: zoe + Date: 2026-01-01 00:00 + Subject: Status + Thread: {history.Message.Id} + Takeover: nora -> zoe ({history.LatestId}, 2026-01-02) + Takeover: maya -> nora ({history.EarliestId}, 2026-01-01) + + All good. + """); + } + + [Fact] + public async Task Read_Should_ReturnTwoTakeoverHopsInJsonNewestFirst() + { + // arrange + var history = await SeedTakeoverHistoryAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "mail", "read", "--message", history.Message.Id, "--actor", "zoe"); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{history.Message.Id}}", + "threadId": "{{history.Message.Id}}", + "inReplyTo": null, + "from": "bob", + "to": [ + "zoe" + ], + "cc": [], + "subject": "Status", + "body": "All good.", + "createdAt": "2026-01-01T00:00:00+00:00", + "read": true, + "archived": false, + "takeovers": [ + { + "id": "{{history.LatestId}}", + "from": "nora", + "to": "zoe", + "createdAt": "2026-01-02T00:00:00+00:00" + }, + { + "id": "{{history.EarliestId}}", + "from": "maya", + "to": "nora", + "createdAt": "2026-01-01T00:00:00+00:00" + } + ] + } + """); } [Fact] @@ -254,4 +335,30 @@ await CreateStore().ReplyMessageAsync( Assert.Equal(original.Id, items[0].GetProperty("id").GetString()); Assert.Equal("Pong.", items[1].GetProperty("body").GetString()); } + + private async Task SeedTakeoverHistoryAsync() + { + await InitWorkspaceAsync(); + await SeedAgentAsync("maya"); + await SeedAgentAsync("nora"); + await SeedAgentAsync("zoe"); + await SeedAgentAsync("bob"); + var message = await SeedMessageAsync( + "bob", "Status", ["maya"], body: "All good."); + + await ExecuteCommandAsync("agent", "takeover", "--from", "maya", "--actor", "nora"); + var earliestId = await QueryScalarAsync("SELECT id FROM agent_takeovers"); + + FakeTime.Advance(TimeSpan.FromDays(1)); + await ExecuteCommandAsync("agent", "takeover", "--from", "nora", "--actor", "zoe"); + var latestId = await QueryScalarAsync( + "SELECT id FROM agent_takeovers ORDER BY created_at DESC LIMIT 1"); + + return new TakeoverHistory(message, earliestId!, latestId!); + } + + private sealed record TakeoverHistory( + ChilliCream.Nitro.CommandLine.Services.Mail.MailMessage Message, + string EarliestId, + string LatestId); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReplyMailCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReplyMailCommandTests.cs index 3dee471fd42..70cfd7c6fb4 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReplyMailCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/ReplyMailCommandTests.cs @@ -145,6 +145,28 @@ public async Task Reply_ThreadsUnderOriginalMessage_AndInheritsRootSubject() Assert.True(root.GetProperty("messageStored").GetBoolean()); } + [Fact] + public async Task Reply_Should_NudgeRecipientWithExactMessageIdAndBody() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "register", "--actor", "alice"); + await ExecuteCommandAsync("agent", "register", "--actor", "bob"); + var originalId = await SendOriginalMessageAsync("alice", "Status", "bob"); + var queueClient = await SetupSuccessfulWakeAsync("host-reply-nudge-test", "alice"); + + // act + await ExecuteCommandAsync( + "agent", "mail", "reply", "--message", originalId, "--body", "Thanks!", "--actor", "bob"); + + // assert + var replyId = await QueryScalarAsync( + "SELECT id FROM messages WHERE in_reply_to = '" + originalId + "'"); + Assert.Equal( + ("thread-alice", replyId, "Thanks!"), + ReadDigestCall(Assert.Single(queueClient.Calls))); + } + [Fact] public async Task BodyAndBodyFileBothMissing_ReturnsParseError() { diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/SendMailCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/SendMailCommandTests.cs index 4f978206eed..86394e199a6 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/SendMailCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Mail/SendMailCommandTests.cs @@ -1,13 +1,41 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Mail; using ChilliCream.Nitro.CommandLine.Services.Notify; using ChilliCream.Nitro.CommandLine.Services.Workspace; using ChilliCream.Nitro.CommandLine.Tests.Agents; using ChilliCream.Nitro.CommandLine.Tests.Hook; +using Moq; namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Agent.Mail; public sealed class SendMailCommandTests(NitroCommandFixture fixture) : MailCommandTestBase(fixture) { + [Fact] + public async Task NudgeAsync_Should_ReturnNormally_When_ParticipantDiscoveryThrows() + { + // arrange + var sessions = new Mock(); + sessions + .Setup(registry => registry.ListParticipantsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("participant discovery failed")); + var nudge = new MailNudge( + sessions.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + TimeProvider.System); + + // act + await nudge.NudgeAsync(["bob"], TestContext.Current.CancellationToken); + + // assert + sessions.Verify( + registry => registry.ListParticipantsAsync(It.IsAny()), + Times.Once); + } + [Fact] public async Task Help_ReturnsSuccess() { @@ -45,7 +73,7 @@ public async Task SingleRecipient_SendsMessage() // arrange await InitWorkspaceAsync(); await ExecuteCommandAsync("agent", "register", "--actor", "bob"); - await SetupSuccessfulWakeAsync("host-send-single-test", "bob"); + var queueClient = await SetupSuccessfulWakeAsync("host-send-single-test", "bob"); // act var result = await ExecuteCommandAsync( @@ -57,6 +85,66 @@ public async Task SingleRecipient_SendsMessage() $""" ✓ Sent '{id}' to bob. """); + Assert.Equal( + ("thread-bob", id, "All good."), + ReadDigestCall(Assert.Single(queueClient.Calls))); + } + + [Fact] + public async Task NudgeAsync_Should_SendPointer_When_TheSameMessageIsPushedTwiceToTheSameSession() + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("test-agent"); + await SeedAgentAsync("bob"); + var queueClient = await SetupSuccessfulWakeAsync("host-send-repeat-test", "bob"); + var message = await SeedMessageAsync( + "test-agent", "Status", ["bob"], body: "All good."); + var nudge = CreateMailNudge("host-send-repeat-test", queueClient); + + // act + await nudge.NudgeAsync(["bob"], TestContext.Current.CancellationToken); + await nudge.NudgeAsync(["bob"], TestContext.Current.CancellationToken); + + // assert + Assert.Collection( + queueClient.Calls, + first => Assert.Equal(("thread-bob", message.Id, "All good."), ReadDigestCall(first)), + second => Assert.Equal( + ("thread-bob", "You have 1 unread nitro message. " + + "Run `nitro agent mail inbox --actor bob`."), + (second.ThreadId, second.Message))); + } + + [Fact] + public async Task SingleRecipient_Should_SendBodyToEachLiveSession_When_ActorHasTwoSessions() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "register", "--actor", "bob"); + SetupInstanceId("host-send-two-sessions-test"); + var queueClient = new FakeCodexQueueClient(); + SetupCodexQueueClient(queueClient); + await SeedAliveSessionAsync( + "session-bob-1", "bob", role: "", host: "host-send-two-sessions-test", + endpointKind: AgentSessionEndpointKind.CodexThread, endpointAddr: "thread-bob-1"); + await SeedAliveSessionAsync( + "session-bob-2", "bob", role: "", host: "host-send-two-sessions-test", + endpointKind: AgentSessionEndpointKind.CodexThread, endpointAddr: "thread-bob-2"); + + // act + await ExecuteCommandAsync( + "agent", "mail", "send", "--to", "bob", "--subject", "Status", "--body", "All good."); + + // assert + var id = await QueryScalarAsync("SELECT id FROM messages WHERE subject = 'Status'"); + Assert.Equal( + new[] + { + ("thread-bob-1", id!, "All good."), + ("thread-bob-2", id!, "All good.") + }, + queueClient.Calls.Select(ReadDigestCall).OrderBy(call => call.ThreadId).ToArray()); } [Fact] diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/RegisterAgentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/RegisterAgentCommandTests.cs index c0d258c9f60..3a204137bf1 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/RegisterAgentCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/RegisterAgentCommandTests.cs @@ -23,13 +23,13 @@ nitro agent register [options] Options: --actor (REQUIRED) The actor to register; allocate one with `nitro agent login` - --role The actor role, normalized lowercase + --role The actor role, normalized lowercase. Known roles: orchestrator, planner, implementer, reviewer, researcher; any other value is accepted. --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] -?, -h, --help Show help and usage information Example: nitro agent register --actor "maya" - nitro agent register --actor "maya" --role "backend" + nitro agent register --actor "maya" --role "researcher" """); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/TakeoverAgentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/TakeoverAgentCommandTests.cs new file mode 100644 index 00000000000..c22ee58bab5 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/TakeoverAgentCommandTests.cs @@ -0,0 +1,354 @@ +using ChilliCream.Nitro.CommandLine.Tests.Commands; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +public sealed class TakeoverAgentCommandTests(NitroCommandFixture fixture) + : AgentCommandTestBase(fixture) +{ + [Fact] + public async Task Help_Should_DescribeTakeoverOptionsAndExamples() + { + // act + var result = await ExecuteCommandAsync("agent", "takeover", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Take over another actor's mail and tasks. + + Usage: + nitro agent takeover [command] [options] + + Options: + --from (REQUIRED) The actor whose mail and tasks to take over + --actor (REQUIRED) The actor taking over the mail and tasks + --force Take over even when the source actor still has a live session + --reason The reason recorded for the takeover + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Commands: + history List actor takeover history, newest first. + + Example: + nitro agent takeover --from "maya" --actor "nora" + nitro agent takeover --from "maya" --actor "nora" --force --reason "session ended" + """); + } + + [Fact] + public async Task Takeover_Should_MoveMailAndTasks_InheritRole_AndWriteHumanOutput() + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("maya", "planner"); + await SeedAgentAsync("nora"); + await SeedAgentAsync("sender"); + await SendMailAsync("sender", "maya", "received"); + await SendMailAsync("maya", "sender", "sent"); + var taskId = await CreateTaskAsync("maya"); + + // act + var result = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora", "--reason", "handoff"); + + // assert + result.AssertSuccess( + $"✓ 'nora' took over from 'maya': role 'planner', 2 messages, 1 tasks ({taskId})."); + var state = string.Join( + "|", + await QueryScalarAsync("SELECT role FROM agents WHERE name = 'nora'"), + await QueryScalarAsync($"SELECT assignee FROM tasks WHERE id = '{taskId}'"), + await QueryScalarAsync($"SELECT text FROM comments WHERE task_id = '{taskId}'")); + state.MatchInlineSnapshot("planner|nora|Taken over from 'maya' by 'nora'."); + } + + [Fact] + public async Task Takeover_Should_ReturnExactJsonShape() + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("maya", "planner"); + await SeedAgentAsync("nora"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora"); + + // assert + var id = await QueryScalarAsync("SELECT id FROM agent_takeovers"); + result.StdOut.Replace(id!, "").MatchInlineSnapshot( + """ + { + "id": "", + "from": "maya", + "to": "nora", + "role": "planner", + "recipientsMoved": 0, + "sendersMoved": 0, + "tasks": [] + } + """); + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public async Task Takeover_Should_RefuseLiveSourceSession_UnlessForced() + { + // arrange + SetupInstanceId("local-instance"); + await InitWorkspaceAsync(); + await SeedAgentAsync("maya"); + await SeedAgentAsync("nora"); + await InsertAliveSessionRowAsync("local-instance", "session-1", "maya"); + + // act + var refused = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora"); + var forced = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora", "--force"); + + // assert + refused.AssertError( + "Actor 'maya' still has a live session; pass --force to take over anyway."); + forced.AssertSuccess("✓ 'nora' took over from 'maya': role '', 0 messages, no tasks."); + Assert.Equal("1", await QueryScalarAsync("SELECT forced FROM agent_takeovers")); + } + + [Theory] + [InlineData("maya", "maya", "The source and target actors must be different.")] + [InlineData("unknown", "nora", "Unknown actor 'unknown'. Run `nitro agent list` to see the actors this workspace knows.")] + [InlineData("maya", "unknown", "Unknown actor 'unknown'. Run `nitro agent list` to see the actors this workspace knows.")] + public async Task Takeover_Should_RejectEqualOrUnknownActors( + string from, + string to, + string expected) + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("maya"); + await SeedAgentAsync("nora"); + + // act + var result = await ExecuteCommandAsync( + "agent", "takeover", "--from", from, "--actor", to); + + // assert + result.AssertError(expected); + Assert.Equal("0", await QueryScalarAsync("SELECT COUNT(*) FROM agent_takeovers")); + } + + [Theory] + [InlineData("", "planner")] + [InlineData("reviewer", "reviewer")] + public async Task Takeover_Should_OnlyInheritRole_When_TargetRoleIsBlank( + string targetRole, + string expectedRole) + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("maya", "planner"); + await SeedAgentAsync("nora", targetRole); + + // act + var result = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal(expectedRole, await QueryScalarAsync("SELECT role FROM agents WHERE name = 'nora'")); + Assert.Equal(expectedRole, await QueryScalarAsync("SELECT role FROM agent_takeovers")); + } + + [Fact] + public async Task Takeover_Should_RecordItemsAndAHeader_OnEveryRun() + { + // arrange + await InitWorkspaceAsync(); + await SeedAgentAsync("maya", "planner"); + await SeedAgentAsync("nora"); + await SeedAgentAsync("sender"); + await SendMailAsync("sender", "maya", "received"); + await SendMailAsync("maya", "sender", "sent"); + await CreateTaskAsync("maya"); + + // act + await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora", "--reason", "handoff"); + var repeated = await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora"); + + // assert + repeated.AssertSuccess("✓ 'nora' took over from 'maya': role 'planner', 0 messages, no tasks."); + var ledger = string.Join( + "|", + await QueryScalarAsync("SELECT COUNT(*) FROM agent_takeovers"), + await QueryScalarAsync("SELECT COUNT(*) FROM agent_takeover_items"), + await QueryScalarAsync( + "SELECT group_concat(kind || ':' || count, ',') FROM " + + "(SELECT kind, COUNT(*) AS count FROM agent_takeover_items GROUP BY kind ORDER BY kind)"), + await QueryScalarAsync( + "SELECT COUNT(*) FROM agent_takeover_items WHERE takeover_id = " + + "(SELECT id FROM agent_takeovers WHERE reason IS NULL)"), + await QueryScalarAsync( + "SELECT from_actor || ':' || to_actor || ':' || actor || ':' || role || ':' || reason " + + "FROM agent_takeovers WHERE reason IS NOT NULL")); + ledger.MatchInlineSnapshot( + "2|3|message_recipient:1,message_sender:1,task:1|0|maya:nora:nora:planner:handoff"); + } + + [Fact] + public async Task History_Should_PrintTwoTakeoversNewestFirst() + { + // arrange + var history = await SeedTakeoverHistoryAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "takeover", "history"); + + // assert + result.AssertSuccess( + $"{history.LatestId} 2026-01-02 00:00 nora -> zoe by zoe 1 messages, 1 tasks\n" + + $"{history.EarliestId} 2026-01-01 00:00 maya -> nora by nora 1 messages, 1 tasks"); + } + + [Fact] + public async Task History_Should_ReturnExactJsonShapeNewestFirst() + { + // arrange + var history = await SeedTakeoverHistoryAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "takeover", "history"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{history.LatestId}}", + "from": "nora", + "to": "zoe", + "actor": "zoe", + "createdAt": "2026-01-02T00:00:00+00:00", + "forced": false, + "role": "planner", + "reason": null, + "messageSenders": 0, + "messageRecipients": 1, + "tasks": [ + "{{history.TaskId}}" + ] + }, + { + "id": "{{history.EarliestId}}", + "from": "maya", + "to": "nora", + "actor": "nora", + "createdAt": "2026-01-01T00:00:00+00:00", + "forced": false, + "role": "planner", + "reason": "handoff", + "messageSenders": 0, + "messageRecipients": 1, + "tasks": [ + "{{history.TaskId}}" + ] + } + ] + } + """); + } + + [Fact] + public async Task History_Should_ApplyActorFilterAndLimitThroughLedger() + { + // arrange + var history = await SeedTakeoverHistoryAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "takeover", "history", "--actor", "maya", "--limit", "1"); + + // assert + result.StdOut + .Replace(history.EarliestId, "") + .Replace(history.TaskId, "") + .MatchInlineSnapshot( + """ + { + "items": [ + { + "id": "", + "from": "maya", + "to": "nora", + "actor": "nora", + "createdAt": "2026-01-01T00:00:00+00:00", + "forced": false, + "role": "planner", + "reason": "handoff", + "messageSenders": 0, + "messageRecipients": 1, + "tasks": [ + "" + ] + } + ] + } + """); + } + + private async Task SendMailAsync(string from, string to, string subject) + { + var result = await ExecuteCommandAsync( + "agent", "mail", "send", + "--to", to, + "--subject", subject, + "--body", "body", + "--actor", from); + Assert.Equal(0, result.ExitCode); + } + + private async Task CreateTaskAsync(string assignee) + { + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Takeover task", + "--assignee", assignee, + "--actor", "sender"); + Assert.Equal(0, result.ExitCode); + + return (await QueryScalarAsync("SELECT id FROM tasks WHERE title = 'Takeover task'"))!; + } + + private async Task SeedTakeoverHistoryAsync() + { + await InitWorkspaceAsync(); + await SeedAgentAsync("maya", "planner"); + await SeedAgentAsync("nora"); + await SeedAgentAsync("zoe"); + await SeedAgentAsync("sender"); + await SendMailAsync("sender", "maya", "received"); + var taskId = await CreateTaskAsync("maya"); + + await ExecuteCommandAsync( + "agent", "takeover", "--from", "maya", "--actor", "nora", "--reason", "handoff"); + var earliestId = (await QueryScalarAsync("SELECT id FROM agent_takeovers"))!; + + FakeTime.Advance(TimeSpan.FromDays(1)); + await ExecuteCommandAsync("agent", "takeover", "--from", "nora", "--actor", "zoe"); + var latestId = (await QueryScalarAsync( + "SELECT id FROM agent_takeovers ORDER BY created_at DESC LIMIT 1"))!; + + return new TakeoverHistory(earliestId, latestId, taskId); + } + + private sealed record TakeoverHistory( + string EarliestId, + string LatestId, + string TaskId); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Tasks/ListTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Tasks/ListTaskCommandTests.cs index 340aefaee17..b4ee0b03bde 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Tasks/ListTaskCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/Tasks/ListTaskCommandTests.cs @@ -20,7 +20,7 @@ nitro agent tasks list [options] Options: --status Filter by status; can be used multiple times - --type The task type (task, bug, feature, epic, chore, docs, question, or custom) + --type Only show tasks of this type (task, bug, feature, epic, chore, docs, question, or custom) --priority The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 --assignee The assignee --label