@rallycry/conveyor-agent 10.13.70 → 10.13.72
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/dist/{boot-7UWVK777.js → boot-ZNL7X5LQ.js} +171 -54
- package/dist/chunk-2L5THOWD.js +697 -0
- package/dist/{chunk-E6WWH4WJ.js → chunk-2SN32LM6.js} +217 -2604
- package/dist/chunk-3F4ZZKCA.js +291 -0
- package/dist/chunk-LSZ2KLJY.js +2527 -0
- package/dist/{chunk-DOB2XE2I.js → chunk-UBDSLM44.js} +4 -2
- package/dist/{chunk-GJXAAPJ6.js → chunk-W4LZ7R6Z.js} +117 -400
- package/dist/{chunk-QU53HND5.js → chunk-WMMBAKPE.js} +4 -48
- package/dist/chunk-XORJ6SII.js +46 -0
- package/dist/cli.js +59 -713
- package/dist/index.js +9 -5
- package/dist/serve-boot-4N3FRXVQ.js +225 -0
- package/dist/{server-CC7KUJOK.js → server-7XH7RYUX.js} +3 -2
- package/package.json +1 -1
|
@@ -1,1930 +1,87 @@
|
|
|
1
1
|
import {
|
|
2
|
-
CLONE_TIMEOUT_MS,
|
|
3
|
-
DEFAULT_RETRY_DELAY_MS,
|
|
4
|
-
FETCH_TIMEOUT_MS,
|
|
5
|
-
GIT_PREP_MAX_RETRIES,
|
|
6
2
|
mapChatHistory,
|
|
7
|
-
readAgentVersion
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const reason = controller.signal.aborted ? "timeout" : "network_error";
|
|
90
|
-
return { ok: false, errorText: message.slice(0, 500), reason };
|
|
91
|
-
} finally {
|
|
92
|
-
clearTimeout(timer);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function buildFailure(reason, attempts, status, detail) {
|
|
96
|
-
const out = { ok: false, reason, attempts };
|
|
97
|
-
if (status === void 0) {
|
|
98
|
-
} else {
|
|
99
|
-
out.status = status;
|
|
100
|
-
}
|
|
101
|
-
if (detail) out.detail = detail;
|
|
102
|
-
return out;
|
|
103
|
-
}
|
|
104
|
-
function isRetryable(reason, retryOnHttpError) {
|
|
105
|
-
if (reason === "timeout" || reason === "network_error") return true;
|
|
106
|
-
return retryOnHttpError === true && reason === "http_error";
|
|
107
|
-
}
|
|
108
|
-
async function fetchBootstrap(opts) {
|
|
109
|
-
const timeoutMs = opts.timeoutMs ?? BOOTSTRAP_TIMEOUT_MS;
|
|
110
|
-
const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
111
|
-
const maxAttempts = delays.length + 1;
|
|
112
|
-
const hasBootstrapToken = Boolean(opts.bootstrapToken);
|
|
113
|
-
const hasTaskToken = Boolean(process.env.CONVEYOR_TASK_TOKEN);
|
|
114
|
-
let lastReason = "unknown";
|
|
115
|
-
let lastStatus;
|
|
116
|
-
let lastDetail;
|
|
117
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
118
|
-
const result = await singleBootstrapAttempt(
|
|
119
|
-
opts.apiUrl,
|
|
120
|
-
opts.instanceName,
|
|
121
|
-
opts.bootstrapToken,
|
|
122
|
-
timeoutMs
|
|
123
|
-
);
|
|
124
|
-
if (result.ok && result.body) {
|
|
125
|
-
return { ok: true, config: result.body, attempts: attempt };
|
|
126
|
-
}
|
|
127
|
-
lastReason = result.reason ?? "unknown";
|
|
128
|
-
lastStatus = result.status;
|
|
129
|
-
lastDetail = result.errorText;
|
|
130
|
-
const failurePayload = {
|
|
131
|
-
event: "bootstrap_failed",
|
|
132
|
-
reason: lastReason,
|
|
133
|
-
apiUrl: opts.apiUrl,
|
|
134
|
-
instanceName: opts.instanceName,
|
|
135
|
-
hasBootstrapToken,
|
|
136
|
-
hasTaskToken,
|
|
137
|
-
attempt
|
|
138
|
-
};
|
|
139
|
-
if (lastStatus === void 0) {
|
|
140
|
-
} else {
|
|
141
|
-
failurePayload.status = lastStatus;
|
|
142
|
-
}
|
|
143
|
-
if (lastDetail) failurePayload.detail = lastDetail;
|
|
144
|
-
emitFailureEvent(failurePayload);
|
|
145
|
-
if (!isRetryable(lastReason, opts.retryOnHttpError) || attempt >= maxAttempts) {
|
|
146
|
-
return buildFailure(lastReason, attempt, lastStatus, lastDetail);
|
|
147
|
-
}
|
|
148
|
-
await sleep(delays[attempt - 1]);
|
|
149
|
-
}
|
|
150
|
-
return buildFailure(lastReason, maxAttempts, lastStatus, lastDetail);
|
|
151
|
-
}
|
|
152
|
-
function applyBootstrapToEnv(config) {
|
|
153
|
-
for (const [key, value] of Object.entries(config.envVars ?? {})) {
|
|
154
|
-
process.env[key] = value;
|
|
155
|
-
}
|
|
156
|
-
if (config.mode === "project") {
|
|
157
|
-
if (config.projectToken) process.env.CONVEYOR_PROJECT_TOKEN = config.projectToken;
|
|
158
|
-
if (config.projectId) process.env.CONVEYOR_PROJECT_ID = config.projectId;
|
|
159
|
-
if (config.workspaceBranch) process.env.CONVEYOR_WORKSPACE_BRANCH = config.workspaceBranch;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (config.taskId) process.env.CONVEYOR_TASK_ID = config.taskId;
|
|
163
|
-
if (config.sessionId) process.env.CONVEYOR_SESSION_ID = config.sessionId;
|
|
164
|
-
if (config.taskToken) process.env.CONVEYOR_TASK_TOKEN = config.taskToken;
|
|
165
|
-
if (config.agentMode !== void 0) process.env.CONVEYOR_AGENT_MODE = config.agentMode;
|
|
166
|
-
if (config.isAuto !== void 0) process.env.CONVEYOR_IS_AUTO = config.isAuto;
|
|
167
|
-
if (config.runnerMode) process.env.CONVEYOR_MODE = config.runnerMode;
|
|
168
|
-
if (config.taskBranch) process.env.CONVEYOR_TASK_BRANCH = config.taskBranch;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// src/utils/logger.ts
|
|
172
|
-
function createServiceLogger(service) {
|
|
173
|
-
const prefix = `[conveyor-agent:${service}]`;
|
|
174
|
-
return {
|
|
175
|
-
info(message, data) {
|
|
176
|
-
const extra = data ? ` ${JSON.stringify(data)}` : "";
|
|
177
|
-
process.stderr.write(`${prefix} ${message}${extra}
|
|
178
|
-
`);
|
|
179
|
-
},
|
|
180
|
-
warn(message, data) {
|
|
181
|
-
const extra = data ? ` ${JSON.stringify(data)}` : "";
|
|
182
|
-
process.stderr.write(`${prefix} WARN ${message}${extra}
|
|
183
|
-
`);
|
|
184
|
-
},
|
|
185
|
-
error(message, data) {
|
|
186
|
-
const extra = data ? ` ${JSON.stringify(data)}` : "";
|
|
187
|
-
process.stderr.write(`${prefix} ERROR ${message}${extra}
|
|
188
|
-
`);
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// src/connection/agent-connection.ts
|
|
194
|
-
import { existsSync } from "fs";
|
|
195
|
-
import { fileURLToPath } from "url";
|
|
196
|
-
import { Worker } from "worker_threads";
|
|
197
|
-
import { io } from "socket.io-client";
|
|
198
|
-
|
|
199
|
-
// src/setup/bootstrap-poll.ts
|
|
200
|
-
var PollUntilBoundHttpError = class extends Error {
|
|
201
|
-
constructor(status) {
|
|
202
|
-
super(`pollUntilBound got unexpected status ${status}`);
|
|
203
|
-
this.status = status;
|
|
204
|
-
this.name = "PollUntilBoundHttpError";
|
|
205
|
-
}
|
|
206
|
-
status;
|
|
207
|
-
};
|
|
208
|
-
async function pollUntilBound(opts) {
|
|
209
|
-
const pollIntervalMs = opts.pollIntervalMs ?? 2e3;
|
|
210
|
-
const maxWaitMs = opts.maxWaitMs ?? 30 * 60 * 1e3;
|
|
211
|
-
const deadline = Date.now() + maxWaitMs;
|
|
212
|
-
while (true) {
|
|
213
|
-
const response = await fetch(`${opts.apiUrl}/api/v3/pods/bootstrap`, {
|
|
214
|
-
headers: { Authorization: `Bearer ${opts.bootstrapToken}` }
|
|
215
|
-
});
|
|
216
|
-
if (response.status === 200) {
|
|
217
|
-
return await response.json();
|
|
218
|
-
}
|
|
219
|
-
if (response.status === 204) {
|
|
220
|
-
if (Date.now() >= deadline) {
|
|
221
|
-
throw new Error(`pollUntilBound timed out after ${maxWaitMs}ms waiting for pod bind`);
|
|
222
|
-
}
|
|
223
|
-
await sleep(pollIntervalMs);
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
throw new PollUntilBoundHttpError(response.status);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// src/connection/bundle-credentials.ts
|
|
231
|
-
function readBundleIdentity(sessionJwt) {
|
|
232
|
-
if (!sessionJwt) return {};
|
|
233
|
-
const segments = sessionJwt.split(".");
|
|
234
|
-
if (segments.length !== 3) return {};
|
|
235
|
-
try {
|
|
236
|
-
const json = Buffer.from(segments[1], "base64url").toString("utf8");
|
|
237
|
-
const claims = JSON.parse(json);
|
|
238
|
-
return {
|
|
239
|
-
...typeof claims.sessionId === "string" ? { sessionId: claims.sessionId } : {},
|
|
240
|
-
...typeof claims.role === "string" ? { role: claims.role } : {}
|
|
241
|
-
};
|
|
242
|
-
} catch {
|
|
243
|
-
return {};
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
function bundleMayWriteGithubFiles(bundle, self) {
|
|
247
|
-
const identity = readBundleIdentity(bundle.sessionJwt);
|
|
248
|
-
if (identity.sessionId && self.sessionId && identity.sessionId !== self.sessionId) {
|
|
249
|
-
return {
|
|
250
|
-
allowed: false,
|
|
251
|
-
reason: `bundle resolved to session ${identity.sessionId}, not ours (${self.sessionId})`
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
if (identity.role === "reader" && self.role && self.role !== "reader") {
|
|
255
|
-
return {
|
|
256
|
-
allowed: false,
|
|
257
|
-
reason: `bundle carries a reader-scoped token but this session is a ${self.role}`
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
return { allowed: true };
|
|
261
|
-
}
|
|
262
|
-
function applyBundleGithubToken(bundle, self) {
|
|
263
|
-
if (!bundle.githubToken) return { written: false, reason: "bundle carried no GitHub token" };
|
|
264
|
-
const permitted = bundleMayWriteGithubFiles(bundle, self);
|
|
265
|
-
if (!permitted.allowed) {
|
|
266
|
-
return { written: false, ...permitted.reason ? { reason: permitted.reason } : {} };
|
|
267
|
-
}
|
|
268
|
-
syncGithubTokenFiles(bundle.githubToken);
|
|
269
|
-
return { written: true };
|
|
270
|
-
}
|
|
271
|
-
function syncBundleGithubToken(token) {
|
|
272
|
-
syncGithubTokenFiles(token);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// src/connection/agent-connection.ts
|
|
276
|
-
var logger = createServiceLogger("agent-connection");
|
|
277
|
-
var EVENT_BATCH_MS = 500;
|
|
278
|
-
var MAX_EVENT_BUFFER = 5e3;
|
|
279
|
-
var TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1e3;
|
|
280
|
-
var AgentConnection = class _AgentConnection {
|
|
281
|
-
socket = null;
|
|
282
|
-
config;
|
|
283
|
-
eventBuffer = [];
|
|
284
|
-
flushTimer = null;
|
|
285
|
-
tokenRefreshTimer = null;
|
|
286
|
-
lastEmittedStatus = null;
|
|
287
|
-
lastReportedStatus = null;
|
|
288
|
-
droppedEventCount = 0;
|
|
289
|
-
// Pending answer resolvers for askUserQuestion room-event fallback
|
|
290
|
-
pendingAnswerResolvers = /* @__PURE__ */ new Map();
|
|
291
|
-
// Dedup: suppress near-identical messages within a short window
|
|
292
|
-
recentMessages = [];
|
|
293
|
-
static DEDUP_WINDOW_MS = 3e4;
|
|
294
|
-
static DEDUP_SIMILARITY_THRESHOLD = 0.7;
|
|
295
|
-
static DEDUP_PREVIEW_LIMIT = 120;
|
|
296
|
-
// Early-buffering: events that arrive before callbacks are registered
|
|
297
|
-
earlyMessages = [];
|
|
298
|
-
earlyStop = false;
|
|
299
|
-
earlySoftStop = false;
|
|
300
|
-
earlyModeChanges = [];
|
|
301
|
-
// Registered callbacks
|
|
302
|
-
messageCallback = null;
|
|
303
|
-
stopCallback = null;
|
|
304
|
-
softStopCallback = null;
|
|
305
|
-
modeChangeCallback = null;
|
|
306
|
-
apiKeyUpdateCallback = null;
|
|
307
|
-
pullBranchCallback = null;
|
|
308
|
-
runStartCommandCallback = null;
|
|
309
|
-
earlyRunStartCommand = false;
|
|
310
|
-
earlyPullBranches = [];
|
|
311
|
-
spawnReviewCallback = null;
|
|
312
|
-
earlySpawnReviews = [];
|
|
313
|
-
spawnTuiCallback = null;
|
|
314
|
-
earlySpawnTuis = [];
|
|
315
|
-
probeUsageCallback = null;
|
|
316
|
-
earlyProbeUsage = false;
|
|
317
|
-
// PTY relay (S5 terminal). Single-slot callbacks, set per PtySession run.
|
|
318
|
-
ptyInputCallback = null;
|
|
319
|
-
ptyResizeCallback = null;
|
|
320
|
-
constructor(config) {
|
|
321
|
-
this.config = config;
|
|
322
|
-
}
|
|
323
|
-
get sessionId() {
|
|
324
|
-
return this.config.sessionId;
|
|
325
|
-
}
|
|
326
|
-
get connected() {
|
|
327
|
-
return this.socket?.connected ?? false;
|
|
328
|
-
}
|
|
329
|
-
// ── Typed service method call ──────────────────────────────────────────
|
|
330
|
-
// Socket.IO keeps the SAME Socket instance across transport-level
|
|
331
|
-
// reconnects (it only goes null on an explicit disconnect() teardown), so a
|
|
332
|
-
// brief flap leaves `this.socket` non-null but `.connected === false`. Rather
|
|
333
|
-
// than failing a tool call instantly (which the spawned `claude` surfaces as
|
|
334
|
-
// "Conveyor MCP disconnected" and an excuse to go idle), we wait out a short
|
|
335
|
-
// reconnect window, then emit with an ack timeout so a buffered packet whose
|
|
336
|
-
// ack never returns can't hang the call forever. We do NOT auto-retry the
|
|
337
|
-
// emit — re-sending a write could double-apply it; the agent prompt instructs
|
|
338
|
-
// the model to retry the tool, which is the safe place to decide idempotency.
|
|
339
|
-
static CALL_CONNECT_WAIT_MS = 2e4;
|
|
340
|
-
static CALL_ACK_TIMEOUT_MS = 3e4;
|
|
341
|
-
// ── Proactive socket recycle ───────────────────────────────────────────
|
|
342
|
-
// Cloud Run severs every WebSocket at its request timeout (3600s is the
|
|
343
|
-
// platform ceiling), so a socket that lives past ~60 minutes is killed at a
|
|
344
|
-
// random moment — historically mid-tool-call, which let the spawned CLI
|
|
345
|
-
// abandon its MCP session. Recycle the transport at a QUIET moment (no
|
|
346
|
-
// in-flight RPC) before the platform deadline instead: an engine-level close
|
|
347
|
-
// looks like a transport drop, so Socket.IO's auto-reconnect and the
|
|
348
|
-
// io "reconnect" → reconnectToSession() recovery path run unchanged. Jitter
|
|
349
|
-
// keeps a fleet of pods from recycling in one thundering herd.
|
|
350
|
-
static SOCKET_RECYCLE_BASE_MS = 52 * 60 * 1e3;
|
|
351
|
-
static SOCKET_RECYCLE_JITTER_MS = 4 * 60 * 1e3;
|
|
352
|
-
static SOCKET_RECYCLE_BUSY_POLL_MS = 15e3;
|
|
353
|
-
recycleTimer = null;
|
|
354
|
-
pendingCalls = 0;
|
|
355
|
-
async call(method, payload) {
|
|
356
|
-
const socket = this.socket;
|
|
357
|
-
if (!socket) {
|
|
358
|
-
throw new Error(
|
|
359
|
-
`Not connected (method: ${String(method)}, session: ${this.config.sessionId})`
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
this.pendingCalls++;
|
|
363
|
-
try {
|
|
364
|
-
if (!socket.connected) {
|
|
365
|
-
await this.waitForConnected(socket, _AgentConnection.CALL_CONNECT_WAIT_MS, String(method));
|
|
366
|
-
}
|
|
367
|
-
return await this.emitWithAck(socket, method, payload);
|
|
368
|
-
} finally {
|
|
369
|
-
this.pendingCalls--;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
/** (Re)arm the recycle timer — called on every successful (re)connect. */
|
|
373
|
-
scheduleSocketRecycle() {
|
|
374
|
-
this.clearSocketRecycle();
|
|
375
|
-
const delay2 = _AgentConnection.SOCKET_RECYCLE_BASE_MS + Math.random() * _AgentConnection.SOCKET_RECYCLE_JITTER_MS;
|
|
376
|
-
this.armRecycleTimer(delay2);
|
|
377
|
-
}
|
|
378
|
-
clearSocketRecycle() {
|
|
379
|
-
if (this.recycleTimer) {
|
|
380
|
-
clearTimeout(this.recycleTimer);
|
|
381
|
-
this.recycleTimer = null;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
armRecycleTimer(delay2) {
|
|
385
|
-
this.recycleTimer = setTimeout(() => {
|
|
386
|
-
this.recycleTimer = null;
|
|
387
|
-
this.attemptSocketRecycle();
|
|
388
|
-
}, delay2);
|
|
389
|
-
this.recycleTimer.unref?.();
|
|
390
|
-
}
|
|
391
|
-
attemptSocketRecycle() {
|
|
392
|
-
const socket = this.socket;
|
|
393
|
-
if (!socket?.connected) return;
|
|
394
|
-
if (this.pendingCalls > 0) {
|
|
395
|
-
this.armRecycleTimer(_AgentConnection.SOCKET_RECYCLE_BUSY_POLL_MS);
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
process.stderr.write(
|
|
399
|
-
"[conveyor-agent] Recycling socket ahead of the platform request timeout\n"
|
|
400
|
-
);
|
|
401
|
-
socket.io.engine?.close?.();
|
|
402
|
-
}
|
|
403
|
-
/** Resolve once `socket` reports connected, or reject after `timeoutMs`. */
|
|
404
|
-
waitForConnected(socket, timeoutMs, method) {
|
|
405
|
-
return waitForConnected(socket, timeoutMs, () => {
|
|
406
|
-
return new Error(
|
|
407
|
-
`Not connected \u2014 socket did not reconnect within ${timeoutMs / 1e3}s (method: ${method}, session: ${this.config.sessionId}). Transient; retry.`
|
|
408
|
-
);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
/** Emit an RPC and resolve on ack, rejecting if no ack arrives in time. */
|
|
412
|
-
emitWithAck(socket, method, payload) {
|
|
413
|
-
return callWithAck(
|
|
414
|
-
socket,
|
|
415
|
-
`agentSessionService:${String(method)}`,
|
|
416
|
-
payload,
|
|
417
|
-
{
|
|
418
|
-
timeoutMs: _AgentConnection.CALL_ACK_TIMEOUT_MS,
|
|
419
|
-
requireData: true,
|
|
420
|
-
makeTimeoutError: () => new Error(
|
|
421
|
-
`Service call timed out after ${_AgentConnection.CALL_ACK_TIMEOUT_MS / 1e3}s (method: ${String(method)}, session: ${this.config.sessionId}). Usually a transient reconnect; retry.`
|
|
422
|
-
),
|
|
423
|
-
makeFailureError: (error) => new Error(error ?? `Service call failed: ${String(method)}`)
|
|
424
|
-
}
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
// ── Connection lifecycle ───────────────────────────────────────────────
|
|
428
|
-
// oxlint-disable-next-line max-lines-per-function -- socket setup requires registering many co-located event handlers
|
|
429
|
-
connect() {
|
|
430
|
-
if (!this.config.apiUrl) {
|
|
431
|
-
return Promise.reject(new Error("Cannot connect: apiUrl is empty"));
|
|
432
|
-
}
|
|
433
|
-
this.startProactiveTokenRefresh();
|
|
434
|
-
return new Promise((resolve, reject) => {
|
|
435
|
-
let settled = false;
|
|
436
|
-
let attempts = 0;
|
|
437
|
-
const maxInitialAttempts = 30;
|
|
438
|
-
process.stderr.write(
|
|
439
|
-
`[conveyor-agent] Connecting to ${this.config.apiUrl} (mode: ${this.config.runnerMode ?? "task"}, session: ${this.config.sessionId})
|
|
440
|
-
`
|
|
441
|
-
);
|
|
442
|
-
this.socket = io(
|
|
443
|
-
this.config.apiUrl,
|
|
444
|
-
buildConveyorSocketOptions({
|
|
445
|
-
taskToken: this.config.taskToken,
|
|
446
|
-
runnerMode: this.config.runnerMode ?? "task"
|
|
447
|
-
})
|
|
448
|
-
);
|
|
449
|
-
this.socket.on("session:message", (msg) => {
|
|
450
|
-
const incoming = {
|
|
451
|
-
content: msg.content,
|
|
452
|
-
userId: msg.userId,
|
|
453
|
-
...msg.source && { source: msg.source },
|
|
454
|
-
...msg.files && { files: msg.files },
|
|
455
|
-
...msg.delivery === "prefill" && { delivery: msg.delivery }
|
|
456
|
-
};
|
|
457
|
-
if (this.messageCallback) this.messageCallback(incoming);
|
|
458
|
-
else this.earlyMessages.push(incoming);
|
|
459
|
-
});
|
|
460
|
-
this.socket.on("session:stop", () => {
|
|
461
|
-
if (this.stopCallback) this.stopCallback();
|
|
462
|
-
else this.earlyStop = true;
|
|
463
|
-
});
|
|
464
|
-
this.socket.on("session:softStop", () => {
|
|
465
|
-
if (this.softStopCallback) this.softStopCallback();
|
|
466
|
-
else this.earlySoftStop = true;
|
|
467
|
-
});
|
|
468
|
-
this.socket.on("session:modeChange", (data) => {
|
|
469
|
-
if (this.modeChangeCallback) this.modeChangeCallback(data);
|
|
470
|
-
else this.earlyModeChanges.push(data);
|
|
471
|
-
});
|
|
472
|
-
this.socket.on(
|
|
473
|
-
"session:answerQuestion",
|
|
474
|
-
(data) => {
|
|
475
|
-
const resolver = this.pendingAnswerResolvers.get(data.requestId);
|
|
476
|
-
if (resolver) resolver(data.answers);
|
|
477
|
-
}
|
|
478
|
-
);
|
|
479
|
-
this.socket.on("agentRunner:updateApiKey", (data) => {
|
|
480
|
-
if (this.apiKeyUpdateCallback) this.apiKeyUpdateCallback(data);
|
|
481
|
-
});
|
|
482
|
-
this.socket.on("session:pullBranch", (data) => {
|
|
483
|
-
if (this.pullBranchCallback) this.pullBranchCallback(data);
|
|
484
|
-
else this.earlyPullBranches.push(data);
|
|
485
|
-
});
|
|
486
|
-
this.socket.on("session:spawnReview", (data) => {
|
|
487
|
-
if (this.spawnReviewCallback) this.spawnReviewCallback(data);
|
|
488
|
-
else this.earlySpawnReviews.push(data);
|
|
489
|
-
});
|
|
490
|
-
this.socket.on("session:spawnTui", (data) => {
|
|
491
|
-
if (this.spawnTuiCallback) this.spawnTuiCallback(data);
|
|
492
|
-
else this.earlySpawnTuis.push(data);
|
|
493
|
-
});
|
|
494
|
-
this.socket.on("session:probeUsage", () => {
|
|
495
|
-
if (this.probeUsageCallback) this.probeUsageCallback();
|
|
496
|
-
else this.earlyProbeUsage = true;
|
|
497
|
-
});
|
|
498
|
-
this.socket.on("session:runStartCommand", () => {
|
|
499
|
-
if (this.runStartCommandCallback) this.runStartCommandCallback();
|
|
500
|
-
else this.earlyRunStartCommand = true;
|
|
501
|
-
});
|
|
502
|
-
this.socket.on("pty:input", (data) => {
|
|
503
|
-
if (data.sessionId && data.sessionId !== this.config.sessionId) return;
|
|
504
|
-
this.ptyInputCallback?.(data.data);
|
|
505
|
-
});
|
|
506
|
-
this.socket.on("pty:resize", (data) => {
|
|
507
|
-
if (data.sessionId && data.sessionId !== this.config.sessionId) return;
|
|
508
|
-
this.ptyResizeCallback?.(data.cols, data.rows);
|
|
509
|
-
});
|
|
510
|
-
this.socket.on("connect", () => {
|
|
511
|
-
process.stderr.write("[conveyor-agent] Socket connected\n");
|
|
512
|
-
this.scheduleSocketRecycle();
|
|
513
|
-
if (!settled) {
|
|
514
|
-
settled = true;
|
|
515
|
-
resolve();
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
this.socket.on("connect_error", (err) => {
|
|
519
|
-
attempts++;
|
|
520
|
-
process.stderr.write(
|
|
521
|
-
`[conveyor-agent] Connection error (attempt ${attempts}/${maxInitialAttempts}): ${err.message}
|
|
522
|
-
`
|
|
523
|
-
);
|
|
524
|
-
if (!settled && attempts >= maxInitialAttempts) {
|
|
525
|
-
settled = true;
|
|
526
|
-
reject(
|
|
527
|
-
new Error(
|
|
528
|
-
`Failed to connect to ${this.config.apiUrl} after ${maxInitialAttempts} attempts: ${err.message}`
|
|
529
|
-
)
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
this.socket.on("disconnect", (reason) => {
|
|
534
|
-
process.stderr.write(`[conveyor-agent] Disconnected: ${reason}
|
|
535
|
-
`);
|
|
536
|
-
if (reason === "io server disconnect" || reason === "server namespace disconnect") {
|
|
537
|
-
this.scheduleReconnectAfterServerDisconnect();
|
|
538
|
-
}
|
|
539
|
-
});
|
|
540
|
-
this.socket.on("auth:rejected", () => {
|
|
541
|
-
process.stderr.write("[conveyor-agent] Auth rejected by server, refreshing taskToken\n");
|
|
542
|
-
void this.refreshTaskTokenFromBootstrap().catch(() => {
|
|
543
|
-
});
|
|
544
|
-
});
|
|
545
|
-
this.socket.io.on("reconnect", (reconnectAttempts) => {
|
|
546
|
-
process.stderr.write(
|
|
547
|
-
`[conveyor-agent] Reconnected (attempts: ${reconnectAttempts}, ${(/* @__PURE__ */ new Date()).toISOString()})
|
|
548
|
-
`
|
|
549
|
-
);
|
|
550
|
-
this.sendHeartbeat();
|
|
551
|
-
void this.reconnectToSession();
|
|
552
|
-
});
|
|
553
|
-
this.socket.io.on("reconnect_attempt", () => {
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
disconnect() {
|
|
558
|
-
this.stopProactiveTokenRefresh();
|
|
559
|
-
this.clearSocketRecycle();
|
|
560
|
-
this.stopHeartbeatWorker();
|
|
561
|
-
void this.flushEvents();
|
|
562
|
-
if (this.socket) {
|
|
563
|
-
this.socket.io.reconnection(false);
|
|
564
|
-
this.socket.removeAllListeners();
|
|
565
|
-
this.socket.disconnect();
|
|
566
|
-
this.socket = null;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
// ── Reconnect with retry ────────────────────────────────────────────
|
|
570
|
-
//
|
|
571
|
-
// Socket.IO already retries the transport forever. This higher-level helper
|
|
572
|
-
// re-issues the `connectAgent` RPC after a successful reconnect to re-join
|
|
573
|
-
// the session room and drain pending messages. We retry indefinitely with a
|
|
574
|
-
// capped exponential backoff — a stranded codespace with a missing agent is
|
|
575
|
-
// worse than a long-running reconnect loop, and a transient API outage
|
|
576
|
-
// shouldn't kill the agent process.
|
|
577
|
-
static RECONNECT_BASE_DELAY_MS = 2e3;
|
|
578
|
-
static RECONNECT_MAX_DELAY_MS = 6e4;
|
|
579
|
-
static RECONNECT_STATUS_EVERY_N = 3;
|
|
580
|
-
isReconnecting = false;
|
|
581
|
-
reconnectingAfterServerDisconnect = false;
|
|
582
|
-
/** Capped exponential backoff (2s, 4s, 8s, 16s, 32s, then 60s steady) shared
|
|
583
|
-
* by both reconnect loops (connectAgent-RPC and server-disconnect). */
|
|
584
|
-
static backoffDelayMs(attempt) {
|
|
585
|
-
return Math.min(
|
|
586
|
-
_AgentConnection.RECONNECT_BASE_DELAY_MS * 2 ** Math.min(attempt - 1, 5),
|
|
587
|
-
_AgentConnection.RECONNECT_MAX_DELAY_MS
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
/** Sleep `ms`, unref'd so it never holds the process open on its own. */
|
|
591
|
-
static delay(ms) {
|
|
592
|
-
return new Promise((resolve) => {
|
|
593
|
-
const timer = setTimeout(resolve, ms);
|
|
594
|
-
timer.unref?.();
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* Invoked after every successful session reconnect (the `connectAgent` RPC
|
|
599
|
-
* re-established the session room). The runner uses this to force a TUI
|
|
600
|
-
* repaint: the reconnect may have landed on a different/restarted API
|
|
601
|
-
* process whose PTY scrollback ring is empty, and a quiet terminal would
|
|
602
|
-
* otherwise never re-seed it.
|
|
603
|
-
*/
|
|
604
|
-
onReconnected;
|
|
605
|
-
async reconnectToSession() {
|
|
606
|
-
if (this.isReconnecting) return;
|
|
607
|
-
this.isReconnecting = true;
|
|
608
|
-
try {
|
|
609
|
-
let attempt = 0;
|
|
610
|
-
while (this.socket) {
|
|
611
|
-
attempt++;
|
|
612
|
-
try {
|
|
613
|
-
const { pendingMessages } = await this.call("connectAgent", {
|
|
614
|
-
sessionId: this.config.sessionId
|
|
615
|
-
});
|
|
616
|
-
this.drainPendingMessages(pendingMessages);
|
|
617
|
-
process.stderr.write(
|
|
618
|
-
`[conveyor-agent] Reconnected to session successfully (attempts: ${attempt})
|
|
619
|
-
`
|
|
620
|
-
);
|
|
621
|
-
if (this.lastEmittedStatus && this.lastEmittedStatus !== this.lastReportedStatus) {
|
|
622
|
-
const status = this.lastEmittedStatus;
|
|
623
|
-
void this.call("reportAgentStatus", {
|
|
624
|
-
sessionId: this.config.sessionId,
|
|
625
|
-
status
|
|
626
|
-
}).then(() => {
|
|
627
|
-
this.lastReportedStatus = status;
|
|
628
|
-
}).catch(() => {
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
this.sendEvent({
|
|
632
|
-
type: "agent_runner_status",
|
|
633
|
-
reason: "reconnected",
|
|
634
|
-
attempts: attempt
|
|
635
|
-
});
|
|
636
|
-
try {
|
|
637
|
-
this.onReconnected?.();
|
|
638
|
-
} catch {
|
|
639
|
-
}
|
|
640
|
-
return;
|
|
641
|
-
} catch (err) {
|
|
642
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
643
|
-
const delayMs = _AgentConnection.backoffDelayMs(attempt);
|
|
644
|
-
process.stderr.write(
|
|
645
|
-
`[conveyor-agent] connectAgent failed (attempt ${attempt}): ${errMsg} \u2014 retrying in ${delayMs / 1e3}s
|
|
646
|
-
`
|
|
647
|
-
);
|
|
648
|
-
if (this.looksLikeAuthError(errMsg)) {
|
|
649
|
-
void this.refreshTaskTokenFromBootstrap().catch(() => {
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
if (attempt % _AgentConnection.RECONNECT_STATUS_EVERY_N === 0) {
|
|
653
|
-
this.sendEvent({
|
|
654
|
-
type: "agent_runner_status",
|
|
655
|
-
reason: "reconnecting",
|
|
656
|
-
attempt
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
await _AgentConnection.delay(delayMs);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
} finally {
|
|
663
|
-
this.isReconnecting = false;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Drive a bounded reconnect after a server-initiated disconnect. Loops until
|
|
668
|
-
* the socket reconnects or is torn down, nudging socket.connect() on each
|
|
669
|
-
* pass with a capped exponential backoff. A token refresh is attempted every
|
|
670
|
-
* pass (rate-limited to once/60s inside refreshTaskTokenFromBootstrap) but
|
|
671
|
-
* its result NEVER gates the reconnect — the socket must recover even when
|
|
672
|
-
* there is no fresh token to apply.
|
|
673
|
-
*/
|
|
674
|
-
scheduleReconnectAfterServerDisconnect() {
|
|
675
|
-
if (this.reconnectingAfterServerDisconnect) return;
|
|
676
|
-
this.reconnectingAfterServerDisconnect = true;
|
|
677
|
-
void this.reconnectAfterServerDisconnect().finally(() => {
|
|
678
|
-
this.reconnectingAfterServerDisconnect = false;
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
async reconnectAfterServerDisconnect() {
|
|
682
|
-
let attempt = 0;
|
|
683
|
-
while (this.socket && !this.socket.connected) {
|
|
684
|
-
attempt++;
|
|
685
|
-
try {
|
|
686
|
-
await this.refreshTaskTokenFromBootstrap();
|
|
687
|
-
} catch {
|
|
688
|
-
}
|
|
689
|
-
const socket = this.socket;
|
|
690
|
-
if (!socket || socket.connected) return;
|
|
691
|
-
socket.connect();
|
|
692
|
-
try {
|
|
693
|
-
await this.waitForConnected(
|
|
694
|
-
socket,
|
|
695
|
-
_AgentConnection.CALL_CONNECT_WAIT_MS,
|
|
696
|
-
"server-disconnect-reconnect"
|
|
697
|
-
);
|
|
698
|
-
this.sendHeartbeat();
|
|
699
|
-
void this.reconnectToSession();
|
|
700
|
-
return;
|
|
701
|
-
} catch {
|
|
702
|
-
const delayMs = _AgentConnection.backoffDelayMs(attempt);
|
|
703
|
-
process.stderr.write(
|
|
704
|
-
`[conveyor-agent] server-disconnect reconnect attempt ${attempt} did not connect within ${_AgentConnection.CALL_CONNECT_WAIT_MS / 1e3}s \u2014 retrying in ${delayMs / 1e3}s
|
|
705
|
-
`
|
|
706
|
-
);
|
|
707
|
-
await _AgentConnection.delay(delayMs);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
looksLikeAuthError(message) {
|
|
712
|
-
return /unauthor|forbid|auth|token|session (?:not found|expired|invalid)|invalid session/i.test(
|
|
713
|
-
message
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
// ── Proactive task-token refresh ────────────────────────────────────────
|
|
717
|
-
//
|
|
718
|
-
// Socket.IO only re-presents the taskToken on a (re)connect handshake, and
|
|
719
|
-
// the server only re-validates the JWT then. So a token that expires while
|
|
720
|
-
// the socket stays connected goes unnoticed until the next RPC fails. Re-mint
|
|
721
|
-
// periodically from the bootstrap endpoint — refreshFromBootstrap() updates
|
|
722
|
-
// both this.config.taskToken and socket.auth.taskToken, so any later
|
|
723
|
-
// reconnect carries a fresh token. No-ops for project mode / missing
|
|
724
|
-
// codespace env, and is rate-limited to once/60s inside refreshFromBootstrap.
|
|
725
|
-
startProactiveTokenRefresh() {
|
|
726
|
-
if (this.tokenRefreshTimer) return;
|
|
727
|
-
this.tokenRefreshTimer = setInterval(() => {
|
|
728
|
-
void this.refreshTaskTokenFromBootstrap().catch(() => {
|
|
729
|
-
});
|
|
730
|
-
}, TOKEN_REFRESH_INTERVAL_MS);
|
|
731
|
-
this.tokenRefreshTimer.unref?.();
|
|
732
|
-
}
|
|
733
|
-
stopProactiveTokenRefresh() {
|
|
734
|
-
if (this.tokenRefreshTimer) {
|
|
735
|
-
clearInterval(this.tokenRefreshTimer);
|
|
736
|
-
this.tokenRefreshTimer = null;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
drainPendingMessages(messages) {
|
|
740
|
-
for (const msg of messages) {
|
|
741
|
-
if (!msg.content) continue;
|
|
742
|
-
if (this.messageCallback) {
|
|
743
|
-
this.messageCallback({ content: msg.content, userId: msg.userId });
|
|
744
|
-
} else {
|
|
745
|
-
this.earlyMessages.push({ content: msg.content, userId: msg.userId });
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
// ── Callback registration with early-buffer draining ───────────────
|
|
750
|
-
onMessage(callback) {
|
|
751
|
-
this.messageCallback = callback;
|
|
752
|
-
for (const msg of this.earlyMessages) callback(msg);
|
|
753
|
-
this.earlyMessages = [];
|
|
754
|
-
}
|
|
755
|
-
onStop(callback) {
|
|
756
|
-
this.stopCallback = callback;
|
|
757
|
-
if (this.earlyStop) {
|
|
758
|
-
callback();
|
|
759
|
-
this.earlyStop = false;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
onSoftStop(callback) {
|
|
763
|
-
this.softStopCallback = callback;
|
|
764
|
-
if (this.earlySoftStop) {
|
|
765
|
-
callback();
|
|
766
|
-
this.earlySoftStop = false;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
onModeChange(callback) {
|
|
770
|
-
this.modeChangeCallback = callback;
|
|
771
|
-
for (const data of this.earlyModeChanges) callback(data);
|
|
772
|
-
this.earlyModeChanges = [];
|
|
773
|
-
}
|
|
774
|
-
onApiKeyUpdate(callback) {
|
|
775
|
-
this.apiKeyUpdateCallback = callback;
|
|
776
|
-
}
|
|
777
|
-
onPullBranch(callback) {
|
|
778
|
-
this.pullBranchCallback = callback;
|
|
779
|
-
for (const data of this.earlyPullBranches) callback(data);
|
|
780
|
-
this.earlyPullBranches = [];
|
|
781
|
-
}
|
|
782
|
-
onSpawnReview(callback) {
|
|
783
|
-
this.spawnReviewCallback = callback;
|
|
784
|
-
for (const data of this.earlySpawnReviews) callback(data);
|
|
785
|
-
this.earlySpawnReviews = [];
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Report that a same-pod review child failed to spawn (fire-and-forget).
|
|
789
|
-
* The server Ends the orphaned review session and falls back to a dedicated
|
|
790
|
-
* review pod. sessionId is OUR (builder) session — the task-identity guard runs on
|
|
791
|
-
* it; the review session is identified separately.
|
|
792
|
-
*/
|
|
793
|
-
reportReviewSpawnFailure(reviewSessionId, error) {
|
|
794
|
-
if (!this.socket) return;
|
|
795
|
-
void this.call("reportReviewSpawnFailure", {
|
|
796
|
-
sessionId: this.config.sessionId,
|
|
797
|
-
reviewSessionId,
|
|
798
|
-
...error ? { error: error.slice(0, 2e3) } : {}
|
|
799
|
-
}).catch(() => {
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
/**
|
|
803
|
-
* Report that this pod's git credential is dead and refreshing did not fix
|
|
804
|
-
* it (fire-and-forget).
|
|
805
|
-
*
|
|
806
|
-
* Purely diagnostic. Until this existed a pod could lose git entirely and
|
|
807
|
-
* leave no server-side trace at all — the refresh RPC succeeded every time,
|
|
808
|
-
* so the failure was visible only in pod stderr, which is why it took two
|
|
809
|
-
* investigations to attribute. `tokenShape` describes the served credential
|
|
810
|
-
* (length, prefix class, mtime) and NEVER carries its value.
|
|
811
|
-
*/
|
|
812
|
-
reportCredentialFailure(details) {
|
|
813
|
-
if (!this.socket) return;
|
|
814
|
-
void this.call("reportCredentialFailure", {
|
|
815
|
-
sessionId: this.config.sessionId,
|
|
816
|
-
...details.error ? { error: details.error.slice(0, 2e3) } : {},
|
|
817
|
-
...details.tokenShape ? { tokenShape: details.tokenShape.slice(0, 500) } : {},
|
|
818
|
-
...details.healed === void 0 ? {} : { healed: details.healed }
|
|
819
|
-
}).catch(() => {
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* Ask the server to destroy and recreate this pod (fire-and-forget). The
|
|
824
|
-
* agent calls this only when it has proven it cannot recover in place — the
|
|
825
|
-
* shared `~/.claude` GCS FUSE mount is dead and no in-container action can
|
|
826
|
-
* remount it. The server rate-limits the recycle and posts `reason` to the
|
|
827
|
-
* card; old servers that don't know the method reject harmlessly, leaving
|
|
828
|
-
* today's behavior (a failed turn with a chat warning).
|
|
829
|
-
*/
|
|
830
|
-
requestWorkspaceRecycle(reason) {
|
|
831
|
-
if (!this.socket) return;
|
|
832
|
-
void this.call("requestWorkspaceRecycle", {
|
|
833
|
-
sessionId: this.config.sessionId,
|
|
834
|
-
reason: reason.slice(0, 2e3)
|
|
835
|
-
}).catch(() => {
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
onSpawnTui(callback) {
|
|
839
|
-
this.spawnTuiCallback = callback;
|
|
840
|
-
for (const data of this.earlySpawnTuis) callback(data);
|
|
841
|
-
this.earlySpawnTuis = [];
|
|
842
|
-
}
|
|
843
|
-
/** Register the on-demand usage-refresh handler; drains an early-buffered
|
|
844
|
-
* `session:probeUsage` that arrived before the runner was ready. */
|
|
845
|
-
onProbeUsage(callback) {
|
|
846
|
-
this.probeUsageCallback = callback;
|
|
847
|
-
if (this.earlyProbeUsage) {
|
|
848
|
-
this.earlyProbeUsage = false;
|
|
849
|
-
callback();
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* Report that a same-pod TUI/shell child failed to spawn (fire-and-forget).
|
|
854
|
-
* The server Ends the orphaned session — no fallback pod (unlike review).
|
|
855
|
-
* sessionId is OUR (builder) session — the task-identity guard runs on it.
|
|
856
|
-
*/
|
|
857
|
-
reportSessionSpawnFailure(spawnedSessionId, error) {
|
|
858
|
-
if (!this.socket) return;
|
|
859
|
-
void this.call("reportSessionSpawnFailure", {
|
|
860
|
-
sessionId: this.config.sessionId,
|
|
861
|
-
spawnedSessionId,
|
|
862
|
-
...error ? { error: error.slice(0, 2e3) } : {}
|
|
863
|
-
}).catch(() => {
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
/** Register the restart handler, draining a `session:runStartCommand` that
|
|
867
|
-
* arrived before the supervisor was ready. Collapsed to one drain: two
|
|
868
|
-
* clicks during boot should produce one restart, not two competing ones. */
|
|
869
|
-
onRunStartCommand(callback) {
|
|
870
|
-
this.runStartCommandCallback = callback;
|
|
871
|
-
if (this.earlyRunStartCommand) {
|
|
872
|
-
this.earlyRunStartCommand = false;
|
|
873
|
-
callback();
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
// ── PTY relay (S5 Connected-TUI terminal) ──────────────────────────
|
|
877
|
-
/**
|
|
878
|
-
* Forward a raw chunk of terminal output to the S2 relay (fire-and-forget).
|
|
879
|
-
* The first chunk creates the server-side scrollback ring, which is what
|
|
880
|
-
* surfaces the terminal in the UI. `dims` seed/refresh the ring geometry.
|
|
881
|
-
*/
|
|
882
|
-
sendPtyOutput(data, dims) {
|
|
883
|
-
if (!this.socket) return;
|
|
884
|
-
void this.call("ptyOutput", {
|
|
885
|
-
sessionId: this.config.sessionId,
|
|
886
|
-
data,
|
|
887
|
-
...dims ? { cols: dims.cols, rows: dims.rows } : {}
|
|
888
|
-
}).catch(() => {
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
/**
|
|
892
|
-
* Forward one compact chat-proxy event derived from the transcript JSONL to
|
|
893
|
-
* the relay (fire-and-forget). Feeds the experimental chat PTY proxy ring.
|
|
894
|
-
* Old servers that don't know the method reject harmlessly.
|
|
895
|
-
*/
|
|
896
|
-
sendPtyChatEvent(event) {
|
|
897
|
-
if (!this.socket) return;
|
|
898
|
-
void this.call("ptyChatEvent", {
|
|
899
|
-
sessionId: this.config.sessionId,
|
|
900
|
-
event
|
|
901
|
-
}).catch(() => {
|
|
902
|
-
});
|
|
903
|
-
}
|
|
904
|
-
/**
|
|
905
|
-
* Report that the interactive CLI process for this session has died and no
|
|
906
|
-
* respawn is imminent (fire-and-forget). The server clears the scrollback
|
|
907
|
-
* ring and broadcasts pty:ended so clients hide the Connected-TUI tab. Old
|
|
908
|
-
* servers that don't know the method reject harmlessly.
|
|
909
|
-
*/
|
|
910
|
-
sendPtyEnded() {
|
|
911
|
-
if (!this.socket) return;
|
|
912
|
-
void this.call("ptyEnded", { sessionId: this.config.sessionId }).catch(() => {
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
/**
|
|
916
|
-
* Report the port this pod's in-pod PTY stream server bound to, or null when
|
|
917
|
-
* it stopped (fire-and-forget). The server persists it so a viewer can be
|
|
918
|
-
* handed a port-scoped tunnel URL and stream the TUI straight from the pod.
|
|
919
|
-
* Old servers that don't know the method reject harmlessly — the session then
|
|
920
|
-
* just stays on the relay transport.
|
|
921
|
-
*/
|
|
922
|
-
reportPtyStream(port) {
|
|
923
|
-
if (!this.socket) return;
|
|
924
|
-
void this.call("reportPtyStream", {
|
|
925
|
-
sessionId: this.config.sessionId,
|
|
926
|
-
port
|
|
927
|
-
}).catch(() => {
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
/** Subscribe to relayed keystrokes. Returns an unsubscribe fn. */
|
|
931
|
-
onPtyInput(handler) {
|
|
932
|
-
this.ptyInputCallback = handler;
|
|
933
|
-
return () => {
|
|
934
|
-
if (this.ptyInputCallback === handler) this.ptyInputCallback = null;
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
/** Subscribe to relayed (reconciled) terminal resizes. Returns an unsubscribe fn. */
|
|
938
|
-
onPtyResize(handler) {
|
|
939
|
-
this.ptyResizeCallback = handler;
|
|
940
|
-
return () => {
|
|
941
|
-
if (this.ptyResizeCallback === handler) this.ptyResizeCallback = null;
|
|
942
|
-
};
|
|
943
|
-
}
|
|
944
|
-
// ── Convenience methods (thin wrappers around call / emit) ─────────
|
|
945
|
-
async emitStatus(status, reason, questionText) {
|
|
946
|
-
this.lastEmittedStatus = status;
|
|
947
|
-
await this.flushEvents();
|
|
948
|
-
const payload = {
|
|
949
|
-
sessionId: this.config.sessionId,
|
|
950
|
-
status,
|
|
951
|
-
...reason ? { reason } : {},
|
|
952
|
-
// Only sent with a pending TUI questionnaire (reason "user_question") so
|
|
953
|
-
// the server can surface the real question text in the notification.
|
|
954
|
-
...questionText ? { questionText } : {}
|
|
955
|
-
};
|
|
956
|
-
const AWAIT_STATUSES = ["idle", "waiting_for_input", "connected"];
|
|
957
|
-
if (AWAIT_STATUSES.includes(status)) {
|
|
958
|
-
try {
|
|
959
|
-
await this.call("reportAgentStatus", payload);
|
|
960
|
-
this.lastReportedStatus = status;
|
|
961
|
-
} catch {
|
|
962
|
-
}
|
|
963
|
-
} else {
|
|
964
|
-
void this.call("reportAgentStatus", payload).then(() => {
|
|
965
|
-
this.lastReportedStatus = status;
|
|
966
|
-
}).catch(() => {
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
postChatMessage(content, milestone) {
|
|
971
|
-
if (!this.socket) return;
|
|
972
|
-
if (this.suppressIfDuplicate(content)) return;
|
|
973
|
-
void this.call("postAgentMessage", {
|
|
974
|
-
sessionId: this.config.sessionId,
|
|
975
|
-
content,
|
|
976
|
-
milestone
|
|
977
|
-
}).catch(() => {
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
// Awaitable variant of postChatMessage for callers that need to guarantee
|
|
981
|
-
// the message is acknowledged by the server before proceeding (e.g. before
|
|
982
|
-
// aborting the session). Dedup still applies; a suppressed message resolves
|
|
983
|
-
// immediately without hitting the wire.
|
|
984
|
-
async postChatMessageAwait(content, milestone) {
|
|
985
|
-
if (!this.socket) return;
|
|
986
|
-
if (this.suppressIfDuplicate(content)) return;
|
|
987
|
-
try {
|
|
988
|
-
await this.call("postAgentMessage", {
|
|
989
|
-
sessionId: this.config.sessionId,
|
|
990
|
-
content,
|
|
991
|
-
milestone
|
|
992
|
-
});
|
|
993
|
-
} catch (err) {
|
|
994
|
-
process.stderr.write(
|
|
995
|
-
`[conveyor-agent] postChatMessageAwait failed: ${err instanceof Error ? err.message : String(err)}
|
|
996
|
-
`
|
|
997
|
-
);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
suppressIfDuplicate(content) {
|
|
1001
|
-
const d = this.checkAndTrackDuplicate(content);
|
|
1002
|
-
if (!d.duplicate) return false;
|
|
1003
|
-
process.stderr.write(
|
|
1004
|
-
`[dedup] Suppressed near-duplicate (matched: "${d.matchedMessagePreview}")
|
|
1005
|
-
`
|
|
1006
|
-
);
|
|
1007
|
-
return true;
|
|
1008
|
-
}
|
|
1009
|
-
// Exposed so `post_to_chat` can surface suppression back to the agent.
|
|
1010
|
-
checkAndTrackDuplicate(content) {
|
|
1011
|
-
const now = Date.now();
|
|
1012
|
-
this.recentMessages = this.recentMessages.filter(
|
|
1013
|
-
(m) => now - m.timestamp < _AgentConnection.DEDUP_WINDOW_MS
|
|
1014
|
-
);
|
|
1015
|
-
const words = new Set(
|
|
1016
|
-
content.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length >= 3)
|
|
1017
|
-
);
|
|
1018
|
-
if (words.size === 0) return { duplicate: false };
|
|
1019
|
-
for (const recent of this.recentMessages) {
|
|
1020
|
-
let intersection = 0;
|
|
1021
|
-
for (const w of words) if (recent.words.has(w)) intersection++;
|
|
1022
|
-
const union = (/* @__PURE__ */ new Set([...words, ...recent.words])).size;
|
|
1023
|
-
if (union > 0 && intersection / union > _AgentConnection.DEDUP_SIMILARITY_THRESHOLD) {
|
|
1024
|
-
return { duplicate: true, matchedMessagePreview: recent.preview };
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
const max = _AgentConnection.DEDUP_PREVIEW_LIMIT;
|
|
1028
|
-
const preview = content.length > max ? content.slice(0, max) + "\u2026" : content;
|
|
1029
|
-
this.recentMessages.push({ words, timestamp: now, preview });
|
|
1030
|
-
if (this.recentMessages.length > 3) this.recentMessages.shift();
|
|
1031
|
-
return { duplicate: false };
|
|
1032
|
-
}
|
|
1033
|
-
/**
|
|
1034
|
-
* @param loopStatus overrides the status derived from the last emitted
|
|
1035
|
-
* runner status. SessionRunner passes it so an idle runner that still has
|
|
1036
|
-
* background work outstanding in the pod beats as `waiting` (→ `active` on
|
|
1037
|
-
* the wire) rather than `idle`, which would let the workspace activity
|
|
1038
|
-
* clock expire mid-gate. See connection/loop-lag.ts `heartbeatStatusFor`.
|
|
1039
|
-
*
|
|
1040
|
-
* Without an override the status comes from `loopStatusForRunnerStatus`, the
|
|
1041
|
-
* same total classifier SessionRunner uses, so both paths agree. This used to
|
|
1042
|
-
* be a partial map covering 5 of the 11 `AgentRunnerStatus` values with a
|
|
1043
|
-
* `?? "active"` fallback, which meant a parked runner (`waiting_for_input`,
|
|
1044
|
-
* `finished`, `error`, `stopping`, `disconnected`) beat as ACTIVE on every
|
|
1045
|
-
* no-arg call site — the reconnect paths below, and the shell/project/adhoc
|
|
1046
|
-
* runners, which never pass a loop status at all. That renewed the workspace
|
|
1047
|
-
* activity clock for an agent doing nothing, so the card stayed "active" and
|
|
1048
|
-
* its pod stayed up long past the project's inactivity window.
|
|
1049
|
-
*/
|
|
1050
|
-
sendHeartbeat(loopLagMs, loopStatus) {
|
|
1051
|
-
if (!this.socket) return;
|
|
1052
|
-
const heartbeatStatus = heartbeatStatusFor(
|
|
1053
|
-
loopStatus ?? loopStatusForRunnerStatus(this.lastEmittedStatus)
|
|
1054
|
-
);
|
|
1055
|
-
void this.call("heartbeat", {
|
|
1056
|
-
sessionId: this.config.sessionId,
|
|
1057
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1058
|
-
status: heartbeatStatus,
|
|
1059
|
-
...loopLagMs !== void 0 && loopLagMs > 0 ? { loopLagMs: Math.round(loopLagMs) } : {}
|
|
1060
|
-
}).catch(() => {
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
// ── Starvation-proof heartbeat worker ────────────────────────────────
|
|
1064
|
-
//
|
|
1065
|
-
// A worker thread with its own event loop + Socket.IO connection keeps the
|
|
1066
|
-
// v3 session lease renewed even when the MAIN loop is stalled (the failure
|
|
1067
|
-
// mode where a heavy gate got the session declared stranded and restarted
|
|
1068
|
-
// mid-run). Best-effort by design: any spawn/runtime failure just degrades
|
|
1069
|
-
// heartbeats to main-loop-only. See heartbeat-worker.ts for the policy.
|
|
1070
|
-
heartbeatWorker = null;
|
|
1071
|
-
startHeartbeatWorker(sharedBuffer, intervalMs = 3e4) {
|
|
1072
|
-
if (this.heartbeatWorker) return;
|
|
1073
|
-
try {
|
|
1074
|
-
const workerUrl = new URL("./heartbeat-worker.js", import.meta.url);
|
|
1075
|
-
if (!existsSync(fileURLToPath(workerUrl))) {
|
|
1076
|
-
process.stderr.write(
|
|
1077
|
-
"[conveyor-agent] heartbeat worker bundle not found \u2014 main-loop heartbeat only\n"
|
|
1078
|
-
);
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
const worker = new Worker(workerUrl, {
|
|
1082
|
-
workerData: {
|
|
1083
|
-
apiUrl: this.config.apiUrl,
|
|
1084
|
-
taskToken: this.config.taskToken,
|
|
1085
|
-
sessionId: this.config.sessionId,
|
|
1086
|
-
runnerMode: this.config.runnerMode ?? "task",
|
|
1087
|
-
sharedBuffer,
|
|
1088
|
-
intervalMs
|
|
1089
|
-
}
|
|
1090
|
-
});
|
|
1091
|
-
worker.unref();
|
|
1092
|
-
worker.on("error", (err) => {
|
|
1093
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1094
|
-
process.stderr.write(`[conveyor-agent] heartbeat worker error: ${message}
|
|
1095
|
-
`);
|
|
1096
|
-
this.heartbeatWorker = null;
|
|
1097
|
-
});
|
|
1098
|
-
worker.on("exit", (code) => {
|
|
1099
|
-
if (code !== 0) {
|
|
1100
|
-
process.stderr.write(`[conveyor-agent] heartbeat worker exited (code ${code})
|
|
1101
|
-
`);
|
|
1102
|
-
}
|
|
1103
|
-
this.heartbeatWorker = null;
|
|
1104
|
-
});
|
|
1105
|
-
this.heartbeatWorker = worker;
|
|
1106
|
-
process.stderr.write("[conveyor-agent] heartbeat worker started\n");
|
|
1107
|
-
} catch (err) {
|
|
1108
|
-
process.stderr.write(
|
|
1109
|
-
`[conveyor-agent] heartbeat worker failed to start: ${err instanceof Error ? err.message : String(err)}
|
|
1110
|
-
`
|
|
1111
|
-
);
|
|
1112
|
-
this.heartbeatWorker = null;
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
stopHeartbeatWorker() {
|
|
1116
|
-
const worker = this.heartbeatWorker;
|
|
1117
|
-
this.heartbeatWorker = null;
|
|
1118
|
-
if (worker) void worker.terminate();
|
|
1119
|
-
}
|
|
1120
|
-
emitModeChanged(agentMode) {
|
|
1121
|
-
this.sendEvent({ type: "mode_changed", agentMode });
|
|
1122
|
-
}
|
|
1123
|
-
async updateTaskFields(fields) {
|
|
1124
|
-
if (!this.socket) return { ok: false, error: "socket not connected" };
|
|
1125
|
-
try {
|
|
1126
|
-
await this.call("updateTaskFields", { sessionId: this.config.sessionId, ...fields });
|
|
1127
|
-
return { ok: true };
|
|
1128
|
-
} catch (err) {
|
|
1129
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
storeSessionId(sdkSessionId) {
|
|
1133
|
-
void this.call("storeSessionId", { sessionId: this.config.sessionId, sdkSessionId }).catch(
|
|
1134
|
-
() => {
|
|
1135
|
-
}
|
|
1136
|
-
);
|
|
1137
|
-
}
|
|
1138
|
-
/** Report the full current set of runtime-discovered listening ports.
|
|
1139
|
-
* Throws on failure so the PortDiscovery poller can retry on its next
|
|
1140
|
-
* tick (a swallowed error here would silently drop the delta). */
|
|
1141
|
-
async reportDiscoveredPorts(ports) {
|
|
1142
|
-
await this.call("reportDiscoveredPorts", { sessionId: this.config.sessionId, ports });
|
|
1143
|
-
}
|
|
1144
|
-
/** Boot-milestone report over the socket — the codespace-parity fallback
|
|
1145
|
-
* for the GKE pod bootstrap-token route. Fire-and-forget: a failed report
|
|
1146
|
-
* must never delay or fail the boot path. */
|
|
1147
|
-
reportBootMilestone(key) {
|
|
1148
|
-
void this.call("reportBootMilestone", { sessionId: this.config.sessionId, key }).catch(
|
|
1149
|
-
() => {
|
|
1150
|
-
}
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
// ── Typing indicators ───────────────────────────────────────────────
|
|
1154
|
-
sendTypingStart() {
|
|
1155
|
-
this.sendEvent({ type: "agent_typing_start" });
|
|
1156
|
-
}
|
|
1157
|
-
sendTypingStop() {
|
|
1158
|
-
this.sendEvent({ type: "agent_typing_stop" });
|
|
1159
|
-
}
|
|
1160
|
-
// ── RPC convenience wrappers (v6 compat, will migrate to call()) ───
|
|
1161
|
-
emitRateLimitPause(resetsAt) {
|
|
1162
|
-
this.sendEvent({ type: "rate_limit_update", resetsAt });
|
|
1163
|
-
}
|
|
1164
|
-
updateStatus(status) {
|
|
1165
|
-
this.emitStatus(status);
|
|
1166
|
-
}
|
|
1167
|
-
/**
|
|
1168
|
-
* The session's key hit a hard usage cap — ask the server to stamp it
|
|
1169
|
-
* limited and hand back the best remaining key's credential env (or a
|
|
1170
|
-
* requeue confirmation when none is left). Awaited: the caller swaps
|
|
1171
|
-
* credentials and resumes on success, so it needs the real response.
|
|
1172
|
-
*/
|
|
1173
|
-
async cycleCodingAgentKey(rateLimitType, resetsAt) {
|
|
1174
|
-
return await this.call("cycleCodingAgentKey", {
|
|
1175
|
-
sessionId: this.config.sessionId,
|
|
1176
|
-
rateLimitType,
|
|
1177
|
-
...resetsAt ? { resetsAt } : {}
|
|
1178
|
-
});
|
|
1179
|
-
}
|
|
1180
|
-
// ── Question handling ──────────────────────────────────────────────
|
|
1181
|
-
async askUserQuestion(questions) {
|
|
1182
|
-
const questionText = questions.map(
|
|
1183
|
-
(q) => `**${q.header}**
|
|
1184
|
-
${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o.description}`).join("\n") : ""}`
|
|
1185
|
-
).join("\n\n");
|
|
1186
|
-
const requestId = crypto.randomUUID();
|
|
1187
|
-
const roomEventPromise = new Promise((resolve) => {
|
|
1188
|
-
this.pendingAnswerResolvers.set(requestId, resolve);
|
|
1189
|
-
});
|
|
1190
|
-
const rpcPromise = this.call("askUserQuestion", {
|
|
1191
|
-
sessionId: this.config.sessionId,
|
|
1192
|
-
question: questionText,
|
|
1193
|
-
requestId,
|
|
1194
|
-
questions
|
|
1195
|
-
}).then((res) => res.answers);
|
|
1196
|
-
try {
|
|
1197
|
-
return await Promise.race([rpcPromise, roomEventPromise]);
|
|
1198
|
-
} finally {
|
|
1199
|
-
this.pendingAnswerResolvers.delete(requestId);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
// ── Typed service method wrappers ───────────────────────────────────
|
|
1203
|
-
getTaskProperties() {
|
|
1204
|
-
return this.call("getTaskProperties", { sessionId: this.config.sessionId });
|
|
1205
|
-
}
|
|
1206
|
-
triggerIdentification() {
|
|
1207
|
-
return this.call("triggerIdentification", { sessionId: this.config.sessionId });
|
|
1208
|
-
}
|
|
1209
|
-
handoffToImplementer(payload) {
|
|
1210
|
-
return this.call("handoffToImplementer", {
|
|
1211
|
-
sessionId: this.config.sessionId,
|
|
1212
|
-
...payload
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
async refreshAuthToken() {
|
|
1216
|
-
const result = await this.refreshFromBootstrap();
|
|
1217
|
-
return result.refreshedClaude;
|
|
1218
|
-
}
|
|
1219
|
-
/**
|
|
1220
|
-
* Refresh the in-process `CONVEYOR_TASK_TOKEN` from the bootstrap endpoint.
|
|
1221
|
-
* Returns true if a new token was applied. Rate-limited locally to once per
|
|
1222
|
-
* 60s so a tight auth-rejected loop can't hammer the bootstrap endpoint —
|
|
1223
|
-
* the server enforces the same window via `lastBootstrapAt`.
|
|
1224
|
-
*/
|
|
1225
|
-
lastTaskTokenRefreshAt = 0;
|
|
1226
|
-
async refreshTaskTokenFromBootstrap() {
|
|
1227
|
-
const result = await this.refreshFromBootstrap();
|
|
1228
|
-
return result.refreshedTaskToken;
|
|
1229
|
-
}
|
|
1230
|
-
refreshFromBootstrap() {
|
|
1231
|
-
const none = Promise.resolve({ refreshedClaude: false, refreshedTaskToken: false });
|
|
1232
|
-
const podBootstrapToken = process.env.POD_BOOTSTRAP_TOKEN;
|
|
1233
|
-
const codespaceName = process.env.CODESPACE_NAME;
|
|
1234
|
-
const apiUrl = this.config.apiUrl;
|
|
1235
|
-
if (!apiUrl || !podBootstrapToken && !codespaceName) {
|
|
1236
|
-
return none;
|
|
1237
|
-
}
|
|
1238
|
-
const now = Date.now();
|
|
1239
|
-
if (now - this.lastTaskTokenRefreshAt < 6e4) {
|
|
1240
|
-
return none;
|
|
1241
|
-
}
|
|
1242
|
-
this.lastTaskTokenRefreshAt = now;
|
|
1243
|
-
if (podBootstrapToken) {
|
|
1244
|
-
return this.refreshFromV3Bootstrap(apiUrl, podBootstrapToken);
|
|
1245
|
-
}
|
|
1246
|
-
if (!codespaceName) return none;
|
|
1247
|
-
return this.refreshFromCodespaceBootstrap(apiUrl, codespaceName);
|
|
1248
|
-
}
|
|
1249
|
-
/** Legacy GitHub Codespaces refresh path — keys on instance name. */
|
|
1250
|
-
async refreshFromCodespaceBootstrap(apiUrl, codespaceName) {
|
|
1251
|
-
const bootstrapToken = process.env.CONVEYOR_BOOTSTRAP_TOKEN;
|
|
1252
|
-
const result = await fetchBootstrap({
|
|
1253
|
-
apiUrl,
|
|
1254
|
-
instanceName: codespaceName,
|
|
1255
|
-
bootstrapToken
|
|
1256
|
-
// Do not retry on http errors during a runtime refresh — a 401/403
|
|
1257
|
-
// means the token is consumed / session terminal and retrying won't
|
|
1258
|
-
// help. Network/timeout still retry inside fetchBootstrap.
|
|
1259
|
-
});
|
|
1260
|
-
if (!result.ok) {
|
|
1261
|
-
logger.warn("bootstrap refresh abandoned \u2014 pod credentials will go stale", {
|
|
1262
|
-
path: "codespace",
|
|
1263
|
-
reason: result.reason,
|
|
1264
|
-
status: result.status,
|
|
1265
|
-
attempts: result.attempts,
|
|
1266
|
-
detail: result.detail
|
|
1267
|
-
});
|
|
1268
|
-
return { refreshedClaude: false, refreshedTaskToken: false };
|
|
1269
|
-
}
|
|
1270
|
-
const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
|
|
1271
|
-
applyBootstrapToEnv(result.config);
|
|
1272
|
-
const env = result.config.envVars ?? {};
|
|
1273
|
-
syncBundleGithubToken(env.CONVEYOR_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN);
|
|
1274
|
-
const refreshedTaskToken = result.config.mode !== "project" && Boolean(result.config.taskToken) && result.config.taskToken !== previousTaskToken;
|
|
1275
|
-
if (refreshedTaskToken && result.config.taskToken) {
|
|
1276
|
-
this.config.taskToken = result.config.taskToken;
|
|
1277
|
-
if (this.socket) {
|
|
1278
|
-
const auth = this.socket.auth;
|
|
1279
|
-
if (auth && typeof auth === "object") {
|
|
1280
|
-
auth.taskToken = result.config.taskToken;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
this.heartbeatWorker?.postMessage({ taskToken: result.config.taskToken });
|
|
1284
|
-
}
|
|
1285
|
-
const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
|
|
1286
|
-
return { refreshedClaude, refreshedTaskToken };
|
|
1287
|
-
}
|
|
1288
|
-
/**
|
|
1289
|
-
* v3 refresh: re-fetch the full bootstrap bundle from the pod's bound v3
|
|
1290
|
-
* route and swap the credentials in place. The GitHub installation token
|
|
1291
|
-
* dies at ~1h and the sessionJwt at 24h; re-polling the bootstrap GET with
|
|
1292
|
-
* the same pod token is the designed refresh mechanism.
|
|
1293
|
-
*/
|
|
1294
|
-
async refreshFromV3Bootstrap(apiUrl, bootstrapToken) {
|
|
1295
|
-
const bundle = await this.pollBundleWithRateLimitRetry(apiUrl, bootstrapToken);
|
|
1296
|
-
if (!bundle) {
|
|
1297
|
-
return { refreshedClaude: false, refreshedTaskToken: false };
|
|
1298
|
-
}
|
|
1299
|
-
const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
|
|
1300
|
-
for (const [key, value] of Object.entries(bundle.envVars ?? {})) {
|
|
1301
|
-
process.env[key] = value;
|
|
1302
|
-
}
|
|
1303
|
-
if (bundle.githubToken) {
|
|
1304
|
-
process.env.CONVEYOR_GITHUB_TOKEN = bundle.githubToken;
|
|
1305
|
-
this.applyBundleCredentialFiles(bundle, previousTaskToken);
|
|
1306
|
-
}
|
|
1307
|
-
if (bundle.anthropicKey) process.env.ANTHROPIC_API_KEY = bundle.anthropicKey;
|
|
1308
|
-
if (bundle.gcpToken) process.env.CLOUDSDK_AUTH_ACCESS_TOKEN = bundle.gcpToken;
|
|
1309
|
-
const refreshedTaskToken = Boolean(bundle.sessionJwt) && bundle.sessionJwt !== previousTaskToken;
|
|
1310
|
-
if (refreshedTaskToken) {
|
|
1311
|
-
process.env.CONVEYOR_TASK_TOKEN = bundle.sessionJwt;
|
|
1312
|
-
this.config.taskToken = bundle.sessionJwt;
|
|
1313
|
-
if (this.socket) {
|
|
1314
|
-
const auth = this.socket.auth;
|
|
1315
|
-
if (auth && typeof auth === "object") {
|
|
1316
|
-
auth.taskToken = bundle.sessionJwt;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
this.heartbeatWorker?.postMessage({ taskToken: bundle.sessionJwt });
|
|
1320
|
-
}
|
|
1321
|
-
const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
|
|
1322
|
-
return { refreshedClaude, refreshedTaskToken };
|
|
1323
|
-
}
|
|
1324
|
-
/**
|
|
1325
|
-
* Write the bundle's GitHub token to the shared credential files — unless the
|
|
1326
|
-
* bundle belongs to another session.
|
|
1327
|
-
*
|
|
1328
|
-
* The bootstrap GET is keyed by the POD, so on a pod also hosting a same-pod
|
|
1329
|
-
* review it can resolve to the reader session, whose token is read-only.
|
|
1330
|
-
* Writing that over the shared files silently downgrades the builder's push
|
|
1331
|
-
* credential. Our own taskToken carries the same claims, so it is what we
|
|
1332
|
-
* compare against.
|
|
1333
|
-
*
|
|
1334
|
-
* The legitimate case this path exists for — our own session's bundle
|
|
1335
|
-
* refreshing the token when the RPC is failing — is unaffected.
|
|
1336
|
-
*/
|
|
1337
|
-
applyBundleCredentialFiles(bundle, previousTaskToken) {
|
|
1338
|
-
const self = readBundleIdentity(previousTaskToken);
|
|
1339
|
-
const result = applyBundleGithubToken(bundle, {
|
|
1340
|
-
sessionId: this.config.sessionId || self.sessionId,
|
|
1341
|
-
...self.role ? { role: self.role } : {}
|
|
1342
|
-
});
|
|
1343
|
-
if (!result.written && result.reason) {
|
|
1344
|
-
process.stderr.write(
|
|
1345
|
-
`[conveyor-agent] Skipped writing GitHub credential files from the bootstrap bundle: ${result.reason}
|
|
1346
|
-
`
|
|
1347
|
-
);
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
/**
|
|
1351
|
-
* Poll the bootstrap bundle once (maxWaitMs 0), retrying only on a transient
|
|
1352
|
-
* 429 (standby-pool pods share one Cloud NAT IP against podBootstrapLimiter)
|
|
1353
|
-
* with a short backoff. Returns null when the refresh should be abandoned —
|
|
1354
|
-
* a non-429 error, or 429s past the retry budget — so the caller no-ops
|
|
1355
|
-
* instead of parking a RUNNING pod in a poll loop.
|
|
1356
|
-
*/
|
|
1357
|
-
async pollBundleWithRateLimitRetry(apiUrl, bootstrapToken) {
|
|
1358
|
-
const retryDelaysMs = [1e3, 3e3];
|
|
1359
|
-
for (let attempt = 0; ; attempt++) {
|
|
1360
|
-
try {
|
|
1361
|
-
return await pollUntilBound({ apiUrl, bootstrapToken, maxWaitMs: 0 });
|
|
1362
|
-
} catch (err) {
|
|
1363
|
-
const isRateLimited = err instanceof PollUntilBoundHttpError && err.status === 429;
|
|
1364
|
-
if (!isRateLimited || attempt >= retryDelaysMs.length) {
|
|
1365
|
-
logger.warn("bootstrap refresh abandoned \u2014 pod credentials will go stale", {
|
|
1366
|
-
path: "v3",
|
|
1367
|
-
attempt,
|
|
1368
|
-
rateLimited: isRateLimited,
|
|
1369
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1370
|
-
});
|
|
1371
|
-
return null;
|
|
1372
|
-
}
|
|
1373
|
-
await new Promise((resolve) => {
|
|
1374
|
-
setTimeout(resolve, retryDelaysMs[attempt]);
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
// ── Event buffering ────────────────────────────────────────────────
|
|
1380
|
-
sendEvent(event) {
|
|
1381
|
-
if (!this.socket) return;
|
|
1382
|
-
this.enqueueEvents([{ event }], false);
|
|
1383
|
-
}
|
|
1384
|
-
/** Append (or, on `toFront`, prepend for a failed-flush re-queue) events to
|
|
1385
|
-
* the buffer, then cap + arm the flush timer. Single owner of the overflow
|
|
1386
|
-
* policy so append and re-queue can't diverge on the drop accounting. */
|
|
1387
|
-
enqueueEvents(entries, toFront) {
|
|
1388
|
-
if (toFront) this.eventBuffer.unshift(...entries);
|
|
1389
|
-
else this.eventBuffer.push(...entries);
|
|
1390
|
-
while (this.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
1391
|
-
this.eventBuffer.shift();
|
|
1392
|
-
this.droppedEventCount++;
|
|
1393
|
-
if (this.droppedEventCount === 1 || this.droppedEventCount % 500 === 0) {
|
|
1394
|
-
process.stderr.write(
|
|
1395
|
-
`[conveyor-agent] eventBuffer overflow \u2014 dropped ${this.droppedEventCount} event(s) (cap: ${MAX_EVENT_BUFFER})
|
|
1396
|
-
`
|
|
1397
|
-
);
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
if (this.socket && !this.flushTimer) {
|
|
1401
|
-
this.flushTimer = setTimeout(() => void this.flushEvents(), EVENT_BATCH_MS);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
async flushEvents() {
|
|
1405
|
-
if (this.flushTimer) {
|
|
1406
|
-
clearTimeout(this.flushTimer);
|
|
1407
|
-
this.flushTimer = null;
|
|
1408
|
-
}
|
|
1409
|
-
if (!this.socket || this.eventBuffer.length === 0) return;
|
|
1410
|
-
const entries = this.eventBuffer;
|
|
1411
|
-
this.eventBuffer = [];
|
|
1412
|
-
const events = entries.map((entry) => entry.event);
|
|
1413
|
-
try {
|
|
1414
|
-
await this.call("emitAgentEvent", { sessionId: this.config.sessionId, events });
|
|
1415
|
-
} catch {
|
|
1416
|
-
this.requeueFailedEvents(entries);
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
/** Put a failed flush's events back at the FRONT of the buffer, preserving
|
|
1420
|
-
* order, via the shared cap-and-arm path. */
|
|
1421
|
-
requeueFailedEvents(entries) {
|
|
1422
|
-
this.enqueueEvents(entries, true);
|
|
1423
|
-
}
|
|
1424
|
-
};
|
|
1425
|
-
|
|
1426
|
-
// src/connection/auth-errors.ts
|
|
1427
|
-
function isPermissionDeniedError(err) {
|
|
1428
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1429
|
-
return /insufficient permissions|authentication required/i.test(message);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// src/runner/git-run.ts
|
|
1433
|
-
import { execFile } from "child_process";
|
|
1434
|
-
import { promisify } from "util";
|
|
1435
|
-
var execFileAsync = promisify(execFile);
|
|
1436
|
-
var GIT_TIMEOUT_MS = 6e4;
|
|
1437
|
-
var GIT_SLOW_TIMEOUT_MS = 12e4;
|
|
1438
|
-
var GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
1439
|
-
async function git(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1440
|
-
if (workbenchEnabled()) {
|
|
1441
|
-
try {
|
|
1442
|
-
const { stdout: stdout2 } = await getWorkbenchClient().execFile("git", args, {
|
|
1443
|
-
cwd,
|
|
1444
|
-
timeout: timeoutMs,
|
|
1445
|
-
maxBuffer: GIT_MAX_BUFFER
|
|
1446
|
-
});
|
|
1447
|
-
return stdout2.trim();
|
|
1448
|
-
} catch (err) {
|
|
1449
|
-
if (err instanceof Error && err.message.startsWith("Command timed out:")) {
|
|
1450
|
-
err.killed = true;
|
|
1451
|
-
}
|
|
1452
|
-
throw err;
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
const { stdout } = await execFileAsync("git", args, {
|
|
1456
|
-
cwd,
|
|
1457
|
-
timeout: timeoutMs,
|
|
1458
|
-
maxBuffer: GIT_MAX_BUFFER
|
|
1459
|
-
});
|
|
1460
|
-
return stdout.toString().trim();
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// src/runner/git-credential-ops.ts
|
|
1464
|
-
function credentialErrorText(err) {
|
|
1465
|
-
const raw = err instanceof Error ? err.message : String(err);
|
|
1466
|
-
return raw.replace(/\/\/[^@\s]+@/g, "//***@");
|
|
1467
|
-
}
|
|
1468
|
-
async function updateRemoteCredential(cwd, credential) {
|
|
1469
|
-
const result = { ok: false, storeWritten: false, helperConfigured: false };
|
|
1470
|
-
try {
|
|
1471
|
-
const currentUrl = await git(cwd, ["remote", "get-url", "origin"]);
|
|
1472
|
-
const cloneUrl = credential.cloneUrl ?? currentUrl;
|
|
1473
|
-
const normalizedUrl = writeGitCredential(cwd, cloneUrl, credential);
|
|
1474
|
-
result.storeWritten = true;
|
|
1475
|
-
if (currentUrl !== normalizedUrl) {
|
|
1476
|
-
await git(cwd, ["remote", "set-url", "origin", normalizedUrl]);
|
|
1477
|
-
}
|
|
1478
|
-
await git(cwd, ["config", "--local", "credential.helper", gitCredentialHelper(cwd)]);
|
|
1479
|
-
result.helperConfigured = true;
|
|
1480
|
-
result.ok = true;
|
|
1481
|
-
} catch (err) {
|
|
1482
|
-
result.error = credentialErrorText(err);
|
|
1483
|
-
}
|
|
1484
|
-
return result;
|
|
1485
|
-
}
|
|
1486
|
-
async function updateRemoteToken(cwd, token) {
|
|
1487
|
-
const username = process.env.CONVEYOR_GIT_USERNAME || "x-access-token";
|
|
1488
|
-
const cloneUrl = process.env.CONVEYOR_GIT_CLONE_URL || void 0;
|
|
1489
|
-
const credential = await updateRemoteCredential(cwd, { username, secret: token, cloneUrl });
|
|
1490
|
-
process.env.CONVEYOR_GIT_SECRET = token;
|
|
1491
|
-
const files = syncGithubTokenFiles(token);
|
|
1492
|
-
return { credential, files };
|
|
1493
|
-
}
|
|
1494
|
-
async function verifyGitCredential(cwd) {
|
|
1495
|
-
try {
|
|
1496
|
-
await git(cwd, ["ls-remote", "--heads", "origin"], 3e4);
|
|
1497
|
-
return { ok: true, outcome: "ok" };
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
const killed = err.killed === true;
|
|
1500
|
-
return {
|
|
1501
|
-
ok: false,
|
|
1502
|
-
outcome: killed ? "timeout" : "denied",
|
|
1503
|
-
error: credentialErrorText(err)
|
|
1504
|
-
};
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
// src/runner/git-utils.ts
|
|
1509
|
-
import { realpathSync } from "fs";
|
|
1510
|
-
|
|
1511
|
-
// src/runner/force-fresh-cooldown.ts
|
|
1512
|
-
var FORCE_FRESH_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
1513
|
-
var blockedUntil = 0;
|
|
1514
|
-
function forceFreshCooldownRemainingMs() {
|
|
1515
|
-
return Math.max(0, blockedUntil - Date.now());
|
|
1516
|
-
}
|
|
1517
|
-
function forceFreshMintBlocked() {
|
|
1518
|
-
return forceFreshCooldownRemainingMs() > 0;
|
|
1519
|
-
}
|
|
1520
|
-
function recordForceFreshFailure() {
|
|
1521
|
-
blockedUntil = Date.now() + FORCE_FRESH_COOLDOWN_MS;
|
|
1522
|
-
}
|
|
1523
|
-
function clearForceFreshCooldown() {
|
|
1524
|
-
blockedUntil = 0;
|
|
1525
|
-
}
|
|
1526
|
-
function forceFreshCooldownNotice() {
|
|
1527
|
-
const minutes = Math.ceil(forceFreshCooldownRemainingMs() / 6e4);
|
|
1528
|
-
return `- the force-fresh retry was SKIPPED: one already failed against this pod, so it is on a ${Math.round(FORCE_FRESH_COOLDOWN_MS / 6e4)}-minute cooldown (${minutes} min left). Re-minting cannot fix a credential the pod cannot serve.`;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
// src/runner/git-utils.ts
|
|
1532
|
-
async function ensureOnTaskBranch(cwd, taskBranch, baseBranch) {
|
|
1533
|
-
if (!taskBranch) return true;
|
|
1534
|
-
try {
|
|
1535
|
-
if (await getCurrentBranch(cwd) === taskBranch) return true;
|
|
1536
|
-
let existsOnOrigin = true;
|
|
1537
|
-
try {
|
|
1538
|
-
await git(cwd, [
|
|
1539
|
-
"fetch",
|
|
1540
|
-
"origin",
|
|
1541
|
-
`+refs/heads/${taskBranch}:refs/remotes/origin/${taskBranch}`
|
|
1542
|
-
]);
|
|
1543
|
-
} catch (err) {
|
|
1544
|
-
if (String(err).includes("couldn't find remote ref")) existsOnOrigin = false;
|
|
1545
|
-
else throw err;
|
|
1546
|
-
}
|
|
1547
|
-
if (existsOnOrigin) {
|
|
1548
|
-
await git(cwd, ["checkout", "-B", taskBranch, `origin/${taskBranch}`], 3e4);
|
|
1549
|
-
process.stderr.write(`[conveyor-agent] Checked out task branch ${taskBranch}
|
|
1550
|
-
`);
|
|
1551
|
-
return true;
|
|
1552
|
-
}
|
|
1553
|
-
if (!baseBranch) {
|
|
1554
|
-
process.stderr.write(
|
|
1555
|
-
`[conveyor-agent] Warning: task branch ${taskBranch} missing on origin and no base branch given
|
|
1556
|
-
`
|
|
1557
|
-
);
|
|
1558
|
-
return false;
|
|
1559
|
-
}
|
|
1560
|
-
await git(cwd, [
|
|
1561
|
-
"fetch",
|
|
1562
|
-
"origin",
|
|
1563
|
-
`+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`
|
|
1564
|
-
]);
|
|
1565
|
-
await git(cwd, ["checkout", "-B", taskBranch, `origin/${baseBranch}`], 3e4);
|
|
1566
|
-
await git(cwd, ["push", "-u", "origin", taskBranch], 3e4);
|
|
1567
|
-
process.stderr.write(
|
|
1568
|
-
`[conveyor-agent] Created task branch ${taskBranch} from origin/${baseBranch} and pushed
|
|
1569
|
-
`
|
|
1570
|
-
);
|
|
1571
|
-
return true;
|
|
1572
|
-
} catch {
|
|
1573
|
-
process.stderr.write(`[conveyor-agent] Warning: ensureOnTaskBranch(${taskBranch}) failed
|
|
1574
|
-
`);
|
|
1575
|
-
return false;
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
async function hasUncommittedChanges(cwd) {
|
|
1579
|
-
const status = await git(cwd, ["status", "--porcelain"], GIT_SLOW_TIMEOUT_MS);
|
|
1580
|
-
return status.length > 0;
|
|
1581
|
-
}
|
|
1582
|
-
async function getCurrentBranch(cwd) {
|
|
1583
|
-
try {
|
|
1584
|
-
const branch = await git(cwd, ["branch", "--show-current"]);
|
|
1585
|
-
return branch || null;
|
|
1586
|
-
} catch {
|
|
1587
|
-
return null;
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
async function hasUnpushedCommits(cwd) {
|
|
1591
|
-
try {
|
|
1592
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
1593
|
-
if (!currentBranch) return false;
|
|
1594
|
-
try {
|
|
1595
|
-
await git(cwd, ["rev-parse", `origin/${currentBranch}`]);
|
|
1596
|
-
} catch {
|
|
1597
|
-
try {
|
|
1598
|
-
await git(cwd, ["rev-parse", "HEAD"]);
|
|
1599
|
-
return true;
|
|
1600
|
-
} catch {
|
|
1601
|
-
return false;
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
const ahead = await git(cwd, [
|
|
1605
|
-
"rev-list",
|
|
1606
|
-
"--count",
|
|
1607
|
-
"HEAD",
|
|
1608
|
-
"--not",
|
|
1609
|
-
`origin/${currentBranch}`
|
|
1610
|
-
]);
|
|
1611
|
-
return parseInt(ahead, 10) > 0;
|
|
1612
|
-
} catch {
|
|
1613
|
-
return false;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
async function remoteMatchesLocalHead(cwd, branch) {
|
|
1617
|
-
try {
|
|
1618
|
-
const [remote, local] = await Promise.all([
|
|
1619
|
-
git(cwd, ["ls-remote", "origin", `refs/heads/${branch}`], GIT_SLOW_TIMEOUT_MS),
|
|
1620
|
-
git(cwd, ["rev-parse", "HEAD"])
|
|
1621
|
-
]);
|
|
1622
|
-
const remoteSha = remote.split(/\s+/)[0] ?? "";
|
|
1623
|
-
return /^[0-9a-f]{40}$/i.test(remoteSha) && remoteSha === local.trim();
|
|
1624
|
-
} catch {
|
|
1625
|
-
return false;
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
async function stageAndCommit(cwd, message) {
|
|
1629
|
-
try {
|
|
1630
|
-
await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
|
|
1631
|
-
if (!await hasUncommittedChanges(cwd)) return null;
|
|
1632
|
-
await git(cwd, ["commit", "-m", message], GIT_SLOW_TIMEOUT_MS);
|
|
1633
|
-
return await git(cwd, ["rev-parse", "HEAD"]);
|
|
1634
|
-
} catch {
|
|
1635
|
-
return null;
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
function errLooksLikeAuth(err) {
|
|
1639
|
-
if (err.killed) return true;
|
|
1640
|
-
const stderr = err.stderr?.toString() ?? "";
|
|
1641
|
-
const stdout = err.stdout?.toString() ?? "";
|
|
1642
|
-
const msg = stderr || stdout || (err instanceof Error ? err.message : "");
|
|
1643
|
-
return /authentication|authorization|403|401|token/i.test(msg);
|
|
1644
|
-
}
|
|
1645
|
-
async function tryPush(cwd, branch, skipVerify = false) {
|
|
1646
|
-
const noVerify = skipVerify ? ["--no-verify"] : [];
|
|
1647
|
-
try {
|
|
1648
|
-
await git(cwd, ["push", ...noVerify, "origin", branch], 3e4);
|
|
1649
|
-
return true;
|
|
1650
|
-
} catch (err) {
|
|
1651
|
-
if (errLooksLikeAuth(err)) return false;
|
|
1652
|
-
process.stderr.write(
|
|
1653
|
-
`[conveyor-agent] Plain push of ${branch} failed \u2014 retrying with --force-with-lease
|
|
1654
|
-
`
|
|
1655
|
-
);
|
|
1656
|
-
try {
|
|
1657
|
-
await git(cwd, ["push", ...noVerify, "--force-with-lease", "origin", branch], 3e4);
|
|
1658
|
-
return true;
|
|
1659
|
-
} catch {
|
|
1660
|
-
return false;
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
async function isAuthError(cwd) {
|
|
1665
|
-
try {
|
|
1666
|
-
await git(cwd, ["push", "--dry-run"], 3e4);
|
|
1667
|
-
return false;
|
|
1668
|
-
} catch (err) {
|
|
1669
|
-
return errLooksLikeAuth(err);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
function wipRefForBranch(branch) {
|
|
1673
|
-
return `conveyor-wip/${branch}`;
|
|
1674
|
-
}
|
|
1675
|
-
var wipRefPushed = /* @__PURE__ */ new Set();
|
|
1676
|
-
var wipRefPreserved = /* @__PURE__ */ new Set();
|
|
1677
|
-
async function createWipSnapshot(cwd, message) {
|
|
1678
|
-
let savedIndexTree;
|
|
1679
|
-
try {
|
|
1680
|
-
savedIndexTree = await git(cwd, ["write-tree"], GIT_SLOW_TIMEOUT_MS);
|
|
1681
|
-
} catch {
|
|
1682
|
-
return null;
|
|
1683
|
-
}
|
|
1684
|
-
try {
|
|
1685
|
-
await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
|
|
1686
|
-
const sha = await git(cwd, ["stash", "create", message], GIT_SLOW_TIMEOUT_MS);
|
|
1687
|
-
return sha || null;
|
|
1688
|
-
} catch {
|
|
1689
|
-
return null;
|
|
1690
|
-
} finally {
|
|
1691
|
-
try {
|
|
1692
|
-
await git(cwd, ["read-tree", savedIndexTree], GIT_SLOW_TIMEOUT_MS);
|
|
1693
|
-
} catch {
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
async function tryPushRefspec(cwd, refspec, force = false) {
|
|
1698
|
-
try {
|
|
1699
|
-
const forceArgs = force ? ["--force"] : [];
|
|
1700
|
-
await git(cwd, ["push", "--no-verify", ...forceArgs, "origin", refspec], 3e4);
|
|
1701
|
-
return true;
|
|
1702
|
-
} catch {
|
|
1703
|
-
return false;
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
async function refreshRemoteToken(cwd, refreshToken) {
|
|
1707
|
-
if (!refreshToken) return;
|
|
1708
|
-
try {
|
|
1709
|
-
const token = await refreshToken();
|
|
1710
|
-
if (token) {
|
|
1711
|
-
await updateRemoteToken(cwd, token);
|
|
1712
|
-
process.env.GITHUB_TOKEN = token;
|
|
1713
|
-
process.env.GH_TOKEN = token;
|
|
1714
|
-
}
|
|
1715
|
-
} catch {
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
async function restoreWipSnapshot(cwd, branch) {
|
|
1719
|
-
if (!branch) return "none";
|
|
1720
|
-
const ref = wipRefForBranch(branch);
|
|
1721
|
-
try {
|
|
1722
|
-
await git(cwd, ["fetch", "origin", `+refs/heads/${ref}:refs/remotes/origin/${ref}`]);
|
|
1723
|
-
} catch (err) {
|
|
1724
|
-
if (isMissingRefError(err)) return "none";
|
|
1725
|
-
wipRefPreserved.add(cwd);
|
|
1726
|
-
return "failed";
|
|
1727
|
-
}
|
|
1728
|
-
try {
|
|
1729
|
-
const sha = await git(cwd, ["rev-parse", `refs/remotes/origin/${ref}`]);
|
|
1730
|
-
const parent = await git(cwd, ["rev-parse", `${sha}^`]);
|
|
1731
|
-
const head = await git(cwd, ["rev-parse", "HEAD"]);
|
|
1732
|
-
if (parent !== head) {
|
|
1733
|
-
try {
|
|
1734
|
-
await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
|
|
1735
|
-
wipRefPushed.add(cwd);
|
|
1736
|
-
wipRefPreserved.delete(cwd);
|
|
1737
|
-
return "applied";
|
|
1738
|
-
} catch {
|
|
1739
|
-
try {
|
|
1740
|
-
await git(cwd, ["reset", "--merge"], GIT_SLOW_TIMEOUT_MS);
|
|
1741
|
-
} catch {
|
|
1742
|
-
}
|
|
1743
|
-
wipRefPreserved.add(cwd);
|
|
1744
|
-
return "stale";
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
|
|
1748
|
-
wipRefPushed.add(cwd);
|
|
1749
|
-
wipRefPreserved.delete(cwd);
|
|
1750
|
-
return "applied";
|
|
1751
|
-
} catch {
|
|
1752
|
-
wipRefPreserved.add(cwd);
|
|
1753
|
-
return "failed";
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
function isMissingRefError(err) {
|
|
1757
|
-
const stderr = err.stderr?.toString() ?? "";
|
|
1758
|
-
const msg = stderr || (err instanceof Error ? err.message : String(err));
|
|
1759
|
-
return /couldn't find remote ref|couldn't find remote|no such ref|not our ref/i.test(msg);
|
|
1760
|
-
}
|
|
1761
|
-
async function flushPendingChanges(cwd, opts) {
|
|
1762
|
-
let committed = false;
|
|
1763
|
-
let pushed = false;
|
|
1764
|
-
let hadWork = false;
|
|
1765
|
-
try {
|
|
1766
|
-
const branch = await getCurrentBranch(cwd);
|
|
1767
|
-
if (!branch) return { committed, pushed, hadWork };
|
|
1768
|
-
const dirty = await hasUncommittedChanges(cwd);
|
|
1769
|
-
const unpushed = await hasUnpushedCommits(cwd);
|
|
1770
|
-
if (!dirty && !unpushed) {
|
|
1771
|
-
await dropStaleWipRef(cwd, branch, opts?.refreshToken);
|
|
1772
|
-
return { committed, pushed, hadWork };
|
|
1773
|
-
}
|
|
1774
|
-
hadWork = true;
|
|
1775
|
-
await refreshRemoteToken(cwd, opts?.refreshToken);
|
|
1776
|
-
if (unpushed) {
|
|
1777
|
-
pushed = await pushToOrigin(cwd, opts?.refreshToken);
|
|
1778
|
-
}
|
|
1779
|
-
if (dirty && !wipRefPreserved.has(cwd)) {
|
|
1780
|
-
const message = opts?.wipMessage ?? "WIP: conveyor-agent snapshot";
|
|
1781
|
-
const sha = await createWipSnapshot(cwd, message);
|
|
1782
|
-
if (sha) {
|
|
1783
|
-
committed = await tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
|
|
1784
|
-
if (committed) wipRefPushed.add(cwd);
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
} catch {
|
|
1788
|
-
}
|
|
1789
|
-
return { committed, pushed, hadWork };
|
|
1790
|
-
}
|
|
1791
|
-
async function dropStaleWipRef(cwd, branch, refreshToken) {
|
|
1792
|
-
if (wipRefPreserved.has(cwd) || !wipRefPushed.has(cwd)) return;
|
|
1793
|
-
await refreshRemoteToken(cwd, refreshToken);
|
|
1794
|
-
if (await tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
|
|
1795
|
-
wipRefPushed.delete(cwd);
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
|
|
1799
|
-
try {
|
|
1800
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
1801
|
-
if (!currentBranch) return false;
|
|
1802
|
-
if (refreshToken) {
|
|
1803
|
-
try {
|
|
1804
|
-
const token = await refreshToken();
|
|
1805
|
-
if (token) {
|
|
1806
|
-
await updateRemoteToken(cwd, token);
|
|
1807
|
-
process.env.GITHUB_TOKEN = token;
|
|
1808
|
-
process.env.GH_TOKEN = token;
|
|
1809
|
-
}
|
|
1810
|
-
} catch {
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
if (await tryPush(cwd, currentBranch, skipVerify)) {
|
|
1814
|
-
clearForceFreshCooldown();
|
|
1815
|
-
return true;
|
|
1816
|
-
}
|
|
1817
|
-
if (refreshToken && !forceFreshMintBlocked() && await isAuthError(cwd)) {
|
|
1818
|
-
const token = await refreshToken({ forceFresh: true });
|
|
1819
|
-
if (token) {
|
|
1820
|
-
await updateRemoteToken(cwd, token);
|
|
1821
|
-
process.env.GITHUB_TOKEN = token;
|
|
1822
|
-
process.env.GH_TOKEN = token;
|
|
1823
|
-
const pushed = await tryPush(cwd, currentBranch, skipVerify);
|
|
1824
|
-
if (pushed) clearForceFreshCooldown();
|
|
1825
|
-
else recordForceFreshFailure();
|
|
1826
|
-
return pushed;
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
return false;
|
|
1830
|
-
} catch {
|
|
1831
|
-
return false;
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
function branchBackupRef(branch) {
|
|
1835
|
-
return `conveyor-wip/branches/${branch}`;
|
|
1836
|
-
}
|
|
1837
|
-
async function listWorktrees(cwd) {
|
|
1838
|
-
try {
|
|
1839
|
-
const out = await git(cwd, ["worktree", "list", "--porcelain"]);
|
|
1840
|
-
const result = [];
|
|
1841
|
-
for (const entry of out.split("\n\n")) {
|
|
1842
|
-
const lines = entry.trim().split("\n");
|
|
1843
|
-
const wl = lines.find((l) => l.startsWith("worktree "));
|
|
1844
|
-
if (!wl) continue;
|
|
1845
|
-
const bl = lines.find((l) => l.startsWith("branch "));
|
|
1846
|
-
result.push({
|
|
1847
|
-
path: wl.slice("worktree ".length),
|
|
1848
|
-
branch: bl ? bl.slice("branch refs/heads/".length) : null
|
|
1849
|
-
});
|
|
1850
|
-
}
|
|
1851
|
-
return result;
|
|
1852
|
-
} catch {
|
|
1853
|
-
return [];
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
async function listLocalBranches(cwd) {
|
|
1857
|
-
try {
|
|
1858
|
-
const out = await git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]);
|
|
1859
|
-
return out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1860
|
-
} catch {
|
|
1861
|
-
return [];
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
async function branchUnpushedCount(cwd, branch) {
|
|
1865
|
-
try {
|
|
1866
|
-
const n = await git(cwd, ["rev-list", "--count", branch, "--not", "--remotes=origin"]);
|
|
1867
|
-
return Number.parseInt(n, 10) || 0;
|
|
1868
|
-
} catch {
|
|
1869
|
-
return 0;
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
function samePath(a, b) {
|
|
1873
|
-
try {
|
|
1874
|
-
return realpathSync(a) === realpathSync(b);
|
|
1875
|
-
} catch {
|
|
1876
|
-
return a === b;
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
async function flushAllPendingWork(cwd, opts) {
|
|
1880
|
-
try {
|
|
1881
|
-
const primary = await flushPendingChanges(cwd, opts);
|
|
1882
|
-
await refreshRemoteToken(cwd, opts?.refreshToken);
|
|
1883
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
1884
|
-
const worktreesSnapshotted = await snapshotOtherWorktrees(cwd, opts?.wipMessage);
|
|
1885
|
-
const branchesBackedUp = await backupOtherBranches(cwd, currentBranch);
|
|
1886
|
-
return {
|
|
1887
|
-
hadWork: primary.hadWork || worktreesSnapshotted > 0 || branchesBackedUp > 0,
|
|
1888
|
-
branchesBackedUp,
|
|
1889
|
-
worktreesSnapshotted
|
|
1890
|
-
};
|
|
1891
|
-
} catch {
|
|
1892
|
-
return { hadWork: false, branchesBackedUp: 0, worktreesSnapshotted: 0 };
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
async function snapshotOtherWorktrees(cwd, wipMessage) {
|
|
1896
|
-
let count = 0;
|
|
1897
|
-
for (const wt of await listWorktrees(cwd)) {
|
|
1898
|
-
if (samePath(wt.path, cwd) || !wt.branch) continue;
|
|
1899
|
-
try {
|
|
1900
|
-
if (!await hasUncommittedChanges(wt.path)) continue;
|
|
1901
|
-
const sha = await createWipSnapshot(wt.path, wipMessage ?? "WIP: conveyor-agent snapshot");
|
|
1902
|
-
if (sha && await tryPushRefspec(wt.path, `${sha}:refs/heads/${wipRefForBranch(wt.branch)}`, true)) {
|
|
1903
|
-
wipRefPushed.add(wt.path);
|
|
1904
|
-
count++;
|
|
1905
|
-
}
|
|
1906
|
-
} catch {
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
return count;
|
|
1910
|
-
}
|
|
1911
|
-
async function backupOtherBranches(cwd, currentBranch) {
|
|
1912
|
-
let count = 0;
|
|
1913
|
-
for (const branch of await listLocalBranches(cwd)) {
|
|
1914
|
-
if (branch === currentBranch || branch.startsWith("conveyor-wip/")) continue;
|
|
1915
|
-
try {
|
|
1916
|
-
if (await branchUnpushedCount(cwd, branch) === 0) continue;
|
|
1917
|
-
if (await tryPushRefspec(
|
|
1918
|
-
cwd,
|
|
1919
|
-
`refs/heads/${branch}:refs/heads/${branchBackupRef(branch)}`,
|
|
1920
|
-
true
|
|
1921
|
-
)) {
|
|
1922
|
-
count++;
|
|
1923
|
-
}
|
|
1924
|
-
} catch {
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
return count;
|
|
3
|
+
readAgentVersion
|
|
4
|
+
} from "./chunk-XORJ6SII.js";
|
|
5
|
+
import {
|
|
6
|
+
AgentConnection,
|
|
7
|
+
CodespacePortVisibility,
|
|
8
|
+
DEFAULT_LIFECYCLE_CONFIG,
|
|
9
|
+
Lifecycle,
|
|
10
|
+
PortDiscovery,
|
|
11
|
+
awaitGitReady,
|
|
12
|
+
clearForceFreshCooldown,
|
|
13
|
+
createServiceLogger,
|
|
14
|
+
ensureOnTaskBranch,
|
|
15
|
+
flushAllPendingWork,
|
|
16
|
+
flushPendingChanges,
|
|
17
|
+
forceFreshCooldownNotice,
|
|
18
|
+
forceFreshMintBlocked,
|
|
19
|
+
getCurrentBranch,
|
|
20
|
+
hasUncommittedChanges,
|
|
21
|
+
hasUnpushedCommits,
|
|
22
|
+
pushToOrigin,
|
|
23
|
+
readWorkspaceBytes,
|
|
24
|
+
readWorkspaceDir,
|
|
25
|
+
readWorkspaceFile,
|
|
26
|
+
recordForceFreshFailure,
|
|
27
|
+
remoteMatchesLocalHead,
|
|
28
|
+
restoreWipSnapshot,
|
|
29
|
+
stageAndCommit,
|
|
30
|
+
statWorkspacePath,
|
|
31
|
+
updateRemoteToken,
|
|
32
|
+
verifyGitCredential
|
|
33
|
+
} from "./chunk-LSZ2KLJY.js";
|
|
34
|
+
import {
|
|
35
|
+
registerBootMilestoneSocketFallback,
|
|
36
|
+
reportBootMilestone
|
|
37
|
+
} from "./chunk-WMMBAKPE.js";
|
|
38
|
+
import {
|
|
39
|
+
LoopLagMonitor,
|
|
40
|
+
loopStatusForRunnerStatus
|
|
41
|
+
} from "./chunk-IA45XHOA.js";
|
|
42
|
+
import {
|
|
43
|
+
getWorkbenchClient
|
|
44
|
+
} from "./chunk-EXQ6AHOY.js";
|
|
45
|
+
import {
|
|
46
|
+
workbenchEnabled
|
|
47
|
+
} from "./chunk-KMB3BU4S.js";
|
|
48
|
+
import {
|
|
49
|
+
MAX_BETWEEN_TURN_BUFFER,
|
|
50
|
+
MAX_DIAGNOSTIC_OUTPUT,
|
|
51
|
+
buildExitErrors,
|
|
52
|
+
buildPromptBytes,
|
|
53
|
+
buildSpawnArgs,
|
|
54
|
+
cleanTerminalOutput,
|
|
55
|
+
inheritedEnv,
|
|
56
|
+
killPtyWithEscalation,
|
|
57
|
+
needsRawReadyGate,
|
|
58
|
+
parseUserQuestions,
|
|
59
|
+
renderPromptContentText,
|
|
60
|
+
resolveClaudeBinary,
|
|
61
|
+
resolvePlanDialogTiming,
|
|
62
|
+
resolvePtySpawn,
|
|
63
|
+
resolveRawTuiProbeTiming,
|
|
64
|
+
resolveSubmitNudgeTiming,
|
|
65
|
+
resolveSubmitRedeliveryMaxAttempts,
|
|
66
|
+
resolveSubmitSettleMs,
|
|
67
|
+
sawTerminalSetup,
|
|
68
|
+
sentinelEchoed,
|
|
69
|
+
sessionTempBase,
|
|
70
|
+
spawnOptionsFingerprint,
|
|
71
|
+
transcriptSize,
|
|
72
|
+
turnOptionsFrom
|
|
73
|
+
} from "./chunk-3F4ZZKCA.js";
|
|
74
|
+
import {
|
|
75
|
+
describeTokenFile,
|
|
76
|
+
ghHostsExternallyOwned,
|
|
77
|
+
githubTokenFilePath,
|
|
78
|
+
sleep
|
|
79
|
+
} from "./chunk-W4LZ7R6Z.js";
|
|
80
|
+
|
|
81
|
+
// src/connection/auth-errors.ts
|
|
82
|
+
function isPermissionDeniedError(err) {
|
|
83
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
84
|
+
return /insufficient permissions|authentication required/i.test(message);
|
|
1928
85
|
}
|
|
1929
86
|
|
|
1930
87
|
// ../shared/dist/chunk-6RHVH33O.js
|
|
@@ -2041,7 +198,8 @@ var projectCheckpointSettingsSchema = z.object({
|
|
|
2041
198
|
finalizeCommand: z.string().trim().min(1),
|
|
2042
199
|
credentialEpoch: z.string().trim().min(1),
|
|
2043
200
|
requiredSecretNames: uniqueSortedArray(secretNameSchema).optional(),
|
|
2044
|
-
optionalSecretNames: uniqueSortedArray(secretNameSchema).optional()
|
|
201
|
+
optionalSecretNames: uniqueSortedArray(secretNameSchema).optional(),
|
|
202
|
+
bakeWebAppBuild: z.boolean().optional()
|
|
2045
203
|
}).superRefine((checkpoint, ctx) => {
|
|
2046
204
|
const required = new Set(checkpoint.requiredSecretNames ?? []);
|
|
2047
205
|
for (const name of checkpoint.optionalSecretNames ?? []) {
|
|
@@ -2291,7 +449,15 @@ var PostToChatInputSchema = z4.object({
|
|
|
2291
449
|
});
|
|
2292
450
|
var GetTaskContextRequestSchema = z4.object({
|
|
2293
451
|
sessionId: z4.string(),
|
|
2294
|
-
includeHistory: z4.boolean().optional().default(false)
|
|
452
|
+
includeHistory: z4.boolean().optional().default(false),
|
|
453
|
+
/**
|
|
454
|
+
* Read the plan-revised marker WITHOUT consuming it. Bookkeeping fetches
|
|
455
|
+
* (the session-identity check, the branch refresh) pass true so they cannot
|
|
456
|
+
* race the boot fetch and swallow the notice before it reaches the prompt.
|
|
457
|
+
* Defaults to false — consuming — so a pod running an older agent build still
|
|
458
|
+
* clears the marker instead of showing the notice on every boot forever.
|
|
459
|
+
*/
|
|
460
|
+
peekPlanRevision: z4.boolean().optional().default(false)
|
|
2295
461
|
});
|
|
2296
462
|
var GetChatMessagesRequestSchema = z4.object({
|
|
2297
463
|
sessionId: z4.string(),
|
|
@@ -3856,6 +2022,17 @@ var PRE_BUILD_TASK_STATUSES = /* @__PURE__ */ new Set(["Planning", "Open"]);
|
|
|
3856
2022
|
function hasTaskPlan(plan) {
|
|
3857
2023
|
return !!plan?.trim();
|
|
3858
2024
|
}
|
|
2025
|
+
var CARD_TYPE_SURFACE = {
|
|
2026
|
+
task: "board",
|
|
2027
|
+
chat: "board",
|
|
2028
|
+
incident: "report",
|
|
2029
|
+
suggestion: "report"
|
|
2030
|
+
};
|
|
2031
|
+
var surfaceTypes = (surface) => Object.keys(CARD_TYPE_SURFACE).filter(
|
|
2032
|
+
(type) => CARD_TYPE_SURFACE[type] === surface
|
|
2033
|
+
);
|
|
2034
|
+
var BOARD_CARD_TYPES = surfaceTypes("board");
|
|
2035
|
+
var REPORT_CARD_TYPES = surfaceTypes("report");
|
|
3859
2036
|
|
|
3860
2037
|
// src/runner/mode-controller.ts
|
|
3861
2038
|
var ModeController = class {
|
|
@@ -4007,141 +2184,6 @@ var ModeController = class {
|
|
|
4007
2184
|
}
|
|
4008
2185
|
};
|
|
4009
2186
|
|
|
4010
|
-
// src/runner/lifecycle.ts
|
|
4011
|
-
var DEFAULT_LIFECYCLE_CONFIG = {
|
|
4012
|
-
idleTimeoutMs: 30 * 60 * 1e3,
|
|
4013
|
-
dormantTimeoutMs: 60 * 60 * 1e3,
|
|
4014
|
-
heartbeatIntervalMs: 3e4,
|
|
4015
|
-
tokenRefreshIntervalMs: 45 * 60 * 1e3,
|
|
4016
|
-
gitFlushIntervalMs: 2 * 60 * 1e3,
|
|
4017
|
-
usageSampleIntervalMs: 5 * 60 * 1e3,
|
|
4018
|
-
usageSampleInitialDelayMs: 3e4
|
|
4019
|
-
};
|
|
4020
|
-
var Lifecycle = class {
|
|
4021
|
-
config;
|
|
4022
|
-
callbacks;
|
|
4023
|
-
heartbeatTimer = null;
|
|
4024
|
-
tokenRefreshTimer = null;
|
|
4025
|
-
idleTimer = null;
|
|
4026
|
-
idleCheckInterval = null;
|
|
4027
|
-
dormantTimer = null;
|
|
4028
|
-
gitFlushTimer = null;
|
|
4029
|
-
usageSampleTimer = null;
|
|
4030
|
-
constructor(config, callbacks) {
|
|
4031
|
-
this.config = config;
|
|
4032
|
-
this.callbacks = callbacks;
|
|
4033
|
-
}
|
|
4034
|
-
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
4035
|
-
startHeartbeat() {
|
|
4036
|
-
this.stopHeartbeat();
|
|
4037
|
-
this.heartbeatTimer = setInterval(() => {
|
|
4038
|
-
this.callbacks.onHeartbeat();
|
|
4039
|
-
}, this.config.heartbeatIntervalMs);
|
|
4040
|
-
}
|
|
4041
|
-
stopHeartbeat() {
|
|
4042
|
-
if (this.heartbeatTimer) {
|
|
4043
|
-
clearInterval(this.heartbeatTimer);
|
|
4044
|
-
this.heartbeatTimer = null;
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
// ── Token refresh ─────────────────────────────────────────────────
|
|
4048
|
-
startTokenRefresh() {
|
|
4049
|
-
this.stopTokenRefresh();
|
|
4050
|
-
this.callbacks.onTokenRefresh();
|
|
4051
|
-
this.tokenRefreshTimer = setInterval(() => {
|
|
4052
|
-
this.callbacks.onTokenRefresh();
|
|
4053
|
-
}, this.config.tokenRefreshIntervalMs);
|
|
4054
|
-
}
|
|
4055
|
-
stopTokenRefresh() {
|
|
4056
|
-
if (this.tokenRefreshTimer) {
|
|
4057
|
-
clearInterval(this.tokenRefreshTimer);
|
|
4058
|
-
this.tokenRefreshTimer = null;
|
|
4059
|
-
}
|
|
4060
|
-
}
|
|
4061
|
-
// ── Periodic git flush ────────────────────────────────────────────
|
|
4062
|
-
startGitFlush() {
|
|
4063
|
-
this.stopGitFlush();
|
|
4064
|
-
if (this.config.gitFlushIntervalMs <= 0) return;
|
|
4065
|
-
this.gitFlushTimer = setInterval(() => {
|
|
4066
|
-
this.callbacks.onGitFlush();
|
|
4067
|
-
}, this.config.gitFlushIntervalMs);
|
|
4068
|
-
}
|
|
4069
|
-
stopGitFlush() {
|
|
4070
|
-
if (this.gitFlushTimer) {
|
|
4071
|
-
clearInterval(this.gitFlushTimer);
|
|
4072
|
-
this.gitFlushTimer = null;
|
|
4073
|
-
}
|
|
4074
|
-
}
|
|
4075
|
-
// ── Claude key usage sampling ─────────────────────────────────────
|
|
4076
|
-
startUsageSample() {
|
|
4077
|
-
this.stopUsageSample();
|
|
4078
|
-
if (this.config.usageSampleIntervalMs <= 0) return;
|
|
4079
|
-
this.usageSampleTimer = setTimeout(() => {
|
|
4080
|
-
this.callbacks.onUsageSample();
|
|
4081
|
-
this.usageSampleTimer = setInterval(() => {
|
|
4082
|
-
this.callbacks.onUsageSample();
|
|
4083
|
-
}, this.config.usageSampleIntervalMs);
|
|
4084
|
-
}, this.config.usageSampleInitialDelayMs);
|
|
4085
|
-
}
|
|
4086
|
-
stopUsageSample() {
|
|
4087
|
-
if (this.usageSampleTimer) {
|
|
4088
|
-
clearInterval(this.usageSampleTimer);
|
|
4089
|
-
this.usageSampleTimer = null;
|
|
4090
|
-
}
|
|
4091
|
-
}
|
|
4092
|
-
// ── Idle timer ─────────────────────────────────────────────────────
|
|
4093
|
-
startIdleTimer() {
|
|
4094
|
-
this.clearIdleTimers();
|
|
4095
|
-
this.idleTimer = setTimeout(() => {
|
|
4096
|
-
this.callbacks.onIdleTimeout();
|
|
4097
|
-
}, this.config.idleTimeoutMs);
|
|
4098
|
-
}
|
|
4099
|
-
cancelIdleTimer() {
|
|
4100
|
-
this.clearIdleTimers();
|
|
4101
|
-
}
|
|
4102
|
-
// ── Dormant timer ──────────────────────────────────────────────────
|
|
4103
|
-
/** Start (or restart) the dormant timer.
|
|
4104
|
-
* @param overrideMs Optional custom delay in ms. When provided, the timer
|
|
4105
|
-
* fires after exactly that delay instead of `dormantTimeoutMs`. SessionRunner
|
|
4106
|
-
* uses this to enforce an *absolute* deadline across cycles: even if the
|
|
4107
|
-
* dormant wait is interrupted by an inbound message, the next iteration
|
|
4108
|
-
* passes the remaining time, so the agent shuts down at the original
|
|
4109
|
-
* deadline regardless of message volume. */
|
|
4110
|
-
startDormantTimer(overrideMs) {
|
|
4111
|
-
this.cancelDormantTimer();
|
|
4112
|
-
const delay2 = Math.max(0, overrideMs ?? this.config.dormantTimeoutMs);
|
|
4113
|
-
this.dormantTimer = setTimeout(() => {
|
|
4114
|
-
this.callbacks.onDormantTimeout();
|
|
4115
|
-
}, delay2);
|
|
4116
|
-
}
|
|
4117
|
-
cancelDormantTimer() {
|
|
4118
|
-
if (this.dormantTimer) {
|
|
4119
|
-
clearTimeout(this.dormantTimer);
|
|
4120
|
-
this.dormantTimer = null;
|
|
4121
|
-
}
|
|
4122
|
-
}
|
|
4123
|
-
// ── Cleanup ────────────────────────────────────────────────────────
|
|
4124
|
-
destroy() {
|
|
4125
|
-
this.stopHeartbeat();
|
|
4126
|
-
this.stopTokenRefresh();
|
|
4127
|
-
this.stopGitFlush();
|
|
4128
|
-
this.stopUsageSample();
|
|
4129
|
-
this.clearIdleTimers();
|
|
4130
|
-
this.cancelDormantTimer();
|
|
4131
|
-
}
|
|
4132
|
-
// ── Private ────────────────────────────────────────────────────────
|
|
4133
|
-
clearIdleTimers() {
|
|
4134
|
-
if (this.idleTimer) {
|
|
4135
|
-
clearTimeout(this.idleTimer);
|
|
4136
|
-
this.idleTimer = null;
|
|
4137
|
-
}
|
|
4138
|
-
if (this.idleCheckInterval) {
|
|
4139
|
-
clearInterval(this.idleCheckInterval);
|
|
4140
|
-
this.idleCheckInterval = null;
|
|
4141
|
-
}
|
|
4142
|
-
}
|
|
4143
|
-
};
|
|
4144
|
-
|
|
4145
2187
|
// src/harness/types.ts
|
|
4146
2188
|
function isExternalMcpStdioServer(value) {
|
|
4147
2189
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -5813,11 +3855,11 @@ var READ_BACK_DELAYS_MS = [250, 500, 1e3, 2e3];
|
|
|
5813
3855
|
var defaultSleep = (ms) => new Promise((resolve) => {
|
|
5814
3856
|
setTimeout(resolve, ms);
|
|
5815
3857
|
});
|
|
5816
|
-
async function writeWithReadBackRetry(
|
|
5817
|
-
const sleep2 =
|
|
3858
|
+
async function writeWithReadBackRetry(io, contents, delaysMs = READ_BACK_DELAYS_MS) {
|
|
3859
|
+
const sleep2 = io.sleep ?? defaultSleep;
|
|
5818
3860
|
for (let attempt = 0; ; attempt++) {
|
|
5819
|
-
await
|
|
5820
|
-
if (await
|
|
3861
|
+
await io.write(contents);
|
|
3862
|
+
if (await io.read() === contents) return true;
|
|
5821
3863
|
if (attempt >= delaysMs.length) return false;
|
|
5822
3864
|
await sleep2(delaysMs[attempt]);
|
|
5823
3865
|
}
|
|
@@ -7599,7 +5641,7 @@ function findOnPath(binary, env = process.env) {
|
|
|
7599
5641
|
import { promises as fs } from "fs";
|
|
7600
5642
|
import { dirname as dirname2, join as join9 } from "path";
|
|
7601
5643
|
import { homedir as homedir4 } from "os";
|
|
7602
|
-
var
|
|
5644
|
+
var logger = createServiceLogger("opencode-auth");
|
|
7603
5645
|
var OPENCODE_CODEX_PLUGIN = "opencode-openai-codex-auth@4.4.0";
|
|
7604
5646
|
var PLUGIN_PACKAGE = "opencode-openai-codex-auth";
|
|
7605
5647
|
function opencodeAuthPath(env) {
|
|
@@ -7646,7 +5688,7 @@ async function ensureAuthEntry(env, seed) {
|
|
|
7646
5688
|
const path2 = opencodeAuthPath(env);
|
|
7647
5689
|
const store = await readJsonFile(path2);
|
|
7648
5690
|
if (!shouldSeed(store.openai, seed)) {
|
|
7649
|
-
|
|
5691
|
+
logger.info("opencode oauth store is fresher than the seed; leaving it alone");
|
|
7650
5692
|
return;
|
|
7651
5693
|
}
|
|
7652
5694
|
store.openai = {
|
|
@@ -7656,7 +5698,7 @@ async function ensureAuthEntry(env, seed) {
|
|
|
7656
5698
|
expires: seed.expires
|
|
7657
5699
|
};
|
|
7658
5700
|
await writeJsonFile(path2, store);
|
|
7659
|
-
|
|
5701
|
+
logger.info("seeded opencode oauth store entry");
|
|
7660
5702
|
}
|
|
7661
5703
|
async function ensurePluginConfig(env) {
|
|
7662
5704
|
const path2 = opencodeConfigPath(env);
|
|
@@ -7669,7 +5711,7 @@ async function ensurePluginConfig(env) {
|
|
|
7669
5711
|
const kept = plugins.filter((p) => !isOurs(p));
|
|
7670
5712
|
config.plugin = [...kept, OPENCODE_CODEX_PLUGIN];
|
|
7671
5713
|
await writeJsonFile(path2, config);
|
|
7672
|
-
|
|
5714
|
+
logger.info("ensured opencode codex-auth plugin in config");
|
|
7673
5715
|
}
|
|
7674
5716
|
async function seedOpenCodeOauth(env) {
|
|
7675
5717
|
const seed = parseOauthSeed(env.CONVEYOR_OPENCODE_OAUTH);
|
|
@@ -7678,7 +5720,7 @@ async function seedOpenCodeOauth(env) {
|
|
|
7678
5720
|
await ensureAuthEntry(env, seed);
|
|
7679
5721
|
await ensurePluginConfig(env);
|
|
7680
5722
|
} catch (err) {
|
|
7681
|
-
|
|
5723
|
+
logger.warn(
|
|
7682
5724
|
`failed to seed opencode oauth store: ${err instanceof Error ? err.message : String(err)}`
|
|
7683
5725
|
);
|
|
7684
5726
|
}
|
|
@@ -8047,14 +6089,14 @@ function resolveTuiAdapter(kind = "claude-code") {
|
|
|
8047
6089
|
|
|
8048
6090
|
// src/harness/pty/stream-server.ts
|
|
8049
6091
|
import net from "net";
|
|
8050
|
-
var
|
|
6092
|
+
var logger2 = createServiceLogger("PtyStreamServer");
|
|
8051
6093
|
var RING_MAX_CHARS = 256 * 1024;
|
|
8052
6094
|
var PtyStreamServer = class {
|
|
8053
6095
|
constructor(options) {
|
|
8054
6096
|
this.options = options;
|
|
8055
6097
|
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
8056
6098
|
this.server.on("error", (err) => {
|
|
8057
|
-
|
|
6099
|
+
logger2.warn(`PTY stream server error: ${err.message}`);
|
|
8058
6100
|
});
|
|
8059
6101
|
}
|
|
8060
6102
|
options;
|
|
@@ -8095,7 +6137,7 @@ var PtyStreamServer = class {
|
|
|
8095
6137
|
return port;
|
|
8096
6138
|
}
|
|
8097
6139
|
}
|
|
8098
|
-
|
|
6140
|
+
logger2.warn(`PTY stream server could not bind any port in ${base}..${base + attempts - 1}`);
|
|
8099
6141
|
return null;
|
|
8100
6142
|
}
|
|
8101
6143
|
tryListen(port) {
|
|
@@ -8216,7 +6258,7 @@ var PtyStreamServer = class {
|
|
|
8216
6258
|
};
|
|
8217
6259
|
|
|
8218
6260
|
// src/harness/pty/direct-stream.ts
|
|
8219
|
-
var
|
|
6261
|
+
var logger3 = createServiceLogger("PtyDirectStream");
|
|
8220
6262
|
var RELAY_COALESCE_MS = 2e3;
|
|
8221
6263
|
var RELAY_MAX_BUFFER_CHARS = 48 * 1024;
|
|
8222
6264
|
var DirectStreamController = class {
|
|
@@ -8265,7 +6307,7 @@ var DirectStreamController = class {
|
|
|
8265
6307
|
});
|
|
8266
6308
|
void created.listen().then((port) => this.onListening(created, port)).catch((err) => {
|
|
8267
6309
|
this.starting = false;
|
|
8268
|
-
|
|
6310
|
+
logger3.warn(
|
|
8269
6311
|
`PTY stream server failed to start: ${err instanceof Error ? err.message : String(err)}`
|
|
8270
6312
|
);
|
|
8271
6313
|
});
|
|
@@ -8278,7 +6320,7 @@ var DirectStreamController = class {
|
|
|
8278
6320
|
}
|
|
8279
6321
|
this.server = created;
|
|
8280
6322
|
this.reporter.reportPtyStream(port);
|
|
8281
|
-
|
|
6323
|
+
logger3.info(`PTY stream server listening on ${port} (session ${this.reporter.sessionId})`);
|
|
8282
6324
|
}
|
|
8283
6325
|
/** Push the min box across both transports to the pty. */
|
|
8284
6326
|
applyDims() {
|
|
@@ -8355,7 +6397,7 @@ function wrapBridgeWithDirectStream(inner, reporter, options = {}) {
|
|
|
8355
6397
|
|
|
8356
6398
|
// src/execution/query-executor.ts
|
|
8357
6399
|
import { createHash as createHash2 } from "crypto";
|
|
8358
|
-
import { existsSync
|
|
6400
|
+
import { existsSync, readFileSync as readFileSync3, truncateSync } from "fs";
|
|
8359
6401
|
|
|
8360
6402
|
// src/execution/chat-instructions.ts
|
|
8361
6403
|
function buildChatInstructions(context, scenario, newMessages) {
|
|
@@ -8615,6 +6657,36 @@ After addressing the feedback, resume your autonomous loop: call list_subtasks a
|
|
|
8615
6657
|
return parts;
|
|
8616
6658
|
}
|
|
8617
6659
|
|
|
6660
|
+
// src/execution/plan-revision-notice.ts
|
|
6661
|
+
var MINUTE_MS = 60 * 1e3;
|
|
6662
|
+
var HOUR_MS = 60 * MINUTE_MS;
|
|
6663
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
6664
|
+
function describeAge(revisedAt, now) {
|
|
6665
|
+
const at = Date.parse(revisedAt);
|
|
6666
|
+
if (Number.isNaN(at)) return "";
|
|
6667
|
+
const ago = now - at;
|
|
6668
|
+
if (ago < MINUTE_MS) return "just now";
|
|
6669
|
+
if (ago < HOUR_MS) return `${Math.round(ago / MINUTE_MS)} minutes ago`;
|
|
6670
|
+
if (ago < DAY_MS) return `${Math.round(ago / HOUR_MS)} hours ago`;
|
|
6671
|
+
return `${Math.round(ago / DAY_MS)} days ago`;
|
|
6672
|
+
}
|
|
6673
|
+
function buildPlanRevisionNotice(context, now = Date.now()) {
|
|
6674
|
+
const revisedAt = context.planRevisedAt;
|
|
6675
|
+
if (!revisedAt) return [];
|
|
6676
|
+
const age = describeAge(revisedAt, now);
|
|
6677
|
+
const when = age ? `${age} (${revisedAt})` : revisedAt;
|
|
6678
|
+
return [
|
|
6679
|
+
`
|
|
6680
|
+
## \u26A0\uFE0F The plan changed since this build was dispatched`,
|
|
6681
|
+
`Someone revised this card's plan ${when}. It was NOT you \u2014 an agent is never told about its own plan edit.`,
|
|
6682
|
+
`Before your next Write or Edit:`,
|
|
6683
|
+
`1. Call \`get_current_plan\` and read the current plan in full.`,
|
|
6684
|
+
`2. Compare it against the plan you were launched with. Treat the current plan as the truth.`,
|
|
6685
|
+
`3. Drop or redo any work the revision supersedes. Do NOT keep building the old plan.`,
|
|
6686
|
+
`If the revision invalidates work you already committed, say so with post_to_chat before you continue.`
|
|
6687
|
+
];
|
|
6688
|
+
}
|
|
6689
|
+
|
|
8618
6690
|
// src/execution/prompt-formatters.ts
|
|
8619
6691
|
function baseDiffCommand(baseBranch, flags) {
|
|
8620
6692
|
const base = baseBranch ?? "dev";
|
|
@@ -8780,45 +6852,6 @@ function formatIncidents(incidents) {
|
|
|
8780
6852
|
return parts;
|
|
8781
6853
|
}
|
|
8782
6854
|
|
|
8783
|
-
// src/workbench/fs.ts
|
|
8784
|
-
import {
|
|
8785
|
-
readFile as localReadFile,
|
|
8786
|
-
readdir as localReaddir,
|
|
8787
|
-
stat as localStat
|
|
8788
|
-
} from "fs/promises";
|
|
8789
|
-
async function readWorkspaceFile(path2) {
|
|
8790
|
-
if (workbenchEnabled()) {
|
|
8791
|
-
return (await getWorkbenchClient().readFile(path2)).toString("utf8");
|
|
8792
|
-
}
|
|
8793
|
-
return localReadFile(path2, "utf-8");
|
|
8794
|
-
}
|
|
8795
|
-
function readWorkspaceBytes(path2) {
|
|
8796
|
-
if (workbenchEnabled()) return getWorkbenchClient().readFile(path2);
|
|
8797
|
-
return localReadFile(path2);
|
|
8798
|
-
}
|
|
8799
|
-
function readWorkspaceDir(path2) {
|
|
8800
|
-
if (workbenchEnabled()) return getWorkbenchClient().readdir(path2);
|
|
8801
|
-
return localReaddir(path2);
|
|
8802
|
-
}
|
|
8803
|
-
async function statWorkspacePath(path2) {
|
|
8804
|
-
if (workbenchEnabled()) return getWorkbenchClient().stat(path2);
|
|
8805
|
-
try {
|
|
8806
|
-
const s = await localStat(path2);
|
|
8807
|
-
return {
|
|
8808
|
-
exists: true,
|
|
8809
|
-
isFile: s.isFile(),
|
|
8810
|
-
isDirectory: s.isDirectory(),
|
|
8811
|
-
size: s.size,
|
|
8812
|
-
mtimeMs: s.mtimeMs
|
|
8813
|
-
};
|
|
8814
|
-
} catch {
|
|
8815
|
-
return { exists: false, isFile: false, isDirectory: false, size: 0, mtimeMs: 0 };
|
|
8816
|
-
}
|
|
8817
|
-
}
|
|
8818
|
-
async function workspacePathExists(path2) {
|
|
8819
|
-
return (await statWorkspacePath(path2)).exists;
|
|
8820
|
-
}
|
|
8821
|
-
|
|
8822
6855
|
// src/execution/tag-context-resolver.ts
|
|
8823
6856
|
var TYPE_PRIORITY = { rule: 0, doc: 1, file: 2, folder: 3 };
|
|
8824
6857
|
var SUMMARY_SCAN_CHARS = 4e3;
|
|
@@ -9265,6 +7298,17 @@ function buildPlanDocumentationSection(context) {
|
|
|
9265
7298
|
`- Identification auto-fills title, story points, and icon with quick AI guesses. After exploring, refine the title, story points, and risk with update_task_properties whenever they no longer match what the work actually is \u2014 adjust in either direction. Icons are automatic \u2014 never set them.`
|
|
9266
7299
|
];
|
|
9267
7300
|
}
|
|
7301
|
+
function buildPlanRevisionSection() {
|
|
7302
|
+
return [
|
|
7303
|
+
``,
|
|
7304
|
+
`### The plan can change while you sleep`,
|
|
7305
|
+
`If a turn opens with "The plan changed since this build was dispatched", honor it BEFORE your next Write or Edit:`,
|
|
7306
|
+
`1. Call get_current_plan and read the current plan in full.`,
|
|
7307
|
+
`2. Compare it against the plan you were launched with \u2014 the current plan wins.`,
|
|
7308
|
+
`3. Drop or redo whatever the revision supersedes, and say so with post_to_chat if it invalidates work you already committed.`,
|
|
7309
|
+
`Building on a superseded plan is the most expensive mistake a resumed card can make.`
|
|
7310
|
+
];
|
|
7311
|
+
}
|
|
9268
7312
|
function buildNoPrWhenNoCodeSection(baseBranch) {
|
|
9269
7313
|
const diffCommand = baseDiffCommand(baseBranch);
|
|
9270
7314
|
return [
|
|
@@ -9464,6 +7508,7 @@ function buildAutoPrompt(context, runnerMode) {
|
|
|
9464
7508
|
...buildPrGuideSection(),
|
|
9465
7509
|
...buildNoPrWhenNoCodeSection(context?.baseBranch)
|
|
9466
7510
|
],
|
|
7511
|
+
...buildPlanRevisionSection(),
|
|
9467
7512
|
``,
|
|
9468
7513
|
`### Autonomous Guidelines:`,
|
|
9469
7514
|
`- Make decisions independently \u2014 do not ask the team for approval at each step`,
|
|
@@ -9493,7 +7538,8 @@ function buildBuildingPrompt(context) {
|
|
|
9493
7538
|
...buildPrGuideSection(),
|
|
9494
7539
|
...buildNoPrWhenNoCodeSection(context?.baseBranch),
|
|
9495
7540
|
...context?.isAuto || !context?.plan?.trim() ? buildPlanDocumentationSection(context) : []
|
|
9496
|
-
]
|
|
7541
|
+
],
|
|
7542
|
+
...buildPlanRevisionSection()
|
|
9497
7543
|
];
|
|
9498
7544
|
if (context) parts.push(...buildPropertyInstructions(context));
|
|
9499
7545
|
return parts.join("\n");
|
|
@@ -9939,6 +7985,7 @@ ${context.description}`);
|
|
|
9939
7985
|
## Plan
|
|
9940
7986
|
${truncatePlanForPrompt(context.plan)}`);
|
|
9941
7987
|
}
|
|
7988
|
+
parts.push(...buildPlanRevisionNotice(context));
|
|
9942
7989
|
if (context.files && context.files.length > 0) {
|
|
9943
7990
|
parts.push(`
|
|
9944
7991
|
## Attached Files`);
|
|
@@ -10209,7 +8256,9 @@ async function buildInitialPrompt(mode, context, isAuto, agentMode) {
|
|
|
10209
8256
|
const isPackRunner = mode === "pack" || mode === "pm" && !!isAuto && !!context.isParentTask;
|
|
10210
8257
|
if (!isPackRunner) {
|
|
10211
8258
|
const sessionRelaunch = buildRelaunchWithSession(mode, context, agentMode, isAuto);
|
|
10212
|
-
if (sessionRelaunch)
|
|
8259
|
+
if (sessionRelaunch) {
|
|
8260
|
+
return [...buildPlanRevisionNotice(context), sessionRelaunch].join("\n");
|
|
8261
|
+
}
|
|
10213
8262
|
}
|
|
10214
8263
|
const isPm = mode === "pm";
|
|
10215
8264
|
let scenario = detectRelaunchScenario(context, isPm);
|
|
@@ -11160,7 +9209,12 @@ function buildGetCurrentPlanTool(connection) {
|
|
|
11160
9209
|
const ctx = await connection.call("getTaskContext", {
|
|
11161
9210
|
sessionId: connection.sessionId
|
|
11162
9211
|
});
|
|
11163
|
-
|
|
9212
|
+
const plan = ctx.plan ?? "No plan available.";
|
|
9213
|
+
return textResult(
|
|
9214
|
+
ctx.planRevisedAt ? `\u26A0\uFE0F This plan was revised ${ctx.planRevisedAt}, after your build was dispatched. Treat it as the truth and drop any work it supersedes.
|
|
9215
|
+
|
|
9216
|
+
${plan}` : plan
|
|
9217
|
+
);
|
|
11164
9218
|
} catch {
|
|
11165
9219
|
return textResult("Could not fetch updated plan.");
|
|
11166
9220
|
}
|
|
@@ -12980,8 +11034,8 @@ function buildDriveTools(connection, projectId) {
|
|
|
12980
11034
|
}
|
|
12981
11035
|
|
|
12982
11036
|
// src/tools/code-review-tools.ts
|
|
12983
|
-
import { execFile
|
|
12984
|
-
import { promisify
|
|
11037
|
+
import { execFile } from "child_process";
|
|
11038
|
+
import { promisify } from "util";
|
|
12985
11039
|
import { z as z18 } from "zod";
|
|
12986
11040
|
async function endReviewSession(connection, reason) {
|
|
12987
11041
|
await connection.call("endReviewSession", {
|
|
@@ -13045,7 +11099,7 @@ async function resolveGitHeadSha(cwd) {
|
|
|
13045
11099
|
const stdout = workbenchEnabled() ? (await getWorkbenchClient().execFile("git", ["rev-parse", "HEAD"], {
|
|
13046
11100
|
cwd,
|
|
13047
11101
|
timeout: 1e4
|
|
13048
|
-
})).stdout : (await
|
|
11102
|
+
})).stdout : (await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd, timeout: 1e4 })).stdout;
|
|
13049
11103
|
const sha = stdout.trim();
|
|
13050
11104
|
return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
|
|
13051
11105
|
} catch {
|
|
@@ -13314,7 +11368,7 @@ function resolvePlaywrightMcpServer(env = process.env) {
|
|
|
13314
11368
|
}
|
|
13315
11369
|
|
|
13316
11370
|
// src/execution/event-handlers.ts
|
|
13317
|
-
var
|
|
11371
|
+
var logger4 = createServiceLogger("event-handlers");
|
|
13318
11372
|
function safeVoid(promise, context) {
|
|
13319
11373
|
if (promise && typeof promise.catch === "function") {
|
|
13320
11374
|
promise.catch((err) => {
|
|
@@ -13355,7 +11409,7 @@ async function processAssistantEvent(event, host, turnToolCalls) {
|
|
|
13355
11409
|
var API_ERROR_PATTERN = /API Error: (?:[45]\d\d|terminated)/;
|
|
13356
11410
|
var IMAGE_ERROR_PATTERN = /Could not process image/i;
|
|
13357
11411
|
var AUTH_ERROR_PATTERN = /Not logged in|Please run \/login|authentication failed|invalid.*token|unauthorized/i;
|
|
13358
|
-
function
|
|
11412
|
+
function isAuthError(msg) {
|
|
13359
11413
|
return AUTH_ERROR_PATTERN.test(msg);
|
|
13360
11414
|
}
|
|
13361
11415
|
function isRetriableMessage(msg) {
|
|
@@ -13421,7 +11475,7 @@ function handleErrorResult(event, host) {
|
|
|
13421
11475
|
if (isStaleSession) {
|
|
13422
11476
|
return { retriable: false, staleSession: true };
|
|
13423
11477
|
}
|
|
13424
|
-
if (
|
|
11478
|
+
if (isAuthError(errorMsg)) {
|
|
13425
11479
|
host.connection.sendEvent({ type: "error", message: errorMsg });
|
|
13426
11480
|
return { retriable: false, authError: true };
|
|
13427
11481
|
}
|
|
@@ -13469,7 +11523,7 @@ async function emitResultEvent(event, host, context, startTime, lastAssistantUsa
|
|
|
13469
11523
|
}
|
|
13470
11524
|
function handleRateLimitEvent(event, host) {
|
|
13471
11525
|
const { rate_limit_info } = event;
|
|
13472
|
-
|
|
11526
|
+
logger4.info("Rate limit event received", { rate_limit_info });
|
|
13473
11527
|
const status = rate_limit_info.status;
|
|
13474
11528
|
const utilization = rate_limit_info.utilization ?? (status === "rejected" ? 1 : void 0);
|
|
13475
11529
|
if (utilization !== void 0 && rate_limit_info.rateLimitType) {
|
|
@@ -14238,9 +12292,9 @@ function buildCanUseTool(host) {
|
|
|
14238
12292
|
}
|
|
14239
12293
|
|
|
14240
12294
|
// src/execution/query-executor.ts
|
|
14241
|
-
var
|
|
12295
|
+
var logger5 = createServiceLogger("QueryExecutor");
|
|
14242
12296
|
var IMAGE_ERROR_PATTERN2 = /Could not process image/i;
|
|
14243
|
-
var
|
|
12297
|
+
var RETRY_DELAYS_MS = [6e4, 12e4, 18e4, 3e5];
|
|
14244
12298
|
function buildHooks(host) {
|
|
14245
12299
|
return {
|
|
14246
12300
|
PostToolUse: [
|
|
@@ -14290,7 +12344,7 @@ function sessionLineageKey(taskId, agentMode, runnerMode) {
|
|
|
14290
12344
|
}
|
|
14291
12345
|
function sessionFileExists(sessionUuid, cwd) {
|
|
14292
12346
|
try {
|
|
14293
|
-
return
|
|
12347
|
+
return existsSync(sessionTranscriptPath(cwd, sessionUuid));
|
|
14294
12348
|
} catch {
|
|
14295
12349
|
return false;
|
|
14296
12350
|
}
|
|
@@ -14309,7 +12363,7 @@ function resolveSessionStart(lineageKey, cwd) {
|
|
|
14309
12363
|
}
|
|
14310
12364
|
function repairTornSessionFile(path2) {
|
|
14311
12365
|
try {
|
|
14312
|
-
if (!
|
|
12366
|
+
if (!existsSync(path2)) return false;
|
|
14313
12367
|
const content = readFileSync3(path2, "utf8");
|
|
14314
12368
|
if (content.length === 0) return false;
|
|
14315
12369
|
let keepEnd = content.length;
|
|
@@ -14330,7 +12384,7 @@ function repairTornSessionFile(path2) {
|
|
|
14330
12384
|
}
|
|
14331
12385
|
if (keepEnd === content.length) return false;
|
|
14332
12386
|
truncateSync(path2, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
|
|
14333
|
-
|
|
12387
|
+
logger5.warn("Repaired torn transcript before resume", {
|
|
14334
12388
|
path: path2,
|
|
14335
12389
|
trimmedBytes: content.length - keepEnd
|
|
14336
12390
|
});
|
|
@@ -14411,7 +12465,7 @@ function buildQueryOptions(host, context) {
|
|
|
14411
12465
|
disallowedTools: buildDisallowedTools(settings, mode, host.hasExitedPlanMode),
|
|
14412
12466
|
enableFileCheckpointing: settings.enableFileCheckpointing,
|
|
14413
12467
|
stderr: (data) => {
|
|
14414
|
-
|
|
12468
|
+
logger5.warn("Claude Code stderr", { data: data.trimEnd() });
|
|
14415
12469
|
}
|
|
14416
12470
|
};
|
|
14417
12471
|
}
|
|
@@ -14842,11 +12896,11 @@ function classifyImageError(error) {
|
|
|
14842
12896
|
async function emitRetryStatus(host, attempt, delayMs) {
|
|
14843
12897
|
const delayMin = Math.round(delayMs / 6e4);
|
|
14844
12898
|
host.connection.postChatMessage(
|
|
14845
|
-
`API error encountered. Retrying in ${delayMin} minute${delayMin > 1 ? "s" : ""}... (attempt ${attempt + 1}/${
|
|
12899
|
+
`API error encountered. Retrying in ${delayMin} minute${delayMin > 1 ? "s" : ""}... (attempt ${attempt + 1}/${RETRY_DELAYS_MS.length})`
|
|
14846
12900
|
);
|
|
14847
12901
|
host.connection.sendEvent({
|
|
14848
12902
|
type: "error",
|
|
14849
|
-
message: `API error, retrying in ${delayMin}m (${attempt + 1}/${
|
|
12903
|
+
message: `API error, retrying in ${delayMin}m (${attempt + 1}/${RETRY_DELAYS_MS.length})`
|
|
14850
12904
|
});
|
|
14851
12905
|
host.connection.emitStatus("waiting_for_input");
|
|
14852
12906
|
await host.callbacks.onStatusChange("waiting_for_input");
|
|
@@ -14907,7 +12961,7 @@ function handleRetryError(error, context, host, options, prevImageError) {
|
|
|
14907
12961
|
if (isStaleOrExitedSession(error, context) && context.claudeSessionId) {
|
|
14908
12962
|
return handleStaleSession(context, host, options);
|
|
14909
12963
|
}
|
|
14910
|
-
if (
|
|
12964
|
+
if (isAuthError(getErrorMessage(error))) {
|
|
14911
12965
|
return handleAuthError(context, host, options);
|
|
14912
12966
|
}
|
|
14913
12967
|
if (!isRetriableError(error)) throw error;
|
|
@@ -14941,7 +12995,7 @@ function handleProcessResult(result, context, host, options) {
|
|
|
14941
12995
|
}
|
|
14942
12996
|
async function runWithRetry(initialQuery, context, host, options) {
|
|
14943
12997
|
let lastErrorWasImage = false;
|
|
14944
|
-
for (let attempt = 0; attempt <=
|
|
12998
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
14945
12999
|
if (host.isStopped()) return;
|
|
14946
13000
|
const agentQuery = attempt === 0 ? initialQuery : await buildRetryQuery(host, context, options, lastErrorWasImage);
|
|
14947
13001
|
try {
|
|
@@ -14956,18 +13010,18 @@ async function runWithRetry(initialQuery, context, host, options) {
|
|
|
14956
13010
|
if (outcome.action === "return") return;
|
|
14957
13011
|
lastErrorWasImage = outcome.lastErrorWasImage;
|
|
14958
13012
|
}
|
|
14959
|
-
if (attempt >=
|
|
13013
|
+
if (attempt >= RETRY_DELAYS_MS.length) {
|
|
14960
13014
|
host.connection.postChatMessage(
|
|
14961
|
-
`Agent shutting down after ${
|
|
13015
|
+
`Agent shutting down after ${RETRY_DELAYS_MS.length} failed retry attempts due to API errors. The task will resume automatically when the codespace restarts.`
|
|
14962
13016
|
);
|
|
14963
13017
|
return;
|
|
14964
13018
|
}
|
|
14965
|
-
await emitRetryStatus(host, attempt,
|
|
13019
|
+
await emitRetryStatus(host, attempt, RETRY_DELAYS_MS[attempt]);
|
|
14966
13020
|
}
|
|
14967
13021
|
}
|
|
14968
13022
|
|
|
14969
13023
|
// src/runner/query-bridge.ts
|
|
14970
|
-
var
|
|
13024
|
+
var logger6 = createServiceLogger("QueryBridge");
|
|
14971
13025
|
function resolveHarnessKind() {
|
|
14972
13026
|
if (process.env.CONVEYOR_FORCE_SDK_CARDS === "1") return "sdk";
|
|
14973
13027
|
return "pty";
|
|
@@ -15149,9 +13203,9 @@ var QueryBridge = class {
|
|
|
15149
13203
|
const msg = err instanceof Error ? err.message : String(err);
|
|
15150
13204
|
const isAbort = this._stopped || /abort/i.test(msg);
|
|
15151
13205
|
if (isAbort) {
|
|
15152
|
-
|
|
13206
|
+
logger6.info("Query stopped by user", { error: msg });
|
|
15153
13207
|
} else {
|
|
15154
|
-
|
|
13208
|
+
logger6.error("Query execution failed", { error: msg });
|
|
15155
13209
|
this.connection.sendEvent({ type: "error", message: msg });
|
|
15156
13210
|
}
|
|
15157
13211
|
} finally {
|
|
@@ -15177,9 +13231,9 @@ var QueryBridge = class {
|
|
|
15177
13231
|
const msg = err instanceof Error ? err.message : String(err);
|
|
15178
13232
|
const isAbort = this._stopped || /abort/i.test(msg);
|
|
15179
13233
|
if (isAbort) {
|
|
15180
|
-
|
|
13234
|
+
logger6.info("Passive turn stopped", { error: msg });
|
|
15181
13235
|
} else {
|
|
15182
|
-
|
|
13236
|
+
logger6.error("Passive turn failed", { error: msg });
|
|
15183
13237
|
this.connection.sendEvent({ type: "error", message: msg });
|
|
15184
13238
|
}
|
|
15185
13239
|
} finally {
|
|
@@ -15275,7 +13329,7 @@ var QueryBridge = class {
|
|
|
15275
13329
|
};
|
|
15276
13330
|
|
|
15277
13331
|
// src/execution/usage-sampler.ts
|
|
15278
|
-
import { existsSync as
|
|
13332
|
+
import { existsSync as existsSync2 } from "fs";
|
|
15279
13333
|
|
|
15280
13334
|
// src/usage/reset-parse.ts
|
|
15281
13335
|
var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
@@ -15506,7 +13560,7 @@ async function runUsageProbe(deps = {}) {
|
|
|
15506
13560
|
}
|
|
15507
13561
|
|
|
15508
13562
|
// src/execution/usage-sampler.ts
|
|
15509
|
-
var
|
|
13563
|
+
var logger7 = createServiceLogger("usage-sampler");
|
|
15510
13564
|
var NO_SAMPLES = { samples: [], unmeasurable: null };
|
|
15511
13565
|
function isAttributable(identity, sessionToken) {
|
|
15512
13566
|
if (!identity) return { ok: true };
|
|
@@ -15518,12 +13572,12 @@ function isAttributable(identity, sessionToken) {
|
|
|
15518
13572
|
}
|
|
15519
13573
|
return { ok: true };
|
|
15520
13574
|
}
|
|
15521
|
-
async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscriptionCredentials = () =>
|
|
13575
|
+
async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscriptionCredentials = () => existsSync2(claudeCredentialsPath()), readIdentity = readCredentialsIdentity) {
|
|
15522
13576
|
if (!token && !hasSubscriptionCredentials()) return NO_SAMPLES;
|
|
15523
13577
|
try {
|
|
15524
13578
|
const attributable = isAttributable(await readIdentity(), token);
|
|
15525
13579
|
if (!attributable.ok) {
|
|
15526
|
-
|
|
13580
|
+
logger7.info("usage sample skipped \u2014 credentials not attributable to this session's key", {
|
|
15527
13581
|
reason: attributable.reason
|
|
15528
13582
|
});
|
|
15529
13583
|
return { samples: [], unmeasurable: { reason: attributable.reason ?? "unattributable" } };
|
|
@@ -15550,14 +13604,14 @@ async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscript
|
|
|
15550
13604
|
});
|
|
15551
13605
|
}
|
|
15552
13606
|
if (samples.length === 0) {
|
|
15553
|
-
|
|
13607
|
+
logger7.info("usage sample produced no gauges", {
|
|
15554
13608
|
stdoutLength: stdout.length,
|
|
15555
13609
|
stdoutHead: stdout.slice(0, 200).replaceAll("\n", " ")
|
|
15556
13610
|
});
|
|
15557
13611
|
}
|
|
15558
13612
|
return { samples, unmeasurable: null };
|
|
15559
13613
|
} catch (error) {
|
|
15560
|
-
|
|
13614
|
+
logger7.info("usage sample failed", {
|
|
15561
13615
|
error: error instanceof Error ? error.message : String(error)
|
|
15562
13616
|
});
|
|
15563
13617
|
return NO_SAMPLES;
|
|
@@ -15583,391 +13637,10 @@ function buildUnmeasurableEvent(reason, codingAgentKeyId) {
|
|
|
15583
13637
|
};
|
|
15584
13638
|
}
|
|
15585
13639
|
|
|
15586
|
-
// src/setup/git-ready.ts
|
|
15587
|
-
var GATE_MARGIN_MS = 5 * 6e4;
|
|
15588
|
-
var DEFAULT_TIMEOUT_MS = GIT_PREP_MAX_RETRIES * (CLONE_TIMEOUT_MS + FETCH_TIMEOUT_MS) + (GIT_PREP_MAX_RETRIES - 1) * DEFAULT_RETRY_DELAY_MS + GATE_MARGIN_MS;
|
|
15589
|
-
var DEFAULT_POLL_MS = 200;
|
|
15590
|
-
function awaitGitReady(opts = {}) {
|
|
15591
|
-
if (!workbenchEnabled()) {
|
|
15592
|
-
return Promise.resolve("not-gated");
|
|
15593
|
-
}
|
|
15594
|
-
const clientFn = opts.clientFn ?? getWorkbenchClient;
|
|
15595
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
15596
|
-
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
15597
|
-
opts.onLog?.("waiting for workspace git (workbench daemon)");
|
|
15598
|
-
return pollDaemon(clientFn, timeoutMs, pollMs, opts.onLog, opts.signal);
|
|
15599
|
-
}
|
|
15600
|
-
function delay(ms, signal) {
|
|
15601
|
-
if (!signal) {
|
|
15602
|
-
return new Promise((resolve) => {
|
|
15603
|
-
setTimeout(resolve, ms);
|
|
15604
|
-
});
|
|
15605
|
-
}
|
|
15606
|
-
if (signal.aborted) return Promise.resolve();
|
|
15607
|
-
return new Promise((resolve) => {
|
|
15608
|
-
const timer = setTimeout(() => {
|
|
15609
|
-
signal.removeEventListener("abort", onAbort);
|
|
15610
|
-
resolve();
|
|
15611
|
-
}, ms);
|
|
15612
|
-
const onAbort = () => {
|
|
15613
|
-
clearTimeout(timer);
|
|
15614
|
-
resolve();
|
|
15615
|
-
};
|
|
15616
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
15617
|
-
});
|
|
15618
|
-
}
|
|
15619
|
-
async function pollOnce(clientFn, reportError) {
|
|
15620
|
-
try {
|
|
15621
|
-
const frame = await clientFn().gitStatus();
|
|
15622
|
-
if (frame.state === "ready") return { state: "ready", log: "workspace git ready" };
|
|
15623
|
-
if (frame.state === "failed") {
|
|
15624
|
-
return {
|
|
15625
|
-
state: "failed",
|
|
15626
|
-
log: `workspace git preparation failed: ${frame.reason ?? "unknown reason"}`
|
|
15627
|
-
};
|
|
15628
|
-
}
|
|
15629
|
-
return { state: null };
|
|
15630
|
-
} catch (err) {
|
|
15631
|
-
if (err instanceof WorkbenchError && err.code === "unauthorized") {
|
|
15632
|
-
return {
|
|
15633
|
-
state: "failed",
|
|
15634
|
-
log: "workspace git gate unauthorized \u2014 workbench token missing or invalid, giving up"
|
|
15635
|
-
};
|
|
15636
|
-
}
|
|
15637
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
15638
|
-
return reportError ? { state: null, log: `workspace git poll error (retrying): ${message}` } : { state: null };
|
|
15639
|
-
}
|
|
15640
|
-
}
|
|
15641
|
-
async function pollDaemon(clientFn, timeoutMs, pollMs, onLog, signal) {
|
|
15642
|
-
const deadline = Date.now() + timeoutMs;
|
|
15643
|
-
let loggedError = false;
|
|
15644
|
-
for (; ; ) {
|
|
15645
|
-
if (signal?.aborted) {
|
|
15646
|
-
onLog?.("workspace git wait aborted \u2014 giving up");
|
|
15647
|
-
return "timeout";
|
|
15648
|
-
}
|
|
15649
|
-
const outcome = await pollOnce(clientFn, !loggedError);
|
|
15650
|
-
if (outcome.log !== void 0) {
|
|
15651
|
-
loggedError = true;
|
|
15652
|
-
onLog?.(outcome.log);
|
|
15653
|
-
}
|
|
15654
|
-
if (outcome.state !== null) return outcome.state;
|
|
15655
|
-
if (Date.now() >= deadline) {
|
|
15656
|
-
onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
|
|
15657
|
-
return "timeout";
|
|
15658
|
-
}
|
|
15659
|
-
await delay(pollMs, signal);
|
|
15660
|
-
}
|
|
15661
|
-
}
|
|
15662
|
-
|
|
15663
|
-
// src/runner/port-discovery.ts
|
|
15664
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
15665
|
-
import { execFile as execFile3 } from "child_process";
|
|
15666
|
-
var PROC_TCP_LISTEN_STATE = "0A";
|
|
15667
|
-
function isLoopbackHexAddress(hex) {
|
|
15668
|
-
const addr = hex.toUpperCase();
|
|
15669
|
-
if (addr.length === 8) {
|
|
15670
|
-
return addr.slice(6, 8) === "7F";
|
|
15671
|
-
}
|
|
15672
|
-
if (addr.length === 32) {
|
|
15673
|
-
if (addr === "00000000000000000000000001000000") return true;
|
|
15674
|
-
if (addr.slice(0, 16) === "0000000000000000" && addr.slice(16, 24) === "FFFF0000") {
|
|
15675
|
-
return addr.slice(30, 32) === "7F";
|
|
15676
|
-
}
|
|
15677
|
-
return false;
|
|
15678
|
-
}
|
|
15679
|
-
return false;
|
|
15680
|
-
}
|
|
15681
|
-
function parseProcNetTcpListeners(content) {
|
|
15682
|
-
const sockets = [];
|
|
15683
|
-
const lines = content.split("\n");
|
|
15684
|
-
for (let i = 1; i < lines.length; i++) {
|
|
15685
|
-
const line = lines[i];
|
|
15686
|
-
if (!line) continue;
|
|
15687
|
-
const cols = line.trim().split(/\s+/);
|
|
15688
|
-
if (cols.length < 4 || cols[3] !== PROC_TCP_LISTEN_STATE) continue;
|
|
15689
|
-
const local = cols[1];
|
|
15690
|
-
if (!local) continue;
|
|
15691
|
-
const [addrHex, portHex] = local.split(":");
|
|
15692
|
-
if (!addrHex || !portHex) continue;
|
|
15693
|
-
const port = Number.parseInt(portHex, 16);
|
|
15694
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
15695
|
-
sockets.push({ port, loopback: isLoopbackHexAddress(addrHex) });
|
|
15696
|
-
}
|
|
15697
|
-
return sockets;
|
|
15698
|
-
}
|
|
15699
|
-
function collectScan(sockets) {
|
|
15700
|
-
const ports = /* @__PURE__ */ new Set();
|
|
15701
|
-
const hasExternal = /* @__PURE__ */ new Set();
|
|
15702
|
-
for (const { port, loopback } of sockets) {
|
|
15703
|
-
ports.add(port);
|
|
15704
|
-
if (!loopback) hasExternal.add(port);
|
|
15705
|
-
}
|
|
15706
|
-
const loopbackOnly = /* @__PURE__ */ new Set();
|
|
15707
|
-
for (const port of ports) {
|
|
15708
|
-
if (!hasExternal.has(port)) loopbackOnly.add(port);
|
|
15709
|
-
}
|
|
15710
|
-
return { ports, loopbackOnly };
|
|
15711
|
-
}
|
|
15712
|
-
var DEFAULT_PROC_PATHS = ["/proc/net/tcp", "/proc/net/tcp6"];
|
|
15713
|
-
async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
|
|
15714
|
-
const sockets = [];
|
|
15715
|
-
let readable = false;
|
|
15716
|
-
for (const path2 of procPaths) {
|
|
15717
|
-
try {
|
|
15718
|
-
const content = await readFile3(path2, "utf8");
|
|
15719
|
-
readable = true;
|
|
15720
|
-
sockets.push(...parseProcNetTcpListeners(content));
|
|
15721
|
-
} catch {
|
|
15722
|
-
}
|
|
15723
|
-
}
|
|
15724
|
-
return readable ? collectScan(sockets) : null;
|
|
15725
|
-
}
|
|
15726
|
-
async function readNetstatListeningPorts() {
|
|
15727
|
-
const output = await new Promise((resolve) => {
|
|
15728
|
-
execFile3("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
|
|
15729
|
-
resolve(err ? null : stdout);
|
|
15730
|
-
});
|
|
15731
|
-
});
|
|
15732
|
-
if (output === null) return null;
|
|
15733
|
-
const sockets = [];
|
|
15734
|
-
for (const line of output.split("\n")) {
|
|
15735
|
-
if (!line.includes("LISTEN")) continue;
|
|
15736
|
-
const cols = line.trim().split(/\s+/);
|
|
15737
|
-
const local = cols[3];
|
|
15738
|
-
if (!local) continue;
|
|
15739
|
-
const lastDot = local.lastIndexOf(".");
|
|
15740
|
-
if (lastDot < 0) continue;
|
|
15741
|
-
const host = local.slice(0, lastDot);
|
|
15742
|
-
const port = Number(local.slice(lastDot + 1));
|
|
15743
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
15744
|
-
const loopback = host.startsWith("127.") || host === "::1" || host === "localhost";
|
|
15745
|
-
sockets.push({ port, loopback });
|
|
15746
|
-
}
|
|
15747
|
-
return collectScan(sockets);
|
|
15748
|
-
}
|
|
15749
|
-
async function readListeningPorts() {
|
|
15750
|
-
const proc = await readProcListeningPorts();
|
|
15751
|
-
if (proc !== null) return proc;
|
|
15752
|
-
if (process.platform !== "linux") return readNetstatListeningPorts();
|
|
15753
|
-
return null;
|
|
15754
|
-
}
|
|
15755
|
-
var DEFAULT_EXCLUDED_PORTS = [2222, 5432, 6379, 9200];
|
|
15756
|
-
var DEFAULT_EPHEMERAL_PORT_MIN = 32768;
|
|
15757
|
-
var DEFAULT_DISCOVERY_INTERVAL_MS = 15e3;
|
|
15758
|
-
var DEFAULT_MAX_PORTS = 16;
|
|
15759
|
-
var CONFIRM_SCANS = 2;
|
|
15760
|
-
var PortDiscovery = class {
|
|
15761
|
-
opts;
|
|
15762
|
-
intervalMs;
|
|
15763
|
-
maxPorts;
|
|
15764
|
-
excluded;
|
|
15765
|
-
ephemeralPortMin;
|
|
15766
|
-
scan;
|
|
15767
|
-
now;
|
|
15768
|
-
log;
|
|
15769
|
-
baseline = null;
|
|
15770
|
-
tracked = /* @__PURE__ */ new Map();
|
|
15771
|
-
/** Loopback-only candidates already warned about (once per port). */
|
|
15772
|
-
warnedLoopback = /* @__PURE__ */ new Set();
|
|
15773
|
-
timer = null;
|
|
15774
|
-
ticking = false;
|
|
15775
|
-
disabled = false;
|
|
15776
|
-
stopped = false;
|
|
15777
|
-
/** Set when the confirmed set changed (or a report failed) — cleared only
|
|
15778
|
-
* after a successful report, so transient RPC failures retry next tick. */
|
|
15779
|
-
reportPending = false;
|
|
15780
|
-
lastReportedKey = "";
|
|
15781
|
-
constructor(options) {
|
|
15782
|
-
this.opts = options;
|
|
15783
|
-
this.intervalMs = options.intervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
|
|
15784
|
-
this.maxPorts = options.maxPorts ?? DEFAULT_MAX_PORTS;
|
|
15785
|
-
this.excluded = new Set(options.excludedPorts ?? DEFAULT_EXCLUDED_PORTS);
|
|
15786
|
-
this.ephemeralPortMin = options.ephemeralPortMin ?? DEFAULT_EPHEMERAL_PORT_MIN;
|
|
15787
|
-
this.scan = options.scan ?? readListeningPorts;
|
|
15788
|
-
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
15789
|
-
this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
|
|
15790
|
-
`));
|
|
15791
|
-
}
|
|
15792
|
-
/** Take the baseline scan and start polling. Safe to call once. */
|
|
15793
|
-
async start() {
|
|
15794
|
-
if (this.timer || this.disabled || this.stopped) return;
|
|
15795
|
-
const baseline = await this.scanSafe();
|
|
15796
|
-
if (this.stopped) return;
|
|
15797
|
-
if (baseline === null) {
|
|
15798
|
-
this.disabled = true;
|
|
15799
|
-
this.log("Port discovery disabled: no listening-socket source available");
|
|
15800
|
-
return;
|
|
15801
|
-
}
|
|
15802
|
-
this.baseline = baseline.ports;
|
|
15803
|
-
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
15804
|
-
this.timer.unref?.();
|
|
15805
|
-
}
|
|
15806
|
-
stop() {
|
|
15807
|
-
this.stopped = true;
|
|
15808
|
-
if (this.timer) {
|
|
15809
|
-
clearInterval(this.timer);
|
|
15810
|
-
this.timer = null;
|
|
15811
|
-
}
|
|
15812
|
-
}
|
|
15813
|
-
/** One poll cycle. Exposed for tests (deterministic, no timers needed). */
|
|
15814
|
-
async tick() {
|
|
15815
|
-
if (this.ticking || this.disabled || !this.baseline) return;
|
|
15816
|
-
this.ticking = true;
|
|
15817
|
-
try {
|
|
15818
|
-
const current = await this.scanSafe();
|
|
15819
|
-
if (current === null) return;
|
|
15820
|
-
this.updateTracking(current);
|
|
15821
|
-
if (this.reportPending) await this.flushReport();
|
|
15822
|
-
} finally {
|
|
15823
|
-
this.ticking = false;
|
|
15824
|
-
}
|
|
15825
|
-
}
|
|
15826
|
-
async scanSafe() {
|
|
15827
|
-
try {
|
|
15828
|
-
return await this.scan();
|
|
15829
|
-
} catch {
|
|
15830
|
-
return null;
|
|
15831
|
-
}
|
|
15832
|
-
}
|
|
15833
|
-
isCandidate(port) {
|
|
15834
|
-
if (this.baseline?.has(port)) return false;
|
|
15835
|
-
if (this.excluded.has(port)) return false;
|
|
15836
|
-
if (port >= this.ephemeralPortMin) return false;
|
|
15837
|
-
return true;
|
|
15838
|
-
}
|
|
15839
|
-
updateTracking(current) {
|
|
15840
|
-
const reachable = /* @__PURE__ */ new Set();
|
|
15841
|
-
for (const port of current.ports) {
|
|
15842
|
-
if (!this.isCandidate(port)) continue;
|
|
15843
|
-
if (current.loopbackOnly.has(port)) {
|
|
15844
|
-
if (!this.warnedLoopback.has(port)) {
|
|
15845
|
-
this.warnedLoopback.add(port);
|
|
15846
|
-
this.log(
|
|
15847
|
-
`Port ${port} is listening on loopback only and cannot be previewed \u2014 bind 0.0.0.0 (or the pod IP) to make it reachable through the preview proxy`
|
|
15848
|
-
);
|
|
15849
|
-
}
|
|
15850
|
-
continue;
|
|
15851
|
-
}
|
|
15852
|
-
reachable.add(port);
|
|
15853
|
-
}
|
|
15854
|
-
for (const port of reachable) {
|
|
15855
|
-
const entry = this.tracked.get(port);
|
|
15856
|
-
if (!entry) {
|
|
15857
|
-
this.tracked.set(port, { seen: 1, missed: 0, confirmed: false, detectedAt: "" });
|
|
15858
|
-
continue;
|
|
15859
|
-
}
|
|
15860
|
-
entry.seen += 1;
|
|
15861
|
-
entry.missed = 0;
|
|
15862
|
-
if (!entry.confirmed && entry.seen >= CONFIRM_SCANS) {
|
|
15863
|
-
entry.confirmed = true;
|
|
15864
|
-
entry.detectedAt = this.now().toISOString();
|
|
15865
|
-
}
|
|
15866
|
-
}
|
|
15867
|
-
for (const [port, entry] of this.tracked) {
|
|
15868
|
-
if (reachable.has(port)) continue;
|
|
15869
|
-
entry.missed += 1;
|
|
15870
|
-
entry.seen = 0;
|
|
15871
|
-
if (entry.missed >= CONFIRM_SCANS || !entry.confirmed) this.tracked.delete(port);
|
|
15872
|
-
}
|
|
15873
|
-
const key = this.confirmedKey();
|
|
15874
|
-
if (key !== this.lastReportedKey) this.reportPending = true;
|
|
15875
|
-
}
|
|
15876
|
-
confirmedPorts() {
|
|
15877
|
-
const confirmed = [...this.tracked.entries()].filter(([, entry]) => entry.confirmed).sort(([a], [b]) => a - b).slice(0, this.maxPorts);
|
|
15878
|
-
return confirmed.map(([port, entry]) => ({
|
|
15879
|
-
port,
|
|
15880
|
-
protocol: "tcp",
|
|
15881
|
-
detectedAt: entry.detectedAt
|
|
15882
|
-
}));
|
|
15883
|
-
}
|
|
15884
|
-
confirmedKey() {
|
|
15885
|
-
return this.confirmedPorts().map(({ port }) => port).join(",");
|
|
15886
|
-
}
|
|
15887
|
-
async flushReport() {
|
|
15888
|
-
const ports = this.confirmedPorts();
|
|
15889
|
-
const key = ports.map(({ port }) => port).join(",");
|
|
15890
|
-
try {
|
|
15891
|
-
await this.opts.report(ports);
|
|
15892
|
-
this.lastReportedKey = key;
|
|
15893
|
-
this.reportPending = false;
|
|
15894
|
-
this.log(`Discovered preview ports: [${key || "none"}]`);
|
|
15895
|
-
} catch {
|
|
15896
|
-
return;
|
|
15897
|
-
}
|
|
15898
|
-
try {
|
|
15899
|
-
await this.opts.onReported?.(ports);
|
|
15900
|
-
} catch {
|
|
15901
|
-
}
|
|
15902
|
-
}
|
|
15903
|
-
};
|
|
15904
|
-
|
|
15905
|
-
// src/runner/codespace-port-visibility.ts
|
|
15906
|
-
import { execFile as execFile4 } from "child_process";
|
|
15907
|
-
var GH_TIMEOUT_MS = 15e3;
|
|
15908
|
-
var VISIBILITIES = ["org", "public"];
|
|
15909
|
-
function runGh(args) {
|
|
15910
|
-
return new Promise((resolve) => {
|
|
15911
|
-
execFile4("gh", [...args], { timeout: GH_TIMEOUT_MS }, (error, _stdout, stderr) => {
|
|
15912
|
-
resolve({ ok: !error, stderr: (stderr || (error ? String(error.message) : "")).trim() });
|
|
15913
|
-
});
|
|
15914
|
-
});
|
|
15915
|
-
}
|
|
15916
|
-
function isCodespaceEnvironment(env = process.env) {
|
|
15917
|
-
return env.CODESPACES === "true" && !!env.CODESPACE_NAME;
|
|
15918
|
-
}
|
|
15919
|
-
var CodespacePortVisibility = class {
|
|
15920
|
-
env;
|
|
15921
|
-
run;
|
|
15922
|
-
log;
|
|
15923
|
-
/** Ports already attempted (success or failure) — one try per process. */
|
|
15924
|
-
attempted = /* @__PURE__ */ new Set();
|
|
15925
|
-
constructor(options = {}) {
|
|
15926
|
-
this.env = options.env ?? process.env;
|
|
15927
|
-
this.run = options.run ?? runGh;
|
|
15928
|
-
this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
|
|
15929
|
-
`));
|
|
15930
|
-
}
|
|
15931
|
-
/** Flip every not-yet-attempted port. Resolves even when everything fails. */
|
|
15932
|
-
async ensureVisible(ports) {
|
|
15933
|
-
if (!isCodespaceEnvironment(this.env)) return;
|
|
15934
|
-
const codespaceName = this.env.CODESPACE_NAME;
|
|
15935
|
-
for (const port of ports) {
|
|
15936
|
-
if (this.attempted.has(port)) continue;
|
|
15937
|
-
this.attempted.add(port);
|
|
15938
|
-
await this.flip(port, codespaceName);
|
|
15939
|
-
}
|
|
15940
|
-
}
|
|
15941
|
-
async flip(port, codespaceName) {
|
|
15942
|
-
let lastError = "";
|
|
15943
|
-
for (const visibility of VISIBILITIES) {
|
|
15944
|
-
const result = await this.run([
|
|
15945
|
-
"codespace",
|
|
15946
|
-
"ports",
|
|
15947
|
-
"visibility",
|
|
15948
|
-
`${port}:${visibility}`,
|
|
15949
|
-
"-c",
|
|
15950
|
-
codespaceName
|
|
15951
|
-
]).catch((error) => ({
|
|
15952
|
-
ok: false,
|
|
15953
|
-
stderr: error instanceof Error ? error.message : String(error)
|
|
15954
|
-
}));
|
|
15955
|
-
if (result.ok) {
|
|
15956
|
-
this.log(`Forwarded port ${port} set to ${visibility} visibility`);
|
|
15957
|
-
return;
|
|
15958
|
-
}
|
|
15959
|
-
lastError = result.stderr;
|
|
15960
|
-
}
|
|
15961
|
-
this.log(
|
|
15962
|
-
`Could not change visibility of forwarded port ${port} \u2014 the preview URL may 404 for other users${lastError ? `: ${lastError}` : ""}`
|
|
15963
|
-
);
|
|
15964
|
-
}
|
|
15965
|
-
};
|
|
15966
|
-
|
|
15967
13640
|
// src/runner/parent-pull-handler.ts
|
|
15968
|
-
import { execFile as
|
|
15969
|
-
import { promisify as
|
|
15970
|
-
var
|
|
13641
|
+
import { execFile as execFile2 } from "child_process";
|
|
13642
|
+
import { promisify as promisify2 } from "util";
|
|
13643
|
+
var execFileAsync = promisify2(execFile2);
|
|
15971
13644
|
async function handlePullBranch(workDir, branch) {
|
|
15972
13645
|
if (!branch) return;
|
|
15973
13646
|
const current = await getCurrentBranch(workDir);
|
|
@@ -15986,14 +13659,14 @@ async function handlePullBranch(workDir, branch) {
|
|
|
15986
13659
|
return;
|
|
15987
13660
|
}
|
|
15988
13661
|
try {
|
|
15989
|
-
await
|
|
13662
|
+
await execFileAsync("git", ["fetch", "origin", branch], { cwd: workDir, timeout: 6e4 });
|
|
15990
13663
|
} catch {
|
|
15991
13664
|
process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
|
|
15992
13665
|
`);
|
|
15993
13666
|
return;
|
|
15994
13667
|
}
|
|
15995
13668
|
try {
|
|
15996
|
-
await
|
|
13669
|
+
await execFileAsync("git", ["pull", "--ff-only", "origin", branch], {
|
|
15997
13670
|
cwd: workDir,
|
|
15998
13671
|
timeout: 6e4
|
|
15999
13672
|
});
|
|
@@ -16893,6 +14566,9 @@ var SessionRunner = class _SessionRunner {
|
|
|
16893
14566
|
title: ctx.title,
|
|
16894
14567
|
description: ctx.description,
|
|
16895
14568
|
plan: ctx.plan,
|
|
14569
|
+
// The server hands this to exactly one context fetch per revision, so it
|
|
14570
|
+
// must survive the mapping or the notice is lost.
|
|
14571
|
+
planRevisedAt: ctx.planRevisedAt ?? null,
|
|
16896
14572
|
status: ctx.status,
|
|
16897
14573
|
chatHistory,
|
|
16898
14574
|
agentId: ctx.agentId ?? null,
|
|
@@ -17065,7 +14741,10 @@ ${outcome.failures.join("\n")}
|
|
|
17065
14741
|
try {
|
|
17066
14742
|
const ctx = await this.connection.call("getTaskContext", {
|
|
17067
14743
|
sessionId: this.sessionId,
|
|
17068
|
-
includeHistory: false
|
|
14744
|
+
includeHistory: false,
|
|
14745
|
+
// Bookkeeping fetch — it must not swallow a plan-revised notice the
|
|
14746
|
+
// next prompt is about to carry.
|
|
14747
|
+
peekPlanRevision: true
|
|
17069
14748
|
});
|
|
17070
14749
|
if (ctx?.githubBranch && this.fullContext) {
|
|
17071
14750
|
this.fullContext.githubBranch = ctx.githubBranch;
|
|
@@ -17173,49 +14852,6 @@ ${outcome.failures.join("\n")}
|
|
|
17173
14852
|
}
|
|
17174
14853
|
};
|
|
17175
14854
|
|
|
17176
|
-
// src/setup/config.ts
|
|
17177
|
-
import { join as join14 } from "path";
|
|
17178
|
-
var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
|
|
17179
|
-
var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
|
|
17180
|
-
async function loadForwardPorts(workspaceDir) {
|
|
17181
|
-
try {
|
|
17182
|
-
const raw = await readWorkspaceFile(join14(workspaceDir, DEVCONTAINER_PATH));
|
|
17183
|
-
const parsed = JSON.parse(raw);
|
|
17184
|
-
const ports = (parsed.forwardPorts ?? []).filter(
|
|
17185
|
-
(p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
|
|
17186
|
-
);
|
|
17187
|
-
const attributes = {};
|
|
17188
|
-
for (const [key, value] of Object.entries(parsed.portsAttributes ?? {})) {
|
|
17189
|
-
if (!value || typeof value !== "object") continue;
|
|
17190
|
-
const entry = {};
|
|
17191
|
-
if (typeof value.label === "string") entry.label = value.label;
|
|
17192
|
-
if (value.visibility === "public" || value.visibility === "private") {
|
|
17193
|
-
entry.visibility = value.visibility;
|
|
17194
|
-
}
|
|
17195
|
-
attributes[key] = entry;
|
|
17196
|
-
}
|
|
17197
|
-
return { ports, attributes };
|
|
17198
|
-
} catch {
|
|
17199
|
-
return { ports: [], attributes: {} };
|
|
17200
|
-
}
|
|
17201
|
-
}
|
|
17202
|
-
function buildSessionPreviewPorts(result) {
|
|
17203
|
-
return result.ports.filter((port) => !DEVCONTAINER_PORT_DENY_LIST.has(port)).map((port) => {
|
|
17204
|
-
const attr = result.attributes[String(port)];
|
|
17205
|
-
const entry = { port };
|
|
17206
|
-
if (attr?.label) entry.label = attr.label;
|
|
17207
|
-
if (attr?.visibility) entry.visibility = attr.visibility;
|
|
17208
|
-
return entry;
|
|
17209
|
-
});
|
|
17210
|
-
}
|
|
17211
|
-
function loadConveyorConfig() {
|
|
17212
|
-
const envStart = process.env.CONVEYOR_START_COMMAND;
|
|
17213
|
-
if (envStart) {
|
|
17214
|
-
return { startCommand: envStart };
|
|
17215
|
-
}
|
|
17216
|
-
return null;
|
|
17217
|
-
}
|
|
17218
|
-
|
|
17219
14855
|
// src/setup/codespace.ts
|
|
17220
14856
|
import { execSync } from "child_process";
|
|
17221
14857
|
function unshallowRepo(workspaceDir) {
|
|
@@ -17230,30 +14866,13 @@ function unshallowRepo(workspaceDir) {
|
|
|
17230
14866
|
}
|
|
17231
14867
|
|
|
17232
14868
|
export {
|
|
17233
|
-
fetchBootstrap,
|
|
17234
|
-
applyBootstrapToEnv,
|
|
17235
|
-
createServiceLogger,
|
|
17236
|
-
AgentConnection,
|
|
17237
14869
|
DEFAULT_SONNET_MODEL,
|
|
17238
14870
|
isPermissionDeniedError,
|
|
17239
|
-
DEFAULT_LIFECYCLE_CONFIG,
|
|
17240
|
-
Lifecycle,
|
|
17241
14871
|
buildSynthesizedCredentials,
|
|
17242
14872
|
claudeJsonPath,
|
|
17243
14873
|
PtyHarness,
|
|
17244
14874
|
resolveTuiKindFromEnv,
|
|
17245
14875
|
resolveTuiAdapter,
|
|
17246
|
-
readWorkspaceBytes,
|
|
17247
|
-
statWorkspacePath,
|
|
17248
|
-
workspacePathExists,
|
|
17249
|
-
GIT_TIMEOUT_MS,
|
|
17250
|
-
updateRemoteToken,
|
|
17251
|
-
hasUncommittedChanges,
|
|
17252
|
-
getCurrentBranch,
|
|
17253
|
-
hasUnpushedCommits,
|
|
17254
|
-
stageAndCommit,
|
|
17255
|
-
flushPendingChanges,
|
|
17256
|
-
pushToOrigin,
|
|
17257
14876
|
buildProjectTools,
|
|
17258
14877
|
resolvePlaywrightMcpServer,
|
|
17259
14878
|
resolveSessionStart,
|
|
@@ -17262,12 +14881,6 @@ export {
|
|
|
17262
14881
|
sampleKeyUsage,
|
|
17263
14882
|
buildRateLimitEvents,
|
|
17264
14883
|
buildUnmeasurableEvent,
|
|
17265
|
-
awaitGitReady,
|
|
17266
|
-
PortDiscovery,
|
|
17267
|
-
CodespacePortVisibility,
|
|
17268
14884
|
SessionRunner,
|
|
17269
|
-
loadForwardPorts,
|
|
17270
|
-
buildSessionPreviewPorts,
|
|
17271
|
-
loadConveyorConfig,
|
|
17272
14885
|
unshallowRepo
|
|
17273
14886
|
};
|