@runuai/host 0.9.0 → 0.9.2

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/repo-clone.ts CHANGED
@@ -1,33 +1,18 @@
1
1
  /**
2
- * Bare-mirror clone helper.
2
+ * Retired bare-mirror host command.
3
3
  *
4
- * Used by project creation. A project is a single repo (ADR-022), bare-
5
- * mirrored into `<projectDir>/repo.git/` one mirror per project, no repoId.
6
- * Per-task worktrees pick it up from there (see scripts/agent/task-up.sh).
4
+ * Project creation stores metadata only. Repository access happens at
5
+ * task-up, after the task creator and host are known, using that user's
6
+ * connected GitHub credential or their explicit SSH fallback. This legacy
7
+ * protocol command cannot express either principal, so running Git here would
8
+ * necessarily risk borrowing the host operator's ambient credential.
7
9
  *
8
- * Uses the same init + remote-add + refspec + fetch pattern as `task-up`
9
- * instead of `git clone --bare`. Plain `git clone --bare` copies upstream
10
- * heads straight into `refs/heads/*` and never populates
11
- * `refs/remotes/origin/*`, so a later `git worktree add ...
12
- * origin/<defaultBranch>` would fail with "invalid reference". `task-up`
13
- * self-heals an existing mirror's refspec on subsequent runs, but only
14
- * if the first task-up runs online — initialising the mirror correctly
15
- * here closes the offline-first-task gap.
16
- *
17
- * Sync in v0.1 — the spec doesn't pin sync-vs-async, and async pipelines
18
- * add a state machine we don't need until the UI shows progress.
19
- *
20
- * Idempotent: if the bare mirror already exists, (re-)assert the refspec
21
- * and treat the repo as ready without re-fetching. Healing an existing
22
- * mirror's refspec is cheap and protects against a mirror created before
23
- * this helper learned the right refspec.
10
+ * Keep the command-shaped response for rolling-version compatibility, but
11
+ * fail closed without touching the filesystem or network. A future preflight
12
+ * needs a user/host-aware protocol of its own; see
13
+ * docs/repo-access-preflight.md.
24
14
  */
25
15
 
26
- import { spawnSync } from "node:child_process";
27
- import { existsSync, rmSync, statSync } from "node:fs";
28
- import { mkdirSync } from "node:fs";
29
- import { dirname } from "node:path";
30
-
31
16
  import { projectRepoMirror } from "./env";
32
17
 
33
18
  export interface CloneRepoInput {
@@ -42,86 +27,12 @@ export interface CloneRepoResult {
42
27
  absolutePath: string;
43
28
  }
44
29
 
45
- const REMOTE_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
46
-
47
30
  export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
48
31
  const target = projectRepoMirror(input.projectId);
49
-
50
- if (existsSync(target) && statSync(target).isDirectory()) {
51
- // Heal an existing mirror's refspec — no-op when already correct.
52
- const setRefspec = git(target, [
53
- "config",
54
- "remote.origin.fetch",
55
- REMOTE_REFSPEC,
56
- ]);
57
- if (setRefspec.status !== 0) {
58
- return errorResult(target, setRefspec, "git config remote.origin.fetch");
59
- }
60
- return { status: "ready", absolutePath: target };
61
- }
62
-
63
- mkdirSync(dirname(target), { recursive: true });
64
-
65
- const init = spawnSync("git", ["init", "--bare", target], { encoding: "utf8" });
66
- if (init.status !== 0) {
67
- return errorResult(target, init, "git init --bare");
68
- }
69
-
70
- const remote = git(target, ["remote", "add", "origin", input.url]);
71
- if (remote.status !== 0) {
72
- cleanup(target);
73
- return errorResult(target, remote, "git remote add origin");
74
- }
75
-
76
- const refspec = git(target, [
77
- "config",
78
- "remote.origin.fetch",
79
- REMOTE_REFSPEC,
80
- ]);
81
- if (refspec.status !== 0) {
82
- cleanup(target);
83
- return errorResult(target, refspec, "git config remote.origin.fetch");
84
- }
85
-
86
- const fetch = git(target, ["fetch", "origin"]);
87
- if (fetch.status !== 0) {
88
- // A failed initial fetch leaves a half-built mirror that task-up
89
- // would later mistake for a healthy one. Tear it down so the next
90
- // retry starts clean.
91
- cleanup(target);
92
- return errorResult(target, fetch, "git fetch origin");
93
- }
94
-
95
- return { status: "ready", absolutePath: target };
96
- }
97
-
98
- function git(
99
- cwd: string,
100
- args: string[],
101
- ): ReturnType<typeof spawnSync> {
102
- return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
103
- }
104
-
105
- function cleanup(target: string): void {
106
- try {
107
- rmSync(target, { recursive: true, force: true });
108
- } catch {
109
- // Best-effort: the caller will surface the original error.
110
- }
111
- }
112
-
113
- function errorResult(
114
- target: string,
115
- result: ReturnType<typeof spawnSync>,
116
- step: string,
117
- ): CloneRepoResult {
118
32
  return {
119
33
  status: "error",
120
34
  absolutePath: target,
121
- error: (
122
- `${step}: ${result.stderr || result.stdout || "failed"}`
123
- )
124
- .trim()
125
- .slice(0, 1024),
35
+ error:
36
+ "repo.clone is retired because it has no task creator credential; start a task to perform the user-scoped repository fetch",
126
37
  };
127
38
  }
package/lib/ssh.ts CHANGED
@@ -143,11 +143,12 @@ export function keyPairForUser(
143
143
  };
144
144
  }
145
145
 
146
- // --- per-task on-disk identity (host clone + container, ADR-029) -------------
146
+ // --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
147
147
  //
148
- // task-up.sh installs the creator's key into the container (and uses it for the
149
- // host clone). The cloud relays ssh.key.get/ensure/delete for the Account card
150
- // via the store functions above; there is no separate docker-exec injection.
148
+ // task-up.sh uses the creator's key for host Git only when GitHub is not
149
+ // connected, and installs it in the container for signing (plus that same
150
+ // fallback). The cloud relays ssh.key.get/ensure/delete for the Account card
151
+ // via the store functions above.
151
152
 
152
153
  function taskIdentityDir(taskId: string): string {
153
154
  return resolve(env.dataDir, "identity", "tasks", taskId);
@@ -155,10 +156,12 @@ function taskIdentityDir(taskId: string): string {
155
156
 
156
157
  /**
157
158
  * Materialize the task creator's keypair to a per-task on-disk identity that
158
- * task-up.sh uses for BOTH the host-side bare clone and the container copy
159
- * (ADR-029) so a task clones + pushes as its creator, not the operator.
160
- * Returns the dir, or null when the creator has no key on this host (task-up.sh
161
- * then falls back to the operator identity from `pnpm setup-identity`). The
159
+ * task-up.sh uses for the disconnected user's host-side SSH fallback and the
160
+ * container's optional signing/fallback identity (ADR-029). Connected users
161
+ * clone + push with their GitHub App user token over HTTPS instead.
162
+ * Returns the dir, or null when the creator has no key on this host. A task
163
+ * never borrows the host operator's identity: without a connected GitHub
164
+ * credential, SSH transport is available only through this owner's key. The
162
165
  * decrypted key lives only on the trusted host (ADR-015) and is removed right
163
166
  * after task-up — see {@link removeTaskIdentity}.
164
167
  */
package/lib/transcript.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  * isolated conversations — each only hears what it's addressed. So they can opt
4
4
  * into cross-agent awareness, the host maintains a plain-text log of every chat
5
5
  * message at `<workspace>/.uai/chat.md` (mounted at /workspace), which any agent
6
- * can read on demand. Messages only no tool calls / actions.
6
+ * can read on demand. Secretary mode adds a frontstage projection at
7
+ * `chat-front.md`; the cloud decides which projection(s) a row belongs to and
8
+ * sends an explicit target list. Conversational rows (including dispatch
9
+ * instructions) only — no tool-call execution traces.
7
10
  */
8
11
 
9
12
  import { appendFileSync, mkdirSync } from "node:fs";
@@ -11,14 +14,23 @@ import { resolve } from "node:path";
11
14
 
12
15
  import { taskWorkspaceDir } from "./env";
13
16
  import { rewriteAttachmentRefs } from "./orchestrator";
17
+ import type { TranscriptTarget } from "../src/protocol";
14
18
 
15
19
  /** Container path agents are pointed at. */
16
20
  export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.uai/chat.md";
21
+ export const CONTAINER_FRONT_TRANSCRIPT_PATH =
22
+ "/workspace/.uai/chat-front.md";
23
+
24
+ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
25
+ chat: "chat.md",
26
+ "chat-front": "chat-front.md",
27
+ };
17
28
 
18
29
  export function appendTranscript(
19
30
  taskId: string,
20
31
  author: string,
21
32
  text: string,
33
+ targets: TranscriptTarget[],
22
34
  ): void {
23
35
  // Rewrite cloud attachment URLs to the in-container path so an agent reading
24
36
  // the transcript can open referenced files directly.
@@ -26,5 +38,8 @@ export function appendTranscript(
26
38
  if (!body) return;
27
39
  const dir = resolve(taskWorkspaceDir(taskId), ".uai");
28
40
  mkdirSync(dir, { recursive: true });
29
- appendFileSync(resolve(dir, "chat.md"), `## ${author}\n\n${body}\n\n`);
41
+ const entry = `## ${author}\n\n${body}\n\n`;
42
+ for (const target of new Set(targets)) {
43
+ appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
44
+ }
30
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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>",
@@ -204,6 +204,69 @@ UAI_CURRENT_STEP=""
204
204
  UAI_ERROR_CODE="UNKNOWN"
205
205
  UAI_TASK_ID=""
206
206
  UAI_LOCK_HELD=""
207
+ UAI_PROJECT_LOCK_PATH=""
208
+ UAI_PROJECT_LOCK_TOKEN=""
209
+
210
+ # Shared project mirrors are mutated by task-up and task-down across different
211
+ # task processes. A task-row lock cannot serialize those operations, so use a
212
+ # portable atomic directory lock (macOS does not ship `flock`). Never reclaim
213
+ # a stale lock automatically: checking a PID and unlinking a fixed path creates
214
+ # a TOCTOU window where a new owner's lock can be deleted. A stale directory is
215
+ # deliberately surfaced for an operator to inspect and remove.
216
+ acquire_project_lock() {
217
+ local projects_root="$1" project_id="$2"
218
+ local project_parent="$projects_root/$project_id"
219
+ local lock_path="$project_parent/.uai-repo.lock"
220
+ local owner_pid attempts=0
221
+ mkdir -p "$project_parent"
222
+ while ! mkdir "$lock_path" 2>/dev/null; do
223
+ if [ ! -d "$lock_path" ]; then
224
+ emit_err "FETCH_FAILED" \
225
+ "project $project_id has an obsolete or invalid repository lock at $lock_path. Confirm no task-up/task-down process is active, remove that lock manually, then retry." \
226
+ "acquire project cache lock"
227
+ fi
228
+ owner_pid=$(cat "$lock_path/pid" 2>/dev/null || true)
229
+ case "$owner_pid" in
230
+ ''|*[!0-9]*) ;;
231
+ *)
232
+ if ! kill -0 "$owner_pid" 2>/dev/null; then
233
+ emit_err "FETCH_FAILED" \
234
+ "project $project_id has a stale repository lock at $lock_path (owner pid $owner_pid). Confirm no task-up/task-down process is active, remove that lock directory manually, then retry." \
235
+ "acquire project cache lock"
236
+ fi
237
+ ;;
238
+ esac
239
+ attempts=$((attempts + 1))
240
+ if [ "$attempts" -ge 300 ]; then
241
+ emit_err "FETCH_FAILED" \
242
+ "project $project_id is busy in another host operation, or has an incomplete lock at $lock_path. Confirm no task-up/task-down process is active; if none is, remove that lock directory manually, then retry." \
243
+ "acquire project cache lock"
244
+ fi
245
+ sleep 0.1
246
+ done
247
+ UAI_PROJECT_LOCK_TOKEN="$$:${RANDOM:-0}"
248
+ printf '%s\n' "$$" > "$lock_path/pid"
249
+ printf '%s\n' "$UAI_PROJECT_LOCK_TOKEN" > "$lock_path/owner"
250
+ UAI_PROJECT_LOCK_PATH="$lock_path"
251
+ }
252
+
253
+ release_project_lock() {
254
+ [ -n "$UAI_PROJECT_LOCK_PATH" ] || return 0
255
+ local owner
256
+ owner=$(cat "$UAI_PROJECT_LOCK_PATH/owner" 2>/dev/null || true)
257
+ if [ -n "$UAI_PROJECT_LOCK_TOKEN" ] \
258
+ && [ "$owner" = "$UAI_PROJECT_LOCK_TOKEN" ]; then
259
+ rm -f "$UAI_PROJECT_LOCK_PATH/pid" "$UAI_PROJECT_LOCK_PATH/owner" \
260
+ 2>/dev/null || true
261
+ rmdir "$UAI_PROJECT_LOCK_PATH" 2>/dev/null || true
262
+ fi
263
+ UAI_PROJECT_LOCK_PATH=""
264
+ UAI_PROJECT_LOCK_TOKEN=""
265
+ }
266
+
267
+ _uai_on_exit() {
268
+ release_project_lock || true
269
+ }
207
270
 
208
271
  step() {
209
272
  UAI_ERROR_CODE="$1"
@@ -230,6 +293,7 @@ _uai_on_err() {
230
293
 
231
294
  setup_err_trap() {
232
295
  trap _uai_on_err ERR
296
+ trap _uai_on_exit EXIT
233
297
  }
234
298
 
235
299
  # ---------------------------------------------------------------------------
@@ -246,3 +310,153 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
246
310
  # ever do scale, swap to discovering the container name via the
247
311
  # `com.docker.compose.project` + `com.docker.compose.service` labels.
248
312
  app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
313
+
314
+ # Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
315
+ # the credential per task: the creator's connected GitHub token is primary;
316
+ # SSH is a fallback only when that user has no GitHub connection on this host.
317
+ # Keeping the stored/shared origin transport-neutral avoids binding a project
318
+ # to whichever user's credential happened to touch its mirror first.
319
+ #
320
+ # Mirrors normalizeRepoUrl() in lib/github/repo-url.ts — keep the two in step.
321
+ # Non-GitHub remotes are returned untouched: we only know how to speak for
322
+ # GitHub, and Uai supports any git remote.
323
+ #
324
+ # Bash 3.2 (macOS /bin/bash): no ${var,,}, no associative arrays.
325
+ normalize_github_repo_url() {
326
+ local input="$1" url after_scheme authority host_port host host_lc
327
+ local path owner rest repo repo_lc left
328
+ url=$(printf '%s' "$input" | sed \
329
+ -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
330
+ if [ "${url#*://}" != "$url" ]; then
331
+ after_scheme=${url#*://}
332
+ authority=${after_scheme%%/*}
333
+ [ "$after_scheme" != "$authority" ] || {
334
+ printf '%s' "$url"
335
+ return
336
+ }
337
+ host_port=${authority##*@}
338
+ host=${host_port%%:*}
339
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
340
+ case "$host_lc" in
341
+ github.com|www.github.com) path=${after_scheme#*/} ;;
342
+ *)
343
+ printf '%s' "$url"
344
+ return
345
+ ;;
346
+ esac
347
+ elif [ "${url#*:}" != "$url" ]; then
348
+ left=${url%%:*}
349
+ host=${left##*@}
350
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
351
+ case "$host_lc" in
352
+ github.com|www.github.com) path=${url#*:} ;;
353
+ *)
354
+ printf '%s' "$url"
355
+ return
356
+ ;;
357
+ esac
358
+ else
359
+ case "$url" in
360
+ */*/*|/*)
361
+ printf '%s' "$url"
362
+ return
363
+ ;;
364
+ */*) path=$url ;;
365
+ *)
366
+ printf '%s' "$url"
367
+ return
368
+ ;;
369
+ esac
370
+ fi
371
+
372
+ # Keep only owner/repo. Browser URLs can include /tree/branch/path, and a
373
+ # pasted clone URL can carry both `.git` and a trailing slash.
374
+ path=${path%%\?*}
375
+ path=${path%%\#*}
376
+ path=$(printf '%s' "$path" | sed -e 's#/*$##')
377
+ owner=${path%%/*}
378
+ rest=${path#*/}
379
+ if [ -z "$owner" ] || [ "$rest" = "$path" ]; then
380
+ printf '%s' "$url"
381
+ return
382
+ fi
383
+ repo=${rest%%/*}
384
+ repo=${repo%%\?*}
385
+ repo=${repo%%\#*}
386
+ repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
387
+ case "$repo_lc" in
388
+ *.git) repo=${repo%????} ;;
389
+ esac
390
+ if [ -z "$repo" ]; then
391
+ printf '%s' "$url"
392
+ return
393
+ fi
394
+ printf 'https://github.com/%s/%s.git' "$owner" "$repo"
395
+ }
396
+
397
+ # True for a reference whose explicit authority is github.com, even if its
398
+ # path is malformed. Callers use this to fail closed rather than handing a
399
+ # GitHub-looking URL to a generic Git command with ambient credentials.
400
+ is_github_hosted_url() {
401
+ local input="$1" url after_scheme authority host_port host host_lc left
402
+ url=$(printf '%s' "$input" | sed \
403
+ -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
404
+ if [ "${url#*://}" != "$url" ]; then
405
+ after_scheme=${url#*://}
406
+ authority=${after_scheme%%/*}
407
+ host_port=${authority##*@}
408
+ host=${host_port%%:*}
409
+ elif [ "${url#*:}" != "$url" ]; then
410
+ left=${url%%:*}
411
+ host=${left##*@}
412
+ else
413
+ case "$url" in
414
+ */*/*|/*|*://*) return 1 ;;
415
+ */*) return 0 ;;
416
+ *) return 1 ;;
417
+ esac
418
+ fi
419
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
420
+ case "$host_lc" in
421
+ github.com|www.github.com) return 0 ;;
422
+ *) return 1 ;;
423
+ esac
424
+ }
425
+
426
+ # HTTPS spelling of a github.com repository. Empty for non-GitHub remotes.
427
+ github_https_repo_url() {
428
+ local https_url rest owner repo
429
+ https_url=$(normalize_github_repo_url "$1")
430
+ case "$https_url" in
431
+ https://github.com/*)
432
+ rest=${https_url#https://github.com/}
433
+ owner=${rest%%/*}
434
+ repo=${rest#*/}
435
+ if [ "$repo" = "$rest" ]; then
436
+ printf ''
437
+ else
438
+ case "$owner/$repo" in
439
+ /*|*/|*/*/*) printf '' ;;
440
+ *) printf '%s' "$https_url" ;;
441
+ esac
442
+ fi
443
+ ;;
444
+ *)
445
+ printf ''
446
+ ;;
447
+ esac
448
+ }
449
+
450
+ # SSH spelling used only when the task owner has not connected GitHub.
451
+ github_ssh_repo_url() {
452
+ local https_url
453
+ https_url=$(github_https_repo_url "$1")
454
+ case "$https_url" in
455
+ https://github.com/*)
456
+ printf 'git@github.com:%s' "${https_url#https://github.com/}"
457
+ ;;
458
+ *)
459
+ printf ''
460
+ ;;
461
+ esac
462
+ }
@@ -2,11 +2,9 @@
2
2
  # task-down <taskId> — tear the task stack down. Idempotent (ADR-022).
3
3
  #
4
4
  # 1. docker compose down -v --rmi local --remove-orphans.
5
- # 2. For each selected project, git worktree remove --force its worktree
6
- # (falls back to rm -rf when git refuses).
7
- # 3. rm -rf the whole task dir — workspace + ephemeral .uai/ render. The
8
- # per-project bare mirrors under projects/<id>/repo.git persist.
9
- # 4. Persist final state: status=killed (unless already error/shipped).
5
+ # 2. rm -rf the whole task dir workspace + task-private Git repositories.
6
+ # Host caches under projects/<id>/host-cache.git persist.
7
+ # 3. Persist final state: status=killed (unless already error/shipped).
10
8
 
11
9
  set -euo pipefail
12
10
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -30,14 +28,14 @@ if [ "$row_count" -lt 1 ]; then
30
28
  log "task row unavailable — tearing down by derived names"
31
29
  worktree_path=""
32
30
  compose_project="$(compose_project_for_task "$task_id")"
33
- projects_json="[]"
34
31
  else
35
32
  worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
36
33
  compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
37
- projects_json=$(db_get_projects_for_task "$task_id" 2>/dev/null) \
38
- || projects_json="[]"
39
34
  fi
40
- projects_root="$UAI_WORKSPACE_ROOT/projects"
35
+ # A task-up failure can leave an error row with compose_project=NULL while its
36
+ # conventionally named container still exists. Bookkeeping must never suppress
37
+ # teardown; the compose project is deterministic from the task id.
38
+ [ -n "$compose_project" ] || compose_project=$(compose_project_for_task "$task_id")
41
39
 
42
40
  # task_dir falls back to the derived path when the row's worktree_path is
43
41
  # missing (e.g. crashed before persisting).
@@ -50,58 +48,36 @@ fi
50
48
  # 1. Compose stack.
51
49
  # -----------------------------------------------------------------------------
52
50
 
53
- if [ -n "$compose_project" ]; then
54
- step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
55
- if [ -n "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
56
- docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
57
- down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
58
- else
59
- # Fallback: tear down by project name only.
60
- docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
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
51
+ step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
52
+ if [ -n "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
53
+ docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
54
+ down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
55
+ else
56
+ # Fallback: tear down by project name only.
57
+ docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
78
58
  fi
79
-
80
- # -----------------------------------------------------------------------------
81
- # 2. Worktrees one per selected project.
82
- # -----------------------------------------------------------------------------
83
-
84
- if [ -n "$task_dir" ] && [ -d "$task_dir/workspace" ]; then
85
- while IFS= read -r project_obj; do
86
- [ -n "$project_obj" ] || continue
87
- project_id=$(jq -r '.id' <<<"$project_obj")
88
- project_slug=$(jq -r '.slug' <<<"$project_obj")
89
- mirror_dir="$projects_root/$project_id/repo.git"
90
- worktree_target="$task_dir/workspace/$project_slug"
91
- [ -d "$worktree_target" ] || continue
92
- if [ -d "$mirror_dir" ]; then
93
- # `--force` because the worktree may carry uncommitted changes the
94
- # user asked us to discard.
95
- git -C "$mirror_dir" worktree remove --force "$worktree_target" 2>/dev/null \
96
- || rm -rf "$worktree_target"
97
- else
98
- rm -rf "$worktree_target"
99
- fi
100
- done < <(jq -c '.[]' <<<"$projects_json")
59
+ # `compose down` is intentionally best-effort because an already-absent
60
+ # stack can make a config-less fallback fail. Destruction itself is not
61
+ # best-effort: before deleting the worktree or reporting success, prove that
62
+ # no container remains under the stable compose project name.
63
+ remaining_containers=""
64
+ if ! remaining_containers=$(docker ps -aq \
65
+ --filter "label=com.docker.compose.project=$compose_project"); then
66
+ emit_err "COMPOSE_DOWN_FAILED" \
67
+ "could not verify teardown for compose project $compose_project" \
68
+ "docker ps after compose down"
69
+ fi
70
+ if [ -n "$remaining_containers" ]; then
71
+ emit_err "COMPOSE_DOWN_FAILED" \
72
+ "compose project $compose_project still has containers after teardown" \
73
+ "docker ps after compose down"
101
74
  fi
102
75
 
103
76
  # -----------------------------------------------------------------------------
104
- # 3. Remove the entire task directory (workspace + .uai/).
77
+ # 2. Remove the entire task directory (workspace + task-private Git data).
78
+ # No host Git command reads a repository that the just-removed container could
79
+ # have modified. Legacy shared-mirror worktree metadata may remain stale, but
80
+ # that retired `repo.git` path is never trusted by task-up again.
105
81
  # -----------------------------------------------------------------------------
106
82
 
107
83
  if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
@@ -110,10 +86,10 @@ if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
110
86
  fi
111
87
 
112
88
  # -----------------------------------------------------------------------------
113
- # 4. Persist final state.
89
+ # 3. Persist final state.
114
90
  # -----------------------------------------------------------------------------
115
91
 
116
- current_status=$(jq -r '.[0].status' <<<"$task_json")
92
+ current_status=$(jq -r '.[0].status // ""' <<<"$task_json")
117
93
  if [ "$current_status" != "shipped" ] && [ "$current_status" != "error" ]; then
118
94
  new_status="killed"
119
95
  else