Skip to content

Commit 62f7a1d

Browse files
committed
Merge branch 'worktree-self-improving-loops'
2 parents 6bda73a + f825fbb commit 62f7a1d

4 files changed

Lines changed: 103 additions & 5 deletions

File tree

GraphcodeKit/Sources/Domain/LoopNode.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
5555
/// full unbounded series lives in the node's memory log; this is the cache the canvas
5656
/// and the plateau rule read.
5757
public var metricHistory: [MetricSample]
58+
/// The loop that asked for this one, when a running session created it through the
59+
/// CLI (`NodeDraft.createdBy`). Recorded on the node, not just as the already-fired
60+
/// edge `linkToCreator` draws — a fired edge is indistinguishable from any drawn
61+
/// handoff, and custody has to be: stopping or deleting a parent takes its spawned
62+
/// descendants with it, while a drawn edge to a peer must never be caught in that.
63+
public let createdBy: UUID?
5864
public var state: LoopState
5965
public var createdAt: Date
6066

@@ -72,6 +78,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
7278
pilotState: PilotState = .notPiloted,
7379
usage: UsageSample? = nil,
7480
metricHistory: [MetricSample] = [],
81+
createdBy: UUID? = nil,
7582
state: LoopState = .idle,
7683
createdAt: Date = Date()
7784
) {
@@ -88,6 +95,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
8895
self.pilotState = pilotState
8996
self.usage = usage
9097
self.metricHistory = metricHistory
98+
self.createdBy = createdBy
9199
self.state = state
92100
self.createdAt = createdAt
93101
}
@@ -164,7 +172,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
164172

165173
private enum CodingKeys: String, CodingKey {
166174
case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier
167-
case worktreeBinding, subGraph, pilotState, usage, metricHistory, state, createdAt
175+
case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy
176+
case state, createdAt
168177
}
169178

170179
/// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph`
@@ -187,6 +196,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
187196
usage = try container.decodeIfPresent(UsageSample.self, forKey: .usage)
188197
metricHistory =
189198
try container.decodeIfPresent([MetricSample].self, forKey: .metricHistory) ?? []
199+
createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy)
190200
state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle
191201
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
192202
}

GraphcodeKit/Sources/Domain/NodeDraft.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public struct NodeDraft: Codable, Equatable, Sendable {
160160
?? LoopGraph(
161161
project: ProjectRef(path: "\(resolvedTitle)-subgraph", name: resolvedTitle)))
162162
: nil,
163+
createdBy: createdBy,
163164
state: loopType == .goalBased ? .running : .idle)
164165
}
165166
}

GraphcodeKit/Sources/GraphStore.swift

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -508,11 +508,21 @@ public actor GraphStore {
508508
/// re-evaluated afterwards for exactly that reason.
509509
private func deleteNode(_ nodeID: UUID) {
510510
guard let node = graph.nodes[id: nodeID] else { return }
511+
// Children go with the parent: deleting a coordinator must not strand the workers
512+
// it fanned out, still running against a plan nobody owns anymore. Custody comes
513+
// from `createdBy`, never from edges — a drawn handoff to a peer is a
514+
// relationship, not ownership, and stays out of the blast radius.
515+
for child in spawnedDescendants(of: nodeID) {
516+
removeSingleNode(child)
517+
}
518+
removeSingleNode(node)
519+
}
511520

512-
let downstream = Set(graph.edges.filter { $0.from == nodeID }.map(\.to))
513-
graph.edges.removeAll { $0.from == nodeID || $0.to == nodeID }
514-
graph.nodes.remove(id: nodeID)
515-
cancelGoalPoller(nodeID)
521+
private func removeSingleNode(_ node: LoopNode) {
522+
let downstream = Set(graph.edges.filter { $0.from == node.id }.map(\.to))
523+
graph.edges.removeAll { $0.from == node.id || $0.to == node.id }
524+
graph.nodes.remove(id: node.id)
525+
cancelGoalPoller(node.id)
516526
for targetID in downstream {
517527
unblockIfStillIdle(targetID)
518528
}
@@ -524,6 +534,22 @@ public actor GraphStore {
524534
onRemoveMemory?(node.id)
525535
}
526536

537+
/// The fan-out descendants of a node — the loops it created, theirs, and so on,
538+
/// walked through `LoopNode.createdBy`.
539+
private func spawnedDescendants(of nodeID: UUID) -> [LoopNode] {
540+
var found: [LoopNode] = []
541+
var queue = [nodeID]
542+
var visited: Set<UUID> = [nodeID]
543+
while let current = queue.popLast() {
544+
for node in graph.nodes
545+
where node.createdBy == current && visited.insert(node.id).inserted {
546+
found.append(node)
547+
queue.append(node.id)
548+
}
549+
}
550+
return found
551+
}
552+
527553
/// The stop/kill affordance from docs/05-orchestrator.md#monitoring-surface — "a
528554
/// proactive routine runs until you turn it off", so there has to be an off.
529555
///
@@ -537,6 +563,17 @@ public actor GraphStore {
537563
recordMemory(nodeID, "stopped by request")
538564
onTerminateSession?(node)
539565
fireOutgoingEdges(from: nodeID, sourceSucceeded: false)
566+
567+
// A stopped coordinator must not leave the workers it fanned out running headless.
568+
// Custody (`createdBy`), not edges: stopping one side of a drawn maker→critic pair
569+
// must not stop the other. Already-resolved children are left as they ended.
570+
for child in spawnedDescendants(of: nodeID) where !child.isResolved {
571+
graph.nodes[id: child.id]?.state = .stopped
572+
cancelGoalPoller(child.id)
573+
recordMemory(child.id, "stopped with \(node.title), which created this loop")
574+
onTerminateSession?(child)
575+
fireOutgoingEdges(from: child.id, sourceSucceeded: false)
576+
}
540577
}
541578

542579
private func deleteEdge(_ edgeID: UUID) {

graphcode/Tests/CreatedByLoopTests.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,56 @@ struct CreatedByLoopTests {
7676
#expect(staged[0].entry.contains("task done: report attached"))
7777
}
7878

79+
@Test
80+
func stoppingAParentStopsItsSpawnedDescendantsButNotPeers() async throws {
81+
// A stopped coordinator must not leave the workers it fanned out running headless —
82+
// and a drawn edge to a peer is a relationship, not custody, so the peer survives.
83+
let terminated = LockIsolated<[String]>([])
84+
let store = GraphStore(
85+
onTerminateSession: { node in terminated.withValue { $0.append(node.title) } })
86+
await store.handle(.createNode(draft("parent", createdBy: nil)))
87+
let parentID = try #require(await store.graph.nodes.first?.id)
88+
await store.handle(.createNode(draft("child", createdBy: parentID)))
89+
let childID = try #require(await store.graph.nodes.first { $0.title == "child" }?.id)
90+
await store.handle(.createNode(draft("grandchild", createdBy: childID)))
91+
await store.handle(.createNode(draft("peer", createdBy: nil)))
92+
let peerID = try #require(await store.graph.nodes.first { $0.title == "peer" }?.id)
93+
await store.handle(.createEdge(from: parentID, to: peerID, spec: EdgeSpec()))
94+
95+
await store.handle(.stopNode(parentID))
96+
97+
let graph = await store.graph
98+
#expect(graph.nodes[id: parentID]?.state == .stopped)
99+
#expect(graph.nodes[id: childID]?.state == .stopped)
100+
#expect(graph.nodes.first { $0.title == "grandchild" }?.state == .stopped)
101+
#expect(graph.nodes[id: peerID]?.state != .stopped)
102+
#expect(Set(terminated.value) == ["parent", "child", "grandchild"])
103+
}
104+
105+
@Test
106+
func deletingAParentDeletesItsSpawnedDescendants() async throws {
107+
// Deleting a coordinator takes its fan-out tree — sessions, edges, memory — while
108+
// an ordinary peer connected by a drawn edge stays in the graph.
109+
let removedMemory = LockIsolated<[UUID]>([])
110+
let store = GraphStore(
111+
onRemoveMemory: { nodeID in removedMemory.withValue { $0.append(nodeID) } })
112+
await store.handle(.createNode(draft("parent", createdBy: nil)))
113+
let parentID = try #require(await store.graph.nodes.first?.id)
114+
await store.handle(.createNode(draft("child", createdBy: parentID)))
115+
let childID = try #require(await store.graph.nodes.first { $0.title == "child" }?.id)
116+
await store.handle(.createNode(draft("peer", createdBy: nil)))
117+
let peerID = try #require(await store.graph.nodes.first { $0.title == "peer" }?.id)
118+
119+
await store.handle(.deleteNode(parentID))
120+
121+
let graph = await store.graph
122+
#expect(graph.nodes[id: parentID] == nil)
123+
#expect(graph.nodes[id: childID] == nil)
124+
#expect(graph.nodes[id: peerID] != nil)
125+
#expect(graph.edges.isEmpty)
126+
#expect(Set(removedMemory.value) == [parentID, childID])
127+
}
128+
79129
@Test
80130
func aChildInheritsItsCreatorsBackend() async throws {
81131
// A Copilot loop fanning work out must produce Copilot loops. The CLI used to

0 commit comments

Comments
 (0)