@runuai/host 0.9.157 → 0.9.159

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, ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.157",
3
+ "version": "0.9.159",
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
@@ -773,9 +773,13 @@ const credentialsActivation = new CredentialsActivationAuthority<CredentialsReti
773
773
  log: (line) => console.warn(line),
774
774
  });
775
775
  setCredentialsModeSource(() => credentialsActivation.mode());
776
- /** ADR-154 §13: how often a runner not yet on cloud credentials asks again,
777
- * so the operator turning them on reaches it within minutes. */
776
+ /** ADR-154 §13: the steady/operator cadence. Active runners report their
777
+ * purge again here too, so keep it deliberately quiet. */
778
778
  const CREDENTIALS_ACTIVATION_RETRY_MS = 5 * 60_000;
779
+ /** A newly assigned marked machine is already behind its floor. Retry a
780
+ * transient freshness/Files proof inside Cloud's boot budget, without making
781
+ * its eventual active purge report a five-second heartbeat. */
782
+ const TASK_MACHINE_CREDENTIALS_ACTIVATION_RETRY_MS = 5_000;
779
783
  const CREDENTIALS_ACTIVATION_ANSWER_MS = 60_000;
780
784
  // Started here, first fired long after the bridge's state exists.
781
785
  const credentialsActivationTimer = setInterval(() => {
@@ -788,6 +792,18 @@ const credentialsActivationTimer = setInterval(() => {
788
792
  void credentialsActivation.advance();
789
793
  }, CREDENTIALS_ACTIVATION_RETRY_MS);
790
794
  credentialsActivationTimer.unref?.();
795
+ const taskMachineCredentialsActivationTimer =
796
+ taskMachineCredentialsRequired && sidecarTaskId() !== null
797
+ ? setInterval(() => {
798
+ if (
799
+ stopping ||
800
+ !credentialFramesReady ||
801
+ credentialsMode() !== "pending"
802
+ ) return;
803
+ void credentialsActivation.advance();
804
+ }, TASK_MACHINE_CREDENTIALS_ACTIVATION_RETRY_MS)
805
+ : null;
806
+ taskMachineCredentialsActivationTimer?.unref?.();
791
807
 
792
808
  const credentialsActivationAsks = new Map<
793
809
  string,