@runuai/host 0.9.153 → 0.9.155

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.
package/lib/agent-cli.ts CHANGED
@@ -210,7 +210,14 @@ export function agentCliEnv(
210
210
  return {
211
211
  ...runtimeAuthority,
212
212
  UAI_TASK_TOKEN: signTaskToken(
213
- { taskId, userId: ownerUserId, permissions, agentId: agent.id },
213
+ {
214
+ taskId,
215
+ userId: ownerUserId,
216
+ permissions,
217
+ agentId: agent.id,
218
+ // ADR-157 §3: binds the token to this agent, not merely its handle.
219
+ ...(agent.tokenGeneration ? { generation: agent.tokenGeneration } : {}),
220
+ },
214
221
  cliSecret,
215
222
  ),
216
223
  UAI_API_URL: apiUrl,
@@ -312,6 +319,58 @@ async function main() {
312
319
  }
313
320
  return;
314
321
  }
322
+ if (group === "crew") {
323
+ // ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
324
+ // persona) changes its own crew. One agent per call, always with a --why.
325
+ const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
326
+ const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
327
+ if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
328
+ if (action === "add") {
329
+ const personaId = pos[0];
330
+ if (!personaId || !flags.why) {
331
+ console.error('uai: crew add needs a persona id (see crew list) and --why "<reason>"');
332
+ process.exit(1);
333
+ }
334
+ const body = { personaId, why: flags.why };
335
+ if (flags.as) body.handle = handleOf(flags.as);
336
+ if (flags.engine) body.kind = flags.engine;
337
+ if (flags.model) body.model = flags.model;
338
+ if (flags.effort) body.effort = flags.effort;
339
+ const r = await api("POST", "/api/agent/crew", body);
340
+ out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
341
+ return;
342
+ }
343
+ if (action === "model") {
344
+ const id = handleOf(pos[0] || "");
345
+ if (!id || !flags.why || !(flags.model || flags.engine || flags.effort)) {
346
+ console.error('uai: crew model needs @agent, at least one of --engine, --model and --effort, and --why "<reason>"');
347
+ process.exit(1);
348
+ }
349
+ // "default" clears the setting back to the engine's own default (null).
350
+ const cleared = (v) => (v === "default" ? null : v);
351
+ const body = { why: flags.why };
352
+ if (flags.engine) body.kind = flags.engine;
353
+ if (flags.model) body.model = cleared(flags.model);
354
+ if (flags.effort) body.effort = cleared(flags.effort);
355
+ const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
356
+ out(r.changed
357
+ ? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
358
+ : "@" + id + " already runs on " + launch(r.agent) + "; nothing changed");
359
+ return;
360
+ }
361
+ if (action === "remove") {
362
+ const id = handleOf(pos[0] || "");
363
+ if (!id || !flags.why) {
364
+ console.error('uai: crew remove needs @agent and --why "<reason>"');
365
+ process.exit(1);
366
+ }
367
+ await api("DELETE", "/api/agent/crew/" + encodeURIComponent(id), { why: flags.why });
368
+ out("removed @" + id);
369
+ return;
370
+ }
371
+ console.error("uai: crew commands are list, add, model and remove (Astronaut only)");
372
+ process.exit(1);
373
+ }
315
374
  const key = group + " " + (action || "");
316
375
  switch (key) {
317
376
  case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
@@ -497,6 +556,10 @@ async function main() {
497
556
  " uai task history (list your past tasks)",
498
557
  " uai task read <taskId> (full chat of a past task you were on)",
499
558
  ' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
559
+ " uai crew list (Astronaut only: personas, engines, models, limits, roster)",
560
+ ' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] --why "<reason>"',
561
+ ' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] --why "<reason>" (at least one of the three)',
562
+ ' uai crew remove @agent --why "<reason>"',
500
563
  " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
501
564
  " uai whoami",
502
565
  ].join("\\n"));
@@ -390,6 +390,17 @@ const CLAUDE_FULL_ACCESS_ARGS = [
390
390
  "--strict-mcp-config",
391
391
  ];
392
392
 
393
+ /**
394
+ * ADR-157 Astronaut profile: the normal tool set, minus Claude Code's native
395
+ * subagents. An Astronaut's helpers must be crew agents the task can see and
396
+ * bill; a built-in Agent/Task subagent would run (and spend) outside the crew.
397
+ */
398
+ const CLAUDE_ASTRONAUT_ARGS = [
399
+ ...CLAUDE_FULL_ACCESS_ARGS,
400
+ "--disallowedTools",
401
+ "Agent,Task",
402
+ ];
403
+
393
404
  /**
394
405
  * ADR-083 communicator profile. This is deliberately an engine-level tool
395
406
  * boundary, not prompt advice: setting sources, hooks, skills, workflows and
@@ -440,7 +451,7 @@ export class ClaudeSession implements AgentSession {
440
451
  agent: RosterAgent;
441
452
  environment: TaskEnvironmentAgentSessionSurface;
442
453
  systemPreamble: string;
443
- executionProfile?: "communicator";
454
+ executionProfile?: "communicator" | "astronaut";
444
455
  attachCompatibilityKey?: string;
445
456
  agentEnv?: Record<string, string>;
446
457
  }) {
@@ -453,7 +464,9 @@ export class ClaudeSession implements AgentSession {
453
464
  const cliArgs = [
454
465
  ...(args.executionProfile === "communicator"
455
466
  ? CLAUDE_COMMUNICATOR_ARGS
456
- : CLAUDE_FULL_ACCESS_ARGS),
467
+ : args.executionProfile === "astronaut"
468
+ ? CLAUDE_ASTRONAUT_ARGS
469
+ : CLAUDE_FULL_ACCESS_ARGS),
457
470
  ];
458
471
  if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
459
472
  cliArgs.push("--include-partial-messages");
@@ -704,6 +717,10 @@ register({
704
717
  defaultModel: CLAUDE_COMMUNICATOR_MODEL,
705
718
  defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
706
719
  },
720
+ {
721
+ id: "astronaut",
722
+ mechanism: "claude-disallowed-agent-task-v1",
723
+ },
707
724
  ],
708
725
  // Usable only when a Claude credential is in the env (injected into task
709
726
  // containers at task-up). Gates advertisement (ADR-044 P2).
@@ -391,6 +391,8 @@ export class CodexSession implements AgentSession {
391
391
  attachCompatibilityKey?: string;
392
392
  /** ADR-128: `-c key=value` overrides a delegating adapter prepends. */
393
393
  cliConfigOverrides?: string[];
394
+ /** ADR-157: the Astronaut runs without Codex's native multi-agent tools. */
395
+ executionProfile?: "communicator" | "astronaut";
394
396
  }) {
395
397
  this.agentId = args.agent.id;
396
398
  this.systemPreamble = args.systemPreamble;
@@ -425,8 +427,15 @@ export class CodexSession implements AgentSession {
425
427
  "shell_snapshot",
426
428
  "--disable",
427
429
  "shell_snapshot_v2",
428
- "app-server",
429
430
  );
431
+ // ADR-157: an Astronaut's helpers must be crew agents the task can see and
432
+ // bill, so Codex's own subagents (spawn_agent; multi_agent is on by
433
+ // default in 0.157, multi_agent_v2 off) are disabled for it. Also after
434
+ // every override, so nothing can turn them back on.
435
+ if (args.executionProfile === "astronaut") {
436
+ codexArgs.push("--disable", "multi_agent", "--disable", "multi_agent_v2");
437
+ }
438
+ codexArgs.push("app-server");
430
439
  // ADR-061: the CLI is owned by an in-container runner (durable across
431
440
  // host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
432
441
  // re-handshakes per host process (initialize + thread/start live in this
@@ -801,6 +810,9 @@ register({
801
810
  defaultModel: CODEX_DEFAULT_MODEL,
802
811
  supportedEfforts: () => [...CODEX_EFFORTS],
803
812
  defaultEffort: CODEX_DEFAULT_EFFORT,
813
+ // ADR-157: enforceable by construction (the --disable flags above). The
814
+ // communicator profile is Claude-only; Codex never declared it.
815
+ executionProfiles: [{ id: "astronaut", mechanism: "codex-disable-multi-agent-v1" }],
804
816
  // Usable only when the owner's ~/.codex login exists — task-up copies it into
805
817
  // each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
806
818
  // (ADR-044 P2).
@@ -820,6 +832,7 @@ register({
820
832
  agentEnv,
821
833
  attachCompatibilityKey,
822
834
  cliConfigOverrides,
835
+ executionProfile,
823
836
  }) =>
824
837
  new CodexSession({
825
838
  taskId,
@@ -829,5 +842,6 @@ register({
829
842
  agentEnv,
830
843
  attachCompatibilityKey,
831
844
  cliConfigOverrides,
845
+ executionProfile,
832
846
  }),
833
847
  });
@@ -88,10 +88,11 @@ export function setAdvertisementFallback(
88
88
  }
89
89
 
90
90
  /**
91
- * ADR-154: on cloud credentials the runner holds no engine credential of its
92
- * own, so availability and the fallback above say nothing: the kinds the
93
- * cloud can lease an account for are advertised, and only those. Null while
94
- * the runner runs its tasks on its own credentials. Installed by main.ts.
91
+ * ADR-154 §10: on cloud credentials the runner holds no engine credential of
92
+ * its own, so availability and the fallback above say nothing: the kinds a
93
+ * leased task can run are advertised, and only those (`CLOUD_CREDENTIAL_KINDS`:
94
+ * the leased lanes and the Managed Engine). Null while the runner runs its
95
+ * tasks on its own credentials. Installed by main.ts.
95
96
  */
96
97
  let leasedKinds: () => ReadonlySet<string> | null = () => null;
97
98
 
@@ -57,6 +57,9 @@ export const RosterAgent = z.object({
57
57
  // ADR-128: which upstream protocol a Uai-engine model speaks — resolved
58
58
  // cloud-side from the model catalog; selects the delegated adapter.
59
59
  uaiProvider: z.enum(["anthropic", "openai"]).optional(),
60
+ builtIn: z.literal("astronaut").optional(), // ADR-157: the cloud-built Astronaut only
61
+ // ADR-157 §3: signed into the agent's task token as `g` (see agentCliEnv).
62
+ tokenGeneration: z.string().min(1).max(64).optional(),
60
63
  });
61
64
  export type RosterAgent = z.infer<typeof RosterAgent>;
62
65
 
@@ -179,7 +182,7 @@ export interface AgentSessionFactory {
179
182
  systemPreamble: string;
180
183
  /** Host-derived restricted execution policy (ADR-083). Never supplied by
181
184
  * the browser or stored on the roster entry. */
182
- executionProfile?: "communicator";
185
+ executionProfile?: "communicator" | "astronaut";
183
186
  /** Durable runners are keyed by task+agent. Host-owned launch settings
184
187
  * supply a compatibility key so restart recovery replaces, rather than
185
188
  * attaches, a process launched with incompatible model/effort/policy. */
package/lib/agents/uai.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * the request's `model`, and the gateway maps slug → upstream id.
16
16
  */
17
17
 
18
- import { factoryFor, register } from "./registry";
18
+ import { factoryFor, register, supportsExecutionProfile } from "./registry";
19
19
  import type { AgentSession, AgentSessionFactory } from "./types";
20
20
 
21
21
  const DEFAULT_GATEWAY_URL = "https://inference.runuai.com";
@@ -80,6 +80,17 @@ const create: AgentSessionFactory["create"] = async (args): Promise<AgentSession
80
80
  if (!delegate) {
81
81
  throw new Error(`uai engine: no ${delegateKind} adapter registered to delegate to`);
82
82
  }
83
+ // ADR-157 §7: the runner checked the profile against the `uai` kind, but the
84
+ // delegate is what enforces it. Refuse rather than run an Astronaut on a
85
+ // delegate that would ignore the profile.
86
+ if (
87
+ args.executionProfile &&
88
+ !supportsExecutionProfile(delegateKind, args.executionProfile)
89
+ ) {
90
+ throw new Error(
91
+ `uai engine: the ${delegateKind} adapter cannot enforce execution profile "${args.executionProfile}"`,
92
+ );
93
+ }
83
94
  const taskToken = args.agentEnv?.UAI_TASK_TOKEN;
84
95
  if (!taskToken) {
85
96
  // agentCliEnv mints no token for a permissionless non-secretary agent;
@@ -110,5 +121,8 @@ register({
110
121
  // Always available: the engine needs no host-side credential — auth is
111
122
  // the per-agent task token, billing is the org's credits.
112
123
  available: () => true,
124
+ // ADR-157: `create` forwards the profile to the Claude or Codex delegate,
125
+ // and both enforce it.
126
+ executionProfiles: [{ id: "astronaut", mechanism: "uai-delegated-astronaut-v1" }],
113
127
  create,
114
128
  });
@@ -257,6 +257,12 @@ export interface TaskCredentialLeaseRegistryOptions {
257
257
  assignment: Readonly<TaskAssignment>,
258
258
  view: TaskCredentialView,
259
259
  ): void;
260
+ /** A material update committed while the existing gate stayed open.
261
+ * Followers may reconcile runtime files from this authoritative view. */
262
+ credentialMaterialChanged?(
263
+ assignment: Readonly<TaskAssignment>,
264
+ view: TaskCredentialView,
265
+ ): void;
260
266
  cleanupExternal(request: CredentialCleanupRequest): Promise<void>;
261
267
  /**
262
268
  * Installs files/proxy/engine consumers transactionally. The env-replica
@@ -2279,6 +2285,9 @@ export class TaskCredentialLeaseRegistry {
2279
2285
  state.bundle = next;
2280
2286
  state.bundleGeneration++;
2281
2287
  this.#armBundleTimers(state);
2288
+ if (!refused) {
2289
+ this.#opts.credentialMaterialChanged?.(cloneAssignment(state.assignment), next.view);
2290
+ }
2282
2291
  // A refusal opens through its release, inside this boundary: an open
2283
2292
  // that fails, its compensating close included, escalates below.
2284
2293
  release();
@@ -63,9 +63,11 @@ import {
63
63
  cleanupPersonCredentials,
64
64
  closePersonCredentialGate,
65
65
  drainPersonCredentialWrites,
66
+ holdPersonCredentialGate,
66
67
  holdTaskGitCredentials,
67
68
  installPersonCredentials,
68
69
  openPersonCredentialGate,
70
+ type PersonCredentialLane,
69
71
  type TaskGitCredentialHold,
70
72
  } from "./credential-consumers/github";
71
73
  import {
@@ -160,18 +162,90 @@ export interface CloudCredentialRuntimeDeps {
160
162
  * on a grant's snapshot, or closed): whoever follows the task's env
161
163
  * (the proxy's trust delivery) looks again. */
162
164
  envServedChanged?(taskId: string): void;
165
+ /** Reconcile the owner's GitHub/SSH files in an already-running task.
166
+ * The signal closes with the exact person-credential gate, and the caller
167
+ * retries a transient rejection while that gate and material stay current. */
168
+ personCredentialsReady?(
169
+ assignment: Readonly<TaskAssignment>,
170
+ lanes: readonly PersonCredentialLane[],
171
+ signal: AbortSignal,
172
+ ): Promise<void>;
163
173
  files?: TaskFilesConsumer | null;
164
174
  launchDeadlineMs?: number;
165
175
  envReadyTimeoutMs?: number;
166
176
  shutdownDrainMs?: number;
167
177
  /** The first retry's delay for a recovered start whose startup failed. */
168
178
  recoveryRetryBaseMs?: number;
179
+ /** The first retry's delay for task-runtime person-credential writes. */
180
+ personReconcileRetryBaseMs?: number;
169
181
  registry?: Partial<
170
182
  Pick<TaskCredentialLeaseRegistryOptions, "createSessionReader" | "now" | "setTimer" | "clearTimer" | "randomId" | "onError">
171
183
  >;
172
184
  cleanup?: Pick<TaskCredentialCleanupDeps, "db" | "removePath" | "destroyRuntime">;
173
185
  }
174
186
 
187
+ interface PersonCredentialStamp {
188
+ /** Stable without retaining another copy of any credential material. */
189
+ identity: string;
190
+ available: boolean;
191
+ }
192
+
193
+ interface PersonCredentialSnapshot {
194
+ runEpoch: number;
195
+ github: PersonCredentialStamp;
196
+ ssh: PersonCredentialStamp;
197
+ }
198
+
199
+ function personCredentialStamp(
200
+ credential: TaskCredentialView["github"] | TaskCredentialView["ssh"],
201
+ ): PersonCredentialStamp {
202
+ if (credential === null) return { identity: "absent", available: false };
203
+ return {
204
+ identity: [
205
+ credential.manifest.credentialId,
206
+ credential.manifest.revision,
207
+ credential.availability,
208
+ credential.observedVersion ?? "unreadable",
209
+ ].join("\u0000"),
210
+ available: credential.availability === "available",
211
+ };
212
+ }
213
+
214
+ function personCredentialSnapshot(view: TaskCredentialView): PersonCredentialSnapshot {
215
+ return {
216
+ runEpoch: view.assignment.runEpoch,
217
+ github: personCredentialStamp(view.github),
218
+ ssh: personCredentialStamp(view.ssh),
219
+ };
220
+ }
221
+
222
+ function samePersonCredentialSnapshot(
223
+ left: PersonCredentialSnapshot | undefined,
224
+ right: PersonCredentialSnapshot,
225
+ ): boolean {
226
+ return Boolean(
227
+ left &&
228
+ left.runEpoch === right.runEpoch &&
229
+ left.github.identity === right.github.identity &&
230
+ left.ssh.identity === right.ssh.identity,
231
+ );
232
+ }
233
+
234
+ /** What must be rewritten for `next`. GitHub and SSH transport are coupled:
235
+ * when GitHub leaves, the surviving SSH key must select SSH again. */
236
+ function changedPersonCredentialLanes(
237
+ previous: PersonCredentialSnapshot | undefined,
238
+ next: PersonCredentialSnapshot,
239
+ ): PersonCredentialLane[] {
240
+ if (samePersonCredentialSnapshot(previous, next)) return [];
241
+ // The pair shares Git transport configuration. Reconcile both available
242
+ // lanes, including a write interrupted by the other lane changing.
243
+ const lanes: PersonCredentialLane[] = [];
244
+ if (next.github.available) lanes.push("github");
245
+ if (next.ssh.available) lanes.push("ssh");
246
+ return lanes;
247
+ }
248
+
175
249
  /**
176
250
  * Why a start was interrupted, as its signal's reason: Stop, teardown or a
177
251
  * newer run took it over, or the runner is shutting down. The first
@@ -308,6 +382,13 @@ export class CloudCredentialRuntime {
308
382
  /** Last agent-facing MCP membership published for each task. Kept across a
309
383
  * gate close/renewal because the config remains in the task runtime. */
310
384
  readonly #publishedMcpMembership = new Map<string, string>();
385
+ /** Person material last opened or installed for each task. Only identities,
386
+ * availability and observed versions: never another copy of a secret. */
387
+ readonly #personCredentialSnapshots = new Map<string, PersonCredentialSnapshot>();
388
+ readonly #personReconciles = new Map<string, { runEpoch: number; controller: AbortController }>();
389
+ /** Stop fences work before it can acquire the lifecycle slot or close the
390
+ * lease. A late grant cannot restart writes in that interval. */
391
+ readonly #personInterrupted = new Map<string, number>();
311
392
  readonly #setTimer: typeof setTimeout;
312
393
  readonly #clearTimer: typeof clearTimeout;
313
394
  #shutDown = false;
@@ -337,6 +418,7 @@ export class CloudCredentialRuntime {
337
418
  send: (frame) => deps.send(frame),
338
419
  closeTaskGate: (assignment) => this.#close(assignment),
339
420
  openTaskGate: (assignment, view) => this.#openGate(assignment, view),
421
+ credentialMaterialChanged: (assignment, view) => this.#personCredentialsChanged(assignment, view),
340
422
  installExternal: (request) => this.#track(this.#install(request)),
341
423
  // From a grant's acceptance until its own reader is active, the env
342
424
  // replica refuses the run's frames for the cloud to retry: none is
@@ -379,6 +461,7 @@ export class CloudCredentialRuntime {
379
461
  throw new CredentialLeaseUnavailableError("a newer run of this task has begun");
380
462
  }
381
463
  this.#floors.set(assignment.taskId, assignment.runEpoch);
464
+ if (!options.recovery) this.#personInterrupted.delete(assignment.taskId);
382
465
  this.#interruptTokens(assignment.taskId, {});
383
466
  // A newer run's start ends an older run's recovery; an ordinary start of
384
467
  // the run being recovered takes its startup over.
@@ -418,6 +501,11 @@ export class CloudCredentialRuntime {
418
501
  */
419
502
  interruptStarts(taskId: string, which: { exact?: number; below?: number } = {}): void {
420
503
  this.#interruptTokens(taskId, which);
504
+ const assignment = this.#assignments.get(taskId);
505
+ if (assignment && (which.exact === undefined || assignment.runEpoch === which.exact) &&
506
+ (which.below === undefined || assignment.runEpoch < which.below)) {
507
+ this.#interruptPersonCredentials(assignment);
508
+ }
421
509
  // A recovery of the run ends with it, ended start token or not.
422
510
  const recovered = this.#recovered.get(taskId);
423
511
  if (
@@ -441,6 +529,8 @@ export class CloudCredentialRuntime {
441
529
  /** A local Stop: the task's recovery, whatever its run, ends now. One
442
530
  * only exists for the run this runner owned at its start. */
443
531
  interruptRecovery(taskId: string): void {
532
+ const assignment = this.#assignments.get(taskId);
533
+ if (assignment) this.#interruptPersonCredentials(assignment);
444
534
  const recovered = this.#recovered.get(taskId);
445
535
  if (recovered) this.#endRecovery(taskId, recovered.runEpoch);
446
536
  }
@@ -776,41 +866,50 @@ export class CloudCredentialRuntime {
776
866
  if (recovered.running === null) this.#recovered.delete(taskId);
777
867
  }
778
868
 
779
- /** A recovered run's gate opened: its recovery is done, and its setup
780
- * runs, once, in the background, its failure its own. */
869
+ /** A recovered runtime is ready only after setup succeeds. Transient
870
+ * reconstruction/exec failures retry; Stop, cleanup and takeover fence
871
+ * every attempt and its retry delay. */
781
872
  #runRecoveredSetup(assignment: Readonly<TaskAssignment>): void {
782
873
  const recovered = this.#recovered.get(assignment.taskId);
783
874
  if (!recovered || recovered.runEpoch !== assignment.runEpoch || recovered.running !== null) return;
784
- if (recovered.controller.signal.aborted) return;
875
+ if (recovered.controller.signal.aborted || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
785
876
  if (recovered.retry !== null) this.#clearTimer(recovered.retry);
786
877
  recovered.retry = null;
787
878
  const setup = recovered.setup;
788
- recovered.setup = null;
789
879
  if (setup === null) {
790
880
  this.#recovered.delete(assignment.taskId);
791
881
  return;
792
882
  }
793
- const signal = recovered.controller.signal;
883
+ const hold = holdPersonCredentialGate(assignment);
884
+ const signal = AbortSignal.any([recovered.controller.signal, hold.signal]);
885
+ let complete = false;
794
886
  recovered.running = this.#track(
795
887
  Promise.resolve()
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);
888
+ .then(async () => {
889
+ signal.throwIfAborted();
890
+ await setup(signal);
891
+ signal.throwIfAborted();
892
+ complete = true;
893
+ })
894
+ .catch(() => {
895
+ if (!signal.aborted) {
896
+ console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed; retrying`);
897
+ }
803
898
  })
804
- .catch((error: unknown) =>
805
- console.warn(
806
- `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed: ${
807
- error instanceof Error ? error.message : String(error)
808
- }`,
809
- ),
810
- )
811
899
  .finally(() => {
900
+ hold.release();
812
901
  recovered.running = null;
813
- if (this.#recovered.get(assignment.taskId) === recovered) this.#recovered.delete(assignment.taskId);
902
+ if (this.#recovered.get(assignment.taskId) !== recovered) return;
903
+ if (complete || recovered.controller.signal.aborted || this.#shutDown) {
904
+ this.#recovered.delete(assignment.taskId);
905
+ return;
906
+ }
907
+ const base = this.#deps.recoveryRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
908
+ const delay = Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(recovered.attempts++, 20));
909
+ recovered.retry = this.#setTimer(() => {
910
+ recovered.retry = null;
911
+ this.#runRecoveredSetup(assignment);
912
+ }, delay);
814
913
  }),
815
914
  );
816
915
  }
@@ -983,10 +1082,72 @@ export class CloudCredentialRuntime {
983
1082
  // Nothing which follows publishes work synchronously. If Files itself
984
1083
  // fails, the registry compensates by closing the earlier gates.
985
1084
  this.#deps.files?.open(assignment, view);
1085
+ this.#personCredentialsChanged(assignment, view);
986
1086
  this.#publishMcpMembership(assignment.taskId, view);
987
1087
  this.#runRecoveredSetup(assignment);
988
1088
  }
989
1089
 
1090
+ #personCredentialsChanged(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
1091
+ const reconcile = this.#deps.personCredentialsReady;
1092
+ if (!reconcile || this.#shutDown || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
1093
+ if ((this.#personInterrupted.get(assignment.taskId) ?? -1) >= assignment.runEpoch) return;
1094
+ const previous = this.#personCredentialSnapshots.get(assignment.taskId);
1095
+ const snapshot = personCredentialSnapshot(view);
1096
+ if (samePersonCredentialSnapshot(previous, snapshot)) return;
1097
+ this.#personCredentialSnapshots.set(assignment.taskId, snapshot);
1098
+ this.#personReconciles.get(assignment.taskId)?.controller.abort();
1099
+ const lanes = changedPersonCredentialLanes(previous, snapshot);
1100
+ if (lanes.length === 0) return;
1101
+ const entry = { runEpoch: assignment.runEpoch, controller: new AbortController() };
1102
+ const hold = holdPersonCredentialGate(assignment);
1103
+ const signal = AbortSignal.any([entry.controller.signal, hold.signal]);
1104
+ this.#personReconciles.set(assignment.taskId, entry);
1105
+ this.#background(
1106
+ Promise.resolve().then(async () => {
1107
+ let attempts = 0;
1108
+ while (!signal.aborted) {
1109
+ try {
1110
+ await reconcile(assignment, lanes, signal);
1111
+ signal.throwIfAborted();
1112
+ return;
1113
+ } catch {
1114
+ if (signal.aborted) return;
1115
+ if (attempts === 0) {
1116
+ console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: person credential files not ready; retrying`);
1117
+ }
1118
+ const base = this.#deps.personReconcileRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
1119
+ await this.#retryDelay(Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(attempts++, 20)), signal);
1120
+ }
1121
+ }
1122
+ }).finally(() => {
1123
+ hold.release();
1124
+ if (this.#personReconciles.get(assignment.taskId) === entry) this.#personReconciles.delete(assignment.taskId);
1125
+ }),
1126
+ );
1127
+ }
1128
+
1129
+ #interruptPersonCredentials(assignment: Readonly<TaskAssignment>): void {
1130
+ this.#personInterrupted.set(assignment.taskId, Math.max(assignment.runEpoch, this.#personInterrupted.get(assignment.taskId) ?? -1));
1131
+ const snapshot = this.#personCredentialSnapshots.get(assignment.taskId);
1132
+ if (snapshot && snapshot.runEpoch <= assignment.runEpoch) this.#personCredentialSnapshots.delete(assignment.taskId);
1133
+ const reconcile = this.#personReconciles.get(assignment.taskId);
1134
+ if (reconcile && reconcile.runEpoch <= assignment.runEpoch) reconcile.controller.abort();
1135
+ }
1136
+
1137
+ /** A retry never holds cleanup for its backoff interval. */
1138
+ #retryDelay(delay: number, signal: AbortSignal): Promise<void> {
1139
+ return new Promise((resolve) => {
1140
+ const finish = (): void => {
1141
+ this.#clearTimer(timer);
1142
+ signal.removeEventListener("abort", finish);
1143
+ resolve();
1144
+ };
1145
+ const timer = this.#setTimer(finish, delay);
1146
+ signal.addEventListener("abort", finish, { once: true });
1147
+ if (signal.aborted) finish();
1148
+ });
1149
+ }
1150
+
990
1151
  #envServedChanged(taskId: string): void {
991
1152
  try {
992
1153
  this.#deps.envServedChanged?.(taskId);
@@ -1030,8 +1191,11 @@ export class CloudCredentialRuntime {
1030
1191
  if (open !== undefined && open <= assignment.runEpoch) {
1031
1192
  this.#open.delete(assignment.taskId);
1032
1193
  this.#served.delete(assignment.taskId);
1194
+ this.#personCredentialSnapshots.delete(assignment.taskId);
1033
1195
  this.#envServedChanged(assignment.taskId);
1034
1196
  }
1197
+ const person = this.#personReconciles.get(assignment.taskId);
1198
+ if (person && person.runEpoch <= assignment.runEpoch) person.controller.abort();
1035
1199
  for (const entry of this.#holds.get(assignment.taskId) ?? []) {
1036
1200
  if (entry.runEpoch <= assignment.runEpoch) {
1037
1201
  attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
@@ -1134,7 +1298,9 @@ export class CloudCredentialRuntime {
1134
1298
  const recovered = this.#recovered.get(request.taskId);
1135
1299
  const setups: Promise<void>[] = [];
1136
1300
  if (request.credentialIds === undefined && recovered && recovered.runEpoch <= request.runEpoch && recovered.running) {
1137
- recovered.controller.abort(new StartInterruptedError("takeover"));
1301
+ // The gate hold already cancelled this attempt. A transient full
1302
+ // cleanup may reacquire the same run's lease, so retain its setup for
1303
+ // that next open. Stop/takeover cancel recovery at their own boundary.
1138
1304
  setups.push(recovered.running);
1139
1305
  }
1140
1306
  const consumer = consumerCleanupRequest(request);