Merge branch 'claudedo/b346a534f0fc4635bd7614a00ab2a20e'

This commit is contained in:
mika kuns
2026-09-02 20:55:43 +02:00
18 changed files with 840 additions and 10 deletions
@@ -146,6 +146,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary>
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default);
/// <summary>Reports that a task-based ConPTY session's process exited non-zero after it had been
/// running. The exit code only exists in this process; the worker decides whether it was a
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+3
View File
@@ -626,6 +626,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
@@ -96,6 +96,13 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
}
}
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
/// (<c>HandlerRestartCoordinator</c>). A startup death keeps using <see cref="StartError"/> instead:
/// it never reached a state worth resuming.</summary>
public event Action<int>? ExitedAfterStartup;
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
internal void OnSessionProcessExited(object? sender, int exitCode)
{
@@ -103,11 +110,16 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
HasExited = true;
ExitCode = exitCode;
if (exitCode == 0) return;
if (_startedAtUtc is not { } startedAt) return; // never reached "running" — nothing ran to resume
// Died at startup: route through the same banner as a launch-time failure instead of
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
// requires StartError).
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
if (_utcNow() - startedAt < StartupGraceWindow)
StartError = $"Session exited immediately (code {exitCode}).";
else
ExitedAfterStartup?.Invoke(exitCode);
}
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
@@ -36,6 +36,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
/// no task to reopen.</summary>
public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
@@ -49,7 +55,34 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
TaskId = taskId;
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged;
Subscribe(Terminal);
}
// Terminal is swapped wholesale on Retry, so the handlers follow the property rather than being
// wired once at construction. (The initial value comes from the field initializer, which never
// runs through the setter -- hence the explicit Subscribe in the constructor.)
partial void OnTerminalChanging(InteractiveTerminalViewModel? oldValue, InteractiveTerminalViewModel newValue)
{
if (oldValue is not null) Unsubscribe(oldValue);
}
partial void OnTerminalChanged(InteractiveTerminalViewModel value) => Subscribe(value);
private void Subscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
}
private void Unsubscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
}
private void OnTerminalExitedAfterStartup(int exitCode)
{
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
}
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
@@ -111,16 +144,15 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Terminal.Dispose();
Terminal = new InteractiveTerminalViewModel();
Terminal.PropertyChanged += OnTerminalPropertyChanged;
var dead = Terminal;
Terminal = new InteractiveTerminalViewModel(); // OnTerminalChanging/Changed move the handlers
dead.Dispose();
Start();
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Unsubscribe(Terminal);
Terminal.Kill();
Terminal.Dispose();
}
@@ -413,10 +413,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
ConPtySessions.Add(pane);
pane.Start();
}
// A ConPTY session that had been running died. The UI process owns the terminal, so this is the
// only place the exit code exists -- report it and let the worker decide whether it was a list
// handler killed by the usage limit and worth reopening after the reset
// (HandlerRestartCoordinator). Every other pane is just a no-op there, so no filtering here.
private async void OnPaneSessionDied(string taskId, int exitCode)
{
try
{
await _worker.ReportInteractiveSessionExitedAsync(taskId, exitCode);
}
catch (Exception ex)
{
// A worker that is down cannot restart anything anyway; the dead tile is already visible.
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
}
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
{
@@ -457,6 +475,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported -= OnConPtyPaneError;
pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
ConPtySessions.Remove(pane);
pane.Dispose();
}
@@ -507,6 +526,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
c.ErrorReported -= OnConPtyPaneError;
c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.Dispose();
}
ConPtySessions.Clear();