@runuai/host 0.8.41 → 0.8.43

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.
@@ -14,6 +14,8 @@
14
14
  * connect fails, which invalidates it (mirrors the container-IP cache). Declared
15
15
  * ports keep their published-port path; only ad-hoc ports use a sidecar.
16
16
  */
17
+ import net from "node:net";
18
+
17
19
  import { dockerCli } from "./docker-exec";
18
20
 
19
21
  /** Port the proxy listens on inside the sidecar (published to a random host port). */
@@ -45,6 +47,86 @@ function proxyScript(appContainer: string, containerPort: number): string {
45
47
  );
46
48
  }
47
49
 
50
+ /** How long to wait for a new sidecar's proxy to start accepting. */
51
+ const READY_TIMEOUT_MS = 10_000;
52
+ const READY_POLL_MS = 100;
53
+ /** A connection that survives this long AFTER connecting has a live upstream. */
54
+ const PROBE_SETTLE_MS = 150;
55
+ /** A handshake that hasn't completed by now isn't going to. */
56
+ const PROBE_CONNECT_MS = 1_000;
57
+
58
+ /** The bit of `net.Socket` the probe uses — so a test can supply its own. */
59
+ interface SocketLike {
60
+ on: (event: string, fn: () => void) => unknown;
61
+ destroy: () => void;
62
+ }
63
+
64
+ /**
65
+ * Whether the whole chain behind `hostPort` is live: the proxy is listening AND
66
+ * its upstream connect succeeded.
67
+ *
68
+ * A plain "did the TCP connect succeed" check proves nothing here, which is the
69
+ * trap this exists for. Docker publishes the port when the CONTAINER starts,
70
+ * not when the process inside binds — so `docker-proxy` accepts the connection
71
+ * either way and then closes it, and an HTTP client reports that as
72
+ * `socket hang up`. The sidecar's proxy behaves identically when it is up but
73
+ * its own upstream connect fails (`u.on('error',()=>c.destroy())`).
74
+ *
75
+ * So the signal is the connection SURVIVING briefly, not opening. Both failure
76
+ * modes close it immediately; a healthy pipe just sits there waiting for a
77
+ * request, which is the one thing neither of them does.
78
+ */
79
+ function probeSidecar(
80
+ hostPort: number,
81
+ /** Seam for tests: a socket that never connects can't be produced locally. */
82
+ connect: (port: number) => SocketLike = (port) =>
83
+ net.connect(port, "127.0.0.1"),
84
+ ): Promise<boolean> {
85
+ return new Promise((resolve) => {
86
+ const socket = connect(hostPort);
87
+ let settled = false;
88
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
89
+ const done = (ok: boolean): void => {
90
+ if (settled) return;
91
+ settled = true;
92
+ clearTimeout(settleTimer);
93
+ clearTimeout(connectTimer);
94
+ socket.destroy();
95
+ resolve(ok);
96
+ };
97
+ // The settle window starts at `connect`, NOT at the call. Starting it here
98
+ // would count a connection still stuck in the handshake as healthy — the
99
+ // one state that is neither of the two we can distinguish, and the one a
100
+ // wedged host is most likely to be in.
101
+ //
102
+ // The connect deadline is disarmed at the same moment, or the two overlap:
103
+ // a connection established at 900ms would be failed at 1000ms, 50ms into a
104
+ // 150ms settle window it was on course to pass. The deadlines are
105
+ // sequential — first "did it connect", then "did it stay" — so only one may
106
+ // be running.
107
+ socket.on("connect", () => {
108
+ clearTimeout(connectTimer);
109
+ settleTimer = setTimeout(() => done(true), PROBE_SETTLE_MS);
110
+ });
111
+ const connectTimer = setTimeout(() => done(false), PROBE_CONNECT_MS);
112
+ socket.on("error", () => done(false));
113
+ socket.on("close", () => done(false));
114
+ });
115
+ }
116
+
117
+ /** Test seam: the probe with an injectable socket. Not used in production. */
118
+ export const probeForTest = probeSidecar;
119
+
120
+ /** Poll `probeSidecar` until the chain answers, or give up. */
121
+ async function waitUntilReady(hostPort: number): Promise<boolean> {
122
+ const deadline = Date.now() + READY_TIMEOUT_MS;
123
+ for (;;) {
124
+ if (await probeSidecar(hostPort)) return true;
125
+ if (Date.now() >= deadline) return false;
126
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
127
+ }
128
+ }
129
+
48
130
  async function imageOf(appContainer: string): Promise<string | null> {
49
131
  const r = await dockerCli(
50
132
  ["inspect", "-f", "{{.Config.Image}}", appContainer],
@@ -74,8 +156,18 @@ async function createSidecar(args: {
74
156
  // Reuse a still-running sidecar from a previous host process (--rm keeps it up
75
157
  // across a host restart; the in-memory cache was cleared). `docker port` is an
76
158
  // exact-name lookup, so no name-filter regex pitfalls.
159
+ //
160
+ // PROBED, not just found. A sidecar outlives the app container it proxies to,
161
+ // so one left over from a previous container generation is still running and
162
+ // still publishing its port while its route is dead. Reused blind, that
163
+ // sidecar serves `socket hang up` forever: the recovery path invalidates the
164
+ // CACHE on a failed connect, then lands right back here and returns the same
165
+ // corpse. Nothing could break the cycle short of removing it by hand.
77
166
  const existing = await publishedPort(name);
78
- if (existing !== null) return existing;
167
+ if (existing !== null) {
168
+ if (await probeSidecar(existing)) return existing;
169
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
170
+ }
79
171
 
80
172
  const image = await imageOf(appContainer);
81
173
  if (!image) return null;
@@ -101,6 +193,20 @@ async function createSidecar(args: {
101
193
  await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
102
194
  return null;
103
195
  }
196
+
197
+ // Do not hand back a port that isn't answering yet. `docker run -d` returns
198
+ // when the CONTAINER starts and the mapping exists from that instant, but the
199
+ // node proxy inside has not called `listen` — so every request arriving in
200
+ // that window is accepted by docker-proxy and dropped.
201
+ //
202
+ // A browser makes that window very expensive. It fetches the document, then
203
+ // fires every sub-resource at once: one page load put ~20 requests into a
204
+ // ~3s gap and lost all of them, which renders as an unstyled page with broken
205
+ // images rather than as anything resembling "not ready yet".
206
+ if (!(await waitUntilReady(hostPort))) {
207
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
208
+ return null;
209
+ }
104
210
  return hostPort;
105
211
  }
106
212
 
@@ -133,8 +239,22 @@ export async function ensurePreviewSidecar(args: {
133
239
  return pending;
134
240
  }
135
241
 
136
- /** Drop any cached sidecar entry on `hostPort` (a tunnel connect failed). No-op
137
- * for published-declared / container-IP targets, which aren't in this cache. */
242
+ /**
243
+ * Drop the cached port for a sidecar whose route just failed. No-op for
244
+ * published-declared / container-IP targets, which aren't in this cache.
245
+ *
246
+ * CACHE-ONLY, deliberately. This fires from the host's `upstream.on("error")`,
247
+ * which cannot tell a dead sidecar from a client that hit stop mid-download or
248
+ * a request that tripped the tunnel's ack deadline — both destroy the socket
249
+ * with ECONNRESET. Removing the container here would let one abandoned asset
250
+ * kill a sidecar that is serving twenty other requests fine, which is a worse
251
+ * failure than the one being recovered from.
252
+ *
253
+ * Forgetting the port is enough now: the next `createSidecar` finds the
254
+ * container by name and PROBES it, so a genuinely dead one is removed there,
255
+ * where the evidence is direct rather than inferred. That is the escape hatch
256
+ * this used to lack — invalidation alone kept landing back on the same corpse.
257
+ */
138
258
  export function invalidatePreviewSidecar(hostPort: number): void {
139
259
  for (const [key, port] of cache) {
140
260
  if (port === hostPort) cache.delete(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.41",
3
+ "version": "0.8.43",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -59,6 +59,22 @@ if [ -n "$compose_project" ]; then
59
59
  # Fallback: tear down by project name only.
60
60
  docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
61
61
  fi
62
+ # `compose down` is intentionally best-effort because an already-absent
63
+ # stack can make a config-less fallback fail. Destruction itself is not
64
+ # best-effort: before deleting the worktree or reporting success, prove that
65
+ # no container remains under the stable compose project name.
66
+ remaining_containers=""
67
+ if ! remaining_containers=$(docker ps -aq \
68
+ --filter "label=com.docker.compose.project=$compose_project"); then
69
+ emit_err "COMPOSE_DOWN_FAILED" \
70
+ "could not verify teardown for compose project $compose_project" \
71
+ "docker ps after compose down"
72
+ fi
73
+ if [ -n "$remaining_containers" ]; then
74
+ emit_err "COMPOSE_DOWN_FAILED" \
75
+ "compose project $compose_project still has containers after teardown" \
76
+ "docker ps after compose down"
77
+ fi
62
78
  fi
63
79
 
64
80
  # -----------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -19,7 +19,11 @@ import {
19
19
  } from "../lib/runtime-state";
20
20
  import { clearTaskGatewayAcl } from "../lib/mcp-gateway";
21
21
  import { parsePreviewPortRuntimes } from "../lib/preview-ports";
22
- import { ensurePreviewSidecar } from "../lib/preview-sidecar";
22
+ import { dockerCli } from "../lib/docker-exec";
23
+ import {
24
+ ensurePreviewSidecar,
25
+ stopPreviewSidecars,
26
+ } from "../lib/preview-sidecar";
23
27
  import {
24
28
  HostErrorCode,
25
29
  type CommandContext,
@@ -106,75 +110,180 @@ export const hostEvents: HostEventStream = {
106
110
  export const hostCommands: HostCommands = {
107
111
  async taskUp(ctx, input) {
108
112
  logCommand(ctx, "taskUp", input.task.id);
109
- recordTaskStarting(input.task.id);
110
- recordTaskOwner(
111
- input.task.id,
112
- input.task.ownerUserId,
113
- input.ownerEmail ?? null,
114
- input.ownerName ?? null,
115
- input.projects.map((p) => p.slug),
116
- );
117
- // ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
118
- // `uai` CLI's per-agent tokens can be minted now + after a host restart.
119
- if (input.task.cliSecret) {
120
- storeTaskCliSecret(input.task.id, input.task.cliSecret);
121
- }
122
- recordHostEvent(input.task.id, "task.created");
123
- const result = await wrapAgent(ctx, "taskUp", async () => {
124
- // Self-heal the standard base image before task-up needs it. The
125
- // boot-time build is best-effort + one-shot: a host that started before
126
- // Docker was ready (common on Docker Desktop) never built
127
- // uai-standard:dev, so every task-up died on a doomed registry pull
128
- // (live 2026-07-22). This is idempotent — a fast inspect when present,
129
- // a real build only when missing/stale (Docker is definitely up now, a
130
- // task just arrived). On a build FAILURE, surface WHY to the cloud (not
131
- // just the host log the operator can't read a remote user's machine).
132
- const img = await ensureStandardImage();
133
- if (!img.ok) {
134
- throw new AgentError(
135
- "STANDARD_IMAGE_UNAVAILABLE",
136
- img.error
137
- ? `could not prepare the host base image uai-standard:dev — the build failed. ${toolStderrDetail(img.error)}`
138
- : "could not prepare the host base image uai-standard:dev — see the host log.",
139
- {},
113
+ const orchestrator = getOrchestrator();
114
+ return orchestrator.runTaskLifecycle(input.task.id, async () => {
115
+ // A direct duplicate teardown may still be draining operations that
116
+ // target this task's stable compose/container names. Never recreate
117
+ // those names until every close caller has crossed the shared fence.
118
+ await orchestrator.waitForChannelClose(input.task.id);
119
+ // A command retry that arrives after the original start completed must
120
+ // not rerender/compose-up/uai-init underneath live agent sessions. Treat
121
+ // that lost-response retry as the successful operation it already was:
122
+ // every cloud caller records a non-ok taskUp as an errored task.
123
+ const existingTask = getHostTask(input.task.id);
124
+ if (existingTask?.statusMirror === "running") {
125
+ if (!existingTask.composeProject || !existingTask.worktreePath) {
126
+ return {
127
+ ok: false,
128
+ code: HostErrorCode.Internal,
129
+ message:
130
+ `task ${input.task.id} is running but its persisted runtime ` +
131
+ "metadata is incomplete",
132
+ };
133
+ }
134
+ // The mirror may be stale after a manual `docker compose down`.
135
+ // Confirm the app itself is live before treating this as a lost-response
136
+ // retry; otherwise fall through to the normal taskUp self-heal.
137
+ const appRuntime = await dockerCli(
138
+ [
139
+ "ps",
140
+ "-a",
141
+ "--filter",
142
+ `label=com.docker.compose.project=${existingTask.composeProject}`,
143
+ "--filter",
144
+ "label=com.docker.compose.service=app",
145
+ "--format",
146
+ "{{.State}}",
147
+ ],
148
+ { timeoutMs: 10_000 },
140
149
  );
150
+ if (appRuntime.status !== 0) {
151
+ return {
152
+ ok: false,
153
+ code: HostErrorCode.HostUnavailable,
154
+ message:
155
+ `could not verify runtime state for task ${input.task.id}: ` +
156
+ (appRuntime.stderr.trim().slice(0, 200) ||
157
+ "docker did not answer"),
158
+ retryable: true,
159
+ };
160
+ }
161
+ if (
162
+ appRuntime.stdout
163
+ .split(/\r?\n/)
164
+ .some((state) => state.trim() === "running")
165
+ ) {
166
+ // waitForChannelClose crossed the teardown fence above. A completed
167
+ // close-only teardown may have left this running task tombstoned, so
168
+ // the idempotent-success transition must reopen delivery just like a
169
+ // fresh successful taskUp does.
170
+ orchestrator.allowChannel(input.task.id);
171
+ return {
172
+ ok: true,
173
+ value: {
174
+ composeProject: existingTask.composeProject,
175
+ worktreePath: existingTask.worktreePath,
176
+ codeServerPort: existingTask.codeServerPort ?? undefined,
177
+ previewPorts: parsePreviewPortRuntimes(
178
+ existingTask.previewPorts,
179
+ ),
180
+ },
181
+ };
182
+ }
183
+ // Docker positively proved the mirrored app is absent/stopped. Drain
184
+ // and invalidate any stale in-memory channel before taskUp recreates
185
+ // the same stable compose/container names.
186
+ await orchestrator.closeChannel(input.task.id);
141
187
  }
142
- return agent.taskUp(input);
143
- });
144
- if (result.ok) {
145
- recordTaskUpResult(input.task.id, result.value);
146
- recordHostEvent(input.task.id, "task.started");
147
- // Degraded start (uai-init failed twice): the task is up, but say so
148
- // in the feed — a silent half-start reads as a broken product.
149
- if (result.value.initWarning) {
150
- getOrchestrator().emitSystemNote(
151
- input.task.id,
152
- result.value.initWarning,
153
- );
188
+ recordTaskStarting(input.task.id);
189
+ recordTaskOwner(
190
+ input.task.id,
191
+ input.task.ownerUserId,
192
+ input.ownerEmail ?? null,
193
+ input.ownerName ?? null,
194
+ input.projects.map((p) => p.slug),
195
+ );
196
+ // ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
197
+ // `uai` CLI's per-agent tokens can be minted now + after a host restart.
198
+ if (input.task.cliSecret) {
199
+ storeTaskCliSecret(input.task.id, input.task.cliSecret);
154
200
  }
155
- // GitHub auth for the container is best-effort (ADR-027) and runs in the
156
- // background it must never block or fail task-up. Awaiting it here would
157
- // couple the command result to a network token-exchange: a slow/hung
158
- // exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
159
- // and mark a running task as errored. setupTaskGithub injects + schedules
160
- // (or emits a system note on failure) on its own; the agents come up
161
- // regardless and the token lands well before the first `gh` call.
162
- void setupTaskGithub(input.task.id, input.task.ownerUserId);
163
- } else {
164
- recordTaskError(input.task.id);
165
- }
166
- return result;
201
+ recordHostEvent(input.task.id, "task.created");
202
+ const result = await wrapAgent(ctx, "taskUp", async () => {
203
+ // Self-heal the standard base image before task-up needs it. The
204
+ // boot-time build is best-effort + one-shot: a host that started before
205
+ // Docker was ready (common on Docker Desktop) never built
206
+ // uai-standard:dev, so every task-up died on a doomed registry pull
207
+ // (live 2026-07-22). This is idempotent a fast inspect when present,
208
+ // a real build only when missing/stale (Docker is definitely up now, a
209
+ // task just arrived). On a build FAILURE, surface WHY to the cloud (not
210
+ // just the host log — the operator can't read a remote user's machine).
211
+ const img = await ensureStandardImage();
212
+ if (!img.ok) {
213
+ throw new AgentError(
214
+ "STANDARD_IMAGE_UNAVAILABLE",
215
+ img.error
216
+ ? `could not prepare the host base image uai-standard:dev — the build failed. ${toolStderrDetail(img.error)}`
217
+ : "could not prepare the host base image uai-standard:dev — see the host log.",
218
+ {},
219
+ );
220
+ }
221
+ return agent.taskUp(input);
222
+ });
223
+ if (result.ok) {
224
+ orchestrator.allowChannel(input.task.id);
225
+ recordTaskUpResult(input.task.id, result.value);
226
+ recordHostEvent(input.task.id, "task.started");
227
+ // Degraded start (uai-init failed twice): the task is up, but say so
228
+ // in the feed — a silent half-start reads as a broken product.
229
+ if (result.value.initWarning) {
230
+ orchestrator.emitSystemNote(
231
+ input.task.id,
232
+ result.value.initWarning,
233
+ );
234
+ }
235
+ // GitHub auth for the container is best-effort (ADR-027) and runs in the
236
+ // background — it must never block or fail task-up. Awaiting it here would
237
+ // couple the command result to a network token-exchange: a slow/hung
238
+ // exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
239
+ // and mark a running task as errored. setupTaskGithub injects + schedules
240
+ // (or emits a system note on failure) on its own; the agents come up
241
+ // regardless and the token lands well before the first `gh` call.
242
+ void setupTaskGithub(input.task.id, input.task.ownerUserId);
243
+ } else {
244
+ recordTaskError(input.task.id);
245
+ }
246
+ return result;
247
+ });
167
248
  },
168
249
 
169
250
  async taskDown(ctx, input) {
170
251
  logCommand(ctx, "taskDown", input.taskId);
171
- const result = await wrapAgent(ctx, "taskDown", () => agent.taskDown(input));
172
- if (result.ok) {
173
- recordTaskDown(input.taskId, result.value.status);
174
- recordHostEvent(input.taskId, "task.ended");
175
- clearRefresh(input.taskId);
176
- }
177
- return result;
252
+ const orchestrator = getOrchestrator();
253
+ return orchestrator.runTaskLifecycle(input.taskId, async () => {
254
+ // Tombstone + close before container destruction. A concurrent cloud
255
+ // ensure cannot reopen the channel while teardown is awaiting Docker.
256
+ await orchestrator.closeChannel(input.taskId);
257
+ // Ad-hoc preview proxies share this task's compose network and stable
258
+ // names. Remove them inside the same lifecycle slot before compose down.
259
+ try {
260
+ await stopPreviewSidecars(input.taskId);
261
+ } catch (err) {
262
+ console.warn(
263
+ `[host-agent] task ${input.taskId}: preview cleanup failed: ${
264
+ err instanceof Error ? err.message : String(err)
265
+ }`,
266
+ );
267
+ }
268
+ const result = await wrapAgent(ctx, "taskDown", () =>
269
+ agent.taskDown(input),
270
+ );
271
+ if (result.ok) {
272
+ // Revoke only after destruction succeeds. On failure the task is
273
+ // reopened below and its on-disk MCP definitions must retain the same
274
+ // gateway token; deleting it early would make those exact definitions
275
+ // look foreign and permanently strand the recovered channel.
276
+ clearTaskGatewayAcl(input.taskId);
277
+ recordTaskDown(input.taskId, result.value.status);
278
+ recordHostEvent(input.taskId, "task.ended");
279
+ clearRefresh(input.taskId);
280
+ } else {
281
+ // Destruction failed; let a subsequent ensure restore the still-running
282
+ // task instead of leaving it permanently tombstoned.
283
+ orchestrator.allowChannel(input.taskId);
284
+ }
285
+ return result;
286
+ });
178
287
  },
179
288
 
180
289
  async taskStatus(ctx, taskId) {
@@ -209,14 +318,17 @@ export const hostCommands: HostCommands = {
209
318
 
210
319
  async channelTeardown(ctx, taskId) {
211
320
  logCommand(ctx, "channelTeardown", taskId);
212
- try {
213
- await getOrchestrator().closeChannel(taskId);
214
- // ADR-057: the task's gateway routes die with the channel.
215
- clearTaskGatewayAcl(taskId);
216
- return ok(undefined);
217
- } catch (err) {
218
- return failFromUnknown(err);
219
- }
321
+ const orchestrator = getOrchestrator();
322
+ return orchestrator.runTaskLifecycle(taskId, async () => {
323
+ try {
324
+ await orchestrator.closeChannel(taskId);
325
+ // ADR-057: the task's gateway routes die with the channel.
326
+ clearTaskGatewayAcl(taskId);
327
+ return ok(undefined);
328
+ } catch (err) {
329
+ return failFromUnknown(err);
330
+ }
331
+ });
220
332
  },
221
333
 
222
334
  async channelDeliver(ctx, taskId, agentId, text) {
@@ -238,33 +350,39 @@ export const hostCommands: HostCommands = {
238
350
 
239
351
  async previewEnsure(ctx, taskId, name, containerPort) {
240
352
  logCommand(ctx, "previewEnsure", taskId, name);
241
- try {
242
- const task = getHostTask(taskId);
243
- if (!task) {
244
- return {
245
- ok: false as const,
246
- code: HostErrorCode.TaskNotFound,
247
- message: `no such task: ${taskId}`,
248
- };
353
+ const orchestrator = getOrchestrator();
354
+ return orchestrator.runTaskLifecycle(taskId, async () => {
355
+ try {
356
+ const task = getHostTask(taskId);
357
+ if (!task) {
358
+ return {
359
+ ok: false as const,
360
+ code: HostErrorCode.TaskNotFound,
361
+ message: `no such task: ${taskId}`,
362
+ };
363
+ }
364
+ if (task.statusMirror !== "running") {
365
+ return ok({ hostPort: null });
366
+ }
367
+ // Task-up published port (preview enabled at launch) wins.
368
+ const declared = parsePreviewPortRuntimes(task.previewPorts).find(
369
+ (port) => port.name === name,
370
+ );
371
+ if (declared) return ok({ hostPort: declared.hostPort });
372
+ // Else start (or find) the node-proxy sidecar NOW — the same one the
373
+ // tunnel lazy-starts on first access — and hand back its host port.
374
+ if (!task.composeProject) return ok({ hostPort: null });
375
+ const hostPort = await ensurePreviewSidecar({
376
+ taskId,
377
+ composeProject: task.composeProject,
378
+ name,
379
+ containerPort,
380
+ });
381
+ return ok({ hostPort });
382
+ } catch (err) {
383
+ return failFromUnknown(err);
249
384
  }
250
- // Task-up published port (preview enabled at launch) wins.
251
- const declared = parsePreviewPortRuntimes(task.previewPorts).find(
252
- (port) => port.name === name,
253
- );
254
- if (declared) return ok({ hostPort: declared.hostPort });
255
- // Else start (or find) the node-proxy sidecar NOW — the same one the
256
- // tunnel lazy-starts on first access — and hand back its host port.
257
- if (!task.composeProject) return ok({ hostPort: null });
258
- const hostPort = await ensurePreviewSidecar({
259
- taskId,
260
- composeProject: task.composeProject,
261
- name,
262
- containerPort,
263
- });
264
- return ok({ hostPort });
265
- } catch (err) {
266
- return failFromUnknown(err);
267
- }
385
+ });
268
386
  },
269
387
 
270
388
  async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
package/src/main.ts CHANGED
@@ -58,7 +58,6 @@ import { dockerCli } from "../lib/docker-exec";
58
58
  import {
59
59
  ensurePreviewSidecar,
60
60
  invalidatePreviewSidecar,
61
- stopPreviewSidecars,
62
61
  } from "../lib/preview-sidecar";
63
62
  import { newId } from "../lib/ulid";
64
63
  import {
@@ -87,7 +86,7 @@ import {
87
86
  type TaskDiffInput,
88
87
  type TaskDownInput,
89
88
  type TaskLaunchInput,
90
- FilesOpInput,
89
+ type FilesOpInput,
91
90
  } from "./protocol";
92
91
 
93
92
  // Crash reporting (ADR-071) — default-ON for installed hosts via the baked
@@ -736,12 +735,24 @@ async function resolveTunnelTarget(
736
735
  // Ad-hoc port (not published): proxy via a node-proxy sidecar that publishes
737
736
  // a 127.0.0.1 port forwarding to <app>:<containerPort> (ADR-043).
738
737
  if (frame.containerPort && task.composeProject) {
739
- const hostPort = await ensurePreviewSidecar({
740
- taskId: frame.taskId,
741
- composeProject: task.composeProject,
742
- name: frame.name,
743
- containerPort: frame.containerPort,
744
- });
738
+ const hostPort = await getOrchestrator().runTaskLifecycle(
739
+ frame.taskId,
740
+ async () => {
741
+ const current = getHostTask(frame.taskId);
742
+ if (
743
+ current?.statusMirror !== "running" ||
744
+ !current.composeProject
745
+ ) {
746
+ return null;
747
+ }
748
+ return ensurePreviewSidecar({
749
+ taskId: frame.taskId,
750
+ composeProject: current.composeProject,
751
+ name: frame.name!,
752
+ containerPort: frame.containerPort!,
753
+ });
754
+ },
755
+ );
745
756
  if (hostPort) return { host: "127.0.0.1", port: hostPort };
746
757
  }
747
758
  }
@@ -883,8 +894,6 @@ function dispatchCommand(
883
894
  return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
884
895
  case "taskDown": {
885
896
  const downInput = expectTaskDownInput(args, 0);
886
- // ADR-043: tear down this task's preview sidecars. Best-effort.
887
- void stopPreviewSidecars(downInput.taskId).catch(() => {});
888
897
  return hostCommands.taskDown(ctx, downInput);
889
898
  }
890
899
  case "taskStatus":