@runuai/host 0.9.152 → 0.9.153

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.
@@ -0,0 +1,5 @@
1
+ -- ADR-154 Files v1: persist the cloud-issued mode for crash recovery. The
2
+ -- task-owned `.uai/files-mounted` marker is an observation, never authority.
3
+ ALTER TABLE `uai_host_tasks` ADD `shared_files_mode` text
4
+ CONSTRAINT `uai_host_tasks_shared_files_mode`
5
+ CHECK (`shared_files_mode` IS NULL OR `shared_files_mode` IN ('off', 'ro', 'rw'));
@@ -204,6 +204,13 @@
204
204
  "when": 1791331200001,
205
205
  "tag": "0028_leased_engine_runtimes",
206
206
  "breakpoints": true
207
+ },
208
+ {
209
+ "idx": 29,
210
+ "version": "6",
211
+ "when": 1791590400000,
212
+ "tag": "0029_task_shared_files_mode",
213
+ "breakpoints": true
207
214
  }
208
215
  ]
209
216
  }
package/db/schema.ts CHANGED
@@ -39,6 +39,12 @@ export const hostTasks = sqliteTable(
39
39
  // Value-free resolver metadata for rebuilding the project-env gateway
40
40
  // after a runner restart, before the cloud re-sends task-up.
41
41
  projectEnvSpec: text("project_env_spec"),
42
+ // ADR-154 Files v1: the normalized mode from the cloud's task launch.
43
+ // Recovery must never trust the task-writable `.uai/files-mounted`
44
+ // marker as authority for what may be mounted.
45
+ sharedFilesMode: text("shared_files_mode", {
46
+ enum: ["off", "ro", "rw"],
47
+ }),
42
48
  statusMirror: text("status_mirror"),
43
49
  // Owner + project slugs — received in TaskLaunchInput at task-up and
44
50
  // persisted here so the local UI (ADR-028) can show "who/what" without a
@@ -70,6 +76,10 @@ export const hostTasks = sqliteTable(
70
76
  "uai_host_tasks_environment_pair",
71
77
  sql`(${table.environmentProvider} IS NULL) = (${table.environmentLocator} IS NULL)`,
72
78
  ),
79
+ sharedFilesModeCheck: check(
80
+ "uai_host_tasks_shared_files_mode",
81
+ sql`${table.sharedFilesMode} IS NULL OR ${table.sharedFilesMode} IN ('off', 'ro', 'rw')`,
82
+ ),
73
83
  }),
74
84
  );
75
85
 
package/lib/agent.ts CHANGED
@@ -68,6 +68,10 @@ export interface LeasedTaskUp {
68
68
  * the start.
69
69
  */
70
70
  runtimeReady: (environment: TaskWriteEnvironment, signal: AbortSignal) => Promise<void>;
71
+ /** This exact start carries a task-machine Files session. Machine task-up
72
+ * skips its legacy copy/mount path only in this case; owned leased tasks do
73
+ * not receive a Files session and keep their normal Files behavior. */
74
+ filesV1?: true;
71
75
  }
72
76
 
73
77
  /** Refuses a start on cloud credentials that is not whole, or not this
@@ -1390,11 +1390,16 @@ export async function setupBrowserTesting(
1390
1390
  }
1391
1391
  }
1392
1392
 
1393
- // Grok registers its servers through its own CLI (project scope, see
1394
- // GROK_BROWSER_ADD_CMD) rather than a file we own; a failure here leaves
1395
- // the task configured-false so the next ensure retries.
1393
+ // Grok registers through its own read-modify-write CLI (user scope,
1394
+ // see grokBrowserAddCommand), under the same lock as gateway reconciliation
1395
+ // so two successful CLI processes cannot silently lose each other's entry.
1396
+ // A failure leaves the task configured-false so the next ensure retries.
1396
1397
  if (hasGrok) {
1397
1398
  const grokArgv = [
1399
+ "flock",
1400
+ "-w",
1401
+ "10",
1402
+ MCP_CONFIG_LOCK_PATH,
1398
1403
  "sh",
1399
1404
  "-lc",
1400
1405
  selectedGrokHomes.map(grokBrowserAddCommand).join(" && "),
@@ -1126,7 +1126,10 @@ export class TaskCredentialLeaseRegistry {
1126
1126
  await this.#applyCredentialVersion(state, frame);
1127
1127
  } catch (error) {
1128
1128
  this.#report(error, state.assignment);
1129
- if (this.#isActive(state)) this.#request(state);
1129
+ // A failed consumer install may already have completed full cleanup
1130
+ // and armed the bounded retry which owns what comes next. Do not cancel
1131
+ // that backoff by immediately requesting the same refused manifest.
1132
+ if (this.#isActive(state) && !state.retryTimer) this.#request(state);
1130
1133
  }
1131
1134
  }
1132
1135
 
@@ -1226,7 +1229,7 @@ export class TaskCredentialLeaseRegistry {
1226
1229
  });
1227
1230
  } catch (error) {
1228
1231
  this.#report(error, state.assignment);
1229
- this.#request(state);
1232
+ if (this.#isActive(state) && !state.retryTimer) this.#request(state);
1230
1233
  }
1231
1234
  } else if (frame.outcome === "needs_sign_in") {
1232
1235
  // The authoritative stale frame supplies any floor and removals.
@@ -2452,8 +2455,16 @@ export class TaskCredentialLeaseRegistry {
2452
2455
  // A permanent revocation answers every request with another denial.
2453
2456
  // Keep asking, but do not turn that into a tight
2454
2457
  // deny/cleanup/request loop against the cloud.
2455
- if (reason === "denied") this.#armRetry(state);
2456
- else this.#request(state);
2458
+ // A denial is stable until cloud state changes, and a consumer which
2459
+ // refused a grant can keep refusing the same manifest (for example
2460
+ // while a task-owned workspace entry is being repaired). Neither is
2461
+ // allowed to become a cleanup/request loop which repeatedly mints STS
2462
+ // sessions and reads Parameter Store without delay.
2463
+ if (reason === "denied" || reason === "manifest-narrowed") {
2464
+ this.#armRetry(state);
2465
+ } else {
2466
+ this.#request(state);
2467
+ }
2457
2468
  }
2458
2469
  }
2459
2470
  }
@@ -79,6 +79,8 @@ import {
79
79
  setMcpCredentialRefresher,
80
80
  } from "./credential-consumers/mcp";
81
81
  import type { ProjectEnvTaskReplicaManager, ProjectEnvTaskReplicaSnapshot } from "./project-env-task-replica";
82
+ import type { SharedFilesMode } from "./shared-files-s3";
83
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
82
84
  import {
83
85
  TaskCredentialCleanupCoordinator,
84
86
  type TaskCredentialCleanupDeps,
@@ -95,6 +97,17 @@ const ENV_READY_POLL_MS = 50;
95
97
  * way; the next process's recovery sweep finishes what is left. */
96
98
  export const SHUTDOWN_DRAIN_MS = 30_000;
97
99
 
100
+ /** The only MCP state an agent config exposes. Material, provider URL,
101
+ * revision and availability stay behind the task gateway, so changing any of
102
+ * them must not churn a long-lived agent process. */
103
+ function mcpAgentMembershipFingerprint(view: TaskCredentialView): string {
104
+ return JSON.stringify(
105
+ view.mcp
106
+ .map((connection) => connection.manifest.slug)
107
+ .sort((left, right) => left.localeCompare(right)),
108
+ );
109
+ }
110
+
98
111
  /** The task's Files (ADR-154 §12): installed, opened and closed with the
99
112
  * rest. `close` is synchronous: no new Files admission after it. */
100
113
  export interface TaskFilesConsumer {
@@ -102,6 +115,32 @@ export interface TaskFilesConsumer {
102
115
  open(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void;
103
116
  close(assignment: Readonly<TaskAssignment>): void;
104
117
  closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
118
+ /** A trusted task-machine runtime could not establish its Files boundary.
119
+ * This run cannot be reopened on the same machine. */
120
+ retire(assignment: Readonly<TaskAssignment>, error: unknown): void;
121
+ /** Optional only so owned runners can use the same composer without a
122
+ * task-machine Files implementation. */
123
+ runtimeReady?(
124
+ assignment: Readonly<TaskAssignment>,
125
+ environment: TaskEnvironmentHandle<unknown>,
126
+ mode: SharedFilesMode,
127
+ signal: AbortSignal,
128
+ ): Promise<void>;
129
+ /** Drop secrets at process shutdown without starting cleanup. */
130
+ shutdown?(): void;
131
+ }
132
+
133
+ /** A Files implementation proved that the runtime-ready operation which
134
+ * failed still owns the exact open run and requested terminal retirement of
135
+ * its one-task machine; a stale operation throws its ordinary error. */
136
+ export class AuthoritativeTaskFilesFailure extends Error {
137
+ override readonly cause: unknown;
138
+
139
+ constructor(cause: unknown) {
140
+ super(cause instanceof Error ? cause.message : "the task's Files boundary failed");
141
+ this.name = "AuthoritativeTaskFilesFailure";
142
+ this.cause = cause;
143
+ }
105
144
  }
106
145
 
107
146
  export interface CloudCredentialRuntimeDeps {
@@ -266,6 +305,9 @@ export class CloudCredentialRuntime {
266
305
  /** Work accepted and not yet over (startups, frames, installs, holds not
267
306
  * yet released, closes): what shutdown joins. */
268
307
  readonly #work = new Set<Promise<unknown>>();
308
+ /** Last agent-facing MCP membership published for each task. Kept across a
309
+ * gate close/renewal because the config remains in the task runtime. */
310
+ readonly #publishedMcpMembership = new Map<string, string>();
269
311
  readonly #setTimer: typeof setTimeout;
270
312
  readonly #clearTimer: typeof clearTimeout;
271
313
  #shutDown = false;
@@ -500,6 +542,69 @@ export class CloudCredentialRuntime {
500
542
  };
501
543
  }
502
544
 
545
+ /** Whether this process has the task-machine Files-v1 consumer wired. */
546
+ taskFilesV1(): boolean {
547
+ return typeof this.#deps.files?.runtimeReady === "function";
548
+ }
549
+
550
+ /** Runtime-ready half of Files v1. It is tracked with the composer's
551
+ * accepted work so shutdown joins a write/mount already admitted. */
552
+ prepareTaskFiles(
553
+ assignment: Readonly<TaskAssignment>,
554
+ environment: TaskEnvironmentHandle<unknown>,
555
+ mode: SharedFilesMode,
556
+ signal: AbortSignal,
557
+ ): Promise<void> {
558
+ const ready = this.#deps.files?.runtimeReady;
559
+ if (!ready) return Promise.reject(new Error("task-machine Files v1 is unavailable"));
560
+ return this.#track(
561
+ ready.call(this.#deps.files, assignment, environment, mode, signal).catch((error: unknown) => {
562
+ if (error instanceof AuthoritativeTaskFilesFailure) {
563
+ // Never reopen this run on the same machine. The consumer already
564
+ // proved this is authoritative; centrally latch/request exact-run
565
+ // terminal retirement too, so an implementation cannot turn this
566
+ // into an ordinary cleanup/regrant by omission.
567
+ let retirementError: unknown = null;
568
+ try {
569
+ this.#deps.files!.retire(assignment, error.cause);
570
+ } catch (failure) {
571
+ retirementError = failure;
572
+ }
573
+ void this.closeAssignment({ ...assignment }, "stop").catch(() => {});
574
+ if (retirementError !== null) throw retirementError;
575
+ throw error.cause;
576
+ }
577
+ throw error;
578
+ }),
579
+ );
580
+ }
581
+
582
+ /** A recovered task whose trusted Files metadata is absent or invalid has
583
+ * failed the same boundary before the consumer can be called. Request its
584
+ * terminal machine retirement, close its gate synchronously, and run the
585
+ * run's full cleanup. */
586
+ failTaskFilesSetup(
587
+ assignment: Readonly<TaskAssignment>,
588
+ error: unknown = new Error("the recovered task has no trusted Files-v1 metadata"),
589
+ ): void {
590
+ try {
591
+ this.#deps.files?.retire(assignment, error);
592
+ } catch (retirementError) {
593
+ console.warn(
594
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: Files machine retirement could not be requested: ${
595
+ retirementError instanceof Error ? retirementError.message : String(retirementError)
596
+ }`,
597
+ );
598
+ }
599
+ void this.closeAssignment({ ...assignment }, "stop").catch((error: unknown) => {
600
+ console.warn(
601
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: Files recovery cleanup failed: ${
602
+ error instanceof Error ? error.message : String(error)
603
+ }`,
604
+ );
605
+ });
606
+ }
607
+
503
608
  /** The start is over, successful or not: its token has no authority left,
504
609
  * and it is no longer the task's newest. */
505
610
  endStart(start: TaskCredentialStart): void {
@@ -688,7 +793,14 @@ export class CloudCredentialRuntime {
688
793
  const signal = recovered.controller.signal;
689
794
  recovered.running = this.#track(
690
795
  Promise.resolve()
691
- .then(() => setup(signal))
796
+ .then(() => {
797
+ // Stop or a newer run can end recovery after the gate schedules
798
+ // this microtask but before it starts. Never enter task-machine
799
+ // setup with already-revoked authority: Files treats a current
800
+ // setup failure as terminal for the whole machine.
801
+ if (signal.aborted) return;
802
+ return setup(signal);
803
+ })
692
804
  .catch((error: unknown) =>
693
805
  console.warn(
694
806
  `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed: ${
@@ -830,6 +942,7 @@ export class CloudCredentialRuntime {
830
942
  setEngineCredentialRefresher(null);
831
943
  setEngineLaunchFenceEscalator(null);
832
944
  setMcpCredentialRefresher(null);
945
+ this.#deps.files?.shutdown?.();
833
946
  }
834
947
 
835
948
  // -- the registry's gate and install --------------------------------------------
@@ -860,13 +973,17 @@ export class CloudCredentialRuntime {
860
973
  openEngineGate(assignment, view);
861
974
  openMcpGate(assignment, view);
862
975
  openPersonCredentialGate(assignment, view);
863
- this.#deps.files?.open(assignment, view);
864
976
  this.#open.set(assignment.taskId, assignment.runEpoch);
865
977
  const generation = this.#generations.get(view);
866
978
  if (generation === undefined) this.#served.delete(assignment.taskId);
867
979
  else this.#served.set(assignment.taskId, generation);
868
- this.#deps.mcpServedChanged?.(assignment.taskId);
869
980
  this.#envServedChanged(assignment.taskId);
981
+ // Last throwing boundary: a recovered runtime's mounts stay staged and
982
+ // root-inaccessible until every other consumer accepted this exact view.
983
+ // Nothing which follows publishes work synchronously. If Files itself
984
+ // fails, the registry compensates by closing the earlier gates.
985
+ this.#deps.files?.open(assignment, view);
986
+ this.#publishMcpMembership(assignment.taskId, view);
870
987
  this.#runRecoveredSetup(assignment);
871
988
  }
872
989
 
@@ -878,6 +995,21 @@ export class CloudCredentialRuntime {
878
995
  }
879
996
  }
880
997
 
998
+ /** Publish only a normalized change visible to agent configuration. A
999
+ * renewed lease, a refreshed OAuth token, and available/unavailable
1000
+ * transitions all keep the same named gateway route and therefore do not
1001
+ * rewrite config or restart agents; an unavailable route answers 503. */
1002
+ #publishMcpMembership(taskId: string, view: TaskCredentialView): void {
1003
+ const fingerprint = mcpAgentMembershipFingerprint(view);
1004
+ if (this.#publishedMcpMembership.get(taskId) === fingerprint) return;
1005
+ this.#publishedMcpMembership.set(taskId, fingerprint);
1006
+ try {
1007
+ this.#deps.mcpServedChanged?.(taskId);
1008
+ } catch {
1009
+ // The gateway gate is authoritative even when its config follower fails.
1010
+ }
1011
+ }
1012
+
881
1013
  /**
882
1014
  * Synchronous and idempotent: every consumer of this run (and older)
883
1015
  * stops being served, env and Files included, and every start holding its
@@ -947,6 +1079,12 @@ export class CloudCredentialRuntime {
947
1079
  installPersonCredentials(request);
948
1080
  await this.#deps.files?.install(request);
949
1081
  this.#assertInstallCurrent(request);
1082
+ // Credential changes normally affect material or availability only. Keep
1083
+ // this normalized comparison at the install boundary so even a future
1084
+ // same-run membership change publishes exactly once.
1085
+ if (request.reason === "credential-change") {
1086
+ this.#publishMcpMembership(request.assignment.taskId, request.next);
1087
+ }
950
1088
  }
951
1089
 
952
1090
  #assertInstallCurrent(request: CredentialInstallRequest): void {
@@ -1019,6 +1157,7 @@ export class CloudCredentialRuntime {
1019
1157
  if (request.credentialIds !== undefined) return;
1020
1158
  this.#deps.envReplica?.dropAssignment(request.taskId, request.runEpoch);
1021
1159
  if (request.reason !== "teardown") return;
1160
+ this.#publishedMcpMembership.delete(request.taskId);
1022
1161
  forgetLeasedEngines({ taskId: request.taskId, runEpoch: request.runEpoch });
1023
1162
  const mcp = leasedMcpFor(request.taskId);
1024
1163
  if (mcp && mcp.assignment.runEpoch <= request.runEpoch) forgetLeasedMcp(request.taskId);
@@ -165,7 +165,9 @@ const SSH_PATHS = new Set(
165
165
  );
166
166
  const FILES_SESSION_PATHS = new Set([
167
167
  LEASE_CREDENTIALS_FILE,
168
+ `${LEASE_CREDENTIALS_FILE}.uai-tmp`,
168
169
  LEASE_AWS_CONFIG_FILE,
170
+ `${LEASE_AWS_CONFIG_FILE}.uai-tmp`,
169
171
  ]);
170
172
 
171
173
  /** Destructive cleanup accepts only lane-owned files. The executor repeats