Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f9709b1
Preserve cancellation when AI operation failure observers throw
binaryfire Nov 2, 2026
932a2b9
Preserve AI generation cancellation and child ownership
binaryfire Nov 2, 2026
52b5cd7
Keep producer cancellation through AI stream failure callbacks
binaryfire Nov 2, 2026
dfc6b6d
Preserve conversation turns and cancellation during title work
binaryfire Nov 2, 2026
8af6ce8
Preserve download cancellation over AI attachment size errors
binaryfire Nov 2, 2026
82494bf
Preserve cancellation through MCP server error responses
binaryfire Nov 2, 2026
8521725
Preserve cancellation across MCP handshake fallback and cleanup
binaryfire Nov 2, 2026
506879c
Preserve cancellation while disconnecting managed MCP clients
binaryfire Nov 2, 2026
8c73403
Retain cancellation during MCP HTTP session teardown
binaryfire Nov 2, 2026
78286e1
Preserve cancellation from MCP stdio cleanup
binaryfire Nov 2, 2026
61a367c
Propagate cancellation from OpenTelemetry cache key resolvers
binaryfire Nov 2, 2026
fe6e6bf
Stop OpenTelemetry enrichment after cancellation
binaryfire Nov 2, 2026
bd52357
Preserve cancellation from HTTP telemetry URL resolvers
binaryfire Nov 2, 2026
297fe00
Propagate cancellation from Redis telemetry query resolvers
binaryfire Nov 2, 2026
64fc32f
Preserve cancellation during telemetry user resolution
binaryfire Nov 2, 2026
add5a87
Preserve cancellation from Sentry application callbacks
binaryfire Nov 2, 2026
f5aeaca
Propagate cancellation through Sentry event handlers
binaryfire Nov 2, 2026
f209a39
Preserve cancellation in Sentry tracing event dispatch
binaryfire Nov 2, 2026
5bea4a5
Propagate cancellation from Sentry AI tool schema hooks
binaryfire Nov 2, 2026
6ae89a3
Preserve cancellation during Sentry session key lookup
binaryfire Nov 2, 2026
2bb529b
Preserve cancellation during Telescope value serialization
binaryfire Nov 2, 2026
bfb8284
Stop Telescope monitored-tag fallback after cancellation
binaryfire Nov 2, 2026
7ced718
Preserve cancellation while starting and recording Telescope entries
binaryfire Nov 2, 2026
829b8aa
Propagate cancellation from Telescope HTTP body producers
binaryfire Nov 2, 2026
d76309f
Preserve cancellation during Telescope dump recording checks
binaryfire Nov 2, 2026
b10c3bc
Remove the completed cancellation audit task
binaryfire Nov 2, 2026
9f7e35e
Merge branch '0.4' into fix/cancellation-integrations
binaryfire Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
- Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again.
- Add PHPStan Eloquent extensions that preserve `Eloquent\Builder<TModel>` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations.
- Investigate static analysis that turns the coroutine-safety and worker-lifetime rules in `AGENTS.md` into automated checks, so mistakes no longer depend on agent steering and review alone. Decide which tool fits each check: PHPStan rules for detection with type information, Rector in dry-run mode, or another linter. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`. Ship the useful rules so applications and private packages can enable the same checks.
- Audit remaining cancellation catches in callback, fallback and I/O paths for cleanup failures that hide cancellation in an exception chain. Trace supported execution paths, fix confirmed cases that retry or continue work after cancellation, and add focused regression tests. Preserve valid native-only checks, cleanup-error reporting and foreign-cancellation ownership; an exact exception check alone is not evidence of a defect.
- Investigate a focused PHPStan rule for cancellation checks that miss cleanup exception chains. Detect bare `CanceledException` checks around callbacks or listeners, including `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Use the cancellation regression cases to assess detection and false positives; preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Integrate useful checks with the coroutine-safety analysis above rather than attempting to infer every ownership decision automatically.
- Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect.

Expand Down
4 changes: 2 additions & 2 deletions src/ai/src/Files/UntrustedUrl.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Http\Client\Destinations\DestinationResolutionException;
use Hypervel\Http\Client\Destinations\DisallowedDestinationException;
use Hypervel\Http\Client\Response;
use Hypervel\Support\Facades\Http;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Throwable;

class UntrustedUrl
Expand Down Expand Up @@ -101,7 +101,7 @@ public static function fetch(string $url): Response
try {
$response = $request->get((string) $uri);
} catch (Throwable $exception) {
if ($tooLarge && ! $exception instanceof CanceledException) {
if ($tooLarge && ! Coroutine::causedByCancellation($exception)) {
throw new InvalidArgumentException("The remote file exceeds the maximum size of {$kilobytes} kilobytes.");
}

Expand Down
9 changes: 6 additions & 3 deletions src/ai/src/Gateway/Concerns/InvokesTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ protected function executeTool(Tool $tool, array $arguments, ?string $toolCallId
// Validation failures are returned to the model so it can correct the arguments and retry...
$result = implode(' ', $exception->validator->errors()->all()) ?: $exception->getMessage();
} catch (Throwable $exception) {
$context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt));

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt));
}
}

$context?->toolInvoked($tool, $arguments, $result, $toolInvocationId, $this->elapsedMilliseconds($startedAt));
Expand Down
41 changes: 28 additions & 13 deletions src/ai/src/Gateway/TextGenerationLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
use Hypervel\Ai\Tools\ToolNameResolver;
use Hypervel\Context\CoroutineContext;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Coroutine\Exceptions\ChildCancellationException;
use Hypervel\Coroutine\WaitConcurrent;
use Hypervel\Database\ConnectionResolver;
use Hypervel\Engine\Channel;
Expand Down Expand Up @@ -197,9 +198,12 @@ public function generate(

$lastResult = $stepResult->response();
} catch (Throwable $exception) {
$this->stepFailed($context, $attempt, $exception);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$this->stepFailed($context, $attempt, $exception);
}
}

$prepared = $attempt->step ?? $pending;
Expand Down Expand Up @@ -387,9 +391,12 @@ public function stream(
$result->reasoning = ReasoningDelta::combine($reasoningDeltas);
}
} catch (Throwable $exception) {
$this->stepFailed($context, $attempt, $exception);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$this->stepFailed($context, $attempt, $exception);
}
}

// A provider may report an error in the stream itself rather than throwing, which still ends the step. The error event travels on the exception so its type and metadata are not lost...
Expand Down Expand Up @@ -542,9 +549,12 @@ protected function attempt(PendingStep $step, ?PendingStep $previous, array $his
try {
$source = $call($stepContext);
} catch (Throwable $exception) {
$context?->stepFailed($stepContext, $exception, $this->elapsedMilliseconds($startedAt), $step->model);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$context?->stepFailed($stepContext, $exception, $this->elapsedMilliseconds($startedAt), $step->model);
}
}

return new StepResult($source, $step, $stepContext, $startedAt);
Expand Down Expand Up @@ -859,9 +869,12 @@ protected function executeAgentToolStreaming(AgentTool $tool, array $arguments,

$result = (string) $events->getReturn();
} catch (Throwable $exception) {
$context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt));

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt));
}
}

$context?->toolInvoked($tool, $arguments, $result, $toolInvocationId, $this->elapsedMilliseconds($startedAt));
Expand Down Expand Up @@ -956,7 +969,9 @@ private function concurrentToolResults(array $resolved, int $max, Closure $execu
try {
$runner === null ? $work() : $runner($work);
} catch (Throwable $exception) {
$failure ??= $exception;
$failure ??= Coroutine::causedByCancellation($exception)
? new ChildCancellationException('An AI tool was canceled while its owner remained active.', previous: $exception)
: $exception;
$events->close();
}
}, [self::REPAIR_TOOL_CALLS_CONTEXT_KEY, ParentInvocation::PARENT_INVOCATION_CONTEXT_KEY, AiManager::ON_DEMAND_PROVIDERS_CONTEXT_KEY, ContextRepository::CONTEXT_KEY, ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY]);
Expand Down
100 changes: 56 additions & 44 deletions src/ai/src/Middleware/RememberConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Hypervel\Ai\Support\ConversationSelection;
use Hypervel\Ai\Support\PendingConversationTitle;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Coroutine\Exceptions\ChildCancellationException;
use Hypervel\Support\ClassMetadataCache;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Str;
Expand Down Expand Up @@ -76,9 +77,12 @@ public function handle(AgentPrompt $prompt, Closure $next): AgentResponse|Stream
try {
$response = $next($prompt);
} catch (Throwable $exception) {
$this->rememberFailedTurn($prompt, $exception, $pendingConversationId);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure recording throws.
try {
throw $exception;
} finally {
$this->rememberFailedTurn($prompt, $exception, $pendingConversationId);
}
}

// Record each failed producer attempt; public catch callbacks run only once per response.
Expand All @@ -97,9 +101,12 @@ public function handle(AgentPrompt $prompt, Closure $next): AgentResponse|Stream
yield from $produce();
} catch (Throwable $exception) {
$threw = true;
$this->rememberFailedTurn($prompt, $exception, $pendingConversationId, retryable: ! $responseReference->get()->hasYielded());

throw $exception;
// Keep the original exception, including cancellation, as previous if failure recording throws.
try {
throw $exception;
} finally {
$this->rememberFailedTurn($prompt, $exception, $pendingConversationId, retryable: ! $responseReference->get()->hasYielded());
}
}

$completed = true;
Expand Down Expand Up @@ -210,6 +217,8 @@ protected function rememberTurn(AgentPrompt $prompt, AgentResponse $response, ?s
if (($exception === null || ! Coroutine::causedByCancellation($exception)) && ! $exception instanceof StreamAbandonedException) {
try {
$title = $prompt->pendingConversationTitle()?->value() ?? $this->generateTitle($prompt->prompt);
} catch (ChildCancellationException) {
// An independently canceled title must not discard the caller's completed turn.
} catch (Throwable $caught) {
if (! Coroutine::causedByCancellation($caught)) {
throw $caught;
Expand All @@ -226,53 +235,56 @@ protected function rememberTurn(AgentPrompt $prompt, AgentResponse $response, ?s
}
}

if ($this->store instanceof StoresConversationTurns) {
$stored = $this->store->storeTurn($conversationId, $title, $participantType, $participantId, $prompt, $response, $exception);
} else {
if ($newConversation) {
$conversationId = $this->store->storeConversation(
// Persist the finished turn if the caller was canceled during title generation; storage failures retain that cancellation.
try {
if ($cancellation !== null) {
throw $cancellation;
}
} finally {
if ($this->store instanceof StoresConversationTurns) {
$stored = $this->store->storeTurn($conversationId, $title, $participantType, $participantId, $prompt, $response, $exception);
} else {
if ($newConversation) {
$conversationId = $this->store->storeConversation(
$participantType,
$participantId,
$title,
$conversationId,
);
}

// A resume continues the turn its decisions answer, so it adds no message of its own...
$userMessageId = $prompt->hasApprovalDecisions() ? null : $this->store->storeUserMessage(
$conversationId,
$participantType,
$participantId,
$title,
$conversationId,
$agent::class,
new UserMessage($prompt->prompt, $prompt->attachments),
);
}

// A resume continues the turn its decisions answer, so it adds no message of its own...
$userMessageId = $prompt->hasApprovalDecisions() ? null : $this->store->storeUserMessage(
$conversationId,
$participantType,
$participantId,
$agent::class,
new UserMessage($prompt->prompt, $prompt->attachments),
);

$assistantMessageId = $this->store->storeAssistantMessage(
$conversationId,
$participantType,
$participantId,
$prompt,
$response,
$exception,
);

$stored = new StoredTurn($conversationId, $userMessageId, $assistantMessageId);
}
$assistantMessageId = $this->store->storeAssistantMessage(
$conversationId,
$participantType,
$participantId,
$prompt,
$response,
$exception,
);

if ($newConversation) {
// Completing a lazy operation must not replace a selection the caller changed meanwhile.
if ($agent->currentConversation() === $selection->conversationId && $agent->conversationParticipant() === $participant) {
$agent->continue($stored->conversationId, $participant);
$stored = new StoredTurn($conversationId, $userMessageId, $assistantMessageId);
}

$selection->conversationId = $stored->conversationId;
}
if ($newConversation) {
// Completing a lazy operation must not replace a selection the caller changed meanwhile.
if ($agent->currentConversation() === $selection->conversationId && $agent->conversationParticipant() === $participant) {
$agent->continue($stored->conversationId, $participant);
}

$response->withinConversation($stored->conversationId, $participant)
->withStoredMessages($stored->userMessageId, $stored->assistantMessageId);
$selection->conversationId = $stored->conversationId;
}

if ($cancellation !== null) {
throw $cancellation;
$response->withinConversation($stored->conversationId, $participant)
->withStoredMessages($stored->userMessageId, $stored->assistantMessageId);
}

return $stored;
Expand Down
9 changes: 6 additions & 3 deletions src/ai/src/Providers/Concerns/GeneratesText.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,12 @@ public function prompt(AgentPrompt $prompt): AgentResponse
return $agentResponse;
});
} catch (Throwable $exception) {
$this->recordAgentFailure($invocationId, $prompt, $exception);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$this->recordAgentFailure($invocationId, $prompt, $exception);
}
}

if ($this->events->hasListeners(AgentPrompted::class)) {
Expand Down
9 changes: 6 additions & 3 deletions src/ai/src/Providers/Concerns/StreamsText.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,12 @@ function () use ($invocationId, $prompt, $agent, $messages, $tools, $approval, $
return $streamable;
});
} catch (Throwable $exception) {
$this->recordAgentFailure($invocationId, $prompt, $exception);

throw $exception;
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$this->recordAgentFailure($invocationId, $prompt, $exception);
}
}

// Avoid retaining the response through its own failure callback.
Expand Down
17 changes: 10 additions & 7 deletions src/ai/src/Providers/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,17 @@ protected function dispatchingFailure(Closure $call, Closure $failed): mixed
try {
return $call();
} catch (Throwable $exception) {
$event = $failed($exception);

// Passive observers alone must not trigger optional failure events; hasListeners() excludes them.
if ($this->events->hasListeners($event::class)) {
$this->events->dispatch($event);
// Keep the original exception, including cancellation, as previous if failure reporting throws.
try {
throw $exception;
} finally {
$event = $failed($exception);

// Passive observers alone must not trigger optional failure events; hasListeners() excludes them.
if ($this->events->hasListeners($event::class)) {
$this->events->dispatch($event);
}
}

throw $exception;
}
}

Expand Down
Loading
Loading