| @@ -282,9 +282,6 @@ interface ClaudeSessionContext { · 281 unmodified lines |
| 282 | 282 | } |
| 283 | 283 | |
| 284 | 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 | 285 | readonly setModel: (model?: string) => Promise<void>; |
| 289 | 286 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; |
| 290 | 287 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; |
| @@ -2106,13 +2103,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1815 unmodified lines |
| 2106 | 2103 | } catch { |
| 2107 | 2104 | return undefined; |
| 2108 | 2105 | } |
| 2109 | | − }); |
| 2110 | | − if (!usage) { |
| 2106 | + }).pipe(Effect.timeoutOption("1 second")); |
| 2107 | + if (Option.isNone(usage) || !usage.value) { |
| 2111 | 2108 | return undefined; |
| 2112 | 2109 | } |
| 2113 | 2110 | |
| 2114 | | − context.lastKnownContextWindow = usage.maxTokens; |
| 2115 | | − return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); |
| 2111 | + context.lastKnownContextWindow = usage.value.maxTokens; |
| 2112 | + return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); |
| 2116 | 2113 | }); |
| 2117 | 2114 | |
| 2118 | 2115 | const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( |
| @@ -3636,8 +3633,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1517 unmodified lines |
| 3636 | 3633 | ) { |
| 3637 | 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 | 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 | 3672 | for (const [requestId, pending] of context.pendingApprovals) { |
| 3642 | 3673 | yield* Deferred.succeed(pending.decision, "cancel"); |
| 3643 | 3674 | const stamp = yield* makeEventStamp(); |
| @@ -3676,26 +3707,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 32 unmodified lines |
| 3676 | 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 | 3710 | const updatedAt = yield* nowIso; |
| 3700 | 3711 | context.session = { |
| 3701 | 3712 | ...context.session, |
| @@ -3704,7 +3715,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 2 unmodified lines |
| 3704 | 3715 | updatedAt, |
| 3705 | 3716 | }; |
| 3706 | 3717 | |
| 3707 | | − if (options?.emitExitEvent !== false) { |
| 3718 | + if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { |
| 3708 | 3719 | const stamp = yield* makeEventStamp(); |
| 3709 | 3720 | yield* offerRuntimeEvent({ |
| 3710 | 3721 | type: "session.exited", |
| @@ -3720,7 +3731,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 9 unmodified lines |
| 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 | 3739 | const requireSession = ( |
| @@ -3765,16 +3778,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 38 unmodified lines |
| 3765 | 3778 | }); |
| 3766 | 3779 | yield* stopSessionInternal(existingContext, { |
| 3767 | 3780 | emitExitEvent: false, |
| 3768 | | − }).pipe( |
| 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 | | − ); |
| 3781 | + }); |
| 3778 | 3782 | } |
| 3779 | 3783 | |
| 3780 | 3784 | const startedAt = yield* nowIso; |
| @@ -4466,62 +4470,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 685 unmodified lines |
| 4466 | 4470 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 4467 | 4471 | function* (threadId, _turnId) { |
| 4468 | 4472 | const context = yield* requireSession(threadId); |
| 4469 | | − // Stop-everything semantics: users reach for Stop precisely when a |
| 4470 | | − // fleet ran away. interrupt() alone only ends the parent turn — |
| 4471 | | − // background subagents/shells keep running and keep burning tokens. |
| 4472 | | − // Stop every live task first (best-effort per task: one refusal must |
| 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 | | − }); |
| 4473 | + // interrupt() can acknowledge while resumed background tasks keep the |
| 4474 | + // CLI alive. Stop is a hard session boundary for Claude, so close the |
| 4475 | + // query and let the SDK escalate to SIGKILL when graceful exit fails. |
| 4476 | + yield* stopSessionInternal(context); |
| 4525 | 4477 | }, |
| 4526 | 4478 | ); |
| 4527 | 4479 | |
| @@ -4594,25 +4546,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 66 unmodified lines |
| 4594 | 4546 | return context !== undefined && !context.stopped; |
| 4595 | 4547 | }); |
| 4596 | 4548 | |
| 4597 | | − const stopAll: ClaudeAdapterShape["stopAll"] = () => |
| 4598 | | − Effect.forEach( |
| 4599 | | − sessions, |
| 4600 | | − ([, context]) => |
| 4601 | | − stopSessionInternal(context, { |
| 4602 | | − emitExitEvent: true, |
| 4603 | | − }), |
| 4604 | | − { discard: true }, |
| 4549 | + const stopSessions = Effect.fn("stopSessions")(function* ( |
| 4550 | + contexts: ReadonlyArray<ClaudeSessionContext>, |
| 4551 | + emitExitEvent: boolean, |
| 4552 | + ) { |
| 4553 | + const results = yield* Effect.forEach(contexts, (context) => |
| 4554 | + stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), |
| 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 | 4567 | yield* Effect.addFinalizer(() => |
| 4608 | | − Effect.forEach( |
| 4609 | | − sessions, |
| 4610 | | − ([, context]) => |
| 4611 | | − stopSessionInternal(context, { |
| 4612 | | − emitExitEvent: false, |
| 4613 | | − }), |
| 4614 | | − { discard: true }, |
| 4615 | | − ).pipe( |
| 4568 | + stopSessions(Array.from(sessions.values()), false).pipe( |
| 4616 | 4569 | Effect.catch((cause) => |
| 4617 | 4570 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), |
| 4618 | 4571 | ), |