| ... | @@ -282,9 +282,6 @@ interface ClaudeSessionContext { | ... | @@ -282,9 +282,6 @@ interface ClaudeSessionContext { |
| 282 | } | 282 | } |
| 283 | | 283 | |
| 284 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { | 284 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { |
| 285 | readonly interrupt: () => Promise<void>; | | |
| 286 | /** SDK Query.stopTask — present on real queries; optional for test doubles. */ | | |
| 287 | readonly stopTask?: (taskId: string) => Promise<void>; | | |
| 288 | readonly setModel: (model?: string) => Promise<void>; | 285 | readonly setModel: (model?: string) => Promise<void>; |
| 289 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; | 286 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; |
| 290 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; | 287 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; |
| ... | @@ -2106,13 +2103,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2106,13 +2103,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2106 | } catch { | 2103 | } catch { |
| 2107 | return undefined; | 2104 | return undefined; |
| 2108 | } | 2105 | } |
| 2109 | }); | 2106 | }).pipe(Effect.timeoutOption("1 second")); |
| 2110 | if (!usage) { | 2107 | if (Option.isNone(usage) || !usage.value) { |
| 2111 | return undefined; | 2108 | return undefined; |
| 2112 | } | 2109 | } |
| 2113 | | 2110 | |
| 2114 | context.lastKnownContextWindow = usage.maxTokens; | 2111 | context.lastKnownContextWindow = usage.value.maxTokens; |
| 2115 | return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); | 2112 | return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); |
| 2116 | }); | 2113 | }); |
| 2117 | | 2114 | |
| 2118 | const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( | 2115 | const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( |
| ... | @@ -3636,8 +3633,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3636,8 +3633,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3636 | ) { | 3633 | ) { |
| 3637 | if (context.stopped) return; | 3634 | if (context.stopped) return; |
| 3638 | | 3635 | |
| | 3636 | // Schedule process termination before any cleanup that can wait on the |
| | 3637 | // provider. The SDK closes stdin, then escalates from SIGTERM to SIGKILL. |
| | 3638 | yield* Effect.try({ |
| | 3639 | try: () => context.query.close(), |
| | 3640 | catch: (cause) => |
| | 3641 | new ProviderAdapterProcessError({ |
| | 3642 | provider: PROVIDER, |
| | 3643 | threadId: context.session.threadId, |
| | 3644 | detail: "Failed to close Claude runtime query.", |
| | 3645 | cause, |
| | 3646 | }), |
| | 3647 | }); |
| | 3648 | |
| 3639 | context.stopped = true; | 3649 | context.stopped = true; |
| 3640 | | 3650 | |
| | 3651 | for (const taskId of Array.from(context.liveTaskIds)) { |
| | 3652 | if (!context.liveTaskIds.delete(taskId)) { |
| | 3653 | continue; |
| | 3654 | } |
| | 3655 | const stamp = yield* makeEventStamp(); |
| | 3656 | yield* offerRuntimeEvent({ |
| | 3657 | type: "task.completed", |
| | 3658 | eventId: stamp.eventId, |
| | 3659 | provider: PROVIDER, |
| | 3660 | createdAt: stamp.createdAt, |
| | 3661 | threadId: context.session.threadId, |
| | 3662 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), |
| | 3663 | payload: { |
| | 3664 | taskId: RuntimeTaskId.make(taskId), |
| | 3665 | status: "stopped", |
| | 3666 | ...taskLinkageFor(context.taskAgents, taskId), |
| | 3667 | }, |
| | 3668 | providerRefs: nativeProviderRefs(context), |
| | 3669 | }); |
| | 3670 | } |
| | 3671 | |
| 3641 | for (const [requestId, pending] of context.pendingApprovals) { | 3672 | for (const [requestId, pending] of context.pendingApprovals) { |
| 3642 | yield* Deferred.succeed(pending.decision, "cancel"); | 3673 | yield* Deferred.succeed(pending.decision, "cancel"); |
| 3643 | const stamp = yield* makeEventStamp(); | 3674 | const stamp = yield* makeEventStamp(); |
| ... | @@ -3676,26 +3707,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3676,26 +3707,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3676 | yield* Fiber.interrupt(streamFiber); | 3707 | yield* Fiber.interrupt(streamFiber); |
| 3677 | } | 3708 | } |
| 3678 | | 3709 | |
| 3679 | yield* Effect.try({ | | |
| 3680 | try: () => context.query.close(), | | |
| 3681 | catch: (cause) => | | |
| 3682 | new ProviderAdapterProcessError({ | | |
| 3683 | provider: PROVIDER, | | |
| 3684 | threadId: context.session.threadId, | | |
| 3685 | detail: "Failed to close Claude runtime query.", | | |
| 3686 | cause, | | |
| 3687 | }), | | |
| 3688 | }).pipe( | | |
| 3689 | Effect.catch((error) => | | |
| 3690 | emitRuntimeError(context, "Failed to close Claude runtime query.", { | | |
| 3691 | errorTag: error._tag, | | |
| 3692 | provider: error.provider, | | |
| 3693 | threadId: error.threadId, | | |
| 3694 | detail: error.detail, | | |
| 3695 | }), | | |
| 3696 | ), | | |
| 3697 | ); | | |
| 3698 | | | |
| 3699 | const updatedAt = yield* nowIso; | 3710 | const updatedAt = yield* nowIso; |
| 3700 | context.session = { | 3711 | context.session = { |
| 3701 | ...context.session, | 3712 | ...context.session, |
| ... | @@ -3704,7 +3715,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3704,7 +3715,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3704 | updatedAt, | 3715 | updatedAt, |
| 3705 | }; | 3716 | }; |
| 3706 | | 3717 | |
| 3707 | if (options?.emitExitEvent !== false) { | 3718 | if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { |
| 3708 | const stamp = yield* makeEventStamp(); | 3719 | const stamp = yield* makeEventStamp(); |
| 3709 | yield* offerRuntimeEvent({ | 3720 | yield* offerRuntimeEvent({ |
| 3710 | type: "session.exited", | 3721 | type: "session.exited", |
| ... | @@ -3720,7 +3731,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3720,7 +3731,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3720 | }); | 3731 | }); |
| 3721 | } | 3732 | } |
| 3722 | | 3733 | |
| 3723 | sessions.delete(context.session.threadId); | 3734 | if (sessions.get(context.session.threadId) === context) { |
| | 3735 | sessions.delete(context.session.threadId); |
| | 3736 | } |
| 3724 | }); | 3737 | }); |
| 3725 | | 3738 | |
| 3726 | const requireSession = ( | 3739 | const requireSession = ( |
| ... | @@ -3765,16 +3778,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3765,16 +3778,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3765 | }); | 3778 | }); |
| 3766 | yield* stopSessionInternal(existingContext, { | 3779 | yield* stopSessionInternal(existingContext, { |
| 3767 | emitExitEvent: false, | 3780 | emitExitEvent: false, |
| 3768 | }).pipe( | 3781 | }); |
| 3769 | // Replacement cleanup is best-effort: never block the new session on | | |
| 3770 | // either typed failures or unexpected defects from tearing down the old one. | | |
| 3771 | Effect.catchCause((cause) => | | |
| 3772 | Effect.logWarning("claude.session.replace.stop-failed", { | | |
| 3773 | threadId: input.threadId, | | |
| 3774 | cause, | | |
| 3775 | }), | | |
| 3776 | ), | | |
| 3777 | ); | | |
| 3778 | } | 3782 | } |
| 3779 | | 3783 | |
| 3780 | const startedAt = yield* nowIso; | 3784 | const startedAt = yield* nowIso; |
| ... | @@ -4466,62 +4470,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -4466,62 +4470,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4466 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( | 4470 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 4467 | function* (threadId, _turnId) { | 4471 | function* (threadId, _turnId) { |
| 4468 | const context = yield* requireSession(threadId); | 4472 | const context = yield* requireSession(threadId); |
| 4469 | // Stop-everything semantics: users reach for Stop precisely when a | 4473 | // interrupt() can acknowledge while resumed background tasks keep the |
| 4470 | // fleet ran away. interrupt() alone only ends the parent turn — | 4474 | // CLI alive. Stop is a hard session boundary for Claude, so close the |
| 4471 | // background subagents/shells keep running and keep burning tokens. | 4475 | // query and let the SDK escalate to SIGKILL when graceful exit fails. |
| 4472 | // Stop every live task first (best-effort per task: one refusal must | 4476 | yield* stopSessionInternal(context); |
| 4473 | // not strand the rest or block the turn interrupt), then interrupt. | | |
| 4474 | if (context.query.stopTask && context.liveTaskIds.size > 0) { | | |
| 4475 | const liveIds = Array.from(context.liveTaskIds); | | |
| 4476 | // Bounded: a wedged child's stopTask promise may never settle | | |
| 4477 | // (Effect.ignore handles rejection, not non-resolution), and the | | |
| 4478 | // parent interrupt below MUST still run — Stop matters most during | | |
| 4479 | // runaway fleets (review finding). Per-task timeout keeps one hung | | |
| 4480 | // child from consuming the whole budget. | | |
| 4481 | yield* Effect.forEach( | | |
| 4482 | liveIds, | | |
| 4483 | (taskId) => | | |
| 4484 | Effect.gen(function* () { | | |
| 4485 | const stopAcknowledged = yield* Effect.tryPromise({ | | |
| 4486 | // Invoke through the query object: SDK methods rely on `this`. | | |
| 4487 | try: () => context.query.stopTask!(taskId), | | |
| 4488 | catch: () => undefined, | | |
| 4489 | }).pipe( | | |
| 4490 | Effect.timeoutOption("3 seconds"), | | |
| 4491 | Effect.orElseSucceed(() => Option.none()), | | |
| 4492 | ); | | |
| 4493 | if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { | | |
| 4494 | return; | | |
| 4495 | } | | |
| 4496 | | | |
| 4497 | // stopTask only acknowledges the control request. Its separate | | |
| 4498 | // task_notification can lose the race with interrupt(), so make | | |
| 4499 | // the acknowledged stop authoritative for the durable UI state. | | |
| 4500 | const stamp = yield* makeEventStamp(); | | |
| 4501 | yield* offerRuntimeEvent({ | | |
| 4502 | type: "task.completed", | | |
| 4503 | eventId: stamp.eventId, | | |
| 4504 | provider: PROVIDER, | | |
| 4505 | createdAt: stamp.createdAt, | | |
| 4506 | threadId: context.session.threadId, | | |
| 4507 | ...(context.turnState | | |
| 4508 | ? { turnId: asCanonicalTurnId(context.turnState.turnId) } | | |
| 4509 | : {}), | | |
| 4510 | payload: { | | |
| 4511 | taskId: RuntimeTaskId.make(taskId), | | |
| 4512 | status: "stopped", | | |
| 4513 | ...taskLinkageFor(context.taskAgents, taskId), | | |
| 4514 | }, | | |
| 4515 | providerRefs: nativeProviderRefs(context), | | |
| 4516 | }); | | |
| 4517 | }).pipe(Effect.ignore), | | |
| 4518 | { concurrency: 8, discard: true }, | | |
| 4519 | ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); | | |
| 4520 | } | | |
| 4521 | yield* Effect.tryPromise({ | | |
| 4522 | try: () => context.query.interrupt(), | | |
| 4523 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), | | |
| 4524 | }); | | |
| 4525 | }, | 4477 | }, |
| 4526 | ); | 4478 | ); |
| 4527 | | 4479 | |
| ... | @@ -4594,25 +4546,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -4594,25 +4546,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4594 | return context !== undefined && !context.stopped; | 4546 | return context !== undefined && !context.stopped; |
| 4595 | }); | 4547 | }); |
| 4596 | | 4548 | |
| 4597 | const stopAll: ClaudeAdapterShape["stopAll"] = () => | 4549 | const stopSessions = Effect.fn("stopSessions")(function* ( |
| 4598 | Effect.forEach( | 4550 | contexts: ReadonlyArray<ClaudeSessionContext>, |
| 4599 | sessions, | 4551 | emitExitEvent: boolean, |
| 4600 | ([, context]) => | 4552 | ) { |
| 4601 | stopSessionInternal(context, { | 4553 | const results = yield* Effect.forEach(contexts, (context) => |
| 4602 | emitExitEvent: true, | 4554 | stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), |
| 4603 | }), | | |
| 4604 | { discard: true }, | | |
| 4605 | ); | 4555 | ); |
| 4606 | | 4556 | |
| | 4557 | for (const result of results) { |
| | 4558 | if (result._tag === "Failure") { |
| | 4559 | return yield* Effect.fail(result.failure); |
| | 4560 | } |
| | 4561 | } |
| | 4562 | }); |
| | 4563 | |
| | 4564 | const stopAll: ClaudeAdapterShape["stopAll"] = () => |
| | 4565 | stopSessions(Array.from(sessions.values()), true); |
| | 4566 | |
| 4607 | yield* Effect.addFinalizer(() => | 4567 | yield* Effect.addFinalizer(() => |
| 4608 | Effect.forEach( | 4568 | stopSessions(Array.from(sessions.values()), false).pipe( |
| 4609 | sessions, | | |
| 4610 | ([, context]) => | | |
| 4611 | stopSessionInternal(context, { | | |
| 4612 | emitExitEvent: false, | | |
| 4613 | }), | | |
| 4614 | { discard: true }, | | |
| 4615 | ).pipe( | | |
| 4616 | Effect.catch((cause) => | 4569 | Effect.catch((cause) => |
| 4617 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), | 4570 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), |
| 4618 | ), | 4571 | ), |