Merge nucleic/lucid-willow-viper-cvpr into dev
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "14a09f4fd1b9399f7ac8b973eaec55c33177c03ca1e02f1cf980ff0cbd8b0524",
|
||||
"originHash" : "e4fd27a1a8c40e7fd339f720fe4a28e2d31539f5e93b334e6f9a6344c0829f48",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user