@runuai/host 0.2.4 → 0.2.6
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/docker-exec.ts +7 -0
- package/lib/github-tokens.ts +134 -10
- package/lib/orchestrator.ts +109 -24
- package/package.json +1 -1
- package/src/protocol.ts +4 -1
package/lib/docker-exec.ts
CHANGED
|
@@ -59,6 +59,13 @@ export function dockerCli(
|
|
|
59
59
|
child.on("close", (code) => {
|
|
60
60
|
finish(code);
|
|
61
61
|
});
|
|
62
|
+
// EPIPE guard: if the child exits without draining stdin (or our own
|
|
63
|
+
// timeout SIGKILLs it mid-write), the failed write surfaces as an 'error'
|
|
64
|
+
// event on the stdin stream — unhandled, that's an uncaughtException that
|
|
65
|
+
// would take down the whole host service. Today's inputs are tiny (a gh
|
|
66
|
+
// token) and complete synchronously, but this keeps the helper safe for
|
|
67
|
+
// any future caller piping real data.
|
|
68
|
+
child.stdin?.on("error", () => {});
|
|
62
69
|
if (opts.input !== undefined) child.stdin?.write(opts.input);
|
|
63
70
|
child.stdin?.end();
|
|
64
71
|
});
|
package/lib/github-tokens.ts
CHANGED
|
@@ -16,7 +16,11 @@ import type { CloudToHost } from "../src/protocol";
|
|
|
16
16
|
|
|
17
17
|
const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
18
18
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
19
|
-
|
|
19
|
+
// Cap the cloud token-exchange round-trip. Generous on purpose: aborting an
|
|
20
|
+
// exchange that GitHub already completed burns the single-use refresh token
|
|
21
|
+
// (we never see the rotated replacement), so a slow success beats a fast
|
|
22
|
+
// ambiguous abort.
|
|
23
|
+
const EXCHANGE_TIMEOUT_MS = 30_000;
|
|
20
24
|
|
|
21
25
|
type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
22
26
|
|
|
@@ -25,6 +29,9 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
|
25
29
|
/** Encrypt + persist the user's refresh token (gh.connect.set handler). */
|
|
26
30
|
export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
|
|
27
31
|
try {
|
|
32
|
+
// Fresh grant — a cached access token from the previous grant may be
|
|
33
|
+
// revoked; drop it so the next mint exchanges against the new token.
|
|
34
|
+
clearAccessCache(frame.userId);
|
|
28
35
|
const sealed = sealAesGcm(frame.refreshToken);
|
|
29
36
|
const now = Date.now();
|
|
30
37
|
getDb()
|
|
@@ -64,6 +71,7 @@ export function onConnectClear(userId: string): void {
|
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
export function deleteToken(userId: string): void {
|
|
74
|
+
clearAccessCache(userId);
|
|
67
75
|
getDb()
|
|
68
76
|
.delete(schema.githubTokens)
|
|
69
77
|
.where(eq(schema.githubTokens.userId, userId))
|
|
@@ -115,8 +123,68 @@ interface ExchangeResult {
|
|
|
115
123
|
* Mint a fresh access token for the user via the cloud exchange. Persists a
|
|
116
124
|
* rotated refresh token if GitHub returned one. Returns null when the user has
|
|
117
125
|
* no token on this host. Throws on exchange failure.
|
|
126
|
+
*
|
|
127
|
+
* Deduped PER USER: GitHub refresh tokens are single-use (each exchange
|
|
128
|
+
* rotates them), and GitHub treats reuse of a consumed token as a compromise
|
|
129
|
+
* signal that can kill the whole grant. Concurrent task setups for the same
|
|
130
|
+
* user (several task-ups at once, or every channel re-ensure after a host
|
|
131
|
+
* restart) used to race the rotation — the loser burned the grant and every
|
|
132
|
+
* exchange afterwards failed until a manual re-connect. The access token is
|
|
133
|
+
* per-user anyway, so concurrent callers share one in-flight exchange.
|
|
134
|
+
*/
|
|
135
|
+
const inflightExchanges = new Map<
|
|
136
|
+
string,
|
|
137
|
+
Promise<{ accessToken: string; expiresAt: number } | null>
|
|
138
|
+
>();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Per-user ACCESS-token cache. Access tokens live ~8h, but every exchange
|
|
142
|
+
* ROTATES the single-use refresh token — and a rotation whose response is
|
|
143
|
+
* lost mid-flight (our timeout firing while GitHub already rotated) burns the
|
|
144
|
+
* grant, forcing a manual re-connect. So exchange as rarely as possible: all
|
|
145
|
+
* task setups and refresh timers for a user share one cached access token
|
|
146
|
+
* until it nears expiry. The refresh timers fire at expiry−5min, inside the
|
|
147
|
+
* 15min stale window, so exactly one of them performs the real exchange (the
|
|
148
|
+
* in-flight dedupe serializes any ties) and the rest re-inject from cache.
|
|
118
149
|
*/
|
|
119
|
-
|
|
150
|
+
const ACCESS_CACHE_LEAD_MS = 15 * 60 * 1000;
|
|
151
|
+
const accessCache = new Map<
|
|
152
|
+
string,
|
|
153
|
+
{ accessToken: string; expiresAt: number }
|
|
154
|
+
>();
|
|
155
|
+
|
|
156
|
+
/** Drop a user's cached access token (token deleted / re-granted). */
|
|
157
|
+
function clearAccessCache(userId: string): void {
|
|
158
|
+
accessCache.delete(userId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Test hook: reset the access-token cache. */
|
|
162
|
+
export function clearAllAccessCache(): void {
|
|
163
|
+
accessCache.clear();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function requestAccessToken(
|
|
167
|
+
userId: string,
|
|
168
|
+
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
169
|
+
const cached = accessCache.get(userId);
|
|
170
|
+
if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
|
|
171
|
+
return Promise.resolve(cached);
|
|
172
|
+
}
|
|
173
|
+
const existing = inflightExchanges.get(userId);
|
|
174
|
+
if (existing) return existing;
|
|
175
|
+
const run = doRequestAccessToken(userId)
|
|
176
|
+
.then((tok) => {
|
|
177
|
+
if (tok) accessCache.set(userId, tok);
|
|
178
|
+
return tok;
|
|
179
|
+
})
|
|
180
|
+
.finally(() => {
|
|
181
|
+
inflightExchanges.delete(userId);
|
|
182
|
+
});
|
|
183
|
+
inflightExchanges.set(userId, run);
|
|
184
|
+
return run;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function doRequestAccessToken(
|
|
120
188
|
userId: string,
|
|
121
189
|
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
122
190
|
const row = getDb()
|
|
@@ -140,7 +208,19 @@ export async function requestAccessToken(
|
|
|
140
208
|
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
141
209
|
});
|
|
142
210
|
if (!res.ok) {
|
|
143
|
-
|
|
211
|
+
// Include the cloud's error body: it passes GitHub's OAuth error code
|
|
212
|
+
// through (e.g. "bad_refresh_token"/"invalid_grant" inside the 502
|
|
213
|
+
// message), which is the ONLY reliable revocation signal. The bare HTTP
|
|
214
|
+
// status is not one — the cloud returns 401 solely for a host-token
|
|
215
|
+
// mismatch and wraps every GitHub-side failure as 502.
|
|
216
|
+
let detail = "";
|
|
217
|
+
try {
|
|
218
|
+
const body = (await res.json()) as { message?: string };
|
|
219
|
+
if (body && typeof body.message === "string") detail = ` — ${body.message}`;
|
|
220
|
+
} catch {
|
|
221
|
+
// non-JSON body — status alone will have to do
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
|
|
144
224
|
}
|
|
145
225
|
const data = (await res.json()) as ExchangeResult;
|
|
146
226
|
if (data.refreshToken) {
|
|
@@ -226,6 +306,19 @@ export function scheduleRefresh(
|
|
|
226
306
|
timers.set(taskId, timer);
|
|
227
307
|
}
|
|
228
308
|
|
|
309
|
+
/**
|
|
310
|
+
* True when the exchange error names a GitHub-side revocation — the refresh
|
|
311
|
+
* token itself is dead, so retrying is pointless and the user must re-grant.
|
|
312
|
+
* Deliberately keyed on GitHub's OAuth error strings (passed through the
|
|
313
|
+
* cloud's 502 body by requestAccessToken), NOT on bare HTTP status: the cloud
|
|
314
|
+
* returns 401 only for a host-token mismatch and wraps all GitHub failures as
|
|
315
|
+
* 502, so a status like "401"/"403" alone never means "revoked" — the old
|
|
316
|
+
* \b40[13]\b match here deleted VALID refresh tokens on host-auth/proxy noise.
|
|
317
|
+
*/
|
|
318
|
+
function isRevokedTokenError(reason: string): boolean {
|
|
319
|
+
return /revoked|invalid_grant|bad_refresh/i.test(reason);
|
|
320
|
+
}
|
|
321
|
+
|
|
229
322
|
async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
230
323
|
try {
|
|
231
324
|
const tok = await requestAccessToken(userId);
|
|
@@ -240,7 +333,7 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
|
240
333
|
// A revoked / expired refresh token can't recover — drop it to force a
|
|
241
334
|
// clean re-grant through the OAuth flow. Anything else is likely transient
|
|
242
335
|
// (network / cloud blip) — self-heal with a bounded retry.
|
|
243
|
-
if (
|
|
336
|
+
if (isRevokedTokenError(reason)) {
|
|
244
337
|
deleteToken(userId);
|
|
245
338
|
} else {
|
|
246
339
|
scheduleGithubRetry(taskId, userId, 0);
|
|
@@ -276,22 +369,42 @@ function clearGithubRetry(taskId: string): void {
|
|
|
276
369
|
}
|
|
277
370
|
}
|
|
278
371
|
|
|
372
|
+
/** Is the task still live on this host? Retries for ended tasks would docker
|
|
373
|
+
* exec against a removed container and post notes into a finished chat. */
|
|
374
|
+
function taskIsActive(taskId: string): boolean {
|
|
375
|
+
const row = getDb()
|
|
376
|
+
.select({ endedAt: schema.hostTasks.endedAt })
|
|
377
|
+
.from(schema.hostTasks)
|
|
378
|
+
.where(eq(schema.hostTasks.taskId, taskId))
|
|
379
|
+
.get();
|
|
380
|
+
return row != null && row.endedAt == null;
|
|
381
|
+
}
|
|
382
|
+
|
|
279
383
|
function scheduleGithubRetry(
|
|
280
384
|
taskId: string,
|
|
281
385
|
userId: string,
|
|
282
386
|
attempt: number,
|
|
387
|
+
deps: SetupTaskDeps = {},
|
|
283
388
|
): void {
|
|
284
389
|
clearGithubRetry(taskId);
|
|
285
390
|
if (attempt >= RETRY_BACKOFF_MS.length) {
|
|
391
|
+
// attempt counts retries fired so far; +1 for the initial try.
|
|
286
392
|
console.warn(
|
|
287
|
-
`[github] task ${taskId}: gh setup still failing after ${attempt} attempts — giving up until /retry-gh`,
|
|
393
|
+
`[github] task ${taskId}: gh setup still failing after ${attempt + 1} attempts — giving up until /retry-gh`,
|
|
394
|
+
);
|
|
395
|
+
authExpiredHandler?.(
|
|
396
|
+
taskId,
|
|
397
|
+
userId,
|
|
398
|
+
"gh setup kept failing — send /retry-gh once the connection recovers",
|
|
288
399
|
);
|
|
289
400
|
return;
|
|
290
401
|
}
|
|
291
402
|
const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
|
|
292
403
|
const timer = setTimeout(() => {
|
|
293
404
|
retryTimers.delete(taskId);
|
|
294
|
-
|
|
405
|
+
const isActive = deps.taskIsActive ?? taskIsActive;
|
|
406
|
+
if (!isActive(taskId)) return; // task ended while we backed off
|
|
407
|
+
void setupTaskGithub(taskId, userId, deps, attempt + 1);
|
|
295
408
|
}, delay);
|
|
296
409
|
timer.unref?.();
|
|
297
410
|
retryTimers.set(taskId, timer);
|
|
@@ -313,6 +426,8 @@ export interface SetupTaskDeps {
|
|
|
313
426
|
) => Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
314
427
|
inject?: (taskId: string, token: string) => void | Promise<void>;
|
|
315
428
|
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
429
|
+
deleteToken?: (userId: string) => void;
|
|
430
|
+
taskIsActive?: (taskId: string) => boolean;
|
|
316
431
|
}
|
|
317
432
|
|
|
318
433
|
/**
|
|
@@ -363,10 +478,19 @@ export async function setupTaskGithub(
|
|
|
363
478
|
} catch (err) {
|
|
364
479
|
const reason = err instanceof Error ? err.message : String(err);
|
|
365
480
|
console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
481
|
+
// A revoked refresh token can't recover by retrying — drop it so the user
|
|
482
|
+
// gets a clean re-grant, and tell the chat once.
|
|
483
|
+
if (isRevokedTokenError(reason)) {
|
|
484
|
+
(deps.deleteToken ?? deleteToken)(userId);
|
|
485
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
// Transient: self-heal with bounded backoff. Post the chat note only on
|
|
489
|
+
// the FIRST failure of a chain — each retry re-enters this catch, and six
|
|
490
|
+
// "gh auth" notes for one outage is noise. Exhaustion posts its own note
|
|
491
|
+
// (in scheduleGithubRetry).
|
|
492
|
+
if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
|
|
493
|
+
scheduleGithubRetry(taskId, userId, attempt, deps);
|
|
370
494
|
return false;
|
|
371
495
|
} finally {
|
|
372
496
|
setupInFlight.delete(taskId);
|
package/lib/orchestrator.ts
CHANGED
|
@@ -56,8 +56,19 @@ interface Channel {
|
|
|
56
56
|
/** Per-agent first-turn message — only agents whose `initialPrompt` is
|
|
57
57
|
* non-empty have an entry. Delivered once at container-ready. */
|
|
58
58
|
firstTurns: Map<string, string>;
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/** In-flight (or completed) session start. A memoized promise — not a
|
|
60
|
+
* boolean flag — so a concurrent deliver() awaits actual readiness instead
|
|
61
|
+
* of observing "started" while the sessions map is still empty (the start
|
|
62
|
+
* awaits docker work before populating it). Reset to null on failure so the
|
|
63
|
+
* next ensure retries. */
|
|
64
|
+
sessionsReady: Promise<boolean> | null;
|
|
65
|
+
/** Agents with turn output already streamed for the current turn — i.e. the
|
|
66
|
+
* cloud has buffered content for them (see ChannelRouter.turnBuffer). */
|
|
67
|
+
openTurns: Set<string>;
|
|
68
|
+
/** Agents whose current turn was interrupted (ESC) — their next
|
|
69
|
+
* turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
|
|
70
|
+
* half-turn instead of delivering it to @-mentioned peers. */
|
|
71
|
+
interrupted: Set<string>;
|
|
61
72
|
/** Per-agent respawn counter — bounded so a broken agent can't
|
|
62
73
|
* loop forever rewriting its config. */
|
|
63
74
|
respawns: Map<string, number>;
|
|
@@ -149,7 +160,9 @@ class Orchestrator {
|
|
|
149
160
|
containerName: `task-${taskId.toLowerCase()}-app-1`,
|
|
150
161
|
preambles,
|
|
151
162
|
firstTurns,
|
|
152
|
-
|
|
163
|
+
sessionsReady: null,
|
|
164
|
+
openTurns: new Set(),
|
|
165
|
+
interrupted: new Set(),
|
|
153
166
|
respawns: new Map(),
|
|
154
167
|
};
|
|
155
168
|
this.channels.set(taskId, channel);
|
|
@@ -167,16 +180,31 @@ class Orchestrator {
|
|
|
167
180
|
*
|
|
168
181
|
* Returns whether sessions are ready.
|
|
169
182
|
*/
|
|
170
|
-
private
|
|
171
|
-
|
|
183
|
+
private ensureSessions(channel: Channel): Promise<boolean> {
|
|
184
|
+
// Memoized: every caller awaits the SAME in-flight start, so a concurrent
|
|
185
|
+
// deliver() can't observe "ready" while the sessions map is still empty
|
|
186
|
+
// (startSessions awaits docker work before populating it — the old boolean
|
|
187
|
+
// flag flipped before that await and opened exactly that window).
|
|
188
|
+
if (!channel.sessionsReady) {
|
|
189
|
+
channel.sessionsReady = this.startSessions(channel);
|
|
190
|
+
// A failed/aborted start must not poison the channel — reset so the
|
|
191
|
+
// next ensure retries (e.g. the task wasn't `running` yet).
|
|
192
|
+
channel.sessionsReady.then(
|
|
193
|
+
(ok) => {
|
|
194
|
+
if (!ok) channel.sessionsReady = null;
|
|
195
|
+
},
|
|
196
|
+
() => {
|
|
197
|
+
channel.sessionsReady = null;
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return channel.sessionsReady;
|
|
202
|
+
}
|
|
172
203
|
|
|
204
|
+
private async startSessions(channel: Channel): Promise<boolean> {
|
|
173
205
|
const task = getHostTask(channel.taskId);
|
|
174
206
|
if (!task || task.statusMirror !== "running") return false;
|
|
175
207
|
|
|
176
|
-
// Flip the flag before the await so a concurrent send can't
|
|
177
|
-
// double-spawn the sessions.
|
|
178
|
-
channel.sessionsStarted = true;
|
|
179
|
-
|
|
180
208
|
// Set the task creator's git author identity in the container (ADR-029).
|
|
181
209
|
// The SSH key itself is installed earlier by task-up.sh (host clone +
|
|
182
210
|
// container), using the creator's per-user key. Best-effort. Awaited but
|
|
@@ -267,8 +295,15 @@ class Orchestrator {
|
|
|
267
295
|
taskId: string,
|
|
268
296
|
agentId: string,
|
|
269
297
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
270
|
-
const
|
|
271
|
-
|
|
298
|
+
const channel = this.channels.get(taskId);
|
|
299
|
+
const session = channel?.sessions.get(agentId);
|
|
300
|
+
if (!channel || !session) return { ok: false, error: "no active session" };
|
|
301
|
+
// An interrupted turn is a half-turn — its eventual turn_complete must not
|
|
302
|
+
// hand the buffered fragment to @-mentioned peers. Flag it so the boundary
|
|
303
|
+
// goes out `aborted` and the cloud discards the buffer. Only when the turn
|
|
304
|
+
// actually streamed output (openTurns): an idle-agent ESC must not mark
|
|
305
|
+
// the NEXT legitimate turn as aborted.
|
|
306
|
+
if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
|
|
272
307
|
void session.interrupt();
|
|
273
308
|
this.emitSystemNote(taskId, `Stopped @${agentId}.`);
|
|
274
309
|
return { ok: true };
|
|
@@ -300,6 +335,7 @@ class Orchestrator {
|
|
|
300
335
|
): Promise<void> {
|
|
301
336
|
switch (event.type) {
|
|
302
337
|
case "message_delta": {
|
|
338
|
+
channel.openTurns.add(agentId);
|
|
303
339
|
this.emitHost({
|
|
304
340
|
kind: "agent.message_delta",
|
|
305
341
|
taskId: channel.taskId,
|
|
@@ -309,6 +345,7 @@ class Orchestrator {
|
|
|
309
345
|
break;
|
|
310
346
|
}
|
|
311
347
|
case "message_complete": {
|
|
348
|
+
channel.openTurns.add(agentId);
|
|
312
349
|
this.emitHost({
|
|
313
350
|
kind: "agent.message_complete",
|
|
314
351
|
taskId: channel.taskId,
|
|
@@ -354,6 +391,10 @@ class Orchestrator {
|
|
|
354
391
|
break;
|
|
355
392
|
}
|
|
356
393
|
case "error": {
|
|
394
|
+
// The turn died with the session — drop its turn-state flags so a
|
|
395
|
+
// respawned session starts clean.
|
|
396
|
+
channel.openTurns.delete(agentId);
|
|
397
|
+
channel.interrupted.delete(agentId);
|
|
357
398
|
// Claude under load (especially Docker Desktop macOS) occasionally
|
|
358
399
|
// unlinks ~/.claude.json mid-write during atomic config rewrites,
|
|
359
400
|
// and a concurrent claude spawn lands during the gap and exits 0
|
|
@@ -381,10 +422,15 @@ class Orchestrator {
|
|
|
381
422
|
case "turn_complete": {
|
|
382
423
|
// Turn boundary — the cloud flushes any @-mentions buffered across this
|
|
383
424
|
// turn's messages and wakes the mentioned peers with the full turn.
|
|
425
|
+
// An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
|
|
426
|
+
// the cloud discards the buffer instead of handing it to peers.
|
|
427
|
+
const aborted = channel.interrupted.delete(agentId);
|
|
428
|
+
channel.openTurns.delete(agentId);
|
|
384
429
|
this.emitHost({
|
|
385
430
|
kind: "agent.turn_complete",
|
|
386
431
|
taskId: channel.taskId,
|
|
387
432
|
agentId,
|
|
433
|
+
aborted,
|
|
388
434
|
});
|
|
389
435
|
break;
|
|
390
436
|
}
|
|
@@ -817,15 +863,21 @@ interface DockerPs {
|
|
|
817
863
|
State: string; // "running" | "exited" | "created" | "paused"
|
|
818
864
|
}
|
|
819
865
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
866
|
+
/**
|
|
867
|
+
* Returns the task's containers, [] when docker reports none, or NULL when
|
|
868
|
+
* docker itself was unreachable/timed out (status null = spawn failure or our
|
|
869
|
+
* SIGKILL). The distinction matters: recovery treats [] as "container gone"
|
|
870
|
+
* and DOWNGRADES the task to stopped/error — doing that because a `docker ps`
|
|
871
|
+
* timed out under load would destroy a perfectly recoverable task.
|
|
872
|
+
*/
|
|
873
|
+
async function dockerListContainersByLabel(
|
|
874
|
+
label: string,
|
|
875
|
+
): Promise<DockerPs[] | null> {
|
|
876
|
+
const res = await dockerCli(
|
|
877
|
+
["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
|
|
878
|
+
{ timeoutMs: 60_000 },
|
|
879
|
+
);
|
|
880
|
+
if (res.status === null) return null; // docker state UNKNOWN — don't act on it
|
|
829
881
|
if (res.status !== 0) return [];
|
|
830
882
|
return res.stdout
|
|
831
883
|
.split("\n")
|
|
@@ -842,7 +894,9 @@ async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
|
|
|
842
894
|
}
|
|
843
895
|
|
|
844
896
|
async function dockerStart(containerName: string): Promise<boolean> {
|
|
845
|
-
|
|
897
|
+
// Starting a big dev container can legitimately take a while — give it far
|
|
898
|
+
// more than dockerCli's 30s default before declaring the task stopped.
|
|
899
|
+
const res = await dockerCli(["start", containerName], { timeoutMs: 120_000 });
|
|
846
900
|
if (res.status !== 0) {
|
|
847
901
|
console.error(
|
|
848
902
|
`[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
|
|
@@ -866,8 +920,15 @@ async function dockerPort(
|
|
|
866
920
|
return Number.isFinite(port) ? port : null;
|
|
867
921
|
}
|
|
868
922
|
|
|
869
|
-
async function dockerExec(
|
|
870
|
-
|
|
923
|
+
async function dockerExec(
|
|
924
|
+
containerName: string,
|
|
925
|
+
cmd: string[],
|
|
926
|
+
timeoutMs?: number,
|
|
927
|
+
): Promise<boolean> {
|
|
928
|
+
const res = await dockerCli(
|
|
929
|
+
["exec", containerName, ...cmd],
|
|
930
|
+
timeoutMs === undefined ? {} : { timeoutMs },
|
|
931
|
+
);
|
|
871
932
|
return res.status === 0;
|
|
872
933
|
}
|
|
873
934
|
|
|
@@ -915,6 +976,14 @@ async function recoverOneTask(
|
|
|
915
976
|
const containers = await dockerListContainersByLabel(
|
|
916
977
|
`com.docker.compose.project=${composeProject}`,
|
|
917
978
|
);
|
|
979
|
+
if (containers === null) {
|
|
980
|
+
// Docker unreachable / timed out — actual state unknown. Leave the row
|
|
981
|
+
// alone; the next recovery pass (or the next ensure) will see the truth.
|
|
982
|
+
console.warn(
|
|
983
|
+
`[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
|
|
984
|
+
);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
918
987
|
|
|
919
988
|
if (containers.length === 0) {
|
|
920
989
|
// Container gone. Worktree state determines whether resume is
|
|
@@ -945,6 +1014,13 @@ async function recoverOneTask(
|
|
|
945
1014
|
});
|
|
946
1015
|
console.log(`[orchestrator] recovery: ${task.taskId} ports refreshed`);
|
|
947
1016
|
}
|
|
1017
|
+
// Re-establish gh proactively (ADR-027): the restart killed the in-memory
|
|
1018
|
+
// refresh timers, so an idle task's access token would silently expire
|
|
1019
|
+
// ~8h later. Cheap with the per-user access-token cache — one exchange
|
|
1020
|
+
// per user, every task re-injects from it. Best-effort.
|
|
1021
|
+
if (task.ownerUserId) {
|
|
1022
|
+
void setupTaskGithub(task.taskId, task.ownerUserId);
|
|
1023
|
+
}
|
|
948
1024
|
return;
|
|
949
1025
|
}
|
|
950
1026
|
|
|
@@ -960,7 +1036,11 @@ async function recoverOneTask(
|
|
|
960
1036
|
});
|
|
961
1037
|
return;
|
|
962
1038
|
}
|
|
963
|
-
|
|
1039
|
+
// uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
|
|
1040
|
+
// repo. dockerCli's 30s default would SIGKILL it mid-install.
|
|
1041
|
+
if (
|
|
1042
|
+
!(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
|
|
1043
|
+
) {
|
|
964
1044
|
console.warn(
|
|
965
1045
|
`[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
|
|
966
1046
|
);
|
|
@@ -969,6 +1049,11 @@ async function recoverOneTask(
|
|
|
969
1049
|
db_setRuntime(task.taskId, {
|
|
970
1050
|
codeServerPort: port,
|
|
971
1051
|
});
|
|
1052
|
+
// Same gh re-establish as the running branch — the restarted container's gh
|
|
1053
|
+
// config is whatever it held when it exited.
|
|
1054
|
+
if (task.ownerUserId) {
|
|
1055
|
+
void setupTaskGithub(task.taskId, task.ownerUserId);
|
|
1056
|
+
}
|
|
972
1057
|
console.log(
|
|
973
1058
|
`[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
|
|
974
1059
|
);
|
package/package.json
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -402,11 +402,14 @@ export type HostEvent =
|
|
|
402
402
|
// The agent finished a turn (it may have emitted several message_complete
|
|
403
403
|
// items within it). The cloud waits for this before waking @-mentioned peers,
|
|
404
404
|
// so a peer is handed the agent's COMPLETE turn rather than a mid-turn
|
|
405
|
-
// fragment that happened to contain the mention.
|
|
405
|
+
// fragment that happened to contain the mention. `aborted` marks a turn that
|
|
406
|
+
// was interrupted (ESC): the cloud discards the buffered half-turn instead
|
|
407
|
+
// of delivering it.
|
|
406
408
|
| {
|
|
407
409
|
kind: "agent.turn_complete";
|
|
408
410
|
taskId: string;
|
|
409
411
|
agentId: string;
|
|
412
|
+
aborted?: boolean;
|
|
410
413
|
}
|
|
411
414
|
| {
|
|
412
415
|
kind: "agent.tool_call";
|