@runuai/host 0.9.86 → 0.9.88

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.
@@ -143,6 +143,8 @@ export class MachineDurableProcess implements LineTransport {
143
143
  #closed = false;
144
144
  #detached = false;
145
145
  #sawSpawnMeta = false;
146
+ /** Completed-and-empty direct outbox probes past the spawn grace. */
147
+ #noMetaStrikes = 0;
146
148
  readonly #startedAt = Date.now();
147
149
  #tail: TaskEnvironmentProcess | null = null;
148
150
  #tailGeneration = 0;
@@ -322,9 +324,40 @@ export class MachineDurableProcess implements LineTransport {
322
324
  this.#probing = true;
323
325
  try {
324
326
  if (!this.#sawSpawnMeta) {
325
- if (Date.now() - this.#startedAt > HEARTBEAT_STALE_MS) {
326
- this.#finish(null, "no spawn meta within grace");
327
+ if (Date.now() - this.#startedAt <= HEARTBEAT_STALE_MS) return;
328
+ // Grace elapsed on OUR clock — but the spawn meta arrives via the
329
+ // tail, an ssh child of THIS host, and both starve together when the
330
+ // host CPU is pegged (live 2026-08-29: a 12-task launch burst pushed
331
+ // load to 16, tails lagged, and healthy runners were declared dead —
332
+ // then respawned, ORPHANING the originals). Before any verdict, ask
333
+ // the machine directly: the runner writes its spawn meta as the
334
+ // outbox's first line.
335
+ let sawOnMachine = false;
336
+ try {
337
+ const probe = await execStep(this.#environment, [
338
+ "/bin/sh",
339
+ "-c",
340
+ 'head -n1 "$1/outbox.jsonl" 2>/dev/null | grep -c __uai',
341
+ "probe",
342
+ this.#sessionDir,
343
+ ]);
344
+ sawOnMachine =
345
+ probe.exitCode === 0 && Number(probe.stdout.trim()) > 0;
346
+ } catch {
347
+ // Unreachable machine: counts as a strike below.
348
+ }
349
+ if (this.#closed || this.#detached) return;
350
+ if (sawOnMachine) {
351
+ this.#sawSpawnMeta = true;
352
+ return;
327
353
  }
354
+ // Two COMPLETED empty probes past the grace — not elapsed time — are
355
+ // the death sentence; probes that never ran prove nothing.
356
+ this.#noMetaStrikes += 1;
357
+ if (this.#noMetaStrikes < 2) return;
358
+ await this.#killRemoteRunner();
359
+ if (this.#closed || this.#detached) return;
360
+ this.#finish(null, "no spawn meta within grace");
328
361
  return;
329
362
  }
330
363
  const fresh = await machineHeartbeatFresh(
@@ -333,12 +366,37 @@ export class MachineDurableProcess implements LineTransport {
333
366
  HEARTBEAT_STALE_MS,
334
367
  );
335
368
  if (this.#closed || this.#detached) return;
336
- if (!fresh) this.#finish(null, "heartbeat stale");
369
+ if (!fresh) {
370
+ // A stale-but-alive runner would be doubled by the respawn; the
371
+ // single-runner-per-session invariant is enforced here, not hoped for.
372
+ await this.#killRemoteRunner();
373
+ if (this.#closed || this.#detached) return;
374
+ this.#finish(null, "heartbeat stale");
375
+ }
337
376
  } finally {
338
377
  this.#probing = false;
339
378
  }
340
379
  }
341
380
 
381
+ /** Best-effort remote kill of this session's runner before declaring it
382
+ * dead: a wrong verdict must cost a restart, never an orphaned agent.
383
+ * `runner[.]mjs` so the kill-shell's own argv (which carries this script
384
+ * text) can never self-match — the pgrep/pkill self-match class. */
385
+ async #killRemoteRunner(): Promise<void> {
386
+ try {
387
+ await execStep(this.#environment, [
388
+ "/bin/sh",
389
+ "-c",
390
+ 'pkill -f "runner[.]mjs $1" >/dev/null 2>&1; true',
391
+ "kill",
392
+ this.#sessionDir,
393
+ ]);
394
+ } catch {
395
+ // The finish verdict stands either way; the orphan sweep in recovery
396
+ // remains the backstop for an unreachable machine.
397
+ }
398
+ }
399
+
342
400
  #finish(code: number | null, reason = "unspecified"): void {
343
401
  if (this.#closed) return;
344
402
  // Session deaths are load-bearing and were undiagnosable without this
@@ -1549,6 +1549,7 @@ export class Orchestrator {
1549
1549
  // can consume/reorder prompt state while the earlier error still owns it.
1550
1550
  let eventTail: Promise<void> = Promise.resolve();
1551
1551
  session.onEvent((event) => {
1552
+ this.markChannelActivity(channel.taskId);
1552
1553
  // Recycle/teardown deletes the old session before calling close().
1553
1554
  // Adapters deliberately emit a final exit event; without this identity
1554
1555
  // guard it can delete a replacement or consume its respawn budget.
@@ -3001,6 +3002,20 @@ export class Orchestrator {
3001
3002
  * reconciles it to `stopped` (worktree present) and it can be resumed. Since
3002
3003
  * `stopped` is not an ACTIVE status, host recovery won't restart it.
3003
3004
  */
3005
+ /** Last observed sign of life per channel: any session event (agent
3006
+ * output, turn boundaries) or an inbound delivery. The idle-stop veto
3007
+ * (ADR-125) reads this — the cloud decides idleness from what it can see,
3008
+ * but only the host can see a turn that is mid-flight RIGHT NOW. */
3009
+ readonly #channelActivity = new Map<string, number>();
3010
+
3011
+ markChannelActivity(taskId: string): void {
3012
+ this.#channelActivity.set(taskId, Date.now());
3013
+ }
3014
+
3015
+ lastChannelActivity(taskId: string): number | null {
3016
+ return this.#channelActivity.get(taskId) ?? null;
3017
+ }
3018
+
3004
3019
  async stopTask(taskId: string): Promise<{ ok: boolean; error?: string }> {
3005
3020
  return this.runTaskLifecycle(taskId, () =>
3006
3021
  this.stopTaskWithinLifecycle(taskId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.86",
3
+ "version": "0.9.88",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -976,18 +976,13 @@ export const hostCommands: HostCommands = {
976
976
  }
977
977
  const result = await wrapAgent(ctx, "taskDown", async () => {
978
978
  if (environment === null) return agent.taskDown(input);
979
- // ADR-121: an ordinary stop of a machine task must keep the machine's
980
- // disk — the workspace lives THERE, not on a host worktree the way
981
- // compose teardown preserves it. Stop is the resumable gesture
982
- // (provision restarts a stopped machine); only orphan GC terminates.
983
- if (
984
- !orphanGc &&
985
- environment.descriptor.locator.provider === "machine"
986
- ) {
987
- stopMachineGatewayTunnel(input.taskId);
988
- await environment.stop();
989
- return taskDownResultForInput(input, { status: "stopped" });
990
- }
979
+ // taskDown is TERMINAL — its only cloud callers are task close and
980
+ // failed-occurrence/orphan cleanup; pause rides the separate stopTask
981
+ // command. Machines therefore TERMINATE here: a closed task can never
982
+ // be resumed, so a stopped-but-kept machine is a per-month disk leak
983
+ // with no consumer (owner, 2026-08-29 — 16 parked disks after one
984
+ // evening of testing). The task branch lives on the remote; the
985
+ // machine's disk is not a preservation surface once the task ends.
991
986
  if (environment.descriptor.locator.provider === "machine") {
992
987
  stopMachineGatewayTunnel(input.taskId);
993
988
  }
@@ -1034,6 +1029,28 @@ export const hostCommands: HostCommands = {
1034
1029
  });
1035
1030
  },
1036
1031
 
1032
+ async taskStop(ctx, taskId, input) {
1033
+ logCommand(ctx, "taskStop", taskId, input.reason);
1034
+ // ADR-125: idle stops are the cloud's judgment from what it can see;
1035
+ // only the host can see a turn that is mid-flight RIGHT NOW. Manual
1036
+ // pause carries no veto — a human's explicit gesture wins.
1037
+ if (input.reason === "idle") {
1038
+ const activeAt = getOrchestrator().lastChannelActivity(taskId);
1039
+ if (activeAt !== null && Date.now() - activeAt < IDLE_STOP_VETO_MS) {
1040
+ return { ok: true, value: { vetoed: "active" as const } };
1041
+ }
1042
+ }
1043
+ const result = await getOrchestrator().stopTask(taskId);
1044
+ if (!result.ok) {
1045
+ return {
1046
+ ok: false,
1047
+ code: HostErrorCode.Internal,
1048
+ message: result.error ?? "stop failed",
1049
+ retryable: true,
1050
+ };
1051
+ }
1052
+ return { ok: true, value: {} };
1053
+ },
1037
1054
  async taskStatus(ctx, taskId) {
1038
1055
  logCommand(ctx, "taskStatus", taskId);
1039
1056
  const runtimeFailure = runtimeUnavailable();
@@ -1117,6 +1134,9 @@ export const hostCommands: HostCommands = {
1117
1134
  const runtimeFailure = runtimeUnavailable();
1118
1135
  if (runtimeFailure) return runtimeFailure;
1119
1136
  try {
1137
+ // An inbound turn is activity: the idle veto must cover the gap between
1138
+ // this delivery landing and the agent's first output event.
1139
+ getOrchestrator().markChannelActivity(taskId);
1120
1140
  const result = await getOrchestrator().deliver(taskId, agentId, text);
1121
1141
  if (!result.ok) {
1122
1142
  return {
@@ -1537,6 +1557,10 @@ function teardownRuntimeUnavailable(): HostCommandResult<never> | null {
1537
1557
  };
1538
1558
  }
1539
1559
 
1560
+ /** ADR-125: an idle stop is vetoed when the channel showed any activity
1561
+ * this recently — a delivery landing or any session event. */
1562
+ const IDLE_STOP_VETO_MS = 120_000;
1563
+
1540
1564
  const RUNTIME_TASK_ADMISSION_WAIT_MS = 3_000;
1541
1565
 
1542
1566
  /**
package/src/main.ts CHANGED
@@ -173,6 +173,7 @@ import {
173
173
  HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
174
174
  HOST_CONFIG_PROTOCOL_FEATURE,
175
175
  HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
176
+ HOST_TASK_STOP_PROTOCOL_FEATURE,
176
177
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
177
178
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
178
179
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
@@ -584,6 +585,7 @@ function buildCapabilities(): HostCapabilities {
584
585
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
585
586
  HOST_CONFIG_PROTOCOL_FEATURE,
586
587
  HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
588
+ HOST_TASK_STOP_PROTOCOL_FEATURE,
587
589
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
588
590
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
589
591
  // The echo adapter cannot execute the in-task CLI. Advertising typed
@@ -2279,6 +2281,12 @@ async function dispatchCommand(
2279
2281
  const downInput = expectTaskDownInput(args, 0);
2280
2282
  return hostCommands.taskDown(ctx, downInput);
2281
2283
  }
2284
+ case "taskStop":
2285
+ return hostCommands.taskStop(
2286
+ ctx,
2287
+ typeof args[0] === "string" ? args[0] : "",
2288
+ (args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
2289
+ );
2282
2290
  case "taskStatus":
2283
2291
  return hostCommands.taskStatus(ctx, expectString(args, 0));
2284
2292
  case "channelEnsure":
package/src/protocol.ts CHANGED
@@ -89,6 +89,10 @@ export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
89
89
  /** Remote update/restart over the bridge (owner-gated cloud routes). */
90
90
  export const HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE =
91
91
  "host-remote-lifecycle-v1";
92
+ /** ADR-125: cloud-initiated resumable task stop (idle auto-stop + manual
93
+ * pause). Idle stops carry reason "idle" and the host may VETO them when a
94
+ * turn is active right now — the veto is an ok result, not an error. */
95
+ export const HOST_TASK_STOP_PROTOCOL_FEATURE = "host-task-stop-v1";
92
96
  /** ADR-116: labeled engine accounts on the cloud host page — account list in
93
97
  * capabilities, account-targeted login (`label` on engine.login.start), and
94
98
  * the engineAccountRemove command. */
@@ -1111,6 +1115,19 @@ export interface TaskLaunchInput {
1111
1115
  ownerName?: string;
1112
1116
  }
1113
1117
 
1118
+ /** ADR-125 resumable stop. "idle" = the cloud's sweep decided nobody is
1119
+ * using the task; the host may veto if a turn is live. "manual" = a human
1120
+ * pressed pause; no veto. */
1121
+ export interface TaskStopInput {
1122
+ reason: "idle" | "manual";
1123
+ }
1124
+
1125
+ export interface TaskStopResult {
1126
+ /** Present when the host declined an idle stop because the channel showed
1127
+ * activity within its veto window. The cloud retries on a later sweep. */
1128
+ vetoed?: "active";
1129
+ }
1130
+
1114
1131
  export interface TaskDownInput {
1115
1132
  taskId: string;
1116
1133
  task: TaskCommandTask;
@@ -1296,6 +1313,11 @@ export interface HostCommands {
1296
1313
  ctx: CommandContext,
1297
1314
  taskId: string,
1298
1315
  ): Promise<HostCommandResult<TaskStatusResult>>;
1316
+ taskStop(
1317
+ ctx: CommandContext,
1318
+ taskId: string,
1319
+ input: TaskStopInput,
1320
+ ): Promise<HostCommandResult<TaskStopResult>>;
1299
1321
  channelEnsure(
1300
1322
  ctx: CommandContext,
1301
1323
  input: ChannelEnsureInput,