Merge nucleic/lucid-willow-viper-cvpr into dev

This commit is contained in:
2026-08-05 20:26:38 -07:00
parent 335ca804b6
commit cc89b3cdea
7 changed files with 62 additions and 10 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "14a09f4fd1b9399f7ac8b973eaec55c33177c03ca1e02f1cf980ff0cbd8b0524",
"originHash" : "e4fd27a1a8c40e7fd339f720fe4a28e2d31539f5e93b334e6f9a6344c0829f48",
"pins" : [
{
"identity" : "async-http-client",
+17 -1
View File
@@ -6604,7 +6604,7 @@ public final class AppStore: ConflictArbiter {
await controller.note("\(NoteEvent.nvrsionPrefix) could not land \(rel): \(why)",
icon: "exclamationmark.triangle", toolCallID: change.toolCallID)
}
case .runFinished:
case .runFinished(let finished):
// Turn-end: if this turn landed work, fold its contribution into the trunk's running
// change summary (NVRSION §6) built incrementally, per turn, so the eventual promotion
// ships a meaningful, on-device-accurate message instead of summarizing the whole diff.
@@ -6614,6 +6614,15 @@ public final class AppStore: ConflictArbiter {
files: landed.sorted())
foldNvrsionSummary(projectID: session.projectID, trunkPath: trunkPath, digest: digest)
}
// A stopped edit gate has turned its queued acquisition into a cross-turn monitor.
// Keep every lock the session already held: `releaseAll` would also cancel the queued
// acquisition the recovery turn depends on. Forget keep-warm scheduling so its idle
// sweep cannot release those earlier locks either.
if finished.waitingOnLock {
await nvrsionGovernor.forget(sessionID)
lockLog.notice("LOCKTRACE turn-end keep-all(lock-wait) session=\(sessionID.rawValue, privacy: .public)")
return
}
// Release every file the turn held warm (NVRSION §4). Fenced on the trunk history
// head covering the turn's landed commits (§17.4 emitted per land, so it always
// exists before this release fires).
@@ -6972,6 +6981,13 @@ public final class AppStore: ConflictArbiter {
materializedLockWork[sid] = nil
await lockManager.releaseAll(sid); continue
}
// This is one logical edit sequence paused between provider turns, not a lifecycle
// boundary. Preserve its earlier locks until the recovery turn starts. Explicit
// archive/terminal handling above and manual release deliberately still win.
if await controller.retainingLocksForLockWait {
lockLog.notice("LOCKTRACE reconcile keep-all(lock-wait) session=\(sid.rawValue, privacy: .public) held=\(heldPaths.joined(separator: ","), privacy: .public)")
continue
}
// Worktree-less sessions edit the branch directly; their commit-based release is a
// follow-up for now they release only via lifecycle/mediated/manual paths.
guard await controller.hasWorktree else {
@@ -94,6 +94,10 @@ public actor SessionController {
/// True once this run's terminal event has arrived, even if its stream is still closing. A
/// queue appearing in that close window needs no interruption it is already about to drain.
private var currentRunFinished = false
/// True between a turn ending on a queued edit lock and its recovery turn beginning. Lock
/// reconciliation reads this to preserve locks acquired earlier in the same logical edit
/// sequence; ordinary `nucleic_monitor` waits leave it false.
private var lockWaitRecoveryActive = false
/// Set while the queue's Interrupt action is stopping the current turn. The backend still
/// reports the ordinary `.interrupted` lifecycle outcome; the controller annotates that one
/// terminal event so transcript surfaces can distinguish this intent from the standalone Stop.
@@ -1337,6 +1341,7 @@ public actor SessionController {
// one-shot run left behind.
fanoutFinished = false
currentRunFinished = false
lockWaitRecoveryActive = false
runTask = Task {
if let userChunk {
await ingest(synthetic(.userText(userChunk)))
@@ -2439,6 +2444,9 @@ public actor SessionController {
if case .runFinished(let finished) = canonical.kind, finished.waitingOnBackground {
session.lastTurnDisposition = .waitingBackground
}
if case .runFinished(let finished) = canonical.kind, finished.waitingOnLock {
lockWaitRecoveryActive = true
}
// Canonical freshness (`lastSeq`) and sidebar-visible freshness (`updatedAt`) are
// deliberately separate (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 3): a streaming
// delta advances `lastSeq` without restamping `updatedAt`, so a token burst no
@@ -2545,6 +2553,7 @@ public actor SessionController {
outcome: finished.outcome, finalText: finished.finalText,
totalUsage: finished.totalUsage, durationMs: finished.durationMs,
waitingOnBackground: finished.waitingOnBackground,
waitingOnLock: finished.waitingOnLock,
interruptionReason: .sendQueuedMessages)))
}
@@ -2562,9 +2571,14 @@ public actor SessionController {
outcome: finished.outcome, finalText: finished.finalText,
totalUsage: finished.totalUsage, durationMs: finished.durationMs,
waitingOnBackground: true,
waitingOnLock: true,
interruptionReason: finished.interruptionReason)))
}
/// Whether lock reconciliation must preserve this session's existing locks while a stopped
/// edit gate watches for its queued lock. The flag clears only when the recovery run begins.
var retainingLocksForLockWait: Bool { lockWaitRecoveryActive }
/// Resolve every approval still pending now that the run has ended. With the backend
/// stream closed there is no live waiter left to answer the request cards are orphaned
/// (their Allow/Deny buttons would call into a coordinator that no longer holds the
+12 -5
View File
@@ -329,10 +329,14 @@ public struct RunFinished: Sendable, Codable, Equatable {
/// backend sets it for `nucleic_monitor`; the session controller may also set it when a queued
/// edit lock outlives the provider turn and becomes that session's availability monitor.
public let waitingOnBackground: Bool
/// This background wait is specifically a stopped edit-gate lock request. Unlike a general
/// `nucleic_monitor` wait, the session is still inside one logical edit sequence and must retain
/// locks it acquired earlier in that sequence until the recovery turn starts.
public let waitingOnLock: Bool
public init(
outcome: Outcome, finalText: String? = nil, totalUsage: Usage? = nil,
durationMs: Int? = nil, waitingOnBackground: Bool = false,
durationMs: Int? = nil, waitingOnBackground: Bool = false, waitingOnLock: Bool = false,
interruptionReason: InterruptionReason? = nil
) {
self.outcome = outcome
@@ -340,16 +344,18 @@ public struct RunFinished: Sendable, Codable, Equatable {
self.totalUsage = totalUsage
self.durationMs = durationMs
self.waitingOnBackground = waitingOnBackground
self.waitingOnLock = waitingOnLock
self.interruptionReason = interruptionReason
}
private enum CodingKeys: String, CodingKey {
case outcome, finalText, totalUsage, durationMs, waitingOnBackground, interruptionReason
case outcome, finalText, totalUsage, durationMs, waitingOnBackground, waitingOnLock,
interruptionReason
}
// Custom decode so `waitingOnBackground` defaults to false when absent older persisted
// transcripts (and peers on a prior build) predate the key, and a synthesized decoder would
// reject them for the missing non-optional field. Encode stays synthesized.
// Custom decode so background-wait flags default to false when absent older persisted
// transcripts (and peers on a prior build) predate these keys, and a synthesized decoder would
// reject them for missing non-optional fields. Encode stays synthesized.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
outcome = try c.decode(Outcome.self, forKey: .outcome)
@@ -357,6 +363,7 @@ public struct RunFinished: Sendable, Codable, Equatable {
totalUsage = try c.decodeIfPresent(Usage.self, forKey: .totalUsage)
durationMs = try c.decodeIfPresent(Int.self, forKey: .durationMs)
waitingOnBackground = try c.decodeIfPresent(Bool.self, forKey: .waitingOnBackground) ?? false
waitingOnLock = try c.decodeIfPresent(Bool.self, forKey: .waitingOnLock) ?? false
interruptionReason = try c.decodeIfPresent(
InterruptionReason.self, forKey: .interruptionReason)
}
+9 -1
View File
@@ -860,12 +860,16 @@ struct AppStoreTests {
await store.activateConflictArbitration()
let recoveryCoordinator = ConflictCoordinator()
await recoveryCoordinator.setArbiter(store)
let project = try #require(await store.addProject(
let created = try #require(await store.addProject(
name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
let holder = try await store.createSession(in: project, title: "holder", prompt: "")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor { store.summaries.first { $0.id == asker }?.status == .running }
#expect(await recoveryCoordinator.arbitrate(
sessionID: asker, task: "prepare another edit", files: ["owned.txt"]
).resolution == .proceed)
#expect(await store.arbitrate(
sessionID: holder, task: "hold shared", files: ["shared.txt"]).resolution == .proceed)
let arbitration = Task {
@@ -879,6 +883,10 @@ struct AppStoreTests {
store.summaries.first { $0.id == asker }?.disposition == .waitingBackground
}
#expect(store.sessionsWaitingForAccess.contains(asker))
let heldWhileWaiting = await store.lockQueueSnapshot()
#expect(heldWhileWaiting.files.first { $0.path == "owned.txt" }?.holders.contains {
$0.sessionID == asker
} == true)
// Lock recovery is a first-class background monitor, not an ordinary clean turn awaiting
// classification. Let every turn-end pipeline settle and verify the structural state is
@@ -47,13 +47,16 @@ import Testing
@Test func runFinishedInterruptionReasonRoundTripsAndDefaultsForOlderTranscripts() throws {
let queued = RunFinished(
outcome: .interrupted, interruptionReason: .sendQueuedMessages)
outcome: .interrupted, waitingOnBackground: true, waitingOnLock: true,
interruptionReason: .sendQueuedMessages)
#expect(try JSONDecoder().decode(
RunFinished.self, from: JSONEncoder().encode(queued)) == queued)
let legacy = Data(#"{"outcome":"interrupted"}"#.utf8)
let decodedLegacy = try JSONDecoder().decode(RunFinished.self, from: legacy)
#expect(decodedLegacy.outcome == .interrupted)
#expect(!decodedLegacy.waitingOnBackground)
#expect(!decodedLegacy.waitingOnLock)
#expect(decodedLegacy.interruptionReason == nil)
}
+5 -1
View File
@@ -506,7 +506,11 @@ queued. A grant which beats turn-end does not fire the monitor, so a healthy ori
never duplicated. While queued, the terminal event uses the same structural background-wait state as
`nucleic_monitor`: the controller persists `.waitingBackground`, the chat reads **Monitoring…**, and
Done classification, notifications, autoship, auto-archive, and resource teardown stay suppressed.
Archived/deleted sessions still cancel their queue entries normally.
The wait is not a lock-lifecycle boundary: locks this session acquired earlier remain held, and the
nvrsion turn-end/keep-warm paths plus periodic landed-lock reconciliation skip them until the recovery
turn starts. This preserves an atomic multi-file edit sequence instead of freeing its first files
while its later file is still queued. Explicit archive/delete/manual release still wins and cancels
queued acquisition normally.
**Mesh release fence (2026-07-21).** Each of the four release paths above is the *publication*
half of release: the domain's lock authority appends `released(paths, requiredHeads)` to the