@runuai/host 0.9.150 → 0.9.152
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1837 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +650 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -18,18 +18,16 @@
|
|
|
18
18
|
* LineProcess gave them.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
closeSync,
|
|
23
|
-
fstatSync,
|
|
24
|
-
mkdirSync,
|
|
25
|
-
openSync,
|
|
26
|
-
promises as fsp,
|
|
27
|
-
readSync,
|
|
28
|
-
statSync,
|
|
29
|
-
} from "node:fs";
|
|
30
|
-
import { join } from "node:path";
|
|
31
21
|
import { fileURLToPath } from "node:url";
|
|
32
22
|
|
|
23
|
+
import {
|
|
24
|
+
appendContainedFile,
|
|
25
|
+
containedModifiedAt,
|
|
26
|
+
NotARegularFileError,
|
|
27
|
+
readContainedFrom,
|
|
28
|
+
WorkspacePathRefusedError,
|
|
29
|
+
} from "../task-environment/workspace-files";
|
|
30
|
+
|
|
33
31
|
import type { ExitHandler, LineHandler } from "./proc";
|
|
34
32
|
|
|
35
33
|
const POLL_MS = 75;
|
|
@@ -46,6 +44,14 @@ export function runnerScriptPath(): string {
|
|
|
46
44
|
export interface DurableProcessOptions {
|
|
47
45
|
/** Session dir as seen FROM THE HOST (on the workspace bind mount). */
|
|
48
46
|
hostSessionDir: string;
|
|
47
|
+
/** The same directory as the task's workspace root and its components
|
|
48
|
+
* below it: every file in it is reached never through a link the task
|
|
49
|
+
* put there (#cb3j0z). It exists before the runner is launched. */
|
|
50
|
+
sessionFiles: { root: string; parts: readonly string[] };
|
|
51
|
+
/** Settles once that directory has been made, where only the task's
|
|
52
|
+
* runtime can make it (macOS, #cb3j0z): no inbox write, the stop
|
|
53
|
+
* included, is attempted before. Absent: it exists already. */
|
|
54
|
+
ready?: Promise<void>;
|
|
49
55
|
/** Provider-owned detached launch. Omit to attach to an existing runner. */
|
|
50
56
|
launch?: () => Promise<{
|
|
51
57
|
exitCode: number | null;
|
|
@@ -73,10 +79,10 @@ interface RunnerMeta {
|
|
|
73
79
|
|
|
74
80
|
export class DurableProcess {
|
|
75
81
|
readonly durableSessionDir: string;
|
|
76
|
-
private readonly
|
|
77
|
-
private readonly
|
|
78
|
-
private readonly
|
|
79
|
-
private readonly
|
|
82
|
+
private readonly root: string;
|
|
83
|
+
private readonly outbox: readonly string[];
|
|
84
|
+
private readonly inbox: readonly string[];
|
|
85
|
+
private readonly heartbeat: readonly string[];
|
|
80
86
|
|
|
81
87
|
private readonly lineHandlers = new Set<LineHandler>();
|
|
82
88
|
private readonly exitHandlers = new Set<ExitHandler>();
|
|
@@ -97,18 +103,22 @@ export class DurableProcess {
|
|
|
97
103
|
|
|
98
104
|
constructor(opts: DurableProcessOptions) {
|
|
99
105
|
this.durableSessionDir = opts.hostSessionDir;
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
102
|
-
this.
|
|
103
|
-
this.
|
|
106
|
+
this.root = opts.sessionFiles.root;
|
|
107
|
+
this.outbox = [...opts.sessionFiles.parts, "outbox.jsonl"];
|
|
108
|
+
this.inbox = [...opts.sessionFiles.parts, "inbox.jsonl"];
|
|
109
|
+
this.heartbeat = [...opts.sessionFiles.parts, "heartbeat"];
|
|
104
110
|
this.offset = opts.initialOutboxOffset ?? 0;
|
|
105
111
|
this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
|
|
106
112
|
this.onCloseRequested = opts.onCloseRequested ?? null;
|
|
113
|
+
if (opts.ready) {
|
|
114
|
+
this.inboxChain = opts.ready.then(
|
|
115
|
+
() => undefined,
|
|
116
|
+
() => undefined,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
107
119
|
this.debug =
|
|
108
120
|
opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
|
|
109
121
|
|
|
110
|
-
mkdirSync(this.dir, { recursive: true });
|
|
111
|
-
|
|
112
122
|
if (opts.launch) {
|
|
113
123
|
void opts.launch().then(
|
|
114
124
|
(result) => {
|
|
@@ -139,27 +149,25 @@ export class DurableProcess {
|
|
|
139
149
|
|
|
140
150
|
private poll(): void {
|
|
141
151
|
if (this.closed || this.detached) return;
|
|
142
|
-
let fd: number;
|
|
143
|
-
try {
|
|
144
|
-
fd = openSync(this.outboxPath, "r");
|
|
145
|
-
} catch {
|
|
146
|
-
this.checkLiveness();
|
|
147
|
-
return; // outbox not created yet
|
|
148
|
-
}
|
|
149
152
|
const before = this.offset;
|
|
150
153
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.offset += read;
|
|
158
|
-
this.lineBuf += buf.toString("utf8", 0, read);
|
|
154
|
+
for (;;) {
|
|
155
|
+
const read = readContainedFrom(this.root, this.outbox, this.offset, 256 * 1024);
|
|
156
|
+
if (read === null) break; // outbox not created yet
|
|
157
|
+
if (read.bytes.length === 0) break;
|
|
158
|
+
this.offset += read.bytes.length;
|
|
159
|
+
this.lineBuf += read.bytes.toString("utf8");
|
|
159
160
|
this.drainLines();
|
|
161
|
+
if (this.offset >= read.size) break;
|
|
160
162
|
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof WorkspacePathRefusedError || error instanceof NotARegularFileError) {
|
|
165
|
+
// A link, or anything but a regular file, where the outbox is: the
|
|
166
|
+
// task broke its own session, and it ends (#cb3j0z).
|
|
167
|
+
this.finish(null);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Anything else may pass: the heartbeat decides, below.
|
|
163
171
|
}
|
|
164
172
|
if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
|
|
165
173
|
try {
|
|
@@ -212,17 +220,21 @@ export class DurableProcess {
|
|
|
212
220
|
|
|
213
221
|
/** Backstop: no exit meta, but the runner stopped beating → it's gone. */
|
|
214
222
|
private checkLiveness(): void {
|
|
215
|
-
|
|
216
|
-
if (!path) {
|
|
223
|
+
if (!this.sawSpawnMeta) {
|
|
217
224
|
// Runner never wrote anything; give the spawn a grace window.
|
|
218
225
|
if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
|
|
219
226
|
return;
|
|
220
227
|
}
|
|
228
|
+
let beat: number | null = null;
|
|
221
229
|
try {
|
|
222
|
-
|
|
223
|
-
if (age > HEARTBEAT_STALE_MS) this.finish(null);
|
|
230
|
+
beat = containedModifiedAt(this.root, this.heartbeat);
|
|
224
231
|
} catch {
|
|
225
|
-
|
|
232
|
+
beat = null;
|
|
233
|
+
}
|
|
234
|
+
if (beat !== null) {
|
|
235
|
+
if (Date.now() - beat > HEARTBEAT_STALE_MS) this.finish(null);
|
|
236
|
+
} else if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) {
|
|
237
|
+
this.finish(null);
|
|
226
238
|
}
|
|
227
239
|
}
|
|
228
240
|
|
|
@@ -265,7 +277,7 @@ export class DurableProcess {
|
|
|
265
277
|
if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
|
|
266
278
|
// Chain appends so concurrent writes can't interleave bytes.
|
|
267
279
|
this.inboxChain = this.inboxChain
|
|
268
|
-
.then(() =>
|
|
280
|
+
.then(() => appendContainedFile(this.root, this.inbox, Buffer.from(`${inputMeta}\n${json}\n`)))
|
|
269
281
|
.catch(() => {
|
|
270
282
|
// Disk error — liveness checks will surface a dead session.
|
|
271
283
|
});
|
|
@@ -306,7 +318,7 @@ export class DurableProcess {
|
|
|
306
318
|
}
|
|
307
319
|
try {
|
|
308
320
|
await this.inboxChain;
|
|
309
|
-
await
|
|
321
|
+
await appendContainedFile(this.root, this.inbox, Buffer.from('{"__uai":"stop"}\n'));
|
|
310
322
|
} catch {
|
|
311
323
|
// Inbox unwritable — fall through to the grace timer.
|
|
312
324
|
}
|
package/lib/agents/registry.ts
CHANGED
|
@@ -87,6 +87,20 @@ export function setAdvertisementFallback(
|
|
|
87
87
|
advertisementFallback = fallback ?? (() => false);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* ADR-154: on cloud credentials the runner holds no engine credential of its
|
|
92
|
+
* own, so availability and the fallback above say nothing: the kinds the
|
|
93
|
+
* cloud can lease an account for are advertised, and only those. Null while
|
|
94
|
+
* the runner runs its tasks on its own credentials. Installed by main.ts.
|
|
95
|
+
*/
|
|
96
|
+
let leasedKinds: () => ReadonlySet<string> | null = () => null;
|
|
97
|
+
|
|
98
|
+
export function setLeasedAdvertisement(
|
|
99
|
+
source: (() => ReadonlySet<string> | null) | null,
|
|
100
|
+
): void {
|
|
101
|
+
leasedKinds = source ?? (() => null);
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* Register (or replace) an adapter for a kind. Called at module load by the
|
|
92
106
|
* adapter modules. Replacing is allowed so a later import wins deterministically.
|
|
@@ -121,10 +135,12 @@ export function supportsExecutionProfile(kind: string, profileId: string): boole
|
|
|
121
135
|
* Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
|
|
122
136
|
*/
|
|
123
137
|
export function capabilities(): AgentKindCapability[] {
|
|
138
|
+
const leased = leasedKinds();
|
|
124
139
|
return list()
|
|
125
|
-
.filter(
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
.filter((adapter) =>
|
|
141
|
+
leased !== null
|
|
142
|
+
? leased.has(adapter.kind)
|
|
143
|
+
: (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
|
|
128
144
|
)
|
|
129
145
|
.map((adapter) => {
|
|
130
146
|
const out: AgentKindCapability = {
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* thread id when launch settings change.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { promises as fsp } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
11
|
|
|
14
12
|
import { and, eq } from "drizzle-orm";
|
|
15
13
|
|
|
16
14
|
import { getDb, schema } from "../db";
|
|
15
|
+
import { taskWorkspaceDir } from "../env";
|
|
16
|
+
import {
|
|
17
|
+
appendContainedFile,
|
|
18
|
+
hostPathParts,
|
|
19
|
+
WorkspaceDirectoryMissingError,
|
|
20
|
+
} from "../task-environment/workspace-files";
|
|
17
21
|
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
18
22
|
import { isEnvironmentTransportProvider } from "../task-environment/types";
|
|
19
23
|
import {
|
|
@@ -81,15 +85,24 @@ export async function stopPersistedAgentSession(
|
|
|
81
85
|
row.sessionDir,
|
|
82
86
|
);
|
|
83
87
|
} else {
|
|
88
|
+
// Never through a link the task put in the session's place (#cb3j0z).
|
|
89
|
+
const root = taskWorkspaceDir(taskId);
|
|
90
|
+
let parts: string[] | null;
|
|
84
91
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
parts = hostPathParts(root, row.sessionDir);
|
|
93
|
+
} catch {
|
|
94
|
+
// Not below the task's workspace: no runner of this task reads it.
|
|
95
|
+
parts = null;
|
|
96
|
+
}
|
|
97
|
+
if (parts !== null) {
|
|
98
|
+
try {
|
|
99
|
+
await appendContainedFile(root, [...parts, "inbox.jsonl"], Buffer.from('{"__uai":"stop"}\n'));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// A removed generation directory proves there is no runner left to
|
|
102
|
+
// read it. Permission, I/O, and other failures are not proof: retry
|
|
103
|
+
// them.
|
|
104
|
+
if (!(err instanceof WorkspaceDirectoryMissingError)) throw err;
|
|
105
|
+
}
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
108
|
|
package/lib/agents/transport.ts
CHANGED
|
@@ -21,16 +21,7 @@
|
|
|
21
21
|
* durable spawn ships the runner version matching this host.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
25
|
-
copyFileSync,
|
|
26
|
-
readFileSync,
|
|
27
|
-
mkdirSync,
|
|
28
|
-
renameSync,
|
|
29
|
-
rmSync,
|
|
30
|
-
statSync,
|
|
31
|
-
symlinkSync,
|
|
32
|
-
promises as fsp,
|
|
33
|
-
} from "node:fs";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
34
25
|
import { basename, dirname, join } from "node:path";
|
|
35
26
|
|
|
36
27
|
import { and, eq, isNull } from "drizzle-orm";
|
|
@@ -39,7 +30,20 @@ import { getDb, schema, type Db } from "../db";
|
|
|
39
30
|
import { taskWorkspaceDir } from "../env";
|
|
40
31
|
import { machineSessionAuthorityEnv } from "../runtime-authority";
|
|
41
32
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
42
|
-
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
33
|
+
import type { TaskEnvironmentAgentSessionSurface, TaskEnvironmentHandle } from "../task-environment/types";
|
|
34
|
+
import {
|
|
35
|
+
appendContainedFile,
|
|
36
|
+
containedModifiedAt,
|
|
37
|
+
hostPathParts,
|
|
38
|
+
makeContainedDirectories,
|
|
39
|
+
makeContainedDirectoriesSync,
|
|
40
|
+
removeContainedEntry,
|
|
41
|
+
replaceContainedLink,
|
|
42
|
+
runtimeNamespaceOf,
|
|
43
|
+
writeContainedFile,
|
|
44
|
+
writeContainedFileSync,
|
|
45
|
+
type RuntimeNamespace,
|
|
46
|
+
} from "../task-environment/workspace-files";
|
|
43
47
|
import { DurableProcess, runnerScriptPath } from "./durable-proc";
|
|
44
48
|
import {
|
|
45
49
|
MachineDurableProcess,
|
|
@@ -206,7 +210,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
206
210
|
explicitEnv: machineSessionAuthorityEnv(opts.explicitEnv),
|
|
207
211
|
};
|
|
208
212
|
if (!durableEnabled()) {
|
|
209
|
-
clearCurrentSession(machineOpts.taskId, machineOpts.agentId);
|
|
213
|
+
clearCurrentSession(machineOpts.taskId, machineOpts.agentId, workspaceRuntime(machineOpts.environment));
|
|
210
214
|
return directEnvironmentTransport(machineOpts);
|
|
211
215
|
}
|
|
212
216
|
return machineDurableTransport(machineOpts);
|
|
@@ -216,7 +220,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
216
220
|
// spawn boundary so a cached ready verdict cannot launch container work.
|
|
217
221
|
requireContainerRuntimeOperational();
|
|
218
222
|
if (!durableEnabled()) {
|
|
219
|
-
clearCurrentSession(opts.taskId, opts.agentId);
|
|
223
|
+
clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
|
|
220
224
|
return directEnvironmentTransport(opts);
|
|
221
225
|
}
|
|
222
226
|
|
|
@@ -234,14 +238,18 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
234
238
|
|
|
235
239
|
const tailKey = `${opts.taskId}:${opts.agentId}`;
|
|
236
240
|
|
|
241
|
+
const runtime = workspaceRuntime(opts.environment);
|
|
242
|
+
const rowFiles = row ? sessionFilesOf(opts.taskId, row.sessionDir) : null;
|
|
243
|
+
|
|
237
244
|
// ---- Attach: a previous host process left this agent's runner alive. ----
|
|
238
245
|
if (
|
|
239
246
|
opts.allowAttach &&
|
|
240
247
|
row &&
|
|
248
|
+
rowFiles &&
|
|
241
249
|
row.status === "running" &&
|
|
242
250
|
row.containerName === opts.environment.durableIdentity &&
|
|
243
251
|
row.kind === opts.kind &&
|
|
244
|
-
heartbeatFresh(
|
|
252
|
+
heartbeatFresh(rowFiles) &&
|
|
245
253
|
claimCompatibleAttachRow(
|
|
246
254
|
db,
|
|
247
255
|
opts.taskId,
|
|
@@ -259,13 +267,14 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
259
267
|
);
|
|
260
268
|
const proc = new DurableProcess({
|
|
261
269
|
hostSessionDir: row.sessionDir,
|
|
270
|
+
sessionFiles: rowFiles,
|
|
262
271
|
initialOutboxOffset: row.outboxOffset,
|
|
263
272
|
onOffsetAdvance: persistOffset,
|
|
264
273
|
onCloseRequested: markClosed,
|
|
265
274
|
debugLabel: opts.debugLabel,
|
|
266
275
|
});
|
|
267
276
|
proc.onExit(markClosed);
|
|
268
|
-
publishCurrentSession(
|
|
277
|
+
publishCurrentSession(rowFiles, runtime);
|
|
269
278
|
return claimTail(tailKey, proc);
|
|
270
279
|
}
|
|
271
280
|
|
|
@@ -274,26 +283,20 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
274
283
|
// by a stale-heartbeat verdict may still be alive, and a stop appended to
|
|
275
284
|
// a dead session's inbox is harmless — this is what guarantees one live
|
|
276
285
|
// CLI per (task, agent).
|
|
277
|
-
if (
|
|
278
|
-
void
|
|
279
|
-
|
|
280
|
-
.catch(() => {
|
|
286
|
+
if (rowFiles) {
|
|
287
|
+
void appendContainedFile(rowFiles.root, [...rowFiles.parts, "inbox.jsonl"], Buffer.from('{"__uai":"stop"}\n')).catch(
|
|
288
|
+
() => {
|
|
281
289
|
// Dir already gone / runner already dead — nothing to stop.
|
|
282
|
-
}
|
|
290
|
+
},
|
|
291
|
+
);
|
|
283
292
|
}
|
|
284
293
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"sessions",
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
);
|
|
292
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
293
|
-
// Ship this host's runner with the session: works in containers created
|
|
294
|
-
// before this feature (the workspace is mounted at its host path) and
|
|
295
|
-
// never skews against the host version.
|
|
296
|
-
const runnerInSession = join(sessionDir, "runner.mjs");
|
|
294
|
+
const workspace = taskWorkspaceDir(opts.taskId);
|
|
295
|
+
const sessionFiles = {
|
|
296
|
+
root: workspace,
|
|
297
|
+
parts: [".uai", "sessions", opts.agentId, `${String(Date.now())}-${process.pid}`],
|
|
298
|
+
};
|
|
299
|
+
const sessionDir = join(workspace, ...sessionFiles.parts);
|
|
297
300
|
const environmentSessionDir = join(
|
|
298
301
|
opts.environment.descriptor.workspacePath,
|
|
299
302
|
".uai",
|
|
@@ -308,8 +311,9 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
308
311
|
sessionDir,
|
|
309
312
|
);
|
|
310
313
|
const runnerInEnvironment = join(environmentSessionDir, "runner.mjs");
|
|
314
|
+
let runnerSource: Buffer;
|
|
311
315
|
try {
|
|
312
|
-
|
|
316
|
+
runnerSource = readFileSync(runnerScriptPath());
|
|
313
317
|
} catch (err) {
|
|
314
318
|
// Runner asset missing (a packaging miss — the 0.3.0/0.3.1 desktop
|
|
315
319
|
// builds vendored the agent without runner/). Degrade to legacy pipes:
|
|
@@ -317,16 +321,45 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
317
321
|
console.warn(
|
|
318
322
|
`[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
|
|
319
323
|
);
|
|
320
|
-
clearCurrentSession(opts.taskId, opts.agentId);
|
|
324
|
+
clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
|
|
321
325
|
return directEnvironmentTransport(opts);
|
|
322
326
|
}
|
|
323
327
|
|
|
328
|
+
// Ship this host's runner with the session: works in containers created
|
|
329
|
+
// before this feature (the workspace is mounted at its host path) and
|
|
330
|
+
// never skews against the host version. Its directory and the runner are
|
|
331
|
+
// made in the task's workspace never through a link the task put there
|
|
332
|
+
// (#cb3j0z), before the runner launches.
|
|
333
|
+
// On Linux both are made at once; on macOS the directory is made through
|
|
334
|
+
// the task's runtime first.
|
|
335
|
+
let preparedNow = false;
|
|
336
|
+
let prepared: Promise<void>;
|
|
337
|
+
try {
|
|
338
|
+
if (makeContainedDirectoriesSync(workspace, sessionFiles.parts)) {
|
|
339
|
+
writeContainedFileSync(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
|
|
340
|
+
preparedNow = true;
|
|
341
|
+
prepared = Promise.resolve();
|
|
342
|
+
} else {
|
|
343
|
+
prepared = (async () => {
|
|
344
|
+
await makeContainedDirectories(workspace, sessionFiles.parts, runtime);
|
|
345
|
+
await writeContainedFile(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
|
|
346
|
+
})();
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
prepared = Promise.reject(error);
|
|
350
|
+
}
|
|
351
|
+
prepared.catch(() => {
|
|
352
|
+
// Reported through the launch, which awaits it.
|
|
353
|
+
});
|
|
324
354
|
const proc = new DurableProcess({
|
|
325
355
|
hostSessionDir: sessionDir,
|
|
356
|
+
sessionFiles,
|
|
357
|
+
...(preparedNow ? {} : { ready: prepared }),
|
|
326
358
|
onOffsetAdvance: persistOffset,
|
|
327
359
|
onCloseRequested: markClosed,
|
|
328
360
|
debugLabel: opts.debugLabel,
|
|
329
361
|
launch: async () => {
|
|
362
|
+
await prepared;
|
|
330
363
|
const cliArgv = agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]);
|
|
331
364
|
const result = await opts.environment.launchDetachedSession({
|
|
332
365
|
argv: [
|
|
@@ -375,7 +408,8 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
375
408
|
.run();
|
|
376
409
|
|
|
377
410
|
proc.onExit(markClosed);
|
|
378
|
-
publishCurrentSession(
|
|
411
|
+
if (preparedNow) publishCurrentSession(sessionFiles, runtime);
|
|
412
|
+
else void prepared.then(() => publishCurrentSession(sessionFiles, runtime), () => {});
|
|
379
413
|
return claimTail(tailKey, proc);
|
|
380
414
|
}
|
|
381
415
|
|
|
@@ -401,7 +435,7 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
|
|
|
401
435
|
}
|
|
402
436
|
}
|
|
403
437
|
if (!capable || runnerSource === null) {
|
|
404
|
-
clearCurrentSession(opts.taskId, opts.agentId);
|
|
438
|
+
clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
|
|
405
439
|
return directEnvironmentTransport(opts);
|
|
406
440
|
}
|
|
407
441
|
const environment = surface as unknown as MachineSessionEnvironment &
|
|
@@ -551,51 +585,49 @@ function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
551
585
|
});
|
|
552
586
|
}
|
|
553
587
|
|
|
588
|
+
/** #cb3j0z: the task's runtime, where only it can make, remove or link an
|
|
589
|
+
* entry in the task's workspace without following its links (macOS). */
|
|
590
|
+
function workspaceRuntime(environment: TaskEnvironmentAgentSessionSurface): RuntimeNamespace | undefined {
|
|
591
|
+
return "exec" in environment
|
|
592
|
+
? runtimeNamespaceOf(environment as unknown as Pick<TaskEnvironmentHandle, "exec" | "descriptor">)
|
|
593
|
+
: undefined;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A stored session directory as the task's workspace root and its parts
|
|
597
|
+
* below it; null when it is not below the workspace root. */
|
|
598
|
+
function sessionFilesOf(taskId: string, sessionDir: string): { root: string; parts: string[] } | null {
|
|
599
|
+
const root = taskWorkspaceDir(taskId);
|
|
600
|
+
try {
|
|
601
|
+
return { root, parts: hostPathParts(root, sessionDir) };
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
554
607
|
/**
|
|
555
608
|
* Make the opaque generation directory discoverable from inside the task.
|
|
556
609
|
* The relative link resolves on both sides of the workspace bind mount. The
|
|
557
610
|
* pointer is diagnostic only: failure to publish it must not kill a session.
|
|
611
|
+
* Made beside the generation, never through a link the task put there.
|
|
558
612
|
*/
|
|
559
|
-
function publishCurrentSession(
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
symlinkSync(basename(sessionDir), pending, "dir");
|
|
565
|
-
renameSync(pending, current);
|
|
566
|
-
} catch {
|
|
567
|
-
try {
|
|
568
|
-
rmSync(pending, { force: true });
|
|
569
|
-
} catch {
|
|
570
|
-
// Best-effort navigation aid; the generation path remains authoritative.
|
|
571
|
-
}
|
|
572
|
-
}
|
|
613
|
+
function publishCurrentSession(files: { root: string; parts: readonly string[] }, runtime?: RuntimeNamespace): void {
|
|
614
|
+
const generation = files.parts[files.parts.length - 1]!;
|
|
615
|
+
void replaceContainedLink(files.root, [...files.parts.slice(0, -1), "current"], generation, runtime).catch(() => {
|
|
616
|
+
// Best-effort navigation aid; the generation path remains authoritative.
|
|
617
|
+
});
|
|
573
618
|
}
|
|
574
619
|
|
|
575
620
|
/** A direct-pipe session must not leave an older durable inbox looking live. */
|
|
576
|
-
function clearCurrentSession(taskId: string, agentId: string): void {
|
|
577
|
-
|
|
578
|
-
rmSync(
|
|
579
|
-
join(
|
|
580
|
-
taskWorkspaceDir(taskId),
|
|
581
|
-
".uai",
|
|
582
|
-
"sessions",
|
|
583
|
-
agentId,
|
|
584
|
-
"current",
|
|
585
|
-
),
|
|
586
|
-
{ force: true },
|
|
587
|
-
);
|
|
588
|
-
} catch {
|
|
621
|
+
function clearCurrentSession(taskId: string, agentId: string, runtime?: RuntimeNamespace): void {
|
|
622
|
+
void removeContainedEntry(taskWorkspaceDir(taskId), [".uai", "sessions", agentId, "current"], runtime).catch(() => {
|
|
589
623
|
// Same best-effort rule as publishCurrentSession.
|
|
590
|
-
}
|
|
624
|
+
});
|
|
591
625
|
}
|
|
592
626
|
|
|
593
|
-
function heartbeatFresh(
|
|
627
|
+
function heartbeatFresh(files: { root: string; parts: readonly string[] }): boolean {
|
|
594
628
|
try {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
ATTACH_HEARTBEAT_FRESH_MS
|
|
598
|
-
);
|
|
629
|
+
const beat = containedModifiedAt(files.root, [...files.parts, "heartbeat"]);
|
|
630
|
+
return beat !== null && Date.now() - beat < ATTACH_HEARTBEAT_FRESH_MS;
|
|
599
631
|
} catch {
|
|
600
632
|
return false;
|
|
601
633
|
}
|