Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions GraphcodeKit/Sources/Domain/LoopType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 26 additions & 4 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 14 additions & 3 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions Tools/windows/Stub-Daemon.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions Tools/windows/Tests/ValidationRunner.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 } }
Expand Down
51 changes: 42 additions & 9 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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*$') {
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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" {
Expand Down
Loading
Loading