diff --git a/package-lock.json b/package-lock.json index 21d6e0f..d2000f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.12", - "@letta-ai/letta-code-sdk": "^0.1.9", + "@letta-ai/letta-code-sdk": "^0.1.11", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -1350,15 +1350,17 @@ "license": "Apache-2.0" }, "node_modules/@letta-ai/letta-code": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.17.1.tgz", - "integrity": "sha512-iLOWfh1ccmkdrx8j4y/Aop4H5D5PAfjxNVGM28TukcS0FZNPbnmDFGA0tcNudi6wslH6BT5X53/gkAIabuIujg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.18.2.tgz", + "integrity": "sha512-HzNqMjBUiAq5IyZ8DSSWBHq/ahkd4RRYfO/V9eXMBZRTRpLb7Dae2hwvicE+aRSLmJqMdxpH6WI7+ZHKlFsILQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@letta-ai/letta-client": "^1.7.11", + "@letta-ai/letta-client": "^1.7.12", "glob": "^13.0.0", + "highlight.js": "^11.11.1", "ink-link": "^5.0.0", + "lowlight": "^3.3.0", "open": "^10.2.0", "sharp": "^0.34.5", "ws": "^8.19.0" @@ -1374,12 +1376,12 @@ } }, "node_modules/@letta-ai/letta-code-sdk": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.10.tgz", - "integrity": "sha512-idNRvPI6RbBho0jzm46NbMM4xjRPXLTvOniKbvimnlHDRkx6acsZy1exeu56Xmkpx83orvdcjqsuccBqnZFxNA==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.11.tgz", + "integrity": "sha512-P1ueLWQuCnERizrvU3fZ9/rrMAJSIT+2j2/xxptqxMOKUuUrDmvAix1/eyDXqAwZkBVGImyqLGm4zqwNVNA7Dg==", "license": "Apache-2.0", "dependencies": { - "@letta-ai/letta-code": "0.17.1" + "@letta-ai/letta-code": "0.18.2" } }, "node_modules/@letta-ai/letta-code/node_modules/balanced-match": { @@ -2311,6 +2313,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2412,8 +2423,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/update-notifier": { "version": "6.0.8", @@ -2624,9 +2634,9 @@ } }, "node_modules/@vscode/ripgrep": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", - "integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.1.tgz", + "integrity": "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3597,7 +3607,6 @@ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -3616,7 +3625,6 @@ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", - "optional": true, "dependencies": { "dequal": "^2.0.0" }, @@ -4588,6 +4596,15 @@ "dev": true, "license": "MIT" }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hookified": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", @@ -5402,6 +5419,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", diff --git a/package.json b/package.json index ce63683..bd12d4b 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.12", - "@letta-ai/letta-code-sdk": "^0.1.9", + "@letta-ai/letta-code-sdk": "^0.1.11", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", diff --git a/src/core/bot.ts b/src/core/bot.ts index 88947a7..c23b2f9 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -1557,33 +1557,33 @@ export class LettaBot implements AgentSession { // Approval conflict recovery if (retryDecision.isApprovalConflict && !retried && this.store.agentId) { - if (retryConvId) { - log.info('Approval conflict detected -- attempting targeted recovery...'); - this.sessionManager.invalidateSession(retryConvKey); - session = null; - clearInterval(typingInterval); - const convResult = await recoverOrphanedConversationApproval( - this.store.agentId, retryConvId, true, - ); - if (convResult.recovered) { - log.info(`Approval recovery succeeded (${convResult.details}), retrying message...`); + log.info('Approval conflict detected -- attempting SDK recovery...'); + clearInterval(typingInterval); + + // Try SDK-level recovery first (through CLI control protocol) + if (session) { + const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + if (sdkResult.recovered) { + log.info('SDK approval recovery succeeded, retrying message...'); + this.sessionManager.invalidateSession(retryConvKey); + session = null; return this.processMessage(msg, adapter, true); } - log.warn(`Approval recovery failed: ${convResult.details}`); - return this.processMessage(msg, adapter, true); - } else { - log.info('Approval conflict in default conversation -- attempting agent-level recovery...'); - this.sessionManager.invalidateSession(retryConvKey); - session = null; - clearInterval(typingInterval); - const agentResult = await recoverPendingApprovalsForAgent(this.store.agentId); - if (agentResult.recovered) { - log.info(`Agent-level recovery succeeded (${agentResult.details}), retrying message...`); - return this.processMessage(msg, adapter, true); - } - log.warn(`Agent-level recovery failed: ${agentResult.details}`); - return this.processMessage(msg, adapter, true); + log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`); } + + // Fall back to API-level recovery + this.sessionManager.invalidateSession(retryConvKey); + session = null; + const result = (retryConvId && isRecoverableConversationId(retryConvId)) + ? await recoverOrphanedConversationApproval(this.store.agentId, retryConvId, true) + : await recoverPendingApprovalsForAgent(this.store.agentId); + if (result.recovered) { + log.info(`API-level recovery succeeded (${result.details}), retrying message...`); + } else { + log.warn(`API-level recovery failed: ${result.details}`); + } + return this.processMessage(msg, adapter, true); } // Empty/error result retry @@ -1830,7 +1830,7 @@ export class LettaBot implements AgentSession { let retried = false; while (true) { - const { stream } = await this.sessionManager.runSession(text, { convKey, retried }); + const { session, stream } = await this.sessionManager.runSession(text, { convKey, retried }); try { let response = ''; @@ -1885,15 +1885,21 @@ export class LettaBot implements AgentSession { || ((lastErrorDetail?.message?.toLowerCase().includes('conflict') || false) && (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false)); if (isApprovalIssue && !retried) { - if (this.store.agentId) { - const recovery = await recoverPendingApprovalsForAgent(this.store.agentId); - if (recovery.recovered) { - log.info(`sendToAgent: agent-level approval recovery succeeded (${recovery.details})`); - } else { - log.warn(`sendToAgent: agent-level approval recovery did not resolve approvals (${recovery.details})`); + log.info('sendToAgent: approval conflict detected -- attempting SDK recovery...'); + const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + if (sdkResult.recovered) { + log.info('sendToAgent: SDK approval recovery succeeded'); + } else { + log.warn(`sendToAgent: SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`); + if (this.store.agentId) { + const recovery = await recoverPendingApprovalsForAgent(this.store.agentId); + if (recovery.recovered) { + log.info(`sendToAgent: API-level recovery succeeded (${recovery.details})`); + } else { + log.warn(`sendToAgent: API-level recovery failed (${recovery.details})`); + } } } - log.info('sendToAgent: approval issue detected -- retrying once with fresh session...'); this.sessionManager.invalidateSession(convKey); retried = true; approvalRetryPending = true; diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index 01867a9..118fa01 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -110,6 +110,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -147,6 +148,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -176,6 +178,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -211,6 +214,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -241,6 +245,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-stale', }; @@ -255,6 +260,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-recreated', conversationId: 'conv-recreated', }; @@ -293,6 +299,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-keep', }; @@ -331,6 +338,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test-1', }; @@ -346,6 +354,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test-2', }; @@ -378,6 +387,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-old', }; @@ -392,6 +402,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-new', }; @@ -453,6 +464,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'default', }; @@ -468,6 +480,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'default', }; @@ -506,6 +519,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-stuck', }; @@ -521,6 +535,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-fresh', }; @@ -567,6 +582,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -596,6 +612,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -625,6 +642,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -654,6 +672,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -692,6 +711,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -754,6 +774,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: `${sessionName}-conversation`, } as never; @@ -872,6 +893,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-new', }; @@ -879,9 +901,11 @@ describe('SDK session contract', () => { const activeSession = { close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), }; const idleSession = { close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), }; const bot = new LettaBot({ @@ -920,6 +944,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -946,6 +971,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-123', }; @@ -978,6 +1004,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conversation-contract-test', }; @@ -1012,6 +1039,7 @@ describe('SDK session contract', () => { })(); }), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-approval', }; @@ -1042,7 +1070,7 @@ describe('SDK session contract', () => { let runCall = 0; (bot as any).sessionManager.runSession = vi.fn(async () => ({ - session: { abort: vi.fn(async () => undefined) }, + session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) }, stream: async function* () { if (runCall++ === 0) { yield { type: 'result', success: false, error: 'error', conversationId: 'conv-approval' }; @@ -1108,7 +1136,7 @@ describe('SDK session contract', () => { let runCall = 0; (bot as any).sessionManager.runSession = vi.fn(async () => ({ - session: { abort: vi.fn(async () => undefined) }, + session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) }, stream: async function* () { if (runCall++ === 0) { // Pre-foreground error is filtered by the pipeline -- it never @@ -1183,7 +1211,7 @@ describe('SDK session contract', () => { let runCall = 0; (bot as any).sessionManager.runSession = vi.fn(async () => ({ - session: { abort: vi.fn(async () => undefined) }, + session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) }, stream: async function* () { if (runCall++ === 0) { yield { type: 'result', success: false, error: 'error', conversationId: 'default' }; @@ -1255,6 +1283,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-stuck', }; @@ -1270,6 +1299,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-contract-test', conversationId: 'conv-fresh', }; @@ -1316,6 +1346,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-new-tagged', conversationId: 'conversation-new-tagged', }; @@ -1371,6 +1402,7 @@ describe('SDK session contract', () => { })(); }), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-runid-test', conversationId: 'conversation-runid-test', }; @@ -1409,6 +1441,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-reuse-false', conversationId: 'conversation-reuse-false', }; @@ -1442,6 +1475,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-background-directives', conversationId: 'conversation-background-directives', }; @@ -1528,6 +1562,7 @@ describe('SDK session contract', () => { })() ), close: vi.fn(() => undefined), + recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })), agentId: 'agent-queue-leak-test', conversationId: 'conversation-queue-leak-test', }; diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 70a3792..cd39b22 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -373,34 +373,27 @@ export class SessionManager { ); if (bootstrap.hasPendingApproval) { const convId = bootstrap.conversationId || session.conversationId; - if (!isRecoverableConversationId(convId)) { - log.warn( - `Pending approval detected at session startup (key=${key}, conv=${convId}) ` + - 'using agent-level recovery fallback.' - ); - session.close(); - const result = await recoverPendingApprovalsForAgent(this.store.agentId); - if (result.recovered) { - log.info(`Proactive agent-level recovery succeeded: ${result.details}`); - } else { - log.warn(`Proactive agent-level recovery did not resolve approvals: ${result.details}`); - } - return this._createSessionForKey(key, true, generation); - } else { - log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`); - session.close(); - const result = await recoverOrphanedConversationApproval( - this.store.agentId, - convId, - true, /* deepScan */ - ); - if (result.recovered) { - log.info(`Proactive approval recovery succeeded: ${result.details}`); - } else { - log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`); - } + log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`); + + // Try SDK-level recovery first (goes through CLI control protocol) + const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + if (sdkResult.recovered) { + log.info('Proactive SDK approval recovery succeeded'); return this._createSessionForKey(key, true, generation); } + + // SDK recovery failed -- fall back to API-level recovery + log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`); + session.close(); + const result = isRecoverableConversationId(convId) + ? await recoverOrphanedConversationApproval(this.store.agentId, convId, true) + : await recoverPendingApprovalsForAgent(this.store.agentId); + if (result.recovered) { + log.info(`Proactive API-level recovery succeeded: ${result.details}`); + } else { + log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`); + } + return this._createSessionForKey(key, true, generation); } } catch (err) { // bootstrapState failure is non-fatal -- the reactive 409 handler in @@ -578,18 +571,25 @@ export class SessionManager { try { await this.withSessionTimeout(session.send(message), `Session send (key=${convKey})`); } catch (error) { - // 409 CONFLICT from orphaned approval + // 409 CONFLICT from orphaned approval -- use SDK recovery first, fall back to API if (!retried && isApprovalConflictError(error) && this.store.agentId) { - log.info('CONFLICT detected - attempting orphaned approval recovery...'); + log.info('CONFLICT detected - attempting SDK approval recovery...'); + const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + if (sdkResult.recovered) { + log.info('SDK approval recovery succeeded, retrying...'); + return this.runSession(message, { retried: true, canUseTool, convKey }); + } + // SDK recovery failed or unsupported -- fall back to API-level recovery + log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`); this.invalidateSession(convKey); const result = isRecoverableConversationId(convId) ? await recoverOrphanedConversationApproval(this.store.agentId, convId) : await recoverPendingApprovalsForAgent(this.store.agentId); if (result.recovered) { - log.info(`Recovery succeeded (${result.details}), retrying...`); + log.info(`API-level recovery succeeded (${result.details}), retrying...`); return this.runSession(message, { retried: true, canUseTool, convKey }); } - log.error(`Orphaned approval recovery failed: ${result.details}`); + log.error(`Approval recovery failed: ${result.details}`); throw error; }