Merge nucleic/hazy-river-vole-ec4p into dev

This commit is contained in:
2026-08-05 20:08:44 -07:00
parent 8905d06bd0
commit 09547bb3b6
7 changed files with 174 additions and 24 deletions
+84 -18
View File
@@ -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
}
+17 -6
View File
@@ -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))
+17
View File
@@ -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,
+5
View File
@@ -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)