Join captured Assistant failure persistence
This commit is contained in:
1 parent
80505ccc0e
commit
7e8427d94d
4 files changed
+435
-51
No files matched your search
@@ -193,6 +193,80 @@ struct AgentReplayPersistenceTests {
|
||||
#expect(runRows.first?.permissionMode == AgentPermissionMode.fullAccess.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
func capturedConfigurationFailureAdoptsEditedStagingOnlyAfterDurableSave() async throws {
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
|
||||
let conversationID = UUID()
|
||||
let image = AgentImageAttachment(
|
||||
label: "capture.png", mimeType: "image/png", data: Data([4, 5, 6]),
|
||||
pixelWidth: 2, pixelHeight: 2
|
||||
)
|
||||
let attachment = AgentContextAttachment(
|
||||
id: UUID(), kind: .file, label: "capture.txt",
|
||||
contentTypeIdentifier: "public.plain-text", suggestedFilename: "capture.txt",
|
||||
data: Data("captured payload".utf8)
|
||||
)
|
||||
let original = AgentTranscriptMessage(
|
||||
role: .user, text: "before", authoredText: "before",
|
||||
images: [image], contextAttachments: [attachment]
|
||||
)
|
||||
try await repository.save(
|
||||
AgentRunSnapshot(
|
||||
conversationID: conversationID, state: .completed,
|
||||
permissionMode: .askForApproval, messages: [original]
|
||||
))
|
||||
let stored = try #require(try await repository.loadConversations().first)
|
||||
let store = AssistantConversationStore()
|
||||
store.restore([stored])
|
||||
let conversation = try #require(store.conversations.first)
|
||||
conversation.permissionMode = .fullAccess
|
||||
let controller = AssistantExecutionController(
|
||||
ai: AIConfigurationModel(), skills: SkillLibraryModel(), repository: repository,
|
||||
authorize: { true },
|
||||
makeRegistry: { _, _, _, _, _ in AgentToolRegistry([]) },
|
||||
title: { _ in "Title" }
|
||||
)
|
||||
let operations = AssistantConversationOperations(repository: repository)
|
||||
let prepared = try #require(
|
||||
controller.prepareAgentEdit(
|
||||
conversation, messageID: original.id, text: "after",
|
||||
context: SessionContext(hostName: "captured-host", workingDirectory: "/captured"),
|
||||
terminalSuggestionsAvailable: true
|
||||
))
|
||||
|
||||
#expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations))
|
||||
let replacement = try #require(conversation.transcript.first)
|
||||
let staging = try #require(conversation.runSummaries[replacement.id])
|
||||
await conversation.stopAndWait()
|
||||
|
||||
#expect(conversation.runState == .failed)
|
||||
#expect(conversation.runSummaries[replacement.id] == nil)
|
||||
|
||||
let freshRepository = SwiftDataAgentRunRepository(context: ModelContext(container))
|
||||
let restored = try #require(try await freshRepository.loadConversations().first)
|
||||
let run = try #require(restored.runs.first)
|
||||
let message = try #require(restored.messages.first)
|
||||
#expect(restored.runs.count == 1)
|
||||
#expect(run.id != replacement.id)
|
||||
#expect(run.state == .failed)
|
||||
#expect(run.failure == String(localized: "No Chat model is configured."))
|
||||
#expect(run.startedAt >= staging.startedAt)
|
||||
#expect(run.finishedAt.map { $0 >= run.startedAt } == true)
|
||||
#expect(restored.messageRunIDs[replacement.id] == run.id)
|
||||
#expect(message.id == replacement.id)
|
||||
#expect(message.text == replacement.text)
|
||||
#expect(message.authoredText == "after")
|
||||
#expect(message.images == [image])
|
||||
#expect(message.contextAttachments == [attachment])
|
||||
#expect(message.contextSnapshot?.sessionContext?.contains("captured-host") == true)
|
||||
#expect(message.contextSnapshot?.toolAvailability?["terminal_suggest"] == true)
|
||||
#expect(message.createdAt <= (run.finishedAt ?? .distantPast))
|
||||
let runRows = try ModelContext(container).fetch(FetchDescriptor<PersistentAgentRun>())
|
||||
#expect(runRows.count == 1)
|
||||
#expect(runRows.first?.permissionMode == AgentPermissionMode.fullAccess.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
func v15MigratesWithoutInventingHistoricalContext() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
@@ -85,6 +85,151 @@ struct AssistantExecutionControllerTests {
|
||||
#expect(await repository.snapshots.last?.state == .failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingModelFailureStaysOwnedUntilSaveAndAllCancellationPathsJoinIt() async throws {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
let controller = makeController(repository: repository)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "inspect"
|
||||
|
||||
#expect(controller.sendAgentDraft(conversation))
|
||||
await repository.waitUntilSaveEntered()
|
||||
|
||||
#expect(conversation.isPreparingAgentRun)
|
||||
#expect(conversation.isPersistingAgentPreparationFailure)
|
||||
#expect(conversation.runState != .failed)
|
||||
#expect(conversation.runSummaries.isEmpty)
|
||||
|
||||
conversation.stop()
|
||||
let probe = JoinProbe(expectedStarts: 2)
|
||||
let joinedStop = Task { @MainActor in
|
||||
probe.started()
|
||||
await conversation.stopAndWait()
|
||||
probe.completed()
|
||||
}
|
||||
let joinedControllerCancellation = Task { @MainActor in
|
||||
probe.started()
|
||||
await controller.cancelPendingRuns().value
|
||||
probe.completed()
|
||||
}
|
||||
try await waitUntil { probe.startedCount == probe.expectedStarts }
|
||||
|
||||
#expect(conversation.isPreparingAgentRun)
|
||||
#expect(await repository.snapshots.map(\.state) == [.failed])
|
||||
#expect(await repository.completedSnapshots.isEmpty)
|
||||
#expect(probe.completedCount == 0)
|
||||
|
||||
await repository.releaseSave()
|
||||
await joinedStop.value
|
||||
await joinedControllerCancellation.value
|
||||
|
||||
#expect(!conversation.isPreparingAgentRun)
|
||||
#expect(!conversation.isPersistingAgentPreparationFailure)
|
||||
#expect(conversation.runState == .failed)
|
||||
#expect(await repository.snapshots.map(\.state) == [.failed])
|
||||
#expect(await repository.completedSnapshots.map(\.state) == [.failed])
|
||||
#expect(probe.completedCount == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func capturedFailureSaveOutlivesReleasedControllerAndConversationHandles() async throws {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
var controller: AssistantExecutionController? = makeController(repository: repository)
|
||||
var conversation: ConversationModel? = ConversationModel()
|
||||
conversation?.composer.draft = "persist me"
|
||||
let releasedController = WeakReference(controller)
|
||||
let releasedConversation = WeakReference(conversation)
|
||||
|
||||
#expect(controller?.sendAgentDraft(try #require(conversation)) == true)
|
||||
await repository.waitUntilSaveEntered()
|
||||
controller = nil
|
||||
conversation = nil
|
||||
|
||||
#expect(releasedController.value != nil)
|
||||
#expect(releasedConversation.value != nil)
|
||||
|
||||
await repository.releaseSave()
|
||||
await repository.waitUntilSaveCompleted()
|
||||
try await waitUntil {
|
||||
releasedController.value == nil && releasedConversation.value == nil
|
||||
}
|
||||
#expect(await repository.completedSnapshots.map(\.state) == [.failed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingCredentialAfterMemoryPreparationProjectsOnlyAfterSaveCompletes() async throws {
|
||||
let memoryStore = SnapshotBarrierMemoryStore()
|
||||
let memory = MemoryModel(storage: memoryStore)
|
||||
await memory.setEnabled(true)
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
let (ai, _) = configuredAI(secret: nil)
|
||||
let controller = makeController(ai: ai, repository: repository, memory: memory)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "inspect"
|
||||
|
||||
#expect(controller.sendAgentDraft(conversation))
|
||||
await memoryStore.waitUntilSnapshotEntered()
|
||||
await memoryStore.releaseSnapshot()
|
||||
await repository.waitUntilSaveEntered()
|
||||
|
||||
#expect(conversation.isPreparingAgentRun)
|
||||
#expect(conversation.isPersistingAgentPreparationFailure)
|
||||
#expect(conversation.runState != .failed)
|
||||
|
||||
await repository.releaseSave()
|
||||
try await waitUntil { !conversation.isPreparingAgentRun }
|
||||
|
||||
#expect(conversation.runState == .failed)
|
||||
#expect(await repository.completedSnapshots.map(\.state) == [.failed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func archiveAndDeleteWaitForCapturedFailurePersistence() async throws {
|
||||
do {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
let controller = makeController(repository: repository)
|
||||
let operations = AssistantConversationOperations(repository: repository)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "archive me"
|
||||
|
||||
#expect(controller.sendAgentDraft(conversation))
|
||||
await repository.waitUntilSaveEntered()
|
||||
let archive = Task {
|
||||
try await operations.setArchived(true, conversation: conversation)
|
||||
}
|
||||
await Task.yield()
|
||||
|
||||
#expect(await repository.metadataMutationCount == 0)
|
||||
#expect(conversation.archivedAt == nil)
|
||||
|
||||
await repository.releaseSave()
|
||||
try await archive.value
|
||||
#expect(await repository.metadataMutationCount == 1)
|
||||
#expect(await repository.events == ["save-attempt", "save-completed", "metadata"])
|
||||
#expect(conversation.archivedAt != nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
let controller = makeController(repository: repository)
|
||||
let operations = AssistantConversationOperations(repository: repository)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "delete me"
|
||||
|
||||
#expect(controller.sendAgentDraft(conversation))
|
||||
await repository.waitUntilSaveEntered()
|
||||
let deletion = Task { try await operations.delete(conversation) }
|
||||
await Task.yield()
|
||||
|
||||
#expect(await repository.deletionCount == 0)
|
||||
|
||||
await repository.releaseSave()
|
||||
#expect(try await deletion.value.deleted)
|
||||
#expect(await repository.deletionCount == 1)
|
||||
#expect(await repository.events == ["save-attempt", "save-completed", "delete"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func immediateStopInvalidatesPreparationBeforeTheMemoryBarrierIsReleased() async throws {
|
||||
let memoryStore = SnapshotBarrierMemoryStore()
|
||||
@@ -493,6 +638,7 @@ struct AssistantExecutionControllerTests {
|
||||
#expect(edited.authoredText == "after")
|
||||
#expect(edited.contextSnapshot?.sessionContext?.contains("captured-host") == true)
|
||||
#expect(edited.contextSnapshot?.planContract == TermindAgentPolicy.planContract)
|
||||
await conversation.stopAndWait()
|
||||
#expect(conversation.runState == .failed)
|
||||
let failed = try #require(conversation.runSummaries.values.first)
|
||||
#expect(conversation.runSummaries.count == 1)
|
||||
@@ -502,6 +648,73 @@ struct AssistantExecutionControllerTests {
|
||||
#expect(conversation.runSummaries[edited.id] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedReplacementSaveCannotBeSupersededOrMutatedBeforeItCompletes() async throws {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true)
|
||||
let controller = makeController(repository: repository)
|
||||
let operations = AssistantConversationOperations(repository: repository)
|
||||
let original = AgentTranscriptMessage(role: .user, text: "opening", authoredText: "opening")
|
||||
let answer = AgentTranscriptMessage(role: .assistant, text: "answer")
|
||||
let later = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before")
|
||||
let conversation = ConversationModel(
|
||||
messages: [
|
||||
ChatMessage(id: original.id, role: .user, text: original.text),
|
||||
ChatMessage(id: answer.id, role: .assistant, text: answer.text),
|
||||
ChatMessage(id: later.id, role: .user, text: later.text),
|
||||
], transcript: [original, answer, later])
|
||||
let prepared = try #require(
|
||||
controller.prepareAgentEdit(conversation, messageID: later.id, text: "after"))
|
||||
|
||||
#expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations))
|
||||
await repository.waitUntilSaveEntered()
|
||||
let edited = try #require(conversation.transcript.last)
|
||||
|
||||
#expect(conversation.messageRunIDs[edited.id] == edited.id)
|
||||
#expect(conversation.transcript.contains(where: { $0.id == answer.id }))
|
||||
#expect(controller.prepareAgentEdit(conversation, messageID: edited.id, text: "newer") == nil)
|
||||
#expect(
|
||||
try await operations.edit(conversation, messageID: edited.id, text: "bypass") == nil)
|
||||
#expect(try await operations.fork(conversation, at: answer.id, title: "Bypass") == nil)
|
||||
#expect(await repository.truncationCount == 1)
|
||||
|
||||
await repository.releaseSave()
|
||||
await conversation.stopAndWait()
|
||||
|
||||
#expect(conversation.runState == .failed)
|
||||
#expect(conversation.messageRunIDs[edited.id] != edited.id)
|
||||
#expect(conversation.runSummaries[edited.id] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedSnapshotSaveRetiresPreparationWithoutAdoptingStaging() async throws {
|
||||
let repository = RecordingAgentRunRepository(blocksFirstSave: true, failsFirstSave: true)
|
||||
let controller = makeController(repository: repository)
|
||||
let operations = AssistantConversationOperations(repository: repository)
|
||||
let original = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before")
|
||||
let conversation = ConversationModel(
|
||||
messages: [ChatMessage(id: original.id, role: .user, text: original.text)],
|
||||
transcript: [original])
|
||||
let prepared = try #require(
|
||||
controller.prepareAgentEdit(conversation, messageID: original.id, text: "after"))
|
||||
|
||||
#expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations))
|
||||
await repository.waitUntilSaveEntered()
|
||||
let edited = try #require(conversation.transcript.first)
|
||||
let staging = try #require(conversation.runSummaries[edited.id])
|
||||
#expect(conversation.messageRunIDs[edited.id] == edited.id)
|
||||
|
||||
await repository.releaseSave()
|
||||
try await waitUntil { !conversation.isPreparingAgentRun }
|
||||
|
||||
#expect(!conversation.isPersistingAgentPreparationFailure)
|
||||
#expect(conversation.messageRunIDs[edited.id] == edited.id)
|
||||
#expect(conversation.runSummaries[edited.id] == staging)
|
||||
#expect(conversation.runSummaries.values.allSatisfy { $0.state != .failed })
|
||||
#expect(conversation.messages.last?.role == .assistant)
|
||||
#expect(conversation.messages.last?.text == CocoaError(.fileWriteNoPermission).localizedDescription)
|
||||
#expect(await repository.completedSnapshots.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func cancellationAfterSuccessfulEditAdoptsTheInterruptedRunProvenance() async throws {
|
||||
let memoryStore = SnapshotBarrierMemoryStore()
|
||||
@@ -573,6 +786,33 @@ struct AssistantExecutionControllerTests {
|
||||
|
||||
private struct TestTimeoutError: Error {}
|
||||
|
||||
private final class WeakReference<Value: AnyObject> {
|
||||
weak var value: Value?
|
||||
|
||||
init(_ value: Value?) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class JoinProbe {
|
||||
let expectedStarts: Int
|
||||
private(set) var startedCount = 0
|
||||
private(set) var completedCount = 0
|
||||
|
||||
init(expectedStarts: Int) {
|
||||
self.expectedStarts = expectedStarts
|
||||
}
|
||||
|
||||
func started() {
|
||||
startedCount += 1
|
||||
}
|
||||
|
||||
func completed() {
|
||||
completedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RegistryCapture {
|
||||
var modelID: String?
|
||||
@@ -635,14 +875,21 @@ private actor SnapshotBarrierMemoryStore: MemoryPersisting {
|
||||
|
||||
private actor RecordingAgentRunRepository: AgentRunPersisting {
|
||||
private(set) var snapshots: [AgentRunSnapshot] = []
|
||||
private(set) var completedSnapshots: [AgentRunSnapshot] = []
|
||||
private(set) var events: [String] = []
|
||||
private(set) var truncationReplacement: AgentTranscriptMessage?
|
||||
private(set) var truncationCount = 0
|
||||
private(set) var metadataMutationCount = 0
|
||||
private(set) var deletionCount = 0
|
||||
private let failTruncation: Bool
|
||||
private let blocksFirstSave: Bool
|
||||
private let failsFirstSave: Bool
|
||||
private let blocksTruncation: Bool
|
||||
private var saveEntered = false
|
||||
private var saveReleased = false
|
||||
private var saveEntryWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var saveReleaseWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var saveCompletionWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var snapshotWaiters: [(state: AgentRunState, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
private var truncationEntered = false
|
||||
private var truncationReleased = false
|
||||
@@ -651,15 +898,17 @@ private actor RecordingAgentRunRepository: AgentRunPersisting {
|
||||
|
||||
init(
|
||||
failTruncation: Bool = false, blocksFirstSave: Bool = false,
|
||||
blocksTruncation: Bool = false
|
||||
blocksTruncation: Bool = false, failsFirstSave: Bool = false
|
||||
) {
|
||||
self.failTruncation = failTruncation
|
||||
self.blocksFirstSave = blocksFirstSave
|
||||
self.blocksTruncation = blocksTruncation
|
||||
self.failsFirstSave = failsFirstSave
|
||||
}
|
||||
|
||||
func save(_ snapshot: AgentRunSnapshot) async {
|
||||
func save(_ snapshot: AgentRunSnapshot) async throws {
|
||||
snapshots.append(snapshot)
|
||||
events.append("save-attempt")
|
||||
var pendingSnapshotWaiters: [(state: AgentRunState, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
for waiter in snapshotWaiters {
|
||||
if waiter.state == snapshot.state {
|
||||
@@ -669,14 +918,23 @@ private actor RecordingAgentRunRepository: AgentRunPersisting {
|
||||
}
|
||||
}
|
||||
snapshotWaiters = pendingSnapshotWaiters
|
||||
guard blocksFirstSave, snapshots.count == 1 else { return }
|
||||
saveEntered = true
|
||||
let entryWaiters = saveEntryWaiters
|
||||
saveEntryWaiters.removeAll()
|
||||
for waiter in entryWaiters { waiter.resume() }
|
||||
if !saveReleased {
|
||||
await withCheckedContinuation { saveReleaseWaiters.append($0) }
|
||||
if blocksFirstSave, snapshots.count == 1 {
|
||||
saveEntered = true
|
||||
let entryWaiters = saveEntryWaiters
|
||||
saveEntryWaiters.removeAll()
|
||||
for waiter in entryWaiters { waiter.resume() }
|
||||
if !saveReleased {
|
||||
await withCheckedContinuation { saveReleaseWaiters.append($0) }
|
||||
}
|
||||
}
|
||||
if failsFirstSave, snapshots.count == 1 {
|
||||
throw CocoaError(.fileWriteNoPermission)
|
||||
}
|
||||
completedSnapshots.append(snapshot)
|
||||
events.append("save-completed")
|
||||
let completionWaiters = saveCompletionWaiters
|
||||
saveCompletionWaiters.removeAll()
|
||||
for waiter in completionWaiters { waiter.resume() }
|
||||
}
|
||||
|
||||
func waitUntilSaveEntered() async {
|
||||
@@ -689,6 +947,11 @@ private actor RecordingAgentRunRepository: AgentRunPersisting {
|
||||
await withCheckedContinuation { snapshotWaiters.append((state, $0)) }
|
||||
}
|
||||
|
||||
func waitUntilSaveCompleted() async {
|
||||
if !completedSnapshots.isEmpty { return }
|
||||
await withCheckedContinuation { saveCompletionWaiters.append($0) }
|
||||
}
|
||||
|
||||
func releaseSave() {
|
||||
saveReleased = true
|
||||
let waiters = saveReleaseWaiters
|
||||
@@ -711,14 +974,21 @@ private actor RecordingAgentRunRepository: AgentRunPersisting {
|
||||
func record(_ event: AgentEvent, runID: UUID) {}
|
||||
func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {}
|
||||
func loadConversations() -> [StoredAgentConversation] { [] }
|
||||
func deleteConversation(_ id: UUID) {}
|
||||
func deleteConversation(_ id: UUID) {
|
||||
deletionCount += 1
|
||||
events.append("delete")
|
||||
}
|
||||
func setTitle(_ title: String, conversationID: UUID) {}
|
||||
func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {}
|
||||
func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {
|
||||
metadataMutationCount += 1
|
||||
events.append("metadata")
|
||||
}
|
||||
func reorderConversations(_ updates: [StoredAgentConversationOrder]) {}
|
||||
func truncateConversation(
|
||||
_ id: UUID, fromUserMessageID messageID: UUID,
|
||||
replacingWith message: AgentTranscriptMessage
|
||||
) async throws {
|
||||
truncationCount += 1
|
||||
if blocksTruncation {
|
||||
truncationEntered = true
|
||||
let waiters = truncationEntryWaiters
|
||||
|
||||
@@ -50,9 +50,15 @@ public final class AssistantExecutionController {
|
||||
}
|
||||
|
||||
private struct PreparationState {
|
||||
enum Phase {
|
||||
case preparing
|
||||
case savingFailure
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
var conversation: ConversationModel
|
||||
var permissionMode: AgentPermissionMode
|
||||
var phase = Phase.preparing
|
||||
var task: Task<Void, Never>?
|
||||
var historyTask: Task<ConversationModel.ComposedUserTurn?, any Error>?
|
||||
var cancellationTask: Task<Void, Never>?
|
||||
@@ -60,6 +66,12 @@ public final class AssistantExecutionController {
|
||||
var cancellationRequested = false
|
||||
}
|
||||
|
||||
private enum StartResult {
|
||||
case runtimeStarted
|
||||
case failurePersistenceStarted
|
||||
case abandoned
|
||||
}
|
||||
|
||||
public struct PreparedAgentEdit {
|
||||
fileprivate var capture: RunCapture
|
||||
fileprivate var replacement: AgentTranscriptMessage
|
||||
@@ -339,10 +351,10 @@ public final class AssistantExecutionController {
|
||||
private func performStartAgentRun(
|
||||
_ conversation: ConversationModel, capture: RunCapture,
|
||||
memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)? = nil
|
||||
) {
|
||||
) -> StartResult {
|
||||
guard isCurrent(capture, for: conversation), !conversation.isStreaming,
|
||||
!conversation.isArchived, !conversation.isHistoryChanging
|
||||
else { return }
|
||||
else { return .abandoned }
|
||||
let operationID = UUID().uuidString.lowercased()
|
||||
let conversationID = conversation.id.uuidString.lowercased()
|
||||
guard let chat = capture.chat else {
|
||||
@@ -352,15 +364,11 @@ public final class AssistantExecutionController {
|
||||
"conversation_id": "\(conversationID)", "error_code": "missing_model",
|
||||
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
|
||||
])
|
||||
recordFailedRun(
|
||||
conversation,
|
||||
promptID: capture.prompt.id, prompt: capture.prompt.text,
|
||||
authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
permissionMode: capture.permissionMode,
|
||||
beginFailedRunPersistence(
|
||||
conversation, capture: capture,
|
||||
reason: String(localized: "No Chat model is configured.")
|
||||
)
|
||||
return
|
||||
return .failurePersistenceStarted
|
||||
}
|
||||
guard let secret = capture.secret else {
|
||||
logger.error(
|
||||
@@ -369,15 +377,11 @@ public final class AssistantExecutionController {
|
||||
"conversation_id": "\(conversationID)", "error_code": "missing_credential",
|
||||
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
|
||||
])
|
||||
recordFailedRun(
|
||||
conversation,
|
||||
promptID: capture.prompt.id, prompt: capture.prompt.text,
|
||||
authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
permissionMode: capture.permissionMode,
|
||||
beginFailedRunPersistence(
|
||||
conversation, capture: capture,
|
||||
reason: String(localized: "The Chat model has no available credential.")
|
||||
)
|
||||
return
|
||||
return .failurePersistenceStarted
|
||||
}
|
||||
|
||||
let model = AgentProviderClient(
|
||||
@@ -391,7 +395,7 @@ public final class AssistantExecutionController {
|
||||
// events had no defined order against the tool events they belong
|
||||
// between, and its durable writes had no defined order against
|
||||
// anything. See `AgentEventChannel`.
|
||||
guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return }
|
||||
guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return .abandoned }
|
||||
let skillCatalog = capture.skillCatalog
|
||||
let skillRegistry = AgentToolCatalog.registry(
|
||||
builtIn: .init(
|
||||
@@ -508,6 +512,7 @@ public final class AssistantExecutionController {
|
||||
"planning": "\(isPlanning)", "stage": "running", "tool_count": "\(registry.registeredTools.count)",
|
||||
])
|
||||
scheduleTitle(for: conversation, isPlanning: isPlanning, operationID: operationID)
|
||||
return .runtimeStarted
|
||||
}
|
||||
|
||||
private func canSubmit(to conversation: ConversationModel) -> Bool {
|
||||
@@ -574,6 +579,7 @@ public final class AssistantExecutionController {
|
||||
}
|
||||
if supersedePreparation {
|
||||
if let current = preparations[conversation.id] {
|
||||
guard current.phase != .savingFailure else { return nil }
|
||||
_ = requestCancellation(
|
||||
for: conversation, expectedID: current.id,
|
||||
recordsInterruption: false)
|
||||
@@ -597,8 +603,12 @@ public final class AssistantExecutionController {
|
||||
guard isCurrent(capture, for: conversation) else { return }
|
||||
let needsAsync = (compactBeforeRun && capture.shouldCompact) || capture.memorySessionID != nil
|
||||
guard needsAsync else {
|
||||
performStartAgentRun(conversation, capture: capture)
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
switch performStartAgentRun(conversation, capture: capture) {
|
||||
case .runtimeStarted, .abandoned:
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
case .failurePersistenceStarted:
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
let task = Task { [weak self, weak conversation] in
|
||||
@@ -620,8 +630,12 @@ public final class AssistantExecutionController {
|
||||
await finishCancelledPreparation(capture, for: conversation)
|
||||
return
|
||||
}
|
||||
performStartAgentRun(conversation, capture: capture, memorySession: memorySession)
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
switch performStartAgentRun(conversation, capture: capture, memorySession: memorySession) {
|
||||
case .runtimeStarted, .abandoned:
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
case .failurePersistenceStarted:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard var state = preparations[conversation.id], state.id == capture.id else {
|
||||
task.cancel()
|
||||
@@ -690,6 +704,9 @@ public final class AssistantExecutionController {
|
||||
guard var state = preparations[conversation.id],
|
||||
expectedID == nil || state.id == expectedID
|
||||
else { return Task {} }
|
||||
if state.phase == .savingFailure {
|
||||
return state.task ?? Task {}
|
||||
}
|
||||
state.recordsInterruption = state.recordsInterruption || recordsInterruption
|
||||
state.cancellationRequested = true
|
||||
state.task?.cancel()
|
||||
@@ -764,35 +781,43 @@ public final class AssistantExecutionController {
|
||||
retirePreparation(preparationID, for: conversation)
|
||||
}
|
||||
|
||||
private func recordFailedRun(
|
||||
_ conversation: ConversationModel,
|
||||
promptID: UUID,
|
||||
prompt: String,
|
||||
authoredPrompt: String?,
|
||||
images: [AgentImageAttachment],
|
||||
contextAttachments: [AgentContextAttachment],
|
||||
permissionMode: AgentPermissionMode,
|
||||
reason: String
|
||||
private func beginFailedRunPersistence(
|
||||
_ conversation: ConversationModel, capture: RunCapture, reason: String
|
||||
) {
|
||||
guard var state = preparations[conversation.id], state.id == capture.id else { return }
|
||||
var messages = conversation.pendingPriorMessages
|
||||
if !messages.contains(where: { $0.id == promptID }) {
|
||||
if !messages.contains(where: { $0.id == capture.prompt.id }) {
|
||||
messages.append(
|
||||
AgentTranscriptMessage(
|
||||
id: promptID, role: .user, text: prompt,
|
||||
authoredText: authoredPrompt, images: images,
|
||||
contextAttachments: contextAttachments,
|
||||
contextSnapshot: conversation.transcript.first(where: { $0.id == promptID })?.contextSnapshot
|
||||
id: capture.prompt.id, role: .user, text: capture.prompt.text,
|
||||
authoredText: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
contextSnapshot: capture.contextSnapshot
|
||||
))
|
||||
}
|
||||
let now = Date.now
|
||||
let snapshot = AgentRunSnapshot(
|
||||
conversationID: conversation.id, state: .failed,
|
||||
permissionMode: permissionMode,
|
||||
permissionMode: capture.permissionMode,
|
||||
messages: messages, startedAt: now, finishedAt: now,
|
||||
failure: reason
|
||||
)
|
||||
conversation.recordFailedRun(snapshot)
|
||||
Task { [agentRunRepository] in try? await agentRunRepository.save(snapshot) }
|
||||
conversation.beginAgentPreparationFailurePersistence(id: capture.id)
|
||||
state.phase = .savingFailure
|
||||
let preparationID = capture.id
|
||||
let task = Task { @MainActor [self, conversation, agentRunRepository] in
|
||||
do {
|
||||
try await agentRunRepository.save(snapshot)
|
||||
guard preparations[conversation.id]?.id == preparationID else { return }
|
||||
conversation.recordFailedRun(snapshot)
|
||||
} catch {
|
||||
guard preparations[conversation.id]?.id == preparationID else { return }
|
||||
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
|
||||
}
|
||||
retirePreparation(preparationID, for: conversation)
|
||||
}
|
||||
state.task = task
|
||||
preparations[conversation.id] = state
|
||||
}
|
||||
|
||||
public func contextSnapshot(
|
||||
|
||||
@@ -884,6 +884,11 @@ public final class ConversationModel: Identifiable {
|
||||
/// there is no runtime to accept steering until preparation completes.
|
||||
public private(set) var isPreparingAgentRun = false
|
||||
|
||||
/// True only after a captured configuration failure has committed to its
|
||||
/// terminal snapshot save. Ordinary prepared edits remain supersedable,
|
||||
/// while history mutations must not overtake this durable boundary.
|
||||
public private(set) var isPersistingAgentPreparationFailure = false
|
||||
|
||||
public var isAgentRunActive: Bool { isStreaming || isPreparingAgentRun }
|
||||
|
||||
/// The current task list, whole. Replaced rather than patched — see
|
||||
@@ -1081,12 +1086,16 @@ public final class ConversationModel: Identifiable {
|
||||
}
|
||||
|
||||
public func canEditUserMessage(_ id: UUID) -> Bool {
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false }
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging,
|
||||
!isPersistingAgentPreparationFailure
|
||||
else { return false }
|
||||
return transcript.contains { $0.id == id && $0.role == .user && !$0.isCompaction }
|
||||
}
|
||||
|
||||
public func canForkAssistantMessage(_ id: UUID) -> Bool {
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false }
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging,
|
||||
!isPersistingAgentPreparationFailure
|
||||
else { return false }
|
||||
return transcript.contains { $0.id == id && $0.role == .assistant }
|
||||
}
|
||||
|
||||
@@ -2032,6 +2041,12 @@ public final class ConversationModel: Identifiable {
|
||||
agentPreparationID = nil
|
||||
cancelAgentPreparation = nil
|
||||
isPreparingAgentRun = false
|
||||
isPersistingAgentPreparationFailure = false
|
||||
}
|
||||
|
||||
func beginAgentPreparationFailurePersistence(id: UUID) {
|
||||
guard agentPreparationID == id else { return }
|
||||
isPersistingAgentPreparationFailure = true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
||||
Reference in new issue
Block a user