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: 4 additions & 1 deletion GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,10 @@ extension GraphcodeCommand {
return lines.joined(separator: "\n")
}
for node in graph.nodes {
var line = " \(node.id) \(node.displayState) \(node.loopType) \(node.title)"
// A finished loop's session can still sit at its prompt; the presence it reports
// must not hide the ending, or the loop reads as one that can still be restarted.
let state = node.isResolved ? node.state : node.displayState
var line = " \(node.id) \(state) \(node.loopType) \(node.title)"
if let exitCode = node.presence?.exitCode {
line += " ← session exited (\(exitCode))"
} else if let reason = AttentionRollup.reason(for: node) {
Expand Down
22 changes: 18 additions & 4 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1317,7 +1317,7 @@
await stopNode(nodeID)

case .restartNode(let nodeID):
await restartNode(nodeID)
await restartNode(nodeID, from: connectionID)

case .restartSessions:
await restartSessions()
Expand Down Expand Up @@ -1586,7 +1586,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 1589 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
// The board's gate forwards like any other side effect: a loop inside a piloted
// composite is a real loop whose session got the standard briefing — teaching
// verbs the child store would refuse is exactly the incoherence the gate exists
Expand Down Expand Up @@ -2503,7 +2503,7 @@
PendingFollowUp(
id: UUID(), nodeID: nodeID, text: message, watchedPostID: nil, resumesIfAbsent: true))
}
await drainAndBroadcast()

Check warning on line 2506 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}
/// A learned note into a node's memory log — `graphcode node memo`, the agent-written
/// half of the log (the daemon's episode records being the objective half). The store
Expand Down Expand Up @@ -2663,7 +2663,7 @@
id: UUID(), nodeID: nodeID, text: prompt, watchedPostID: nil, resumesIfAbsent: true)
pendingFollowUps.append(followUp)
goalFollowUps[nodeID] = followUp.id
await drainAndBroadcast()

Check warning on line 2666 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}

/// Opening a resolved loop whose session was ended brings its conversation back. Panes
Expand Down Expand Up @@ -3252,21 +3252,22 @@
/// a stopped one was told to stay down, so both are refused rather than revived — except
/// a loop stopped for a missing CLI, which nobody told to stay down and whose restart
/// is exactly what its dialog asks the human for once the CLI is installed.
private func restartNode(_ nodeID: UUID) async {
private func restartNode(_ nodeID: UUID, from connectionID: UUID?) async {
guard let node = graph.nodes[id: nodeID] else {
announceError("no loop \(nodeID) in this graph")
await refuse("no loop \(nodeID) in this graph", to: connectionID)
return
}
if node.state == .stopped, let failure = node.launchFailure {
relaunchAfterMissingProvider(node, failure)
return
}
guard !node.isResolved else {
announceError("\(node.title) has finished — there is no session to restart")
await refuse(
"\(node.title) has finished — there is no session to restart", to: connectionID)
return
}
if node.loopType == .composite {
await runInSubGraph(nodeID, .restartSessions, broadcastErrors: false)

Check warning on line 3270 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
return
}
await restart([node])
Expand All @@ -3275,7 +3276,7 @@
private func restartSessions() async {
let live = graph.nodes.filter { !$0.isResolved }
for composite in live where composite.loopType == .composite {
await runInSubGraph(composite.id, .restartSessions, broadcastErrors: false)

Check warning on line 3279 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
}
await restart(live.filter { $0.loopType != .composite })
}
Expand Down Expand Up @@ -3331,7 +3332,7 @@
// set below — a graph whose nodes have all stopped aggregates to `.idle`.
if node.loopType == .composite, let subGraph = node.subGraph {
for child in subGraph.nodes where !child.isResolved {
await runInSubGraph(

Check warning on line 3335 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
node.id, .stopNode(child.id), broadcastErrors: false)
}
}
Expand Down Expand Up @@ -4442,7 +4443,7 @@
let attempt = DeliveryAttempt()
let delivered = await withDeadline(deliveryDeadline) {
let result =
await self.deliverToSession(node, pending.text, liveness: liveness) == .delivered

Check warning on line 4446 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

reference to captured var 'liveness' in concurrently-executing code; this is an error in the Swift 6 language mode
await attempt.complete(result)
return result
}
Expand Down Expand Up @@ -4508,6 +4509,19 @@
pendingErrors.append(message)
}

/// A refusal that is one client's business: changing nothing, it has no `.graphChanged`
/// to acknowledge it, so the asker's CLI hears only this — and a bystander parked on
/// its own verdict would take a broadcast copy for one (`mailroomInbox` does the same).
/// Without a v1 requester to answer, it falls back to the broadcast.
private func refuse(_ message: String, to connectionID: UUID?) async {
guard let connectionID, connections[connectionID] != nil else {
announceError(message)
return
}
_ = await send(.errorOccurred(message), to: connectionID)
onAnnounceError?(message)
}

private func reject(
_ message: String,
broadcastErrors: Bool
Expand Down Expand Up @@ -4655,7 +4669,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 4672 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
onMailroomEnabled: onMailroomEnabled,
goalCache: goalCache,
recurrence: effects.recurrence,
Expand All @@ -4671,7 +4685,7 @@
processRecurrence(effects.recurrence)
graph.nodes[id: ownerID]?.subGraph = await child.graph
rollUpComposite(ownerID)
await drainAndBroadcast()

Check warning on line 4688 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}

/// Applies the recurrence requests a child store handed up, in order — an update's
Expand Down
7 changes: 6 additions & 1 deletion graphcode-cli/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,14 @@ func runAndPrintGraph(projectPath: String, _ commands: [DaemonCommand]) throws {
try sendCommand(command)
}
phase = "waiting for the daemon to acknowledge the command"
// A refused command changes nothing, so its error is the only answer that will come.
let event = try client.waitForEvent {
if case .graphChanged = $0 { return true } else { return false }
switch $0 {
case .graphChanged, .errorOccurred: return true
default: return false
}
}
if case .errorOccurred(let message) = event { fail(message) }
if case .graphChanged(let graph) = event {
print(GraphcodeCommand.render(graph, mailroomReader: mailroomReader))
}
Expand Down
14 changes: 14 additions & 0 deletions graphcode/Tests/GraphcodeCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,20 @@ struct GraphcodeCommandTests {
#expect(output.contains("session exited (1)"))
}

@Test
func renderingAFinishedLoopShowsItsEndingOverItsOpenSession() {
let node = LoopNode(
title: "Naming check", loopType: .goalBased, goal: GoalSpec(summary: "work"),
presence: PresenceReading(presence: .awaitingInput, confidence: .scanned),
state: .succeeded)
let graph = LoopGraph(
project: ProjectRef(path: "/tmp/x", name: "x"), nodes: [node])

let output = GraphcodeCommand.render(graph)

#expect(output.contains("\(node.id) succeeded goalBased Naming check"))
}

@Test
func renderingAnEmptyGraphSaysSoRatherThanPrintingNothing() {
let output = GraphcodeCommand.render(
Expand Down
59 changes: 59 additions & 0 deletions graphcode/Tests/SessionRestartTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,65 @@ struct SessionRestartTests {
#expect(errors.value.count == 1)
}

/// A refused restart changes nothing, so no `.graphChanged` acknowledges it: the
/// asking CLI hears only the refusal, and a bystander's CLI, parked on a verdict of its
/// own, must not take a broadcast copy for one.
@Test
func aRefusedRestartAnswersOnlyTheConnectionThatAsked() async throws {
let store = GraphStore(onRestartSession: { _, _ in true })
await store.handle(.createNode(draft("Done")))
let id = await store.graph.nodes[0].id
await store.handle(.nodeCheckApproved(id))
var asker: [Int32] = [0, 0]
var bystander: [Int32] = [0, 0]
#expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &asker) == 0)
#expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &bystander) == 0)
defer {
for descriptor in [asker[0], asker[1], bystander[0], bystander[1]] { close(descriptor) }
}
let askerID = UUID()
await store.addConnection(id: askerID, fileDescriptor: asker[0])
await store.addConnection(id: UUID(), fileDescriptor: bystander[0])
guard case .graphChanged = try nextEvent(from: asker[1]),
case .graphChanged = try nextEvent(from: bystander[1])
else {
Issue.record("expected each connection's joining snapshot")
return
}

await store.handle(.restartNode(id), from: askerID)

guard case .errorOccurred(let message) = try nextEvent(from: asker[1]) else {
Issue.record("the asker heard nothing, so its CLI waits out its timeout")
return
}
#expect(message == "Done has finished — there is no session to restart")
var reached: [String] = []
while let event = try nextEvent(from: bystander[1], waiting: 1) {
if case .errorOccurred(let message) = event { reached.append(message) }
}
#expect(reached.isEmpty)
}

/// Blocking read with a deadline: `OutboundChannels` writes off the actor, so a
/// non-blocking read straight after `handle` races the delivery.
private func nextEvent(from descriptor: Int32, waiting seconds: Int32 = 2) throws
-> DaemonEvent?
{
var timeout = timeval(tv_sec: Int(seconds), tv_usec: 0)
setsockopt(
descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
var length: UInt32 = 0
var header = [UInt8](repeating: 0, count: 4)
guard recv(descriptor, &header, 4, MSG_WAITALL) == 4 else { return nil }
withUnsafeMutableBytes(of: &length) { $0.copyBytes(from: header) }
var payload = [UInt8](repeating: 0, count: Int(UInt32(bigEndian: length)))
guard recv(descriptor, &payload, payload.count, MSG_WAITALL) == payload.count else {
return nil
}
return try JSONDecoder().decode(DaemonEvent.self, from: Data(payload))
}

@Test
func restartingEverySessionSkipsTheFinishedOnes() async {
let restarted = LockIsolated<Set<UUID>>([])
Expand Down
Loading