@runuai/host 0.9.70 → 0.9.71
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/agents/transport.ts +9 -0
- package/lib/task-environment/index.ts +56 -2
- package/lib/task-environment/machine-task-up.ts +333 -0
- package/lib/task-environment/machine.ts +50 -19
- package/lib/transcript.ts +83 -5
- package/package.json +1 -1
- package/src/index.ts +106 -11
package/lib/agents/transport.ts
CHANGED
|
@@ -106,6 +106,15 @@ function durableEnabled(): boolean {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
109
|
+
// ADR-121: machine-backed sessions ride the environment's own transport
|
|
110
|
+
// (ssh streaming) directly. The container-runtime preflight is a
|
|
111
|
+
// docker/apple concern a machine task must not trip over, and durable
|
|
112
|
+
// sessions poll host-FS files a machine does not share — they return for
|
|
113
|
+
// machines with an ssh-tail backend.
|
|
114
|
+
if (opts.environment.descriptor.locator.provider === "machine") {
|
|
115
|
+
clearCurrentSession(opts.taskId, opts.agentId);
|
|
116
|
+
return directEnvironmentTransport(opts);
|
|
117
|
+
}
|
|
109
118
|
// Session creation can happen after channel/task lifecycle queues drain, well
|
|
110
119
|
// after the command-level runtime preflight. Recheck at the actual attach or
|
|
111
120
|
// spawn boundary so a cached ready verdict cannot launch container work.
|
|
@@ -22,6 +22,12 @@ import {
|
|
|
22
22
|
type DockerTaskEnvironmentRecoveryDriver,
|
|
23
23
|
type DockerMachineIdentity,
|
|
24
24
|
} from "./docker";
|
|
25
|
+
import { createAwsMachineProvider } from "../machine-provider-aws";
|
|
26
|
+
import { createLocalMachineProvider } from "../machine-provider-local";
|
|
27
|
+
import { ensureMachineKeyPair } from "../machine-keys";
|
|
28
|
+
import { requestAccessToken } from "../github-tokens";
|
|
29
|
+
import { createMachineTaskEnvironmentProvider } from "./machine";
|
|
30
|
+
import { createMachineHostTaskEnvironmentProvider } from "./machine-task-up";
|
|
25
31
|
import { TaskEnvironmentRegistry } from "./registry";
|
|
26
32
|
import {
|
|
27
33
|
parseTaskEnvironmentLocator,
|
|
@@ -113,6 +119,53 @@ const appleProvider = createAppleContainerTaskEnvironmentProvider<
|
|
|
113
119
|
|
|
114
120
|
registry.register(appleProvider);
|
|
115
121
|
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// ADR-121: machine-backed tasks (ephemeral VM per task). Opt-in via
|
|
124
|
+
// UAI_MACHINE_TASKS=1 — the guinea-pig flag; selection stays coarse (whole
|
|
125
|
+
// host) until machine-backing becomes a per-task property.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function machineTasksEnabled(): boolean {
|
|
129
|
+
return process.env.UAI_MACHINE_TASKS === "1";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function machineBackend() {
|
|
133
|
+
if (process.env.UAI_MACHINE_PROVIDER === "aws") {
|
|
134
|
+
const region = process.env.UAI_AWS_REGION;
|
|
135
|
+
if (!region) {
|
|
136
|
+
throw new Error("UAI_MACHINE_PROVIDER=aws requires UAI_AWS_REGION");
|
|
137
|
+
}
|
|
138
|
+
return createAwsMachineProvider({
|
|
139
|
+
region,
|
|
140
|
+
subnetId: process.env.UAI_AWS_SUBNET_ID,
|
|
141
|
+
securityGroupId: process.env.UAI_AWS_SECURITY_GROUP_ID,
|
|
142
|
+
iamInstanceProfileArn: process.env.UAI_AWS_INSTANCE_PROFILE_ARN,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return createLocalMachineProvider();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const machineEnvironmentProvider = createMachineTaskEnvironmentProvider({
|
|
149
|
+
machines: machineBackend(),
|
|
150
|
+
taskControlDir: (taskId) => taskDir(taskId),
|
|
151
|
+
mintKeyPair: ensureMachineKeyPair,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const machineProvider = createMachineHostTaskEnvironmentProvider({
|
|
155
|
+
environment: machineEnvironmentProvider,
|
|
156
|
+
taskControlDir: (taskId) => taskDir(taskId),
|
|
157
|
+
machineImage: () => process.env.UAI_MACHINE_IMAGE ?? "uai-machine:dev",
|
|
158
|
+
machineCpus: () => Number(process.env.UAI_MACHINE_CPUS ?? "2"),
|
|
159
|
+
machineMemoryMiB: () => Number(process.env.UAI_MACHINE_MEMORY_MIB ?? "4096"),
|
|
160
|
+
// Local machines on macOS need loopback-published ssh (bridge IPs are not
|
|
161
|
+
// host-routable there); cloud machines and Linux dial the address directly.
|
|
162
|
+
publishSsh: () =>
|
|
163
|
+
process.env.UAI_MACHINE_PROVIDER !== "aws" && process.platform === "darwin",
|
|
164
|
+
githubToken: (userId) => requestAccessToken(userId),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
registry.register(machineProvider);
|
|
168
|
+
|
|
116
169
|
/**
|
|
117
170
|
* Bind the Docker provider's private recovery implementation. Kept as a
|
|
118
171
|
* replaceable module seam so HMR can reload the orchestrator without leaving
|
|
@@ -129,8 +182,9 @@ export function provisionTaskEnvironment(
|
|
|
129
182
|
credentials: TaskUpCredentials = {},
|
|
130
183
|
onPrepared?: (locator: TaskEnvironmentLocator) => Promise<void>,
|
|
131
184
|
): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
|
|
132
|
-
const provider =
|
|
133
|
-
|
|
185
|
+
const provider = machineTasksEnabled()
|
|
186
|
+
? machineProvider
|
|
187
|
+
: requireMachineIdentity().backend === "apple-container"
|
|
134
188
|
? appleProvider
|
|
135
189
|
: dockerProvider;
|
|
136
190
|
return provider.provision({
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the machine-backed task-up adapter.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the host's task contract (TaskLaunchInput → TaskUpResult) onto the
|
|
5
|
+
* pure machine environment provider (MachineTaskInput → descriptor). This is
|
|
6
|
+
* where task semantics live for machines: project clones happen IN the
|
|
7
|
+
* machine's world with a one-shot credential over stdin, the task branch is
|
|
8
|
+
* created off the clone's default HEAD, and repo problems degrade into an
|
|
9
|
+
* initWarning system note instead of failing the task (a machine with an
|
|
10
|
+
* empty workspace and a clear message beats a respawn loop).
|
|
11
|
+
*
|
|
12
|
+
* Compose-era result fields are satisfied honestly rather than faked:
|
|
13
|
+
* `composeProject` carries the machine id (the one durable runtime name this
|
|
14
|
+
* task owns) and `worktreePath` carries the host-side task control dir (the
|
|
15
|
+
* directory that really does hold this task's host-side state — its machine
|
|
16
|
+
* key). Consumers that need the workspace go through the environment handle.
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
TaskDownResult,
|
|
20
|
+
TaskLaunchInput,
|
|
21
|
+
TaskUpCredentials,
|
|
22
|
+
TaskUpResult,
|
|
23
|
+
} from "../agent";
|
|
24
|
+
import {
|
|
25
|
+
MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
26
|
+
parseMachineTaskEnvironmentLocator,
|
|
27
|
+
type MachineTaskInput,
|
|
28
|
+
} from "./machine";
|
|
29
|
+
import type {
|
|
30
|
+
TaskEnvironmentDescriptor,
|
|
31
|
+
TaskEnvironmentHandle,
|
|
32
|
+
TaskEnvironmentProvider,
|
|
33
|
+
TaskEnvironmentProvisionRequest,
|
|
34
|
+
TaskEnvironmentProvisioned,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
/** In-machine clone budget per project. */
|
|
38
|
+
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
|
39
|
+
const GIT_STEP_TIMEOUT_MS = 60_000;
|
|
40
|
+
const STEP_OUTPUT_BYTES = 256 * 1024;
|
|
41
|
+
|
|
42
|
+
export interface MachineHostTaskUpDeps {
|
|
43
|
+
/** The pure machine environment provider (createMachineTaskEnvironmentProvider). */
|
|
44
|
+
environment: TaskEnvironmentProvider<
|
|
45
|
+
MachineTaskInput,
|
|
46
|
+
void,
|
|
47
|
+
TaskEnvironmentDescriptor
|
|
48
|
+
>;
|
|
49
|
+
/** Host-side control dir for the task (holds the machine key). */
|
|
50
|
+
taskControlDir(taskId: string): string;
|
|
51
|
+
machineImage(): string;
|
|
52
|
+
machineCpus(): number;
|
|
53
|
+
machineMemoryMiB(): number;
|
|
54
|
+
/** macOS-local machines need loopback-published ssh; cloud machines don't. */
|
|
55
|
+
publishSsh(): boolean;
|
|
56
|
+
/** The task owner's GitHub access token, or null when GitHub is not
|
|
57
|
+
* connected (public/anonymous clone fallback). */
|
|
58
|
+
githubToken(userId: string): Promise<{ accessToken: string } | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
|
|
62
|
+
export function isGithubRepoUrl(input: string): boolean {
|
|
63
|
+
const value = input.trim();
|
|
64
|
+
if (/^(?:[^@/]+@)?(?:www\.)?github\.com:/i.test(value)) return true;
|
|
65
|
+
if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) return true;
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(value);
|
|
68
|
+
return /^(?:www\.)?github\.com$/i.test(url.hostname);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Canonical HTTPS form for GitHub repos (shorthand and ssh forms included);
|
|
75
|
+
* non-GitHub URLs pass through untouched. */
|
|
76
|
+
export function machineCloneUrl(repoUrl: string): string {
|
|
77
|
+
const value = repoUrl.trim();
|
|
78
|
+
const ssh = /^(?:[^@/]+@)?(?:www\.)?github\.com:(.+)$/i.exec(value);
|
|
79
|
+
if (ssh?.[1]) {
|
|
80
|
+
return `https://github.com/${ssh[1].replace(/\.git$/i, "")}.git`;
|
|
81
|
+
}
|
|
82
|
+
if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) {
|
|
83
|
+
return `https://github.com/${value.replace(/\/+$/, "").replace(/\.git$/i, "")}.git`;
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Workspace-safe project directory name. The slug is cloud-normalized, but
|
|
89
|
+
* the machine path re-checks: a path-capable slug must never reach argv. */
|
|
90
|
+
function projectDirectoryName(slug: string): string {
|
|
91
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
|
|
92
|
+
throw new Error(`unsafe project slug for machine clone: ${slug}`);
|
|
93
|
+
}
|
|
94
|
+
return slug;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface StepResult {
|
|
98
|
+
exitCode: number | null;
|
|
99
|
+
stderr: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One-shot exec with stdin: spawn, deliver the secret, drain, await exit.
|
|
103
|
+
* The secret rides the ssh stream only — never argv, never the persisted
|
|
104
|
+
* env, never machine disk. */
|
|
105
|
+
async function execWithStdin(
|
|
106
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
107
|
+
argv: readonly [string, ...string[]],
|
|
108
|
+
stdin: string,
|
|
109
|
+
timeoutMs: number,
|
|
110
|
+
): Promise<StepResult> {
|
|
111
|
+
const proc = await handle.spawn({
|
|
112
|
+
argv,
|
|
113
|
+
inheritEnv: [],
|
|
114
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
115
|
+
timeoutMs,
|
|
116
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
117
|
+
});
|
|
118
|
+
let stderr = "";
|
|
119
|
+
const drainOut = (async () => {
|
|
120
|
+
for await (const chunk of proc.stdout) {
|
|
121
|
+
void chunk; // drained; clone progress is not retained
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
const drainErr = (async () => {
|
|
125
|
+
for await (const chunk of proc.stderr) {
|
|
126
|
+
stderr = (stderr + Buffer.from(chunk).toString("utf8")).slice(-8192);
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
await proc.write(Buffer.from(stdin));
|
|
130
|
+
await proc.closeInput();
|
|
131
|
+
const exit = await proc.completion;
|
|
132
|
+
await Promise.allSettled([drainOut, drainErr]);
|
|
133
|
+
return { exitCode: exit.exitCode, stderr: stderr.trim() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function execStep(
|
|
137
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
138
|
+
argv: readonly [string, ...string[]],
|
|
139
|
+
timeoutMs: number,
|
|
140
|
+
): Promise<StepResult> {
|
|
141
|
+
const result = await handle.exec({
|
|
142
|
+
argv,
|
|
143
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
144
|
+
timeoutMs,
|
|
145
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
exitCode: result.exitCode,
|
|
149
|
+
stderr: Buffer.from(result.stderr).toString("utf8").trim().slice(-8192),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The remote clone script for the credentialed path. The token is read from
|
|
154
|
+
* stdin into the clone process env; /usr/local/bin/uai-git-askpass (baked in
|
|
155
|
+
* the machine image) answers git's prompts from it. */
|
|
156
|
+
const CREDENTIALED_CLONE_SCRIPT =
|
|
157
|
+
'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass GIT_TERMINAL_PROMPT=0 && exec git clone -- "$1" "$2"';
|
|
158
|
+
|
|
159
|
+
async function cloneProject(
|
|
160
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
161
|
+
workspacePath: string,
|
|
162
|
+
project: { slug: string; repoUrl: string },
|
|
163
|
+
taskBranch: string,
|
|
164
|
+
owner: { name?: string; email?: string },
|
|
165
|
+
token: string | null,
|
|
166
|
+
): Promise<string | null> {
|
|
167
|
+
const destination = `${workspacePath}/${projectDirectoryName(project.slug)}`;
|
|
168
|
+
const url = machineCloneUrl(project.repoUrl);
|
|
169
|
+
const useToken = token !== null && isGithubRepoUrl(project.repoUrl);
|
|
170
|
+
|
|
171
|
+
// Resume/retry idempotency: a repository that already exists is the task's
|
|
172
|
+
// live work — never re-clone over it, and leave its branch state alone.
|
|
173
|
+
const already = await execStep(
|
|
174
|
+
handle,
|
|
175
|
+
["/bin/sh", "-c", 'test -e "$1/.git"', "check", destination],
|
|
176
|
+
GIT_STEP_TIMEOUT_MS,
|
|
177
|
+
);
|
|
178
|
+
if (already.exitCode === 0) return null;
|
|
179
|
+
|
|
180
|
+
const clone = useToken
|
|
181
|
+
? await execWithStdin(
|
|
182
|
+
handle,
|
|
183
|
+
["/bin/sh", "-c", CREDENTIALED_CLONE_SCRIPT, "clone", url, destination],
|
|
184
|
+
`${token}\n`,
|
|
185
|
+
CLONE_TIMEOUT_MS,
|
|
186
|
+
)
|
|
187
|
+
: await execStep(
|
|
188
|
+
handle,
|
|
189
|
+
["git", "clone", "--", url, destination],
|
|
190
|
+
CLONE_TIMEOUT_MS,
|
|
191
|
+
);
|
|
192
|
+
if (clone.exitCode !== 0) {
|
|
193
|
+
return `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Task branch off the clone's default HEAD (matches the compose path's
|
|
197
|
+
// `-b <branch> origin/<default>`). -B keeps an empty-repo clone (unborn
|
|
198
|
+
// HEAD) and a re-run both working; failure degrades, never fails the task.
|
|
199
|
+
const branch = await execStep(
|
|
200
|
+
handle,
|
|
201
|
+
["git", "-C", destination, "checkout", "-B", taskBranch],
|
|
202
|
+
GIT_STEP_TIMEOUT_MS,
|
|
203
|
+
);
|
|
204
|
+
if (branch.exitCode !== 0) {
|
|
205
|
+
return `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Repo-local commit identity so agent commits work out of the box.
|
|
209
|
+
if (owner.name) {
|
|
210
|
+
await execStep(
|
|
211
|
+
handle,
|
|
212
|
+
["git", "-C", destination, "config", "user.name", owner.name],
|
|
213
|
+
GIT_STEP_TIMEOUT_MS,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (owner.email) {
|
|
217
|
+
await execStep(
|
|
218
|
+
handle,
|
|
219
|
+
["git", "-C", destination, "config", "user.email", owner.email],
|
|
220
|
+
GIT_STEP_TIMEOUT_MS,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Wrap the void-teardown machine handle so registry consumers get the
|
|
227
|
+
* TaskDownResult contract the compose providers speak. */
|
|
228
|
+
function withTaskDownResult(
|
|
229
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
230
|
+
): TaskEnvironmentHandle<TaskDownResult> {
|
|
231
|
+
// Explicit delegation, never Object.create: the machine handle is a class
|
|
232
|
+
// with private fields, and a prototype-borrowed method invoked on a foreign
|
|
233
|
+
// receiver throws on its first #member access.
|
|
234
|
+
return {
|
|
235
|
+
descriptor: handle.descriptor,
|
|
236
|
+
durableIdentity: handle.durableIdentity,
|
|
237
|
+
readWorkspaceFile: (path) => handle.readWorkspaceFile(path),
|
|
238
|
+
writeWorkspaceFile: (path, bytes) => handle.writeWorkspaceFile(path, bytes),
|
|
239
|
+
exec: (request) => handle.exec(request),
|
|
240
|
+
spawn: (request) => handle.spawn(request),
|
|
241
|
+
spawnSession: (request) => handle.spawnSession(request),
|
|
242
|
+
launchDetachedSession: (request) => handle.launchDetachedSession(request),
|
|
243
|
+
copy: (request) => handle.copy(request),
|
|
244
|
+
ports: () => handle.ports(),
|
|
245
|
+
status: () => handle.status(),
|
|
246
|
+
recover: (context) => handle.recover(context),
|
|
247
|
+
stop: () => handle.stop(),
|
|
248
|
+
teardown: async (): Promise<TaskDownResult> => {
|
|
249
|
+
await handle.teardown();
|
|
250
|
+
return { status: "terminated" };
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function createMachineHostTaskEnvironmentProvider(
|
|
256
|
+
deps: MachineHostTaskUpDeps,
|
|
257
|
+
): TaskEnvironmentProvider<
|
|
258
|
+
TaskLaunchInput,
|
|
259
|
+
TaskUpCredentials,
|
|
260
|
+
TaskUpResult,
|
|
261
|
+
TaskDownResult
|
|
262
|
+
> {
|
|
263
|
+
return {
|
|
264
|
+
kind: MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
265
|
+
|
|
266
|
+
async provision(
|
|
267
|
+
request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
|
|
268
|
+
): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
|
|
269
|
+
const { input } = request;
|
|
270
|
+
const provisioned = await deps.environment.provision({
|
|
271
|
+
taskId: request.taskId,
|
|
272
|
+
input: {
|
|
273
|
+
image: deps.machineImage(),
|
|
274
|
+
cpus: deps.machineCpus(),
|
|
275
|
+
memoryMiB: deps.machineMemoryMiB(),
|
|
276
|
+
...(deps.publishSsh() ? { publishSsh: true } : {}),
|
|
277
|
+
},
|
|
278
|
+
credentials: undefined,
|
|
279
|
+
onPrepared: request.onPrepared,
|
|
280
|
+
});
|
|
281
|
+
const handle = provisioned.handle;
|
|
282
|
+
const workspacePath = handle.descriptor.workspacePath;
|
|
283
|
+
const warnings: string[] = [];
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const projects = [...input.projects].sort(
|
|
287
|
+
(a, b) => a.position - b.position,
|
|
288
|
+
);
|
|
289
|
+
const wantsGithub = projects.some((project) =>
|
|
290
|
+
isGithubRepoUrl(project.repoUrl),
|
|
291
|
+
);
|
|
292
|
+
const token = wantsGithub
|
|
293
|
+
? await deps.githubToken(input.task.ownerUserId)
|
|
294
|
+
: null;
|
|
295
|
+
for (const project of projects) {
|
|
296
|
+
const warning = await cloneProject(
|
|
297
|
+
handle,
|
|
298
|
+
workspacePath,
|
|
299
|
+
project,
|
|
300
|
+
input.task.branch,
|
|
301
|
+
{ name: input.ownerName, email: input.ownerEmail },
|
|
302
|
+
token?.accessToken ?? null,
|
|
303
|
+
);
|
|
304
|
+
if (warning) warnings.push(warning);
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
// Provisioning-infrastructure failures (ssh died, unsafe slug) tear
|
|
308
|
+
// the machine down rather than stranding an orphan; per-repo problems
|
|
309
|
+
// were already converted to warnings above and do not land here.
|
|
310
|
+
await handle.teardown().catch(() => {});
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const machineId = parseMachineTaskEnvironmentLocator(
|
|
315
|
+
handle.descriptor.locator,
|
|
316
|
+
).machineId;
|
|
317
|
+
return {
|
|
318
|
+
handle: withTaskDownResult(handle),
|
|
319
|
+
result: {
|
|
320
|
+
composeProject: machineId,
|
|
321
|
+
worktreePath: deps.taskControlDir(request.taskId),
|
|
322
|
+
...(warnings.length > 0
|
|
323
|
+
? { initWarning: warnings.join("\n") }
|
|
324
|
+
: {}),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
async reconstruct(locator) {
|
|
330
|
+
return withTaskDownResult(await deps.environment.reconstruct(locator));
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -340,12 +340,18 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
340
340
|
request: TaskEnvironmentSessionRequest,
|
|
341
341
|
): Promise<TaskEnvironmentProcess> {
|
|
342
342
|
assertTaskEnvironmentSessionRequest(request);
|
|
343
|
+
// Sessions default to the workspace the way container images default to
|
|
344
|
+
// WORKDIR /workspace — an agent CLI must start in the task's world.
|
|
345
|
+
const sessionRequest = {
|
|
346
|
+
...request,
|
|
347
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
348
|
+
};
|
|
343
349
|
const target = await this.#target();
|
|
344
350
|
return streamingCliExec(
|
|
345
351
|
this.#deps.spawn,
|
|
346
352
|
"ssh",
|
|
347
|
-
machineEnvironmentSshArgs(target,
|
|
348
|
-
|
|
353
|
+
machineEnvironmentSshArgs(target, sessionRequest, "interactive"),
|
|
354
|
+
sessionRequest,
|
|
349
355
|
);
|
|
350
356
|
}
|
|
351
357
|
|
|
@@ -353,14 +359,18 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
353
359
|
request: TaskEnvironmentDetachedSessionRequest,
|
|
354
360
|
): Promise<TaskEnvironmentExecResult> {
|
|
355
361
|
assertTaskEnvironmentSessionRequest(request);
|
|
362
|
+
const sessionRequest = {
|
|
363
|
+
...request,
|
|
364
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
365
|
+
};
|
|
356
366
|
const target = await this.#target();
|
|
357
367
|
return capturedCliExec(
|
|
358
368
|
this.#deps.spawn,
|
|
359
369
|
"ssh",
|
|
360
|
-
machineEnvironmentSshArgs(target,
|
|
370
|
+
machineEnvironmentSshArgs(target, sessionRequest, "detached"),
|
|
361
371
|
{
|
|
362
|
-
...
|
|
363
|
-
timeoutMs:
|
|
372
|
+
...sessionRequest,
|
|
373
|
+
timeoutMs: sessionRequest.launchTimeoutMs,
|
|
364
374
|
},
|
|
365
375
|
);
|
|
366
376
|
}
|
|
@@ -630,23 +640,44 @@ export function createMachineTaskEnvironmentProvider(
|
|
|
630
640
|
});
|
|
631
641
|
await request.onPrepared?.(locator);
|
|
632
642
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
await deps.machines.terminate(launched.id);
|
|
643
|
+
// Resume-aware launch: a machine may already exist for this task
|
|
644
|
+
// (host restart mid-provision, task resume after stop). Only a
|
|
645
|
+
// machine PROVEN to carry this task's ownership label may be reused;
|
|
646
|
+
// an unlabeled or foreign occupant of the name fails the provision —
|
|
647
|
+
// it is never adopted and never terminated from here.
|
|
648
|
+
const existing = await deps.machines.describe(machineId);
|
|
649
|
+
if (existing.state !== "absent" && existing.taskLabel !== taskId) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`machine name ${machineId} is occupied by a resource not labeled for task ${taskId}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (existing.state === "unknown") {
|
|
646
655
|
throw new Error(
|
|
647
|
-
`machine
|
|
656
|
+
`machine ${machineId} state is unknown: ${existing.detail ?? "no detail"}`,
|
|
648
657
|
);
|
|
649
658
|
}
|
|
659
|
+
if (existing.state === "absent") {
|
|
660
|
+
const launched = await deps.machines.launch({
|
|
661
|
+
taskId,
|
|
662
|
+
image: input.image,
|
|
663
|
+
cpus: input.cpus,
|
|
664
|
+
memoryMiB: input.memoryMiB,
|
|
665
|
+
authorizedPublicKey: keys.publicKey,
|
|
666
|
+
...(input.publishSsh ? { publishSsh: true } : {}),
|
|
667
|
+
});
|
|
668
|
+
if (launched.id !== machineId) {
|
|
669
|
+
// The backend named the machine something other than what the
|
|
670
|
+
// durable locator promises — tear it down rather than strand an
|
|
671
|
+
// untracked machine, then fail the provision loudly.
|
|
672
|
+
await deps.machines.terminate(launched.id);
|
|
673
|
+
throw new Error(
|
|
674
|
+
`machine backend named ${launched.id}, locator promised ${machineId}`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
} else if (existing.state === "stopped") {
|
|
678
|
+
await deps.machines.start(machineId);
|
|
679
|
+
}
|
|
680
|
+
// running/pending: fall through to the ssh-readiness proof below.
|
|
650
681
|
|
|
651
682
|
const handle = new MachineTaskEnvironmentHandle(locator, deps);
|
|
652
683
|
await waitForSsh(handle, deps.readyTimeoutMs ?? 120_000);
|
package/lib/transcript.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { resolve } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { taskWorkspaceDir } from "./env";
|
|
16
16
|
import { rewriteAttachmentRefs } from "./orchestrator";
|
|
17
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
17
18
|
import type { TranscriptTarget } from "../src/protocol";
|
|
18
19
|
|
|
19
20
|
/** Container path agents are pointed at. */
|
|
@@ -26,20 +27,97 @@ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
|
|
|
26
27
|
"chat-front": "chat-front.md",
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
function transcriptEntry(author: string, text: string): string | null {
|
|
31
|
+
// Rewrite cloud attachment URLs to the in-container path so an agent reading
|
|
32
|
+
// the transcript can open referenced files directly.
|
|
33
|
+
const body = rewriteAttachmentRefs(text).trim();
|
|
34
|
+
if (!body) return null;
|
|
35
|
+
return `## ${author}\n\n${body}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export function appendTranscript(
|
|
30
39
|
taskId: string,
|
|
31
40
|
author: string,
|
|
32
41
|
text: string,
|
|
33
42
|
targets: TranscriptTarget[],
|
|
34
43
|
): void {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const body = rewriteAttachmentRefs(text).trim();
|
|
38
|
-
if (!body) return;
|
|
44
|
+
const entry = transcriptEntry(author, text);
|
|
45
|
+
if (entry === null) return;
|
|
39
46
|
const dir = resolve(taskWorkspaceDir(taskId), ".uai");
|
|
40
47
|
mkdirSync(dir, { recursive: true });
|
|
41
|
-
const entry = `## ${author}\n\n${body}\n\n`;
|
|
42
48
|
for (const target of new Set(targets)) {
|
|
43
49
|
appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
|
|
44
50
|
}
|
|
45
51
|
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// ADR-121: environment-side transcript for machine-backed tasks. The
|
|
55
|
+
// workspace lives in the machine's world, so the append travels over the
|
|
56
|
+
// environment transport (entry via stdin). A per-task promise chain
|
|
57
|
+
// preserves message order the way the sync host-FS path did implicitly.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const environmentAppendChains = new Map<string, Promise<void>>();
|
|
61
|
+
|
|
62
|
+
async function appendOneViaEnvironment(
|
|
63
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
64
|
+
target: TranscriptTarget,
|
|
65
|
+
entry: string,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const path = `${environment.descriptor.workspacePath}/.uai/${TARGET_FILENAME[target]}`;
|
|
68
|
+
const proc = await environment.spawn({
|
|
69
|
+
argv: [
|
|
70
|
+
"/bin/sh",
|
|
71
|
+
"-c",
|
|
72
|
+
'mkdir -p "$(dirname "$1")" && cat >> "$1"',
|
|
73
|
+
"append",
|
|
74
|
+
path,
|
|
75
|
+
],
|
|
76
|
+
inheritEnv: [],
|
|
77
|
+
env: {},
|
|
78
|
+
timeoutMs: 30_000,
|
|
79
|
+
maxOutputBytes: 8 * 1024,
|
|
80
|
+
});
|
|
81
|
+
const drains = [
|
|
82
|
+
(async () => {
|
|
83
|
+
for await (const chunk of proc.stdout) void chunk;
|
|
84
|
+
})(),
|
|
85
|
+
(async () => {
|
|
86
|
+
for await (const chunk of proc.stderr) void chunk;
|
|
87
|
+
})(),
|
|
88
|
+
];
|
|
89
|
+
await proc.write(Buffer.from(entry));
|
|
90
|
+
await proc.closeInput();
|
|
91
|
+
const exit = await proc.completion;
|
|
92
|
+
await Promise.allSettled(drains);
|
|
93
|
+
if (exit.exitCode !== 0) {
|
|
94
|
+
throw new Error(`environment transcript append exited ${exit.exitCode}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function appendTranscriptViaEnvironment(
|
|
99
|
+
taskId: string,
|
|
100
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
101
|
+
author: string,
|
|
102
|
+
text: string,
|
|
103
|
+
targets: TranscriptTarget[],
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const entry = transcriptEntry(author, text);
|
|
106
|
+
if (entry === null) return Promise.resolve();
|
|
107
|
+
const previous = environmentAppendChains.get(taskId) ?? Promise.resolve();
|
|
108
|
+
const next = previous
|
|
109
|
+
.catch(() => {})
|
|
110
|
+
.then(async () => {
|
|
111
|
+
for (const target of new Set(targets)) {
|
|
112
|
+
await appendOneViaEnvironment(environment, target, entry);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
environmentAppendChains.set(taskId, next);
|
|
116
|
+
// Bound the map: forget the chain once it settles as the latest entry.
|
|
117
|
+
void next.finally(() => {
|
|
118
|
+
if (environmentAppendChains.get(taskId) === next) {
|
|
119
|
+
environmentAppendChains.delete(taskId);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return next;
|
|
123
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
} from "../lib/agent-cli";
|
|
20
20
|
import {
|
|
21
21
|
clearRefresh,
|
|
22
|
+
injectIntoContainer,
|
|
22
23
|
reconcileTaskGitAuth,
|
|
24
|
+
requestAccessToken,
|
|
23
25
|
} from "../lib/github-tokens";
|
|
24
26
|
import {
|
|
25
27
|
prepareTaskGithubGitCredential,
|
|
@@ -35,7 +37,10 @@ import {
|
|
|
35
37
|
missingRequiredKeys,
|
|
36
38
|
parseEnvSchema,
|
|
37
39
|
} from "../lib/env-schema";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
appendTranscript as writeTranscript,
|
|
42
|
+
appendTranscriptViaEnvironment,
|
|
43
|
+
} from "../lib/transcript";
|
|
39
44
|
import { buildTaskDiff } from "../lib/task-diff";
|
|
40
45
|
import {
|
|
41
46
|
deleteHostTask,
|
|
@@ -448,6 +453,37 @@ export const hostCommands: HostCommands = {
|
|
|
448
453
|
runtimeQuarantinedForRecreate = true;
|
|
449
454
|
}
|
|
450
455
|
}
|
|
456
|
+
} else if (
|
|
457
|
+
existingTask?.statusMirror === "running" &&
|
|
458
|
+
existingTask.environmentProvider === "machine"
|
|
459
|
+
) {
|
|
460
|
+
// ADR-121: a machine-backed task revalidates through its own
|
|
461
|
+
// provider — the compose fast path below speaks docker and would
|
|
462
|
+
// misread a machine row. Running machine → persisted success;
|
|
463
|
+
// anything else falls through to provisionTaskEnvironment, whose
|
|
464
|
+
// machine provider owns recovery semantics.
|
|
465
|
+
try {
|
|
466
|
+
const environment = await reconstructPersistedTaskEnvironment(
|
|
467
|
+
existingTask,
|
|
468
|
+
);
|
|
469
|
+
if (environment) {
|
|
470
|
+
const machineStatus = await environment.status();
|
|
471
|
+
if (machineStatus.state === "running") {
|
|
472
|
+
orchestrator.allowChannel(input.task.id);
|
|
473
|
+
return {
|
|
474
|
+
ok: true,
|
|
475
|
+
value: {
|
|
476
|
+
composeProject: existingTask.composeProject ?? "",
|
|
477
|
+
worktreePath: existingTask.worktreePath ?? "",
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
console.warn(
|
|
484
|
+
`[machine] task ${input.task.id}: running-row revalidation failed, continuing to provision: ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
451
487
|
} else if (existingTask?.statusMirror === "running") {
|
|
452
488
|
if (!existingTask.composeProject || !existingTask.worktreePath) {
|
|
453
489
|
return {
|
|
@@ -813,16 +849,33 @@ export const hostCommands: HostCommands = {
|
|
|
813
849
|
// and mark a running task as errored. The reconciler injects + schedules
|
|
814
850
|
// (or emits a system note on failure) on its own; the agents come up
|
|
815
851
|
// regardless and the token lands well before the first `gh` call.
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
)
|
|
825
|
-
|
|
852
|
+
// ADR-121: machine-backed tasks take a direct environment injection —
|
|
853
|
+
// the compose reconciler would probe for an app container, find none,
|
|
854
|
+
// and silently skip. Reconnect re-injection for machines is a
|
|
855
|
+
// follow-up alongside the machine recovery pass.
|
|
856
|
+
if (getHostTask(input.task.id)?.environmentProvider === "machine") {
|
|
857
|
+
void machineTaskGithubAuth(
|
|
858
|
+
input.task.id,
|
|
859
|
+
input.task.ownerUserId,
|
|
860
|
+
).catch((err) =>
|
|
861
|
+
console.warn(
|
|
862
|
+
`[github] machine task ${input.task.id}: auth injection failed: ${
|
|
863
|
+
err instanceof Error ? err.message : String(err)
|
|
864
|
+
}`,
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
void reconcileTaskGitAuth(
|
|
869
|
+
input.task.id,
|
|
870
|
+
input.task.ownerUserId,
|
|
871
|
+
).catch((err) =>
|
|
872
|
+
console.warn(
|
|
873
|
+
`[github] task ${input.task.id}: post-start reconciliation failed: ${
|
|
874
|
+
err instanceof Error ? err.message : String(err)
|
|
875
|
+
}`,
|
|
876
|
+
),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
826
879
|
} else if (result.code === HostErrorCode.HostUnavailable) {
|
|
827
880
|
// A daemon outage is retryable infrastructure state, not proof that
|
|
828
881
|
// the task itself failed. Undo the optimistic local `starting` mirror
|
|
@@ -1265,6 +1318,24 @@ export const hostCommands: HostCommands = {
|
|
|
1265
1318
|
async appendTranscript(_ctx, taskId, author, text, targets) {
|
|
1266
1319
|
// Per-message + high-frequency, so no logCommand (avoid log spam).
|
|
1267
1320
|
try {
|
|
1321
|
+
// ADR-121: a machine-backed workspace lives in the machine's world —
|
|
1322
|
+
// the append must travel over the environment transport. Container
|
|
1323
|
+
// tasks keep the direct host-FS write (their workspace is a bind
|
|
1324
|
+
// mount and the sync path is cheaper than a docker exec per message).
|
|
1325
|
+
const task = getHostTask(taskId);
|
|
1326
|
+
if (task?.environmentProvider === "machine") {
|
|
1327
|
+
const environment = await reconstructHostTaskEnvironment(taskId);
|
|
1328
|
+
if (environment) {
|
|
1329
|
+
await appendTranscriptViaEnvironment(
|
|
1330
|
+
taskId,
|
|
1331
|
+
environment,
|
|
1332
|
+
author,
|
|
1333
|
+
text,
|
|
1334
|
+
targets,
|
|
1335
|
+
);
|
|
1336
|
+
return ok(undefined);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1268
1339
|
writeTranscript(taskId, author, text, targets);
|
|
1269
1340
|
return ok(undefined);
|
|
1270
1341
|
} catch (err) {
|
|
@@ -1277,6 +1348,30 @@ function normalizeChannelSpec(input: ChannelEnsureInput): ChannelEnsureInput {
|
|
|
1277
1348
|
return { ...input, workspacePath: "/workspace" };
|
|
1278
1349
|
}
|
|
1279
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* ADR-121: gh auth for a machine-backed task, injected over the environment
|
|
1353
|
+
* transport (token via exec stdin — same `gh auth login --with-token` +
|
|
1354
|
+
* `setup-git` gesture as containers, no compose probing). Best-effort like
|
|
1355
|
+
* the compose reconciler: absence of a GitHub connection is a quiet no-op.
|
|
1356
|
+
*/
|
|
1357
|
+
async function machineTaskGithubAuth(
|
|
1358
|
+
taskId: string,
|
|
1359
|
+
userId: string,
|
|
1360
|
+
): Promise<void> {
|
|
1361
|
+
const environment = await reconstructHostTaskEnvironment(taskId);
|
|
1362
|
+
if (!environment) return;
|
|
1363
|
+
const token = await requestAccessToken(userId);
|
|
1364
|
+
if (!token) return;
|
|
1365
|
+
// Machine work is not container work: no docker/apple runtime admission.
|
|
1366
|
+
await injectIntoContainer(
|
|
1367
|
+
taskId,
|
|
1368
|
+
token.accessToken,
|
|
1369
|
+
undefined,
|
|
1370
|
+
() => {},
|
|
1371
|
+
environment,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1280
1375
|
async function reconstructHostTaskEnvironment(
|
|
1281
1376
|
taskId: string,
|
|
1282
1377
|
): Promise<TaskEnvironmentHandle<TaskDownResult> | null> {
|