@runuai/host 0.9.12 → 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/event-outbox.ts +104 -0
- package/src/index.ts +116 -19
- package/src/main.ts +74 -7
- package/src/protocol.ts +21 -3
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
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-103: the host-side event outbox.
|
|
3
|
+
*
|
|
4
|
+
* Every HostEvent gets a monotonic seq and its serialized wire frame is held
|
|
5
|
+
* here until the cloud acks it. The bridge connection drains the outbox in
|
|
6
|
+
* order — live traffic and reconnect replay are the same code path, so a
|
|
7
|
+
* WSS blip (a cloud deploy) can no longer drop events on the floor.
|
|
8
|
+
*
|
|
9
|
+
* Bounded, in-memory. A host PROCESS restart still loses unsent events —
|
|
10
|
+
* that path is ADR-061's (the restarted host reattaches to the container
|
|
11
|
+
* and resumes streaming); this outbox closes the disconnect gap only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HostEvent } from "./protocol";
|
|
15
|
+
|
|
16
|
+
export interface OutboxEntry {
|
|
17
|
+
seq: number;
|
|
18
|
+
/** The full serialized `{kind:"event", seq, event}` frame, ready to send. */
|
|
19
|
+
raw: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Overflow bounds. Generous: a disconnect longer than this many events is a
|
|
23
|
+
* real outage, not a deploy blip, and dropping OLDEST keeps the tail — the
|
|
24
|
+
* most recent turn — intact for replay. */
|
|
25
|
+
const MAX_ENTRIES = 10_000;
|
|
26
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export class EventOutbox {
|
|
29
|
+
private entries: OutboxEntry[] = [];
|
|
30
|
+
private totalBytes = 0;
|
|
31
|
+
private nextSeq = 1;
|
|
32
|
+
/** Highest seq ever transmitted on any connection. */
|
|
33
|
+
private sentUpTo = 0;
|
|
34
|
+
/** Entries dropped by overflow since the last drain — for one loud log. */
|
|
35
|
+
private droppedSinceDrain = 0;
|
|
36
|
+
|
|
37
|
+
/** Serialize + append. Returns the entry's seq. */
|
|
38
|
+
enqueue(event: HostEvent): number {
|
|
39
|
+
const seq = this.nextSeq++;
|
|
40
|
+
const raw = JSON.stringify({ kind: "event", seq, event });
|
|
41
|
+
this.entries.push({ seq, raw });
|
|
42
|
+
this.totalBytes += raw.length;
|
|
43
|
+
while (
|
|
44
|
+
this.entries.length > MAX_ENTRIES ||
|
|
45
|
+
(this.totalBytes > MAX_BYTES && this.entries.length > 1)
|
|
46
|
+
) {
|
|
47
|
+
const dropped = this.entries.shift();
|
|
48
|
+
if (!dropped) break;
|
|
49
|
+
this.totalBytes -= dropped.raw.length;
|
|
50
|
+
// Only a NEVER-SENT entry is a real loss; an unacked-but-sent one most
|
|
51
|
+
// likely landed and just lost its ack.
|
|
52
|
+
if (dropped.seq > this.sentUpTo) this.droppedSinceDrain += 1;
|
|
53
|
+
}
|
|
54
|
+
return seq;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Cumulative ack: forget everything up to and including `seq`. */
|
|
58
|
+
ack(seq: number): void {
|
|
59
|
+
let i = 0;
|
|
60
|
+
for (const entry of this.entries) {
|
|
61
|
+
if (entry.seq > seq) break;
|
|
62
|
+
this.totalBytes -= entry.raw.length;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
if (i > 0) this.entries.splice(0, i);
|
|
66
|
+
if (seq > this.sentUpTo) this.sentUpTo = seq;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rewind the transmit cursor for a reconnect (ADR-103 resume handshake).
|
|
71
|
+
* `afterSeq` is the cloud's watermark: everything newer re-sends on the
|
|
72
|
+
* next drain. `null` = the cloud has no state for this boot — replay
|
|
73
|
+
* nothing; only never-transmitted entries go out.
|
|
74
|
+
*/
|
|
75
|
+
resume(afterSeq: number | null): void {
|
|
76
|
+
if (afterSeq !== null && afterSeq < this.sentUpTo) this.sentUpTo = afterSeq;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Entries due for transmission, in order. The caller sends them and the
|
|
81
|
+
* cursor advances — drain is idempotent per entry until `resume` rewinds.
|
|
82
|
+
*/
|
|
83
|
+
drain(): OutboxEntry[] {
|
|
84
|
+
const due = this.entries.filter((e) => e.seq > this.sentUpTo);
|
|
85
|
+
const last = due[due.length - 1];
|
|
86
|
+
if (last) this.sentUpTo = last.seq;
|
|
87
|
+
return due;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Never-sent entries lost to overflow since the last call; resets. */
|
|
91
|
+
takeDroppedCount(): number {
|
|
92
|
+
const n = this.droppedSinceDrain;
|
|
93
|
+
this.droppedSinceDrain = 0;
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get size(): number {
|
|
98
|
+
return this.entries.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get bytes(): number {
|
|
102
|
+
return this.totalBytes;
|
|
103
|
+
}
|
|
104
|
+
}
|
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);
|
package/src/main.ts
CHANGED
|
@@ -82,8 +82,10 @@ import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
|
|
|
82
82
|
import "../lib/agents/factory";
|
|
83
83
|
import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
|
|
84
84
|
import { hostCommands, hostEvents } from "./index";
|
|
85
|
+
import { EventOutbox } from "./event-outbox";
|
|
85
86
|
import {
|
|
86
87
|
HostErrorCode,
|
|
88
|
+
EVENT_REPLAY_PROTOCOL_FEATURE,
|
|
87
89
|
GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
|
|
88
90
|
GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
|
|
89
91
|
GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
|
|
@@ -149,6 +151,33 @@ let shutdownRequested = false;
|
|
|
149
151
|
let pendingBinaryTunnelId: string | null = null;
|
|
150
152
|
const tunnels = new TunnelRegistry();
|
|
151
153
|
|
|
154
|
+
// ADR-103: event outbox. BOOT_ID scopes seqs to this process lifetime; the
|
|
155
|
+
// cloud's replay watermark only applies within a matching boot. Events are
|
|
156
|
+
// enqueued by a PROCESS-level subscription (below, before connect()) so a
|
|
157
|
+
// dropped WSS no longer drops events — they drain on reconnect, after the
|
|
158
|
+
// cloud names its watermark via event.resume.
|
|
159
|
+
const BOOT_ID = newId();
|
|
160
|
+
const eventOutbox = new EventOutbox();
|
|
161
|
+
// Transmission is held per-connection until the cloud's event.resume sets the
|
|
162
|
+
// replay cursor (or the fallback timer concedes the cloud predates ADR-103).
|
|
163
|
+
let eventFlushEnabled = false;
|
|
164
|
+
let resumeFallbackTimer: NodeJS.Timeout | null = null;
|
|
165
|
+
const RESUME_FALLBACK_MS = 3_000;
|
|
166
|
+
|
|
167
|
+
function flushEvents(): void {
|
|
168
|
+
const socket = ws;
|
|
169
|
+
if (!eventFlushEnabled || !socket || socket.readyState !== WebSocket.OPEN) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const dropped = eventOutbox.takeDroppedCount();
|
|
173
|
+
if (dropped > 0) {
|
|
174
|
+
console.warn(
|
|
175
|
+
`[host-agent] event outbox overflowed: ${dropped} unsent event(s) lost`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
for (const entry of eventOutbox.drain()) socket.send(entry.raw);
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
interface PausableSource {
|
|
153
182
|
pause(): unknown;
|
|
154
183
|
resume(): unknown;
|
|
@@ -187,6 +216,13 @@ void ensureStandardImage();
|
|
|
187
216
|
// self-heals such containers: it re-copies and chowns every running task.
|
|
188
217
|
void recoveryComplete().then(() => reinjectCodexRunningTasks());
|
|
189
218
|
watchCodexAuth();
|
|
219
|
+
// ADR-103: subscribe ONCE, for the process — not per connection. Every event
|
|
220
|
+
// lands in the outbox regardless of socket state; flushEvents is a no-op
|
|
221
|
+
// while disconnected and the backlog drains after the resume handshake.
|
|
222
|
+
hostEvents.subscribe((event) => {
|
|
223
|
+
eventOutbox.enqueue(event);
|
|
224
|
+
flushEvents();
|
|
225
|
+
});
|
|
190
226
|
connect();
|
|
191
227
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
|
192
228
|
// Best-effort: a UI bind failure must not take the host service down.
|
|
@@ -234,6 +270,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
234
270
|
version: packageVersion(),
|
|
235
271
|
protocolFeatures: [
|
|
236
272
|
TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
|
|
273
|
+
EVENT_REPLAY_PROTOCOL_FEATURE,
|
|
237
274
|
GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
|
|
238
275
|
GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
|
|
239
276
|
GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
|
|
@@ -275,19 +312,22 @@ function connect(): void {
|
|
|
275
312
|
|
|
276
313
|
let lastTraffic = Date.now();
|
|
277
314
|
let ready = false;
|
|
278
|
-
let unsubscribe: (() => void) | null = null;
|
|
279
315
|
let pingTimer: NodeJS.Timeout | null = null;
|
|
280
316
|
let deadTimer: NodeJS.Timeout | null = null;
|
|
281
317
|
|
|
282
318
|
const cleanup = (): void => {
|
|
283
319
|
if (pingTimer) clearInterval(pingTimer);
|
|
284
320
|
if (deadTimer) clearInterval(deadTimer);
|
|
285
|
-
|
|
321
|
+
eventFlushEnabled = false;
|
|
322
|
+
if (resumeFallbackTimer) {
|
|
323
|
+
clearTimeout(resumeFallbackTimer);
|
|
324
|
+
resumeFallbackTimer = null;
|
|
325
|
+
}
|
|
286
326
|
if (ws === socket) ws = null;
|
|
287
327
|
};
|
|
288
328
|
|
|
289
329
|
socket.on("open", () => {
|
|
290
|
-
send(socket, { kind: "auth", token, hostId });
|
|
330
|
+
send(socket, { kind: "auth", token, hostId, bootId: BOOT_ID });
|
|
291
331
|
// Advertise capabilities immediately after auth (ADR-021). The bridge
|
|
292
332
|
// rejects with close-code 4001 if auth fails, so sending here is harmless
|
|
293
333
|
// on a bad token and saves a round-trip on a good one. Re-sent on every
|
|
@@ -303,10 +343,14 @@ function connect(): void {
|
|
|
303
343
|
setHostObsTag(hostId);
|
|
304
344
|
addHostBreadcrumb("bridge", "connected");
|
|
305
345
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
346
|
+
// ADR-103: hold event transmission until the cloud names its replay
|
|
347
|
+
// watermark (event.resume). A pre-103 cloud never will — after the
|
|
348
|
+
// fallback, drain never-transmitted entries only (no replay, no dupes).
|
|
349
|
+
resumeFallbackTimer = setTimeout(() => {
|
|
350
|
+
resumeFallbackTimer = null;
|
|
351
|
+
eventFlushEnabled = true;
|
|
352
|
+
flushEvents();
|
|
353
|
+
}, RESUME_FALLBACK_MS);
|
|
310
354
|
|
|
311
355
|
pingTimer = setInterval(() => {
|
|
312
356
|
if (socket.readyState === WebSocket.OPEN) {
|
|
@@ -339,6 +383,20 @@ function connect(): void {
|
|
|
339
383
|
switch (frame.kind) {
|
|
340
384
|
case "pong":
|
|
341
385
|
break;
|
|
386
|
+
case "event.resume":
|
|
387
|
+
// ADR-103: the cloud named its watermark — rewind the transmit
|
|
388
|
+
// cursor to it (null = no state, replay nothing) and start draining.
|
|
389
|
+
if (resumeFallbackTimer) {
|
|
390
|
+
clearTimeout(resumeFallbackTimer);
|
|
391
|
+
resumeFallbackTimer = null;
|
|
392
|
+
}
|
|
393
|
+
eventOutbox.resume(frame.afterSeq);
|
|
394
|
+
eventFlushEnabled = true;
|
|
395
|
+
flushEvents();
|
|
396
|
+
break;
|
|
397
|
+
case "event.ack":
|
|
398
|
+
eventOutbox.ack(frame.seq);
|
|
399
|
+
break;
|
|
342
400
|
case "command":
|
|
343
401
|
void handleCommand(socket, frame);
|
|
344
402
|
break;
|
|
@@ -1227,6 +1285,15 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
1227
1285
|
if (frame.kind === "pong" && typeof frame.ts === "number") {
|
|
1228
1286
|
return { kind: "pong", ts: frame.ts };
|
|
1229
1287
|
}
|
|
1288
|
+
if (
|
|
1289
|
+
frame.kind === "event.resume" &&
|
|
1290
|
+
(frame.afterSeq === null || typeof frame.afterSeq === "number")
|
|
1291
|
+
) {
|
|
1292
|
+
return { kind: "event.resume", afterSeq: frame.afterSeq };
|
|
1293
|
+
}
|
|
1294
|
+
if (frame.kind === "event.ack" && typeof frame.seq === "number") {
|
|
1295
|
+
return { kind: "event.ack", seq: frame.seq };
|
|
1296
|
+
}
|
|
1230
1297
|
if (
|
|
1231
1298
|
frame.kind === "command" &&
|
|
1232
1299
|
typeof frame.commandId === "string" &&
|
package/src/protocol.ts
CHANGED
|
@@ -41,6 +41,11 @@ export const GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE =
|
|
|
41
41
|
"github-repository-access-v1";
|
|
42
42
|
export const GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE =
|
|
43
43
|
"github-credential-generation-v1";
|
|
44
|
+
// ADR-103: the host runs an event outbox — seq'd event frames, resume
|
|
45
|
+
// handshake on reconnect, cumulative acks. A cloud seeing this feature knows
|
|
46
|
+
// a disconnect no longer loses events (they replay), so it keeps its
|
|
47
|
+
// in-flight turn buffers across the gap.
|
|
48
|
+
export const EVENT_REPLAY_PROTOCOL_FEATURE = "event-replay-v1";
|
|
44
49
|
export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
|
|
45
50
|
export const MAX_AGENT_ID_CHARS = 128;
|
|
46
51
|
export const MAX_SECRETARY_DISPATCH_RECIPIENTS = 16;
|
|
@@ -603,7 +608,15 @@ export type CloudToHost =
|
|
|
603
608
|
// discovery, DCR, PKCE, token exchange, encrypted storage. Secrets in a
|
|
604
609
|
// probe (headerValue, clientSecret) are write-only; nothing secret ever
|
|
605
610
|
// rides an ack. `opId` correlates request↔ack.
|
|
606
|
-
| { kind: "mcp.op"; opId: string; op: McpOp }
|
|
611
|
+
| { kind: "mcp.op"; opId: string; op: McpOp }
|
|
612
|
+
// ADR-103: sent once right after auth to a host that supplied a bootId.
|
|
613
|
+
// `afterSeq` is the cloud's replay watermark for this boot: the host
|
|
614
|
+
// replays every outbox entry with seq > afterSeq. `null` means the cloud
|
|
615
|
+
// has no watermark for this boot (fresh boot, or lost KV) — the host sends
|
|
616
|
+
// only entries it never transmitted, replaying nothing.
|
|
617
|
+
| { kind: "event.resume"; afterSeq: number | null }
|
|
618
|
+
// ADR-103: cumulative — the host trims outbox entries with seq <= seq.
|
|
619
|
+
| { kind: "event.ack"; seq: number };
|
|
607
620
|
|
|
608
621
|
/** One MCP-connection operation (ADR-057), executed by the host. */
|
|
609
622
|
export type McpOp =
|
|
@@ -632,14 +645,19 @@ export type McpOp =
|
|
|
632
645
|
| { kind: "disconnect"; connectionId: string };
|
|
633
646
|
|
|
634
647
|
export type HostToCloud =
|
|
635
|
-
|
|
648
|
+
// ADR-103: `bootId` scopes event seqs to one host process lifetime — the
|
|
649
|
+
// cloud's replay watermark only applies within a matching boot. Optional
|
|
650
|
+
// for pre-103 hosts (which also never send seq'd events).
|
|
651
|
+
| { kind: "auth"; token: string; hostId: string; bootId?: string }
|
|
636
652
|
| { kind: "host.capabilities"; capabilities: HostCapabilities }
|
|
637
653
|
| {
|
|
638
654
|
kind: "result";
|
|
639
655
|
commandId: string;
|
|
640
656
|
result: HostCommandResult<unknown>;
|
|
641
657
|
}
|
|
642
|
-
|
|
658
|
+
// ADR-103: `seq` is the outbox sequence number (monotonic within a bootId).
|
|
659
|
+
// Absent from pre-103 hosts; the cloud dedups replay overlap by it.
|
|
660
|
+
| { kind: "event"; event: HostEvent; seq?: number }
|
|
643
661
|
// Host-pushed task lifecycle (ADR-028 local-UI Stop): the host operator paused
|
|
644
662
|
// a task's containers, so the cloud mirrors the status (currently "stopped").
|
|
645
663
|
| { kind: "task.status"; taskId: string; status: string }
|