Skip to content

Commit 3702e58

Browse files
authored
feat(agent): add isolated worktree workflows (#1326)
1 parent 3cac544 commit 3702e58

8 files changed

Lines changed: 1840 additions & 24 deletions
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//
2+
// AgentTaskComparison.swift
3+
// Pine
4+
//
5+
// Fact-only comparison and explicit cross-agent handoff (#1309).
6+
//
7+
8+
import Foundation
9+
10+
nonisolated struct AgentTaskComparisonCandidate: Sendable, Equatable {
11+
let taskID: UUID
12+
let worktreePath: String
13+
let agentTypeIdentifier: String
14+
let changedFiles: [AgentCompletionChange]
15+
let commands: [AgentCompletionCommand]
16+
let evidenceGaps: [AgentCompletionGap]
17+
let elapsedTime: TimeInterval?
18+
let verifiedTestCount: Int
19+
let failedCommandCount: Int
20+
let agentReportedNarrative: AgentCompletionNarrative?
21+
}
22+
23+
/// Side-by-side facts have no score, rank, recommendation, or winner field.
24+
/// Choosing a result is always a separate explicit user action.
25+
nonisolated struct AgentTaskComparison: Sendable, Equatable {
26+
let objective: String?
27+
let left: AgentTaskComparisonCandidate
28+
let right: AgentTaskComparisonCandidate
29+
}
30+
31+
nonisolated enum AgentTaskComparisonFailure: Error, Sendable, Equatable {
32+
case sameTask
33+
case differentProject
34+
case briefDoesNotBelongToTask(UUID)
35+
}
36+
37+
@MainActor
38+
enum AgentTaskComparisonBuilder {
39+
static func build(
40+
leftTask: AgentTask,
41+
leftBrief: AgentCompletionBrief,
42+
rightTask: AgentTask,
43+
rightBrief: AgentCompletionBrief
44+
) -> Result<AgentTaskComparison, AgentTaskComparisonFailure> {
45+
guard leftTask.id != rightTask.id else { return .failure(.sameTask) }
46+
guard leftTask.project.canonicalProjectPath
47+
== rightTask.project.canonicalProjectPath else {
48+
return .failure(.differentProject)
49+
}
50+
guard brief(leftBrief, belongsTo: leftTask) else {
51+
return .failure(.briefDoesNotBelongToTask(leftTask.id))
52+
}
53+
guard brief(rightBrief, belongsTo: rightTask) else {
54+
return .failure(.briefDoesNotBelongToTask(rightTask.id))
55+
}
56+
let commonObjective = leftTask.objective == rightTask.objective
57+
? leftTask.objective
58+
: nil
59+
return .success(AgentTaskComparison(
60+
objective: commonObjective,
61+
left: candidate(task: leftTask, brief: leftBrief),
62+
right: candidate(task: rightTask, brief: rightBrief)
63+
))
64+
}
65+
66+
private static func brief(
67+
_ brief: AgentCompletionBrief,
68+
belongsTo task: AgentTask
69+
) -> Bool {
70+
task.runs.contains { $0.id == brief.sessionID }
71+
&& task.descriptor.typeIdentifier == brief.agentTypeRaw
72+
}
73+
74+
private static func candidate(
75+
task: AgentTask,
76+
brief: AgentCompletionBrief
77+
) -> AgentTaskComparisonCandidate {
78+
let elapsed = brief.endedAt.map {
79+
max(0, $0.timeIntervalSince(brief.startedAt))
80+
}
81+
return AgentTaskComparisonCandidate(
82+
taskID: task.id,
83+
worktreePath: task.project.canonicalWorktreePath,
84+
agentTypeIdentifier: task.descriptor.typeIdentifier,
85+
changedFiles: brief.changes,
86+
commands: brief.commands,
87+
evidenceGaps: brief.gaps,
88+
elapsedTime: elapsed,
89+
verifiedTestCount: brief.verifiedTestCount,
90+
failedCommandCount: brief.commands.lazy.filter {
91+
if case .failed = $0.outcome { return true }
92+
return false
93+
}.count,
94+
agentReportedNarrative: brief.narrative
95+
)
96+
}
97+
}
98+
99+
nonisolated struct AgentTaskHandoffRequest: Sendable, Equatable {
100+
let sourceTaskID: UUID
101+
let targetTaskID: UUID
102+
let sourceCompletionBriefID: UUID
103+
/// Fresh text authored by the user for this handoff. Pine never copies the
104+
/// source task objective, credentials, environment, or transcript here.
105+
let followUpObjective: String
106+
let selectedEvidencePaths: [String]
107+
}
108+
109+
nonisolated struct AgentTaskHandoffAttribution: Sendable, Equatable {
110+
let relativePath: String
111+
let sourceTaskID: UUID
112+
let sourceSessionID: UUID
113+
let originalEvidence: AgentCompletionEvidenceLevel
114+
}
115+
116+
nonisolated struct AgentTaskHandoffPackage: Sendable, Equatable, Identifiable {
117+
let id: UUID
118+
let sourceTaskID: UUID
119+
let targetTaskID: UUID
120+
let sourceCompletionBriefID: UUID
121+
let createdAt: Date
122+
let followUpObjective: String
123+
let priorAttribution: [AgentTaskHandoffAttribution]
124+
125+
/// Deliberate schema guarantee: there is no transcript, environment,
126+
/// credential, command output, or copied agent narrative in this package.
127+
}
128+
129+
nonisolated enum AgentTaskHandoffFailure: Error, Sendable, Equatable {
130+
case sameTask
131+
case taskMismatch
132+
case differentProject
133+
case sourceBriefMismatch
134+
case invalidObjective
135+
case invalidEvidencePath(String)
136+
case evidencePathNotInBrief(String)
137+
case resourceLimitExceeded
138+
}
139+
140+
nonisolated enum AgentTaskHandoffPlanner {
141+
private static let maximumObjectiveBytes = 64 * 1_024
142+
private static let maximumEvidencePaths = 10_000
143+
144+
static func prepare(
145+
request: AgentTaskHandoffRequest,
146+
sourceTask: AgentTask,
147+
targetTask: AgentTask,
148+
sourceBrief: AgentCompletionBrief,
149+
handoffID: UUID = UUID(),
150+
createdAt: Date = Date()
151+
) -> Result<AgentTaskHandoffPackage, AgentTaskHandoffFailure> {
152+
guard sourceTask.id != targetTask.id else { return .failure(.sameTask) }
153+
guard request.sourceTaskID == sourceTask.id,
154+
request.targetTaskID == targetTask.id else {
155+
return .failure(.taskMismatch)
156+
}
157+
guard sourceTask.project.canonicalProjectPath
158+
== targetTask.project.canonicalProjectPath else {
159+
return .failure(.differentProject)
160+
}
161+
guard request.sourceCompletionBriefID == sourceBrief.id,
162+
sourceTask.runs.contains(where: {
163+
$0.id == sourceBrief.sessionID
164+
}) else {
165+
return .failure(.sourceBriefMismatch)
166+
}
167+
let objective = request.followUpObjective.trimmingCharacters(
168+
in: .whitespacesAndNewlines
169+
)
170+
guard !objective.isEmpty,
171+
objective.utf8.count <= maximumObjectiveBytes,
172+
!objective.utf8.contains(0),
173+
!objective.unicodeScalars.contains(where: {
174+
CharacterSet.controlCharacters.contains($0)
175+
&& $0 != "\n" && $0 != "\t"
176+
}) else {
177+
return .failure(.invalidObjective)
178+
}
179+
guard request.selectedEvidencePaths.count <= maximumEvidencePaths else {
180+
return .failure(.resourceLimitExceeded)
181+
}
182+
183+
var changesByPath: [String: AgentCompletionChange] = [:]
184+
for change in sourceBrief.changes
185+
where changesByPath[change.relativePath] == nil {
186+
changesByPath[change.relativePath] = change
187+
}
188+
var seen: Set<String> = []
189+
var attributions: [AgentTaskHandoffAttribution] = []
190+
for path in request.selectedEvidencePaths {
191+
guard AgentHistoryUndoPreflight.isCanonicalRelativePath(path),
192+
seen.insert(path).inserted else {
193+
return .failure(.invalidEvidencePath(path))
194+
}
195+
guard let change = changesByPath[path] else {
196+
return .failure(.evidencePathNotInBrief(path))
197+
}
198+
attributions.append(AgentTaskHandoffAttribution(
199+
relativePath: path,
200+
sourceTaskID: sourceTask.id,
201+
sourceSessionID: sourceBrief.sessionID,
202+
originalEvidence: change.attribution
203+
))
204+
}
205+
206+
return .success(AgentTaskHandoffPackage(
207+
id: handoffID,
208+
sourceTaskID: sourceTask.id,
209+
targetTaskID: targetTask.id,
210+
sourceCompletionBriefID: sourceBrief.id,
211+
createdAt: createdAt,
212+
followUpObjective: objective,
213+
priorAttribution: attributions
214+
))
215+
}
216+
}

Pine/Agent/AgentTaskRegistry.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -564,11 +564,30 @@ final class AgentTaskRegistry {
564564
/// A closed project window leaves terminal processes alive in Pine's
565565
/// background project registry. Only route presentation changes.
566566
func setWindowOpen(_ isOpen: Bool, projectPath: String) {
567+
setWindowOpen(isOpen) {
568+
$0.canonicalProjectPath == projectPath
569+
}
570+
}
571+
572+
/// Updates only one project/worktree window. Multiple managed worktrees
573+
/// deliberately share `canonicalProjectPath`, so closing one must not
574+
/// background sibling agent runs.
575+
func setWindowOpen(
576+
_ isOpen: Bool,
577+
project: AgentTaskProjectIdentity
578+
) {
579+
setWindowOpen(isOpen) { $0 == project }
580+
}
581+
582+
private func setWindowOpen(
583+
_ isOpen: Bool,
584+
matchesProject: (AgentTaskProjectIdentity) -> Bool
585+
) {
567586
expireClaims()
568587
guard !isTerminating else { return }
569588
var projects = Set<AgentTaskProjectIdentity>()
570589
for key in Array(pendingClaims.keys)
571-
where key.project.canonicalProjectPath == projectPath {
590+
where matchesProject(key.project) {
572591
guard var claim = pendingClaims[key] else { continue }
573592
if isOpen, claim.route.availability == .background {
574593
claim.route.availability = .available
@@ -580,7 +599,7 @@ final class AgentTaskRegistry {
580599
pendingClaims[key] = claim
581600
}
582601
for (_, taskID) in taskIDByTerminal
583-
where task(for: taskID)?.project.canonicalProjectPath == projectPath {
602+
where task(for: taskID).map({ matchesProject($0.project) }) == true {
584603
guard let index = taskIndex(for: taskID) else { continue }
585604
let availability = tasks[index].route.availability
586605
if isOpen, availability == .background {

0 commit comments

Comments
 (0)