mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(managed-agent): implement durable Hosted Hooks (H2) (#13129)
* feat(managed-agent): implement durable Hosted Hooks (H2)
* style(runtime-broker): remove blank line rejected by Checkstyle
* test(managed-hooks): align CI guards with H2 enablement
* fix(managed-agent): select current Turn by admission sequence
* fix(hooks): keep managed command environment out of argv
* fix(serve): close Hosted Hook recovery and capacity gaps
* fix(serve): settle cancelled startup hooks and identify deletions
* fix(hooks): settle cancelled callbacks and retry journal commits
* test(hosted): exhaust identical Store commit retries in fault gates
* fix(hooks): retain HTTP completion evidence after managed cancellation
* fix(hooks): settle cancellation before occurrence planning
* fix(hooks): settle proven-unstarted pre-tool cancellations
* fix(hooks): recover initialization and bounded cancellation results
* fix(hooks): settle cancelled initialization and bounded tool results
* fix(ci): bound hosted browser dependency installation
* fix(ci): record the ci.yml growth in the workflow size baseline
The hosted-browser install step adds two lines to ci.yml, taking it to
147136 bytes: 4110 over the recorded 143026 and 14 past the 4096-byte
allowance, which is why "Check workflow file size" failed. Most of that
growth is main-side drift the allowance had been absorbing, but this PR
touches the file, so the ratchet's stale-baseline leniency does not apply
and the number has to move in the same PR. The growth is real — a bounded
apt/Playwright install — so bump the entry instead of shrinking the
workflow.
Verified locally: .github/scripts/check-workflow-size.sh exits 0 against
base a7deb01bcb, and scripts/tests/workflow-size.test.js passes 220/220.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmupvw3d00b
* fix(hosted): refuse cancellation with an unknown execution outcome
* fix(ci): reserve browser smoke time after slow dependency downloads
* fix(ci): keep time for smoke after slow hosted installs
* test(web-shell): wait for step expansion before selecting next group
* test(ci): align hosted browser timeout contract
* fix(hooks): preserve recovery owners and diagnostics
---------
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: jinjing.zzj <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
95 files changed
+17410
-787
No files matched your search
@@ -19,7 +19,7 @@
|
||||
9256 build-and-publish-image.yml
|
||||
50773 cd-cua-driver.yml
|
||||
2222 cd-mobile-mcp.yml
|
||||
143026 ci.yml
|
||||
147136 ci.yml
|
||||
1482 codeql.yml
|
||||
9389 comment-attachment-guard.yml
|
||||
1634 desktop-packaging-check.yml
|
||||
|
||||
@@ -1362,9 +1362,9 @@ jobs:
|
||||
# document performance budget on ECS, while both gates passed on hosted.
|
||||
# Keep this browser job hosted without relaxing its test assertions.
|
||||
runs-on: 'ubuntu-latest'
|
||||
# 184 smoke tests plus ~7.5 min of setup now finish right at 20 min, so
|
||||
# passing runs get cancelled in cleanup. Sharding is the real fix.
|
||||
timeout-minutes: 30
|
||||
# Slow hosted mirrors kept downloading past 23 min; reserve time for
|
||||
# the 208 smoke tests and artifact cleanup after installation.
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
@@ -1533,7 +1533,9 @@ jobs:
|
||||
|
||||
- name: 'Install Playwright Chromium and WebKit (hosted)'
|
||||
if: "${{ runner.environment == 'github-hosted' }}"
|
||||
timeout-minutes: 30
|
||||
run: |-
|
||||
printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null
|
||||
node node_modules/playwright/cli.js install --with-deps chromium webkit
|
||||
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
|
||||
if [ -f "${nested_cli}" ]; then
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Managed Hooks runtime (H2)
|
||||
|
||||
[English](2026-09-30-managed-hooks-runtime.md) | [简体中文](2026-09-30-managed-hooks-runtime.zh-CN.md)
|
||||
|
||||
Status: implemented and locally validated; Linux cgroup execution validation
|
||||
remains pending. This implements H2 of [#12827](https://github.com/QwenLM/qwen-code/issues/12827), following H1.
|
||||
The references are section 5 of the
|
||||
[extension runtime design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md)
|
||||
and section 6 of the
|
||||
[configuration design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-config-extensions.md).
|
||||
|
||||
## Problem and scope
|
||||
|
||||
Hosted Sessions disable ambient Hooks. Legacy runners keep local processes,
|
||||
callbacks and once state in memory, which cannot prove what happened after a
|
||||
Harness replacement. H2 commits plans and execution identities before effects,
|
||||
then reconciles the original owner after uncertainty. An after Hook failure
|
||||
must not change a successful tool's receipt.
|
||||
|
||||
The implementation extends the existing private Hosted Workspace profiles and
|
||||
adds the public read-only Hook catalog. Production AgentBundle enablement remains
|
||||
separate, as in H1. It does not enable unrelated H3/H4 capabilities or start a
|
||||
second main Agent loop. The native Hook dispatcher accepts every existing event;
|
||||
producer availability continues to follow the selected profile.
|
||||
|
||||
## Records and planning
|
||||
|
||||
`hook_registration` pins a deployment catalog and immutable resource.
|
||||
`hook_execution` records occurrence, ordinal, registration, plan, effective input,
|
||||
original Runtime Session, cancellation intent, physical state and result reference.
|
||||
Both use the existing `ExtensionRun` validator and `domain.committed` journal.
|
||||
They do not create user task entries. TypeScript and Java consume shared positive
|
||||
and negative fixtures and enforce resource closure, stable pins, unique ordinals,
|
||||
and atomic consumption of once keys at intent.
|
||||
|
||||
Each occurrence fixes its catalog and plan. Occurrences are admitted serially
|
||||
within a Session so concurrent events cannot reserve the same once Hook; execution
|
||||
inside each plan retains native parallel/sequential behavior. Repeated IDs with different semantic
|
||||
input are rejected. Sequential execution persists each effective input and reuses
|
||||
native prompt-context and tool-input accumulation. Parallel results aggregate in
|
||||
plan order. Fail-open/fail-closed policy is applied before saving the result, also
|
||||
when status reconciliation supplies the receipt. Failed or unknown once attempts
|
||||
do not become eligible again.
|
||||
|
||||
With a ready catalog and no pending recovery, cancellation before a new occurrence
|
||||
still saves its plan and cancelled result, without dispatching a child Hook or
|
||||
consuming its once key. PreToolUse can then durably refuse every committed
|
||||
assistant tool call, including later calls in the same batch, before the turn
|
||||
ends as cancelled. An unknown prior effect or a failed journal write still blocks
|
||||
this continuation.
|
||||
|
||||
Catalog replacement is an idempotent registration operation with an expected
|
||||
registration count. Revisions increase within each catalog namespace. Retrying an
|
||||
older operation acknowledges it without restoring an older effective catalog.
|
||||
Removing a Hook from a new revision affects future occurrences only. Deployed
|
||||
Session/agent entries carry explicit owner metadata; filtering precedes native
|
||||
configuration deduplication so one agent cannot inherit another agent's handlers.
|
||||
|
||||
## Runtime ownership
|
||||
|
||||
Set `QWEN_MANAGED_HOOK_CONFIG` on the Tool Runtime process to a deployment-owned
|
||||
JSON manifest. Its version is `1`; each catalog has `tenantId`, `workspaceId`,
|
||||
`catalogId`, `catalogRevision`, a 64-character hexadecimal `definitionDigest`, and
|
||||
`hooks`. Each Hook specifies its ID, event, matcher, execution policy and recipe.
|
||||
Command/HTTP recipes and trusted function module paths stay at the Runtime;
|
||||
the Harness receives planning metadata and prompt definitions. Function modules
|
||||
export a versioned object with `callback` and optional `onHookSuccess`.
|
||||
Unavailable handlers block accurately instead of evaluating serialized closures.
|
||||
Function context uses the live Session history, with a durable snapshot for replay.
|
||||
Only matching function plans store messages; large snapshots use immutable
|
||||
60 KiB parts. The full control envelope is bounded at 8 MiB and checked before
|
||||
network dispatch, without truncating messages or inventing an unknown outcome.
|
||||
Snapshots, plans and reference metadata also share the Session Store’s existing
|
||||
8 MiB transaction budget. Snapshot admission reserves 256 KiB for the plan, input,
|
||||
chunk manifest and record closure; oversized snapshots become bounded stops before
|
||||
publication. The commit includes the entire snapshot reference
|
||||
closure; the Store rejects a missing manifest or part atomically.
|
||||
|
||||
The manifest is an explicit deployment catalog. It does not import the Harness
|
||||
host's ambient user/project settings or arbitrary client callbacks. Source
|
||||
metadata is sorted using the native registry; Session registrations retain their
|
||||
native append behavior. Migrating Legacy settings into a deployment catalog must
|
||||
preserve its already-resolved user/project merged fallback and trust decisions.
|
||||
|
||||
All worker Hook routes are **live-session-owner scoped**. Broker forwarding
|
||||
validates tenant, workspace and Session, resolves the saved Runtime, and checks
|
||||
workspace generation and the operation grant before new effects. Status and
|
||||
cancel only inspect the original execution. Missing or replaced owners remain
|
||||
unknown; no status request dispatches a replacement effect.
|
||||
A Hook owner ID is fixed at construction from the already durable activation ID
|
||||
and epoch. A new load installs a new activation, so it never reuses a released
|
||||
Runtime Session ID. Recovery can therefore find even an owner acquired only for
|
||||
a catalog request before the first Hook execution record. Earlier random owner
|
||||
IDs remain recoverable through execution records; an old random owner with no
|
||||
durable execution record requires operator recovery.
|
||||
Before a replacement Hook owner acquires the Workspace, it releases earlier
|
||||
owners whose Hook records are all terminal, including owners reconciled through
|
||||
status. Tool-result continuation uses the same acquisition path. Broker checks
|
||||
still reject release while shared physical work is active. Only an explicit
|
||||
`runtime_session_not_found` is accepted as an already absent owner. Detach also
|
||||
releases settled earlier owners. An attached idle owner retains the Workspace
|
||||
lease, as MCP does; changing that lifetime is a separate design task.
|
||||
|
||||
Commands reuse native output, timeout and TERM/KILL handling. Managed execution
|
||||
requires Linux cgroup v2 delegation: set `QWEN_MANAGED_HOOK_CGROUP_ROOT` to a
|
||||
writable domain with `cgroup.kill`. A clean launcher enters a fresh unit before
|
||||
starting the command. Membership survives `setsid` and detached descendants;
|
||||
completion requires `cgroup.events` to report no remaining processes. Cancellation
|
||||
sends TERM, then uses `cgroup.kill` if needed, and retains the owner when emptiness
|
||||
cannot be proved. This is lifecycle isolation for deployment-owned trusted Hooks,
|
||||
not a sandbox against scripts deliberately modifying the cgroup control plane.
|
||||
|
||||
Missing isolation, including macOS and Windows, returns
|
||||
`managed_hook_command_isolation_unavailable` before command execution. The ledger
|
||||
records `not_started_proven` with `handler_unavailable`, and cancellation can close
|
||||
the blocked occurrence. For SessionStart, UserPromptSubmit and native
|
||||
InstructionsLoaded before model execution, explicit cancellation
|
||||
also settles the admitted turn identified by the Hook's durable input `prompt_id`
|
||||
when no model attempt, tool intent/receipt or non-user message has followed
|
||||
admission, and no Hook remains pending. An earlier SessionStart cancellation cannot
|
||||
settle a later turn. For a refused PreToolUse child explicitly cancelled with
|
||||
`not_started_proven`, recovery may also close the first committed tool-call batch:
|
||||
all model attempts must have ended, at least one has committed output, no tool
|
||||
intent/receipt, pending approval, file-history work or Hook may remain, and the
|
||||
cancelled child's occurrence and input must match an original call in the sole
|
||||
unsettled turn. Recovery persists a matching refusal for every missing call before
|
||||
settling the turn as cancelled. Responses are split by the actual UTF-8 size of
|
||||
each complete record, including metadata, to respect the 64 KiB inline limit;
|
||||
each successful record becomes the parent of the next. A response that cannot
|
||||
fit alone retains the recovery barrier without truncating its identity.
|
||||
New batches check each cancellation response before committing the assistant
|
||||
message, rejecting oversized calls before tool acquisition or dispatch.
|
||||
Already committed responses are preserved across partial batches; failed
|
||||
writes retain the barrier and retries do not duplicate them. This recovery is
|
||||
serialized with turn and control admission and also runs on load for previously
|
||||
cancelled records. Other model/tool continuations stay blocked. A process group
|
||||
alone is never accepted as proof.
|
||||
Environment inheritance is restricted to execution necessities and explicit
|
||||
recipe entries. Async admission waits for the Runtime acknowledgement; it is not
|
||||
a completion receipt. Normal acknowledged async work may coexist with later
|
||||
turns; unknown work blocks new admission and retains its Runtime hold.
|
||||
|
||||
For a cancelled or timed-out trusted function Hook, Runtime waits up to one second
|
||||
for its actual callback Promise to settle before issuing a terminal failure receipt.
|
||||
A callback that never started is also safe to settle. Callback resolution or
|
||||
rejection is completion evidence; an abort signal alone is not. A callback still
|
||||
pending after the grace period keeps its unknown outcome and original Runtime
|
||||
hold, with no replay. Native Legacy function-Hook cancellation remains unchanged.
|
||||
|
||||
Workspace Write/Edit backups and explicit rewind share the Session's Hook Runtime
|
||||
owner, while snapshots retain the actual prompt identity. Acknowledged async Hooks
|
||||
may coexist with history bind, prepare and snapshot; rewind and owner release still
|
||||
require all Hook executions to settle. Rewind also excludes Hook catalog/model
|
||||
operations and inactive activations. On cold load, pending file history is observed
|
||||
through the original Runtime owner saved in the matching checkpoint tool inputs,
|
||||
before earlier Hook owners are released. Missing or conflicting owner evidence
|
||||
keeps recovery blocked; no new durable field is needed.
|
||||
Shell receipt recovery also reads the original Runtime owner from its committed
|
||||
tool input when acknowledging capture delivery; neither the prompt ID nor the
|
||||
replacement Hook owner identifies that execution.
|
||||
|
||||
HTTP uses native URL/DNS, credential-variable and timeout policy. Redirects are
|
||||
refused. The Runtime URL allowlist uses the same allowed-variable interpolation
|
||||
as the HTTP runner; internal secrets and native SSRF checks remain enforced.
|
||||
A local runner-construction failure before dispatch is a settled failure
|
||||
under the saved fail policy. A received failure response can settle; a lost response after sending
|
||||
remains unknown and cannot be retried automatically. Hook outputs are bounded.
|
||||
For an in-flight managed HTTP Hook, user cancellation keeps the original request
|
||||
and body read alive until the configured HTTP timeout so a complete response can
|
||||
provide settlement evidence. The occurrence still ends as cancelled. Cancellation
|
||||
before dispatch sends no request. Runtime shutdown aborts the transport immediately;
|
||||
shutdown, timeout and disconnected or partial responses after dispatch remain
|
||||
unknown. Native HTTP cancellation keeps its existing immediate-abort behavior.
|
||||
Individual receipts and aggregate outputs use a 60 KiB bound. An oversized
|
||||
initial plan (including event input, descriptors and snapshot references) saves a
|
||||
bounded blocking receipt and a digest of the original semantic input. Recovery
|
||||
returns that refusal without dispatch or once consumption, including when no
|
||||
handlers matched; changed input for the same occurrence remains a conflict.
|
||||
An oversized
|
||||
individual receipt becomes a bounded failure under its saved fail-open/fail-closed
|
||||
policy. An oversized aggregate or next sequential input instead stops the
|
||||
occurrence with a durable blocking result, preserving completed child receipts;
|
||||
later undispatched Hooks do not consume once keys. Recovery derives the same
|
||||
result from the saved receipts without repeating effects.
|
||||
Combined tool Hook context is checked against the existing history record limit;
|
||||
if it cannot fit, orchestration stops with the original tool and Hook receipts
|
||||
preserved, without writing or retrying an oversized history record.
|
||||
Runtime concurrency is limited to 16 active operations and catalogs to 128 Hooks.
|
||||
Concurrency refusals retain bounded failure receipts under the saved fail policy.
|
||||
Runtime keeps at most 4096 operation receipts, including these refusals, without
|
||||
eviction or replay. Once full, it returns an explicit blocking receipt before
|
||||
installing grants, including a PermissionRequest deny regardless of fail policy;
|
||||
the Harness persists that outcome. An immediate settled refusal is returned
|
||||
before asynchronous admission can report success. This permanent capacity limit is distinct from
|
||||
the temporary concurrency refusal. Capacity reclamation needs a durable
|
||||
acknowledgement protocol and is deferred;
|
||||
an unrecorded refusal whose response is lost remains unknown on lookup.
|
||||
|
||||
H2 explicitly accepts an unbounded availability loss for a genuine unknown.
|
||||
An unknown SessionEnd or SessionDelete prevents DELETE from completing: it returns
|
||||
503 and retains the attached Session and original Workspace owner. Retries observe
|
||||
the same saved occurrence; they do not redispatch it. Detach also requires all
|
||||
Hook effects to settle. The retained owner can block tools in other Sessions in
|
||||
that Workspace; it does not block a different Workspace's Runtime worker.
|
||||
Unknown operations count toward the worker's 16-operation admission limit and
|
||||
retain their holds for its lifetime. All saved receipts, including settled ones,
|
||||
count toward the 4096 lifetime limit. Neither timeout, user cancellation, DELETE
|
||||
nor process replacement proves completion or permits replay. H2 provides no
|
||||
administrative abandonment route; receipt reconciliation or durable fenced
|
||||
reclamation is tracked in [#13133](https://github.com/QwenLM/qwen-code/issues/13133).
|
||||
This is an accepted recovery limitation, not a bounded recovery or availability
|
||||
guarantee, and does not declare the original review findings resolved.
|
||||
|
||||
## Model activation
|
||||
|
||||
Only the Harness runs prompt Hooks. In-turn Hooks use the turn's exclusive model
|
||||
scope. Notification, expansion and closing events can acquire a `hook_operation`
|
||||
activation without creating a user turn, task completion, tool loop or startup
|
||||
Hook. Both subjects use the Session's monotonically increasing activation epoch.
|
||||
The scope remains held until the provider call actually settles, even if the
|
||||
provider ignores timeout cancellation. If installing a Hook activation fails after
|
||||
release, the controller still restores the Session activation. If restoration
|
||||
also fails, new prompts are rejected before admission until activation recovery.
|
||||
HTTP journal commits retry a transient transport failure, 429 or 5xx response up
|
||||
to three attempts, 250 ms apart, using the identical transaction identity, record
|
||||
bytes, resources and writer scope. A replay must return the matching original
|
||||
receipt before local authority advances. Permanent rejection, a mismatched
|
||||
receipt or exhausted retries still stops writes and requires journal recovery;
|
||||
no failure clears the write fence or installs a different speculative activation.
|
||||
|
||||
Model attempts and usage are associated with the original Hook operation and
|
||||
originating turn when present. Budget accounting follows the existing Session
|
||||
and turn budget semantics; Hook operations must not reset the original budget.
|
||||
There is no new monetary-budget policy or independent Hook token pool.
|
||||
Isolated model Config cleanup failures are logged separately and do not replace
|
||||
an already returned Hook result or the primary operation error.
|
||||
|
||||
## Event integration
|
||||
|
||||
| Events | Producer and ordering |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SessionStart, UserPromptSubmit | Hosted startup occurs once; original submit and effective model input remain distinct. A blocking before Hook prevents the model request. |
|
||||
| UserPromptExpansion, Notification | Authenticated private operation route with stable caller occurrence. Expansion output is returned to its caller for submission. |
|
||||
| PermissionRequest, PreToolUse | Permission comes first. Modified arguments are validated again and require a new Action when the policy asks. No physical after event is fabricated for a refused call. |
|
||||
| PostToolUse, PostToolUseFailure, PostToolBatch | Native receipts are committed first. Physical success/error/cancel selects the event; batch waits for all results. Recovery adds Hook context without repeating tools or history. |
|
||||
| MessageDisplay, Stop, StopFailure | Final display has its own stable occurrence; suppression affects returned parts. Stop may continue inference. Actual model API failure emits StopFailure. |
|
||||
| InstructionsLoaded, PreCompact, PostCompact | Existing native producers delegate to the durable dispatcher. Initial instruction events wait for model authentication; Hook recovery errors propagate through compaction. |
|
||||
| PermissionDenied, SubagentStart/Stop, TodoCreated/Completed | The complete native event bridge preserves producer payloads, owner identities and Todo phases. These are emitted when their native capability runs; Hosted file/shell/MCP profiles do not fabricate AUTO, child or Todo actions. |
|
||||
| SessionEnd, SessionDelete | Explicit deletion drains prior operations, runs lifecycle Hooks, then releases Runtime/provider/writer ownership. Detach emits neither event. |
|
||||
|
||||
The dispatcher avoids loading or executing ambient Legacy Hooks in a Managed
|
||||
Config. Explicit Hosted producers are excluded from duplicate native emission.
|
||||
A reconstructed activation is not a new startup or user resume. Stop occurrences
|
||||
use durable model attempt IDs. Only a blocking Stop activates the continuation
|
||||
flag; an ordinary tool round does not. An explicit after/batch Hook stop ends
|
||||
orchestration while preserving physical receipts and their unconsumed status,
|
||||
and leaves the Session ready for another turn.
|
||||
|
||||
When the loaded catalog contains Stop or MessageDisplay, model text stays buffered
|
||||
until their decisions complete. Discarded or suppressed drafts never become durable
|
||||
text deltas. Sessions without either output policy retain incremental streaming.
|
||||
|
||||
## Interfaces and compatibility
|
||||
|
||||
Session creation/load accepts `hookCatalog: {catalogId, catalogRevision,
|
||||
definitionDigest}` with a Hosted Workspace tool profile and Broker. The saved
|
||||
initial pin is restored when omitted on load and must match when supplied; later
|
||||
committed registrations remain authoritative. Workspace cold load verifies Hook
|
||||
record resources and the complete function-message snapshot closure before
|
||||
attachment, while retaining original-owner recovery barriers.
|
||||
Hook scope identifiers accept the existing Broker character grammar, including
|
||||
leading punctuation. Session status reports `recoveryBlocked`, and load reports
|
||||
`recoveryRequired`, while Hook operations block prompt admission. Reconciliation
|
||||
clears this diagnostic when the saved operation actually settles.
|
||||
|
||||
Runtime-only takeover flags keep Hook Sessions on the existing Hook-aware load
|
||||
reconciliation path. The Runtime-only continue/cancel routes refuse Hook Sessions
|
||||
with `hosted_hook_recovery_required` before changing records or Runtime ownership;
|
||||
they cannot settle a turn while ignoring its pending Hook effects or model scope.
|
||||
|
||||
Private Session/client-scoped routes provide `GET /session/:id/hooks`, registration
|
||||
updates, Notification/expansion operations, and operation status/cancel. Mutations
|
||||
cannot overlap a turn or another control operation. The public tenant/actor-scoped
|
||||
`GET /v1/agents/sessions/{sessionId}/hook-catalog` projects only display metadata;
|
||||
it exposes no recipes, credentials, module paths or handler references. Existing
|
||||
Sessions without a Hook pin retain their current behavior.
|
||||
|
||||
Changed areas are Core Hook dispatch/activation/record validation, CLI Hosted
|
||||
orchestration and Runtime execution, Java Broker transport and Session Store
|
||||
projection, and their collocated tests. Migration V27 records the first admission
|
||||
journal sequence as `first_sequence`, which later revisions preserve. The latest
|
||||
settled catalog is chosen by this sequence, matching native registration order
|
||||
even when an older registration settles later, independently of clocks or UUIDs.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
The ignored working plan is `.qwen/e2e-tests/12827-h2.md`. Baseline uses global
|
||||
`qwen`; verification uses the local bundle, deterministic model responses, real
|
||||
processes and side-effect counters. Production transport coverage connects Hosted,
|
||||
Java Session Store, Embedded Broker and a spawned Tool Runtime. Local SQL coverage
|
||||
uses H2; it is not a claim of a MySQL deployment test. The current validation host
|
||||
is macOS without a Linux container runtime. Linux cgroup descendant, cancellation
|
||||
and async drain tests are conditional and have not been executed here; their setup
|
||||
is recorded in `.qwen/e2e-tests/12827-h2-cgroup-validation.md`. Local transport E2E
|
||||
uses trusted function handlers for side effects and separately verifies command
|
||||
refusal before execution. It does not claim to verify Linux command drain.
|
||||
|
||||
Acceptance includes all four runners, ordered aggregation, once failure, async
|
||||
admission/cancellation/drain, input reapproval, cold reconstruction, lost execute
|
||||
and HTTP responses without duplicate effects, catalog replacement, owner isolation,
|
||||
prompt activation and unchanged no-Hook behavior. Run `npm run build`,
|
||||
`npm run typecheck`, `npm run bundle`, focused package tests and Java contract tests.
|
||||
Two clean self-audit passes and independent review follow integration verification.
|
||||
Unknown physical or model outcomes remain blocked with their original evidence;
|
||||
passing a mocked event test alone does not establish a missing producer's support.
|
||||
|
||||
Explicit Runtime recovery and cancellation retain the owner proven by the original durable tool inputs, including a shared MCP owner, rather than assuming the prompt ID. Missing or conflicting ownership evidence refuses recovery before any Broker call. Raw Shell intents without routing evidence can use the prompt owner only when the saved definition has no shared Hook or MCP owner.
|
||||
|
||||
A Broker execution-unknown response remains an unknown outcome, distinct from a missing execution. Cancellation refuses this state before or after a cancel request; it cannot commit a cancelled result or release ownership based on unknown physical stopping. Passive recovery continues reporting the outcome as unknown.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Managed Hooks 运行时(H2)
|
||||
|
||||
[English](2026-09-30-managed-hooks-runtime.md) | [简体中文](2026-09-30-managed-hooks-runtime.zh-CN.md)
|
||||
|
||||
状态:已实现并完成本地验证;Linux cgroup 执行验证待完成。本变更在 H1 之后实现
|
||||
[#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 H2。
|
||||
依据为[扩展运行时设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md)
|
||||
第 5 节,以及[配置设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-config-extensions.md)
|
||||
第 6 节。
|
||||
|
||||
## 问题与范围
|
||||
|
||||
Hosted Session 禁用环境中的 Hooks。Legacy runner 在内存中保留本地进程、回调和 once
|
||||
状态,无法证明替换 Harness 后原操作发生了什么。H2 在副作用之前提交计划和执行身份,
|
||||
遇到不确定结果时向原 owner 对账。后置 Hook 失败不能改变已成功工具的物理回执。
|
||||
|
||||
实现扩展现有私有 Hosted Workspace profile,并增加公开只读 Hook catalog。与 H1
|
||||
相同,生产 AgentBundle 启用另行处理。本变更不启用无关的 H3/H4 能力,也不启动第二套
|
||||
主 Agent loop。原生 Hook dispatcher 接受全部现有事件;生产者是否可用仍由选定
|
||||
profile 决定。
|
||||
|
||||
## 记录与计划
|
||||
|
||||
`hook_registration` 固定部署目录与不可变资源。`hook_execution` 保存 occurrence、
|
||||
ordinal、registration、plan、有效输入、原 Runtime Session、取消意图、物理状态和
|
||||
结果引用。两者复用 `ExtensionRun` 校验器和 `domain.committed` 日志,不创建用户任务
|
||||
条目。TypeScript 与 Java 消费共用正反例,并校验资源闭包、固定版本、唯一 ordinal,
|
||||
以及在 intent 时原子消费 once key。
|
||||
|
||||
每个 occurrence 固定目录与计划。Session 内串行准入 occurrence,防止并发事件
|
||||
同时预订同一个 once Hook;每个计划内部仍保留原生并行/顺序执行行为。同一 ID 携带不同语义输入会被拒绝。顺序执行持久化
|
||||
每一步有效输入,并复用原生 prompt context 和工具输入累积逻辑;并行结果按计划顺序
|
||||
聚合。保存结果前应用 fail-open/fail-closed,包括由 status 对账取得的回执。失败或
|
||||
未知的 once 尝试不会重新获得资格。
|
||||
|
||||
目录就绪且没有待恢复操作时,即使取消发生在新 occurrence 建立前,也会保存计划和
|
||||
取消结果,不派发子 Hook,也不消耗其 once key。PreToolUse 因而可以先持久化拒绝
|
||||
每个已提交的 assistant 工具调用(包括同批次的后续调用),再以 cancelled 结束
|
||||
turn。已有未知副作用或 journal 写入失败仍会阻塞该续行。
|
||||
|
||||
目录替换是带预期 registration 数量的幂等操作。同一目录命名空间内 revision 递增。
|
||||
重试旧操作只确认原结果,不恢复旧的有效目录。新 revision 删除 Hook 只影响未来事件。
|
||||
部署的 Session/agent 条目携带明确 owner;先做 owner 过滤,再复用原生配置去重,避免
|
||||
一个 agent 继承另一个 agent 的 handler。
|
||||
|
||||
## Runtime 所有权
|
||||
|
||||
在 Tool Runtime 进程设置 `QWEN_MANAGED_HOOK_CONFIG`,指向部署方持有的 JSON
|
||||
manifest。版本为 `1`,每个目录含 `tenantId`、`workspaceId`、`catalogId`、
|
||||
`catalogRevision`、64 位十六进制 `definitionDigest` 和 `hooks`。每项 Hook 声明 ID、
|
||||
事件、matcher、执行策略与 recipe。Command/HTTP recipe 和可信 function 模块路径
|
||||
留在 Runtime;Harness 只接收计划元数据与 prompt 定义。Function 模块导出带版本的
|
||||
对象,包含 `callback` 和可选 `onHookSuccess`。Handler 不可用时准确阻塞,不对序列化
|
||||
closure 求值。Function context 使用当前 Session 的实时历史,并保存持久化快照供
|
||||
重放。只有匹配 function 的计划保存 messages;大快照使用不可变的 60 KiB 分片。完整
|
||||
control envelope 上限为 8 MiB,网络派发前检查,不截断消息,也不把已知未执行误记为
|
||||
unknown。快照、计划和引用元数据也共用 Session Store 现有的 8 MiB 事务预算。快照准入
|
||||
为计划、输入、分片 manifest 和记录闭包预留 256 KiB;超限快照在发布前转换为有界停止。
|
||||
提交包含完整快照引用闭包;Store 在 manifest 或分片缺失时原子拒绝事务。
|
||||
|
||||
Manifest 是明确的部署目录,不导入 Harness 主机环境中的 user/project settings 或
|
||||
任意 client callback。Source 元数据按原生 registry 排序;Session 注册保留原生追加
|
||||
行为。把 Legacy settings 迁移到部署目录时,必须保留其已经解析的 user/project merged
|
||||
fallback 与信任决定。
|
||||
|
||||
全部 worker Hook 路由属于 **live-session-owner scope**。Broker 转发校验 tenant、
|
||||
workspace 和 Session,定位已保存的 Runtime,并在新副作用之前检查 workspace generation
|
||||
和 operation grant。Status/cancel 只查询原执行。Owner 丢失或被替换时保持 unknown;
|
||||
状态查询不派发替代副作用。
|
||||
Hook owner ID 在构造时根据已经持久化的 activation ID 与 epoch 固定。每次 load
|
||||
安装新的 activation,因此不会复用已释放的 Runtime Session ID。即使 acquire 仅用于
|
||||
目录请求、尚无首条 Hook execution 记录,恢复也能找到 owner。旧随机 owner ID
|
||||
继续从 execution 记录恢复;旧版本若没有留下任何 execution 记录,则需要运维恢复。
|
||||
替换后的 Hook owner acquire Workspace 前,会释放所有 Hook 记录已终态的旧 owner,
|
||||
包括经 status 完成对账的 owner。工具结果 continuation 复用同一 acquire 入口。
|
||||
共享的物理工作仍在运行时,Broker 继续拒绝释放。仅明确的
|
||||
`runtime_session_not_found` 可以视为 owner 已不存在。Detach 同样释放已结算的旧
|
||||
owner。已 attach 的空闲 owner 仍像 MCP 一样保留 Workspace 租约;调整该生命周期
|
||||
作为独立设计任务处理。
|
||||
|
||||
命令复用原生输出、超时和 TERM/KILL 处理。Managed 执行要求 Linux cgroup v2 委派:
|
||||
将 `QWEN_MANAGED_HOOK_CGROUP_ROOT` 指向可写且提供 `cgroup.kill` 的 domain。干净的
|
||||
launcher 先进入独立 unit,再启动命令。`setsid` 和 detached 子进程不会脱离该 unit;
|
||||
只有 `cgroup.events` 确认无剩余进程后才完成。取消先发送 TERM,必要时调用
|
||||
`cgroup.kill`;无法证明排空时保留 owner。这是面向部署方可信 Hook 的生命周期隔离,
|
||||
不是防止脚本故意篡改 cgroup 控制面的安全沙箱。
|
||||
|
||||
隔离能力不可用时(包括 macOS 和 Windows),在命令启动前返回
|
||||
`managed_hook_command_isolation_unavailable`。日志记录 `not_started_proven` 和
|
||||
`handler_unavailable`,可通过取消关闭被阻塞的 occurrence。对于 SessionStart、
|
||||
UserPromptSubmit 和模型执行前的原生 InstructionsLoaded,若准入后没有 model
|
||||
attempt、tool intent/receipt 或非 user 消息,
|
||||
且没有未决 Hook,显式取消还会持久化结算 Hook 持久化输入 `prompt_id` 对应的已准入
|
||||
turn。此前 SessionStart 的取消不能结算后来的 turn。对于被拒绝且显式取消后具有
|
||||
`not_started_proven` 的 PreToolUse child,恢复还可关闭首个已提交的工具调用批次:
|
||||
所有模型 attempt 必须已结束,至少一个已提交输出,不得有 tool intent/receipt、
|
||||
未决审批、文件历史工作或 Hook,且取消 child 的 occurrence 与输入必须匹配唯一
|
||||
未结算 turn 的原始调用。恢复先为每个缺失的调用持久化匹配的拒绝结果,再将 turn
|
||||
结算为 cancelled。响应按包含元数据的完整记录实际 UTF-8 大小分批,遵守 64 KiB
|
||||
inline 上限;每条成功记录作为下一条的 parent。单条响应仍超限时保留恢复屏障,
|
||||
不截断调用身份。
|
||||
新批次在提交 assistant 消息前检查每个取消响应的大小,超限调用在工具 acquire
|
||||
或派发前即被拒绝。
|
||||
部分批次已提交的响应保持不变;写入失败保留屏障,重试不会重复响应。
|
||||
恢复与 turn、控制操作准入串行化;Load 同样修复此前已取消的记录。其他模型或
|
||||
工具 continuation 仍保持阻塞。单个进程组消失不能作为
|
||||
进程树排空证明。环境继承限于执行必需变量和 recipe 显式条目。Async 准入等待 Runtime
|
||||
ACK,这不是完成回执。正常已确认的 async 工作可与后续 turn 共存;未知工作阻塞新准入
|
||||
并保留 Runtime hold。
|
||||
|
||||
trusted function Hook 取消或超时时,Runtime 最多等待一秒,确认真实回调 Promise
|
||||
已结束后再给出终态失败回执;回调从未开始时也可安全结算。回调 resolve 或 reject
|
||||
是结束证据,仅有 abort signal 不是。超过宽限期仍未结束的回调保留 unknown 结果和
|
||||
原 Runtime hold,不重放。原生 Legacy function Hook 的取消行为保持不变。
|
||||
|
||||
Workspace 的 Write/Edit 备份和显式撤销共用 Session 的 Hook Runtime owner,
|
||||
快照仍保存真实 prompt 身份。已确认准入的 async Hook 可以与 history bind、prepare
|
||||
及 snapshot 并行;撤销和释放 owner 仍要求全部 Hook 执行结束。撤销也排除 Hook
|
||||
目录/模型操作和非 active activation。冷加载时,先用匹配 checkpoint 工具输入中
|
||||
保存的原 Runtime owner 观察未决文件历史,再释放此前的 Hook owner。缺失或冲突的
|
||||
owner 证据继续阻塞恢复,不增加新的持久化字段。
|
||||
Shell 回执恢复确认 capture 投递时,也从已提交的工具输入读取原 Runtime owner;
|
||||
prompt ID 和接管后的 Hook owner 都不能标识原执行。
|
||||
|
||||
HTTP 使用原生 URL/DNS、凭据环境变量与超时策略,拒绝重定向。收到失败响应可以结算;
|
||||
Runtime URL 白名单与 HTTP 执行器使用相同的允许变量插值,仍限制内部密钥并执行原生
|
||||
SSRF 检查。
|
||||
执行器在派发前构造失败时,按保存的失败策略结算;
|
||||
发送后丢失响应保持 unknown,不自动重试。Managed HTTP Hook 已派发后,用户取消
|
||||
保留原请求及响应体读取,最多等待配置的 HTTP 超时,用完整响应作为结算证据;
|
||||
occurrence 仍以 cancelled 结束。派发前取消不发送请求。Runtime 关闭时立即中止
|
||||
transport;派发后的关闭、超时、断连或不完整响应均保持 unknown。原生 HTTP 的取消
|
||||
仍立即中止请求。单项回执和聚合输出上限均为 60 KiB。
|
||||
初始计划(含事件输入、descriptor 和快照引用)超限时,保存有界 blocking 回执及原始
|
||||
语义输入的摘要。恢复直接返回该拒绝,不派发或消费 once,即使没有匹配 handler 也能
|
||||
完成结算;同一 occurrence 携带变化后的输入仍会冲突。
|
||||
单项回执超限时转换为有界失败结果,遵守保存的 fail-open/fail-closed 策略。聚合输出或
|
||||
下一项顺序输入超限时,则以持久化的 blocking 结果结束 occurrence,保留已完成子项的
|
||||
真实回执;后续未派发的 Hook 不消费 once key。恢复根据保存的回执得到相同结果,
|
||||
不重复副作用。合并后的工具 Hook context 还需通过现有历史记录大小检查;若无法容纳,
|
||||
则保留原工具和 Hook 回执并停止编排,不写入或重试超限历史记录。
|
||||
Runtime 最多同时运行 16 个操作,每个目录最多 128 项 Hook。
|
||||
并发额度拒绝保留有界的失败回执,遵守保存的失败策略。Runtime 最多保留 4096 条
|
||||
操作回执(含这些拒绝),不淘汰或重放。达到上限后,在安装 grant 前返回明确的
|
||||
blocking 回执,PermissionRequest 始终返回 deny,不受失败策略影响;Harness
|
||||
持久化该结果。立即返回的已结算拒绝必须先返回,不能被异步准入成功掩盖。这个永久容量上限区别于临时并发拒绝。容量回收需要持久确认协议,
|
||||
留待后续实现;
|
||||
未保存的拒绝若丢失响应,查询仍保持 unknown。
|
||||
|
||||
H2 明确接受真正 unknown 带来的无期限可用性损失。SessionEnd 或 SessionDelete
|
||||
结果未知时,DELETE 返回 503,保留已 attach 的 Session 和原 Workspace owner。
|
||||
重试观察同一个已保存的 occurrence,不重新派发。Detach 也要求全部 Hook 副作用
|
||||
已结算。保留的 owner 可能阻塞同 Workspace 的其他 Session 的工具执行,不会阻塞
|
||||
不同 Workspace 的 Runtime worker。未知操作在 worker 生命周期内持续占用 16 个
|
||||
准入名额之一并保留 hold;包括已结算结果在内的全部已保存回执都计入 4096 条生命周期
|
||||
上限。超时、用户取消、DELETE 或进程替换都不能证明完成或允许重放。H2 不提供运维
|
||||
放弃 unknown 的接口;回执对账和有持久化屏障的回收在
|
||||
[#13133](https://github.com/QwenLM/qwen-code/issues/13133) 跟踪。这是明确接受的恢复
|
||||
限制,不代表有界恢复或可用性保证,也不表示原评审问题已解决。
|
||||
|
||||
## 模型 activation
|
||||
|
||||
只有 Harness 执行 prompt Hook。Turn 内 Hook 使用该 turn 的独占模型 scope。
|
||||
Notification、扩展和关闭事件可以领取 `hook_operation` activation,不创建用户 turn、
|
||||
任务完成、工具循环或 startup Hook。两种 subject 共用 Session 单调递增的 activation
|
||||
epoch。即使 provider 忽略超时取消,scope 也会保持占用直到调用实际结束。
|
||||
释放旧 activation 后若安装 Hook activation 失败,控制器仍会恢复 Session activation。
|
||||
如果恢复也失败,则在 activation 恢复前拒绝接收新的 prompt。
|
||||
HTTP journal 提交遇到临时传输失败、429 或 5xx 时,最多尝试三次,间隔 250 ms,
|
||||
保持事务身份、记录字节、资源和 writer scope 完全相同。重放必须返回匹配的原回执,
|
||||
本地 authority 才能推进。永久拒绝、回执不匹配或重试耗尽仍会停止写入并要求 journal
|
||||
恢复;不会清除写入屏障或尝试安装另一个未经确认的 activation。
|
||||
|
||||
模型尝试和用量关联原 Hook operation,并在存在时关联原 turn。预算记账沿用 Session
|
||||
与 turn 的现有语义;Hook operation 不得重置原预算。本变更不增加金额预算策略或独立
|
||||
Hook token 池。
|
||||
隔离模型的 Config 清理错误单独记录,不替换已返回的 Hook 结果或原操作错误。
|
||||
|
||||
## 事件接线
|
||||
|
||||
| 事件 | 生产者与顺序 |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SessionStart、UserPromptSubmit | Hosted startup 只发生一次;原始提交与有效模型输入分别保留。前置 Hook 阻断时不派发模型。 |
|
||||
| UserPromptExpansion、Notification | 通过认证的私有 operation 路由使用调用方稳定 occurrence。扩展结果返回调用方,供其提交。 |
|
||||
| PermissionRequest、PreToolUse | 先权限,后 PreToolUse。修改参数后重新校验,策略要求询问时创建新 Action。拒绝执行的调用不伪造物理后置事件。 |
|
||||
| PostToolUse、PostToolUseFailure、PostToolBatch | 先提交原生回执,按物理 success/error/cancel 选择事件;批次等待全部结果。恢复补充 Hook context,不重复工具或历史。 |
|
||||
| MessageDisplay、Stop、StopFailure | 最终显示有独立稳定 occurrence;suppress 同时影响返回 parts。Stop 可要求继续推理;真实模型 API 失败触发 StopFailure。 |
|
||||
| InstructionsLoaded、PreCompact、PostCompact | 现有原生生产者委派到持久化 dispatcher。初始化指令事件等待模型认证;压缩路径向上传递 Hook 恢复错误。 |
|
||||
| PermissionDenied、SubagentStart/Stop、TodoCreated/Completed | 完整原生事件桥保留生产者 payload、owner 身份和 Todo phase。在对应原生能力执行时发出;Hosted file/shell/MCP profile 不伪造 AUTO、child 或 Todo 操作。 |
|
||||
| SessionEnd、SessionDelete | 显式删除先排空先前操作,再执行生命周期 Hook,最后释放 Runtime/provider/writer。Detach 不发这两个事件。 |
|
||||
|
||||
Dispatcher 不在 Managed Config 中加载或执行环境中的 Legacy Hooks。Hosted 显式生产者
|
||||
从原生重复发射中排除。重建 activation 不等于新的 startup 或用户 resume。Stop occurrence
|
||||
使用持久化 model attempt ID;只有 Stop 阻断后才设置续行标志,普通工具 round 不会
|
||||
设置。显式 after/batch Hook stop 结束编排,同时保留物理回执和未被模型消费的状态,
|
||||
Session 仍可开始下一轮。
|
||||
|
||||
已加载目录包含 Stop 或 MessageDisplay 时,模型文本在两者完成决策前保持缓冲。
|
||||
丢弃或隐藏的草稿不会成为持久化 text delta。没有这两类输出策略的 Session 保留增量流式输出。
|
||||
|
||||
## 接口与兼容性
|
||||
|
||||
Session 创建/加载可提交 `hookCatalog: {catalogId, catalogRevision, definitionDigest}`,
|
||||
同时需要 Hosted Workspace tool profile 和 Broker。加载时省略初始 pin 会恢复保存值,
|
||||
显式提供时必须相同;后续已提交 registration 保持权威性。Workspace 冷加载在 attach
|
||||
前校验 Hook 记录资源及完整 function messages 快照闭包,并保留原 owner 恢复屏障。
|
||||
Hook scope 标识符兼容现有 Broker 字符语法,包括开头的标点。Hook 操作阻塞 prompt
|
||||
准入期间,Session status 返回 `recoveryBlocked`,load 返回 `recoveryRequired`。
|
||||
已保存的操作实际结算后,对账会清除此诊断。
|
||||
|
||||
Runtime-only 接管标志仍让 Hook Session 使用现有的 Hook 感知加载与核对路径。
|
||||
Runtime-only continue/cancel 路由在修改记录或 Runtime owner 前,以
|
||||
`hosted_hook_recovery_required` 拒绝 Hook Session;不能忽略待结算 Hook 副作用或
|
||||
模型 scope 来结束回合。
|
||||
|
||||
私有 Session/client scope 路由提供 `GET /session/:id/hooks`、注册更新、Notification/
|
||||
扩展操作和 operation status/cancel。修改操作不能与 turn 或另一控制操作重叠。公开
|
||||
tenant/actor scope 的 `GET /v1/agents/sessions/{sessionId}/hook-catalog` 只投影展示
|
||||
元数据,不暴露 recipe、凭据、模块路径或 handler 引用。未配置 Hook pin 的 Session
|
||||
保持原有行为。
|
||||
|
||||
变更涉及 Core Hook dispatch/activation/record 校验、CLI Hosted 编排与 Runtime 执行、
|
||||
Java Broker transport 和 Session Store 投影,以及对应同目录测试。Migration V27 将首次
|
||||
准入的日志序号保存为 `first_sequence`,后续修订保持不变。最新已结算目录按该序号选择,
|
||||
与原生注册顺序一致;即使旧注册较晚结算,也不受时钟或 UUID 排序影响。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
被忽略的工作计划为 `.qwen/e2e-tests/12827-h2.md`。基线使用全局 `qwen`;验证使用本地
|
||||
bundle、确定性模型响应、真实进程与副作用计数。生产 transport 测试连接 Hosted、Java
|
||||
Session Store、Embedded Broker 与派生的 Tool Runtime。本地 SQL 使用 H2,不代表已经
|
||||
测试 MySQL 部署。当前验证主机为 macOS,未安装 Linux 容器运行时。Linux cgroup 子进程、
|
||||
取消及 async 排空测试为条件测试,尚未在本机执行;环境要求记录在
|
||||
`.qwen/e2e-tests/12827-h2-cgroup-validation.md`。本地 transport E2E 使用可信 function
|
||||
handler 验证副作用,并单独验证 command 在执行前拒绝,不能据此声称验证了 Linux
|
||||
command 排空。
|
||||
|
||||
验收包含四类 runner、顺序聚合、once 失败、async 准入/取消/排空、参数重新审批、冷恢复、
|
||||
丢失执行与 HTTP 响应而不重复副作用、目录替换、owner 隔离、prompt activation 和无 Hook
|
||||
回归。运行 `npm run build`、`npm run typecheck`、`npm run bundle`、包内定向测试与 Java
|
||||
契约测试。集成验证后进行两轮无新问题的自审及独立评审。未知物理或模型结果保留原证据并
|
||||
阻塞;仅模拟事件的测试通过,不代表缺少生产者的能力已经受支持。
|
||||
|
||||
显式 Runtime 恢复与取消保留原始持久工具输入证明的 owner(包括共用 MCP owner),不默认替换为 prompt ID。所有权证据缺失或冲突时,在任何 Broker 调用之前拒绝恢复。Raw Shell intent 缺少路由证据时,仅在保存定义没有共用 Hook 或 MCP owner 的情况下使用 prompt owner。
|
||||
|
||||
Broker 的 execution-unknown 响应仍表示结果未知,与执行记录不存在区分。取消请求之前或之后观察到未知状态时都拒绝结算;不能据此提交 cancelled 结果或释放 owner。被动恢复仍把该结果报告为 unknown。
|
||||
@@ -43,6 +43,7 @@ const reports = config.sessions.map((session) => ({
|
||||
signals: [] as unknown[],
|
||||
prefix: undefined as { resourceId: string; digest: string } | undefined,
|
||||
target: undefined as Transaction | undefined,
|
||||
targetDigest: '',
|
||||
receipt: undefined as Record<string, unknown> | undefined,
|
||||
restoreTransactions: [] as Transaction[],
|
||||
}));
|
||||
@@ -145,10 +146,19 @@ const proxy = createServer(async (req, res) => {
|
||||
events(fields).some((event) => event.kind === 'tool.receipt');
|
||||
if (target) {
|
||||
assert(current.fault.startsWith('receipt-'));
|
||||
assert.equal(current.injections++, 0);
|
||||
assert(
|
||||
current.injections < 3,
|
||||
'Harness exceeded the commit retry budget',
|
||||
);
|
||||
current.injections++;
|
||||
assert.equal(fields.operation, 'recordToolResult');
|
||||
assert.equal(fields.commandId, current.executionCallId);
|
||||
current.target = fields;
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
if (current.target) assert.equal(digest, current.targetDigest);
|
||||
else {
|
||||
current.target = fields;
|
||||
current.targetDigest = digest;
|
||||
}
|
||||
}
|
||||
const upstream = await fetch(url, {
|
||||
method: req.method,
|
||||
@@ -165,7 +175,10 @@ const proxy = createServer(async (req, res) => {
|
||||
if (target && current.fault === 'receipt-failure') {
|
||||
assert.equal(upstream.status, 500, bytes.toString());
|
||||
} else if (!store && upstream.status === 409) {
|
||||
assert.equal(current.injections, 1);
|
||||
assert.equal(
|
||||
current.injections,
|
||||
current.fault.startsWith('receipt-') ? 3 : 1,
|
||||
);
|
||||
assert(['status', 'cancel'].includes(operation));
|
||||
assert(
|
||||
[
|
||||
@@ -214,8 +227,11 @@ const proxy = createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
if (target && current.fault === 'receipt-reply') {
|
||||
assert.equal(json.replayed, false);
|
||||
current.receipt = json;
|
||||
assert.equal(json.replayed, current.injections > 1);
|
||||
if (current.receipt)
|
||||
assert.deepEqual(json, { ...current.receipt, replayed: true });
|
||||
else current.receipt = json;
|
||||
// Lose every bounded retry's reply to retain the unknown-write probe.
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -338,7 +354,7 @@ try {
|
||||
await json(route + '/prompt', input, 202);
|
||||
await waitUntil(() => {
|
||||
if (proxyFailure) throw proxyFailure;
|
||||
return current.injections === 1;
|
||||
return current.injections > 0;
|
||||
});
|
||||
if (current.fault === 'publisher-kill') {
|
||||
await waitUntil(() => cli.child!.signalCode === 'SIGKILL');
|
||||
@@ -365,6 +381,10 @@ try {
|
||||
);
|
||||
await control('finished');
|
||||
await assertEffect();
|
||||
assert.equal(
|
||||
current.injections,
|
||||
current.fault.startsWith('receipt-') ? 3 : 1,
|
||||
);
|
||||
assert.equal(current.modelCalls, 1);
|
||||
assert(current.prefix);
|
||||
for (const operation of ['acquire', 'publisher', 'prepare', 'start'])
|
||||
|
||||
@@ -47,6 +47,7 @@ const reports = config.sessions.map((session) => ({
|
||||
faults: 0,
|
||||
operations: [] as string[],
|
||||
target: undefined as Transaction | undefined,
|
||||
targetDigest: '',
|
||||
receipt: undefined as Receipt | undefined,
|
||||
restoreTransactions: [] as Transaction[],
|
||||
}));
|
||||
@@ -138,12 +139,14 @@ const proxy = createServer(async (req, res) => {
|
||||
const commit = store && url.pathname.endsWith('/transactions:commit');
|
||||
const target = commit && !restoring && selected(fields, report.fault);
|
||||
if (target) {
|
||||
assert.equal(
|
||||
report.faults++,
|
||||
0,
|
||||
'Harness must not retry the failed write',
|
||||
);
|
||||
report.target = fields;
|
||||
assert(report.faults < 3, 'Harness exceeded the commit retry budget');
|
||||
report.faults++;
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
if (report.target) assert.equal(digest, report.targetDigest);
|
||||
else {
|
||||
report.target = fields;
|
||||
report.targetDigest = digest;
|
||||
}
|
||||
}
|
||||
const upstream = await fetch(url, init);
|
||||
const bytes = Buffer.from(await upstream.arrayBuffer());
|
||||
@@ -155,12 +158,16 @@ const proxy = createServer(async (req, res) => {
|
||||
if (target) {
|
||||
if (report.fault.endsWith('-reply')) {
|
||||
assert.equal(upstream.status, 200, bytes.toString());
|
||||
assert.equal(json.replayed, false);
|
||||
report.receipt = json;
|
||||
// Probe the Store's exact-request replay contract without retrying the Harness write.
|
||||
const replay = await fetch(url, init);
|
||||
assert.equal(replay.status, 200, await replay.clone().text());
|
||||
assert.deepEqual(await replay.json(), { ...json, replayed: true });
|
||||
assert.equal(json.replayed, report.faults > 1);
|
||||
if (report.receipt)
|
||||
assert.deepEqual(json, { ...report.receipt, replayed: true });
|
||||
else {
|
||||
report.receipt = json;
|
||||
const replay = await fetch(url, init);
|
||||
assert.equal(replay.status, 200, await replay.clone().text());
|
||||
assert.deepEqual(await replay.json(), { ...json, replayed: true });
|
||||
}
|
||||
// Lose every bounded retry's reply to retain the unknown-write probe.
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -308,7 +315,7 @@ try {
|
||||
await waitUntil(
|
||||
async () => !(await json(`${route}/status`)).hasActivePrompt,
|
||||
);
|
||||
assert.equal(current.faults, 1, `${current.fault}: fault did not fire`);
|
||||
assert.equal(current.faults, 3, `${current.fault}: retries not exhausted`);
|
||||
assert.equal((await json(`${route}/status`)).recoveryBlocked, true);
|
||||
assert.equal(current.modelCalls, current.fault === 'turn-reply' ? 2 : 1);
|
||||
const records = await transcript();
|
||||
@@ -408,7 +415,7 @@ try {
|
||||
'decoy',
|
||||
);
|
||||
console.log(
|
||||
`FG6B ${current.fault}: faults=1, modelCalls=${calls}, starts=${started ? 1 : 0}, cold=${completed ? 'committed' : 'blocked'}`,
|
||||
`FG6B ${current.fault}: faults=${current.faults}, modelCalls=${calls}, starts=${started ? 1 : 0}, cold=${completed ? 'committed' : 'blocked'}`,
|
||||
);
|
||||
}
|
||||
if (proxyFailure) throw proxyFailure;
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
*/
|
||||
|
||||
import { LlmEventType } from '@qwen-code/qwen-code-core/core/turn.js';
|
||||
import { TurnBudget } from '@qwen-code/qwen-code-core/core/turn-budget.js';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { ConfigInitializeOptions } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import {
|
||||
HookEventName,
|
||||
type HookInput,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import type { ManagedHookDispatcher } from '@qwen-code/qwen-code-core/hooks/hookEventHandler.js';
|
||||
import type { HostedHookSession } from './hosted-hook-session.js';
|
||||
import { HostedHookRecoveryRequiredError } from './hosted-hook-session.js';
|
||||
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
@@ -39,10 +49,23 @@ function config(
|
||||
const setTools = vi.fn(async () => undefined);
|
||||
const shutdown = vi.fn(async () => undefined);
|
||||
const setHistory = vi.fn();
|
||||
const initialize = vi
|
||||
.fn<(options?: ConfigInitializeOptions) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const refreshAuth = vi.fn(async () => undefined);
|
||||
const requests: Part[][] = [];
|
||||
const getHistory = vi.fn<() => Content[]>(() => [
|
||||
{ role: 'model', parts: [{ text: 'answer' }] },
|
||||
]);
|
||||
const budget = new TurnBudget();
|
||||
const sendMessageStream = vi.fn(async function* (request: Part[]) {
|
||||
requests.push(request);
|
||||
for (const event of events) yield event;
|
||||
});
|
||||
state.config = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
initialize,
|
||||
getModelsConfig: () => ({ getCurrentAuthType: () => 'test-auth' }),
|
||||
refreshAuth: vi.fn(async () => undefined),
|
||||
refreshAuth,
|
||||
getToolRegistry: () => ({
|
||||
warmAll: vi.fn(async () => undefined),
|
||||
getAllTools: () => [...tools].map((name) => ({ name })),
|
||||
@@ -51,15 +74,50 @@ function config(
|
||||
}),
|
||||
getLlmClient: () => ({
|
||||
setTools,
|
||||
getChat: () => ({ setHistory }),
|
||||
async *sendMessageStream() {
|
||||
for (const event of events) yield event;
|
||||
},
|
||||
getChat: () => ({ setHistory, setTools: vi.fn() }),
|
||||
getHistory,
|
||||
sendMessageStream,
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getTurnBudget: () => budget,
|
||||
shutdown,
|
||||
};
|
||||
return { unregisterTool, setTools, shutdown, setHistory };
|
||||
return {
|
||||
unregisterTool,
|
||||
setTools,
|
||||
shutdown,
|
||||
setHistory,
|
||||
initialize,
|
||||
refreshAuth,
|
||||
sendMessageStream,
|
||||
requests,
|
||||
budget,
|
||||
getHistory,
|
||||
};
|
||||
}
|
||||
|
||||
function hostedHooks(events: HookEventName[] = []) {
|
||||
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
|
||||
const hasCompletedOccurrence = vi.fn().mockReturnValue(false);
|
||||
const session = {
|
||||
ensureReady: vi.fn().mockResolvedValue(undefined),
|
||||
getCatalog: () => ({ hooks: events.map((eventName) => ({ eventName })) }),
|
||||
hasCompletedOccurrence,
|
||||
wasStopBlocked: vi.fn().mockResolvedValue(false),
|
||||
setMessagesProvider: vi.fn(),
|
||||
fire,
|
||||
} as unknown as HostedHookSession;
|
||||
return { session, fire, hasCompletedOccurrence };
|
||||
}
|
||||
|
||||
function nativeInput(event: HookEventName): HookInput {
|
||||
return {
|
||||
session_id: input.sessionId,
|
||||
cwd: input.cwd,
|
||||
transcript_path: '',
|
||||
hook_event_name: event,
|
||||
timestamp: '2026-09-30T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Hosted Harness model boundary', () => {
|
||||
@@ -142,8 +200,737 @@ describe('Hosted Harness model boundary', () => {
|
||||
text: 'answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reapply a completed SessionStart on later turns', async () => {
|
||||
const model = config([{ type: LlmEventType.Finished }]);
|
||||
const hooks = hostedHooks();
|
||||
hooks.hasCompletedOccurrence.mockReturnValue(true);
|
||||
await runHostedHarnessTextTurn({ ...input, hooks: hooks.session });
|
||||
expect(
|
||||
hooks.fire.mock.calls.some(
|
||||
([event]) => event === HookEventName.SessionStart,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(model.requests[0]).toEqual([{ text: input.prompt }]);
|
||||
});
|
||||
|
||||
it('defers initialization InstructionsLoaded until authentication, preserving native fields', async () => {
|
||||
const model = config([{ type: LlmEventType.Finished }]);
|
||||
const hooks = hostedHooks([HookEventName.InstructionsLoaded]);
|
||||
let authenticated = false;
|
||||
model.refreshAuth.mockImplementation(async () => {
|
||||
authenticated = true;
|
||||
});
|
||||
const fields = {
|
||||
...nativeInput(HookEventName.InstructionsLoaded),
|
||||
file_path: '/workspace/AGENTS.md',
|
||||
memory_type: 'project',
|
||||
load_reason: 'session_start',
|
||||
};
|
||||
model.initialize.mockImplementation(async (options) => {
|
||||
await options!.managedHookDispatcher!.execute(
|
||||
HookEventName.InstructionsLoaded,
|
||||
fields,
|
||||
input.signal,
|
||||
);
|
||||
expect(hooks.fire).not.toHaveBeenCalled();
|
||||
});
|
||||
hooks.fire.mockImplementation(async () => {
|
||||
expect(authenticated).toBe(true);
|
||||
return undefined;
|
||||
});
|
||||
await runHostedHarnessTextTurn({ ...input, hooks: hooks.session });
|
||||
const events = hooks.fire.mock.calls.map(([event]) => event);
|
||||
expect(events.slice(0, 3)).toEqual([
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.InstructionsLoaded,
|
||||
HookEventName.UserPromptSubmit,
|
||||
]);
|
||||
expect(
|
||||
hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.InstructionsLoaded,
|
||||
)?.[2],
|
||||
).toMatchObject({
|
||||
file_path: '/workspace/AGENTS.md',
|
||||
memory_type: 'project',
|
||||
load_reason: 'session_start',
|
||||
prompt_id: input.promptId,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes real native compaction callbacks through the managed dispatcher', async () => {
|
||||
const model = config([]);
|
||||
const hooks = hostedHooks([
|
||||
HookEventName.PreCompact,
|
||||
HookEventName.PostCompact,
|
||||
HookEventName.Stop,
|
||||
HookEventName.PermissionDenied,
|
||||
]);
|
||||
let dispatcher: ManagedHookDispatcher;
|
||||
model.initialize.mockImplementation(async (options) => {
|
||||
dispatcher = options!.managedHookDispatcher!;
|
||||
});
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
expect(dispatcher.hasHooksForEvent(HookEventName.Stop)).toBe(false);
|
||||
expect(dispatcher.hasHooksForEvent(HookEventName.PermissionDenied)).toBe(
|
||||
true,
|
||||
);
|
||||
await dispatcher.execute(
|
||||
HookEventName.PreCompact,
|
||||
nativeInput(HookEventName.PreCompact),
|
||||
);
|
||||
await dispatcher.execute(
|
||||
HookEventName.PostCompact,
|
||||
nativeInput(HookEventName.PostCompact),
|
||||
);
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
await runHostedHarnessTextTurn({ ...input, hooks: hooks.session });
|
||||
expect(
|
||||
hooks.fire.mock.calls
|
||||
.filter(
|
||||
([event]) =>
|
||||
event === HookEventName.PreCompact ||
|
||||
event === HookEventName.PostCompact,
|
||||
)
|
||||
.map(([event]) => event),
|
||||
).toEqual([HookEventName.PreCompact, HookEventName.PostCompact]);
|
||||
});
|
||||
|
||||
it('keeps InstructionsLoaded occurrence identity after tool recovery and skips its completed effects', async () => {
|
||||
const hooks = hostedHooks([HookEventName.InstructionsLoaded]);
|
||||
const fields = {
|
||||
...nativeInput(HookEventName.InstructionsLoaded),
|
||||
file_path: '/workspace/AGENTS.md',
|
||||
memory_type: 'project',
|
||||
load_reason: 'session_start',
|
||||
};
|
||||
const first = config([{ type: LlmEventType.Finished }]);
|
||||
first.initialize.mockImplementation(async (options) => {
|
||||
await options!.managedHookDispatcher!.execute(
|
||||
HookEventName.InstructionsLoaded,
|
||||
fields,
|
||||
);
|
||||
});
|
||||
await runHostedHarnessTextTurn({ ...input, hooks: hooks.session });
|
||||
const occurrence = hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.InstructionsLoaded,
|
||||
)![1];
|
||||
hooks.hasCompletedOccurrence.mockImplementation(
|
||||
(event, id) =>
|
||||
event === HookEventName.InstructionsLoaded && id === occurrence,
|
||||
);
|
||||
hooks.fire.mockClear();
|
||||
const resumed = config([{ type: LlmEventType.Finished }]);
|
||||
resumed.initialize.mockImplementation(async (options) => {
|
||||
await options!.managedHookDispatcher!.execute(
|
||||
HookEventName.InstructionsLoaded,
|
||||
{ ...fields, timestamp: '2026-09-30T01:00:00.000Z' },
|
||||
);
|
||||
});
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
resumeFromToolResults: [],
|
||||
history: [{ type: 'tool_result', uuid: 'after-tool' } as never],
|
||||
});
|
||||
expect(hooks.hasCompletedOccurrence).toHaveBeenCalledWith(
|
||||
HookEventName.InstructionsLoaded,
|
||||
occurrence,
|
||||
);
|
||||
expect(
|
||||
hooks.fire.mock.calls.some(
|
||||
([event]) => event === HookEventName.InstructionsLoaded,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the shared budget before startup Hooks and persists main usage in the same scope', async () => {
|
||||
const model = config([
|
||||
{
|
||||
type: LlmEventType.Finished,
|
||||
value: { usageMetadata: { candidatesTokenCount: 30 } },
|
||||
},
|
||||
]);
|
||||
const hooks = hostedHooks();
|
||||
const complete = vi.fn();
|
||||
const bindBudget = vi.fn(async (budget: TurnBudget, prompt?: string) => {
|
||||
expect(prompt).toBe('original +5k');
|
||||
budget.beginTurn({
|
||||
promptId: input.promptId,
|
||||
sessionId: input.sessionId,
|
||||
budget: 5_000,
|
||||
outputTokensAtTurnStart: 100,
|
||||
});
|
||||
});
|
||||
hooks.fire.mockImplementation(async (event) => {
|
||||
expect(model.budget.current(input.sessionId)).toMatchObject({
|
||||
budget: 5_000,
|
||||
outputTokensAtTurnStart: 100,
|
||||
});
|
||||
return event === HookEventName.UserPromptSubmit
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: event,
|
||||
updatedPrompt: 'rewritten +10k',
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
});
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
prompt: 'original +5k',
|
||||
hooks: hooks.session,
|
||||
modelScope: {
|
||||
bindBudget,
|
||||
evaluate: vi.fn(),
|
||||
beginMainAttempt: vi.fn().mockResolvedValue(complete),
|
||||
},
|
||||
});
|
||||
expect(bindBudget).toHaveBeenCalledOnce();
|
||||
expect(complete).toHaveBeenCalledWith(true, [{ candidatesTokenCount: 30 }]);
|
||||
expect(model.requests[0]).toEqual([{ text: 'rewritten +10k' }]);
|
||||
});
|
||||
|
||||
it('reports provider failure through StopFailure and leaves Hook recovery failures untouched', async () => {
|
||||
config([
|
||||
{
|
||||
type: LlmEventType.Error,
|
||||
value: { error: { message: 'provider failed' } },
|
||||
},
|
||||
]);
|
||||
const hooks = hostedHooks();
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({ ...input, hooks: hooks.session }),
|
||||
).rejects.toThrow('provider failed');
|
||||
expect(hooks.fire).toHaveBeenCalledWith(
|
||||
HookEventName.StopFailure,
|
||||
`${input.promptId}:0:model-failure`,
|
||||
expect.objectContaining({ error_details: 'provider failed' }),
|
||||
input.signal,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const model = config([]);
|
||||
hooks.fire.mockClear();
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: LlmEventType.Content, value: 'partial' };
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
});
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({ ...input, hooks: hooks.session }),
|
||||
).rejects.toThrow('requires reconciliation');
|
||||
expect(
|
||||
hooks.fire.mock.calls.some(
|
||||
([event]) => event === HookEventName.StopFailure,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses both returned text and persisted parts when MessageDisplay hides the answer', async () => {
|
||||
config([
|
||||
{ type: LlmEventType.Content, value: 'answer' },
|
||||
{ type: LlmEventType.Finished },
|
||||
]);
|
||||
const hooks = hostedHooks([HookEventName.MessageDisplay]);
|
||||
const getCatalog = hooks.session.getCatalog;
|
||||
let ready = false;
|
||||
vi.spyOn(hooks.session, 'getCatalog').mockImplementation(() =>
|
||||
ready ? getCatalog() : undefined,
|
||||
);
|
||||
vi.mocked(hooks.session.ensureReady).mockImplementation(async () => {
|
||||
ready = true;
|
||||
});
|
||||
const textDeltas = { delta: vi.fn(), published: vi.fn(() => false) };
|
||||
hooks.fire.mockImplementation(async (event) =>
|
||||
event === HookEventName.MessageDisplay
|
||||
? { suppressOutput: true }
|
||||
: undefined,
|
||||
);
|
||||
const toolTurn = {
|
||||
execute: vi.fn(),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
};
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
toolTurn,
|
||||
textDeltas,
|
||||
}),
|
||||
).resolves.toMatchObject({ text: '', parts: [] });
|
||||
expect(textDeltas.delta).not.toHaveBeenCalled();
|
||||
expect(
|
||||
hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.MessageDisplay,
|
||||
)?.[2],
|
||||
).toMatchObject({
|
||||
message_id: `${input.promptId}:0`,
|
||||
displayed_text: 'answer',
|
||||
is_final: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('buffers discarded Stop drafts until the final answer is accepted', async () => {
|
||||
const model = config([]);
|
||||
const hooks = hostedHooks([HookEventName.Stop]);
|
||||
let attempts = 0;
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
yield {
|
||||
type: LlmEventType.Content,
|
||||
value: ++attempts === 1 ? 'discarded draft' : 'accepted answer',
|
||||
};
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
let stops = 0;
|
||||
hooks.fire.mockImplementation(async (event) =>
|
||||
event === HookEventName.Stop && ++stops === 1
|
||||
? { decision: 'block', reason: 'Continue' }
|
||||
: undefined,
|
||||
);
|
||||
const textDeltas = { delta: vi.fn(), published: vi.fn(() => false) };
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({ ...input, hooks: hooks.session, textDeltas }),
|
||||
).resolves.toMatchObject({ text: 'accepted answer' });
|
||||
expect(textDeltas.delta).not.toHaveBeenCalled();
|
||||
expect(model.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('streams text when the Hook catalog has no output policy', async () => {
|
||||
config([
|
||||
{ type: LlmEventType.Content, value: 'answer' },
|
||||
{ type: LlmEventType.Finished },
|
||||
]);
|
||||
const hooks = hostedHooks([HookEventName.Notification]);
|
||||
const textDeltas = { delta: vi.fn(), published: vi.fn(() => false) };
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
textDeltas,
|
||||
});
|
||||
expect(textDeltas.delta).toHaveBeenCalledExactlyOnceWith('answer');
|
||||
});
|
||||
|
||||
it('reconstructs after-Hook tool results after installing the authorized prompt runner', async () => {
|
||||
const model = config([{ type: LlmEventType.Finished }]);
|
||||
const responses: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'shell',
|
||||
response: { output: 'done' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const resumed = [...responses, { text: 'after-Hook context' }];
|
||||
const toolTurn = {
|
||||
execute: vi.fn(),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
resumeHookResults: vi.fn().mockResolvedValue(resumed),
|
||||
};
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
resumeFromToolResults: responses,
|
||||
modelScope: {
|
||||
evaluate: vi.fn(),
|
||||
bindBudget: vi.fn(),
|
||||
beginMainAttempt: vi.fn(),
|
||||
},
|
||||
toolTurn,
|
||||
});
|
||||
expect(model.requests[0]).toEqual(resumed);
|
||||
expect(
|
||||
toolTurn.setPromptHookRunner.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(toolTurn.resumeHookResults.mock.invocationCallOrder[0]);
|
||||
expect(toolTurn.consumeResults).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the first Stop inactive after ordinary tool rounds', async () => {
|
||||
const model = config([]);
|
||||
const hooks = hostedHooks();
|
||||
let round = 0;
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
if (round++ === 0) {
|
||||
model.getHistory.mockReturnValue([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 'call-1', name: 'read_file', args: {} } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
yield {
|
||||
type: LlmEventType.ToolCallRequest,
|
||||
value: { callId: 'call-1', name: 'read_file', args: {} },
|
||||
};
|
||||
} else {
|
||||
model.getHistory.mockReturnValue([
|
||||
{ role: 'model', parts: [{ text: 'done' }] },
|
||||
]);
|
||||
yield { type: LlmEventType.Content, value: 'done' };
|
||||
}
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
toolTurn: {
|
||||
execute: vi.fn().mockResolvedValue([
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: { output: 'read' },
|
||||
},
|
||||
},
|
||||
]),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
hooks.fire.mock.calls
|
||||
.filter(([event]) => event === HookEventName.Stop)
|
||||
.map(([, , fields]) => fields['stop_hook_active']),
|
||||
).toEqual([false]);
|
||||
});
|
||||
|
||||
it.each(['decision', 'continue'] as const)(
|
||||
'uses durable model attempt identities and restores a Stop %s block chain across a tool restart',
|
||||
async (kind) => {
|
||||
const hooks = hostedHooks();
|
||||
const complete = (attemptId: string) =>
|
||||
Object.assign(vi.fn().mockResolvedValue(undefined), { attemptId });
|
||||
const modelScope = {
|
||||
evaluate: vi.fn(),
|
||||
bindBudget: vi.fn(),
|
||||
beginMainAttempt: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(complete('turn:model-1'))
|
||||
.mockResolvedValueOnce(complete('turn:model-2'))
|
||||
.mockResolvedValueOnce(complete('turn:model-3')),
|
||||
};
|
||||
hooks.fire.mockImplementation(async (event) =>
|
||||
event === HookEventName.Stop
|
||||
? kind === 'decision'
|
||||
? { decision: 'block', reason: 'continue' }
|
||||
: { continue: false, stopReason: 'continue' }
|
||||
: undefined,
|
||||
);
|
||||
const first = config([]);
|
||||
let round = 0;
|
||||
first.sendMessageStream.mockImplementation(async function* () {
|
||||
if (round++ === 0)
|
||||
yield { type: LlmEventType.Content, value: 'initial answer' };
|
||||
else {
|
||||
first.getHistory.mockReturnValue([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 'call-1', name: 'read_file', args: {} } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
yield {
|
||||
type: LlmEventType.ToolCallRequest,
|
||||
value: { callId: 'call-1', name: 'read_file', args: {} },
|
||||
};
|
||||
}
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
const toolTurn = {
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('restart after durable tool receipt')),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
resumeHookResults: vi
|
||||
.fn()
|
||||
.mockImplementation(async (parts: Part[]) => parts),
|
||||
};
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
modelScope,
|
||||
toolTurn,
|
||||
}),
|
||||
).rejects.toThrow('restart after durable tool receipt');
|
||||
const initialStop = hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.Stop,
|
||||
)!;
|
||||
expect(initialStop[1]).toBe('turn:model-1');
|
||||
expect(initialStop[2]).toMatchObject({ stop_hook_active: false });
|
||||
vi.mocked(hooks.session.wasStopBlocked).mockResolvedValue(true);
|
||||
hooks.fire.mockReset().mockResolvedValue(undefined);
|
||||
config([
|
||||
{ type: LlmEventType.Content, value: 'recovered answer' },
|
||||
{ type: LlmEventType.Finished },
|
||||
]);
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
modelScope,
|
||||
toolTurn,
|
||||
resumeFromToolResults: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: { output: 'saved' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const resumedStop = hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.Stop,
|
||||
)!;
|
||||
expect(hooks.session.wasStopBlocked).toHaveBeenCalledWith(input.promptId);
|
||||
expect(resumedStop[1]).toBe('turn:model-3');
|
||||
expect(resumedStop[2]).toMatchObject({ stop_hook_active: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('provides only Session conversation messages before model initialization and live history for native and Stop Hooks', async () => {
|
||||
const model = config([]);
|
||||
const hooks = hostedHooks([HookEventName.PreCompact]);
|
||||
let dispatcher: ManagedHookDispatcher;
|
||||
model.initialize.mockImplementation(async (options) => {
|
||||
dispatcher = options!.managedHookDispatcher!;
|
||||
});
|
||||
const live: Content[] = [
|
||||
{ role: 'user', parts: [{ text: input.prompt }] },
|
||||
{ role: 'model', parts: [{ text: 'live answer' }] },
|
||||
];
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
model.getHistory.mockReturnValue(live);
|
||||
await dispatcher.execute(
|
||||
HookEventName.PreCompact,
|
||||
nativeInput(HookEventName.PreCompact),
|
||||
);
|
||||
yield { type: LlmEventType.Content, value: 'live answer' };
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
history: [
|
||||
{
|
||||
type: 'user',
|
||||
message: { role: 'user', parts: [{ text: 'prior question' }] },
|
||||
metadata: { secret: 'not context' },
|
||||
} as never,
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { role: 'model', parts: [{ text: 'prior answer' }] },
|
||||
} as never,
|
||||
{
|
||||
type: 'system',
|
||||
message: { parts: [{ text: 'private implementation metadata' }] },
|
||||
} as never,
|
||||
],
|
||||
});
|
||||
expect(
|
||||
hooks.fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.UserPromptSubmit,
|
||||
)![2]['messages'],
|
||||
).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'prior question' }] },
|
||||
{ role: 'model', parts: [{ text: 'prior answer' }] },
|
||||
{ role: 'user', parts: [{ text: input.prompt }] },
|
||||
]);
|
||||
for (const event of [HookEventName.PreCompact, HookEventName.Stop])
|
||||
expect(
|
||||
hooks.fire.mock.calls.find(([name]) => name === event)![2]['messages'],
|
||||
).toEqual(live);
|
||||
live[1].parts![0].text = 'later mutation';
|
||||
expect(
|
||||
hooks.fire.mock.calls.find(([event]) => event === HookEventName.Stop)![2][
|
||||
'messages'
|
||||
],
|
||||
).toEqual([
|
||||
{ role: 'user', parts: [{ text: input.prompt }] },
|
||||
{ role: 'model', parts: [{ text: 'live answer' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs live messages for tool callbacks and clears the provider when the turn exits', async () => {
|
||||
const hooks = hostedHooks();
|
||||
const model = config([]);
|
||||
let provider: (() => Array<Record<string, unknown>>) | undefined;
|
||||
vi.mocked(hooks.session.setMessagesProvider).mockImplementation((value) => {
|
||||
provider = value;
|
||||
});
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
model.getHistory.mockReturnValue([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Stop continuation not yet projected' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 'call-1', name: 'read_file', args: {} } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
yield {
|
||||
type: LlmEventType.ToolCallRequest,
|
||||
value: { callId: 'call-1', name: 'read_file', args: {} },
|
||||
};
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
const toolTurn = {
|
||||
execute: vi.fn(async () => {
|
||||
expect(provider?.()).toEqual(model.getHistory());
|
||||
expect(provider?.()[0]).toMatchObject({
|
||||
parts: [{ text: 'Stop continuation not yet projected' }],
|
||||
});
|
||||
throw new Error('tool exit');
|
||||
}),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
};
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({ ...input, hooks: hooks.session, toolTurn }),
|
||||
).rejects.toThrow('tool exit');
|
||||
expect(provider).toBeUndefined();
|
||||
expect(hooks.session.setMessagesProvider).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'settles a post-tool Hook stop before another model request (resume=%s)',
|
||||
async (resume) => {
|
||||
const hooks = hostedHooks();
|
||||
const model = config([]);
|
||||
const responses: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: { output: 'original physical result' },
|
||||
},
|
||||
},
|
||||
];
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
model.getHistory.mockReturnValue([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 'call-1', name: 'read_file', args: {} } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
yield {
|
||||
type: LlmEventType.ToolCallRequest,
|
||||
value: { callId: 'call-1', name: 'read_file', args: {} },
|
||||
};
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
const toolTurn = {
|
||||
hookStopReason: undefined as string | undefined,
|
||||
execute: vi.fn(async () => {
|
||||
toolTurn.hookStopReason = 'Stopped after tools.';
|
||||
return responses;
|
||||
}),
|
||||
resumeHookResults: vi.fn(async () => {
|
||||
toolTurn.hookStopReason = 'Stopped after tools.';
|
||||
return responses;
|
||||
}),
|
||||
consumeResults: vi.fn(),
|
||||
declarations: vi.fn().mockResolvedValue([]),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
};
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
toolTurn,
|
||||
...(resume ? { resumeFromToolResults: responses } : {}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: 'Stopped after tools.',
|
||||
parts: [{ text: 'Stopped after tools.' }],
|
||||
model: 'test-model',
|
||||
});
|
||||
expect(model.sendMessageStream).toHaveBeenCalledTimes(resume ? 0 : 1);
|
||||
expect(toolTurn.consumeResults).not.toHaveBeenCalled();
|
||||
expect(
|
||||
hooks.fire.mock.calls.some(([event]) => event === HookEventName.Stop),
|
||||
).toBe(false);
|
||||
expect(responses[0].functionResponse?.response).toEqual({
|
||||
output: 'original physical result',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['decision', 'continue'] as const)(
|
||||
'consumes recovered tool results before Stop %s continuation without consuming them twice',
|
||||
async (kind) => {
|
||||
const model = config([{ type: LlmEventType.Finished }]);
|
||||
const hooks = hostedHooks();
|
||||
const order: string[] = [];
|
||||
let stops = 0;
|
||||
model.sendMessageStream.mockImplementation(async function* () {
|
||||
order.push('model');
|
||||
yield { type: LlmEventType.Finished };
|
||||
});
|
||||
hooks.fire.mockImplementation(async (event) => {
|
||||
if (event !== HookEventName.Stop) return undefined;
|
||||
order.push('stop');
|
||||
if (++stops === 1)
|
||||
return kind === 'decision'
|
||||
? { decision: 'block', reason: 'Continue' }
|
||||
: { continue: false, stopReason: 'Continue' };
|
||||
return undefined;
|
||||
});
|
||||
const toolTurn = {
|
||||
execute: vi.fn(),
|
||||
consumeResults: vi.fn(async () => {
|
||||
order.push('consume');
|
||||
}),
|
||||
declarations: vi.fn(async () => []),
|
||||
setPromptHookRunner: vi.fn(),
|
||||
resumeHookResults: vi.fn(async (parts: readonly Part[]) => [...parts]),
|
||||
};
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
hooks: hooks.session,
|
||||
toolTurn,
|
||||
resumeFromToolResults: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'original',
|
||||
name: 'read_file',
|
||||
response: { output: 'saved' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(order).toEqual(['model', 'consume', 'stop', 'model', 'stop']);
|
||||
expect(model.sendMessageStream.mock.calls[1][0]).toEqual([
|
||||
{ text: 'Continue' },
|
||||
]);
|
||||
expect(toolTurn.consumeResults).toHaveBeenCalledOnce();
|
||||
expect(toolTurn.execute).not.toHaveBeenCalled();
|
||||
expect(
|
||||
hooks.fire.mock.calls
|
||||
.filter(([event]) => event === HookEventName.Stop)
|
||||
.map(([, , fields]) => fields['stop_hook_active']),
|
||||
).toEqual([false, true]);
|
||||
},
|
||||
);
|
||||
|
||||
describe('Hosted Harness resume and retraction', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -213,6 +1000,7 @@ describe('Hosted Harness resume and retraction', () => {
|
||||
execute: vi.fn(),
|
||||
consumeResults: vi.fn(async () => undefined),
|
||||
declarations: async () => [],
|
||||
setPromptHookRunner: vi.fn(),
|
||||
};
|
||||
const result = await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
|
||||
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
|
||||
import { LlmEventType } from '@qwen-code/qwen-code-core/core/turn.js';
|
||||
@@ -14,6 +15,35 @@ import { loadSettings } from '../config/settings.js';
|
||||
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
|
||||
|
||||
import type { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js';
|
||||
import {
|
||||
HostedHookRecoveryRequiredError,
|
||||
type HostedHookSession,
|
||||
type HostedPromptHookRunner,
|
||||
} from './hosted-hook-session.js';
|
||||
import type { ManagedHookDispatcher } from '@qwen-code/qwen-code-core/hooks/hookEventHandler.js';
|
||||
import type { ManagedHookModelScope } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-activation.js';
|
||||
import { createHostedPromptHookRunner } from './hosted-hook-model.js';
|
||||
import {
|
||||
HookEventName,
|
||||
createHookOutput,
|
||||
type HookInput,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
|
||||
const HOSTED_EXPLICIT_HOOK_EVENTS = new Set<string>([
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.SessionEnd,
|
||||
HookEventName.SessionDelete,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.UserPromptExpansion,
|
||||
HookEventName.Stop,
|
||||
HookEventName.StopFailure,
|
||||
HookEventName.MessageDisplay,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PostToolUseFailure,
|
||||
HookEventName.PostToolBatch,
|
||||
HookEventName.PermissionRequest,
|
||||
]);
|
||||
|
||||
export interface HostedHarnessModelResult {
|
||||
text: string;
|
||||
@@ -35,10 +65,15 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
promptId: string;
|
||||
signal: AbortSignal;
|
||||
resumeFromToolResults?: readonly Part[];
|
||||
hooks?: HostedHookSession;
|
||||
modelScope?: ManagedHookModelScope;
|
||||
toolTurn?: Pick<
|
||||
HostedWorkspaceToolTurn,
|
||||
'execute' | 'consumeResults' | 'declarations'
|
||||
>;
|
||||
'execute' | 'consumeResults' | 'declarations' | 'setPromptHookRunner'
|
||||
> &
|
||||
Partial<
|
||||
Pick<HostedWorkspaceToolTurn, 'resumeHookResults' | 'hookStopReason'>
|
||||
>;
|
||||
textDeltas?: HostedHarnessTextDeltas;
|
||||
}): Promise<HostedHarnessModelResult> {
|
||||
const settings = loadSettings(input.cwd, {
|
||||
@@ -64,7 +99,109 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
true,
|
||||
{ toolInvocationGuard: () => ({ allowed: false }) },
|
||||
);
|
||||
let runPromptHook: HostedPromptHookRunner | undefined;
|
||||
let modelReady = false;
|
||||
let historyReady = false;
|
||||
const initialMessages: Content[] = input.history.flatMap((record) =>
|
||||
(record.type === 'user' ||
|
||||
record.type === 'assistant' ||
|
||||
record.type === 'tool_result') &&
|
||||
record.message?.parts
|
||||
? [
|
||||
{
|
||||
role: record.type === 'assistant' ? 'model' : 'user',
|
||||
parts: record.message.parts,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
if (!input.resumeFromToolResults)
|
||||
initialMessages.push({ role: 'user', parts: [{ text: input.prompt }] });
|
||||
const initializationHooks: Array<{
|
||||
fields: HookInput;
|
||||
signal?: AbortSignal;
|
||||
}> = [];
|
||||
const nativeOccurrences = new Map<string, number>();
|
||||
const fire = async (
|
||||
event: HookEventName,
|
||||
occurrence: string,
|
||||
fields: Record<string, unknown>,
|
||||
signal = input.signal,
|
||||
) => {
|
||||
const result = await input.hooks?.fire(
|
||||
event,
|
||||
occurrence,
|
||||
{
|
||||
...fields,
|
||||
prompt_id: input.promptId,
|
||||
messages: structuredClone(
|
||||
fields['messages'] ??
|
||||
(historyReady
|
||||
? config.getLlmClient().getHistory()
|
||||
: initialMessages),
|
||||
),
|
||||
},
|
||||
signal,
|
||||
runPromptHook,
|
||||
);
|
||||
return result ? createHookOutput(event, result) : undefined;
|
||||
};
|
||||
const dispatcher: ManagedHookDispatcher = {
|
||||
hasHooksForEvent: (event) =>
|
||||
!HOSTED_EXPLICIT_HOOK_EVENTS.has(event) &&
|
||||
(input.hooks
|
||||
?.getCatalog()
|
||||
?.hooks.some((hook) => hook.eventName === event) ??
|
||||
false),
|
||||
async execute(event, fields, signal) {
|
||||
const empty = {
|
||||
success: true,
|
||||
allOutputs: [],
|
||||
errors: [],
|
||||
totalDuration: 0,
|
||||
};
|
||||
if (!modelReady) {
|
||||
if (event !== HookEventName.InstructionsLoaded)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
initializationHooks.push({ fields, signal });
|
||||
return empty;
|
||||
}
|
||||
const stableFields = { ...fields, timestamp: undefined };
|
||||
const key = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
event,
|
||||
fields: { ...stableFields, messages: undefined },
|
||||
resumedAfter:
|
||||
event !== HookEventName.InstructionsLoaded &&
|
||||
input.resumeFromToolResults
|
||||
? input.history.at(-1)?.uuid
|
||||
: null,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
const ordinal = nativeOccurrences.get(key) ?? 0;
|
||||
nativeOccurrences.set(key, ordinal + 1);
|
||||
const occurrenceId = `${input.promptId}:native:${key}:${ordinal}`;
|
||||
if (
|
||||
event === HookEventName.InstructionsLoaded &&
|
||||
input.hooks?.hasCompletedOccurrence(event, occurrenceId)
|
||||
)
|
||||
return empty;
|
||||
try {
|
||||
const output = await fire(event, occurrenceId, stableFields, signal);
|
||||
return {
|
||||
...empty,
|
||||
allOutputs: output ? [output] : [],
|
||||
finalOutput: output,
|
||||
};
|
||||
} catch {
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
}
|
||||
},
|
||||
};
|
||||
try {
|
||||
await input.hooks?.ensureReady(input.signal);
|
||||
await config.initialize({
|
||||
signal: input.signal,
|
||||
skipHooks: true,
|
||||
@@ -72,11 +209,70 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
skipSkillManager: true,
|
||||
skipFileCheckpointing: true,
|
||||
lenientToolWarmup: true,
|
||||
...(input.hooks ? { managedHookDispatcher: dispatcher } : {}),
|
||||
});
|
||||
const authType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!authType)
|
||||
throw new Error('Hosted Harness model authentication is unavailable.');
|
||||
await config.refreshAuth(authType, true);
|
||||
await input.modelScope?.bindBudget(config.getTurnBudget(), input.prompt);
|
||||
runPromptHook = input.modelScope
|
||||
? createHostedPromptHookRunner(config, input.modelScope)
|
||||
: undefined;
|
||||
if (runPromptHook) input.toolTurn?.setPromptHookRunner?.(runPromptHook);
|
||||
modelReady = true;
|
||||
let effectivePrompt = input.prompt;
|
||||
if (input.hooks && !input.resumeFromToolResults) {
|
||||
const startupId = `session-start:${input.sessionId}`;
|
||||
const start = input.hooks.hasCompletedOccurrence(
|
||||
HookEventName.SessionStart,
|
||||
startupId,
|
||||
)
|
||||
? undefined
|
||||
: await fire(HookEventName.SessionStart, startupId, {
|
||||
source: 'startup',
|
||||
});
|
||||
if (start?.shouldStopExecution() || start?.isBlockingDecision())
|
||||
throw new Error('Hook blocked the model request.');
|
||||
for (const queued of initializationHooks)
|
||||
await dispatcher.execute(
|
||||
HookEventName.InstructionsLoaded,
|
||||
queued.fields,
|
||||
queued.signal,
|
||||
);
|
||||
const submit = await fire(
|
||||
HookEventName.UserPromptSubmit,
|
||||
input.promptId,
|
||||
{ prompt: input.prompt, submitted_prompt: input.prompt },
|
||||
);
|
||||
if (submit?.shouldStopExecution() || submit?.isBlockingDecision())
|
||||
throw new Error('Hook blocked the model request.');
|
||||
const updated = submit?.hookSpecificOutput?.['updatedPrompt'];
|
||||
effectivePrompt = typeof updated === 'string' ? updated : input.prompt;
|
||||
const context = [
|
||||
start?.getAdditionalContext(),
|
||||
submit?.getAdditionalContext(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (context) effectivePrompt += `\n${context}`;
|
||||
} else {
|
||||
for (const queued of initializationHooks)
|
||||
await dispatcher.execute(
|
||||
HookEventName.InstructionsLoaded,
|
||||
queued.fields,
|
||||
queued.signal,
|
||||
);
|
||||
}
|
||||
const textDeltas = input.hooks
|
||||
?.getCatalog()
|
||||
?.hooks.some(
|
||||
(hook) =>
|
||||
hook.eventName === HookEventName.Stop ||
|
||||
hook.eventName === HookEventName.MessageDisplay,
|
||||
)
|
||||
? undefined
|
||||
: input.textDeltas;
|
||||
const client = config.getLlmClient();
|
||||
const registry = config.getToolRegistry();
|
||||
await registry.warmAll();
|
||||
@@ -125,11 +321,28 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
: answered(entry),
|
||||
),
|
||||
);
|
||||
historyReady = true;
|
||||
input.hooks?.setMessagesProvider(() =>
|
||||
client.getHistory().map((message) => ({ ...message })),
|
||||
);
|
||||
let request: Part[] = input.resumeFromToolResults
|
||||
? [...input.resumeFromToolResults]
|
||||
: [{ text: input.prompt }];
|
||||
? ((await input.toolTurn?.resumeHookResults?.(
|
||||
input.resumeFromToolResults,
|
||||
config.getModel(),
|
||||
input.signal,
|
||||
)) ?? [...input.resumeFromToolResults])
|
||||
: [{ text: effectivePrompt }];
|
||||
let pendingToolResults = input.resumeFromToolResults !== undefined;
|
||||
let stopHookActive =
|
||||
(await input.hooks?.wasStopBlocked(input.promptId)) ?? false;
|
||||
for (let round = 0; round < 16; round++) {
|
||||
input.signal.throwIfAborted();
|
||||
if (input.hooks?.hasPendingOperations)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
if (input.toolTurn?.hookStopReason) {
|
||||
const text = input.toolTurn.hookStopReason;
|
||||
return { text, parts: [{ text }], model: config.getModel() };
|
||||
}
|
||||
if (input.toolTurn)
|
||||
client.getChat().setTools([
|
||||
{
|
||||
@@ -141,69 +354,130 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
let calls: ToolCallRequestInfo[] = [];
|
||||
let text = '';
|
||||
let finished = false;
|
||||
for await (const event of client.sendMessageStream(
|
||||
request,
|
||||
input.signal,
|
||||
input.promptId,
|
||||
{
|
||||
type:
|
||||
round === 0 && !input.resumeFromToolResults
|
||||
? SendMessageType.UserQuery
|
||||
: SendMessageType.ToolResult,
|
||||
},
|
||||
)) {
|
||||
if (event.type === LlmEventType.Content) {
|
||||
text += event.value;
|
||||
await input.textDeltas?.delta(event.value);
|
||||
} else if (event.type === LlmEventType.Finished) finished = true;
|
||||
else if (event.type === LlmEventType.Retry) {
|
||||
calls = [];
|
||||
if (!event.isContinuation) {
|
||||
if (input.textDeltas?.published()) {
|
||||
let modelFailure = true;
|
||||
const completeAttempt = await input.modelScope?.beginMainAttempt(
|
||||
config.getModel(),
|
||||
);
|
||||
const modelOccurrence =
|
||||
completeAttempt?.attemptId ?? `${input.promptId}:${round}`;
|
||||
const usage: unknown[] = [];
|
||||
try {
|
||||
for await (const event of client.sendMessageStream(
|
||||
request,
|
||||
input.signal,
|
||||
input.promptId,
|
||||
{
|
||||
type:
|
||||
round === 0 && !input.resumeFromToolResults
|
||||
? SendMessageType.UserQuery
|
||||
: SendMessageType.ToolResult,
|
||||
},
|
||||
)) {
|
||||
if (event.type === LlmEventType.Content) {
|
||||
text += event.value;
|
||||
await textDeltas?.delta(event.value);
|
||||
} else if (event.type === LlmEventType.Finished) {
|
||||
finished = true;
|
||||
usage.push(event.value?.usageMetadata ?? null);
|
||||
} else if (event.type === LlmEventType.Retry) {
|
||||
calls = [];
|
||||
if (!event.isContinuation) {
|
||||
if (textDeltas?.published()) {
|
||||
throw new Error(
|
||||
'Hosted Harness cannot retract a published model attempt.',
|
||||
);
|
||||
}
|
||||
text = '';
|
||||
}
|
||||
} else if (event.type === LlmEventType.ModelFallback) {
|
||||
if (textDeltas?.published()) {
|
||||
throw new Error(
|
||||
'Hosted Harness cannot retract a published model attempt.',
|
||||
);
|
||||
}
|
||||
calls = [];
|
||||
text = '';
|
||||
}
|
||||
} else if (event.type === LlmEventType.ModelFallback) {
|
||||
if (input.textDeltas?.published()) {
|
||||
} else if (
|
||||
event.type === LlmEventType.ToolCallRequest &&
|
||||
input.toolTurn
|
||||
) {
|
||||
calls.push(event.value);
|
||||
} else if (
|
||||
event.type === LlmEventType.ToolCallRequest ||
|
||||
event.type === LlmEventType.ToolCallConfirmation ||
|
||||
event.type === LlmEventType.ToolCallResponse
|
||||
) {
|
||||
modelFailure = false;
|
||||
throw new Error('Hosted Harness no-tool turn refused a tool call.');
|
||||
} else if (event.type === LlmEventType.Error) {
|
||||
throw new Error(event.value.error.message);
|
||||
} else if (event.type === LlmEventType.UserCancelled) {
|
||||
modelFailure = false;
|
||||
throw new Error('Hosted Harness turn was cancelled.');
|
||||
} else if (
|
||||
event.type !== LlmEventType.ChatCompressed &&
|
||||
event.type !== LlmEventType.Thought &&
|
||||
event.type !== LlmEventType.Citation
|
||||
) {
|
||||
modelFailure = false;
|
||||
throw new Error(
|
||||
'Hosted Harness cannot retract a published model attempt.',
|
||||
'Hosted Harness model returned an unsupported continuation.',
|
||||
);
|
||||
}
|
||||
calls = [];
|
||||
text = '';
|
||||
} else if (
|
||||
event.type === LlmEventType.ToolCallRequest &&
|
||||
input.toolTurn
|
||||
}
|
||||
if (!finished)
|
||||
throw new Error('Hosted Harness model turn did not finish.');
|
||||
} catch (cause) {
|
||||
if (
|
||||
modelFailure &&
|
||||
!input.signal.aborted &&
|
||||
!(cause instanceof HostedHookRecoveryRequiredError)
|
||||
) {
|
||||
calls.push(event.value);
|
||||
} else if (
|
||||
event.type === LlmEventType.ToolCallRequest ||
|
||||
event.type === LlmEventType.ToolCallConfirmation ||
|
||||
event.type === LlmEventType.ToolCallResponse
|
||||
) {
|
||||
throw new Error('Hosted Harness no-tool turn refused a tool call.');
|
||||
} else if (event.type === LlmEventType.Error) {
|
||||
throw new Error(event.value.error.message);
|
||||
} else if (event.type === LlmEventType.UserCancelled) {
|
||||
throw new Error('Hosted Harness turn was cancelled.');
|
||||
} else if (
|
||||
event.type !== LlmEventType.ChatCompressed &&
|
||||
event.type !== LlmEventType.Thought &&
|
||||
event.type !== LlmEventType.Citation
|
||||
) {
|
||||
throw new Error(
|
||||
'Hosted Harness model returned an unsupported continuation.',
|
||||
await fire(
|
||||
HookEventName.StopFailure,
|
||||
`${modelOccurrence}:model-failure`,
|
||||
{
|
||||
error: 'unknown',
|
||||
error_details:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
last_assistant_message: text,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
} finally {
|
||||
await completeAttempt?.(finished, usage);
|
||||
}
|
||||
if (!finished)
|
||||
throw new Error('Hosted Harness model turn did not finish.');
|
||||
if (!input.toolTurn) return { text, model: config.getModel() };
|
||||
if (round > 0 || input.resumeFromToolResults)
|
||||
if (pendingToolResults && input.toolTurn) {
|
||||
await input.toolTurn.consumeResults();
|
||||
pendingToolResults = false;
|
||||
}
|
||||
let suppressDisplay = false;
|
||||
if (calls.length === 0) {
|
||||
const stopped = await fire(HookEventName.Stop, modelOccurrence, {
|
||||
stop_hook_active: stopHookActive,
|
||||
last_assistant_message: text,
|
||||
});
|
||||
if (stopped?.isBlockingDecision() || stopped?.shouldStopExecution()) {
|
||||
stopHookActive = true;
|
||||
request = [
|
||||
{ text: stopped.getEffectiveReason() || 'Continue working.' },
|
||||
];
|
||||
continue;
|
||||
}
|
||||
const display = await fire(
|
||||
HookEventName.MessageDisplay,
|
||||
modelOccurrence,
|
||||
{
|
||||
message_id: modelOccurrence,
|
||||
displayed_text: text,
|
||||
is_final: true,
|
||||
},
|
||||
);
|
||||
suppressDisplay = display?.suppressOutput ?? false;
|
||||
if (suppressDisplay) text = '';
|
||||
}
|
||||
if (!input.toolTurn) return { text, model: config.getModel() };
|
||||
const output = client.getHistory().at(-1);
|
||||
if (output?.role !== 'model' || !output.parts)
|
||||
throw new Error('Hosted model output is unavailable.');
|
||||
@@ -217,16 +491,23 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
throw new Error('Hosted model call identity changed.');
|
||||
part.functionCall!.id = call.callId;
|
||||
}
|
||||
if (calls.length === 0) return { text, parts, model: config.getModel() };
|
||||
if (calls.length === 0)
|
||||
return {
|
||||
text,
|
||||
parts: suppressDisplay ? [] : parts,
|
||||
model: config.getModel(),
|
||||
};
|
||||
request = await input.toolTurn.execute(
|
||||
calls,
|
||||
parts,
|
||||
config.getModel(),
|
||||
input.signal,
|
||||
);
|
||||
pendingToolResults = true;
|
||||
}
|
||||
throw new Error('Hosted tool turn exceeded 16 model rounds.');
|
||||
} finally {
|
||||
input.hooks?.setMessagesProvider(undefined);
|
||||
try {
|
||||
await config.shutdown({
|
||||
shutdownTelemetry: false,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import { TurnBudget } from '@qwen-code/qwen-code-core/core/turn-budget.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import type { ManagedHookModelScope } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-activation.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import * as stdio from '../utils/stdioHelpers.js';
|
||||
import {
|
||||
createHostedPromptHookRunner,
|
||||
runHostedHookOperation,
|
||||
} from './hosted-hook-model.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ config: undefined as unknown }));
|
||||
vi.mock('../config/settings.js', () => ({
|
||||
loadSettings: () => ({ merged: {} }),
|
||||
}));
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(async () => state.config),
|
||||
}));
|
||||
|
||||
const hook = { type: HookType.Prompt as const, prompt: '$ARGUMENTS' };
|
||||
const input = {
|
||||
session_id: 'session-1',
|
||||
transcript_path: '',
|
||||
cwd: '/workspace',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: '2026-09-30T00:00:00.000Z',
|
||||
managed_hook_execution_id: 'execution-1',
|
||||
managed_hook_occurrence_id: 'occurrence-1',
|
||||
managed_hook_origin_turn_id: null,
|
||||
};
|
||||
|
||||
const response = (text = '{"ok":true}') =>
|
||||
({
|
||||
candidates: [{ content: { parts: [{ text }] } }],
|
||||
usageMetadata: { totalTokenCount: 7 },
|
||||
}) as GenerateContentResponse;
|
||||
|
||||
function fixture() {
|
||||
const generateContent = vi.fn(async () => response());
|
||||
const generator = {
|
||||
generateContent,
|
||||
generateContentStream: vi.fn(),
|
||||
embedContent: vi.fn(),
|
||||
};
|
||||
const config = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
getModelsConfig: () => ({ getCurrentAuthType: () => 'test-auth' }),
|
||||
refreshAuth: vi.fn(async () => undefined),
|
||||
getModel: () => 'model-1',
|
||||
getTurnBudget: () => new TurnBudget(),
|
||||
getContentGenerator: () => generator,
|
||||
getContentGeneratorConfig: () => ({}),
|
||||
getBaseLlmClient: () => ({ resolveForModel: vi.fn() }),
|
||||
shutdown: vi.fn(async () => undefined),
|
||||
};
|
||||
state.config = config;
|
||||
const usage = vi.fn();
|
||||
const evaluate = vi.fn<ManagedHookModelScope['evaluate']>(
|
||||
async (_operation, run) => run(usage),
|
||||
);
|
||||
const scope: ManagedHookModelScope = {
|
||||
evaluate,
|
||||
bindBudget: vi.fn(),
|
||||
beginMainAttempt: vi.fn(),
|
||||
};
|
||||
return { config, generateContent, evaluate, usage, scope };
|
||||
}
|
||||
|
||||
describe('Hosted Hook model boundary', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves the operation result or error when cleanup fails (operation failed: %s)',
|
||||
async (failed) => {
|
||||
const { config, scope } = fixture();
|
||||
const log = vi
|
||||
.spyOn(stdio, 'writeStderrLineSafe')
|
||||
.mockImplementation(() => {});
|
||||
config.shutdown.mockRejectedValue(new Error('cleanup failed'));
|
||||
const primary = new Error('operation failed');
|
||||
const result = { committed: true };
|
||||
const operation = runHostedHookOperation(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
cwd: '/workspace',
|
||||
signal: new AbortController().signal,
|
||||
scope,
|
||||
},
|
||||
async () => {
|
||||
if (failed) throw primary;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
if (failed) await expect(operation).rejects.toBe(primary);
|
||||
else await expect(operation).resolves.toBe(result);
|
||||
expect(config.shutdown).toHaveBeenCalledExactlyOnceWith({
|
||||
shutdownTelemetry: false,
|
||||
strictResourceCleanup: true,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cleanup failed'),
|
||||
);
|
||||
log.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it('authorizes an isolated prompt evaluation and attributes provider usage to its original operation', async () => {
|
||||
const { config, generateContent, evaluate, usage, scope } = fixture();
|
||||
const signal = new AbortController().signal;
|
||||
const result = await runHostedHookOperation(
|
||||
{ sessionId: 'session-1', cwd: '/workspace', signal, scope },
|
||||
(runner) => runner(hook, HookEventName.SessionEnd, input, signal),
|
||||
);
|
||||
expect(result).toMatchObject({ success: true, outcome: 'success' });
|
||||
expect(config.initialize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skipHooks: true,
|
||||
skipMcpDiscovery: true,
|
||||
skipSkillManager: true,
|
||||
}),
|
||||
);
|
||||
expect(config.refreshAuth).toHaveBeenCalledWith('test-auth', true);
|
||||
expect(evaluate).toHaveBeenCalledWith(
|
||||
{
|
||||
operationId: 'execution-1',
|
||||
occurrenceId: 'occurrence-1',
|
||||
originTurnId: null,
|
||||
eventName: HookEventName.SessionEnd,
|
||||
model: 'model-1',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'model-1' }),
|
||||
'execution-1',
|
||||
);
|
||||
expect(usage).toHaveBeenCalledWith({
|
||||
model: 'model-1',
|
||||
usage: { totalTokenCount: 7 },
|
||||
});
|
||||
expect(config.shutdown).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
vi
|
||||
.mocked(loadCliConfig)
|
||||
.mock.calls[0]?.[9]?.toolInvocationGuard?.({} as never),
|
||||
).toMatchObject({ allowed: false });
|
||||
});
|
||||
|
||||
it('cannot call the provider when controller authorization fails', async () => {
|
||||
const { config, generateContent, evaluate, scope } = fixture();
|
||||
evaluate.mockRejectedValue(new Error('slot is busy'));
|
||||
const runner = createHostedPromptHookRunner(
|
||||
config as unknown as Config,
|
||||
scope,
|
||||
);
|
||||
await expect(
|
||||
runner(
|
||||
hook,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow('slot is busy');
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('captures usage and operation identity when a Hook selects another configured model', async () => {
|
||||
const { config, generateContent, usage, scope } = fixture();
|
||||
const alternate = vi.fn(async () => response());
|
||||
const resolveForModel = vi.fn().mockResolvedValue({
|
||||
model: 'provider-model',
|
||||
contentGeneratorConfig: {},
|
||||
contentGenerator: {
|
||||
generateContent: alternate,
|
||||
generateContentStream: vi.fn(),
|
||||
embedContent: vi.fn(),
|
||||
},
|
||||
});
|
||||
config.getBaseLlmClient = () => ({ resolveForModel });
|
||||
const runner = createHostedPromptHookRunner(
|
||||
config as unknown as Config,
|
||||
scope,
|
||||
);
|
||||
await expect(
|
||||
runner(
|
||||
{ ...hook, model: 'configured-alias' },
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toMatchObject({ success: true });
|
||||
expect(resolveForModel).toHaveBeenCalledWith('configured-alias', {
|
||||
failClosed: true,
|
||||
});
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
expect(alternate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'provider-model' }),
|
||||
'execution-1',
|
||||
);
|
||||
expect(usage).toHaveBeenCalledWith({
|
||||
model: 'provider-model',
|
||||
usage: { totalTokenCount: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['not JSON', '{"ok":"yes"}'])(
|
||||
'reports invalid response %s as failure',
|
||||
async (text) => {
|
||||
const { config, generateContent, scope } = fixture();
|
||||
generateContent.mockResolvedValue(response(text));
|
||||
const runner = createHostedPromptHookRunner(
|
||||
config as unknown as Config,
|
||||
scope,
|
||||
);
|
||||
await expect(
|
||||
runner(
|
||||
hook,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the model slot until the provider really settles after timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { config, generateContent, scope } = fixture();
|
||||
let finish!: (value: GenerateContentResponse) => void;
|
||||
generateContent.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const runner = createHostedPromptHookRunner(
|
||||
config as unknown as Config,
|
||||
scope,
|
||||
);
|
||||
let completed = false;
|
||||
const running = runner(
|
||||
{ ...hook, timeout: 0.001 },
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
new AbortController().signal,
|
||||
).then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(completed).toBe(false);
|
||||
finish(response());
|
||||
await expect(running).resolves.toMatchObject({
|
||||
success: false,
|
||||
outcome: 'timeout',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import type { ContentGenerator } from '@qwen-code/qwen-code-core/core/contentGenerator.js';
|
||||
import { PromptHookRunner } from '@qwen-code/qwen-code-core/hooks/promptHookRunner.js';
|
||||
import type { ManagedHookModelScope } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-activation.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
|
||||
import type { HostedPromptHookRunner } from './hosted-hook-session.js';
|
||||
|
||||
function validateResponse(response: GenerateContentResponse): void {
|
||||
const text = response.candidates?.[0]?.content?.parts
|
||||
?.filter((part) => !part.thought)
|
||||
.map((part) => part.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
const json = text?.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] ?? text;
|
||||
const result: unknown = JSON.parse(json ?? '');
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
!('ok' in result) ||
|
||||
typeof result.ok !== 'boolean' ||
|
||||
('reason' in result && typeof result.reason !== 'string') ||
|
||||
('additionalContext' in result &&
|
||||
typeof result.additionalContext !== 'string')
|
||||
) {
|
||||
throw new Error('Hosted prompt Hook returned an invalid decision.');
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostedPromptHookRunner(
|
||||
config: Config,
|
||||
scope: ManagedHookModelScope,
|
||||
): HostedPromptHookRunner {
|
||||
return async (hookConfig, eventName, input, signal) => {
|
||||
const operationId =
|
||||
'managed_hook_execution_id' in input
|
||||
? input.managed_hook_execution_id
|
||||
: undefined;
|
||||
const occurrenceId =
|
||||
'managed_hook_occurrence_id' in input
|
||||
? input.managed_hook_occurrence_id
|
||||
: undefined;
|
||||
const originTurnId =
|
||||
'managed_hook_origin_turn_id' in input
|
||||
? input.managed_hook_origin_turn_id
|
||||
: undefined;
|
||||
if (
|
||||
typeof operationId !== 'string' ||
|
||||
typeof occurrenceId !== 'string' ||
|
||||
(originTurnId !== null && typeof originTurnId !== 'string')
|
||||
) {
|
||||
throw new Error('Hosted prompt Hook is missing its committed identity.');
|
||||
}
|
||||
return scope.evaluate(
|
||||
{
|
||||
operationId,
|
||||
occurrenceId,
|
||||
originTurnId,
|
||||
eventName,
|
||||
model: hookConfig.model ?? config.getModel(),
|
||||
},
|
||||
async (recordUsage) => {
|
||||
const pending: Array<Promise<GenerateContentResponse>> = [];
|
||||
const wrap = (generator: ContentGenerator): ContentGenerator => ({
|
||||
generateContent(request) {
|
||||
const response = generator.generateContent(request, operationId);
|
||||
pending.push(response);
|
||||
return response.then((result) => {
|
||||
recordUsage({
|
||||
model: request.model,
|
||||
usage: result.usageMetadata ?? null,
|
||||
});
|
||||
validateResponse(result);
|
||||
return result;
|
||||
});
|
||||
},
|
||||
generateContentStream:
|
||||
generator.generateContentStream.bind(generator),
|
||||
embedContent: generator.embedContent.bind(generator),
|
||||
});
|
||||
const hooked = new Proxy(config, {
|
||||
get(target, property) {
|
||||
if (property === 'getContentGenerator') {
|
||||
return () => wrap(target.getContentGenerator());
|
||||
}
|
||||
if (property === 'getBaseLlmClient') {
|
||||
return () => ({
|
||||
async resolveForModel(
|
||||
...args: Parameters<
|
||||
ReturnType<Config['getBaseLlmClient']>['resolveForModel']
|
||||
>
|
||||
) {
|
||||
const resolved = await target
|
||||
.getBaseLlmClient()
|
||||
.resolveForModel(...args);
|
||||
return {
|
||||
...resolved,
|
||||
contentGenerator: wrap(resolved.contentGenerator),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await new PromptHookRunner(hooked).execute(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
signal,
|
||||
);
|
||||
} finally {
|
||||
// A timeout requests cancellation; the slot stays held until the
|
||||
// provider promise settles, including providers that ignore abort.
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function runHostedHookOperation<T>(
|
||||
input: {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
signal: AbortSignal;
|
||||
scope: ManagedHookModelScope;
|
||||
},
|
||||
run: (runner: HostedPromptHookRunner) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const settings = loadSettings(input.cwd, {
|
||||
skipLoadEnvironment: true,
|
||||
skipWorkspaceSettings: true,
|
||||
workspaceTrusted: false,
|
||||
});
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
{
|
||||
acp: true,
|
||||
safeMode: true,
|
||||
chatRecording: false,
|
||||
sessionId: input.sessionId,
|
||||
} as CliArgs,
|
||||
input.cwd,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
{ toolInvocationGuard: () => ({ allowed: false }) },
|
||||
);
|
||||
try {
|
||||
await config.initialize({
|
||||
signal: input.signal,
|
||||
skipHooks: true,
|
||||
skipMcpDiscovery: true,
|
||||
skipSkillManager: true,
|
||||
skipFileCheckpointing: true,
|
||||
lenientToolWarmup: true,
|
||||
});
|
||||
const authType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!authType)
|
||||
throw new Error('Hosted Hook model authentication is unavailable.');
|
||||
await config.refreshAuth(authType, true);
|
||||
await input.scope.bindBudget(config.getTurnBudget());
|
||||
return await run(createHostedPromptHookRunner(config, input.scope));
|
||||
} finally {
|
||||
try {
|
||||
await config.shutdown({
|
||||
shutdownTelemetry: false,
|
||||
strictResourceCleanup: true,
|
||||
});
|
||||
} catch (cause) {
|
||||
writeStderrLineSafe(
|
||||
`qwen serve: Hosted Hook model cleanup failed: ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -94,6 +94,7 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
settleBeforeClose = false,
|
||||
withIntent = true,
|
||||
extraExecutionId?: string,
|
||||
runtimeSessionId = PROMPT_ID,
|
||||
): Promise<ManagedSession> {
|
||||
const session = await open('boot-1', true);
|
||||
const harness = createManagedHarnessHandle(session);
|
||||
@@ -138,7 +139,7 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
const input = Buffer.from(
|
||||
JSON.stringify({
|
||||
harnessSessionId: SESSION_ID,
|
||||
runtimeSessionId: PROMPT_ID,
|
||||
runtimeSessionId,
|
||||
payloadJson: JSON.stringify({
|
||||
toolName,
|
||||
input: { file_path: `${ordinal}.txt`, content: 'x' },
|
||||
@@ -433,38 +434,42 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an execution the Broker cannot account for as unknown', async () => {
|
||||
await parkAtAwaitRuntime();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
const replacement = await open('boot-2', false);
|
||||
try {
|
||||
const recovered = await recoverHostedRuntimeTurn({
|
||||
session: replacement,
|
||||
sessionId: SESSION_ID,
|
||||
cwd: root,
|
||||
promptId: PROMPT_ID,
|
||||
brokerOptions,
|
||||
passive: true,
|
||||
});
|
||||
expect(recovered!.report.executions).toEqual([
|
||||
expect.objectContaining({
|
||||
executionCallId: EXECUTION_ID,
|
||||
outcome: 'unknown',
|
||||
}),
|
||||
]);
|
||||
// A passive load only reads: nothing may be journaled for the prompt.
|
||||
expect(
|
||||
(await replacement.sink.project()).filter(
|
||||
(entry) =>
|
||||
entry.daemonPromptId === PROMPT_ID && entry.type === 'tool_result',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
});
|
||||
it.each([undefined, { state: 'unknown' }])(
|
||||
'reports an execution the Broker cannot account for as unknown (%s)',
|
||||
async (status) => {
|
||||
await parkAtAwaitRuntime();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue(
|
||||
status,
|
||||
);
|
||||
const replacement = await open('boot-2', false);
|
||||
try {
|
||||
const recovered = await recoverHostedRuntimeTurn({
|
||||
session: replacement,
|
||||
sessionId: SESSION_ID,
|
||||
cwd: root,
|
||||
promptId: PROMPT_ID,
|
||||
brokerOptions,
|
||||
passive: true,
|
||||
});
|
||||
expect(recovered!.report.executions).toEqual([
|
||||
expect.objectContaining({
|
||||
executionCallId: EXECUTION_ID,
|
||||
outcome: 'unknown',
|
||||
}),
|
||||
]);
|
||||
// A passive load only reads: nothing may be journaled for the prompt.
|
||||
expect(
|
||||
(await replacement.sink.project()).filter(
|
||||
(entry) =>
|
||||
entry.daemonPromptId === PROMPT_ID &&
|
||||
entry.type === 'tool_result',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses a turn whose checkpoint is not a Runtime wait', async () => {
|
||||
const session = await open('boot-1', true);
|
||||
@@ -529,37 +534,46 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
});
|
||||
expect(recovered).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
// The decline happens after the lease was acquired, so it must hand it
|
||||
// back — the caller never learns about it otherwise.
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
// Missing ownership evidence is refused before acquiring a Runtime.
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels parked executions without settling them', async () => {
|
||||
await parkAtAwaitRuntime();
|
||||
await parkAtAwaitRuntime(
|
||||
'write_file',
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
'hooks-old-owner',
|
||||
);
|
||||
let stopped = false;
|
||||
const order: string[] = [];
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockImplementation(
|
||||
async () => {
|
||||
async function (this: HostedWorkspaceBroker) {
|
||||
expect(this.runtimeSessionId).toBe('hooks-old-owner');
|
||||
order.push('status');
|
||||
return { state: stopped ? 'settled' : 'executing' };
|
||||
},
|
||||
);
|
||||
const cancel = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'cancel')
|
||||
.mockImplementation(async () => {
|
||||
.mockImplementation(async function (this: HostedWorkspaceBroker) {
|
||||
expect(this.runtimeSessionId).toBe('hooks-old-owner');
|
||||
order.push('cancel');
|
||||
stopped = true;
|
||||
});
|
||||
const replacement = await open('boot-2', false);
|
||||
try {
|
||||
await stopParkedRuntimeExecutions({
|
||||
const broker = await stopParkedRuntimeExecutions({
|
||||
session: replacement,
|
||||
promptId: PROMPT_ID,
|
||||
brokerOptions,
|
||||
});
|
||||
expect(broker.runtimeSessionId).toBe('hooks-old-owner');
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(cancel).toHaveBeenCalledWith(EXECUTION_ID);
|
||||
// Issuing the cancel is not proof of the stop: a status read must
|
||||
@@ -640,6 +654,44 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'refuses cancellation when an execution outcome is unknown (afterCancel=%s)',
|
||||
async (afterCancel) => {
|
||||
await parkAtAwaitRuntime();
|
||||
const status = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'status')
|
||||
.mockResolvedValue({ state: 'unknown' });
|
||||
if (afterCancel) status.mockResolvedValueOnce({ state: 'executing' });
|
||||
const cancel = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'cancel')
|
||||
.mockResolvedValue();
|
||||
const release = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'release')
|
||||
.mockResolvedValue();
|
||||
const replacement = await open('boot-2', false);
|
||||
try {
|
||||
await expect(
|
||||
stopParkedRuntimeExecutions({
|
||||
session: replacement,
|
||||
promptId: PROMPT_ID,
|
||||
brokerOptions,
|
||||
}),
|
||||
).rejects.toThrow('Runtime execution outcome is unknown.');
|
||||
expect(cancel).toHaveBeenCalledTimes(afterCancel ? 1 : 0);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
const authorization =
|
||||
await replacement.authority.harnessRunAuthorization();
|
||||
expect(authorization.status).toBe('runnable');
|
||||
if (authorization.status === 'runnable')
|
||||
expect(authorization.checkpoint.continuation.phase).toBe(
|
||||
'await_runtime',
|
||||
);
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('does not journal a tool result twice across a recovery retry', async () => {
|
||||
await parkAtAwaitRuntime('write_file', true);
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'acquire').mockResolvedValue();
|
||||
@@ -678,6 +730,59 @@ describe('recoverHostedRuntimeTurn', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps a shared Runtime owner during recovery (passive=%s)',
|
||||
async (passive) => {
|
||||
await parkAtAwaitRuntime(
|
||||
'write_file',
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
'hooks-old-owner',
|
||||
);
|
||||
const acquire = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'acquire')
|
||||
.mockImplementation(async function (this: HostedWorkspaceBroker) {
|
||||
expect(this.runtimeSessionId).toBe('hooks-old-owner');
|
||||
});
|
||||
const status = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'status')
|
||||
.mockImplementation(async function (this: HostedWorkspaceBroker) {
|
||||
expect(this.runtimeSessionId).toBe('hooks-old-owner');
|
||||
return { state: 'executing' };
|
||||
});
|
||||
const execute = vi
|
||||
.spyOn(HostedWorkspaceBroker.prototype, 'execute')
|
||||
.mockImplementation(async function (this: HostedWorkspaceBroker) {
|
||||
expect(this.runtimeSessionId).toBe('hooks-old-owner');
|
||||
return {
|
||||
executionStatus: 'success',
|
||||
responseParts: [{ text: 'done' }],
|
||||
} as never;
|
||||
});
|
||||
const replacement = await open('boot-2', false);
|
||||
try {
|
||||
const recovered = await recoverHostedRuntimeTurn({
|
||||
session: replacement,
|
||||
sessionId: SESSION_ID,
|
||||
cwd: root,
|
||||
promptId: PROMPT_ID,
|
||||
brokerOptions,
|
||||
passive,
|
||||
});
|
||||
expect(recovered?.report.executions[0]?.runtimeSessionId).toBe(
|
||||
'hooks-old-owner',
|
||||
);
|
||||
expect(acquire).toHaveBeenCalledTimes(passive ? 0 : 1);
|
||||
expect(execute).toHaveBeenCalledTimes(passive ? 0 : 1);
|
||||
expect(status).toHaveBeenCalledTimes(passive ? 1 : 0);
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('reports only the state, never the result payload, from a passive read', async () => {
|
||||
await parkAtAwaitRuntime();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({
|
||||
|
||||
@@ -9,7 +9,10 @@ import type { Part } from '@google/genai';
|
||||
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import { createManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js';
|
||||
import type { HarnessToolItem } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-checkpoint.js';
|
||||
import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import {
|
||||
assertManagedSessionStableId,
|
||||
type ManagedSessionDurableRef,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
|
||||
import {
|
||||
convertToFunctionErrorResponse,
|
||||
@@ -50,6 +53,70 @@ export interface HostedRecoveryTurn {
|
||||
acquiredRuntime: boolean;
|
||||
}
|
||||
|
||||
async function originalRuntimeBroker(
|
||||
session: ManagedSession,
|
||||
promptId: string,
|
||||
items: readonly HarnessToolItem[],
|
||||
options: HostedWorkspaceBrokerOptions,
|
||||
): Promise<HostedWorkspaceBroker> {
|
||||
const owners = new Set<string>();
|
||||
const intents = new Map(
|
||||
session.authority
|
||||
.eventsInSequenceRange(1, session.authority.committedSequence)
|
||||
.filter((event) => event.kind === 'tool.intent')
|
||||
.map((event) => [
|
||||
event.payload['executionCallId'],
|
||||
event.payload['argsRef'],
|
||||
]),
|
||||
);
|
||||
for (const item of items) {
|
||||
if (item.outcomeSource !== 'runtime') continue;
|
||||
const ref = intents.get(item.executionCallId) as
|
||||
| ManagedSessionDurableRef
|
||||
| undefined;
|
||||
if (!ref) throw new RecoveryDeclined();
|
||||
if (
|
||||
ref.kind === 'managed-tool-args' &&
|
||||
item.toolName === 'run_shell_command'
|
||||
) {
|
||||
const definition = JSON.parse(
|
||||
(
|
||||
await session.resources.read(
|
||||
session.authority.sessionHeader.definitionRef,
|
||||
)
|
||||
).toString('utf8'),
|
||||
) as { hookCatalog?: unknown; mcpServers?: unknown };
|
||||
if (definition.hookCatalog || definition.mcpServers)
|
||||
throw new RecoveryDeclined();
|
||||
owners.add(promptId);
|
||||
continue;
|
||||
}
|
||||
if (ref.kind !== 'managed-tool-input') throw new RecoveryDeclined();
|
||||
const route = JSON.parse(
|
||||
(await session.resources.read(ref)).toString('utf8'),
|
||||
) as { harnessSessionId?: unknown; runtimeSessionId?: unknown };
|
||||
if (
|
||||
route.harnessSessionId !==
|
||||
session.authority.sessionHeader.sessionKey.sessionId ||
|
||||
typeof route.runtimeSessionId !== 'string'
|
||||
)
|
||||
throw new RecoveryDeclined();
|
||||
owners.add(
|
||||
assertManagedSessionStableId(
|
||||
route.runtimeSessionId,
|
||||
'recovered Runtime owner',
|
||||
),
|
||||
);
|
||||
}
|
||||
const [runtimeSessionId] = owners;
|
||||
if (owners.size !== 1 || !runtimeSessionId) throw new RecoveryDeclined();
|
||||
return new HostedWorkspaceBroker(
|
||||
options,
|
||||
session.authority.sessionHeader.sessionKey,
|
||||
runtimeSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
function toolResultParts(
|
||||
item: HarnessToolItem,
|
||||
result: ManagedToolResultPayload,
|
||||
@@ -174,23 +241,32 @@ export async function stopParkedRuntimeExecutions(input: {
|
||||
session: ManagedSession;
|
||||
promptId: string;
|
||||
brokerOptions: HostedWorkspaceBrokerOptions;
|
||||
}): Promise<void> {
|
||||
}): Promise<HostedWorkspaceBroker> {
|
||||
const authorization = await input.session.authority.harnessRunAuthorization();
|
||||
if (authorization.status !== 'runnable') return;
|
||||
const broker = new HostedWorkspaceBroker(
|
||||
input.brokerOptions,
|
||||
input.session.authority.sessionHeader.sessionKey,
|
||||
if (
|
||||
authorization.status !== 'runnable' ||
|
||||
authorization.checkpoint.identity.turnId !== input.promptId
|
||||
)
|
||||
throw new RecoveryDeclined();
|
||||
const broker = await originalRuntimeBroker(
|
||||
input.session,
|
||||
input.promptId,
|
||||
authorization.checkpoint.tools?.items ?? [],
|
||||
input.brokerOptions,
|
||||
);
|
||||
for (const item of authorization.checkpoint.tools?.items ?? []) {
|
||||
if (item.state !== 'in_progress' || item.outcomeSource !== 'runtime')
|
||||
continue;
|
||||
const before = await broker.status(item.executionCallId);
|
||||
if (before?.state === 'unknown')
|
||||
throw new Error('Runtime execution outcome is unknown.');
|
||||
if (before === undefined || before.state === 'settled') continue;
|
||||
await broker.cancel(item.executionCallId).catch(() => undefined);
|
||||
const deadline = Date.now() + 30_000;
|
||||
for (;;) {
|
||||
const status = await broker.status(item.executionCallId);
|
||||
if (status?.state === 'unknown')
|
||||
throw new Error('Runtime execution outcome is unknown.');
|
||||
if (status === undefined || status.state === 'settled') break;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
@@ -200,6 +276,7 @@ export async function stopParkedRuntimeExecutions(input: {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
return broker;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,11 +312,18 @@ export async function recoverHostedRuntimeTurn(input: {
|
||||
const items = (checkpoint.tools?.items ?? []).filter(
|
||||
(item) => item.outcomeSource === 'runtime',
|
||||
);
|
||||
const broker = new HostedWorkspaceBroker(
|
||||
input.brokerOptions,
|
||||
session.authority.sessionHeader.sessionKey,
|
||||
promptId,
|
||||
);
|
||||
let broker: HostedWorkspaceBroker;
|
||||
try {
|
||||
broker = await originalRuntimeBroker(
|
||||
session,
|
||||
promptId,
|
||||
items,
|
||||
input.brokerOptions,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof RecoveryDeclined) return undefined;
|
||||
throw cause;
|
||||
}
|
||||
const pending = items.filter((item) => item.state === 'in_progress');
|
||||
const states = new Map<string, { state: string } | undefined>();
|
||||
let acquiredRuntime = false;
|
||||
@@ -249,7 +333,7 @@ export async function recoverHostedRuntimeTurn(input: {
|
||||
const status = await broker.status(item.executionCallId);
|
||||
states.set(
|
||||
item.executionCallId,
|
||||
status === undefined ? undefined : { state: status.state },
|
||||
status?.state === 'unknown' ? undefined : status,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -419,7 +503,7 @@ export async function recoverHostedRuntimeTurn(input: {
|
||||
functionCallId: item.functionCallId,
|
||||
toolName: item.toolName,
|
||||
executionCallId: item.executionCallId,
|
||||
runtimeSessionId: promptId,
|
||||
runtimeSessionId: broker.runtimeSessionId,
|
||||
outcome:
|
||||
item.state === 'settled' || state !== undefined ? 'known' : 'unknown',
|
||||
...(item.state === 'settled'
|
||||
|
||||
@@ -541,6 +541,44 @@ it('stops observation immediately when the original execution is terminally unkn
|
||||
expect(paths).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forwards hook recovery to the original owner and rejects a changed receipt identity', async () => {
|
||||
let changed = false;
|
||||
const broker = await fixture((path, body) => {
|
||||
expect(path).toBe('/internal/runtime-broker/v1/tool-sessions/turn/control');
|
||||
expect(body['operation']).toMatchObject({
|
||||
kind: 'hook-status',
|
||||
targetOperationId: 'original-hook',
|
||||
});
|
||||
return {
|
||||
body: {
|
||||
...identity,
|
||||
result: {
|
||||
operationId: changed ? 'different-hook' : 'original-hook',
|
||||
state: 'outcome_unknown',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const control = {
|
||||
kind: 'hook-status' as const,
|
||||
sessionKey: {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId: 'session',
|
||||
},
|
||||
operationId: 'lookup',
|
||||
targetOperationId: 'original-hook',
|
||||
};
|
||||
expect(await broker.hookControl(control)).toEqual({
|
||||
operationId: 'original-hook',
|
||||
state: 'outcome_unknown',
|
||||
});
|
||||
changed = true;
|
||||
await expect(broker.hookControl(control)).rejects.toThrow(
|
||||
'Hook response identity',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a worker history refusal reason', async () => {
|
||||
const broker = await fixture(() => ({
|
||||
code: 409,
|
||||
@@ -579,7 +617,7 @@ it('resolves a durable execution status for recovery reports', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads unknown only from a definitive not-found', async () => {
|
||||
it('distinguishes unknown outcomes from definitive not-found records', async () => {
|
||||
const missing = await fixture(() => ({
|
||||
code: 404,
|
||||
body: { code: 'runtime_execution_not_found' },
|
||||
@@ -593,7 +631,17 @@ it('reads unknown only from a definitive not-found', async () => {
|
||||
details: { terminal: true },
|
||||
},
|
||||
}));
|
||||
await expect(abandoned.status('execution')).resolves.toBeUndefined();
|
||||
await expect(abandoned.status('execution')).resolves.toEqual({
|
||||
state: 'unknown',
|
||||
});
|
||||
|
||||
const unknown = await fixture(() => ({
|
||||
code: 409,
|
||||
body: { code: 'runtime_broker_execution_unknown' },
|
||||
}));
|
||||
await expect(unknown.status('execution')).resolves.toEqual({
|
||||
state: 'unknown',
|
||||
});
|
||||
|
||||
const failing = await fixture(() => ({
|
||||
code: 500,
|
||||
|
||||
@@ -25,6 +25,11 @@ import type {
|
||||
ManagedMcpOperationView,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-mcp-protocol.js';
|
||||
|
||||
import type {
|
||||
ManagedHookControl,
|
||||
ManagedHookOperationView,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js';
|
||||
|
||||
export interface HostedWorkspaceBrokerOptions {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
@@ -456,6 +461,27 @@ export class HostedWorkspaceBroker {
|
||||
return result as unknown as ManagedMcpOperationView;
|
||||
}
|
||||
|
||||
async hookControl(
|
||||
operation: ManagedHookControl,
|
||||
): Promise<ManagedHookOperationView> {
|
||||
const envelope = await this.request(
|
||||
`/tool-sessions/${encodeURIComponent(this.identity.runtimeSessionId)}/control`,
|
||||
{ operation },
|
||||
);
|
||||
const result = object(envelope['result']);
|
||||
if (
|
||||
result['operationId'] !==
|
||||
(operation.kind === 'hook-status' || operation.kind === 'hook-cancel'
|
||||
? operation.targetOperationId
|
||||
: operation.operationId) ||
|
||||
!['running', 'settled', 'outcome_unknown'].includes(
|
||||
String(result['state']),
|
||||
)
|
||||
)
|
||||
throw new Error('Runtime Hook response identity is invalid.');
|
||||
return result as unknown as ManagedHookOperationView;
|
||||
}
|
||||
|
||||
async acknowledgeV3(
|
||||
id: string,
|
||||
receipt: {
|
||||
@@ -478,27 +504,26 @@ export class HostedWorkspaceBroker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only execution state for recovery reports. A definitive not-found or
|
||||
* a definitive unknown/abandoned answer resolves to undefined; anything else
|
||||
* fails the caller — a recovery report must never read "unknown" from a
|
||||
* transient error.
|
||||
* Read-only execution state. Only a definitive not-found resolves to
|
||||
* undefined; an unknown outcome is not proof that execution stopped.
|
||||
*/
|
||||
async status(id: string): Promise<{ state: string } | undefined> {
|
||||
let response: Record<string, unknown>;
|
||||
try {
|
||||
response = await this.request(`/executions/${encodeURIComponent(id)}`);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof HostedWorkspaceBrokerRejection &&
|
||||
((cause.status === 404 &&
|
||||
cause.code === 'runtime_execution_not_found') ||
|
||||
// The Broker answers UNKNOWN/ABANDONED records with this definitive
|
||||
// terminal state, which the recovery report carries as outcome
|
||||
// 'unknown' — that is a state to report, not a read failure.
|
||||
(cause.status === 409 &&
|
||||
cause.code === 'runtime_broker_execution_unknown'))
|
||||
)
|
||||
return undefined;
|
||||
if (cause instanceof HostedWorkspaceBrokerRejection) {
|
||||
if (
|
||||
cause.status === 404 &&
|
||||
cause.code === 'runtime_execution_not_found'
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
cause.status === 409 &&
|
||||
cause.code === 'runtime_broker_execution_unknown'
|
||||
)
|
||||
return { state: 'unknown' };
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
if (response['executionCallId'] !== id)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
|
||||
import {
|
||||
createHttpManagedSessionStores,
|
||||
ManagedSessionStoreHttpError,
|
||||
type HttpToolPublicationOwner,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js';
|
||||
@@ -31,8 +32,13 @@ import {
|
||||
commitHostedFileHistory,
|
||||
} from './hosted-file-history.js';
|
||||
import { HostedShellPublisher } from './hosted-shell-publisher.js';
|
||||
import { HostedHookSession } from './hosted-hook-session.js';
|
||||
import { HookEventName } from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import { boundedShellPreview } from './managed-shell-publisher.js';
|
||||
import { HostedWorkspaceBrokerRejection } from './hosted-workspace-broker.js';
|
||||
import {
|
||||
HostedWorkspaceBrokerRejection,
|
||||
type HostedWorkspaceBroker,
|
||||
} from './hosted-workspace-broker.js';
|
||||
import {
|
||||
HostedWorkspaceToolTurn,
|
||||
HostedToolRecoveryRequiredError,
|
||||
@@ -83,12 +89,15 @@ let session: ManagedSession;
|
||||
let harness: ReturnType<typeof createManagedHarnessHandle>;
|
||||
let turn: HostedWorkspaceToolTurn;
|
||||
let commit: ConstructorParameters<typeof HostedWorkspaceToolTurn>[4];
|
||||
const messageFitsInline = vi.fn(() => true);
|
||||
const messageFitsInline = vi.fn<
|
||||
ConstructorParameters<typeof HostedWorkspaceToolTurn>[5]
|
||||
>(() => true);
|
||||
let waiters: HostedApprovalWaiters;
|
||||
let expectWritesStopped: boolean;
|
||||
function createTurn(
|
||||
shell = false,
|
||||
approval?: { mode: HostedApprovalMode; timeoutMs?: number },
|
||||
hooks?: HostedHookSession,
|
||||
) {
|
||||
return new HostedWorkspaceToolTurn(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
@@ -108,6 +117,8 @@ function createTurn(
|
||||
},
|
||||
waiters,
|
||||
},
|
||||
undefined,
|
||||
hooks,
|
||||
);
|
||||
}
|
||||
const calls = ['read_file', 'edit'].map((name, index) => ({
|
||||
@@ -175,13 +186,14 @@ beforeEach(async () => {
|
||||
executionStatus: 'success',
|
||||
responseParts: [{ text: 'original result' }],
|
||||
});
|
||||
commit = async (type, messageParts) => {
|
||||
const uuid = randomUUID();
|
||||
commit = async (type, messageParts, model, identity) => {
|
||||
const uuid = identity?.uuid ?? randomUUID();
|
||||
await session.sink.write({
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
sessionId: sessionKey.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: identity?.timestamp ?? new Date().toISOString(),
|
||||
model,
|
||||
type,
|
||||
cwd: root,
|
||||
version: 'test',
|
||||
@@ -1575,7 +1587,10 @@ it.each([false, true])(
|
||||
const resolve = vi.spyOn(harness, 'resolveAwaitRuntime');
|
||||
const publish = vi.spyOn(session.resources, 'publish');
|
||||
messageFitsInline.mockImplementation(
|
||||
(...args: unknown[]) => args[0] !== 'tool_result' || !rejectHistory,
|
||||
(type, content) =>
|
||||
type !== 'tool_result' ||
|
||||
!rejectHistory ||
|
||||
!content.some((part) => part.functionResponse?.response?.['capture']),
|
||||
);
|
||||
const call = {
|
||||
...calls[0],
|
||||
@@ -2290,11 +2305,13 @@ it('reports an answer that loses the race to the expiry as expired', async () =>
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks recovery when the refusals do not fit durably', async () => {
|
||||
it('blocks recovery when a refusal record write fails', async () => {
|
||||
turn = createTurn(false, { mode: 'default' });
|
||||
messageFitsInline.mockImplementation(
|
||||
(...args: unknown[]) => args[0] !== 'tool_result',
|
||||
);
|
||||
const write = session.sink.write.bind(session.sink);
|
||||
vi.spyOn(session.sink, 'write').mockImplementation(async (record) => {
|
||||
if (record.type === 'tool_result') throw new Error('resource unavailable');
|
||||
return write(record);
|
||||
});
|
||||
const running = turn.execute(
|
||||
[calls[1]],
|
||||
[parts[1]],
|
||||
@@ -2691,6 +2708,808 @@ it.each(['decision', 'expiry'])(
|
||||
},
|
||||
);
|
||||
|
||||
function hookSession(fire: HostedHookSession['fire']): HostedHookSession {
|
||||
return {
|
||||
fire,
|
||||
broker: { ...broker, runtimeSessionId: 'prompt' },
|
||||
acquire: () => broker.acquire(),
|
||||
} as unknown as HostedHookSession;
|
||||
}
|
||||
|
||||
it.each(['assistant-commit', 'second-call'] as const)(
|
||||
'settles every tool refusal when cancelled before a new Hook plan (%s)',
|
||||
async (window) => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('turn cancelled');
|
||||
const pin = {
|
||||
catalogId: 'pre-cancel',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const hookControl = vi.fn<HostedWorkspaceBroker['hookControl']>(
|
||||
async (operation) => {
|
||||
if (operation.kind === 'hook-catalog')
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
catalog: {
|
||||
...pin,
|
||||
hooks: [
|
||||
{
|
||||
hookId: 'before',
|
||||
eventName: HookEventName.PreToolUse,
|
||||
sequential: false,
|
||||
async: false,
|
||||
failClosed: true,
|
||||
onceKey: null,
|
||||
config: { type: 'command' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(operation.kind).toBe('hook-execute');
|
||||
controller.abort(reason);
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
result: { success: true, outcome: 'success', duration: 0 },
|
||||
};
|
||||
},
|
||||
);
|
||||
const hookBroker = {
|
||||
...broker,
|
||||
runtimeSessionId: 'prompt',
|
||||
runtime: {
|
||||
bindingId: 'binding',
|
||||
generation: '1',
|
||||
workspaceGeneration: '1',
|
||||
},
|
||||
hookControl,
|
||||
} as unknown as HostedWorkspaceBroker;
|
||||
const hooks = new HostedHookSession(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
pin,
|
||||
hookBroker,
|
||||
);
|
||||
await hooks.ensureReady();
|
||||
if (window === 'assistant-commit') {
|
||||
const originalCommit = commit;
|
||||
commit = async (...args) => {
|
||||
const id = await originalCommit(...args);
|
||||
if (args[0] === 'assistant') controller.abort(reason);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
turn = createTurn(false, { mode: 'yolo' }, hooks);
|
||||
await expect(
|
||||
turn.execute(calls, parts, 'model', controller.signal),
|
||||
).rejects.toBe(reason);
|
||||
await expect(turn.finish()).resolves.toBeUndefined();
|
||||
const history = await session.sink.project();
|
||||
expect(history.map((record) => record.type)).toEqual([
|
||||
'assistant',
|
||||
'tool_result',
|
||||
]);
|
||||
expect(history[1].message?.parts).toEqual(
|
||||
calls.map((call) => ({
|
||||
functionResponse: {
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
response: { error: 'Hook execution cancelled.' },
|
||||
},
|
||||
})),
|
||||
);
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
expect(hooks.hasPendingOperations).toBe(false);
|
||||
const restored = new HostedHookSession(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
pin,
|
||||
hookBroker,
|
||||
);
|
||||
for (const call of calls) {
|
||||
expect(
|
||||
await restored.fire(
|
||||
HookEventName.PreToolUse,
|
||||
`prompt:${call.callId}`,
|
||||
{
|
||||
tool_name: call.name,
|
||||
tool_input: call.args,
|
||||
tool_use_id: call.callId,
|
||||
permission_mode: 'yolo',
|
||||
prompt_id: 'prompt',
|
||||
},
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({ continue: false, reason: 'Hook execution cancelled.' });
|
||||
}
|
||||
expect(
|
||||
hookControl.mock.calls.filter(
|
||||
([operation]) => operation.kind === 'hook-execute',
|
||||
),
|
||||
).toHaveLength(window === 'assistant-commit' ? 0 : 1);
|
||||
},
|
||||
);
|
||||
|
||||
it('recovers committed tool results through the Hook owner acquisition', async () => {
|
||||
const hooks = hookSession(vi.fn());
|
||||
const acquire = vi.spyOn(hooks, 'acquire');
|
||||
const restored = createTurn(false, undefined, hooks);
|
||||
await restored.resumeCommittedResults();
|
||||
expect(acquire).toHaveBeenCalledOnce();
|
||||
expect(broker.acquire).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses tool dispatch when fail-closed permission evaluation is combined with an allow', async () => {
|
||||
const pin = {
|
||||
catalogId: 'permission-test',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const hookBroker = {
|
||||
...broker,
|
||||
runtimeSessionId: 'prompt',
|
||||
runtime: {
|
||||
bindingId: 'binding',
|
||||
generation: '1',
|
||||
workspaceGeneration: '1',
|
||||
},
|
||||
hookControl: vi.fn<HostedWorkspaceBroker['hookControl']>(
|
||||
async (operation) => ({
|
||||
operationId: operation.operationId,
|
||||
state: 'settled' as const,
|
||||
...(operation.kind === 'hook-catalog'
|
||||
? {
|
||||
catalog: {
|
||||
...pin,
|
||||
hooks: ['fail', 'allow'].map((hookId) => ({
|
||||
hookId,
|
||||
eventName: HookEventName.PermissionRequest,
|
||||
sequential: false,
|
||||
async: false,
|
||||
failClosed: true,
|
||||
onceKey: null,
|
||||
config: { type: 'command' as const },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {
|
||||
result:
|
||||
operation.kind === 'hook-execute' && operation.hookId === 'fail'
|
||||
? {
|
||||
success: false,
|
||||
outcome: 'non_blocking_error' as const,
|
||||
duration: 0,
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
outcome: 'success' as const,
|
||||
duration: 0,
|
||||
output: {
|
||||
hookSpecificOutput: { decision: { behavior: 'allow' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const hooks = new HostedHookSession(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
pin,
|
||||
hookBroker as unknown as HostedWorkspaceBroker,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'default' }, hooks);
|
||||
const result = await turn.execute(
|
||||
[calls[1]],
|
||||
[parts[1]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result[0].functionResponse?.response?.['error']).toBeTruthy();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(
|
||||
session.authority
|
||||
.eventsInSequenceRange(1, session.authority.committedSequence)
|
||||
.filter((event) => event.kind === 'action.changed'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs PreToolUse after approval and asks again for changed arguments', async () => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: event,
|
||||
updatedInput: { ...calls[1].args, file_path: 'changed.txt' },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'default' }, hookSession(fire));
|
||||
const running = turn.execute(
|
||||
[calls[1]],
|
||||
[parts[1]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
const first = await requested();
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PermissionRequest,
|
||||
]);
|
||||
await resolveHostedAction(session, waiters, first, answer('allow'));
|
||||
const second = await requested(2);
|
||||
expect(second).not.toBe(first);
|
||||
const revised = (await checkpoint()).approval!.invocationRef!;
|
||||
expect(
|
||||
JSON.parse(
|
||||
JSON.parse((await session.resources.read(revised)).toString())
|
||||
.payloadJson,
|
||||
).input.file_path,
|
||||
).toBe('changed.txt');
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
await resolveHostedAction(session, waiters, second, answer('allow'));
|
||||
await running;
|
||||
expect(JSON.parse(broker.execute.mock.calls[0][1])).toMatchObject({
|
||||
input: { file_path: 'changed.txt' },
|
||||
});
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PermissionRequest,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['allow', 'deny'] as const)(
|
||||
'honors a top-level PreToolUse ask before a preapproved call (%s)',
|
||||
async (decision) => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? { decision: 'ask', reason: 'Confirm this read.' }
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
const running = turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
const approval = await requested();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
await resolveHostedAction(session, waiters, approval, answer(decision));
|
||||
await running;
|
||||
expect(broker.execute).toHaveBeenCalledTimes(decision === 'allow' ? 1 : 0);
|
||||
expect(
|
||||
fire.mock.calls.filter(([event]) => event === HookEventName.PreToolUse),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ decision: 'allow', oversized: false },
|
||||
{ decision: 'deny', oversized: false },
|
||||
{ decision: 'ask', oversized: false },
|
||||
{ decision: 'allow', oversized: true },
|
||||
] as const)(
|
||||
'restores PreToolUse context from its durable receipt without duplicate effects ($decision, oversized: $oversized)',
|
||||
async ({ decision, oversized }) => {
|
||||
if (oversized) {
|
||||
const remote = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionKey: session.authority.sessionHeader.sessionKey,
|
||||
writerId: 'test',
|
||||
});
|
||||
const publish = session.resources.publish.bind(session.resources);
|
||||
vi.spyOn(session.resources, 'publish').mockImplementation(
|
||||
async (kind, bytes) => {
|
||||
await remote.resourceStore.publish(kind, bytes);
|
||||
return publish(kind, bytes);
|
||||
},
|
||||
);
|
||||
messageFitsInline.mockImplementation(
|
||||
(_type, content) =>
|
||||
Buffer.byteLength(JSON.stringify(content)) <= 60 * 1024,
|
||||
);
|
||||
}
|
||||
const pin = {
|
||||
catalogId: 'pre-context',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const hookControl = vi.fn<HostedWorkspaceBroker['hookControl']>(
|
||||
async (operation) => ({
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
...(operation.kind === 'hook-catalog'
|
||||
? {
|
||||
catalog: {
|
||||
...pin,
|
||||
hooks: [
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
].map((eventName) => ({
|
||||
hookId: eventName,
|
||||
eventName,
|
||||
sequential: false,
|
||||
async: false,
|
||||
failClosed: true,
|
||||
onceKey: null,
|
||||
config: { type: 'command' as const },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {
|
||||
result: {
|
||||
success: true,
|
||||
outcome: 'success',
|
||||
duration: 0,
|
||||
output: {
|
||||
hookSpecificOutput:
|
||||
operation.kind === 'hook-execute' &&
|
||||
operation.hookId === HookEventName.PreToolUse
|
||||
? {
|
||||
permissionDecision: decision,
|
||||
additionalContext: oversized
|
||||
? 'x'.repeat(36 * 1024)
|
||||
: 'before <tool>',
|
||||
}
|
||||
: {
|
||||
additionalContext: oversized
|
||||
? 'y'.repeat(36 * 1024)
|
||||
: 'after tool',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const hookBroker = {
|
||||
...broker,
|
||||
runtimeSessionId: 'prompt',
|
||||
runtime: {
|
||||
bindingId: 'binding',
|
||||
generation: '1',
|
||||
workspaceGeneration: '1',
|
||||
},
|
||||
hookControl,
|
||||
} as unknown as HostedWorkspaceBroker;
|
||||
const createHooks = () =>
|
||||
new HostedHookSession(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
pin,
|
||||
hookBroker,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'yolo' }, createHooks());
|
||||
const running = turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
if (decision === 'ask')
|
||||
await resolveHostedAction(
|
||||
session,
|
||||
waiters,
|
||||
await requested(),
|
||||
answer('deny'),
|
||||
);
|
||||
const responses = await running;
|
||||
const contexts = responses.filter((part) => typeof part.text === 'string');
|
||||
expect(contexts).toEqual(
|
||||
decision === 'ask' || oversized
|
||||
? []
|
||||
: [
|
||||
{ text: 'before <tool>' },
|
||||
...(decision === 'allow' ? [{ text: 'after tool' }] : []),
|
||||
],
|
||||
);
|
||||
if (oversized) {
|
||||
expect(turn.hookStopReason).toContain('context');
|
||||
await turn.finish();
|
||||
const authorization = await harness.ensureRunnable();
|
||||
expect(authorization.continuation.phase).toBe('turn_settled');
|
||||
expect(authorization.tools?.items[0]).toMatchObject({
|
||||
state: 'settled',
|
||||
consumed: false,
|
||||
});
|
||||
}
|
||||
const history = await session.sink.project();
|
||||
const controls = hookControl.mock.calls.length;
|
||||
const restored = createTurn(false, { mode: 'yolo' }, createHooks());
|
||||
expect(
|
||||
await restored.resumeHookResults(
|
||||
responses,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toEqual(responses);
|
||||
expect(await session.sink.project()).toEqual(history);
|
||||
if (oversized) expect(restored.hookStopReason).toBe(turn.hookStopReason);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
expect(broker.execute).toHaveBeenCalledTimes(decision === 'allow' ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
it('validates Hook-modified arguments before dispatch', async () => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: event,
|
||||
updatedInput: { file_path: '../outside' },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
const responses = await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toContain(
|
||||
'relative',
|
||||
);
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not execute a denied tool or emit a physical post-tool event', async () => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? { decision: 'block', reason: 'policy' }
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
const responses = await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toBe('policy');
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers a failed after Hook from the committed physical receipt without executing the tool again', async () => {
|
||||
let fail = true;
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) => {
|
||||
if (event === HookEventName.PostToolUse && fail)
|
||||
throw new Error('Hook owner disconnected');
|
||||
return undefined;
|
||||
});
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
await expect(
|
||||
turn.execute([calls[0]], [parts[0]], 'model', new AbortController().signal),
|
||||
).rejects.toThrow('Hook owner disconnected');
|
||||
const history = await session.sink.project();
|
||||
const saved = history.find((entry) => entry.type === 'tool_result')!.message!
|
||||
.parts!;
|
||||
expect(saved[0].functionResponse?.response?.['executionStatus']).toBe(
|
||||
'success',
|
||||
);
|
||||
fail = false;
|
||||
const recovered = await turn.resumeHookResults(
|
||||
saved,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(recovered).toEqual(saved);
|
||||
expect(broker.execute).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
fire.mock.calls.filter(([event]) => event === HookEventName.PostToolUse),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
event: HookEventName.PostToolUse,
|
||||
output: { continue: false, stopReason: 'policy stop' },
|
||||
},
|
||||
{
|
||||
event: HookEventName.PostToolBatch,
|
||||
output: { continue: false, stopReason: 'policy stop' },
|
||||
},
|
||||
{
|
||||
event: HookEventName.PostToolBatch,
|
||||
output: { decision: 'deny' as const, reason: 'policy stop' },
|
||||
},
|
||||
])(
|
||||
'honors $event orchestration stop while retaining the successful unconsumed receipt',
|
||||
async ({ event, output }) => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (actual) =>
|
||||
actual === event ? output : undefined,
|
||||
);
|
||||
const hooks = hookSession(fire);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hooks);
|
||||
const responses = await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toBeUndefined();
|
||||
expect(turn.hookStopReason).toBe('policy stop');
|
||||
const original = (await checkpoint()).tools!.items;
|
||||
expect(original).toEqual([
|
||||
expect.objectContaining({ state: 'settled', consumed: false }),
|
||||
]);
|
||||
const restored = createTurn(false, { mode: 'yolo' }, hooks);
|
||||
await restored.resumeHookResults(
|
||||
responses,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(restored.hookStopReason).toBe('policy stop');
|
||||
await turn.finish();
|
||||
expect((await checkpoint()).continuation.phase).toBe('turn_settled');
|
||||
expect((await checkpoint()).tools!.items).toEqual(original);
|
||||
expect(broker.execute).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not duplicate durable Hook context when resuming committed tool results', async () => {
|
||||
const fire = vi
|
||||
.fn<HostedHookSession['fire']>()
|
||||
.mockImplementation(async (event) =>
|
||||
event === HookEventName.PostToolUse
|
||||
? { hookSpecificOutput: { additionalContext: 'after-tool context' } }
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
const response = await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
const before = await session.sink.project();
|
||||
expect(response.at(-1)).toEqual({ text: 'after-tool context' });
|
||||
expect(before.filter((entry) => entry.type === 'tool_result')).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
await turn.resumeHookResults(
|
||||
response,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toEqual(response);
|
||||
expect(await session.sink.project()).toEqual(before);
|
||||
expect(broker.execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('preserves native success in after and batch Hooks when oversized output is omitted', async () => {
|
||||
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
broker.execute.mockResolvedValue({
|
||||
executionStatus: 'success',
|
||||
responseParts: [{ text: 'x'.repeat(70 * 1024) }],
|
||||
});
|
||||
const results = await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(results[0].functionResponse?.response).toMatchObject({
|
||||
executionStatus: 'success',
|
||||
outputOmitted: true,
|
||||
error: expect.any(String),
|
||||
});
|
||||
expect(fire.mock.calls.map(([event]) => event)).toContain(
|
||||
HookEventName.PostToolUse,
|
||||
);
|
||||
expect(fire.mock.calls.map(([event]) => event)).not.toContain(
|
||||
HookEventName.PostToolUseFailure,
|
||||
);
|
||||
expect(
|
||||
fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.PostToolBatch,
|
||||
)?.[2],
|
||||
).toMatchObject({ tool_calls: [{ status: 'success' }] });
|
||||
});
|
||||
|
||||
it('preserves a bounded Shell failure without an error field in after and batch Hooks', async () => {
|
||||
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
|
||||
turn = createTurn(false, { mode: 'yolo' }, hookSession(fire));
|
||||
await turn.execute(
|
||||
[calls[0]],
|
||||
[parts[0]],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
fire.mockClear();
|
||||
const saved: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: calls[0].callId,
|
||||
name: calls[0].name,
|
||||
response: {
|
||||
executionStatus: 'error',
|
||||
outputOmitted: true,
|
||||
summary: 'The Shell result was saved in its immutable capture.',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
await turn.resumeHookResults(saved, 'model', new AbortController().signal);
|
||||
expect(fire.mock.calls.map(([event]) => event)).toContain(
|
||||
HookEventName.PostToolUseFailure,
|
||||
);
|
||||
expect(
|
||||
fire.mock.calls.find(
|
||||
([event]) => event === HookEventName.PostToolBatch,
|
||||
)?.[2],
|
||||
).toMatchObject({ tool_calls: [{ status: 'error' }] });
|
||||
expect(broker.execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 0],
|
||||
[true, 0],
|
||||
[true, 8 * 1024 * 1024 + 1],
|
||||
] as const)(
|
||||
'stops oversized Hook data and recovers physical receipts (matching hook: %s, snapshot bytes: %s)',
|
||||
async (matchingHook, snapshotBytes) => {
|
||||
const remote = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionKey: session.authority.sessionHeader.sessionKey,
|
||||
writerId: 'test',
|
||||
});
|
||||
const publish = session.resources.publish.bind(session.resources);
|
||||
vi.spyOn(session.resources, 'publish').mockImplementation(
|
||||
async (kind, bytes) => {
|
||||
await remote.resourceStore.publish(kind, bytes);
|
||||
return publish(kind, bytes);
|
||||
},
|
||||
);
|
||||
messageFitsInline.mockImplementation(
|
||||
(_type, content) =>
|
||||
Buffer.byteLength(JSON.stringify(content)) <= 60 * 1024,
|
||||
);
|
||||
const pin = {
|
||||
catalogId: 'round8',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const control = vi.fn<HostedWorkspaceBroker['hookControl']>(
|
||||
async (operation) => ({
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
...(operation.kind === 'hook-catalog'
|
||||
? {
|
||||
catalog: {
|
||||
...pin,
|
||||
hooks: matchingHook
|
||||
? [
|
||||
{
|
||||
hookId: 'batch',
|
||||
eventName: HookEventName.PostToolBatch,
|
||||
sequential: false,
|
||||
async: false,
|
||||
onceKey: null,
|
||||
failClosed: true,
|
||||
config: {
|
||||
type: snapshotBytes
|
||||
? ('function' as const)
|
||||
: ('command' as const),
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
: { result: { success: true, outcome: 'success', duration: 0 } }),
|
||||
}),
|
||||
);
|
||||
const hookBroker = {
|
||||
...broker,
|
||||
runtimeSessionId: 'prompt',
|
||||
runtime: {
|
||||
bindingId: 'binding',
|
||||
generation: '1',
|
||||
workspaceGeneration: '1',
|
||||
},
|
||||
hookControl: control,
|
||||
} as unknown as HostedWorkspaceBroker;
|
||||
const createHooks = () => {
|
||||
const hooks = new HostedHookSession(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
pin,
|
||||
hookBroker,
|
||||
);
|
||||
if (snapshotBytes)
|
||||
hooks.setMessagesProvider(() => [{ text: 'x'.repeat(snapshotBytes) }]);
|
||||
return hooks;
|
||||
};
|
||||
broker.execute.mockResolvedValue({
|
||||
executionStatus: 'success',
|
||||
responseParts: [{ text: 'x'.repeat(snapshotBytes ? 1024 : 36 * 1024) }],
|
||||
});
|
||||
turn = createTurn(false, { mode: 'yolo' }, createHooks());
|
||||
await turn.execute(calls, parts, 'model', new AbortController().signal);
|
||||
expect(turn.hookStopReason).toContain('60 KiB');
|
||||
const saved = await checkpoint();
|
||||
const history = await session.sink.project();
|
||||
const responses = history
|
||||
.filter((r) => r.type === 'tool_result')
|
||||
.flatMap((r) => r.message?.parts ?? []);
|
||||
const key = session.authority.sessionHeader.sessionKey;
|
||||
await turn.close();
|
||||
await session.close();
|
||||
session = await openManagedSession({
|
||||
runtimeBaseDir: root,
|
||||
cwd: root,
|
||||
transcriptPath: path.join(root, 'transcript.jsonl'),
|
||||
sessionId: key.sessionId,
|
||||
sessionKey: key,
|
||||
version: 'test',
|
||||
workerId: 'reopened-worker',
|
||||
activationLeaseDurationMs: 60000,
|
||||
});
|
||||
harness = createManagedHarnessHandle(session);
|
||||
await harness.ensureRunnable();
|
||||
const publishAfter = session.resources.publish.bind(session.resources);
|
||||
vi.spyOn(session.resources, 'publish').mockImplementation(
|
||||
async (kind, bytes) => {
|
||||
await remote.resourceStore.publish(kind, bytes);
|
||||
return publishAfter(kind, bytes);
|
||||
},
|
||||
);
|
||||
const restored = createTurn(false, { mode: 'yolo' }, createHooks());
|
||||
await restored.resumeCommittedResults();
|
||||
turn = restored;
|
||||
expect(
|
||||
await restored.resumeHookResults(
|
||||
responses,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toEqual(responses);
|
||||
expect(restored.hookStopReason).toContain('60 KiB');
|
||||
expect(saved.tools!.items).toEqual([
|
||||
expect.objectContaining({ state: 'settled', consumed: false }),
|
||||
expect.objectContaining({ state: 'settled', consumed: false }),
|
||||
]);
|
||||
await restored.finish();
|
||||
expect((await checkpoint()).continuation.phase).toBe('turn_settled');
|
||||
expect((await checkpoint()).tools!.items).toEqual(saved.tools!.items);
|
||||
expect(await session.sink.project()).toEqual(history);
|
||||
expect(broker.execute).toHaveBeenCalledTimes(2);
|
||||
expect(control.mock.calls.map(([operation]) => operation.kind)).toEqual([
|
||||
'hook-catalog',
|
||||
]);
|
||||
},
|
||||
);
|
||||
it('persists the prepared history before effects and settled history before continuation', async () => {
|
||||
broker.execute.mockImplementation(async () => {
|
||||
expect((await readHostedFileHistory(session))?.pendingTurn).toBe('prompt');
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result.js';
|
||||
import {
|
||||
assertManagedSessionDurableRef,
|
||||
assertManagedSessionStableId,
|
||||
type ManagedSessionDurableRef,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import type { ManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js';
|
||||
@@ -55,6 +56,18 @@ import {
|
||||
} from './hosted-workspace-broker.js';
|
||||
import { HostedShellPublisher } from './hosted-shell-publisher.js';
|
||||
import type { HostedMcpSession } from './hosted-mcp-session.js';
|
||||
import type {
|
||||
HostedHookSession,
|
||||
HostedPromptHookRunner,
|
||||
} from './hosted-hook-session.js';
|
||||
import {
|
||||
HookEventName,
|
||||
PreToolUseHookOutput,
|
||||
createHookOutput,
|
||||
type HookOutput,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import { hostedHookOccurrenceId } from './hosted-hook-session.js';
|
||||
import { parseHookExecution } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-record.js';
|
||||
import { waitForTurn } from './hosted-turn-wait.js';
|
||||
import { boundedShellPreview } from './managed-shell-publisher.js';
|
||||
import {
|
||||
@@ -184,6 +197,18 @@ export const HOSTED_WORKSPACE_SHELL_TOOLS: FunctionDeclaration[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function physicalToolStatus(
|
||||
response: Record<string, unknown> | undefined,
|
||||
): 'success' | 'error' | 'cancelled' {
|
||||
if (response?.['executionStatus'] !== undefined)
|
||||
return response['executionStatus'] === 'success'
|
||||
? 'success'
|
||||
: response['executionStatus'] === 'cancelled'
|
||||
? 'cancelled'
|
||||
: 'error';
|
||||
return response?.['error'] ? 'error' : 'success';
|
||||
}
|
||||
|
||||
export class HostedToolRecoveryRequiredError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(
|
||||
@@ -194,6 +219,7 @@ export class HostedToolRecoveryRequiredError extends Error {
|
||||
}
|
||||
|
||||
export class HostedWorkspaceToolTurn {
|
||||
hookStopReason?: string;
|
||||
private readonly broker: HostedWorkspaceBroker;
|
||||
private readonly warmed: Promise<void>;
|
||||
private acquired = false;
|
||||
@@ -208,9 +234,11 @@ export class HostedWorkspaceToolTurn {
|
||||
private readonly shell?: HostedShellTurnOptions;
|
||||
// Once an approval expires nobody is answering, so the Turn asks no more.
|
||||
private unanswered = false;
|
||||
private promptHookRunner?: HostedPromptHookRunner;
|
||||
private readonly hookPermission = new Map<string, 'allow' | 'deny'>();
|
||||
|
||||
constructor(
|
||||
options: HostedWorkspaceBrokerOptions,
|
||||
private readonly options: HostedWorkspaceBrokerOptions,
|
||||
private readonly session: ManagedSession,
|
||||
private readonly harness: ManagedHarnessHandle,
|
||||
private readonly promptId: string,
|
||||
@@ -231,6 +259,7 @@ export class HostedWorkspaceToolTurn {
|
||||
shell?: HostedShellTurnOptions,
|
||||
private readonly approval?: HostedApprovalTurnOptions,
|
||||
private readonly mcp?: HostedMcpSession,
|
||||
private readonly hooks?: HostedHookSession,
|
||||
) {
|
||||
this.publication =
|
||||
publicationOrShell && 'owner' in publicationOrShell
|
||||
@@ -242,6 +271,7 @@ export class HostedWorkspaceToolTurn {
|
||||
: shell;
|
||||
this.broker =
|
||||
mcp?.broker ??
|
||||
hooks?.broker ??
|
||||
new HostedWorkspaceBroker(
|
||||
options,
|
||||
session.authority.sessionHeader.sessionKey,
|
||||
@@ -267,6 +297,90 @@ export class HostedWorkspaceToolTurn {
|
||||
async resumeCommittedResults(): Promise<void> {
|
||||
if (this.acquired) return;
|
||||
await this.warmed;
|
||||
const saved = await readHostedFileHistory(this.session);
|
||||
if (saved?.pendingTurn) {
|
||||
this.uncertain = true;
|
||||
try {
|
||||
if (
|
||||
saved.pendingTurn !== this.promptId ||
|
||||
!(await canSettleHostedFileHistory(this.session, saved))
|
||||
)
|
||||
throw new Error('Hosted file history requires recovery.');
|
||||
const authorization =
|
||||
await this.session.authority.harnessRunAuthorization();
|
||||
if (authorization.status !== 'runnable')
|
||||
throw new Error('Hosted file history requires recovery.');
|
||||
const owners = new Set<string>();
|
||||
for (const item of authorization.checkpoint.tools?.items ?? []) {
|
||||
if (
|
||||
item.modelMessageId !== saved.pendingMessageId ||
|
||||
!['write_file', 'edit'].includes(item.toolName)
|
||||
)
|
||||
continue;
|
||||
const intent = this.session.authority
|
||||
.eventsInSequenceRange(1, this.session.authority.committedSequence)
|
||||
.findLast(
|
||||
(event) =>
|
||||
event.kind === 'tool.intent' &&
|
||||
event.payload['executionCallId'] === item.executionCallId &&
|
||||
event.payload['batchId'] === saved.pendingMessageId,
|
||||
);
|
||||
const inputRef = assertManagedSessionDurableRef(
|
||||
intent?.payload['argsRef'],
|
||||
'file history tool input',
|
||||
);
|
||||
if (inputRef.kind !== 'managed-tool-input')
|
||||
throw new Error('Hosted file history owner conflicts.');
|
||||
const input = JSON.parse(
|
||||
(await this.session.resources.read(inputRef)).toString(),
|
||||
) as { harnessSessionId: unknown; runtimeSessionId: unknown };
|
||||
if (
|
||||
input.harnessSessionId !==
|
||||
this.session.authority.sessionHeader.sessionKey.sessionId ||
|
||||
typeof input.runtimeSessionId !== 'string'
|
||||
)
|
||||
throw new Error('Hosted file history owner conflicts.');
|
||||
owners.add(
|
||||
assertManagedSessionStableId(
|
||||
input.runtimeSessionId,
|
||||
'file history Runtime owner',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (owners.size !== 1)
|
||||
throw new Error('Hosted file history owner conflicts.');
|
||||
const originalId = [...owners][0];
|
||||
const original =
|
||||
originalId === this.broker.runtimeSessionId
|
||||
? this.broker
|
||||
: new HostedWorkspaceBroker(
|
||||
this.options,
|
||||
this.session.authority.sessionHeader.sessionKey,
|
||||
originalId,
|
||||
);
|
||||
await original.warm();
|
||||
await original.acquire();
|
||||
const state = await original.fileHistory({
|
||||
kind: 'raw-file-history',
|
||||
action: 'snapshot',
|
||||
});
|
||||
if (!isDeepStrictEqual(state.snapshots, saved.state.snapshots))
|
||||
throw new Error('Original file history snapshots changed.');
|
||||
await commitHostedFileHistory(this.session, {
|
||||
schemaVersion: 1,
|
||||
state,
|
||||
pendingTurn: null,
|
||||
pendingUndo: null,
|
||||
});
|
||||
if (original === this.broker) {
|
||||
this.acquired = true;
|
||||
this.uncertain = false;
|
||||
return;
|
||||
}
|
||||
} catch (cause) {
|
||||
throw new HostedToolRecoveryRequiredError(cause);
|
||||
}
|
||||
}
|
||||
await this.acquire(true);
|
||||
this.uncertain = false;
|
||||
}
|
||||
@@ -274,33 +388,15 @@ export class HostedWorkspaceToolTurn {
|
||||
private async acquire(recovering = false): Promise<void> {
|
||||
this.uncertain = true;
|
||||
try {
|
||||
await this.broker.acquire();
|
||||
if (this.hooks && !this.mcp) await this.hooks.acquire();
|
||||
else await this.broker.acquire();
|
||||
this.acquired = true;
|
||||
if (!this.mcp) {
|
||||
const saved = await readHostedFileHistory(this.session);
|
||||
if (saved?.pendingUndo)
|
||||
throw new Error('Hosted file history requires recovery.');
|
||||
if (saved?.pendingTurn) {
|
||||
if (
|
||||
!recovering ||
|
||||
saved.pendingTurn !== this.promptId ||
|
||||
!(await canSettleHostedFileHistory(this.session, saved))
|
||||
)
|
||||
throw new Error('Hosted file history requires recovery.');
|
||||
const state = await this.broker.fileHistory({
|
||||
kind: 'raw-file-history',
|
||||
action: 'snapshot',
|
||||
});
|
||||
if (!isDeepStrictEqual(state.snapshots, saved.state.snapshots))
|
||||
throw new Error('Original file history snapshots changed.');
|
||||
await commitHostedFileHistory(this.session, {
|
||||
schemaVersion: 1,
|
||||
state,
|
||||
pendingTurn: null,
|
||||
pendingUndo: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (saved?.pendingTurn)
|
||||
throw new Error('Hosted file history requires recovery.');
|
||||
try {
|
||||
await this.broker.fileHistory({
|
||||
kind: 'raw-file-history',
|
||||
@@ -310,7 +406,7 @@ export class HostedWorkspaceToolTurn {
|
||||
} catch (cause) {
|
||||
// A saved continuation still needs this original runtime ID.
|
||||
if (recovering || !isHostedFileHistoryRefusal(cause)) throw cause;
|
||||
await this.broker.release();
|
||||
if (!this.hooks) await this.broker.release();
|
||||
this.acquired = false;
|
||||
throw new HostedFileHistoryRefusedError(
|
||||
cause.reason ?? cause.message,
|
||||
@@ -334,105 +430,387 @@ export class HostedWorkspaceToolTurn {
|
||||
parts: Part[],
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
if (!this.hooks) return this.executeNative(calls, parts, model, signal);
|
||||
const responses = await this.executeNative(calls, parts, model, signal);
|
||||
return this.completeHookResults(calls, responses, model, signal);
|
||||
}
|
||||
|
||||
private async completeHookResults(
|
||||
calls: ToolCallRequestInfo[],
|
||||
responses: Part[],
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
if (!this.hooks) return responses;
|
||||
const contextParts: Part[] = [];
|
||||
const history = await this.session.sink.project();
|
||||
const assistant = history.findLast(
|
||||
(entry) =>
|
||||
entry.type === 'assistant' &&
|
||||
entry.daemonPromptId === this.promptId &&
|
||||
entry.message?.parts?.some(
|
||||
(part) => part.functionCall?.id === calls[0]?.callId,
|
||||
),
|
||||
);
|
||||
const intents = this.session.authority
|
||||
.eventsInSequenceRange(1, this.session.authority.committedSequence)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.kind === 'tool.intent' &&
|
||||
entry.payload['batchId'] === assistant?.uuid,
|
||||
);
|
||||
const preToolOutputs = new Map<string, PreToolUseHookOutput>();
|
||||
const effective = await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
const marker = this.session.authority.extensionRecord(
|
||||
'hook_execution',
|
||||
hostedHookOccurrenceId(
|
||||
HookEventName.PreToolUse,
|
||||
`${this.promptId}:${call.callId}`,
|
||||
),
|
||||
);
|
||||
if (!marker) return call;
|
||||
const record = parseHookExecution(marker.record);
|
||||
const original = JSON.parse(
|
||||
(await this.session.resources.read(record.inputRef)).toString(),
|
||||
) as { tool_input?: Record<string, unknown> };
|
||||
const saved = record.resultRef
|
||||
? (JSON.parse(
|
||||
(await this.session.resources.read(record.resultRef)).toString(),
|
||||
) as { output?: HookOutput })
|
||||
: undefined;
|
||||
if (saved?.output)
|
||||
preToolOutputs.set(
|
||||
call.callId,
|
||||
new PreToolUseHookOutput(saved.output),
|
||||
);
|
||||
return {
|
||||
...call,
|
||||
args:
|
||||
(saved?.output?.hookSpecificOutput?.['updatedInput'] as
|
||||
| Record<string, unknown>
|
||||
| undefined) ??
|
||||
original.tool_input ??
|
||||
call.args,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const [ordinal, call] of effective.entries()) {
|
||||
const dispatched = intents.some(
|
||||
(entry) => entry.payload['ordinal'] === ordinal,
|
||||
);
|
||||
const response = responses.find(
|
||||
(part) => part.functionResponse?.id === call.callId,
|
||||
)?.functionResponse?.response;
|
||||
const preToolOutput = preToolOutputs.get(call.callId);
|
||||
const beforeContext = preToolOutput?.getAdditionalContext();
|
||||
if (
|
||||
beforeContext &&
|
||||
response?.['executionStatus'] !== 'cancelled' &&
|
||||
(!preToolOutput?.isAsk() || dispatched)
|
||||
)
|
||||
contextParts.push({ text: beforeContext });
|
||||
if (!dispatched) continue;
|
||||
if (response?.['executionStatus'] === 'not_started') continue;
|
||||
const failed = physicalToolStatus(response) !== 'success';
|
||||
const result = await this.hooks.fire(
|
||||
failed ? HookEventName.PostToolUseFailure : HookEventName.PostToolUse,
|
||||
`${this.promptId}:${call.callId}`,
|
||||
{
|
||||
tool_name: call.name,
|
||||
tool_input: call.args,
|
||||
tool_use_id: call.callId,
|
||||
...(failed
|
||||
? {
|
||||
error: String(
|
||||
response?.['error'] ??
|
||||
response?.['executionStatus'] ??
|
||||
'Tool failed',
|
||||
),
|
||||
is_interrupt: response?.['executionStatus'] === 'cancelled',
|
||||
}
|
||||
: { tool_response: response }),
|
||||
permission_mode: this.approval?.settings.mode ?? 'yolo',
|
||||
prompt_id: this.promptId,
|
||||
},
|
||||
signal,
|
||||
this.promptHookRunner,
|
||||
);
|
||||
const output =
|
||||
result &&
|
||||
createHookOutput(
|
||||
failed ? HookEventName.PostToolUseFailure : HookEventName.PostToolUse,
|
||||
result,
|
||||
);
|
||||
if (output?.shouldStopExecution())
|
||||
this.hookStopReason ??=
|
||||
output.getEffectiveReason() || 'Stopped by Hook.';
|
||||
const context = output?.getAdditionalContext();
|
||||
if (context) contextParts.push({ text: context });
|
||||
}
|
||||
const batch = await this.hooks.fire(
|
||||
HookEventName.PostToolBatch,
|
||||
`${this.promptId}:${calls.map((call) => call.callId).join(':')}`,
|
||||
{
|
||||
tool_calls: effective.map((call) => {
|
||||
const response = responses.find(
|
||||
(part) => part.functionResponse?.id === call.callId,
|
||||
)?.functionResponse?.response;
|
||||
return {
|
||||
tool_name: call.name,
|
||||
tool_input: call.args,
|
||||
tool_use_id: call.callId,
|
||||
status: physicalToolStatus(response),
|
||||
tool_response: response,
|
||||
};
|
||||
}),
|
||||
permission_mode: this.approval?.settings.mode ?? 'yolo',
|
||||
prompt_id: this.promptId,
|
||||
},
|
||||
signal,
|
||||
this.promptHookRunner,
|
||||
);
|
||||
const output =
|
||||
batch && createHookOutput(HookEventName.PostToolBatch, batch);
|
||||
if (output?.shouldStopExecution())
|
||||
this.hookStopReason ??= output.getEffectiveReason() || 'Stopped by Hook.';
|
||||
const context = output?.getAdditionalContext();
|
||||
if (context) contextParts.push({ text: context });
|
||||
if (contextParts.length) {
|
||||
if (!this.messageFitsInline('tool_result', contextParts, model)) {
|
||||
this.hookStopReason ??=
|
||||
'Hook context exceeds the inline Session Store limit.';
|
||||
return [...responses];
|
||||
}
|
||||
const batchId = `${this.promptId}:${calls.map((call) => call.callId).join(':')}`;
|
||||
const uuid = shellHistoryId(`${batchId}:hook-context`);
|
||||
if (!history.some((entry) => entry.uuid === uuid))
|
||||
await this.commit('tool_result', contextParts, model, {
|
||||
uuid,
|
||||
timestamp: assistant?.timestamp ?? new Date(0).toISOString(),
|
||||
});
|
||||
}
|
||||
return [...responses, ...contextParts];
|
||||
}
|
||||
|
||||
async resumeHookResults(
|
||||
responses: readonly Part[],
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
if (!this.hooks) return [...responses];
|
||||
const history = await this.session.sink.project();
|
||||
const assistant = history.findLast(
|
||||
(entry) =>
|
||||
entry.type === 'assistant' &&
|
||||
entry.daemonPromptId === this.promptId &&
|
||||
entry.message?.parts?.some((part) => part.functionCall),
|
||||
);
|
||||
if (!assistant?.message?.parts)
|
||||
throw new HostedToolRecoveryRequiredError(
|
||||
'Original Hook batch is unavailable.',
|
||||
);
|
||||
const calls = assistant.message.parts.flatMap((part) =>
|
||||
part.functionCall
|
||||
? [
|
||||
{
|
||||
name: part.functionCall.name!,
|
||||
callId: part.functionCall.id!,
|
||||
args: part.functionCall.args ?? {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: this.promptId,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const contextId = shellHistoryId(
|
||||
`${this.promptId}:${calls.map((call) => call.callId).join(':')}:hook-context`,
|
||||
);
|
||||
const savedContext =
|
||||
history.find((entry) => entry.uuid === contextId)?.message?.parts ?? [];
|
||||
const original = savedContext.length
|
||||
? responses.slice(0, -savedContext.length)
|
||||
: [...responses];
|
||||
return this.completeHookResults(
|
||||
calls,
|
||||
original,
|
||||
assistant.model ?? model,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
setPromptHookRunner(runner: HostedPromptHookRunner): void {
|
||||
this.promptHookRunner = runner;
|
||||
}
|
||||
|
||||
private async executeNative(
|
||||
calls: ToolCallRequestInfo[],
|
||||
parts: Part[],
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
signal.throwIfAborted();
|
||||
if (this.mcp) await waitForTurn(this.warmed, signal);
|
||||
const declarations = this.advertised ?? (await this.declarations(signal));
|
||||
const ids = new Set<string>();
|
||||
const requests = calls.map((call) => {
|
||||
const runtimeCallId = randomUUID();
|
||||
const mcpInput = this.mcp?.toolInput(call.name, call.args, runtimeCallId);
|
||||
const isShell = call.name === 'run_shell_command';
|
||||
if (
|
||||
!declarations.some((tool) => tool.name === call.name) ||
|
||||
ids.has(call.callId) ||
|
||||
call.wasOutputTruncated === true
|
||||
)
|
||||
throw new Error('Hosted Workspace profile refused a tool call.');
|
||||
ids.add(call.callId);
|
||||
let validationError: string | undefined;
|
||||
let input: Record<string, unknown>;
|
||||
if (mcpInput) {
|
||||
input = { ...mcpInput.input };
|
||||
} else if (isShell) {
|
||||
const args = call.args;
|
||||
const unsupportedKey = Object.keys(args).find(
|
||||
(key) =>
|
||||
!['command', 'timeout', 'description', 'is_background'].includes(
|
||||
key,
|
||||
),
|
||||
);
|
||||
if (typeof args['command'] !== 'string' || !args['command'].trim()) {
|
||||
validationError = 'Hosted Shell requires a nonempty command.';
|
||||
} else if (unsupportedKey !== undefined) {
|
||||
validationError = `Hosted Shell received unsupported argument ${JSON.stringify(unsupportedKey)}.`;
|
||||
} else if (
|
||||
args['is_background'] !== undefined &&
|
||||
args['is_background'] !== false &&
|
||||
!(
|
||||
typeof args['is_background'] === 'string' &&
|
||||
args['is_background'].toLowerCase() === 'false'
|
||||
this.hookPermission.clear();
|
||||
if (this.hooks && this.approval) {
|
||||
calls = await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
if (!hostedApprovalAsks(this.approval!.settings, call.name))
|
||||
return call;
|
||||
const output = await this.hooks!.fire(
|
||||
HookEventName.PermissionRequest,
|
||||
`${this.promptId}:${call.callId}`,
|
||||
{
|
||||
tool_name: call.name,
|
||||
tool_input: call.args,
|
||||
tool_use_id: call.callId,
|
||||
prompt_id: this.promptId,
|
||||
},
|
||||
signal,
|
||||
this.promptHookRunner,
|
||||
);
|
||||
const decision = output?.hookSpecificOutput?.['decision'] as
|
||||
| {
|
||||
behavior?: 'allow' | 'deny';
|
||||
updatedInput?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (decision?.behavior)
|
||||
this.hookPermission.set(call.callId, decision.behavior);
|
||||
if (
|
||||
output?.continue === false ||
|
||||
output?.decision === 'block' ||
|
||||
output?.decision === 'deny'
|
||||
)
|
||||
) {
|
||||
validationError =
|
||||
'Hosted Shell requires a foreground command in the saved directory. Background jobs and Monitor are unavailable; correct the arguments before retrying.';
|
||||
} else if (
|
||||
args['description'] !== undefined &&
|
||||
typeof args['description'] !== 'string'
|
||||
) {
|
||||
validationError = 'Hosted Shell description must be a string.';
|
||||
} else if (
|
||||
args['timeout'] !== undefined &&
|
||||
(!Number.isSafeInteger(args['timeout']) ||
|
||||
(args['timeout'] as number) < 1 ||
|
||||
(args['timeout'] as number) > 600000)
|
||||
) {
|
||||
validationError =
|
||||
'Hosted Shell timeout must be an integer from 1 to 600000 ms.';
|
||||
}
|
||||
if (this.publication && args['is_background'] !== undefined) {
|
||||
validationError = 'Hosted Shell requires one foreground command.';
|
||||
}
|
||||
input = this.publication
|
||||
? { ...args }
|
||||
: { ...args, is_background: false };
|
||||
} else {
|
||||
const file = call.args['file_path'];
|
||||
input = { ...call.args };
|
||||
const filePathError =
|
||||
'Hosted file tools require file_path relative to the saved Session working directory. Absolute paths and ".." traversal are not allowed. Correct file_path and retry.';
|
||||
if (typeof file !== 'string') {
|
||||
validationError = filePathError;
|
||||
this.hookPermission.set(call.callId, 'deny');
|
||||
return decision?.updatedInput
|
||||
? { ...call, args: decision.updatedInput }
|
||||
: call;
|
||||
}),
|
||||
);
|
||||
}
|
||||
const prepareRequests = (source: ToolCallRequestInfo[]) => {
|
||||
const ids = new Set<string>();
|
||||
return source.map((call) => {
|
||||
const runtimeCallId = randomUUID();
|
||||
const mcpInput = this.mcp?.toolInput(
|
||||
call.name,
|
||||
call.args,
|
||||
runtimeCallId,
|
||||
);
|
||||
const isShell = call.name === 'run_shell_command';
|
||||
if (
|
||||
!declarations.some((tool) => tool.name === call.name) ||
|
||||
ids.has(call.callId) ||
|
||||
call.wasOutputTruncated === true
|
||||
)
|
||||
throw new Error('Hosted Workspace profile refused a tool call.');
|
||||
ids.add(call.callId);
|
||||
let validationError: string | undefined;
|
||||
let input: Record<string, unknown>;
|
||||
if (mcpInput) {
|
||||
input = { ...mcpInput.input };
|
||||
} else if (isShell) {
|
||||
const args = call.args;
|
||||
const unsupportedKey = Object.keys(args).find(
|
||||
(key) =>
|
||||
!['command', 'timeout', 'description', 'is_background'].includes(
|
||||
key,
|
||||
),
|
||||
);
|
||||
if (typeof args['command'] !== 'string' || !args['command'].trim()) {
|
||||
validationError = 'Hosted Shell requires a nonempty command.';
|
||||
} else if (unsupportedKey !== undefined) {
|
||||
validationError = `Hosted Shell received unsupported argument ${JSON.stringify(unsupportedKey)}.`;
|
||||
} else if (
|
||||
args['is_background'] !== undefined &&
|
||||
args['is_background'] !== false &&
|
||||
!(
|
||||
typeof args['is_background'] === 'string' &&
|
||||
args['is_background'].toLowerCase() === 'false'
|
||||
)
|
||||
) {
|
||||
validationError =
|
||||
'Hosted Shell requires a foreground command in the saved directory. Background jobs and Monitor are unavailable; correct the arguments before retrying.';
|
||||
} else if (
|
||||
args['description'] !== undefined &&
|
||||
typeof args['description'] !== 'string'
|
||||
) {
|
||||
validationError = 'Hosted Shell description must be a string.';
|
||||
} else if (
|
||||
args['timeout'] !== undefined &&
|
||||
(!Number.isSafeInteger(args['timeout']) ||
|
||||
(args['timeout'] as number) < 1 ||
|
||||
(args['timeout'] as number) > 600000)
|
||||
) {
|
||||
validationError =
|
||||
'Hosted Shell timeout must be an integer from 1 to 600000 ms.';
|
||||
}
|
||||
if (this.publication && args['is_background'] !== undefined) {
|
||||
validationError = 'Hosted Shell requires one foreground command.';
|
||||
}
|
||||
input = this.publication
|
||||
? { ...args }
|
||||
: { ...args, is_background: false };
|
||||
} else {
|
||||
try {
|
||||
input['file_path'] = normalizeWorkspaceRelativePath(file.trim());
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof InvalidWorkspaceRelativePathError))
|
||||
throw cause;
|
||||
const file = call.args['file_path'];
|
||||
input = { ...call.args };
|
||||
const filePathError =
|
||||
'Hosted file tools require file_path relative to the saved Session working directory. Absolute paths and ".." traversal are not allowed. Correct file_path and retry.';
|
||||
if (typeof file !== 'string') {
|
||||
validationError = filePathError;
|
||||
} else {
|
||||
try {
|
||||
input['file_path'] = normalizeWorkspaceRelativePath(file.trim());
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof InvalidWorkspaceRelativePathError))
|
||||
throw cause;
|
||||
validationError = filePathError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const encoded = this.encodeToolInput(
|
||||
mcpInput ?? { toolName: call.name, input },
|
||||
);
|
||||
return {
|
||||
call,
|
||||
validationError,
|
||||
input,
|
||||
isShell,
|
||||
inputDigest: isShell ? managedToolDigest(input) : undefined,
|
||||
mcp: mcpInput !== undefined,
|
||||
...encoded,
|
||||
argsDigest: `sha256:${managedToolDigest(input)}`,
|
||||
publicationId: isShell && this.publication ? randomUUID() : null,
|
||||
runtimeCallId,
|
||||
};
|
||||
});
|
||||
const encoded = this.encodeToolInput(
|
||||
mcpInput ?? { toolName: call.name, input },
|
||||
);
|
||||
return {
|
||||
call,
|
||||
validationError,
|
||||
input,
|
||||
isShell,
|
||||
inputDigest: isShell ? managedToolDigest(input) : undefined,
|
||||
mcp: mcpInput !== undefined,
|
||||
...encoded,
|
||||
argsDigest: `sha256:${managedToolDigest(input)}`,
|
||||
publicationId: isShell && this.publication ? randomUUID() : null,
|
||||
runtimeCallId,
|
||||
};
|
||||
});
|
||||
};
|
||||
let requests = prepareRequests(calls);
|
||||
if (!this.messageFitsInline('assistant', parts, model))
|
||||
throw new Error(
|
||||
'Hosted assistant record exceeds the inline Session Store limit.',
|
||||
);
|
||||
if (
|
||||
calls.some(
|
||||
(call) =>
|
||||
!this.messageFitsInline(
|
||||
'tool_result',
|
||||
convertToFunctionErrorResponse(
|
||||
call.name,
|
||||
call.callId,
|
||||
[],
|
||||
'The turn was cancelled before this tool call ran.',
|
||||
),
|
||||
model,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Hosted tool cancellation exceeds the inline Session Store limit.',
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (requests.some((request) => request.validationError)) {
|
||||
const responses = requests.flatMap((request) =>
|
||||
@@ -505,6 +883,87 @@ export class HostedWorkspaceToolTurn {
|
||||
}
|
||||
messageId = await this.commit('assistant', parts, model);
|
||||
refusals = await this.approve(requests, messageId, inputRefs, signal);
|
||||
if (this.hooks) {
|
||||
const updated = requests.map((request) => request.call);
|
||||
const askAgain = new Set<number>();
|
||||
for (const [index, request] of requests.entries()) {
|
||||
if (refusals[index]) continue;
|
||||
const output = await this.hooks.fire(
|
||||
HookEventName.PreToolUse,
|
||||
`${this.promptId}:${request.call.callId}`,
|
||||
{
|
||||
tool_name: request.call.name,
|
||||
tool_input: request.call.args,
|
||||
tool_use_id: request.call.callId,
|
||||
permission_mode: this.approval?.settings.mode ?? 'yolo',
|
||||
prompt_id: this.promptId,
|
||||
},
|
||||
signal,
|
||||
this.promptHookRunner,
|
||||
);
|
||||
const parsed = output && new PreToolUseHookOutput(output);
|
||||
const permission = parsed?.getPermissionDecision();
|
||||
if (
|
||||
parsed?.shouldStopExecution() ||
|
||||
parsed?.isBlockingDecision() ||
|
||||
permission === 'deny'
|
||||
) {
|
||||
refusals[index] =
|
||||
parsed?.getEffectiveReason() ||
|
||||
'PreToolUse Hook denied the call.';
|
||||
continue;
|
||||
}
|
||||
const input = output?.hookSpecificOutput?.['updatedInput'];
|
||||
if (
|
||||
input &&
|
||||
typeof input === 'object' &&
|
||||
!Array.isArray(input) &&
|
||||
!isDeepStrictEqual(input, request.call.args)
|
||||
) {
|
||||
updated[index] = {
|
||||
...request.call,
|
||||
args: input as Record<string, unknown>,
|
||||
};
|
||||
inputRefs.delete(index);
|
||||
if (
|
||||
this.approval &&
|
||||
hostedApprovalAsks(this.approval.settings, request.call.name)
|
||||
)
|
||||
askAgain.add(index);
|
||||
}
|
||||
if (permission === 'ask') askAgain.add(index);
|
||||
}
|
||||
const revised = prepareRequests(updated);
|
||||
requests = requests.map((request, index) =>
|
||||
updated[index] === request.call ? request : revised[index],
|
||||
);
|
||||
for (const [index, request] of requests.entries()) {
|
||||
if (refusals[index]) continue;
|
||||
if (request.validationError) {
|
||||
refusals[index] = request.validationError;
|
||||
continue;
|
||||
}
|
||||
if (askAgain.has(index)) {
|
||||
if (!this.approval) {
|
||||
refusals[index] =
|
||||
'Hook requires approval, but no approval policy is available.';
|
||||
continue;
|
||||
}
|
||||
const inputRef = await this.session.resources.publish(
|
||||
'managed-tool-input',
|
||||
request.inputBytes,
|
||||
);
|
||||
inputRefs.set(index, inputRef);
|
||||
refusals[index] = await this.ask(
|
||||
this.approval,
|
||||
request.call,
|
||||
inputRef,
|
||||
messageId,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
throw new HostedToolRecoveryRequiredError(cause);
|
||||
}
|
||||
@@ -1328,6 +1787,7 @@ export class HostedWorkspaceToolTurn {
|
||||
const response = converted[0].functionResponse;
|
||||
response.response = {
|
||||
...response.response,
|
||||
executionStatus: envelope.executionStatus,
|
||||
manifestRef,
|
||||
captureStatus: envelope.capture?.captureStatus ?? 'unavailable',
|
||||
previewTruncated: envelope.capture?.previewTruncated ?? false,
|
||||
@@ -1461,12 +1921,21 @@ export class HostedWorkspaceToolTurn {
|
||||
inputRefs: Map<number, ManagedSessionDurableRef>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<string | undefined>> {
|
||||
const refusals: Array<string | undefined> = requests.map(() => undefined);
|
||||
const refusals: Array<string | undefined> = requests.map((request) =>
|
||||
this.hookPermission.get(request.call.callId) === 'deny'
|
||||
? 'PermissionRequest Hook denied the call.'
|
||||
: undefined,
|
||||
);
|
||||
const approval = this.approval;
|
||||
if (!approval) return refusals;
|
||||
let asked = false;
|
||||
for (const [index, request] of requests.entries()) {
|
||||
if (!hostedApprovalAsks(approval.settings, request.call.name)) continue;
|
||||
if (
|
||||
refusals[index] ||
|
||||
this.hookPermission.get(request.call.callId) === 'allow' ||
|
||||
!hostedApprovalAsks(approval.settings, request.call.name)
|
||||
)
|
||||
continue;
|
||||
asked = true;
|
||||
if (signal.aborted) break;
|
||||
if (this.unanswered) {
|
||||
@@ -1570,8 +2039,10 @@ export class HostedWorkspaceToolTurn {
|
||||
throw new HostedToolRecoveryRequiredError('Tool outcome is unknown.');
|
||||
if (!this.acquired) return;
|
||||
try {
|
||||
await this.harness.settleConsumedRuntimeContinuation();
|
||||
if (!this.mcp) await this.broker.release();
|
||||
if (this.hookStopReason)
|
||||
await this.harness.settleHookStoppedRuntimeContinuation();
|
||||
else await this.harness.settleConsumedRuntimeContinuation();
|
||||
if (!this.mcp && !this.hooks) await this.broker.release();
|
||||
this.acquired = false;
|
||||
} catch (cause) {
|
||||
this.uncertain = true;
|
||||
|
||||
@@ -45,6 +45,14 @@ import {
|
||||
WORKSPACE_CAPABILITY_DIGEST,
|
||||
WORKSPACE_CONTEXT_CONFIG_REF,
|
||||
} from './managed-workspace-activation.js';
|
||||
import {
|
||||
ManagedHookRuntime,
|
||||
loadManagedHookManifest,
|
||||
} from './managed-hook-runtime.js';
|
||||
import {
|
||||
MANAGED_HOOK_WORKER_ROUTE,
|
||||
registerManagedHookRoutes,
|
||||
} from './managed-hook-routes.js';
|
||||
import {
|
||||
ManagedMcpRuntime,
|
||||
loadManagedMcpManifest,
|
||||
@@ -62,6 +70,7 @@ export const MANAGED_CONTEXT_WORKER_ROUTES = Object.freeze([
|
||||
...MANAGED_CONTEXT_ROUTES,
|
||||
WORKSPACE_ACTIVATION_ROUTE,
|
||||
MANAGED_MCP_WORKER_ROUTE,
|
||||
MANAGED_HOOK_WORKER_ROUTE,
|
||||
MANAGED_RUNTIME_PROVIDER_ROUTE,
|
||||
...OWNED_MANAGED_RUNTIME_ROUTES.filter((route) => route.key !== 'attest'),
|
||||
]);
|
||||
@@ -221,6 +230,18 @@ export function registerManagedContextRoutes(
|
||||
loadManagedMcpManifest(process.env['QWEN_MANAGED_MCP_CONFIG']),
|
||||
);
|
||||
registerManagedMcpRoutes(app, boot, mcp);
|
||||
const hooks = new ManagedHookRuntime(
|
||||
boot,
|
||||
async (runtimeSessionId) => {
|
||||
if (!requiresActivation || !activations.isActive(runtimeSessionId))
|
||||
return undefined;
|
||||
const binding = installations.installed(runtimeSessionId);
|
||||
const directory = binding && (await mount.resolve(binding.cwdRelative));
|
||||
return activations.isActive(runtimeSessionId) ? directory : undefined;
|
||||
},
|
||||
loadManagedHookManifest(process.env['QWEN_MANAGED_HOOK_CONFIG']),
|
||||
);
|
||||
registerManagedHookRoutes(app, boot, hooks);
|
||||
const executor = new ManagedToolExecutor(
|
||||
async (reference) => {
|
||||
const isActive = () =>
|
||||
@@ -253,6 +274,7 @@ export function registerManagedContextRoutes(
|
||||
},
|
||||
publisher,
|
||||
mcp,
|
||||
hooks,
|
||||
);
|
||||
registerManagedRuntimeProviderRoute(
|
||||
app,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import {
|
||||
MANAGED_HOOK_ROUTE,
|
||||
type ManagedHookControl,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js';
|
||||
import {
|
||||
startManagedRuntimeAttestationWorker,
|
||||
type ManagedRuntimeAttestationWorkerHandle,
|
||||
} from './managed-runtime-attestation-worker.js';
|
||||
import type { ManagedContextBoot } from './managed-context-envelope.js';
|
||||
import {
|
||||
computeManagedContextDigest,
|
||||
type ManagedContextBinding,
|
||||
} from './managed-workspace-binding.js';
|
||||
import {
|
||||
WORKSPACE_CAPABILITY_DIGEST,
|
||||
WORKSPACE_CONTEXT_CONFIG_REF,
|
||||
WORKSPACE_EXECUTION_PROFILE,
|
||||
} from './managed-workspace-activation.js';
|
||||
let worker: ManagedRuntimeAttestationWorkerHandle | undefined;
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
await worker?.close();
|
||||
if (directory) await rm(directory, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
it('authenticates hooks and retains the active workspace until its original command drains', async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'qwen-hook-worker-'));
|
||||
const key = {
|
||||
tenantId: 'tenant-a',
|
||||
workspaceId: 'workspace-a',
|
||||
sessionId: 'session-a',
|
||||
};
|
||||
const runtimeSessionId = 'hook-session-a';
|
||||
const pin = {
|
||||
catalogId: 'hooks',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const manifest = path.join(directory, 'hooks.json');
|
||||
await writeFile(
|
||||
manifest,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
catalogs: [
|
||||
{
|
||||
...pin,
|
||||
tenantId: key.tenantId,
|
||||
workspaceId: key.workspaceId,
|
||||
hooks: [
|
||||
{
|
||||
hookId: 'hook',
|
||||
eventName: HookEventName.PreToolUse,
|
||||
sequential: false,
|
||||
failClosed: true,
|
||||
async: true,
|
||||
onceKey: null,
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: 'printf started > started; sleep 30',
|
||||
timeout: 60000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubEnv('QWEN_MANAGED_HOOK_CONFIG', manifest);
|
||||
const boot: ManagedContextBoot = {
|
||||
type: 'boot',
|
||||
version: 2,
|
||||
managedContext: 'managed-context/1',
|
||||
runtimeInstanceId: 'runtime-a',
|
||||
runtimeIncarnation: 'boot-a',
|
||||
leaseId: 'lease-a',
|
||||
provisionRequestId: 'provision-a',
|
||||
token: 'test-token',
|
||||
epoch: 1,
|
||||
capabilityDigest: WORKSPACE_CAPABILITY_DIGEST,
|
||||
isolationClass: 'session',
|
||||
tenantId: key.tenantId,
|
||||
workspaceId: key.workspaceId,
|
||||
workspaceGeneration: '1',
|
||||
storageId: 'storage://pvc/workspace-a',
|
||||
mountRoot: directory,
|
||||
};
|
||||
worker = await startManagedRuntimeAttestationWorker(boot);
|
||||
const headers = {
|
||||
authorization: 'Bearer test-token',
|
||||
'content-type': 'application/json',
|
||||
'cache-control': 'no-store',
|
||||
'x-qwen-managed-lease-id': 'lease-a',
|
||||
'x-qwen-managed-lease-epoch': '1',
|
||||
};
|
||||
const post = (route: string, body: unknown, auth = headers) =>
|
||||
fetch(`${worker!.ready.url}${route}`, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const binding: ManagedContextBinding = {
|
||||
tenantId: key.tenantId,
|
||||
workspaceId: key.workspaceId,
|
||||
workspaceGeneration: '1',
|
||||
storageId: boot.storageId,
|
||||
cwdRelative: '.',
|
||||
contextConfigRef: WORKSPACE_CONTEXT_CONFIG_REF,
|
||||
contextRevision: '1',
|
||||
};
|
||||
const contextDigest = computeManagedContextDigest(binding);
|
||||
const context = await post('/internal/managed-runtime/v3/context', {
|
||||
protocolVersion: 3,
|
||||
managedContext: 'managed-context/1',
|
||||
operationId: 'context-a',
|
||||
sessionId: runtimeSessionId,
|
||||
binding,
|
||||
contextDigest,
|
||||
});
|
||||
expect(context.status).toBe(200);
|
||||
const activation = {
|
||||
protocolVersion: 1,
|
||||
profile: WORKSPACE_EXECUTION_PROFILE,
|
||||
sessionId: runtimeSessionId,
|
||||
contextConfigRef: WORKSPACE_CONTEXT_CONFIG_REF,
|
||||
contextDigest,
|
||||
operation: 'activate',
|
||||
};
|
||||
expect(
|
||||
(await post('/internal/managed-runtime/v3/activation', activation)).status,
|
||||
).toBe(200);
|
||||
|
||||
const operation: ManagedHookControl = {
|
||||
kind: 'hook-execute',
|
||||
pin,
|
||||
sessionKey: key,
|
||||
operationId: 'execution',
|
||||
hookId: 'hook',
|
||||
input: {
|
||||
session_id: key.sessionId,
|
||||
cwd: 'ignored',
|
||||
transcript_path: '',
|
||||
hook_event_name: HookEventName.PreToolUse,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
grant: {
|
||||
sessionKey: key,
|
||||
operationId: 'execution',
|
||||
domain: 'hook_execution',
|
||||
operationRevision: 1,
|
||||
ownerId: 'harness',
|
||||
workspaceGeneration: '1',
|
||||
leaseDurationMs: 300000,
|
||||
expiresAt: Date.now() + 300000,
|
||||
resourceScope: {
|
||||
recordRef: {
|
||||
resourceId: 'record',
|
||||
kind: 'managed-hook_execution',
|
||||
schemaVersion: 1,
|
||||
byteLength: 1,
|
||||
digest: 'b'.repeat(64),
|
||||
},
|
||||
phases: ['execute'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const request = (operation: ManagedHookControl) => ({
|
||||
protocolVersion: 1,
|
||||
runtimeSessionId,
|
||||
operation,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await post(MANAGED_HOOK_ROUTE, request(operation), {
|
||||
...headers,
|
||||
authorization: 'Bearer wrong',
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await post(MANAGED_HOOK_ROUTE, request(operation), {
|
||||
...headers,
|
||||
'x-qwen-managed-lease-epoch': '2',
|
||||
})
|
||||
).status,
|
||||
).toBe(409);
|
||||
await expect(readFile(path.join(directory, 'started'))).rejects.toThrow();
|
||||
const started = await post(MANAGED_HOOK_ROUTE, request(operation));
|
||||
expect(started.status).toBe(200);
|
||||
expect(started.headers.get('cache-control')).toBe('no-store');
|
||||
const release = () =>
|
||||
post('/internal/managed-runtime/v3/activation', {
|
||||
...activation,
|
||||
operation: 'release',
|
||||
});
|
||||
if (
|
||||
process.platform !== 'linux' ||
|
||||
!process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT']
|
||||
) {
|
||||
await vi.waitFor(async () => {
|
||||
const response = await post(
|
||||
MANAGED_HOOK_ROUTE,
|
||||
request({
|
||||
kind: 'hook-status',
|
||||
sessionKey: key,
|
||||
operationId: 'lookup',
|
||||
targetOperationId: 'execution',
|
||||
}),
|
||||
);
|
||||
expect(await response.json()).toMatchObject({
|
||||
operation: {
|
||||
state: 'settled',
|
||||
error: { code: 'managed_hook_command_isolation_unavailable' },
|
||||
},
|
||||
});
|
||||
});
|
||||
await expect(readFile(path.join(directory, 'started'))).rejects.toThrow();
|
||||
expect((await release()).status).toBe(200);
|
||||
return;
|
||||
}
|
||||
await vi.waitFor(async () =>
|
||||
expect(await readFile(path.join(directory!, 'started'), 'utf8')).toBe(
|
||||
'started',
|
||||
),
|
||||
);
|
||||
expect((await release()).status).toBe(409);
|
||||
await post(
|
||||
MANAGED_HOOK_ROUTE,
|
||||
request({
|
||||
kind: 'hook-cancel',
|
||||
sessionKey: key,
|
||||
operationId: 'cancel',
|
||||
targetOperationId: 'execution',
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const response = await post(
|
||||
MANAGED_HOOK_ROUTE,
|
||||
request({
|
||||
kind: 'hook-status',
|
||||
sessionKey: key,
|
||||
operationId: 'lookup',
|
||||
targetOperationId: 'execution',
|
||||
}),
|
||||
);
|
||||
expect(await response.json()).toMatchObject({
|
||||
operation: { state: 'settled', result: { outcome: 'cancelled' } },
|
||||
});
|
||||
},
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
expect((await release()).status).toBe(200);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Application, Request, Response } from 'express';
|
||||
import {
|
||||
MANAGED_HOOK_ROUTE,
|
||||
MANAGED_HOOK_MAX_REQUEST_BYTES,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js';
|
||||
import {
|
||||
authorizeManagedRuntime,
|
||||
handleManagedRuntimeJsonError,
|
||||
managedRuntimeJsonBody,
|
||||
managedRuntimeNoStore,
|
||||
type ManagedRuntimeRequestIdentity,
|
||||
} from './managed-runtime-attestation-contract.js';
|
||||
import {
|
||||
ManagedHookError,
|
||||
type ManagedHookRuntime,
|
||||
} from './managed-hook-runtime.js';
|
||||
|
||||
export const MANAGED_HOOK_WORKER_ROUTE = Object.freeze({
|
||||
key: 'hook',
|
||||
method: 'POST',
|
||||
path: MANAGED_HOOK_ROUTE,
|
||||
protocolVersion: 1,
|
||||
requestBodyLimitBytes: MANAGED_HOOK_MAX_REQUEST_BYTES,
|
||||
responseBodyLimitBytes: 1024 * 1024,
|
||||
cacheControl: 'no-store',
|
||||
} as const);
|
||||
|
||||
export function registerManagedHookRoutes(
|
||||
app: Application,
|
||||
identity: ManagedRuntimeRequestIdentity,
|
||||
runtime: ManagedHookRuntime,
|
||||
): void {
|
||||
app.post(
|
||||
MANAGED_HOOK_ROUTE,
|
||||
managedRuntimeNoStore,
|
||||
authorizeManagedRuntime(identity),
|
||||
managedRuntimeJsonBody(MANAGED_HOOK_WORKER_ROUTE.requestBodyLimitBytes),
|
||||
async (req: Request, res: Response) => {
|
||||
const body: unknown = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
res.status(400).json({ code: 'managed_hook_invalid' });
|
||||
return;
|
||||
}
|
||||
const request = body as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(request).sort().join(',') !==
|
||||
'operation,protocolVersion,runtimeSessionId' ||
|
||||
request['protocolVersion'] !== 1 ||
|
||||
typeof request['runtimeSessionId'] !== 'string' ||
|
||||
!request['runtimeSessionId']
|
||||
) {
|
||||
res.status(400).json({ code: 'managed_hook_invalid' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const operation = await runtime.control(
|
||||
request['runtimeSessionId'],
|
||||
request['operation'],
|
||||
);
|
||||
res.json({
|
||||
protocolVersion: 1,
|
||||
runtimeSessionId: request['runtimeSessionId'],
|
||||
operation,
|
||||
});
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof ManagedHookError
|
||||
? error.code
|
||||
: 'managed_hook_unavailable';
|
||||
res.status(code === 'managed_hook_invalid' ? 400 : 409).json({ code });
|
||||
}
|
||||
},
|
||||
handleManagedRuntimeJsonError,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,937 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createAbortController } from '@qwen-code/qwen-code-core/utils/abortController.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { HookRegistry } from '@qwen-code/qwen-code-core/hooks/hookRegistry.js';
|
||||
import { HookRunner } from '@qwen-code/qwen-code-core/hooks/hookRunner.js';
|
||||
import { interpolateUrl } from '@qwen-code/qwen-code-core/hooks/envInterpolator.js';
|
||||
import { HookCommandIsolationUnavailableError } from '@qwen-code/qwen-code-core/hooks/hook-command-cgroup.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
getHookKey,
|
||||
type HookDefinition,
|
||||
type FunctionHookConfig,
|
||||
HookType,
|
||||
type CommandHookConfig,
|
||||
type FunctionHookCallback,
|
||||
type HookConfig,
|
||||
type HookExecutionResult,
|
||||
type HttpHookConfig,
|
||||
type PromptHookConfig,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import { parseOperationGrant } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js';
|
||||
import { ManagedOperationGrantGate } from '@qwen-code/qwen-code-core/managed-runtime/managed-operation-grant-gate.js';
|
||||
import {
|
||||
MANAGED_HOOK_MAX_RUNNING,
|
||||
type ManagedHookCatalog,
|
||||
type ManagedHookCatalogPin,
|
||||
type ManagedHookControl,
|
||||
type ManagedHookDescriptor,
|
||||
type ManagedHookExecute,
|
||||
type ManagedHookOperationView,
|
||||
type RegisteredHandlerRef,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js';
|
||||
import type { ManagedSessionKey } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
|
||||
export interface ManagedHookDefinition
|
||||
extends Omit<ManagedHookDescriptor, 'config' | 'handler' | 'plannerKey'> {
|
||||
readonly config:
|
||||
| CommandHookConfig
|
||||
| HttpHookConfig
|
||||
| PromptHookConfig
|
||||
| {
|
||||
readonly type: 'function';
|
||||
readonly name?: string;
|
||||
readonly timeout: number;
|
||||
};
|
||||
readonly handler?: RegisteredHandlerRef & {
|
||||
readonly modulePath: string;
|
||||
readonly exportName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManagedHookManifest {
|
||||
readonly version: 1;
|
||||
readonly catalogs: ReadonlyArray<
|
||||
ManagedHookCatalogPin & {
|
||||
readonly tenantId: string;
|
||||
readonly workspaceId: string;
|
||||
readonly hooks: readonly ManagedHookDefinition[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export class ManagedHookError extends Error {
|
||||
constructor(readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = /^[a-zA-Z0-9:_.-]{1,512}$/u;
|
||||
const MAX_OPERATIONS = 4096;
|
||||
const MAX_BYTES = 60 * 1024;
|
||||
const FUNCTION_SETTLEMENT_GRACE_MS = 1000;
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function closed(value: Record<string, unknown>, keys: string[]) {
|
||||
if (Object.keys(value).some((key) => !keys.includes(key)))
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
}
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === 'string' && identifier.test(value);
|
||||
}
|
||||
function positive(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
}
|
||||
function pin(value: unknown): ManagedHookCatalogPin {
|
||||
const parsed = object(value);
|
||||
closed(parsed, ['catalogId', 'catalogRevision', 'definitionDigest']);
|
||||
if (
|
||||
!text(parsed['catalogId']) ||
|
||||
!positive(parsed['catalogRevision']) ||
|
||||
typeof parsed['definitionDigest'] !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(parsed['definitionDigest'])
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
return parsed as unknown as ManagedHookCatalogPin;
|
||||
}
|
||||
function scope(key: ManagedSessionKey) {
|
||||
return `${key.tenantId}\0${key.workspaceId}\0${key.sessionId}`;
|
||||
}
|
||||
function fingerprint(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
function quotaExceeded(operationId: string): ManagedHookOperationView {
|
||||
return {
|
||||
operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
duration: 0,
|
||||
error: 'Managed hook execution quota exceeded before dispatch.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseManagedHookControl(value: unknown): ManagedHookControl {
|
||||
const control = object(value);
|
||||
const key = object(control['sessionKey']);
|
||||
closed(key, ['tenantId', 'workspaceId', 'sessionId']);
|
||||
if (
|
||||
!['tenantId', 'workspaceId', 'sessionId'].every((field) =>
|
||||
text(key[field]),
|
||||
) ||
|
||||
!text(control['operationId'])
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
if (control['kind'] === 'hook-status' || control['kind'] === 'hook-cancel') {
|
||||
closed(control, ['kind', 'sessionKey', 'operationId', 'targetOperationId']);
|
||||
if (!text(control['targetOperationId']))
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
} else if (control['kind'] === 'hook-catalog') {
|
||||
closed(control, ['kind', 'sessionKey', 'operationId', 'pin']);
|
||||
pin(control['pin']);
|
||||
} else if (control['kind'] === 'hook-execute') {
|
||||
closed(control, [
|
||||
'kind',
|
||||
'sessionKey',
|
||||
'operationId',
|
||||
'pin',
|
||||
'hookId',
|
||||
'input',
|
||||
'grant',
|
||||
]);
|
||||
pin(control['pin']);
|
||||
const input = object(control['input']);
|
||||
if (
|
||||
!text(control['hookId']) ||
|
||||
input['session_id'] !== key['sessionId'] ||
|
||||
typeof input['cwd'] !== 'string' ||
|
||||
typeof input['transcript_path'] !== 'string' ||
|
||||
typeof input['timestamp'] !== 'string' ||
|
||||
(input['messages'] !== undefined &&
|
||||
(!Array.isArray(input['messages']) ||
|
||||
input['messages'].some(
|
||||
(value) =>
|
||||
!value || typeof value !== 'object' || Array.isArray(value),
|
||||
))) ||
|
||||
!Object.values(HookEventName).includes(
|
||||
input['hook_event_name'] as HookEventName,
|
||||
)
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_invalid');
|
||||
try {
|
||||
parseOperationGrant(control['grant']);
|
||||
} catch {
|
||||
throw new ManagedHookError('managed_hook_grant_invalid');
|
||||
}
|
||||
} else throw new ManagedHookError('managed_hook_invalid');
|
||||
return structuredClone(control) as unknown as ManagedHookControl;
|
||||
}
|
||||
|
||||
export function loadManagedHookManifest(path?: string): ManagedHookManifest {
|
||||
if (!path) return { version: 1, catalogs: [] };
|
||||
try {
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length > 1024 * 1024) throw new Error('too large');
|
||||
return parseManifest(JSON.parse(bytes.toString('utf8')));
|
||||
} catch {
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): ManagedHookManifest {
|
||||
const manifest = object(value);
|
||||
closed(manifest, ['version', 'catalogs']);
|
||||
if (manifest['version'] !== 1 || !Array.isArray(manifest['catalogs']))
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
const pins = new Set<string>();
|
||||
for (const raw of manifest['catalogs']) {
|
||||
const catalog = object(raw);
|
||||
closed(catalog, [
|
||||
'tenantId',
|
||||
'workspaceId',
|
||||
'catalogId',
|
||||
'catalogRevision',
|
||||
'definitionDigest',
|
||||
'hooks',
|
||||
]);
|
||||
pin({
|
||||
catalogId: catalog['catalogId'],
|
||||
catalogRevision: catalog['catalogRevision'],
|
||||
definitionDigest: catalog['definitionDigest'],
|
||||
});
|
||||
if (
|
||||
!text(catalog['tenantId']) ||
|
||||
!text(catalog['workspaceId']) ||
|
||||
!Array.isArray(catalog['hooks']) ||
|
||||
catalog['hooks'].length > 128
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
const identity = JSON.stringify([
|
||||
catalog['tenantId'],
|
||||
catalog['workspaceId'],
|
||||
catalog['catalogId'],
|
||||
catalog['catalogRevision'],
|
||||
]);
|
||||
if (pins.has(identity))
|
||||
throw new ManagedHookError('managed_hook_definition_conflict');
|
||||
pins.add(identity);
|
||||
const ids = new Set<string>();
|
||||
for (const rawHook of catalog['hooks']) {
|
||||
const hook = object(rawHook);
|
||||
closed(hook, [
|
||||
'hookId',
|
||||
'eventName',
|
||||
'matcher',
|
||||
'sequential',
|
||||
'onceKey',
|
||||
'failClosed',
|
||||
'async',
|
||||
'config',
|
||||
'handler',
|
||||
'source',
|
||||
'sourceTrusted',
|
||||
'enabled',
|
||||
'agentScope',
|
||||
'owner',
|
||||
]);
|
||||
const config = object(hook['config']);
|
||||
if (
|
||||
(hook['source'] !== undefined &&
|
||||
!Object.values(HooksConfigSource).includes(
|
||||
hook['source'] as HooksConfigSource,
|
||||
)) ||
|
||||
['sourceTrusted', 'enabled'].some(
|
||||
(key) => hook[key] !== undefined && typeof hook[key] !== 'boolean',
|
||||
) ||
|
||||
(hook['agentScope'] !== undefined && !text(hook['agentScope']))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
if (hook['owner'] !== undefined) {
|
||||
const owner = object(hook['owner']);
|
||||
closed(owner, ['sessionId', 'agentId']);
|
||||
if (
|
||||
!text(owner['sessionId']) ||
|
||||
!(owner['agentId'] === null || text(owner['agentId']))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
}
|
||||
if (
|
||||
(hook['source'] === HooksConfigSource.Session &&
|
||||
hook['owner'] === undefined) ||
|
||||
(hook['agentScope'] !== undefined &&
|
||||
(hook['source'] !== HooksConfigSource.Session ||
|
||||
object(hook['owner'])['agentId'] === null))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
if (
|
||||
!text(hook['hookId']) ||
|
||||
ids.has(hook['hookId'] as string) ||
|
||||
!Object.values(HookEventName).includes(
|
||||
hook['eventName'] as HookEventName,
|
||||
) ||
|
||||
!['sequential', 'failClosed', 'async'].every(
|
||||
(key) => typeof hook[key] === 'boolean',
|
||||
) ||
|
||||
!(hook['onceKey'] === null || text(hook['onceKey'])) ||
|
||||
(hook['matcher'] !== undefined &&
|
||||
typeof hook['matcher'] !== 'string') ||
|
||||
(config['name'] !== undefined && typeof config['name'] !== 'string') ||
|
||||
(config['timeout'] !== undefined &&
|
||||
(!positive(config['timeout']) || Number(config['timeout']) > 600_000))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
ids.add(hook['hookId'] as string);
|
||||
if (config['type'] === 'command') {
|
||||
closed(config, ['type', 'name', 'command', 'timeout', 'env', 'shell']);
|
||||
if (
|
||||
typeof config['command'] !== 'string' ||
|
||||
!config['command'] ||
|
||||
config['command'].includes('\0') ||
|
||||
(config['shell'] !== undefined &&
|
||||
!['bash', 'powershell'].includes(String(config['shell'])))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
} else if (config['type'] === 'http') {
|
||||
closed(config, [
|
||||
'type',
|
||||
'name',
|
||||
'url',
|
||||
'headers',
|
||||
'allowedEnvVars',
|
||||
'timeout',
|
||||
]);
|
||||
const url = new URL(String(config['url']));
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
(config['allowedEnvVars'] !== undefined &&
|
||||
(!Array.isArray(config['allowedEnvVars']) ||
|
||||
!config['allowedEnvVars'].every(
|
||||
(item) => typeof item === 'string',
|
||||
)))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
} else if (config['type'] === 'prompt') {
|
||||
closed(config, ['type', 'name', 'prompt', 'model', 'timeout']);
|
||||
if (
|
||||
typeof config['prompt'] !== 'string' ||
|
||||
!config['prompt'] ||
|
||||
(config['model'] !== undefined && typeof config['model'] !== 'string')
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
} else if (config['type'] === 'function') {
|
||||
closed(config, ['type', 'name', 'timeout']);
|
||||
const handler = object(hook['handler']);
|
||||
closed(handler, [
|
||||
'handlerId',
|
||||
'handlerRevision',
|
||||
'modulePath',
|
||||
'exportName',
|
||||
]);
|
||||
if (
|
||||
!text(handler['handlerId']) ||
|
||||
!positive(handler['handlerRevision']) ||
|
||||
typeof handler['modulePath'] !== 'string' ||
|
||||
!isAbsolute(handler['modulePath']) ||
|
||||
!text(handler['exportName']) ||
|
||||
!positive(config['timeout'])
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
} else throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
if (config['type'] !== 'function' && hook['handler'] !== undefined)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
if (hook['async'] && config['type'] !== 'command')
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
for (const field of ['env', 'headers']) {
|
||||
if (
|
||||
config[field] !== undefined &&
|
||||
Object.entries(object(config[field])).some(
|
||||
([key, value]) =>
|
||||
key.includes('\0') ||
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0'),
|
||||
)
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_manifest_invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
return structuredClone(manifest) as unknown as ManagedHookManifest;
|
||||
}
|
||||
|
||||
interface Operation {
|
||||
runtimeSessionId: string;
|
||||
fingerprint: string;
|
||||
sessionKey: ManagedSessionKey;
|
||||
view: ManagedHookOperationView;
|
||||
controller: AbortController;
|
||||
done?: Promise<void>;
|
||||
}
|
||||
|
||||
export class ManagedHookRuntime {
|
||||
private readonly manifest: ManagedHookManifest;
|
||||
private readonly operations = new Map<string, Operation>();
|
||||
private readonly gates = new ManagedOperationGrantGate();
|
||||
private readonly shutdown = createAbortController();
|
||||
private closing = false;
|
||||
|
||||
constructor(
|
||||
private readonly workspace: {
|
||||
tenantId: string;
|
||||
workspaceId: string;
|
||||
workspaceGeneration: string;
|
||||
},
|
||||
private readonly sessionDirectory: (
|
||||
runtimeSessionId: string,
|
||||
) => Promise<string | undefined>,
|
||||
manifest: ManagedHookManifest,
|
||||
) {
|
||||
this.manifest = parseManifest(manifest);
|
||||
}
|
||||
|
||||
async control(
|
||||
runtimeSessionId: string,
|
||||
value: unknown,
|
||||
): Promise<ManagedHookOperationView> {
|
||||
const control = parseManagedHookControl(value);
|
||||
if (
|
||||
control.sessionKey.tenantId !== this.workspace.tenantId ||
|
||||
control.sessionKey.workspaceId !== this.workspace.workspaceId
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_scope_conflict');
|
||||
if (control.kind === 'hook-status' || control.kind === 'hook-cancel') {
|
||||
const existing = this.operations.get(
|
||||
`${scope(control.sessionKey)}\0${control.targetOperationId}`,
|
||||
);
|
||||
if (!existing || existing.runtimeSessionId !== runtimeSessionId)
|
||||
return {
|
||||
operationId: control.targetOperationId,
|
||||
state: 'outcome_unknown',
|
||||
};
|
||||
if (control.kind === 'hook-cancel') existing.controller.abort();
|
||||
return structuredClone(existing.view);
|
||||
}
|
||||
const { pin: expected } = control;
|
||||
const catalog = this.manifest.catalogs.find(
|
||||
(entry) =>
|
||||
entry.tenantId === control.sessionKey.tenantId &&
|
||||
entry.workspaceId === control.sessionKey.workspaceId &&
|
||||
entry.catalogId === expected.catalogId &&
|
||||
entry.catalogRevision === expected.catalogRevision &&
|
||||
entry.definitionDigest === expected.definitionDigest,
|
||||
);
|
||||
if (!catalog)
|
||||
throw new ManagedHookError('managed_hook_catalog_unavailable');
|
||||
const key = `${scope(control.sessionKey)}\0${control.operationId}`;
|
||||
const { grant: _grant, ...effect } =
|
||||
control.kind === 'hook-execute'
|
||||
? control
|
||||
: { ...control, grant: undefined };
|
||||
const digest = fingerprint(effect);
|
||||
const existing = this.operations.get(key);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.runtimeSessionId !== runtimeSessionId ||
|
||||
existing.fingerprint !== digest
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_operation_conflict');
|
||||
return structuredClone(existing.view);
|
||||
}
|
||||
if (this.closing) throw new ManagedHookError('managed_hook_closed');
|
||||
const directory = await this.sessionDirectory(runtimeSessionId);
|
||||
if (!directory)
|
||||
throw new ManagedHookError('managed_hook_session_unavailable');
|
||||
if (control.kind === 'hook-catalog') {
|
||||
const exposed: ManagedHookCatalog = {
|
||||
...expected,
|
||||
hooks: await this.descriptors(catalog.hooks),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(exposed)) > MAX_BYTES)
|
||||
throw new ManagedHookError('managed_hook_catalog_limit');
|
||||
return {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
catalog: exposed,
|
||||
};
|
||||
}
|
||||
const definition = catalog.hooks.find(
|
||||
(entry) => entry.hookId === control.hookId,
|
||||
);
|
||||
if (
|
||||
!definition ||
|
||||
definition.eventName !== control.input.hook_event_name ||
|
||||
definition.sourceTrusted === false ||
|
||||
definition.enabled === false ||
|
||||
(definition.owner &&
|
||||
(definition.owner.sessionId !== control.sessionKey.sessionId ||
|
||||
(definition.agentScope !== undefined &&
|
||||
definition.owner.agentId !== (control.input.agent_id ?? null))))
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_definition_unavailable');
|
||||
if (definition.config.type === 'prompt')
|
||||
throw new ManagedHookError('managed_hook_requires_harness');
|
||||
const grant = control.grant;
|
||||
if (
|
||||
scope(grant.sessionKey) !== scope(control.sessionKey) ||
|
||||
grant.domain !== 'hook_execution' ||
|
||||
grant.operationId !== control.operationId ||
|
||||
grant.workspaceGeneration !== this.workspace.workspaceGeneration
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_grant_invalid');
|
||||
if (this.operations.has(key))
|
||||
return this.control(runtimeSessionId, control);
|
||||
if (this.closing) throw new ManagedHookError('managed_hook_closed');
|
||||
if (this.operations.size >= MAX_OPERATIONS)
|
||||
return {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: false,
|
||||
outcome: 'blocking',
|
||||
duration: 0,
|
||||
output: {
|
||||
continue: false,
|
||||
decision: 'block',
|
||||
reason: 'Managed Hook receipt capacity is exhausted.',
|
||||
...(definition.eventName === HookEventName.PermissionRequest
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
decision: {
|
||||
behavior: 'deny',
|
||||
interrupt: true,
|
||||
message: 'Managed Hook receipt capacity is exhausted.',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
this.gates.install(grant);
|
||||
} catch {
|
||||
throw new ManagedHookError('managed_hook_grant_invalid');
|
||||
}
|
||||
if (
|
||||
!this.gates.admits(
|
||||
control.sessionKey,
|
||||
control.operationId,
|
||||
'execute',
|
||||
Date.now(),
|
||||
)
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_grant_invalid');
|
||||
const atRunningLimit =
|
||||
[...this.operations.values()].filter(
|
||||
(entry) => entry.view.state !== 'settled',
|
||||
).length >= MANAGED_HOOK_MAX_RUNNING;
|
||||
const entry: Operation = {
|
||||
runtimeSessionId,
|
||||
fingerprint: digest,
|
||||
sessionKey: control.sessionKey,
|
||||
controller: new AbortController(),
|
||||
view: atRunningLimit
|
||||
? quotaExceeded(control.operationId)
|
||||
: { operationId: control.operationId, state: 'running' },
|
||||
};
|
||||
this.operations.set(key, entry);
|
||||
if (!atRunningLimit)
|
||||
entry.done = this.execute(entry, definition, control, directory);
|
||||
return structuredClone(entry.view);
|
||||
}
|
||||
|
||||
private async descriptors(
|
||||
definitions: readonly ManagedHookDefinition[],
|
||||
): Promise<ManagedHookDescriptor[]> {
|
||||
const native = new Map<HookConfig, ManagedHookDefinition>();
|
||||
const sources = new Map<
|
||||
HooksConfigSource,
|
||||
Partial<Record<HookEventName, HookDefinition[]>>
|
||||
>();
|
||||
const agentHooks: Array<{
|
||||
definition: ManagedHookDefinition;
|
||||
config: HookConfig;
|
||||
}> = [];
|
||||
const sessionHooks: Array<{
|
||||
definition: ManagedHookDefinition;
|
||||
config: HookConfig;
|
||||
}> = [];
|
||||
for (const definition of definitions) {
|
||||
if (definition.enabled === false || definition.sourceTrusted === false)
|
||||
continue;
|
||||
const config: HookConfig =
|
||||
definition.config.type === 'function'
|
||||
? {
|
||||
...definition.config,
|
||||
type: HookType.Function,
|
||||
id: definition.handler!.handlerId,
|
||||
callback: async () => undefined,
|
||||
errorMessage: 'Managed hook handler failed.',
|
||||
}
|
||||
: { ...definition.config };
|
||||
native.set(config, definition);
|
||||
const source = definition.source ?? HooksConfigSource.System;
|
||||
if (source === HooksConfigSource.Session) {
|
||||
(definition.agentScope ? agentHooks : sessionHooks).push({
|
||||
definition,
|
||||
config,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const grouped = sources.get(source) ?? {};
|
||||
(grouped[definition.eventName] ??= []).push({
|
||||
matcher: definition.matcher,
|
||||
sequential: definition.sequential,
|
||||
hooks: [config],
|
||||
});
|
||||
sources.set(source, grouped);
|
||||
}
|
||||
const registry = new HookRegistry({
|
||||
getProjectRoot: () => '',
|
||||
isTrustedFolder: () => true,
|
||||
getSystemHooks: () => sources.get(HooksConfigSource.System),
|
||||
getUserHooks: () => sources.get(HooksConfigSource.User),
|
||||
getProjectHooks: () => sources.get(HooksConfigSource.Project),
|
||||
getExtensions: () => [
|
||||
{ isActive: true, hooks: sources.get(HooksConfigSource.Extensions) },
|
||||
],
|
||||
});
|
||||
await registry.initialize();
|
||||
for (const { definition, config } of agentHooks) {
|
||||
registry.addAgentHooks(
|
||||
{
|
||||
[definition.eventName]: [
|
||||
{
|
||||
matcher: definition.matcher,
|
||||
sequential: definition.sequential,
|
||||
hooks: [config],
|
||||
},
|
||||
],
|
||||
},
|
||||
definition.agentScope!,
|
||||
{
|
||||
owner: {
|
||||
sessionId: definition.owner!.sessionId,
|
||||
agentId: definition.owner!.agentId!,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const expose = (
|
||||
definition: ManagedHookDefinition,
|
||||
config: HookConfig,
|
||||
deduplicate: boolean,
|
||||
): ManagedHookDescriptor => {
|
||||
const { handler, config: _recipe, ...descriptor } = definition;
|
||||
return {
|
||||
...descriptor,
|
||||
source: definition.source ?? HooksConfigSource.System,
|
||||
...(deduplicate ? { plannerKey: fingerprint(getHookKey(config)) } : {}),
|
||||
config:
|
||||
config.type === HookType.Prompt
|
||||
? (definition.config as PromptHookConfig)
|
||||
: {
|
||||
type: config.type,
|
||||
...(config.name ? { name: config.name } : {}),
|
||||
},
|
||||
...(handler
|
||||
? {
|
||||
handler: {
|
||||
handlerId: handler.handlerId,
|
||||
handlerRevision: handler.handlerRevision,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
return [
|
||||
...Object.values(HookEventName).flatMap((event) =>
|
||||
registry
|
||||
.getHooksForEvent(event)
|
||||
.map((entry) =>
|
||||
expose(native.get(entry.config)!, entry.config, true),
|
||||
),
|
||||
),
|
||||
...sessionHooks.map(({ definition, config }) =>
|
||||
expose(definition, config, false),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
hasHolds(runtimeSessionId: string): boolean {
|
||||
return [...this.operations.values()].some(
|
||||
(entry) =>
|
||||
entry.runtimeSessionId === runtimeSessionId &&
|
||||
entry.view.state !== 'settled',
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closing = true;
|
||||
this.shutdown.abort();
|
||||
for (const entry of this.operations.values()) entry.controller.abort();
|
||||
await Promise.all([...this.operations.values()].map((entry) => entry.done));
|
||||
}
|
||||
|
||||
private async execute(
|
||||
entry: Operation,
|
||||
definition: ManagedHookDefinition,
|
||||
control: ManagedHookExecute,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
let dispatched = false;
|
||||
let functionCompletion: Promise<boolean> | undefined;
|
||||
try {
|
||||
let config: HookConfig;
|
||||
if (definition.config.type === 'function') {
|
||||
const handler = definition.handler!;
|
||||
let registered: Record<string, unknown>;
|
||||
try {
|
||||
const module: Record<string, unknown> = await import(
|
||||
pathToFileURL(handler.modulePath).href
|
||||
);
|
||||
registered = object(module[handler.exportName]);
|
||||
if (
|
||||
registered['handlerRevision'] !== handler.handlerRevision ||
|
||||
typeof registered['callback'] !== 'function' ||
|
||||
(registered['onHookSuccess'] !== undefined &&
|
||||
typeof registered['onHookSuccess'] !== 'function')
|
||||
)
|
||||
throw new Error('handler revision unavailable');
|
||||
} catch {
|
||||
throw new ManagedHookError('managed_hook_handler_unavailable');
|
||||
}
|
||||
const callback = registered['callback'] as FunctionHookCallback;
|
||||
config = {
|
||||
...definition.config,
|
||||
type: HookType.Function,
|
||||
id: handler.handlerId,
|
||||
callback: (input, context) => {
|
||||
const pending = Promise.resolve().then(() =>
|
||||
callback(input, context),
|
||||
);
|
||||
functionCompletion = pending.then(
|
||||
() => true,
|
||||
() => true,
|
||||
);
|
||||
return pending;
|
||||
},
|
||||
errorMessage: 'Managed hook handler failed.',
|
||||
...(registered['onHookSuccess']
|
||||
? {
|
||||
onHookSuccess: registered[
|
||||
'onHookSuccess'
|
||||
] as FunctionHookConfig['onHookSuccess'],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} else if (definition.config.type === 'command') {
|
||||
config = { ...definition.config, async: false };
|
||||
} else {
|
||||
config = definition.config;
|
||||
}
|
||||
const currentDirectory = await this.sessionDirectory(
|
||||
entry.runtimeSessionId,
|
||||
);
|
||||
if (
|
||||
!this.gates.admits(
|
||||
control.sessionKey,
|
||||
control.operationId,
|
||||
'execute',
|
||||
Date.now(),
|
||||
) ||
|
||||
currentDirectory !== directory
|
||||
)
|
||||
throw new ManagedHookError('managed_hook_grant_invalid');
|
||||
if (entry.controller.signal.aborted) {
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
result: { success: false, outcome: 'cancelled', duration: 0 },
|
||||
};
|
||||
return;
|
||||
}
|
||||
const runner = new HookRunner(
|
||||
config.type === HookType.Http
|
||||
? [interpolateUrl(config.url, config.allowedEnvVars ?? [])]
|
||||
: undefined,
|
||||
);
|
||||
dispatched = true;
|
||||
const result = await runner.executeHook(
|
||||
config,
|
||||
definition.eventName,
|
||||
{
|
||||
...control.input,
|
||||
cwd: directory,
|
||||
session_id: control.sessionKey.sessionId,
|
||||
},
|
||||
{
|
||||
signal: entry.controller.signal,
|
||||
...(Array.isArray(
|
||||
(control.input as unknown as Record<string, unknown>)['messages'],
|
||||
)
|
||||
? {
|
||||
messages: (
|
||||
control.input as unknown as {
|
||||
messages: Array<Record<string, unknown>>;
|
||||
}
|
||||
).messages,
|
||||
}
|
||||
: {}),
|
||||
...('tool_use_id' in control.input &&
|
||||
typeof control.input.tool_use_id === 'string'
|
||||
? { toolUseID: control.input.tool_use_id }
|
||||
: {}),
|
||||
},
|
||||
config.type === HookType.Command
|
||||
? {
|
||||
waitForProcessTree: true,
|
||||
cgroupRoot: process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT'],
|
||||
environment: {
|
||||
PATH: process.env['PATH'] ?? '',
|
||||
HOME: directory,
|
||||
USERPROFILE: directory,
|
||||
...(process.env['SystemRoot']
|
||||
? { SYSTEMROOT: process.env['SystemRoot'] }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: config.type === HookType.Http
|
||||
? {
|
||||
trackHttpRequest: true,
|
||||
// Preserve the response as completion proof after user cancel.
|
||||
httpRequestSignal: this.shutdown.signal,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (result.error instanceof HookCommandIsolationUnavailableError) {
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
error: { code: 'managed_hook_command_isolation_unavailable' },
|
||||
};
|
||||
return;
|
||||
}
|
||||
let functionSettled = true;
|
||||
if (
|
||||
config.type === HookType.Function &&
|
||||
(result.outcome === 'timeout' || result.outcome === 'cancelled') &&
|
||||
functionCompletion
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
functionSettled = await Promise.race([
|
||||
functionCompletion,
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve(false),
|
||||
FUNCTION_SETTLEMENT_GRACE_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const uncertain =
|
||||
(config.type === HookType.Command &&
|
||||
result.processTreeDrained !== true) ||
|
||||
(config.type === HookType.Function && !functionSettled) ||
|
||||
(config.type === HookType.Http &&
|
||||
(!result.httpRequestState ||
|
||||
result.httpRequestState === 'outcome_unknown'));
|
||||
if (uncertain) {
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: 'outcome_unknown',
|
||||
error: { code: 'managed_hook_outcome_unknown' },
|
||||
};
|
||||
return;
|
||||
}
|
||||
this.settle(entry, result);
|
||||
} catch (error) {
|
||||
if (
|
||||
!dispatched &&
|
||||
error instanceof ManagedHookError &&
|
||||
error.code === 'managed_hook_grant_invalid'
|
||||
) {
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
duration: 0,
|
||||
error: 'Managed hook authorization expired before dispatch.',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!dispatched && !(error instanceof ManagedHookError)) {
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
duration: 0,
|
||||
error: 'Managed hook failed before dispatch.',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
entry.view = {
|
||||
operationId: control.operationId,
|
||||
state: dispatched ? 'outcome_unknown' : 'settled',
|
||||
error: {
|
||||
code:
|
||||
error instanceof ManagedHookError
|
||||
? error.code
|
||||
: 'managed_hook_execution_failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private settle(entry: Operation, result: HookExecutionResult) {
|
||||
const view: ManagedHookOperationView = {
|
||||
operationId: entry.view.operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: result.success,
|
||||
outcome:
|
||||
result.outcome ?? (result.success ? 'success' : 'non_blocking_error'),
|
||||
duration: result.duration,
|
||||
...(result.output ? { output: result.output } : {}),
|
||||
...(result.stdout ? { stdout: result.stdout } : {}),
|
||||
...(result.stderr ? { stderr: result.stderr } : {}),
|
||||
...(result.error ? { error: 'Managed hook execution failed.' } : {}),
|
||||
},
|
||||
};
|
||||
entry.view =
|
||||
Buffer.byteLength(JSON.stringify(view)) <= MAX_BYTES
|
||||
? view
|
||||
: {
|
||||
operationId: entry.view.operationId,
|
||||
state: 'settled',
|
||||
result: {
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
duration: result.duration,
|
||||
error: 'Managed hook output exceeds the size limit.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './managed-runtime-tool-executor.js';
|
||||
import { parseHostedFileHistoryState } from './hosted-file-history-protocol.js';
|
||||
import { registerManagedRuntimeToolRoutes } from './managed-runtime-tool-routes.js';
|
||||
import type { ManagedHookRuntime } from './managed-hook-runtime.js';
|
||||
|
||||
vi.mock('node:fs/promises', async (original) => ({
|
||||
...(await original<typeof import('node:fs/promises')>()),
|
||||
@@ -455,6 +456,47 @@ it('rejects symlinks, traversal, foreign owners and mutation without preparation
|
||||
expect(await readFile(path.join(root, 'outside'), 'utf8')).toBe('decoy');
|
||||
});
|
||||
|
||||
it('admits history preparation beside async Hooks while retaining their close and undo hold', async () => {
|
||||
const runtime = 'hooks-activation-original';
|
||||
const holds = vi.fn(() => true);
|
||||
const executor = new ManagedToolExecutor(
|
||||
async () => createManagedToolSet(workspace, runtime),
|
||||
undefined,
|
||||
undefined,
|
||||
{ hasHolds: holds } as unknown as ManagedHookRuntime,
|
||||
);
|
||||
const control = (
|
||||
operation: Parameters<typeof executor.controlFileHistory>[2],
|
||||
) => executor.controlFileHistory(owner, runtime, operation);
|
||||
await control({ kind: 'raw-file-history', action: 'bind', state: null });
|
||||
await control({
|
||||
kind: 'raw-file-history',
|
||||
action: 'prepare',
|
||||
promptId: 'original-prompt',
|
||||
paths: ['new'],
|
||||
});
|
||||
await expect(
|
||||
control({ kind: 'raw-file-history', action: 'snapshot' }),
|
||||
).resolves.toMatchObject({ ownerSessionId: owner });
|
||||
expect(() => executor.closeSessionAdmission(runtime)).toThrow(
|
||||
'unfinished work',
|
||||
);
|
||||
await expect(
|
||||
control({
|
||||
kind: 'raw-file-history',
|
||||
action: 'rewind',
|
||||
promptId: 'original-prompt',
|
||||
}),
|
||||
).rejects.toThrow('idle');
|
||||
holds.mockReturnValue(false);
|
||||
await control({
|
||||
kind: 'raw-file-history',
|
||||
action: 'rewind',
|
||||
promptId: 'original-prompt',
|
||||
});
|
||||
executor.closeSessionAdmission(runtime);
|
||||
});
|
||||
|
||||
it('wires history to the real raw executor and preserves its original invocation', async () => {
|
||||
const runtime = randomUUID();
|
||||
const tools = createManagedToolSet(workspace, runtime);
|
||||
|
||||
@@ -937,6 +937,15 @@ it('validates raw file history ownership, paths and the closed operation shape',
|
||||
session,
|
||||
),
|
||||
).toMatchObject({ state });
|
||||
const prepare = {
|
||||
kind: 'raw-file-history',
|
||||
action: 'prepare',
|
||||
promptId: 'original-prompt',
|
||||
paths: ['a'],
|
||||
};
|
||||
expect(parseManagedRuntimeProviderOperation(prepare, session)).toEqual(
|
||||
prepare,
|
||||
);
|
||||
for (const operation of [
|
||||
{
|
||||
kind: 'raw-file-history',
|
||||
@@ -946,7 +955,7 @@ it('validates raw file history ownership, paths and the closed operation shape',
|
||||
{
|
||||
kind: 'raw-file-history',
|
||||
action: 'prepare',
|
||||
promptId: 'other',
|
||||
promptId: '',
|
||||
paths: ['a'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -208,11 +208,8 @@ export function parseManagedRuntimeProviderOperation(
|
||||
parseHostedFileHistoryState(op['state'], session.harnessSessionId);
|
||||
} else if (action === 'prepare') {
|
||||
keys(op, ['kind', 'action', 'promptId', 'paths']);
|
||||
if (
|
||||
op['promptId'] !== session.runtimeSessionId ||
|
||||
!Array.isArray(op['paths']) ||
|
||||
!op['paths'].length
|
||||
)
|
||||
parseManagedToolFileHistoryPromptId(op['promptId']);
|
||||
if (!Array.isArray(op['paths']) || !op['paths'].length)
|
||||
throw new ManagedRuntimeProviderProtocolError();
|
||||
op['paths'].forEach(historyPath);
|
||||
} else if (action === 'rewind') {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
ToolResult,
|
||||
} from '@qwen-code/qwen-code-core/tools/tools.js';
|
||||
import { MANAGED_RUNTIME_TOOL_RESULT_BODY_LIMIT_BYTES } from './managed-runtime-attestation-contract.js';
|
||||
import type { ManagedHookRuntime } from './managed-hook-runtime.js';
|
||||
import { MANAGED_MCP_TOOL } from '@qwen-code/qwen-code-core/managed-runtime/managed-mcp-protocol.js';
|
||||
import type { ManagedMcpOperationView } from '@qwen-code/qwen-code-core/managed-runtime/managed-mcp-protocol.js';
|
||||
import {
|
||||
@@ -211,7 +212,9 @@ export class ManagedToolExecutor {
|
||||
this.assertLegacySession(sessionId);
|
||||
if (
|
||||
this.closing ||
|
||||
this.hasActiveSession(sessionId) ||
|
||||
this.hasActiveToolSession(sessionId) ||
|
||||
(operation.action === 'rewind' &&
|
||||
this.hooks?.hasHolds(sessionId) === true) ||
|
||||
[...this.entries.values()].some(
|
||||
(entry) =>
|
||||
entry.reference.sessionId === sessionId && entry.state === 'unknown',
|
||||
@@ -276,6 +279,7 @@ export class ManagedToolExecutor {
|
||||
private readonly toolsFor: ManagedToolSetResolver,
|
||||
private readonly capturePublisher?: ManagedShellCapturePublisher,
|
||||
private readonly mcp?: ManagedMcpRuntime,
|
||||
private readonly hooks?: ManagedHookRuntime,
|
||||
) {}
|
||||
|
||||
static forWorkspace(workspaceCwd: string, runtimeInstanceId: string) {
|
||||
@@ -667,6 +671,13 @@ export class ManagedToolExecutor {
|
||||
|
||||
/** Read-only lookup; never creates or advances an invocation. */
|
||||
hasActiveSession(sessionId: string): boolean {
|
||||
return (
|
||||
this.hooks?.hasHolds(sessionId) === true ||
|
||||
this.hasActiveToolSession(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
private hasActiveToolSession(sessionId: string): boolean {
|
||||
if (this.historyControls.has(sessionId)) return true;
|
||||
return (
|
||||
this.mcp?.hasHolds(sessionId) === true ||
|
||||
@@ -735,7 +746,7 @@ export class ManagedToolExecutor {
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closing = true;
|
||||
await this.mcp?.close();
|
||||
await Promise.all([this.mcp?.close(), this.hooks?.close()]);
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.state === 'executing' || entry.state === 'cancel_requested') {
|
||||
entry.controller.abort();
|
||||
|
||||
@@ -167,8 +167,23 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
'packages/cli/src/serve/managed-context-worker.ts',
|
||||
{
|
||||
reason:
|
||||
'Managed Runtime startup selects its deployment-owned MCP manifest from the process environment; server definitions are then scoped by tenant and workspace.',
|
||||
accesses: { 'key:QWEN_MANAGED_MCP_CONFIG': 1 },
|
||||
'Managed Runtime startup selects its deployment-owned MCP and Hook manifests from the process environment; definitions are then scoped by tenant and workspace.',
|
||||
accesses: {
|
||||
'key:QWEN_MANAGED_HOOK_CONFIG': 1,
|
||||
'key:QWEN_MANAGED_MCP_CONFIG': 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/managed-hook-runtime.ts',
|
||||
{
|
||||
reason:
|
||||
'Hook commands use the Runtime host PATH and Windows SystemRoot for executable lookup and OS startup, and the deployment-owned cgroup root for process-tree isolation; HOME and USERPROFILE come from the verified Session directory.',
|
||||
accesses: {
|
||||
'key:PATH': 1,
|
||||
'key:QWEN_MANAGED_HOOK_CGROUP_ROOT': 1,
|
||||
'key:SystemRoot': 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -313,6 +313,7 @@ vi.mock('../hooks/index.js', () => {
|
||||
HookSystemMock.prototype.runtimeId = 'test-hook-runtime';
|
||||
HookSystemMock.prototype.initialize = vi.fn().mockResolvedValue(undefined);
|
||||
HookSystemMock.prototype.hasHooksForEvent = vi.fn().mockReturnValue(false);
|
||||
HookSystemMock.prototype.isManaged = vi.fn().mockReturnValue(false);
|
||||
HookSystemMock.prototype.getAllHooks = vi.fn().mockReturnValue([]);
|
||||
return {
|
||||
HookSystem: HookSystemMock,
|
||||
@@ -5676,6 +5677,22 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.getFileCheckpointingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('installs a managed dispatcher while ambient Hook discovery is disabled', async () => {
|
||||
const config = new Config({ ...baseParams });
|
||||
const dispatcher = {
|
||||
hasHooksForEvent: () => false,
|
||||
execute: vi.fn(),
|
||||
};
|
||||
await config.initialize({
|
||||
skipHooks: true,
|
||||
skipMcpDiscovery: true,
|
||||
skipSkillManager: true,
|
||||
managedHookDispatcher: dispatcher,
|
||||
});
|
||||
expect(HookSystem).toHaveBeenCalledWith(config, dispatcher);
|
||||
expect(config.getHookSystem()).toBeDefined();
|
||||
});
|
||||
|
||||
it('warms tools strictly by default and leniently when lenientToolWarmup is set', async () => {
|
||||
// Regression guard for the read-only transcript-replay path: a Config
|
||||
// that skips the SkillManager must warm tools leniently, or warmAll()
|
||||
|
||||
@@ -209,6 +209,7 @@ import {
|
||||
createInstructionsLoadedCallback,
|
||||
} from '../hooks/index.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ManagedHookDispatcher } from '../hooks/hookEventHandler.js';
|
||||
import {
|
||||
MessageBusType,
|
||||
type HookExecutionRequest,
|
||||
@@ -2220,6 +2221,7 @@ export interface ConfigInitializeOptions {
|
||||
* helpers use this to avoid loading or subscribing user/workspace hooks.
|
||||
*/
|
||||
skipHooks?: boolean;
|
||||
managedHookDispatcher?: ManagedHookDispatcher;
|
||||
/**
|
||||
* Skip SkillManager creation and file watching. Read-only replay helpers do
|
||||
* not need skill discovery and must not start long-lived watchers.
|
||||
@@ -4105,8 +4107,12 @@ export class Config {
|
||||
|
||||
// Bare mode and read-only replay helpers skip all hook loading and execution.
|
||||
recordStartupEvent('config_initialize_hooks_start');
|
||||
if (!options?.skipHooks && !this.getDisableAllHooks()) {
|
||||
this.hookSystem = new HookSystem(this);
|
||||
if (
|
||||
!this.shellExecutionSandbox &&
|
||||
(options?.managedHookDispatcher ||
|
||||
(!options?.skipHooks && !this.getDisableAllHooks()))
|
||||
) {
|
||||
this.hookSystem = new HookSystem(this, options?.managedHookDispatcher);
|
||||
await this.hookSystem.initialize();
|
||||
this.debugLogger.debug('Hook system initialized');
|
||||
|
||||
@@ -10634,6 +10640,7 @@ export class Config {
|
||||
*/
|
||||
getDisableAllHooks(): boolean {
|
||||
if (this.shellExecutionSandbox) return true;
|
||||
if (this.hookSystem?.isManaged()) return false;
|
||||
return this.disableAllHooks || this.getBareMode() || this.isSafeMode();
|
||||
}
|
||||
|
||||
|
||||
@@ -2267,6 +2267,21 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a budget opened before prompt Hooks when the UserQuery starts', async () => {
|
||||
turns.beginTurn({
|
||||
promptId: 'p1',
|
||||
sessionId: 'test-session-id',
|
||||
budget: 5_000,
|
||||
outputTokensAtTurnStart: 100,
|
||||
});
|
||||
sessionTokens.mockReturnValue(150);
|
||||
await send([{ text: 'Hook rewritten prompt +10k' }], 'p1');
|
||||
expect(turns.current('test-session-id')).toMatchObject({
|
||||
budget: 5_000,
|
||||
outputTokensAtTurnStart: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the turn alone for a tool result and for a side question', async () => {
|
||||
await send([{ text: 'fan out +500k' }], 'p1');
|
||||
sessionTokens.mockReturnValue(9_999);
|
||||
|
||||
@@ -3394,7 +3394,8 @@ export class LlmClient {
|
||||
if (!turnBudget) return;
|
||||
const sessionId = this.config.getSessionId();
|
||||
if (
|
||||
messageType === SendMessageType.Retry &&
|
||||
(messageType === SendMessageType.Retry ||
|
||||
messageType === SendMessageType.UserQuery) &&
|
||||
turnBudget.current(sessionId)?.promptId === promptId
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { HookCommandCgroup } from './hook-command-cgroup.js';
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'hook-launcher-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('managed command launcher environment', () => {
|
||||
it('keeps environment values out of argv and applies them only after membership', async () => {
|
||||
const preload = join(directory, 'preload.cjs');
|
||||
const marker = join(directory, 'preloaded');
|
||||
await writeFile(
|
||||
preload,
|
||||
`const fs = require('node:fs');
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, fs.readFileSync(${JSON.stringify(join(directory, 'cgroup.procs'))}, 'utf8') + '\\n');`,
|
||||
);
|
||||
// This exercises the real launcher with a membership-file stand-in,
|
||||
// without claiming that the temporary directory is a cgroup.
|
||||
const unit: HookCommandCgroup = Reflect.construct(HookCommandCgroup, [
|
||||
directory,
|
||||
]);
|
||||
const env = {
|
||||
SERVICE_TOKEN: 'fake-secret-"quoted"\n非真实凭据',
|
||||
NODE_OPTIONS: `--require ${JSON.stringify(preload)}`,
|
||||
PATH: '/deployment-command-path',
|
||||
};
|
||||
const launch = unit.launch(
|
||||
process.execPath,
|
||||
[
|
||||
'--eval',
|
||||
'process.stdout.write(JSON.stringify({ token: process.env.SERVICE_TOKEN, path: process.env.PATH }));',
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(launch.args.join('\n')).not.toContain('fake-secret');
|
||||
expect(launch.env).not.toHaveProperty('NODE_OPTIONS');
|
||||
const result = spawnSync(launch.executable, launch.args, {
|
||||
env: launch.env,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
token: env.SERVICE_TOKEN,
|
||||
path: env.PATH,
|
||||
});
|
||||
expect(await readFile(marker, 'utf8')).toBe(`${result.pid}\n`);
|
||||
});
|
||||
|
||||
it('does not apply command environment when membership fails', async () => {
|
||||
const preload = join(directory, 'preload.cjs');
|
||||
const marker = join(directory, 'forbidden');
|
||||
await writeFile(
|
||||
preload,
|
||||
`require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`,
|
||||
);
|
||||
const unit: HookCommandCgroup = Reflect.construct(HookCommandCgroup, [
|
||||
join(directory, 'missing'),
|
||||
]);
|
||||
const launch = unit.launch(process.execPath, ['--eval', ''], {
|
||||
NODE_OPTIONS: `--require ${JSON.stringify(preload)}`,
|
||||
});
|
||||
const result = spawnSync(launch.executable, launch.args, {
|
||||
env: launch.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.output[3]).toBe('unavailable\n');
|
||||
await expect(readFile(marker, 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmdirSync,
|
||||
statfsSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
|
||||
export class HookCommandIsolationUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Managed command hooks require a delegated Linux cgroup v2 directory.',
|
||||
);
|
||||
this.name = 'HookCommandIsolationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHER = String.raw`
|
||||
const { writeFileSync, writeSync } = require('node:fs');
|
||||
const { spawn } = require('node:child_process');
|
||||
const [group, executable, args] = process.argv.slice(1);
|
||||
try {
|
||||
writeFileSync(group + '/cgroup.procs', String(process.pid));
|
||||
} catch {
|
||||
writeSync(3, 'unavailable\n');
|
||||
process.exit(1);
|
||||
}
|
||||
// No deployment command or environment is evaluated before membership.
|
||||
const child = spawn(executable, JSON.parse(args), {
|
||||
env: JSON.parse(process.env.QWEN_HOOK_COMMAND_ENV),
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
});
|
||||
child.on('error', () => process.exit(1));
|
||||
child.on('exit', (code) => process.exit(code ?? 1));
|
||||
`;
|
||||
|
||||
export class HookCommandCgroup {
|
||||
private constructor(readonly directory: string) {}
|
||||
|
||||
static create(root: string | undefined): HookCommandCgroup {
|
||||
let directory: string | undefined;
|
||||
try {
|
||||
if (process.platform !== 'linux' || !root || !isAbsolute(root))
|
||||
throw new HookCommandIsolationUnavailableError();
|
||||
const resolved = realpathSync(root);
|
||||
if (statfsSync(resolved).type !== 0x63677270)
|
||||
throw new HookCommandIsolationUnavailableError();
|
||||
if (
|
||||
readFileSync(join(resolved, 'cgroup.type'), 'utf8').trim() !== 'domain'
|
||||
)
|
||||
throw new HookCommandIsolationUnavailableError();
|
||||
directory = join(resolved, `qwen-hook-${randomUUID()}`);
|
||||
mkdirSync(directory, { mode: 0o700 });
|
||||
const unit = new HookCommandCgroup(directory);
|
||||
if (!unit.empty()) throw new HookCommandIsolationUnavailableError();
|
||||
for (const file of ['cgroup.procs', 'cgroup.kill']) {
|
||||
const fd = openSync(join(directory, file), 'w');
|
||||
closeSync(fd);
|
||||
}
|
||||
return unit;
|
||||
} catch {
|
||||
if (directory) {
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch {
|
||||
// A nonempty unit must remain available to the deployment owner.
|
||||
}
|
||||
}
|
||||
throw new HookCommandIsolationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
launch(executable: string, args: string[], env: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
executable: process.execPath,
|
||||
args: [
|
||||
'--input-type=commonjs',
|
||||
'--eval',
|
||||
LAUNCHER,
|
||||
this.directory,
|
||||
executable,
|
||||
JSON.stringify(args),
|
||||
],
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'C.UTF-8',
|
||||
QWEN_HOOK_COMMAND_ENV: JSON.stringify(env),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
empty(): boolean {
|
||||
try {
|
||||
return /^populated 0$/m.test(
|
||||
readFileSync(join(this.directory, 'cgroup.events'), 'utf8'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForEmpty(
|
||||
timeoutMs: number,
|
||||
stopped?: () => boolean,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!this.empty()) {
|
||||
if (stopped?.()) return false;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
writeFileSync(join(this.directory, 'cgroup.kill'), '1');
|
||||
}
|
||||
|
||||
async terminate(graceMs: number): Promise<void> {
|
||||
// Membership survives setsid and double-fork. cgroup.kill closes the fork
|
||||
// race left by the graceful per-process signal pass.
|
||||
for (const line of readFileSync(
|
||||
join(this.directory, 'cgroup.procs'),
|
||||
'utf8',
|
||||
).split('\n')) {
|
||||
const pid = Number(line);
|
||||
if (!Number.isSafeInteger(pid) || pid <= 1) continue;
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
// The member may already have exited.
|
||||
}
|
||||
}
|
||||
if (!(await this.waitForEmpty(graceMs))) this.kill();
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
if (this.empty()) rmdirSync(this.directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import {
|
||||
HookCommandCgroup,
|
||||
HookCommandIsolationUnavailableError,
|
||||
} from './hook-command-cgroup.js';
|
||||
import { HookEventName, HookType, type HookInput } from './types.js';
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:child_process')>();
|
||||
return { ...original, spawn: vi.fn(original.spawn) };
|
||||
});
|
||||
|
||||
const cgroupRoot = process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT'];
|
||||
const hasCgroup = process.platform === 'linux' && Boolean(cgroupRoot);
|
||||
let cwd: string;
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(tmpdir(), 'managed-hook-process-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
function input(eventName = HookEventName.PreToolUse): HookInput {
|
||||
return {
|
||||
session_id: 'session',
|
||||
transcript_path: '',
|
||||
cwd,
|
||||
hook_event_name: eventName,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
async function expectNotLive(pid: number) {
|
||||
try {
|
||||
const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
|
||||
expect(stat.split(') ')[1]?.[0]).toBe('Z');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
describe('managed command admission', () => {
|
||||
it('does not drain a removed unit after a launcher spawn error has settled', async () => {
|
||||
const child = childProcess.spawn(process.execPath, ['--eval', ''], {
|
||||
cwd: path.join(cwd, 'missing'),
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => child.once('close', resolve));
|
||||
vi.mocked(childProcess.spawn).mockReturnValueOnce(child);
|
||||
const waitForEmpty = vi.fn(async () => false);
|
||||
const terminate = vi.fn(async () => undefined);
|
||||
const remove = vi.fn();
|
||||
vi.spyOn(HookCommandCgroup, 'create').mockReturnValue({
|
||||
directory: cwd,
|
||||
launch: () => ({
|
||||
executable: process.execPath,
|
||||
args: [],
|
||||
env: { PATH: '', LANG: 'C.UTF-8', QWEN_HOOK_COMMAND_ENV: '{}' },
|
||||
}),
|
||||
empty: () => true,
|
||||
waitForEmpty,
|
||||
kill: vi.fn(),
|
||||
terminate,
|
||||
remove,
|
||||
});
|
||||
const result = await new HookRunner().executeHook(
|
||||
{ type: HookType.Command, command: 'printf forbidden', timeout: 0.2 },
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot: cwd },
|
||||
);
|
||||
await closed;
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
processTreeDrained: true,
|
||||
error: { code: 'ENOENT' },
|
||||
});
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
expect(waitForEmpty).not.toHaveBeenCalled();
|
||||
expect(terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, '/definitely-missing-qwen-cgroup'])(
|
||||
'rejects unavailable isolation before command execution (%s)',
|
||||
async (root) => {
|
||||
const result = await new HookRunner().executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'printf effect > forbidden-effect',
|
||||
timeout: 5000,
|
||||
},
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot: root },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
processTreeDrained: true,
|
||||
});
|
||||
expect(result.error).toBeInstanceOf(HookCommandIsolationUnavailableError);
|
||||
await expect(
|
||||
readFile(path.join(cwd, 'forbidden-effect')),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
it('rejects an ordinary directory instead of accepting fake cgroup evidence', async () => {
|
||||
const result = await new HookRunner().executeHook(
|
||||
{ type: HookType.Command, command: 'printf effect > forbidden-effect' },
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot: cwd },
|
||||
);
|
||||
expect(result.error).toBeInstanceOf(HookCommandIsolationUnavailableError);
|
||||
await expect(
|
||||
readFile(path.join(cwd, 'forbidden-effect')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe.skipIf(!hasCgroup)('managed command process receipt', () => {
|
||||
it('waits for a detached descendant after the original process group exits', async () => {
|
||||
await writeFile(
|
||||
path.join(cwd, 'child.mjs'),
|
||||
`import { existsSync, writeFileSync } from 'node:fs';
|
||||
const poll = setInterval(() => {
|
||||
if (!existsSync('release')) return;
|
||||
writeFileSync('escaped-effect', 'done');
|
||||
clearInterval(poll);
|
||||
}, 10);
|
||||
setTimeout(() => process.exit(), 5000).unref();`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, 'launch.mjs'),
|
||||
`import { spawn } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const child = spawn(process.execPath, ['child.mjs'], {
|
||||
detached: true, stdio: 'ignore',
|
||||
});
|
||||
writeFileSync('escaped.pid', String(child.pid));
|
||||
child.unref();`,
|
||||
);
|
||||
let pid = 0;
|
||||
try {
|
||||
let completed = false;
|
||||
const pending = new HookRunner()
|
||||
.executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: `"${process.execPath}" launch.mjs`,
|
||||
timeout: 5000,
|
||||
},
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot },
|
||||
)
|
||||
.then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
pid = Number(await readFile(path.join(cwd, 'escaped.pid'), 'utf8'));
|
||||
expect(pid).toBeGreaterThan(1);
|
||||
});
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(completed).toBe(false);
|
||||
await writeFile(path.join(cwd, 'release'), '');
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readFile(path.join(cwd, 'escaped-effect'), 'utf8')).toBe(
|
||||
'done',
|
||||
);
|
||||
});
|
||||
expect(await pending).toMatchObject({
|
||||
success: true,
|
||||
processTreeDrained: true,
|
||||
});
|
||||
} finally {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// The detached descendant already exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it('waits for descendants even when the root and output streams already closed', async () => {
|
||||
let completed = false;
|
||||
const pending = new HookRunner()
|
||||
.executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: '(sleep 0.2; printf done > finished) >/dev/null 2>&1 &',
|
||||
timeout: 5000,
|
||||
},
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot },
|
||||
)
|
||||
.then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(completed).toBe(false);
|
||||
expect(await pending).toMatchObject({
|
||||
success: true,
|
||||
processTreeDrained: true,
|
||||
});
|
||||
expect(await readFile(path.join(cwd, 'finished'), 'utf8')).toBe('done');
|
||||
});
|
||||
it('waits beyond SIGKILL escalation before certifying cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = new HookRunner().executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command:
|
||||
'trap "" TERM; printf "%s" $$ > pid; while :; do sleep 1; done',
|
||||
timeout: 10_000,
|
||||
},
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
controller.signal,
|
||||
{ waitForProcessTree: true, cgroupRoot },
|
||||
);
|
||||
let pid = 0;
|
||||
await vi.waitFor(async () => {
|
||||
pid = Number(await readFile(path.join(cwd, 'pid'), 'utf8'));
|
||||
expect(pid).toBeGreaterThan(1);
|
||||
});
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
outcome: 'cancelled',
|
||||
processTreeDrained: true,
|
||||
});
|
||||
await expectNotLive(pid);
|
||||
});
|
||||
it('kills a detached descendant that ignores SIGTERM before cancellation settles', async () => {
|
||||
await writeFile(
|
||||
path.join(cwd, 'holder.mjs'),
|
||||
`import { writeFileSync, existsSync } from 'node:fs';
|
||||
process.on('SIGTERM', () => {});
|
||||
writeFileSync('detached-ready', String(process.pid));
|
||||
setInterval(() => {
|
||||
if (existsSync('release')) writeFileSync('forbidden-effect', 'alive');
|
||||
}, 10);`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, 'launch.mjs'),
|
||||
`import { spawn } from 'node:child_process';
|
||||
spawn(process.execPath, ['holder.mjs'], {
|
||||
detached: true, stdio: 'ignore',
|
||||
}).unref();`,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
let pid = 0;
|
||||
try {
|
||||
const pending = new HookRunner().executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: `"${process.execPath}" launch.mjs`,
|
||||
timeout: 10_000,
|
||||
},
|
||||
HookEventName.PreToolUse,
|
||||
input(),
|
||||
controller.signal,
|
||||
{ waitForProcessTree: true, cgroupRoot },
|
||||
);
|
||||
await vi.waitFor(async () => {
|
||||
pid = Number(await readFile(path.join(cwd, 'detached-ready'), 'utf8'));
|
||||
expect(pid).toBeGreaterThan(1);
|
||||
});
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
outcome: 'cancelled',
|
||||
processTreeDrained: true,
|
||||
});
|
||||
await expectNotLive(pid);
|
||||
await writeFile(path.join(cwd, 'release'), '');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await expect(
|
||||
readFile(path.join(cwd, 'forbidden-effect')),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// A failed assertion must not leave the test descendant alive.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it('keeps display hooks owned and captures their final result', async () => {
|
||||
const result = await new HookRunner().executeHook(
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'printf "{\\"continue\\":true}"',
|
||||
timeout: 5000,
|
||||
},
|
||||
HookEventName.MessageDisplay,
|
||||
input(HookEventName.MessageDisplay),
|
||||
undefined,
|
||||
{ waitForProcessTree: true, cgroupRoot },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
processTreeDrained: true,
|
||||
output: { continue: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHookOutput, HookEventName } from './types.js';
|
||||
import type {
|
||||
HookInput,
|
||||
HookOutput,
|
||||
PreToolUseInput,
|
||||
UserPromptExpansionInput,
|
||||
UserPromptSubmitInput,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Apply hook output to modify input for the next hook in sequential execution
|
||||
*/
|
||||
export function applyHookOutputToInput(
|
||||
originalInput: HookInput,
|
||||
hookOutput: HookOutput,
|
||||
eventName: HookEventName,
|
||||
): HookInput {
|
||||
// Create a copy of the original input
|
||||
const modifiedInput = { ...originalInput };
|
||||
|
||||
// Apply modifications based on hook output and event type
|
||||
if (hookOutput.hookSpecificOutput) {
|
||||
switch (eventName) {
|
||||
case HookEventName.UserPromptSubmit:
|
||||
{
|
||||
const additionalContext =
|
||||
hookOutput.hookSpecificOutput['additionalContext'];
|
||||
if (
|
||||
typeof additionalContext === 'string' &&
|
||||
additionalContext &&
|
||||
'prompt' in modifiedInput
|
||||
) {
|
||||
(modifiedInput as UserPromptSubmitInput).prompt +=
|
||||
'\n\n' + additionalContext;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case HookEventName.UserPromptExpansion:
|
||||
{
|
||||
const additionalContext = createHookOutput(
|
||||
eventName,
|
||||
hookOutput,
|
||||
).getAdditionalContext();
|
||||
if (additionalContext && 'prompt' in modifiedInput) {
|
||||
(modifiedInput as UserPromptExpansionInput).prompt +=
|
||||
'\n\n' + additionalContext;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case HookEventName.PreToolUse:
|
||||
if ('tool_input' in hookOutput.hookSpecificOutput) {
|
||||
const newToolInput = hookOutput.hookSpecificOutput[
|
||||
'tool_input'
|
||||
] as Record<string, unknown>;
|
||||
if (newToolInput && 'tool_input' in modifiedInput) {
|
||||
(modifiedInput as PreToolUseInput).tool_input = {
|
||||
...(modifiedInput as PreToolUseInput).tool_input,
|
||||
...newToolInput,
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For other events, no special input modification is needed
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedInput;
|
||||
}
|
||||
@@ -91,6 +91,15 @@ const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
*/
|
||||
let hookInvocationSerial = 0;
|
||||
|
||||
export interface ManagedHookDispatcher {
|
||||
hasHooksForEvent(eventName: string): boolean;
|
||||
execute(
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult>;
|
||||
}
|
||||
|
||||
/** Longest prompt text used as a hook's display name. */
|
||||
const HOOK_DISPLAY_NAME_MAX_LENGTH = 80;
|
||||
|
||||
@@ -216,6 +225,7 @@ export class HookEventHandler {
|
||||
sessionHooksManager: SessionHooksManager,
|
||||
messagesProvider?: MessagesProvider,
|
||||
private readonly runtimeId: string = randomUUID(),
|
||||
private readonly managedDispatcher?: ManagedHookDispatcher,
|
||||
) {
|
||||
this.config = config;
|
||||
this.hookPlanner = hookPlanner;
|
||||
@@ -935,6 +945,17 @@ export class HookEventHandler {
|
||||
context?: HookEventContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult> {
|
||||
if (this.managedDispatcher) {
|
||||
if (!this.managedDispatcher.hasHooksForEvent(eventName)) {
|
||||
return { success: true, allOutputs: [], errors: [], totalDuration: 0 };
|
||||
}
|
||||
const messages = this.messagesProvider?.();
|
||||
const managedInput = {
|
||||
...input,
|
||||
...(messages ? { messages: structuredClone(messages) } : {}),
|
||||
};
|
||||
return this.managedDispatcher.execute(eventName, managedInput, signal);
|
||||
}
|
||||
const failClosedResult: AggregatedHookResult = {
|
||||
success: false,
|
||||
allOutputs: [],
|
||||
|
||||
@@ -9,16 +9,14 @@ import { randomUUID } from 'node:crypto';
|
||||
import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHookOutput, HookEventName, HookType } from './types.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import { resolveCommandHookTimeoutMs } from './hook-timeout.js';
|
||||
import { applyHookOutputToInput } from './hook-sequential-input.js';
|
||||
import type {
|
||||
HookConfig,
|
||||
HookInput,
|
||||
HookOutput,
|
||||
HookExecutionResult,
|
||||
PreToolUseInput,
|
||||
UserPromptExpansionInput,
|
||||
UserPromptSubmitInput,
|
||||
CommandHookConfig,
|
||||
FunctionHookContext,
|
||||
PromptHookConfig,
|
||||
@@ -38,6 +36,10 @@ import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getShellContextEnvVars } from '../services/shellContextEnv.js';
|
||||
import { sanitizeChildEnv } from '../utils/sanitize-child-env.js';
|
||||
import {
|
||||
HookCommandCgroup,
|
||||
HookCommandIsolationUnavailableError,
|
||||
} from './hook-command-cgroup.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
|
||||
@@ -50,6 +52,7 @@ const MAX_OUTPUT_LENGTH = 1024 * 1024;
|
||||
const HOOK_TERMINATE_GRACE_MS = 2000;
|
||||
const HOOK_PROCESS_GROUP_POLL_MS = 50;
|
||||
const HOOK_CHILD_CLOSE_WAIT_MS = 1000;
|
||||
const activeHookCgroups = new Map<ChildProcess, HookCommandCgroup>();
|
||||
const WINDOWS_TASKKILL_TIMEOUT_MS = 2000;
|
||||
const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`;
|
||||
const SURVIVING_HOOK_TIMEOUT_EXIT_CODE = 124;
|
||||
@@ -331,6 +334,16 @@ function killDirectChild(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
|
||||
function forceKillActivePosixHookProcesses(): void {
|
||||
for (const child of activePosixHookProcesses) {
|
||||
const unit = activeHookCgroups.get(child);
|
||||
if (unit) {
|
||||
killDirectChild(child, 'SIGKILL');
|
||||
try {
|
||||
unit.kill();
|
||||
} catch {
|
||||
// A failed cgroup cleanup cannot provide a drained receipt.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const pid = child.pid;
|
||||
if (!pid) {
|
||||
killDirectChild(child, 'SIGKILL');
|
||||
@@ -372,6 +385,7 @@ function registerActivePosixHookProcess(child: ChildProcess): void {
|
||||
}
|
||||
|
||||
function unregisterActivePosixHookProcess(child: ChildProcess): void {
|
||||
activeHookCgroups.delete(child);
|
||||
activePosixHookProcesses.delete(child);
|
||||
if (activePosixHookProcesses.size === 0 && parentExitCleanupRegistered) {
|
||||
process.removeListener('exit', forceKillActivePosixHookProcesses);
|
||||
@@ -663,6 +677,13 @@ export class HookRunner {
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
contextOrSignal?: FunctionHookContext | AbortSignal,
|
||||
options?: {
|
||||
waitForProcessTree?: true;
|
||||
cgroupRoot?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
trackHttpRequest?: true;
|
||||
httpRequestSignal?: AbortSignal;
|
||||
},
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -707,6 +728,7 @@ export class HookRunner {
|
||||
input,
|
||||
startTime,
|
||||
signal,
|
||||
options,
|
||||
);
|
||||
case HookType.Http:
|
||||
return await this.httpRunner.execute(
|
||||
@@ -714,6 +736,8 @@ export class HookRunner {
|
||||
eventName,
|
||||
input,
|
||||
signal,
|
||||
options?.trackHttpRequest,
|
||||
options?.httpRequestSignal,
|
||||
);
|
||||
case HookType.Function: {
|
||||
// Function hooks accept context, not just signal
|
||||
@@ -1045,7 +1069,7 @@ export class HookRunner {
|
||||
|
||||
// If the hook succeeded and has output, use it to modify the input for the next hook
|
||||
if (result.success && result.output) {
|
||||
currentInput = this.applyHookOutputToInput(
|
||||
currentInput = applyHookOutputToInput(
|
||||
currentInput,
|
||||
result.output,
|
||||
eventName,
|
||||
@@ -1056,71 +1080,6 @@ export class HookRunner {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hook output to modify input for the next hook in sequential execution
|
||||
*/
|
||||
private applyHookOutputToInput(
|
||||
originalInput: HookInput,
|
||||
hookOutput: HookOutput,
|
||||
eventName: HookEventName,
|
||||
): HookInput {
|
||||
// Create a copy of the original input
|
||||
const modifiedInput = { ...originalInput };
|
||||
|
||||
// Apply modifications based on hook output and event type
|
||||
if (hookOutput.hookSpecificOutput) {
|
||||
switch (eventName) {
|
||||
case HookEventName.UserPromptSubmit:
|
||||
{
|
||||
const additionalContext =
|
||||
hookOutput.hookSpecificOutput['additionalContext'];
|
||||
if (
|
||||
typeof additionalContext === 'string' &&
|
||||
additionalContext &&
|
||||
'prompt' in modifiedInput
|
||||
) {
|
||||
(modifiedInput as UserPromptSubmitInput).prompt +=
|
||||
'\n\n' + additionalContext;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case HookEventName.UserPromptExpansion:
|
||||
{
|
||||
const additionalContext = createHookOutput(
|
||||
eventName,
|
||||
hookOutput,
|
||||
).getAdditionalContext();
|
||||
if (additionalContext && 'prompt' in modifiedInput) {
|
||||
(modifiedInput as UserPromptExpansionInput).prompt +=
|
||||
'\n\n' + additionalContext;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case HookEventName.PreToolUse:
|
||||
if ('tool_input' in hookOutput.hookSpecificOutput) {
|
||||
const newToolInput = hookOutput.hookSpecificOutput[
|
||||
'tool_input'
|
||||
] as Record<string, unknown>;
|
||||
if (newToolInput && 'tool_input' in modifiedInput) {
|
||||
(modifiedInput as PreToolUseInput).tool_input = {
|
||||
...(modifiedInput as PreToolUseInput).tool_input,
|
||||
...newToolInput,
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For other events, no special input modification is needed
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook
|
||||
* @param hookConfig Hook configuration
|
||||
@@ -1135,6 +1094,12 @@ export class HookRunner {
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
signal?: AbortSignal,
|
||||
options?: {
|
||||
waitForProcessTree?: true;
|
||||
cgroupRoot?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
trackHttpRequest?: true;
|
||||
},
|
||||
): Promise<HookExecutionResult> {
|
||||
const timeout = resolveCommandHookTimeoutMs(
|
||||
hookConfig.timeout,
|
||||
@@ -1164,6 +1129,7 @@ export class HookRunner {
|
||||
let settled = false;
|
||||
let terminationPromise: Promise<void> | undefined;
|
||||
let childClosed = false;
|
||||
let processTreeDrained = false;
|
||||
let survivingHookPid: number | undefined;
|
||||
let survivingHookOutcome:
|
||||
| 'completed'
|
||||
@@ -1231,7 +1197,7 @@ export class HookRunner {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
// Hook commands are child processes launched on the agent's behalf,
|
||||
// so they must not inherit Qwen-internal daemon secrets.
|
||||
...sanitizeChildEnv(process.env),
|
||||
...sanitizeChildEnv(options?.environment ?? process.env),
|
||||
GEMINI_PROJECT_DIR: input.cwd,
|
||||
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
|
||||
QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility
|
||||
@@ -1239,10 +1205,30 @@ export class HookRunner {
|
||||
...hookConfig.env,
|
||||
};
|
||||
|
||||
let processUnit: HookCommandCgroup | undefined;
|
||||
if (options?.waitForProcessTree) {
|
||||
try {
|
||||
processUnit = HookCommandCgroup.create(options.cgroupRoot);
|
||||
} catch {
|
||||
resolve({
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
error: new HookCommandIsolationUnavailableError(),
|
||||
processTreeDrained: true,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
let isolationUnavailable = false;
|
||||
|
||||
const survivesParentExit =
|
||||
eventName === HookEventName.MessageDisplay ||
|
||||
eventName === HookEventName.StopFailure ||
|
||||
eventName === HookEventName.SessionDelete;
|
||||
!options?.waitForProcessTree &&
|
||||
(eventName === HookEventName.MessageDisplay ||
|
||||
eventName === HookEventName.StopFailure ||
|
||||
eventName === HookEventName.SessionDelete);
|
||||
let parentIndependentInputPath: string | undefined;
|
||||
let child: ChildProcess;
|
||||
if (survivesParentExit) {
|
||||
@@ -1289,18 +1275,33 @@ export class HookRunner {
|
||||
}
|
||||
} else {
|
||||
resolveSupervisorStarted();
|
||||
child = spawn(
|
||||
const launch = processUnit?.launch(
|
||||
shellConfig.executable,
|
||||
[...shellConfig.argsPrefix, command],
|
||||
env,
|
||||
);
|
||||
child = spawn(
|
||||
launch?.executable ?? shellConfig.executable,
|
||||
launch?.args ?? [...shellConfig.argsPrefix, command],
|
||||
{
|
||||
env,
|
||||
env: launch?.env ?? env,
|
||||
cwd: input.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
stdio: processUnit
|
||||
? ['pipe', 'pipe', 'pipe', 'pipe']
|
||||
: ['pipe', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
// Own a process group so cancellation can signal the entire tree.
|
||||
// Own a process group for legacy cancellation.
|
||||
detached: process.platform !== 'win32',
|
||||
},
|
||||
);
|
||||
if (processUnit) {
|
||||
let status = '';
|
||||
child.stdio[3]?.on('data', (data: Buffer) => {
|
||||
status += data.toString();
|
||||
isolationUnavailable = status.includes('unavailable\n');
|
||||
});
|
||||
activeHookCgroups.set(child, processUnit);
|
||||
}
|
||||
}
|
||||
if (!survivesParentExit) {
|
||||
registerActivePosixHookProcess(child);
|
||||
@@ -1317,9 +1318,19 @@ export class HookRunner {
|
||||
rmSync(parentIndependentInputPath, { force: true });
|
||||
parentIndependentInputPath = undefined;
|
||||
}
|
||||
if (!survivesParentExit) {
|
||||
if (
|
||||
!survivesParentExit &&
|
||||
(!options?.waitForProcessTree || processTreeDrained)
|
||||
) {
|
||||
unregisterActivePosixHookProcess(child);
|
||||
}
|
||||
if (processTreeDrained) {
|
||||
try {
|
||||
processUnit?.remove();
|
||||
} catch {
|
||||
debugLogger.warn('Failed to remove an empty hook cgroup');
|
||||
}
|
||||
}
|
||||
if (signal && abortListenerAttached) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
abortListenerAttached = false;
|
||||
@@ -1332,7 +1343,11 @@ export class HookRunner {
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
resolve(
|
||||
options?.waitForProcessTree
|
||||
? { ...result, processTreeDrained }
|
||||
: result,
|
||||
);
|
||||
};
|
||||
|
||||
const finishCancellation = async () => {
|
||||
@@ -1366,6 +1381,11 @@ export class HookRunner {
|
||||
child.stderr?.destroy();
|
||||
}
|
||||
|
||||
if (processUnit) {
|
||||
processTreeDrained =
|
||||
childClosed &&
|
||||
(await processUnit.waitForEmpty(HOOK_CHILD_CLOSE_WAIT_MS));
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
finish({
|
||||
hookConfig,
|
||||
@@ -1385,12 +1405,19 @@ export class HookRunner {
|
||||
|
||||
const startTermination = () => {
|
||||
if (!terminationPromise) {
|
||||
const childTermination = terminateHookProcessTree(
|
||||
child,
|
||||
survivesParentExit
|
||||
? SURVIVING_HOOK_SUPERVISOR_GRACE_MS
|
||||
: HOOK_TERMINATE_GRACE_MS,
|
||||
);
|
||||
const childTermination = processUnit
|
||||
? (async () => {
|
||||
// Stop the trusted launcher even if cancellation raced its
|
||||
// initial cgroup join. The unit owns all command descendants.
|
||||
killDirectChild(child, 'SIGKILL');
|
||||
await processUnit.terminate(HOOK_TERMINATE_GRACE_MS);
|
||||
})()
|
||||
: terminateHookProcessTree(
|
||||
child,
|
||||
survivesParentExit
|
||||
? SURVIVING_HOOK_SUPERVISOR_GRACE_MS
|
||||
: HOOK_TERMINATE_GRACE_MS,
|
||||
);
|
||||
terminationPromise = survivesParentExit
|
||||
? Promise.all([
|
||||
childTermination,
|
||||
@@ -1480,15 +1507,39 @@ export class HookRunner {
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
child.on('close', (exitCode) => {
|
||||
child.on('close', async (exitCode) => {
|
||||
childClosed = true;
|
||||
resolveChildClosed();
|
||||
resolveSupervisorStarted();
|
||||
if (aborted || timedOut) {
|
||||
if (settled || aborted || timedOut) {
|
||||
return;
|
||||
}
|
||||
if (processUnit) {
|
||||
processTreeDrained = await processUnit.waitForEmpty(
|
||||
Math.max(0, timeout - (Date.now() - startTime)),
|
||||
() => aborted || timedOut,
|
||||
);
|
||||
if (aborted || timedOut) return;
|
||||
if (!processTreeDrained) {
|
||||
timedOut = true;
|
||||
startTermination();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (isolationUnavailable) {
|
||||
finish({
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
error: new HookCommandIsolationUnavailableError(),
|
||||
duration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (survivesParentExit && survivingHookOutcome === 'timed_out') {
|
||||
timedOut = true;
|
||||
finish({
|
||||
@@ -1603,6 +1654,8 @@ export class HookRunner {
|
||||
|
||||
// Handle process errors
|
||||
child.on('error', (error) => {
|
||||
if (options?.waitForProcessTree && !child.pid)
|
||||
processTreeDrained = true;
|
||||
if (aborted || timedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ describe('HookSystem', () => {
|
||||
expect.any(SessionHooksManager),
|
||||
undefined,
|
||||
hookSystem.runtimeId,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ import { HookRegistry } from './hookRegistry.js';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import { HookAggregator, type AggregatedHookResult } from './hookAggregator.js';
|
||||
import { HookPlanner } from './hookPlanner.js';
|
||||
import { HookEventHandler } from './hookEventHandler.js';
|
||||
import {
|
||||
HookEventHandler,
|
||||
type ManagedHookDispatcher,
|
||||
} from './hookEventHandler.js';
|
||||
import type { HookRegistryEntry } from './hookRegistry.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type {
|
||||
@@ -65,7 +68,10 @@ export class HookSystem {
|
||||
/** Optional provider for automatically fetching conversation history */
|
||||
private messagesProvider?: MessagesProvider;
|
||||
|
||||
constructor(config: Config) {
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly managedDispatcher?: ManagedHookDispatcher,
|
||||
) {
|
||||
// Get allowed HTTP URLs from config
|
||||
const allowedHttpUrls = config.getAllowedHttpHookUrls();
|
||||
|
||||
@@ -83,6 +89,7 @@ export class HookSystem {
|
||||
this.sessionHooksManager,
|
||||
undefined,
|
||||
this.runtimeId,
|
||||
this.managedDispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,11 +97,13 @@ export class HookSystem {
|
||||
* Initialize the hook system
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.managedDispatcher) return;
|
||||
await this.hookRegistry.initialize();
|
||||
debugLogger.debug('Hook system initialized successfully');
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
if (this.managedDispatcher) return;
|
||||
await this.hookRegistry.reloadConfiguredHooks();
|
||||
debugLogger.debug('Hook system reloaded successfully');
|
||||
}
|
||||
@@ -149,11 +158,17 @@ export class HookSystem {
|
||||
* when no hooks are configured for a given event.
|
||||
*/
|
||||
hasHooksForEvent(eventName: string, sessionId?: string): boolean {
|
||||
if (this.managedDispatcher)
|
||||
return this.managedDispatcher.hasHooksForEvent(eventName);
|
||||
const event = eventName as HookEventName;
|
||||
if (this.hookRegistry.getHooksForEvent(event).length > 0) return true;
|
||||
return this.sessionHooksManager.hasHooksForEvent(event, sessionId);
|
||||
}
|
||||
|
||||
isManaged(): boolean {
|
||||
return this.managedDispatcher !== undefined;
|
||||
}
|
||||
|
||||
async fireUserPromptSubmitEvent(
|
||||
prompt: string,
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -134,6 +134,177 @@ describe('HttpHookRunner', () => {
|
||||
expect(result.output?.continue).toBe(true);
|
||||
};
|
||||
|
||||
describe('managed request evidence', () => {
|
||||
it('proves cancellation during DNS validation did not send a request', async () => {
|
||||
const abort = new AbortController();
|
||||
const pending = httpRunner.execute(
|
||||
createMockConfig(),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
abort.signal,
|
||||
true,
|
||||
new AbortController().signal,
|
||||
);
|
||||
abort.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
outcome: 'cancelled',
|
||||
httpRequestState: 'not_started',
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['response', 'body-loss', 'timeout', 'shutdown'])(
|
||||
'preserves an in-flight managed response after user cancellation (%s)',
|
||||
async (completion) => {
|
||||
vi.useFakeTimers();
|
||||
const caller = new AbortController();
|
||||
const shutdown = new AbortController();
|
||||
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let transport!: AbortSignal;
|
||||
let started!: () => void;
|
||||
const dispatched = new Promise<void>((resolve) => (started = resolve));
|
||||
mockFetch.mockImplementationOnce(async (_url, options) => {
|
||||
transport = options.signal;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
stream = controller;
|
||||
transport.addEventListener('abort', () =>
|
||||
controller.error(transport.reason),
|
||||
);
|
||||
},
|
||||
});
|
||||
started();
|
||||
return new Response(body, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const pending = httpRunner.execute(
|
||||
createMockConfig({ timeout: 1 }),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
caller.signal,
|
||||
true,
|
||||
shutdown.signal,
|
||||
);
|
||||
await dispatched;
|
||||
caller.abort();
|
||||
expect(transport.aborted).toBe(false);
|
||||
if (completion === 'response') {
|
||||
stream.enqueue(new TextEncoder().encode('{"continue":false}'));
|
||||
stream.close();
|
||||
} else if (completion === 'body-loss') {
|
||||
stream.error(new TypeError('response disconnected'));
|
||||
} else if (completion === 'timeout') {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
} else shutdown.abort();
|
||||
const result = await pending;
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.httpRequestState).toBe(
|
||||
completion === 'response' ? 'response_received' : 'outcome_unknown',
|
||||
);
|
||||
if (completion === 'response')
|
||||
expect(result.output).toMatchObject({ continue: false });
|
||||
if (completion === 'timeout') expect(result.outcome).toBe('timeout');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a partially received JSON response unknown', async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"continue":false'));
|
||||
},
|
||||
pull(controller) {
|
||||
controller.error(new TypeError('response body disconnected'));
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const result = await httpRunner.execute(
|
||||
createMockConfig(),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'non_blocking_error',
|
||||
httpRequestState: 'outcome_unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps timeout active while waiting for the response body', async () => {
|
||||
mockFetch.mockImplementationOnce(async (_url, options) => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
options.signal.addEventListener('abort', () =>
|
||||
controller.error(options.signal.reason),
|
||||
);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const result = await httpRunner.execute(
|
||||
createMockConfig({ timeout: 0.01 }),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'timeout',
|
||||
httpRequestState: 'outcome_unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('proves a blocked destination was not sent', async () => {
|
||||
const result = await httpRunner.execute(
|
||||
createMockConfig({ url: 'https://other.example/hook' }),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(result.httpRequestState).toBe('not_started');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it('distinguishes a received failure response from a lost reply', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||
const received = await httpRunner.execute(
|
||||
createMockConfig(),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(received).toMatchObject({
|
||||
outcome: 'non_blocking_error',
|
||||
httpRequestState: 'response_received',
|
||||
});
|
||||
mockFetch.mockRejectedValueOnce(new Error('reply lost'));
|
||||
const lost = await httpRunner.execute(
|
||||
createMockConfig(),
|
||||
HookEventName.PreToolUse,
|
||||
createMockInput(),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(lost).toMatchObject({
|
||||
outcome: 'non_blocking_error',
|
||||
httpRequestState: 'outcome_unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should fail for URL not in whitelist', () =>
|
||||
expectBlocked(
|
||||
|
||||
@@ -120,6 +120,37 @@ export class HttpHookRunner {
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
trackRequest = false,
|
||||
requestSignal?: AbortSignal,
|
||||
): Promise<HookExecutionResult> {
|
||||
let requestState: NonNullable<HookExecutionResult['httpRequestState']> =
|
||||
'not_started';
|
||||
const result = await this.executeRequest(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
signal,
|
||||
trackRequest
|
||||
? (state) => {
|
||||
requestState = state;
|
||||
}
|
||||
: undefined,
|
||||
requestSignal,
|
||||
);
|
||||
return trackRequest
|
||||
? { ...result, httpRequestState: requestState }
|
||||
: result;
|
||||
}
|
||||
|
||||
private async executeRequest(
|
||||
hookConfig: HttpHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
onRequestState?: (
|
||||
state: NonNullable<HookExecutionResult['httpRequestState']>,
|
||||
) => void,
|
||||
requestSignal?: AbortSignal,
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const hookId = hookConfig.name || hookConfig.url;
|
||||
@@ -207,18 +238,21 @@ export class HttpHookRunner {
|
||||
hook_event_name: eventName,
|
||||
});
|
||||
|
||||
// Set up combined abort signal (external signal + timeout)
|
||||
// Managed requests keep their response channel until timeout or shutdown.
|
||||
const timeout = hookConfig.timeout
|
||||
? hookConfig.timeout * 1000
|
||||
: DEFAULT_HTTP_HOOK_TIMEOUT_SECONDS * 1000;
|
||||
const { signal: combinedSignal, cleanup } = combineAbortSignals(
|
||||
[signal],
|
||||
[requestSignal ?? signal],
|
||||
{ timeoutMs: timeout },
|
||||
);
|
||||
|
||||
try {
|
||||
debugLogger.debug(`Executing HTTP hook: ${hookId} -> ${url}`);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
combinedSignal.throwIfAborted();
|
||||
onRequestState?.('outcome_unknown');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -233,14 +267,10 @@ export class HttpHookRunner {
|
||||
// non-2xx branch below (non-blocking error).
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Per Qwen Code spec: Non-2xx status is a non-blocking error
|
||||
// Execution continues, but we log a warning
|
||||
if (!response.ok) {
|
||||
onRequestState?.('response_received');
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${hookId} returned non-2xx status ${response.status} (non-blocking)`,
|
||||
);
|
||||
@@ -253,12 +283,17 @@ export class HttpHookRunner {
|
||||
outcome: 'non_blocking_error',
|
||||
error: new Error(`HTTP hook returned ${response.status}`),
|
||||
output: { continue: true },
|
||||
duration,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const output = await this.parseResponse(response, eventName);
|
||||
const output = await this.parseResponse(
|
||||
response,
|
||||
eventName,
|
||||
onRequestState && (() => onRequestState('response_received')),
|
||||
);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
debugLogger.debug(
|
||||
`HTTP hook ${hookId} completed successfully in ${duration}ms`,
|
||||
@@ -277,8 +312,6 @@ export class HttpHookRunner {
|
||||
duration,
|
||||
};
|
||||
} catch (fetchError) {
|
||||
cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (
|
||||
@@ -286,9 +319,9 @@ export class HttpHookRunner {
|
||||
(fetchError.name === 'AbortError' || combinedSignal.aborted)
|
||||
) {
|
||||
// Timeout or abort is a non-blocking error per Qwen Code spec.
|
||||
// The combined signal fires for both, so the caller's own signal
|
||||
// tells them apart.
|
||||
const cancelled = signal?.aborted === true;
|
||||
const cancelled =
|
||||
(requestSignal ?? signal)?.aborted === true ||
|
||||
(signal?.aborted === true && !combinedSignal.aborted);
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${hookId} ${cancelled ? 'was aborted' : `timed out after ${timeout}ms`} (non-blocking)`,
|
||||
);
|
||||
@@ -321,6 +354,8 @@ export class HttpHookRunner {
|
||||
output: { continue: true },
|
||||
duration,
|
||||
};
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -346,13 +381,19 @@ export class HttpHookRunner {
|
||||
private async parseResponse(
|
||||
response: Response,
|
||||
eventName: HookEventName,
|
||||
onBodyReceived?: () => void,
|
||||
): Promise<HookOutput> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
// Managed receipts require the body, not just headers. Keep transport
|
||||
// failures outside the native malformed-JSON fallback.
|
||||
const body = onBodyReceived ? await response.text() : undefined;
|
||||
onBodyReceived?.();
|
||||
|
||||
// Try to parse as JSON
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const json = await response.json();
|
||||
const json =
|
||||
body === undefined ? await response.json() : JSON.parse(body);
|
||||
return this.normalizeOutput(json, eventName);
|
||||
} catch {
|
||||
debugLogger.warn('Failed to parse JSON response, using empty output');
|
||||
@@ -361,7 +402,7 @@ export class HttpHookRunner {
|
||||
}
|
||||
|
||||
// For plain text responses, add as context (truncated if needed)
|
||||
const text = await response.text();
|
||||
const text = body ?? (await response.text());
|
||||
if (text.trim()) {
|
||||
return {
|
||||
continue: true,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { HookSystem } from './hookSystem.js';
|
||||
import { HookRegistry } from './hookRegistry.js';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import type {
|
||||
HookEventHandler,
|
||||
ManagedHookDispatcher,
|
||||
} from './hookEventHandler.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookPhase,
|
||||
NotificationType,
|
||||
PermissionMode,
|
||||
PostCompactTrigger,
|
||||
PreCompactTrigger,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
} from './types.js';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const calls: Record<
|
||||
HookEventName,
|
||||
(handler: HookEventHandler) => Promise<unknown>
|
||||
> = {
|
||||
PreToolUse: (h) =>
|
||||
h.firePreToolUseEvent('shell', {}, 'call-1', PermissionMode.Default),
|
||||
PostToolUse: (h) =>
|
||||
h.firePostToolUseEvent('shell', {}, {}, 'call-1', PermissionMode.Default),
|
||||
PostToolUseFailure: (h) =>
|
||||
h.firePostToolUseFailureEvent('call-1', 'shell', {}, 'failed'),
|
||||
PostToolBatch: (h) => h.firePostToolBatchEvent([]),
|
||||
Notification: (h) =>
|
||||
h.fireNotificationEvent('idle', NotificationType.IdlePrompt),
|
||||
UserPromptSubmit: (h) => h.fireUserPromptSubmitEvent('hello'),
|
||||
UserPromptExpansion: (h) =>
|
||||
h.fireUserPromptExpansionEvent('test', '', 'expanded'),
|
||||
SessionStart: (h) =>
|
||||
h.fireSessionStartEvent(
|
||||
SessionStartSource.Startup,
|
||||
'model-1',
|
||||
PermissionMode.Default,
|
||||
),
|
||||
Stop: (h) => h.fireStopEvent(false, 'answer'),
|
||||
MessageDisplay: (h) => h.fireMessageDisplayEvent('message-1', 'answer', true),
|
||||
SubagentStart: (h) =>
|
||||
h.fireSubagentStartEvent('child-1', 'worker', PermissionMode.Default),
|
||||
SubagentStop: (h) =>
|
||||
h.fireSubagentStopEvent(
|
||||
'child-1',
|
||||
'worker',
|
||||
'',
|
||||
'answer',
|
||||
false,
|
||||
PermissionMode.Default,
|
||||
),
|
||||
PreCompact: (h) => h.firePreCompactEvent(PreCompactTrigger.Auto, ''),
|
||||
PostCompact: (h) =>
|
||||
h.firePostCompactEvent(PostCompactTrigger.Auto, 'summary'),
|
||||
SessionEnd: (h) => h.fireSessionEndEvent(SessionEndReason.Other),
|
||||
SessionDelete: (h) => h.fireSessionDeleteEvent('session-1'),
|
||||
PermissionRequest: (h) =>
|
||||
h.firePermissionRequestEvent('shell', {}, PermissionMode.Default),
|
||||
PermissionDenied: (h) =>
|
||||
h.firePermissionDeniedEvent('shell', {}, 'call-1', 'classifier_blocked'),
|
||||
StopFailure: (h) => h.fireStopFailureEvent('server_error'),
|
||||
TodoCreated: (h) =>
|
||||
h.fireTodoCreatedEvent(
|
||||
'todo-1',
|
||||
'work',
|
||||
'pending',
|
||||
[],
|
||||
HookPhase.Validation,
|
||||
),
|
||||
TodoCompleted: (h) =>
|
||||
h.fireTodoCompletedEvent(
|
||||
'todo-1',
|
||||
'work',
|
||||
'in_progress',
|
||||
[],
|
||||
HookPhase.PostWrite,
|
||||
),
|
||||
InstructionsLoaded: (h) =>
|
||||
h.fireInstructionsLoadedEvent(
|
||||
'/workspace/AGENTS.md',
|
||||
'project',
|
||||
'session_start',
|
||||
),
|
||||
};
|
||||
|
||||
function fixture() {
|
||||
const config = {
|
||||
getSessionId: () => 'session-1',
|
||||
getSessionSourceType: () => undefined,
|
||||
getSessionSourceId: () => undefined,
|
||||
getTranscriptPath: () => '/workspace/session.jsonl',
|
||||
getWorkingDir: () => '/workspace',
|
||||
getApprovalMode: () => 'default',
|
||||
getAllowedHttpHookUrls: () => [],
|
||||
getAllowPrivateNetworkHooks: () => false,
|
||||
} as unknown as Config;
|
||||
const output = {
|
||||
success: true,
|
||||
allOutputs: [],
|
||||
errors: [],
|
||||
totalDuration: 1,
|
||||
finalOutput: { continue: false, stopReason: 'managed decision' },
|
||||
};
|
||||
const execute = vi
|
||||
.fn<ManagedHookDispatcher['execute']>()
|
||||
.mockResolvedValue(output);
|
||||
const hasHooksForEvent = vi.fn(() => true);
|
||||
const system = new HookSystem(config, { hasHooksForEvent, execute });
|
||||
return { system, execute, hasHooksForEvent, output };
|
||||
}
|
||||
|
||||
describe('Managed native Hook dispatch', () => {
|
||||
it('preserves the native function messages provider as an immutable dispatch snapshot', async () => {
|
||||
const { system, execute } = fixture();
|
||||
const messages = [{ role: 'user', parts: [{ text: 'this Session only' }] }];
|
||||
system.setMessagesProvider(() => messages);
|
||||
await system.fireNotificationEvent('idle', NotificationType.IdlePrompt);
|
||||
expect(execute.mock.calls[0][1]).toMatchObject({ messages });
|
||||
messages[0].parts[0].text = 'later';
|
||||
expect(execute.mock.calls[0][1]).toMatchObject({
|
||||
messages: [{ role: 'user', parts: [{ text: 'this Session only' }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(Object.values(HookEventName))(
|
||||
'forwards %s with native inputs and never invokes local runners',
|
||||
async (event) => {
|
||||
const { system, execute, output } = fixture();
|
||||
const parallel = vi.spyOn(HookRunner.prototype, 'executeHooksParallel');
|
||||
const sequential = vi.spyOn(
|
||||
HookRunner.prototype,
|
||||
'executeHooksSequential',
|
||||
);
|
||||
await expect(calls[event](system.getEventHandler())).resolves.toBe(
|
||||
output,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
event,
|
||||
expect.objectContaining({
|
||||
session_id: 'session-1',
|
||||
cwd: '/workspace',
|
||||
hook_event_name: event,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(parallel).not.toHaveBeenCalled();
|
||||
expect(sequential).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('never loads ambient catalogs and suppresses unmanaged event producers', async () => {
|
||||
const initialize = vi.spyOn(HookRegistry.prototype, 'initialize');
|
||||
const reload = vi.spyOn(HookRegistry.prototype, 'reloadConfiguredHooks');
|
||||
const { system, execute, hasHooksForEvent } = fixture();
|
||||
await system.initialize();
|
||||
await system.reload();
|
||||
expect(initialize).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
hasHooksForEvent.mockReturnValue(false);
|
||||
expect(system.hasHooksForEvent(HookEventName.Stop)).toBe(false);
|
||||
await system.fireStopEvent();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates authoritative recovery failures instead of converting them into success', async () => {
|
||||
const { system, execute } = fixture();
|
||||
execute.mockRejectedValue(new Error('Hook recovery required'));
|
||||
await expect(system.fireStopEvent()).rejects.toThrow(
|
||||
'Hook recovery required',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1362,6 +1362,9 @@ export function detectTodoChanges(
|
||||
* Hook execution result
|
||||
*/
|
||||
export interface HookExecutionResult {
|
||||
/** True only if a managed command never starts or its owned cgroup is empty. */
|
||||
processTreeDrained?: boolean;
|
||||
httpRequestState?: 'not_started' | 'response_received' | 'outcome_unknown';
|
||||
hookConfig: HookConfig;
|
||||
eventName: HookEventName;
|
||||
success: boolean;
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
"recordBodies": {
|
||||
"monitor_run": "monitor",
|
||||
"mcp_configuration": null,
|
||||
"mcp_operation": null
|
||||
"mcp_operation": null,
|
||||
"hook_registration": null,
|
||||
"hook_execution": null
|
||||
},
|
||||
"taskStates": [
|
||||
"pending",
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"contract": "managed-hook-record/1",
|
||||
"templates": {
|
||||
"hook_registration": {
|
||||
"registrationId": "registration-1",
|
||||
"catalogId": "catalog-1",
|
||||
"catalogRevision": 1,
|
||||
"catalogRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"run": {
|
||||
"state": "admitted",
|
||||
"reason": null,
|
||||
"definition": {
|
||||
"definitionId": "catalog-1",
|
||||
"definitionRevision": 1,
|
||||
"definitionDigest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
"executionCallId": null,
|
||||
"effectId": "registration-1",
|
||||
"dispatchId": null,
|
||||
"deliveryId": null,
|
||||
"execution": null,
|
||||
"runtime": null,
|
||||
"delivery": null
|
||||
}
|
||||
},
|
||||
"hook_execution": {
|
||||
"hookExecutionId": "execution-1",
|
||||
"runtimeSessionId": "hook:session-1",
|
||||
"occurrenceId": "occurrence-1",
|
||||
"registrationId": "registration-1",
|
||||
"eventName": "BeforeTool",
|
||||
"ordinal": 0,
|
||||
"hookId": "hook-1",
|
||||
"planRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"inputRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"resultRef": null,
|
||||
"onceKey": "once-1",
|
||||
"cancelRequested": false,
|
||||
"run": {
|
||||
"state": "admitted",
|
||||
"reason": null,
|
||||
"definition": {
|
||||
"definitionId": "catalog-1",
|
||||
"definitionRevision": 1,
|
||||
"definitionDigest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
"executionCallId": null,
|
||||
"effectId": "execution-1",
|
||||
"dispatchId": null,
|
||||
"deliveryId": null,
|
||||
"execution": "intent",
|
||||
"runtime": null,
|
||||
"delivery": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "registration-start",
|
||||
"domain": "hook_registration",
|
||||
"patch": {},
|
||||
"valid": true,
|
||||
"start": true
|
||||
},
|
||||
{
|
||||
"id": "registration-local-revision",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "running"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-settled",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "settled"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-no-execution",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"run": {
|
||||
"execution": "intent"
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-wrong-pin",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"catalogRevision": 2
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-no-unknown-keys",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"extra": false
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-positive-revision",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"catalogRevision": 0
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-effect-identity",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"run": {
|
||||
"effectId": "other"
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "registration-no-unpinned",
|
||||
"domain": "hook_registration",
|
||||
"patch": {
|
||||
"run": {
|
||||
"definition": null
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-start",
|
||||
"domain": "hook_execution",
|
||||
"patch": {},
|
||||
"valid": true,
|
||||
"start": true
|
||||
},
|
||||
{
|
||||
"id": "execution-local-handler",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-unknown",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "recovery_blocked",
|
||||
"execution": "outcome_unknown",
|
||||
"reason": "outcome_unknown"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-settled-local-handler",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-settled-runtime",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"runtime": {
|
||||
"runtimeBindingId": "runtime-1",
|
||||
"generation": "1"
|
||||
}
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-missing-result",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-premature-result",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-null-execution",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"execution": null
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-negative-ordinal",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"ordinal": -1
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-invalid-once",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"onceKey": ""
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-invalid-runtime-session",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"runtimeSessionId": ""
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-cancel-start",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"cancelRequested": true
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-delivery",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"delivery": {
|
||||
"target": "session",
|
||||
"state": "planned"
|
||||
}
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-call-id",
|
||||
"domain": "hook_execution",
|
||||
"patch": {
|
||||
"run": {
|
||||
"executionCallId": "call-1",
|
||||
"effectId": null
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false
|
||||
}
|
||||
],
|
||||
"successors": [
|
||||
{
|
||||
"id": "registration-runs",
|
||||
"domain": "hook_registration",
|
||||
"before": {},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "running"
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"id": "registration-settles",
|
||||
"domain": "hook_registration",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "running"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled"
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"id": "registration-cannot-skip",
|
||||
"domain": "hook_registration",
|
||||
"before": {},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "registration-fixed-pin",
|
||||
"domain": "hook_registration",
|
||||
"before": {},
|
||||
"after": {
|
||||
"catalogRevision": 2,
|
||||
"run": {
|
||||
"definition": {
|
||||
"definitionRevision": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "registration-fixed-catalog",
|
||||
"domain": "hook_registration",
|
||||
"before": {},
|
||||
"after": {
|
||||
"catalogRef": {
|
||||
"resourceId": "other"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-dispatch",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started"
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"id": "execution-cancel",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"cancelRequested": true
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"id": "execution-no-uncancel",
|
||||
"domain": "hook_execution",
|
||||
"before": {
|
||||
"cancelRequested": true
|
||||
},
|
||||
"after": {},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-no-replay",
|
||||
"domain": "hook_execution",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "recovery_blocked",
|
||||
"execution": "outcome_unknown",
|
||||
"reason": "outcome_unknown"
|
||||
}
|
||||
},
|
||||
"after": {},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-late-result",
|
||||
"domain": "hook_execution",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "recovery_blocked",
|
||||
"execution": "outcome_unknown",
|
||||
"reason": "outcome_unknown"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"id": "execution-result-immutable",
|
||||
"domain": "hook_execution",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "other",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-terminal-cancel-immutable",
|
||||
"domain": "hook_execution",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled"
|
||||
},
|
||||
"resultRef": {
|
||||
"resourceId": "hook-data",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"cancelRequested": true
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-hookId",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"hookId": "other"
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-runtimeSessionId",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"runtimeSessionId": "other"
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-occurrenceId",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"occurrenceId": "other"
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-registrationId",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"registrationId": "other"
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-eventName",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"eventName": "AfterTool"
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-ordinal",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"ordinal": 1
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-onceKey",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"onceKey": null
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-planRef",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"planRef": {
|
||||
"resourceId": "other",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "execution-fixed-inputRef",
|
||||
"domain": "hook_execution",
|
||||
"before": {},
|
||||
"after": {
|
||||
"inputRef": {
|
||||
"resourceId": "other",
|
||||
"kind": "hook-data",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"id": "registration-terminal-not-reordered",
|
||||
"domain": "hook_registration",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "settled"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "settled"
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { openManagedSession } from './managed-session-assembly.js';
|
||||
import { ManagedHookActivationController } from './managed-hook-activation.js';
|
||||
import {
|
||||
ManagedSessionMessageProjection,
|
||||
projectManagedSessionRecords,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import type { ManagedSessionStoreHttpError } from './http-managed-session-store.js';
|
||||
import { createHttpManagedSessionStores } from './http-managed-session-store.js';
|
||||
import type { McpConfiguration } from './managed-mcp-record.js';
|
||||
import type { HookExecution, HookRegistration } from './managed-hook-record.js';
|
||||
import type {
|
||||
ManagedSessionDurableRef,
|
||||
ManagedSessionKey,
|
||||
@@ -316,6 +318,159 @@ describe('HTTP Managed Session store', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'uncommitted-503',
|
||||
'lost-commit-response',
|
||||
'lost-response-body',
|
||||
'permanent-409',
|
||||
'exhausted-503',
|
||||
'changed-receipt',
|
||||
'invalid-json',
|
||||
])(
|
||||
'preserves activation transaction identity and failure fencing after %s',
|
||||
async (failure) => {
|
||||
const server = new FakeManagedSessionStore();
|
||||
const runtimeBaseDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'managed-http-store-'),
|
||||
);
|
||||
temporaryDirectories.push(runtimeBaseDir);
|
||||
const requests: string[] = [];
|
||||
const recoverable =
|
||||
failure === 'uncommitted-503' ||
|
||||
failure === 'lost-commit-response' ||
|
||||
failure === 'lost-response-body';
|
||||
let armed = false;
|
||||
let replay: unknown;
|
||||
const stores = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://session-store.test',
|
||||
sessionKey: SESSION_KEY,
|
||||
writerId: 'harness-a',
|
||||
writerToken: TOKEN_A,
|
||||
fetchFn: async (input, init) => {
|
||||
if (armed && requestUrl(input).endsWith('/transactions:commit')) {
|
||||
const body = JSON.parse(String(init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (body['operation'] === 'installActivation') {
|
||||
requests.push(String(init?.body));
|
||||
if (failure === 'permanent-409' || failure === 'exhausted-503')
|
||||
return jsonResponse(
|
||||
{ error: { code: 'commit_rejected' } },
|
||||
failure === 'permanent-409' ? 409 : 503,
|
||||
);
|
||||
if (failure === 'changed-receipt') {
|
||||
const receipt = (await (
|
||||
await server.fetch(input, init)
|
||||
).json()) as Record<string, unknown>;
|
||||
return jsonResponse({ ...receipt, transactionId: 'different' });
|
||||
}
|
||||
if (failure === 'invalid-json')
|
||||
return new Response('{', {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
if (requests.length === 1) {
|
||||
if (failure === 'uncommitted-503')
|
||||
return jsonResponse(
|
||||
{ error: { code: 'temporary_failure' } },
|
||||
503,
|
||||
);
|
||||
replay = await (await server.fetch(input, init)).json();
|
||||
if (failure === 'lost-response-body')
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(
|
||||
new TypeError('Response stream terminated'),
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
throw new TypeError('Commit response lost');
|
||||
}
|
||||
armed = false;
|
||||
if (replay !== undefined) return jsonResponse(replay);
|
||||
}
|
||||
}
|
||||
return server.fetch(input, init);
|
||||
},
|
||||
});
|
||||
const definitionRef = await stores.resourceStore.publish(
|
||||
'managed-session-definition',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
const rootSnapshotRef = await stores.resourceStore.publish(
|
||||
'managed-session-root-snapshot',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
const session = await openManagedSession({
|
||||
runtimeBaseDir,
|
||||
sessionId: SESSION_KEY.sessionId,
|
||||
transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'),
|
||||
sessionKey: SESSION_KEY,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
workerId: 'harness-a',
|
||||
activationLeaseDurationMs: 60_000,
|
||||
journalStore: stores.journalStore,
|
||||
resourceStore: stores.resourceStore,
|
||||
create: { definitionRef, rootSnapshotRef, createdBy: 'test' },
|
||||
});
|
||||
try {
|
||||
armed = true;
|
||||
const controller = new ManagedHookActivationController(session);
|
||||
const run = vi.fn(async () => 'completed');
|
||||
const operation = controller.runHookOperation(
|
||||
{
|
||||
operationId: 'notification',
|
||||
occurrenceId: 'notification',
|
||||
originTurnId: null,
|
||||
},
|
||||
run,
|
||||
);
|
||||
if (recoverable) {
|
||||
await expect(operation).resolves.toBe('completed');
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(requests).toHaveLength(2);
|
||||
const transaction = JSON.parse(requests[0]) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(
|
||||
server.commits.filter(
|
||||
(commit) =>
|
||||
commit['transactionId'] === transaction['transactionId'],
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(session.authority.writesStopped).toBe(false);
|
||||
expect(session.authority.currentActivation).toMatchObject({
|
||||
...session.activation,
|
||||
phase: 'active',
|
||||
epoch: 3,
|
||||
});
|
||||
expect(session.authority.currentActivationSubject?.type).not.toBe(
|
||||
'hook_operation',
|
||||
);
|
||||
await expect(
|
||||
controller.runTurn('next-turn', async () => 'accepted'),
|
||||
).resolves.toBe('accepted');
|
||||
} else {
|
||||
await expect(operation).rejects.toThrow('writes stopped');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(requests).toHaveLength(failure === 'exhausted-503' ? 3 : 1);
|
||||
expect(session.authority.writesStopped).toBe(true);
|
||||
await expect(controller.runTurn('next-turn', run)).rejects.toThrow(
|
||||
'current Session activation',
|
||||
);
|
||||
}
|
||||
expect(requests.every((body) => body === requests[0])).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('commits staged resources and restores without a local transcript', async () => {
|
||||
const server = new FakeManagedSessionStore();
|
||||
const runtimeBaseDir = await mkdtemp(
|
||||
@@ -949,6 +1104,96 @@ describe('HTTP Managed Session store', () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const hookTemplates = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./contracts/managed-hook-record-v1.fixtures.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).templates as {
|
||||
hook_registration: HookRegistration;
|
||||
hook_execution: HookExecution;
|
||||
};
|
||||
const registration = { ...hookTemplates.hook_registration, catalogRef };
|
||||
const commitHook = (
|
||||
commandId: string,
|
||||
domain: 'hook_registration' | 'hook_execution',
|
||||
record: unknown,
|
||||
) =>
|
||||
first.authority.commitExtensionRecord(
|
||||
{
|
||||
operation: 'commitHookRecord',
|
||||
commandId,
|
||||
sessionKey: SESSION_KEY,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
{ domain, record },
|
||||
{ class: 'trusted_entry' },
|
||||
);
|
||||
for (const state of ['admitted', 'running', 'settled'] as const)
|
||||
await commitHook(`register-${state}`, 'hook_registration', {
|
||||
...registration,
|
||||
run: { ...registration.run, state },
|
||||
});
|
||||
const orphanRef = await first.resources.publish(
|
||||
'untrusted-user-object',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
const messageCases = [
|
||||
[{ role: 'user', content: 'small snapshot' }],
|
||||
[{ role: 'user', content: '😀'.repeat(40_000) }],
|
||||
];
|
||||
for (const [index, messages] of messageCases.entries()) {
|
||||
const bytes = Buffer.from(JSON.stringify(messages));
|
||||
const parts: ManagedSessionDurableRef[] = [];
|
||||
if (bytes.length > 60 * 1024) {
|
||||
for (let offset = 0; offset < bytes.length; offset += 60 * 1024)
|
||||
parts.push(
|
||||
await first.resources.publish(
|
||||
'managed-hook-message-part',
|
||||
bytes.subarray(offset, offset + 60 * 1024),
|
||||
),
|
||||
);
|
||||
}
|
||||
const messagesRef = await first.resources.publish(
|
||||
parts.length ? 'managed-hook-message-chunks' : 'managed-hook-messages',
|
||||
parts.length ? Buffer.from(JSON.stringify({ parts })) : bytes,
|
||||
);
|
||||
const planRef = await first.resources.publish(
|
||||
'managed-hook-plan',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
input: { userObject: orphanRef },
|
||||
messagesRef,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const executionId = `messages-${index}`;
|
||||
await commitHook(executionId, 'hook_execution', {
|
||||
...hookTemplates.hook_execution,
|
||||
hookExecutionId: executionId,
|
||||
occurrenceId: executionId,
|
||||
planRef,
|
||||
inputRef: catalogRef,
|
||||
onceKey: null,
|
||||
run: { ...hookTemplates.hook_execution.run, effectId: executionId },
|
||||
});
|
||||
const uploaded = server.commits.at(-1)!['resources'] as Array<{
|
||||
resourceId: string;
|
||||
}>;
|
||||
expect(uploaded.map((ref) => ref.resourceId)).toEqual(
|
||||
expect.arrayContaining([
|
||||
planRef.resourceId,
|
||||
messagesRef.resourceId,
|
||||
...parts.map((ref) => ref.resourceId),
|
||||
]),
|
||||
);
|
||||
expect(uploaded.map((ref) => ref.resourceId)).not.toContain(
|
||||
orphanRef.resourceId,
|
||||
);
|
||||
}
|
||||
const views = first.authority.taskViews();
|
||||
expect(views).toHaveLength(1);
|
||||
await first.close();
|
||||
@@ -967,6 +1212,30 @@ describe('HTTP Managed Session store', () => {
|
||||
expect(
|
||||
restored.authority.extensionRecord('monitor_run', 'monitor-1'),
|
||||
).toMatchObject({ revision: 1, recordRef: committed.recordRef });
|
||||
for (const [index, messages] of messageCases.entries()) {
|
||||
const execution = restored.authority.extensionRecord(
|
||||
'hook_execution',
|
||||
`messages-${index}`,
|
||||
)!.record as HookExecution;
|
||||
const plan = JSON.parse(
|
||||
(await restored.resources.read(execution.planRef)).toString(),
|
||||
) as { messagesRef: ManagedSessionDurableRef };
|
||||
let bytes = await restored.resources.read(plan.messagesRef);
|
||||
if (plan.messagesRef.kind === 'managed-hook-message-chunks') {
|
||||
const manifest = JSON.parse(bytes.toString()) as {
|
||||
parts: ManagedSessionDurableRef[];
|
||||
};
|
||||
bytes = Buffer.concat(
|
||||
await Promise.all(
|
||||
manifest.parts.map((ref) => restored.resources.read(ref)),
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(JSON.parse(bytes.toString('utf8'))).toEqual(messages);
|
||||
}
|
||||
await expect(restored.resources.read(orphanRef)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ export class ManagedSessionStoreHttpError extends ManagedSessionRecordError {
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedSessionStoreTransportError extends ManagedSessionRecordError {}
|
||||
|
||||
export function createHttpManagedSessionStores(
|
||||
options: HttpManagedSessionStoreOptions,
|
||||
): HttpManagedSessionStores {
|
||||
@@ -278,6 +280,16 @@ class HttpManagedSessionResourceStore implements ManagedSessionResourceStore {
|
||||
} else if (EXTENSION_RECORD_KINDS.has(ref.kind)) {
|
||||
// A Stage H record commits the resources its closed body names.
|
||||
pending.push(...collectRefs([JSON.parse(staged.bytes.toString())]));
|
||||
} else if (ref.kind === 'managed-hook-plan') {
|
||||
const plan = JSON.parse(staged.bytes.toString()) as {
|
||||
messagesRef?: ManagedSessionDurableRef;
|
||||
};
|
||||
pending.push(...collectRefs([plan.messagesRef]));
|
||||
} else if (ref.kind === 'managed-hook-message-chunks') {
|
||||
const manifest = JSON.parse(staged.bytes.toString()) as {
|
||||
parts: ManagedSessionDurableRef[];
|
||||
};
|
||||
pending.push(...collectRefs(manifest.parts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,26 +578,26 @@ class ManagedSessionStoreHttpClient {
|
||||
resources: commitResources,
|
||||
};
|
||||
let committed: unknown;
|
||||
if (publicationId === undefined) {
|
||||
committed = await this.json('/transactions:commit', 'POST', commitBody);
|
||||
} else {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
committed = await this.publicationRequest(
|
||||
`/publications/${encodeURIComponent(publicationId)}/receipts/commit`,
|
||||
commitBody,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
const uncertain =
|
||||
(error instanceof ManagedSessionStoreHttpError &&
|
||||
(error.status === 429 || error.status >= 500)) ||
|
||||
error instanceof TypeError ||
|
||||
(error instanceof DOMException &&
|
||||
['AbortError', 'TimeoutError'].includes(error.name));
|
||||
if (!uncertain || attempt === 2) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
committed =
|
||||
publicationId === undefined
|
||||
? await this.json('/transactions:commit', 'POST', commitBody)
|
||||
: await this.publicationRequest(
|
||||
`/publications/${encodeURIComponent(publicationId)}/receipts/commit`,
|
||||
commitBody,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
const uncertain =
|
||||
(error instanceof ManagedSessionStoreHttpError &&
|
||||
(error.status === 429 || error.status >= 500)) ||
|
||||
error instanceof ManagedSessionStoreTransportError ||
|
||||
error instanceof TypeError ||
|
||||
(error instanceof DOMException &&
|
||||
['AbortError', 'TimeoutError'].includes(error.name));
|
||||
if (!uncertain || attempt === 2) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
const receipt = asRecord(committed, 'commit receipt');
|
||||
@@ -937,6 +949,15 @@ class ManagedSessionStoreHttpClient {
|
||||
try {
|
||||
return (await response.json()) as unknown;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof TypeError ||
|
||||
(error instanceof DOMException &&
|
||||
['AbortError', 'TimeoutError'].includes(error.name))
|
||||
) {
|
||||
throw new ManagedSessionStoreTransportError(
|
||||
`Managed Session Store response failed: ${error.message}.`,
|
||||
);
|
||||
}
|
||||
throw corrupt(
|
||||
`Managed Session Store returned invalid JSON: ${error instanceof Error ? error.message : String(error)}.`,
|
||||
);
|
||||
@@ -966,7 +987,7 @@ class ManagedSessionStoreHttpClient {
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ManagedSessionRecordError(
|
||||
throw new ManagedSessionStoreTransportError(
|
||||
`Managed Session Store request failed: ${error instanceof Error ? error.message : String(error)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
parseMcpConfiguration,
|
||||
parseMcpOperation,
|
||||
} from './managed-mcp-record.js';
|
||||
import {
|
||||
isHookRegistrationStart,
|
||||
isHookRegistrationSuccessor,
|
||||
isHookExecutionStart,
|
||||
isHookExecutionSuccessor,
|
||||
parseHookRegistration,
|
||||
parseHookExecution,
|
||||
} from './managed-hook-record.js';
|
||||
|
||||
// H0c of #12827: how the Session authority keys, chains and projects the
|
||||
// Stage H records of managed-extension-record/1. The shared fixtures in
|
||||
@@ -101,6 +109,24 @@ export const MANAGED_EXTENSION_RECORD_BODIES: Readonly<
|
||||
isStart: isMcpOperationStart,
|
||||
isSuccessor: isMcpOperationSuccessor,
|
||||
}),
|
||||
hook_registration: Object.freeze({
|
||||
taskKind: null,
|
||||
parse: (value: unknown) => {
|
||||
const record = parseHookRegistration(value);
|
||||
return { record, recordId: record.registrationId, run: record.run };
|
||||
},
|
||||
isStart: isHookRegistrationStart,
|
||||
isSuccessor: isHookRegistrationSuccessor,
|
||||
}),
|
||||
hook_execution: Object.freeze({
|
||||
taskKind: null,
|
||||
parse: (value: unknown) => {
|
||||
const record = parseHookExecution(value);
|
||||
return { record, recordId: record.hookExecutionId, run: record.run };
|
||||
},
|
||||
isStart: isHookExecutionStart,
|
||||
isSuccessor: isHookExecutionSuccessor,
|
||||
}),
|
||||
monitor_run: Object.freeze({
|
||||
taskKind: 'monitor',
|
||||
parse: (value: unknown) => {
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
createAwaitActionHarnessCheckpoint,
|
||||
createAwaitRuntimeHarnessCheckpoint,
|
||||
createConsumedRuntimeResultsHarnessCheckpoint,
|
||||
createHookStoppedRuntimeHarnessCheckpoint,
|
||||
createInitialHarnessCheckpoint,
|
||||
createModelOutputCommittedHarnessCheckpoint,
|
||||
createNextTurnReadyHarnessCheckpoint,
|
||||
createResultsReadyHarnessCheckpoint,
|
||||
createTurnSettledHarnessCheckpoint,
|
||||
encodeHarnessCheckpointV1,
|
||||
parseHarnessCheckpointV1,
|
||||
tryParseHarnessCheckpointV1,
|
||||
@@ -458,6 +460,34 @@ describe('harness checkpoint v1', () => {
|
||||
expect(ready.runtime?.bindings[0]?.state).toBe('settled');
|
||||
expect(parseHarnessCheckpointV1(bytesOf(ready))).toEqual(ready);
|
||||
|
||||
const stopped = createHookStoppedRuntimeHarnessCheckpoint({
|
||||
previous: ready,
|
||||
checkpointId: 'ckpt-7',
|
||||
coveredSequence: 6,
|
||||
previousCheckpointId: 'ckpt-6',
|
||||
});
|
||||
expect(stopped.continuation.phase).toBe('turn_settled');
|
||||
expect(stopped.tools).toEqual(ready.tools);
|
||||
expect(stopped.runtime).toEqual(ready.runtime);
|
||||
expect(stopped.tools?.items[0]?.consumed).toBe(false);
|
||||
expect(parseHarnessCheckpointV1(bytesOf(stopped))).toEqual(stopped);
|
||||
expect(() =>
|
||||
createTurnSettledHarnessCheckpoint({
|
||||
previous: ready,
|
||||
checkpointId: 'ckpt-7',
|
||||
coveredSequence: 6,
|
||||
previousCheckpointId: 'ckpt-6',
|
||||
}),
|
||||
).toThrow('every Runtime receipt to be consumed');
|
||||
expect(() =>
|
||||
createHookStoppedRuntimeHarnessCheckpoint({
|
||||
previous: wait,
|
||||
checkpointId: 'ckpt-7',
|
||||
coveredSequence: 6,
|
||||
previousCheckpointId: 'ckpt-6',
|
||||
}),
|
||||
).toThrow('settled Runtime results not yet consumed');
|
||||
|
||||
const consumed = createConsumedRuntimeResultsHarnessCheckpoint({
|
||||
previous: ready,
|
||||
checkpointId: 'ckpt-7',
|
||||
@@ -471,6 +501,14 @@ describe('harness checkpoint v1', () => {
|
||||
consumed: true,
|
||||
});
|
||||
expect(parseHarnessCheckpointV1(bytesOf(consumed))).toEqual(consumed);
|
||||
expect(() =>
|
||||
createHookStoppedRuntimeHarnessCheckpoint({
|
||||
previous: consumed,
|
||||
checkpointId: 'ckpt-8',
|
||||
coveredSequence: 7,
|
||||
previousCheckpointId: 'ckpt-7',
|
||||
}),
|
||||
).toThrow('settled Runtime results not yet consumed');
|
||||
});
|
||||
|
||||
it('settles multiple Runtime executions independently in reverse order', () => {
|
||||
|
||||
@@ -1550,6 +1550,40 @@ export function createTurnSettledHarnessCheckpoint(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/** An after-tool Hook ended the turn before its results reached a model. */
|
||||
export function createHookStoppedRuntimeHarnessCheckpoint(input: {
|
||||
readonly previous: HarnessCheckpointV1;
|
||||
readonly checkpointId: string;
|
||||
readonly coveredSequence: number;
|
||||
readonly previousCheckpointId: string | null;
|
||||
}): HarnessCheckpointV1 {
|
||||
const items = input.previous.tools?.items ?? [];
|
||||
if (
|
||||
input.previous.continuation.phase !== 'results_ready' ||
|
||||
items.length === 0 ||
|
||||
items.some((item) => item.state !== 'settled') ||
|
||||
items.every((item) => item.consumed)
|
||||
) {
|
||||
throw new ManagedSessionRecordError(
|
||||
'Hook stop requires settled Runtime results not yet consumed by a model.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...input.previous,
|
||||
identity: {
|
||||
...input.previous.identity,
|
||||
checkpointId: input.checkpointId,
|
||||
coveredSequence: input.coveredSequence,
|
||||
previousCheckpointId: input.previousCheckpointId,
|
||||
},
|
||||
resume: {
|
||||
...input.previous.resume,
|
||||
throughSequence: input.coveredSequence,
|
||||
},
|
||||
continuation: { phase: 'turn_settled', pendingEventIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* After the original Runtime receipts are present on the next model
|
||||
* request, mark those settled items consumed. Phase stays
|
||||
|
||||
@@ -954,6 +954,63 @@ describe('managed harness factory', () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('settles an after-tool Hook stop without claiming consumption, survives cold reopen and allows the next turn', async () => {
|
||||
const workspace = await createWorkspace();
|
||||
const session = await open(workspace);
|
||||
const handle = createManagedHarnessHandle(session);
|
||||
await handle.ensureRunnable();
|
||||
expect(await handle.settleHookStoppedRuntimeContinuation()).toBeNull();
|
||||
await handle.commitAwaitRuntimeBatch([await runtimeCommit(session)], {
|
||||
turnId: 'turn-1',
|
||||
promptId: 'prompt-1',
|
||||
});
|
||||
expect(await handle.settleHookStoppedRuntimeContinuation()).toBeNull();
|
||||
const bytes = Buffer.from(
|
||||
'{"outcome":"completed","output":"original physical receipt"}',
|
||||
);
|
||||
const outcomeRef = await session.resources.publish(
|
||||
'managed-tool-outcome',
|
||||
bytes,
|
||||
);
|
||||
const ready = await handle.resolveAwaitRuntime('ex-1', outcomeRef);
|
||||
expect(await handle.settleConsumedRuntimeContinuation()).toBeNull();
|
||||
const stopped = await handle.settleHookStoppedRuntimeContinuation();
|
||||
expect(stopped?.continuation.phase).toBe('turn_settled');
|
||||
expect(stopped?.tools).toEqual(ready?.tools);
|
||||
expect(stopped?.tools?.items[0]).toMatchObject({
|
||||
state: 'settled',
|
||||
consumed: false,
|
||||
outcomeRef,
|
||||
});
|
||||
expect(stopped?.runtime).toEqual(ready?.runtime);
|
||||
expect(await session.resources.read(outcomeRef)).toEqual(bytes);
|
||||
expect(await handle.settleHookStoppedRuntimeContinuation()).toBeNull();
|
||||
await session.close();
|
||||
resetManagedRuntimeDispatchGatesForTest();
|
||||
const restored = await open(workspace);
|
||||
try {
|
||||
const successor = createManagedHarnessHandle(restored);
|
||||
const restoredCheckpoint = await successor.ensureRunnable();
|
||||
expect(restoredCheckpoint.continuation.phase).toBe('turn_settled');
|
||||
expect(restoredCheckpoint.tools).toEqual(ready?.tools);
|
||||
expect(await restored.resources.read(outcomeRef)).toEqual(bytes);
|
||||
await successor.commitDurableWait(waitCommit(await waitRefs(restored)), {
|
||||
turnId: 'turn-2',
|
||||
promptId: 'prompt-2',
|
||||
});
|
||||
const next = parseHarnessCheckpointV1(
|
||||
(await restored.authority.readCheckpointState())!,
|
||||
);
|
||||
expect(next.identity).toMatchObject({
|
||||
turnId: 'turn-2',
|
||||
promptId: 'prompt-2',
|
||||
});
|
||||
expect(next.continuation.phase).toBe('await_action');
|
||||
} finally {
|
||||
await restored.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps await_runtime across a cold reopen until results are settled', async () => {
|
||||
const workspace = await createWorkspace();
|
||||
const session = await open(workspace);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createAwaitActionHarnessCheckpoint,
|
||||
createAwaitRuntimeHarnessCheckpoint,
|
||||
createConsumedRuntimeResultsHarnessCheckpoint,
|
||||
createHookStoppedRuntimeHarnessCheckpoint,
|
||||
createInitialHarnessCheckpoint,
|
||||
createModelOutputCommittedHarnessCheckpoint,
|
||||
createResultsReadyHarnessCheckpoint,
|
||||
@@ -220,6 +221,11 @@ export interface ManagedHarnessHandle {
|
||||
* finishes. No-op until every settled receipt is consumed.
|
||||
*/
|
||||
settleConsumedRuntimeContinuation(): Promise<HarnessCheckpointV1 | null>;
|
||||
/**
|
||||
* Trusted Harness calls this only after an original after-tool Hook stop
|
||||
* has settled. Close that continuation without claiming model consumption.
|
||||
*/
|
||||
settleHookStoppedRuntimeContinuation(): Promise<HarnessCheckpointV1 | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -794,6 +800,34 @@ class LocalManagedHarnessHandle implements ManagedHarnessHandle {
|
||||
});
|
||||
}
|
||||
|
||||
async settleHookStoppedRuntimeContinuation(): Promise<HarnessCheckpointV1 | null> {
|
||||
return this.mutateCheckpoint(async () => {
|
||||
this.assertNotDetached();
|
||||
this.assertCurrentActivation();
|
||||
const previous = (await this.requireRunnableAuthorization()).checkpoint;
|
||||
const items = previous.tools?.items ?? [];
|
||||
if (
|
||||
previous.continuation.phase !== 'results_ready' ||
|
||||
items.length === 0 ||
|
||||
items.some((item) => item.state !== 'settled') ||
|
||||
items.every((item) => item.consumed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const identity = this.nextCheckpointIdentity();
|
||||
const checkpoint = createHookStoppedRuntimeHarnessCheckpoint({
|
||||
previous,
|
||||
...identity,
|
||||
});
|
||||
await this.commitHarnessCheckpoint(
|
||||
`harness:hook_stopped:${this.activation.activationId}:${identity.coveredSequence}`,
|
||||
checkpoint,
|
||||
null,
|
||||
);
|
||||
return checkpoint;
|
||||
});
|
||||
}
|
||||
|
||||
private runtimeBoundary(): HarnessDurableWaitBoundary {
|
||||
const committed = this.authority.latestCheckpoint;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HookEventName, HookType } from '../hooks/types.js';
|
||||
import {
|
||||
openManagedSession,
|
||||
type ManagedSession,
|
||||
} from './managed-session-assembly.js';
|
||||
import {
|
||||
ManagedHookActivationController,
|
||||
type ManagedHookModelScope,
|
||||
} from './managed-hook-activation.js';
|
||||
import { createManagedHarnessHandle } from './managed-harness-factory.js';
|
||||
import { TurnBudget } from '../core/turn-budget.js';
|
||||
import { uiTelemetryService, type UiEvent } from '../telemetry/uiTelemetry.js';
|
||||
import { ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { EVENT_API_RESPONSE } from '../telemetry/constants.js';
|
||||
|
||||
const operation = {
|
||||
operationId: 'operation-1',
|
||||
occurrenceId: 'occurrence-1',
|
||||
originTurnId: null,
|
||||
};
|
||||
const evaluation = {
|
||||
...operation,
|
||||
eventName: HookEventName.SessionEnd,
|
||||
model: 'model-1',
|
||||
};
|
||||
const result = {
|
||||
hookConfig: { type: HookType.Prompt as const, prompt: 'allow?' },
|
||||
eventName: HookEventName.SessionEnd,
|
||||
success: true,
|
||||
outcome: 'success' as const,
|
||||
duration: 2,
|
||||
};
|
||||
const sessions: ManagedSession[] = [];
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(sessions.splice(0).map((session) => session.close()));
|
||||
await Promise.all(
|
||||
dirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'hook-model-'));
|
||||
dirs.push(root);
|
||||
const ref = {
|
||||
resourceId: 'resource',
|
||||
kind: 'managed-test',
|
||||
schemaVersion: 1,
|
||||
byteLength: 2,
|
||||
digest: '9'.repeat(64),
|
||||
};
|
||||
const options = {
|
||||
runtimeBaseDir: root,
|
||||
sessionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
transcriptPath: path.join(root, 'session.jsonl'),
|
||||
sessionKey: {
|
||||
tenantId: 't1',
|
||||
workspaceId: 'w1',
|
||||
sessionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
cwd: root,
|
||||
version: 'test',
|
||||
workerId: 'worker-1',
|
||||
activationLeaseDurationMs: 60_000,
|
||||
};
|
||||
const session = await openManagedSession({
|
||||
...options,
|
||||
create: { definitionRef: ref, rootSnapshotRef: ref, createdBy: 'test' },
|
||||
});
|
||||
sessions.push(session);
|
||||
uiTelemetryService.resetSession(options.sessionId);
|
||||
return {
|
||||
session,
|
||||
options,
|
||||
controller: new ManagedHookActivationController(session),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Managed Hook model activation', () => {
|
||||
it('shares the original turn budget and all model usage with lifecycle Hooks after cold restore', async () => {
|
||||
const { session, controller, options } = await fixture();
|
||||
await createManagedHarnessHandle(session).ensureRunnable();
|
||||
const charge = (model: string, tokens: number) =>
|
||||
uiTelemetryService.addEvent(
|
||||
{
|
||||
...new ApiResponseEvent('r1', model, 1, 'turn-1', undefined, {
|
||||
candidatesTokenCount: tokens,
|
||||
totalTokenCount: tokens,
|
||||
}),
|
||||
'event.name': EVENT_API_RESPONSE,
|
||||
} as UiEvent,
|
||||
options.sessionId,
|
||||
);
|
||||
charge('main', 100);
|
||||
const budget = new TurnBudget();
|
||||
await controller.runTurn('turn-1', async (scope) => {
|
||||
await scope.bindBudget(budget, 'finish this +5k');
|
||||
await scope.evaluate(
|
||||
{ ...evaluation, originTurnId: 'turn-1' },
|
||||
async (usage) => {
|
||||
charge('hook', 20);
|
||||
usage({ model: 'hook', usage: { candidatesTokenCount: 20 } });
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const complete = await scope.beginMainAttempt('main');
|
||||
expect(
|
||||
session.authority.lastEventOfKind('model.attempt')?.payload,
|
||||
).toMatchObject({ attemptId: complete.attemptId, state: 'started' });
|
||||
charge('main', 30);
|
||||
await complete(true, [{ candidatesTokenCount: 30 }]);
|
||||
const replacement = new TurnBudget();
|
||||
await scope.bindBudget(replacement, 'rewritten prompt +10k');
|
||||
expect(replacement.current(options.sessionId)).toEqual(
|
||||
budget.current(options.sessionId),
|
||||
);
|
||||
});
|
||||
expect(budget.current(options.sessionId)).toMatchObject({
|
||||
budget: 5_000,
|
||||
outputTokensAtTurnStart: 100,
|
||||
});
|
||||
await session.close();
|
||||
sessions.splice(sessions.indexOf(session), 1);
|
||||
uiTelemetryService.resetSession(options.sessionId);
|
||||
const restored = await openManagedSession(options);
|
||||
sessions.push(restored);
|
||||
const lifecycleBudget = new TurnBudget();
|
||||
await new ManagedHookActivationController(restored).runHookOperation(
|
||||
{ ...operation, operationId: 'lifecycle' },
|
||||
async (scope) => {
|
||||
await scope.bindBudget(lifecycleBudget);
|
||||
expect(lifecycleBudget.current(options.sessionId)).toEqual(
|
||||
budget.current(options.sessionId),
|
||||
);
|
||||
expect(uiTelemetryService.getTotalOutputTokens(options.sessionId)).toBe(
|
||||
150,
|
||||
);
|
||||
await expect(scope.beginMainAttempt('main')).rejects.toThrow(
|
||||
'cannot run the main model loop',
|
||||
);
|
||||
await scope.evaluate(
|
||||
{ ...evaluation, operationId: 'lifecycle' },
|
||||
async (usage) => {
|
||||
charge('hook', 10);
|
||||
usage({ model: 'hook', usage: { candidatesTokenCount: 10 } });
|
||||
return result;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(
|
||||
uiTelemetryService.getTotalOutputTokens(options.sessionId) -
|
||||
lifecycleBudget.current(options.sessionId)!.outputTokensAtTurnStart,
|
||||
).toBe(60);
|
||||
const event = restored.authority.lastEventOfKind('model.attempt')!;
|
||||
const saved = JSON.parse(
|
||||
(
|
||||
await restored.resources.read(event.payload['usageRef'] as never)
|
||||
).toString(),
|
||||
);
|
||||
expect(saved).toMatchObject({
|
||||
operationId: 'lifecycle',
|
||||
originTurnId: null,
|
||||
budget: { budget: 5_000 },
|
||||
attempts: [{ model: 'hook', usage: { candidatesTokenCount: 10 } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('owns a new epoch, preserves its subject on renewal and survives cold restore without a turn', async () => {
|
||||
const { session, controller, options } = await fixture();
|
||||
const initialEpoch = session.activation.epoch;
|
||||
let expired: ManagedHookModelScope | undefined;
|
||||
await controller.runHookOperation(operation, async (scope) => {
|
||||
expired = scope;
|
||||
expect(session.activation.epoch).toBe(initialEpoch + 1);
|
||||
expect(session.authority.currentActivationSubject).toEqual({
|
||||
type: 'hook_operation',
|
||||
operationId: operation.operationId,
|
||||
occurrenceId: operation.occurrenceId,
|
||||
});
|
||||
await session.authority.renewActivation({ leaseDurationMs: 60_000 });
|
||||
expect(session.authority.currentActivationSubject?.type).toBe(
|
||||
'hook_operation',
|
||||
);
|
||||
await expect(
|
||||
scope.evaluate(evaluation, async (usage) => {
|
||||
usage({ totalTokenCount: 7 });
|
||||
return result;
|
||||
}),
|
||||
).resolves.toEqual(result);
|
||||
});
|
||||
expect(session.activation.epoch).toBe(initialEpoch + 2);
|
||||
expect(() => expired!.evaluate(evaluation, async () => result)).toThrow(
|
||||
'active Session model slot',
|
||||
);
|
||||
const events = session.authority.eventsInSequenceRange(
|
||||
1,
|
||||
session.authority.committedSequence,
|
||||
);
|
||||
const attempts = events.filter((event) => event.kind === 'model.attempt');
|
||||
expect(attempts.map((event) => event.payload['state'])).toEqual([
|
||||
'started',
|
||||
'output_committed',
|
||||
]);
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.kind === 'turn.settled' ||
|
||||
event.kind === 'checkpoint.committed',
|
||||
),
|
||||
).toBe(false);
|
||||
const usage = JSON.parse(
|
||||
(
|
||||
await session.resources.read(attempts[1].payload['usageRef'] as never)
|
||||
).toString(),
|
||||
);
|
||||
expect(usage).toMatchObject({
|
||||
...evaluation,
|
||||
attempts: [{ totalTokenCount: 7 }],
|
||||
});
|
||||
expect(await session.authority.harnessRunAuthorization()).toEqual({
|
||||
status: 'initial',
|
||||
});
|
||||
await session.close();
|
||||
sessions.splice(sessions.indexOf(session), 1);
|
||||
const restored = await openManagedSession(options);
|
||||
sessions.push(restored);
|
||||
expect(await restored.authority.harnessRunAuthorization()).toEqual({
|
||||
status: 'initial',
|
||||
});
|
||||
});
|
||||
|
||||
it('shares the Session slot with turns and other controller instances', async () => {
|
||||
const { session, controller } = await fixture();
|
||||
const other = new ManagedHookActivationController(session);
|
||||
await controller.runTurn('turn-1', async () => {
|
||||
await expect(
|
||||
other.runHookOperation(operation, async () => undefined),
|
||||
).rejects.toThrow('slot is busy');
|
||||
await expect(
|
||||
other.runTurn('turn-2', async () => undefined),
|
||||
).rejects.toThrow('slot is busy');
|
||||
});
|
||||
await other.runHookOperation(operation, async () => undefined);
|
||||
});
|
||||
|
||||
it('restores a turn activation when installing a Hook activation fails', async () => {
|
||||
const { session, controller } = await fixture();
|
||||
const failure = new Error('install resource failed');
|
||||
vi.spyOn(session.authority, 'installActivation').mockRejectedValueOnce(
|
||||
failure,
|
||||
);
|
||||
const evaluate = vi.fn();
|
||||
|
||||
await expect(controller.runHookOperation(operation, evaluate)).rejects.toBe(
|
||||
failure,
|
||||
);
|
||||
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(session.authority.currentActivation).toMatchObject({
|
||||
...session.activation,
|
||||
phase: 'active',
|
||||
});
|
||||
expect(session.authority.currentActivationSubject?.type).not.toBe(
|
||||
'hook_operation',
|
||||
);
|
||||
await expect(
|
||||
controller.runTurn('next-turn', async () => 'accepted'),
|
||||
).resolves.toBe('accepted');
|
||||
await expect(
|
||||
controller.runHookOperation(operation, async () => 'retry'),
|
||||
).resolves.toBe('retry');
|
||||
});
|
||||
|
||||
it('serializes prompt Hooks inside a turn and preserves the checkpoint', async () => {
|
||||
const { session, controller } = await fixture();
|
||||
await createManagedHarnessHandle(session).ensureRunnable();
|
||||
const checkpoint = session.authority.latestCheckpoint;
|
||||
const activation = session.activation;
|
||||
let finish!: () => void;
|
||||
const firstFinished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const second = vi.fn(async () => result);
|
||||
await controller.runTurn('turn-1', async (scope) => {
|
||||
const first = scope.evaluate(
|
||||
{ ...evaluation, originTurnId: 'turn-1' },
|
||||
async () => {
|
||||
await firstFinished;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const next = scope.evaluate(
|
||||
{ ...evaluation, operationId: 'operation-2', originTurnId: 'turn-1' },
|
||||
second,
|
||||
);
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await Promise.all([first, next]);
|
||||
});
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(session.activation).toEqual(activation);
|
||||
expect(session.authority.latestCheckpoint).toEqual(checkpoint);
|
||||
expect(
|
||||
session.authority
|
||||
.eventsInSequenceRange(1, session.authority.committedSequence)
|
||||
.filter((event) => event.kind === 'model.attempt')
|
||||
.map((event) => event.payload['state']),
|
||||
).toEqual(['started', 'output_committed', 'started', 'output_committed']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'await_runtime',
|
||||
'await_action',
|
||||
'results_ready',
|
||||
'model_output_committed',
|
||||
])('does not bypass %s recovery', async (phase) => {
|
||||
const { session, controller } = await fixture();
|
||||
const evaluate = vi.fn();
|
||||
const epoch = session.activation.epoch;
|
||||
vi.spyOn(session.authority, 'harnessRunAuthorization').mockResolvedValue({
|
||||
status: 'runnable',
|
||||
checkpoint: { continuation: { phase } },
|
||||
} as never);
|
||||
await expect(
|
||||
controller.runHookOperation(operation, evaluate),
|
||||
).rejects.toThrow('pending Harness recovery');
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(session.activation.epoch).toBe(epoch);
|
||||
});
|
||||
|
||||
it('records failed evaluation as abandoned and refuses replay or cross-origin evaluation', async () => {
|
||||
const { session, controller } = await fixture();
|
||||
await controller.runHookOperation(operation, async (scope) => {
|
||||
await expect(
|
||||
scope.evaluate(
|
||||
{ ...evaluation, originTurnId: 'other-turn' },
|
||||
async () => result,
|
||||
),
|
||||
).rejects.toThrow('does not belong');
|
||||
await expect(
|
||||
scope.evaluate(evaluation, async () => {
|
||||
throw new Error('provider failed');
|
||||
}),
|
||||
).rejects.toThrow('provider failed');
|
||||
await expect(
|
||||
scope.evaluate(evaluation, async () => result),
|
||||
).rejects.toThrow('already exists');
|
||||
});
|
||||
expect(
|
||||
session.authority.lastEventOfKind('model.attempt')?.payload['state'],
|
||||
).toBe('abandoned');
|
||||
expect(await session.authority.harnessRunAuthorization()).toEqual({
|
||||
status: 'initial',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HookExecutionResult } from '../hooks/types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
extractTurnBudgetDirectiveText,
|
||||
parseTurnBudgetDirective,
|
||||
type TurnBudget,
|
||||
type TurnBudgetSnapshot,
|
||||
} from '../core/turn-budget.js';
|
||||
import {
|
||||
uiTelemetryService,
|
||||
type SessionMetrics,
|
||||
} from '../telemetry/uiTelemetry.js';
|
||||
import type { ManagedSession } from './managed-session-assembly.js';
|
||||
import { ManagedSessionConflictError } from './managed-session-authority.js';
|
||||
import type { ManagedSessionDurableRef } from './managed-session-records.js';
|
||||
|
||||
export interface ManagedHookModelOperation {
|
||||
readonly operationId: string;
|
||||
readonly occurrenceId: string;
|
||||
readonly originTurnId: string | null;
|
||||
}
|
||||
|
||||
export type ManagedHookModelScope = Pick<
|
||||
LocalManagedHookModelScope,
|
||||
'evaluate' | 'bindBudget' | 'beginMainAttempt'
|
||||
>;
|
||||
|
||||
export type ManagedMainModelAttempt = ((
|
||||
success: boolean,
|
||||
usage: unknown,
|
||||
) => Promise<void>) & { readonly attemptId: string };
|
||||
|
||||
const sessionBudgets = new WeakMap<
|
||||
ManagedSession['authority'],
|
||||
{ snapshot: TurnBudgetSnapshot | null }
|
||||
>();
|
||||
|
||||
const MODEL_BUDGET_RESOURCE_KINDS = new Set([
|
||||
'managed-hook-model-route',
|
||||
'managed-hook-model-usage',
|
||||
'managed-hosted-model-route',
|
||||
'managed-hosted-model-usage',
|
||||
]);
|
||||
|
||||
class LocalManagedHookModelScope {
|
||||
private open = true;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly session: ManagedSession,
|
||||
private readonly turnId: string | null,
|
||||
private readonly operation: ManagedHookModelOperation | null,
|
||||
private readonly isOwner: () => boolean,
|
||||
) {}
|
||||
|
||||
async bindBudget(budget: TurnBudget, prompt?: string): Promise<void> {
|
||||
this.assertOwner();
|
||||
const sessionId = this.session.authority.sessionHeader.sessionKey.sessionId;
|
||||
let shared = sessionBudgets.get(this.session.authority);
|
||||
if (!shared) {
|
||||
shared = { snapshot: null };
|
||||
let restoredModels = false;
|
||||
const events = this.session.authority.eventsInSequenceRange(
|
||||
1,
|
||||
this.session.authority.committedSequence,
|
||||
);
|
||||
for (const event of [...events].reverse()) {
|
||||
if (event.kind !== 'model.attempt') continue;
|
||||
for (const field of ['usageRef', 'routeRef']) {
|
||||
const ref = event.payload[field] as ManagedSessionDurableRef | null;
|
||||
if (!ref || !MODEL_BUDGET_RESOURCE_KINDS.has(ref.kind)) continue;
|
||||
const saved = JSON.parse(
|
||||
(await this.session.resources.read(ref)).toString(),
|
||||
) as {
|
||||
budget?: TurnBudgetSnapshot | null;
|
||||
models?: SessionMetrics['models'];
|
||||
};
|
||||
shared.snapshot ??= saved.budget ?? null;
|
||||
if (!restoredModels && saved.models) {
|
||||
uiTelemetryService.restoreSessionModelMetrics(
|
||||
sessionId,
|
||||
saved.models,
|
||||
);
|
||||
restoredModels = true;
|
||||
}
|
||||
}
|
||||
if (shared.snapshot && restoredModels) break;
|
||||
}
|
||||
sessionBudgets.set(this.session.authority, shared);
|
||||
}
|
||||
if (prompt !== undefined && shared.snapshot?.promptId !== this.turnId) {
|
||||
if (this.operation || !this.turnId)
|
||||
throw new ManagedSessionConflictError(
|
||||
'Only a turn can begin a model budget.',
|
||||
);
|
||||
const directive = parseTurnBudgetDirective(
|
||||
extractTurnBudgetDirectiveText(prompt),
|
||||
);
|
||||
shared.snapshot = {
|
||||
sessionId,
|
||||
promptId: this.turnId,
|
||||
budget: directive?.total ?? null,
|
||||
...(directive ? { directiveText: directive.text } : {}),
|
||||
outputTokensAtTurnStart:
|
||||
uiTelemetryService.getTotalOutputTokens(sessionId),
|
||||
};
|
||||
}
|
||||
if (shared.snapshot) budget.beginTurn(shared.snapshot);
|
||||
}
|
||||
|
||||
async beginMainAttempt(model: string): Promise<ManagedMainModelAttempt> {
|
||||
this.assertOwner();
|
||||
if (this.operation)
|
||||
throw new ManagedSessionConflictError(
|
||||
'A Hook operation cannot run the main model loop.',
|
||||
);
|
||||
const attemptId = `${this.turnId}:main:${randomUUID()}`;
|
||||
return Object.assign(
|
||||
await this.beginAttempt(attemptId, 'hosted', {
|
||||
turnId: this.turnId,
|
||||
model,
|
||||
}),
|
||||
{ attemptId },
|
||||
);
|
||||
}
|
||||
|
||||
private async beginAttempt(
|
||||
attemptId: string,
|
||||
kind: 'hook' | 'hosted',
|
||||
route: object,
|
||||
): Promise<(success: boolean, usage: unknown) => Promise<void>> {
|
||||
const authority = this.session.authority;
|
||||
const activation = this.session.activation;
|
||||
const budget = sessionBudgets.get(authority)?.snapshot ?? null;
|
||||
const routeRef = await this.session.resources.publish(
|
||||
`managed-${kind}-model-route`,
|
||||
Buffer.from(JSON.stringify({ version: 1, ...route, budget })),
|
||||
);
|
||||
const inputCheckpointRef = authority.latestCheckpoint?.stateRef ?? null;
|
||||
const commit = async (
|
||||
state: 'started' | 'output_committed' | 'abandoned',
|
||||
usageRef: ManagedSessionDurableRef | null,
|
||||
) => {
|
||||
this.assertOwner();
|
||||
await authority.appendExecutionEvent(
|
||||
{
|
||||
operation: 'hostedModelAttempt',
|
||||
commandId: `${attemptId}:${state}`,
|
||||
sessionKey: authority.sessionHeader.sessionKey,
|
||||
contentDigest: routeRef.digest,
|
||||
},
|
||||
(sequence) => ({
|
||||
v: 1,
|
||||
sequence,
|
||||
eventId: `${attemptId}:${state}`,
|
||||
sessionKey: authority.sessionHeader.sessionKey,
|
||||
kind: 'model.attempt',
|
||||
occurredAt: Date.now(),
|
||||
subject: {
|
||||
type: 'activation',
|
||||
scopeId: activation.activationId,
|
||||
...activation,
|
||||
},
|
||||
payload: { attemptId, routeRef, inputCheckpointRef, state, usageRef },
|
||||
}),
|
||||
{ class: 'harness', activation },
|
||||
);
|
||||
};
|
||||
await commit('started', null);
|
||||
return async (success, usage) => {
|
||||
const usageRef = await this.session.resources.publish(
|
||||
`managed-${kind}-model-usage`,
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
...route,
|
||||
attempts: usage,
|
||||
budget,
|
||||
models: uiTelemetryService.getMetricsForSession(
|
||||
authority.sessionHeader.sessionKey.sessionId,
|
||||
).models,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await commit(success ? 'output_committed' : 'abandoned', usageRef);
|
||||
};
|
||||
}
|
||||
|
||||
evaluate(
|
||||
operation: ManagedHookModelOperation & {
|
||||
readonly eventName: string;
|
||||
readonly model: string;
|
||||
},
|
||||
evaluate: (
|
||||
recordUsage: (usage: unknown) => void,
|
||||
) => Promise<HookExecutionResult>,
|
||||
): Promise<HookExecutionResult> {
|
||||
this.assertOwner();
|
||||
const pending = this.queue.then(async () => {
|
||||
this.assertOwner();
|
||||
if (
|
||||
operation.originTurnId !== this.turnId ||
|
||||
(this.operation &&
|
||||
operation.occurrenceId !== this.operation.occurrenceId)
|
||||
) {
|
||||
throw new ManagedSessionConflictError(
|
||||
'Prompt Hook does not belong to this model activation.',
|
||||
);
|
||||
}
|
||||
const authority = this.session.authority;
|
||||
const attemptId = `${operation.operationId}:model`;
|
||||
if (
|
||||
authority
|
||||
.eventsInSequenceRange(1, authority.committedSequence)
|
||||
.some(
|
||||
(event) =>
|
||||
event.kind === 'model.attempt' &&
|
||||
event.payload['attemptId'] === attemptId,
|
||||
)
|
||||
) {
|
||||
throw new ManagedSessionConflictError(
|
||||
'Prompt Hook model attempt already exists; recovery cannot replay it.',
|
||||
);
|
||||
}
|
||||
const complete = await this.beginAttempt(attemptId, 'hook', operation);
|
||||
const usage: unknown[] = [];
|
||||
let result: HookExecutionResult | undefined;
|
||||
try {
|
||||
result = await evaluate((entry) => usage.push(entry));
|
||||
return result;
|
||||
} finally {
|
||||
await complete(
|
||||
result?.success === true || result?.outcome === 'blocking',
|
||||
usage,
|
||||
);
|
||||
}
|
||||
});
|
||||
this.queue = pending.catch(() => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue;
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
private assertOwner(): void {
|
||||
if (!this.open || !this.isOwner()) {
|
||||
throw new ManagedSessionConflictError(
|
||||
'Prompt Hook requires the active Session model slot.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelOwners = new WeakMap<
|
||||
ManagedSession['authority'],
|
||||
LocalManagedHookModelScope
|
||||
>();
|
||||
|
||||
export class ManagedHookActivationController {
|
||||
constructor(private readonly session: ManagedSession) {}
|
||||
|
||||
runTurn<T>(
|
||||
turnId: string,
|
||||
run: (scope: ManagedHookModelScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.run(turnId, null, run);
|
||||
}
|
||||
|
||||
runHookOperation<T>(
|
||||
operation: ManagedHookModelOperation,
|
||||
run: (scope: ManagedHookModelScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.run(operation.originTurnId, operation, run);
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
turnId: string | null,
|
||||
operation: ManagedHookModelOperation | null,
|
||||
run: (scope: ManagedHookModelScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (modelOwners.has(this.session.authority)) {
|
||||
throw new ManagedSessionConflictError('Session model slot is busy.');
|
||||
}
|
||||
const scope: LocalManagedHookModelScope = new LocalManagedHookModelScope(
|
||||
this.session,
|
||||
turnId,
|
||||
operation,
|
||||
() => modelOwners.get(this.session.authority) === scope,
|
||||
);
|
||||
modelOwners.set(this.session.authority, scope);
|
||||
let restoreActivation = false;
|
||||
try {
|
||||
if (operation) {
|
||||
const authorization =
|
||||
await this.session.authority.harnessRunAuthorization();
|
||||
if (
|
||||
authorization.status === 'blocked' ||
|
||||
(authorization.status === 'runnable' &&
|
||||
authorization.checkpoint.continuation.phase !== 'before_model' &&
|
||||
authorization.checkpoint.continuation.phase !== 'turn_settled')
|
||||
) {
|
||||
throw new ManagedSessionConflictError(
|
||||
'Prompt Hook cannot bypass pending Harness recovery.',
|
||||
);
|
||||
}
|
||||
restoreActivation = true;
|
||||
await this.session.replaceActivation({
|
||||
type: 'hook_operation',
|
||||
operationId: operation.operationId,
|
||||
occurrenceId: operation.occurrenceId,
|
||||
});
|
||||
}
|
||||
const activation = this.session.authority.currentActivation;
|
||||
if (
|
||||
activation?.phase !== 'active' ||
|
||||
activation.activationId !== this.session.activation.activationId ||
|
||||
activation.epoch !== this.session.activation.epoch
|
||||
) {
|
||||
throw new ManagedSessionConflictError(
|
||||
'Prompt Hook requires the current Session activation.',
|
||||
);
|
||||
}
|
||||
return await run(scope);
|
||||
} finally {
|
||||
await scope.close();
|
||||
try {
|
||||
if (restoreActivation) await this.session.replaceActivation();
|
||||
} finally {
|
||||
modelOwners.delete(this.session.authority);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookExecutionOutcome,
|
||||
HookInput,
|
||||
HookOutput,
|
||||
PromptHookConfig,
|
||||
} from '../hooks/types.js';
|
||||
import type { OperationGrant } from './managed-extension-record.js';
|
||||
import type { ManagedSessionKey } from './managed-session-records.js';
|
||||
|
||||
export const MANAGED_HOOK_ROUTE = '/internal/managed-runtime/v3/hooks';
|
||||
export const MANAGED_HOOK_MAX_RUNNING = 16;
|
||||
export const MANAGED_HOOK_MAX_REQUEST_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export interface RegisteredHandlerRef {
|
||||
readonly handlerId: string;
|
||||
readonly handlerRevision: number;
|
||||
}
|
||||
|
||||
export interface ManagedHookCatalogPin {
|
||||
readonly catalogId: string;
|
||||
readonly catalogRevision: number;
|
||||
readonly definitionDigest: string;
|
||||
}
|
||||
|
||||
export interface ManagedHookDescriptor {
|
||||
readonly source?: HooksConfigSource;
|
||||
readonly sourceTrusted?: boolean;
|
||||
readonly enabled?: boolean;
|
||||
readonly agentScope?: string;
|
||||
readonly owner?: {
|
||||
readonly sessionId: string;
|
||||
readonly agentId: string | null;
|
||||
};
|
||||
readonly plannerKey?: string;
|
||||
readonly hookId: string;
|
||||
readonly eventName: HookEventName;
|
||||
readonly matcher?: string;
|
||||
readonly sequential: boolean;
|
||||
readonly onceKey: string | null;
|
||||
readonly failClosed: boolean;
|
||||
readonly async: boolean;
|
||||
readonly config:
|
||||
| PromptHookConfig
|
||||
| {
|
||||
readonly type: 'command' | 'http' | 'function';
|
||||
readonly name?: string;
|
||||
};
|
||||
readonly handler?: RegisteredHandlerRef;
|
||||
}
|
||||
|
||||
export interface ManagedHookCatalog extends ManagedHookCatalogPin {
|
||||
readonly hooks: readonly ManagedHookDescriptor[];
|
||||
}
|
||||
|
||||
interface Identity {
|
||||
readonly sessionKey: ManagedSessionKey;
|
||||
readonly operationId: string;
|
||||
}
|
||||
|
||||
export interface ManagedHookCatalogRequest extends Identity {
|
||||
readonly kind: 'hook-catalog';
|
||||
readonly pin: ManagedHookCatalogPin;
|
||||
}
|
||||
|
||||
export interface ManagedHookExecute extends Identity {
|
||||
readonly kind: 'hook-execute';
|
||||
readonly pin: ManagedHookCatalogPin;
|
||||
readonly hookId: string;
|
||||
readonly input: HookInput;
|
||||
readonly grant: OperationGrant;
|
||||
}
|
||||
|
||||
export type ManagedHookLookup = Identity & {
|
||||
readonly targetOperationId: string;
|
||||
} & ({ readonly kind: 'hook-status' } | { readonly kind: 'hook-cancel' });
|
||||
|
||||
export type ManagedHookControl =
|
||||
| ManagedHookCatalogRequest
|
||||
| ManagedHookExecute
|
||||
| ManagedHookLookup;
|
||||
|
||||
export interface ManagedHookResult {
|
||||
readonly success: boolean;
|
||||
readonly outcome: HookExecutionOutcome;
|
||||
readonly duration: number;
|
||||
readonly output?: HookOutput;
|
||||
readonly stdout?: string;
|
||||
readonly stderr?: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface ManagedHookOperationView {
|
||||
readonly operationId: string;
|
||||
readonly state: 'running' | 'settled' | 'outcome_unknown';
|
||||
readonly catalog?: ManagedHookCatalog;
|
||||
readonly result?: ManagedHookResult;
|
||||
readonly error?: { readonly code: string };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MANAGED_EXTENSION_RECORD_BODIES } from './managed-extension-projection.js';
|
||||
import { MANAGED_SESSION_ENABLED_DOMAINS } from './managed-session-records.js';
|
||||
|
||||
type Domain = 'hook_registration' | 'hook_execution';
|
||||
interface Fixture {
|
||||
id: string;
|
||||
domain: Domain;
|
||||
patch: Record<string, unknown>;
|
||||
valid: boolean;
|
||||
start: boolean;
|
||||
}
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./contracts/managed-hook-record-v1.fixtures.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
) as {
|
||||
templates: Record<Domain, Record<string, unknown>>;
|
||||
cases: Fixture[];
|
||||
successors: Array<
|
||||
Omit<Fixture, 'patch' | 'start'> & {
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function merge(
|
||||
base: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const value = structuredClone(base ?? {});
|
||||
for (const [key, replacement] of Object.entries(patch)) {
|
||||
value[key] =
|
||||
replacement !== null &&
|
||||
typeof replacement === 'object' &&
|
||||
!Array.isArray(replacement)
|
||||
? merge(
|
||||
value[key] as Record<string, unknown>,
|
||||
replacement as Record<string, unknown>,
|
||||
)
|
||||
: replacement;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('managed-hook-record/1 shared contract', () => {
|
||||
it('enables both domains without projecting Session tasks', () => {
|
||||
for (const domain of ['hook_registration', 'hook_execution'] as const) {
|
||||
expect(MANAGED_SESSION_ENABLED_DOMAINS).toContain(domain);
|
||||
expect(MANAGED_EXTENSION_RECORD_BODIES[domain]!.taskKind).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(fixtures.cases)('$id', (fixture) => {
|
||||
const body = MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!;
|
||||
const record = merge(fixtures.templates[fixture.domain], fixture.patch);
|
||||
if (fixture.valid) expect(() => body.parse(record)).not.toThrow();
|
||||
else expect(() => body.parse(record)).toThrow();
|
||||
expect(body.isStart(record)).toBe(fixture.start);
|
||||
});
|
||||
|
||||
it.each(fixtures.successors)('$id', (fixture) => {
|
||||
const template = fixtures.templates[fixture.domain];
|
||||
expect(
|
||||
MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!.isSuccessor(
|
||||
merge(template, fixture.before),
|
||||
merge(template, fixture.after),
|
||||
),
|
||||
).toBe(fixture.valid);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
isExtensionRunStart,
|
||||
isExtensionRunSuccessor,
|
||||
parseExtensionRun,
|
||||
type ExtensionRun,
|
||||
} from './managed-extension-record.js';
|
||||
import {
|
||||
assertManagedSessionDurableRef,
|
||||
assertManagedSessionSequence,
|
||||
assertManagedSessionStableId,
|
||||
ManagedSessionRecordError,
|
||||
type ManagedSessionDurableRef,
|
||||
type ManagedSessionJsonValue,
|
||||
} from './managed-session-records.js';
|
||||
|
||||
export interface HookRegistration {
|
||||
readonly registrationId: string;
|
||||
readonly catalogId: string;
|
||||
readonly catalogRevision: number;
|
||||
readonly catalogRef: ManagedSessionDurableRef;
|
||||
readonly run: ExtensionRun;
|
||||
}
|
||||
|
||||
export interface HookExecution {
|
||||
readonly hookExecutionId: string;
|
||||
readonly occurrenceId: string;
|
||||
readonly runtimeSessionId: string;
|
||||
readonly registrationId: string;
|
||||
readonly eventName: string;
|
||||
readonly ordinal: number;
|
||||
readonly hookId: string;
|
||||
readonly planRef: ManagedSessionDurableRef;
|
||||
readonly inputRef: ManagedSessionDurableRef;
|
||||
readonly resultRef: ManagedSessionDurableRef | null;
|
||||
readonly onceKey: string | null;
|
||||
readonly cancelRequested: boolean;
|
||||
readonly run: ExtensionRun;
|
||||
}
|
||||
|
||||
const REGISTRATION_KEYS = [
|
||||
'registrationId',
|
||||
'catalogId',
|
||||
'catalogRevision',
|
||||
'catalogRef',
|
||||
'run',
|
||||
] as const;
|
||||
const EXECUTION_KEYS = [
|
||||
'hookExecutionId',
|
||||
'occurrenceId',
|
||||
'runtimeSessionId',
|
||||
'registrationId',
|
||||
'eventName',
|
||||
'ordinal',
|
||||
'hookId',
|
||||
'planRef',
|
||||
'inputRef',
|
||||
'resultRef',
|
||||
'onceKey',
|
||||
'cancelRequested',
|
||||
'run',
|
||||
] as const;
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new ManagedSessionRecordError(message);
|
||||
}
|
||||
|
||||
function closed<Key extends string>(
|
||||
value: unknown,
|
||||
keys: readonly Key[],
|
||||
): Record<Key, ManagedSessionJsonValue> {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null) ||
|
||||
Object.keys(value).length !== keys.length ||
|
||||
Object.keys(value).some((key) => !keys.includes(key as Key))
|
||||
)
|
||||
fail(`Hook record must have exactly the keys ${keys.join(', ')}.`);
|
||||
return { ...value } as Record<Key, ManagedSessionJsonValue>;
|
||||
}
|
||||
|
||||
function ref(value: ManagedSessionJsonValue, label: string) {
|
||||
return Object.freeze(assertManagedSessionDurableRef(value, label));
|
||||
}
|
||||
|
||||
function pinnedRun(value: unknown, effectId: string) {
|
||||
const run = parseExtensionRun(value);
|
||||
if (
|
||||
run.definition === null ||
|
||||
run.effectId !== effectId ||
|
||||
run.executionCallId !== null ||
|
||||
run.dispatchId !== null ||
|
||||
run.deliveryId !== null ||
|
||||
run.delivery !== null
|
||||
) {
|
||||
fail(
|
||||
'Hook run must pin its catalog, identify its effect and have no delivery.',
|
||||
);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export function parseHookRegistration(value: unknown): HookRegistration {
|
||||
const body = closed(value, REGISTRATION_KEYS);
|
||||
const registrationId = assertManagedSessionStableId(
|
||||
body.registrationId,
|
||||
'registrationId',
|
||||
);
|
||||
const catalogId = assertManagedSessionStableId(body.catalogId, 'catalogId');
|
||||
const catalogRevision = assertManagedSessionSequence(
|
||||
body.catalogRevision,
|
||||
'catalogRevision',
|
||||
);
|
||||
const catalogRef = ref(body.catalogRef, 'catalogRef');
|
||||
const run = pinnedRun(body.run, registrationId);
|
||||
if (
|
||||
catalogRevision < 1 ||
|
||||
run.definition!.definitionId !== catalogId ||
|
||||
run.definition!.definitionRevision !== catalogRevision ||
|
||||
run.execution !== null
|
||||
) {
|
||||
fail(
|
||||
'Hook registration must pin its catalog resource and have no execution.',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
registrationId,
|
||||
catalogId,
|
||||
catalogRevision,
|
||||
catalogRef,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHookExecution(value: unknown): HookExecution {
|
||||
const body = closed(value, EXECUTION_KEYS);
|
||||
const hookExecutionId = assertManagedSessionStableId(
|
||||
body.hookExecutionId,
|
||||
'hookExecutionId',
|
||||
);
|
||||
const run = pinnedRun(body.run, hookExecutionId);
|
||||
if (run.execution === null)
|
||||
fail('Hook execution must have an execution state.');
|
||||
if (typeof body.cancelRequested !== 'boolean')
|
||||
fail('Hook cancelRequested must be boolean.');
|
||||
const resultRef =
|
||||
body.resultRef === null ? null : ref(body.resultRef, 'resultRef');
|
||||
if (
|
||||
(run.state === 'settled' && resultRef === null) ||
|
||||
(resultRef !== null && run.execution !== 'settled')
|
||||
) {
|
||||
fail(
|
||||
'Hook result requires a settled execution; successful executions need a result.',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
hookExecutionId,
|
||||
occurrenceId: assertManagedSessionStableId(
|
||||
body.occurrenceId,
|
||||
'occurrenceId',
|
||||
),
|
||||
runtimeSessionId: assertManagedSessionStableId(
|
||||
body.runtimeSessionId,
|
||||
'runtimeSessionId',
|
||||
),
|
||||
registrationId: assertManagedSessionStableId(
|
||||
body.registrationId,
|
||||
'registrationId',
|
||||
),
|
||||
eventName: assertManagedSessionStableId(body.eventName, 'eventName'),
|
||||
ordinal: assertManagedSessionSequence(body.ordinal, 'ordinal'),
|
||||
hookId: assertManagedSessionStableId(body.hookId, 'hookId'),
|
||||
planRef: ref(body.planRef, 'planRef'),
|
||||
inputRef: ref(body.inputRef, 'inputRef'),
|
||||
resultRef,
|
||||
onceKey:
|
||||
body.onceKey === null
|
||||
? null
|
||||
: assertManagedSessionStableId(body.onceKey, 'onceKey'),
|
||||
cancelRequested: body.cancelRequested,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
function accepts(check: () => boolean): boolean {
|
||||
try {
|
||||
return check();
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedSessionRecordError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function isHookRegistrationStart(value: unknown): boolean {
|
||||
return accepts(() => isExtensionRunStart(parseHookRegistration(value).run));
|
||||
}
|
||||
|
||||
export function isHookExecutionStart(value: unknown): boolean {
|
||||
return accepts(() => {
|
||||
const record = parseHookExecution(value);
|
||||
return (
|
||||
isExtensionRunStart(record.run) &&
|
||||
record.resultRef === null &&
|
||||
!record.cancelRequested
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isHookRegistrationSuccessor(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
): boolean {
|
||||
return accepts(() => {
|
||||
const before = parseHookRegistration(previous);
|
||||
const after = parseHookRegistration(next);
|
||||
return (
|
||||
!['settled', 'failed', 'cancelled'].includes(before.run.state) &&
|
||||
REGISTRATION_KEYS.filter((key) => key !== 'run').every((key) =>
|
||||
same(before[key], after[key]),
|
||||
) &&
|
||||
isExtensionRunSuccessor(before.run, after.run)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isHookExecutionSuccessor(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
): boolean {
|
||||
return accepts(() => {
|
||||
const before = parseHookExecution(previous);
|
||||
const after = parseHookExecution(next);
|
||||
return (
|
||||
EXECUTION_KEYS.filter(
|
||||
(key) =>
|
||||
key !== 'run' && key !== 'resultRef' && key !== 'cancelRequested',
|
||||
).every((key) => same(before[key], after[key])) &&
|
||||
isExtensionRunSuccessor(before.run, after.run) &&
|
||||
(!before.cancelRequested || after.cancelRequested) &&
|
||||
(before.resultRef === null || same(before.resultRef, after.resultRef)) &&
|
||||
(!['settled', 'failed', 'cancelled'].includes(before.run.state) ||
|
||||
same(before, after))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type {
|
||||
ManagedSessionDurableRef,
|
||||
ManagedSessionKey,
|
||||
ManagedSessionSubject,
|
||||
} from './managed-session-records.js';
|
||||
import { ManagedSessionRecordError } from './managed-session-records.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
@@ -115,7 +116,7 @@ export interface ManagedSession {
|
||||
* Releases the current activation and installs a successor. The next Harness
|
||||
* handle must present the returned identity; the sink names it automatically.
|
||||
*/
|
||||
replaceActivation(): Promise<{
|
||||
replaceActivation(subject?: ManagedSessionSubject): Promise<{
|
||||
readonly activationId: string;
|
||||
readonly epoch: number;
|
||||
}>;
|
||||
@@ -214,12 +215,13 @@ export async function openManagedSession(
|
||||
return activation;
|
||||
},
|
||||
releaseActivation: () => authority.releaseActivation(),
|
||||
async replaceActivation() {
|
||||
async replaceActivation(subject) {
|
||||
await authority.releaseActivation();
|
||||
activation = await authority.installActivation({
|
||||
activationId: randomUUID(),
|
||||
workerId: options.workerId,
|
||||
leaseDurationMs: options.activationLeaseDurationMs,
|
||||
subject,
|
||||
});
|
||||
return activation;
|
||||
},
|
||||
|
||||
@@ -742,25 +742,25 @@ describe('managed session authority Stage H records', () => {
|
||||
const harness = await createHarness();
|
||||
await withAuthority(harness, async (authority) => {
|
||||
const recordRef = await harness.store.publish(
|
||||
'managed-hook_execution',
|
||||
'managed-schedule',
|
||||
Buffer.from('{}', 'utf8'),
|
||||
);
|
||||
const sequence = authority.committedSequence;
|
||||
await expect(
|
||||
authority.appendExecution(
|
||||
command('raw-hook'),
|
||||
command('raw-schedule'),
|
||||
[
|
||||
{
|
||||
v: 1,
|
||||
sequence: sequence + 1,
|
||||
eventId: 'hook-execution-1',
|
||||
eventId: 'schedule-1',
|
||||
sessionKey,
|
||||
kind: 'domain.committed',
|
||||
occurredAt: harness.now,
|
||||
payload: {
|
||||
domain: 'hook_execution',
|
||||
domain: 'schedule',
|
||||
version: 1,
|
||||
operationId: 'raw-hook',
|
||||
operationId: 'raw-schedule',
|
||||
recordRef,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { SessionWriterLease } from '../services/session-writer-lease.js';
|
||||
import { LocalManagedSessionAuthority } from './managed-session-authority.js';
|
||||
import { LocalManagedSessionResourceStore } from './managed-session-resources.js';
|
||||
import {
|
||||
type HookExecution,
|
||||
type HookRegistration,
|
||||
} from './managed-hook-record.js';
|
||||
|
||||
const directories: string[] = [];
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
};
|
||||
const actor = { class: 'trusted_entry' } as const;
|
||||
const templates = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./contracts/managed-hook-record-v1.fixtures.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).templates as {
|
||||
hook_registration: HookRegistration;
|
||||
hook_execution: HookExecution;
|
||||
};
|
||||
function command(commandId: string) {
|
||||
return {
|
||||
operation: 'commitHookRecord',
|
||||
commandId,
|
||||
sessionKey,
|
||||
contentDigest: 'd'.repeat(64),
|
||||
};
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
async function setup() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'qwen-hook-authority-'));
|
||||
directories.push(root);
|
||||
const runtimeBaseDir = path.join(root, 'runtime');
|
||||
const transcriptPath = path.join(
|
||||
root,
|
||||
'chats',
|
||||
`${sessionKey.sessionId}.jsonl`,
|
||||
);
|
||||
await mkdir(runtimeBaseDir, { recursive: true });
|
||||
await mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
const resources = LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir,
|
||||
sessionKey,
|
||||
});
|
||||
const open = async (create: boolean) => {
|
||||
const lease = await SessionWriterLease.acquire({
|
||||
runtimeBaseDir,
|
||||
sessionId: sessionKey.sessionId,
|
||||
transcriptPath,
|
||||
});
|
||||
try {
|
||||
const authority = await LocalManagedSessionAuthority.open({
|
||||
lease,
|
||||
sessionKey,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
resources,
|
||||
...(create
|
||||
? {
|
||||
create: {
|
||||
definitionRef: await resources.publish(
|
||||
'definition',
|
||||
Buffer.from('{}'),
|
||||
),
|
||||
rootSnapshotRef: await resources.publish(
|
||||
'root',
|
||||
Buffer.from('{}'),
|
||||
),
|
||||
createdBy: 'daemon',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return { lease, authority };
|
||||
} catch (error) {
|
||||
await lease.release();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const data = await resources.publish('hook-data', Buffer.from('{}'));
|
||||
const registration: HookRegistration = {
|
||||
...templates.hook_registration,
|
||||
catalogRef: data,
|
||||
};
|
||||
const execution: HookExecution = {
|
||||
...templates.hook_execution,
|
||||
planRef: data,
|
||||
inputRef: data,
|
||||
};
|
||||
return { resources, open, data, registration, execution };
|
||||
}
|
||||
|
||||
it('pins Hook plans, consumes once intents atomically and recovers unknown execution without tasks', async () => {
|
||||
const { open, data, registration, execution, resources } = await setup();
|
||||
const first = await open(true);
|
||||
let blocked: HookExecution;
|
||||
const commit = (id: string, record: HookExecution) =>
|
||||
first.authority.commitExtensionRecord(
|
||||
command(id),
|
||||
{ domain: 'hook_execution', record },
|
||||
actor,
|
||||
);
|
||||
const another = (
|
||||
id: string,
|
||||
patch: Partial<HookExecution> = {},
|
||||
): HookExecution => ({
|
||||
...execution,
|
||||
...patch,
|
||||
hookExecutionId: id,
|
||||
run: { ...execution.run, effectId: id },
|
||||
});
|
||||
try {
|
||||
await expect(commit('not-registered', execution)).rejects.toThrow(
|
||||
'settled committed registration',
|
||||
);
|
||||
for (const state of ['admitted', 'running', 'settled'] as const) {
|
||||
const receipt = await first.authority.commitExtensionRecord(
|
||||
command(`register-${state}`),
|
||||
{
|
||||
domain: 'hook_registration',
|
||||
record: { ...registration, run: { ...registration.run, state } },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
expect(receipt.taskId).toBeNull();
|
||||
}
|
||||
await expect(
|
||||
first.authority.commitExtensionRecord(
|
||||
command('conflicting-definition'),
|
||||
{
|
||||
domain: 'hook_registration',
|
||||
record: {
|
||||
...registration,
|
||||
registrationId: 'other',
|
||||
run: {
|
||||
...registration.run,
|
||||
effectId: 'other',
|
||||
definition: {
|
||||
...registration.run.definition!,
|
||||
definitionDigest: 'c'.repeat(64),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('two definition digests');
|
||||
await expect(
|
||||
commit('wrong-pin', {
|
||||
...execution,
|
||||
run: {
|
||||
...execution.run,
|
||||
definition: { ...execution.run.definition!, definitionRevision: 2 },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('settled committed registration');
|
||||
for (const field of ['planRef', 'inputRef'] as const) {
|
||||
await expect(
|
||||
commit(`missing-${field}`, {
|
||||
...execution,
|
||||
[field]: { ...data, resourceId: 'missing' },
|
||||
}),
|
||||
).rejects.toThrow('not present');
|
||||
}
|
||||
const intents = await Promise.allSettled([
|
||||
commit('execution-1', execution),
|
||||
commit(
|
||||
'execution-2',
|
||||
another('execution-2', { occurrenceId: 'occurrence-2' }),
|
||||
),
|
||||
]);
|
||||
expect(intents.map((result) => result.status)).toEqual([
|
||||
'fulfilled',
|
||||
'rejected',
|
||||
]);
|
||||
expect((intents[1] as PromiseRejectedResult).reason.message).toContain(
|
||||
'onceKey',
|
||||
);
|
||||
expect((await commit('execution-1', execution)).receipt.replayed).toBe(
|
||||
true,
|
||||
);
|
||||
const differentPlan = await resources.publish(
|
||||
'hook-plan',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
for (const patch of [
|
||||
{ ordinal: 0 },
|
||||
{ ordinal: 1, eventName: 'AfterTool' },
|
||||
{ ordinal: 1, planRef: differentPlan },
|
||||
]) {
|
||||
await expect(
|
||||
commit(
|
||||
'bad-occurrence',
|
||||
another('bad-occurrence', { ...patch, onceKey: null }),
|
||||
),
|
||||
).rejects.toThrow('unique ordinals');
|
||||
}
|
||||
const running: HookExecution = {
|
||||
...execution,
|
||||
run: {
|
||||
...execution.run,
|
||||
state: 'running',
|
||||
execution: 'dispatch_started',
|
||||
},
|
||||
};
|
||||
await commit('execution-dispatch', running);
|
||||
blocked = {
|
||||
...running,
|
||||
run: {
|
||||
...running.run,
|
||||
state: 'recovery_blocked',
|
||||
execution: 'outcome_unknown',
|
||||
reason: 'outcome_unknown',
|
||||
},
|
||||
};
|
||||
await commit('execution-unknown', blocked);
|
||||
expect(first.authority.taskViews()).toEqual([]);
|
||||
} finally {
|
||||
await first.lease.release();
|
||||
}
|
||||
const reopened = await open(false);
|
||||
try {
|
||||
expect(
|
||||
reopened.authority.extensionRecord('hook_execution', 'execution-1'),
|
||||
).toMatchObject({ record: blocked!, task: null });
|
||||
await expect(
|
||||
reopened.authority.commitExtensionRecord(
|
||||
command('once-after-recovery'),
|
||||
{
|
||||
domain: 'hook_execution',
|
||||
record: another('once-after-recovery', {
|
||||
occurrenceId: 'occurrence-2',
|
||||
}),
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('onceKey');
|
||||
const settled: HookExecution = {
|
||||
...blocked!,
|
||||
resultRef: data,
|
||||
run: {
|
||||
...blocked!.run,
|
||||
state: 'settled',
|
||||
execution: 'settled',
|
||||
reason: null,
|
||||
},
|
||||
};
|
||||
await reopened.authority.commitExtensionRecord(
|
||||
command('late-result'),
|
||||
{ domain: 'hook_execution', record: settled },
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
reopened.authority.commitExtensionRecord(
|
||||
command('rewrite-result'),
|
||||
{
|
||||
domain: 'hook_execution',
|
||||
record: { ...settled, cancelRequested: true },
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('cannot follow');
|
||||
expect(reopened.authority.taskViews()).toEqual([]);
|
||||
} finally {
|
||||
await reopened.lease.release();
|
||||
}
|
||||
const final = await open(false);
|
||||
try {
|
||||
expect(
|
||||
final.authority.extensionRecord('hook_execution', 'execution-1')?.run,
|
||||
).toMatchObject({ state: 'settled', runtime: null });
|
||||
} finally {
|
||||
await final.lease.release();
|
||||
}
|
||||
await rm(path.join(resources.sessionRoot, data.kind, data.resourceId));
|
||||
await expect(open(false)).rejects.toThrow('not present');
|
||||
});
|
||||
@@ -55,12 +55,17 @@ import {
|
||||
type ManagedSessionEventKind,
|
||||
type ManagedSessionHeader,
|
||||
type ManagedSessionKey,
|
||||
type ManagedSessionSubject,
|
||||
} from './managed-session-records.js';
|
||||
import { readManagedBranchCheckpoint } from './managed-session-resources.js';
|
||||
import {
|
||||
parseMcpConfiguration,
|
||||
parseMcpOperation,
|
||||
} from './managed-mcp-record.js';
|
||||
import {
|
||||
parseHookRegistration,
|
||||
parseHookExecution,
|
||||
} from './managed-hook-record.js';
|
||||
import {
|
||||
managedSessionActivationStateFrom,
|
||||
managedSessionCommandKey,
|
||||
@@ -362,6 +367,7 @@ export class LocalManagedSessionAuthority {
|
||||
private readonly checkpointSequences = new Map<string, number>();
|
||||
private checkpoint: ManagedSessionCheckpoint | undefined;
|
||||
private hasContinuation = false;
|
||||
private readonly hookOperationActivations = new Set<string>();
|
||||
private compactedThrough = 0;
|
||||
private readonly domainRecords = new Map<
|
||||
string,
|
||||
@@ -1330,7 +1336,7 @@ export class LocalManagedSessionAuthority {
|
||||
if (replayed !== undefined) return replayed;
|
||||
assertManagedSessionDomainEnabled(request.domain);
|
||||
const parsed = body.parse(request.record);
|
||||
await this.verifyMcpResources(request.domain, parsed.record);
|
||||
await this.verifyExtensionResources(request.domain, parsed.record);
|
||||
this.assertExtensionRevision(
|
||||
request.domain,
|
||||
body,
|
||||
@@ -1546,6 +1552,51 @@ export class LocalManagedSessionAuthority {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (domain === 'hook_registration') {
|
||||
for (const registration of this.extensionRecordsInDomain(domain)) {
|
||||
if (
|
||||
!isDefinitionPinConsistent(
|
||||
registration.run.definition,
|
||||
parsed.run.definition,
|
||||
)
|
||||
) {
|
||||
reject('A Hook catalog revision cannot name two definition digests.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (domain === 'hook_execution' && previous === undefined) {
|
||||
const execution = parseHookExecution(parsed.record);
|
||||
const registration = this.extensionRecord(
|
||||
'hook_registration',
|
||||
execution.registrationId,
|
||||
);
|
||||
if (
|
||||
registration?.run.state !== 'settled' ||
|
||||
JSON.stringify(registration.run.definition) !==
|
||||
JSON.stringify(execution.run.definition)
|
||||
) {
|
||||
reject(
|
||||
'Hook execution must bind to its settled committed registration.',
|
||||
);
|
||||
}
|
||||
for (const committed of this.extensionRecordsInDomain(domain)) {
|
||||
const other = parseHookExecution(committed.record);
|
||||
if (execution.onceKey !== null && execution.onceKey === other.onceKey) {
|
||||
reject('Hook onceKey is already consumed in this Session.');
|
||||
}
|
||||
if (
|
||||
other.occurrenceId === execution.occurrenceId &&
|
||||
(other.ordinal === execution.ordinal ||
|
||||
other.registrationId !== execution.registrationId ||
|
||||
other.eventName !== execution.eventName ||
|
||||
JSON.stringify(other.planRef) !== JSON.stringify(execution.planRef))
|
||||
) {
|
||||
reject(
|
||||
'Hook occurrence must keep its registration, event and plan, with unique ordinals.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (domain === 'mcp_operation' && previous === undefined) {
|
||||
const operation = parseMcpOperation(parsed.record);
|
||||
const configuration = this.extensionRecord(
|
||||
@@ -1668,7 +1719,7 @@ export class LocalManagedSessionAuthority {
|
||||
MANAGED_SESSION_LIMITS.maxEventBytes,
|
||||
),
|
||||
);
|
||||
await this.verifyMcpResources(domain, parsed.record);
|
||||
await this.verifyExtensionResources(domain, parsed.record);
|
||||
this.assertExtensionRevision(
|
||||
domain,
|
||||
body,
|
||||
@@ -1691,7 +1742,7 @@ export class LocalManagedSessionAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMcpResources(
|
||||
private async verifyExtensionResources(
|
||||
domain: ManagedSessionDomain,
|
||||
record: unknown,
|
||||
): Promise<void> {
|
||||
@@ -1703,7 +1754,15 @@ export class LocalManagedSessionAuthority {
|
||||
parseMcpOperation(record).argsRef,
|
||||
parseMcpOperation(record).resultRef,
|
||||
]
|
||||
: [];
|
||||
: domain === 'hook_registration'
|
||||
? [parseHookRegistration(record).catalogRef]
|
||||
: domain === 'hook_execution'
|
||||
? [
|
||||
parseHookExecution(record).planRef,
|
||||
parseHookExecution(record).inputRef,
|
||||
parseHookExecution(record).resultRef,
|
||||
]
|
||||
: [];
|
||||
for (const ref of refs) {
|
||||
if (ref !== null) await this.resources!.read(ref);
|
||||
}
|
||||
@@ -1955,6 +2014,7 @@ export class LocalManagedSessionAuthority {
|
||||
* whether the holder is still there.
|
||||
*/
|
||||
readonly leaseDurationMs: number;
|
||||
readonly subject?: ManagedSessionSubject;
|
||||
}): Promise<{ activationId: string; epoch: number }> {
|
||||
const epoch = (this.activation?.epoch ?? 0) + 1;
|
||||
const installRef = await this.publishActivationBody(
|
||||
@@ -1965,6 +2025,7 @@ export class LocalManagedSessionAuthority {
|
||||
epoch,
|
||||
workerId: input.workerId,
|
||||
leaseDurationMs: input.leaseDurationMs,
|
||||
...(input.subject ? { subject: input.subject } : {}),
|
||||
},
|
||||
);
|
||||
await this.commitActivation({
|
||||
@@ -1977,6 +2038,7 @@ export class LocalManagedSessionAuthority {
|
||||
installRef,
|
||||
boundaryRef: null,
|
||||
operation: 'installActivation',
|
||||
subject: input.subject,
|
||||
});
|
||||
return { activationId: input.activationId, epoch };
|
||||
}
|
||||
@@ -2008,6 +2070,7 @@ export class LocalManagedSessionAuthority {
|
||||
installRef: current.installRef,
|
||||
boundaryRef: null,
|
||||
operation: 'renewActivation',
|
||||
subject: this.currentActivationSubject,
|
||||
renewalSeq,
|
||||
});
|
||||
return this.activation;
|
||||
@@ -2050,6 +2113,7 @@ export class LocalManagedSessionAuthority {
|
||||
installRef: null,
|
||||
boundaryRef,
|
||||
operation: 'releaseActivation',
|
||||
subject: this.currentActivationSubject,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2066,6 +2130,12 @@ export class LocalManagedSessionAuthority {
|
||||
return store.publish(kind, Buffer.from(JSON.stringify(body), 'utf8'));
|
||||
}
|
||||
|
||||
get currentActivationSubject(): ManagedSessionSubject | undefined {
|
||||
return this.lastEventOfKind('activation.changed')?.payload['subject'] as
|
||||
| ManagedSessionSubject
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private async commitActivation(input: {
|
||||
readonly activationId: string;
|
||||
readonly epoch: number;
|
||||
@@ -2077,6 +2147,7 @@ export class LocalManagedSessionAuthority {
|
||||
readonly boundaryRef: ManagedSessionDurableRef | null;
|
||||
readonly operation: string;
|
||||
readonly renewalSeq?: number;
|
||||
readonly subject?: ManagedSessionSubject;
|
||||
}): Promise<void> {
|
||||
// A renewal repeats the install's phase under the same activation, so it
|
||||
// needs its own command and event identity or the log's idempotency and
|
||||
@@ -2101,7 +2172,7 @@ export class LocalManagedSessionAuthority {
|
||||
activationId: input.activationId,
|
||||
epoch: input.epoch,
|
||||
workerId: input.workerId,
|
||||
subject: {
|
||||
subject: input.subject ?? {
|
||||
type: 'activation',
|
||||
scopeId: input.activationId,
|
||||
activationId: input.activationId,
|
||||
@@ -2153,6 +2224,15 @@ export class LocalManagedSessionAuthority {
|
||||
event: ManagedSessionEvent,
|
||||
branch: boolean,
|
||||
): void {
|
||||
if (
|
||||
event.kind === 'activation.changed' &&
|
||||
(event.payload['subject'] as ManagedSessionSubject).type ===
|
||||
'hook_operation'
|
||||
) {
|
||||
this.hookOperationActivations.add(
|
||||
event.payload['activationId'] as string,
|
||||
);
|
||||
}
|
||||
if (event.kind === 'checkpoint.committed') {
|
||||
this.checkpointSequences.set(
|
||||
event.payload['checkpointId'] as string,
|
||||
@@ -2207,7 +2287,11 @@ export class LocalManagedSessionAuthority {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.kind === 'model.attempt' ||
|
||||
(event.kind === 'model.attempt' &&
|
||||
!(
|
||||
event.subject?.type === 'activation' &&
|
||||
this.hookOperationActivations.has(event.subject.activationId)
|
||||
)) ||
|
||||
event.kind === 'tool.intent' ||
|
||||
event.kind === 'tool.receipt' ||
|
||||
(event.kind === 'message.committed' &&
|
||||
|
||||
@@ -121,6 +121,8 @@ export const MANAGED_SESSION_ENABLED_DOMAINS: readonly ManagedSessionDomain[] =
|
||||
'session_source',
|
||||
'mcp_configuration',
|
||||
'mcp_operation',
|
||||
'hook_registration',
|
||||
'hook_execution',
|
||||
];
|
||||
|
||||
export function assertManagedSessionDomainEnabled(
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('ChatCompressionService', () => {
|
||||
),
|
||||
appendSystemInstruction: vi.fn(),
|
||||
} as unknown as LlmChat;
|
||||
mockGetHookSystem = vi.fn().mockReturnValue({});
|
||||
mockGetHookSystem = vi.fn().mockReturnValue({ isManaged: () => false });
|
||||
mockConfig = {
|
||||
getChatCompression: vi.fn(),
|
||||
getAutoCompactThreshold: vi.fn(),
|
||||
@@ -966,6 +966,7 @@ describe('ChatCompressionService', () => {
|
||||
mockFirePreCompactEvent = resolvedFn();
|
||||
mockFirePostCompactEvent = resolvedFn();
|
||||
mockGetHookSystem.mockReturnValue({
|
||||
isManaged: () => false,
|
||||
firePreCompactEvent: mockFirePreCompactEvent,
|
||||
firePostCompactEvent: mockFirePostCompactEvent,
|
||||
});
|
||||
@@ -1018,6 +1019,49 @@ describe('ChatCompressionService', () => {
|
||||
expect(mockFirePreCompactEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['PreCompact', 'PostCompact'])(
|
||||
'propagates managed %s recovery failures',
|
||||
async (event) => {
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'message one' }] },
|
||||
{ role: 'model', parts: [{ text: 'answer one' }] },
|
||||
{ role: 'user', parts: [{ text: 'message two' }] },
|
||||
{ role: 'model', parts: [{ text: 'answer two' }] },
|
||||
]);
|
||||
const generateText = vi.fn().mockResolvedValue({
|
||||
text: 'Summary',
|
||||
usage: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
});
|
||||
vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({
|
||||
generateText,
|
||||
} as unknown as BaseLlmClient);
|
||||
mockGetHookSystem.mockReturnValue({
|
||||
isManaged: () => true,
|
||||
firePreCompactEvent: mockFirePreCompactEvent,
|
||||
firePostCompactEvent: mockFirePostCompactEvent,
|
||||
});
|
||||
const failing =
|
||||
event === 'PreCompact'
|
||||
? mockFirePreCompactEvent
|
||||
: mockFirePostCompactEvent;
|
||||
failing.mockRejectedValue(new Error('Hook recovery required'));
|
||||
await expect(
|
||||
service.compress(mockChat, {
|
||||
promptId: mockPromptId,
|
||||
force: true,
|
||||
config: mockConfig,
|
||||
consecutiveFailures: 0,
|
||||
originalTokenCount: 1000,
|
||||
}),
|
||||
).rejects.toThrow('Hook recovery required');
|
||||
if (event === 'PreCompact') expect(generateText).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should fire PreCompact hook before compression', async () => {
|
||||
arrangeAuto();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
@@ -584,6 +584,7 @@ export class ChatCompressionService {
|
||||
.slice(0, MAX_HOOK_INSTRUCTIONS_CHARS);
|
||||
}
|
||||
} catch (err) {
|
||||
if (hookSystem.isManaged()) throw err;
|
||||
config.getDebugLogger().warn(`PreCompact hook failed: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -1341,6 +1342,7 @@ export class ChatCompressionService {
|
||||
signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if (config.getHookSystem()?.isManaged()) throw err;
|
||||
config.getDebugLogger().warn(`PostCompact hook failed: ${err}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1324,4 +1324,52 @@ describe('UiTelemetryService.getTotalOutputTokens', () => {
|
||||
service.resetSession('session-a');
|
||||
expect(service.getTotalOutputTokens('session-a')).toBe(0);
|
||||
});
|
||||
|
||||
it('restores a durable Session model ledger once without resetting warm usage or charging process totals', () => {
|
||||
const service = new UiTelemetryService();
|
||||
service.addEvent(response('main', 120), 'session-a');
|
||||
service.addEvent(response('hook', 30), 'session-a');
|
||||
const saved = structuredClone(
|
||||
service.getMetricsForSession('session-a').models,
|
||||
);
|
||||
service.reset();
|
||||
service.addEvent(response('other', 9), 'session-b');
|
||||
service.restoreSessionModelMetrics('session-a', saved);
|
||||
expect(service.getTotalOutputTokens('session-a')).toBe(150);
|
||||
expect(Object.keys(service.getMetrics().models)).toEqual(['other']);
|
||||
service.addEvent(response('hook', 20), 'session-a');
|
||||
service.restoreSessionModelMetrics('session-a', saved);
|
||||
expect(service.getTotalOutputTokens('session-a')).toBe(170);
|
||||
expect(service.getTotalOutputTokens('session-b')).toBe(9);
|
||||
});
|
||||
|
||||
it.each(['constructor', 'toString', 'hasOwnProperty', '__proto__'])(
|
||||
'accumulates source %s after restoring a JSON model ledger',
|
||||
(source) => {
|
||||
const service = new UiTelemetryService();
|
||||
service.addEvent(response('main', 120), 'session-a');
|
||||
const saved = JSON.parse(
|
||||
JSON.stringify(service.getMetricsForSession('session-a').models),
|
||||
);
|
||||
service.reset();
|
||||
|
||||
service.restoreSessionModelMetrics('session-a', saved);
|
||||
expect(() =>
|
||||
service.addEvent(
|
||||
apiResponse('main', 10, [5, 20, 25, 0, 0], {
|
||||
subagent_name: source,
|
||||
}),
|
||||
'session-a',
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
const restored = service.getMetricsForSession('session-a').models;
|
||||
expect(Object.getPrototypeOf(restored['main'].bySource)).toBeNull();
|
||||
expect(restored['main'].bySource[source].api.totalRequests).toBe(1);
|
||||
expect(restored['main'].bySource[source].tokens.candidates).toBe(20);
|
||||
expect(service.getTotalOutputTokens('session-a')).toBe(140);
|
||||
expect(service.getMetrics().models['main'].tokens.candidates).toBe(20);
|
||||
expect(Object.hasOwn(saved['main'].bySource, source)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -382,6 +382,27 @@ export class UiTelemetryService extends EventEmitter {
|
||||
return total;
|
||||
}
|
||||
|
||||
restoreSessionModelMetrics(
|
||||
sessionId: string,
|
||||
models: SessionMetrics['models'],
|
||||
): void {
|
||||
const restoredTotal = Object.values(models).reduce(
|
||||
(total, model) => total + model.tokens.candidates,
|
||||
0,
|
||||
);
|
||||
// A warm owner already includes these requests. A cold owner restores
|
||||
// the Session ledger without charging historical requests to this process.
|
||||
if (this.getTotalOutputTokens(sessionId) >= restoredTotal) return;
|
||||
const metrics =
|
||||
this.#sessionMetrics.get(sessionId) ?? createInitialMetrics();
|
||||
metrics.models = cloneSessionMetrics({
|
||||
...createInitialMetrics(),
|
||||
models,
|
||||
}).models;
|
||||
this.#sessionMetrics.set(sessionId, metrics);
|
||||
this.#closedSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
recordSkillInvocation(
|
||||
skillName: string,
|
||||
success: boolean,
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.alibaba.qwen.code.managedagent.api;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.service.ManagedHookCatalogService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class ManagedHookController {
|
||||
private final ManagedHookCatalogService service;
|
||||
|
||||
public ManagedHookController(ManagedHookCatalogService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/agents/sessions/{sessionId}/hook-catalog")
|
||||
public ResponseEntity<JsonNode> get(TenantContext tenant, @PathVariable String sessionId) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.body(service.get(tenant.tenantId(), tenant.actorId(), sessionId));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ManagedHookCatalogService {
|
||||
private final ManagedAgentService sessions;
|
||||
private final ManagedExtensionRecordStore records;
|
||||
|
||||
public ManagedHookCatalogService(ManagedAgentService sessions, ManagedExtensionRecordStore records) {
|
||||
this.sessions = sessions;
|
||||
this.records = records;
|
||||
}
|
||||
|
||||
public JsonNode get(String tenantId, String actorId, String sessionId) {
|
||||
sessions.requireReadableSession(tenantId, actorId, sessionId);
|
||||
ObjectNode result = JsonNodeFactory.instance.objectNode()
|
||||
.put("object", "agent.hook_catalog").put("session_id", sessionId);
|
||||
ArrayNode catalogs = result.putArray("catalogs");
|
||||
records.latestHookRegistration(tenantId, sessionId).ifPresent(registration -> {
|
||||
JsonNode catalog = records.readRecordResource(tenantId, sessionId, registration.get("catalogRef"));
|
||||
ObjectNode published = catalogs.addObject().put("catalog_id", registration.get("catalogId").textValue());
|
||||
published.set("catalog_revision", registration.get("catalogRevision"));
|
||||
ArrayNode hooks = published.putArray("hooks");
|
||||
for (JsonNode descriptor : catalog.path("hooks")) {
|
||||
ObjectNode hook = hooks.addObject()
|
||||
.put("hook_id", descriptor.path("hookId").asText())
|
||||
.put("event_name", descriptor.path("eventName").asText())
|
||||
.put("type", descriptor.path("config").path("type").asText())
|
||||
.put("sequential", descriptor.path("sequential").asBoolean())
|
||||
.put("async", descriptor.path("async").asBoolean())
|
||||
.put("fail_closed", descriptor.path("failClosed").asBoolean())
|
||||
.put("once", descriptor.hasNonNull("onceKey"));
|
||||
if (descriptor.path("matcher").isTextual()) {
|
||||
hook.put("matcher", descriptor.get("matcher").textValue());
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.service;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore;
|
||||
import com.alibaba.qwen.code.runtimebroker.HttpRuntimeTransport;
|
||||
import com.alibaba.qwen.code.runtimebroker.ManagedMcpProtocol;
|
||||
import com.alibaba.qwen.code.runtimebroker.ManagedHookProtocol;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeAttestation;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository;
|
||||
@@ -277,12 +278,16 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport {
|
||||
@Override
|
||||
public CompletionStage<Object> control(RuntimeLease lease, RuntimeSession session,
|
||||
Map<String, Object> operation) {
|
||||
if (ManagedMcpProtocol.isOperation(operation)) {
|
||||
if (ManagedMcpProtocol.isOperation(operation) || ManagedHookProtocol.isOperation(operation)) {
|
||||
if (!managed(session)) {
|
||||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
ManagedMcpProtocol.validateSession(session, operation);
|
||||
boolean recovery = ManagedMcpProtocol.isRecovery(operation);
|
||||
if (ManagedHookProtocol.isOperation(operation)) {
|
||||
ManagedHookProtocol.validateSession(session, operation);
|
||||
} else {
|
||||
ManagedMcpProtocol.validateSession(session, operation);
|
||||
}
|
||||
boolean recovery = ManagedMcpProtocol.isRecovery(operation) || ManagedHookProtocol.isRecovery(operation);
|
||||
Context context = context(lease, session, !recovery);
|
||||
if (context.runtime().getState() != RuntimeBindingRecord.State.READY
|
||||
&& !(recovery && context.runtime().getState() == RuntimeBindingRecord.State.DRAINING)) {
|
||||
|
||||
+8
-7
@@ -76,6 +76,10 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
"ACCEPTED", "RUNNING", "CANCELLING");
|
||||
private static final String TURN_SUMMARY_COLUMNS = "session_id,"
|
||||
+ " turn_id, status, created_at, completed_at, error_code";
|
||||
private static final String LATEST_TURN_ID =
|
||||
"SELECT turn_id FROM managed_agent_event WHERE tenant_id = ?"
|
||||
+ " AND session_id = ? AND event_type = 'turn.accepted'"
|
||||
+ " ORDER BY sequence_id DESC LIMIT 1";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
@@ -901,9 +905,8 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
String sessionId) {
|
||||
List<TurnRecord> rows = jdbc.query(
|
||||
"SELECT * FROM managed_agent_turn WHERE tenant_id = ? AND"
|
||||
+ " session_id = ? ORDER BY created_at DESC,"
|
||||
+ " turn_id DESC LIMIT 1",
|
||||
turnMapper, tenantId, sessionId);
|
||||
+ " session_id = ? AND turn_id = (" + LATEST_TURN_ID + ")",
|
||||
turnMapper, tenantId, sessionId, tenantId, sessionId);
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
@@ -966,10 +969,8 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
+ " event.tenant_id = ? AND event.session_id = ? AND"
|
||||
+ " event.event_type IN ('environment.provisioning',"
|
||||
+ " 'environment.ready', 'environment.failed') AND"
|
||||
+ " turn_record.turn_id = (SELECT turn_id FROM"
|
||||
+ " managed_agent_turn WHERE tenant_id = ? AND"
|
||||
+ " session_id = ? ORDER BY created_at DESC, turn_id"
|
||||
+ " DESC LIMIT 1) ORDER BY event.sequence_id DESC"
|
||||
+ " turn_record.turn_id = (" + LATEST_TURN_ID
|
||||
+ ") ORDER BY event.sequence_id DESC"
|
||||
+ " LIMIT 1",
|
||||
eventMapper, tenantId, sessionId, tenantId, sessionId)
|
||||
.stream().findFirst();
|
||||
|
||||
+11
-1
@@ -37,7 +37,17 @@ public final class ManagedExtensionProjection {
|
||||
ManagedMcpRecords::requireOperation,
|
||||
body -> body.get("operationId").textValue(),
|
||||
ManagedMcpRecords::isOperationStart,
|
||||
ManagedMcpRecords::isOperationSuccessor));
|
||||
ManagedMcpRecords::isOperationSuccessor),
|
||||
"hook_registration", new Body(null,
|
||||
ManagedHookRecords::requireRegistration,
|
||||
body -> body.get("registrationId").textValue(),
|
||||
ManagedHookRecords::isRegistrationStart,
|
||||
ManagedHookRecords::isRegistrationSuccessor),
|
||||
"hook_execution", new Body(null,
|
||||
ManagedHookRecords::requireExecution,
|
||||
body -> body.get("hookExecutionId").textValue(),
|
||||
ManagedHookRecords::isExecutionStart,
|
||||
ManagedHookRecords::isExecutionSuccessor));
|
||||
public static final List<String> TASK_STATES = List.of("pending",
|
||||
"running", "waiting", "completed", "failed", "cancelled",
|
||||
"degraded", "recovery_blocked");
|
||||
|
||||
+88
-7
@@ -105,6 +105,21 @@ public class ManagedExtensionRecordStore {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public Optional<JsonNode> latestHookRegistration(String tenantId, String sessionId) {
|
||||
return jdbc.query("SELECT record_resource_id FROM qwen_managed_session_extension_record"
|
||||
+ " WHERE session_scope_key = ? AND tenant_id = ? AND session_id = ?"
|
||||
+ " AND domain = 'hook_registration' AND settled_at IS NOT NULL"
|
||||
+ " ORDER BY first_sequence DESC",
|
||||
(result, row) -> result.getString("record_resource_id"),
|
||||
ManagedSessionStore.sessionScopeKey(tenantId, sessionId), tenantId, sessionId)
|
||||
.stream().map(id -> {
|
||||
JsonNode record = readBody(readResource(tenantId, sessionId, id));
|
||||
ManagedHookRecords.requireRegistration(record);
|
||||
return record;
|
||||
}).filter(record -> "settled".equals(record.path("run").path("state").textValue()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/** Reads only a committed resource in this Session's scope. */
|
||||
public JsonNode readRecordResource(String tenantId, String sessionId,
|
||||
JsonNode ref) {
|
||||
@@ -192,7 +207,7 @@ public class ManagedExtensionRecordStore {
|
||||
applyRevision(tenantId, workspaceId, sessionId, domain, body,
|
||||
payload.get("operationId").textValue(),
|
||||
payload.get("recordRef"),
|
||||
occurredAt, resources);
|
||||
firstSequence + index, occurredAt, resources);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
@@ -288,9 +303,15 @@ public class ManagedExtensionRecordStore {
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
private List<String> domainResources(String scopeKey, String domain) {
|
||||
return jdbc.query("SELECT record_resource_id FROM qwen_managed_session_extension_record"
|
||||
+ " WHERE session_scope_key = ? AND domain = ?",
|
||||
(result, row) -> result.getString("record_resource_id"), scopeKey, domain);
|
||||
}
|
||||
|
||||
private void applyRevision(String tenantId, String workspaceId,
|
||||
String sessionId, String domain, Body body, String operationId,
|
||||
JsonNode recordRef, long occurredAt,
|
||||
JsonNode recordRef, long sequence, long occurredAt,
|
||||
Function<String, StoredResource> resources) {
|
||||
String resourceId = recordRef.get("resourceId").textValue();
|
||||
StoredResource resource = resources.apply(resourceId);
|
||||
@@ -307,19 +328,54 @@ public class ManagedExtensionRecordStore {
|
||||
} catch (InvalidRecordException error) {
|
||||
throw rejected(error.getMessage());
|
||||
}
|
||||
if (domain.equals("mcp_configuration") || domain.equals("mcp_operation")) {
|
||||
for (String field : List.of("catalogRef", "argsRef", "resultRef")) {
|
||||
if (List.of("mcp_configuration", "mcp_operation", "hook_registration", "hook_execution").contains(domain)) {
|
||||
for (String field : List.of("catalogRef", "argsRef", "resultRef", "planRef", "inputRef")) {
|
||||
JsonNode ref = record.get(field);
|
||||
if (ref != null && !ref.isNull()) {
|
||||
requireReference(resources.apply(ref.get("resourceId").textValue()), ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (domain.equals("hook_execution")) {
|
||||
StoredResource plan = resources.apply(record.get("planRef").get("resourceId").textValue());
|
||||
if (plan.kind().equals("managed-hook-plan")) {
|
||||
JsonNode messagesRef = readBody(plan).get("messagesRef");
|
||||
if (messagesRef != null && !messagesRef.isNull()) {
|
||||
try {
|
||||
ManagedExtensionRecords.durableRef(messagesRef, "plan.messagesRef");
|
||||
StoredResource messages = resources.apply(messagesRef.get("resourceId").textValue());
|
||||
requireReference(messages, messagesRef);
|
||||
require(List.of("managed-hook-messages", "managed-hook-message-chunks").contains(messages.kind()),
|
||||
"The Hook plan must reference a messages snapshot or chunk manifest.");
|
||||
if (messages.kind().equals("managed-hook-message-chunks")) {
|
||||
JsonNode parts = readBody(messages).get("parts");
|
||||
require(parts != null && parts.isArray(), "The Hook messages manifest must contain parts.");
|
||||
for (JsonNode part : parts) {
|
||||
ManagedExtensionRecords.durableRef(part, "messages.parts");
|
||||
require("managed-hook-message-part".equals(part.get("kind").textValue()),
|
||||
"The Hook messages manifest must reference message parts.");
|
||||
requireReference(resources.apply(part.get("resourceId").textValue()), part);
|
||||
}
|
||||
}
|
||||
} catch (InvalidRecordException error) {
|
||||
throw rejected(error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String recordId = body.recordId().apply(record);
|
||||
String recordKey = ManagedExtensionProjection.recordKey(sessionId,
|
||||
domain, recordId);
|
||||
String scopeKey = ManagedSessionStore.sessionScopeKey(tenantId,
|
||||
sessionId);
|
||||
if (domain.equals("hook_registration")) {
|
||||
for (String registration : domainResources(scopeKey, domain)) {
|
||||
require(ManagedExtensionRecords.isDefinitionPinConsistent(
|
||||
readBody(resources.apply(registration)).get("run").get("definition"),
|
||||
record.get("run").get("definition")),
|
||||
"A Hook catalog revision cannot name two definition digests.");
|
||||
}
|
||||
}
|
||||
if (domain.equals("mcp_configuration")) {
|
||||
List<String> configurations = jdbc.query("SELECT record_resource_id FROM"
|
||||
+ " qwen_managed_session_extension_record WHERE"
|
||||
@@ -339,6 +395,31 @@ public class ManagedExtensionRecordStore {
|
||||
.stream().findFirst().orElse(null);
|
||||
String operationHash = sha256(operationId);
|
||||
if (previous == null) {
|
||||
if (domain.equals("hook_execution")) {
|
||||
String registrationKey = ManagedExtensionProjection.recordKey(sessionId,
|
||||
"hook_registration", record.get("registrationId").textValue());
|
||||
String registrationResource = jdbc.query("SELECT record_resource_id FROM"
|
||||
+ " qwen_managed_session_extension_record WHERE"
|
||||
+ " session_scope_key = ? AND record_key = ?",
|
||||
(result, row) -> result.getString("record_resource_id"),
|
||||
scopeKey, registrationKey).stream().findFirst().orElse(null);
|
||||
require(registrationResource != null,
|
||||
"Hook execution must bind to its settled committed registration.");
|
||||
JsonNode registration = readBody(resources.apply(registrationResource));
|
||||
require("settled".equals(registration.get("run").get("state").textValue())
|
||||
&& ManagedMcpRecords.same(registration.get("run").get("definition"), record.get("run").get("definition")),
|
||||
"Hook execution must bind to its settled committed registration.");
|
||||
for (String execution : domainResources(scopeKey, domain)) {
|
||||
JsonNode other = readBody(resources.apply(execution));
|
||||
require(record.get("onceKey").isNull() || !record.get("onceKey").equals(other.get("onceKey")),
|
||||
"Hook onceKey is already consumed in this Session.");
|
||||
require(!record.get("occurrenceId").equals(other.get("occurrenceId"))
|
||||
|| !ManagedMcpRecords.same(record.get("ordinal"), other.get("ordinal"))
|
||||
&& List.of("registrationId", "eventName", "planRef").stream()
|
||||
.allMatch(key -> ManagedMcpRecords.same(record.get(key), other.get(key))),
|
||||
"Hook occurrence must keep its registration, event and plan, with unique ordinals.");
|
||||
}
|
||||
}
|
||||
if (domain.equals("mcp_operation")) {
|
||||
String configKey = ManagedExtensionProjection.recordKey(sessionId,
|
||||
"mcp_configuration", record.get("configurationId").textValue());
|
||||
@@ -395,15 +476,15 @@ public class ManagedExtensionRecordStore {
|
||||
+ " task_kind, task_state, runtime_state,"
|
||||
+ " definition_revision, delivery_target,"
|
||||
+ " delivery_state, created_at, started_at,"
|
||||
+ " settled_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?,"
|
||||
+ " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
+ " settled_at, first_sequence) VALUES (?, ?, ?, ?, ?, ?, ?, ?,"
|
||||
+ " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
scopeKey, recordKey, tenantId, workspaceId, sessionId,
|
||||
domain, recordId, operationHash, revision, resourceId,
|
||||
body.taskKind(),
|
||||
body.taskKind() == null ? null : projection.state(), projection.runtimeState(),
|
||||
projection.definitionRevision(), deliveryTarget,
|
||||
deliveryState, projection.createdAt(),
|
||||
projection.startedAt(), projection.settledAt());
|
||||
projection.startedAt(), projection.settledAt(), sequence);
|
||||
} else {
|
||||
jdbc.update("UPDATE qwen_managed_session_extension_record SET"
|
||||
+ " revision = ?, record_resource_id = ?,"
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/** Closed Hook domain bodies; TypeScript replays the same fixtures. */
|
||||
public final class ManagedHookRecords {
|
||||
private static final Set<String> REGISTRATION_KEYS = Set.of("registrationId",
|
||||
"catalogId", "catalogRevision", "catalogRef", "run");
|
||||
private static final Set<String> EXECUTION_KEYS = Set.of("hookExecutionId",
|
||||
"occurrenceId", "runtimeSessionId", "registrationId", "eventName", "ordinal", "hookId",
|
||||
"planRef", "inputRef", "resultRef", "onceKey", "cancelRequested", "run");
|
||||
|
||||
private ManagedHookRecords() {
|
||||
}
|
||||
|
||||
public static void requireRegistration(JsonNode record) {
|
||||
ManagedExtensionRecords.closed(record, REGISTRATION_KEYS, "hookRegistration");
|
||||
pinnedRun(record, "registrationId");
|
||||
String catalogId = ManagedExtensionRecords.id(record.get("catalogId"), "catalogId");
|
||||
long catalogRevision = ManagedExtensionRecords.count(record.get("catalogRevision"),
|
||||
1, Long.MAX_VALUE, "catalogRevision");
|
||||
ManagedExtensionRecords.durableRef(record.get("catalogRef"), "catalogRef");
|
||||
JsonNode run = record.get("run");
|
||||
JsonNode definition = run.get("definition");
|
||||
require(catalogId.equals(definition.get("definitionId").textValue())
|
||||
&& catalogRevision == definition.get("definitionRevision").longValue()
|
||||
&& run.get("execution").isNull(),
|
||||
"Hook registration must pin its catalog resource and have no execution");
|
||||
}
|
||||
|
||||
public static void requireExecution(JsonNode record) {
|
||||
ManagedExtensionRecords.closed(record, EXECUTION_KEYS, "hookExecution");
|
||||
pinnedRun(record, "hookExecutionId");
|
||||
for (String key : List.of("occurrenceId", "runtimeSessionId", "registrationId", "eventName", "hookId")) {
|
||||
ManagedExtensionRecords.id(record.get(key), key);
|
||||
}
|
||||
ManagedExtensionRecords.count(record.get("ordinal"), 0, Long.MAX_VALUE, "ordinal");
|
||||
for (String key : List.of("planRef", "inputRef")) {
|
||||
ManagedExtensionRecords.durableRef(record.get(key), key);
|
||||
}
|
||||
if (!record.get("onceKey").isNull()) {
|
||||
ManagedExtensionRecords.id(record.get("onceKey"), "onceKey");
|
||||
}
|
||||
require(record.get("cancelRequested").isBoolean(), "Hook cancelRequested must be boolean");
|
||||
JsonNode run = record.get("run");
|
||||
require(!run.get("execution").isNull(), "Hook execution must have an execution state");
|
||||
JsonNode result = record.get("resultRef");
|
||||
if (!result.isNull()) {
|
||||
ManagedExtensionRecords.durableRef(result, "resultRef");
|
||||
}
|
||||
require((!"settled".equals(run.get("state").textValue()) || !result.isNull())
|
||||
&& (result.isNull() || "settled".equals(run.get("execution").textValue())),
|
||||
"Hook result requires a settled execution; successful executions need a result");
|
||||
}
|
||||
|
||||
public static boolean isRegistrationStart(JsonNode record) {
|
||||
return accepts(() -> {
|
||||
requireRegistration(record);
|
||||
return ManagedExtensionRecords.isRunStart(record.get("run"));
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean isExecutionStart(JsonNode record) {
|
||||
return accepts(() -> {
|
||||
requireExecution(record);
|
||||
return ManagedExtensionRecords.isRunStart(record.get("run"))
|
||||
&& record.get("resultRef").isNull() && !record.get("cancelRequested").booleanValue();
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean isRegistrationSuccessor(JsonNode previous, JsonNode next) {
|
||||
return accepts(() -> {
|
||||
requireRegistration(previous);
|
||||
requireRegistration(next);
|
||||
return !List.of("settled", "failed", "cancelled").contains(previous.get("run").get("state").textValue())
|
||||
&& ManagedMcpRecords.same(without(previous, "run"), without(next, "run"))
|
||||
&& ManagedExtensionRecords.isRunSuccessor(previous.get("run"), next.get("run"));
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean isExecutionSuccessor(JsonNode previous, JsonNode next) {
|
||||
return accepts(() -> {
|
||||
requireExecution(previous);
|
||||
requireExecution(next);
|
||||
return ManagedMcpRecords.same(without(previous, "run", "resultRef", "cancelRequested"),
|
||||
without(next, "run", "resultRef", "cancelRequested"))
|
||||
&& ManagedExtensionRecords.isRunSuccessor(previous.get("run"), next.get("run"))
|
||||
&& (!previous.get("cancelRequested").booleanValue() || next.get("cancelRequested").booleanValue())
|
||||
&& (previous.get("resultRef").isNull() || ManagedMcpRecords.same(previous.get("resultRef"), next.get("resultRef")))
|
||||
&& (!List.of("settled", "failed", "cancelled").contains(previous.get("run").get("state").textValue())
|
||||
|| ManagedMcpRecords.same(previous, next));
|
||||
});
|
||||
}
|
||||
|
||||
private static void pinnedRun(JsonNode record, String idField) {
|
||||
String id = ManagedExtensionRecords.id(record.get(idField), idField);
|
||||
JsonNode run = record.get("run");
|
||||
ManagedExtensionRecords.requireRun(run);
|
||||
require(!run.get("definition").isNull() && id.equals(run.get("effectId").textValue())
|
||||
&& run.get("executionCallId").isNull() && run.get("dispatchId").isNull()
|
||||
&& run.get("deliveryId").isNull() && run.get("delivery").isNull(),
|
||||
"Hook run must pin its catalog, identify its effect and have no delivery");
|
||||
}
|
||||
|
||||
private static JsonNode without(JsonNode record, String... keys) {
|
||||
ObjectNode copy = record.deepCopy();
|
||||
copy.remove(List.of(keys));
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static boolean accepts(BooleanSupplier check) {
|
||||
try {
|
||||
return check.getAsBoolean();
|
||||
} catch (InvalidRecordException error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new InvalidRecordException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qwen_managed_session_extension_record
|
||||
ADD COLUMN first_sequence BIGINT NOT NULL DEFAULT 0;
|
||||
+88
-2
@@ -2499,16 +2499,26 @@
|
||||
"get": {
|
||||
"tags": ["Public Resources"],
|
||||
"operationId": "getSessionHookCatalog",
|
||||
"x-qwen-implementation-status": "planned",
|
||||
"x-qwen-implementation-status": "implemented",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Planned name only: the sources of the effective hook plan of this Session; hook decisions use the Action API. Stage H2 defines the response."
|
||||
"description": "Sanitized planning summary of the latest settled Hook registration in this Session. A pending or failed replacement preserves the previous catalog. Hook recipes, prompts, credentials and handler implementations are never returned.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublicHookCatalog"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7842,6 +7852,82 @@
|
||||
},
|
||||
"x-qwen-implementation-status": "planned"
|
||||
},
|
||||
"PublicHookCatalog": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["object", "session_id", "catalogs"],
|
||||
"properties": {
|
||||
"object": {
|
||||
"type": "string",
|
||||
"const": "agent.hook_catalog"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"catalogs": {
|
||||
"type": "array",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["catalog_id", "catalog_revision", "hooks"],
|
||||
"properties": {
|
||||
"catalog_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"catalog_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"hooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"hook_id",
|
||||
"event_name",
|
||||
"type",
|
||||
"sequential",
|
||||
"async",
|
||||
"fail_closed",
|
||||
"once"
|
||||
],
|
||||
"properties": {
|
||||
"hook_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"event_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["command", "http", "prompt", "function"]
|
||||
},
|
||||
"sequential": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"async": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fail_closed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"once": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"matcher": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolResultPreview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
+2
-1
@@ -322,7 +322,8 @@ final class HostedShellOutputProbe implements AutoCloseable {
|
||||
void assertReport(Map<String, Object> session, JsonNode report) throws Exception {
|
||||
assertThat(failure.get()).as("independent Shell output probe").isNull();
|
||||
assertThat(report.path("fault").asText()).isEqualTo(session.get("fault"));
|
||||
assertThat(report.path("injections").asInt()).isEqualTo(1);
|
||||
assertThat(report.path("injections").asInt())
|
||||
.isEqualTo(session.get("fault").toString().startsWith("receipt-") ? 3 : 1);
|
||||
assertThat(report.path("modelCalls").asInt()).isEqualTo(1);
|
||||
var row = execution(session.get("sessionId").toString());
|
||||
assertThat(row.get("execution_call_id")).isEqualTo(report.path("executionCallId").asText());
|
||||
|
||||
+15
@@ -943,6 +943,21 @@ class ManagedAgentApiContractTest {
|
||||
.header(TENANT, workspaceTenant).principal(actor(otherTenant)), null);
|
||||
exchange(drift, "getSessionMcpCatalog", 400,
|
||||
get("/v1/agents/sessions/{id}/mcp-catalog", publicBoundId), null);
|
||||
String hookCatalog = exchange(drift, "getSessionHookCatalog", 200,
|
||||
get("/v1/agents/sessions/{id}/hook-catalog", publicBoundId)
|
||||
.header(TENANT, workspaceTenant).principal(actor), null);
|
||||
assertThat(json(hookCatalog).path("catalogs")).isEmpty();
|
||||
exchange(drift, "getSessionHookCatalog", 404,
|
||||
get("/v1/agents/sessions/{id}/hook-catalog", publicBoundId)
|
||||
.header(TENANT, otherTenant), null);
|
||||
exchange(drift, "getSessionHookCatalog", 404,
|
||||
get("/v1/agents/sessions/{id}/hook-catalog", publicBoundId)
|
||||
.header(TENANT, workspaceTenant), null);
|
||||
exchange(drift, "getSessionHookCatalog", 403,
|
||||
get("/v1/agents/sessions/{id}/hook-catalog", publicBoundId)
|
||||
.header(TENANT, workspaceTenant).principal(actor(otherTenant)), null);
|
||||
exchange(drift, "getSessionHookCatalog", 400,
|
||||
get("/v1/agents/sessions/{id}/hook-catalog", publicBoundId), null);
|
||||
exchangeTurns(drift, tenant, otherTenant);
|
||||
assertThat(exercised).containsExactlyInAnyOrderElementsOf(
|
||||
CONTRACT.operations().stream()
|
||||
|
||||
+213
-5
@@ -20,8 +20,11 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -458,10 +461,10 @@ class ManagedExtensionRecordStoreTest {
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
JsonNode fixtures = ManagedMcpRecordContractTest.fixtures();
|
||||
JsonNode configuration = fixtures.get("templates").get("mcp_configuration");
|
||||
commitMcp(journal, "configure-1", "mcp_configuration", configuration, List.of());
|
||||
commitDomain(journal, "configure-1", "mcp_configuration", configuration, List.of());
|
||||
JsonNode dispatched = ManagedMcpRecordContractTest.merge(configuration,
|
||||
fixtures.get("successors").get(0).get("after"));
|
||||
commitMcp(journal, "configure-dispatch", "mcp_configuration", dispatched, List.of());
|
||||
commitDomain(journal, "configure-dispatch", "mcp_configuration", dispatched, List.of());
|
||||
ObjectNode configured = (ObjectNode) ManagedMcpRecordContractTest.merge(configuration,
|
||||
fixtures.get("cases").get(3).get("patch"));
|
||||
CommitResource data = new CommitResource("mcp-data", "mcp-data", 1,
|
||||
@@ -473,7 +476,7 @@ class ManagedExtensionRecordStoreTest {
|
||||
"mcp_configuration", configured, List.of(), 1000);
|
||||
assertThatThrownBy(() -> journal.commit(missing)).isInstanceOf(ApiException.class);
|
||||
assertThat(revisions(sessionId)).isEqualTo(2);
|
||||
commitMcp(journal, "configure-result", "mcp_configuration", configured, List.of(data));
|
||||
commitDomain(journal, "configure-result", "mcp_configuration", configured, List.of(data));
|
||||
assertThat(records.listRecords(TENANT, sessionId, "mcp_configuration"))
|
||||
.containsExactly(configured);
|
||||
assertThat(records.readRecordResource(TENANT, sessionId, ref).isEmpty()).isTrue();
|
||||
@@ -493,7 +496,7 @@ class ManagedExtensionRecordStoreTest {
|
||||
wrong.put("catalogRevision", 2);
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("wrong-binding",
|
||||
"mcp_operation", wrong, List.of(), 1000))).hasMessageContaining("active committed configuration");
|
||||
commitMcp(journal, "operation-1", "mcp_operation", operation, List.of());
|
||||
commitDomain(journal, "operation-1", "mcp_operation", operation, List.of());
|
||||
assertThat(records.listTasks(TENANT, sessionId, null, null, 10).tasks()).isEmpty();
|
||||
String fakeTask = ManagedExtensionProjection.taskId(ManagedExtensionProjection.recordKey(
|
||||
sessionId, "mcp_operation", "operation-1"));
|
||||
@@ -504,13 +507,218 @@ class ManagedExtensionRecordStoreTest {
|
||||
.listRecords(TENANT, sessionId, "mcp_operation")).containsExactly(operation);
|
||||
}
|
||||
|
||||
private static void commitMcp(ExtensionRecordJournal journal, String commandId,
|
||||
@Test
|
||||
void materializesHookChainsAndAtomicallyConsumesOnceIntentsWithoutTasks() throws Exception {
|
||||
String sessionId = agents.createSession(TENANT, "hook-" + UUID.randomUUID(),
|
||||
"qwen-code", null, "hook", Map.of(), List.of()).sessionId();
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
JsonNode fixtures = ManagedHookRecordContractTest.fixtures();
|
||||
ObjectNode registration = fixtures.get("templates").get("hook_registration").deepCopy();
|
||||
CommitResource data = new CommitResource("hook-data", "hook-data", 1,
|
||||
2, ExtensionRecordJournal.sha256("{}"), "e30=");
|
||||
ObjectNode ref = registration.withObject("/catalogRef");
|
||||
ref.put("digest", data.digest());
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("missing-catalog",
|
||||
"hook_registration", registration, List.of(), 1000))).isInstanceOf(ApiException.class);
|
||||
commitDomain(journal, "register-admitted", "hook_registration", registration, List.of(data));
|
||||
ObjectNode execution = fixtures.get("templates").get("hook_execution").deepCopy();
|
||||
execution.set("planRef", ref.deepCopy());
|
||||
execution.set("inputRef", ref.deepCopy());
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("unsettled-registration",
|
||||
"hook_execution", execution, List.of(), 1000))).hasMessageContaining("settled committed registration");
|
||||
for (String status : List.of("running", "settled")) {
|
||||
registration.withObject("/run").put("state", status);
|
||||
commitDomain(journal, "register-" + status, "hook_registration", registration, List.of());
|
||||
}
|
||||
ObjectNode otherRegistration = registration.deepCopy();
|
||||
otherRegistration.put("registrationId", "registration-other");
|
||||
otherRegistration.withObject("/run").put("effectId", "registration-other").put("state", "admitted");
|
||||
otherRegistration.withObject("/run/definition").put("definitionDigest", "c".repeat(64));
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("conflicting-pin",
|
||||
"hook_registration", otherRegistration, List.of(), 1000))).hasMessageContaining("two definition digests");
|
||||
for (String field : List.of("planRef", "inputRef")) {
|
||||
ObjectNode missing = execution.deepCopy();
|
||||
missing.withObject("/" + field).put("resourceId", "missing");
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("missing-" + field,
|
||||
"hook_execution", missing, List.of(), 1000))).isInstanceOf(ApiException.class);
|
||||
}
|
||||
commitDomain(journal, "execute-intent", "hook_execution", execution, List.of());
|
||||
ObjectNode another = execution.deepCopy();
|
||||
another.put("hookExecutionId", "execution-2").put("occurrenceId", "occurrence-2");
|
||||
another.withObject("/run").put("effectId", "execution-2");
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("consumed-once",
|
||||
"hook_execution", another, List.of(), 1000))).hasMessageContaining("onceKey");
|
||||
assertThat(revisions(sessionId)).isEqualTo(4);
|
||||
another.putNull("onceKey").put("occurrenceId", "occurrence-1");
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("duplicate-ordinal",
|
||||
"hook_execution", another, List.of(), 1000))).hasMessageContaining("unique ordinals");
|
||||
another.put("ordinal", 1).put("eventName", "AfterTool");
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("changed-event",
|
||||
"hook_execution", another, List.of(), 1000))).hasMessageContaining("unique ordinals");
|
||||
execution.withObject("/run").put("state", "running").put("execution", "dispatch_started");
|
||||
commitDomain(journal, "execute-dispatch", "hook_execution", execution, List.of());
|
||||
execution.withObject("/run").put("state", "recovery_blocked").put("execution", "outcome_unknown")
|
||||
.put("reason", "outcome_unknown");
|
||||
commitDomain(journal, "execute-unknown", "hook_execution", execution, List.of());
|
||||
assertThat(new ManagedExtensionRecordStore(jdbc, state).listRecords(TENANT, sessionId, "hook_execution"))
|
||||
.containsExactly(execution);
|
||||
execution.withObject("/run").put("state", "settled").put("execution", "settled").putNull("reason");
|
||||
execution.set("resultRef", ref.deepCopy());
|
||||
commitDomain(journal, "execute-late-result", "hook_execution", execution, List.of());
|
||||
execution.put("cancelRequested", true);
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("rewrite-terminal",
|
||||
"hook_execution", execution, List.of(), 1000))).hasMessageContaining("cannot follow");
|
||||
assertThat(records.latestHookRegistration(TENANT, sessionId)).contains(registration);
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("repeat-terminal-registration",
|
||||
"hook_registration", registration, List.of(), 1000))).hasMessageContaining("cannot follow");
|
||||
ObjectNode replacement = registration.deepCopy();
|
||||
replacement.put("registrationId", "replacement").put("catalogId", "catalog-2");
|
||||
replacement.withObject("/run").put("effectId", "replacement");
|
||||
replacement.withObject("/run/definition").put("definitionId", "catalog-2");
|
||||
for (String status : List.of("admitted", "running", "settled")) {
|
||||
replacement.withObject("/run").put("state", status);
|
||||
CommitTransactionRequest request = journal.requestDomain("replacement-" + status,
|
||||
"hook_registration", replacement, List.of(), 500);
|
||||
journal.commit(request);
|
||||
journal.committed(request);
|
||||
assertThat(records.latestHookRegistration(TENANT, sessionId))
|
||||
.contains("settled".equals(status) ? replacement : registration);
|
||||
}
|
||||
assertThat(records.listTasks(TENANT, sessionId, null, null, 10).tasks()).isEmpty();
|
||||
assertThat(state.findEvents(TENANT, sessionId, 0, 100)).extracting(EventRecord::type)
|
||||
.doesNotContain("task.updated");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLatestCatalogWhenAnOlderRegistrationSettlesLater() throws Exception {
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
CommitResource data = hookResource("hook-data", "hook-data", "{}".getBytes(StandardCharsets.UTF_8));
|
||||
ObjectNode older = ManagedHookRecordContractTest.fixtures()
|
||||
.get("templates").get("hook_registration").deepCopy();
|
||||
older.withObject("/catalogRef").put("digest", data.digest());
|
||||
commitDomain(journal, "older-admitted", "hook_registration", older, List.of(data));
|
||||
assertThat(records.latestHookRegistration(TENANT, sessionId)).isEmpty();
|
||||
|
||||
ObjectNode newer = older.deepCopy();
|
||||
newer.put("registrationId", "registration-2").put("catalogRevision", 2);
|
||||
newer.withObject("/run").put("effectId", "registration-2");
|
||||
newer.withObject("/run/definition").put("definitionRevision", 2)
|
||||
.put("definitionDigest", "c".repeat(64));
|
||||
for (String status : List.of("admitted", "running", "settled")) {
|
||||
newer.withObject("/run").put("state", status);
|
||||
commitDomain(journal, "newer-" + status, "hook_registration", newer, List.of());
|
||||
if ("settled".equals(status)) {
|
||||
assertThat(records.latestHookRegistration(TENANT, sessionId)).contains(newer);
|
||||
} else {
|
||||
assertThat(records.latestHookRegistration(TENANT, sessionId)).isEmpty();
|
||||
}
|
||||
}
|
||||
for (String status : List.of("running", "settled")) {
|
||||
older.withObject("/run").put("state", status);
|
||||
commitDomain(journal, "older-" + status, "hook_registration", older, List.of());
|
||||
assertThat(new ManagedExtensionRecordStore(jdbc, state)
|
||||
.latestHookRegistration(TENANT, sessionId)).contains(newer);
|
||||
}
|
||||
}
|
||||
|
||||
private static void commitDomain(ExtensionRecordJournal journal, String commandId,
|
||||
String domain, JsonNode record, List<CommitResource> resources) {
|
||||
CommitTransactionRequest request = journal.requestDomain(commandId, domain, record, resources, 1000);
|
||||
journal.commit(request);
|
||||
journal.committed(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commitsHookMessageSnapshotsAndRejectsIncompleteOrMismatchedClosures() throws Exception {
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
JsonNode templates = ManagedHookRecordContractTest.fixtures().get("templates");
|
||||
CommitResource data = hookResource("hook-data", "hook-data", "{}".getBytes(StandardCharsets.UTF_8));
|
||||
ObjectNode registration = templates.get("hook_registration").deepCopy();
|
||||
registration.set("catalogRef", hookRef(data));
|
||||
for (String status : List.of("admitted", "running", "settled")) {
|
||||
registration.withObject("/run").put("state", status);
|
||||
commitDomain(journal, "register-" + status, "hook_registration", registration, List.of(data));
|
||||
}
|
||||
long resourcesBefore = rows("qwen_managed_session_resource", sessionId);
|
||||
byte[] messages = ("[{\"role\":\"user\",\"content\":\"" + "😀".repeat(20_000) + "\"}]")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
CommitResource first = hookResource("messages-part-1", "managed-hook-message-part",
|
||||
Arrays.copyOfRange(messages, 0, 60 * 1024));
|
||||
CommitResource second = hookResource("messages-part-2", "managed-hook-message-part",
|
||||
Arrays.copyOfRange(messages, 60 * 1024, messages.length));
|
||||
ObjectNode manifestBody = JsonNodeFactory.instance.objectNode();
|
||||
manifestBody.putArray("parts").add(hookRef(first)).add(hookRef(second));
|
||||
CommitResource manifest = hookResource("messages", "managed-hook-message-chunks",
|
||||
ExtensionRecordJournal.bytes(manifestBody));
|
||||
ObjectNode planBody = JsonNodeFactory.instance.objectNode();
|
||||
planBody.set("messagesRef", hookRef(manifest));
|
||||
planBody.putObject("input").set("userObject", hookRef(hookResource("not-a-dependency", "user-data", messages)));
|
||||
CommitResource plan = hookResource("plan", "managed-hook-plan", ExtensionRecordJournal.bytes(planBody));
|
||||
ObjectNode execution = templates.get("hook_execution").deepCopy();
|
||||
execution.set("planRef", hookRef(plan));
|
||||
execution.set("inputRef", hookRef(data));
|
||||
execution.putNull("onceKey");
|
||||
for (List<CommitResource> incomplete : List.of(List.of(plan, first, second), List.of(plan, manifest, first))) {
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("missing-messages", "hook_execution",
|
||||
execution, incomplete, 1000))).isInstanceOf(ApiException.class);
|
||||
assertThat(revisions(sessionId)).isEqualTo(3);
|
||||
assertThat(rows("qwen_managed_session_resource", sessionId)).isEqualTo(resourcesBefore);
|
||||
}
|
||||
for (boolean mismatchPart : List.of(false, true)) {
|
||||
ObjectNode badPlanBody = planBody.deepCopy();
|
||||
ObjectNode badManifestBody = manifestBody.deepCopy();
|
||||
if (mismatchPart)
|
||||
((ObjectNode) badManifestBody.get("parts").get(1)).put("digest", "c".repeat(64));
|
||||
CommitResource badManifest = hookResource("messages", "managed-hook-message-chunks",
|
||||
ExtensionRecordJournal.bytes(badManifestBody));
|
||||
badPlanBody.set("messagesRef", hookRef(badManifest));
|
||||
if (!mismatchPart) badPlanBody.withObject("/messagesRef").put("digest", "c".repeat(64));
|
||||
CommitResource badPlan = hookResource("plan", "managed-hook-plan", ExtensionRecordJournal.bytes(badPlanBody));
|
||||
ObjectNode invalid = execution.deepCopy();
|
||||
invalid.set("planRef", hookRef(badPlan));
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("mismatched-messages", "hook_execution",
|
||||
invalid, List.of(badPlan, badManifest, first, second), 1000)))
|
||||
.isInstanceOf(ApiException.class).hasMessageContaining("does not match");
|
||||
assertThat(revisions(sessionId)).isEqualTo(3);
|
||||
assertThat(rows("qwen_managed_session_resource", sessionId)).isEqualTo(resourcesBefore);
|
||||
}
|
||||
commitDomain(journal, "messages-valid", "hook_execution", execution, List.of(plan, manifest, first, second));
|
||||
assertThat(new ManagedExtensionRecordStore(jdbc, state).listRecords(TENANT, sessionId, "hook_execution"))
|
||||
.extracting(JsonNode::toString).containsExactly(execution.toString());
|
||||
assertThat(sessionStore.readResource(TENANT, WORKSPACE, sessionId, manifest.resourceId(),
|
||||
"extension-writer-token-0123456789").bytes()).isEqualTo(ExtensionRecordJournal.bytes(manifestBody));
|
||||
ByteArrayOutputStream restored = new ByteArrayOutputStream();
|
||||
for (CommitResource part : List.of(first, second))
|
||||
restored.write(sessionStore.readResource(TENANT, WORKSPACE, sessionId, part.resourceId(),
|
||||
"extension-writer-token-0123456789").bytes());
|
||||
assertThat(restored.toByteArray()).isEqualTo(messages);
|
||||
|
||||
CommitResource small = hookResource("small-messages", "managed-hook-messages", "[]".getBytes(StandardCharsets.UTF_8));
|
||||
planBody.set("messagesRef", hookRef(small));
|
||||
CommitResource smallPlan = hookResource("small-plan", "managed-hook-plan", ExtensionRecordJournal.bytes(planBody));
|
||||
ObjectNode smallExecution = execution.deepCopy();
|
||||
smallExecution.put("hookExecutionId", "small").put("occurrenceId", "small");
|
||||
smallExecution.withObject("/run").put("effectId", "small");
|
||||
smallExecution.set("planRef", hookRef(smallPlan));
|
||||
assertThatThrownBy(() -> journal.commit(journal.requestDomain("small-missing", "hook_execution",
|
||||
smallExecution, List.of(smallPlan), 1000))).isInstanceOf(ApiException.class);
|
||||
commitDomain(journal, "small-valid", "hook_execution", smallExecution, List.of(smallPlan, small));
|
||||
assertThat(sessionStore.readResource(TENANT, WORKSPACE, sessionId, small.resourceId(),
|
||||
"extension-writer-token-0123456789").bytes()).isEqualTo("[]".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static CommitResource hookResource(String id, String kind, byte[] bytes) {
|
||||
return new CommitResource(id, kind, 1, bytes.length, ExtensionRecordJournal.sha256(bytes),
|
||||
Base64.getEncoder().encodeToString(bytes));
|
||||
}
|
||||
|
||||
private static ObjectNode hookRef(CommitResource resource) {
|
||||
return JsonNodeFactory.instance.objectNode().put("resourceId", resource.resourceId()).put("kind", resource.kind())
|
||||
.put("schemaVersion", resource.schemaVersion()).put("byteLength", resource.byteLength()).put("digest", resource.digest());
|
||||
}
|
||||
|
||||
private ExtensionRecordJournal journal(String sessionId) {
|
||||
return new ExtensionRecordJournal(sessionStore, TENANT, WORKSPACE,
|
||||
sessionId).open();
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManagedHookCatalogContractTest {
|
||||
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void closesThePublicCatalogAndExcludesPrivateRecipesAndExecutionFields() throws Exception {
|
||||
ObjectNode catalog = (ObjectNode) JSON.readTree("""
|
||||
{"object":"agent.hook_catalog","session_id":"6f1c7d7e-3a4b-4c2d-9e8f-0123456789ab","catalogs":[
|
||||
{"catalog_id":"catalog-1","catalog_revision":1,"hooks":[
|
||||
{"hook_id":"hook-1","event_name":"BeforeTool","type":"prompt","matcher":"shell",
|
||||
"sequential":true,"async":false,"fail_closed":true,"once":true}]}]}
|
||||
""");
|
||||
assertThat(CONTRACT.validate("/components/schemas/PublicHookCatalog", catalog)).isEmpty();
|
||||
for (String field : List.of("prompt", "command", "headers", "env", "modulePath", "handler", "runtimeSessionId", "onceKey")) {
|
||||
ObjectNode leaked = catalog.deepCopy();
|
||||
((ObjectNode) leaked.at("/catalogs/0/hooks/0")).put(field, "secret");
|
||||
assertThat(CONTRACT.validate("/components/schemas/PublicHookCatalog", leaked)).as(field).isNotEmpty();
|
||||
}
|
||||
ObjectNode invalid = catalog.deepCopy();
|
||||
((ObjectNode) invalid.at("/catalogs/0")).put("catalog_revision", 0);
|
||||
assertThat(CONTRACT.validate("/components/schemas/PublicHookCatalog", invalid)).isNotEmpty();
|
||||
ObjectNode untyped = catalog.deepCopy();
|
||||
((ObjectNode) untyped.at("/catalogs/0/hooks/0")).putObject("matcher").put("prompt", "secret");
|
||||
assertThat(CONTRACT.validate("/components/schemas/PublicHookCatalog", untyped)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionProjection;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManagedHookRecordContractTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void validatesTheSharedRecordsAndStarts() throws IOException {
|
||||
JsonNode fixtures = fixtures();
|
||||
for (JsonNode fixture : fixtures.get("cases")) {
|
||||
String domain = fixture.get("domain").textValue();
|
||||
String id = fixture.get("id").textValue();
|
||||
var body = ManagedExtensionProjection.RECORD_BODIES.get(domain);
|
||||
assertNull(body.taskKind());
|
||||
JsonNode record = merge(fixtures.get("templates").get(domain), fixture.get("patch"));
|
||||
if (fixture.get("valid").booleanValue()) {
|
||||
body.require().accept(record);
|
||||
} else {
|
||||
assertThrows(InvalidRecordException.class, () -> body.require().accept(record), id);
|
||||
}
|
||||
assertEquals(fixture.get("start").booleanValue(), body.isStart().test(record), id);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesTheSharedSuccessors() throws IOException {
|
||||
JsonNode fixtures = fixtures();
|
||||
for (JsonNode fixture : fixtures.get("successors")) {
|
||||
String domain = fixture.get("domain").textValue();
|
||||
JsonNode template = fixtures.get("templates").get(domain);
|
||||
assertEquals(fixture.get("valid").booleanValue(),
|
||||
ManagedExtensionProjection.RECORD_BODIES.get(domain).isSuccessor().test(
|
||||
merge(template, fixture.get("before")), merge(template, fixture.get("after"))),
|
||||
fixture.get("id").textValue());
|
||||
}
|
||||
}
|
||||
|
||||
static JsonNode fixtures() throws IOException {
|
||||
Path directory = Path.of("").toAbsolutePath();
|
||||
while (directory != null) {
|
||||
Path path = directory.resolve("packages/core/src/managed-runtime/contracts/managed-hook-record-v1.fixtures.json");
|
||||
if (Files.exists(path)) {
|
||||
return JSON.readTree(Files.readString(path));
|
||||
}
|
||||
directory = directory.getParent();
|
||||
}
|
||||
throw new IOException("Hook contract fixtures not found");
|
||||
}
|
||||
|
||||
static JsonNode merge(JsonNode base, JsonNode patch) {
|
||||
ObjectNode value = base == null || !base.isObject() ? JSON.createObjectNode() : base.deepCopy();
|
||||
patch.fields().forEachRemaining(entry -> value.set(entry.getKey(), entry.getValue().isObject()
|
||||
? merge(value.get(entry.getKey()), entry.getValue()) : entry.getValue().deepCopy()));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+42
@@ -8,14 +8,18 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -55,6 +59,44 @@ class ManagedTurnQueryTest {
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private ManagedAgentStore store;
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(longs = {1_000, 2_000})
|
||||
void latestTurnAndEnvironmentFollowAdmissionOrder(long firstCreatedAt)
|
||||
throws Exception {
|
||||
String tenant = tenant();
|
||||
String sessionId = emptySession(tenant);
|
||||
turn(tenant, sessionId, "turn_z", "COMPLETED", firstCreatedAt,
|
||||
3_000L, null);
|
||||
turn(tenant, sessionId, "turn_a", "RUNNING", 1_000, null, null);
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId, "turn_z",
|
||||
"turn.accepted", Map.of("input", List.of()), false,
|
||||
"accepted:first");
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId, "turn_z",
|
||||
"environment.ready", Map.of(), false, "ready:first");
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId, "turn_a",
|
||||
"turn.accepted", Map.of("input", List.of()), false,
|
||||
"accepted:second");
|
||||
|
||||
assertThat(store.findLatestTurn(tenant, sessionId)).get()
|
||||
.satisfies(turn -> assertThat(turn.turnId()).isEqualTo("turn_a"));
|
||||
assertThat(store.findLatestEnvironmentEvent(tenant, sessionId)).isEmpty();
|
||||
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId, "turn_a",
|
||||
"environment.ready", Map.of(), false, "ready:second");
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId, "turn_z",
|
||||
"environment.failed", Map.of("code", "runtime_warm_failed"),
|
||||
false, "failed:first");
|
||||
|
||||
assertThat(store.findLatestEnvironmentEvent(tenant, sessionId)).get()
|
||||
.satisfies(event -> {
|
||||
assertThat(event.turnId()).isEqualTo("turn_a");
|
||||
assertThat(event.type()).isEqualTo("environment.ready");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesNewestFirstAndBreaksTiesByTurnId() throws Exception {
|
||||
String tenant = tenant();
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
class ManagedHookCatalogServiceTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private final ManagedAgentService sessions = mock(ManagedAgentService.class);
|
||||
private final ManagedExtensionRecordStore records = mock(ManagedExtensionRecordStore.class);
|
||||
private final ManagedHookCatalogService service = new ManagedHookCatalogService(sessions, records);
|
||||
|
||||
@Test
|
||||
void returnsOnlyScalarPlanningFieldsWithoutRecipesOrHandlerImplementations() throws Exception {
|
||||
ObjectNode registration = JSON.createObjectNode().put("catalogId", "catalog-1").put("catalogRevision", 2);
|
||||
registration.putObject("catalogRef").put("resourceId", "catalog");
|
||||
when(records.latestHookRegistration("tenant", "session")).thenReturn(Optional.of(registration));
|
||||
when(records.readRecordResource("tenant", "session", registration.get("catalogRef"))).thenReturn(JSON.readTree("""
|
||||
{"catalogId":"catalog-1","catalogRevision":2,"definitionDigest":"secret",
|
||||
"hooks":[{"hookId":"hook-1","eventName":"BeforeTool","matcher":"shell","sequential":true,
|
||||
"async":false,"failClosed":true,"onceKey":"secret",
|
||||
"config":{"type":"prompt","prompt":"secret","command":"secret","headers":{"Authorization":"secret"},
|
||||
"env":{"TOKEN":"secret"},"url":"https://secret.invalid","modulePath":"/secret.js"},
|
||||
"handler":{"handlerId":"secret","handlerRevision":1}},
|
||||
{"hookId":"hook-2","eventName":"AfterTool","sequential":false,"async":true,"failClosed":false,
|
||||
"onceKey":null,"config":{"type":"function"},"matcher":{"prompt":"secret"}}]}
|
||||
"""));
|
||||
assertThat(service.get("tenant", "actor", "session")).isEqualTo(JSON.readTree("""
|
||||
{"object":"agent.hook_catalog","session_id":"session","catalogs":[{
|
||||
"catalog_id":"catalog-1","catalog_revision":2,"hooks":[
|
||||
{"hook_id":"hook-1","event_name":"BeforeTool","type":"prompt","matcher":"shell",
|
||||
"sequential":true,"async":false,"fail_closed":true,"once":true},
|
||||
{"hook_id":"hook-2","event_name":"AfterTool","type":"function",
|
||||
"sequential":false,"async":true,"fail_closed":false,"once":false}]}]}
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsAnEmptyCatalogBeforeAnyRegistrationSettles() {
|
||||
when(records.latestHookRegistration("tenant", "session")).thenReturn(Optional.empty());
|
||||
assertThat(service.get("tenant", "actor", "session").path("catalogs")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizesBeforeReadingRecordsOrResources() {
|
||||
when(sessions.requireReadableSession("tenant", "actor", "session"))
|
||||
.thenThrow(new ApiException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."));
|
||||
assertThatThrownBy(() -> service.get("tenant", "actor", "session")).isInstanceOf(ApiException.class);
|
||||
verifyNoInteractions(records);
|
||||
}
|
||||
}
|
||||
+47
@@ -585,6 +585,53 @@ class WorkspaceRuntimeTest {
|
||||
authority.assertHeld(session.workspace(), fixture.record());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hookAdmissionRequiresOwnershipAndRecoveryUsesTheOriginalOwnerAfterRevocation() throws Exception {
|
||||
SessionRecord session = createSession("storage", ".");
|
||||
var fixture = transport(session);
|
||||
var runtimeSession = fixture.record().getSession();
|
||||
Map<String, Object> configure = hookControl(session, "hook-execute");
|
||||
assertBusy(() -> fixture.transport().control(fixture.lease(), runtimeSession, configure));
|
||||
verify(fixture.http(), never()).control(any(), any(), any());
|
||||
authority.claim(session.workspace(), fixture.record());
|
||||
when(fixture.http().control(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(Map.of("state", "running")));
|
||||
assertThat(fixture.transport().control(fixture.lease(), runtimeSession, configure).toCompletableFuture().join())
|
||||
.isEqualTo(Map.of("state", "running"));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
for (String kind : List.of("hook-execute", "hook-catalog")) {
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, kind)));
|
||||
}
|
||||
for (String kind : List.of("hook-status", "hook-cancel")) {
|
||||
Map<String, Object> operation = hookControl(session, kind);
|
||||
fixture.transport().control(fixture.lease(), runtimeSession, operation).toCompletableFuture().join();
|
||||
verify(fixture.http()).control(fixture.lease(), runtimeSession, operation);
|
||||
}
|
||||
authority.assertHeld(session.workspace(), fixture.record());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hookLookupCannotUseAReplacedLeaseOrLostRuntime() throws Exception {
|
||||
SessionRecord session = createSession("storage", ".");
|
||||
var fixture = transport(session);
|
||||
var runtimeSession = fixture.record().getSession();
|
||||
authority.claim(session.workspace(), fixture.record());
|
||||
Map<String, Object> lookup = hookControl(session, "hook-status");
|
||||
RuntimeLease changed = new RuntimeLease("replacement", fixture.lease().getEndpoint(), fixture.lease().getToken(),
|
||||
fixture.lease().getLeaseId(), fixture.lease().getEpoch() + 1);
|
||||
assertUnavailable(() -> fixture.transport().control(changed, runtimeSession, lookup));
|
||||
assertThat(fixture.bindings().compareAndSet(fixture.runtime(),
|
||||
fixture.runtime().withState(RuntimeBindingRecord.State.LOST, fixture.lease(), Instant.now()))).isNotNull();
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, lookup));
|
||||
verify(fixture.http(), never()).control(any(), any(), any());
|
||||
authority.assertHeld(session.workspace(), fixture.record());
|
||||
}
|
||||
|
||||
private static Map<String, Object> hookControl(SessionRecord session, String kind) {
|
||||
return Map.of("kind", kind, "operationId", kind,
|
||||
"sessionKey", Map.of("tenantId", session.tenantId(),
|
||||
"workspaceId", session.workspace().getWorkspaceId(), "sessionId", session.sessionId()));
|
||||
}
|
||||
|
||||
private static Map<String, Object> mcpControl(SessionRecord session, String kind) {
|
||||
return Map.of("kind", kind, "operationId", kind,
|
||||
"sessionKey", Map.of("tenantId", session.tenantId(),
|
||||
|
||||
+14
@@ -664,6 +664,20 @@ public final class HttpRuntimeTransport implements RuntimeTransport {
|
||||
return post(lease, ManagedMcpProtocol.PATH, encoded, TOOL_RESULT_LIMIT_BYTES)
|
||||
.thenApply(bytes -> ManagedMcpProtocol.response(bytes, session, immutable));
|
||||
}
|
||||
if (ManagedHookProtocol.isOperation(immutable)) {
|
||||
ManagedHookProtocol.validateSession(session, immutable);
|
||||
Map<String, Object> body = Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", immutable);
|
||||
byte[] encoded;
|
||||
try {
|
||||
encoded = encodeToolRequest(body, 8 * 1024 * 1024);
|
||||
} catch (IllegalArgumentException tooLarge) {
|
||||
throw new RuntimeBrokerException(413, "runtime_control_operation_too_large",
|
||||
"Runtime Hook operation exceeds its size limit.", false);
|
||||
}
|
||||
return post(lease, ManagedHookProtocol.PATH, encoded, TOOL_RESULT_LIMIT_BYTES)
|
||||
.thenApply(bytes -> ManagedHookProtocol.response(bytes, session, immutable));
|
||||
}
|
||||
ProviderRuntimeProtocol.control(immutable, session.getHarnessSessionId(), session.getRuntimeSessionId());
|
||||
if ("history".equals(immutable.get("kind")) || "raw-file-history".equals(immutable.get("kind"))) {
|
||||
return provider(lease, session, immutable);
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.alibaba.qwen.code.runtimebroker;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Session ownership and bounded wire envelope for private Hook controls. */
|
||||
public final class ManagedHookProtocol {
|
||||
static final String PATH = "/internal/managed-runtime/v3/hooks";
|
||||
private static final Set<String> KINDS = Set.of("hook-catalog", "hook-execute", "hook-status", "hook-cancel");
|
||||
private static final Set<String> RECOVERY_KINDS = Set.of("hook-status", "hook-cancel");
|
||||
private static final Set<String> VIEW_FIELDS = Set.of("operationId", "state", "catalog", "result", "error");
|
||||
private static final Set<String> STATES = Set.of("running", "settled", "outcome_unknown");
|
||||
|
||||
private ManagedHookProtocol() {
|
||||
}
|
||||
|
||||
public static boolean isOperation(Map<String, Object> operation) {
|
||||
return operation.get("kind") instanceof String kind && KINDS.contains(kind);
|
||||
}
|
||||
|
||||
public static boolean isRecovery(Map<String, Object> operation) {
|
||||
return operation.get("kind") instanceof String kind && RECOVERY_KINDS.contains(kind);
|
||||
}
|
||||
|
||||
public static void validateSession(RuntimeSession session, Map<String, Object> operation) {
|
||||
if (!BrokerValues.isWellFormedJson(operation)) {
|
||||
throw invalid("Hook operation is invalid.");
|
||||
}
|
||||
RuntimeScope scope = session.getScope();
|
||||
Map<String, Object> expected = Map.of("tenantId", scope.getTenantId(),
|
||||
"workspaceId", scope.getWorkspaceId(), "sessionId", session.getHarnessSessionId());
|
||||
if (!isOperation(operation) || !expected.equals(operation.get("sessionKey"))
|
||||
|| !(operation.get("operationId") instanceof String id) || id.isBlank() || id.length() > 512) {
|
||||
throw invalid("Hook operation does not belong to the acquired Session.");
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, Object> response(byte[] bytes, RuntimeSession session, Map<String, Object> request) {
|
||||
Map<String, Object> envelope = ManagedContextProtocol.parse(bytes);
|
||||
Object expectedId = Set.of("hook-status", "hook-cancel").contains(request.get("kind"))
|
||||
? request.get("targetOperationId") : request.get("operationId");
|
||||
if (!envelope.keySet().equals(Set.of("protocolVersion", "runtimeSessionId", "operation"))
|
||||
|| !Integer.valueOf(1).equals(envelope.get("protocolVersion"))
|
||||
|| !session.getRuntimeSessionId().equals(envelope.get("runtimeSessionId"))
|
||||
|| !(envelope.get("operation") instanceof Map<?, ?> view)
|
||||
|| !VIEW_FIELDS.containsAll(view.keySet())
|
||||
|| expectedId == null || !expectedId.equals(view.get("operationId"))
|
||||
|| !(view.get("state") instanceof String state) || !STATES.contains(state)
|
||||
|| view.containsKey("result") && !(view.get("result") instanceof Map)
|
||||
|| view.containsKey("catalog") && !(view.get("catalog") instanceof Map)
|
||||
|| view.containsKey("error") && (!(view.get("error") instanceof Map<?, ?> error)
|
||||
|| !error.keySet().equals(Set.of("code"))
|
||||
|| !(error.get("code") instanceof String code) || !code.matches("[a-z][a-z0-9_]{0,127}"))
|
||||
|| !"settled".equals(state) && (view.containsKey("result") || view.containsKey("catalog"))) {
|
||||
throw new RuntimeBrokerException(502, "managed_hook_response_invalid",
|
||||
"Managed Hook response is invalid.", false);
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> result = (Map<String, Object>) view;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RuntimeBrokerException invalid(String message) {
|
||||
return new RuntimeBrokerException(400, "runtime_control_operation_invalid", message, false);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -67,7 +67,7 @@ final class ProviderRuntimeProtocol {
|
||||
}
|
||||
case "prepare" -> {
|
||||
required = Set.of("kind", "action", "promptId", "paths");
|
||||
if (!sessionId.equals(string(operation, "promptId"))
|
||||
if (string(operation, "promptId").length() > 128
|
||||
|| !(operation.get("paths") instanceof List<?> paths)
|
||||
|| paths.isEmpty() || paths.stream().anyMatch(value -> !(value instanceof String))) {
|
||||
throw invalid();
|
||||
|
||||
+4
-1
@@ -202,7 +202,7 @@ public final class RuntimeBrokerService implements AutoCloseable {
|
||||
requireOpen();
|
||||
Map<String, Object> immutable = immutableMap(operation,
|
||||
"operation");
|
||||
if (!ManagedMcpProtocol.isOperation(immutable)) {
|
||||
if (!ManagedMcpProtocol.isOperation(immutable) && !ManagedHookProtocol.isOperation(immutable)) {
|
||||
ProviderRuntimeProtocol.control(immutable, harnessSessionId, runtimeSessionId);
|
||||
}
|
||||
return requireReadySession(harnessSessionId, runtimeSessionId)
|
||||
@@ -210,6 +210,9 @@ public final class RuntimeBrokerService implements AutoCloseable {
|
||||
if (ManagedMcpProtocol.isOperation(immutable)) {
|
||||
ManagedMcpProtocol.validateSession(context.session(), immutable);
|
||||
}
|
||||
if (ManagedHookProtocol.isOperation(immutable)) {
|
||||
ManagedHookProtocol.validateSession(context.session(), immutable);
|
||||
}
|
||||
synchronized (context) {
|
||||
requireReadySessionRecord(context);
|
||||
context.beginControl();
|
||||
|
||||
+85
@@ -992,6 +992,91 @@ class HttpRuntimeTransportTest {
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsHookReceiptsThroughTheBoundedPrivateRoute() throws Exception {
|
||||
RuntimeSession session = toolSession();
|
||||
Map<String, Object> operation = hookOperation(session, "hook-execute");
|
||||
Map<String, Object> data = Map.of("contents", List.of(Map.of("uri", "test:blob",
|
||||
"blob", "AAE=", "mimeType", "application/octet-stream")));
|
||||
Map<String, Object> view = Map.of("operationId", "hook-operation", "state", "settled", "result", data);
|
||||
reply.set(json(200, JsonCodec.encode(Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", view))));
|
||||
assertEquals(view, transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
assertEquals(ManagedHookProtocol.PATH, capturedPath.get());
|
||||
assertEquals(JSON.valueToTree(Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", operation)), JSON.readTree(captured.get()));
|
||||
assertEquals("Bearer " + toolSuite.required("identity").required("token").textValue(), capturedAuthorization.get());
|
||||
assertEquals("no-store", capturedCacheControl.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsCompleteHookContextBeyondTheToolRequestLimit() throws Exception {
|
||||
RuntimeSession session = toolSession();
|
||||
Map<String, Object> operation = new LinkedHashMap<>(hookOperation(session, "hook-execute"));
|
||||
operation.put("input", Map.of("messages", List.of(Map.of("text", "x".repeat(300 * 1024)))));
|
||||
Map<String, Object> view = Map.of("operationId", "hook-operation", "state", "running");
|
||||
reply.set(json(200, JsonCodec.encode(Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", view))));
|
||||
assertEquals(view, transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
assertEquals(JSON.valueToTree(operation), JSON.readTree(captured.get()).get("operation"));
|
||||
operation.put("input", Map.of("messages", List.of(Map.of("text", "x".repeat(8 * 1024 * 1024)))));
|
||||
assertThrows(RuntimeBrokerException.class,
|
||||
() -> transport.control(toolLease(server.getAddress().getPort()), session, operation));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hookRecoveryKeepsTheOriginalOperationIdentityAndRejectsMalformedReceipts() throws Exception {
|
||||
RuntimeSession session = toolSession();
|
||||
Map<String, Object> operation = hookOperation(session, "hook-status");
|
||||
Map<String, Object> view = Map.of("operationId", "original-operation", "state", "outcome_unknown");
|
||||
Map<String, Object> envelope = Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", view);
|
||||
reply.set(json(200, JsonCodec.encode(envelope)));
|
||||
assertEquals(view, transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
for (Map<String, Object> invalid : List.<Map<String, Object>>of(
|
||||
Map.of("operationId", "hook-operation", "state", "settled"),
|
||||
Map.of("operationId", "original-operation", "state", "unknown"),
|
||||
Map.of("operationId", "original-operation", "state", "outcome_unknown", "result", Map.of()),
|
||||
Map.of("operationId", "original-operation", "state", "settled", "toolResult", Map.of()),
|
||||
Map.of("operationId", "original-operation", "state", "settled", "error", Map.of("code", "failed", "message", "secret")))) {
|
||||
reply.set(json(200, JsonCodec.encode(Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", session.getRuntimeSessionId(), "operation", invalid))));
|
||||
assertThrows(ExecutionException.class, () -> transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
}
|
||||
reply.set(json(200, JsonCodec.encode(Map.of("protocolVersion", 1,
|
||||
"runtimeSessionId", "different-session", "operation", view))));
|
||||
assertThrows(ExecutionException.class, () -> transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
reply.set(json(200, new byte[HttpRuntimeTransport.TOOL_RESULT_LIMIT_BYTES + 1]));
|
||||
assertThrows(ExecutionException.class, () -> transport.control(toolLease(server.getAddress().getPort()), session, operation)
|
||||
.toCompletableFuture().get(2, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private static Map<String, Object> hookOperation(RuntimeSession session, String kind) {
|
||||
return Map.of("kind", kind, "operationId", "hook-operation",
|
||||
"targetOperationId", "original-operation",
|
||||
"sessionKey", Map.of("tenantId", session.getScope().getTenantId(),
|
||||
"workspaceId", session.getScope().getWorkspaceId(), "sessionId", session.getHarnessSessionId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForeignHookSessionsBeforeSending() {
|
||||
RuntimeSession session = toolSession();
|
||||
for (String field : List.of("tenantId", "workspaceId", "sessionId")) {
|
||||
Map<String, Object> operation = new LinkedHashMap<>(hookOperation(session, "hook-status"));
|
||||
Map<String, Object> key = new LinkedHashMap<>(Map.of("tenantId", session.getScope().getTenantId(),
|
||||
"workspaceId", session.getScope().getWorkspaceId(), "sessionId", session.getHarnessSessionId()));
|
||||
key.put(field, "foreign");
|
||||
operation.put("sessionKey", key);
|
||||
assertThrows(RuntimeBrokerException.class, () -> transport.control(toolLease(server.getAddress().getPort()), session, operation));
|
||||
}
|
||||
assertNull(captured.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForeignMcpSessionsAndToolCallsBeforeSending() {
|
||||
RuntimeSession session = toolSession();
|
||||
|
||||
+6
-2
@@ -99,11 +99,15 @@ class ProviderRuntimeTransportTest {
|
||||
assertEquals(result, transport.control(lease, session, bind).toCompletableFuture().join());
|
||||
assertEquals(1, requests.size());
|
||||
assertEquals(bind, requests.getFirst().get("operation"));
|
||||
Map<String, Object> prepare = Map.of("kind", "raw-file-history", "action", "prepare",
|
||||
"promptId", "original-prompt", "paths", List.of("a"));
|
||||
assertEquals(result, transport.control(lease, session, prepare).toCompletableFuture().join());
|
||||
assertEquals(prepare, requests.getLast().get("operation"));
|
||||
assertThrows(RuntimeBrokerException.class, () -> transport.control(lease, session,
|
||||
Map.of("kind", "raw-file-history", "action", "prepare", "promptId", "other", "paths", List.of("a"))));
|
||||
Map.of("kind", "raw-file-history", "action", "prepare", "promptId", "", "paths", List.of("a"))));
|
||||
assertThrows(RuntimeBrokerException.class, () -> transport.control(lease, session,
|
||||
Map.of("kind", "raw-file-history", "action", "bind", "state", Map.of("ownerSessionId", "other"))));
|
||||
assertEquals(1, requests.size());
|
||||
assertEquals(2, requests.size());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
+19
@@ -1161,6 +1161,25 @@ class RuntimeBrokerServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hookControlsStayWithTheAcquiredSessionAndNeverProvisionForLookup() {
|
||||
try (Fixture fixture = new Fixture(SESSION_SCOPE)) {
|
||||
RuntimeSessionRecord session = join(fixture.service.acquire("harness", "runtime", "bootstrap"));
|
||||
for (String kind : List.of("hook-catalog", "hook-execute", "hook-status", "hook-cancel")) {
|
||||
Map<String, Object> operation = Map.of("kind", kind, "operationId", "operation",
|
||||
"sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness"));
|
||||
assertEquals("ok", join(fixture.service.control("harness", "runtime", operation)));
|
||||
assertEquals(operation, fixture.transport.lastControl);
|
||||
}
|
||||
Map<String, Object> foreign = Map.of("kind", "hook-status", "operationId", "lookup",
|
||||
"sessionKey", Map.of("tenantId", "tenant", "workspaceId", "foreign", "sessionId", "harness"));
|
||||
assertEquals("runtime_control_operation_invalid", failure(fixture.service.control("harness", "runtime", foreign)).getCode());
|
||||
assertEquals("runtime_session_not_found", failure(fixture.service.control("harness", "new-runtime", foreign)).getCode());
|
||||
assertEquals(1, fixture.provisioner.calls.get());
|
||||
assertEquals(session.getSession(), fixture.transport.lastSession);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesIllFormedMcpStringsBeforeForwarding() {
|
||||
try (Fixture fixture = new Fixture(SESSION_SCOPE)) {
|
||||
|
||||
@@ -100,6 +100,7 @@ test('shell card separates output, reveals and copies commands, and retains fail
|
||||
});
|
||||
await expect(collapsedSteps).toHaveCount(2);
|
||||
await collapsedSteps.first().click();
|
||||
await expect(collapsedSteps).toHaveCount(1);
|
||||
await collapsedSteps.first().click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Wait for daemon health', exact: true })
|
||||
|
||||
@@ -104,13 +104,11 @@ it('keeps lint_and_static sized for cold-cache pool runs', () => {
|
||||
|
||||
it('keeps browser gates hosted independently of the shared Linux runner', () => {
|
||||
expect(ci.jobs.web_shell_e2e_smoke['runs-on']).toBe('ubuntu-latest');
|
||||
// 30, not 20: at 184 smoke tests plus ~7.5 min of setup, passing runs hit
|
||||
// 20 flat (tests done at 20:07:49, cancelled 20:07:53; the last of 184
|
||||
// still running at 02:04:58, cancelled 02:05:04). Sharding is the fix at
|
||||
// the source once the suite keeps growing.
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(30);
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(30);
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(30);
|
||||
// Slow hosted browser installs took over 23 min, so reserve time in the
|
||||
// 60-minute job for transcript/smoke tests and artifact upload.
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(60);
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(60);
|
||||
expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(60);
|
||||
});
|
||||
|
||||
// One helper for both "an <event> run reaches exactly these jobs" invariants.
|
||||
|
||||
Reference in new issue
Block a user