@runuai/host 0.9.13 → 0.9.14
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/agent-cli.ts +36 -2
- package/lib/orchestrator.ts +10 -5
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +59 -3
- package/src/index.ts +116 -19
package/lib/agent-cli.ts
CHANGED
|
@@ -168,6 +168,22 @@ function parseFlags(args) {
|
|
|
168
168
|
return { flags, rest };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// A punchlist item's text is prose, and prose written in THIS repo contains
|
|
172
|
+
// --id and --text. parseFlags is global and greedy, so routing todo text
|
|
173
|
+
// through it silently rewrote the request: "todo edit aaaaaa the --id flag"
|
|
174
|
+
// resolved to { ref: "flag", text: "aaaaaa the" } and edited a different item,
|
|
175
|
+
// and a valueless --text became the literal string "true" — an item
|
|
176
|
+
// overwritten with garbage, or worse, the wrong item overwritten at all. Todo
|
|
177
|
+
// text is therefore read from the raw argv tail and never flag-parsed. The
|
|
178
|
+
// --id <ref> form survives only in leading position, where it cannot be
|
|
179
|
+
// mistaken for prose.
|
|
180
|
+
function todoRef(args) {
|
|
181
|
+
return args[0] === "--id"
|
|
182
|
+
? { ref: args[1], tail: args.slice(2) }
|
|
183
|
+
: { ref: args[0] && !args[0].startsWith("--") ? args[0] : "", tail: args.slice(1) };
|
|
184
|
+
}
|
|
185
|
+
function todoText(args) { return args.join(" ").trim(); }
|
|
186
|
+
|
|
171
187
|
async function api(method, path, body) {
|
|
172
188
|
const res = await fetch(API_URL + path, {
|
|
173
189
|
method,
|
|
@@ -292,14 +308,30 @@ async function main() {
|
|
|
292
308
|
break;
|
|
293
309
|
}
|
|
294
310
|
case "todo add": {
|
|
295
|
-
const text =
|
|
311
|
+
const text = todoText(rest);
|
|
296
312
|
if (!text) { console.error("uai: todo add needs text"); process.exit(1); }
|
|
297
313
|
const t = (await api("POST", "/api/agent/todos", { op: "add", text })).todo;
|
|
298
314
|
out("added #" + t.shortId + ": " + t.text);
|
|
299
315
|
break;
|
|
300
316
|
}
|
|
317
|
+
case "todo edit": {
|
|
318
|
+
const { ref, tail } = todoRef(rest);
|
|
319
|
+
const text = todoText(tail);
|
|
320
|
+
if (!ref) { console.error("uai: todo edit needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
321
|
+
if (!text) { console.error("uai: todo edit needs replacement text"); process.exit(1); }
|
|
322
|
+
const t = (await api("POST", "/api/agent/todos", { op: "edit", ref, text })).todo;
|
|
323
|
+
out("edited #" + t.shortId + ": " + t.text);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case "todo remove": {
|
|
327
|
+
const { ref } = todoRef(rest);
|
|
328
|
+
if (!ref) { console.error("uai: todo remove needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
329
|
+
await api("POST", "/api/agent/todos", { op: "remove", ref });
|
|
330
|
+
out("removed #" + String(ref).replace(/^#/, ""));
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
301
333
|
case "todo claim": case "todo done": case "todo reopen": case "todo unclaim": {
|
|
302
|
-
const ref =
|
|
334
|
+
const { ref } = todoRef(rest);
|
|
303
335
|
if (!ref) { console.error("uai: todo " + action + " needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
304
336
|
const t = (await api("POST", "/api/agent/todos", { op: action, ref })).todo;
|
|
305
337
|
out(action + " #" + t.shortId + ": " + t.text);
|
|
@@ -359,6 +391,8 @@ async function main() {
|
|
|
359
391
|
" uai memory delete <id>",
|
|
360
392
|
" uai todo list",
|
|
361
393
|
" uai todo add <text>",
|
|
394
|
+
" uai todo edit <#id> <text>",
|
|
395
|
+
" uai todo remove <#id>",
|
|
362
396
|
" uai todo claim|done|reopen|unclaim <#id>",
|
|
363
397
|
" uai preview add <name> <containerPort>",
|
|
364
398
|
" uai project create --name <n> --repo <git-url> [--prompt <p>]",
|
package/lib/orchestrator.ts
CHANGED
|
@@ -3083,7 +3083,9 @@ export function buildSystemPreamble(
|
|
|
3083
3083
|
"You are one agent in a uai task chat channel, shared with the human",
|
|
3084
3084
|
"and the other agents. To hand work to or ask another agent, mention",
|
|
3085
3085
|
"it by id at the start of a line — e.g. `@codex please review the",
|
|
3086
|
-
"diff`. uai routes
|
|
3086
|
+
"diff`. uai buffers the handoff and routes your complete turn into that",
|
|
3087
|
+
"agent's input only after your current turn ends. The mention does NOT",
|
|
3088
|
+
"wake the peer while you continue using tools in the same turn.",
|
|
3087
3089
|
];
|
|
3088
3090
|
const collaborationBrief = isSecretary
|
|
3089
3091
|
? [
|
|
@@ -3107,8 +3109,9 @@ export function buildSystemPreamble(
|
|
|
3107
3109
|
"Collaborate with your peers — divide up the work, review each other's",
|
|
3108
3110
|
"changes, share concrete ideas, and debate approach decisions by",
|
|
3109
3111
|
"@-mentioning them. That is how the team gets things done, and you",
|
|
3110
|
-
"should do it freely whenever it moves the work forward. But
|
|
3111
|
-
"
|
|
3112
|
+
"should do it freely whenever it moves the work forward. But a peer",
|
|
3113
|
+
"@mention WAKES it after your turn ends and costs a turn, so make each",
|
|
3114
|
+
"one count: a message to",
|
|
3112
3115
|
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
3113
3116
|
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
3114
3117
|
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
@@ -3267,7 +3270,7 @@ export function buildSystemPreamble(
|
|
|
3267
3270
|
? ", `memory search <query>`"
|
|
3268
3271
|
: "") +
|
|
3269
3272
|
(agent.permissions?.includes("todo.write")
|
|
3270
|
-
? ", `todo list|add|claim|done`"
|
|
3273
|
+
? ", `todo list|add|edit|remove|claim|done`"
|
|
3271
3274
|
: "") +
|
|
3272
3275
|
(agent.permissions?.includes("previews.write")
|
|
3273
3276
|
? ", `preview add <name> <port>`"
|
|
@@ -3327,7 +3330,9 @@ export function buildSystemPreamble(
|
|
|
3327
3330
|
"**`todo claim <#id>` BEFORE you start** an item so two agents",
|
|
3328
3331
|
"don't do the same work — and `todo done <#id>` when it's",
|
|
3329
3332
|
"complete. Check `todo list` before picking up work. The human",
|
|
3330
|
-
"watches the same list in the task UI.",
|
|
3333
|
+
"watches the same list in the task UI. Use `todo edit <#id>",
|
|
3334
|
+
"<text>` to correct an item and `todo remove <#id>` to delete",
|
|
3335
|
+
"one; both refuse an item another agent is actively claiming.",
|
|
3331
3336
|
]
|
|
3332
3337
|
: []),
|
|
3333
3338
|
...(agent.permissions?.includes("tasks.history")
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -167,6 +167,22 @@ fi
|
|
|
167
167
|
reject_unsafe_host_directory "$task_workspace" "the task workspace"
|
|
168
168
|
mkdir -p "$task_workspace"
|
|
169
169
|
|
|
170
|
+
# Git resolves symlinked ancestors when it writes a linked worktree's absolute
|
|
171
|
+
# `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
|
|
172
|
+
# paths, but also remember their physical aliases for validation and bind-mount
|
|
173
|
+
# destinations. The task root itself was rejected above if it was a symlink;
|
|
174
|
+
# `pwd -P` therefore resolves only trusted ancestors such as a migrated
|
|
175
|
+
# UAI_WORKSPACE_ROOT (`~/.uai-host-app/workspace -> ~/.uai`).
|
|
176
|
+
step "WORKTREE_FAILED" "resolve task workspace paths"
|
|
177
|
+
if ! task_dir_physical=$(cd "$task_dir" && pwd -P) \
|
|
178
|
+
|| [ -z "$task_dir_physical" ]; then
|
|
179
|
+
emit_err "WORKTREE_FAILED" \
|
|
180
|
+
"Uai could not resolve the task workspace's physical host path" \
|
|
181
|
+
"resolve task workspace paths"
|
|
182
|
+
fi
|
|
183
|
+
task_workspace_physical="$task_dir_physical/workspace"
|
|
184
|
+
task_uai_dir_physical="$task_dir_physical/.uai"
|
|
185
|
+
|
|
170
186
|
# -----------------------------------------------------------------------------
|
|
171
187
|
# 3. Bare mirrors + worktrees, one per selected project.
|
|
172
188
|
# -----------------------------------------------------------------------------
|
|
@@ -486,6 +502,7 @@ while IFS= read -r project_obj; do
|
|
|
486
502
|
# that old bind mount. The new cache path has never entered a container.
|
|
487
503
|
mirror_dir="$projects_root/$project_id/host-cache.git"
|
|
488
504
|
task_repo_dir="$task_uai_dir/repos/$project_id.git"
|
|
505
|
+
task_repo_dir_physical="$task_uai_dir_physical/repos/$project_id.git"
|
|
489
506
|
worktree_target="$task_workspace/$project_slug"
|
|
490
507
|
reject_unsafe_host_directory "$worktree_target" \
|
|
491
508
|
"the project worktree for $project_slug"
|
|
@@ -513,14 +530,18 @@ while IFS= read -r project_obj; do
|
|
|
513
530
|
gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
|
|
514
531
|
gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
|
|
515
532
|
case "$gitdir_value" in
|
|
516
|
-
"$task_repo_dir"/worktrees/*)
|
|
533
|
+
"$task_repo_dir"/worktrees/*)
|
|
534
|
+
gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
|
|
535
|
+
;;
|
|
536
|
+
"$task_repo_dir_physical"/worktrees/*)
|
|
537
|
+
gitdir_leaf=${gitdir_value#"$task_repo_dir_physical"/worktrees/}
|
|
538
|
+
;;
|
|
517
539
|
*)
|
|
518
540
|
emit_err "WORKTREE_FAILED" \
|
|
519
541
|
"This task uses Uai's legacy shared Git layout. Its container has been removed so it can no longer write the shared mirror. Preserve any uncommitted changes from $worktree_target, then recreate the task to migrate safely." \
|
|
520
542
|
"legacy task repository requires recreation ($project_id)"
|
|
521
543
|
;;
|
|
522
544
|
esac
|
|
523
|
-
gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
|
|
524
545
|
case "$gitdir_leaf" in
|
|
525
546
|
''|.|..|*/*)
|
|
526
547
|
emit_err "WORKTREE_FAILED" \
|
|
@@ -1018,11 +1039,24 @@ fi
|
|
|
1018
1039
|
# repository is mounted; the host cache never enters a container. Legacy
|
|
1019
1040
|
# worktrees fail closed before Compose rendering and must be recreated.
|
|
1020
1041
|
printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
|
|
1042
|
+
if [ "$task_workspace_physical" != "$task_workspace" ]; then
|
|
1043
|
+
# Git canonicalizes a symlinked workspace root in both directions: the
|
|
1044
|
+
# worktree's `.git` points at the physical repository, and the repository's
|
|
1045
|
+
# worktree metadata points back at the physical workspace. Mount the same
|
|
1046
|
+
# already-approved workspace at that alias without exposing its `.uai`
|
|
1047
|
+
# sibling or any broader host directory.
|
|
1048
|
+
printf ' - "%s:%s"\n' \
|
|
1049
|
+
"$task_workspace" "$task_workspace_physical"
|
|
1050
|
+
fi
|
|
1021
1051
|
while IFS= read -r vol_obj; do
|
|
1022
1052
|
[ -n "$vol_obj" ] || continue
|
|
1023
1053
|
vol_pid=$(jq -r '.id' <<<"$vol_obj")
|
|
1024
1054
|
printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
|
|
1025
1055
|
"$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
|
|
1056
|
+
if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
|
|
1057
|
+
printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
|
|
1058
|
+
"$task_uai_dir" "$vol_pid" "$task_uai_dir_physical" "$vol_pid"
|
|
1059
|
+
fi
|
|
1026
1060
|
done < <(jq -c '.[]' <<<"$projects_json")
|
|
1027
1061
|
printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
|
|
1028
1062
|
# ADR-053: host-wide Playwright browser cache. Always mounted (harmless
|
|
@@ -1290,7 +1324,9 @@ while IFS= read -r safe_project; do
|
|
|
1290
1324
|
for safe_worktree in \
|
|
1291
1325
|
"/workspace/$safe_project_slug" \
|
|
1292
1326
|
"$task_workspace/$safe_project_slug" \
|
|
1293
|
-
"$task_uai_dir/repos/$safe_project_id.git"
|
|
1327
|
+
"$task_uai_dir/repos/$safe_project_id.git" \
|
|
1328
|
+
"$task_workspace_physical/$safe_project_slug" \
|
|
1329
|
+
"$task_uai_dir_physical/repos/$safe_project_id.git"; do
|
|
1294
1330
|
if ! printf '%s\n' "$container_safe_dirs" \
|
|
1295
1331
|
| grep -Fqx -- "$safe_worktree"; then
|
|
1296
1332
|
if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
|
|
@@ -1305,6 +1341,26 @@ while IFS= read -r safe_project; do
|
|
|
1305
1341
|
done
|
|
1306
1342
|
done < <(jq -c '.[]' <<<"$projects_json")
|
|
1307
1343
|
|
|
1344
|
+
# A running container is not sufficient proof that its linked worktrees are
|
|
1345
|
+
# usable. In particular, Git may have canonicalized a symlinked host workspace
|
|
1346
|
+
# root while Compose mounted only its lexical spelling. Verify the actual path
|
|
1347
|
+
# agents use before credentials, dependency installation, or status=running.
|
|
1348
|
+
step "CONTAINER_INIT_FAILED" "verify task Git worktrees"
|
|
1349
|
+
while IFS= read -r verify_project; do
|
|
1350
|
+
[ -n "$verify_project" ] || continue
|
|
1351
|
+
verify_project_slug=$(jq -r '.slug' <<<"$verify_project")
|
|
1352
|
+
verify_is_worktree=""
|
|
1353
|
+
if ! verify_is_worktree=$(docker exec -u node "${managed_exec_env[@]}" \
|
|
1354
|
+
"$app_container" "${managed_clean_env[@]}" /usr/bin/git \
|
|
1355
|
+
-C "/workspace/$verify_project_slug" rev-parse \
|
|
1356
|
+
--is-inside-work-tree 2>/dev/null) \
|
|
1357
|
+
|| [ "$verify_is_worktree" != "true" ]; then
|
|
1358
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1359
|
+
"Uai could not open $verify_project_slug as a Git worktree inside the task container. Retry the task after checking the host workspace path." \
|
|
1360
|
+
"verify task Git worktree ($verify_project_slug)"
|
|
1361
|
+
fi
|
|
1362
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
1363
|
+
|
|
1308
1364
|
# A failed/retried start can preserve a container that still holds a previous
|
|
1309
1365
|
# account. Erase that Uai-managed state first for both connected and
|
|
1310
1366
|
# disconnected starts; otherwise a no-credential resume could briefly reuse a
|
package/src/index.ts
CHANGED
|
@@ -171,26 +171,123 @@ export const hostCommands: HostCommands = {
|
|
|
171
171
|
.split(/\r?\n/)
|
|
172
172
|
.some((state) => state.trim() === "running")
|
|
173
173
|
) {
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// the
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
174
|
+
// Docker being up is not enough to prove a lost-response retry is
|
|
175
|
+
// healthy. The worktree's `.git` file contains an absolute pointer
|
|
176
|
+
// into the task-private repository; after a host workspace-root
|
|
177
|
+
// migration the old container can keep running while every agent
|
|
178
|
+
// sees `fatal: not a git repository`. Probe from INSIDE the live
|
|
179
|
+
// container, which is the filesystem view the agents actually use.
|
|
180
|
+
// Scratchpads have no projects, so this remains an O(1) fast path.
|
|
181
|
+
const appContainer = `${existingTask.composeProject}-app-1`;
|
|
182
|
+
let projectsHealthy = true;
|
|
183
|
+
for (const project of input.projects) {
|
|
184
|
+
const gitHealth = await dockerCli(
|
|
185
|
+
[
|
|
186
|
+
"exec",
|
|
187
|
+
"-u",
|
|
188
|
+
"node",
|
|
189
|
+
// docker exec inherits the Compose/project environment. Clear
|
|
190
|
+
// loader and Git controls before the first executable, then
|
|
191
|
+
// give Git a minimal environment of its own. Keep this aligned
|
|
192
|
+
// with task-up.sh's managed container operations.
|
|
193
|
+
"-e",
|
|
194
|
+
"HOME=/home/node",
|
|
195
|
+
"-e",
|
|
196
|
+
"PATH=/usr/bin:/bin",
|
|
197
|
+
"-e",
|
|
198
|
+
"LD_PRELOAD=",
|
|
199
|
+
"-e",
|
|
200
|
+
"LD_LIBRARY_PATH=",
|
|
201
|
+
"-e",
|
|
202
|
+
"DYLD_INSERT_LIBRARIES=",
|
|
203
|
+
"-e",
|
|
204
|
+
"DYLD_LIBRARY_PATH=",
|
|
205
|
+
"-e",
|
|
206
|
+
"BASH_ENV=",
|
|
207
|
+
"-e",
|
|
208
|
+
"ENV=",
|
|
209
|
+
"-e",
|
|
210
|
+
"GIT_CONFIG=",
|
|
211
|
+
"-e",
|
|
212
|
+
"GIT_CONFIG_GLOBAL=",
|
|
213
|
+
"-e",
|
|
214
|
+
"GIT_CONFIG_SYSTEM=",
|
|
215
|
+
"-e",
|
|
216
|
+
"GIT_CONFIG_NOSYSTEM=",
|
|
217
|
+
"-e",
|
|
218
|
+
"GIT_CONFIG_COUNT=0",
|
|
219
|
+
"-e",
|
|
220
|
+
"GIT_EXEC_PATH=",
|
|
221
|
+
"-e",
|
|
222
|
+
"GIT_SSH=",
|
|
223
|
+
"-e",
|
|
224
|
+
"GIT_SSH_COMMAND=",
|
|
225
|
+
"-e",
|
|
226
|
+
"GIT_ASKPASS=",
|
|
227
|
+
"-e",
|
|
228
|
+
"SSH_ASKPASS=",
|
|
229
|
+
"-w",
|
|
230
|
+
`/workspace/${project.slug}`,
|
|
231
|
+
appContainer,
|
|
232
|
+
"/usr/bin/env",
|
|
233
|
+
"-i",
|
|
234
|
+
"HOME=/home/node",
|
|
235
|
+
"PATH=/usr/bin:/bin",
|
|
236
|
+
"/usr/bin/git",
|
|
237
|
+
"rev-parse",
|
|
238
|
+
"--is-inside-work-tree",
|
|
239
|
+
],
|
|
240
|
+
{ timeoutMs: 10_000 },
|
|
241
|
+
);
|
|
242
|
+
// A timeout/spawn error is not proof that the repository is
|
|
243
|
+
// broken. Preserve the running task and let the caller retry once
|
|
244
|
+
// Docker can answer, just as the runtime-state probe above does.
|
|
245
|
+
if (gitHealth.status === null) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
code: HostErrorCode.HostUnavailable,
|
|
249
|
+
message:
|
|
250
|
+
`could not verify Git state for task ${input.task.id} ` +
|
|
251
|
+
`project ${project.id}: ` +
|
|
252
|
+
(gitHealth.stderr.trim().slice(0, 200) ||
|
|
253
|
+
"docker did not answer"),
|
|
254
|
+
retryable: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
gitHealth.status !== 0 ||
|
|
259
|
+
gitHealth.stdout.trim() !== "true"
|
|
260
|
+
) {
|
|
261
|
+
projectsHealthy = false;
|
|
262
|
+
console.warn(
|
|
263
|
+
`[host-agent] task ${input.task.id}: project ${project.id} Git health check failed; rebuilding the running task (${gitHealth.stderr.trim().slice(0, 200) || `git exited ${String(gitHealth.status)}`})`,
|
|
264
|
+
);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (projectsHealthy) {
|
|
269
|
+
// waitForChannelClose crossed the teardown fence above. A completed
|
|
270
|
+
// close-only teardown may have left this running task tombstoned,
|
|
271
|
+
// so the idempotent-success transition must reopen delivery just
|
|
272
|
+
// like a fresh successful taskUp does.
|
|
273
|
+
orchestrator.allowChannel(input.task.id);
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
value: {
|
|
277
|
+
composeProject: existingTask.composeProject,
|
|
278
|
+
worktreePath: existingTask.worktreePath,
|
|
279
|
+
codeServerPort: existingTask.codeServerPort ?? undefined,
|
|
280
|
+
previewPorts: parsePreviewPortRuntimes(
|
|
281
|
+
existingTask.previewPorts,
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
190
286
|
}
|
|
191
|
-
// Docker positively proved the mirrored app is absent/stopped
|
|
192
|
-
//
|
|
193
|
-
//
|
|
287
|
+
// Docker positively proved the mirrored app is absent/stopped, or the
|
|
288
|
+
// live container proved one of its project repositories is broken.
|
|
289
|
+
// Drain and invalidate any stale in-memory channel before taskUp
|
|
290
|
+
// recreates the same stable compose/container names and repairs Git.
|
|
194
291
|
await orchestrator.closeChannel(input.task.id);
|
|
195
292
|
}
|
|
196
293
|
recordTaskStarting(input.task.id);
|