@runuai/host 0.9.156 → 0.9.157

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/src/index.ts CHANGED
@@ -346,7 +346,7 @@ function untilLeavingLegacy<T>(legacy: LegacyStartPermit | null, wait: Promise<T
346
346
 
347
347
  /** A start the runner does not take while it switches its tasks to cloud
348
348
  * credentials: the cloud starts it again shortly. */
349
- function switchingToCloudCredentials(): TaskUpOutcome {
349
+ function switchingToCloudCredentials(): HostCommandResult<never> {
350
350
  return {
351
351
  ok: false,
352
352
  code: HostErrorCode.HostUnavailable,
@@ -621,8 +621,9 @@ async function taskUpOn(
621
621
  // that failed after Compose created its network can retain an empty subnet
622
622
  // while it waits for Resume. Reclaim only networks whose trusted host row,
623
623
  // exact Docker identity and two empty-container inventories all agree. The
624
- // sweep runs before this task's lifecycle slot so concurrent failed starts
625
- // can never deadlock while trying to inspect one another.
624
+ // sweep runs before this task's lifecycle slot and never waits on another
625
+ // task's: a candidate whose slot is busy is skipped, so concurrent starts
626
+ // neither deadlock nor queue behind one another's provisioning.
626
627
  if (
627
628
  !sidecarMode() &&
628
629
  !machineTasksEnabled() &&
@@ -642,6 +643,12 @@ async function taskUpOn(
642
643
  "[host-agent] skipped task-network recovery because Docker returned an invalid network inventory",
643
644
  );
644
645
  }
646
+ if (networkRecovery.busy > 0) {
647
+ console.log(
648
+ `[host-agent] task-network recovery left ${String(networkRecovery.busy)} ` +
649
+ `network(s) alone while their task was busy`,
650
+ );
651
+ }
645
652
  } catch (error) {
646
653
  // Best-effort capacity recovery must not block a task when Docker still
647
654
  // has room. `docker compose up` remains the authoritative allocation.
@@ -1976,11 +1983,17 @@ export const hostCommands: HostCommands = {
1976
1983
 
1977
1984
  async channelEnsure(ctx, input) {
1978
1985
  logCommand(ctx, "channelEnsure", input.taskId);
1986
+ if (credentialsMode() === "pending") {
1987
+ return switchingToCloudCredentials();
1988
+ }
1979
1989
  const admissionFailure = managedMaintenanceUnavailable();
1980
1990
  if (admissionFailure) return admissionFailure;
1981
1991
  const runtimeFailure = runtimeUnavailable();
1982
1992
  if (runtimeFailure) return runtimeFailure;
1983
1993
  await agentClisReady;
1994
+ if (credentialsMode() === "pending") {
1995
+ return switchingToCloudCredentials();
1996
+ }
1984
1997
  const readyAdmissionFailure = managedMaintenanceUnavailable();
1985
1998
  if (readyAdmissionFailure) return readyAdmissionFailure;
1986
1999
  const readyRuntimeFailure = runtimeUnavailable();
@@ -2025,6 +2038,9 @@ export const hostCommands: HostCommands = {
2025
2038
 
2026
2039
  async channelDeliver(ctx, taskId, agentId, text, origin) {
2027
2040
  logCommand(ctx, "channelDeliver", taskId, agentId);
2041
+ if (credentialsMode() === "pending") {
2042
+ return switchingToCloudCredentials();
2043
+ }
2028
2044
  const runtimeFailure = runtimeUnavailable();
2029
2045
  if (runtimeFailure) return runtimeFailure;
2030
2046
  try {
package/src/main.ts CHANGED
@@ -302,6 +302,7 @@ import {
302
302
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
303
303
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
304
304
  isEngineAccountScope,
305
+ isTaskMachineRetireAckFrame,
305
306
  HOST_LOGS_PROTOCOL_FEATURE,
306
307
  HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
307
308
  MAX_ENGINE_ACCOUNT_LABEL_CHARS,
@@ -413,11 +414,29 @@ import {
413
414
  import { taskContainerBackend, taskContainerCli } from "../lib/task-container-cli";
414
415
  import { CloudCredentialRuntime } from "../lib/cloud-credential-runtime";
415
416
  import { createTaskFilesV1Consumer } from "../lib/credential-consumers/files";
416
- import { taskFilesV1Readiness } from "../lib/task-files-readiness";
417
417
  import {
418
+ TASK_FILES_SESSION_ROOT,
419
+ taskFilesV1Readiness,
420
+ } from "../lib/task-files-readiness";
421
+ import {
422
+ latchDamagedTaskFilesMachineRetirement,
418
423
  requestTaskFilesMachineRetirement,
424
+ setTaskFilesMachineRetirementHandler,
419
425
  taskFilesMachineRetirementReady,
420
426
  } from "../lib/task-files-machine-retirement";
427
+ import {
428
+ kernelBootId,
429
+ TaskFilesMachineRetirementOutbox,
430
+ TASK_FILES_MACHINE_RETIREMENT_RECORD,
431
+ taskMachinePeerProcessIds,
432
+ } from "../lib/task-files-machine-retirement-outbox";
433
+ import {
434
+ planFreshTaskMachineCredentialsActivation,
435
+ taskMachineCredentialsV1Required,
436
+ verifyFreshTaskMachineCredentialsActivation,
437
+ type TaskMachineCredentialsActivationDeps,
438
+ type TaskMachineCredentialsActivationPlan,
439
+ } from "../lib/task-machine-credentials-activation";
421
440
  import { ProjectEnvTaskReplicaManager } from "../lib/project-env-task-replica";
422
441
  import {
423
442
  purgeDefaultEngineSlots,
@@ -669,27 +688,82 @@ try {
669
688
  );
670
689
  }
671
690
  migrateHostDb();
691
+ const taskMachineCredentialsRequired =
692
+ sidecarMode() && taskMachineCredentialsV1Required();
693
+ const taskFilesReadiness = taskFilesV1Readiness(sidecarMode());
694
+ const taskFilesMachineRetirementOutbox = sidecarMode()
695
+ ? new TaskFilesMachineRetirementOutbox({
696
+ // Root-only tmpfs survives runner restarts but not a machine reboot,
697
+ // exactly matching the retirement proof boundary. It also keeps data
698
+ // disk exhaustion from blocking the first durable handoff.
699
+ recordPath: join(
700
+ TASK_FILES_SESSION_ROOT,
701
+ TASK_FILES_MACHINE_RETIREMENT_RECORD,
702
+ ),
703
+ send: sendCredentialFrame,
704
+ log: (line) => console.warn(line),
705
+ })
706
+ : null;
707
+ if (taskFilesMachineRetirementOutbox !== null) {
708
+ setTaskFilesMachineRetirementHandler((request) => {
709
+ try {
710
+ return taskFilesMachineRetirementOutbox.handler(request);
711
+ } finally {
712
+ // The terminal latch closes before the handler runs. Withdraw Files-v1
713
+ // even if persisting the outbox record fails and the same exact handoff
714
+ // must be retried.
715
+ sendCapabilities();
716
+ }
717
+ });
718
+ if (taskFilesMachineRetirementOutbox.startup.kind === "pending") {
719
+ requestTaskFilesMachineRetirement(
720
+ taskFilesMachineRetirementOutbox.startup.request,
721
+ );
722
+ } else if (taskFilesMachineRetirementOutbox.startup.kind === "damaged") {
723
+ latchDamagedTaskFilesMachineRetirement(
724
+ taskFilesMachineRetirementOutbox.startup.error,
725
+ );
726
+ taskFilesMachineRetirementOutbox.quarantineDamagedStartup();
727
+ }
728
+ }
672
729
  // ADR-154 §13: the activation authority, before anything reads the mode
673
730
  // (the refreshers, the v0 lease, the runtime's recovery, the first
674
- // capability frame). It moves only owned runners in this release: a task
675
- // machine needs its run's Files session on v1 first, and the sign-in box
676
- // runs no task.
677
- const credentialsActivation = new CredentialsActivationAuthority({
731
+ // capability frame). A cloud-marked task machine starts pending here—before
732
+ // a v0 lease can be requested—and activates only after its local Files and
733
+ // exact-machine retirement boundaries are installed.
734
+ type CredentialsRetirementPlan =
735
+ | { kind: "owned-runner"; plan: RetirementPlan }
736
+ | TaskMachineCredentialsActivationPlan;
737
+ const credentialsActivation = new CredentialsActivationAuthority<CredentialsRetirementPlan>({
678
738
  recordPath: join(env.dataDir, CREDENTIALS_ACTIVATION_RECORD),
679
739
  floorClaimed: () => credentialsCapabilityFloorExists(),
680
- eligible: () => !sidecarMode() && !signInBoxMode(),
740
+ eligible: () =>
741
+ taskMachineCredentialsRequired
742
+ ? sidecarTaskId() !== null
743
+ : !sidecarMode() && !signInBoxMode(),
744
+ initiallyPending: () => taskMachineCredentialsRequired,
681
745
  ask: askCredentialsActivation,
682
746
  pauseLegacyStarts,
683
- planRetirement: () => planLegacyTaskRuntimeRetirement(retirementPlanDeps()),
747
+ planRetirement: planCredentialsRetirement,
684
748
  commitLocally: (record) => commitCredentialsSwitchLocally(record),
685
- retire: retireTaskRuntimesForCloudCredentials,
749
+ retire: retireCredentialsPlan,
686
750
  reportStopped: reportRetiredTasksStopped,
687
751
  claimFloor: claimCredentialsCapabilityFloor,
688
752
  leaveLegacy,
689
753
  purge: purgeCredentialStores,
690
754
  swept: cloudCredentialsSwept,
691
755
  sweep: recoverCloudCredentials,
692
- onActive: cloudCredentialsActive,
756
+ onActive: (how) => {
757
+ if (
758
+ sidecarMode() &&
759
+ (!taskFilesReadiness.ready || !taskFilesMachineRetirementReady())
760
+ ) {
761
+ throw new Error(
762
+ "the task machine cannot activate cloud credentials without its Files and retirement boundaries",
763
+ );
764
+ }
765
+ cloudCredentialsActive(how);
766
+ },
693
767
  // Pending again: the next capability frame advertises no v1.
694
768
  onActiveWithdrawn: () => sendCapabilities(),
695
769
  restart: (why) => {
@@ -914,7 +988,6 @@ if (signInBoxMode()) {
914
988
  // ADR-154: the composer for tasks on cloud credentials. Nothing runs on it
915
989
  // until the runner's activation authority makes cloud credentials its
916
990
  // tasks' only ones (lib/cloud-credential-host.ts).
917
- const taskFilesReadiness = taskFilesV1Readiness(sidecarMode());
918
991
  const taskFiles = sidecarMode()
919
992
  ? createTaskFilesV1Consumer({
920
993
  recoveredBinding: (assignment) => {
@@ -979,8 +1052,12 @@ if (sidecarMode()) {
979
1052
  }
980
1053
  setCloudCredentialRuntime(cloudCredentials);
981
1054
  // A runner that was already on cloud credentials follows them from here:
982
- // once its boot recovery sweep (the container runtime's own pass) is done.
983
- credentialsActivation.booted();
1055
+ // once its boot recovery sweep is done. Owned runners get that sweep from
1056
+ // container-runtime activation; a task machine has no container runtime, so
1057
+ // its authority must run the composer sweep itself before it can reattach a
1058
+ // surviving agent or ask for a new grant.
1059
+ if (sidecarMode()) credentialsActivation.sweepItself();
1060
+ else credentialsActivation.booted();
984
1061
  void cloudCredentialsRecovered().then(() => {
985
1062
  if (!stopping) void credentialsActivation.advance();
986
1063
  });
@@ -1641,6 +1718,7 @@ function sendCredentialFrame(frame: Parameters<typeof send>[1]): boolean {
1641
1718
  */
1642
1719
  function bridgeResumed(): void {
1643
1720
  credentialFramesReady = true;
1721
+ taskFilesMachineRetirementOutbox?.bridgeAuthenticated();
1644
1722
  const mode = credentialsMode();
1645
1723
  if (mode === "legacy") leaseManager?.reconnected();
1646
1724
  else if (mode === "active") {
@@ -1688,6 +1766,47 @@ function reportCredentialsPurged(): void {
1688
1766
  /** ADR-154 §13: what the retirement reads (lib/legacy-runtime-retirement.ts):
1689
1767
  * the task rows once, the identity fence over those same rows, the pinned
1690
1768
  * engine. */
1769
+ async function planCredentialsRetirement(): Promise<CredentialsRetirementPlan> {
1770
+ if (sidecarMode()) {
1771
+ return planFreshTaskMachineCredentialsActivation(
1772
+ taskMachineCredentialsActivationDeps(),
1773
+ );
1774
+ }
1775
+ return {
1776
+ kind: "owned-runner",
1777
+ plan: await planLegacyTaskRuntimeRetirement(retirementPlanDeps()),
1778
+ };
1779
+ }
1780
+
1781
+ async function retireCredentialsPlan(
1782
+ plan: CredentialsRetirementPlan,
1783
+ ): Promise<{ stopped: StoppedReport[]; problem: string | null }> {
1784
+ if (plan.kind === "owned-runner") {
1785
+ return retireTaskRuntimesForCloudCredentials(plan.plan);
1786
+ }
1787
+ verifyFreshTaskMachineCredentialsActivation(
1788
+ plan,
1789
+ taskMachineCredentialsActivationDeps(),
1790
+ );
1791
+ return { stopped: [], problem: null };
1792
+ }
1793
+
1794
+ function taskMachineCredentialsActivationDeps(): TaskMachineCredentialsActivationDeps {
1795
+ return {
1796
+ required: () => taskMachineCredentialsRequired,
1797
+ taskId: sidecarTaskId,
1798
+ machineBootId: kernelBootId,
1799
+ filesReady: () => taskFilesReadiness.ready,
1800
+ retirementReady: taskFilesMachineRetirementReady,
1801
+ taskRows: () =>
1802
+ getDb()
1803
+ .select({ taskId: schema.hostTasks.taskId })
1804
+ .from(schema.hostTasks)
1805
+ .all(),
1806
+ peerProcessIds: taskMachinePeerProcessIds,
1807
+ };
1808
+ }
1809
+
1691
1810
  function retirementPlanDeps(): RetirementPlanDeps {
1692
1811
  return {
1693
1812
  snapshot: () => {
@@ -2890,6 +3009,9 @@ async function connect(): Promise<void> {
2890
3009
  eventOutbox.ack(frame.seq);
2891
3010
  flushEvents();
2892
3011
  break;
3012
+ case "task.machine.retire.ack":
3013
+ taskFilesMachineRetirementOutbox?.acknowledge(frame);
3014
+ break;
2893
3015
  case "command":
2894
3016
  void handleCommand(socket, frame);
2895
3017
  break;
@@ -4632,6 +4754,7 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
4632
4754
  if (frame.kind === "event.ack" && typeof frame.seq === "number") {
4633
4755
  return { kind: "event.ack", seq: frame.seq };
4634
4756
  }
4757
+ if (isTaskMachineRetireAckFrame(frame)) return frame;
4635
4758
  if (
4636
4759
  frame.kind === "host.inventory.request" &&
4637
4760
  isSafeInventoryScanId(frame.scanId) &&
@@ -5203,6 +5326,7 @@ function rejectFrameDuringShutdown(
5203
5326
  case "credential.changed":
5204
5327
  case "credential.refresh.result":
5205
5328
  case "credentials.activate.result":
5329
+ case "task.machine.retire.ack":
5206
5330
  return false;
5207
5331
  }
5208
5332
  }
@@ -5802,6 +5926,7 @@ function requestShutdown(signal: NodeJS.Signals): void {
5802
5926
  void stopRemoteOperationManagers();
5803
5927
  stopMcpGatewaySubscription();
5804
5928
  stopAgentClisReadySubscription();
5929
+ taskFilesMachineRetirementOutbox?.stop();
5805
5930
  // ADR-154: no credential is served or taken after this; what was accepted
5806
5931
  // drains, bounded, and no cleanup starts (the next process's recovery runs
5807
5932
  // it). Exit waits for it.
package/src/protocol.ts CHANGED
@@ -2961,6 +2961,26 @@ export const MAX_REMOTE_HOST_MESSAGE_BYTES = 512;
2961
2961
  export const MAX_HOST_TASK_INVENTORY_PAGE_SIZE = 256;
2962
2962
  export const MAX_HOST_TASK_INVENTORY_SCAN_ID_CHARS = 128;
2963
2963
  export const MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS = 128;
2964
+ /** A refusal/staleness explanation is diagnostic only and must stay bounded
2965
+ * on the authenticated control socket. */
2966
+ export const MAX_TASK_MACHINE_RETIRE_ERROR_BYTES = 512;
2967
+ /** Files-v1 terminal failures are the only reasons a task sidecar may ask the
2968
+ * cloud to cold-stop its exact machine incarnation. Keep this vocabulary
2969
+ * closed: the cloud persists it as audit provenance. */
2970
+ export const TASK_MACHINE_RETIRE_REASONS = [
2971
+ "setup-unproven",
2972
+ "visibility-unproven",
2973
+ "mount-retirement-unproven",
2974
+ ] as const;
2975
+ export type TaskMachineRetireReason =
2976
+ (typeof TASK_MACHINE_RETIRE_REASONS)[number];
2977
+ export const TASK_MACHINE_RETIRE_OUTCOMES = [
2978
+ "accepted",
2979
+ "stale",
2980
+ "refused",
2981
+ ] as const;
2982
+ export type TaskMachineRetireOutcome =
2983
+ (typeof TASK_MACHINE_RETIRE_OUTCOMES)[number];
2964
2984
  /**
2965
2985
  * A redeem response carrying this exact header proves the cloud checks a spent
2966
2986
  * token before expiry. That lets a new CLI distinguish a definitive 404/410
@@ -3561,6 +3581,65 @@ export function isHostOpId(value: unknown): value is HostOpId {
3561
3581
  );
3562
3582
  }
3563
3583
 
3584
+ function isTaskMachineRetireTaskId(value: unknown): value is string {
3585
+ return (
3586
+ typeof value === "string" &&
3587
+ value.length > 0 &&
3588
+ value.length <= MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS &&
3589
+ /^[a-z0-9][a-z0-9_-]*$/.test(value)
3590
+ );
3591
+ }
3592
+
3593
+ function isTaskMachineRetireRunEpoch(value: unknown): value is number {
3594
+ return Number.isSafeInteger(value) && (value as number) > 0;
3595
+ }
3596
+
3597
+ export function isTaskMachineRetireFrame(
3598
+ value: unknown,
3599
+ ): value is TaskMachineRetireFrame {
3600
+ const frame = exactWireObject(value, [
3601
+ "kind",
3602
+ "opId",
3603
+ "taskId",
3604
+ "runEpoch",
3605
+ "reason",
3606
+ ]);
3607
+ return Boolean(
3608
+ frame &&
3609
+ frame.kind === "task.machine.retire" &&
3610
+ isHostOpId(frame.opId) &&
3611
+ isTaskMachineRetireTaskId(frame.taskId) &&
3612
+ isTaskMachineRetireRunEpoch(frame.runEpoch) &&
3613
+ (TASK_MACHINE_RETIRE_REASONS as readonly unknown[]).includes(
3614
+ frame.reason,
3615
+ ),
3616
+ );
3617
+ }
3618
+
3619
+ export function isTaskMachineRetireAckFrame(
3620
+ value: unknown,
3621
+ ): value is TaskMachineRetireAckFrame {
3622
+ const frame = exactWireObject(
3623
+ value,
3624
+ ["kind", "opId", "taskId", "runEpoch", "outcome"],
3625
+ ["error"],
3626
+ );
3627
+ return Boolean(
3628
+ frame &&
3629
+ frame.kind === "task.machine.retire.ack" &&
3630
+ isHostOpId(frame.opId) &&
3631
+ isTaskMachineRetireTaskId(frame.taskId) &&
3632
+ isTaskMachineRetireRunEpoch(frame.runEpoch) &&
3633
+ (TASK_MACHINE_RETIRE_OUTCOMES as readonly unknown[]).includes(
3634
+ frame.outcome,
3635
+ ) &&
3636
+ (frame.error === undefined ||
3637
+ (typeof frame.error === "string" &&
3638
+ utf8ByteLength(frame.error) <=
3639
+ MAX_TASK_MACHINE_RETIRE_ERROR_BYTES)),
3640
+ );
3641
+ }
3642
+
3564
3643
  export function isHostLogsStartFrame(
3565
3644
  value: unknown,
3566
3645
  ): value is HostLogsStartFrame {
@@ -4470,6 +4549,31 @@ export type GitHubRepositoryAccessErrorCode =
4470
4549
  | "github_unavailable"
4471
4550
  | "invalid_response";
4472
4551
 
4552
+ /**
4553
+ * A one-task sidecar cannot prove a Files-v1 boundary closed in this boot, so
4554
+ * it asks the cloud to stop the exact EC2 incarnation. `runEpoch` fences a
4555
+ * delayed request from a successor assignment; the authenticated connection
4556
+ * supplies the host identity and is deliberately not sender-controlled here.
4557
+ */
4558
+ export interface TaskMachineRetireFrame {
4559
+ kind: "task.machine.retire";
4560
+ opId: string;
4561
+ taskId: string;
4562
+ runEpoch: number;
4563
+ reason: TaskMachineRetireReason;
4564
+ }
4565
+
4566
+ /** Durable acceptance, not completion: after `accepted` the cloud owns
4567
+ * retrying the persisted exact-instance stop even if this socket disappears. */
4568
+ export interface TaskMachineRetireAckFrame {
4569
+ kind: "task.machine.retire.ack";
4570
+ opId: string;
4571
+ taskId: string;
4572
+ runEpoch: number;
4573
+ outcome: TaskMachineRetireOutcome;
4574
+ error?: string;
4575
+ }
4576
+
4473
4577
  export type CloudToHost =
4474
4578
  | {
4475
4579
  kind: "command";
@@ -4633,6 +4737,7 @@ export type CloudToHost =
4633
4737
  | CredentialChangedFrame
4634
4738
  | CredentialRefreshResultFrame
4635
4739
  | CredentialsActivateResultFrame
4740
+ | TaskMachineRetireAckFrame
4636
4741
  | McpCaptureProbeFrame
4637
4742
  | McpCaptureCompleteFrame
4638
4743
  | McpCaptureCancelFrame
@@ -4751,6 +4856,7 @@ export type HostToCloud =
4751
4856
  | CredentialRefreshFrame
4752
4857
  | CredentialsActivateFrame
4753
4858
  | CredentialsActivatedFrame
4859
+ | TaskMachineRetireFrame
4754
4860
  | McpCaptureAckFrame
4755
4861
  | PersonCaptureAckFrame
4756
4862
  | GithubRevokeAckFrame