@gajae-code/coding-agent 0.5.2 → 0.5.3
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/CHANGELOG.md +14 -0
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +153 -39
- package/src/config/file-lock.ts +9 -1
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/tmux-gc.ts +86 -37
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/lsp/client.ts +64 -26
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +21 -0
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/model-selector.ts +34 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +5 -0
- package/src/modes/controllers/selector-controller.ts +6 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +27 -0
- package/src/session/agent-session.ts +168 -22
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/session-manager.ts +29 -13
- package/src/session/streaming-output.ts +54 -3
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
package/src/async/job-manager.ts
CHANGED
|
@@ -7,6 +7,11 @@ const DELIVERY_RETRY_JITTER_MS = 200;
|
|
|
7
7
|
const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
|
|
8
8
|
const DEFAULT_MAX_RUNNING_JOBS = 15;
|
|
9
9
|
const MONITOR_TOMBSTONE_TTL_MS = 5 * 60_000;
|
|
10
|
+
const DEFAULT_MAX_DELIVERY_QUEUE = 100;
|
|
11
|
+
const DELIVERY_MAX_TEXT_BYTES = 64 * 1024;
|
|
12
|
+
const DELIVERY_PREVIEW_HEAD_BYTES = 32 * 1024;
|
|
13
|
+
const DELIVERY_PREVIEW_TAIL_BYTES = 32 * 1024;
|
|
14
|
+
const DELIVERY_MAX_ATTEMPTS = 3;
|
|
10
15
|
|
|
11
16
|
export interface AsyncJob {
|
|
12
17
|
id: string;
|
|
@@ -128,9 +133,16 @@ export interface AsyncJobManagerOptions {
|
|
|
128
133
|
retentionMs?: number;
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
export interface AsyncJobDisposeDiagnostics {
|
|
137
|
+
stuckJobIds: string[];
|
|
138
|
+
deliveriesDrained: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
interface AsyncJobDelivery {
|
|
132
142
|
jobId: string;
|
|
133
143
|
text: string;
|
|
144
|
+
originalBytes?: number;
|
|
145
|
+
truncated?: boolean;
|
|
134
146
|
attempt: number;
|
|
135
147
|
nextAttemptAt: number;
|
|
136
148
|
lastError?: string;
|
|
@@ -143,6 +155,7 @@ export interface AsyncJobDeliveryState {
|
|
|
143
155
|
delivering: boolean;
|
|
144
156
|
nextRetryAt?: number;
|
|
145
157
|
pendingJobIds: string[];
|
|
158
|
+
deadLettered: number;
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
export interface AsyncJobLifecycleCleanup {
|
|
@@ -198,6 +211,32 @@ function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string
|
|
|
198
211
|
return text.slice(codeUnitIndex);
|
|
199
212
|
}
|
|
200
213
|
|
|
214
|
+
function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
|
|
215
|
+
if (offsetBytes <= 0) return text;
|
|
216
|
+
let consumedBytes = 0;
|
|
217
|
+
let codeUnitIndex = 0;
|
|
218
|
+
for (const char of text) {
|
|
219
|
+
const charBytes = Buffer.byteLength(char, "utf8");
|
|
220
|
+
consumedBytes += charBytes;
|
|
221
|
+
codeUnitIndex += char.length;
|
|
222
|
+
if (consumedBytes >= offsetBytes) break;
|
|
223
|
+
}
|
|
224
|
+
return text.slice(codeUnitIndex);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sliceTextToUtf8ByteLength(text: string, maxBytes: number): string {
|
|
228
|
+
if (maxBytes <= 0) return "";
|
|
229
|
+
let consumedBytes = 0;
|
|
230
|
+
let codeUnitIndex = 0;
|
|
231
|
+
for (const char of text) {
|
|
232
|
+
const charBytes = Buffer.byteLength(char, "utf8");
|
|
233
|
+
if (consumedBytes + charBytes > maxBytes) break;
|
|
234
|
+
consumedBytes += charBytes;
|
|
235
|
+
codeUnitIndex += char.length;
|
|
236
|
+
}
|
|
237
|
+
return text.slice(0, codeUnitIndex);
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
/**
|
|
202
241
|
* A slice of process-stream output for a background job, as recorded by
|
|
203
242
|
* `appendOutput` / read by `readOutputSince`.
|
|
@@ -277,6 +316,8 @@ export class AsyncJobManager {
|
|
|
277
316
|
#resumeSeq = 0;
|
|
278
317
|
#resumeRunner?: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
|
|
279
318
|
readonly #resumeDescriptors = new Map<string, ResumeDescriptor>();
|
|
319
|
+
readonly #deadLetteredDeliveries = new Map<string, AsyncJobDelivery>();
|
|
320
|
+
#lastDisposeDiagnostics: AsyncJobDisposeDiagnostics = { stuckJobIds: [], deliveriesDrained: true };
|
|
280
321
|
/**
|
|
281
322
|
* Change listeners notified on any mutation that can alter the live job set
|
|
282
323
|
* (register, terminal/eviction transitions, dispose). Used by the status-line
|
|
@@ -381,7 +422,7 @@ export class AsyncJobManager {
|
|
|
381
422
|
|
|
382
423
|
if (job.status === "cancelled") {
|
|
383
424
|
job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
|
|
384
|
-
this.#runLifecycle(id, "terminal");
|
|
425
|
+
this.#runLifecycle(id, "terminal", job);
|
|
385
426
|
this.#scheduleEviction(id);
|
|
386
427
|
this.#markRecordTerminal(id, "cancelled");
|
|
387
428
|
this.#drainResumeQueue();
|
|
@@ -403,20 +444,20 @@ export class AsyncJobManager {
|
|
|
403
444
|
this.#freezeEndTime(job);
|
|
404
445
|
job.resultText = outcome.text;
|
|
405
446
|
this.#enqueueDelivery(id, outcome.text);
|
|
406
|
-
this.#runLifecycle(id, "terminal");
|
|
447
|
+
this.#runLifecycle(id, "terminal", job);
|
|
407
448
|
this.#scheduleEviction(id);
|
|
408
449
|
this.#markRecordTerminal(id, "completed");
|
|
409
450
|
this.#drainResumeQueue();
|
|
410
451
|
} catch (error) {
|
|
411
452
|
if (job.status === "cancelled") {
|
|
412
453
|
job.errorText = error instanceof Error ? error.message : String(error);
|
|
413
|
-
this.#runLifecycle(id, "terminal");
|
|
454
|
+
this.#runLifecycle(id, "terminal", job);
|
|
414
455
|
this.#scheduleEviction(id);
|
|
415
456
|
this.#markRecordTerminal(id, "cancelled");
|
|
416
457
|
this.#drainResumeQueue();
|
|
417
458
|
return;
|
|
418
459
|
}
|
|
419
|
-
this.#runLifecycle(id, "terminal");
|
|
460
|
+
this.#runLifecycle(id, "terminal", job);
|
|
420
461
|
const errorText = error instanceof Error ? error.message : String(error);
|
|
421
462
|
job.status = "failed";
|
|
422
463
|
this.#freezeEndTime(job);
|
|
@@ -471,14 +512,14 @@ export class AsyncJobManager {
|
|
|
471
512
|
job.endTime ??= Date.now();
|
|
472
513
|
}
|
|
473
514
|
|
|
474
|
-
#runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict"): void {
|
|
515
|
+
#runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict", jobOverride?: AsyncJob): void {
|
|
516
|
+
const lifecycle = this.#lifecycles.get(jobId);
|
|
517
|
+
const job = jobOverride ?? this.#jobs.get(jobId);
|
|
518
|
+
if (!lifecycle || !job) return;
|
|
475
519
|
const fired = this.#lifecyclePhases.get(jobId) ?? new Set<"cancel" | "terminal" | "evict">();
|
|
476
520
|
if (fired.has(phase)) return;
|
|
477
521
|
fired.add(phase);
|
|
478
522
|
this.#lifecyclePhases.set(jobId, fired);
|
|
479
|
-
const lifecycle = this.#lifecycles.get(jobId);
|
|
480
|
-
const job = this.#jobs.get(jobId);
|
|
481
|
-
if (!lifecycle || !job) return;
|
|
482
523
|
try {
|
|
483
524
|
if (phase === "cancel") lifecycle.onCancel?.(job);
|
|
484
525
|
else if (phase === "terminal") lifecycle.onTerminal?.(job);
|
|
@@ -649,6 +690,16 @@ export class AsyncJobManager {
|
|
|
649
690
|
}
|
|
650
691
|
}
|
|
651
692
|
|
|
693
|
+
#purgeTerminalSubagentStateForJob(jobId: string): void {
|
|
694
|
+
const rec = this.#recordByJobId(jobId);
|
|
695
|
+
if (!rec) return;
|
|
696
|
+
if (rec.status === "paused" || rec.status === "queued") return;
|
|
697
|
+
this.#liveHandles.delete(rec.subagentId);
|
|
698
|
+
this.#subagentProgress.delete(rec.subagentId);
|
|
699
|
+
this.#resumeDescriptors.delete(rec.subagentId);
|
|
700
|
+
this.#subagentRecords.delete(rec.subagentId);
|
|
701
|
+
}
|
|
702
|
+
|
|
652
703
|
#markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled"): void {
|
|
653
704
|
const rec = this.#recordByJobId(jobId);
|
|
654
705
|
if (!rec) return;
|
|
@@ -967,6 +1018,10 @@ export class AsyncJobManager {
|
|
|
967
1018
|
getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
|
|
968
1019
|
const deliveries = this.#filterDeliveries(filter);
|
|
969
1020
|
const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
|
|
1021
|
+
const ownerId = filter?.ownerId;
|
|
1022
|
+
const deadLettered = Array.from(this.#deadLetteredDeliveries.values()).filter(
|
|
1023
|
+
delivery => !ownerId || delivery.ownerId === ownerId,
|
|
1024
|
+
).length;
|
|
970
1025
|
const nextRetryAt = deliveries.reduce<number | undefined>((next, delivery) => {
|
|
971
1026
|
if (next === undefined) return delivery.nextAttemptAt;
|
|
972
1027
|
return Math.min(next, delivery.nextAttemptAt);
|
|
@@ -977,6 +1032,7 @@ export class AsyncJobManager {
|
|
|
977
1032
|
delivering: inFlightDeliveries.length > 0 || (this.#deliveryLoop !== undefined && deliveries.length > 0),
|
|
978
1033
|
nextRetryAt,
|
|
979
1034
|
pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
|
|
1035
|
+
deadLettered,
|
|
980
1036
|
};
|
|
981
1037
|
}
|
|
982
1038
|
|
|
@@ -1035,6 +1091,29 @@ export class AsyncJobManager {
|
|
|
1035
1091
|
}
|
|
1036
1092
|
}
|
|
1037
1093
|
|
|
1094
|
+
getLastDisposeDiagnostics(): AsyncJobDisposeDiagnostics {
|
|
1095
|
+
return { ...this.#lastDisposeDiagnostics, stuckJobIds: [...this.#lastDisposeDiagnostics.stuckJobIds] };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async #waitForAllWithDeadline(timeoutMs: number): Promise<{ completed: boolean; stuckJobIds: string[] }> {
|
|
1099
|
+
const jobs = Array.from(this.#jobs.values());
|
|
1100
|
+
if (jobs.length === 0) return { completed: true, stuckJobIds: [] };
|
|
1101
|
+
let timedOut = false;
|
|
1102
|
+
await Promise.race([
|
|
1103
|
+
Promise.allSettled(jobs.map(job => job.promise)),
|
|
1104
|
+
Bun.sleep(Math.max(0, timeoutMs)).then(() => {
|
|
1105
|
+
timedOut = true;
|
|
1106
|
+
}),
|
|
1107
|
+
]);
|
|
1108
|
+
if (!timedOut) return { completed: true, stuckJobIds: [] };
|
|
1109
|
+
return {
|
|
1110
|
+
completed: false,
|
|
1111
|
+
stuckJobIds: Array.from(this.#jobs.values())
|
|
1112
|
+
.filter(job => job.status === "running" || job.status === "cancelled")
|
|
1113
|
+
.map(job => job.id),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1038
1117
|
async waitForAll(): Promise<void> {
|
|
1039
1118
|
await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
|
|
1040
1119
|
}
|
|
@@ -1102,12 +1181,18 @@ export class AsyncJobManager {
|
|
|
1102
1181
|
}
|
|
1103
1182
|
}
|
|
1104
1183
|
this.#monitorTombstones.clear();
|
|
1105
|
-
|
|
1106
|
-
const
|
|
1184
|
+
const timeoutMs = options?.timeoutMs ?? 3_000;
|
|
1185
|
+
const waitResult = await this.#waitForAllWithDeadline(timeoutMs);
|
|
1186
|
+
const drained = waitResult.completed ? await this.drainDeliveries({ timeoutMs }) : false;
|
|
1187
|
+
this.#lastDisposeDiagnostics = { stuckJobIds: waitResult.stuckJobIds, deliveriesDrained: drained };
|
|
1188
|
+
if (waitResult.stuckJobIds.length > 0) {
|
|
1189
|
+
logger.warn("Async job manager dispose timed out waiting for jobs", { stuckJobIds: waitResult.stuckJobIds });
|
|
1190
|
+
}
|
|
1107
1191
|
this.#clearEvictionTimers();
|
|
1108
1192
|
this.#jobs.clear();
|
|
1109
1193
|
this.#deliveries.length = 0;
|
|
1110
1194
|
this.#inFlightDeliveries.length = 0;
|
|
1195
|
+
this.#deadLetteredDeliveries.clear();
|
|
1111
1196
|
this.#suppressedDeliveries.clear();
|
|
1112
1197
|
this.#watchedJobs.clear();
|
|
1113
1198
|
this.#outputState.clear();
|
|
@@ -1119,7 +1204,7 @@ export class AsyncJobManager {
|
|
|
1119
1204
|
this.#resumeQueue.length = 0;
|
|
1120
1205
|
this.#notifyChange();
|
|
1121
1206
|
this.#changeListeners.clear();
|
|
1122
|
-
return drained;
|
|
1207
|
+
return drained && waitResult.completed;
|
|
1123
1208
|
}
|
|
1124
1209
|
|
|
1125
1210
|
#resolveJobId(preferredId?: string): string {
|
|
@@ -1148,16 +1233,10 @@ export class AsyncJobManager {
|
|
|
1148
1233
|
}
|
|
1149
1234
|
|
|
1150
1235
|
#scheduleEviction(jobId: string): void {
|
|
1236
|
+
if (this.#disposed) return;
|
|
1151
1237
|
this.#notifyChange();
|
|
1152
1238
|
if (this.#retentionMs <= 0) {
|
|
1153
|
-
this.#
|
|
1154
|
-
this.#runLifecycle(jobId, "evict");
|
|
1155
|
-
this.#jobs.delete(jobId);
|
|
1156
|
-
this.#lifecycles.delete(jobId);
|
|
1157
|
-
this.#lifecyclePhases.delete(jobId);
|
|
1158
|
-
this.#suppressedDeliveries.delete(jobId);
|
|
1159
|
-
this.#watchedJobs.delete(jobId);
|
|
1160
|
-
this.#outputState.delete(jobId);
|
|
1239
|
+
this.#evictJob(jobId);
|
|
1161
1240
|
return;
|
|
1162
1241
|
}
|
|
1163
1242
|
const existing = this.#evictionTimers.get(jobId);
|
|
@@ -1166,20 +1245,25 @@ export class AsyncJobManager {
|
|
|
1166
1245
|
}
|
|
1167
1246
|
const timer = setTimeout(() => {
|
|
1168
1247
|
this.#evictionTimers.delete(jobId);
|
|
1169
|
-
this.#
|
|
1170
|
-
this.#runLifecycle(jobId, "evict");
|
|
1171
|
-
this.#jobs.delete(jobId);
|
|
1172
|
-
this.#lifecycles.delete(jobId);
|
|
1173
|
-
this.#lifecyclePhases.delete(jobId);
|
|
1174
|
-
this.#suppressedDeliveries.delete(jobId);
|
|
1175
|
-
this.#watchedJobs.delete(jobId);
|
|
1176
|
-
this.#outputState.delete(jobId);
|
|
1248
|
+
this.#evictJob(jobId);
|
|
1177
1249
|
this.#notifyChange();
|
|
1178
1250
|
}, this.#retentionMs);
|
|
1179
1251
|
timer.unref();
|
|
1180
1252
|
this.#evictionTimers.set(jobId, timer);
|
|
1181
1253
|
}
|
|
1182
1254
|
|
|
1255
|
+
#evictJob(jobId: string): void {
|
|
1256
|
+
this.#recordMonitorTombstone(jobId);
|
|
1257
|
+
this.#runLifecycle(jobId, "evict");
|
|
1258
|
+
this.#purgeTerminalSubagentStateForJob(jobId);
|
|
1259
|
+
this.#jobs.delete(jobId);
|
|
1260
|
+
this.#lifecycles.delete(jobId);
|
|
1261
|
+
this.#lifecyclePhases.delete(jobId);
|
|
1262
|
+
this.#suppressedDeliveries.delete(jobId);
|
|
1263
|
+
this.#watchedJobs.delete(jobId);
|
|
1264
|
+
this.#outputState.delete(jobId);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1183
1267
|
#clearEvictionTimers(): void {
|
|
1184
1268
|
for (const timer of this.#evictionTimers.values()) {
|
|
1185
1269
|
clearTimeout(timer);
|
|
@@ -1245,17 +1329,38 @@ export class AsyncJobManager {
|
|
|
1245
1329
|
if (this.isDeliverySuppressed(jobId)) {
|
|
1246
1330
|
return;
|
|
1247
1331
|
}
|
|
1332
|
+
const deliveryText = this.#boundedDeliveryText(text);
|
|
1248
1333
|
this.#deliveries.push({
|
|
1249
1334
|
jobId,
|
|
1250
|
-
text,
|
|
1335
|
+
text: deliveryText.text,
|
|
1336
|
+
originalBytes: deliveryText.originalBytes,
|
|
1337
|
+
truncated: deliveryText.truncated,
|
|
1251
1338
|
attempt: 0,
|
|
1252
1339
|
nextAttemptAt: Date.now(),
|
|
1253
1340
|
ownerId: this.#jobs.get(jobId)?.ownerId,
|
|
1254
1341
|
});
|
|
1342
|
+
while (this.#deliveries.length > DEFAULT_MAX_DELIVERY_QUEUE) {
|
|
1343
|
+
const dropped = this.#deliveries.shift();
|
|
1344
|
+
if (dropped) this.#deadLetteredDeliveries.set(dropped.jobId, dropped);
|
|
1345
|
+
}
|
|
1255
1346
|
this.#ensureDeliveryLoop();
|
|
1256
1347
|
}
|
|
1257
1348
|
|
|
1349
|
+
#boundedDeliveryText(text: string): { text: string; originalBytes?: number; truncated?: boolean } {
|
|
1350
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
1351
|
+
if (bytes <= DELIVERY_MAX_TEXT_BYTES) return { text };
|
|
1352
|
+
const head = sliceTextToUtf8ByteLength(text, DELIVERY_PREVIEW_HEAD_BYTES);
|
|
1353
|
+
const tailStart = Math.max(0, bytes - DELIVERY_PREVIEW_TAIL_BYTES);
|
|
1354
|
+
const tail = sliceTextAfterUtf8ByteOffset(text, tailStart);
|
|
1355
|
+
return {
|
|
1356
|
+
text: `${head}\n\n[async delivery output truncated from ${bytes} bytes]\n\n${tail}`,
|
|
1357
|
+
originalBytes: bytes,
|
|
1358
|
+
truncated: true,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1258
1362
|
#ensureDeliveryLoop(): void {
|
|
1363
|
+
if (this.#disposed) return;
|
|
1259
1364
|
if (this.#deliveryLoop) {
|
|
1260
1365
|
return;
|
|
1261
1366
|
}
|
|
@@ -1266,7 +1371,7 @@ export class AsyncJobManager {
|
|
|
1266
1371
|
})
|
|
1267
1372
|
.finally(() => {
|
|
1268
1373
|
this.#deliveryLoop = undefined;
|
|
1269
|
-
if (this.#deliveries.length > 0) {
|
|
1374
|
+
if (!this.#disposed && this.#deliveries.length > 0) {
|
|
1270
1375
|
this.#ensureDeliveryLoop();
|
|
1271
1376
|
}
|
|
1272
1377
|
});
|
|
@@ -1304,20 +1409,29 @@ export class AsyncJobManager {
|
|
|
1304
1409
|
} catch (error) {
|
|
1305
1410
|
delivery.attempt += 1;
|
|
1306
1411
|
delivery.lastError = error instanceof Error ? error.message : String(error);
|
|
1307
|
-
delivery.
|
|
1308
|
-
|
|
1309
|
-
|
|
1412
|
+
if (delivery.attempt >= DELIVERY_MAX_ATTEMPTS) {
|
|
1413
|
+
this.#deadLetteredDeliveries.set(delivery.jobId, delivery);
|
|
1414
|
+
logger.warn("Async job completion delivery reached retry cap", {
|
|
1415
|
+
jobId: delivery.jobId,
|
|
1416
|
+
attempt: delivery.attempt,
|
|
1417
|
+
error: delivery.lastError,
|
|
1418
|
+
});
|
|
1419
|
+
} else {
|
|
1420
|
+
delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
|
|
1421
|
+
if (!this.isDeliverySuppressed(delivery.jobId)) {
|
|
1422
|
+
this.#deliveries.push(delivery);
|
|
1423
|
+
}
|
|
1424
|
+
logger.warn("Async job completion delivery failed", {
|
|
1425
|
+
jobId: delivery.jobId,
|
|
1426
|
+
attempt: delivery.attempt,
|
|
1427
|
+
nextRetryAt: delivery.nextAttemptAt,
|
|
1428
|
+
error: delivery.lastError,
|
|
1429
|
+
});
|
|
1310
1430
|
}
|
|
1311
|
-
logger.warn("Async job completion delivery failed", {
|
|
1312
|
-
jobId: delivery.jobId,
|
|
1313
|
-
attempt: delivery.attempt,
|
|
1314
|
-
nextRetryAt: delivery.nextAttemptAt,
|
|
1315
|
-
error: delivery.lastError,
|
|
1316
|
-
});
|
|
1317
1431
|
} finally {
|
|
1318
1432
|
const index = this.#inFlightDeliveries.indexOf(delivery);
|
|
1319
1433
|
if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
|
|
1320
|
-
if (this.#deliveries.length > 0) this.#ensureDeliveryLoop();
|
|
1434
|
+
if (!this.#disposed && this.#deliveries.length > 0) this.#ensureDeliveryLoop();
|
|
1321
1435
|
}
|
|
1322
1436
|
})();
|
|
1323
1437
|
delivery.promise = promise;
|
package/src/config/file-lock.ts
CHANGED
|
@@ -101,7 +101,15 @@ function ownerLiveness(pid: number): OwnerLiveness {
|
|
|
101
101
|
|
|
102
102
|
async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
|
|
103
103
|
const info = await readLockInfo(lockPath);
|
|
104
|
-
if (!info)
|
|
104
|
+
if (!info) {
|
|
105
|
+
try {
|
|
106
|
+
const stats = await fs.stat(lockPath);
|
|
107
|
+
return Date.now() - stats.mtimeMs > staleMs;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (isEnoent(err)) return false;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
// Never reap a live owner by elapsed time: a long legitimate critical section must
|
|
107
115
|
// not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
|
package/src/dap/client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import { logger } from "@gajae-code/utils";
|
|
2
4
|
import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
|
|
3
5
|
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
|
6
|
+
import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
|
|
4
7
|
import { ToolAbortError } from "../tools/tool-errors";
|
|
5
8
|
import type {
|
|
6
9
|
DapCapabilities,
|
|
@@ -69,10 +72,21 @@ function toErrorMessage(value: unknown): string {
|
|
|
69
72
|
return String(value);
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
async function drainReadable(readable: ReadableStream<Uint8Array>): Promise<void> {
|
|
76
|
+
const reader = readable.getReader();
|
|
77
|
+
try {
|
|
78
|
+
while (!(await reader.read()).done) {}
|
|
79
|
+
} catch {
|
|
80
|
+
/* drain best-effort */
|
|
81
|
+
} finally {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
72
85
|
export class DapClient {
|
|
73
86
|
readonly adapter: DapResolvedAdapter;
|
|
74
87
|
readonly cwd: string;
|
|
75
88
|
readonly proc: DapClientState["proc"];
|
|
89
|
+
readonly #owner: OwnedProcess;
|
|
76
90
|
/** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */
|
|
77
91
|
readonly #readable: ReadableStream<Uint8Array>;
|
|
78
92
|
/** Write sink — proc.stdin (stdio) or a socket (socket mode). */
|
|
@@ -93,14 +107,15 @@ export class DapClient {
|
|
|
93
107
|
constructor(
|
|
94
108
|
adapter: DapResolvedAdapter,
|
|
95
109
|
cwd: string,
|
|
96
|
-
|
|
110
|
+
owner: OwnedProcess,
|
|
97
111
|
options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
|
|
98
112
|
) {
|
|
99
113
|
this.adapter = adapter;
|
|
100
114
|
this.cwd = cwd;
|
|
101
|
-
this.proc = proc;
|
|
102
|
-
this.#
|
|
103
|
-
this.#
|
|
115
|
+
this.proc = owner.child as DapClientState["proc"];
|
|
116
|
+
this.#owner = owner;
|
|
117
|
+
this.#readable = options?.readable ?? (this.proc.stdout as ReadableStream<Uint8Array>);
|
|
118
|
+
this.#writeSink = options?.writeSink ?? this.proc.stdin;
|
|
104
119
|
this.#socket = options?.socket;
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -116,13 +131,14 @@ export class DapClient {
|
|
|
116
131
|
...Bun.env,
|
|
117
132
|
...NON_INTERACTIVE_ENV,
|
|
118
133
|
};
|
|
119
|
-
const
|
|
134
|
+
const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args], {
|
|
120
135
|
cwd,
|
|
121
136
|
stdin: "pipe",
|
|
122
137
|
env,
|
|
123
|
-
|
|
138
|
+
name: `dap:${adapter.name}`,
|
|
124
139
|
});
|
|
125
|
-
const client = new DapClient(adapter, cwd,
|
|
140
|
+
const client = new DapClient(adapter, cwd, owner);
|
|
141
|
+
const proc = owner.child as DapClientState["proc"];
|
|
126
142
|
proc.exited.then(() => {
|
|
127
143
|
client.#handleProcessExit();
|
|
128
144
|
});
|
|
@@ -159,32 +175,40 @@ export class DapClient {
|
|
|
159
175
|
env: Record<string, string | undefined>;
|
|
160
176
|
}): Promise<DapClient> {
|
|
161
177
|
const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`;
|
|
162
|
-
const
|
|
178
|
+
const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
|
|
163
179
|
cwd,
|
|
164
180
|
stdin: "pipe",
|
|
165
181
|
env,
|
|
166
|
-
|
|
182
|
+
name: `dap:${adapter.name}:unix-socket`,
|
|
167
183
|
});
|
|
184
|
+
const proc = owner.child as DapClientState["proc"];
|
|
185
|
+
void drainReadable(proc.stdout);
|
|
186
|
+
let transport: SocketTransport | undefined;
|
|
168
187
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
10_000,
|
|
180
|
-
proc,
|
|
181
|
-
);
|
|
188
|
+
try {
|
|
189
|
+
// Wait for the socket file to appear (dlv needs to start listening)
|
|
190
|
+
await waitForCondition(
|
|
191
|
+
// `Bun.file(path).size` returns 0 for a missing file instead of
|
|
192
|
+
// throwing, so it can't gate socket readiness. Use an existence
|
|
193
|
+
// check so the adapter has actually created the listener socket.
|
|
194
|
+
() => existsSync(socketPath),
|
|
195
|
+
10_000,
|
|
196
|
+
proc,
|
|
197
|
+
);
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
transport = await connectSocket({ unix: socketPath }, 10_000);
|
|
200
|
+
const client = new DapClient(adapter, cwd, owner, transport);
|
|
201
|
+
proc.exited.then(() => client.#handleProcessExit());
|
|
202
|
+
void client.#startMessageReader();
|
|
203
|
+
return client;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
transport?.socket.end();
|
|
206
|
+
await owner.dispose();
|
|
207
|
+
await owner.awaitExit({ timeoutMs: 1_000 });
|
|
208
|
+
throw err;
|
|
209
|
+
} finally {
|
|
210
|
+
await fs.unlink(socketPath).catch(() => undefined);
|
|
211
|
+
}
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
/** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */
|
|
@@ -214,12 +238,14 @@ export class DapClient {
|
|
|
214
238
|
});
|
|
215
239
|
|
|
216
240
|
const port = server.port;
|
|
217
|
-
const
|
|
241
|
+
const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], {
|
|
218
242
|
cwd,
|
|
219
243
|
stdin: "pipe",
|
|
220
244
|
env,
|
|
221
|
-
|
|
245
|
+
name: `dap:${adapter.name}:client-addr`,
|
|
222
246
|
});
|
|
247
|
+
const proc = owner.child as DapClientState["proc"];
|
|
248
|
+
void drainReadable(proc.stdout);
|
|
223
249
|
|
|
224
250
|
// Wait for dlv to connect (with timeout)
|
|
225
251
|
let rawSocket: Bun.Socket<undefined>;
|
|
@@ -230,13 +256,17 @@ export class DapClient {
|
|
|
230
256
|
);
|
|
231
257
|
try {
|
|
232
258
|
rawSocket = await Promise.race([connPromise, timeoutPromise]);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
await owner.dispose();
|
|
261
|
+
await owner.awaitExit({ timeoutMs: 1_000 });
|
|
262
|
+
throw err;
|
|
233
263
|
} finally {
|
|
234
264
|
clearTimeout(connectTimeout);
|
|
235
265
|
server.stop();
|
|
236
266
|
}
|
|
237
267
|
|
|
238
268
|
const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
|
|
239
|
-
const client = new DapClient(adapter, cwd,
|
|
269
|
+
const client = new DapClient(adapter, cwd, owner, { readable, writeSink, socket });
|
|
240
270
|
proc.exited.then(() => client.#handleProcessExit());
|
|
241
271
|
void client.#startMessageReader();
|
|
242
272
|
return client;
|
|
@@ -414,14 +444,14 @@ export class DapClient {
|
|
|
414
444
|
/* socket may already be closed */
|
|
415
445
|
}
|
|
416
446
|
try {
|
|
417
|
-
this.
|
|
447
|
+
await this.#owner.dispose();
|
|
448
|
+
await this.#owner.awaitExit({ timeoutMs: 1_000 });
|
|
418
449
|
} catch (error) {
|
|
419
|
-
logger.debug("Failed to
|
|
450
|
+
logger.debug("Failed to dispose DAP adapter", {
|
|
420
451
|
adapter: this.adapter.name,
|
|
421
452
|
error: toErrorMessage(error),
|
|
422
453
|
});
|
|
423
454
|
}
|
|
424
|
-
await this.proc.exited.catch(() => {});
|
|
425
455
|
}
|
|
426
456
|
|
|
427
457
|
async #startMessageReader(): Promise<void> {
|
|
@@ -604,8 +634,8 @@ function socketToSink(socket: Bun.Socket<undefined>): DapWriteSink {
|
|
|
604
634
|
}
|
|
605
635
|
|
|
606
636
|
/** Connect to a unix domain socket and return DAP transport streams. */
|
|
607
|
-
async function connectSocket(options: { unix: string }): Promise<SocketTransport> {
|
|
608
|
-
const { promise, resolve } = Promise.withResolvers<SocketTransport>();
|
|
637
|
+
async function connectSocket(options: { unix: string }, timeoutMs = 10_000): Promise<SocketTransport> {
|
|
638
|
+
const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
|
|
609
639
|
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
|
610
640
|
|
|
611
641
|
const readable = new ReadableStream<Uint8Array>({
|
|
@@ -614,35 +644,46 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
|
|
|
614
644
|
},
|
|
615
645
|
});
|
|
616
646
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
streamController.
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
|
|
647
|
+
const timeout = setTimeout(() => reject(new Error(`Socket connect timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
648
|
+
let settled = false;
|
|
649
|
+
const settle = (fn: () => void) => {
|
|
650
|
+
if (settled) return;
|
|
651
|
+
settled = true;
|
|
652
|
+
clearTimeout(timeout);
|
|
653
|
+
fn();
|
|
654
|
+
};
|
|
655
|
+
try {
|
|
656
|
+
const socketPromise = Bun.connect({
|
|
657
|
+
unix: options.unix,
|
|
658
|
+
socket: {
|
|
659
|
+
open(socket) {
|
|
660
|
+
settle(() =>
|
|
661
|
+
resolve({
|
|
662
|
+
readable,
|
|
663
|
+
writeSink: socketToSink(socket),
|
|
664
|
+
socket,
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
},
|
|
668
|
+
data(_socket, data) {
|
|
669
|
+
streamController.enqueue(new Uint8Array(data));
|
|
670
|
+
},
|
|
671
|
+
close() {
|
|
672
|
+
try {
|
|
673
|
+
streamController.close();
|
|
674
|
+
} catch {
|
|
675
|
+
/* already closed */
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
error(_socket, err) {
|
|
679
|
+
settle(() => reject(err));
|
|
680
|
+
},
|
|
643
681
|
},
|
|
644
|
-
}
|
|
645
|
-
|
|
682
|
+
});
|
|
683
|
+
void socketPromise.catch(err => settle(() => reject(err)));
|
|
684
|
+
} catch (err) {
|
|
685
|
+
settle(() => reject(err));
|
|
686
|
+
}
|
|
646
687
|
|
|
647
688
|
return promise;
|
|
648
689
|
}
|