diff --git a/GraphcodeKit/Sources/Domain/LoopType.swift b/GraphcodeKit/Sources/Domain/LoopType.swift index d490a924..f989b1b2 100644 --- a/GraphcodeKit/Sources/Domain/LoopType.swift +++ b/GraphcodeKit/Sources/Domain/LoopType.swift @@ -33,8 +33,9 @@ public enum LoopType: String, Codable, CaseIterable, Sendable { /// Whether `graphcoded` starts this loop's session and keeps it alive across its own /// restarts, because nothing else would. A turn-based loop or a sketch is *attended*: - /// it only ever runs because a human opened it, so whichever pane opens it owns the - /// launch — locally and, over ssh, on the remote host. Every gate that decides who + /// it only ever runs because a human opened it. macOS panes own the launch; Windows + /// panes ask the daemon with `resumeSession` before attaching. Over ssh the launch + /// still happens on the remote host. Every gate that decides who /// launches must read this rather than name `turnBased`, which is how remote sketches /// came to wait forever for a daemon that deliberately never starts them (#253). public var runsUnattended: Bool { diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 3b7f7c63..c128ad1e 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -122,6 +122,19 @@ public actor GraphStore { /// Whether a local loop's session is alive and not a husk — what decides if a pane /// closing may resolve the loop (`sessionPermitsResolution`). private let onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? + + /// Whether the terminal pane that opens an attended loop launches its session. The Mac + /// app's panes do (`GhosttyTerminalView` starts the agent); the Windows shell's panes only + /// attach, so there opening a loop (`resumeSession`) is the daemon's cue to start it. + private let panesLaunchAttendedSessions: Bool + + public static let platformPanesLaunchAttendedSessions: Bool = { + #if os(Windows) + false + #else + true + #endif + }() /// Cross-graph `.spawn`. `GraphStore` owns exactly one graph and cannot reach another, /// so it hands the request up to `ProjectRegistry`, which is the layer that knows every /// open project — the same split that keeps this actor unaware multi-project routing @@ -403,10 +416,12 @@ public actor GraphStore { recurrence: RecurrenceSink? = nil, presenceReadDeadline: Duration = .seconds(45), drainLeaseDuration: Duration = .seconds(300), - subGraphDepth: Int = 0 + subGraphDepth: Int = 0, + panesLaunchAttendedSessions: Bool = GraphStore.platformPanesLaunchAttendedSessions ) { self.graph = graph self.subGraphDepth = subGraphDepth + self.panesLaunchAttendedSessions = panesLaunchAttendedSessions self.onGraphChanged = onGraphChanged self.onGraphEvent = onGraphEvent self.onConnectionFailure = onConnectionFailure @@ -2555,15 +2570,22 @@ public actor GraphStore { } /// A chat-surface loop (Nod) has no terminal pane whose attach would start its session, - /// so opening it, or sending to it with nothing running, asks for one here. Unattended - /// loops already run; this starts the rest, and is a no-op while a session is alive. + /// so opening it, or sending to it with nothing running, asks for one here. So does an + /// attended terminal loop where panes only attach (`panesLaunchAttendedSessions`). + /// Unattended loops already run; this starts the rest, and is a no-op while a session is + /// alive. private func ensureChatSession(_ node: LoopNode) async { - guard node.backend.surface == .chat, node.state != .stopped, + guard launchesWhenOpened(node), node.state != .stopped, await onSessionAlive?(node, graph.project.path) != true else { return } ensureSession(node) } + private func launchesWhenOpened(_ node: LoopNode) -> Bool { + if node.backend.surface == .chat { return true } + return !panesLaunchAttendedSessions && !node.runsUnattended && node.loopType != .composite + } + /// Arms the end of a resolved loop's session, after the grace the Settings choose — long /// enough for the resolution ask to be answered. No grace configured keeps it. private func scheduleSessionEnd(_ nodeID: UUID, confirming: Bool = false) { diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index fe14e406..bd1c1fb5 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1268,9 +1268,20 @@ public enum ZmxSessionLauncher { settings: GraphcodeSettings = GraphcodeSettingsStore.load(), shedPrompt: ShedPromptReport? = nil ) -> [String]? { - guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else { - return node.backend == .nod - ? nodRunArguments(forNode: node, projectPath: projectPath, settings: settings) : nil + let prompt: String + if let opening = node.sessionPrompt(forProjectPath: projectPath), !opening.isEmpty { + prompt = opening + } else { + if node.backend == .nod { + return nodRunArguments(forNode: node, projectPath: projectPath, settings: settings) + } + #if os(Windows) + // Windows panes only attach; even an empty Main loop needs a daemon launch. + guard node.loopType == .sketch else { return nil } + prompt = "" + #else + return nil + #endif } // A backend graphcode can't launch has no argv. `canHost` already refuses to create // such a node, so this is the belt to that braces — but silently starting the wrong diff --git a/Tools/windows/Stub-Daemon.ps1 b/Tools/windows/Stub-Daemon.ps1 index f4f2a91d..140bc0d2 100644 --- a/Tools/windows/Stub-Daemon.ps1 +++ b/Tools/windows/Stub-Daemon.ps1 @@ -155,13 +155,22 @@ function Invoke-MultiProjectRequest($peer, $frame) { if ($verb -ceq "graphCommand") { $command = $frame.command.graphCommand Assert-MultiProjectObject $command @("projectPath", "command") "graphCommand" - Assert-MultiProjectObject $command.command @("renameNode") "graphCommand.command" - $rename = $command.command.renameNode - Assert-MultiProjectObject $rename @("_0", "title") "renameNode" - Assert-MultiProjectUuid $rename._0 "nodeID" - if ($rename.title -isnot [string]) { throw "MULTIPROJECT_TYPE: title must be a string" } - if ([string]::IsNullOrWhiteSpace($rename.title)) { throw "MULTIPROJECT_TITLE: title cannot be blank" } - $owner = Find-MultiProjectGraph $peer $command.projectPath $rename._0 + if ($command.command -is [Collections.IDictionary] -and + @($command.command.Keys) -ccontains "resumeSession") { + Assert-MultiProjectObject $command.command @("resumeSession") "graphCommand.command" + $resume = $command.command.resumeSession + Assert-MultiProjectObject $resume @("_0") "resumeSession" + Assert-MultiProjectUuid $resume._0 "nodeID" + $owner = Find-MultiProjectGraph $peer $command.projectPath $resume._0 + } else { + Assert-MultiProjectObject $command.command @("renameNode") "graphCommand.command" + $rename = $command.command.renameNode + Assert-MultiProjectObject $rename @("_0", "title") "renameNode" + Assert-MultiProjectUuid $rename._0 "nodeID" + if ($rename.title -isnot [string]) { throw "MULTIPROJECT_TYPE: title must be a string" } + if ([string]::IsNullOrWhiteSpace($rename.title)) { throw "MULTIPROJECT_TITLE: title cannot be blank" } + $owner = Find-MultiProjectGraph $peer $command.projectPath $rename._0 + } $response = [ordered]@{ version = 2; kind = "response"; requestID = $frame.requestID; success = $true } } elseif ($verb -ceq "listRecentProjects") { Assert-MultiProjectObject $frame.command.listRecentProjects @() "listRecentProjects" @@ -182,7 +191,7 @@ function Invoke-MultiProjectRequest($peer, $frame) { nodeID = $rename._0; beforeTitle = $owner.nodes[0].title; title = $rename.title }) $owner.nodes[0].title = $rename.title } - return [ordered]@{ response = $response; publishPath = if ($null -ne $owner) { $owner.project.path } else { $null } } + return [ordered]@{ response = $response; publishPath = if ($null -ne $rename) { $owner.project.path } else { $null } } } function Complete-MultiProjectResponse($peer, $response) { diff --git a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 index e6e44edb..ef70275a 100644 --- a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 @@ -124,6 +124,18 @@ function Start-OwnedDaemon { if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { $script:daemonStdout = $stdout $script:daemonStderr = $stderr + # A PID exists before the lifetime mutex and listener are initialized. This is + # especially observable on restart, when the Zig test executable is already built. + $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), + [IO.Pipes.PipeDirection]::InOut) + try { + $readyPipe.Connect(15000) + } catch { + if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } + throw "Owned daemon did not publish its listener before the round-trip test: $($_.Exception.Message)" + } finally { + $readyPipe.Dispose() + } return $process } Start-Sleep -Milliseconds 100 diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 66759a4b..d87aa520 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -482,6 +482,34 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate $receipt.report.multiProjectPeer.controls.Count -eq 1 -and $receipt.rawJson -ceq $receiptFixture) "whole actual receipt lost custody/bytes" return "Actual accepted91dc file fixture only; not claimed as95e success state or current native evidence" } + function New-MultiReceiptWithResume { + $report = New-MultiReceiptReport + $peer = $report.multiProjectPeer + $id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + $owner = $peer.graphs[1] + $response = [ordered]@{ version = 2; kind = "response"; requestID = $id; success = $true } + $frame = [ordered]@{ version = 2; kind = "request"; requestID = $id + command = [ordered]@{ graphCommand = [ordered]@{ projectPath = $owner.project.path + command = [ordered]@{ resumeSession = [ordered]@{ _0 = $owner.nodes[0].id } } } } } + $peer.received += [ordered]@{ requestID = $id; frame = $frame; expectedResponse = $response } + $peer.answered += [ordered]@{ requestID = $id; response = $response } + $peer.receivedCount++; $peer.requestCount++; $peer.responseCount++ + $report.requestCount++; $report.responseCount++ + $report.commands += "graphCommand" + return $report + } + Invoke-MultiCase "whole peer receipt accepts a correlated nonmutating session open" { + $report = New-MultiReceiptWithResume + Assert-MultiProjectCompleteReport $report + Assert-MultiCase ($report.multiProjectPeer.applied.Count -eq 2) "session open changed rename accounting" + return "Resume acknowledgement is accounted separately from the two exact renames" + } + Invoke-MultiCase "whole peer receipt rejects a session open for a foreign owner's node" -Negative { + $report = New-MultiReceiptWithResume + $report.multiProjectPeer.received[-1].frame.command.graphCommand.command.resumeSession._0 = + $report.multiProjectPeer.graphs[0].nodes[0].id + return Reject-MultiCase { Assert-MultiProjectCompleteReport $report } "MULTIPROJECT_PEER_RECEIPT:" + } foreach ($mutation in @("missing-top", "unknown-top", "disconnected", "correlation-false", "global-error", "global-unanswered", "root-count", "connection-type", "graph-not-sent", "missing-peer", "unknown-peer", "count-type", "count-missing", "unanswered", "empty-requests", "request-duplicate", "request-id", "request-kind-type", "request-extra", @@ -1611,6 +1639,27 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate Invoke-MultiCase "offscreen target rejected" -Negative { return Reject-MultiCase { Get-MultiProjectClippedRectangle @(0,0,50,50) @(220,34,1200,900) } "MULTIPROJECT_BOUNDS" } + Invoke-MultiCase "resumeSession acknowledges an exact fixture owner without changing its graph" { + $state = New-MultiBaseline + $before = ConvertTo-MultiProjectCanonicalJson $state.graphs + $frame = Copy-MultiProjectValue (New-MultiRequest) + $frame.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $a } } + $result = Invoke-MultiProjectRequest $state $frame + Assert-MultiCase ($result.response.success -eq $true -and $null -eq $result.publishPath -and + $state.applied.Count -eq 0 -and + (ConvertTo-MultiProjectCanonicalJson $state.graphs) -ceq $before) "session open mutated the fixture graph" + return "Exact owner accepted; acknowledgement only, no agent launch or graph mutation" + } + Invoke-MultiCase "resumeSession refuses a node belonging to another project" -Negative { + $state = New-MultiBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $state) + $frame = Copy-MultiProjectValue (New-MultiRequest) + $frame.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $b } } + $result = Reject-MultiCase { Invoke-MultiProjectRequest $state $frame } "MULTIPROJECT_OWNER" + Assert-MultiCase ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $state)) -ceq $before) ` + "foreign session open changed peer state" + return $result + } $requestMutations = [ordered]@{ owner = @{ prefix = "MULTIPROJECT_OWNER"; change = { param($f) $f.command.graphCommand.projectPath = $beta } } node = @{ prefix = "MULTIPROJECT_OWNER"; change = { param($f) $f.command.graphCommand.command.renameNode._0 = $b } } diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 34aa2cba..91c03cc9 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -333,16 +333,47 @@ Assert-Contract ($shellSource -match '(?s)\$evidence = .*?STUB_DAEMON_EVIDENCE_J "stub protocol evidence is not emitted before validation can fail" Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` "session tracking must not block on unrelated zmx namespaces" +Assert-Contract ($shellSource -notmatch 'ASSUME_LOOP_SESSIONS') ` + "smoke must verify real loop sessions, not bypass launch readiness" +& { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput( + $shellSource, [ref]$tokens, [ref]$errors) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-SmokeZmxRoot" + }, $true) + Assert-Contract ($null -ne $function) "smoke zmx root initializer is missing" + . ([scriptblock]::Create($function.Extent.Text)) + $ownedRoot = Join-Path ([IO.Path]::GetTempPath()) ("zmx-owner-test-" + [guid]::NewGuid().ToString("N")) + try { + New-SmokeZmxRoot $ownedRoot + $acl = Get-Acl -LiteralPath $ownedRoot + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + Assert-Contract ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $sid.Value) ` + "smoke root owner must be the user SID, not Administrators" + Assert-Contract $acl.AreAccessRulesProtected "smoke root must not inherit foreign access" + $refused = $false + try { New-SmokeZmxRoot $ownedRoot } catch { $refused = $true } + Assert-Contract $refused "smoke must never take ownership of a preexisting directory" + } finally { + if (Test-Path -LiteralPath $ownedRoot) { Remove-Item -LiteralPath $ownedRoot -Recurse -Force } + } +} & { $sessionPrefix = "gs-owned" $testSessionIds = @("11111111-1111-4111-8111-111111111111") $processFixtures = @( - [pscustomobject]@{ ProcessId = 101; CommandLine = 'zmx.exe --daemon gs-owned-pane' }, - [pscustomobject]@{ ProcessId = 102; CommandLine = 'zmx.exe attach "11111111-1111-4111-8111-111111111111"' }, - [pscustomobject]@{ ProcessId = 103; CommandLine = 'zmx.exe --daemon gs-other-pane' }, - [pscustomobject]@{ ProcessId = 104; CommandLine = 'zmx.exe --daemon prefix-gs-owned-pane' }, - [pscustomobject]@{ ProcessId = 105; CommandLine = 'zmx.exe --daemon 11111111-1111-4111-8111-111111111111-suffix' }, - [pscustomobject]@{ ProcessId = 106; CommandLine = $null } + [pscustomobject]@{ ProcessId = 101; CommandLine = 'zmx.exe --daemon graphcode-gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 102; CommandLine = 'zmx.exe attach "graphcode-11111111-1111-4111-8111-111111111111"' }, + [pscustomobject]@{ ProcessId = 103; CommandLine = 'zmx.exe --daemon graphcode-gs-other-pane' }, + [pscustomobject]@{ ProcessId = 104; CommandLine = 'zmx.exe --daemon prefix-graphcode-gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 105; CommandLine = 'zmx.exe --daemon graphcode-11111111-1111-4111-8111-111111111111-suffix' }, + [pscustomobject]@{ ProcessId = 106; CommandLine = $null }, + [pscustomobject]@{ ProcessId = 107; CommandLine = 'zmx.exe --daemon gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 108; CommandLine = 'zmx.exe attach 11111111-1111-4111-8111-111111111111' } ) function Get-CimInstance { $processFixtures } $tokens = $null @@ -359,7 +390,7 @@ Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` Assert-Contract (($records.Pid -join ",") -eq "101,102") ` "session tracking included a foreign or partial-match process" Assert-Contract (($records.Name -join ",") -eq - "gs-owned-pane,11111111-1111-4111-8111-111111111111") ` + "graphcode-gs-owned-pane,graphcode-11111111-1111-4111-8111-111111111111") ` "session tracking did not preserve exact owned names" } if ($shellSource -match '(?m)^\s*Write-OwnedResourceMetrics\s*$') { @@ -606,9 +637,9 @@ Assert-Contract ($zmxSessionSource -match '(?s)pub fn child\(.*?\.create_no_wind "zmx children must be created without a console window" Assert-Contract ($terminalSurfaceSource -notmatch 'std\.process\.Child\.init\(' -and $workspaceTeardownSource -notmatch 'std\.process\.Child\.init\(' -and - [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 2 -and + [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 3 -and [regex]::Matches($workspaceTeardownSource, 'ZmxSession\.child\(').Count -eq 1) ` - "zmx attach, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" + "zmx attach, listing, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" $zig = Resolve-TestZig Invoke-Native "Accessibility contract executable tests" { @@ -876,6 +907,8 @@ Invoke-Native "Zmx session identity executable tests" { Push-Location $shellRoot try { & $zig test src\ZmxSession.zig + if ($LASTEXITCODE -ne 0) { throw "zmx session identity tests failed" } + & $zig test src\LoopLaunchWait.zig } finally { Pop-Location } } Invoke-Native "Loop bar layout executable tests" { diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index bb605465..51974d38 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2796,14 +2796,25 @@ function Assert-MultiProjectCompleteReport($report) { if ($verb -ceq "graphCommand") { $command = $frame.command.graphCommand Assert-MultiProjectReceiptObject $command @("projectPath","command") "graph command" - Assert-MultiProjectReceiptObject $command.command @("renameNode") "inner command" - Assert-MultiProjectReceiptObject $command.command.renameNode @("_0","title") "rename" - Assert-MultiProjectReceiptId $command.command.renameNode._0 - if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or - $command.projectPath -cne $peer.graphs[0].project.path -or - $command.command.renameNode._0 -cne $owners[$command.projectPath].nodes[0].id -or - $command.command.renameNode.title -isnot [string] -or [string]::IsNullOrWhiteSpace($command.command.renameNode.title)) { - throw "MULTIPROJECT_PEER_RECEIPT: actual rename owner/value mismatch" + if ($command.command -is [Collections.IDictionary] -and + @($command.command.Keys) -ccontains "resumeSession") { + Assert-MultiProjectReceiptObject $command.command @("resumeSession") "inner command" + Assert-MultiProjectReceiptObject $command.command.resumeSession @("_0") "session open" + Assert-MultiProjectReceiptId $command.command.resumeSession._0 + if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or + $command.command.resumeSession._0 -cne $owners[$command.projectPath].nodes[0].id) { + throw "MULTIPROJECT_PEER_RECEIPT: actual session-open owner mismatch" + } + } else { + Assert-MultiProjectReceiptObject $command.command @("renameNode") "inner command" + Assert-MultiProjectReceiptObject $command.command.renameNode @("_0","title") "rename" + Assert-MultiProjectReceiptId $command.command.renameNode._0 + if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or + $command.projectPath -cne $peer.graphs[0].project.path -or + $command.command.renameNode._0 -cne $owners[$command.projectPath].nodes[0].id -or + $command.command.renameNode.title -isnot [string] -or [string]::IsNullOrWhiteSpace($command.command.renameNode.title)) { + throw "MULTIPROJECT_PEER_RECEIPT: actual rename owner/value mismatch" + } } } elseif ($verb -ceq "openProject") { Assert-MultiProjectReceiptObject $frame.command.openProject @("path") "openProject" @@ -2869,13 +2880,17 @@ function Assert-MultiProjectCompleteReport($report) { if ($application.requestID -isnot [string] -or -not $requests.ContainsKey($application.requestID) -or $applied.ContainsKey($application.requestID)) { throw "MULTIPROJECT_PEER_RECEIPT: application correlation missing/duplicated" } $wire = $requests[$application.requestID].frame.command.graphCommand - if ($null -eq $wire -or $application.projectPath -cne $wire.projectPath -or + if ($null -eq $wire -or -not $wire.command.Contains("renameNode") -or + $application.projectPath -cne $wire.projectPath -or $application.projectPath -isnot [string] -or $application.nodeID -isnot [string] -or $application.title -isnot [string] -or $application.nodeID -cne $wire.command.renameNode._0 -or $application.title -cne $wire.command.renameNode.title -or $application.beforeTitle -isnot [string]) { throw "MULTIPROJECT_PEER_RECEIPT: actual application differs from received wire" } $applied.Add($application.requestID,$application) } - if (@($peer.received | Where-Object { $_.frame.command.Contains("graphCommand") }).Count -ne 2) { + if (@($peer.received | Where-Object { + $_.frame.command.Contains("graphCommand") -and + $_.frame.command.graphCommand.command.Contains("renameNode") + }).Count -ne 2) { throw "MULTIPROJECT_PEER_RECEIPT: received/applied rename count differs" } $control = $peer.controls[0] diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 12810245..ccec9fc5 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -48,6 +48,9 @@ $oldWorkspaceLayout = [Environment]::GetEnvironmentVariable("GRAPHCODE_WORKSPACE $oldStubNodeA = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_A") $oldStubNodeB = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_B") $oldSessionPrefix = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_SESSION_PREFIX") +$oldZmxDir = [Environment]::GetEnvironmentVariable("ZMX_DIR") +$smokeZmxDir = Join-Path ([IO.Path]::GetPathRoot([string] $repoRoot)) ("gcz-" + [guid]::NewGuid().ToString("N").Substring(0, 12)) +$stubSessionHosts = @{} $workspaceLayoutBase = Join-Path $shellRoot "graphcode-workspace-$PID.json" $ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() $ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() @@ -70,6 +73,25 @@ function Assert-Equal([string] $actual, [string] $expected, [string] $label) { } } +function New-SmokeZmxRoot([string] $Path) { + if (Test-Path -LiteralPath $Path) { throw "Smoke zmx root already exists: $Path" } + New-Item -ItemType Directory -Path $Path | Out-Null + # Elevated runners default the owner to Administrators. zmx requires the token's + # actual user SID; set it only on this fresh, test-owned directory. + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($sid) + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]"ContainerInherit,ObjectInherit", + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $Path -AclObject $acl + $owner = (Get-Acl -LiteralPath $Path).GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -ne $sid.Value) { throw "Smoke zmx root owner does not match the current user" } +} + function Test-TestSessionProcess([object] $process) { if (-not $process.CommandLine) { return $false } foreach ($session in $testSessionIds) { @@ -81,8 +103,8 @@ function Test-TestSessionProcess([object] $process) { } function Get-ZmxSessionRecords { - $names = @($testSessionIds | ForEach-Object { [regex]::Escape($_) }) - $names += [regex]::Escape($sessionPrefix) + "-[A-Za-z0-9_-]+" + $names = @($testSessionIds | ForEach-Object { [regex]::Escape("graphcode-$_") }) + $names += [regex]::Escape("graphcode-$sessionPrefix") + "-[A-Za-z0-9_-]+" $pattern = "(? 0 and !workspace.hasSurface(0)) { - workspace.openNode(0, graph.nodes.items[0].id) catch { - self.setStatus("Unable to attach terminal A"); + // Only shows loops that are already running: attaching here must never create a + // session, or a loop's agent loses the race to a bare shell (LoopLaunchWait). + for (0..@min(graph.nodes.items.len, 2)) |pane| { + if (workspace.hasSurface(pane) or workspace.isAwaitingLaunch(pane)) continue; + workspace.openLaunchedNode(pane, graph.nodes.items[pane].id, 0) catch { + self.setStatus(if (pane == 0) "Unable to attach terminal A" else "Unable to attach terminal B"); }; } + } - if (graph.nodes.items.len > 1 and !workspace.hasSurface(1)) { - workspace.openNode(1, graph.nodes.items[1].id) catch { - self.setStatus("Unable to attach terminal B"); - }; - } + /// Asks the daemon to start an attended loop's agent — a no-op for a loop already + /// running — and opens its pane once that session exists, never as a bare shell. + fn openGraphLoop(self: *App, workspace: *TerminalWorkspace.Workspace, project_path: []const u8, node_id: []const u8) void { + self.client.sendNodeAction(project_path, node_id, "resumeSession", null); + workspace.openLaunchedNode(0, node_id, TerminalWorkspace.Workspace.loop_open_timeout_ms) catch { + self.setStatus("Unable to open selected loop"); + return; + }; + if (workspace.isAwaitingLaunch(0)) self.setStatus("Starting loop"); + } + + fn reportLaunchOutcome(self: *App, workspace: *TerminalWorkspace.Workspace) void { + const outcome = workspace.takeLaunchOutcome() orelse return; + self.setStatus(switch (outcome) { + .started => "Loop opened", + .not_started => "Loop session did not start; check the loop's agent and try again", + .attach_failed => "Unable to open selected loop", + }); } fn rebindWorkspace(self: *App, path: []const u8) void { @@ -3313,14 +3330,13 @@ pub const App = struct { return; } const workspace = if (self.workspace) |value| value else return; + const project_path = self.currentProject() orelse return; self.workspace_is_quick_chat = false; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); self.layoutEmptyStateControls(); - workspace.openNode(0, graph.nodes.items[index].id) catch { - self.setStatus("Unable to open selected node"); - }; + self.openGraphLoop(workspace, project_path, graph.nodes.items[index].id); } fn stopSelectedNode(self: *App) void { @@ -6638,9 +6654,7 @@ pub const App = struct { self.clearEdgeSelection(); self.rebindWorkspace(project_path); if (self.workspace) |workspace| { - workspace.openNode(0, graph.nodes.items[index].id) catch { - self.setStatus("Unable to open selected loop"); - }; + self.openGraphLoop(workspace, project_path, graph.nodes.items[index].id); workspace.focusRestoredPane() catch self.setStatus("Unable to restore selected terminal focus"); } self.syncAccessibility(); @@ -7551,6 +7565,7 @@ fn onWindowMessage( if (app.client.isIdle()) app.smoke_idle_ticks += 1 else app.smoke_idle_ticks = 0; if (app.workspace) |workspace| { workspace.poll(); + app.reportLaunchOutcome(workspace); if (!app.smoke_workspace_restart_observed) { if (app.smoke_restart_index) |index| { if (workspace.surfaceIdentityReady(index, app.smoke_restart_session, workspace.projectPath())) { @@ -7823,9 +7838,7 @@ fn onWindowMessage( app.rebindWorkspace(graph.project.path); _ = app.selectNodeIndex(hit.node_index); if (app.workspace) |workspace| { - workspace.openNode(0, graph.nodes.items[hit.node_index].id) catch { - app.setStatus("Unable to open selected loop"); - }; + app.openGraphLoop(workspace, graph.project.path, graph.nodes.items[hit.node_index].id); workspace.focusRestoredPane() catch app.setStatus("Unable to restore selected terminal focus"); } } @@ -8034,9 +8047,7 @@ fn onWindowMessage( if (row.index >= selected_graph.nodes.items.len) return true; _ = app.selectNodeIndex(row.index); if (app.workspace) |workspace| { - workspace.openNode(0, selected_graph.nodes.items[row.index].id) catch { - app.setStatus("Unable to open selected loop"); - }; + app.openGraphLoop(workspace, path, selected_graph.nodes.items[row.index].id); workspace.focusRestoredPane() catch app.setStatus("Unable to restore selected terminal focus"); } } diff --git a/graphcode-windows/src/LoopLaunchWait.zig b/graphcode-windows/src/LoopLaunchWait.zig new file mode 100644 index 00000000..89937f86 --- /dev/null +++ b/graphcode-windows/src/LoopLaunchWait.zig @@ -0,0 +1,208 @@ +//! Waits for the daemon to start a graph loop's zmx session before a pane attaches. +//! +//! `zmx attach` creates a plain shell session when the named one is not reachable, so a +//! pane that attached first would win the race against the daemon's launch and the loop's +//! agent would never start. The pane instead probes `zmx ls` until the task is live, +//! then attaches. Pure state so it is testable without zmx. + +const std = @import("std"); +const ZmxSession = @import("ZmxSession.zig"); + +pub const probe_interval_ms: i64 = 250; +/// How long an explicit open waits for the daemon to start the loop's agent. +pub const open_timeout_ms: i64 = 30_000; +/// After a passive check found nothing, how long before another passive check. +pub const passive_retry_ms: i64 = 5_000; + +pub const Probe = enum { running, live, missing }; +pub const Step = enum { idle, start_probe, attach, give_up }; + +/// Whether a persisted pane is restored. A shell tab is (its session is recreated if it +/// ended); a loop pane only while the daemon's session is still running. Restoring it +/// otherwise would create a bare shell under the loop's name, and every later open would +/// then find the loop "running" and never launch its agent. +pub fn restoreKeepsPane(launches_agent: bool, session_live: ?bool) bool { + return !launches_agent or session_live != false; +} + +pub const Wait = struct { + session: []u8 = &.{}, + deadline_ms: i64 = 0, + next_probe_ms: i64 = 0, + probing: bool = false, + /// An explicit open reports a launch that never came; a passive check stays quiet. + reports_timeout: bool = false, + + pub fn active(self: *const Wait) bool { + return self.session.len != 0; + } + + pub fn begin(self: *Wait, session: []u8, now_ms: i64, timeout_ms: i64, reports_timeout: bool) void { + self.* = .{ + .session = session, + .deadline_ms = now_ms + @max(timeout_ms, 0), + .next_probe_ms = now_ms, + .reports_timeout = reports_timeout, + }; + } + + /// An explicit open of a loop already being waited on keeps the wait, but no longer + /// gives up sooner than the open asks and reports if it does. + pub fn extend(self: *Wait, now_ms: i64, timeout_ms: i64, reports_timeout: bool) void { + self.deadline_ms = @max(self.deadline_ms, now_ms + @max(timeout_ms, 0)); + self.reports_timeout = self.reports_timeout or reports_timeout; + } + + /// The caller owns `session` again once this returns. + pub fn finish(self: *Wait) []u8 { + const session = self.session; + self.* = .{}; + return session; + } + + /// `probe` is the outcome of the probe in flight, or null when none was started. + pub fn step(self: *Wait, now_ms: i64, probe: ?Probe) Step { + if (!self.active()) return .idle; + if (self.probing) { + const outcome = probe orelse .missing; + switch (outcome) { + .running => return .idle, + .live => { + self.probing = false; + return .attach; + }, + .missing => { + self.probing = false; + if (now_ms >= self.deadline_ms) return .give_up; + self.next_probe_ms = now_ms + probe_interval_ms; + return .idle; + }, + } + } + if (now_ms < self.next_probe_ms) return .idle; + self.probing = true; + return .start_probe; + } +}; + +/// `zmx ls`, never `info` or `attach`: only the listing says whether a session's task has +/// ended, and attaching to such a husk exits at once. +pub fn probeArguments(program: []const u8, output: *[2][]const u8) []const []const u8 { + output.* = .{ program, "ls" }; + return output; +} + +/// The daemon's own test (`ZmxSessionLauncher.isSessionAlive`): the session is listed and +/// its task has neither ended nor become unreachable. +pub fn listingShowsLive(listing: []const u8, session: []const u8) bool { + var name_buffer: [ZmxSession.prefix.len + 128]u8 = undefined; + const name = ZmxSession.nameBuffer(session, &name_buffer) catch return false; + var lines = std.mem.splitScalar(u8, listing, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimRight(u8, raw, "\r"); + if (std.mem.indexOf(u8, line, "\tended=") != null or + std.mem.indexOf(u8, line, "\texit_code=") != null or + std.mem.indexOf(u8, line, "\terr=") != null) continue; + var fields = std.mem.tokenizeAny(u8, line, " \t"); + const field = fields.next() orelse continue; + if (std.mem.startsWith(u8, field, "name=") and std.mem.eql(u8, field["name=".len..], name)) return true; + } + return false; +} + +test "an explicit open probes until the daemon's session exists, then attaches" { + var name = "11111111-1111-4111-8111-111111111111".*; + var wait: Wait = .{}; + wait.begin(&name, 1_000, open_timeout_ms, true); + try std.testing.expectEqual(Step.start_probe, wait.step(1_000, null)); + try std.testing.expectEqual(Step.idle, wait.step(1_010, .running)); + try std.testing.expectEqual(Step.idle, wait.step(1_100, .missing)); + try std.testing.expectEqual(Step.idle, wait.step(1_200, null)); + try std.testing.expectEqual(Step.start_probe, wait.step(1_350, null)); + try std.testing.expectEqual(Step.attach, wait.step(1_400, .live)); + try std.testing.expectEqualStrings(&name, wait.finish()); + try std.testing.expect(!wait.active()); +} + +test "an open whose launch never comes gives up at the deadline and says so" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 500, true); + var now: i64 = 0; + var outcome = Step.idle; + while (now <= 10_000 and outcome != .give_up) : (now += 50) { + outcome = wait.step(now, if (wait.probing) .missing else null); + try std.testing.expect(outcome != .attach); + } + try std.testing.expectEqual(Step.give_up, outcome); + try std.testing.expect(now - 50 >= 500); + try std.testing.expect(wait.reports_timeout); +} + +test "a passive check probes once and never creates or retries a missing session" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 5, 0, false); + try std.testing.expectEqual(Step.start_probe, wait.step(5, null)); + try std.testing.expectEqual(Step.give_up, wait.step(90, .missing)); + try std.testing.expect(!wait.reports_timeout); +} + +test "a failed probe spawn counts as missing rather than wedging the wait" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 1_000, true); + try std.testing.expectEqual(Step.start_probe, wait.step(0, null)); + try std.testing.expectEqual(Step.idle, wait.step(1, null)); + try std.testing.expectEqual(Step.start_probe, wait.step(251, null)); +} + +test "re-opening a loop being waited on extends the wait without shortening it" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 0, false); + wait.extend(100, open_timeout_ms, true); + try std.testing.expectEqual(@as(i64, 100 + open_timeout_ms), wait.deadline_ms); + try std.testing.expect(wait.reports_timeout); + wait.extend(200, 0, false); + try std.testing.expectEqual(@as(i64, 100 + open_timeout_ms), wait.deadline_ms); + try std.testing.expect(wait.reports_timeout); +} + +test "a restored loop pane is kept only while its session runs; a shell tab always is" { + try std.testing.expect(restoreKeepsPane(true, true)); + try std.testing.expect(!restoreKeepsPane(true, false)); + try std.testing.expect(restoreKeepsPane(false, false)); + try std.testing.expect(restoreKeepsPane(false, true)); +} + +test "the probe lists sessions without attaching to or creating one" { + var output: [2][]const u8 = undefined; + const argv = probeArguments("zmx.exe", &output); + try std.testing.expectEqual(@as(usize, 2), argv.len); + try std.testing.expectEqualStrings("zmx.exe", argv[0]); + try std.testing.expectEqualStrings("ls", argv[1]); +} + +test "a listed session counts as live only while its task runs" { + const listing = + "name=graphcode-00000000-0000-4000-8000-0A6CC9277ED5\tpid=11048\tclients=0\tcreated=1791318218\tcwd=C:\\p\tended=1791318818\texit_code=0\r\n" ++ + "name=graphcode-00000000-0000-4000-8000-0B492B5F5249\tpid=23500\tclients=1\tcreated=1791319165\tcwd=C:\\p\tcmd=copilot --interactive x\r\n" ++ + " name=graphcode-gone\terr=PipeBusy\tstatus=unreachable\n"; + try std.testing.expect(listingShowsLive(listing, "00000000-0000-4000-8000-0B492B5F5249")); + try std.testing.expect(!listingShowsLive(listing, "00000000-0000-4000-8000-0A6CC9277ED5")); + try std.testing.expect(!listingShowsLive(listing, "gone")); + try std.testing.expect(!listingShowsLive(listing, "00000000-0000-4000-8000-0B492B5F524")); + try std.testing.expect(!listingShowsLive("", "anything")); +} + +test "a failed listing must not delete a saved loop pane" { + try std.testing.expect(restoreKeepsPane(true, null)); +} + +test "a session name mentioned in another task command is not a live session" { + try std.testing.expect(!listingShowsLive( + "name=graphcode-other\tpid=1\tcmd=echo name=graphcode-wanted\n", + "wanted", + )); +} diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 5a2e54c8..df7ad917 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -8,6 +8,7 @@ const GdiGradient = @import("GdiGradient.zig"); const Dpi = @import("Dpi.zig"); const TerminalVt = @import("TerminalVt.zig"); const ZmxSession = @import("ZmxSession.zig"); +const LoopLaunchWait = @import("LoopLaunchWait.zig"); const columns: usize = 120; const rows: usize = 40; @@ -339,6 +340,13 @@ fn moveReplacementSurface( } pub const Workspace = struct { + pub const LaunchOutcome = enum { started, not_started, attach_failed }; + pub const loop_open_timeout_ms = LoopLaunchWait.open_timeout_ms; + // A listing that outlives its wait's deadline by this much is treated as no answer. + const launch_probe_grace_ms: i64 = 5_000; + const restore_probe_timeout_ms: i64 = 2_000; + const max_listing_bytes: usize = 1 << 20; + parent: c.HWND, host: ?*c.winghostty_host = null, surfaces: [max_surfaces]Surface = [_]Surface{.{}} ** max_surfaces, @@ -351,6 +359,13 @@ pub const Workspace = struct { recreate_due_ms: [max_surfaces]i64 = [_]i64{0} ** max_surfaces, recreate_delay_ms: [max_surfaces]i64 = [_]i64{100} ** max_surfaces, restore_errors: [max_surfaces][]u8 = [_][]u8{&.{}} ** max_surfaces, + // A graph loop's pane attaches only after the daemon's session exists (LoopLaunchWait). + launch_waits: [max_surfaces]LoopLaunchWait.Wait = [_]LoopLaunchWait.Wait{.{}} ** max_surfaces, + launch_probes: [max_surfaces]?std.process.Child = [_]?std.process.Child{null} ** max_surfaces, + launch_probe_output: [max_surfaces]std.ArrayListUnmanaged(u8) = [_]std.ArrayListUnmanaged(u8){.empty} ** max_surfaces, + daemon_sessions: [max_surfaces]bool = [_]bool{false} ** max_surfaces, + passive_retry_due_ms: [max_surfaces]i64 = [_]i64{0} ** max_surfaces, + launch_outcome: ?LaunchOutcome = null, fatal_error: bool = false, render_error: c.winghostty_result = c.WINGHOSTTY_OK, input_mutex: std.Thread.Mutex = .{}, @@ -445,6 +460,7 @@ pub const Workspace = struct { pub fn deinit(self: *Workspace) void { self.stopResizeChild(); self.stopInputWorker(); + self.cancelAllLaunchWaits(); for (self.surfaces, 0..) |_, index| self.destroySurface(index); for (&self.recreate_sessions) |*session| { if (session.*.len != 0) self.allocator.free(session.*); @@ -570,7 +586,242 @@ pub const Workspace = struct { } + /// Attaches a pane to a session, creating a plain shell session when none is running — + /// right for a shell tab, wrong for a graph loop, whose pane uses `openLaunchedNode`. pub fn openNode(self: *Workspace, index: usize, node_id: []const u8) !void { + if (index >= self.surfaces.len) return error.InvalidSurface; + self.cancelLaunchWait(index); + self.daemon_sessions[index] = false; + try self.attachNode(index, node_id, false); + } + + /// Opens a graph loop's pane once the daemon has started its session, never creating + /// one itself. A `timeout_ms` of 0 is a passive check: attach if the loop is already + /// running, otherwise leave the pane alone. + pub fn openLaunchedNode(self: *Workspace, index: usize, node_id: []const u8, timeout_ms: i64) !void { + if (index >= self.surfaces.len) return error.InvalidSurface; + const slot = &self.surfaces[index]; + if ((slot.surface != null or slot.attach != null) and + std.mem.eql(u8, slot.session_name, node_id)) + { + self.cancelLaunchWait(index); + if (timeout_ms > 0) self.launch_outcome = .started; + return; + } + const now = nowMilliseconds(); + const explicit = timeout_ms > 0; + const wait = &self.launch_waits[index]; + if (!explicit and now < self.passive_retry_due_ms[index]) return; + const session = try self.allocator.dupe(u8, node_id); + errdefer self.allocator.free(session); + // Mount the tab immediately, but not the terminal: pending/failed launches still + // need navigable workspace chrome, without creating a session behind the daemon. + if (explicit and slot.surface == null and slot.attach == null) { + if (self.layout.tabs.items.len == 0) { + const previous_next_id = self.layout.next_tab_id; + try self.layout.addTab(node_id, true); + self.persistLayout() catch |err| { + _ = self.layout.removePane(node_id); + self.layout.next_tab_id = previous_next_id; + return err; + }; + } else if (wait.active() and wait.reports_timeout) { + try self.layout.replacePaneID(wait.session, node_id); + self.persistLayout() catch |err| { + self.layout.replacePaneID(node_id, wait.session) catch {}; + return err; + }; + } + } + if (wait.active() and std.mem.eql(u8, wait.session, node_id)) { + self.allocator.free(session); + wait.extend(now, timeout_ms, explicit); + return; + } + self.cancelLaunchWait(index); + self.clearRecreateSession(index); + self.launch_waits[index].begin(session, now, timeout_ms, explicit); + } + + pub fn isAwaitingLaunch(self: *const Workspace, index: usize) bool { + return index < self.launch_waits.len and self.launch_waits[index].active(); + } + + pub fn takeLaunchOutcome(self: *Workspace) ?LaunchOutcome { + defer self.launch_outcome = null; + return self.launch_outcome; + } + + /// Asks zmx, without attaching or creating, whether a session is running. Bounded so a + /// wedged zmx cannot hold the UI thread. + fn sessionIsLive(self: *Workspace, session: []const u8) ?bool { + var child = self.spawnListing() orelse return null; + var output: std.ArrayListUnmanaged(u8) = .empty; + defer output.deinit(self.allocator); + const deadline = nowMilliseconds() + restore_probe_timeout_ms; + var exit_code: c.DWORD = c.STILL_ACTIVE; + while (true) { + self.drainListing(&child, &output) catch { + _ = child.kill() catch {}; + self.setInputError("Unable to read loop session listing"); + return null; + }; + if (c.GetExitCodeProcess(child.id, &exit_code) == 0) { + _ = child.kill() catch {}; + self.setInputError("Unable to query loop session listing process"); + return null; + } + if (exit_code != c.STILL_ACTIVE) break; + if (nowMilliseconds() >= deadline) break; + std.Thread.sleep(10 * std.time.ns_per_ms); + } + if (exit_code == c.STILL_ACTIVE) { + _ = child.kill() catch {}; + self.setInputError("Loop session listing timed out"); + return null; + } + self.drainListing(&child, &output) catch { + _ = child.wait() catch {}; + self.setInputError("Unable to read loop session listing"); + return null; + }; + _ = child.wait() catch { + self.setInputError("Unable to reap loop session listing process"); + return null; + }; + if (exit_code != 0) { + self.setInputError("Loop session listing failed"); + return null; + } + return LoopLaunchWait.listingShowsLive(output.items, session); + } + + fn spawnListing(self: *Workspace) ?std.process.Child { + var args: [2][]const u8 = undefined; + var child = ZmxSession.child( + self.allocator, + LoopLaunchWait.probeArguments(self.zmx_path, &args), + self.cwd, + .capture, + ); + child.spawn() catch { + self.setInputError("Unable to start loop session listing"); + return null; + }; + return child; + } + + /// Reads whatever the listing has written so far, so a long one never blocks on a + /// full pipe. Bounded: the listing is a few hundred bytes per session. + fn drainListing(self: *Workspace, child: *std.process.Child, output: *std.ArrayListUnmanaged(u8)) !void { + const stdout = child.stdout orelse return error.SessionListingPipeMissing; + var buffer: [4096]u8 = undefined; + while (output.items.len < max_listing_bytes) { + var available: c.DWORD = 0; + if (c.PeekNamedPipe(stdout.handle, null, 0, null, &available, null) == 0) { + if (c.GetLastError() == c.ERROR_BROKEN_PIPE) return; + return error.SessionListingReadFailed; + } + if (available == 0) return; + var count: c.DWORD = 0; + const want: c.DWORD = @intCast(@min(buffer.len, available, max_listing_bytes - output.items.len)); + if (c.ReadFile(stdout.handle, &buffer, want, &count, null) == 0) return error.SessionListingReadFailed; + if (count == 0) return; + try output.appendSlice(self.allocator, buffer[0..count]); + } + return error.SessionListingTooLarge; + } + + fn pollLaunchWaits(self: *Workspace) void { + const now = nowMilliseconds(); + for (&self.launch_waits, 0..) |*wait, index| { + if (!wait.active()) continue; + const outcome = self.launchProbeOutcome(index, now >= wait.deadline_ms + launch_probe_grace_ms); + switch (wait.step(now, outcome)) { + .idle => {}, + .start_probe => self.startLaunchProbe(index), + .attach => { + const explicit = wait.reports_timeout; + const session = wait.finish(); + defer self.allocator.free(session); + self.attachNode(index, session, true) catch { + if (explicit) self.launch_outcome = .attach_failed; + continue; + }; + self.daemon_sessions[index] = true; + if (explicit) { + self.launch_outcome = .started; + self.focusRestoredPane() catch {}; + } + }, + .give_up => { + if (wait.reports_timeout) { + self.launch_outcome = .not_started; + } else { + self.passive_retry_due_ms[index] = now + LoopLaunchWait.passive_retry_ms; + } + self.allocator.free(wait.finish()); + }, + } + } + } + + fn startLaunchProbe(self: *Workspace, index: usize) void { + self.launch_probe_output[index].clearRetainingCapacity(); + self.launch_probes[index] = self.spawnListing(); + } + + fn launchProbeOutcome(self: *Workspace, index: usize, overdue: bool) ?LoopLaunchWait.Probe { + const child = if (self.launch_probes[index]) |*value| value else return null; + const output = &self.launch_probe_output[index]; + self.drainListing(child, output) catch { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + self.setInputError("Unable to read loop session listing"); + return .missing; + }; + var exit_code: c.DWORD = 0; + if (c.GetExitCodeProcess(child.id, &exit_code) == 0 or + (exit_code == c.STILL_ACTIVE and overdue)) + { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + return .missing; + } + if (exit_code == c.STILL_ACTIVE) return .running; + self.drainListing(child, output) catch { + _ = child.wait() catch {}; + self.launch_probes[index] = null; + self.setInputError("Unable to read loop session listing"); + return .missing; + }; + _ = child.wait() catch {}; + self.launch_probes[index] = null; + const live = exit_code == 0 and + LoopLaunchWait.listingShowsLive(output.items, self.launch_waits[index].session); + return if (live) .live else .missing; + } + + fn cancelLaunchWait(self: *Workspace, index: usize) void { + if (self.launch_probes[index]) |*child| { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + } + self.launch_probe_output[index].clearAndFree(self.allocator); + if (self.launch_waits[index].active()) self.allocator.free(self.launch_waits[index].finish()); + } + + fn cancelAllLaunchWaits(self: *Workspace) void { + for (0..max_surfaces) |index| { + self.cancelLaunchWait(index); + self.daemon_sessions[index] = false; + self.passive_retry_due_ms[index] = 0; + } + self.launch_outcome = null; + } + + /// `loop_pane` marks a pane the daemon owns, so a restore never recreates its session. + fn attachNode(self: *Workspace, index: usize, node_id: []const u8, loop_pane: bool) !void { if (index >= self.surfaces.len) return error.InvalidSurface; if (self.surfaces[index].surface != null or self.surfaces[index].attach != null) { const old_id = try self.allocator.dupe(u8, self.surfaces[index].session_name); @@ -581,6 +832,7 @@ pub const Workspace = struct { self.destroySurface(replacement_index); return err; }; + _ = self.layout.setLaunchesAgent(node_id, loop_pane); self.persistLayout() catch |err| { self.layout.replacePaneID(node_id, old_id) catch {}; self.destroySurface(replacement_index); @@ -603,7 +855,7 @@ pub const Workspace = struct { if (self.layout.tabs.items.len == 0) { try self.layout.addTab(node_id, true); } else if (index > 0 and self.layout.tabs.items.len == 1) { - try self.layout.addTab(node_id, false); + try self.layout.addTab(node_id, loop_pane); } try self.persistLayout(); var options = self.surfaceOptions(index); @@ -653,7 +905,7 @@ pub const Workspace = struct { fn createAttachedSurface(self: *Workspace, session: []const u8, initial_grid: GridSize) !usize { for (&self.surfaces, 0..) |*slot, index| { - if (slot.surface != null or slot.attach != null) continue; + if (slot.surface != null or slot.attach != null or self.launch_waits[index].active()) continue; slot.session_name = try self.allocator.dupe(u8, session); errdefer self.destroySurface(index); slot.project_path = try self.allocator.dupe(u8, self.project_path); @@ -686,30 +938,45 @@ pub const Workspace = struct { fn restorePersistedSurfaces(self: *Workspace) void { var ids: [max_surfaces][]u8 = undefined; + var agents: [max_surfaces]bool = undefined; var count: usize = 0; for (self.layout.tabs.items) |tab| for (tab.panes.items) |pane| { if (count == ids.len) break; ids[count] = self.allocator.dupe(u8, pane.id) catch continue; + agents[count] = pane.launches_agent; count += 1; }; defer for (ids[0..count]) |id| self.allocator.free(id); - for (ids[0..count]) |id| { + var pruned = false; + for (ids[0..count], agents[0..count]) |id, launches_agent| { + const live: ?bool = if (launches_agent) self.sessionIsLive(id) else true; + if (live == null) { + self.queueRestoreRetry(id, error.SessionListingUnavailable, launches_agent); + continue; + } + if (!LoopLaunchWait.restoreKeepsPane(launches_agent, live)) { + pruned = self.layout.removePane(id) or pruned; + continue; + } const initial_grid = self.gridForSession(id) catch |err| { - self.queueRestoreRetry(id, err); + self.queueRestoreRetry(id, err, launches_agent); continue; }; if (self.createAttachedSurface(id, initial_grid)) |index| { + self.daemon_sessions[index] = launches_agent; self.clearRestoreError(index); } else |err| { - self.queueRestoreRetry(id, err); + self.queueRestoreRetry(id, err, launches_agent); } } + if (pruned) self.persistLayout() catch {}; self.syncTopology(); } - fn queueRestoreRetry(self: *Workspace, session: []const u8, err: anyerror) void { + fn queueRestoreRetry(self: *Workspace, session: []const u8, err: anyerror, daemon_session: bool) void { for (self.surfaces, 0..) |slot, index| { if (slot.surface == null and slot.attach == null and self.recreate_sessions[index].len == 0) { + self.daemon_sessions[index] = daemon_session; self.recreate_sessions[index] = self.allocator.dupe(u8, session) catch &.{}; self.recreate_due_ms[index] = nowMilliseconds() + self.recreate_delay_ms[index]; const message = std.fmt.allocPrint(self.allocator, "workspace restore pending: {s}", .{@errorName(err)}) catch return; @@ -730,6 +997,7 @@ pub const Workspace = struct { } fn clearAllRecreateState(self: *Workspace) void { + self.cancelAllLaunchWaits(); for (&self.recreate_sessions, 0..) |*session, index| { if (session.*.len != 0) self.allocator.free(session.*); session.* = &.{}; @@ -862,7 +1130,8 @@ pub const Workspace = struct { const session = if (self.surfaces[index].session_name.len == 0) return else try self.allocator.dupe(u8, self.surfaces[index].session_name); defer self.allocator.free(session); self.destroySurface(index); - try self.openNode(index, session); + if (self.daemon_sessions[index]) return self.openLaunchedNode(index, session, 0); + try self.attachNode(index, session, false); } pub fn resize(self: *Workspace, origin_x: i32, origin_y: i32, width: i32, height: i32) void { @@ -1057,6 +1326,10 @@ pub const Workspace = struct { } pub fn poll(self: *Workspace) void { + // Launch waits run even while collapsed: attaching a loop whose session just came up + // is what the old synchronous auto-attach did regardless of visibility, and the + // probes are console-less `zmx ls` runs that never touch accessibility. + self.pollLaunchWaits(); // While the workspace is collapsed (not visible as either the full surface or the // picture-in-picture panel), skip draining terminal output entirely. Feeding output // notifies winghostty's own accessibility layer via @@ -1888,7 +2161,14 @@ pub const Workspace = struct { if (session.len == 0 or self.surfaces[index].surface != null or now < self.recreate_due_ms[index]) { continue; } - self.openNode(index, session) catch { + // A loop whose session ended is not re-created as a bare shell; it is re-attached + // only if the daemon's session is still there. + if (self.daemon_sessions[index]) { + self.openLaunchedNode(index, session, 0) catch {}; + self.clearRecreateSession(index); + continue; + } + self.attachNode(index, session, false) catch { self.recreate_due_ms[index] = now + self.recreate_delay_ms[index]; self.recreate_delay_ms[index] = @min(self.recreate_delay_ms[index] * 2, 4_000); continue; @@ -3861,6 +4141,33 @@ fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace { }; } +test "opening a loop mounts its pending tab without spawning a terminal" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.cancelAllLaunchWaits(); + const path = "terminal-pending-loop-test.json"; + workspace.layout_path = @constCast(path); + defer std.fs.cwd().deleteFile(path) catch {}; + try workspace.openLaunchedNode(0, "first-loop", LoopLaunchWait.open_timeout_ms); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + try std.testing.expect(workspace.isAwaitingLaunch(0)); + try std.testing.expect(workspace.surfaces[0].attach == null); + try std.testing.expect(workspace.surfaces[0].surface == null); + try std.testing.expectEqualStrings("first-loop", workspace.layout.tabs.items[0].panes.items[0].id); + try workspace.openLaunchedNode(0, "second-loop", LoopLaunchWait.open_timeout_ms); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + try std.testing.expectEqualStrings("second-loop", workspace.layout.tabs.items[0].panes.items[0].id); +} + +test "passive loop observation does not create a pending tab" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.cancelAllLaunchWaits(); + try workspace.openLaunchedNode(0, "idle-loop", 0); + try std.testing.expectEqual(@as(usize, 0), workspace.tabCount()); + try std.testing.expect(workspace.surfaces[0].attach == null); +} + fn paneResizeWorkspaceForTest(allocator: std.mem.Allocator) !Workspace { var workspace = try minimalWorkspaceForOptionsTest(allocator); errdefer workspace.layout.deinit(); diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index e66b3f0d..60f7585d 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -475,11 +475,13 @@ pub fn commandGraphNodeAction( }); } - if (std.mem.eql(u8, action, "stopNode")) { + if (std.mem.eql(u8, action, "stopNode") or std.mem.eql(u8, action, "resumeSession")) { return std.mem.concat(allocator, u8, &.{ "{\"graphCommand\":{\"projectPath\":", quoted_path, - ",\"command\":{\"stopNode\":{\"_0\":", + ",\"command\":{\"", + action, + "\":{\"_0\":", quoted_node, "}}}}", }); @@ -1281,6 +1283,12 @@ test "graph commands match Swift Codable associated-value shapes" { "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"stopNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", stop, ); + const resume_session = try commandGraphNodeAction(allocator, project, node, "resumeSession", null); + defer allocator.free(resume_session); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"resumeSession\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", + resume_session, + ); const detach_template = try commandGraphDetachTemplate(allocator, project, node); defer allocator.free(detach_template); try std.testing.expectEqualStrings( diff --git a/graphcode-windows/src/WorkspaceLayout.zig b/graphcode-windows/src/WorkspaceLayout.zig index 8f6f0608..9cb87530 100644 --- a/graphcode-windows/src/WorkspaceLayout.zig +++ b/graphcode-windows/src/WorkspaceLayout.zig @@ -214,6 +214,17 @@ pub const Layout = struct { return error.InvalidSurface; } + /// Records whether a pane's session is a loop the daemon starts (see `Pane`). + pub fn setLaunchesAgent(self: *Layout, id: []const u8, launches_agent: bool) bool { + for (self.tabs.items) |*tab| for (tab.panes.items) |*pane| { + if (std.mem.eql(u8, pane.id, id)) { + pane.launches_agent = launches_agent; + return true; + } + }; + return false; + } + pub fn removePane(self: *Layout, id: []const u8) bool { for (self.tabs.items, 0..) |*tab, tab_index| { for (tab.panes.items, 0..) |pane, pane_index| { @@ -433,6 +444,20 @@ test "validated persistence rejects corruption and scopes projects" { )); } +test "a loop opened into a shell tab's slot is persisted as a loop pane" { + var layout = try Layout.init(std.testing.allocator, "project-a"); + defer layout.deinit(); + try layout.addTab("shell-tab", false); + try layout.replacePaneID("shell-tab", "loop-node"); + try std.testing.expect(layout.setLaunchesAgent("loop-node", true)); + try std.testing.expect(!layout.setLaunchesAgent("missing", true)); + try layout.save("workspace-layout-agent-test.json"); + defer std.fs.cwd().deleteFile("workspace-layout-agent-test.json") catch {}; + var restored = try Layout.load(std.testing.allocator, "workspace-layout-agent-test.json", "project-a"); + defer restored.deinit(); + try std.testing.expect(restored.tabs.items[0].panes.items[0].launches_agent); +} + test "generated surface IDs are unique across tabs" { var layout = try Layout.init(std.testing.allocator, "project"); defer layout.deinit(); diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index fb7a63d0..177ae7d5 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -1562,6 +1562,7 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { try std.fs.cwd().makeDir(target); try std.fs.cwd().makeDir(outside); try fixtureGit(&.{ "git", "-C", target, "init", "-q", "-b", "main" }); + try fixtureGit(&.{ "git", "-C", target, "config", "--global", "--get", "graphcode.sentinel" }); try fixtureGit(&.{ "git", "-C", outside, "init", "-q", "-b", "outside" }); try fixtureGit(&.{ "git", "-C", target, "config", "graphcode.identity", "target" }); try fixtureGit(&.{ "git", "-C", outside, "config", "graphcode.identity", "outside-control" }); @@ -1781,7 +1782,10 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { try environment.put(entry.key_ptr.*, entry.value_ptr.*); } try environment.put("GIT_CONFIG_NOSYSTEM", "1"); - try environment.put("GIT_CONFIG_GLOBAL", "NUL"); + // The harness supplies an isolated config file. Newer Git builds reject the + // Windows NUL device as a config path before fixture initialization can run. + const fixture_config = inherited.get("GIT_CONFIG_GLOBAL") orelse return error.FixtureGitConfigMissing; + try environment.put("GIT_CONFIG_GLOBAL", fixture_config); const real_git = try std.process.getEnvVarOwned(allocator, "GRAPHCODE_WORKTREE_TEST_REAL_GIT"); defer allocator.free(real_git); const fixture_args = try allocator.dupe([]const u8, args); @@ -1791,9 +1795,20 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { child.env_map = &environment; child.create_no_window = true; child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - const term = try child.spawnAndWait(); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + var output: std.ArrayList(u8) = .empty; + defer output.deinit(allocator); + var errors: std.ArrayList(u8) = .empty; + defer errors.deinit(allocator); + try child.spawn(); + try child.collectOutput(allocator, &output, &errors, 64 * 1024); + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) { + std.debug.print("fixture Git failed: {any}\nstdout: {s}\nstderr: {s}\n", .{ + term, output.items, errors.items, + }); + } try std.testing.expect(term == .Exited and term.Exited == 0); } } else void; diff --git a/graphcode-windows/src/ZmxSession.zig b/graphcode-windows/src/ZmxSession.zig index 154d99ef..2f52bf5e 100644 --- a/graphcode-windows/src/ZmxSession.zig +++ b/graphcode-windows/src/ZmxSession.zig @@ -17,6 +17,8 @@ pub const ChildStdio = enum { attach, /// One-shot control commands such as `resize` and `kill`. control, + /// One-shot listings whose output is inspected before attaching. + capture, }; /// zmx.exe is a console-subsystem program. Spawned from the GUI shell without @@ -31,7 +33,7 @@ pub fn child( var result = std.process.Child.init(argv, allocator); result.cwd = cwd; result.stdin_behavior = if (stdio == .attach) .Pipe else .Ignore; - result.stdout_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stdout_behavior = if (stdio == .attach or stdio == .capture) .Pipe else .Ignore; result.stderr_behavior = .Ignore; result.create_no_window = true; return result; @@ -64,3 +66,10 @@ test "canonical zmx session names add the ownership prefix exactly once" { try nameBuffer("graphcode-alpha", &storage), ); } + +test "zmx listing capture has readable output without stdin or a console" { + const listing = child(std.testing.allocator, &.{ "zmx.exe", "ls" }, null, .capture); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, listing.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, listing.stdout_behavior); + try std.testing.expect(listing.create_no_window); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index c9e7c07a..edb59455 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -178,7 +178,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Terminal VT state and rendering | Incremental VT parsing, complete Unicode text, styles, cursor, resize and scrollback with matching visible output | Explicit startup opt-in `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=1` uses the existing pinned public scalar libghostty-vt API; default ASCII behavior is unchanged. Real-library memory tests cover chunk boundaries, grapheme arrays/UTF-16 offsets, wide occupancy, colors, alternate screen, viewport/reflow, owned snapshots, allocation failures, and bounded pane-owned replies through the production input queue. The same publication seam fails with the legacy parser and passes with the opt-in state. Strict projection rejects unsupported clusters/wide/decorated cells with explicit unconfirmed-render status while preserving authoritative accessible text; the host remains a one-codepoint 5x7 renderer, not a full Unicode/glyph renderer. The earlier "production is still 120x40" claim was stale: `TerminalSurface.zig` already derives each pane's grid from live pane bounds and the measured cell metrics, and resizes the backend surface when the topology syncs. The remaining defect found there was degenerate geometry — a minimized or zero-sized pane clamped the grid to 1x1 and the old size was lost on restore. `paneBounds` now guards zero-sized rects and `syncPaneGrid` takes an injected resize callback so the grid-state transition is testable without the native provider, with behavioral minimize-then-restore tests asserting the pre-minimize grid is preserved. That is unit evidence over the pure state transition; live PTY resize negotiation against a real backend is still not demonstrated. Wheel/selection, visible cursor, native TextPattern/glyph parity, and OSC 7 PWD retention remain residuals. Memory tests and a build do not establish native UI parity or SIMD performance. | Partial | -| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing) | Validated | +| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing). Beta10 Dev Box qualification (D7) then found that opening an attended (Turn-based or main) loop showed a bare `cmd` shell: the pane's `zmx attach` created the session before the daemon launched the agent, so the first instruction was never sent. The shell now asks the daemon to start the loop (`resumeSession`) and attaches only once its session is running, and restores persisted loop panes only while their session is live. That fix has unit evidence and a local dev-layout walkthrough with a stub agent, not a Dev Box requalification with a real backend, so the row is `Partial` | Partial | | Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. The live gate's `workspace-toolbar-*` assertion (named `"UIA project"`) now runs against the real shell build and passes on the `windows-shell` CI job (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. Focused hit-testing/UIA unit coverage plus the live `windows-shell` CI run (workspace toolbar/loop-bar UIA assertions passing at run https://github.com/scgopi/GraphCode/actions/runs/35415967793) validated presence end to end. The beta10 Dev Box qualification (100% DPI) then observed the metadata text drawn under Stop/Show in graph at a 960px window with the sidebar rail and loop detail panel visible. Painting, mouse hit-testing, and UIA bounds now share `LoopBarLayout.zig`, which clamps every text run to the space left of the leftmost button, ellipsizes squeezed runs, and drops runs narrower than 24px; at 960px with rail and panel that drops the metadata line, where macOS truncates instead. Only focused `LoopBarLayout.zig` unit tests cover this; no live observation of the corrected layout has been made | Partial | | Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift index 572a9ca9..8c3e10e6 100644 --- a/windows-tests/WindowsDaemonTests.swift +++ b/windows-tests/WindowsDaemonTests.swift @@ -7,6 +7,21 @@ import XCTest import WinSDK #endif +/// Records the node ids a store asked to launch; `onEnsureSession` is `@Sendable`. +private final class AttendedLaunchCapture: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Foundation.UUID] = [] + func append(_ id: Foundation.UUID) { lock.withLock { recorded.append(id) } } + var ids: [Foundation.UUID] { lock.withLock { recorded } } +} + +private final class AttendedLaunchFlag: @unchecked Sendable { + private let lock = NSLock() + private var current = false + func set(_ value: Bool) { lock.withLock { current = value } } + var value: Bool { lock.withLock { current } } +} + final class WindowsDaemonTests: XCTestCase { private actor PredicateCapture { private(set) var workingDirectory: String? @@ -37,6 +52,78 @@ final class WindowsDaemonTests: XCTestCase { } #if os(Windows) + // The Windows shell only attaches to a loop's zmx session; it never launches the + // agent. Opening an attended loop must therefore get the daemon to start it, or the + // attach creates a bare shell and the first instruction is never sent (beta10). + func testOpeningAnAttendedLoopStartsItWhenPanesDoNotLaunchSessions() async { + let turn = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + let sketch = LoopNode(title: "Main", loopType: .sketch, backend: .copilotCLI) + let stopped = LoopNode( + title: "Stopped", loopType: .turnBased, backend: .copilotCLI, state: .stopped) + let group = LoopNode(title: "Group", loopType: .composite) + let started = AttendedLaunchCapture() + let alive = AttendedLaunchFlag() + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + graph.nodes.append(contentsOf: [turn, sketch, stopped, group]) + let store = GraphStore( + graph: graph, + onEnsureSession: { node, _ in started.append(node.id) }, + onSessionAlive: { _, _ in alive.value }, + panesLaunchAttendedSessions: false) + + for node in [turn, sketch, stopped, group] { await store.handle(.resumeSession(node.id)) } + alive.set(true) + await store.handle(.resumeSession(turn.id)) + + XCTAssertEqual(started.ids, [turn.id, sketch.id]) + } + + func testPanesThatLaunchSessionsKeepAttendedLoopsToThemselves() async { + let turn = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + let started = AttendedLaunchCapture() + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + graph.nodes.append(turn) + let store = GraphStore( + graph: graph, + onEnsureSession: { node, _ in started.append(node.id) }, + onSessionAlive: { _, _ in false }, + panesLaunchAttendedSessions: true) + + await store.handle(.resumeSession(turn.id)) + + XCTAssertEqual(started.ids, []) + } + + func testWindowsPanesDoNotLaunchAttendedSessions() { + XCTAssertFalse(GraphStore.platformPanesLaunchAttendedSessions) + } + + func testWindowsMainLoopWithoutAnInstructionStillLaunchesItsBackend() throws { + let node = LoopNode(title: "Main", loopType: .sketch, backend: .copilotCLI) + var settings = GraphcodeSettings() + settings.copilotPermissions = .ask + let arguments = try XCTUnwrap(ZmxSessionLauncher.arguments(forNode: node, settings: settings)) + XCTAssertEqual( + Array(arguments.prefix(3)), + ["run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d"]) + XCTAssertTrue(arguments.contains("copilot")) + XCTAssertFalse(arguments.contains("--interactive")) + XCTAssertFalse(arguments.contains("--yolo")) + XCTAssertFalse(arguments.contains("/bin/zsh")) + } + + func testWindowsAttendedLaunchPreservesInstructionAndAskPermissions() throws { + var node = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + node.firstInstruction = "Reply with a short confirmation." + var settings = GraphcodeSettings() + settings.copilotPermissions = .ask + let arguments = try XCTUnwrap(ZmxSessionLauncher.arguments(forNode: node, settings: settings)) + let promptIndex = try XCTUnwrap(arguments.firstIndex(of: "--interactive")) + XCTAssertTrue(arguments[promptIndex + 1].hasPrefix("Reply with a short confirmation.")) + XCTAssertFalse(arguments.contains("--yolo")) + XCTAssertFalse(arguments.contains("--autopilot")) + } + func testZmxLocatorUsesWindowsExecutableName() { XCTAssertEqual(ZmxLocator.binaryURL.lastPathComponent, "zmx.exe") }