@runuai/host 0.9.155 → 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/lib/compose-network-recovery.ts +283 -0
- package/lib/credentials-activation-authority.ts +25 -10
- package/lib/orchestrator.ts +35 -0
- package/lib/standard-image.ts +18 -0
- package/lib/task-environment/self.ts +19 -5
- package/lib/task-files-machine-retirement-outbox.ts +521 -0
- package/lib/task-files-machine-retirement.ts +33 -5
- package/lib/task-machine-credentials-activation.ts +93 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +21 -2
- package/src/cloud-credentials.ts +9 -0
- package/src/index.ts +53 -1
- package/src/main.ts +137 -12
- package/src/protocol.ts +106 -0
package/scripts/agent/task-up.sh
CHANGED
|
@@ -2607,8 +2607,27 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
|
2607
2607
|
fi
|
|
2608
2608
|
else
|
|
2609
2609
|
step "COMPOSE_UP_FAILED" "docker compose up -d"
|
|
2610
|
-
|
|
2611
|
-
|
|
2610
|
+
compose_up_error=""
|
|
2611
|
+
# Keep the diagnostic in memory only, but bound it: a broken plugin or
|
|
2612
|
+
# daemon must not make task-up retain an arbitrarily large stderr stream.
|
|
2613
|
+
# Docker's allocation error is emitted at the end of Compose's failure.
|
|
2614
|
+
if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
|
|
2615
|
+
| docker compose -p "$compose_project" \
|
|
2616
|
+
-f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
|
|
2617
|
+
2>&1 >/dev/null | tail -c 65536); then
|
|
2618
|
+
[ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
|
|
2619
|
+
require_docker_after_failed_step "docker compose up -d"
|
|
2620
|
+
if printf '%s\n' "$compose_up_error" \
|
|
2621
|
+
| grep -Fq 'all predefined address pools have been fully subnetted'; then
|
|
2622
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2623
|
+
"Docker has no subnet left for this task. Uai already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
|
|
2624
|
+
"docker compose up -d"
|
|
2625
|
+
fi
|
|
2626
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2627
|
+
"Uai could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
|
|
2628
|
+
"docker compose up -d"
|
|
2629
|
+
fi
|
|
2630
|
+
unset compose_up_error
|
|
2612
2631
|
fi
|
|
2613
2632
|
|
|
2614
2633
|
# Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
|
package/src/cloud-credentials.ts
CHANGED
|
@@ -31,6 +31,15 @@ export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
|
|
|
31
31
|
*/
|
|
32
32
|
export const HOST_TASK_FILES_LEASE_FEATURE = "host-task-files-lease-v1";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Set to `1` in a Uai Cloud task machine's root-only launch state
|
|
36
|
+
* (`/etc/uai/sidecar.env`) when the cloud launched it on cloud credentials
|
|
37
|
+
* (ADR-154 §16; the cloud records the same machine on its floor). Its runner
|
|
38
|
+
* then runs nothing on its own credentials: it is pending from process start
|
|
39
|
+
* until it activates, and never falls back to them.
|
|
40
|
+
*/
|
|
41
|
+
export const TASK_MACHINE_CREDENTIALS_V1_ENV = "UAI_TASK_MACHINE_CREDENTIALS_V1";
|
|
42
|
+
|
|
34
43
|
// ---------------------------------------------------------------------------
|
|
35
44
|
// Parameter layout
|
|
36
45
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,7 @@ import {
|
|
|
126
126
|
waitForContainerRuntimeOperational,
|
|
127
127
|
} from "../lib/container-runtime";
|
|
128
128
|
import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
|
|
129
|
+
import { reclaimUnusedUaiComposeNetworks } from "../lib/compose-network-recovery";
|
|
129
130
|
import {
|
|
130
131
|
machineTasksEnabled,
|
|
131
132
|
provisionTaskEnvironment,
|
|
@@ -345,7 +346,7 @@ function untilLeavingLegacy<T>(legacy: LegacyStartPermit | null, wait: Promise<T
|
|
|
345
346
|
|
|
346
347
|
/** A start the runner does not take while it switches its tasks to cloud
|
|
347
348
|
* credentials: the cloud starts it again shortly. */
|
|
348
|
-
function switchingToCloudCredentials():
|
|
349
|
+
function switchingToCloudCredentials(): HostCommandResult<never> {
|
|
349
350
|
return {
|
|
350
351
|
ok: false,
|
|
351
352
|
code: HostErrorCode.HostUnavailable,
|
|
@@ -616,6 +617,48 @@ async function taskUpOn(
|
|
|
616
617
|
);
|
|
617
618
|
}
|
|
618
619
|
}
|
|
620
|
+
// Every local Docker task consumes one daemon address-pool subnet. A task
|
|
621
|
+
// that failed after Compose created its network can retain an empty subnet
|
|
622
|
+
// while it waits for Resume. Reclaim only networks whose trusted host row,
|
|
623
|
+
// exact Docker identity and two empty-container inventories all agree. The
|
|
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.
|
|
627
|
+
if (
|
|
628
|
+
!sidecarMode() &&
|
|
629
|
+
!machineTasksEnabled() &&
|
|
630
|
+
pinnedContainerRuntimeProvider() === "docker"
|
|
631
|
+
) {
|
|
632
|
+
try {
|
|
633
|
+
const networkRecovery = await reclaimUnusedUaiComposeNetworks(
|
|
634
|
+
orchestrator,
|
|
635
|
+
);
|
|
636
|
+
if (networkRecovery.removed > 0) {
|
|
637
|
+
console.warn(
|
|
638
|
+
`[host-agent] reclaimed ${String(networkRecovery.removed)} empty UAI task ` +
|
|
639
|
+
`network(s) before starting ${input.task.id}`,
|
|
640
|
+
);
|
|
641
|
+
} else if (networkRecovery.inventory === "invalid") {
|
|
642
|
+
console.warn(
|
|
643
|
+
"[host-agent] skipped task-network recovery because Docker returned an invalid network inventory",
|
|
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
|
+
}
|
|
652
|
+
} catch (error) {
|
|
653
|
+
// Best-effort capacity recovery must not block a task when Docker still
|
|
654
|
+
// has room. `docker compose up` remains the authoritative allocation.
|
|
655
|
+
console.warn(
|
|
656
|
+
`[host-agent] task-network recovery was unavailable: ${
|
|
657
|
+
error instanceof Error ? error.message : String(error)
|
|
658
|
+
}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
619
662
|
return orchestrator.runTaskLifecycle(input.task.id, releasingHold(cloud, async () => {
|
|
620
663
|
// A direct duplicate teardown may still be draining operations that
|
|
621
664
|
// target this task's stable compose/container names. Never recreate
|
|
@@ -1940,11 +1983,17 @@ export const hostCommands: HostCommands = {
|
|
|
1940
1983
|
|
|
1941
1984
|
async channelEnsure(ctx, input) {
|
|
1942
1985
|
logCommand(ctx, "channelEnsure", input.taskId);
|
|
1986
|
+
if (credentialsMode() === "pending") {
|
|
1987
|
+
return switchingToCloudCredentials();
|
|
1988
|
+
}
|
|
1943
1989
|
const admissionFailure = managedMaintenanceUnavailable();
|
|
1944
1990
|
if (admissionFailure) return admissionFailure;
|
|
1945
1991
|
const runtimeFailure = runtimeUnavailable();
|
|
1946
1992
|
if (runtimeFailure) return runtimeFailure;
|
|
1947
1993
|
await agentClisReady;
|
|
1994
|
+
if (credentialsMode() === "pending") {
|
|
1995
|
+
return switchingToCloudCredentials();
|
|
1996
|
+
}
|
|
1948
1997
|
const readyAdmissionFailure = managedMaintenanceUnavailable();
|
|
1949
1998
|
if (readyAdmissionFailure) return readyAdmissionFailure;
|
|
1950
1999
|
const readyRuntimeFailure = runtimeUnavailable();
|
|
@@ -1989,6 +2038,9 @@ export const hostCommands: HostCommands = {
|
|
|
1989
2038
|
|
|
1990
2039
|
async channelDeliver(ctx, taskId, agentId, text, origin) {
|
|
1991
2040
|
logCommand(ctx, "channelDeliver", taskId, agentId);
|
|
2041
|
+
if (credentialsMode() === "pending") {
|
|
2042
|
+
return switchingToCloudCredentials();
|
|
2043
|
+
}
|
|
1992
2044
|
const runtimeFailure = runtimeUnavailable();
|
|
1993
2045
|
if (runtimeFailure) return runtimeFailure;
|
|
1994
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).
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
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: () =>
|
|
740
|
+
eligible: () =>
|
|
741
|
+
taskMachineCredentialsRequired
|
|
742
|
+
? sidecarTaskId() !== null
|
|
743
|
+
: !sidecarMode() && !signInBoxMode(),
|
|
744
|
+
initiallyPending: () => taskMachineCredentialsRequired,
|
|
681
745
|
ask: askCredentialsActivation,
|
|
682
746
|
pauseLegacyStarts,
|
|
683
|
-
planRetirement:
|
|
747
|
+
planRetirement: planCredentialsRetirement,
|
|
684
748
|
commitLocally: (record) => commitCredentialsSwitchLocally(record),
|
|
685
|
-
retire:
|
|
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:
|
|
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
|
|
983
|
-
|
|
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
|