Merge claudedo/c97dcaafbb3542bda9c670e502ffffb6
This commit is contained in:
@@ -641,7 +641,7 @@
|
|||||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
|
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
|
||||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||||
|
|||||||
@@ -641,7 +641,7 @@
|
|||||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
|
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
|
||||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||||
|
|||||||
@@ -88,7 +88,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private void OpenQuickClaudeSession()
|
private void OpenQuickClaudeSession()
|
||||||
{
|
{
|
||||||
var dir = _currentList?.WorkingDir;
|
var dir = _currentList?.WorkingDir;
|
||||||
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
|
if (string.IsNullOrWhiteSpace(dir))
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeNoWorkingDir"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!System.IO.Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeDirMissing", dir));
|
||||||
|
return;
|
||||||
|
}
|
||||||
OpenQuickClaudeSessionRequested?.Invoke(dir);
|
OpenQuickClaudeSessionRequested?.Invoke(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button Classes="icon-btn" IsVisible="{Binding IsQuickClaudeVisible}"
|
<Button Classes="icon-btn" IsVisible="{Binding IsQuickClaudeVisible}"
|
||||||
Command="{Binding OpenQuickClaudeSessionCommand}" ToolTip.Tip="{loc:Tr tasks.quickClaudeTip}">
|
Command="{Binding OpenQuickClaudeSessionCommand}" ToolTip.Tip="{loc:Tr tasks.quickClaudeTip}">
|
||||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Plus}"/>
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.ArrowOut}"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
|
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
|
||||||
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
|
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
// wait_for_task_change blocks up to TaskWaitMcpTools.MaxTimeoutSeconds (900 s) and the
|
// wait_for_task_change blocks up to TaskWaitMcpTools.MaxTimeoutSeconds (900 s) and the
|
||||||
// in-task AskUser tool blocks up to 3 min waiting for the user, so lift the cap well
|
// in-task AskUser tool blocks up to 3 min waiting for the user, so lift the cap well
|
||||||
// past the longer of the two (with margin) or that wait would be killed early. Harmless
|
// past the longer of the two (with margin) or that wait would be killed early. Harmless
|
||||||
// for every other tool. Keep in sync with InteractiveLaunchSpecService's MCP_TOOL_TIMEOUT.
|
// for every other tool. Shares the value with InteractiveLaunchSpecService.McpToolTimeoutMs.
|
||||||
psi.Environment["MCP_TOOL_TIMEOUT"] = "930000";
|
psi.Environment["MCP_TOOL_TIMEOUT"] = InteractiveLaunchSpecService.McpToolTimeoutMs;
|
||||||
|
|
||||||
using var process = new Process { StartInfo = psi };
|
using var process = new Process { StartInfo = psi };
|
||||||
process.Start();
|
process.Start();
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ namespace ClaudeDo.Worker.Runner;
|
|||||||
// `--resume` pickup in a Windows Terminal window.
|
// `--resume` pickup in a Windows Terminal window.
|
||||||
public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||||
{
|
{
|
||||||
|
// Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this
|
||||||
|
// service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with
|
||||||
|
// ClaudeProcess's own MCP_TOOL_TIMEOUT (same value, set independently for headless runs).
|
||||||
|
public const string McpToolTimeoutMs = "930000";
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly ISessionSkillSeeder _skillSeeder;
|
private readonly ISessionSkillSeeder _skillSeeder;
|
||||||
private readonly ISessionSkillRegistry _skillRegistry;
|
private readonly ISessionSkillRegistry _skillRegistry;
|
||||||
@@ -111,7 +116,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "930000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
|
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
|
||||||
@@ -129,7 +134,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
{
|
{
|
||||||
["MAX_THINKING_TOKENS"] = "20000",
|
["MAX_THINKING_TOKENS"] = "20000",
|
||||||
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
||||||
["MCP_TOOL_TIMEOUT"] = "930000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(
|
return new LaunchSpec(
|
||||||
@@ -147,7 +152,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
||||||
["MCP_TOOL_TIMEOUT"] = "930000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(
|
return new LaunchSpec(
|
||||||
@@ -170,7 +175,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
|
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "930000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// No task and no list here — the global default model's preset decides the effort.
|
// No task and no list here — the global default model's preset decides the effort.
|
||||||
@@ -247,7 +252,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
|
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "930000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||||
@@ -320,7 +325,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
|
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "200000",
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
|
|
||||||
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
|
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class TasksIslandOpenQuickClaudeSessionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public TasksIslandOpenQuickClaudeSessionTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_quickclaude_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null);
|
||||||
|
|
||||||
|
private static ListNavItemViewModel UserList(string? workingDir) =>
|
||||||
|
new() { Id = "user:l1", Kind = ListKind.User, Name = "L1", WorkingDir = workingDir };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoWorkingDirConfigured_ReportsError_DoesNotRaiseRequested()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
vm.LoadForList(UserList(workingDir: null));
|
||||||
|
string? error = null;
|
||||||
|
var requested = false;
|
||||||
|
vm.ErrorReported += msg => error = msg;
|
||||||
|
vm.OpenQuickClaudeSessionRequested += _ => requested = true;
|
||||||
|
|
||||||
|
vm.OpenQuickClaudeSessionCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(error);
|
||||||
|
Assert.False(requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkingDirMissingOnDisk_ReportsError_DoesNotRaiseRequested()
|
||||||
|
{
|
||||||
|
var missingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
vm.LoadForList(UserList(workingDir: missingDir));
|
||||||
|
string? error = null;
|
||||||
|
var requested = false;
|
||||||
|
vm.ErrorReported += msg => error = msg;
|
||||||
|
vm.OpenQuickClaudeSessionRequested += _ => requested = true;
|
||||||
|
|
||||||
|
vm.OpenQuickClaudeSessionCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(error);
|
||||||
|
Assert.Contains(missingDir, error);
|
||||||
|
Assert.False(requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkingDirExists_RaisesRequested_WithDir_NoError()
|
||||||
|
{
|
||||||
|
var dir = Path.GetTempPath();
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
vm.LoadForList(UserList(workingDir: dir));
|
||||||
|
string? error = null;
|
||||||
|
string? requestedDir = null;
|
||||||
|
vm.ErrorReported += msg => error = msg;
|
||||||
|
vm.OpenQuickClaudeSessionRequested += d => requestedDir = d;
|
||||||
|
|
||||||
|
vm.OpenQuickClaudeSessionCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.Equal(dir, requestedDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,7 +242,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
|
Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
|
||||||
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -401,7 +401,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Equal(_tempDir, spec.Cwd);
|
Assert.Equal(_tempDir, spec.Cwd);
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
Assert.Empty(ArgsAfterEffort(spec));
|
Assert.Empty(ArgsAfterEffort(spec));
|
||||||
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
Assert.Empty(_seeder.Calls);
|
Assert.Empty(_seeder.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Contains(briefPath, kickoff);
|
Assert.Contains(briefPath, kickoff);
|
||||||
Assert.DoesNotContain('\n', kickoff);
|
Assert.DoesNotContain('\n', kickoff);
|
||||||
|
|
||||||
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -791,7 +791,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Contains(survivor, handoffBrief);
|
Assert.Contains(survivor, handoffBrief);
|
||||||
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> CountTasksAsync()
|
private async Task<int> CountTasksAsync()
|
||||||
@@ -841,4 +841,57 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
|
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
|
||||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression guard for the bug where BuildForMergeHelperHandoffAsync set MCP_TOOL_TIMEOUT to
|
||||||
|
// an older, shorter value (200000) than every other ConPTY spec (930000) after a parallel
|
||||||
|
// merge landed the two changes independently. Every spec this service builds must carry the
|
||||||
|
// SAME value, sourced from the one constant, so the value can never drift again.
|
||||||
|
[Fact]
|
||||||
|
public async Task AllBuiltSpecs_CarryTheSameMcpToolTimeout()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoTimeoutCheck");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
|
await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||||
|
await SeedRunAsync(taskId, "sess-timeout-check");
|
||||||
|
|
||||||
|
var handlerTaskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
|
||||||
|
var survivor = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_tempDir, "sess-timeout-check");
|
||||||
|
Directory.CreateDirectory(sessionDir);
|
||||||
|
var planningStartCtx = new PlanningSessionStartContext(
|
||||||
|
ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
|
||||||
|
WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
|
||||||
|
Files: new PlanningSessionFiles(sessionDir,
|
||||||
|
Path.Combine(sessionDir, "system-prompt.md"),
|
||||||
|
Path.Combine(sessionDir, "initial-prompt.txt")));
|
||||||
|
var planningResumeCtx = new PlanningSessionResumeContext(
|
||||||
|
ParentTaskId: "p1", WorkingDir: _worktreeDir,
|
||||||
|
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None);
|
||||||
|
TrackSessionDir(mergeHelperSpec);
|
||||||
|
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None);
|
||||||
|
TrackSessionDir(handoffSpec);
|
||||||
|
|
||||||
|
var specs = new List<LaunchSpec>
|
||||||
|
{
|
||||||
|
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
|
||||||
|
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
|
||||||
|
mergeHelperSpec,
|
||||||
|
handoffSpec,
|
||||||
|
svc.BuildPlanningStart(planningStartCtx),
|
||||||
|
svc.BuildPlanningResume(planningResumeCtx),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.All(specs, spec =>
|
||||||
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,46 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable
|
|||||||
releaseProcess.TrySetResult();
|
releaseProcess.TrySetResult();
|
||||||
await runTask;
|
await runTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueAsync_with_alreadyClaimed_broadcasts_TaskUpdated_exactly_once_before_the_run_finishes()
|
||||||
|
{
|
||||||
|
string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString();
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running,
|
||||||
|
StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.TaskRuns.Add(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
|
||||||
|
Prompt = "p", SessionId = "sess-1",
|
||||||
|
StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
ExitCode = 0, ResultMarkdown = "ok",
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var processStarted = new TaskCompletionSource();
|
||||||
|
var releaseProcess = new TaskCompletionSource();
|
||||||
|
var fake = new FakeClaudeProcess(async (_, _, _, _, _) =>
|
||||||
|
{
|
||||||
|
processStarted.TrySetResult();
|
||||||
|
await releaseProcess.Task;
|
||||||
|
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
|
||||||
|
});
|
||||||
|
var runner = BuildRunner(fake);
|
||||||
|
|
||||||
|
var runTask = runner.ContinueAsync(taskId, "follow up", "queue", CancellationToken.None, alreadyClaimed: true);
|
||||||
|
|
||||||
|
await processStarted.Task;
|
||||||
|
|
||||||
|
Assert.Single(_hubContext.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||||
|
|
||||||
|
releaseProcess.TrySetResult();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user