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 +4 -5
- package/browser-extension/manifest.json +1 -1
- package/docs/AUDIT.md +2 -1
- package/docs/LOGGING.md +1 -1
- package/docs/OPERATIONS.md +2 -2
- package/package.json +2 -1
- package/scripts/github-release.mjs +49 -47
- package/scripts/release-ci.mjs +88 -9
- package/src/local/agent-context.mjs +34 -3
- package/src/local/managed-job-output-redaction.mjs +73 -37
- package/src/local/relay-peer-diagnostics.mjs +5 -0
- package/src/local/resilient-relay-connection.mjs +63 -4
- package/src/local/runtime-diagnostics.mjs +1 -1
- package/src/local/runtime-info-relay-projection.mjs +1 -0
- package/src/local/runtime-process-routing.mjs +14 -0
- package/src/local/system-sleep-diagnostics.mjs +8 -4
- package/src/worker/daemon-http-controller.ts +3 -0
- package/src/worker/daemon-ready-messages.ts +6 -4
- package/src/worker/daemon-relay-diagnostics.ts +12 -0
- package/src/worker/daemon-resume-reconciliation.ts +43 -0
- package/src/worker/daemon-tool-redelivery.ts +14 -0
- package/src/worker/index.ts +7 -6
- package/src/worker/pending-call-contract.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 3.0.0-beta.
|
|
3
|
+
## 3.0.0-beta.190 - 2026-09-13
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
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.
|
|
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.
|
|
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
|
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 {
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
394
|
-
fail(`local ${tag} points to ${existingLocal}, not ${
|
|
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 !==
|
|
402
|
-
fail(`remote ${tag} points to ${existingRemote}, not ${
|
|
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 });
|
package/scripts/release-ci.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
text =
|
|
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
|
|
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
|
|
84
|
-
if (!Buffer.isBuffer(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 (
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
28
|
-
if (!
|
|
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
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
582
|
-
if (
|
|
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;
|