@opengeni/runtime 0.6.1 → 0.7.0
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/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
- package/dist/index.d.ts +293 -123
- package/dist/index.js +1819 -790
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -898
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +84 -14
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-DYBF7ZEB.js.map +0 -1
- package/src/image-history.ts +0 -149
- package/src/screenshot-omitted-card.ts +0 -15
|
@@ -44,6 +44,13 @@ export interface SelfhostedEnrollment {
|
|
|
44
44
|
allowScreenControl: boolean;
|
|
45
45
|
hasDisplay: boolean;
|
|
46
46
|
lastSeenAt: string | null;
|
|
47
|
+
/** An un-cleared clean going-offline marker (the machine announced a typed
|
|
48
|
+
* GoingOffline). When set, the derivation reads the machine OFFLINE immediately,
|
|
49
|
+
* regardless of a still-fresh `lastSeenAt`. NULL ⇒ no pending goodbye. */
|
|
50
|
+
wentOfflineAt: string | null;
|
|
51
|
+
/** The typed reason string of the pending goodbye (rides alongside
|
|
52
|
+
* `wentOfflineAt`; NULL when there is no un-cleared marker). */
|
|
53
|
+
wentOfflineReason: string | null;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** The derived liveness state of a selfhosted machine (the online/offline/
|
|
@@ -72,6 +79,8 @@ export const SELFHOSTED_RECONNECT_WINDOW_MS = 30_000;
|
|
|
72
79
|
* (stale / never seen).
|
|
73
80
|
*
|
|
74
81
|
* - no enrollment / revoked → offline (the machine isn't enrolled).
|
|
82
|
+
* - un-cleared clean goodbye → offline (an announced GoingOffline beats
|
|
83
|
+
* last_seen aging AND a lingering probe).
|
|
75
84
|
* - probe responded → online.
|
|
76
85
|
* - probe missed, lastSeenAt recent → reconnecting (a transient blip).
|
|
77
86
|
* - probe missed, lastSeenAt stale → offline.
|
|
@@ -89,6 +98,15 @@ export function selfhostedLiveness(input: {
|
|
|
89
98
|
}
|
|
90
99
|
const consented = enrollment.exposure === "whole-machine" && enrollment.allowScreenControl;
|
|
91
100
|
const hasDisplay = enrollment.hasDisplay;
|
|
101
|
+
// A CLEAN going-offline the machine announced takes precedence over BOTH
|
|
102
|
+
// last_seen aging and a lingering probe: a machine that said "I'm stopping" must
|
|
103
|
+
// read offline immediately (so no new work is routed at it) until a NEWER
|
|
104
|
+
// liveness signal — a reconnect Hello or a fresher heartbeat — clears the marker
|
|
105
|
+
// in the row. `status: "revoked"` above still trumps this (a revoked machine is
|
|
106
|
+
// offline regardless). This is the #348 lease-waits-on-dead-detect fix.
|
|
107
|
+
if (enrollment.wentOfflineAt) {
|
|
108
|
+
return { state: "offline", consented, hasDisplay };
|
|
109
|
+
}
|
|
92
110
|
if (input.probeResponded) {
|
|
93
111
|
return { state: "online", consented, hasDisplay };
|
|
94
112
|
}
|
|
@@ -130,8 +148,11 @@ export interface SelfhostedNegotiationInput {
|
|
|
130
148
|
* with the online/offline/reconnecting/consent_required/display_unavailable cells
|
|
131
149
|
* correctly decided. Async because it issues the liveness probe.
|
|
132
150
|
*/
|
|
133
|
-
export async function negotiateSelfhostedCapabilities(
|
|
134
|
-
|
|
151
|
+
export async function negotiateSelfhostedCapabilities(
|
|
152
|
+
input: SelfhostedNegotiationInput,
|
|
153
|
+
): Promise<SessionCapabilities> {
|
|
154
|
+
const probeResponded =
|
|
155
|
+
input.probeResponded ?? (input.session ? await input.session.ping() : false);
|
|
135
156
|
const liveness = selfhostedLiveness({
|
|
136
157
|
enrollment: input.enrollment,
|
|
137
158
|
probeResponded,
|
|
@@ -142,7 +163,8 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
|
|
|
142
163
|
// descriptor-shaped cells (FS/Terminal/Git/Desktop) negotiate as on a warm box
|
|
143
164
|
// when online, and a cold box when not reachable (no live tunnel). The
|
|
144
165
|
// selfhosted overlay below then stamps the selfhosted-specific reasons.
|
|
145
|
-
const baseLiveness: NegotiationContext["liveness"] =
|
|
166
|
+
const baseLiveness: NegotiationContext["liveness"] =
|
|
167
|
+
liveness.state === "online" ? "warm" : "cold";
|
|
146
168
|
const base: NegotiationContext = {
|
|
147
169
|
sessionId: input.sessionId,
|
|
148
170
|
backend: "selfhosted",
|
|
@@ -152,7 +174,9 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
|
|
|
152
174
|
desktopEnabled: input.desktopEnabled ?? true,
|
|
153
175
|
terminalEnabled: input.terminalEnabled ?? true,
|
|
154
176
|
computerUseEnabled: input.computerUseEnabled ?? true,
|
|
155
|
-
...(input.desktopAcknowledged !== undefined
|
|
177
|
+
...(input.desktopAcknowledged !== undefined
|
|
178
|
+
? { desktopAcknowledged: input.desktopAcknowledged }
|
|
179
|
+
: {}),
|
|
156
180
|
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
|
157
181
|
...(input.sharedSessionIds !== undefined ? { sharedSessionIds: input.sharedSessionIds } : {}),
|
|
158
182
|
...(input.now ? { now: input.now } : {}),
|
|
@@ -166,11 +190,19 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
|
|
|
166
190
|
// is the dominant degrade (like os_unsupported): an offline/reconnecting agent
|
|
167
191
|
// knocks out every reachable capability with the single coherent reason.
|
|
168
192
|
if (liveness.state !== "online") {
|
|
169
|
-
const reason: CapabilityUnavailableReason =
|
|
193
|
+
const reason: CapabilityUnavailableReason =
|
|
194
|
+
liveness.state === "offline" ? "agent_offline" : "agent_reconnecting";
|
|
170
195
|
return {
|
|
171
196
|
...caps,
|
|
172
197
|
FileSystem: { ...caps.FileSystem, available: false, readOnly: true, reason },
|
|
173
|
-
Terminal: {
|
|
198
|
+
Terminal: {
|
|
199
|
+
...caps.Terminal,
|
|
200
|
+
transport: null,
|
|
201
|
+
url: null,
|
|
202
|
+
token: null,
|
|
203
|
+
expiresAt: null,
|
|
204
|
+
reason,
|
|
205
|
+
},
|
|
174
206
|
Git: { ...caps.Git, available: false, reason },
|
|
175
207
|
DesktopStream: {
|
|
176
208
|
...caps.DesktopStream,
|
|
@@ -209,22 +241,23 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
|
|
|
209
241
|
const reason: CapabilityUnavailableReason = "display_unavailable";
|
|
210
242
|
return {
|
|
211
243
|
...caps,
|
|
212
|
-
DesktopStream:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
244
|
+
DesktopStream:
|
|
245
|
+
caps.DesktopStream.transport !== null
|
|
246
|
+
? {
|
|
247
|
+
...caps.DesktopStream,
|
|
248
|
+
transport: null,
|
|
249
|
+
client: null,
|
|
250
|
+
mode: "read-only",
|
|
251
|
+
url: null,
|
|
252
|
+
token: null,
|
|
253
|
+
expiresAt: null,
|
|
254
|
+
requiresAcknowledgment: false,
|
|
255
|
+
acknowledged: false,
|
|
256
|
+
shared: false,
|
|
257
|
+
sharedSessionIds: [],
|
|
258
|
+
reason,
|
|
259
|
+
}
|
|
260
|
+
: caps.DesktopStream,
|
|
228
261
|
Recording: caps.Recording.available
|
|
229
262
|
? { ...caps.Recording, available: false, modes: [], codecs: [], reason }
|
|
230
263
|
: caps.Recording,
|
|
@@ -240,9 +273,10 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
|
|
|
240
273
|
// so no input is forwarded even if the base offered an interactive mode.
|
|
241
274
|
return {
|
|
242
275
|
...caps,
|
|
243
|
-
DesktopStream:
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
DesktopStream:
|
|
277
|
+
caps.DesktopStream.transport !== null
|
|
278
|
+
? { ...caps.DesktopStream, mode: "read-only" }
|
|
279
|
+
: caps.DesktopStream,
|
|
246
280
|
ComputerUse: caps.ComputerUse.available
|
|
247
281
|
? { ...caps.ComputerUse, available: false, reason: "consent_required" }
|
|
248
282
|
: caps.ComputerUse,
|
|
@@ -20,12 +20,7 @@
|
|
|
20
20
|
// hardening, retries) behind this SAME `ControlRpc` interface — design for that,
|
|
21
21
|
// do not duplicate.
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
AgentError,
|
|
25
|
-
ControlRequest,
|
|
26
|
-
ControlResponse,
|
|
27
|
-
ErrorCode,
|
|
28
|
-
} from "@opengeni/agent-proto";
|
|
23
|
+
import { AgentError, ControlRequest, ControlResponse, ErrorCode } from "@opengeni/agent-proto";
|
|
29
24
|
import type { CapabilityUnavailableReason } from "@opengeni/contracts";
|
|
30
25
|
|
|
31
26
|
// Re-export the contracts reason union locally so callers of the mapping below
|
|
@@ -93,6 +88,15 @@ export class SelfhostedControlError extends Error {
|
|
|
93
88
|
readonly draining: boolean;
|
|
94
89
|
readonly agentOffline: boolean;
|
|
95
90
|
readonly osNotFound: boolean;
|
|
91
|
+
/** The reply exceeded the transport's max payload (a distinguishing flag so the
|
|
92
|
+
* fault renderer can name the size wall + recovery instead of a generic OS error). */
|
|
93
|
+
readonly payloadTooLarge: boolean;
|
|
94
|
+
/** The transport synthesized this fault PRE-SEND — it KNOWS the request never
|
|
95
|
+
* reached the machine (no connection, or no responder on the subject), so the op
|
|
96
|
+
* provably did not execute. Only such a fault is safe to re-issue for ANY op kind
|
|
97
|
+
* (a post-send timeout is ambiguous — at-least-once — and must not be re-sent
|
|
98
|
+
* unless the op is read-only). Never set for an agent-returned error. */
|
|
99
|
+
readonly neverSent: boolean;
|
|
96
100
|
readonly detail: Record<string, string>;
|
|
97
101
|
|
|
98
102
|
constructor(input: {
|
|
@@ -104,6 +108,8 @@ export class SelfhostedControlError extends Error {
|
|
|
104
108
|
draining?: boolean;
|
|
105
109
|
agentOffline?: boolean;
|
|
106
110
|
osNotFound?: boolean;
|
|
111
|
+
payloadTooLarge?: boolean;
|
|
112
|
+
neverSent?: boolean;
|
|
107
113
|
detail?: Record<string, string>;
|
|
108
114
|
}) {
|
|
109
115
|
super(input.message);
|
|
@@ -114,6 +120,8 @@ export class SelfhostedControlError extends Error {
|
|
|
114
120
|
this.draining = input.draining ?? false;
|
|
115
121
|
this.agentOffline = input.agentOffline ?? false;
|
|
116
122
|
this.osNotFound = input.osNotFound ?? false;
|
|
123
|
+
this.payloadTooLarge = input.payloadTooLarge ?? false;
|
|
124
|
+
this.neverSent = input.neverSent ?? false;
|
|
117
125
|
this.detail = input.detail ?? {};
|
|
118
126
|
}
|
|
119
127
|
}
|
|
@@ -133,6 +141,9 @@ export class SelfhostedControlError extends Error {
|
|
|
133
141
|
* real "file does not exist"), no machine-liveness
|
|
134
142
|
* reason. (This is the ONLY NotFound; it is NOT the
|
|
135
143
|
* box-gone NotFound that licenses a cold restore.)
|
|
144
|
+
* - PAYLOAD_TOO_LARGE → the reply exceeded the transport's max payload;
|
|
145
|
+
* non-retryable, with actionable copy (the sizes + a
|
|
146
|
+
* redirect-to-file workaround).
|
|
136
147
|
* - OS / UNSUPPORTED / STREAM / PROTOCOL / UNSPECIFIED → op-level error, no
|
|
137
148
|
* reason, non-retryable.
|
|
138
149
|
*/
|
|
@@ -141,12 +152,17 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
|
|
|
141
152
|
const detail = err.detail ?? {};
|
|
142
153
|
switch (err.code) {
|
|
143
154
|
case ErrorCode.ERROR_CODE_AGENT_OFFLINE:
|
|
155
|
+
// AGENT_OFFLINE is ONLY ever synthesized by our transport (the agent never
|
|
156
|
+
// sends it). The synthesis marks `neverSent` on the pre-send paths (no NATS
|
|
157
|
+
// connection, or no responder on the subject) — the op provably never reached
|
|
158
|
+
// the machine — via the reserved detail key `NEVER_SENT_DETAIL_KEY`.
|
|
144
159
|
return new SelfhostedControlError({
|
|
145
160
|
message: message || "the enrolled agent is offline",
|
|
146
161
|
code: err.code,
|
|
147
162
|
reason: "agent_offline",
|
|
148
163
|
retryable: false,
|
|
149
164
|
agentOffline: true,
|
|
165
|
+
neverSent: detail[NEVER_SENT_DETAIL_KEY] === "1",
|
|
150
166
|
detail,
|
|
151
167
|
});
|
|
152
168
|
case ErrorCode.ERROR_CODE_TIMEOUT:
|
|
@@ -168,14 +184,33 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
|
|
|
168
184
|
detail,
|
|
169
185
|
});
|
|
170
186
|
case ErrorCode.ERROR_CODE_DRAINING:
|
|
187
|
+
// A pre-admission backpressure rejection (the machine's bounded host-work
|
|
188
|
+
// pool is full, or it is shutting down): the op NEVER started, so it is safe
|
|
189
|
+
// to retry (SelfhostedSession.call retries it a bounded number of times).
|
|
190
|
+
// Replace the agent's internal "N in flight" phrasing with human-language,
|
|
191
|
+
// actionable copy; call() appends the retry count when it finally surfaces.
|
|
171
192
|
return new SelfhostedControlError({
|
|
172
|
-
message:
|
|
193
|
+
message: drainingMessage(0),
|
|
173
194
|
code: err.code,
|
|
174
195
|
reason: null,
|
|
175
196
|
retryable: true,
|
|
176
197
|
draining: true,
|
|
177
198
|
detail,
|
|
178
199
|
});
|
|
200
|
+
case ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE:
|
|
201
|
+
// The op's reply exceeded the control transport's negotiated max payload and
|
|
202
|
+
// could not be published (a huge file read, an unbounded exec dump, a full-res
|
|
203
|
+
// screenshot). Not retryable — the same op reproduces the same oversized reply.
|
|
204
|
+
// Surface the actual sizes + a concrete workaround (write to a file, read in
|
|
205
|
+
// ranges) instead of the raw byte-count message.
|
|
206
|
+
return new SelfhostedControlError({
|
|
207
|
+
message: payloadTooLargeMessage(detail),
|
|
208
|
+
code: err.code,
|
|
209
|
+
reason: null,
|
|
210
|
+
retryable: false,
|
|
211
|
+
payloadTooLarge: true,
|
|
212
|
+
detail,
|
|
213
|
+
});
|
|
179
214
|
case ErrorCode.ERROR_CODE_FENCED:
|
|
180
215
|
return new SelfhostedControlError({
|
|
181
216
|
message: message || "a stale op was fenced by the epoch guard; re-resolve and retry",
|
|
@@ -211,14 +246,97 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
|
|
|
211
246
|
}
|
|
212
247
|
}
|
|
213
248
|
|
|
249
|
+
// ── Actionable, human-language error copy ─────────────────────────────────────
|
|
250
|
+
// These build the messages the model / API caller sees for the failure modes that
|
|
251
|
+
// have a concrete workaround. They live here (next to the mapping) so the phrasing
|
|
252
|
+
// is single-sourced and unit-testable, and deliberately avoid the wire's internal
|
|
253
|
+
// vocabulary ("in flight", "negotiated max payload", raw byte counts alone).
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* PAYLOAD_TOO_LARGE copy: state the actual sizes (from the agent's `detail` map —
|
|
257
|
+
* `encoded_bytes` / `max_payload`) and tell the caller how to get the data anyway:
|
|
258
|
+
* redirect the output to a file and read it back in ranges/chunks.
|
|
259
|
+
*/
|
|
260
|
+
export function payloadTooLargeMessage(detail: Record<string, string>): string {
|
|
261
|
+
const encoded = detail.encoded_bytes;
|
|
262
|
+
const max = detail.max_payload;
|
|
263
|
+
const sizes =
|
|
264
|
+
encoded && max
|
|
265
|
+
? `The result was ${encoded} bytes, over the machine link's ${max}-byte per-message limit. `
|
|
266
|
+
: "The result was larger than the machine link can deliver in a single message. ";
|
|
267
|
+
return (
|
|
268
|
+
`${sizes}Redirect the command's output to a file (for example ` +
|
|
269
|
+
"`<command> > /tmp/out.log 2>&1`) and then read that file back in ranges or chunks " +
|
|
270
|
+
"instead of returning it all at once."
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* DRAINING copy: the machine is at its concurrent-work capacity. `retries` is the
|
|
276
|
+
* number of times `SelfhostedSession.call` already re-tried before giving up (0 at
|
|
277
|
+
* the mapping layer, the final count when it surfaces after exhausting retries).
|
|
278
|
+
*/
|
|
279
|
+
export function drainingMessage(retries: number): string {
|
|
280
|
+
const retried =
|
|
281
|
+
retries > 0 ? ` It was retried ${retries} time${retries === 1 ? "" : "s"} first.` : "";
|
|
282
|
+
return (
|
|
283
|
+
"The machine is at its concurrent-work capacity and could not accept this command." +
|
|
284
|
+
retried +
|
|
285
|
+
" Try again shortly, or reduce the number of commands you run in parallel."
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Rebuild a DRAINING error with the final retry count folded into its message —
|
|
290
|
+
* used by `SelfhostedSession.call` when the bounded DRAINING retries are exhausted. */
|
|
291
|
+
export function drainingExhaustedError(
|
|
292
|
+
base: SelfhostedControlError,
|
|
293
|
+
retries: number,
|
|
294
|
+
): SelfhostedControlError {
|
|
295
|
+
return new SelfhostedControlError({
|
|
296
|
+
message: drainingMessage(retries),
|
|
297
|
+
code: base.code,
|
|
298
|
+
reason: base.reason,
|
|
299
|
+
retryable: base.retryable,
|
|
300
|
+
draining: base.draining,
|
|
301
|
+
// Carry the retry count in `detail` so the fault renderer can state it.
|
|
302
|
+
detail: { ...base.detail, retries: String(retries) },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* exec-deadline copy: the command was terminated at the exec time limit. Advise
|
|
308
|
+
* running long jobs in the background and polling their output rather than blocking
|
|
309
|
+
* the turn on one long command. Surfaced on a timed-out exec result's stderr.
|
|
310
|
+
*/
|
|
311
|
+
export function execDeadlineHint(seconds: number): string {
|
|
312
|
+
return (
|
|
313
|
+
`The command was terminated at the ${seconds}-second execution limit. ` +
|
|
314
|
+
"Run long jobs in the background (for example " +
|
|
315
|
+
"`nohup <command> > /tmp/job.log 2>&1 &`, or start them in a terminal session) " +
|
|
316
|
+
"and poll the output file instead of blocking on a single command."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The reserved `detail` key the transport sets to "1" when it KNOWS a synthesized
|
|
321
|
+
* AGENT_OFFLINE fault occurred pre-send (no connection / no responder) — so the op
|
|
322
|
+
* provably never reached the machine. `agentErrorToControlError` reads it into
|
|
323
|
+
* `SelfhostedControlError.neverSent`. Reserved (never sent by the agent). */
|
|
324
|
+
export const NEVER_SENT_DETAIL_KEY = "_never_sent";
|
|
325
|
+
|
|
214
326
|
/** Build a synthesized AGENT_OFFLINE `AgentError` — the control plane uses this
|
|
215
|
-
* when no agent responds on the subject at all.
|
|
216
|
-
|
|
327
|
+
* when no agent responds on the subject at all. `neverSent` marks that the request
|
|
328
|
+
* provably never reached the machine (no connection / no responder), which makes it
|
|
329
|
+
* safe to re-issue for ANY op kind; leave it false for an AMBIGUOUS transport error
|
|
330
|
+
* (a connection torn mid-request may have delivered the op). */
|
|
331
|
+
export function offlineAgentError(
|
|
332
|
+
message = "no agent responded (offline)",
|
|
333
|
+
neverSent = false,
|
|
334
|
+
): AgentError {
|
|
217
335
|
return {
|
|
218
336
|
code: ErrorCode.ERROR_CODE_AGENT_OFFLINE,
|
|
219
337
|
message,
|
|
220
338
|
retryable: false,
|
|
221
|
-
detail: {},
|
|
339
|
+
detail: neverSent ? { [NEVER_SENT_DETAIL_KEY]: "1" } : {},
|
|
222
340
|
};
|
|
223
341
|
}
|
|
224
342
|
|
|
@@ -288,24 +406,33 @@ function isRequestTimeoutError(err: unknown): boolean {
|
|
|
288
406
|
*/
|
|
289
407
|
export class NatsControlRpc implements ControlRpc {
|
|
290
408
|
private readonly connect: () => Promise<NatsRequestConnection | null>;
|
|
291
|
-
private connection: NatsRequestConnection |
|
|
409
|
+
private connection: NatsRequestConnection | undefined;
|
|
410
|
+
private connecting: Promise<NatsRequestConnection | null> | undefined;
|
|
292
411
|
|
|
293
412
|
constructor(connect: () => Promise<NatsRequestConnection | null>) {
|
|
294
413
|
this.connect = connect;
|
|
295
414
|
}
|
|
296
415
|
|
|
297
416
|
private async resolveConnection(): Promise<NatsRequestConnection | null> {
|
|
298
|
-
if (this.connection
|
|
299
|
-
|
|
300
|
-
this.connection = await this.connect();
|
|
301
|
-
} catch {
|
|
302
|
-
// A connect failure is an OFFLINE condition, not a crash. Cache null so a
|
|
303
|
-
// later request retries the factory (set undefined to allow retry).
|
|
304
|
-
this.connection = null;
|
|
305
|
-
return null;
|
|
306
|
-
}
|
|
417
|
+
if (this.connection) {
|
|
418
|
+
return this.connection;
|
|
307
419
|
}
|
|
308
|
-
|
|
420
|
+
// Share one in-flight dial across concurrent callers, but cache only a real
|
|
421
|
+
// connection. A transient null/throw must be retried by the next request;
|
|
422
|
+
// pinning null here would make a recovered NATS bus look offline until the
|
|
423
|
+
// API/worker process restarted.
|
|
424
|
+
this.connecting ??= this.connect()
|
|
425
|
+
.then((connection) => {
|
|
426
|
+
if (connection) {
|
|
427
|
+
this.connection = connection;
|
|
428
|
+
}
|
|
429
|
+
return connection;
|
|
430
|
+
})
|
|
431
|
+
.catch(() => null)
|
|
432
|
+
.finally(() => {
|
|
433
|
+
this.connecting = undefined;
|
|
434
|
+
});
|
|
435
|
+
return this.connecting;
|
|
309
436
|
}
|
|
310
437
|
|
|
311
438
|
async request(
|
|
@@ -316,7 +443,8 @@ export class NatsControlRpc implements ControlRpc {
|
|
|
316
443
|
const conn = await this.resolveConnection();
|
|
317
444
|
if (!conn) {
|
|
318
445
|
// No NATS configured / not reachable → the agent is unaddressable → offline.
|
|
319
|
-
|
|
446
|
+
// The request was never published (no connection) → NEVER SENT: safe to re-issue.
|
|
447
|
+
return offlineControlResponse(req.requestId, true);
|
|
320
448
|
}
|
|
321
449
|
const payload = ControlRequest.encode(req).finish();
|
|
322
450
|
try {
|
|
@@ -325,11 +453,13 @@ export class NatsControlRpc implements ControlRpc {
|
|
|
325
453
|
} catch (err) {
|
|
326
454
|
// Re-allow a future request to re-dial if the cached conn was torn down.
|
|
327
455
|
if (isNoRespondersError(err)) {
|
|
328
|
-
// No subscriber on the subject at all → the
|
|
329
|
-
|
|
456
|
+
// No subscriber on the subject at all → the request reached no responder and
|
|
457
|
+
// was NOT delivered → NEVER SENT (provably not executed): safe to re-issue.
|
|
458
|
+
return offlineControlResponse(req.requestId, true);
|
|
330
459
|
}
|
|
331
460
|
if (isRequestTimeoutError(err)) {
|
|
332
|
-
// A responder may exist but the request timed out → a transient blip.
|
|
461
|
+
// A responder may exist but the request timed out → a transient blip. AMBIGUOUS
|
|
462
|
+
// (the op may have run and only the reply was lost) — never marked never-sent.
|
|
333
463
|
return timeoutControlResponse(req.requestId);
|
|
334
464
|
}
|
|
335
465
|
// Any other transport error → treat as offline (never a NotFound). The op
|
|
@@ -340,9 +470,11 @@ export class NatsControlRpc implements ControlRpc {
|
|
|
340
470
|
}
|
|
341
471
|
}
|
|
342
472
|
|
|
343
|
-
/** A `ControlResponse` carrying a synthesized AGENT_OFFLINE error.
|
|
344
|
-
|
|
345
|
-
|
|
473
|
+
/** A `ControlResponse` carrying a synthesized AGENT_OFFLINE error. `neverSent` marks
|
|
474
|
+
* a pre-send synthesis (no connection / no responder) — the op provably never
|
|
475
|
+
* reached the machine, so it is safe to re-issue for any op kind. */
|
|
476
|
+
export function offlineControlResponse(requestId: string, neverSent = false): ControlResponse {
|
|
477
|
+
return { requestId, error: offlineAgentError(undefined, neverSent), result: undefined };
|
|
346
478
|
}
|
|
347
479
|
|
|
348
480
|
/** A `ControlResponse` carrying a synthesized TIMEOUT error (→ reconnecting). */
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The in-band fault renderer for Connected Machine (selfhosted) control faults.
|
|
2
|
+
//
|
|
3
|
+
// The failure-visibility doctrine: every fault the model sees at the point of
|
|
4
|
+
// impact must carry FOUR fields — what happened, which layer, what was preserved,
|
|
5
|
+
// what to try — with a retry verdict that is actually correct for the fault class.
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: a thrown `SelfhostedControlError` reaches the model already
|
|
8
|
+
// wrapped by the SDK's generic tool-error function as
|
|
9
|
+
// "An error occurred while running the tool. Please try again. Error: …". That
|
|
10
|
+
// "Please try again" is actively wrong for a machine that is offline, a consent
|
|
11
|
+
// that has not been granted, or a reply that was too large — the model retries a
|
|
12
|
+
// fault that a retry cannot fix, and must parse prose to recover which layer
|
|
13
|
+
// failed. The typed `SelfhostedControlError` flags carry the truth; this renders
|
|
14
|
+
// them into the four structural fields so the capability tools can surface a
|
|
15
|
+
// legible fault as their result instead of the misleading wrapper.
|
|
16
|
+
|
|
17
|
+
import { errorCodeToJSON, ErrorCode } from "@opengeni/agent-proto";
|
|
18
|
+
import { SelfhostedControlError } from "./control-rpc";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A stable, low-cardinality fault-class string for a `SelfhostedControlError` — the
|
|
22
|
+
* out-of-band twin of the in-band renderer's class taxonomy (single-sourced here).
|
|
23
|
+
* Used for the `machine.op.*` event class + the metrics label + the infra filter.
|
|
24
|
+
*/
|
|
25
|
+
export function selfhostedFaultClass(error: SelfhostedControlError): string {
|
|
26
|
+
if (error.agentOffline || error.code === ErrorCode.ERROR_CODE_AGENT_OFFLINE) return "offline";
|
|
27
|
+
if (error.draining) return "draining";
|
|
28
|
+
if (error.payloadTooLarge || error.code === ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE)
|
|
29
|
+
return "payload_too_large";
|
|
30
|
+
if (error.reason === "agent_reconnecting") return "reconnecting";
|
|
31
|
+
if (error.reason === "consent_required") return "consent";
|
|
32
|
+
if (error.osNotFound || error.code === ErrorCode.ERROR_CODE_NOT_FOUND) return "not_found";
|
|
33
|
+
if (error.fenced || error.code === ErrorCode.ERROR_CODE_FENCED) return "fenced";
|
|
34
|
+
switch (error.code) {
|
|
35
|
+
case ErrorCode.ERROR_CODE_OS:
|
|
36
|
+
return "os";
|
|
37
|
+
case ErrorCode.ERROR_CODE_STREAM:
|
|
38
|
+
return "stream";
|
|
39
|
+
case ErrorCode.ERROR_CODE_PROTOCOL:
|
|
40
|
+
return "protocol";
|
|
41
|
+
default:
|
|
42
|
+
return "unspecified";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The fault classes that count as an INFRASTRUCTURE fault (a `machine.op.failed`
|
|
48
|
+
* event). A semantic miss the model asked about — a missing path (`not_found`), a
|
|
49
|
+
* consent gate (`consent`), a nonzero exit (never a fault at all) — is an OUTCOME,
|
|
50
|
+
* not an infra fault, and is DELIBERATELY excluded; `fenced` is a routing/session
|
|
51
|
+
* condition, not a machine fault. (Doctrine two-planes / the team ruling.)
|
|
52
|
+
*/
|
|
53
|
+
export const SELFHOSTED_INFRASTRUCTURE_FAULT_CLASSES: ReadonlySet<string> = new Set([
|
|
54
|
+
"offline",
|
|
55
|
+
"draining",
|
|
56
|
+
"payload_too_large",
|
|
57
|
+
"reconnecting",
|
|
58
|
+
"os",
|
|
59
|
+
"stream",
|
|
60
|
+
"protocol",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** The four mandatory field labels (exported so tests can assert their presence). */
|
|
64
|
+
export const FAULT_FIELD_WHAT_HAPPENED = "What happened:";
|
|
65
|
+
export const FAULT_FIELD_WHICH_LAYER = "Which layer:";
|
|
66
|
+
export const FAULT_FIELD_WHAT_PRESERVED = "What was preserved:";
|
|
67
|
+
export const FAULT_FIELD_WHAT_TO_TRY = "What to try:";
|
|
68
|
+
|
|
69
|
+
interface FaultFields {
|
|
70
|
+
headline: string;
|
|
71
|
+
happened: string;
|
|
72
|
+
layer: string;
|
|
73
|
+
preserved: string;
|
|
74
|
+
tryNext: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Assemble the four-field block. The typed wire code is folded into "what
|
|
78
|
+
* happened" so the model has both the plain language and the precise code. */
|
|
79
|
+
function assemble(error: SelfhostedControlError, f: FaultFields): string {
|
|
80
|
+
const code = errorCodeToJSON(error.code);
|
|
81
|
+
return [
|
|
82
|
+
`[connected machine] ${f.headline}`,
|
|
83
|
+
`${FAULT_FIELD_WHAT_HAPPENED} ${f.happened} (control code ${code}).`,
|
|
84
|
+
`${FAULT_FIELD_WHICH_LAYER} ${f.layer}`,
|
|
85
|
+
`${FAULT_FIELD_WHAT_PRESERVED} ${f.preserved}`,
|
|
86
|
+
`${FAULT_FIELD_WHAT_TO_TRY} ${f.tryNext}`,
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render a `SelfhostedControlError` into the doctrine's four in-band fields with a
|
|
92
|
+
* correct retry verdict. The fault CLASS is read from the typed flags (never the
|
|
93
|
+
* message prose), so each class gets the right "which layer" and "what to try":
|
|
94
|
+
*
|
|
95
|
+
* - PAYLOAD_TOO_LARGE → the command ran but its reply was too big to return; the
|
|
96
|
+
* fix is to bound the output, not to retry.
|
|
97
|
+
* - DRAINING → pre-admission backpressure; nothing ran; safe to retry shortly.
|
|
98
|
+
* - CONSENT_REQUIRED → a machine-side consent gate; retrying cannot help.
|
|
99
|
+
* - AGENT_OFFLINE → the machine link is down. If the transport KNOWS the request
|
|
100
|
+
* never left (`neverSent`), nothing ran; otherwise the effect is ambiguous and
|
|
101
|
+
* the model must check before re-running. Either way "try again" is wrong until
|
|
102
|
+
* the machine reconnects.
|
|
103
|
+
* - agent_reconnecting (TIMEOUT) → a transient link blip AFTER send: ambiguous, so
|
|
104
|
+
* a state-changing command must be checked before re-running (at-least-once).
|
|
105
|
+
* - NOT_FOUND → a command/filesystem miss, not a link fault.
|
|
106
|
+
* - FENCED → the active machine session changed (a swap); re-run.
|
|
107
|
+
* - default (OS/UNSUPPORTED/STREAM/PROTOCOL) → the command/op failed on the machine.
|
|
108
|
+
*/
|
|
109
|
+
export function renderSelfhostedFault(error: SelfhostedControlError): string {
|
|
110
|
+
const detail = error.detail;
|
|
111
|
+
|
|
112
|
+
if (error.payloadTooLarge || error.code === ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE) {
|
|
113
|
+
// The STREAMING retention-ceiling overflow (op-stream OP_OVERFLOW) is a
|
|
114
|
+
// different truth than the legacy reply-size wall: the machine STOPPED the
|
|
115
|
+
// command at the ceiling (it did not run to completion), and none of its
|
|
116
|
+
// output was returned. Same correct next action, honest distinct fields.
|
|
117
|
+
if (detail.failure_code === "OP_OVERFLOW") {
|
|
118
|
+
const retained = detail.retained_bytes
|
|
119
|
+
? `after retaining ${detail.retained_bytes} bytes of output`
|
|
120
|
+
: "when its output exceeded what the machine can retain for delivery";
|
|
121
|
+
return assemble(error, {
|
|
122
|
+
headline: "the command produced more output than the machine link can deliver",
|
|
123
|
+
happened: `the machine stopped the command ${retained} — its output-retention ceiling was reached`,
|
|
124
|
+
layer:
|
|
125
|
+
"the machine link's output-retention ceiling — the command was terminated at the ceiling, not by its own logic",
|
|
126
|
+
preserved:
|
|
127
|
+
"no output was returned, and the command did NOT run to completion — any side effects up to the stop point may have applied.",
|
|
128
|
+
tryNext:
|
|
129
|
+
"re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it " +
|
|
130
|
+
"back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const sizes =
|
|
134
|
+
detail.encoded_bytes && detail.max_payload
|
|
135
|
+
? `its ${detail.encoded_bytes}-byte reply exceeded the machine link's ${detail.max_payload}-byte per-message limit`
|
|
136
|
+
: "its reply was larger than the machine link can deliver in one message";
|
|
137
|
+
return assemble(error, {
|
|
138
|
+
headline: "the command's output was too large to return",
|
|
139
|
+
happened: `the command ran on the machine, but ${sizes}`,
|
|
140
|
+
layer: "the machine link's per-message size limit — the command itself executed",
|
|
141
|
+
preserved: "nothing was returned; the oversized reply was dropped whole.",
|
|
142
|
+
tryNext:
|
|
143
|
+
"re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it " +
|
|
144
|
+
"back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`.",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (error.draining) {
|
|
149
|
+
const retried = detail.retries ? ` after ${detail.retries} retries` : "";
|
|
150
|
+
return assemble(error, {
|
|
151
|
+
headline: "the machine is at its concurrent-work capacity",
|
|
152
|
+
happened: `the machine rejected this command at its admission gate${retried} because its work pool is full`,
|
|
153
|
+
layer: "the machine's host-work admission — the machine is online, just saturated",
|
|
154
|
+
preserved: "nothing ran; the command was rejected before it started.",
|
|
155
|
+
tryNext: "try again shortly, or reduce the number of commands you run in parallel.",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (error.reason === "consent_required") {
|
|
160
|
+
return assemble(error, {
|
|
161
|
+
headline: "the operation needs consent that has not been granted",
|
|
162
|
+
happened: "the machine rejected the operation because the required consent is not granted",
|
|
163
|
+
layer: "the machine's consent gate — not your command",
|
|
164
|
+
preserved: "nothing ran; the operation was rejected.",
|
|
165
|
+
tryNext:
|
|
166
|
+
"the consent must be granted on the machine itself; retrying will keep failing until it is.",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (error.agentOffline || error.code === ErrorCode.ERROR_CODE_AGENT_OFFLINE) {
|
|
171
|
+
const preserved = error.neverSent
|
|
172
|
+
? "nothing ran; the command never reached the machine."
|
|
173
|
+
: "unknown — the link dropped and the command may or may not have run.";
|
|
174
|
+
const tryNext = error.neverSent
|
|
175
|
+
? "the machine appears offline; check that it is powered on and connected. Retrying will not help until it reconnects."
|
|
176
|
+
: "check the machine, and check whether the command already took effect before re-running it.";
|
|
177
|
+
return assemble(error, {
|
|
178
|
+
headline: "the machine is unreachable",
|
|
179
|
+
happened: "the enrolled machine did not respond; it appears offline",
|
|
180
|
+
layer: "the machine link — the machine itself, not your command",
|
|
181
|
+
preserved,
|
|
182
|
+
tryNext,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (error.reason === "agent_reconnecting") {
|
|
187
|
+
return assemble(error, {
|
|
188
|
+
headline: "the machine link blipped",
|
|
189
|
+
happened: "the machine did not respond in time; the link may be reconnecting",
|
|
190
|
+
layer: "the machine link (transport) — not your command",
|
|
191
|
+
preserved: "unknown — the command may or may not have run (its reply was lost).",
|
|
192
|
+
tryNext:
|
|
193
|
+
"wait a moment and try again; if this was a state-changing command, check whether it " +
|
|
194
|
+
"already took effect before re-running it.",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (error.osNotFound || error.code === ErrorCode.ERROR_CODE_NOT_FOUND) {
|
|
199
|
+
return assemble(error, {
|
|
200
|
+
headline: "the path or reference was not found on the machine",
|
|
201
|
+
happened: `the machine reported: ${error.message}`,
|
|
202
|
+
layer: "the command / filesystem on the machine — not the machine link",
|
|
203
|
+
preserved: "not applicable; the operation did not apply.",
|
|
204
|
+
tryNext: "check the path or reference exists on the machine, then re-run.",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (error.fenced || error.code === ErrorCode.ERROR_CODE_FENCED) {
|
|
209
|
+
return assemble(error, {
|
|
210
|
+
headline: "the active machine session changed",
|
|
211
|
+
happened: "the machine session was swapped underneath the command",
|
|
212
|
+
layer: "the session-routing layer — not your command",
|
|
213
|
+
preserved: "nothing ran on this session.",
|
|
214
|
+
tryNext: "re-run the command; it will target the machine's current session.",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// OS / UNSUPPORTED / STREAM / PROTOCOL / UNSPECIFIED — a command/op-level failure.
|
|
219
|
+
return assemble(error, {
|
|
220
|
+
headline: "the command failed on the machine",
|
|
221
|
+
happened: `the machine reported: ${error.message}`,
|
|
222
|
+
layer: "the command / operating system on the machine",
|
|
223
|
+
preserved: "not applicable; the command did not complete successfully.",
|
|
224
|
+
tryNext: "read the message above and adjust the command; a blind retry is unlikely to help.",
|
|
225
|
+
});
|
|
226
|
+
}
|