@botbuddy/cli 1.64.12 → 1.64.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/botbuddy.mjs +14 -2
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/commands.mjs +43 -10
- package/src/docker-hygiene.mjs +2 -1
- package/src/feedback-outbox.mjs +119 -0
- package/src/feedback-reminder.mjs +16 -0
- package/src/feedback-safety.mjs +31 -0
- package/src/feedback.integration.fixture.mjs +103 -0
- package/src/feedback.mjs +269 -0
- package/src/locks.mjs +105 -0
- package/src/pw/run.mjs +18 -6
- package/src/run.mjs +175 -6
- package/src/stack-run-outcome.mjs +3 -1
- package/src/stack.mjs +32 -5
- package/src/test/playwright-reporter.mjs +3 -1
- package/src/test-lane-events.mjs +8 -1
- package/src/wait-feedback-capture.mjs +62 -0
- package/src/wait.mjs +41 -0
package/src/run.mjs
CHANGED
|
@@ -102,6 +102,12 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
102
102
|
return { opts, command, errors };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
const FOREGROUND_INSPECTION = Object.freeze({
|
|
106
|
+
run_kind: "foreground_telemetry",
|
|
107
|
+
telemetry_status_command: "bb telemetry status",
|
|
108
|
+
inspection_note: "Preserve the original terminal output. bb run inspect and bb run status inspect detached command runs; bb telemetry status checks delivery, not the command outcome.",
|
|
109
|
+
});
|
|
110
|
+
|
|
105
111
|
function foregroundOutcome({ exitCode, signal, timedOut }) {
|
|
106
112
|
if (timedOut) return "timed_out";
|
|
107
113
|
if (signal) return "canceled";
|
|
@@ -179,7 +185,7 @@ export async function launchForegroundRun(argv, {
|
|
|
179
185
|
child = spawnImpl(command[0], command.slice(1), { cwd, detached: process.platform !== "win32", stdio: "inherit", windowsHide: true });
|
|
180
186
|
} catch (error) {
|
|
181
187
|
await telemetry.append({ ...base, action: "terminal", producer_event_id: `${runId}:terminal`, producer_sequence: 1, outcome: "crashed", diagnostic: { code: "spawn_failed", category: "spawn" } }).catch(() => {});
|
|
182
|
-
return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL } };
|
|
188
|
+
return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL, ...FOREGROUND_INSPECTION } };
|
|
183
189
|
}
|
|
184
190
|
let timedOut = false;
|
|
185
191
|
let killTimer;
|
|
@@ -212,7 +218,7 @@ export async function launchForegroundRun(argv, {
|
|
|
212
218
|
});
|
|
213
219
|
} catch { telemetryState = "degraded"; }
|
|
214
220
|
const exitCode = result.error ? EXIT.INTERNAL : timedOut ? signalExitCode("SIGTERM") : terminalSignal ? signalExitCode(terminalSignal) : result.exitCode ?? EXIT.INTERNAL;
|
|
215
|
-
return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode } };
|
|
221
|
+
return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode, ...FOREGROUND_INSPECTION } };
|
|
216
222
|
}
|
|
217
223
|
|
|
218
224
|
export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
|
|
@@ -292,6 +298,7 @@ async function inspectRunEvidence(runId, {
|
|
|
292
298
|
processAlive = workerProcessGroupAlive,
|
|
293
299
|
receiptFile = receiptPath,
|
|
294
300
|
contextFile = contextPath,
|
|
301
|
+
remoteTimeoutMs = 3000,
|
|
295
302
|
} = {}) {
|
|
296
303
|
if (typeof runId !== "string" || !runId.trim()) {
|
|
297
304
|
return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
|
|
@@ -310,9 +317,15 @@ async function inspectRunEvidence(runId, {
|
|
|
310
317
|
// A durable run belongs to the agent that launched it. Prefer its sealed
|
|
311
318
|
// session identity; older contexts must use a current worktree session and
|
|
312
319
|
// never silently fall back to an unrelated durable machine credential.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
320
|
+
let remoteResult = null, remoteTimer;
|
|
321
|
+
try {
|
|
322
|
+
remoteResult = await Promise.race([
|
|
323
|
+
callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
|
|
324
|
+
...(context ? { serverUrl: contextServerUrl(context) } : {}), signal: AbortSignal.timeout(remoteTimeoutMs),
|
|
325
|
+
}).catch(() => null),
|
|
326
|
+
new Promise((resolve) => { remoteTimer = setTimeout(() => resolve(null), remoteTimeoutMs); }),
|
|
327
|
+
]);
|
|
328
|
+
} finally { if (remoteTimer) clearTimeout(remoteTimer); }
|
|
316
329
|
const remote = publicRemoteSummary(remoteResult);
|
|
317
330
|
const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
|
|
318
331
|
const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
|
|
@@ -421,6 +434,124 @@ export async function inspectRun(runId, options = {}) {
|
|
|
421
434
|
},
|
|
422
435
|
};
|
|
423
436
|
}
|
|
437
|
+
|
|
438
|
+
function laneProgressFromText(raw) {
|
|
439
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
440
|
+
const { cases, malformed, catalogComplete } = parseLaneEvents(raw, { warn: () => {} });
|
|
441
|
+
if (malformed !== 0 || (!cases.size && !catalogComplete)) return null;
|
|
442
|
+
const counts = laneCaseCounts(cases);
|
|
443
|
+
return { passed: counts.passed, failed: counts.failed, executed: counts.passed + counts.failed, total: catalogComplete ? counts.case_count : null, remaining: catalogComplete ? counts.pending : null, observed_pending: counts.pending, skipped: counts.skipped,
|
|
444
|
+
reason: catalogComplete ? "complete_reporter_catalog" : "observed_cases_only_catalog_may_be_incomplete" };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A compact status for the original owned run. Only count explicit case
|
|
448
|
+
* events; a partially written catalog does not establish total remaining. */
|
|
449
|
+
export async function statusRun(runId, options = {}) {
|
|
450
|
+
const inspection = await inspectRun(runId, options);
|
|
451
|
+
let testCounts = { passed: null, failed: null, executed: null, total: null, remaining: null, reason: "producer_progress_unavailable" };
|
|
452
|
+
let eventsArgv = null;
|
|
453
|
+
let logArgv = null;
|
|
454
|
+
let elapsedSeconds = null;
|
|
455
|
+
let progressObservedAt = null;
|
|
456
|
+
if (inspection.classification === "running") {
|
|
457
|
+
const read = options.read ?? readFile;
|
|
458
|
+
const contextFile = options.contextFile ?? contextPath;
|
|
459
|
+
const contextRaw = await read(contextFile(inspection.run_id), "utf8").catch(() => null);
|
|
460
|
+
const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, inspection.run_id) : null;
|
|
461
|
+
const alive = options.processAlive ?? workerProcessGroupAlive;
|
|
462
|
+
const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
463
|
+
if (context && freshContext(context, options.now ?? Date.now()) && pids.some((pid) => alive(pid))) {
|
|
464
|
+
const started = Date.parse(context.launched_at ?? "");
|
|
465
|
+
const observedNow = options.now ?? Date.now();
|
|
466
|
+
if (Number.isFinite(started) && observedNow >= started) elapsedSeconds = Math.floor((observedNow - started) / 1000);
|
|
467
|
+
if (typeof context.test_run?.events_path === "string") {
|
|
468
|
+
const raw = await read(context.test_run.events_path, "utf8").catch(() => null);
|
|
469
|
+
if (typeof raw === "string") eventsArgv = ["cat", context.test_run.events_path];
|
|
470
|
+
testCounts = laneProgressFromText(raw) ?? testCounts;
|
|
471
|
+
if (testCounts.passed !== null) {
|
|
472
|
+
const info = await (options.stat ?? stat)(context.test_run.events_path).catch(() => null);
|
|
473
|
+
if (Number.isFinite(info?.mtimeMs) && info.mtimeMs >= 0 && info.mtimeMs <= observedNow) progressObservedAt = new Date(info.mtimeMs).toISOString();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const logPath = `${contextFile(inspection.run_id)}.live-log.json`;
|
|
477
|
+
const rawLog = await read(logPath, "utf8").catch(() => null);
|
|
478
|
+
const log = typeof rawLog === "string" ? parseOwnedRunFile(rawLog, inspection.run_id) : null;
|
|
479
|
+
if (log && /^[a-f0-9]{64}$/i.test(context.command_hash ?? "") && /^[a-f0-9]{40,64}$/i.test(context.source_sha ?? "") && log.command_hash === context.command_hash && log.source_sha === context.source_sha && typeof log.bounded_log === "string") logArgv = ["cat", logPath];
|
|
480
|
+
}
|
|
481
|
+
} else if (inspection.source_bound === true && typeof inspection.local?.receipt_path === "string") {
|
|
482
|
+
const read = options.read ?? readFile;
|
|
483
|
+
const raw = await read(inspection.local.receipt_path, "utf8").catch(() => null);
|
|
484
|
+
const receipt = typeof raw === "string" ? parseOwnedRunFile(raw, inspection.run_id) : null;
|
|
485
|
+
const start = Date.parse(receipt?.started_at ?? ""), end = Date.parse(receipt?.ended_at ?? "");
|
|
486
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) elapsedSeconds = Math.floor((end - start) / 1000);
|
|
487
|
+
const progress = receipt?.test_counts;
|
|
488
|
+
if (receipt?.command_hash === inspection.local.command_hash && receipt?.source_sha === inspection.local.source_sha && progress && [progress.passed, progress.failed, progress.observed_pending, progress.skipped].every((n) => Number.isSafeInteger(n) && n >= 0) &&
|
|
489
|
+
(progress.remaining === null || (Number.isSafeInteger(progress.remaining) && progress.remaining >= 0)) &&
|
|
490
|
+
["complete_reporter_catalog", "observed_cases_only_catalog_may_be_incomplete"].includes(progress.reason) &&
|
|
491
|
+
(progress.reason === "complete_reporter_catalog" ? progress.remaining === progress.observed_pending : progress.remaining === null)) {
|
|
492
|
+
testCounts = { passed: progress.passed, failed: progress.failed, executed: progress.passed + progress.failed, total: progress.reason === "complete_reporter_catalog" ? progress.passed + progress.failed + progress.observed_pending + progress.skipped : null, remaining: progress.remaining, observed_pending: progress.observed_pending, skipped: progress.skipped, reason: progress.reason };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const path = inspection.source_bound === true ? inspection.local?.receipt_path : null;
|
|
496
|
+
const receiptArgv = typeof path === "string" && path.length > 0 ? ["cat", path] : null;
|
|
497
|
+
const logReadArgv = (logArgv || receiptArgv) && inspection.run_id ? ["bb", "run", "logs", inspection.run_id, "--tail", "100"] : null;
|
|
498
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
499
|
+
return {
|
|
500
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
501
|
+
run_id: inspection.run_id ?? null,
|
|
502
|
+
classification: inspection.classification,
|
|
503
|
+
...(inspection.error ? { error: inspection.error } : {}),
|
|
504
|
+
source_bound: inspection.source_bound === true,
|
|
505
|
+
local: inspection.local ?? null,
|
|
506
|
+
remote: inspection.remote ?? null,
|
|
507
|
+
test_counts: testCounts,
|
|
508
|
+
elapsed_seconds: elapsedSeconds,
|
|
509
|
+
progress_observed_at: progressObservedAt,
|
|
510
|
+
current_batch: null,
|
|
511
|
+
current_batch_reason: "producer_batch_unavailable",
|
|
512
|
+
counts_basis: testCounts.passed === null ? null : "latest_observed_case_verdicts_retries_may_change",
|
|
513
|
+
receipt_argv: receiptArgv,
|
|
514
|
+
receipt_command: receiptArgv ? receiptArgv.map(quote).join(" ") : null,
|
|
515
|
+
test_events_argv: eventsArgv,
|
|
516
|
+
test_events_command: eventsArgv ? eventsArgv.map(quote).join(" ") : null,
|
|
517
|
+
live_log_argv: logArgv,
|
|
518
|
+
live_log_command: logArgv ? logArgv.map(quote).join(" ") : null,
|
|
519
|
+
log_read_argv: logReadArgv,
|
|
520
|
+
log_read_command: logReadArgv ? logReadArgv.map(quote).join(" ") : null,
|
|
521
|
+
recovery: inspection.recovery,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Read only the original source-bound local snapshot. Remote logs are not
|
|
526
|
+
* assumed available, and no command is rerun to obtain output. */
|
|
527
|
+
export async function logsRun(runId, { tail = 100, ...options } = {}) {
|
|
528
|
+
if (!Number.isSafeInteger(tail) || tail < 1 || tail > 1000) return { schema_version: RUN_SCHEMA_VERSION, available: false, error: "invalid_tail", classification: "rejected" };
|
|
529
|
+
const status = await statusRun(runId, options);
|
|
530
|
+
const statusArgv = status.run_id ? ["bb", "run", "status", status.run_id] : null;
|
|
531
|
+
const base = { schema_version: RUN_SCHEMA_VERSION, run_id: status.run_id, status_argv: statusArgv, status_command: statusArgv?.join(" ") ?? null };
|
|
532
|
+
if (status.classification === "rejected") return { ...base, available: false, classification: "rejected", error: status.error };
|
|
533
|
+
const path = status.live_log_argv?.[1] ?? status.receipt_argv?.[1];
|
|
534
|
+
if (!path) return { ...base, available: false, reason: "owned_log_unavailable", remote_logs: "unavailable" };
|
|
535
|
+
const read = options.read ?? readFile;
|
|
536
|
+
const raw = await read(path, "utf8").catch(() => null);
|
|
537
|
+
const snapshot = typeof raw === "string" ? parseOwnedRunFile(raw, status.run_id) : null;
|
|
538
|
+
let bound = false;
|
|
539
|
+
if (status.live_log_argv) {
|
|
540
|
+
const contextFile = options.contextFile ?? contextPath;
|
|
541
|
+
const contextRaw = await read(contextFile(status.run_id), "utf8").catch(() => null);
|
|
542
|
+
const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, status.run_id) : null;
|
|
543
|
+
const alive = options.processAlive ?? workerProcessGroupAlive;
|
|
544
|
+
const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
545
|
+
bound = Boolean(context && freshContext(context, options.now ?? Date.now()) && pids.some((pid) => alive(pid)) &&
|
|
546
|
+
/^[a-f0-9]{64}$/i.test(context.command_hash ?? "") && /^[a-f0-9]{40,64}$/i.test(context.source_sha ?? "") &&
|
|
547
|
+
snapshot?.command_hash === context.command_hash && snapshot?.source_sha === context.source_sha);
|
|
548
|
+
} else bound = status.source_bound && snapshot?.command_hash === status.local?.command_hash && snapshot?.source_sha === status.local?.source_sha;
|
|
549
|
+
if (!bound || typeof snapshot?.bounded_log !== "string") return { ...base, available: false, reason: "owned_log_changed_or_unavailable", remote_logs: "unavailable" };
|
|
550
|
+
const text = snapshot.bounded_log.slice(-8192);
|
|
551
|
+
const lines = text ? text.split(/\r?\n/) : [];
|
|
552
|
+
if (lines.at(-1) === "") lines.pop();
|
|
553
|
+
return { ...base, available: true, source: status.live_log_argv ? "local_live_snapshot" : "local_terminal_receipt", lines: lines.slice(-tail), truncated: lines.length > tail, remote_logs: "not_read" };
|
|
554
|
+
}
|
|
424
555
|
function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
|
|
425
556
|
|
|
426
557
|
async function markCancellation(contextFile, runId) {
|
|
@@ -644,6 +775,7 @@ export async function runWorker(contextFile, {
|
|
|
644
775
|
sleep, now, terminalBudgetMs, startDelivery,
|
|
645
776
|
// BOT-2530: worker liveness lease renewal cadence (tests inject a fake timer).
|
|
646
777
|
workerHeartbeatIntervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval,
|
|
778
|
+
liveLogIntervalMs = 1_000,
|
|
647
779
|
} = {}) {
|
|
648
780
|
const retry = { sleep, now, budgetMs: terminalBudgetMs, startDelivery };
|
|
649
781
|
const context = JSON.parse(await readFile(contextFile, "utf8"));
|
|
@@ -653,6 +785,7 @@ export async function runWorker(contextFile, {
|
|
|
653
785
|
const startedAt = new Date().toISOString();
|
|
654
786
|
let timedOut = false;
|
|
655
787
|
let child;
|
|
788
|
+
let liveLogTimer;
|
|
656
789
|
let cancellationStateWrite = Promise.resolve();
|
|
657
790
|
let contextWrite = Promise.resolve();
|
|
658
791
|
const persistWorkerContext = (state) => {
|
|
@@ -726,6 +859,20 @@ export async function runWorker(contextFile, {
|
|
|
726
859
|
const capture = boundedCapture();
|
|
727
860
|
child.stdout?.on("data", (chunk) => capture.append(chunk));
|
|
728
861
|
child.stderr?.on("data", (chunk) => capture.append(chunk));
|
|
862
|
+
// A bounded, mode-0600 snapshot gives the owner an exact local log command
|
|
863
|
+
// while the detached workload runs. It is advisory; failure to write it
|
|
864
|
+
// must not stop the workload or replace the terminal receipt.
|
|
865
|
+
let logWrites = Promise.resolve();
|
|
866
|
+
let loggedCharacters = -1;
|
|
867
|
+
const flushLiveLog = () => {
|
|
868
|
+
if (loggedCharacters === capture.characters) return;
|
|
869
|
+
loggedCharacters = capture.characters;
|
|
870
|
+
const snapshot = { run_id: context.run_id, command_hash: context.command_hash ?? null, source_sha: context.source_sha ?? null, observed_at: new Date().toISOString(), bounded_log: capture.output, output_characters: capture.characters };
|
|
871
|
+
logWrites = logWrites.then(() => writeJson(`${contextFile}.live-log.json`, snapshot)).catch(() => { loggedCharacters = -1; });
|
|
872
|
+
};
|
|
873
|
+
flushLiveLog();
|
|
874
|
+
liveLogTimer = setInterval(flushLiveLog, liveLogIntervalMs);
|
|
875
|
+
liveLogTimer.unref?.();
|
|
729
876
|
await persistWorkerContext({ workload_pid: child.pid, worker_state: "running" });
|
|
730
877
|
// BOT-1510: for a test lane, tail the reporter's NDJSON and flush per-case
|
|
731
878
|
// verdicts while the child runs. The test process never touches the network.
|
|
@@ -739,6 +886,9 @@ export async function runWorker(contextFile, {
|
|
|
739
886
|
const result = await completion;
|
|
740
887
|
clearTimeout(timer);
|
|
741
888
|
clearTimeout(killTimer);
|
|
889
|
+
clearInterval(liveLogTimer);
|
|
890
|
+
flushLiveLog();
|
|
891
|
+
await logWrites;
|
|
742
892
|
await cancellationStateWrite;
|
|
743
893
|
if (lane) await lane.stop();
|
|
744
894
|
const endedAt = new Date().toISOString();
|
|
@@ -750,6 +900,7 @@ export async function runWorker(contextFile, {
|
|
|
750
900
|
const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
|
|
751
901
|
return receipt;
|
|
752
902
|
} finally {
|
|
903
|
+
clearInterval(liveLogTimer);
|
|
753
904
|
if (workerLease) await workerLease.stop();
|
|
754
905
|
if (ownerHeartbeat) await ownerHeartbeat.stop();
|
|
755
906
|
signalEmitter.removeListener("SIGTERM", cancellationHandler);
|
|
@@ -1049,6 +1200,11 @@ async function finalize(context, state, call, retry = {}) {
|
|
|
1049
1200
|
const boundedLog = String(state.output ?? "").slice(-8192);
|
|
1050
1201
|
const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
|
|
1051
1202
|
const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
|
|
1203
|
+
if (typeof context.test_run?.events_path === "string") {
|
|
1204
|
+
const events = await readFile(context.test_run.events_path, "utf8").catch(() => null);
|
|
1205
|
+
const progress = laneProgressFromText(events);
|
|
1206
|
+
if (progress) receipt.test_counts = progress;
|
|
1207
|
+
}
|
|
1052
1208
|
await writeJson(context.receipt_path, receipt);
|
|
1053
1209
|
const bytes = await stat(context.receipt_path);
|
|
1054
1210
|
const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
|
|
@@ -1132,6 +1288,19 @@ export async function cmdRun(args) {
|
|
|
1132
1288
|
process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
|
|
1133
1289
|
return;
|
|
1134
1290
|
}
|
|
1291
|
+
if (args[0] === "status") {
|
|
1292
|
+
const result = await statusRun(args[1] ?? "");
|
|
1293
|
+
oneLine(result, false);
|
|
1294
|
+
process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (args[0] === "logs") {
|
|
1298
|
+
const valid = args.length === 2 || (args.length === 4 && args[2] === "--tail" && /^(?:[1-9][0-9]{0,2}|1000)$/.test(args[3] ?? ""));
|
|
1299
|
+
const result = valid ? await logsRun(args[1] ?? "", { tail: args.length === 4 ? Number(args[3]) : 100 }) : { schema_version: RUN_SCHEMA_VERSION, available: false, classification: "rejected", error: "invalid_logs_arguments" };
|
|
1300
|
+
oneLine(result, false);
|
|
1301
|
+
process.exitCode = result.classification === "rejected" ? EXIT.INVALID : result.available ? EXIT.OK : EXIT.BACKEND;
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1135
1304
|
if (args[0] === "cancel") {
|
|
1136
1305
|
const result = await cancelRun(args[1] ?? "");
|
|
1137
1306
|
oneLine(result.receipt, false);
|
|
@@ -1139,7 +1308,7 @@ export async function cmdRun(args) {
|
|
|
1139
1308
|
return;
|
|
1140
1309
|
}
|
|
1141
1310
|
if (args[0] === "help" || args[0] === "--help") {
|
|
1142
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id
|
|
1311
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run status <run_id> Show observed counts and exact local log, test-event and receipt commands when available; counts may be unknown. Live logs are bounded private snapshots.\nbotbuddy run logs <run_id> --tail 100 Read the original owned local log snapshot; remote logs may be unavailable.\nbotbuddy run inspect <run_id> Inspect a detached command run; foreground telemetry IDs use the original terminal receipt.\nbotbuddy run cancel <run_id>\n bb telemetry status Check foreground telemetry delivery, not the command outcome.\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
1143
1312
|
return;
|
|
1144
1313
|
}
|
|
1145
1314
|
// BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
|
|
@@ -239,7 +239,9 @@ export async function reportStackRunOutcome({ fields, exitCode, runId, opts, env
|
|
|
239
239
|
const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
|
|
240
240
|
const result = await Promise.race([call.catch(() => null), timeout]);
|
|
241
241
|
if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
|
|
242
|
-
|
|
242
|
+
const capture = result?.data?.feedback_capture;
|
|
243
|
+
const feedbackCapture = result?.ok && !result?.isError && capture?.status === "captured" && typeof capture.feedback_id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(capture.feedback_id) ? { status: "captured", feedback_id: capture.feedback_id } : result?.ok && !result?.isError && capture?.status === "pending" ? { status: "pending" } : null;
|
|
244
|
+
return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload, ...(feedbackCapture ? { feedback_capture: feedbackCapture } : {}) };
|
|
243
245
|
} catch {
|
|
244
246
|
return { sent: false, reason: "error", payload };
|
|
245
247
|
} finally {
|
package/src/stack.mjs
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// `stop` itself and self-activates/finalizes via the fallback MCP tools.
|
|
29
29
|
|
|
30
30
|
import { writeLaneStatus } from "./lane-status.mjs";
|
|
31
|
+
import { markFeedbackGuidanceShown } from "./feedback-reminder.mjs";
|
|
31
32
|
import { spawn, spawnSync } from "child_process";
|
|
32
33
|
import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
|
|
33
34
|
import { open, unlink, readFile } from "fs/promises";
|
|
@@ -58,7 +59,7 @@ import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestatio
|
|
|
58
59
|
import { stackServicesForForwards } from "./stack-service-discovery.mjs";
|
|
59
60
|
import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
|
|
60
61
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
61
|
-
import { STACK_RELEASE_INTENT_ENV, observeStackRunSignal, ownerReleaseHeld, reportStackRunOutcome } from "./stack-run-outcome.mjs";
|
|
62
|
+
import { STACK_RELEASE_INTENT_ENV, classifyStackRunOutcome, observeStackRunSignal, ownerReleaseHeld, reportStackRunOutcome } from "./stack-run-outcome.mjs";
|
|
62
63
|
|
|
63
64
|
export const STACK_SCHEMA_VERSION = 1;
|
|
64
65
|
|
|
@@ -149,6 +150,10 @@ const STACK_READINESS_HELP = `${bold("READINESS")}
|
|
|
149
150
|
|
|
150
151
|
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
151
152
|
|
|
153
|
+
If BotBuddy blocks your work, run bb feedback init --file .botbuddy/feedback-report.json,
|
|
154
|
+
fill in the failure and evidence, then run bb feedback submit --file .botbuddy/feedback-report.json.
|
|
155
|
+
Preserve the original lease and do not cancel or repeat it merely to report the problem.
|
|
156
|
+
|
|
152
157
|
${bold("USAGE")}
|
|
153
158
|
botbuddy stack up [options] Request a lease; park if the host is full; hold it active
|
|
154
159
|
botbuddy stack status --admission-request-id <uuid>
|
|
@@ -887,7 +892,7 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
|
887
892
|
const r = { ...receipt };
|
|
888
893
|
if (r.connection !== undefined) r.connection = { connection_truncated: true };
|
|
889
894
|
if (enc(r) <= maxBytes) return r;
|
|
890
|
-
|
|
895
|
+
const minimal = {
|
|
891
896
|
schema_version: r.schema_version, command: r.command, outcome: r.outcome,
|
|
892
897
|
lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
|
|
893
898
|
error: r.error ?? null, truncated: true,
|
|
@@ -896,6 +901,8 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
|
896
901
|
...(r.cleanup_code ? { cleanup_code: r.cleanup_code } : {}),
|
|
897
902
|
...(r.cleanup_error ? { cleanup_error: String(r.cleanup_error).slice(0, 160) } : {}),
|
|
898
903
|
};
|
|
904
|
+
const withFeedback = r.feedback_recovery ? { ...minimal, feedback_recovery: r.feedback_recovery } : minimal;
|
|
905
|
+
return enc(withFeedback) <= maxBytes ? withFeedback : minimal;
|
|
899
906
|
}
|
|
900
907
|
|
|
901
908
|
/** Resolve the requested stack directory once, before it leaves the coding
|
|
@@ -1442,6 +1449,12 @@ const AGENT_DEEPLINK_REQUIRED_GUIDANCE = "the control plane refused this stack b
|
|
|
1442
1449
|
|
|
1443
1450
|
/** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
|
|
1444
1451
|
export function leaseRefusalError(data) {
|
|
1452
|
+
if (data?.code === "STALE_AGENT_CONTEXT") {
|
|
1453
|
+
// These identifiers are shown only in the local receipt; never copy an
|
|
1454
|
+
// arbitrary server string into it or into the separate telemetry envelope.
|
|
1455
|
+
const ticket = (value) => typeof value === "string" && /^[A-Z][A-Z0-9]{1,15}-[0-9]{1,12}$/.test(value) ? value : "unknown";
|
|
1456
|
+
return `STALE_AGENT_CONTEXT: registered ticket ${ticket(data.registered_ticket_id)}; requested ticket ${ticket(data.requested_ticket_id)}. No lease was created. Run \`bb help register_agent\` to inspect the supported session registration options before retrying.`;
|
|
1457
|
+
}
|
|
1445
1458
|
if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
|
|
1446
1459
|
return `${data.message || data.code}; lease ${data.lease_id} is ${data.state ?? "unknown"}. ` +
|
|
1447
1460
|
`Inspect with \`bb stack status ${data.lease_id}\`; after the owning batch ends, release with \`bb stack done ${data.lease_id} --reap\`, ` +
|
|
@@ -5326,7 +5339,7 @@ export function runReceiptFields(result) {
|
|
|
5326
5339
|
* or the returned exit code.
|
|
5327
5340
|
*/
|
|
5328
5341
|
export async function cmdRun(opts, childArgv, {
|
|
5329
|
-
lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
|
|
5342
|
+
lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env, feedbackLog = (message) => process.stderr.write(message), markFeedback = markFeedbackGuidanceShown,
|
|
5330
5343
|
} = {}) {
|
|
5331
5344
|
// A caller that needs a wait selects this immutable UUID before dispatch.
|
|
5332
5345
|
// Ordinary foreground runs still receive a unique telemetry id, but it is not
|
|
@@ -5335,9 +5348,23 @@ export async function cmdRun(opts, childArgv, {
|
|
|
5335
5348
|
const startedAt = now();
|
|
5336
5349
|
const result = await lifecycle(opts, childArgv);
|
|
5337
5350
|
const fields = runReceiptFields(result);
|
|
5338
|
-
const
|
|
5351
|
+
const receipt = buildReceipt(fields);
|
|
5352
|
+
const failureClass = classifyStackRunOutcome(stackRunOutcomeFields(result), result.exitCode);
|
|
5353
|
+
const feedbackEligible = failureClass.category === "platform" || (failureClass.category === "invocation" && failureClass.code !== "INVALID_ARGUMENTS");
|
|
5354
|
+
if (feedbackEligible) {
|
|
5355
|
+
receipt.feedback_recovery = { status: "not_yet_checked", available_after: "command_exits", recover_command: "bb feedback recover", list_command: "bb call list_feedback" };
|
|
5356
|
+
}
|
|
5357
|
+
const code = emit(receipt, opts, result.exitCode);
|
|
5339
5358
|
try {
|
|
5340
|
-
await report({ fields: stackRunOutcomeFields(result), exitCode: code, runId, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
5359
|
+
const outcome = await report({ fields: stackRunOutcomeFields(result), exitCode: code, runId, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
5360
|
+
const capture = outcome?.feedback_capture;
|
|
5361
|
+
if (!opts?.json && capture?.status === "captured" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(capture.feedback_id ?? "")) {
|
|
5362
|
+
feedbackLog(`BotBuddy retained a failure report. Check it: bb feedback status --feedback-id ${capture.feedback_id}\nCreate a private draft: bb feedback init --file bb-feedback.json\nEdit the draft, then add your explanation: bb feedback enrich --feedback-id ${capture.feedback_id} --file bb-feedback.json\n`);
|
|
5363
|
+
markFeedback();
|
|
5364
|
+
} else if (!opts?.json && (capture?.status === "pending" || (outcome?.sent === false && feedbackEligible))) {
|
|
5365
|
+
feedbackLog("Automatic feedback capture was not confirmed and may still complete. Recover using the same identity: bb feedback recover\nThen check existing reports: bb call list_feedback\nIf the source event was not recorded, create a report: bb feedback init --file bb-feedback.json\n");
|
|
5366
|
+
markFeedback();
|
|
5367
|
+
}
|
|
5341
5368
|
} catch { /* best-effort: the receipt and exit code are already final */ }
|
|
5342
5369
|
return code;
|
|
5343
5370
|
}
|
|
@@ -68,13 +68,15 @@ export default class BotBuddyPlaywrightReporter {
|
|
|
68
68
|
|
|
69
69
|
onBegin(_config, suite) {
|
|
70
70
|
if (!this._path) return;
|
|
71
|
-
const tests = typeof suite?.allTests === "function" ? suite.allTests() :
|
|
71
|
+
const tests = typeof suite?.allTests === "function" ? suite.allTests() : null;
|
|
72
|
+
if (!Array.isArray(tests)) return;
|
|
72
73
|
tests.forEach((t, i) => {
|
|
73
74
|
const key = caseKey(t);
|
|
74
75
|
this._order.set(key, i);
|
|
75
76
|
const title = (typeof t?.titlePath === "function" ? t.titlePath() : []).filter(Boolean).join(" › ") || key;
|
|
76
77
|
this._append({ type: "begin", key, title, sort_order: i });
|
|
77
78
|
});
|
|
79
|
+
this._append({ type: "catalog_complete", case_count: tests.length });
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
onTestEnd(test, result) {
|
package/src/test-lane-events.mjs
CHANGED
|
@@ -14,12 +14,19 @@ export const SYNC_BATCH = 500;
|
|
|
14
14
|
export function parseLaneEvents(text, { warn = (m) => process.stderr.write(m) } = {}) {
|
|
15
15
|
const cases = new Map();
|
|
16
16
|
let malformed = 0;
|
|
17
|
+
let catalogComplete = false;
|
|
17
18
|
for (const raw of String(text ?? "").split("\n")) {
|
|
18
19
|
const line = raw.trim();
|
|
19
20
|
if (!line) continue;
|
|
20
21
|
let ev;
|
|
21
22
|
try { ev = JSON.parse(line); } catch { malformed += 1; warn("[botbuddy] skipping corrupt lane event line\n"); continue; }
|
|
23
|
+
if (ev?.type === "catalog_complete") {
|
|
24
|
+
catalogComplete = Number.isSafeInteger(ev.case_count) && ev.case_count >= 0 && ev.case_count === cases.size;
|
|
25
|
+
if (!catalogComplete) malformed += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
22
28
|
if (!ev || typeof ev.key !== "string" || (ev.type !== "begin" && ev.type !== "verdict")) { malformed += 1; continue; }
|
|
29
|
+
if (!cases.has(ev.key)) catalogComplete = false;
|
|
23
30
|
const c = cases.get(ev.key) ?? { key: ev.key, title: ev.key, sort_order: cases.size, verdict: "pending", fail_reason: null, duration_ms: null };
|
|
24
31
|
if (typeof ev.title === "string" && ev.title) c.title = ev.title;
|
|
25
32
|
if (Number.isInteger(ev.sort_order)) c.sort_order = ev.sort_order;
|
|
@@ -30,7 +37,7 @@ export function parseLaneEvents(text, { warn = (m) => process.stderr.write(m) }
|
|
|
30
37
|
}
|
|
31
38
|
cases.set(ev.key, c);
|
|
32
39
|
}
|
|
33
|
-
return { cases, malformed };
|
|
40
|
+
return { cases, malformed, catalogComplete };
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
// Case map → the sync_test_run_cases payload, ordered by sort_order.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Retain only the fixed failure code, and only when this exact session token
|
|
2
|
+
// binds to the cached agent and tenant. Replay re-verifies both identities.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { cacheBelongsToHarness, findAgentStateRoot, harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
|
|
8
|
+
import { restrictWindowsAcl, restrictWindowsAclDir } from "./agent-state-fs.mjs";
|
|
9
|
+
import { queueFeedback } from "./feedback-outbox.mjs";
|
|
10
|
+
|
|
11
|
+
/** @param {any} options */
|
|
12
|
+
export async function captureWaitRegistrationFailure({ token, claim = false, cwd = process.cwd(),
|
|
13
|
+
loadState = readAgentState, rootFor = findAgentStateRoot, queue = queueFeedback,
|
|
14
|
+
harnessId = harnessSessionIdFromEnv(), uuid = randomUUID } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
const state = await loadState(cwd);
|
|
17
|
+
if (!state || !cacheBelongsToHarness(state, harnessId) ||
|
|
18
|
+
typeof token !== "string" || token !== state.agent_session_token ||
|
|
19
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(state.agent_id ?? "") ||
|
|
20
|
+
!/^[a-z0-9][a-z0-9-]{0,63}$/.test(state.tenant ?? "")) return false;
|
|
21
|
+
const code = claim ? "claim_registration_failed" : "register_failed";
|
|
22
|
+
const result = await queue({ root: await rootFor(cwd), agentId: state.agent_id, tenant: state.tenant,
|
|
23
|
+
report: { idempotency_key: uuid(), kind: "unknown", surface: "wait", impact: "unknown",
|
|
24
|
+
title: claim ? "Wait claim registration failed" : "Wait registration failed", error_code: code,
|
|
25
|
+
actual: claim ? "The claim wait could not confirm a registered session." : "The wait could not confirm a registered session.",
|
|
26
|
+
evidence: [{ type: "text", value: `Wait receipt error: ${code}` }] } });
|
|
27
|
+
return result?.queued === true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function captureClaimRegistrationFailure(options = {}) {
|
|
34
|
+
return captureWaitRegistrationFailure({ ...options, claim: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// With no proven reporter binding, save only fixed, non-identifying context.
|
|
38
|
+
// It is never replayed automatically: the agent must review it and choose its
|
|
39
|
+
// authenticated reporter and tenant before submitting.
|
|
40
|
+
export async function saveUnboundWaitDraft({ claim = false, tempRoot = tmpdir() } = {}) {
|
|
41
|
+
let directory;
|
|
42
|
+
try {
|
|
43
|
+
directory = await mkdtemp(join(tempRoot, "bb-feedback-wait-"));
|
|
44
|
+
if (process.platform === "win32") await restrictWindowsAclDir(directory);
|
|
45
|
+
const file = join(directory, "report.json");
|
|
46
|
+
const code = claim ? "claim_registration_failed" : "register_failed";
|
|
47
|
+
const report = {
|
|
48
|
+
idempotency_key: randomUUID(), kind: "unknown", surface: "wait", impact: "unknown",
|
|
49
|
+
title: claim ? "Wait claim registration failed" : "Wait registration failed",
|
|
50
|
+
error_code: code, goal: "", steps: "", expected: "",
|
|
51
|
+
actual: claim ? "The claim wait could not confirm a registered session." : "The wait could not confirm a registered session.",
|
|
52
|
+
blocked_reason: "", blocking_step: "", can_continue: null, safe_next_action: "", ticket_ref: "",
|
|
53
|
+
evidence: [{ type: "text", value: `Wait receipt error: ${code}` }],
|
|
54
|
+
};
|
|
55
|
+
await writeFile(file, `${JSON.stringify(report, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
56
|
+
if (process.platform === "win32") await restrictWindowsAcl(file);
|
|
57
|
+
return file;
|
|
58
|
+
} catch {
|
|
59
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/wait.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./
|
|
|
22
22
|
import { clearAgentState, hasAgentSessionRuntime, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
23
23
|
import { repairFailureRecovery } from "./agent-doctor.mjs";
|
|
24
24
|
import { touchAgentStateExpiry } from "./agent-state.mjs";
|
|
25
|
+
import { captureWaitRegistrationFailure, saveUnboundWaitDraft } from "./wait-feedback-capture.mjs";
|
|
26
|
+
import { markFeedbackGuidanceShown } from "./feedback-reminder.mjs";
|
|
25
27
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
26
28
|
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
27
29
|
import { realpath } from "node:fs/promises";
|
|
@@ -56,6 +58,10 @@ Park until a condition holds, then exit ONCE with one JSON receipt on stdout. Ze
|
|
|
56
58
|
and zero tokens while parked. Every wait — time included — is a bb wait: never sleep loops,
|
|
57
59
|
until/while polling, watch, or gh run watch. Full guide: docs/agent-wait.md.
|
|
58
60
|
|
|
61
|
+
If BotBuddy blocks your work, run bb feedback init --file .botbuddy/feedback-report.json,
|
|
62
|
+
fill in the failure and evidence, then run bb feedback submit --file .botbuddy/feedback-report.json.
|
|
63
|
+
Preserve the original wait and do not cancel or repeat it merely to report the problem.
|
|
64
|
+
|
|
59
65
|
USAGE
|
|
60
66
|
bb wait [--any] <condition>... [options] arm a wait (also: bb help wait)
|
|
61
67
|
bb wait <subcommand> … inspect or recover a saved wait (SUBCOMMANDS)
|
|
@@ -902,6 +908,12 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
|
|
|
902
908
|
process.stderr.write(`bb-wait: finalize (non-fatal) returned ${res.status}\n`);
|
|
903
909
|
return { delivered: false, error: body?.error || (res.ok ? "finalize_not_applied" : `finalize_http_${res.status}`) };
|
|
904
910
|
}
|
|
911
|
+
const capture = body?.feedback_capture;
|
|
912
|
+
if (capture?.status === "captured" && typeof capture.feedback_id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(capture.feedback_id)) {
|
|
913
|
+
process.stderr.write(`bb-wait: failure context retained as feedback ${capture.feedback_id}. Create the file, then set kind, surface to wait, title, impact, goal, reproduction, expected and actual outcomes, blocking reason, whether you can continue, safe next action and evidence before enriching:\n bb feedback init --file .botbuddy/feedback-report.json\n bb feedback enrich --feedback-id ${capture.feedback_id} --file .botbuddy/feedback-report.json\n`);
|
|
914
|
+
} else if (capture?.status === "pending") {
|
|
915
|
+
process.stderr.write("bb-wait: feedback capture is pending. After reconnecting with the same agent and tenant, run: bb feedback recover\n");
|
|
916
|
+
}
|
|
905
917
|
return { delivered: true };
|
|
906
918
|
} catch (err) {
|
|
907
919
|
process.stderr.write(`bb-wait: finalize (non-fatal) failed: ${err && err.message || err}\n`);
|
|
@@ -1619,10 +1631,32 @@ async function sameWorktree(first, second) {
|
|
|
1619
1631
|
catch { return false; }
|
|
1620
1632
|
}
|
|
1621
1633
|
|
|
1634
|
+
function writeWaitFeedbackGuidance() {
|
|
1635
|
+
markFeedbackGuidanceShown();
|
|
1636
|
+
process.stderr.write("bb-wait: If this error blocks your work, preserve the receipt and report your goal, expected and actual outcomes, why you are blocked and evidence:\n bb feedback init --file .botbuddy/feedback-report.json\n bb feedback submit --file .botbuddy/feedback-report.json\nEdit the report before submitting; these instructions do not record feedback. If a partial draft is saved or queued below, use it instead of creating a duplicate. If the file exists, choose a new filename in both commands.\n");
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
async function preserveWaitRegistrationFeedback(token, claim) {
|
|
1640
|
+
if (await captureWaitRegistrationFailure({ token, claim })) {
|
|
1641
|
+
process.stderr.write("bb-wait: A partial draft was queued for this agent and tenant. Do not submit a duplicate. Reconnect and run:\n bb feedback flush\nUse the feedback_id from its receipt. Create and edit the details file with your goal, expected and actual outcomes and why you are blocked, then enrich the same report:\n bb feedback init --file .botbuddy/feedback-details.json\n bb feedback enrich --feedback-id <feedback_id> --file .botbuddy/feedback-details.json\n");
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const file = await saveUnboundWaitDraft({ claim });
|
|
1645
|
+
if (!file) {
|
|
1646
|
+
process.stderr.write("bb-wait: Could not save a partial draft. Preserve the receipt and use the feedback init and submit commands above.\n");
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const quoted = process.platform === "win32"
|
|
1650
|
+
? `'${file.replace(/'/g, "''")}'`
|
|
1651
|
+
: `'${file.replace(/'/g, "'\\''")}'`;
|
|
1652
|
+
process.stderr.write(`bb-wait: A private partial draft was saved without a verified reporter or tenant. Review and complete ${quoted}; verify the reporter and tenant before submitting. The OS may clear temporary files. Run:\n bb call whoami\n bb feedback submit --file ${quoted}\n`);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1622
1655
|
async function emitSavedWaitError(error) {
|
|
1623
1656
|
const code = error?.code ?? "wait_checkpoint_invalid";
|
|
1624
1657
|
const exitCode = code === "wait_checkpoint_write_failed" ? EXIT.INTERNAL : EXIT.INVALID;
|
|
1625
1658
|
process.stderr.write(`bb-wait: ${error?.message ?? code}\n`);
|
|
1659
|
+
writeWaitFeedbackGuidance();
|
|
1626
1660
|
await writeWaitReceipt({
|
|
1627
1661
|
schema_version: 1,
|
|
1628
1662
|
outcome: "error",
|
|
@@ -1835,6 +1869,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1835
1869
|
// replayed — carries the lease-lost stderr line when it is a lease_lost terminal.
|
|
1836
1870
|
const emitReceipt = (receipt, options = {}) => {
|
|
1837
1871
|
writeLeaseLostNotice(receipt);
|
|
1872
|
+
if (receipt?.outcome === "error") writeWaitFeedbackGuidance();
|
|
1838
1873
|
return emit(receipt, { ...options, maxBytes: opts.receiptMaxBytes });
|
|
1839
1874
|
};
|
|
1840
1875
|
if (opts.help) return writeThenExit(HELP);
|
|
@@ -2302,6 +2337,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2302
2337
|
if (hasClaim && waitSessionId == null) {
|
|
2303
2338
|
process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
|
|
2304
2339
|
await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2340
|
+
await preserveWaitRegistrationFeedback(opts.token, true);
|
|
2305
2341
|
process.exit(EXIT.INTERNAL);
|
|
2306
2342
|
}
|
|
2307
2343
|
} catch (err) {
|
|
@@ -2456,6 +2492,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2456
2492
|
if (hasClaim) {
|
|
2457
2493
|
process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
2458
2494
|
await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2495
|
+
await preserveWaitRegistrationFeedback(opts.token, true);
|
|
2459
2496
|
process.exit(EXIT.INTERNAL);
|
|
2460
2497
|
}
|
|
2461
2498
|
// BOT-1338/1608: every non-timer wait is a relay-tracked, session-token wait.
|
|
@@ -2477,6 +2514,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2477
2514
|
null,
|
|
2478
2515
|
{ sessionTenant: sessionTenant ?? null, agentId: null },
|
|
2479
2516
|
));
|
|
2517
|
+
await preserveWaitRegistrationFeedback(opts.token, false);
|
|
2480
2518
|
process.exit(EXIT.INTERNAL);
|
|
2481
2519
|
}
|
|
2482
2520
|
}
|
|
@@ -2584,6 +2622,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2584
2622
|
// BOT-2360: likewise `abandoned` — the relay refused the stream because the row is ended.
|
|
2585
2623
|
if (waitSessionId && !SERVER_ENDED_OUTCOMES.has(receipt.outcome)) {
|
|
2586
2624
|
const finalized = await finalizeWait(opts, waitSessionId, terminalReceipt);
|
|
2625
|
+
if (!finalized.delivered && terminalReceipt.outcome === "error" && typeof checkpoint.local_wait_id === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(checkpoint.local_wait_id)) {
|
|
2626
|
+
process.stderr.write(`bb-wait: the Wait receipt is saved locally, but cloud finalization was not confirmed. With the same agent and tenant, recover the saved Wait and then recover feedback:\n bb wait resume ${checkpoint.local_wait_id}\n bb feedback recover\n`);
|
|
2627
|
+
}
|
|
2587
2628
|
try {
|
|
2588
2629
|
checkpoint = await store.update(checkpoint.local_wait_id, finalized.delivered
|
|
2589
2630
|
? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
|