mcp-subagents-opencode 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +602 -0
- package/build/config/timeouts.d.ts +9 -0
- package/build/config/timeouts.d.ts.map +1 -0
- package/build/config/timeouts.js +18 -0
- package/build/config/timeouts.js.map +1 -0
- package/build/helpers.d.ts +6 -0
- package/build/helpers.d.ts.map +1 -0
- package/build/helpers.js +47 -0
- package/build/helpers.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +245 -0
- package/build/index.js.map +1 -0
- package/build/models.d.ts +32 -0
- package/build/models.d.ts.map +1 -0
- package/build/models.js +58 -0
- package/build/models.js.map +1 -0
- package/build/server/register-notifications.d.ts +3 -0
- package/build/server/register-notifications.d.ts.map +1 -0
- package/build/server/register-notifications.js +77 -0
- package/build/server/register-notifications.js.map +1 -0
- package/build/server/register-resources.d.ts +3 -0
- package/build/server/register-resources.d.ts.map +1 -0
- package/build/server/register-resources.js +210 -0
- package/build/server/register-resources.js.map +1 -0
- package/build/server/register-retry-execution.d.ts +2 -0
- package/build/server/register-retry-execution.d.ts.map +1 -0
- package/build/server/register-retry-execution.js +28 -0
- package/build/server/register-retry-execution.js.map +1 -0
- package/build/server/register-tasks.d.ts +3 -0
- package/build/server/register-tasks.d.ts.map +1 -0
- package/build/server/register-tasks.js +52 -0
- package/build/server/register-tasks.js.map +1 -0
- package/build/server/register-tools.d.ts +3 -0
- package/build/server/register-tools.d.ts.map +1 -0
- package/build/server/register-tools.js +32 -0
- package/build/server/register-tools.js.map +1 -0
- package/build/server/resource-helpers.d.ts +21 -0
- package/build/server/resource-helpers.d.ts.map +1 -0
- package/build/server/resource-helpers.js +84 -0
- package/build/server/resource-helpers.js.map +1 -0
- package/build/services/account-manager.d.ts +88 -0
- package/build/services/account-manager.d.ts.map +1 -0
- package/build/services/account-manager.js +239 -0
- package/build/services/account-manager.js.map +1 -0
- package/build/services/claude-code-runner.d.ts +15 -0
- package/build/services/claude-code-runner.d.ts.map +1 -0
- package/build/services/claude-code-runner.js +475 -0
- package/build/services/claude-code-runner.js.map +1 -0
- package/build/services/client-context.d.ts +31 -0
- package/build/services/client-context.d.ts.map +1 -0
- package/build/services/client-context.js +44 -0
- package/build/services/client-context.js.map +1 -0
- package/build/services/exhaustion-fallback.d.ts +27 -0
- package/build/services/exhaustion-fallback.d.ts.map +1 -0
- package/build/services/exhaustion-fallback.js +30 -0
- package/build/services/exhaustion-fallback.js.map +1 -0
- package/build/services/fallback-orchestrator.d.ts +16 -0
- package/build/services/fallback-orchestrator.d.ts.map +1 -0
- package/build/services/fallback-orchestrator.js +48 -0
- package/build/services/fallback-orchestrator.js.map +1 -0
- package/build/services/opencode-client.d.ts +40 -0
- package/build/services/opencode-client.d.ts.map +1 -0
- package/build/services/opencode-client.js +147 -0
- package/build/services/opencode-client.js.map +1 -0
- package/build/services/opencode-spawner.d.ts +56 -0
- package/build/services/opencode-spawner.d.ts.map +1 -0
- package/build/services/opencode-spawner.js +426 -0
- package/build/services/opencode-spawner.js.map +1 -0
- package/build/services/output-file.d.ts +24 -0
- package/build/services/output-file.d.ts.map +1 -0
- package/build/services/output-file.js +90 -0
- package/build/services/output-file.js.map +1 -0
- package/build/services/progress-registry.d.ts +12 -0
- package/build/services/progress-registry.d.ts.map +1 -0
- package/build/services/progress-registry.js +97 -0
- package/build/services/progress-registry.js.map +1 -0
- package/build/services/question-registry.d.ts +79 -0
- package/build/services/question-registry.d.ts.map +1 -0
- package/build/services/question-registry.js +249 -0
- package/build/services/question-registry.js.map +1 -0
- package/build/services/retry-queue.d.ts +41 -0
- package/build/services/retry-queue.d.ts.map +1 -0
- package/build/services/retry-queue.js +195 -0
- package/build/services/retry-queue.js.map +1 -0
- package/build/services/sdk-client-manager.d.ts +149 -0
- package/build/services/sdk-client-manager.d.ts.map +1 -0
- package/build/services/sdk-client-manager.js +632 -0
- package/build/services/sdk-client-manager.js.map +1 -0
- package/build/services/sdk-session-adapter.d.ts +203 -0
- package/build/services/sdk-session-adapter.d.ts.map +1 -0
- package/build/services/sdk-session-adapter.js +1088 -0
- package/build/services/sdk-session-adapter.js.map +1 -0
- package/build/services/sdk-spawner.d.ts +42 -0
- package/build/services/sdk-spawner.d.ts.map +1 -0
- package/build/services/sdk-spawner.js +488 -0
- package/build/services/sdk-spawner.js.map +1 -0
- package/build/services/session-hooks.d.ts +24 -0
- package/build/services/session-hooks.d.ts.map +1 -0
- package/build/services/session-hooks.js +130 -0
- package/build/services/session-hooks.js.map +1 -0
- package/build/services/session-snapshot.d.ts +19 -0
- package/build/services/session-snapshot.d.ts.map +1 -0
- package/build/services/session-snapshot.js +203 -0
- package/build/services/session-snapshot.js.map +1 -0
- package/build/services/subscription-registry.d.ts +12 -0
- package/build/services/subscription-registry.d.ts.map +1 -0
- package/build/services/subscription-registry.js +27 -0
- package/build/services/subscription-registry.js.map +1 -0
- package/build/services/task-manager.d.ts +150 -0
- package/build/services/task-manager.d.ts.map +1 -0
- package/build/services/task-manager.js +765 -0
- package/build/services/task-manager.js.map +1 -0
- package/build/services/task-persistence.d.ts +29 -0
- package/build/services/task-persistence.d.ts.map +1 -0
- package/build/services/task-persistence.js +159 -0
- package/build/services/task-persistence.js.map +1 -0
- package/build/services/task-status-mapper.d.ts +21 -0
- package/build/services/task-status-mapper.d.ts.map +1 -0
- package/build/services/task-status-mapper.js +171 -0
- package/build/services/task-status-mapper.js.map +1 -0
- package/build/templates/index.d.ts +22 -0
- package/build/templates/index.d.ts.map +1 -0
- package/build/templates/index.js +147 -0
- package/build/templates/index.js.map +1 -0
- package/build/templates/overlays/coder-csharp.mdx +58 -0
- package/build/templates/overlays/coder-go.mdx +53 -0
- package/build/templates/overlays/coder-java.mdx +54 -0
- package/build/templates/overlays/coder-kotlin.mdx +56 -0
- package/build/templates/overlays/coder-nextjs.mdx +65 -0
- package/build/templates/overlays/coder-python.mdx +53 -0
- package/build/templates/overlays/coder-react.mdx +55 -0
- package/build/templates/overlays/coder-ruby.mdx +59 -0
- package/build/templates/overlays/coder-rust.mdx +48 -0
- package/build/templates/overlays/coder-supabase.mdx +268 -0
- package/build/templates/overlays/coder-supastarter.mdx +313 -0
- package/build/templates/overlays/coder-swift.mdx +56 -0
- package/build/templates/overlays/coder-tauri.mdx +566 -0
- package/build/templates/overlays/coder-triggerdev.mdx +296 -0
- package/build/templates/overlays/coder-typescript.mdx +45 -0
- package/build/templates/overlays/coder-vue.mdx +62 -0
- package/build/templates/overlays/planner-architecture.mdx +78 -0
- package/build/templates/overlays/planner-bugfix.mdx +36 -0
- package/build/templates/overlays/planner-feature.mdx +38 -0
- package/build/templates/overlays/planner-migration.mdx +50 -0
- package/build/templates/overlays/planner-refactor.mdx +57 -0
- package/build/templates/overlays/researcher-library.mdx +59 -0
- package/build/templates/overlays/researcher-performance.mdx +68 -0
- package/build/templates/overlays/researcher-security.mdx +86 -0
- package/build/templates/overlays/tester-graphql.mdx +191 -0
- package/build/templates/overlays/tester-playwright.mdx +621 -0
- package/build/templates/overlays/tester-rest.mdx +101 -0
- package/build/templates/overlays/tester-suite.mdx +177 -0
- package/build/templates/super-coder.mdx +529 -0
- package/build/templates/super-planner.mdx +568 -0
- package/build/templates/super-researcher.mdx +406 -0
- package/build/templates/super-tester.mdx +243 -0
- package/build/tools/answer-question.d.ts +30 -0
- package/build/tools/answer-question.d.ts.map +1 -0
- package/build/tools/answer-question.js +108 -0
- package/build/tools/answer-question.js.map +1 -0
- package/build/tools/cancel-task.d.ts +44 -0
- package/build/tools/cancel-task.d.ts.map +1 -0
- package/build/tools/cancel-task.js +144 -0
- package/build/tools/cancel-task.js.map +1 -0
- package/build/tools/send-message.d.ts +39 -0
- package/build/tools/send-message.d.ts.map +1 -0
- package/build/tools/send-message.js +124 -0
- package/build/tools/send-message.js.map +1 -0
- package/build/tools/shared-spawn.d.ts +56 -0
- package/build/tools/shared-spawn.d.ts.map +1 -0
- package/build/tools/shared-spawn.js +114 -0
- package/build/tools/shared-spawn.js.map +1 -0
- package/build/tools/spawn-agent.d.ts +85 -0
- package/build/tools/spawn-agent.d.ts.map +1 -0
- package/build/tools/spawn-agent.js +133 -0
- package/build/tools/spawn-agent.js.map +1 -0
- package/build/tools/spawn-coder.d.ts +70 -0
- package/build/tools/spawn-coder.d.ts.map +1 -0
- package/build/tools/spawn-coder.js +71 -0
- package/build/tools/spawn-coder.js.map +1 -0
- package/build/tools/spawn-planner.d.ts +70 -0
- package/build/tools/spawn-planner.d.ts.map +1 -0
- package/build/tools/spawn-planner.js +71 -0
- package/build/tools/spawn-planner.js.map +1 -0
- package/build/tools/spawn-researcher.d.ts +70 -0
- package/build/tools/spawn-researcher.d.ts.map +1 -0
- package/build/tools/spawn-researcher.js +70 -0
- package/build/tools/spawn-researcher.js.map +1 -0
- package/build/tools/spawn-task.d.ts +74 -0
- package/build/tools/spawn-task.d.ts.map +1 -0
- package/build/tools/spawn-task.js +107 -0
- package/build/tools/spawn-task.js.map +1 -0
- package/build/tools/spawn-tester.d.ts +70 -0
- package/build/tools/spawn-tester.d.ts.map +1 -0
- package/build/tools/spawn-tester.js +69 -0
- package/build/tools/spawn-tester.js.map +1 -0
- package/build/types.d.ts +101 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +28 -0
- package/build/types.js.map +1 -0
- package/build/utils/brief-validator.d.ts +30 -0
- package/build/utils/brief-validator.d.ts.map +1 -0
- package/build/utils/brief-validator.js +254 -0
- package/build/utils/brief-validator.js.map +1 -0
- package/build/utils/format.d.ts +34 -0
- package/build/utils/format.d.ts.map +1 -0
- package/build/utils/format.js +55 -0
- package/build/utils/format.js.map +1 -0
- package/build/utils/sanitize.d.ts +240 -0
- package/build/utils/sanitize.d.ts.map +1 -0
- package/build/utils/sanitize.js +89 -0
- package/build/utils/sanitize.js.map +1 -0
- package/build/utils/task-id-generator.d.ts +10 -0
- package/build/utils/task-id-generator.d.ts.map +1 -0
- package/build/utils/task-id-generator.js +22 -0
- package/build/utils/task-id-generator.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK Session Adapter - Bridges Copilot SDK sessions to the MCP server's TaskState model.
|
|
3
|
+
*
|
|
4
|
+
* This adapter:
|
|
5
|
+
* - Maps SDK session events to TaskState updates using SDK's native types
|
|
6
|
+
* - Handles mid-session rate limit detection via session.error events
|
|
7
|
+
* - Triggers account rotation and session resume on rate limits
|
|
8
|
+
* - Manages streaming output accumulation
|
|
9
|
+
* - Provides unified error handling with proper typing
|
|
10
|
+
* - Collects completion metrics from session.shutdown
|
|
11
|
+
* - Tracks quota info from assistant.usage
|
|
12
|
+
* - Monitors tool execution and subagent activity
|
|
13
|
+
*/
|
|
14
|
+
import { taskManager } from './task-manager.js';
|
|
15
|
+
import { sdkClientManager } from './sdk-client-manager.js';
|
|
16
|
+
import { TaskStatus, isTerminalStatus, ROTATABLE_STATUS_CODES, RATE_LIMIT_STATUS_CODE, } from '../types.js';
|
|
17
|
+
import { shouldFallbackToClaudeCode, isFallbackEnabled } from './exhaustion-fallback.js';
|
|
18
|
+
import { triggerClaudeFallback } from './fallback-orchestrator.js';
|
|
19
|
+
// String-based rate limit detection fallback
|
|
20
|
+
const RATE_LIMIT_STRING = "Sorry, you've hit a rate limit that restricts the number of Copilot model requests";
|
|
21
|
+
// Health check model for testing account availability
|
|
22
|
+
const HEALTH_CHECK_MODEL = 'claude-haiku-4.5';
|
|
23
|
+
class SDKSessionAdapter {
|
|
24
|
+
bindings = new Map();
|
|
25
|
+
rotationCallback;
|
|
26
|
+
/**
|
|
27
|
+
* Set the callback for rotation requests.
|
|
28
|
+
* This is called when mid-session rate limits are detected.
|
|
29
|
+
*/
|
|
30
|
+
onRotationRequest(callback) {
|
|
31
|
+
this.rotationCallback = callback;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Perform a health check on the current account by creating a test session.
|
|
35
|
+
* Uses claude-haiku-4.5 for fast/cheap verification.
|
|
36
|
+
* Returns true if the account can successfully respond.
|
|
37
|
+
*/
|
|
38
|
+
async performHealthCheck(cwd) {
|
|
39
|
+
const strictProbe = process.env.COPILOT_STRICT_HEALTH_CHECK === 'true';
|
|
40
|
+
const authStatus = await sdkClientManager.checkAuthStatus(cwd);
|
|
41
|
+
if (!authStatus.isAuthenticated) {
|
|
42
|
+
console.error(`[sdk-session-adapter] Health check failed: account is not authenticated`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (!strictProbe) {
|
|
46
|
+
console.error(`[sdk-session-adapter] Health check passed (auth status)`);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const healthCheckSessionId = `health-check-${Date.now()}`;
|
|
50
|
+
try {
|
|
51
|
+
console.error(`[sdk-session-adapter] Health check: strict probe with ${HEALTH_CHECK_MODEL}...`);
|
|
52
|
+
const testSession = await sdkClientManager.createSession(cwd, healthCheckSessionId, { model: HEALTH_CHECK_MODEL });
|
|
53
|
+
// Send a simple test message
|
|
54
|
+
await testSession.sendAndWait({ prompt: 'hi' });
|
|
55
|
+
console.error(`[sdk-session-adapter] Health check passed`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`[sdk-session-adapter] Health check failed:`, err);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
// Always clean up the health check session, whether it succeeded or failed
|
|
64
|
+
await sdkClientManager.destroySession(healthCheckSessionId).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Bind a SDK session to a task, setting up event handlers.
|
|
69
|
+
*/
|
|
70
|
+
bind(taskId, session, pendingPrompt) {
|
|
71
|
+
// Clean up any existing binding
|
|
72
|
+
this.unbind(taskId);
|
|
73
|
+
const startTime = new Date();
|
|
74
|
+
const binding = {
|
|
75
|
+
taskId,
|
|
76
|
+
session,
|
|
77
|
+
sessionId: session.sessionId,
|
|
78
|
+
unsubscribe: () => { },
|
|
79
|
+
outputBuffer: [],
|
|
80
|
+
reasoningBuffer: [],
|
|
81
|
+
startTime,
|
|
82
|
+
isCompleted: false,
|
|
83
|
+
isPaused: false,
|
|
84
|
+
rotationAttempts: 0,
|
|
85
|
+
maxRotationAttempts: 10,
|
|
86
|
+
rotationInProgress: false,
|
|
87
|
+
isUnbound: false,
|
|
88
|
+
pendingPrompt,
|
|
89
|
+
// Initialize metrics tracking
|
|
90
|
+
turnCount: 0,
|
|
91
|
+
totalTokens: { input: 0, output: 0 },
|
|
92
|
+
toolMetrics: new Map(),
|
|
93
|
+
toolStartTimes: new Map(),
|
|
94
|
+
toolCallIdToName: new Map(),
|
|
95
|
+
activeSubagents: new Map(),
|
|
96
|
+
completedSubagents: [],
|
|
97
|
+
quotas: new Map(),
|
|
98
|
+
};
|
|
99
|
+
// Subscribe to all session events using SDK's typed event system
|
|
100
|
+
const unsubscribe = session.on((event) => {
|
|
101
|
+
this.handleEvent(taskId, event, binding).catch((err) => {
|
|
102
|
+
console.error(`[sdk-session-adapter] Error handling event ${event.type} for task ${taskId}:`, err);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
binding.unsubscribe = unsubscribe;
|
|
106
|
+
this.bindings.set(taskId, binding);
|
|
107
|
+
// Initialize session metrics in task
|
|
108
|
+
taskManager.updateTask(taskId, {
|
|
109
|
+
status: TaskStatus.RUNNING,
|
|
110
|
+
sessionId: session.sessionId,
|
|
111
|
+
session,
|
|
112
|
+
sessionMetrics: {
|
|
113
|
+
quotas: {},
|
|
114
|
+
toolMetrics: {},
|
|
115
|
+
activeSubagents: [],
|
|
116
|
+
completedSubagents: [],
|
|
117
|
+
turnCount: 0,
|
|
118
|
+
totalTokens: { input: 0, output: 0 },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
console.error(`[sdk-session-adapter] Bound session ${session.sessionId} to task ${taskId}`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Handle SDK session events and map them to task updates.
|
|
125
|
+
* Uses type narrowing for type-safe event handling.
|
|
126
|
+
*/
|
|
127
|
+
async handleEvent(taskId, event, binding) {
|
|
128
|
+
// Guard: skip events if binding was already unbound (race from queued events)
|
|
129
|
+
if (binding.isUnbound) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const task = taskManager.getTask(taskId);
|
|
133
|
+
if (!task) {
|
|
134
|
+
console.error(`[sdk-session-adapter] Task ${taskId} not found for event ${event.type}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Skip events if session is paused (during rotation)
|
|
138
|
+
if (binding.isPaused && event.type !== 'session.error') {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
switch (event.type) {
|
|
142
|
+
case 'session.start':
|
|
143
|
+
this.handleSessionStart(taskId, event);
|
|
144
|
+
break;
|
|
145
|
+
case 'session.resume':
|
|
146
|
+
taskManager.appendOutput(taskId, `[session] Resumed at ${event.data.resumeTime}`);
|
|
147
|
+
binding.isPaused = false; // Clear pause state on resume
|
|
148
|
+
break;
|
|
149
|
+
case 'session.idle':
|
|
150
|
+
this.handleSessionIdle(taskId, event, binding);
|
|
151
|
+
break;
|
|
152
|
+
case 'session.error':
|
|
153
|
+
await this.handleSessionError(taskId, event, binding);
|
|
154
|
+
break;
|
|
155
|
+
case 'assistant.turn_start':
|
|
156
|
+
this.handleTurnStart(taskId, event, binding);
|
|
157
|
+
break;
|
|
158
|
+
case 'assistant.message_delta':
|
|
159
|
+
this.handleMessageDelta(taskId, event, binding);
|
|
160
|
+
break;
|
|
161
|
+
case 'assistant.message':
|
|
162
|
+
await this.handleAssistantMessage(taskId, event, binding);
|
|
163
|
+
break;
|
|
164
|
+
case 'assistant.reasoning': {
|
|
165
|
+
const reasoning = event.data.content || binding.reasoningBuffer.join('');
|
|
166
|
+
if (reasoning) {
|
|
167
|
+
// Write reasoning to file only — not to in-memory output (saves tokens for caller)
|
|
168
|
+
taskManager.appendOutputFileOnly(taskId, `[reasoning] ${reasoning}`);
|
|
169
|
+
}
|
|
170
|
+
binding.reasoningBuffer.length = 0;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'assistant.reasoning_delta':
|
|
174
|
+
binding.reasoningBuffer.push(event.data.deltaContent);
|
|
175
|
+
break;
|
|
176
|
+
case 'assistant.turn_end': {
|
|
177
|
+
if (binding.reasoningBuffer.length) {
|
|
178
|
+
// Reasoning → file only (verbose debug, not for caller tokens)
|
|
179
|
+
taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
|
|
180
|
+
binding.reasoningBuffer.length = 0;
|
|
181
|
+
}
|
|
182
|
+
if (binding.outputBuffer.length) {
|
|
183
|
+
taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
|
|
184
|
+
binding.outputBuffer.length = 0;
|
|
185
|
+
}
|
|
186
|
+
// Turn ended marker → file only (Turn started is sufficient for caller)
|
|
187
|
+
taskManager.appendOutputFileOnly(taskId, `[assistant] Turn ended: ${event.data.turnId}`);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'assistant.usage':
|
|
191
|
+
this.handleUsage(taskId, event, binding);
|
|
192
|
+
break;
|
|
193
|
+
case 'tool.execution_start':
|
|
194
|
+
this.handleToolStart(taskId, event, binding);
|
|
195
|
+
break;
|
|
196
|
+
case 'tool.execution_progress':
|
|
197
|
+
taskManager.appendOutput(taskId, `[tool] Progress: ${event.data.progressMessage}`);
|
|
198
|
+
break;
|
|
199
|
+
case 'tool.execution_complete':
|
|
200
|
+
this.handleToolComplete(taskId, event, binding);
|
|
201
|
+
break;
|
|
202
|
+
case 'subagent.started':
|
|
203
|
+
this.handleSubagentStarted(taskId, event, binding);
|
|
204
|
+
break;
|
|
205
|
+
case 'subagent.completed':
|
|
206
|
+
this.handleSubagentCompleted(taskId, event, binding);
|
|
207
|
+
break;
|
|
208
|
+
case 'subagent.failed':
|
|
209
|
+
this.handleSubagentFailed(taskId, event, binding);
|
|
210
|
+
break;
|
|
211
|
+
case 'session.compaction_start':
|
|
212
|
+
taskManager.appendOutput(taskId, `[session] Context compaction started`);
|
|
213
|
+
break;
|
|
214
|
+
case 'session.compaction_complete':
|
|
215
|
+
if (event.data.success) {
|
|
216
|
+
taskManager.appendOutput(taskId, `[session] Compaction complete: removed ${event.data.tokensRemoved} tokens`);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
taskManager.appendOutput(taskId, `[session] Compaction failed: ${event.data.error}`);
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case 'session.shutdown':
|
|
223
|
+
this.handleSessionShutdown(taskId, event, binding);
|
|
224
|
+
break;
|
|
225
|
+
case 'abort':
|
|
226
|
+
this.handleAbort(taskId, event, binding);
|
|
227
|
+
break;
|
|
228
|
+
case 'user.message':
|
|
229
|
+
// User message → file only (caller already knows what it sent)
|
|
230
|
+
taskManager.appendOutputFileOnly(taskId, `[user] ${event.data.content.length > 100 ? event.data.content.slice(0, 100) + '...' : event.data.content}`);
|
|
231
|
+
break;
|
|
232
|
+
default:
|
|
233
|
+
// Log other events in debug mode
|
|
234
|
+
if (process.env.DEBUG_SDK_EVENTS === 'true') {
|
|
235
|
+
console.error(`[sdk-session-adapter] Event: ${event.type}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Handle session.start event
|
|
241
|
+
*/
|
|
242
|
+
handleSessionStart(taskId, event) {
|
|
243
|
+
// Session setup details → file only (internal metadata, not useful to caller)
|
|
244
|
+
taskManager.appendOutputFileOnly(taskId, `[session] Started: ${event.data.sessionId}`);
|
|
245
|
+
if (event.data.selectedModel) {
|
|
246
|
+
taskManager.appendOutputFileOnly(taskId, `[session] Model: ${event.data.selectedModel}`);
|
|
247
|
+
}
|
|
248
|
+
if (event.data.context?.cwd) {
|
|
249
|
+
taskManager.appendOutputFileOnly(taskId, `[session] CWD: ${event.data.context.cwd}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Handle assistant.turn_start event - track turn count
|
|
254
|
+
*/
|
|
255
|
+
handleTurnStart(taskId, event, binding) {
|
|
256
|
+
binding.turnCount++;
|
|
257
|
+
taskManager.appendOutput(taskId, `--- Turn ${binding.turnCount} ---`);
|
|
258
|
+
// Update session metrics
|
|
259
|
+
this.updateSessionMetrics(taskId, binding);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Handle session.idle event - indicates completion
|
|
263
|
+
*/
|
|
264
|
+
handleSessionIdle(taskId, _event, binding) {
|
|
265
|
+
if (!binding.isCompleted) {
|
|
266
|
+
binding.isCompleted = true;
|
|
267
|
+
// Finalize session metrics
|
|
268
|
+
this.updateSessionMetrics(taskId, binding);
|
|
269
|
+
// Emit compact summary line (replaces verbose per-turn [usage]/[quota])
|
|
270
|
+
const totalTokens = binding.totalTokens.input + binding.totalTokens.output;
|
|
271
|
+
const elapsed = Date.now() - binding.startTime.getTime();
|
|
272
|
+
const toolCount = Array.from(binding.toolMetrics.values()).reduce((s, m) => s + m.executionCount, 0);
|
|
273
|
+
taskManager.appendOutput(taskId, `[summary] ${binding.turnCount} turns | ${toolCount} tool calls | ${Math.round(totalTokens / 1000)}K tokens | ${Math.round(elapsed / 1000)}s`);
|
|
274
|
+
taskManager.updateTask(taskId, {
|
|
275
|
+
status: TaskStatus.COMPLETED,
|
|
276
|
+
endTime: new Date().toISOString(),
|
|
277
|
+
exitCode: 0,
|
|
278
|
+
session: undefined,
|
|
279
|
+
});
|
|
280
|
+
// Destroy session to release PTY FDs
|
|
281
|
+
this.unbind(taskId);
|
|
282
|
+
console.error(`[sdk-session-adapter] Task ${taskId} completed (session.idle)`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Handle session.error event - key for mid-session rate limit detection
|
|
287
|
+
* Now stores structured failure context
|
|
288
|
+
*/
|
|
289
|
+
async handleSessionError(taskId, event, binding) {
|
|
290
|
+
// Flush any buffered output before handling the error
|
|
291
|
+
if (binding.outputBuffer.length) {
|
|
292
|
+
taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
|
|
293
|
+
binding.outputBuffer.length = 0;
|
|
294
|
+
}
|
|
295
|
+
if (binding.reasoningBuffer.length) {
|
|
296
|
+
taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
|
|
297
|
+
binding.reasoningBuffer.length = 0;
|
|
298
|
+
}
|
|
299
|
+
const { errorType, message, statusCode, providerCallId, stack } = event.data;
|
|
300
|
+
taskManager.appendOutput(taskId, `[error] ${errorType}: ${message} (status: ${statusCode || 'unknown'})`);
|
|
301
|
+
// Create structured failure context from SDK event
|
|
302
|
+
const failureContext = {
|
|
303
|
+
errorType,
|
|
304
|
+
statusCode,
|
|
305
|
+
providerCallId,
|
|
306
|
+
message,
|
|
307
|
+
stack,
|
|
308
|
+
recoverable: statusCode !== undefined && ROTATABLE_STATUS_CODES.has(statusCode),
|
|
309
|
+
};
|
|
310
|
+
// Store failure context in task immediately
|
|
311
|
+
taskManager.updateTask(taskId, { failureContext });
|
|
312
|
+
// Check if this is a rotatable error (rate limit or server error)
|
|
313
|
+
const isRotatableError = statusCode !== undefined && ROTATABLE_STATUS_CODES.has(statusCode);
|
|
314
|
+
const isRateLimit = statusCode === RATE_LIMIT_STATUS_CODE;
|
|
315
|
+
if (isRotatableError && binding.rotationAttempts < binding.maxRotationAttempts && !binding.rotationInProgress) {
|
|
316
|
+
// RC-1: Guard against concurrent rotation from multiple error events
|
|
317
|
+
binding.rotationInProgress = true;
|
|
318
|
+
binding.isPaused = true;
|
|
319
|
+
binding.rotationAttempts++;
|
|
320
|
+
taskManager.appendOutput(taskId, `[rotation] Attempting account rotation (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts}) due to ${isRateLimit ? 'rate limit' : 'server error'} ${statusCode}`);
|
|
321
|
+
try {
|
|
322
|
+
// Try to rotate and resume
|
|
323
|
+
const rotationSuccess = await this.attemptRotationAndResume(taskId, binding, statusCode, message);
|
|
324
|
+
if (rotationSuccess) {
|
|
325
|
+
return; // Successfully rotated and resumed, don't mark as failed
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
binding.rotationInProgress = false;
|
|
330
|
+
}
|
|
331
|
+
// Rotation failed - fall through to handle as error
|
|
332
|
+
taskManager.appendOutput(taskId, `[rotation] Rotation failed, marking task as ${isRateLimit ? 'rate_limited' : 'failed'}`);
|
|
333
|
+
}
|
|
334
|
+
else if (isRotatableError && binding.rotationInProgress) {
|
|
335
|
+
// RC-1: Another error arrived while rotation is already in progress — skip
|
|
336
|
+
taskManager.appendOutput(taskId, `[rotation] Rotation already in progress, ignoring duplicate error event`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// RC-6: Re-check terminal state before marking — task may have been cancelled/completed during rotation
|
|
340
|
+
const currentTask = taskManager.getTask(taskId);
|
|
341
|
+
if (!currentTask || isTerminalStatus(currentTask.status)) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Handle based on error type
|
|
345
|
+
if (isRateLimit) {
|
|
346
|
+
if (isFallbackEnabled()) {
|
|
347
|
+
const started = await triggerClaudeFallback(taskId, {
|
|
348
|
+
reason: 'copilot_rate_limited',
|
|
349
|
+
errorMessage: message,
|
|
350
|
+
session: binding.session,
|
|
351
|
+
});
|
|
352
|
+
if (started) {
|
|
353
|
+
this.unbind(taskId);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.markAsRateLimited(taskId, binding, message, failureContext);
|
|
358
|
+
}
|
|
359
|
+
else if (isFallbackEnabled() && !isRotatableError) {
|
|
360
|
+
// Non-rotatable error (CLI crash, auth error, etc.) — fallback to Claude Agent SDK
|
|
361
|
+
console.error(`[sdk-session-adapter] Task ${taskId} hit non-rotatable error, falling back to Claude Agent SDK`);
|
|
362
|
+
const started = await triggerClaudeFallback(taskId, {
|
|
363
|
+
reason: 'copilot_non_rotatable_error',
|
|
364
|
+
errorMessage: message,
|
|
365
|
+
session: binding.session,
|
|
366
|
+
});
|
|
367
|
+
if (started) {
|
|
368
|
+
this.unbind(taskId);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
this.markAsFailed(taskId, binding, message, statusCode, failureContext);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Attempt to rotate to a new account and resume the session
|
|
378
|
+
*/
|
|
379
|
+
async attemptRotationAndResume(taskId, binding, statusCode, errorMessage) {
|
|
380
|
+
// First try the registered callback
|
|
381
|
+
if (this.rotationCallback) {
|
|
382
|
+
try {
|
|
383
|
+
const result = await this.rotationCallback(taskId, binding.sessionId, `status_${statusCode}`, statusCode);
|
|
384
|
+
// RC-2: Check terminal state after await — task may have been cancelled during rotation callback
|
|
385
|
+
const taskAfterCallback = taskManager.getTask(taskId);
|
|
386
|
+
if (!taskAfterCallback || isTerminalStatus(taskAfterCallback.status)) {
|
|
387
|
+
console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterCallback?.status ?? 'deleted'} during rotation callback`);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
if (result.rotated && result.newSession) {
|
|
391
|
+
// Successfully rotated - rebind with new session
|
|
392
|
+
await this.rebindWithNewSession(taskId, binding, result.newSession);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
console.error(`[sdk-session-adapter] Rotation callback failed:`, err);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Try rotating via SDK client manager directly
|
|
401
|
+
const task = taskManager.getTask(taskId);
|
|
402
|
+
if (!task)
|
|
403
|
+
return false;
|
|
404
|
+
const taskCwd = task.cwd || process.cwd();
|
|
405
|
+
// RC-5: Heartbeat before long-running rotateOnError
|
|
406
|
+
taskManager.appendOutput(taskId, `[rotation] Rotating to next account...`);
|
|
407
|
+
const rotationResult = await sdkClientManager.rotateOnError(taskCwd, `status_${statusCode}`);
|
|
408
|
+
// RC-2: Check terminal state after rotateOnError await
|
|
409
|
+
const taskAfterRotate = taskManager.getTask(taskId);
|
|
410
|
+
if (!taskAfterRotate || isTerminalStatus(taskAfterRotate.status)) {
|
|
411
|
+
console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterRotate?.status ?? 'deleted'} during rotateOnError`);
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (!rotationResult.success) {
|
|
415
|
+
if (shouldFallbackToClaudeCode(rotationResult)) {
|
|
416
|
+
// All accounts exhausted - fallback to Claude Agent SDK
|
|
417
|
+
taskManager.appendOutput(taskId, `[rotation] All accounts exhausted. Switching to Claude Agent SDK...`);
|
|
418
|
+
// Get task and calculate remaining timeout
|
|
419
|
+
const task = taskManager.getTask(taskId);
|
|
420
|
+
if (!task)
|
|
421
|
+
return false;
|
|
422
|
+
const started = await triggerClaudeFallback(taskId, {
|
|
423
|
+
reason: 'copilot_accounts_exhausted',
|
|
424
|
+
errorMessage: 'All Copilot accounts exhausted',
|
|
425
|
+
session: binding.session,
|
|
426
|
+
cwd: taskCwd,
|
|
427
|
+
});
|
|
428
|
+
if (started) {
|
|
429
|
+
// Unbind current session
|
|
430
|
+
this.unbind(taskId);
|
|
431
|
+
return true; // Indicate fallback was triggered
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
// RC-5: Heartbeat before long-running health check
|
|
437
|
+
taskManager.appendOutput(taskId, `[rotation] Running health check on new account...`);
|
|
438
|
+
const healthCheckPassed = await this.performHealthCheck(taskCwd);
|
|
439
|
+
// RC-2: Check terminal state after health check await
|
|
440
|
+
const taskAfterHealth = taskManager.getTask(taskId);
|
|
441
|
+
if (!taskAfterHealth || isTerminalStatus(taskAfterHealth.status)) {
|
|
442
|
+
console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterHealth?.status ?? 'deleted'} during health check`);
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
if (!healthCheckPassed) {
|
|
446
|
+
binding.rotationAttempts++;
|
|
447
|
+
taskManager.appendOutput(taskId, `[rotation] Health check failed, trying next account (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts})...`);
|
|
448
|
+
if (binding.rotationAttempts >= binding.maxRotationAttempts) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
// Recursively try next account
|
|
452
|
+
return this.attemptRotationAndResume(taskId, binding, statusCode, errorMessage);
|
|
453
|
+
}
|
|
454
|
+
// Try to resume session with new account
|
|
455
|
+
try {
|
|
456
|
+
// RC-5: Heartbeat before long-running resumeSession
|
|
457
|
+
taskManager.appendOutput(taskId, `[rotation] Health check passed, resuming session ${binding.sessionId}...`);
|
|
458
|
+
const newSession = await sdkClientManager.resumeSession(taskCwd, binding.sessionId, {}, taskId);
|
|
459
|
+
// RC-2: Check terminal state after resumeSession await
|
|
460
|
+
const taskAfterResume = taskManager.getTask(taskId);
|
|
461
|
+
if (!taskAfterResume || isTerminalStatus(taskAfterResume.status)) {
|
|
462
|
+
console.error(`[sdk-session-adapter] Task ${taskId} became ${taskAfterResume?.status ?? 'deleted'} during resumeSession`);
|
|
463
|
+
// Clean up the orphaned session
|
|
464
|
+
await sdkClientManager.destroySession(newSession.sessionId).catch(() => { });
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
await this.rebindWithNewSession(taskId, binding, newSession);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
catch (resumeErr) {
|
|
471
|
+
console.error(`[sdk-session-adapter] Failed to resume session after rotation:`, resumeErr);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Rebind a task with a new session after rotation
|
|
477
|
+
*/
|
|
478
|
+
async rebindWithNewSession(taskId, oldBinding, newSession) {
|
|
479
|
+
// Unsubscribe from old session and destroy it to release PTY FDs (RC-3 fix)
|
|
480
|
+
oldBinding.unsubscribe();
|
|
481
|
+
sdkClientManager.destroySession(oldBinding.sessionId).catch((err) => {
|
|
482
|
+
console.error(`[sdk-session-adapter] Failed to destroy old session ${oldBinding.sessionId} during rebind:`, err);
|
|
483
|
+
});
|
|
484
|
+
// RC-4: Verify task is still alive before rebinding
|
|
485
|
+
const task = taskManager.getTask(taskId);
|
|
486
|
+
if (!task || isTerminalStatus(task.status)) {
|
|
487
|
+
console.error(`[sdk-session-adapter] Task ${taskId} is ${task?.status ?? 'deleted'}, skipping rebind`);
|
|
488
|
+
await sdkClientManager.destroySession(newSession.sessionId).catch(() => { });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// Create new binding preserving state
|
|
492
|
+
const newBinding = {
|
|
493
|
+
taskId,
|
|
494
|
+
session: newSession,
|
|
495
|
+
sessionId: newSession.sessionId,
|
|
496
|
+
unsubscribe: () => { },
|
|
497
|
+
outputBuffer: oldBinding.outputBuffer,
|
|
498
|
+
reasoningBuffer: oldBinding.reasoningBuffer,
|
|
499
|
+
lastMessageId: oldBinding.lastMessageId,
|
|
500
|
+
startTime: oldBinding.startTime,
|
|
501
|
+
isCompleted: false,
|
|
502
|
+
isPaused: false,
|
|
503
|
+
rotationAttempts: oldBinding.rotationAttempts,
|
|
504
|
+
maxRotationAttempts: oldBinding.maxRotationAttempts,
|
|
505
|
+
rotationInProgress: false,
|
|
506
|
+
isUnbound: false,
|
|
507
|
+
pendingPrompt: oldBinding.pendingPrompt,
|
|
508
|
+
// Preserve metrics
|
|
509
|
+
turnCount: oldBinding.turnCount,
|
|
510
|
+
totalTokens: oldBinding.totalTokens,
|
|
511
|
+
toolMetrics: oldBinding.toolMetrics,
|
|
512
|
+
toolStartTimes: oldBinding.toolStartTimes,
|
|
513
|
+
toolCallIdToName: oldBinding.toolCallIdToName,
|
|
514
|
+
activeSubagents: oldBinding.activeSubagents,
|
|
515
|
+
completedSubagents: oldBinding.completedSubagents,
|
|
516
|
+
quotas: oldBinding.quotas,
|
|
517
|
+
};
|
|
518
|
+
// Subscribe to new session events
|
|
519
|
+
const unsubscribe = newSession.on((event) => {
|
|
520
|
+
this.handleEvent(taskId, event, newBinding).catch((err) => {
|
|
521
|
+
console.error(`[sdk-session-adapter] Error handling event ${event.type} for task ${taskId}:`, err);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
newBinding.unsubscribe = unsubscribe;
|
|
525
|
+
this.bindings.set(taskId, newBinding);
|
|
526
|
+
// Update task with new session reference
|
|
527
|
+
taskManager.updateTask(taskId, {
|
|
528
|
+
sessionId: newSession.sessionId,
|
|
529
|
+
session: newSession,
|
|
530
|
+
});
|
|
531
|
+
taskManager.appendOutput(taskId, `[rotation] Successfully resumed with new session`);
|
|
532
|
+
console.error(`[sdk-session-adapter] Rebind task ${taskId} to new session ${newSession.sessionId}`);
|
|
533
|
+
// Send "continue" message to resume the conversation
|
|
534
|
+
taskManager.appendOutput(taskId, `[rotation] Sending 'continue' to resume conversation...`);
|
|
535
|
+
try {
|
|
536
|
+
await newSession.sendAndWait({ prompt: 'continue' });
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
console.error(`[sdk-session-adapter] Failed to send continue message:`, err);
|
|
540
|
+
// Don't fail the rebind - the session is still valid, just the continue failed
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Mark task as rate limited (for when rotation is exhausted)
|
|
545
|
+
*/
|
|
546
|
+
markAsRateLimited(taskId, binding, message, failureContext) {
|
|
547
|
+
binding.isCompleted = true;
|
|
548
|
+
binding.rateLimitInfo = { statusCode: RATE_LIMIT_STATUS_CODE };
|
|
549
|
+
// Use quota reset date if available
|
|
550
|
+
let nextRetryTime;
|
|
551
|
+
const quotaInfo = Array.from(binding.quotas.values()).find(q => q.resetDate);
|
|
552
|
+
if (quotaInfo?.resetDate) {
|
|
553
|
+
nextRetryTime = quotaInfo.resetDate;
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
nextRetryTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
557
|
+
}
|
|
558
|
+
const retryInfo = {
|
|
559
|
+
reason: message,
|
|
560
|
+
retryCount: 0,
|
|
561
|
+
nextRetryTime,
|
|
562
|
+
maxRetries: 6,
|
|
563
|
+
originalTaskId: taskId,
|
|
564
|
+
};
|
|
565
|
+
this.updateSessionMetrics(taskId, binding);
|
|
566
|
+
taskManager.updateTask(taskId, {
|
|
567
|
+
status: TaskStatus.RATE_LIMITED,
|
|
568
|
+
endTime: new Date().toISOString(),
|
|
569
|
+
error: message,
|
|
570
|
+
retryInfo,
|
|
571
|
+
failureContext,
|
|
572
|
+
session: undefined,
|
|
573
|
+
});
|
|
574
|
+
// Destroy session to release PTY FDs
|
|
575
|
+
this.unbind(taskId);
|
|
576
|
+
console.error(`[sdk-session-adapter] Task ${taskId} rate limited (all rotations exhausted)`);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Mark task as failed with structured failure context
|
|
580
|
+
*/
|
|
581
|
+
markAsFailed(taskId, binding, message, statusCode, failureContext) {
|
|
582
|
+
if (!binding.isCompleted) {
|
|
583
|
+
binding.isCompleted = true;
|
|
584
|
+
binding.error = message;
|
|
585
|
+
this.updateSessionMetrics(taskId, binding);
|
|
586
|
+
taskManager.updateTask(taskId, {
|
|
587
|
+
status: TaskStatus.FAILED,
|
|
588
|
+
endTime: new Date().toISOString(),
|
|
589
|
+
error: `${message}${statusCode ? ` (status: ${statusCode})` : ''}`,
|
|
590
|
+
exitCode: 1,
|
|
591
|
+
failureContext,
|
|
592
|
+
session: undefined,
|
|
593
|
+
});
|
|
594
|
+
// Destroy session to release PTY FDs
|
|
595
|
+
this.unbind(taskId);
|
|
596
|
+
console.error(`[sdk-session-adapter] Task ${taskId} failed: ${message}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Handle assistant.message_delta event (streaming)
|
|
601
|
+
*/
|
|
602
|
+
handleMessageDelta(taskId, event, binding) {
|
|
603
|
+
if (event.data.deltaContent) {
|
|
604
|
+
binding.outputBuffer.push(event.data.deltaContent);
|
|
605
|
+
}
|
|
606
|
+
binding.lastMessageId = event.data.messageId;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Handle assistant.message event (final message)
|
|
610
|
+
* Also checks for string-based rate limit detection as fallback
|
|
611
|
+
*/
|
|
612
|
+
async handleAssistantMessage(taskId, event, binding) {
|
|
613
|
+
// Prefer the final content from the event; fall back to accumulated buffer.
|
|
614
|
+
// After appending, clear the buffer so assistant.turn_end won't duplicate.
|
|
615
|
+
const content = event.data.content || binding.outputBuffer.join('');
|
|
616
|
+
if (content) {
|
|
617
|
+
taskManager.appendOutput(taskId, content);
|
|
618
|
+
}
|
|
619
|
+
binding.outputBuffer.length = 0;
|
|
620
|
+
// Message complete UUID → file only (noise for caller)
|
|
621
|
+
taskManager.appendOutputFileOnly(taskId, `[assistant] Message complete: ${event.data.messageId}`);
|
|
622
|
+
binding.lastMessageId = event.data.messageId;
|
|
623
|
+
// String-based rate limit detection fallback
|
|
624
|
+
// Only check current message content to avoid loops
|
|
625
|
+
const rateLimitContent = event.data.content || '';
|
|
626
|
+
if (rateLimitContent.includes(RATE_LIMIT_STRING) && !binding.isCompleted && !binding.isPaused && !binding.rotationInProgress) {
|
|
627
|
+
// Check max rotation attempts first (parity with error-driven path at line 418)
|
|
628
|
+
if (binding.rotationAttempts >= binding.maxRotationAttempts) {
|
|
629
|
+
const currentTask = taskManager.getTask(taskId);
|
|
630
|
+
if (!currentTask || isTerminalStatus(currentTask.status)) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (isFallbackEnabled()) {
|
|
634
|
+
const started = await triggerClaudeFallback(taskId, {
|
|
635
|
+
reason: 'copilot_rate_limited',
|
|
636
|
+
errorMessage: 'Rate limit detected in response (max rotations exhausted)',
|
|
637
|
+
session: binding.session,
|
|
638
|
+
});
|
|
639
|
+
if (started) {
|
|
640
|
+
this.unbind(taskId);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
this.markAsRateLimited(taskId, binding, 'Rate limit detected in response (max rotations exhausted)');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// RC-1: Guard against concurrent rotation from string-based rate limit detection
|
|
648
|
+
binding.rotationInProgress = true;
|
|
649
|
+
binding.isPaused = true;
|
|
650
|
+
binding.rotationAttempts++;
|
|
651
|
+
taskManager.appendOutput(taskId, `[rate-limit] Detected rate limit in response, attempting rotation...`);
|
|
652
|
+
let rotationSuccess = false;
|
|
653
|
+
try {
|
|
654
|
+
rotationSuccess = await this.attemptRotationAndResume(taskId, binding, 429, 'Rate limit detected in response');
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
binding.rotationInProgress = false;
|
|
658
|
+
}
|
|
659
|
+
if (!rotationSuccess) {
|
|
660
|
+
// RC-6: Re-check terminal state before marking
|
|
661
|
+
const currentTask = taskManager.getTask(taskId);
|
|
662
|
+
if (!currentTask || isTerminalStatus(currentTask.status)) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (isFallbackEnabled()) {
|
|
666
|
+
const started = await triggerClaudeFallback(taskId, {
|
|
667
|
+
reason: 'copilot_rate_limited',
|
|
668
|
+
errorMessage: 'Rate limit detected in response (rotation failed)',
|
|
669
|
+
session: binding.session,
|
|
670
|
+
});
|
|
671
|
+
if (started) {
|
|
672
|
+
this.unbind(taskId);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
this.markAsRateLimited(taskId, binding, 'Rate limit detected in response (rotation failed)');
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Handle assistant.usage event for quota tracking
|
|
682
|
+
* Stores structured quota info and updates session metrics
|
|
683
|
+
*/
|
|
684
|
+
handleUsage(taskId, event, binding) {
|
|
685
|
+
const { model, inputTokens, outputTokens, quotaSnapshots, cacheReadTokens, cacheWriteTokens, cost } = event.data;
|
|
686
|
+
// Update total tokens
|
|
687
|
+
binding.totalTokens.input += inputTokens || 0;
|
|
688
|
+
binding.totalTokens.output += outputTokens || 0;
|
|
689
|
+
// Per-turn usage → file only (cumulative summary emitted at completion)
|
|
690
|
+
taskManager.appendOutputFileOnly(taskId, `[usage] Model: ${model}, Input: ${inputTokens || 0}, Output: ${outputTokens || 0}${cost ? `, Cost: $${cost.toFixed(4)}` : ''}`);
|
|
691
|
+
// Process quota snapshots and store structured info
|
|
692
|
+
if (quotaSnapshots) {
|
|
693
|
+
for (const [tier, snapshotRaw] of Object.entries(quotaSnapshots)) {
|
|
694
|
+
const snapshot = snapshotRaw;
|
|
695
|
+
const quotaInfo = {
|
|
696
|
+
tier,
|
|
697
|
+
remainingPercentage: snapshot.remainingPercentage,
|
|
698
|
+
usedRequests: snapshot.usedRequests ?? 0,
|
|
699
|
+
entitlementRequests: snapshot.entitlementRequests ?? 0,
|
|
700
|
+
isUnlimited: snapshot.isUnlimitedEntitlement ?? false,
|
|
701
|
+
overage: snapshot.overage ?? 0,
|
|
702
|
+
resetDate: snapshot.resetDate,
|
|
703
|
+
lastUpdated: new Date().toISOString(),
|
|
704
|
+
};
|
|
705
|
+
binding.quotas.set(tier, quotaInfo);
|
|
706
|
+
if (snapshot.remainingPercentage <= 10) {
|
|
707
|
+
// Quota warning → file only (available via quotaInfo in MCP resource)
|
|
708
|
+
taskManager.appendOutputFileOnly(taskId, `[quota] Warning: ${tier} at ${snapshot.remainingPercentage}% remaining (resets: ${snapshot.resetDate || 'unknown'})`);
|
|
709
|
+
// Update task with quota warning
|
|
710
|
+
taskManager.updateTask(taskId, { quotaInfo });
|
|
711
|
+
binding.rateLimitInfo = {
|
|
712
|
+
statusCode: 0, // Not yet rate limited
|
|
713
|
+
remainingPercentage: snapshot.remainingPercentage,
|
|
714
|
+
resetDate: snapshot.resetDate,
|
|
715
|
+
};
|
|
716
|
+
// Proactively rotate if quota is critically low (< 1%)
|
|
717
|
+
// Guard: Only rotate if not already rotating (prevents race condition)
|
|
718
|
+
if (snapshot.remainingPercentage < 1 &&
|
|
719
|
+
binding.rotationAttempts < binding.maxRotationAttempts &&
|
|
720
|
+
!binding.rotationInProgress) {
|
|
721
|
+
binding.rotationInProgress = true;
|
|
722
|
+
binding.isPaused = true;
|
|
723
|
+
binding.rotationAttempts++; // Count proactive rotation toward the limit
|
|
724
|
+
taskManager.appendOutput(taskId, `[quota] Quota critically low, proactively rotating (attempt ${binding.rotationAttempts}/${binding.maxRotationAttempts})...`);
|
|
725
|
+
this.attemptRotationAndResume(taskId, binding, 429, 'Quota critically low')
|
|
726
|
+
.finally(() => {
|
|
727
|
+
binding.rotationInProgress = false;
|
|
728
|
+
binding.isPaused = false;
|
|
729
|
+
})
|
|
730
|
+
.catch(console.error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Update session metrics
|
|
736
|
+
this.updateSessionMetrics(taskId, binding);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Handle tool.execution_start event - track tool metrics
|
|
740
|
+
*/
|
|
741
|
+
handleToolStart(taskId, event, binding) {
|
|
742
|
+
const { toolName, toolCallId, mcpServerName, mcpToolName } = event.data;
|
|
743
|
+
// Track start time and toolCallId → toolName mapping for accurate completion matching (METRIC-1 fix)
|
|
744
|
+
binding.toolStartTimes.set(toolCallId, Date.now());
|
|
745
|
+
binding.toolCallIdToName.set(toolCallId, toolName);
|
|
746
|
+
// Initialize or update tool metrics
|
|
747
|
+
let metrics = binding.toolMetrics.get(toolName);
|
|
748
|
+
if (!metrics) {
|
|
749
|
+
metrics = {
|
|
750
|
+
toolName,
|
|
751
|
+
mcpServer: mcpServerName,
|
|
752
|
+
mcpToolName,
|
|
753
|
+
executionCount: 0,
|
|
754
|
+
successCount: 0,
|
|
755
|
+
failureCount: 0,
|
|
756
|
+
totalDurationMs: 0,
|
|
757
|
+
};
|
|
758
|
+
binding.toolMetrics.set(toolName, metrics);
|
|
759
|
+
}
|
|
760
|
+
const serverInfo = mcpServerName ? ` (MCP: ${mcpServerName})` : '';
|
|
761
|
+
// Tool start → file only; the completion line (with duration) will appear in-memory
|
|
762
|
+
taskManager.appendOutputFileOnly(taskId, `[tool] Starting: ${toolName}${serverInfo}`);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Handle tool.execution_complete event - finalize tool metrics
|
|
766
|
+
*/
|
|
767
|
+
handleToolComplete(taskId, event, binding) {
|
|
768
|
+
const { toolCallId, success } = event.data;
|
|
769
|
+
// METRIC-1 fix: Use toolCallId → toolName mapping for accurate completion matching
|
|
770
|
+
const startTime = binding.toolStartTimes.get(toolCallId);
|
|
771
|
+
const duration = startTime ? Date.now() - startTime : 0;
|
|
772
|
+
const toolName = binding.toolCallIdToName.get(toolCallId);
|
|
773
|
+
// Clean up tracking maps
|
|
774
|
+
binding.toolStartTimes.delete(toolCallId);
|
|
775
|
+
binding.toolCallIdToName.delete(toolCallId);
|
|
776
|
+
// Find and update metrics using the tracked toolName (deterministic matching)
|
|
777
|
+
const metrics = toolName ? binding.toolMetrics.get(toolName) : undefined;
|
|
778
|
+
if (metrics) {
|
|
779
|
+
metrics.executionCount++;
|
|
780
|
+
metrics.totalDurationMs += duration;
|
|
781
|
+
metrics.lastExecutedAt = new Date().toISOString();
|
|
782
|
+
if (success) {
|
|
783
|
+
metrics.successCount++;
|
|
784
|
+
// Trivial tools (<100ms): compact single line to in-memory, verbose to file
|
|
785
|
+
if (duration < 100) {
|
|
786
|
+
taskManager.appendOutputFileOnly(taskId, `[tool] Completed: ${metrics.toolName} (${duration}ms)`);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
taskManager.appendOutput(taskId, `[tool] ${metrics.toolName} (${duration}ms)`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
metrics.failureCount++;
|
|
794
|
+
const errorMsg = event.data.error?.message || 'Unknown error';
|
|
795
|
+
taskManager.appendOutput(taskId, `[tool] Failed: ${metrics.toolName} - ${errorMsg}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
// Fallback: log completion without metrics update if toolName not found
|
|
800
|
+
taskManager.appendOutput(taskId, `[tool] Completed: unknown (${duration}ms, callId: ${toolCallId})`);
|
|
801
|
+
}
|
|
802
|
+
this.updateSessionMetrics(taskId, binding);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Handle subagent.started event
|
|
806
|
+
*/
|
|
807
|
+
handleSubagentStarted(taskId, event, binding) {
|
|
808
|
+
const { agentName, agentDisplayName, agentDescription, toolCallId } = event.data;
|
|
809
|
+
const subagentInfo = {
|
|
810
|
+
agentName,
|
|
811
|
+
agentDisplayName,
|
|
812
|
+
agentDescription,
|
|
813
|
+
toolCallId,
|
|
814
|
+
status: 'running',
|
|
815
|
+
startedAt: new Date().toISOString(),
|
|
816
|
+
};
|
|
817
|
+
binding.activeSubagents.set(toolCallId, subagentInfo);
|
|
818
|
+
taskManager.appendOutput(taskId, `[subagent] Started: ${agentDisplayName}`);
|
|
819
|
+
this.updateSessionMetrics(taskId, binding);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Handle subagent.completed event
|
|
823
|
+
*/
|
|
824
|
+
handleSubagentCompleted(taskId, event, binding) {
|
|
825
|
+
const { agentName, toolCallId } = event.data;
|
|
826
|
+
const subagent = binding.activeSubagents.get(toolCallId);
|
|
827
|
+
if (subagent) {
|
|
828
|
+
subagent.status = 'completed';
|
|
829
|
+
subagent.endedAt = new Date().toISOString();
|
|
830
|
+
binding.completedSubagents.push(subagent);
|
|
831
|
+
binding.activeSubagents.delete(toolCallId);
|
|
832
|
+
}
|
|
833
|
+
taskManager.appendOutput(taskId, `[subagent] Completed: ${agentName}`);
|
|
834
|
+
this.updateSessionMetrics(taskId, binding);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Handle subagent.failed event
|
|
838
|
+
*/
|
|
839
|
+
handleSubagentFailed(taskId, event, binding) {
|
|
840
|
+
const { agentName, toolCallId, error } = event.data;
|
|
841
|
+
const subagent = binding.activeSubagents.get(toolCallId);
|
|
842
|
+
if (subagent) {
|
|
843
|
+
subagent.status = 'failed';
|
|
844
|
+
subagent.error = error;
|
|
845
|
+
subagent.endedAt = new Date().toISOString();
|
|
846
|
+
binding.completedSubagents.push(subagent);
|
|
847
|
+
binding.activeSubagents.delete(toolCallId);
|
|
848
|
+
}
|
|
849
|
+
taskManager.appendOutput(taskId, `[subagent] Failed: ${agentName} - ${error}`);
|
|
850
|
+
this.updateSessionMetrics(taskId, binding);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Handle session.shutdown event - extract completion metrics
|
|
854
|
+
*/
|
|
855
|
+
handleSessionShutdown(taskId, event, binding) {
|
|
856
|
+
// Flush any remaining buffered output before shutdown
|
|
857
|
+
if (binding.outputBuffer.length) {
|
|
858
|
+
taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
|
|
859
|
+
binding.outputBuffer.length = 0;
|
|
860
|
+
}
|
|
861
|
+
if (binding.reasoningBuffer.length) {
|
|
862
|
+
taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
|
|
863
|
+
binding.reasoningBuffer.length = 0;
|
|
864
|
+
}
|
|
865
|
+
// Session shutdown details → file only
|
|
866
|
+
taskManager.appendOutputFileOnly(taskId, `[session] Shutdown: ${event.data.shutdownType}`);
|
|
867
|
+
// Extract completion metrics from shutdown event
|
|
868
|
+
const completionMetrics = {
|
|
869
|
+
totalApiCalls: event.data.totalPremiumRequests || 0,
|
|
870
|
+
totalApiDurationMs: event.data.totalApiDurationMs || 0,
|
|
871
|
+
codeChanges: {
|
|
872
|
+
linesAdded: event.data.codeChanges?.linesAdded || 0,
|
|
873
|
+
linesRemoved: event.data.codeChanges?.linesRemoved || 0,
|
|
874
|
+
filesModified: event.data.codeChanges?.filesModified || [],
|
|
875
|
+
},
|
|
876
|
+
modelUsage: {},
|
|
877
|
+
sessionStartTime: binding.startTime.getTime(),
|
|
878
|
+
currentModel: event.data.modelMetrics ? Object.keys(event.data.modelMetrics)[0] : undefined,
|
|
879
|
+
};
|
|
880
|
+
// Process model metrics if available
|
|
881
|
+
if (event.data.modelMetrics) {
|
|
882
|
+
for (const [model, metricsRaw] of Object.entries(event.data.modelMetrics)) {
|
|
883
|
+
const metrics = metricsRaw;
|
|
884
|
+
completionMetrics.modelUsage[model] = {
|
|
885
|
+
requests: metrics.requests?.count || 0,
|
|
886
|
+
cost: metrics.requests?.cost || 0,
|
|
887
|
+
tokens: {
|
|
888
|
+
input: metrics.usage?.inputTokens || 0,
|
|
889
|
+
output: metrics.usage?.outputTokens || 0,
|
|
890
|
+
cacheRead: metrics.usage?.cacheReadTokens || 0,
|
|
891
|
+
cacheWrite: metrics.usage?.cacheWriteTokens || 0,
|
|
892
|
+
},
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Log completion metrics summary
|
|
897
|
+
if (completionMetrics.totalApiCalls > 0) {
|
|
898
|
+
taskManager.appendOutput(taskId, `[metrics] API calls: ${completionMetrics.totalApiCalls}, Duration: ${completionMetrics.totalApiDurationMs}ms`);
|
|
899
|
+
}
|
|
900
|
+
if (completionMetrics.codeChanges.linesAdded > 0 || completionMetrics.codeChanges.linesRemoved > 0) {
|
|
901
|
+
taskManager.appendOutput(taskId, `[metrics] Code: +${completionMetrics.codeChanges.linesAdded}/-${completionMetrics.codeChanges.linesRemoved} lines, ${completionMetrics.codeChanges.filesModified.length} files`);
|
|
902
|
+
}
|
|
903
|
+
// Update session metrics and completion metrics
|
|
904
|
+
this.updateSessionMetrics(taskId, binding);
|
|
905
|
+
taskManager.updateTask(taskId, { completionMetrics });
|
|
906
|
+
if (event.data.shutdownType === 'error' && !binding.isCompleted) {
|
|
907
|
+
binding.isCompleted = true;
|
|
908
|
+
taskManager.updateTask(taskId, {
|
|
909
|
+
status: TaskStatus.FAILED,
|
|
910
|
+
endTime: new Date().toISOString(),
|
|
911
|
+
error: event.data.errorReason || 'Session shutdown with error',
|
|
912
|
+
exitCode: 1,
|
|
913
|
+
session: undefined,
|
|
914
|
+
});
|
|
915
|
+
// Destroy session to release PTY FDs
|
|
916
|
+
this.unbind(taskId);
|
|
917
|
+
}
|
|
918
|
+
else if (binding.isCompleted) {
|
|
919
|
+
// Session shut down normally after completion — ensure cleanup
|
|
920
|
+
this.unbind(taskId);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Handle abort event
|
|
925
|
+
*/
|
|
926
|
+
handleAbort(taskId, event, binding) {
|
|
927
|
+
// Flush any remaining buffered output before abort
|
|
928
|
+
if (binding.outputBuffer.length) {
|
|
929
|
+
taskManager.appendOutput(taskId, binding.outputBuffer.join(''));
|
|
930
|
+
binding.outputBuffer.length = 0;
|
|
931
|
+
}
|
|
932
|
+
if (binding.reasoningBuffer.length) {
|
|
933
|
+
taskManager.appendOutputFileOnly(taskId, `[reasoning] ${binding.reasoningBuffer.join('')}`);
|
|
934
|
+
binding.reasoningBuffer.length = 0;
|
|
935
|
+
}
|
|
936
|
+
taskManager.appendOutput(taskId, `[session] Aborted: ${event.data.reason}`);
|
|
937
|
+
if (!binding.isCompleted) {
|
|
938
|
+
binding.isCompleted = true;
|
|
939
|
+
this.updateSessionMetrics(taskId, binding);
|
|
940
|
+
taskManager.updateTask(taskId, {
|
|
941
|
+
status: TaskStatus.CANCELLED,
|
|
942
|
+
endTime: new Date().toISOString(),
|
|
943
|
+
session: undefined,
|
|
944
|
+
});
|
|
945
|
+
// Destroy session to release PTY FDs
|
|
946
|
+
this.unbind(taskId);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Update session metrics in task state
|
|
951
|
+
* Uses SDK's UsageMetricsTracker for token/request metrics, combined with our custom tracking
|
|
952
|
+
*/
|
|
953
|
+
updateSessionMetrics(taskId, binding) {
|
|
954
|
+
const toolMetricsObj = {};
|
|
955
|
+
for (const [name, metrics] of binding.toolMetrics) {
|
|
956
|
+
toolMetricsObj[name] = metrics;
|
|
957
|
+
}
|
|
958
|
+
const quotasObj = {};
|
|
959
|
+
for (const [tier, quota] of binding.quotas) {
|
|
960
|
+
quotasObj[tier] = quota;
|
|
961
|
+
}
|
|
962
|
+
const sessionMetrics = {
|
|
963
|
+
quotas: quotasObj,
|
|
964
|
+
toolMetrics: toolMetricsObj,
|
|
965
|
+
activeSubagents: Array.from(binding.activeSubagents.values()),
|
|
966
|
+
completedSubagents: binding.completedSubagents,
|
|
967
|
+
turnCount: binding.turnCount,
|
|
968
|
+
totalTokens: {
|
|
969
|
+
input: binding.totalTokens.input,
|
|
970
|
+
output: binding.totalTokens.output,
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
taskManager.updateTask(taskId, { sessionMetrics });
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Unbind a session from a task.
|
|
977
|
+
* Also destroys the session and removes it from the client manager's tracking
|
|
978
|
+
* to prevent PTY file descriptor leaks.
|
|
979
|
+
*/
|
|
980
|
+
unbind(taskId) {
|
|
981
|
+
const binding = this.bindings.get(taskId);
|
|
982
|
+
if (binding && !binding.isUnbound) {
|
|
983
|
+
// Set flag first to prevent concurrent unbinds from double-destroying
|
|
984
|
+
binding.isUnbound = true;
|
|
985
|
+
binding.unsubscribe();
|
|
986
|
+
// Destroy the session to release PTY file descriptors (RC-1 fix)
|
|
987
|
+
const sessionId = binding.sessionId;
|
|
988
|
+
sdkClientManager.destroySession(sessionId).catch((err) => {
|
|
989
|
+
console.error(`[sdk-session-adapter] Failed to destroy session ${sessionId} during unbind:`, err);
|
|
990
|
+
});
|
|
991
|
+
this.bindings.delete(taskId);
|
|
992
|
+
console.error(`[sdk-session-adapter] Unbound and destroyed session ${sessionId} for task ${taskId}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Get the binding for a task.
|
|
997
|
+
*/
|
|
998
|
+
getBinding(taskId) {
|
|
999
|
+
return this.bindings.get(taskId);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Get the session for a task.
|
|
1003
|
+
*/
|
|
1004
|
+
getSession(taskId) {
|
|
1005
|
+
return this.bindings.get(taskId)?.session;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Check if a task has an active session.
|
|
1009
|
+
*/
|
|
1010
|
+
hasSession(taskId) {
|
|
1011
|
+
return this.bindings.has(taskId);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Get accumulated output for a task.
|
|
1015
|
+
*/
|
|
1016
|
+
getOutput(taskId) {
|
|
1017
|
+
return this.bindings.get(taskId)?.outputBuffer || [];
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Mark a task as timed out.
|
|
1021
|
+
*/
|
|
1022
|
+
markTimedOut(taskId, timeoutMs) {
|
|
1023
|
+
const binding = this.bindings.get(taskId);
|
|
1024
|
+
if (binding && !binding.isCompleted) {
|
|
1025
|
+
binding.isCompleted = true;
|
|
1026
|
+
this.updateSessionMetrics(taskId, binding);
|
|
1027
|
+
taskManager.updateTask(taskId, {
|
|
1028
|
+
status: TaskStatus.TIMED_OUT,
|
|
1029
|
+
endTime: new Date().toISOString(),
|
|
1030
|
+
error: `Task timed out after ${timeoutMs}ms`,
|
|
1031
|
+
timeoutReason: 'hard_timeout',
|
|
1032
|
+
timeoutContext: {
|
|
1033
|
+
timeoutMs,
|
|
1034
|
+
detectedBy: 'sdk_adapter',
|
|
1035
|
+
},
|
|
1036
|
+
session: undefined,
|
|
1037
|
+
});
|
|
1038
|
+
// Abort the session, then destroy to release PTY FDs
|
|
1039
|
+
// Safety timeout: if abort hangs for >10s, unbind anyway
|
|
1040
|
+
const abortTimeout = setTimeout(() => {
|
|
1041
|
+
console.error(`[sdk-session-adapter] Abort timed out for task ${taskId}, force unbinding`);
|
|
1042
|
+
this.unbind(taskId);
|
|
1043
|
+
}, 10_000);
|
|
1044
|
+
binding.session.abort().catch((err) => {
|
|
1045
|
+
console.error(`[sdk-session-adapter] Failed to abort timed-out session ${taskId}:`, err);
|
|
1046
|
+
}).finally(() => {
|
|
1047
|
+
clearTimeout(abortTimeout);
|
|
1048
|
+
this.unbind(taskId);
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Cleanup all bindings.
|
|
1054
|
+
* Destroys all sessions to release PTY file descriptors.
|
|
1055
|
+
*/
|
|
1056
|
+
cleanup() {
|
|
1057
|
+
for (const [taskId, binding] of this.bindings) {
|
|
1058
|
+
binding.unsubscribe();
|
|
1059
|
+
// Destroy session to release PTY FDs (RC-6 fix)
|
|
1060
|
+
sdkClientManager.destroySession(binding.sessionId).catch((err) => {
|
|
1061
|
+
console.error(`[sdk-session-adapter] Failed to destroy session ${binding.sessionId} during cleanup:`, err);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
this.bindings.clear();
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Get statistics.
|
|
1068
|
+
*/
|
|
1069
|
+
getStats() {
|
|
1070
|
+
let totalRotations = 0;
|
|
1071
|
+
let totalTurns = 0;
|
|
1072
|
+
const totalTokens = { input: 0, output: 0 };
|
|
1073
|
+
for (const binding of this.bindings.values()) {
|
|
1074
|
+
totalRotations += binding.rotationAttempts;
|
|
1075
|
+
totalTurns += binding.turnCount;
|
|
1076
|
+
totalTokens.input += binding.totalTokens.input;
|
|
1077
|
+
totalTokens.output += binding.totalTokens.output;
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
activeBindings: this.bindings.size,
|
|
1081
|
+
totalRotations,
|
|
1082
|
+
totalTurns,
|
|
1083
|
+
totalTokens,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
export const sdkSessionAdapter = new SDKSessionAdapter();
|
|
1088
|
+
//# sourceMappingURL=sdk-session-adapter.js.map
|