@opengeni/runtime 0.6.1 → 0.7.1

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.
Files changed (45) hide show
  1. package/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
  4. package/dist/index.d.ts +296 -123
  5. package/dist/index.js +1907 -809
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2687 -920
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +84 -14
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-DYBF7ZEB.js.map +0 -1
  44. package/src/image-history.ts +0 -149
  45. package/src/screenshot-omitted-card.ts +0 -15
@@ -18,6 +18,7 @@
18
18
  import {
19
19
  ControlRequest,
20
20
  ControlResponse,
21
+ ErrorCode,
21
22
  FsEntryKind,
22
23
  StreamKind,
23
24
  type DesktopInputRequest,
@@ -36,9 +37,22 @@ import { Manifest } from "@openai/agents/sandbox";
36
37
  import type { ExposedPortEndpoint } from "../stream-port";
37
38
  import {
38
39
  agentErrorToControlError,
40
+ drainingExhaustedError,
41
+ execDeadlineHint,
42
+ SelfhostedControlError,
39
43
  subjectFor,
40
44
  type ControlRpc,
41
45
  } from "./control-rpc";
46
+ import {
47
+ decideSelfhostedRetry,
48
+ defaultSelfhostedRetryClock,
49
+ type SelfhostedRetryClock,
50
+ } from "./retry-policy";
51
+ import type { SelfhostedOpObservation, SelfhostedOpObserver } from "./op-observer";
52
+ import { selfhostedFaultClass } from "./fault-rendering";
53
+ import { nextDurableOpId } from "../op-correlation";
54
+ import { OpStreamExecClient, type OpStreamJournal } from "./op-stream";
55
+ import { OpStreamUnavailableError, type OpStreamTransport } from "./op-transport";
42
56
 
43
57
  const decoder = new TextDecoder();
44
58
  const encoder = new TextEncoder();
@@ -116,7 +130,11 @@ function toMachinePath(p: string | undefined, workingDir: string): string {
116
130
  * runtime barrel (`packages/runtime/src/index.ts`, which DOES import that root)
117
131
  * injects it via `setSelfhostedApplyDiff` at module load. Until injected,
118
132
  * `createEditor()` surfaces a clear error rather than a silent wrong-edit. */
119
- export type SelfhostedApplyDiff = (input: string, diff: string, mode?: "default" | "create") => string;
133
+ export type SelfhostedApplyDiff = (
134
+ input: string,
135
+ diff: string,
136
+ mode?: "default" | "create",
137
+ ) => string;
120
138
  let injectedApplyDiff: SelfhostedApplyDiff | undefined;
121
139
 
122
140
  /** Register the SDK's `applyDiff` so `SelfhostedSession.createEditor()` can apply
@@ -128,8 +146,14 @@ export function setSelfhostedApplyDiff(fn: SelfhostedApplyDiff): void {
128
146
  /** The structural Editor surface the SDK's filesystem capability consumes (the
129
147
  * three apply_patch operations). Mirrors `@openai/agents-core`'s `Editor`. */
130
148
  export interface SelfhostedEditor {
131
- createFile(operation: { path: string; diff: string }, context?: unknown): Promise<{ output?: string } | void>;
132
- updateFile(operation: { path: string; diff: string; moveTo?: string }, context?: unknown): Promise<{ output?: string } | void>;
149
+ createFile(
150
+ operation: { path: string; diff: string },
151
+ context?: unknown,
152
+ ): Promise<{ output?: string } | void>;
153
+ updateFile(
154
+ operation: { path: string; diff: string; moveTo?: string },
155
+ context?: unknown,
156
+ ): Promise<{ output?: string } | void>;
133
157
  deleteFile(operation: { path: string }, context?: unknown): Promise<{ output?: string } | void>;
134
158
  }
135
159
 
@@ -143,6 +167,13 @@ export interface SelfhostedImageOutput {
143
167
  /** Default control-op timeout. A transient miss surfaces as `agent_reconnecting`
144
168
  * (the turn pauses + retries); it is NOT a hard failure. */
145
169
  export const SELFHOSTED_DEFAULT_TIMEOUT_MS = 30_000;
170
+ /** Reply grace after an agent-side exec deadline. The child is terminated at
171
+ * `ExecRequest.timeoutMs`; this outer window lets the typed `timedOut` response
172
+ * cross NATS before request/reply gives up. */
173
+ const SELFHOSTED_EXEC_REPLY_GRACE_MS = 5_000;
174
+ /** nats.js ultimately schedules a JS timer; stay inside the signed 32-bit timer
175
+ * range while also fitting the reply grace. */
176
+ const SELFHOSTED_MAX_EXEC_TIMEOUT_MS = 2_147_483_647 - SELFHOSTED_EXEC_REPLY_GRACE_MS;
146
177
 
147
178
  /**
148
179
  * The ONLY manifest-environment keys exported into a selfhosted machine's exec
@@ -153,7 +184,10 @@ export const SELFHOSTED_DEFAULT_TIMEOUT_MS = 30_000;
153
184
  * and the token-FILE PATH to reach the tool surface. The token VALUE never rides
154
185
  * here — it is seeded to that file off-manifest over the same exec channel.
155
186
  */
156
- const SELFHOSTED_EXEC_ENV_ALLOWLIST = ["OPENGENI_TOOLSPACE_URL", "OPENGENI_TOOLSPACE_TOKEN_FILE"] as const;
187
+ const SELFHOSTED_EXEC_ENV_ALLOWLIST = [
188
+ "OPENGENI_TOOLSPACE_URL",
189
+ "OPENGENI_TOOLSPACE_TOKEN_FILE",
190
+ ] as const;
157
191
 
158
192
  function selfhostedExecEnv(environment: Record<string, string>): Record<string, string> {
159
193
  const env: Record<string, string> = {};
@@ -184,17 +218,55 @@ export interface SelfhostedRelayConfig {
184
218
  /** The relay's default wss dial path (the `opengeni-relay` server route). */
185
219
  export const SELFHOSTED_RELAY_STREAM_PATH = "/stream";
186
220
 
221
+ /**
222
+ * The op-stream injection (op-stream protocol v1.1 — see op-stream.ts). PRESENT
223
+ * = the streaming exec transport is enabled for this session (the worker
224
+ * injects it only when the runner advertised `Capabilities.op_stream` AND the
225
+ * server flag is on — the leaf carries no flag logic). ABSENT = the legacy
226
+ * monolithic exec, byte-identical to before. The timing knobs exist for tests.
227
+ */
228
+ export interface SelfhostedOpStreamDeps {
229
+ transport: OpStreamTransport;
230
+ /** The durable-resume journal (attach generation + settled-frontier persist).
231
+ * Absent (tests, non-activity callers) ⇒ generation "1", no persistence. */
232
+ journal?: OpStreamJournal;
233
+ windowBytes?: number;
234
+ ackIntervalMs?: number;
235
+ silenceTimeoutMs?: number;
236
+ reconnectHoldMs?: number;
237
+ }
238
+
187
239
  export interface SelfhostedSessionDeps {
188
240
  workspaceId: string;
189
241
  agentId: string;
190
242
  controlRpc: ControlRpc;
191
243
  relay: SelfhostedRelayConfig;
244
+ /** Op-stream exec transport (present = enabled; see SelfhostedOpStreamDeps). */
245
+ opStream?: SelfhostedOpStreamDeps;
192
246
  /** The lease/active epoch this session is fenced under (echoed on every
193
247
  * ControlRequest so the agent can reject a stale op with ERROR_CODE_FENCED).
194
248
  * Defaults to 0 (no fence) for the negotiation-only / test path. */
195
249
  epoch?: number;
196
- /** Override the control-op timeout (tests). */
250
+ /** The CONTROL-op timeout (ping/fs/desktop/pty and the exec wire fallback). The
251
+ * agent's own control liveness must stay responsive, so this is short (the 30s
252
+ * default). Override for tests or per-deployment tuning. */
197
253
  timeoutMs?: number;
254
+ /**
255
+ * The EXEC process deadline — distinct from `timeoutMs`. A real command
256
+ * (compile, test run, install) routinely outlives the short control timeout, so
257
+ * exec gets its own, much larger budget: the agent kills the child at this wall,
258
+ * and the wire waits `execTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS`. Omitted ⇒
259
+ * falls back to `timeoutMs` (byte-identical to before this split), so any caller
260
+ * that does not opt in keeps today's behavior.
261
+ */
262
+ execTimeoutMs?: number;
263
+ /** The clock the bounded control-op retry loop drives (sleep + jitter). Injected
264
+ * so tests are deterministic; defaults to a real timer + `Math.random()`. */
265
+ retryClock?: SelfhostedRetryClock;
266
+ /** A fire-and-forget observer invoked once per completed control op (out-of-band
267
+ * telemetry — metrics + `machine.*` events). The worker/api wire it to the sinks;
268
+ * the leaf only invokes it (guarded so it can never break an op). Omitted ⇒ no-op. */
269
+ onOp?: SelfhostedOpObserver;
198
270
  /**
199
271
  * The run's declared sandbox environment — the SAME `Record<string,string>` the
200
272
  * worker turn passes to `runtime.buildAgent`'s `sandboxEnvironment` (and that the
@@ -226,6 +298,10 @@ export interface SelfhostedExecResult {
226
298
  stdout: string;
227
299
  stderr: string;
228
300
  exitCode: number | null;
301
+ /** True when the agent killed the child at the exec deadline. Additive to the
302
+ * Channel-A superset (consumers that don't read it are unaffected); `execCommand`
303
+ * reads it to surface the deadline hint on the stdout-only SDK path. */
304
+ timedOut?: boolean;
229
305
  }
230
306
 
231
307
  /** The `exec` args the structural surface accepts (mirrors ChannelAExecArgs). */
@@ -260,7 +336,14 @@ export class SelfhostedSession {
260
336
  private readonly relay: SelfhostedRelayConfig;
261
337
  private readonly epoch: number;
262
338
  private readonly timeoutMs: number;
339
+ /** The exec process deadline (falls back to `timeoutMs` when unset). */
340
+ private readonly execTimeoutMs: number | undefined;
341
+ private readonly retryClock: SelfhostedRetryClock;
342
+ private readonly onOp: SelfhostedOpObserver | undefined;
263
343
  private readonly subject: string;
344
+ /** The op-stream exec client — constructed iff `deps.opStream` was injected
345
+ * (= the streaming transport is enabled for this session). */
346
+ private readonly opStreamClient: OpStreamExecClient | undefined;
264
347
  /** The session working directory — the path/cwd base every op is rooted under
265
348
  * (see `toMachinePath`). "" by default ⇒ today's workspace_root behavior. */
266
349
  private readonly workingDir: string;
@@ -292,7 +375,12 @@ export class SelfhostedSession {
292
375
  * selfhosted (resume re-addresses the machine by agentId via the lease pointer,
293
376
  * never from this SDK envelope), so its only job is to not crash the serialize.
294
377
  */
295
- readonly state: { agentId: string; instanceId: string; manifest: Manifest; environment: Record<string, string> };
378
+ readonly state: {
379
+ agentId: string;
380
+ instanceId: string;
381
+ manifest: Manifest;
382
+ environment: Record<string, string>;
383
+ };
296
384
 
297
385
  constructor(deps: SelfhostedSessionDeps) {
298
386
  this.workspaceId = deps.workspaceId;
@@ -301,8 +389,36 @@ export class SelfhostedSession {
301
389
  this.relay = deps.relay;
302
390
  this.epoch = deps.epoch ?? 0;
303
391
  this.timeoutMs = deps.timeoutMs ?? SELFHOSTED_DEFAULT_TIMEOUT_MS;
392
+ this.execTimeoutMs = deps.execTimeoutMs;
393
+ this.retryClock = deps.retryClock ?? defaultSelfhostedRetryClock;
394
+ this.onOp = deps.onOp;
304
395
  this.subject = subjectFor(deps.workspaceId, deps.agentId);
305
396
  this.workingDir = deps.workingDir ?? "";
397
+ this.opStreamClient = deps.opStream
398
+ ? new OpStreamExecClient({
399
+ workspaceId: deps.workspaceId,
400
+ agentId: deps.agentId,
401
+ epoch: this.epoch,
402
+ controlRpc: deps.controlRpc,
403
+ rpcSubject: this.subject,
404
+ transport: deps.opStream.transport,
405
+ controlTimeoutMs: this.timeoutMs,
406
+ retryClock: this.retryClock,
407
+ ...(deps.opStream.journal !== undefined ? { journal: deps.opStream.journal } : {}),
408
+ ...(deps.opStream.windowBytes !== undefined
409
+ ? { windowBytes: deps.opStream.windowBytes }
410
+ : {}),
411
+ ...(deps.opStream.ackIntervalMs !== undefined
412
+ ? { ackIntervalMs: deps.opStream.ackIntervalMs }
413
+ : {}),
414
+ ...(deps.opStream.silenceTimeoutMs !== undefined
415
+ ? { silenceTimeoutMs: deps.opStream.silenceTimeoutMs }
416
+ : {}),
417
+ ...(deps.opStream.reconnectHoldMs !== undefined
418
+ ? { reconnectHoldMs: deps.opStream.reconnectHoldMs }
419
+ : {}),
420
+ })
421
+ : undefined;
306
422
  // A valid Manifest mirroring the Modal create-manifest shape (sandbox/index.ts
307
423
  // `createManifest`: `new Manifest({ root: "/workspace", environment })`). `root`
308
424
  // is "/workspace" to match `buildManifest`'s declared root (the root-delta guard
@@ -320,7 +436,11 @@ export class SelfhostedSession {
320
436
  this.state = {
321
437
  agentId: deps.agentId,
322
438
  instanceId: deps.agentId,
323
- manifest: new Manifest({ root: "/workspace", entries: {}, environment: deps.environment ?? {} }),
439
+ manifest: new Manifest({
440
+ root: "/workspace",
441
+ entries: {},
442
+ environment: deps.environment ?? {},
443
+ }),
324
444
  // The SDK `SandboxSessionState.environment` — the run's threaded env (or `{}`).
325
445
  // The group client's end-of-turn serialize reads `state.environment` directly
326
446
  // (Object.entries), so it must be a defined object, not absent.
@@ -328,32 +448,145 @@ export class SelfhostedSession {
328
448
  };
329
449
  }
330
450
 
331
- /** Issue a control op, decoding the agent's reply or throwing the mapped
332
- * `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
333
- * timeout error from the transport). */
334
- private async call(op: NonNullable<ControlRequest["op"]>): Promise<NonNullable<ControlResponse["result"]>> {
335
- const req: ControlRequest = {
336
- requestId: crypto.randomUUID(),
337
- epoch: this.epoch,
338
- op,
339
- };
340
- const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
341
- if (res.error) {
342
- throw agentErrorToControlError(res.error);
451
+ /** Invoke the injected per-op observer, guarded: a telemetry tap must NEVER break
452
+ * an op (a throwing sink is swallowed). No-op when no observer is wired. */
453
+ private emitOp(observation: SelfhostedOpObservation): void {
454
+ if (!this.onOp) {
455
+ return;
456
+ }
457
+ try {
458
+ this.onOp(observation);
459
+ } catch {
460
+ // A telemetry sink must never surface into the op path.
343
461
  }
344
- if (!res.result) {
345
- throw agentErrorToControlError({
346
- code: 7, // ERROR_CODE_PROTOCOL — an empty result is a protocol violation
347
- message: "agent returned an empty control response",
348
- retryable: false,
349
- detail: {},
462
+ }
463
+
464
+ /**
465
+ * Issue a control op, decoding the agent's reply or throwing the mapped
466
+ * `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
467
+ * timeout error from the transport).
468
+ *
469
+ * Two failure modes are retried in-place (the decision is the pure
470
+ * `decideSelfhostedRetry` policy):
471
+ * - DRAINING — a pre-admission host-work backpressure rejection; the op never
472
+ * started, so it is safe to re-issue for ANY op kind (bounded).
473
+ * - a TIMEOUT / `agent_reconnecting` blip — re-issued ONCE, and ONLY for a
474
+ * read-only idempotent-safe op. A timed-out MUTATION is NEVER re-issued: it
475
+ * may already have executed on the machine (at-least-once), which would
476
+ * duplicate the write/command. FENCED is intentionally NOT retried here — the
477
+ * routing proxy retries it against a re-resolved backend under a fresh epoch.
478
+ * Each attempt carries a FRESH `requestId` (a retry is a distinct request).
479
+ */
480
+ private async call(
481
+ op: NonNullable<ControlRequest["op"]>,
482
+ timeoutMs = this.timeoutMs,
483
+ ): Promise<NonNullable<ControlResponse["result"]>> {
484
+ const opKind = op.$case;
485
+ const startedAt = Date.now();
486
+ let drainingRetries = 0;
487
+ let timeoutRetries = 0;
488
+ let neverSentRetries = 0;
489
+ for (;;) {
490
+ const req: ControlRequest = {
491
+ requestId: crypto.randomUUID(),
492
+ epoch: this.epoch,
493
+ op,
494
+ };
495
+ const res = await this.controlRpc.request(this.subject, req, { timeoutMs });
496
+ if (!res.error && res.result) {
497
+ const retries = drainingRetries + timeoutRetries + neverSentRetries;
498
+ this.emitOp({
499
+ op: opKind,
500
+ outcome: "ok",
501
+ healed: retries > 0,
502
+ retries,
503
+ durationMs: Date.now() - startedAt,
504
+ machineId: this.agentId,
505
+ // A healed op's class is whichever retry budget it recovered from.
506
+ ...(retries > 0
507
+ ? {
508
+ faultClass:
509
+ neverSentRetries > 0
510
+ ? "offline"
511
+ : drainingRetries > 0
512
+ ? "draining"
513
+ : "reconnecting",
514
+ }
515
+ : {}),
516
+ });
517
+ return res.result;
518
+ }
519
+ const error = res.error
520
+ ? agentErrorToControlError(res.error)
521
+ : agentErrorToControlError({
522
+ code: 7, // ERROR_CODE_PROTOCOL — an empty result is a protocol violation
523
+ message: "agent returned an empty control response",
524
+ retryable: false,
525
+ detail: {},
526
+ });
527
+ const decision = decideSelfhostedRetry({
528
+ opKind,
529
+ error,
530
+ drainingRetries,
531
+ timeoutRetries,
532
+ neverSentRetries,
533
+ jitter: this.retryClock.jitter(),
350
534
  });
535
+ if (decision.action === "fail") {
536
+ const retries = drainingRetries + timeoutRetries + neverSentRetries;
537
+ const encoded = Number(error.detail.encoded_bytes);
538
+ this.emitOp({
539
+ op: opKind,
540
+ outcome: "failed",
541
+ healed: false,
542
+ retries,
543
+ durationMs: Date.now() - startedAt,
544
+ code: error.code,
545
+ reason: error.reason,
546
+ neverSent: error.neverSent,
547
+ machineId: this.agentId,
548
+ faultClass: selfhostedFaultClass(error),
549
+ // Known only on a PAYLOAD_TOO_LARGE fault (the agent's encoded_bytes detail).
550
+ ...(Number.isFinite(encoded) ? { replyBytes: encoded } : {}),
551
+ });
552
+ // Fold the retry count into the DRAINING copy when we actually retried, so
553
+ // the surfaced message reads "…retried N times first…".
554
+ if (error.draining && drainingRetries > 0) {
555
+ throw drainingExhaustedError(error, drainingRetries);
556
+ }
557
+ throw error;
558
+ }
559
+ await this.retryClock.sleep(decision.delayMs);
560
+ // Advance the counter for the class that was retried (separate budgets).
561
+ if (error.neverSent) {
562
+ neverSentRetries += 1;
563
+ } else if (error.draining) {
564
+ drainingRetries += 1;
565
+ } else {
566
+ timeoutRetries += 1;
567
+ }
351
568
  }
352
- return res.result;
569
+ }
570
+
571
+ /** The clamped exec process deadline (ms): the exec-specific budget when threaded,
572
+ * else the control timeout. Shared by `exec()` (the wire deadline) and
573
+ * `execCommand()` (the timed-out hint's "N-second limit"), so the two never drift. */
574
+ private execDeadlineMs(): number {
575
+ // exec gets its OWN (much larger) deadline distinct from the short control
576
+ // timeout — a real command routinely outlives 30s. Falls back to `timeoutMs`
577
+ // when no exec deadline is threaded (unchanged for those callers).
578
+ return Math.max(
579
+ 1,
580
+ Math.min(Math.trunc(this.execTimeoutMs ?? this.timeoutMs), SELFHOSTED_MAX_EXEC_TIMEOUT_MS),
581
+ );
353
582
  }
354
583
 
355
584
  /** Channel-A `exec`: run a command on the machine and return its output. */
356
585
  async exec(args: SelfhostedExecArgs): Promise<SelfhostedExecResult> {
586
+ // Keep the process deadline inside the request/reply deadline. Previously the
587
+ // wire carried timeoutMs=0 (unbounded) while the caller stopped waiting after
588
+ // ~30s, leaving accepted work invisible and able to starve control liveness.
589
+ const executionTimeoutMs = this.execDeadlineMs();
357
590
  const execReq: ExecRequest = {
358
591
  // The agent does NOT shell-interpret unless `shell` — Channel-A passes a
359
592
  // single shell command string, so run it through the platform shell.
@@ -370,13 +603,116 @@ export class SelfhostedSession {
370
603
  // seed hook can find the tool surface. See SELFHOSTED_EXEC_ENV_ALLOWLIST.
371
604
  env: selfhostedExecEnv(this.state.environment),
372
605
  stdin: new Uint8Array(0),
373
- timeoutMs: 0,
606
+ timeoutMs: executionTimeoutMs,
374
607
  };
375
- const result = await this.call({ $case: "exec", exec: execReq });
608
+ if (this.opStreamClient) {
609
+ try {
610
+ return await this.execViaOpStream(this.opStreamClient, execReq, executionTimeoutMs);
611
+ } catch (error) {
612
+ // The transport had no live connection, or the runner refused the START
613
+ // itself (capability/downgrade race) — the op provably never ran, so
614
+ // the legacy monolithic exec (the permanent fallback wire form) is safe.
615
+ if (!(error instanceof OpStreamUnavailableError)) {
616
+ throw error;
617
+ }
618
+ }
619
+ }
620
+ return this.execLegacy(execReq, executionTimeoutMs);
621
+ }
622
+
623
+ /** The legacy monolithic exec request/reply — the permanent fallback wire
624
+ * form (and the only form for runners without `op_stream`). */
625
+ private async execLegacy(
626
+ execReq: ExecRequest,
627
+ executionTimeoutMs: number,
628
+ ): Promise<SelfhostedExecResult> {
629
+ const result = await this.call(
630
+ { $case: "exec", exec: execReq },
631
+ executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS,
632
+ );
376
633
  if (result.$case !== "exec") {
377
634
  throw new Error(`selfhosted exec: unexpected result ${result.$case}`);
378
635
  }
379
- return execResultToChannelA(result.exec);
636
+ return execResultToChannelA(result.exec, executionTimeoutMs);
637
+ }
638
+
639
+ /**
640
+ * The op-stream exec path: the SAME `ExecRequest`, streamed (see
641
+ * op-stream.ts). The durable op id comes from the tool-call correlation
642
+ * context (`{callId}:{ordinal}` — B1) when this exec runs inside an SDK tool
643
+ * invocation; a non-tool caller falls back to a random unique id (never
644
+ * collides, merely not stable across a turn re-dispatch). Emits the SAME
645
+ * per-op observation the legacy path does, with `replyBytes` filled from the
646
+ * reassembled stream (the field the framed transport was designed to own).
647
+ */
648
+ private async execViaOpStream(
649
+ client: OpStreamExecClient,
650
+ execReq: ExecRequest,
651
+ executionTimeoutMs: number,
652
+ ): Promise<SelfhostedExecResult> {
653
+ const startedAt = Date.now();
654
+ const opId = nextDurableOpId() ?? `anon_${crypto.randomUUID()}`;
655
+ try {
656
+ const outcome = await client.exec(
657
+ opId,
658
+ execReq,
659
+ executionTimeoutMs,
660
+ executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS,
661
+ );
662
+ const retries = outcome.heals + outcome.startRetries;
663
+ this.emitOp({
664
+ op: "exec",
665
+ outcome: "ok",
666
+ healed: retries > 0,
667
+ retries,
668
+ durationMs: Date.now() - startedAt,
669
+ machineId: this.agentId,
670
+ replyBytes: outcome.replyBytes,
671
+ // A healed op's class: attach heals are link blips (reconnecting);
672
+ // start retries without heals were admission backpressure (draining).
673
+ ...(retries > 0 ? { faultClass: outcome.heals > 0 ? "reconnecting" : "draining" } : {}),
674
+ });
675
+ return execResultToChannelA(outcome.response, executionTimeoutMs);
676
+ } catch (error) {
677
+ if (error instanceof OpStreamUnavailableError) {
678
+ // Not a fault: the caller falls back to the legacy exec, which emits
679
+ // its own observation.
680
+ throw error;
681
+ }
682
+ const controlError =
683
+ error instanceof SelfhostedControlError
684
+ ? error
685
+ : new SelfhostedControlError({
686
+ message: error instanceof Error ? error.message : String(error),
687
+ code: ErrorCode.ERROR_CODE_PROTOCOL,
688
+ reason: null,
689
+ retryable: false,
690
+ });
691
+ this.emitOp({
692
+ op: "exec",
693
+ outcome: "failed",
694
+ healed: false,
695
+ retries: 0,
696
+ durationMs: Date.now() - startedAt,
697
+ code: controlError.code,
698
+ reason: controlError.reason,
699
+ neverSent: controlError.neverSent,
700
+ machineId: this.agentId,
701
+ faultClass: selfhostedFaultClass(controlError),
702
+ });
703
+ throw controlError;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * The turn-end op-stream durability hook: persists each settled op's frontier
709
+ * to the journal, then final-acks it on the wire (licensing the runner to GC
710
+ * its retained frames). The WORKER calls this after the turn's results are
711
+ * durably recorded — never mid-turn (the durable-before-wire-ack ordering).
712
+ * A no-op for sessions without the op-stream transport.
713
+ */
714
+ async finalizeOpStreamOps(): Promise<void> {
715
+ await this.opStreamClient?.finalizeSettledOps();
380
716
  }
381
717
 
382
718
  // ── The agent-turn provided-session contract (over the SAME NATS primitives) ──
@@ -386,9 +722,22 @@ export class SelfhostedSession {
386
722
 
387
723
  /** SDK shell capability `execCommand`: run a command and return its stdout (the
388
724
  * `exec_command` tool). Selfhosted exec is non-interactive (no PTY) — `tty` is
389
- * ignored; `supportsPty()` is false so the SDK never offers a stdin session. */
725
+ * ignored; `supportsPty()` is false so the SDK never offers a stdin session.
726
+ *
727
+ * On a DEADLINE timeout the SDK path is stdout-only (the model sees `result.output`
728
+ * and nothing else), so the deadline hint that `exec()` puts on stderr would be
729
+ * invisible — a >deadline command would return `{"output":""}` and the model would
730
+ * conclude "no output" with no idea it was killed. So when the result timed out we
731
+ * surface the hint HERE, on stdout: the hint alone when stdout is empty, or appended
732
+ * after a newline when there is partial output (a timed-out result is already not
733
+ * cleanly parseable — silence is worse than an explanatory suffix). The structured
734
+ * `exec()` result is left untouched for the Channel-A parsers. */
390
735
  async execCommand(args: { cmd: string; workdir?: string; runAs?: string }): Promise<string> {
391
736
  const result = await this.exec({ cmd: args.cmd, workdir: args.workdir, runAs: args.runAs });
737
+ if (result.timedOut) {
738
+ const hint = execDeadlineHint(Math.round(this.execDeadlineMs() / 1000));
739
+ return result.output ? `${result.output}\n${hint}` : hint;
740
+ }
392
741
  return result.output;
393
742
  }
394
743
 
@@ -403,7 +752,10 @@ export class SelfhostedSession {
403
752
  * and wrap them in the tool-output image shape (magic-byte sniff + path fallback,
404
753
  * mirroring the SDK's `imageOutputFromBytes`). */
405
754
  async viewImage(args: { path: string; runAs?: string }): Promise<SelfhostedImageOutput> {
406
- const bytes = await this.readFile({ path: args.path, ...(args.runAs ? { runAs: args.runAs } : {}) });
755
+ const bytes = await this.readFile({
756
+ path: args.path,
757
+ ...(args.runAs ? { runAs: args.runAs } : {}),
758
+ });
407
759
  const mediaType = sniffImageMediaType(bytes, args.path);
408
760
  if (!mediaType) {
409
761
  throw new Error(`selfhosted view_image: unsupported image format for ${args.path}`);
@@ -418,7 +770,10 @@ export class SelfhostedSession {
418
770
  }
419
771
 
420
772
  /** SDK skills `listDir`: list a directory as `{name, path, type}[]`. */
421
- async listDir(args: { path: string; runAs?: string }): Promise<Array<{ name: string; path: string; type: "file" | "dir" | "other" }>> {
773
+ async listDir(args: {
774
+ path: string;
775
+ runAs?: string;
776
+ }): Promise<Array<{ name: string; path: string; type: "file" | "dir" | "other" }>> {
422
777
  const result = await this.listFiles({ path: args.path });
423
778
  return result.fsList.entries.map((entry) => ({
424
779
  name: entry.name,
@@ -468,7 +823,10 @@ export class SelfhostedSession {
468
823
  // EMPTY base) — prefixing workingDir here too would DOUBLE it (the cwd is
469
824
  // already workingDir). A non-virtual absolute path passes through and rm
470
825
  // uses it as-is; an empty workingDir is byte-identical to before.
471
- await this.exec({ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`, ...(runAs ? { runAs } : {}) });
826
+ await this.exec({
827
+ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`,
828
+ ...(runAs ? { runAs } : {}),
829
+ });
472
830
  };
473
831
  return {
474
832
  async createFile(operation) {
@@ -512,7 +870,12 @@ export class SelfhostedSession {
512
870
  }
513
871
 
514
872
  /** Write a file onto the machine (the fs surface the descriptor advertises). */
515
- async writeFile(args: { path: string; content: string | Uint8Array; createParents?: boolean; append?: boolean }): Promise<number> {
873
+ async writeFile(args: {
874
+ path: string;
875
+ content: string | Uint8Array;
876
+ createParents?: boolean;
877
+ append?: boolean;
878
+ }): Promise<number> {
516
879
  const content = typeof args.content === "string" ? encoder.encode(args.content) : args.content;
517
880
  const result = await this.call({
518
881
  $case: "fsWrite",
@@ -531,10 +894,16 @@ export class SelfhostedSession {
531
894
  }
532
895
 
533
896
  /** List a directory on the machine. */
534
- async listFiles(args: { path: string; recursive?: boolean }): Promise<NonNullable<ControlResponse["result"]> & { $case: "fsList" }> {
897
+ async listFiles(args: {
898
+ path: string;
899
+ recursive?: boolean;
900
+ }): Promise<NonNullable<ControlResponse["result"]> & { $case: "fsList" }> {
535
901
  const result = await this.call({
536
902
  $case: "fsList",
537
- fsList: { path: toMachinePath(args.path, this.workingDir), recursive: args.recursive ?? false },
903
+ fsList: {
904
+ path: toMachinePath(args.path, this.workingDir),
905
+ recursive: args.recursive ?? false,
906
+ },
538
907
  });
539
908
  if (result.$case !== "fsList") {
540
909
  throw new Error(`selfhosted listFiles: unexpected result ${result.$case}`);
@@ -544,7 +913,10 @@ export class SelfhostedSession {
544
913
 
545
914
  /** Stat a path on the machine. */
546
915
  async statFile(args: { path: string }): Promise<{ exists: boolean }> {
547
- const result = await this.call({ $case: "fsStat", fsStat: { path: toMachinePath(args.path, this.workingDir) } });
916
+ const result = await this.call({
917
+ $case: "fsStat",
918
+ fsStat: { path: toMachinePath(args.path, this.workingDir) },
919
+ });
548
920
  if (result.$case !== "fsStat") {
549
921
  throw new Error(`selfhosted statFile: unexpected result ${result.$case}`);
550
922
  }
@@ -648,7 +1020,9 @@ export class SelfhostedSession {
648
1020
  desktopEnsure: { width: 0, height: 0 },
649
1021
  });
650
1022
  if (result.$case !== "desktopEnsure") {
651
- throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
1023
+ throw new Error(
1024
+ `selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`,
1025
+ );
652
1026
  }
653
1027
  channel = result.desktopEnsure.channel;
654
1028
  } else {
@@ -660,10 +1034,19 @@ export class SelfhostedSession {
660
1034
  // Open the terminal in the session workingDir (default "" ⇒ the agent's
661
1035
  // workspace_root, byte-identical to before). A relative workingDir resolves
662
1036
  // under workspace_root; an absolute one is used as-is by the agent.
663
- ptyOpen: { command: [], cwd: this.workingDir, env: {}, cols: 0, rows: 0, term: "xterm-256color" },
1037
+ ptyOpen: {
1038
+ command: [],
1039
+ cwd: this.workingDir,
1040
+ env: {},
1041
+ cols: 0,
1042
+ rows: 0,
1043
+ term: "xterm-256color",
1044
+ },
664
1045
  });
665
1046
  if (result.$case !== "ptyOpen") {
666
- throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
1047
+ throw new Error(
1048
+ `selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`,
1049
+ );
667
1050
  }
668
1051
  channel = result.ptyOpen.channel;
669
1052
  }
@@ -716,8 +1099,11 @@ export class SelfhostedSandboxClient {
716
1099
  private readonly defaultAgentId: string | undefined;
717
1100
  private readonly epoch: number | undefined;
718
1101
  private readonly timeoutMs: number | undefined;
1102
+ private readonly execTimeoutMs: number | undefined;
719
1103
  private readonly environment: Record<string, string> | undefined;
720
1104
  private readonly workingDir: string | undefined;
1105
+ private readonly onOp: SelfhostedOpObserver | undefined;
1106
+ private readonly opStream: SelfhostedOpStreamDeps | undefined;
721
1107
  private controlRpcMemo: ControlRpc | undefined;
722
1108
 
723
1109
  constructor(opts: {
@@ -729,7 +1115,11 @@ export class SelfhostedSandboxClient {
729
1115
  * resume path supplies it via deserializeSessionState. */
730
1116
  agentId?: string;
731
1117
  epoch?: number;
1118
+ /** The control-op timeout threaded into every bound session. */
732
1119
  timeoutMs?: number;
1120
+ /** The exec process deadline threaded into every bound session (distinct from
1121
+ * `timeoutMs`; falls back to it when unset). See SelfhostedSessionDeps.execTimeoutMs. */
1122
+ execTimeoutMs?: number;
733
1123
  /** The run's declared sandbox environment, threaded into every bound session's
734
1124
  * `state.manifest.environment` so the SDK's per-turn manifest-env delta is
735
1125
  * empty (validateNoEnvironmentDelta). See SelfhostedSessionDeps.environment.
@@ -739,6 +1129,11 @@ export class SelfhostedSandboxClient {
739
1129
  * cwd base; see SelfhostedSessionDeps.workingDir). Omitted/empty ⇒ the default
740
1130
  * workspace_root behavior. */
741
1131
  workingDir?: string;
1132
+ /** The per-op observer threaded into every bound session (out-of-band telemetry). */
1133
+ onOp?: SelfhostedOpObserver;
1134
+ /** The op-stream exec transport threaded into every bound session (present
1135
+ * = enabled; see SelfhostedOpStreamDeps). */
1136
+ opStream?: SelfhostedOpStreamDeps;
742
1137
  }) {
743
1138
  this.workspaceId = opts.workspaceId;
744
1139
  this.relay = opts.relay;
@@ -746,8 +1141,11 @@ export class SelfhostedSandboxClient {
746
1141
  this.defaultAgentId = opts.agentId;
747
1142
  this.epoch = opts.epoch;
748
1143
  this.timeoutMs = opts.timeoutMs;
1144
+ this.execTimeoutMs = opts.execTimeoutMs;
749
1145
  this.environment = opts.environment;
750
1146
  this.workingDir = opts.workingDir;
1147
+ this.onOp = opts.onOp;
1148
+ this.opStream = opts.opStream;
751
1149
  }
752
1150
 
753
1151
  private controlRpc(): ControlRpc {
@@ -765,8 +1163,11 @@ export class SelfhostedSandboxClient {
765
1163
  relay: this.relay,
766
1164
  ...(this.epoch !== undefined ? { epoch: this.epoch } : {}),
767
1165
  ...(this.timeoutMs !== undefined ? { timeoutMs: this.timeoutMs } : {}),
1166
+ ...(this.execTimeoutMs !== undefined ? { execTimeoutMs: this.execTimeoutMs } : {}),
768
1167
  ...(this.environment !== undefined ? { environment: this.environment } : {}),
769
1168
  ...(this.workingDir !== undefined ? { workingDir: this.workingDir } : {}),
1169
+ ...(this.onOp !== undefined ? { onOp: this.onOp } : {}),
1170
+ ...(this.opStream !== undefined ? { opStream: this.opStream } : {}),
770
1171
  });
771
1172
  }
772
1173
 
@@ -777,13 +1178,18 @@ export class SelfhostedSandboxClient {
777
1178
  }
778
1179
 
779
1180
  /** Resume = re-address the subject. Identical to create — no provider state. */
780
- async resume(state: SelfhostedSessionState | Record<string, unknown>, _options?: unknown): Promise<SelfhostedSession> {
1181
+ async resume(
1182
+ state: SelfhostedSessionState | Record<string, unknown>,
1183
+ _options?: unknown,
1184
+ ): Promise<SelfhostedSession> {
781
1185
  const agentId = readAgentId(state) ?? this.requireAgentId();
782
1186
  return this.bind(agentId);
783
1187
  }
784
1188
 
785
1189
  /** Serialize a live session's state → `{agentId}` ONLY. */
786
- async serializeSessionState(state: SelfhostedSessionState | { agentId?: string } | unknown): Promise<SelfhostedSessionState> {
1190
+ async serializeSessionState(
1191
+ state: SelfhostedSessionState | { agentId?: string } | unknown,
1192
+ ): Promise<SelfhostedSessionState> {
787
1193
  const agentId = readAgentId(state) ?? this.requireAgentId();
788
1194
  return { agentId };
789
1195
  }
@@ -802,7 +1208,9 @@ export class SelfhostedSandboxClient {
802
1208
 
803
1209
  private requireAgentId(): string {
804
1210
  if (!this.defaultAgentId) {
805
- throw new Error("selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)");
1211
+ throw new Error(
1212
+ "selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)",
1213
+ );
806
1214
  }
807
1215
  return this.defaultAgentId;
808
1216
  }
@@ -832,8 +1240,17 @@ export interface SelfhostedSessionBuild {
832
1240
  environment?: Record<string, string>;
833
1241
  /** The session working directory (the path/cwd base). Null/absent ⇒ workspace_root. */
834
1242
  workingDir?: string | null;
835
- /** Override the control-op timeout (tests). */
1243
+ /** The control-op timeout (ping/fs/desktop/pty). Absent ⇒ the 30s default. */
836
1244
  timeoutMs?: number;
1245
+ /** The exec process deadline, distinct from `timeoutMs`. Absent ⇒ falls back to
1246
+ * `timeoutMs` (a real turn threads the long exec budget here). */
1247
+ execTimeoutMs?: number;
1248
+ /** The per-op observer (out-of-band telemetry). Absent ⇒ no-op. */
1249
+ onOp?: SelfhostedOpObserver;
1250
+ /** The op-stream exec transport (present = enabled for the session; the
1251
+ * worker injects it only when the runner advertised the capability AND the
1252
+ * server flag is on). Absent ⇒ the legacy exec, byte-identical to before. */
1253
+ opStream?: SelfhostedOpStreamDeps;
837
1254
  }
838
1255
 
839
1256
  /**
@@ -861,8 +1278,11 @@ export async function buildSelfhostedBackendSession(
861
1278
  agentId: deps.agentId,
862
1279
  epoch: deps.epoch,
863
1280
  ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
1281
+ ...(deps.execTimeoutMs !== undefined ? { execTimeoutMs: deps.execTimeoutMs } : {}),
1282
+ ...(deps.onOp !== undefined ? { onOp: deps.onOp } : {}),
864
1283
  ...(deps.environment !== undefined ? { environment: deps.environment } : {}),
865
1284
  ...(deps.workingDir ? { workingDir: deps.workingDir } : {}),
1285
+ ...(deps.opStream !== undefined ? { opStream: deps.opStream } : {}),
866
1286
  });
867
1287
  const session = await client.resume({ agentId: deps.agentId });
868
1288
  return { client, session };
@@ -870,8 +1290,9 @@ export async function buildSelfhostedBackendSession(
870
1290
 
871
1291
  function readAgentId(state: unknown): string | undefined {
872
1292
  if (state && typeof state === "object") {
873
- const candidate = (state as { agentId?: unknown }).agentId
874
- ?? ((state as { providerState?: { agentId?: unknown } }).providerState?.agentId);
1293
+ const candidate =
1294
+ (state as { agentId?: unknown }).agentId ??
1295
+ (state as { providerState?: { agentId?: unknown } }).providerState?.agentId;
875
1296
  if (typeof candidate === "string" && candidate.length > 0) {
876
1297
  return candidate;
877
1298
  }
@@ -879,14 +1300,23 @@ function readAgentId(state: unknown): string | undefined {
879
1300
  return undefined;
880
1301
  }
881
1302
 
882
- function execResultToChannelA(res: ExecResponse): SelfhostedExecResult {
1303
+ function execResultToChannelA(res: ExecResponse, execDeadlineMs: number): SelfhostedExecResult {
883
1304
  const stdout = decoder.decode(res.stdout);
884
- const stderr = decoder.decode(res.stderr);
1305
+ let stderr = decoder.decode(res.stderr);
1306
+ if (res.timedOut) {
1307
+ // The agent killed the child at the exec deadline. Surface an actionable hint
1308
+ // on STDERR ONLY — stdout is left byte-exact because the structural Channel-A
1309
+ // surface parses command stdout (find/stat/git listings); a hint injected there
1310
+ // would corrupt those parsers.
1311
+ const hint = execDeadlineHint(Math.round(execDeadlineMs / 1000));
1312
+ stderr = stderr ? `${stderr}\n${hint}` : hint;
1313
+ }
885
1314
  return {
886
1315
  output: stdout,
887
1316
  stdout,
888
1317
  stderr,
889
1318
  exitCode: res.exitCode,
1319
+ timedOut: res.timedOut,
890
1320
  };
891
1321
  }
892
1322
 
@@ -904,24 +1334,37 @@ function shellQuote(value: string): string {
904
1334
  * mirroring @openai/agents-core's `sniffImageMediaType` so `viewImage` returns the
905
1335
  * SAME media types the SDK would. Returns undefined for an unrecognized format. */
906
1336
  function sniffImageMediaType(bytes: Uint8Array, path: string): string | undefined {
907
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
1337
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
1338
+ return "image/png";
908
1339
  if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
909
- if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return "image/gif";
1340
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
1341
+ return "image/gif";
910
1342
  if (
911
- bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
912
- bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
913
- ) return "image/webp";
1343
+ bytes[0] === 0x52 &&
1344
+ bytes[1] === 0x49 &&
1345
+ bytes[2] === 0x46 &&
1346
+ bytes[3] === 0x46 &&
1347
+ bytes[8] === 0x57 &&
1348
+ bytes[9] === 0x45 &&
1349
+ bytes[10] === 0x42 &&
1350
+ bytes[11] === 0x50
1351
+ )
1352
+ return "image/webp";
914
1353
  if (bytes[0] === 0x42 && bytes[1] === 0x4d) return "image/bmp";
915
1354
  if (
916
1355
  (bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
917
1356
  (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
918
- ) return "image/tiff";
1357
+ )
1358
+ return "image/tiff";
919
1359
  if (looksLikeSvg(bytes)) return "image/svg+xml";
920
1360
  return mediaTypeFromPath(path);
921
1361
  }
922
1362
 
923
1363
  function looksLikeSvg(bytes: Uint8Array): boolean {
924
- const prefix = decoder.decode(bytes.subarray(0, Math.min(bytes.byteLength, 512))).trimStart().toLowerCase();
1364
+ const prefix = decoder
1365
+ .decode(bytes.subarray(0, Math.min(bytes.byteLength, 512)))
1366
+ .trimStart()
1367
+ .toLowerCase();
925
1368
  return prefix.startsWith("<svg") || /^<\?xml[\s\S]*<svg/u.test(prefix);
926
1369
  }
927
1370