Merge nucleic/hazy-river-vole-ec4p into dev
This commit is contained in:
@@ -81,6 +81,8 @@ struct SubagentsPanel: View {
|
||||
switch group {
|
||||
case .worker(let worker):
|
||||
workerRow(worker, waitingOn: nil)
|
||||
case .batch(_, let name, let purpose, let workers):
|
||||
batchGroup(name: name, purpose: purpose, workers: workers)
|
||||
case .sequence(_, let name, let purpose, let workers):
|
||||
sequenceGroup(name: name, purpose: purpose, workers: workers)
|
||||
}
|
||||
@@ -98,6 +100,55 @@ struct SubagentsPanel: View {
|
||||
open: { store.openSession(sub) })
|
||||
}
|
||||
|
||||
/// An independent plan batch is the unit the supervisor can wait on and act upon. Keep its
|
||||
/// workers together even when later plan submissions interleave them in creation-time order.
|
||||
private func batchGroup(
|
||||
name: String, purpose: String?, workers: [SessionSummary]
|
||||
) -> some View {
|
||||
let done = workers.filter {
|
||||
if case .done = SubagentRunState(summary: $0) { return true }
|
||||
return false
|
||||
}.count
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "rectangle.3.group.fill")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.orchestra)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(name)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
.lineLimit(1)
|
||||
if let purpose, !purpose.isEmpty {
|
||||
Text(purpose)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTheme.softTextDim)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text("\(done)/\(workers.count) done")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(AppTheme.softText)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.top, 7)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
ForEach(Array(workers.enumerated()), id: \.element.id) { index, worker in
|
||||
workerRow(worker, waitingOn: nil)
|
||||
if index < workers.count - 1 { Divider().padding(.leading, 9) }
|
||||
}
|
||||
}
|
||||
.background(AppTheme.orchestra.opacity(0.045), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(AppTheme.orchestra.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
/// A sequence reads as one connected unit instead of a coincidental run of rows: the header
|
||||
/// names the chain and its progress, and a numbered vertical rail makes dependency order clear
|
||||
/// without turning the narrow panel into a node graph.
|
||||
@@ -257,42 +308,57 @@ struct SubagentsPanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable display grouping for the Subagents panel. Sequence workers may be interleaved with other
|
||||
/// workers in creation-time order (several plans can be submitted over a chat's life), so group by
|
||||
/// the durable sequence id and sort by its explicit step rather than relying on adjacency.
|
||||
/// Stable display grouping for the Subagents panel. Batch and sequence workers may be interleaved
|
||||
/// with other workers in creation-time order (several plans can be submitted over a chat's life),
|
||||
/// so group by durable placement ids rather than relying on adjacency.
|
||||
enum SubagentPanelGroup: Identifiable {
|
||||
case worker(SessionSummary)
|
||||
case batch(
|
||||
id: String, name: String, purpose: String?, workers: [SessionSummary])
|
||||
case sequence(
|
||||
id: String, name: String, purpose: String?, workers: [SessionSummary])
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .worker(let worker): "worker-\(worker.id.rawValue)"
|
||||
case .batch(let id, _, _, _): "batch-\(id)"
|
||||
case .sequence(let id, _, _, _): "sequence-\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
static func arrange(_ workers: [SessionSummary]) -> [SubagentPanelGroup] {
|
||||
var seenSequences: Set<String> = []
|
||||
var seenBatches: Set<String> = []
|
||||
var result: [SubagentPanelGroup] = []
|
||||
for worker in workers {
|
||||
guard let placement = worker.orchestraSequence else {
|
||||
result.append(.worker(worker))
|
||||
if let placement = worker.orchestraSequence {
|
||||
guard seenSequences.insert(placement.id).inserted else { continue }
|
||||
let steps = workers
|
||||
.filter { $0.orchestraSequence?.id == placement.id }
|
||||
.sorted {
|
||||
let lhs = $0.orchestraSequence?.step ?? .max
|
||||
let rhs = $1.orchestraSequence?.step ?? .max
|
||||
if lhs != rhs { return lhs < rhs }
|
||||
return $0.createdAt < $1.createdAt
|
||||
}
|
||||
result.append(
|
||||
.sequence(
|
||||
id: placement.id, name: placement.name, purpose: placement.purpose,
|
||||
workers: steps))
|
||||
continue
|
||||
}
|
||||
guard seenSequences.insert(placement.id).inserted else { continue }
|
||||
let steps = workers
|
||||
.filter { $0.orchestraSequence?.id == placement.id }
|
||||
.sorted {
|
||||
let lhs = $0.orchestraSequence?.step ?? .max
|
||||
let rhs = $1.orchestraSequence?.step ?? .max
|
||||
if lhs != rhs { return lhs < rhs }
|
||||
return $0.createdAt < $1.createdAt
|
||||
}
|
||||
result.append(
|
||||
.sequence(
|
||||
id: placement.id, name: placement.name, purpose: placement.purpose,
|
||||
workers: steps))
|
||||
if let placement = worker.orchestraBatch {
|
||||
guard seenBatches.insert(placement.id).inserted else { continue }
|
||||
let members = workers
|
||||
.filter { $0.orchestraBatch?.id == placement.id }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
result.append(
|
||||
.batch(
|
||||
id: placement.id, name: placement.name, purpose: placement.purpose,
|
||||
workers: members))
|
||||
continue
|
||||
}
|
||||
result.append(.worker(worker))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
|
||||
public var rootSpawnedBySessionID: SessionID?
|
||||
/// Durable placement in an ordered delegation-plan chain.
|
||||
public var orchestraSequence: OrchestraSequencePlacement?
|
||||
/// Durable placement in an independent delegation-plan batch.
|
||||
public var orchestraBatch: OrchestraBatchPlacement?
|
||||
/// Set when this chat runs on another device but is folded under *this* Mac's project section —
|
||||
/// a peer Mac's chat or a Covalence Cloud runner's (docs/COVALENCE_RUNNER.md §3): the owning
|
||||
/// device's display label. Every device holds the same projects, so a project is one sidebar row
|
||||
@@ -219,6 +221,7 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
|
||||
self.spawnedBySessionID = session.spawnedBySessionID
|
||||
self.rootSpawnedBySessionID = session.rootSpawnedBySessionID
|
||||
self.orchestraSequence = session.orchestraSequence
|
||||
self.orchestraBatch = session.orchestraBatch
|
||||
}
|
||||
|
||||
/// Build a summary for a session that lives on a peer Mac (mesh session sync), from the
|
||||
@@ -259,6 +262,7 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
|
||||
self.spawnedBySessionID = nil
|
||||
self.rootSpawnedBySessionID = nil
|
||||
self.orchestraSequence = nil
|
||||
self.orchestraBatch = nil
|
||||
}
|
||||
|
||||
/// List-row identity for the sidebar's per-project session rows. Origin-qualified because
|
||||
@@ -8607,7 +8611,8 @@ public final class AppStore: ConflictArbiter {
|
||||
attachments: [PendingAttachment] = [], ephemeral: Bool = false,
|
||||
spawnedBy: SessionID? = nil, startRun: Bool = true, covalenceOriginDeviceID: String? = nil,
|
||||
inheritWorktreeFrom: Session? = nil,
|
||||
orchestraSequence: OrchestraSequencePlacement? = nil
|
||||
orchestraSequence: OrchestraSequencePlacement? = nil,
|
||||
orchestraBatch: OrchestraBatchPlacement? = nil
|
||||
) async throws -> SessionID {
|
||||
// Gate on the launch lock/nvrsion reconciliation (plan item 6): a new chat's first turn
|
||||
// must never run against not-yet-rebuilt lock state. Free once the gate completes.
|
||||
@@ -8725,6 +8730,7 @@ public final class AppStore: ConflictArbiter {
|
||||
spawnedBySessionID: spawnedBy,
|
||||
rootSpawnedBySessionID: spawnedByRoot,
|
||||
orchestraSequence: orchestraSequence,
|
||||
orchestraBatch: orchestraBatch,
|
||||
model: resolvedModel, effort: resolvedEffort ?? defaultEffort,
|
||||
routedPurpose: routing?.purpose.rawValue,
|
||||
routedLevel: routing?.level.rawValue,
|
||||
@@ -9100,7 +9106,8 @@ public final class AppStore: ConflictArbiter {
|
||||
/// immediately while still withholding a dependent worker's turn until its predecessor reports.
|
||||
private func prepareOrchestraSubagent(
|
||||
_ request: OrchestraSubagentRequest, parent: Session, project: Project,
|
||||
sequence: OrchestraSequencePlacement? = nil
|
||||
sequence: OrchestraSequencePlacement? = nil,
|
||||
batch: OrchestraBatchPlacement? = nil
|
||||
) async -> (worker: PreparedOrchestraWorker?, error: String?) {
|
||||
let prompt = request.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !prompt.isEmpty else { return (nil, "Worker prompt is required.") }
|
||||
@@ -9122,7 +9129,7 @@ public final class AppStore: ConflictArbiter {
|
||||
routing: selection.routing,
|
||||
useWorktree: !project.nvrsionActive, auto: true, autoShip: false,
|
||||
spawnedBy: parent.id, startRun: false, inheritWorktreeFrom: parent,
|
||||
orchestraSequence: sequence)
|
||||
orchestraSequence: sequence, orchestraBatch: batch)
|
||||
} catch {
|
||||
return (nil, "Worker session could not be created: \(error)")
|
||||
}
|
||||
@@ -9153,12 +9160,14 @@ public final class AppStore: ConflictArbiter {
|
||||
/// task — so the cap (`defaultOrchestraMaxConcurrentWorkers`) bounds how many run at once without
|
||||
/// ever blocking the spawn call itself (blocking it would deadlock a fan-out wider than the cap).
|
||||
func spawnOrchestraSubagent(
|
||||
_ request: OrchestraSubagentRequest, parent: Session, project: Project
|
||||
_ request: OrchestraSubagentRequest, parent: Session, project: Project,
|
||||
batch: OrchestraBatchPlacement? = nil
|
||||
) async -> OrchestraSubagentResult {
|
||||
if request.rebindWorkerID != nil {
|
||||
return await rebindOrchestraSubagent(request, parent: parent, project: project)
|
||||
}
|
||||
let prepared = await prepareOrchestraSubagent(request, parent: parent, project: project)
|
||||
let prepared = await prepareOrchestraSubagent(
|
||||
request, parent: parent, project: project, batch: batch)
|
||||
guard let worker = prepared.worker else {
|
||||
return .denied(message: prepared.error ?? "Worker session could not be prepared.")
|
||||
}
|
||||
@@ -9256,13 +9265,15 @@ public final class AppStore: ConflictArbiter {
|
||||
}
|
||||
var dispatched: [OrchestraDispatchedBatch] = []
|
||||
for batch in request.batches {
|
||||
let placement = OrchestraBatchPlacement(
|
||||
id: UUID().uuidString, name: batch.name, purpose: batch.purpose)
|
||||
var workers: [OrchestraDispatchedWorker] = []
|
||||
var rejected: [OrchestraRejectedTask] = []
|
||||
for task in batch.tasks {
|
||||
let result = await spawnOrchestraSubagent(
|
||||
OrchestraSubagentRequest(
|
||||
task: task.task, prompt: task.prompt, batch: batch.name, files: task.files),
|
||||
parent: parent, project: project)
|
||||
parent: parent, project: project, batch: placement)
|
||||
switch result {
|
||||
case .spawned(let workerID, _, _):
|
||||
workers.append(OrchestraDispatchedWorker(workerID: workerID, task: task.task))
|
||||
|
||||
@@ -329,6 +329,23 @@ public struct OrchestraPlanBatch: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// How one worker session sits inside an independent delegation-plan batch. Stored with the
|
||||
/// session so the Subagents panel can reconstruct a plan's parallel groups after relaunch,
|
||||
/// rather than relying on the supervisor's transient tool result.
|
||||
public struct OrchestraBatchPlacement: Sendable, Codable, Equatable {
|
||||
/// Unique per submitted batch; display names are intentionally allowed to repeat across plans.
|
||||
public let id: String
|
||||
public let name: String
|
||||
/// The supervisor's stated reason for this batch, surfaced beside its name in the panel.
|
||||
public let purpose: String?
|
||||
|
||||
public init(id: String, name: String, purpose: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.purpose = purpose
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered chain of delegated tasks. Unlike a batch (whose tasks are independent and all run at
|
||||
/// once), a sequence runs one worker at a time and hands each worker's terminal report to the next
|
||||
/// worker before that next turn starts. Ordering is the dependency graph, so chains can be any
|
||||
|
||||
@@ -406,6 +406,12 @@ public final class GRDBMetadataStore: SessionMetadataStore {
|
||||
// concern across several nullable columns. Existing workers remain ordinary rows.
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN orchestra_sequence TEXT;")
|
||||
}
|
||||
migrator.registerMigration("v40-orchestra-batches") { db in
|
||||
// Durable placement of a worker inside an independent delegation-plan batch. JSON
|
||||
// keeps the small, UI-facing shape additive without splitting it across nullable
|
||||
// columns. Existing workers stay as ungrouped rows.
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN orchestra_batch TEXT;")
|
||||
}
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -759,6 +765,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
var spawned_by_session_id: String?
|
||||
var root_spawned_by_session_id: String?
|
||||
var orchestra_sequence: String?
|
||||
var orchestra_batch: String?
|
||||
var model: String?
|
||||
var effort: String?
|
||||
var routed_purpose: String?
|
||||
@@ -817,6 +824,9 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
orchestra_sequence = s.orchestraSequence.flatMap {
|
||||
(try? JSONEncoder().encode($0)).map { String(decoding: $0, as: UTF8.self) }
|
||||
}
|
||||
orchestra_batch = s.orchestraBatch.flatMap {
|
||||
(try? JSONEncoder().encode($0)).map { String(decoding: $0, as: UTF8.self) }
|
||||
}
|
||||
model = s.model
|
||||
effort = s.effort
|
||||
routed_purpose = s.routedPurpose
|
||||
@@ -881,6 +891,10 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
try? JSONDecoder().decode(
|
||||
OrchestraSequencePlacement.self, from: Data(json.utf8))
|
||||
},
|
||||
orchestraBatch: orchestra_batch.flatMap { json in
|
||||
try? JSONDecoder().decode(
|
||||
OrchestraBatchPlacement.self, from: Data(json.utf8))
|
||||
},
|
||||
model: model,
|
||||
effort: effort,
|
||||
routedPurpose: routed_purpose,
|
||||
|
||||
@@ -90,6 +90,9 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
/// Kept on the worker session (rather than only in the supervisor's tool result) so the
|
||||
/// Subagents panel can render durable connected chains after a relaunch.
|
||||
public var orchestraSequence: OrchestraSequencePlacement?
|
||||
/// Placement inside an independent Orchestra delegation-plan batch. Nil for ordinary workers,
|
||||
/// direct subagent spawns, and sequence steps.
|
||||
public var orchestraBatch: OrchestraBatchPlacement?
|
||||
/// Model alias or full id passed via `--model` (nil = CLI default).
|
||||
public var model: String?
|
||||
/// Reasoning effort passed via `--effort` (low/medium/high/xhigh/max; nil = default).
|
||||
@@ -246,6 +249,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
spawnedBySessionID: SessionID? = nil,
|
||||
rootSpawnedBySessionID: SessionID? = nil,
|
||||
orchestraSequence: OrchestraSequencePlacement? = nil,
|
||||
orchestraBatch: OrchestraBatchPlacement? = nil,
|
||||
model: String? = nil,
|
||||
effort: String? = nil,
|
||||
routedPurpose: String? = nil,
|
||||
@@ -301,6 +305,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
self.spawnedBySessionID = spawnedBySessionID
|
||||
self.rootSpawnedBySessionID = rootSpawnedBySessionID
|
||||
self.orchestraSequence = orchestraSequence
|
||||
self.orchestraBatch = orchestraBatch
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.routedPurpose = routedPurpose
|
||||
|
||||
@@ -17,6 +17,7 @@ struct SubagentProgressTests {
|
||||
disposition: TurnDisposition? = nil,
|
||||
pendingApprovals: Int = 0,
|
||||
sequence: OrchestraSequencePlacement? = nil,
|
||||
batch: OrchestraBatchPlacement? = nil,
|
||||
createdAt: TimeInterval
|
||||
) -> SessionSummary {
|
||||
let session = Session(
|
||||
@@ -28,6 +29,7 @@ struct SubagentProgressTests {
|
||||
spawnedBySessionID: SessionID(rawValue: "parent"),
|
||||
rootSpawnedBySessionID: SessionID(rawValue: "parent"),
|
||||
orchestraSequence: sequence,
|
||||
orchestraBatch: batch,
|
||||
transcriptPath: "/tmp/\(name).jsonl",
|
||||
lastTurnDisposition: disposition,
|
||||
createdAt: Date(timeIntervalSince1970: createdAt),
|
||||
@@ -73,6 +75,36 @@ struct SubagentProgressTests {
|
||||
#expect(steps.map(\.id) == [step1.id, step2.id, step3.id])
|
||||
}
|
||||
|
||||
/// Independent plan batches remain intact even when their workers are interleaved with a
|
||||
/// later batch or a standalone spawn in creation-time order.
|
||||
@Test func panelGroupingKeepsPlanBatchesTogether() {
|
||||
let explore = OrchestraBatchPlacement(
|
||||
id: "batch-explore", name: "explore", purpose: "map the existing code")
|
||||
let implement = OrchestraBatchPlacement(
|
||||
id: "batch-implement", name: "implement", purpose: "make the change")
|
||||
let exploreOne = worker("explore-one", status: .running, batch: explore, createdAt: 1)
|
||||
let standalone = worker("standalone", status: .running, createdAt: 2)
|
||||
let implementOne = worker("implement-one", status: .running, batch: implement, createdAt: 3)
|
||||
let exploreTwo = worker("explore-two", status: .finished, batch: explore, createdAt: 4)
|
||||
|
||||
let groups = SubagentPanelGroup.arrange(
|
||||
[exploreOne, standalone, implementOne, exploreTwo])
|
||||
#expect(groups.count == 3)
|
||||
guard case .batch(_, let exploreName, let explorePurpose, let exploreWorkers) = groups[0],
|
||||
case .worker(let plain) = groups[1],
|
||||
case .batch(_, let implementName, _, let implementWorkers) = groups[2]
|
||||
else {
|
||||
Issue.record("expected the two durable plan batches around the standalone worker")
|
||||
return
|
||||
}
|
||||
#expect(exploreName == "explore")
|
||||
#expect(explorePurpose == "map the existing code")
|
||||
#expect(exploreWorkers.map(\.id) == [exploreOne.id, exploreTwo.id])
|
||||
#expect(plain.id == standalone.id)
|
||||
#expect(implementName == "implement")
|
||||
#expect(implementWorkers.map(\.id) == [implementOne.id])
|
||||
}
|
||||
|
||||
/// A worker reaches Done the moment its autonomous turn finishes, while archival is a separate
|
||||
/// supervisor decision. So "done" comes from the run's outcome: `.finished`, or any
|
||||
/// `.awaitingInput` stop. Everything else is working, and an errored or interrupted run failed.
|
||||
|
||||
@@ -187,6 +187,7 @@ struct GRDBMetadataStoreTests {
|
||||
#expect(loaded?.spawnedBySessionID == nil) // v25 migration column, default null
|
||||
#expect(loaded?.rootSpawnedBySessionID == nil) // v26 migration column, default null
|
||||
#expect(loaded?.orchestraSequence == nil) // v39 migration column, default null
|
||||
#expect(loaded?.orchestraBatch == nil) // v40 migration column, default null
|
||||
#expect(loaded?.keywords == nil) // v31 migration column, default null
|
||||
#expect(loaded?.contextSwitchMuted == false) // v37 migration column, default false
|
||||
|
||||
@@ -202,6 +203,8 @@ struct GRDBMetadataStoreTests {
|
||||
session.orchestraSequence = OrchestraSequencePlacement(
|
||||
id: "sequence-1", name: "inspect then build", purpose: "ordered handoff",
|
||||
step: 2, count: 3, dependsOnWorkerID: SessionID(rawValue: "worker-1"))
|
||||
session.orchestraBatch = OrchestraBatchPlacement(
|
||||
id: "batch-1", name: "implementation", purpose: "independent edits")
|
||||
session.keywords = "auth, login, session"
|
||||
session.contextSwitchMuted = true
|
||||
try await store.saveSession(session)
|
||||
@@ -218,6 +221,8 @@ struct GRDBMetadataStoreTests {
|
||||
#expect(reloaded?.orchestraSequence == OrchestraSequencePlacement(
|
||||
id: "sequence-1", name: "inspect then build", purpose: "ordered handoff",
|
||||
step: 2, count: 3, dependsOnWorkerID: SessionID(rawValue: "worker-1")))
|
||||
#expect(reloaded?.orchestraBatch == OrchestraBatchPlacement(
|
||||
id: "batch-1", name: "implementation", purpose: "independent edits"))
|
||||
#expect(reloaded?.contextSwitchMuted == true) // v37 round-trip
|
||||
// Still a single row.
|
||||
#expect(try await store.loadSessions(projectID: project.id).count == 1)
|
||||
|
||||
Reference in New Issue
Block a user