@runuai/host 0.9.167 → 0.9.169

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.
@@ -1,3 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AssignmentEvidenceJournal } from "./assignment-evidence";
3
+ import { durableAgentSessionId } from "./agents/transport";
4
+ import { agentOutputSource } from "./agents/output-source";
5
+ import { taskDir } from "./env";
6
+ import { readTaskRunEpochState } from "./runtime-state";
7
+ import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
8
+
1
9
  /**
2
10
  * Orchestrator — the chat channel runtime.
3
11
  *
@@ -292,6 +300,7 @@ interface Channel {
292
300
  /** ADR-083: host-derived role assignment for execution-profile enforcement. */
293
301
  mode: "open" | "secretary";
294
302
  secretaryAgentId?: string;
303
+ leadRunId?: string | null;
295
304
  sessions: Map<string, AgentSession>;
296
305
  /** Session-spawn inputs, kept so sessions can be started lazily. */
297
306
  containerName: string;
@@ -386,10 +395,10 @@ interface Channel {
386
395
  * boundary. Adapters do not share one mid-turn input contract, so only this
387
396
  * cross-lane role is serialized at the host boundary; open mode keeps its
388
397
  * legacy immediate-concurrent delivery behavior. */
389
- pendingPrompts: Map<string, string[]>;
398
+ pendingPrompts: Map<string, AcceptedPrompt[]>;
390
399
  /** Prompts accepted by channelDeliver whose final session boundary rejected
391
400
  * specifically because the container runtime became unavailable. */
392
- runtimeDeferredPrompts: Map<string, string[]>;
401
+ runtimeDeferredPrompts: Map<string, AcceptedPrompt[]>;
393
402
  /** Missing/blocked sessions to reconcile exactly once after runtime ready. */
394
403
  runtimeDeferredAgents: Set<string>;
395
404
  /** Agents whose current turn was interrupted (ESC) — their next
@@ -471,7 +480,7 @@ interface Channel {
471
480
  accountByAgent: Map<string, string>;
472
481
  /** ADR-076: the last prompt delivered to each agent, re-sent after an
473
482
  * account rotation so the interrupted turn resumes on the new account. */
474
- lastPrompt: Map<string, string>;
483
+ lastPrompt: Map<string, AcceptedPrompt>;
475
484
  /** ADR-076: per-agent account-rotation budget (reset on a successful turn) —
476
485
  * bounds a rate-limit storm across accounts, mirroring the respawn budget. */
477
486
  rotations: Map<string, number>;
@@ -537,8 +546,27 @@ function mcpAgentMembershipFingerprint(
537
546
  );
538
547
  }
539
548
 
549
+ const evidenceSessionIds = new WeakMap<AgentSession, string>();
550
+ function evidenceSessionId(taskId: string, agentId: string, session: AgentSession): string {
551
+ let id = evidenceSessionIds.get(session);
552
+ if (!id) {
553
+ // Pin to this session object, including its final/aborted boundary. The
554
+ // registry may already contain its replacement by the time output ends.
555
+ id = durableAgentSessionId(taskId, agentId) ?? randomUUID();
556
+ evidenceSessionIds.set(session, id);
557
+ }
558
+ return id;
559
+ }
560
+ const evidenceRuntimeVersion: string | null = (() => {
561
+ try { return (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; }
562
+ catch { return null; }
563
+ })();
564
+
540
565
  interface AcceptedPrompt {
541
566
  text: string;
567
+ /** Frozen at acceptance. Null means unallocated, not inherit the latest. */
568
+ assignment: AssignmentDelivery | null;
569
+ leadRunId?: string | null;
542
570
  }
543
571
 
544
572
  interface RosterSessionChange {
@@ -927,6 +955,16 @@ export class Orchestrator {
927
955
  }
928
956
 
929
957
  private emitHost(event: HostEvent): void {
958
+ if (event.kind === "agent.message_complete" || event.kind === "agent.turn_complete") {
959
+ const session = this.channels.get(event.taskId)?.sessions.get(event.agentId);
960
+ const path = join(taskDir(event.taskId), ".uai", "assignment-evidence", `${event.agentId}.json`);
961
+ if (session && existsSync(path)) {
962
+ const evidence = new AssignmentEvidenceJournal(path).observe(
963
+ evidenceSessionId(event.taskId, event.agentId, session), agentOutputSource.getStore() ? agentOutputSource.getStore() + ":" + event.kind : undefined, event.kind === "agent.turn_complete",
964
+ );
965
+ if (evidence) event = { ...event, evidence };
966
+ }
967
+ }
930
968
  for (const fn of this.hostSubscribers) {
931
969
  try {
932
970
  fn(event);
@@ -1090,6 +1128,7 @@ export class Orchestrator {
1090
1128
  channel.humans = spec.humans ?? [];
1091
1129
  channel.mode = spec.mode === "secretary" ? "secretary" : "open";
1092
1130
  channel.secretaryAgentId = spec.secretaryAgentId;
1131
+ channel.leadRunId = spec.leadRunId ?? null;
1093
1132
  channel.browserTesting = spec.browserTesting === true;
1094
1133
  if (channel.browserTesting) {
1095
1134
  const affectedKinds = browserWasEnabled
@@ -1390,6 +1429,7 @@ export class Orchestrator {
1390
1429
  HOST_ACCOUNT_SCOPE,
1391
1430
  mode: spec.mode === "secretary" ? "secretary" : "open",
1392
1431
  secretaryAgentId: spec.secretaryAgentId,
1432
+ leadRunId: spec.leadRunId ?? null,
1393
1433
  sessions: new Map(),
1394
1434
  containerName: `task-${taskId.toLowerCase()}-app-1`,
1395
1435
  agentEnvironment: null,
@@ -2667,6 +2707,7 @@ export class Orchestrator {
2667
2707
  task.ownerUserId,
2668
2708
  apiUrl,
2669
2709
  cliSecret,
2710
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
2670
2711
  ),
2671
2712
  task,
2672
2713
  ),
@@ -4450,6 +4491,7 @@ export class Orchestrator {
4450
4491
  task.ownerUserId,
4451
4492
  apiUrl,
4452
4493
  cliSecret,
4494
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
4453
4495
  ),
4454
4496
  task,
4455
4497
  ),
@@ -4686,28 +4728,48 @@ export class Orchestrator {
4686
4728
  channel: Channel,
4687
4729
  agentId: string,
4688
4730
  session: AgentSession,
4689
- prompt: string,
4731
+ prompt: string | AcceptedPrompt,
4732
+ assignment?: AssignmentDelivery,
4690
4733
  ): void {
4734
+ const replay = typeof prompt !== "string";
4735
+ const text = typeof prompt === "string" ? prompt : prompt.text;
4736
+ const leadRunId = replay ? (prompt as AcceptedPrompt).leadRunId ?? null : this.isSecretary(channel, agentId) ? channel.leadRunId ?? null : null;
4737
+ const originalAssignment = typeof prompt === "string" ? (this.isSecretary(channel, agentId) ? null : assignment) : prompt.assignment;
4738
+ let retainedAssignment = originalAssignment ?? null;
4739
+ const journalPath = join(taskDir(channel.taskId), ".uai", "assignment-evidence", `${agentId}.json`);
4740
+ let turn: AssignmentTurnEvidence | undefined;
4741
+ const journal = assignment || leadRunId || existsSync(journalPath) ? new AssignmentEvidenceJournal(journalPath) : null;
4742
+ const sessionId = evidenceSessionId(channel.taskId, agentId, session);
4743
+ if (journal) {
4744
+ const begun = journal.begin({ sessionId, engine: session.kind,
4745
+ runEpoch: readTaskRunEpochState(channel.taskId)?.highestRunEpoch ?? 0,
4746
+ recipientGeneration: channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null,
4747
+ runtimeVersion: evidenceRuntimeVersion, assignment: originalAssignment, replay, leadRunId });
4748
+ if (!begun) return; // stable assignment replay, already accepted
4749
+ turn = begun;
4750
+ retainedAssignment = begun.assignment;
4751
+ }
4691
4752
  // ADR-076: this is the prompt actually in flight. A queued follow-up must
4692
4753
  // not replace it or account rotation would replay the wrong turn.
4693
- channel.lastPrompt.set(agentId, prompt);
4694
- const accepted = { text: prompt };
4754
+ const accepted: AcceptedPrompt = { text, assignment: retainedAssignment, leadRunId };
4755
+ channel.lastPrompt.set(agentId, accepted);
4695
4756
  const acceptedForAgent = channel.acceptedPrompts.get(agentId) ?? [];
4696
4757
  acceptedForAgent.push(accepted);
4697
4758
  channel.acceptedPrompts.set(agentId, acceptedForAgent);
4698
4759
  this.incrementActiveTurns(channel, agentId);
4699
- void session.send(prompt).catch((err: unknown) => {
4760
+ void session.send(text).catch((err: unknown) => {
4761
+ if (turn) journal?.failed(sessionId, turn.turnId);
4700
4762
  if (channel.sessions.get(agentId) === session) {
4701
4763
  this.removeAcceptedPrompt(channel, agentId, accepted);
4702
4764
  const remaining = this.decrementActiveTurns(channel, agentId);
4703
4765
  if (
4704
4766
  remaining === 0 &&
4705
- channel.lastPrompt.get(agentId) === prompt
4767
+ channel.lastPrompt.get(agentId) === accepted
4706
4768
  ) {
4707
4769
  channel.lastPrompt.delete(agentId);
4708
4770
  }
4709
4771
  if (err instanceof ContainerRuntimeUnavailableError) {
4710
- this.deferPromptUntilRuntimeReady(channel, agentId, prompt);
4772
+ this.deferPromptUntilRuntimeReady(channel, agentId, accepted);
4711
4773
  } else {
4712
4774
  this.startNextPendingPrompt(channel, agentId);
4713
4775
  }
@@ -4745,7 +4807,7 @@ export class Orchestrator {
4745
4807
  private deferPromptUntilRuntimeReady(
4746
4808
  channel: Channel,
4747
4809
  agentId: string,
4748
- prompt: string,
4810
+ prompt: AcceptedPrompt,
4749
4811
  ): void {
4750
4812
  channel.runtimeDeferredAgents.add(agentId);
4751
4813
  if (this.isSecretary(channel, agentId)) {
@@ -4810,6 +4872,7 @@ export class Orchestrator {
4810
4872
  /** ADR-141 rung 1: only a human turn is shadow-routed. An older cloud
4811
4873
  * sends nothing, which reads as "not human" and keeps the pass off. */
4812
4874
  origin?: DeliveryOrigin,
4875
+ assignment?: AssignmentDelivery,
4813
4876
  ): Promise<{ ok: true } | { ok: false; error: string }> {
4814
4877
  // Slash command: re-run GitHub auth setup for this task (ADR-027). Handled
4815
4878
  // before sessions so it works even when an agent isn't ready.
@@ -4829,11 +4892,15 @@ export class Orchestrator {
4829
4892
  const session = channel.sessions.get(agentId);
4830
4893
  if (!session) return { ok: false, error: `no such agent: ${agentId}` };
4831
4894
 
4895
+ if (assignment && (assignment.runEpoch !== (readTaskRunEpochState(taskId)?.highestRunEpoch ?? 0) ||
4896
+ assignment.recipientGeneration !== (channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null))) {
4897
+ return { ok: false, error: "assignment belongs to another run or agent generation" };
4898
+ }
4832
4899
  const prompt = rewriteAttachmentRefs(text);
4833
4900
  if (this.isSecretary(channel, agentId)) {
4834
4901
  const queue = channel.pendingPrompts.get(agentId) ?? [];
4835
4902
  if ((channel.activeTurns.get(agentId) ?? 0) > 0 || queue.length > 0) {
4836
- queue.push(prompt);
4903
+ queue.push({ text: prompt, assignment: assignment ?? null, leadRunId: channel.leadRunId ?? null });
4837
4904
  channel.pendingPrompts.set(agentId, queue);
4838
4905
  // A replacement session may have bound after the queue was observed
4839
4906
  // but before this delivery. Preserve FIFO by starting the oldest item.
@@ -4845,7 +4912,7 @@ export class Orchestrator {
4845
4912
  // Preserve the legacy open-room contract: adapters receive concurrent
4846
4913
  // deliveries immediately. Only the designated Secretary needs the hard
4847
4914
  // turn boundary because crew replies can arrive during its dispatch turn.
4848
- this.startPrompt(channel, agentId, session, prompt);
4915
+ this.startPrompt(channel, agentId, session, prompt, assignment);
4849
4916
  }
4850
4917
  // ADR-141 rung 1: after the turn is on its way, ask what Auto would have
4851
4918
  // done and report it. Fire-and-forget by design — it must never delay or
@@ -5561,7 +5628,7 @@ export class Orchestrator {
5561
5628
  channel.lastPrompt.delete(agentId);
5562
5629
  if (accepted.length > 0 && !interrupted) {
5563
5630
  for (const prompt of accepted) {
5564
- this.deferPromptUntilRuntimeReady(channel, agentId, prompt.text);
5631
+ this.deferPromptUntilRuntimeReady(channel, agentId, prompt);
5565
5632
  }
5566
5633
  } else {
5567
5634
  // Recreate even an idle session after readiness, without fabricating a
@@ -5695,6 +5762,7 @@ export class Orchestrator {
5695
5762
  task.ownerUserId,
5696
5763
  apiUrl,
5697
5764
  cliSecret,
5765
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
5698
5766
  );
5699
5767
  const boundAccountId = channel.accountByAgent.get(agentId);
5700
5768
  const boundAccount = boundAccountId
@@ -5909,6 +5977,7 @@ export class Orchestrator {
5909
5977
  task.ownerUserId,
5910
5978
  apiUrl,
5911
5979
  cliSecret,
5980
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
5912
5981
  );
5913
5982
  channel.accountByAgent.set(agentId, next.id);
5914
5983
  noteEngineAccountUsed(next.id);
@@ -6118,6 +6187,7 @@ export class Orchestrator {
6118
6187
  task.ownerUserId,
6119
6188
  apiUrl,
6120
6189
  cliSecret,
6190
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
6121
6191
  );
6122
6192
  channel.accountByAgent.set(agentId, account.id);
6123
6193
  noteEngineAccountUsed(account.id);
@@ -7484,7 +7554,13 @@ export function buildSystemPreamble(
7484
7554
  `\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
7485
7555
  "`people list`, `task list`" +
7486
7556
  (agent.permissions?.includes("tasks.create")
7487
- ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
7557
+ ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]`"
7558
+ : "") +
7559
+ (agent.permissions?.includes("tasks.read")
7560
+ ? ", `task watch <childId> [--after <cursor>]`, `task usage <childId>`"
7561
+ : "") +
7562
+ (agent.permissions?.includes("tasks.chat")
7563
+ ? ", `task send <childId> <text>`, `task answer <childId> <cardId> <option>`"
7488
7564
  : "") +
7489
7565
  (agent.permissions?.includes("tasks.close")
7490
7566
  ? ", `task close --status finished|canceled [--reason \"final report\"]`"
@@ -7520,8 +7596,13 @@ export function buildSystemPreamble(
7520
7596
  `shell command \`node ${CONTAINER_CLI_PATH} task create …\`. Do NOT`,
7521
7597
  "use your built-in TaskCreate / task tools for this — those are just",
7522
7598
  "your own private to-do notes and are invisible to the human in Uai.",
7523
- "Uai tasks you create are DRAFTS — a human reviews and starts them;",
7524
- "nothing runs (or spends) until then. Discover options first with",
7599
+ "Tasks you create are your own direct children, DRAFTS by default.",
7600
+ "Only an explicit tasks.start grant lets --start launch them,",
7601
+ "under the owner's limits and this task's account lane.",
7602
+ "Only children you started are delegated to you; a live chat-capable Secretary receives coalesced events. Otherwise the child addresses the person.",
7603
+ "Closing the parent leaves running children alive. Child permissions never exceed your live creation ceiling.",
7604
+ "Send/answer are visibly delegated agent inputs, never human approval.",
7605
+ "Discover options first with",
7525
7606
  "`projects list` / `people list`.",
7526
7607
  ]
7527
7608
  : []),
@@ -181,6 +181,7 @@ export function taskProjectEnvResolver(
181
181
  readonly placeholders: Readonly<Record<string, string>>;
182
182
  } = () => ({ withheld: [], placeholders: {} }),
183
183
  taskSource: () => TaskEnvSource = () => STORED,
184
+ taskSecrets: () => { revision: string; withheld: readonly string[]; values: Readonly<Record<string, string>> } = () => ({ revision: "", withheld: [], values: {} }),
184
185
  ): TaskGatewayEnvResolver {
185
186
  const projectIds = [...new Set(spec.projects.map((project) => project.projectId))];
186
187
  let revisions: number[] | undefined;
@@ -192,7 +193,9 @@ export function taskProjectEnvResolver(
192
193
  const currentUai = uaiEnv();
193
194
  const currentProxied = proxiedEnv();
194
195
  const currentSource = taskSource();
196
+ const secrets = taskSecrets();
195
197
  const currentRunnerKey = JSON.stringify([
198
+ secrets.revision,
196
199
  Object.entries(currentUai).sort(([a], [b]) => compareAscii(a, b)),
197
200
  [...currentProxied.withheld].sort(compareAscii),
198
201
  Object.entries(currentProxied.placeholders).sort(([a], [b]) => compareAscii(a, b)),
@@ -214,6 +217,7 @@ export function taskProjectEnvResolver(
214
217
  Object.assign(values, resolved.values);
215
218
  const withheld = new Set([
216
219
  ...currentProxied.withheld,
220
+ ...secrets.withheld,
217
221
  ...(currentSource.kind === "view" ? currentSource.view.notDelivered : []),
218
222
  ]);
219
223
  for (const name of withheld) delete values[name];
@@ -225,6 +229,7 @@ export function taskProjectEnvResolver(
225
229
  values[name] = placeholder;
226
230
  }
227
231
  }
232
+ Object.assign(values, secrets.values);
228
233
  for (const [name, value] of Object.entries(currentUai)) {
229
234
  // Only reserved names: Uai's own variables, never a project's.
230
235
  if (ENV_NAME.test(name) && isReservedTaskEnvName(name) && !value.includes("\0")) values[name] = value;
@@ -1,3 +1,4 @@
1
+ import { taskSecretsView } from "../credential-consumers/task-secrets";
1
2
  /**
2
3
  * Which keys each task has in proxy mode (ADR-153's effective policy: the
3
4
  * owner's binding ∩ the task's schema observation), and what of that the
@@ -36,21 +37,29 @@ export function createTaskProxyPolicySource(
36
37
  return {
37
38
  proxiedKeys: (taskId) => {
38
39
  if (!isServed(taskId)) return null;
39
- return (viewOf(taskId)?.proxied ?? []).map(({ projectId, key, domains, placeholder, head }) => ({
40
+ const secrets = taskSecretsView(taskId);
41
+ const taskKeys = secrets.filter(s => s.manifest.mode === "proxied").map(s => ({ projectId: `task-secret:${taskId}`, key: s.manifest.name, domains: s.manifest.domains, placeholder: s.manifest.placeholder, head: { revision: s.manifest.generation, generation: s.manifest.generation, tombstone: false as const, valueRef: { kind: "staged" as const, stagingId: s.manifest.stagingId } } }));
42
+ return [...taskKeys, ...(viewOf(taskId)?.proxied ?? []).filter(e => !secrets.some(s => s.manifest.name === e.key)).map(({ projectId, key, domains, placeholder, head }) => ({
40
43
  projectId,
41
44
  key,
42
45
  domains,
43
46
  placeholder,
44
47
  head,
45
- }));
48
+ }))];
46
49
  },
47
50
  realValue: (taskId, ref) => {
51
+ if (!isServed(taskId)) return null;
52
+ if (ref.projectId === `task-secret:${taskId}`) {
53
+ const secret = taskSecretsView(taskId).find(s => s.manifest.name === ref.key && s.manifest.mode === "proxied");
54
+ return secret && ref.head?.generation === secret.manifest.generation && ref.head.valueRef?.kind === "staged" && ref.head.valueRef.stagingId === secret.manifest.stagingId ? secret.material?.value ?? null : null;
55
+ }
56
+ if (taskSecretsView(taskId).some(s => s.manifest.name === ref.key)) return null;
48
57
  const { head } = ref;
49
58
  if (head === undefined) return null;
50
59
  const proxied = viewOf(taskId)?.proxied.find((entry) => entry.projectId === ref.projectId && entry.key === ref.key);
51
60
  return proxied !== undefined && sameTaskEnvHead(proxied.head, head) ? proxied.value : null;
52
61
  },
53
- notDelivered: (taskId) => viewOf(taskId)?.notDelivered ?? null,
62
+ notDelivered: (taskId) => [...(viewOf(taskId)?.notDelivered ?? []), ...taskSecretsView(taskId).map(s => s.manifest.name)],
54
63
  };
55
64
  }
56
65
 
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { CONTEXT_HASH_LABEL } from "./standard-image-pin";
5
+
6
+ type Result = { code: number | null; stdout: string; stderr: string };
7
+ export type DockerCommand = (command: string, args: string[], timeoutMs?: number) => Promise<Result>;
8
+ const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
9
+ const SWEEP_LIMIT = 16;
10
+ const HISTORY_LIMIT = 128;
11
+ const HISTORY_BYTES = 16 * 1024;
12
+
13
+ /** Context labels are inherited by task images. Authority to delete comes
14
+ * from observing this exact id under the standard tag, never from a scan. */
15
+ export class StandardImageCleanup {
16
+ private readonly observed = new Set<string>();
17
+ private loading: Promise<void> | undefined;
18
+ private statePath: string | undefined;
19
+
20
+ constructor(private readonly stateFile?: () => Promise<string>) {}
21
+
22
+ private async load(): Promise<void> {
23
+ if (!this.stateFile) return;
24
+ try {
25
+ this.statePath = await this.stateFile();
26
+ const fd = openSync(this.statePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
27
+ try {
28
+ const stat = fstatSync(fd);
29
+ if (!stat.isFile() || stat.size > HISTORY_BYTES) return;
30
+ const bytes = Buffer.alloc(HISTORY_BYTES + 1);
31
+ const length = readSync(fd, bytes, 0, bytes.length, 0);
32
+ if (length > HISTORY_BYTES) return;
33
+ const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as { version?: unknown; ids?: unknown };
34
+ if (value?.version !== 1 || !Array.isArray(value.ids) || value.ids.length > HISTORY_LIMIT || !value.ids.every(id => typeof id === "string" && IMAGE_ID.test(id))) return;
35
+ for (const id of value.ids) this.observed.add(id as string);
36
+ } finally { closeSync(fd); }
37
+ } catch { /* Missing, corrupt or unreadable history authorizes nothing. */ }
38
+ }
39
+
40
+ private save(): void {
41
+ if (!this.statePath) return;
42
+ const temporary = `${this.statePath}.${process.pid}.${randomUUID()}.tmp`;
43
+ try {
44
+ mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
45
+ writeFileSync(temporary, JSON.stringify({ version: 1, ids: [...this.observed] }), { flag: "wx", mode: 0o600 });
46
+ renameSync(temporary, this.statePath);
47
+ } catch {
48
+ // ENOSPC must not block a build or safe cleanup using in-memory proof.
49
+ console.warn("[host-agent] could not persist standard-image history");
50
+ } finally {
51
+ try { unlinkSync(temporary); } catch { /* Renamed or never created. */ }
52
+ }
53
+ }
54
+
55
+ async observe(run: DockerCommand, ref = "uai-standard:dev"): Promise<string | null> {
56
+ await (this.loading ??= this.load());
57
+ const result = await run("docker", ["image", "inspect", "--format", "{{json .}}", ref]);
58
+ if (result.code !== 0) return null;
59
+ try {
60
+ const image = JSON.parse(result.stdout) as { Id?: string; Config?: { Labels?: Record<string, string> } };
61
+ if (!IMAGE_ID.test(image.Id ?? "") || !/^[a-f0-9]{32}$/.test(image.Config?.Labels?.[CONTEXT_HASH_LABEL] ?? "")) return null;
62
+ if (ref === "uai-standard:dev") {
63
+ this.observed.delete(image.Id!);
64
+ this.observed.add(image.Id!);
65
+ if (this.observed.size > HISTORY_LIMIT) this.observed.delete(this.observed.values().next().value!);
66
+ this.save();
67
+ }
68
+ return image.Id!;
69
+ } catch { return null; }
70
+ }
71
+
72
+ async sweep(run: DockerCommand, previousId: string | null): Promise<void> {
73
+ const deadline = Date.now() + 30_000;
74
+ const boundedRun: DockerCommand = (command, args) => run(command, args, 5_000);
75
+ const currentId = await this.observe(boundedRun);
76
+ if (!currentId) return;
77
+ const history = await boundedRun("docker", ["image", "history", "--quiet", "--no-trunc", currentId]);
78
+ if (history.code !== 0) return;
79
+ const currentChain = new Set([currentId, ...history.stdout.trim().split(/\s+/)]);
80
+ const candidates = new Set([...(previousId ? [previousId] : []), ...this.observed]);
81
+ for (const id of [...candidates].filter(id => this.observed.has(id) && !currentChain.has(id)).slice(0, SWEEP_LIMIT)) {
82
+ if (Date.now() >= deadline) break;
83
+ // Round-robin retained/in-use ids so a busy prefix cannot starve later ids.
84
+ this.observed.delete(id);
85
+ this.observed.add(id);
86
+ const tags = await boundedRun("docker", ["image", "inspect", "--format", "{{json .RepoTags}}", id]);
87
+ if (tags.code !== 0) continue;
88
+ try { const parsed: unknown = JSON.parse(tags.stdout); if (parsed !== null && (!Array.isArray(parsed) || parsed.length !== 0)) continue; } catch { continue; }
89
+ const containers = await boundedRun("docker", ["ps", "--all", "--quiet", "--filter", `ancestor=${id}`]);
90
+ if (containers.code !== 0 || containers.stdout.trim()) continue;
91
+ // Normal Docker removal reclaims otherwise-unused untagged parents.
92
+ // Shared ancestors of the current image (or any other child/container)
93
+ // remain protected by the daemon. Never force or target a parent id.
94
+ const removed = await boundedRun("docker", ["image", "rm", id]);
95
+ if (removed.code === 0) {
96
+ this.observed.delete(id);
97
+ console.log("[host-agent] removed unused previous standard image", id);
98
+ }
99
+ }
100
+ this.save();
101
+ }
102
+ }
103
+
104
+ /** Per-process exponential retry, keyed by the desired content/configuration.
105
+ * A new host payload/engine configuration retries immediately. A restart also
106
+ * retries once; repeated failures back off 2h, 4h, …, 24h instead of hourly. */
107
+ export class StandardImageBuildBackoff {
108
+ private state: { key: string; failures: number; retryAt: number; error: string } | null = null;
109
+ remaining(key: string, now = Date.now()): { delay: number; retryAt: number; error: string } | null {
110
+ return this.state?.key === key && this.state.retryAt > now ? { delay: this.state.retryAt - now, retryAt: this.state.retryAt, error: this.state.error } : null;
111
+ }
112
+ failed(key: string, error: string, now = Date.now()): void {
113
+ const failures = this.state?.key === key ? this.state.failures + 1 : 1;
114
+ this.state = { key, failures, retryAt: now + Math.min(24, 2 ** Math.min(failures, 5)) * 3_600_000, error };
115
+ }
116
+ clear(): void { this.state = null; }
117
+ }
@@ -2,7 +2,8 @@
2
2
  * Standard-image build pipeline (ADR-022 / docs/runtime.md).
3
3
  *
4
4
  * Every host builds exactly one standard image (`uai-standard:dev`) and
5
- * caches it. `ensureStandardImage()` is called once at host startup:
5
+ * caches it. `ensureStandardImage()` runs at startup and on the owned-host
6
+ * hourly maintenance schedule (local build failures back off):
6
7
  *
7
8
  * 1. `docker volume create uai-asdf-data` — the host-wide asdf data volume
8
9
  * that caches lazily-installed runtime versions across tasks (idempotent;
@@ -52,6 +53,7 @@ import {
52
53
  standardImagePullDigest,
53
54
  } from "./standard-image-pin";
54
55
  import { sidecarMode } from "./sidecar";
56
+ import { StandardImageCleanup, StandardImageBuildBackoff } from "./standard-image-cleanup";
55
57
 
56
58
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
57
59
  export const STANDARD_IMAGE_TAG = "uai-standard:dev";
@@ -1296,8 +1298,15 @@ export type ActivationIsCurrent = () => boolean;
1296
1298
  /** Tests: forget every remembered pass. */
1297
1299
  export function resetStandardImageMemo(): void {
1298
1300
  lastOkByKey.clear();
1301
+ buildBackoff.clear();
1299
1302
  }
1300
1303
 
1304
+ const buildBackoff = new StandardImageBuildBackoff();
1305
+ const imageCleanup = new StandardImageCleanup(async () => {
1306
+ const { env } = await import("./env");
1307
+ return resolve(env.dataDir, "standard-image-seen.json");
1308
+ });
1309
+
1301
1310
  /** The owned-host schedule (docs/agent-notes.md, "image maintenance"). */
1302
1311
  export const MAINTENANCE_INTERVAL_MS = 60 * 60_000;
1303
1312
 
@@ -1418,6 +1427,7 @@ async function ensureStandardImageInner(
1418
1427
  // hash, not this host's per-config hash, which would disagree by design
1419
1428
  // and rebuild forever). Any pull-path failure falls through to the local
1420
1429
  // build below, so offline and registry-down hosts behave exactly as today.
1430
+ const previousImageId = engine.apple ? null : await imageCleanup.observe(run);
1421
1431
  const pin = await readStandardImagePin();
1422
1432
  if (pin !== null) {
1423
1433
  if (inspect.code === 0 && labeledHash === pin.contextHash) {
@@ -1473,13 +1483,18 @@ async function ensureStandardImageInner(
1473
1483
  imageReady = true;
1474
1484
  } else {
1475
1485
  const context = standardImageDir();
1476
- console.log(
1477
- inspect.code === 0
1478
- ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
1479
- : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
1480
- );
1481
- const build = await run(engine.command, [
1486
+ const buildKey = `${contextHash ?? "unavailable"}:${JSON.stringify(engines)}:${pin?.contextHash ?? "local"}`;
1487
+ const deferred = engine.apple || labeledHash === null ? null : buildBackoff.remaining(buildKey);
1488
+ if (!deferred) {
1489
+ console.log(
1490
+ inspect.code === 0
1491
+ ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
1492
+ : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
1493
+ );
1494
+ }
1495
+ const build = deferred ? { code: 1, stdout: "", stderr: `${deferred.error}\nStandard image build deferred until ${new Date(deferred.retryAt).toISOString()}.` } : await run(engine.command, [
1482
1496
  "build",
1497
+ ...(engine.apple ? [] : ["--force-rm"]),
1483
1498
  "-t",
1484
1499
  STANDARD_IMAGE_TAG,
1485
1500
  ...engineArgs,
@@ -1491,11 +1506,14 @@ async function ensureStandardImageInner(
1491
1506
  if (build.code === 0) {
1492
1507
  console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
1493
1508
  imageReady = true;
1509
+ buildBackoff.clear();
1494
1510
  } else {
1495
1511
  buildError =
1496
1512
  build.stderr.trim() ||
1497
1513
  `docker build exited ${build.code ?? "spawn error"}`;
1514
+ if (!deferred && !engine.apple) buildBackoff.failed(buildKey, buildError);
1498
1515
  console.warn(
1516
+ deferred ? `[host-agent] ${buildError}` :
1499
1517
  `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
1500
1518
  "continuing. Tasks needing the image will surface this error.\n" +
1501
1519
  buildError,
@@ -1505,6 +1523,10 @@ async function ensureStandardImageInner(
1505
1523
  }
1506
1524
  }
1507
1525
 
1526
+ // Revisit ids durably observed under the standard tag across restarts.
1527
+ // Inherited labels never authorize sweeping task/compose images.
1528
+ if (imageReady && !engine.apple) await imageCleanup.sweep(run, previousImageId);
1529
+
1508
1530
  // 3. Reconcile every Uai-owned writer under the volume-global flock. Lock
1509
1531
  // bootstrap/permission repair precedes the single upgrade→repair→proof
1510
1532
  // transaction so node-run task materializers can acquire legacy volumes.
@@ -1648,7 +1670,7 @@ async function ensureStandardImageInner(
1648
1670
  }
1649
1671
  return {
1650
1672
  ok: false,
1651
- error: `Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
1673
+ error: `${buildError ? `${buildError} ` : ""}Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
1652
1674
  };
1653
1675
  }
1654
1676
  if (!(await reconcileVolumeAgentClis(activation))) {
@@ -1661,7 +1683,7 @@ async function ensureStandardImageInner(
1661
1683
  return {
1662
1684
  ok: false,
1663
1685
  error:
1664
- "Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof.",
1686
+ `${buildError ? `${buildError} ` : ""}Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof.`,
1665
1687
  };
1666
1688
  }
1667
1689
  return { ok: true };
@@ -1,3 +1,4 @@
1
+ import { deliveredTaskSecrets } from "./credential-consumers/task-secrets";
1
2
  /**
2
3
  * ADR-153 §4 and §6: where a task's project env comes from, for its /env and
3
4
  * its proxy alike, on an owned runner and on a task machine's sidecar.
@@ -233,6 +234,7 @@ export function taskEnvResolver(taskId: string, spec: TaskProjectEnvSpec, runEpo
233
234
  () => taskTrustEnv(taskId),
234
235
  () => taskProxiedEnv(taskId),
235
236
  () => taskEnvSource(taskId, spec, runEpoch),
237
+ () => deliveredTaskSecrets(taskId, runEpoch),
236
238
  );
237
239
  }
238
240