From d374bae0708595e81c27a0f7129b5ab378e454c5 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Fri, 10 Jul 2026 11:29:17 -0700 Subject: [PATCH] feat(agent-builder): log the silent generation/join pipeline failures Diagnosing a prod "agent never joins" report was nearly blind: every terminal failure in the AgentTemplateRepository generation stage (markFailed), the submit/poll retry loops, and the agents/join HTTP outcomes left no trace in device logs. This adds logging across the build -> generate -> provision -> direct-add pipeline so the next repro shows exactly where it died: - AgentTemplateRepository: generation start, status transitions, submit/poll retry errors, backend-reported generation failures, invite start/success, and every markFailed with its message - warn loudly if the raw join fallback (no direct-add) runs outside tests, since it provisions an agent that never joins - ConvosAPIClient: log non-2xx bodies from the generation endpoints and agents/join; log join success with instanceId/inboxId presence - SessionManager: log the provision -> addMembers handoff - behavior fix: 401/403 from generation endpoints are now terminal with the server's message (e.g. "Account required") instead of retrying through the .server path and surfacing a generic "Couldn't reach the builder" Co-Authored-By: Claude Fable 5 --- .../ConvosCore/API/ConvosAPIClient.swift | 16 ++++++- .../AgentTemplateRepository.swift | 20 ++++++++- .../ConvosCore/Sessions/SessionManager.swift | 1 + .../AgentTemplateRepositoryTests.swift | 43 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift b/ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift index 453f22a11..81325db47 100644 --- a/ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift +++ b/ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift @@ -903,10 +903,15 @@ final class ConvosAPIClient: ConvosAPIClientProtocol, Sendable { let (data, httpResponse) = try await performAuthenticatedRequest(request) + if !(200...299).contains(httpResponse.statusCode) { + Log.error("agents/join failed [\(httpResponse.statusCode)]: \(String(data: data, encoding: .utf8) ?? "nil data")") + } switch httpResponse.statusCode { case 200...299: let decoder = JSONDecoder() - return try decoder.decode(ConvosAPI.AgentJoinResponse.self, from: data) + let response = try decoder.decode(ConvosAPI.AgentJoinResponse.self, from: data) + Log.info("agents/join succeeded: instanceId=\(response.instanceId ?? "nil") joined=\(response.joined) inboxIdPresent=\(response.inboxId != nil)") + return response case 502: throw APIError.agentProvisionFailed case 503: @@ -1266,11 +1271,20 @@ extension ConvosAPIClient { data: Data, httpResponse: HTTPURLResponse ) throws -> ConvosAPI.AgentTemplateGenerationResponse { + if !(200...299).contains(httpResponse.statusCode) { + let path: String = httpResponse.url?.path(percentEncoded: false) ?? "agent-template generation" + Log.error("\(path) failed [\(httpResponse.statusCode)]: \(String(data: data, encoding: .utf8) ?? "nil data")") + } switch httpResponse.statusCode { case 200...299: return try JSONDecoder().decode(ConvosAPI.AgentTemplateGenerationResponse.self, from: data) case 400: throw AgentGenerationError.badRequest(parseErrorMessage(from: data)) + case 401, 403: + // Auth refresh already ran inside performAuthenticatedRequest, so a + // surfaced 401/403 won't heal on a plain retry - treat as terminal + // rather than looping through the retryable `.server` path. + throw AgentGenerationError.badRequest(parseErrorMessage(from: data) ?? "Not authorized") case 404: throw AgentGenerationError.notFound case 409: diff --git a/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentTemplateRepository.swift b/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentTemplateRepository.swift index bc140b513..b85e7a9cd 100644 --- a/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentTemplateRepository.swift +++ b/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentTemplateRepository.swift @@ -179,6 +179,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { createdAt: now, updatedAt: now ) + Log.info("AgentTemplateRepository: starting generation \(idempotencyKey) for conversation \(conversationId)") Task { [weak self] in guard let self else { return } do { @@ -308,7 +309,8 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { return await applyResponse(response, to: row.idempotencyKey) } catch let error as AgentGenerationError { switch error { - case .server: + case .server(let message): + Log.error("AgentTemplateRepository: submit server error (attempt \(attempt + 1)/\(Constant.maxSubmitAttempts)): \(message ?? "no message")") attempt += 1 await backoff(attempt: attempt) continue @@ -325,6 +327,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { return await markFailed(idempotencyKey: row.idempotencyKey, message: "Not found") } } catch { + Log.error("AgentTemplateRepository: submit failed (attempt \(attempt + 1)/\(Constant.maxSubmitAttempts)): \(error.localizedDescription)") attempt += 1 await backoff(attempt: attempt) continue @@ -345,14 +348,19 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { let response = try await apiClient.getAgentTemplateGeneration(generationId: generationId) let updated = await applyResponse(response, to: row.idempotencyKey) if let updated, updated.statusValue == .done || updated.statusValue == .failed { + if updated.statusValue == .failed { + Log.error("AgentTemplateRepository: generation \(generationId) reported failed by backend: \(updated.errorMessage ?? "no error message")") + } return updated } } catch let error as AgentGenerationError { if case .notFound = error { return await markFailed(idempotencyKey: row.idempotencyKey, message: "Build expired") } + Log.warning("AgentTemplateRepository: poll error for generation \(generationId) (attempt \(attempt + 1)/\(Constant.maxPollAttempts)): \(error)") } catch { // network blip - keep polling + Log.warning("AgentTemplateRepository: poll transient error for generation \(generationId) (attempt \(attempt + 1)/\(Constant.maxPollAttempts)): \(error.localizedDescription)") } attempt += 1 await backoff(attempt: min(attempt, Constant.pollBackoffCap)) @@ -368,6 +376,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { _ = await markFailed(idempotencyKey: row.idempotencyKey, message: "Missing template id") return } + Log.info("AgentTemplateRepository: inviting template \(templateId) into conversation \(row.conversationId)") var attempt: Int = 0 while attempt < Constant.maxInviteAttempts { do { @@ -380,6 +389,9 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { if let handler { try await handler(row.conversationId, templateId, row.variantId) } else { + // Reaching this outside tests means the agent gets provisioned + // but never direct-added, so it silently never joins. + Log.warning("AgentTemplateRepository: join handler unset; raw join without direct-add for template \(templateId)") let options = row.variantId.map { ConvosAPI.AgentJoinOptions(onboarding: nil, variantId: $0) } _ = try await apiClient.requestAgentJoin( slug: nil, @@ -390,6 +402,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { forceErrorCode: nil ) } + Log.info("AgentTemplateRepository: invite succeeded for template \(templateId) in conversation \(row.conversationId)") Self.cleanupAttachments(idempotencyKey: row.idempotencyKey) _ = await updateRow(idempotencyKey: row.idempotencyKey) { $0.status = DBAgentTemplateGeneration.Status.invited.rawValue } return @@ -423,6 +436,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { to idempotencyKey: String ) async -> DBAgentTemplateGeneration? { await updateRow(idempotencyKey: idempotencyKey) { row in + let previousStatus = row.status row.generationId = response.generationId row.templateId = response.templateId ?? row.templateId switch response.status { @@ -447,6 +461,9 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { if let phrases = response.progressPhrases, !phrases.isEmpty { row.progressPhrases = Self.encodePhrases(phrases) } + if row.status != previousStatus { + Log.info("AgentTemplateRepository: generation \(response.generationId) status \(previousStatus) -> \(row.status)") + } } } @@ -572,6 +589,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol { } private func markFailed(idempotencyKey: String, message: String) async -> DBAgentTemplateGeneration? { + Log.error("AgentTemplateRepository: generation \(idempotencyKey) marked failed: \(message)") Self.cleanupAttachments(idempotencyKey: idempotencyKey) return await updateRow(idempotencyKey: idempotencyKey) { row in row.status = DBAgentTemplateGeneration.Status.failed.rawValue diff --git a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift index c6a67520d..c2f130fbe 100644 --- a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift +++ b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift @@ -1329,6 +1329,7 @@ extension SessionManager { ) let instanceId = resolved.instanceId let agentInboxId = resolved.inboxId + Log.info("Direct-add: provisioned instance \(instanceId) with inbox \(agentInboxId); adding to conversation \(conversationId)") // 2. Add: a plain member add by this device — synchronous, no DM // round-trip, no online-accepter dependency. The add durably diff --git a/ConvosCore/Tests/ConvosCoreTests/AgentTemplateRepositoryTests.swift b/ConvosCore/Tests/ConvosCoreTests/AgentTemplateRepositoryTests.swift index e0708633f..14d1af915 100644 --- a/ConvosCore/Tests/ConvosCoreTests/AgentTemplateRepositoryTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/AgentTemplateRepositoryTests.swift @@ -113,6 +113,49 @@ struct AgentTemplateRepositoryTests { #expect(api.generationCalls == 0) #expect(api.joinCalls == 0) } + + @Test("Generation 401/403 map to terminal badRequest, 5xx stays retryable server") + func generationAuthFailuresAreTerminal() throws { + // A surfaced 401/403 already exhausted the auth refresh inside + // performAuthenticatedRequest, so a plain retry can't heal it. Mapping + // it to the retryable `.server` case made the builder retry silently + // and then fail with a generic "Couldn't reach the builder". + let api = try #require(ConvosAPIClientFactory.client( + environment: .local(config: ConvosConfiguration( + apiBaseURL: "https://api.example.com", + appGroupIdentifier: "group.test", + relyingPartyIdentifier: "example.com", + siweConfiguration: SIWEConfiguration(domain: "example.com", uri: "https://example.com", chainId: 1) + )) + ) as? ConvosAPIClient) + func decode(status: Int, body: String = #"{"error":"Account required"}"#) throws -> ConvosAPI.AgentTemplateGenerationResponse { + let url = try #require(URL(string: "https://api.example.com/api/v2/agent-templates/generations")) + let response = try #require(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)) + return try api.decodeGenerationResponse(data: Data(body.utf8), httpResponse: response) + } + + for status in [401, 403] { + #expect(throws: AgentGenerationError.self) { try decode(status: status) } + do { + _ = try decode(status: status) + } catch let error as AgentGenerationError { + guard case .badRequest(let message) = error else { + Issue.record("Expected .badRequest for \(status), got \(error)") + return + } + #expect(message == "Account required") + } + } + + do { + _ = try decode(status: 500) + } catch let error as AgentGenerationError { + guard case .server = error else { + Issue.record("Expected .server for 500, got \(error)") + return + } + } + } } /// Submit returns pending, the first poll returns done, and the join records