@runuai/host 0.9.158 → 0.9.160

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.
@@ -9374,10 +9374,54 @@ async function recoverPersistedTaskEnvironment(
9374
9374
  return result.outcome === "recovered";
9375
9375
  } catch (error) {
9376
9376
  if (tunnelReadinessFailed) return false;
9377
+ if (error instanceof TaskRecoveryRowConflictError && isCurrent()) {
9378
+ return quarantineConflictingTask(task, environment, error, isCurrent, lifecycle);
9379
+ }
9377
9380
  throw error;
9378
9381
  }
9379
9382
  }
9380
9383
 
9384
+ /** A durable row the provider can never reconcile with its locator. Retrying
9385
+ * cannot change the verdict, so the sweep must not treat it as an unknown
9386
+ * runtime state: that kept the whole runtime in checking, retrying every
9387
+ * ~50 s, while one task's row was wrong (2026-10-19). */
9388
+ export class TaskRecoveryRowConflictError extends Error {
9389
+ override name = "TaskRecoveryRowConflictError";
9390
+ }
9391
+
9392
+ /** Settle one task whose row conflicts with its locator without holding the
9393
+ * runtime: drain its consumers, stop the locator's environment, and mark the
9394
+ * row `error` so the task reads as failed rather than running. Only a stop
9395
+ * that cannot be proven throws on, keeping the sweep unresolved, because
9396
+ * then the runtime state really is unknown. Resume or Destroy clears it. */
9397
+ async function quarantineConflictingTask(
9398
+ task: typeof schema.hostTasks.$inferSelect,
9399
+ environment: TaskEnvironmentHandle<unknown>,
9400
+ conflict: TaskRecoveryRowConflictError,
9401
+ isCurrent: () => boolean,
9402
+ lifecycle?: Orchestrator,
9403
+ ): Promise<boolean> {
9404
+ await lifecycle?.quarantineChannelForRecovery(task.taskId);
9405
+ if (!isCurrent()) return true;
9406
+ await environment.stop();
9407
+ const after = await environment.status();
9408
+ if (!isCurrent()) return true;
9409
+ if (after.state === "running" || after.state === "unknown") {
9410
+ throw new Error(
9411
+ `${conflict.message}, and its environment could not be proven stopped`,
9412
+ );
9413
+ }
9414
+ db_setStatus(task.taskId, "error", {
9415
+ codeServerPort: null,
9416
+ previewPorts: "[]",
9417
+ });
9418
+ clearTaskGatewayAcl(task.taskId);
9419
+ console.error(
9420
+ `[orchestrator] recovery: ${task.taskId} quarantined: ${conflict.message}; its environment was stopped and the task marked error so the runtime can go operational`,
9421
+ );
9422
+ return true;
9423
+ }
9424
+
9381
9425
  interface RecoveryTaskConsumers {
9382
9426
  quarantineChannelForRecovery(taskId: string): Promise<void>;
9383
9427
  prepareChannelForRecovery(taskId: string): Promise<void>;
@@ -10020,23 +10064,28 @@ async function recoverDockerTaskEnvironment(
10020
10064
  if (!task || !task.statusMirror || !isActive(task.statusMirror)) {
10021
10065
  return { outcome: "recovered" };
10022
10066
  }
10023
- const isPreparedRow =
10024
- (task.statusMirror === "starting" || task.statusMirror === "error") &&
10025
- task.composeProject === null &&
10026
- task.worktreePath === null;
10067
+ // A starting/error row may hold only part of its legacy mirrors: a crash
10068
+ // before task-up finished leaves both NULL, and a start that failed after
10069
+ // an earlier recovery settled the row half-written leaves compose_project
10070
+ // set with worktree_path NULL (live 2026-10-19: one such row held the
10071
+ // whole runtime in checking for 2.5 h). Each mirror present must still
10072
+ // equal the locator; a missing one is adopted from it.
10073
+ const mayAdoptLocator =
10074
+ task.statusMirror === "starting" || task.statusMirror === "error";
10075
+ const mirrorAgrees = (mirror: string | null, expected: string): boolean =>
10076
+ mirror === expected || (mayAdoptLocator && mirror === null);
10027
10077
  if (
10028
- !isPreparedRow &&
10029
- (task.composeProject !== locator.composeProject ||
10030
- task.worktreePath !== locator.hostWorktreePath)
10078
+ !mirrorAgrees(task.composeProject, locator.composeProject) ||
10079
+ !mirrorAgrees(task.worktreePath, locator.hostWorktreePath)
10031
10080
  ) {
10032
- throw new Error(
10081
+ throw new TaskRecoveryRowConflictError(
10033
10082
  `task ${descriptor.taskId} environment locator no longer matches its durable row`,
10034
10083
  );
10035
10084
  }
10036
10085
  // Provider-owned provisional metadata is authoritative after a crash. Feed
10037
10086
  // the already-validated deterministic values to the Docker-private repair
10038
10087
  // helpers without teaching the generic sweep how to derive them.
10039
- const recoveryTask = isPreparedRow
10088
+ const recoveryTask = mayAdoptLocator
10040
10089
  ? {
10041
10090
  ...task,
10042
10091
  composeProject: locator.composeProject,
@@ -10147,7 +10196,13 @@ async function recoverOneDockerTask(
10147
10196
  db_setStatus(task.taskId, next, {
10148
10197
  codeServerPort: null,
10149
10198
  previewPorts: "[]",
10150
- composeProject: next === "error" ? null : composeProject,
10199
+ // A prepared row reaches here with both mirrors adopted from its
10200
+ // locator: write them back as a pair. Writing compose_project alone
10201
+ // left a stopped row whose next failed Resume became the half-written
10202
+ // error row of 2026-10-19.
10203
+ ...(next === "error"
10204
+ ? { composeProject: null }
10205
+ : { composeProject, worktreePath: task.worktreePath }),
10151
10206
  });
10152
10207
  console.log(
10153
10208
  `[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
@@ -243,6 +243,77 @@ export function taskMachinePeerProcessIds(): number[] {
243
243
  );
244
244
  }
245
245
 
246
+ /** A process's parent from /proc; null once the process is gone. */
247
+ export function processParentId(pid: number): number | null {
248
+ let stat: string;
249
+ try {
250
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
251
+ } catch (error) {
252
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
253
+ throw error;
254
+ }
255
+ // `pid (comm) state ppid ...`: comm may itself hold spaces or parens.
256
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
257
+ if (!Number.isSafeInteger(ppid) || ppid < 0) {
258
+ throw new Error(`process ${pid} has an unreadable parent`);
259
+ }
260
+ return ppid;
261
+ }
262
+
263
+ /**
264
+ * Every process in the sidecar service cgroup that this runner did not start.
265
+ * The fresh-incarnation proof asks this, not `taskMachinePeerProcessIds`: the
266
+ * runner's own helpers share its cgroup from boot, tsx's esbuild service above
267
+ * all (a long-lived child of every runner that compiled a module; live
268
+ * 2026-10-20, every marked machine stayed pending "not quiescent" on it), and
269
+ * a pending runner admits no task, so nothing it started is a task process.
270
+ * What an earlier runner process left behind (KillMode=process) was reparented
271
+ * when that process ended: it is never this runner's descendant, so it counts.
272
+ */
273
+ export function taskMachineForeignProcessIds(): number[] {
274
+ return foreignProcessIdsWith({
275
+ list: taskMachinePeerProcessIds,
276
+ parent: processParentId,
277
+ self: process.pid,
278
+ });
279
+ }
280
+
281
+ export interface ForeignProcessDeps {
282
+ list(): readonly number[];
283
+ parent(pid: number): number | null;
284
+ self: number;
285
+ }
286
+
287
+ /** Exported seam keeps the ancestry walk executable in tests. */
288
+ export function foreignProcessIdsWith(deps: ForeignProcessDeps): number[] {
289
+ const foreign: number[] = [];
290
+ for (const pid of deps.list()) {
291
+ if (pid === deps.self) continue;
292
+ const seen = new Set<number>();
293
+ let current: number | null = pid;
294
+ let ancestry: "ours" | "foreign" | "gone" = "foreign";
295
+ for (;;) {
296
+ const parent = deps.parent(current);
297
+ if (parent === null) {
298
+ // The process itself is gone: nothing to count. An ancestor gone
299
+ // mid-walk leaves an orphan the next look sees reparented: fail
300
+ // closed now, as that look would.
301
+ if (current === pid) ancestry = "gone";
302
+ break;
303
+ }
304
+ if (parent === deps.self) {
305
+ ancestry = "ours";
306
+ break;
307
+ }
308
+ if (parent <= 1 || seen.has(parent)) break;
309
+ seen.add(parent);
310
+ current = parent;
311
+ }
312
+ if (ancestry === "foreign") foreign.push(pid);
313
+ }
314
+ return foreign;
315
+ }
316
+
246
317
  /** Kill everything in the sidecar service cgroup except this root runner.
247
318
  * The task's agents, editor, terminals and sudo children all descend from the
248
319
  * service; KillMode=process is precisely why they otherwise survive. */
@@ -26,6 +26,8 @@ export interface TaskMachineCredentialsActivationDeps {
26
26
  filesReady(): boolean;
27
27
  retirementReady(): boolean;
28
28
  taskRows(): readonly { taskId: string }[];
29
+ /** Processes in the sidecar's service cgroup that this runner did not
30
+ * start (its own helpers, such as tsx's esbuild service, never count). */
29
31
  peerProcessIds(): readonly number[];
30
32
  }
31
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.158",
3
+ "version": "0.9.160",
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/main.ts CHANGED
@@ -428,7 +428,7 @@ import {
428
428
  kernelBootId,
429
429
  TaskFilesMachineRetirementOutbox,
430
430
  TASK_FILES_MACHINE_RETIREMENT_RECORD,
431
- taskMachinePeerProcessIds,
431
+ taskMachineForeignProcessIds,
432
432
  } from "../lib/task-files-machine-retirement-outbox";
433
433
  import {
434
434
  planFreshTaskMachineCredentialsActivation,
@@ -1819,7 +1819,8 @@ function taskMachineCredentialsActivationDeps(): TaskMachineCredentialsActivatio
1819
1819
  .select({ taskId: schema.hostTasks.taskId })
1820
1820
  .from(schema.hostTasks)
1821
1821
  .all(),
1822
- peerProcessIds: taskMachinePeerProcessIds,
1822
+ // Not the runner's own children (tsx's esbuild service): see the helper.
1823
+ peerProcessIds: taskMachineForeignProcessIds,
1823
1824
  };
1824
1825
  }
1825
1826