| ... | @@ -385,6 +385,8 @@ interface ClaudeTaskAgentState { | ... | @@ -385,6 +385,8 @@ interface ClaudeTaskAgentState { |
| 385 | * lifetime; oldest entries evict first. | 385 | * lifetime; oldest entries evict first. |
| 386 | */ | 386 | */ |
| 387 | const PENDING_TASK_MODEL_CAP = 64; | 387 | const PENDING_TASK_MODEL_CAP = 64; |
| | 388 | /** How long Stop waits for Claude to abort a turn before killing the process. */ |
| | 389 | const CLAUDE_INTERRUPT_GRACE = "3 seconds"; |
| 388 | | 390 | |
| 389 | /** | 391 | /** |
| 390 | * Buffers a subagent snapshot's authoritative model under its | 392 | * Buffers a subagent snapshot's authoritative model under its |
| ... | @@ -453,10 +455,14 @@ interface ClaudeSessionContext { | ... | @@ -453,10 +455,14 @@ interface ClaudeSessionContext { |
| 453 | lastThreadStartedId: string | undefined; | 455 | lastThreadStartedId: string | undefined; |
| 454 | /** Limits already announced for the running turn, keyed `window:resetsAt`. */ | 456 | /** Limits already announced for the running turn, keyed `window:resetsAt`. */ |
| 455 | announcedUsageLimits: { turnId: string; keys: Set<string> } | undefined; | 457 | announcedUsageLimits: { turnId: string; keys: Set<string> } | undefined; |
| | 458 | /** Resolved by completeTurn while Stop waits for Claude to abort the turn. */ |
| | 459 | interruptedTurnSettled: Deferred.Deferred<void> | undefined; |
| 456 | stopped: boolean; | 460 | stopped: boolean; |
| 457 | } | 461 | } |
| 458 | | 462 | |
| 459 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { | 463 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { |
| | 464 | /** SDK Query.interrupt — present on real queries; optional for test doubles. */ |
| | 465 | readonly interrupt?: () => Promise<unknown>; |
| 460 | readonly setModel: (model?: string) => Promise<void>; | 466 | readonly setModel: (model?: string) => Promise<void>; |
| 461 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; | 467 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; |
| 462 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; | 468 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; |
| ... | @@ -2836,6 +2842,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2836,6 +2842,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2836 | | 2842 | |
| 2837 | const updatedAt = yield* nowIso; | 2843 | const updatedAt = yield* nowIso; |
| 2838 | context.turnState = undefined; | 2844 | context.turnState = undefined; |
| | 2845 | if (context.interruptedTurnSettled) { |
| | 2846 | yield* Deferred.succeed(context.interruptedTurnSettled, undefined); |
| | 2847 | } |
| 2839 | context.session = { | 2848 | context.session = { |
| 2840 | ...context.session, | 2849 | ...context.session, |
| 2841 | status: "ready", | 2850 | status: "ready", |
| ... | @@ -5045,6 +5054,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -5045,6 +5054,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5045 | lastAssistantUuid: resumeState?.resumeSessionAt, | 5054 | lastAssistantUuid: resumeState?.resumeSessionAt, |
| 5046 | lastThreadStartedId: undefined, | 5055 | lastThreadStartedId: undefined, |
| 5047 | announcedUsageLimits: undefined, | 5056 | announcedUsageLimits: undefined, |
| | 5057 | interruptedTurnSettled: undefined, |
| 5048 | stopped: false, | 5058 | stopped: false, |
| 5049 | }; | 5059 | }; |
| 5050 | yield* Ref.set(contextRef, context); | 5060 | yield* Ref.set(contextRef, context); |
| ... | @@ -5276,6 +5286,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -5276,6 +5286,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5276 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( | 5286 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 5277 | function* (threadId, _turnId) { | 5287 | function* (threadId, _turnId) { |
| 5278 | const context = yield* requireSession(threadId); | 5288 | const context = yield* requireSession(threadId); |
| | 5289 | yield* settleInterruptedTurn(context); |
| 5279 | // interrupt() can acknowledge while resumed background tasks keep the | 5290 | // interrupt() can acknowledge while resumed background tasks keep the |
| 5280 | // CLI alive. Stop is a hard session boundary for Claude, so close the | 5291 | // CLI alive. Stop is a hard session boundary for Claude, so close the |
| 5281 | // query and let the SDK escalate to SIGKILL when graceful exit fails. | 5292 | // query and let the SDK escalate to SIGKILL when graceful exit fails. |
| ... | @@ -5283,6 +5294,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -5283,6 +5294,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5283 | }, | 5294 | }, |
| 5284 | ); | 5295 | ); |
| 5285 | | 5296 | |
| | 5297 | // Lets Claude abort the running turn through its own path before Stop kills |
| | 5298 | // the process, so the prompt reaches the transcript. Killing a first turn |
| | 5299 | // before Claude writes it leaves a resume cursor for a session Claude never |
| | 5300 | // saved, and every later message fails with "No conversation found". |
| | 5301 | const settleInterruptedTurn = Effect.fn("settleInterruptedTurn")(function* ( |
| | 5302 | context: ClaudeSessionContext, |
| | 5303 | ) { |
| | 5304 | const interrupt = context.query.interrupt?.bind(context.query); |
| | 5305 | if (context.stopped || !context.turnState || !interrupt) return; |
| | 5306 | const settled = yield* Deferred.make<void>(); |
| | 5307 | context.interruptedTurnSettled = settled; |
| | 5308 | yield* Effect.tryPromise(interrupt).pipe( |
| | 5309 | Effect.ignore, |
| | 5310 | Effect.andThen(Deferred.await(settled)), |
| | 5311 | Effect.timeoutOption(CLAUDE_INTERRUPT_GRACE), |
| | 5312 | ); |
| | 5313 | context.interruptedTurnSettled = undefined; |
| | 5314 | }); |
| | 5315 | |
| 5286 | const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( | 5316 | const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( |
| 5287 | function* (threadId) { | 5317 | function* (threadId) { |
| 5288 | const context = yield* requireSession(threadId); | 5318 | const context = yield* requireSession(threadId); |