machine-bridge-mcp 3.0.0-beta.185 → 3.0.0-beta.190

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 3.0.0-beta.185 - 2026-09-11
3
+ ## 3.0.0-beta.190 - 2026-09-13
4
4
 
5
- - Preserve interrupted workspace-profile migration after a source-profile rename when Windows exposes the same state root through a different long/short or junction-equivalent path: relocated historical proof canonicalizes only the nearest existing ancestor, still requires the exact historical profile hash plus `state.json` suffix, and rejects a different canonical ancestor.
6
- - Normalize Windows native-path identity during workspace migration so active locks and relocated historical profile state remain comparable across ordinary drive paths and native namespace-prefixed forms without weakening containment checks.
7
- - Keep hosted validation compatible with CodeQL by replacing dynamic resolver regular-expression construction with fixed token parsing while retaining the same release-carrier and worktree-selection behavior.
8
- - Invalidate the stale beta.184 acceptance after the packaged migration repair and advance package, Worker, and browser-extension identity to `3.0.0-beta.185`; fresh activation, deployed OAuth canary evidence, and observed live verification are required before recording acceptance.
5
+ - Reuse generation-bound V8 coverage collected during the authoritative full verification plan so the coverage gate only executes fixtures whose evidence is still missing; standalone coverage verification remains self-contained.
6
+ - Make GitHub release publication consume exact-main provider CI instead of repeating a local dependency install and full verification after merge, while retaining synchronized-version checks, accepted-candidate byte and promotion-digest revalidation, exact-main revalidation before remote mutation, and uploaded-asset digest verification.
7
+ - Increase only the Windows runtime self-test success-fixture budget for loaded hosted CI; production process and resource-admission deadlines remain unchanged.
9
8
 
10
9
  ## Historical releases
11
10
 
@@ -30,6 +30,6 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "3.0.0-beta.185",
33
+ "version_name": "3.0.0-beta.190",
34
34
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxryYkpZhq8+VAQLHcGS9BAHQcyKX8RHGIpIwvtIVRU/rcOcE0bNdnM0aZJ/h6xWQsGDHlhvjT2+1aJaAn/9k8473BRWajzVXld961CdHYVFVHoce2hHiSJ0xydWrHMMZhAm0mN0UzjEpgZ0tMw209efcZHIvSwuxhteZMRy4kyiVjwFlOf5oXFCxRuCJnPj3AK9CmCf4XgEBuPIJ0TZmjGHOOdBvJmbCNnAWXYEo5/mf7MfCGhV4IJ1hNuhpoNQfOFKMUcw9/v/IpT62XpfXdGYTfGYCmCjC+gntK1spbkr2P4/2+sYMQtLpse71mpSNGXfcf3abU55Vpn+gncSxRQIDAQAB"
35
35
  }
package/docs/AUDIT.md CHANGED
@@ -26,9 +26,10 @@ This file is the current audit summary. Historical findings, closed incidents, a
26
26
  - Beta.181 supersedes the activated-but-unaccepted beta.180 candidate after live owner-machine routing exposed two semantic classification defects rather than a relay failure: `非交互工作` and conditional `外部输入或授权` wording could exclude task supervision, while a project token could weak-match an unrelated installed application sharing one lexical fragment. The repair narrows interactive intent to explicit process/input contexts, requires lexical-token evidence for partial application matches, and makes an already-positive task-supervisor continuation contract authoritative for primary route selection. beta.180 relay standby/takeover behavior is unchanged.
27
27
  - Beta.182 is the reviewed predecessor candidate that added repository-specific durable routing for known long prerelease commands, extracted reconnect result settlement into a focused policy module while retaining the then-existing per-tool settlement ceiling, and recorded the controlled relay A/B. Its packaged bytes remain associated with beta.182 and are not reused after the independent re-review changed shipped source.
28
28
  - Beta.183 supersedes beta.182 after that re-review found package-affecting continuity, routing, privacy, and auditability defects. Repository-specific long workflows now request durable task supervision through project-provided registered-command metadata rather than hard-coded script names in generic routing; explicit read-only/negated/hypothetical/interactive/existing-job wording is kept out of new-job creation, and weak application-name matches require UI-operation intent. Worker reconnect settlement keeps execution/redelivery on the original deadline but grants the verified same-daemon terminal-result owner one fixed non-cumulative 15-second delivery-only extension beyond the original settlement deadline. Managed-job output redaction is byte-first and truncation-boundary safe; saturated retention tolerates a stale active-to-terminal dependency-plan deletion race without weakening genuine active-state fail-closed behavior; multiline static module edges are included in the architecture graph; and public worktree resolution no longer depends on maintainer-home tooling. The beta.182 controlled application-proxy A/B remains evidence only for the induced fault branch and does not identify the cause of spontaneous historical 1006 resets.
29
+ - Beta.186 is the source candidate produced by an independent beta.185 review. The first review confirmed two issues: a zero-wait `read_job` starts with exactly the 10-second managed-read headroom, so any positive reconnect delay made daemon-proven non-delivery ineligible for redelivery; and the output-redaction sink itself accepted an empty literal even though current resource materialization already filters empty patterns. Subsequent hardening reproduced four adjacent boundary defects before acceptance: truncation-tail protection could receive truthy non-string redaction entries; repeated missing acknowledgements could re-enter redelivery policy; `resume_calls_ack.missing_ids` was not bound to the exact resume set emitted on that connection; and shorter protected bytes/paths/literals could partially replace a longer overlapping value and leave its suffix visible. The candidate now uses one literal-pattern filter, processes overlapping protected values longest-first, binds resume acknowledgement to the exact connection-scoped resume set, consumes one semantic acknowledgement while treating only its exact duplicate as idempotent, and records the channel of the one successful same-ID transport redelivery. A different later acknowledgement or out-of-set ID is a protocol error, while a later reconnect that again proves non-delivery returns the existing retryable no-side-effect failure. Low-headroom `read_job` redelivery still becomes `wait_ms: 0`, preserves the original execution deadline, and refuses less than one second of execution budget; fresh dispatch still requires the ordinary 10-second reconciliation headroom. The existing socket protocol-error counter is now driven by real WebSocket and signed-HTTPS ready-channel rejection paths without recording daemon payload values. Here “production protocol rejection paths” means the shipped Worker handler paths that can serve production traffic; it is a source-path reachability claim, not evidence that beta.186 has been deployed to or observed in a production environment. Other report proposals are not treated as defects without stronger evidence: request-stream closure is deliberately cancellation for request-scoped work; the 2-second durable-process initial settlement window is a response-coalescing bound rather than task lifetime and same-response `read_job` continuation remains the orchestration contract; historical macOS relocation already canonicalizes existing realpath ancestors, so platform-wide lowercasing would be unsound on case-sensitive volumes; beta.179 and beta.181 have passed acceptance records and are retained; and disabling fallback-proxy keep-alive is not justified by the current fault-injection evidence alone.
29
30
 
30
31
  ## Residual review requirements
31
32
 
32
33
  A green fast or full suite is necessary but not sufficient security evidence for publication. Release acceptance still requires the package/install/security gates and any hosted or live boundary evidence required by the changed surface. This summary does not authorize deployment or npm publication.
33
34
 
34
- Beta.181 is the current accepted/live baseline. Beta.182 remains the reviewed but superseded predecessor candidate. Beta.183 is the unaccepted replacement candidate and must pass frozen-tree verification plus exact-candidate owner-machine activation before acceptance. The controlled application-proxy A/B establishes only the induced fault branch; the spontaneous historical upstream reset source remains unassigned within the current privacy-bounded evidence. A future relay reset may still occur; continuity success means bounded warm HTTPS takeover plus durable task ownership, not an impossible no-disconnect guarantee. npm publication remains separately gated.
35
+ Beta.185 is the latest repository acceptance record and remains the accepted prior-byte baseline. Beta.186 is a new local source candidate and must not inherit beta.185 acceptance: frozen-tree verification, packaging/install/security checks, and any exact-candidate live activation evidence required by the changed surface must be produced again before acceptance. The spontaneous historical upstream reset source remains unassigned within the current privacy-bounded evidence; a future relay reset may still occur. npm publication remains separately gated.
package/docs/LOGGING.md CHANGED
@@ -156,7 +156,7 @@ Each managed job has owner-only runner diagnostic logs. Child-step output is ret
156
156
 
157
157
  `network_route` describes only Machine Bridge's application-level proxy decision. `system-network-stack` does **not** mean a direct physical path: an operating-system VPN, TUN, packet tunnel, DNS interceptor, or endpoint-security product may still carry the connection. `network_route_scope` therefore remains `application-proxy-selection-only`.
158
158
 
159
- During an outage, remote-owner `diagnose_runtime.runtime.relay` and local stdio `server_info.runtime.relay` expose bounded live fields: outage count/start/duration, attempts, last close category/code, coarse transport error class plus strict allowlisted `last_transport_error_reason`, last disconnect/ready time, prior ready duration, prior ready inbound-silence duration, the thirty-second WSS connect budget, bounded `last_connect_milestones_ms`, transport-probe queue/dispatch/Pong state, second-stage transport-confirmation timing/recovery state, bounded sender backlog bytes, HTTPS fallback active/warming state, `https_fallback_last_takeover_ms`, and next retry timing. `https_fallback_last_takeover_ms` retains the bounded WSS-close-to-verified-HTTPS-ready interval even after WSS later reclaims primary ownership, so a longer WebSocket outage is not misreported as the same duration of whole-bridge unavailability when HTTPS recovered earlier. The fallback status separates `last_success_at` (any successful signed HTTP exchange, including standby) from `last_ready_at` (only verified ready ownership), so a successful standby poll can no longer be misread as a completed failover. Connect milestones are relative durations only and never include hostnames, addresses, DNS answers, certificates, proxy endpoints, or credentials. The current-attempt milestones and separate `last_failed_connect_*` fields are retained independently so a successful retry cannot erase the immediately preceding failed DNS/TCP/TLS/upgrade evidence; `last_transport_error_ready` and `last_transport_error_authenticated` state whether the retained transport error occurred after channel authentication/readiness. Worker-side `daemon.websocket.closed` records only a bounded close code plus `was_clean`; raw peer close reasons are deliberately omitted. An already-dispatched Worker call detached from a failed channel is retained only until the smaller of reconnect grace and that call's original remaining absolute deadline. If it settles without rebinding, the public bounded error distinguishes `original call deadline expired during reconnect` from a true full `reconnect grace expired`; neither diagnostic includes tool arguments, paths, account identity, endpoint data, or result content, and the distinction does not extend the hosted deadline. Resource-coordinator snapshot contention is likewise classified separately from execution failure: when the bounded diagnostic cannot acquire a transaction/staging lock, it reports retryable `unavailable`, `reason=coordinator_busy`, and `snapshot_available=false`. This says the diagnostic snapshot was unavailable under contention; it does not relax admission policy or claim that the underlying host pressure is Green. `outage_duration_ms` is the close-to-ready recovery interval; `last_ready_inbound_silence_ms` is the pre-close interval since the preceding ready transport last proved inbound activity. After recovery, authenticated remote `server_info.daemon.relay_transport` retains the bounded preceding episode supplied during the current connection handshake, including `previous_ready_inbound_silence_ms` and brief interruptions below the default warning threshold. Promotion to a ready socket sets `outage_active=false`, extends the outage duration through actual readiness, preserves the preceding healthy-ready duration and inbound-silence evidence across failed candidates, canonicalizes timestamps, and accepts only enumerated coarse operational error classes; it does not claim that the recovered connection remains in outage. The `local_authority_revocation_retry` category is deliberately not diagnosed as a network failure: a sustained warning directs the operator to local authority, process-session, and managed-job state while the retained Worker revocation retries on reconnection; ordinary transport categories retain network/Worker troubleshooting guidance. On macOS, `diagnose_runtime` may also return a coarse default-route class, `operating_system_interception` boolean, privacy-bounded `runtime.idle_sleep_guard` state (`supported`, `enabled`, `active`, `grace_ms`, `requests_system_sleep_prevention_on_ac`, `last_error_class`), bounded `runtime.system_sleep`, and `runtime.relay_outage_analysis`. The sleep correlation distinguishes direct close-to-ready overlap from `wake_boundary_system_sleep_aftermath`, which requires the same sleep interval to match both the observed wake-adjacent disconnect and the daemon event-loop stall end/duration; a socket close cannot necessarily be observed while JavaScript is suspended. A near-wake reset without that independent stall match remains unassigned. That diagnostic is returned on demand and is not promoted to default logs; interface names, IP addresses, DNS answers, proxy endpoints/credentials, Worker endpoints, tool arguments, and results remain absent. `relay.outage.active` and `relay.outage.recovered` carry the existing safe relay fields.
159
+ During an outage, remote-owner `diagnose_runtime.runtime.relay` and local stdio `server_info.runtime.relay` expose bounded live fields: outage count/start/duration, attempts, last close category/code, coarse transport error class plus strict allowlisted `last_transport_error_reason`, last disconnect/ready time, prior ready duration, prior ready inbound-silence duration, the thirty-second WSS connect budget, bounded `last_connect_milestones_ms`, transport-probe queue/dispatch/Pong state, second-stage transport-confirmation timing/recovery state, bounded sender backlog bytes, HTTPS fallback active/warming state, `https_fallback_last_takeover_ms`, `https_fallback_last_takeover_outage_number`, and next retry timing. `https_fallback_last_takeover_ms` retains the bounded WSS-close-to-verified-HTTPS-ready interval even after WSS later reclaims primary ownership, so a longer WebSocket outage is not misreported as the same duration of whole-bridge unavailability when HTTPS recovered earlier; `https_fallback_last_takeover_outage_number` binds that takeover to the exact WebSocket outage episode. The fallback status separates `last_success_at` (any successful signed HTTP exchange, including standby) from `last_ready_at` (only verified ready ownership), so a successful standby poll can no longer be misread as a completed failover. In addition, each completed episode in `recent_outages` independently records `https_fallback_taken_over` and bounded `https_fallback_takeover_ms` keyed to that outage number, ensuring prior takeovers or intervening no-takeover episodes are not conflated. Connect milestones are relative durations only and never include hostnames, addresses, DNS answers, certificates, proxy endpoints, or credentials. The current-attempt milestones and separate `last_failed_connect_*` fields are retained independently so a successful retry cannot erase the immediately preceding failed DNS/TCP/TLS/upgrade evidence; `last_transport_error_ready` and `last_transport_error_authenticated` state whether the retained transport error occurred after channel authentication/readiness. Worker-side `daemon.websocket.closed` records only a bounded close code plus `was_clean`; raw peer close reasons are deliberately omitted. An already-dispatched Worker call detached from a failed channel is retained only until the smaller of reconnect grace and that call's original remaining absolute deadline. If it settles without rebinding, the public bounded error distinguishes `original call deadline expired during reconnect` from a true full `reconnect grace expired`; neither diagnostic includes tool arguments, paths, account identity, endpoint data, or result content, and the distinction does not extend the hosted deadline. Resource-coordinator snapshot contention is likewise classified separately from execution failure: when the bounded diagnostic cannot acquire a transaction/staging lock, it reports retryable `unavailable`, `reason=coordinator_busy`, and `snapshot_available=false`. This says the diagnostic snapshot was unavailable under contention; it does not relax admission policy or claim that the underlying host pressure is Green. `outage_duration_ms` is the close-to-ready recovery interval; `last_ready_inbound_silence_ms` is the pre-close interval since the preceding ready transport last proved inbound activity. After recovery, authenticated remote `server_info.daemon.relay_transport` retains the bounded preceding episode supplied during the current connection handshake, including `previous_ready_inbound_silence_ms` and brief interruptions below the default warning threshold. Promotion to a ready socket sets `outage_active=false`, extends the outage duration through actual readiness, preserves the preceding healthy-ready duration and inbound-silence evidence across failed candidates, canonicalizes timestamps, and accepts only enumerated coarse operational error classes; it does not claim that the recovered connection remains in outage. The `local_authority_revocation_retry` category is deliberately not diagnosed as a network failure: a sustained warning directs the operator to local authority, process-session, and managed-job state while the retained Worker revocation retries on reconnection; ordinary transport categories retain network/Worker troubleshooting guidance. On macOS, `diagnose_runtime` may also return a coarse default-route class, `operating_system_interception` boolean, privacy-bounded `runtime.idle_sleep_guard` state (`supported`, `enabled`, `active`, `grace_ms`, `requests_system_sleep_prevention_on_ac`, `last_error_class`), bounded `runtime.system_sleep`, and `runtime.relay_outage_analysis`. The sleep correlation distinguishes direct close-to-ready overlap from `wake_boundary_system_sleep_aftermath`, which requires the same sleep interval to match both the observed wake-adjacent disconnect and the daemon event-loop stall end/duration; a socket close cannot necessarily be observed while JavaScript is suspended. A near-wake reset without that independent stall match remains unassigned. That diagnostic is returned on demand and is not promoted to default logs; interface names, IP addresses, DNS answers, proxy endpoints/credentials, Worker endpoints, tool arguments, and results remain absent. `relay.outage.active` and `relay.outage.recovered` carry the existing safe relay fields.
160
160
 
161
161
  Schema 4 is strict NDJSON. Before daemon startup, both active log files are opened as owner-only regular single-link files. A schema change clears both only after validation and commits the marker only after the transition succeeds. A symlink, multiple-hard-link inode, permission error, or marker-write failure blocks startup rather than mixing formats or repeatedly erasing evidence.
162
162
 
@@ -10,7 +10,7 @@ machine-mcp service status
10
10
 
11
11
  Routine remote checks should use authenticated `server_info` with `detail: "summary"`; request the default/full projection only when the caller's authority permits and exact effective-tool, OAuth/account, or detailed owner observability is actually needed. Non-owner full responses intentionally retain hidden markers/counts instead of cross-principal activity, resource aliases, stable device-key identity, or daemon-only tool names. Remote `diagnose_runtime` is owner-only because its fixed probes expose machine-wide control-plane activity; narrower roles use `server_info`/`project_overview` for authority-scoped readiness and workspace state. `status` prints redacted profile state and verifies the deployed Worker version. Resource source paths remain redacted. `doctor` checks Node.js, the package-installed Wrangler binary, Cloudflare login, Worker health, the configured policy, the automatic-without-per-operation-prompts authorization model, and the same fixed local filesystem/process/shell/job-storage/resource probes exposed to the remote owner by `diagnose_runtime`. It constructs an isolated local runtime: `diagnosticScope.running_service_process_inspected=false` and `remote_relay_inspected=false` are deliberate, so a green doctor result is not evidence that the launchd/systemd/Scheduled Task daemon retained its Worker WebSocket. Inspect authenticated `server_info.daemon.relay_transport` for the running service relay. Authenticated `server_info.authorization.execution_model` reports the authority contract and identifies whether the account has daemon-OS-user ambient authority. Public `/healthz` output contains only server identity and version; daemon details require an authenticated `server_info` call.
12
12
 
13
- For interruption analysis, prefer one owner `diagnose_runtime` call over a chain of inventory probes. It now includes `runtime.managed_jobs.recent_activity`, `runtime.security_audit.recent_activity`, bounded `runtime.resource_admission.waiters.diagnostics`, `runtime.system_sleep`, `runtime.event_loop_pause_analysis`, and `runtime.relay_outage_analysis`. The audit aggregate contains only counts, bounded tool names, failure totals, calls-per-minute density, and numeric result-pressure fields (`output_bytes_last_15m`, `maximum_output_bytes_last_15m`, `large_result_calls_last_15m`, and `peak_output_bytes_per_minute_last_15m`) derived from the existing content-free hash-chained audit log; it contains no tool arguments or result content. Its `coverage=daemon_reached_relay_tool_calls_only` and `host_side_events_observable=false` fields make the evidence boundary explicit: host-only discovery/control-plane/final-delivery events are not counted. The waiter projection reports only resource-request shape and the current admission reason. On macOS with shell-capable owner diagnostics, the fixed power probe reduces `pmset` history to a small list of sleep start/end/duration/reason classes; it never returns raw power-log lines. `event_loop_pause_analysis.classification=matched_system_sleep` requires both the recorded runtime-stall end time and duration to match one of those bounded operating-system sleep intervals within a fixed tolerance. `relay_outage_analysis` separately compares the most recent completed `recent_outages[0].disconnected_at` -> `recent_outages[0].ready_at` interval with the same bounded sleep history and reports exact overlap duration/ratio; an active outage uses `outage_started_at` instead of the later `last_disconnected_at`. `majority_system_sleep_overlap` means at least half of that observed relay outage occurred while macOS was suspended. A sleeping JavaScript process may be unable to observe the stale socket until wake, so a zero-overlap close-to-ready interval is not automatically awake-network evidence: `wake_boundary_system_sleep_aftermath` is emitted only when the disconnect occurs within the fixed wake tolerance and that same sleep independently matches the event-loop stall in both end time and duration. Either sleep classification makes a retained `connection_reset`/timeout transport aftermath rather than sufficient evidence of a separate network root cause. `no_matching_recent_system_sleep` remains the classification for an awake reset or a merely coincidental near-wake reset without same-sleep stall evidence; it leaves the cause unassigned rather than guessing a VPN, edge, Worker, or host cause. Full relay diagnostics additionally expose `recent_outages`, a newest-first in-memory history capped at eight completed WebSocket reconnect episodes. Each entry contains only bounded outage numbering, first/final disconnect and ready timestamps, duration, close/error classes, previous-ready duration/silence, first-disconnect liveness phase/timing, coarse application-route class, and connection-stage timings. `disconnected_at` is the first outage transition and `last_disconnect_at` is the final failed reconnect transition. A protocol/application Pong that clears a liveness suspicion without rebuilding the WebSocket remains heartbeat evidence and is deliberately absent from `recent_outages`; the array is therefore reconnect history, not a list of every transient transport suspicion. ChatGPT host-turn termination/final-message receipt remains explicitly unobservable.
13
+ For interruption analysis, prefer one owner `diagnose_runtime` call over a chain of inventory probes. It now includes `runtime.managed_jobs.recent_activity`, `runtime.security_audit.recent_activity`, bounded `runtime.resource_admission.waiters.diagnostics`, `runtime.system_sleep`, `runtime.event_loop_pause_analysis`, and `runtime.relay_outage_analysis`. The audit aggregate contains only counts, bounded tool names, failure totals, calls-per-minute density, and numeric result-pressure fields (`output_bytes_last_15m`, `maximum_output_bytes_last_15m`, `large_result_calls_last_15m`, and `peak_output_bytes_per_minute_last_15m`) derived from the existing content-free hash-chained audit log; it contains no tool arguments or result content. Its `coverage=daemon_reached_relay_tool_calls_only` and `host_side_events_observable=false` fields make the evidence boundary explicit: host-only discovery/control-plane/final-delivery events are not counted. The waiter projection reports only resource-request shape and the current admission reason. On macOS with shell-capable owner diagnostics, the fixed power probe reduces `pmset` history to a small list of sleep start/end/duration/reason classes; it never returns raw power-log lines. If that bounded power-history probe is unavailable or times out, `runtime.system_sleep` remains `supported=true` with `available=false` and an `error_class`, while the `system-sleep-history` check is retained as skipped auxiliary causality evidence. That auxiliary evidence gap does not by itself make `diagnose_runtime.ok=false`; relay readiness, local filesystem/process/shell execution, managed-job storage, resource admission, and registered-resource availability remain health gates. `event_loop_pause_analysis.classification=matched_system_sleep` requires both the recorded runtime-stall end time and duration to match one of those bounded operating-system sleep intervals within a fixed tolerance. `relay_outage_analysis` separately compares the most recent completed `recent_outages[0].disconnected_at` -> `recent_outages[0].ready_at` interval with the same bounded sleep history and reports exact overlap duration/ratio; an active outage uses `outage_started_at` instead of the later `last_disconnected_at`. `majority_system_sleep_overlap` means at least half of that observed relay outage occurred while macOS was suspended. A sleeping JavaScript process may be unable to observe the stale socket until wake, so a zero-overlap close-to-ready interval is not automatically awake-network evidence: `wake_boundary_system_sleep_aftermath` is emitted only when the disconnect occurs within the fixed wake tolerance and that same sleep independently matches the event-loop stall in both end time and duration. Either sleep classification makes a retained `connection_reset`/timeout transport aftermath rather than sufficient evidence of a separate network root cause. `no_matching_recent_system_sleep` remains the classification for an awake reset or a merely coincidental near-wake reset without same-sleep stall evidence; it leaves the cause unassigned rather than guessing a VPN, edge, Worker, or host cause. Full relay diagnostics additionally expose `recent_outages`, a newest-first in-memory history capped at eight completed WebSocket reconnect episodes. Each entry also carries `https_fallback_taken_over` and bounded `https_fallback_takeover_ms`, attributed by the stable WebSocket outage number; a later fallback takeover cannot relabel an earlier or no-takeover episode. The top-level `https_fallback_last_takeover_outage_number` is only a bounded correlation aid for the latest takeover and must not be applied to another outage number. Each entry contains only bounded outage numbering, first/final disconnect and ready timestamps, duration, close/error classes, previous-ready duration/silence, first-disconnect liveness phase/timing, coarse application-route class, and connection-stage timings. `disconnected_at` is the first outage transition and `last_disconnect_at` is the final failed reconnect transition. A protocol/application Pong that clears a liveness suspicion without rebuilding the WebSocket remains heartbeat evidence and is deliberately absent from `recent_outages`; the array is therefore reconnect history, not a list of every transient transport suspicion. ChatGPT host-turn termination/final-message receipt remains explicitly unobservable.
14
14
 
15
15
  `runtime.idle_sleep_guard` carries coarse activity/grace/release timestamps plus `mode`, `requests_idle_sleep_prevention`, `requests_system_sleep_prevention_on_ac`, `assertion_generation`, `restart_count`, `recovery_pending`, and bounded current/last unprotected duration. These fields are diagnostic ownership evidence only. The default `activity` mode is backward compatible: authorized relay activity holds `/usr/bin/caffeinate -i -s -w <owner-pid>` through execution and the fixed thirty-minute inactivity grace. In `activity` and `ac-continuous`, that grace begins only after the last owned daemon-side activity settles; a new authorized activity cancels a pending release and receives the full grace after it later settles. `machine-mcp idle-sleep set ac-continuous` adds a daemon-lifetime `/usr/bin/caffeinate -s -w <daemon-pid>` assertion while keeping the normal activity assertion, so idle-sleep prevention on battery remains activity-scoped. `machine-mcp idle-sleep set continuous` instead holds `/usr/bin/caffeinate -i -s -w <daemon-pid>` for the daemon lifetime and does not arm inactivity grace; restart the daemon/service after changing the persisted mode. Every assertion uses fixed 1/5/30-second recovery after unexpected child exit or setup failure, while explicit release/shutdown cancels recovery. The `-s` request is effective only on AC power; `continuous` adds `-i` specifically to request Idle Sleep prevention on battery as well. None of these modes claims to defeat explicit sleep, lid-close policy, power loss, or operating-system behavior outside the documented assertion contracts. Remote account managed-job runners retain their separate runner-owned assertion and now inherit the same bounded child self-healing.
16
16
 
@@ -171,7 +171,7 @@ npm --version
171
171
  machine-mcp --verbose
172
172
  ```
173
173
 
174
- The published runtime package does not install Wrangler or Miniflare into its global dependency tree. On the first start or deployment, Machine Bridge first constructs a private hardened npm 12.0.2 from exact integrity-pinned npm, undici 6.28.0, and brace-expansion 5.0.9 registry tarballs, then uses that CLI to install the exact private Wrangler toolchain under its owner-only state root. It audits the resulting production tree and verifies registry signatures before contacting Cloudflare. This requires npm registry access and may add one bounded setup step; do not manually edit either private toolchain directory. Candidate installation, published global installation, GitHub Release publication checks, and npm publication also create temporary hardened/private staging sessions. Published installation preserves the global prefix selected by the owner's npm. GitHub and npm artifacts must match the accepted candidate digest/hash evidence before service mutation. Permission, I/O, quota, memory, retry, stale-handle, storage, read-only-filesystem, or timeout errors are not treated as an empty/corrupt tree and must be corrected without deleting the state root.
174
+ The published runtime package does not install Wrangler or Miniflare into its global dependency tree. On the first start or deployment, Machine Bridge first constructs a private hardened npm 12.0.2 from exact integrity-pinned npm, undici 6.28.0, and brace-expansion 5.0.9 registry tarballs, then uses that CLI to install the exact private Wrangler toolchain under its owner-only state root. It audits the resulting production tree and verifies registry signatures before contacting Cloudflare. This requires npm registry access and may add one bounded setup step; do not manually edit either private toolchain directory. Candidate installation, published global installation, GitHub Release publication checks, and npm publication also create temporary hardened/private staging sessions. Published installation preserves the global prefix selected by the owner's npm. GitHub and npm artifacts must match the accepted candidate digest/hash evidence before service mutation. GitHub release publication after merge does not reinstall the source dependency tree or repeat local `check:full`: it first fetches `origin/main`, requires local `HEAD` to equal that exact commit, and requires every configured push-triggered provider workflow for that commit to have succeeded. It then uses the hardened npm session to recheck synchronized versions and rematerialize the accepted candidate bytes/promotion digest. Immediately before any tag or GitHub Release mutation it fetches `origin/main` again and requires the same CI-verified commit; publication aborts if main moved. Remote asset SHA-256 reconciliation remains mandatory after upload. Permission, I/O, quota, memory, retry, stale-handle, storage, read-only-filesystem, or timeout errors are not treated as an empty/corrupt tree and must be corrected without deleting the state root.
175
175
 
176
176
  ### Ambiguous publication outcome
177
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "3.0.0-beta.185",
3
+ "version": "3.0.0-beta.190",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -108,6 +108,7 @@
108
108
  "stdio:integration-test": "node tests/stdio-integration-test.mjs",
109
109
  "catalog:test": "node tests/catalog-test.mjs",
110
110
  "tool-arguments:test": "node tests/tool-argument-validation-test.mjs",
111
+ "managed-job-output-redaction:test": "node tests/managed-job-output-redaction-test.mjs",
111
112
  "managed-jobs:test": "node tests/managed-jobs-test.mjs",
112
113
  "ssh-key:test": "node tests/ssh-key-test.mjs",
113
114
  "full-access:test": "node tests/full-access-test.mjs",
@@ -10,13 +10,12 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { runNetworkCommand } from "./network-retry.mjs";
13
- import { requireSuccessfulWorkflowRun } from "./release-ci.mjs";
13
+ import { waitForSuccessfulWorkflowRun } from "./release-ci.mjs";
14
14
  import { tagSyncError } from "./release-state.mjs";
15
15
  import { verifyCurrentReleaseAcceptance } from "./release-acceptance.mjs";
16
16
  import { stageAcceptedCandidateTarball } from "./accepted-candidate-tarball.mjs";
17
17
  import { createHardenedNpmSession } from "./hardened-npm-session.mjs";
18
18
  import { nestedNpmEnvironment } from "../src/local/npm-environment.mjs";
19
- import { sourceDependencyTreeInstallArguments, sourceDependencyTreeInstallTimeoutMs } from "./source-dependency-tree.mjs";
20
19
  import { runExecutable } from "../src/local/shell.mjs";
21
20
  import { resolveTrustedGitExecutable } from "../src/local/trusted-git-executable.mjs";
22
21
  import { resolveTrustedGithubCli } from "../src/local/trusted-github-cli.mjs";
@@ -30,6 +29,8 @@ import { fileURLToPath } from "node:url";
30
29
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
30
  const git = resolveTrustedGitExecutable({ workspace: root });
32
31
  const gh = resolveTrustedGithubCli({ workspace: root });
32
+ const RELEASE_CI_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
33
+ const RELEASE_CI_POLL_INTERVAL_MS = 15_000;
33
34
  process.chdir(root);
34
35
 
35
36
  function fail(message) {
@@ -92,17 +93,6 @@ async function runNpmScript(npmCli, task) {
92
93
  });
93
94
  }
94
95
 
95
- async function installSourceDependencyTree(npmCli) {
96
- await runExecutable(process.execPath, [npmCli, ...sourceDependencyTreeInstallArguments(root)], {
97
- cwd: root,
98
- capture: true,
99
- env: nestedNpmEnvironment(process.env),
100
- timeoutMs: sourceDependencyTreeInstallTimeoutMs,
101
- hardTimeout: true,
102
- maxOutputBytes: 8 * 1024 * 1024,
103
- });
104
- ensureClean();
105
- }
106
96
 
107
97
  function packageMetadata() {
108
98
  const data = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
@@ -158,7 +148,7 @@ function remoteTagCommit(tag) {
158
148
  return (peeled ?? direct)?.[0] ?? null;
159
149
  }
160
150
 
161
- function assertSuccessfulCi(head) {
151
+ async function waitForSuccessfulCi(head) {
162
152
  const required = [
163
153
  [".github/workflows/ci.yml", "CI"],
164
154
  [".github/workflows/codeql.yml", "CodeQL"],
@@ -166,34 +156,32 @@ function assertSuccessfulCi(head) {
166
156
  [".github/workflows/scorecard.yml", "OpenSSF Scorecard"],
167
157
  [".github/workflows/workflow-policy.yml", "Workflow Policy Gate"],
168
158
  ];
159
+ const deadlineMs = performance.now() + RELEASE_CI_WAIT_TIMEOUT_MS;
169
160
  const verified = [];
170
161
  for (const [workflow, name] of required) {
171
- const text = outputNetwork(gh, [
172
- "run",
173
- "list",
174
- "--workflow",
175
- workflow,
176
- "--commit",
177
- head,
178
- "--event",
179
- "push",
180
- "--limit",
181
- "20",
182
- "--json",
183
- "databaseId,status,conclusion,headSha,event,createdAt,url",
184
- ]);
185
- let runs;
186
- try { runs = JSON.parse(text); }
187
- catch { fail(`GitHub Actions did not return valid JSON for ${name}`); }
162
+ const loadRuns = () => {
163
+ const text = outputNetwork(gh, [
164
+ "run", "list", "--workflow", workflow, "--commit", head, "--event", "push",
165
+ "--limit", "20", "--json", "databaseId,status,conclusion,headSha,event,createdAt,url",
166
+ ]);
167
+ try { return JSON.parse(text); }
168
+ catch { fail(`GitHub Actions did not return valid JSON for ${name}`); }
169
+ };
188
170
  let run;
189
- try { run = requireSuccessfulWorkflowRun(runs, head, name); }
190
- catch (error) { fail(String(error?.message || error)); }
171
+ try {
172
+ run = await waitForSuccessfulWorkflowRun(loadRuns, head, name, {
173
+ deadlineMs,
174
+ pollIntervalMs: RELEASE_CI_POLL_INTERVAL_MS,
175
+ now: () => performance.now(),
176
+ });
177
+ } catch (error) {
178
+ fail(String(error?.message || error));
179
+ }
191
180
  console.log(`GitHub Actions ${name} succeeded for ${head} (run ${run.databaseId}).`);
192
181
  verified.push(run);
193
182
  }
194
183
  return verified;
195
184
  }
196
-
197
185
  function releaseInfo(tag) {
198
186
  const args = ["api", githubReleaseByTagEndpoint(tag)];
199
187
  const result = runNetwork(gh, args, { capture: true, allowFailure: true });
@@ -230,7 +218,7 @@ async function assertCoreSync({ requireReleaseAsset }) {
230
218
  if (head !== originMain) {
231
219
  fail(`HEAD ${head} does not match origin/main ${originMain}`);
232
220
  }
233
- assertSuccessfulCi(head);
221
+ await waitForSuccessfulCi(head);
234
222
 
235
223
  const localCommit = localTagCommit(tag);
236
224
  const localTagError = tagSyncError({ scope: "local", tag, head, commit: localCommit });
@@ -349,13 +337,14 @@ async function publishCurrent({ prereleaseMode = false } = {}) {
349
337
  fail(`CHANGELOG.md has no section for ${pkg.version}`);
350
338
  }
351
339
 
340
+ const releaseHead = exactReleaseHead();
341
+ await waitForSuccessfulCi(releaseHead);
342
+
352
343
  const npmSession = await createHardenedNpmSession();
353
344
  let acceptance;
354
345
  let candidate = null;
355
346
  let verificationError = null;
356
347
  try {
357
- await installSourceDependencyTree(npmSession.cli);
358
- await runNpmScript(npmSession.cli, "check");
359
348
  await runNpmScript(npmSession.cli, "version:check");
360
349
  ensureClean();
361
350
  acceptance = assertLocalAcceptance(npmSession.cli);
@@ -382,24 +371,19 @@ async function publishCurrent({ prereleaseMode = false } = {}) {
382
371
  let primaryError = null;
383
372
  let releaseVerified = false;
384
373
  try {
385
- const head = output(git, ["rev-parse", "HEAD"]);
386
- const originMain = output(git, ["rev-parse", "origin/main"]);
387
- if (head !== originMain) {
388
- fail("HEAD does not match origin/main; local acceptance must be committed, pushed through npm run github:push, reviewed, and merged before release publication");
389
- }
390
- assertSuccessfulCi(head);
374
+ revalidateReleaseHead(releaseHead);
391
375
 
392
376
  const existingLocal = localTagCommit(tag);
393
- if (existingLocal && existingLocal !== head) {
394
- fail(`local ${tag} points to ${existingLocal}, not ${head}`);
377
+ if (existingLocal && existingLocal !== releaseHead) {
378
+ fail(`local ${tag} points to ${existingLocal}, not ${releaseHead}`);
395
379
  }
396
380
  if (!existingLocal) {
397
381
  run(git, ["tag", "-a", tag, "-m", `Release ${pkg.version}`]);
398
382
  }
399
383
 
400
384
  const existingRemote = remoteTagCommit(tag);
401
- if (existingRemote && existingRemote !== head) {
402
- fail(`remote ${tag} points to ${existingRemote}, not ${head}`);
385
+ if (existingRemote && existingRemote !== releaseHead) {
386
+ fail(`remote ${tag} points to ${existingRemote}, not ${releaseHead}`);
403
387
  }
404
388
  if (!existingRemote) {
405
389
  runNetwork(git, ["push", "origin", tag]);
@@ -442,6 +426,24 @@ async function publishCurrent({ prereleaseMode = false } = {}) {
442
426
  await assertCoreSync({ requireReleaseAsset: true });
443
427
  }
444
428
 
429
+ function exactReleaseHead() {
430
+ const head = output(git, ["rev-parse", "HEAD"]);
431
+ const originMain = output(git, ["rev-parse", "origin/main"]);
432
+ if (head !== originMain) {
433
+ fail(`HEAD ${head} does not match origin/main ${originMain}; merge the accepted candidate before release publication`);
434
+ }
435
+ return head;
436
+ }
437
+
438
+ function revalidateReleaseHead(expectedHead) {
439
+ ensureClean();
440
+ fetchRemote();
441
+ const currentHead = exactReleaseHead();
442
+ if (currentHead !== expectedHead) {
443
+ fail(`release source moved from verified main ${expectedHead} to ${currentHead}; restart publication against the new exact main`);
444
+ }
445
+ }
446
+
445
447
  function assertStableSoak(npmCli = process.env.npm_execpath) {
446
448
  try {
447
449
  const result = verifyCurrentStableSoak(root, { npmCli });
@@ -1,6 +1,62 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ const DEFAULT_WORKFLOW_POLL_INTERVAL_MS = 15_000;
4
+ const DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
5
+
1
6
  export function requireSuccessfulWorkflowRun(runs, head, workflowName = "CI") {
7
+ const run = latestPushWorkflowRun(runs, head, workflowName);
8
+ if (run.status !== "completed") {
9
+ throw new Error(`${workflowName} run ${run.databaseId || "unknown"} for release commit ${head} is ${run.status || "unknown"}; wait for completion and retry`);
10
+ }
11
+ if (run.conclusion !== "success") {
12
+ throw new Error(`${workflowName} run ${run.databaseId || "unknown"} for release commit ${head} concluded ${run.conclusion || "unknown"}; fix or rerun the workflow before release`);
13
+ }
14
+ return run;
15
+ }
16
+
17
+ export async function waitForSuccessfulWorkflowRun(loadRuns, head, workflowName = "CI", options = {}) {
18
+ if (typeof loadRuns !== "function") throw new Error("GitHub Actions run loader must be a function");
19
+ validateReleaseHead(head);
20
+ const now = typeof options.now === "function" ? options.now : () => performance.now();
21
+ const wait = typeof options.wait === "function" ? options.wait : defaultWorkflowWait;
22
+ const pollIntervalMs = positiveFinite(options.pollIntervalMs, DEFAULT_WORKFLOW_POLL_INTERVAL_MS, "workflow poll interval");
23
+ const deadlineMs = options.deadlineMs === undefined
24
+ ? now() + DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS
25
+ : finiteDeadline(options.deadlineMs);
26
+ let observed = null;
27
+ for (;;) {
28
+ if (now() >= deadlineMs) throw workflowWaitTimeout(workflowName, head, observed);
29
+ const runs = await loadRuns();
30
+ let run = null;
31
+ try {
32
+ run = latestPushWorkflowRun(runs, head, workflowName);
33
+ } catch (error) {
34
+ if (!isMissingWorkflowRun(error, workflowName, head)) throw error;
35
+ }
36
+ if (run) {
37
+ observed = run;
38
+ if (run.status === "completed") {
39
+ if (run.conclusion !== "success") {
40
+ throw new Error(`${workflowName} run ${run.databaseId || "unknown"} for release commit ${head} concluded ${run.conclusion || "unknown"}; fix or rerun the workflow before release`);
41
+ }
42
+ return run;
43
+ }
44
+ } else {
45
+ observed = null;
46
+ }
47
+ const remainingMs = deadlineMs - now();
48
+ if (!(remainingMs > 0)) throw workflowWaitTimeout(workflowName, head, observed);
49
+ await wait(Math.min(pollIntervalMs, remainingMs));
50
+ }
51
+ }
52
+
53
+ export function requireSuccessfulCiRun(runs, head) {
54
+ return requireSuccessfulWorkflowRun(runs, head, "CI");
55
+ }
56
+
57
+ function latestPushWorkflowRun(runs, head, workflowName) {
2
58
  if (!Array.isArray(runs)) throw new Error("GitHub Actions response is not an array");
3
- if (!/^[0-9a-f]{40,64}$/i.test(String(head || ""))) throw new Error("release commit SHA is invalid");
59
+ validateReleaseHead(head);
4
60
  const label = String(workflowName || "workflow");
5
61
  const matching = runs
6
62
  .filter((run) => run && run.headSha === head && run.event === "push")
@@ -9,15 +65,38 @@ export function requireSuccessfulWorkflowRun(runs, head, workflowName = "CI") {
9
65
  if (!run) {
10
66
  throw new Error(`no push-triggered ${label} run exists for release commit ${head}; wait for GitHub Actions to register the run and retry`);
11
67
  }
12
- if (run.status !== "completed") {
13
- throw new Error(`${label} run ${run.databaseId || "unknown"} for release commit ${head} is ${run.status || "unknown"}; wait for completion and retry`);
14
- }
15
- if (run.conclusion !== "success") {
16
- throw new Error(`${label} run ${run.databaseId || "unknown"} for release commit ${head} concluded ${run.conclusion || "unknown"}; fix or rerun the workflow before release`);
17
- }
18
68
  return run;
19
69
  }
20
70
 
21
- export function requireSuccessfulCiRun(runs, head) {
22
- return requireSuccessfulWorkflowRun(runs, head, "CI");
71
+ function validateReleaseHead(head) {
72
+ if (!/^[0-9a-f]{40,64}$/i.test(String(head || ""))) throw new Error("release commit SHA is invalid");
73
+ }
74
+
75
+ function positiveFinite(value, fallback, label) {
76
+ if (value === undefined) return fallback;
77
+ const number = Number(value);
78
+ if (!Number.isFinite(number) || number <= 0) throw new Error(`${label} must be a positive finite number`);
79
+ return number;
80
+ }
81
+
82
+ function finiteDeadline(value) {
83
+ const number = Number(value);
84
+ if (!Number.isFinite(number)) throw new Error("workflow wait deadline must be finite");
85
+ return number;
86
+ }
87
+
88
+ function isMissingWorkflowRun(error, workflowName, head) {
89
+ return String(error?.message || error).startsWith(`no push-triggered ${String(workflowName || "workflow")} run exists for release commit ${head};`);
90
+ }
91
+
92
+ function workflowWaitTimeout(workflowName, head, run) {
93
+ const label = String(workflowName || "workflow");
94
+ const state = run
95
+ ? `latest run ${run.databaseId || "unknown"} remained ${run.status || "unknown"}`
96
+ : "no exact-commit push run was registered";
97
+ return new Error(`${label} did not succeed for release commit ${head} before the finite CI wait deadline; ${state}`);
98
+ }
99
+
100
+ function defaultWorkflowWait(ms) {
101
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); });
23
102
  }
@@ -256,7 +256,30 @@ export class AgentContextManager {
256
256
  };
257
257
  }
258
258
 
259
- async discoverState(inputPath, context = {}) {
259
+ async managedJobCommandForLocalInvocation(args = {}, context = {}) {
260
+ const command = await this.resolveLocalCommand(args, context);
261
+ return command.executionMode === "managed_job" ? managedJobCommandSummary(command) : null;
262
+ }
263
+
264
+ async managedJobCommandForDirectInvocation(args = {}, context = {}) {
265
+ const argv = args.argv;
266
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((value) => typeof value !== "string")) return null;
267
+ const state = await this.discoverState(args.cwd || ".", context, { includeUserGlobalContext: false });
268
+ if (state.target !== state.targetDir) return null;
269
+ for (const command of state.commands.values()) {
270
+ if (command.executionMode !== "managed_job" || !sameArgv(command.argv, argv)) continue;
271
+ let commandCwd;
272
+ try { commandCwd = await realpath(command.cwd); }
273
+ catch (error) {
274
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") continue;
275
+ throw error;
276
+ }
277
+ if (commandCwd === state.target) return managedJobCommandSummary(command);
278
+ }
279
+ return null;
280
+ }
281
+
282
+ async discoverState(inputPath, context = {}, options = {}) {
260
283
  this.throwIfCancelled(context);
261
284
  const effectivePolicy = this.policyForContext(context);
262
285
  this.workspace = await realpath(this.workspace);
@@ -269,14 +292,14 @@ export class AgentContextManager {
269
292
  unrestricted: effectivePolicy.unrestrictedPaths === true,
270
293
  });
271
294
  const directories = directoriesBetween(scopeRoot, targetDir);
272
- const userGlobalContextAllowed = allowsUserGlobalContext(context, effectivePolicy);
295
+ const userGlobalContextAllowed = options.includeUserGlobalContext !== false && allowsUserGlobalContext(context, effectivePolicy);
273
296
  const state = {
274
297
  target,
275
298
  targetDir,
276
299
  scopeRoot,
277
300
  instructionFiles: [...DEFAULT_INSTRUCTION_FILES],
278
301
  instructionMaxBytes: DEFAULT_INSTRUCTION_MAX_BYTES,
279
- skillRoots: defaultSkillRoots(directories, this.home, this.codexHome, effectivePolicy.unrestrictedPaths === true),
302
+ skillRoots: defaultSkillRoots(directories, this.home, this.codexHome, userGlobalContextAllowed && effectivePolicy.unrestrictedPaths === true),
280
303
  commands: new Map(),
281
304
  builtinInstructionsEnabled: true,
282
305
  automaticProjectContextEnabled: true,
@@ -428,6 +451,14 @@ export class AgentContextManager {
428
451
  }
429
452
  }
430
453
 
454
+ function sameArgv(left, right) {
455
+ return left.length === right.length && left.every((value, index) => value === right[index]);
456
+ }
457
+
458
+ function managedJobCommandSummary(command) {
459
+ return Object.freeze({ name: command.name, managedJobTimeoutSeconds: command.managedJobTimeoutSeconds });
460
+ }
461
+
431
462
  async function findScopeRoot({ targetDir, workspace, unrestricted }) {
432
463
  const target = await realpath(targetDir);
433
464
  const canonicalWorkspace = await realpath(workspace);
@@ -11,23 +11,10 @@ export function managedJobResourcePathVariants(value, platform = process.platfor
11
11
 
12
12
  export function redactManagedJobOutput(buffer, context, runtimeDir, platform = process.platform, truncatedBytes = 0) {
13
13
  const safeBuffer = discardUnsafeTruncationTail(buffer, context, runtimeDir, platform, truncatedBytes);
14
- let redactedBytes = safeBuffer;
15
- for (const [name, value] of Object.entries(context.bytes || {})) {
16
- redactedBytes = replaceBuffer(redactedBytes, value, Buffer.from(`<redacted-resource:${name}>`));
17
- }
14
+ const redactedBytes = replaceBufferEntries(safeBuffer, byteRedactionEntries(context, runtimeDir));
18
15
  let text = new TextDecoder("utf-8").decode(redactedBytes);
19
- for (const [name, path] of Object.entries(context.paths)) {
20
- text = replacePathText(text, path, `<resource:${name}>`, platform);
21
- }
22
- for (const [name, paths] of Object.entries(context.sourcePaths || {})) {
23
- for (const path of paths) text = replacePathText(text, path, `<resource-source:${name}>`, platform);
24
- }
25
- for (const [name, path] of Object.entries(context.temporaryPaths)) {
26
- text = replacePathText(text, path, `<temp:${name}>`, platform);
27
- }
28
- text = replacePathText(text, runtimeDir, "<job-runtime>", platform);
29
- for (const [name, patterns] of Object.entries(context.redactions)) {
30
- for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
16
+ for (const entry of textRedactionEntries(context, runtimeDir, platform)) {
17
+ text = replaceTextEntry(text, entry, platform);
31
18
  }
32
19
  return text;
33
20
  }
@@ -57,14 +44,62 @@ function protectedBytePatterns(context, runtimeDir, platform) {
57
44
  }
58
45
  for (const path of Object.values(context.temporaryPaths || {})) values.push(...managedJobResourcePathVariants(path, platform).map((value) => Buffer.from(value)));
59
46
  values.push(...managedJobResourcePathVariants(runtimeDir, platform).map((value) => Buffer.from(value)));
60
- for (const patterns of Object.values(context.redactions || {})) {
61
- for (const value of patterns || []) if (value) values.push(Buffer.from(value));
62
- }
47
+ for (const [, value] of literalRedactionEntries(context)) values.push(Buffer.from(value));
63
48
  const unique = new Map();
64
49
  for (const value of values) if (value.length) unique.set(value.toString("hex"), value);
65
50
  return [...unique.values()].sort((left, right) => right.length - left.length);
66
51
  }
67
52
 
53
+ function literalRedactionEntries(context) {
54
+ const entries = [];
55
+ for (const [name, patterns] of Object.entries(context.redactions || {})) {
56
+ if (!Array.isArray(patterns)) continue;
57
+ for (const value of patterns) {
58
+ if (typeof value === "string" && value.length > 0) entries.push([name, value]);
59
+ }
60
+ }
61
+ return entries.sort((left, right) => right[1].length - left[1].length);
62
+ }
63
+
64
+ function pathRedactionEntries(context, runtimeDir) {
65
+ const entries = [];
66
+ const add = (value, replacement) => {
67
+ for (const variant of pathTextVariants(value)) {
68
+ if (variant) entries.push({ value: variant, replacement, caseInsensitive: true });
69
+ }
70
+ };
71
+ for (const [name, path] of Object.entries(context.paths || {})) add(path, `<resource:${name}>`);
72
+ for (const [name, paths] of Object.entries(context.sourcePaths || {})) {
73
+ for (const path of paths || []) add(path, `<resource-source:${name}>`);
74
+ }
75
+ for (const [name, path] of Object.entries(context.temporaryPaths || {})) add(path, `<temp:${name}>`);
76
+ add(runtimeDir, "<job-runtime>");
77
+ return entries;
78
+ }
79
+
80
+ function textRedactionEntries(context, runtimeDir, platform) {
81
+ if (platform !== "win32") return [];
82
+ return pathRedactionEntries(context, runtimeDir)
83
+ .map((entry) => ({ ...entry, caseInsensitive: true }))
84
+ .sort((left, right) => right.value.length - left.value.length);
85
+ }
86
+
87
+ function byteRedactionEntries(context, runtimeDir) {
88
+ const entries = [];
89
+ for (const [name, value] of Object.entries(context.bytes || {})) {
90
+ if (Buffer.isBuffer(value) && value.length > 0) {
91
+ entries.push({ pattern: value, replacement: Buffer.from(`<redacted-resource:${name}>`) });
92
+ }
93
+ }
94
+ for (const entry of pathRedactionEntries(context, runtimeDir)) {
95
+ entries.push({ pattern: Buffer.from(entry.value), replacement: Buffer.from(entry.replacement) });
96
+ }
97
+ for (const [name, value] of literalRedactionEntries(context)) {
98
+ entries.push({ pattern: Buffer.from(value), replacement: Buffer.from(`<redacted-resource:${name}>`) });
99
+ }
100
+ return entries.sort((left, right) => right.pattern.length - left.pattern.length);
101
+ }
102
+
68
103
  function partialSuffixLength(buffer, pattern) {
69
104
  if (!pattern?.length || !buffer.length) return 0;
70
105
  if (pattern.length <= buffer.length && buffer.subarray(buffer.length - pattern.length).equals(pattern)) return 0;
@@ -80,19 +115,22 @@ function partialSuffixLength(buffer, pattern) {
80
115
  return 0;
81
116
  }
82
117
 
83
- function replaceBuffer(buffer, pattern, replacement) {
84
- if (!Buffer.isBuffer(pattern) || pattern.length === 0 || buffer.length < pattern.length) return buffer;
85
- let offset = 0;
86
- let match = buffer.indexOf(pattern, offset);
87
- if (match < 0) return buffer;
118
+ function replaceBufferEntries(buffer, entries) {
119
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0 || entries.length === 0) return buffer;
88
120
  const parts = [];
89
- while (match >= 0) {
90
- if (match > offset) parts.push(buffer.subarray(offset, match));
91
- parts.push(replacement);
92
- offset = match + pattern.length;
93
- match = buffer.indexOf(pattern, offset);
121
+ let cursor = 0;
122
+ let literalStart = 0;
123
+ while (cursor < buffer.length) {
124
+ const entry = entries.find(({ pattern }) => cursor + pattern.length <= buffer.length
125
+ && buffer.subarray(cursor, cursor + pattern.length).equals(pattern));
126
+ if (!entry) { cursor += 1; continue; }
127
+ if (cursor > literalStart) parts.push(buffer.subarray(literalStart, cursor));
128
+ parts.push(entry.replacement);
129
+ cursor += entry.pattern.length;
130
+ literalStart = cursor;
94
131
  }
95
- if (offset < buffer.length) parts.push(buffer.subarray(offset));
132
+ if (literalStart === 0) return buffer;
133
+ if (literalStart < buffer.length) parts.push(buffer.subarray(literalStart));
96
134
  return Buffer.concat(parts);
97
135
  }
98
136
 
@@ -101,14 +139,12 @@ function pathTextVariants(value) {
101
139
  return [...new Set([path, path.replaceAll("\\", "/"), path.replaceAll("/", "\\")])];
102
140
  }
103
141
 
104
- function replacePathText(text, value, replacement, platform) {
105
- let output = text;
106
- for (const variant of pathTextVariants(value)) {
107
- if (!variant) continue;
108
- if (platform === "win32") output = output.replace(new RegExp(escapeRegExp(variant), "gi"), replacement);
109
- else output = output.split(variant).join(replacement);
142
+ function replaceTextEntry(text, entry, platform) {
143
+ if (!entry.value) return text;
144
+ if (platform === "win32" && entry.caseInsensitive) {
145
+ return text.replace(new RegExp(escapeRegExp(entry.value), "gi"), entry.replacement);
110
146
  }
111
- return output;
147
+ return text.split(entry.value).join(entry.replacement);
112
148
  }
113
149
 
114
150
  function escapeRegExp(value) {
@@ -70,6 +70,7 @@ export function relayHandshakeDiagnostics(value = {}) {
70
70
  previous_ready_duration_ms: clampInteger(status.last_ready_duration_ms, 0, 0, 365 * 24 * 60 * 60_000),
71
71
  previous_ready_inbound_silence_ms: clampInteger(status.last_ready_inbound_silence_ms, 0, 0, 31 * 24 * 60 * 60_000),
72
72
  https_fallback_last_takeover_ms: clampInteger(status.https_fallback_last_takeover_ms, 0, 0, 10 * 60_000),
73
+ https_fallback_last_takeover_outage_number: clampInteger(status.https_fallback_last_takeover_outage_number, 0, 0, 1_000_000_000),
73
74
  };
74
75
  }
75
76
 
@@ -80,12 +81,16 @@ function recentOutages(value) {
80
81
  if (!isPlainRecord(candidate)) continue;
81
82
  const outageNumber = Number(candidate.outage_number);
82
83
  if (!Number.isSafeInteger(outageNumber) || outageNumber < 1 || outageNumber > 1_000_000_000) continue;
84
+ const fallbackTakenOver = candidate.https_fallback_taken_over === true;
83
85
  result.push({
84
86
  outage_number: outageNumber,
85
87
  disconnected_at: boundedTimestamp(candidate.disconnected_at),
86
88
  last_disconnect_at: boundedTimestamp(candidate.last_disconnect_at),
87
89
  ready_at: boundedTimestamp(candidate.ready_at),
88
90
  duration_ms: clampInteger(candidate.duration_ms, 0, 0, 31 * 24 * 60 * 60_000),
91
+ https_fallback_taken_over: fallbackTakenOver,
92
+ https_fallback_takeover_ms: fallbackTakenOver
93
+ ? clampInteger(candidate.https_fallback_takeover_ms, 0, 0, 10 * 60_000) : 0,
89
94
  attempts: clampInteger(candidate.attempts, 0, 0, 1_000_000),
90
95
  close_category: typeof candidate.close_category === "string" ? candidate.close_category.slice(0, 128) : null,
91
96
  close_code: Number.isSafeInteger(candidate.close_code) && candidate.close_code >= 0 && candidate.close_code <= 4999
@@ -17,6 +17,8 @@ export class ResilientRelayConnection {
17
17
  this.fallbackTimer = null;
18
18
  this.fallbackRecoveredOutageMs = 0;
19
19
  this.lastFallbackTakeoverMs = 0;
20
+ this.lastFallbackTakeoverOutageNumber = 0;
21
+ this.fallbackTakeoversByOutage = new Map();
20
22
  const WebSocketRelayClass = options.WebSocketRelayClass || RelayConnection;
21
23
  const HttpRelayClass = options.HttpRelayClass || DaemonHttpRelayConnection;
22
24
  this.websocket = new WebSocketRelayClass({
@@ -53,13 +55,15 @@ export class ResilientRelayConnection {
53
55
  this.activeTransport = "";
54
56
  this.fallbackRecoveredOutageMs = 0;
55
57
  this.lastFallbackTakeoverMs = 0;
58
+ this.lastFallbackTakeoverOutageNumber = 0;
59
+ this.fallbackTakeoversByOutage.clear();
56
60
  this.startResolve?.(false);
57
61
  this.startResolve = null;
58
62
  this.startPromise = null;
59
63
  }
60
64
 
61
65
  status() {
62
- const websocket = this.websocket.status();
66
+ const websocket = this.projectWebSocketStatus(this.websocket.status());
63
67
  const http = this.http.status();
64
68
  if (this.activeTransport === "https" && http.ready) {
65
69
  return {
@@ -84,6 +88,7 @@ export class ResilientRelayConnection {
84
88
  https_fallback_warming: false,
85
89
  https_fallback_standby: false,
86
90
  https_fallback_last_takeover_ms: this.lastFallbackTakeoverMs,
91
+ https_fallback_last_takeover_outage_number: this.lastFallbackTakeoverOutageNumber,
87
92
  };
88
93
  }
89
94
  return {
@@ -94,6 +99,7 @@ export class ResilientRelayConnection {
94
99
  https_fallback_standby: http.standby === true,
95
100
  https_fallback: http,
96
101
  https_fallback_last_takeover_ms: this.lastFallbackTakeoverMs,
102
+ https_fallback_last_takeover_outage_number: this.lastFallbackTakeoverOutageNumber,
97
103
  };
98
104
  }
99
105
 
@@ -153,13 +159,15 @@ export class ResilientRelayConnection {
153
159
  this.clearFallbackTimer();
154
160
  this.armFallback(0, "", true);
155
161
  } else {
156
- if (this.websocket.status().ready === true) {
162
+ const websocket = this.websocket.status();
163
+ if (websocket.ready === true) {
157
164
  this.http.stop();
158
165
  this.armFallback(this.standbyDelayMs, "", true);
159
166
  return;
160
167
  }
161
- this.fallbackRecoveredOutageMs = Math.max(0, Number(this.websocket.status().outage_duration_ms) || 0);
162
- this.lastFallbackTakeoverMs = this.fallbackRecoveredOutageMs;
168
+ this.fallbackRecoveredOutageMs = Math.max(0, Number(websocket.outage_duration_ms) || 0);
169
+ this.lastFallbackTakeoverMs = boundedFallbackTakeoverMs(this.fallbackRecoveredOutageMs);
170
+ this.recordFallbackTakeover(websocket);
163
171
  this.activeTransport = "https";
164
172
  }
165
173
  this.startResolve?.(true);
@@ -192,6 +200,46 @@ export class ResilientRelayConnection {
192
200
  if (this.http.status().closed === true) this.armFallback(0, "", true);
193
201
  }
194
202
 
203
+ recordFallbackTakeover(websocket = {}) {
204
+ const outageNumber = relayOutageNumber(websocket.outage_count);
205
+ if (websocket.outage_active !== true || outageNumber === 0) return;
206
+ this.lastFallbackTakeoverOutageNumber = outageNumber;
207
+ this.fallbackTakeoversByOutage.set(outageNumber, this.lastFallbackTakeoverMs);
208
+ this.pruneFallbackTakeovers(websocket);
209
+ }
210
+
211
+ projectWebSocketStatus(websocket = {}) {
212
+ this.pruneFallbackTakeovers(websocket);
213
+ const recentOutages = Array.isArray(websocket.recent_outages) ? websocket.recent_outages : [];
214
+ return {
215
+ ...websocket,
216
+ recent_outages: recentOutages.map((entry) => {
217
+ const outageNumber = relayOutageNumber(entry?.outage_number);
218
+ const hasTakeover = outageNumber > 0 && this.fallbackTakeoversByOutage.has(outageNumber);
219
+ return {
220
+ ...entry,
221
+ https_fallback_taken_over: hasTakeover,
222
+ https_fallback_takeover_ms: hasTakeover ? this.fallbackTakeoversByOutage.get(outageNumber) : 0,
223
+ };
224
+ }),
225
+ };
226
+ }
227
+
228
+ pruneFallbackTakeovers(websocket = {}) {
229
+ const retained = new Set();
230
+ const activeOutageNumber = websocket.outage_active === true ? relayOutageNumber(websocket.outage_count) : 0;
231
+ if (activeOutageNumber > 0) retained.add(activeOutageNumber);
232
+ if (Array.isArray(websocket.recent_outages)) {
233
+ for (const entry of websocket.recent_outages) {
234
+ const outageNumber = relayOutageNumber(entry?.outage_number);
235
+ if (outageNumber > 0) retained.add(outageNumber);
236
+ }
237
+ }
238
+ for (const outageNumber of this.fallbackTakeoversByOutage.keys()) {
239
+ if (!retained.has(outageNumber)) this.fallbackTakeoversByOutage.delete(outageNumber);
240
+ }
241
+ }
242
+
195
243
  armFallback(delay, takeoverWebSocketConnectionId = "", allowReadyWebSocket = false) {
196
244
  if (this.closed) return;
197
245
  const takeoverWebSocket = /^connection_[A-Za-z0-9_-]{43}$/.test(String(takeoverWebSocketConnectionId || ""));
@@ -215,6 +263,17 @@ export class ResilientRelayConnection {
215
263
  }
216
264
  }
217
265
 
266
+ function relayOutageNumber(value) {
267
+ const number = Number(value);
268
+ return Number.isSafeInteger(number) && number > 0 && number <= 1_000_000_000 ? number : 0;
269
+ }
270
+
271
+ function boundedFallbackTakeoverMs(value) {
272
+ const number = Number(value);
273
+ if (!Number.isFinite(number) || number <= 0) return 0;
274
+ return Math.min(10 * 60_000, Math.round(number));
275
+ }
276
+
218
277
  function positiveInteger(value, fallback) {
219
278
  const number = Number(value);
220
279
  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
@@ -44,7 +44,7 @@ export async function diagnoseRuntime({
44
44
  last_transport_error_class: relay.last_transport_error_class || null,
45
45
  last_disconnected_at: relay.last_disconnected_at || null,
46
46
  last_ready_at: relay.last_ready_at || null, last_ready_duration_ms: Number(relay.last_ready_duration_ms) || 0,
47
- https_fallback_last_takeover_ms: Number(relay.https_fallback_last_takeover_ms) || 0, next_reconnect_in_ms: Number(relay.next_reconnect_in_ms) || 0,
47
+ https_fallback_last_takeover_ms: Number(relay.https_fallback_last_takeover_ms) || 0, https_fallback_last_takeover_outage_number: Number(relay.https_fallback_last_takeover_outage_number) || 0, next_reconnect_in_ms: Number(relay.next_reconnect_in_ms) || 0,
48
48
  heartbeat: relay.heartbeat || null,
49
49
  } : {
50
50
  layer: "remote-relay", ok: false, skipped: true, transport: "stdio-or-local",
@@ -18,5 +18,6 @@ export function compactRuntimeRelay(value) {
18
18
  https_fallback_active: value.https_fallback_active === true,
19
19
  websocket_ready: value.websocket_ready === true,
20
20
  https_fallback_last_takeover_ms: value.https_fallback_last_takeover_ms ?? 0,
21
+ https_fallback_last_takeover_outage_number: value.https_fallback_last_takeover_outage_number ?? 0,
21
22
  };
22
23
  }
@@ -4,12 +4,16 @@ import {
4
4
  prepareDurableShellProcess,
5
5
  } from "./durable-process-spec.mjs";
6
6
  import { settleDurableProcessAcceptance } from "./durable-process-initial-settlement.mjs";
7
+ import { BridgeError } from "./errors.mjs";
7
8
 
8
9
  function usesDurableProcessDelivery(args, context = {}) {
9
10
  return context.origin === "relay" || args?.idempotency_key !== undefined;
10
11
  }
11
12
 
12
13
  export async function runRuntimeDirectProcess(runtime, args, context = {}) {
14
+ runtime.processExecutionService.policyGate.assert("run_process");
15
+ const managedCommand = await runtime.agentContextManager.managedJobCommandForDirectInvocation(args, context);
16
+ if (managedCommand) throw managedJobOnlyCarrierError(managedCommand);
13
17
  if (!usesDurableProcessDelivery(args, context)) {
14
18
  return runtime.processExecutionService.runDirect(args, context);
15
19
  }
@@ -19,6 +23,9 @@ export async function runRuntimeDirectProcess(runtime, args, context = {}) {
19
23
  }
20
24
 
21
25
  export async function runRuntimeLocalCommand(runtime, args, context = {}) {
26
+ runtime.processExecutionService.policyGate.assert("run_local_command");
27
+ const managedCommand = await runtime.agentContextManager.managedJobCommandForLocalInvocation(args, context);
28
+ if (managedCommand) throw managedJobOnlyCarrierError(managedCommand);
22
29
  if (!usesDurableProcessDelivery(args, context)) {
23
30
  return runtime.processExecutionService.runRegistered(args, context);
24
31
  }
@@ -35,3 +42,10 @@ export async function runRuntimeExecCommand(runtime, args, context = {}) {
35
42
  }
36
43
  return runtime.processExecutionService.runShell(args.command, args.timeout_seconds, context);
37
44
  }
45
+
46
+ function managedJobOnlyCarrierError(command) {
47
+ return new BridgeError("invalid_request", `registered command '${command.name}' requires start_job; run_process and run_local_command intentionally refuse execution_mode=managed_job commands`, {
48
+ retryable: false,
49
+ details: { side_effects_started: false, required_tool: "start_job", registered_command: command.name, managed_job_timeout_seconds: command.managedJobTimeoutSeconds },
50
+ });
51
+ }
@@ -20,13 +20,17 @@ export async function systemSleepDiagnostic({ runFixedInternal, context, workspa
20
20
  check: { layer: "system-sleep-history", ok: true, recent_sleep_intervals: intervals.length },
21
21
  };
22
22
  } catch (error) {
23
- return {
24
- snapshot: { supported: true, available: false, source: "macos_pmset", recent_sleep_intervals: [], error_class: classifyOperationalError(error) },
25
- check: { layer: "system-sleep-history", ok: false, error_class: classifyOperationalError(error) },
26
- };
23
+ return unavailableSystemSleepHistory(classifyOperationalError(error));
27
24
  }
28
25
  }
29
26
 
27
+ function unavailableSystemSleepHistory(errorClass) {
28
+ return {
29
+ snapshot: { supported: true, available: false, source: "macos_pmset", recent_sleep_intervals: [], error_class: errorClass },
30
+ check: { layer: "system-sleep-history", ok: false, skipped: true, error_class: errorClass },
31
+ };
32
+ }
33
+
30
34
  export function parseSystemSleepIntervals(text, limit = 8) {
31
35
  const intervals = [];
32
36
  for (const line of String(text || "").split(/\r?\n/)) {
@@ -6,6 +6,7 @@ import type { DaemonHttpChannel } from "./daemon-http-channel.ts";
6
6
  import { normalizeDaemonHttpExchange } from "./daemon-http-protocol.ts";
7
7
  import type { DaemonRegistry } from "./daemon-registry.ts";
8
8
  import { handleReadyDaemonMessage } from "./daemon-ready-messages.ts";
9
+ import { beginDaemonResumeReconciliation } from "./daemon-resume-reconciliation.ts";
9
10
  import { notifyReadyDaemon } from "./daemon-ready-waiters.ts";
10
11
  import { HttpError, json, readBoundedBytes } from "./http.ts";
11
12
  import type { WorkerObservability } from "./observability.ts";
@@ -77,6 +78,7 @@ export async function handleDaemonHttpRelay(input: {
77
78
  const queuedRevocations = await authorityRevocations(input.storage);
78
79
  input.registry.http.activate(exchange.sessionId, channel.activationToken, now);
79
80
  const rebound = input.pending.rebindInstance(exchange.instanceId, channel);
81
+ beginDaemonResumeReconciliation(channel, rebound);
80
82
  channel.send(JSON.stringify({ type: "resume_calls", ids: rebound }));
81
83
  for (const revocation of queuedRevocations) channel.send(JSON.stringify(authorityRevocationWireMessage(revocation)));
82
84
  channel.send(JSON.stringify({ type: "ready_ack", server: input.server, version: input.version }));
@@ -147,6 +149,7 @@ export async function handleDaemonHttpRelay(input: {
147
149
  async function invalidate(channel: DaemonHttpChannel, input: Parameters<typeof handleDaemonHttpRelay>[0], code: string): Promise<Response> {
148
150
  await input.detachChannel(channel, `HTTPS fallback protocol failure: ${code}`);
149
151
  input.registry.http.close(channel);
152
+ input.observability.socketProtocolError(code);
150
153
  input.observability.event("warn", "daemon.https_fallback.invalidated", { error_class: code });
151
154
  await input.scheduleAlarm();
152
155
  return json({ error: code }, 409);
@@ -1,7 +1,7 @@
1
1
  import { acknowledgeAuthorityRevocation, authorityRevocationAckId } from "./authority-revocations.ts";
2
2
  import type { DaemonChannel } from "./daemon-channel.ts";
3
3
  import { settleDaemonPlannedDrain } from "./daemon-planned-drain.ts";
4
- import { daemonResumeMissingCallIds } from "./websocket-protocol.ts";
4
+ import { daemonResumeAcknowledgement } from "./daemon-resume-reconciliation.ts";
5
5
  import { daemonCallNotReceivedAfterReconnectError, daemonToolError } from "./errors.ts";
6
6
  import type { PendingCallOutcome } from "./pending-call-contract.ts";
7
7
  import type { PendingCallRegistry } from "./pending-calls.ts";
@@ -24,9 +24,9 @@ export async function handleReadyDaemonMessage(input: {
24
24
  }): Promise<ReadyMessageDisposition> {
25
25
  const { channel, body, pending, storage, observability, beginDrain } = input;
26
26
  if (body.type === "resume_calls_ack") {
27
- const missingIds = daemonResumeMissingCallIds(body.missing_ids);
28
- if (!missingIds) return invalid("invalid_resume_calls_ack", "invalid resume calls acknowledgement");
29
- await settleDaemonProvenMissingCalls({ ids: missingIds, channel, pending, observability });
27
+ const acknowledgement = daemonResumeAcknowledgement(channel, body.missing_ids);
28
+ if (!acknowledgement) return invalid("invalid_resume_calls_ack", "invalid resume calls acknowledgement");
29
+ if (!acknowledgement.duplicate) await settleDaemonProvenMissingCalls({ ids: acknowledgement.missingIds, channel, pending, observability });
30
30
  return { ok: true };
31
31
  }
32
32
  if (body.type === "authority_revoke_ack") {
@@ -62,7 +62,9 @@ export async function settleDaemonProvenMissingCalls(input: {
62
62
  const rejected = await input.pending.rejectSocketIds(
63
63
  input.ids, input.channel, (record) => daemonCallNotReceivedAfterReconnectError(record.recovery), undefined,
64
64
  (record) => {
65
+ if (record.provenMissingRedeliveryChannel) return record.provenMissingRedeliveryChannel === input.channel;
65
66
  if (record.redeliverAfterProvenMissing?.(record, input.channel) !== true) return false;
67
+ record.provenMissingRedeliveryChannel = input.channel;
66
68
  redelivered += 1; return true;
67
69
  },
68
70
  );
@@ -54,6 +54,8 @@ export interface DaemonRelayOutageDiagnostics {
54
54
  last_disconnect_at: string | null;
55
55
  ready_at: string | null;
56
56
  duration_ms: number;
57
+ https_fallback_taken_over: boolean;
58
+ https_fallback_takeover_ms: number;
57
59
  attempts: number;
58
60
  close_category: string | null;
59
61
  close_code: number | null;
@@ -129,6 +131,7 @@ export interface DaemonRelayDiagnostics {
129
131
  previous_ready_duration_ms: number;
130
132
  previous_ready_inbound_silence_ms: number;
131
133
  https_fallback_last_takeover_ms: number;
134
+ https_fallback_last_takeover_outage_number: number;
132
135
  }
133
136
 
134
137
  export function sanitizeDaemonRelayDiagnostics(value: unknown): DaemonRelayDiagnostics | undefined {
@@ -184,6 +187,7 @@ export function sanitizeDaemonRelayDiagnostics(value: unknown): DaemonRelayDiagn
184
187
  previous_ready_duration_ms: boundedInteger(candidate.previous_ready_duration_ms, 0, 365 * 24 * 60 * 60_000, 0),
185
188
  previous_ready_inbound_silence_ms: boundedInteger(candidate.previous_ready_inbound_silence_ms, 0, 31 * 24 * 60 * 60_000, 0),
186
189
  https_fallback_last_takeover_ms: boundedInteger(candidate.https_fallback_last_takeover_ms, 0, 10 * 60_000, 0),
190
+ https_fallback_last_takeover_outage_number: boundedInteger(candidate.https_fallback_last_takeover_outage_number, 0, 1_000_000_000, 0),
187
191
  };
188
192
  }
189
193
 
@@ -225,12 +229,16 @@ function recoveredOutage(
225
229
  readyAt: string,
226
230
  durationMs: number,
227
231
  ): DaemonRelayOutageDiagnostics {
232
+ const fallbackTakenOver = value.outage_count > 0
233
+ && value.https_fallback_last_takeover_outage_number === value.outage_count;
228
234
  return {
229
235
  outage_number: value.outage_count,
230
236
  disconnected_at: value.outage_started_at ?? value.last_disconnected_at,
231
237
  last_disconnect_at: value.last_disconnected_at,
232
238
  ready_at: readyAt,
233
239
  duration_ms: boundedInteger(durationMs, 0, 31 * 24 * 60 * 60_000, 0),
240
+ https_fallback_taken_over: fallbackTakenOver,
241
+ https_fallback_takeover_ms: fallbackTakenOver ? value.https_fallback_last_takeover_ms : 0,
234
242
  attempts: value.outage_attempts,
235
243
  close_category: value.last_close_category,
236
244
  close_code: value.last_close_code,
@@ -293,12 +301,16 @@ function recentOutages(value: unknown): DaemonRelayOutageDiagnostics[] {
293
301
  const entry = candidate as Record<string, unknown>;
294
302
  const outageNumber = Number(entry.outage_number);
295
303
  if (!Number.isSafeInteger(outageNumber) || outageNumber < 1 || outageNumber > 1_000_000_000) continue;
304
+ const fallbackTakenOver = entry.https_fallback_taken_over === true;
296
305
  result.push({
297
306
  outage_number: outageNumber,
298
307
  disconnected_at: timestamp(entry.disconnected_at),
299
308
  last_disconnect_at: timestamp(entry.last_disconnect_at),
300
309
  ready_at: timestamp(entry.ready_at),
301
310
  duration_ms: boundedInteger(entry.duration_ms, 0, 31 * 24 * 60 * 60_000, 0),
311
+ https_fallback_taken_over: fallbackTakenOver,
312
+ https_fallback_takeover_ms: fallbackTakenOver
313
+ ? boundedInteger(entry.https_fallback_takeover_ms, 0, 10 * 60_000, 0) : 0,
302
314
  attempts: boundedInteger(entry.attempts, 0, 1_000_000, 0),
303
315
  close_category: nullableEnum(entry.close_category, CLOSE_CATEGORIES),
304
316
  close_code: nullableInteger(entry.close_code, 0, 4999),
@@ -0,0 +1,43 @@
1
+ import type { DaemonChannel } from "./daemon-channel.ts";
2
+ import { daemonResumeMissingCallIds } from "./websocket-protocol.ts";
3
+
4
+ type ResumeReconciliation = {
5
+ expectedIds: ReadonlySet<string>;
6
+ acknowledgedMissingKey?: string;
7
+ };
8
+
9
+ export type DaemonResumeAcknowledgementDisposition = Readonly<{ ok: boolean; duplicate: boolean }>;
10
+ export type DaemonResumeAcknowledgement = Readonly<{ missingIds: readonly string[]; duplicate: boolean }>;
11
+
12
+ const reconciliations = new WeakMap<DaemonChannel, ResumeReconciliation>();
13
+
14
+ export function beginDaemonResumeReconciliation(channel: DaemonChannel, ids: readonly string[]): void {
15
+ reconciliations.set(channel, { expectedIds: new Set(ids) });
16
+ }
17
+
18
+ export function consumeDaemonResumeAcknowledgement(
19
+ channel: DaemonChannel,
20
+ missingIds: readonly string[],
21
+ ): DaemonResumeAcknowledgementDisposition {
22
+ const reconciliation = reconciliations.get(channel);
23
+ if (!reconciliation || missingIds.some((id) => !reconciliation.expectedIds.has(id))) {
24
+ return { ok: false, duplicate: false };
25
+ }
26
+ const key = JSON.stringify([...missingIds].sort());
27
+ if (reconciliation.acknowledgedMissingKey !== undefined) {
28
+ const duplicate = reconciliation.acknowledgedMissingKey === key;
29
+ return { ok: duplicate, duplicate };
30
+ }
31
+ reconciliation.acknowledgedMissingKey = key;
32
+ return { ok: true, duplicate: false };
33
+ }
34
+
35
+ export function daemonResumeAcknowledgement(
36
+ channel: DaemonChannel,
37
+ value: unknown,
38
+ ): DaemonResumeAcknowledgement | null {
39
+ const missingIds = daemonResumeMissingCallIds(value);
40
+ if (!missingIds || channel.readyState !== 1) return null;
41
+ const disposition = consumeDaemonResumeAcknowledgement(channel, missingIds);
42
+ return disposition.ok ? { missingIds, duplicate: disposition.duplicate } : null;
43
+ }
@@ -0,0 +1,14 @@
1
+ import {
2
+ managedJobReadArgumentsWithinExecutionBudget, managedJobReadExecutionBudgetHasHeadroom,
3
+ } from "./managed-job-read-timeout.ts";
4
+
5
+ export function daemonToolRedeliveryArguments(
6
+ name: string, args: Record<string, unknown>, remainingExecutionMs: number,
7
+ ): Record<string, unknown> | null {
8
+ if (!Number.isSafeInteger(remainingExecutionMs) || remainingExecutionMs < 1_000) return null;
9
+ if (name !== "read_job") return args;
10
+ if (managedJobReadExecutionBudgetHasHeadroom(remainingExecutionMs)) {
11
+ return managedJobReadArgumentsWithinExecutionBudget(args, remainingExecutionMs);
12
+ }
13
+ return { ...args, wait_ms: 0 };
14
+ }
@@ -18,6 +18,7 @@ import { processRuntimeAlarm, scheduleRuntimeAlarm } from "./runtime-alarm.ts";
18
18
  import { consumeDaemonPreflightNonce, createDaemonChallenge, verifyDaemonAuthentication, verifyDaemonPreflight } from "./daemon-auth.ts";
19
19
  import { handleDaemonHttpRelay } from "./daemon-http-controller.ts";
20
20
  import { handleReadyDaemonMessage } from "./daemon-ready-messages.ts";
21
+ import { beginDaemonResumeReconciliation } from "./daemon-resume-reconciliation.ts";
21
22
  import { McpController } from "./mcp-controller.ts";
22
23
  import { authorizeMcpRequest } from "./mcp-access.ts";
23
24
  import { removedProtocolResponse } from "./mcp-removed-protocol.ts";
@@ -51,11 +52,12 @@ import { MCP_DISCOVERY_TTL_MS, MCP_INSTRUCTIONS, MCP_PROTOCOL_VERSIONS,
51
52
  import { projectOverviewDetail, projectProjectOverview } from "../shared/project-overview-projection.mjs";
52
53
  import { asObject, isJsonRpcRequest, isJsonRpcResponse, rpcError } from "./mcp-jsonrpc.ts";
53
54
  import { managedJobReadArgumentsWithinExecutionBudget, managedJobReadExecutionBudgetHasHeadroom } from "./managed-job-read-timeout.ts";
55
+ import { daemonToolRedeliveryArguments } from "./daemon-tool-redelivery.ts";
54
56
  import { hostedManagedJobDaemonArguments, projectHostedManagedJobResult } from "./managed-job-hosted-authority.ts";
55
57
  import { cancelManagedJobMonitorClaimsIfAvailable, claimManagedJobMonitor, hasManagedJobMonitorClaimIfAvailable, ManagedJobMonitorClaimStore } from "./mcp-job-monitor-claims.ts";
56
58
  import { JOB_MONITOR_CLAIM_TOOL, JOB_MONITOR_READ_TOOL, JOB_MONITOR_RENDER_TOOL, managedJobMonitorReadDaemonArguments, projectManagedJobMonitorStatus, renderManagedJobMonitor } from "./mcp-job-monitor-tools.ts";
57
59
  import { closeWebSocketQuietly, daemonErrorCloseCode, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly, trySendWebSocket } from "./websocket-protocol.ts";
58
- const SERVER_VERSION = "3.0.0-beta.185";
60
+ const SERVER_VERSION = "3.0.0-beta.190";
59
61
  const MCP_SERVER_INFO = mcpServerInfo(SERVER_VERSION);
60
62
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
61
63
  const DAEMON_RECONNECT_GRACE_MS = relayContract.reconnectGraceMs; const NEW_CALL_RECONNECT_GRACE_MS = relayContract.newCallReconnectGraceMs;
@@ -332,6 +334,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
332
334
  const fallbackSocket = previousSockets.find((socket) => this.daemonRegistry.readyAttachment(socket)?.instanceId === daemonInstanceId);
333
335
  const fallbackHttp = previousHttpChannels.find((channel) => this.daemonRegistry.readyAttachment(channel)?.instanceId === daemonInstanceId);
334
336
  const reboundCallIds = this.pending.rebindInstance(daemonInstanceId, ws);
337
+ beginDaemonResumeReconciliation(ws, reboundCallIds);
335
338
  if (reboundCallIds.length > 0) {
336
339
  this.observability.event("info", "daemon.calls.rebound", { rebound_calls: reboundCallIds.length });
337
340
  }
@@ -375,6 +378,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
375
378
  beginDrain: (channel) => this.daemonRegistry.beginDrain(channel),
376
379
  });
377
380
  if (!handled.ok) {
381
+ this.observability.socketProtocolError(handled.errorCode ?? "unknown_message_type");
378
382
  rejectDaemonMessage(ws, handled.errorCode ?? "unknown_message_type", 1002, handled.errorMessage ?? "invalid daemon message");
379
383
  return;
380
384
  }
@@ -578,11 +582,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
578
582
  redeliverAfterProvenMissing: (record, channel) => {
579
583
  const remainingExecutionMs = Math.min(dispatchBudget.executionTimeoutMs,
580
584
  Math.floor(record.startedAt + dispatchBudget.executionTimeoutMs - performance.now()));
581
- if (remainingExecutionMs < 1_000) return false;
582
- if (name === "read_job" && !managedJobReadExecutionBudgetHasHeadroom(remainingExecutionMs)) return false;
583
- const redeliveryArgs = name === "read_job"
584
- ? managedJobReadArgumentsWithinExecutionBudget(args, remainingExecutionMs)
585
- : args;
585
+ const redeliveryArgs = daemonToolRedeliveryArguments(name, args, remainingExecutionMs);
586
+ if (!redeliveryArgs) return false;
586
587
  return trySendDaemonChannel(channel, {
587
588
  type: "tool_call", id: record.id, tool: name, arguments: redeliveryArgs, timeout_ms: remainingExecutionMs,
588
589
  authorization: {
@@ -43,6 +43,7 @@ export interface PendingCallRecord {
43
43
  remainingTimeoutMs: number;
44
44
  onTimeout: (record: PendingCallRecord) => Error;
45
45
  redeliverAfterProvenMissing?: (record: PendingCallRecord, channel: DaemonChannel) => boolean;
46
+ provenMissingRedeliveryChannel?: DaemonChannel;
46
47
  settlement: PendingCallSettlement;
47
48
  signal?: AbortSignal;
48
49
  abortHandler?: () => void;