@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/feedback.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// BOT-2887: explicit feedback submission. The caller's file is the durable
|
|
2
|
+
// retry source; the idempotency key must be inside it before the first send.
|
|
3
|
+
import { lstat, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { CALL_EXIT, callToolJson, resolveCallAuth } from "./api.mjs";
|
|
8
|
+
import { containsFeedbackSecret } from "./feedback-safety.mjs";
|
|
9
|
+
import { restrictWindowsAcl, restrictWindowsAclDir, sessionPathIsConfidential } from "./agent-state-fs.mjs";
|
|
10
|
+
import { cacheBelongsToHarness, findAgentStateRoot, harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
|
|
11
|
+
import { hasPendingFeedback, queueFeedback, withFeedbackOutbox } from "./feedback-outbox.mjs";
|
|
12
|
+
import { CONDITION_TYPES } from "./wait-core.mjs";
|
|
13
|
+
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
16
|
+
const OPTIONAL_TEXT = new Set(["goal", "steps", "expected", "actual", "blocked_reason", "blocking_step", "safe_next_action", "environment", "operation", "component_version", "error_code", "correlation_id", "workaround", "cause_confidence", "ticket_ref"]);
|
|
17
|
+
const USAGE = `Usage: bb feedback init --file <report.json>
|
|
18
|
+
bb feedback init --file <report.json> --from-run <run_id>
|
|
19
|
+
bb feedback submit --file <report.json>
|
|
20
|
+
bb feedback enrich --feedback-id <id> --file <report.json>
|
|
21
|
+
bb feedback status --feedback-id <id>
|
|
22
|
+
bb feedback updates --feedback-id <id> [--after <revision>]
|
|
23
|
+
bb feedback acknowledge --feedback-id <id> --revision <revision>
|
|
24
|
+
bb feedback flush
|
|
25
|
+
bb feedback recover
|
|
26
|
+
|
|
27
|
+
init creates a private starter file without overwriting an existing file.
|
|
28
|
+
--from-run adds an exact status command and a bounded status observation from
|
|
29
|
+
the original command run. It never copies raw logs or restarts the workload.
|
|
30
|
+
Fill in the fields before submitting it. The JSON report must contain a stable
|
|
31
|
+
UUID idempotency_key, kind, surface, title and impact. Set kind to bug,
|
|
32
|
+
feature_request, improvement, friction, docs_gap or unknown; surface to mcp, cli, stack,
|
|
33
|
+
wait, web, helper, docs or other; and impact to blocked, workaround, slowed,
|
|
34
|
+
cosmetic or unknown. Unknown values keep the report in needs_context. Include your goal, steps, expected and actual outcomes,
|
|
35
|
+
evidence, why you are blocked and what you can safely do next. Keep the file
|
|
36
|
+
and its idempotency_key unchanged if delivery is uncertain; retry using the
|
|
37
|
+
same reporter and tenant. Tenant identity sharing follows the server's policy;
|
|
38
|
+
a report cannot override it.
|
|
39
|
+
|
|
40
|
+
An unavailable service does not mean a report was recorded. A failed send is
|
|
41
|
+
queued privately only when the original agent and tenant can be bound to the
|
|
42
|
+
cached session. Run bb feedback flush to replay those drafts after reconnecting.
|
|
43
|
+
Unbound drafts stay in their original file for a deliberate retry.`;
|
|
44
|
+
|
|
45
|
+
export async function cmdFeedback(args, { call = callToolJson, resolveAuth = resolveCallAuth, read = readFile, write = writeFile, makeDir = mkdir, uuid = randomUUID, inspectStatus = async (id) => (await import("./run.mjs")).statusRun(id), cwd = process.cwd(), loadState = readAgentState, rootFor = findAgentStateRoot, queue = queueFeedback, outbox = withFeedbackOutbox, hasPending = hasPendingFeedback, waitTypes = CONDITION_TYPES, platform = process.platform, restrictAcl = restrictWindowsAcl, restrictAclDir = restrictWindowsAclDir, confidential = sessionPathIsConfidential, log = console.log, errorLog = console.error } = {}) {
|
|
46
|
+
if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) { log(USAGE); return CALL_EXIT.OK; }
|
|
47
|
+
const cachedBinding = async (resolved) => {
|
|
48
|
+
const state = await loadState(cwd);
|
|
49
|
+
if (!state || !cacheBelongsToHarness(state, harnessSessionIdFromEnv()) || resolved?.sessionToken !== true ||
|
|
50
|
+
resolved.auth?.["x-agent-api-key"] !== state.agent_session_token || !UUID.test(state.agent_id ?? "") ||
|
|
51
|
+
!/^[a-z0-9][a-z0-9-]{0,63}$/.test(state.tenant ?? "")) return null;
|
|
52
|
+
return { agentId: state.agent_id, tenant: state.tenant };
|
|
53
|
+
};
|
|
54
|
+
if (args.length === 1 && args[0] === "flush") {
|
|
55
|
+
let delivered = 0;
|
|
56
|
+
const receipts = [];
|
|
57
|
+
const remember = (id) => { delivered++; receipts.push({ feedback_id: id, status_command: `bb feedback status --feedback-id ${id}`, updates_command: `bb feedback updates --feedback-id ${id}` }); };
|
|
58
|
+
try {
|
|
59
|
+
const resolved = await resolveAuth({ toolName: "submit_feedback", preferSession: true });
|
|
60
|
+
const binding = await cachedBinding(resolved);
|
|
61
|
+
if (!binding) { errorLog("Feedback replay requires the original worktree's bound agent session and tenant. Drafts remain queued."); return CALL_EXIT.AUTH; }
|
|
62
|
+
const identity = await call("whoami", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
63
|
+
if (!identity?.ok || identity.isError || identity.data?.credential_kind !== "agent_session" || identity.data?.agent?.id !== binding.agentId || identity.data?.tenant_id !== binding.tenant) {
|
|
64
|
+
errorLog("Feedback replay could not verify the original reporter and tenant. Drafts remain queued."); return CALL_EXIT.AUTH;
|
|
65
|
+
}
|
|
66
|
+
const root = await rootFor(cwd);
|
|
67
|
+
const result = await outbox(root, async (items, io) => {
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const record = await io.read(item);
|
|
70
|
+
if (record.agent_id !== binding.agentId || record.tenant !== binding.tenant) return { ok: false, error: "outbox_identity_mismatch", delivered, receipts, remaining: true };
|
|
71
|
+
const response = await call("submit_feedback", record.report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
72
|
+
const data = response?.data;
|
|
73
|
+
if (response?.ok && !response.isError && (response.status == null || (response.status >= 200 && response.status < 300)) && data?.ok === true && data.source_duplicate === true && typeof data.feedback_id === "string" && UUID.test(data.feedback_id)) {
|
|
74
|
+
const current = await call("get_feedback", { feedback_id: data.feedback_id }, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
75
|
+
const intended = Object.entries(record.report).filter(([key]) => key !== "idempotency_key");
|
|
76
|
+
if (current?.ok && !current.isError && (current.status == null || (current.status >= 200 && current.status < 300)) && current.data?.ok === true && current.data.feedback_id === data.feedback_id &&
|
|
77
|
+
current.data.report && intended.every(([key, value]) => isDeepStrictEqual(current.data.report[key], value))) {
|
|
78
|
+
remember(data.feedback_id); await io.remove(item); continue;
|
|
79
|
+
}
|
|
80
|
+
const draft = await io.exportDraft(record);
|
|
81
|
+
const argv = ["bb", "feedback", "enrich", "--feedback-id", data.feedback_id, "--file", draft];
|
|
82
|
+
return { ok: false, error: "source_already_reported", delivered, receipts, remaining: true, feedback_id: data.feedback_id,
|
|
83
|
+
status_command: `bb feedback status --feedback-id ${data.feedback_id}`, enrich_argv: argv,
|
|
84
|
+
enrich_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") };
|
|
85
|
+
}
|
|
86
|
+
if (!response?.ok || response.isError || (response.status != null && (response.status < 200 || response.status >= 300)) || data?.ok !== true ||
|
|
87
|
+
typeof data.feedback_id !== "string" || !UUID.test(data.feedback_id) || typeof data.occurrence_id !== "string" || !UUID.test(data.occurrence_id) || !["complete", "needs_context"].includes(data.capture_state)) {
|
|
88
|
+
return { ok: false, error: "delivery_unconfirmed", delivered, receipts, remaining: true,
|
|
89
|
+
...(typeof data?.feedback_id === "string" && UUID.test(data.feedback_id) ? { feedback_id: data.feedback_id, status_command: `bb feedback status --feedback-id ${data.feedback_id}` } : {}) };
|
|
90
|
+
}
|
|
91
|
+
remember(data.feedback_id);
|
|
92
|
+
await io.remove(item);
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, delivered, receipts, remaining: io.more, ...(io.more ? { continue_command: "bb feedback flush" } : {}) };
|
|
95
|
+
});
|
|
96
|
+
log(JSON.stringify(result));
|
|
97
|
+
if (!result.ok) errorLog("Feedback replay stopped without deleting the unconfirmed draft.");
|
|
98
|
+
return result.ok ? CALL_EXIT.OK : CALL_EXIT.ERROR;
|
|
99
|
+
} catch {
|
|
100
|
+
log(JSON.stringify({ ok: false, error: "replay_unavailable", delivered, receipts, remaining: true, retry_command: "bb feedback flush" }));
|
|
101
|
+
errorLog("Feedback replay stopped. Check the receipts and retry: bb feedback flush");
|
|
102
|
+
return CALL_EXIT.ERROR;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (args.length === 1 && args[0] === "recover") {
|
|
106
|
+
let outboxRecovery = null;
|
|
107
|
+
try {
|
|
108
|
+
if (await hasPending(await rootFor(cwd))) {
|
|
109
|
+
let receipt = null;
|
|
110
|
+
const code = await cmdFeedback(["flush"], { call, resolveAuth, read, write, makeDir, uuid, inspectStatus, cwd, loadState, rootFor, queue, outbox, hasPending,
|
|
111
|
+
log: (line) => { try { receipt = JSON.parse(line); } catch { /* retain unknown */ } }, errorLog: () => {} });
|
|
112
|
+
outboxRecovery = { ok: code === 0 && receipt?.ok === true, delivered: receipt?.delivered ?? null, receipts: receipt?.receipts ?? null,
|
|
113
|
+
remaining: code !== 0 || receipt?.remaining === true, ...(code !== 0 || receipt?.remaining === true ? { retry_command: "bb feedback flush" } : {}),
|
|
114
|
+
...(receipt?.error === "source_already_reported" ? { error: receipt.error, feedback_id: receipt.feedback_id, status_command: receipt.status_command, enrich_argv: receipt.enrich_argv, enrich_command: receipt.enrich_command } : {}) };
|
|
115
|
+
}
|
|
116
|
+
} catch { outboxRecovery = { ok: false, delivered: null, receipts: null, remaining: true, retry_command: "bb feedback flush" }; }
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
const resolved = await resolveAuth({ toolName: "recover_feedback_sources", preferSession: true });
|
|
120
|
+
result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call("recover_feedback_sources", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
121
|
+
} catch { result = { ok: false, status: null }; }
|
|
122
|
+
const data = result?.data;
|
|
123
|
+
if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || data?.ok !== true || !Number.isInteger(data.captured) || data.captured < 0 || data.captured > 5 || typeof data.complete !== "boolean" || !["none", "more_sources", "retry_later"].includes(data.remaining) || data.complete !== (data.remaining === "none")) {
|
|
124
|
+
if (outboxRecovery) log(JSON.stringify({ ok: false, cloud_recovery: "unconfirmed", outbox_recovery: outboxRecovery, all_complete: false, retry_command: "bb feedback recover" }));
|
|
125
|
+
errorLog("Feedback recovery was not confirmed. Preserve the original run; retry bb feedback recover using the same reporter and tenant.");
|
|
126
|
+
return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
|
|
127
|
+
}
|
|
128
|
+
log(JSON.stringify({ ...data, ...(outboxRecovery ? { outbox_recovery: outboxRecovery, all_complete: data.complete && outboxRecovery.ok && !outboxRecovery.remaining } : {}), list_command: "bb call list_feedback", ...(data.remaining === "more_sources" ? { continue_command: "bb feedback recover" } : {}), ...(data.remaining === "retry_later" ? { retry_command: "bb feedback recover" } : {}) }));
|
|
129
|
+
return outboxRecovery && !outboxRecovery.ok ? CALL_EXIT.ERROR : CALL_EXIT.OK;
|
|
130
|
+
}
|
|
131
|
+
if (["status", "updates", "acknowledge"].includes(args[0])) {
|
|
132
|
+
const kind = args[0];
|
|
133
|
+
const validId = args[1] === "--feedback-id" && UUID.test(args[2] ?? "");
|
|
134
|
+
const cursorText = args[4];
|
|
135
|
+
const validCursor = typeof cursorText === "string" && /^(0|[1-9][0-9]*)$/.test(cursorText) && Number.isSafeInteger(Number(cursorText)) && Number(cursorText) <= 2147483647;
|
|
136
|
+
const valid = validId && (kind === "status" ? args.length === 3 : kind === "updates" ? args.length === 3 || (args.length === 5 && args[3] === "--after" && validCursor) : args.length === 5 && args[3] === "--revision" && validCursor && Number(cursorText) > 0);
|
|
137
|
+
if (!valid) { errorLog(USAGE); return 2; }
|
|
138
|
+
const tool = kind === "status" ? "get_feedback" : kind === "updates" ? "list_feedback_updates" : "acknowledge_feedback_update";
|
|
139
|
+
const input = { feedback_id: args[2], ...(kind === "updates" && args.length === 5 ? { after_revision: Number(cursorText) } : {}), ...(kind === "acknowledge" ? { revision: Number(cursorText) } : {}) };
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
const resolved = await resolveAuth({ toolName: tool, preferSession: true });
|
|
143
|
+
result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call(tool, input, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
144
|
+
} catch { result = { ok: false, status: null }; }
|
|
145
|
+
const data = result?.data;
|
|
146
|
+
const receiptValid = data?.ok === true && typeof data.feedback_id === "string" && data.feedback_id.toLowerCase() === args[2].toLowerCase() && (kind === "status" || (kind === "updates" ? Array.isArray(data.items) && data.items.every((item) => Number.isInteger(item?.revision) && item.revision > 0 && item.revision <= 2147483647) && typeof data.has_more === "boolean" && Number.isInteger(data.next_after_revision) && data.next_after_revision >= 0 && data.next_after_revision <= 2147483647 : data.revision === Number(cursorText) && typeof data.acknowledged_at === "string"));
|
|
147
|
+
if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || !receiptValid) {
|
|
148
|
+
errorLog(kind === "acknowledge" ? "Acknowledgement was not confirmed; check updates before retrying." : "Feedback status is unavailable; retry using the same reporter and tenant.");
|
|
149
|
+
return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
|
|
150
|
+
}
|
|
151
|
+
if (kind === "updates") {
|
|
152
|
+
const items = data.items.map((item) => ({ ...item, acknowledge_command: `bb feedback acknowledge --feedback-id ${args[2]} --revision ${item.revision}` }));
|
|
153
|
+
const next = data.has_more ? `bb feedback updates --feedback-id ${args[2]} --after ${data.next_after_revision}` : null;
|
|
154
|
+
const waitCommand = !data.has_more && waitTypes.includes("feedback") ? `bb wait 'feedback:id=${args[2].toLowerCase()},after=${data.next_after_revision}'` : null;
|
|
155
|
+
log(JSON.stringify({ ...data, items, next_command: next, ...(waitCommand ? { after_consuming_wait_command: waitCommand } : {}) }));
|
|
156
|
+
} else log(JSON.stringify(data));
|
|
157
|
+
return CALL_EXIT.OK;
|
|
158
|
+
}
|
|
159
|
+
const enrich = args.length === 5 && args[0] === "enrich" && args[1] === "--feedback-id" && UUID.test(args[2] ?? "") && args[3] === "--file";
|
|
160
|
+
const fromRun = args.length === 5 && args[0] === "init" && args[1] === "--file" && args[3] === "--from-run" && RUN_ID.test(args[4] ?? "");
|
|
161
|
+
const filename = enrich ? args[4] : args[2];
|
|
162
|
+
if ((!enrich && !fromRun && (args.length !== 3 || !["init", "submit"].includes(args[0]) || args[1] !== "--file")) || !filename || filename.startsWith("-")) {
|
|
163
|
+
errorLog(USAGE); return 2;
|
|
164
|
+
}
|
|
165
|
+
if (args[0] === "init") {
|
|
166
|
+
const template = { idempotency_key: uuid(), kind: "", surface: "", title: "", impact: "", goal: "", steps: "", expected: "", actual: "", blocked_reason: "", blocking_step: "", can_continue: null, safe_next_action: "", ticket_ref: "", evidence: [] };
|
|
167
|
+
if (fromRun) {
|
|
168
|
+
let status;
|
|
169
|
+
try { status = await inspectStatus(args[4]); } catch { errorLog("The original run status is unavailable; no feedback file was created. Preserve the original run and retry."); return CALL_EXIT.TRANSPORT; }
|
|
170
|
+
const allowed = ["running", "completed", "completed_locally_remote_conflict", "completed_locally_remote_stale", "completed_locally_remote_unknown", "unknown"];
|
|
171
|
+
if (!status || status.run_id !== args[4] || !allowed.includes(status.classification)) { errorLog("The original run status could not be verified; no feedback file was created."); return CALL_EXIT.ERROR; }
|
|
172
|
+
template.surface = "cli";
|
|
173
|
+
template.operation = "bb run";
|
|
174
|
+
template.correlation_id = args[4];
|
|
175
|
+
const local = status.local;
|
|
176
|
+
const localOutcome = status.source_bound === true && ["success", "failure", "timed_out", "interrupted", "cancelled"].includes(local?.status)
|
|
177
|
+
? ` Local outcome: ${local.status}${Number.isSafeInteger(local.exit_code) && local.exit_code >= 0 && local.exit_code <= 255 ? ` (exit ${local.exit_code})` : ""}.`
|
|
178
|
+
: "";
|
|
179
|
+
template.actual = `Original command run status: ${status.classification}.${localOutcome}`;
|
|
180
|
+
template.evidence = [{ type: "command", value: `bb run status ${args[4]}` }];
|
|
181
|
+
const counts = status.test_counts;
|
|
182
|
+
if (counts && typeof counts === "object" && [counts.passed, counts.failed, counts.remaining].every((value) => value === null || (Number.isSafeInteger(value) && value >= 0)) && (counts.passed !== null || counts.failed !== null)) {
|
|
183
|
+
template.evidence.push({ type: "text", value: `Observed test counts: passed=${counts.passed ?? "unknown"}, failed=${counts.failed ?? "unknown"}, remaining=${counts.remaining ?? "unknown"}. Counts are observations and retries may change them.` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (platform === "win32") {
|
|
187
|
+
// A mode bit does not protect Windows files. The parent must be private
|
|
188
|
+
// before path-based ACL checks, so another user cannot replace the file.
|
|
189
|
+
// Do not change ACLs of an existing user-selected directory.
|
|
190
|
+
let handle, created = false;
|
|
191
|
+
try {
|
|
192
|
+
const parent = dirname(filename);
|
|
193
|
+
const made = await makeDir(parent, { recursive: true, mode: 0o700 });
|
|
194
|
+
if (made) await restrictAclDir(parent);
|
|
195
|
+
const info = await lstat(parent);
|
|
196
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !(await confidential(parent, info.mode, { platform }))) throw new Error("directory_not_private");
|
|
197
|
+
handle = await open(filename, "wx", 0o600); created = true;
|
|
198
|
+
await restrictAcl(filename);
|
|
199
|
+
const fileInfo = await handle.stat();
|
|
200
|
+
if (!(await confidential(filename, fileInfo.mode, { platform }))) throw new Error("file_not_private");
|
|
201
|
+
await handle.writeFile(JSON.stringify(template, null, 2) + "\n");
|
|
202
|
+
await handle.sync();
|
|
203
|
+
await handle.close(); handle = null;
|
|
204
|
+
} catch {
|
|
205
|
+
if (handle) await handle.close().catch(() => {});
|
|
206
|
+
if (created) await unlink(filename).catch(() => {});
|
|
207
|
+
const retryPath = join(".botbuddy", `feedback-${template.idempotency_key}`, "report.json");
|
|
208
|
+
const retry = `bb feedback init --file "${retryPath}"${fromRun ? ` --from-run ${args[4]}` : ""}`;
|
|
209
|
+
errorLog(`Could not create a private feedback file. Existing files are never overwritten. Retry in a new private directory: ${retry}`); return 2;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
try { await makeDir(dirname(filename), { recursive: true, mode: 0o700 }); await write(filename, JSON.stringify(template, null, 2) + "\n", { flag: "wx", mode: 0o600 }); }
|
|
213
|
+
catch { errorLog("Could not create the feedback file; an existing file is never overwritten."); return 2; }
|
|
214
|
+
}
|
|
215
|
+
const argv = ["bb", "feedback", "submit", "--file", filename];
|
|
216
|
+
log(JSON.stringify({ ok: true, file: filename, state: "needs_context", submit_argv: argv, submit_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") }));
|
|
217
|
+
return CALL_EXIT.OK;
|
|
218
|
+
}
|
|
219
|
+
let report;
|
|
220
|
+
try { report = JSON.parse(await read(filename, "utf8")); }
|
|
221
|
+
catch { errorLog("Could not read the feedback file as JSON; the file was not submitted."); return 2; }
|
|
222
|
+
if (!report || typeof report !== "object" || Array.isArray(report) || typeof report.idempotency_key !== "string" || !UUID.test(report.idempotency_key)) {
|
|
223
|
+
errorLog("The feedback file must contain a stable UUID idempotency_key; the file was not submitted."); return 2;
|
|
224
|
+
}
|
|
225
|
+
// Blank template slots represent missing context. The server accepts omitted
|
|
226
|
+
// fields for partial capture, but rejects explicitly blank strings.
|
|
227
|
+
report = Object.fromEntries(Object.entries(report).filter(([key, value]) => !(OPTIONAL_TEXT.has(key) && typeof value === "string" && !value.trim()) && !(key === "can_continue" && value === null)));
|
|
228
|
+
if (containsFeedbackSecret(report)) {
|
|
229
|
+
errorLog("The feedback file contains a possible credential, sensitive header or signed URL; remove it before sending.");
|
|
230
|
+
return 2;
|
|
231
|
+
}
|
|
232
|
+
let result, resolved;
|
|
233
|
+
const tool = enrich ? "enrich_feedback" : "submit_feedback";
|
|
234
|
+
const patch = { ...report };
|
|
235
|
+
delete patch.idempotency_key;
|
|
236
|
+
try {
|
|
237
|
+
resolved = await resolveAuth({ toolName: tool, preferSession: true });
|
|
238
|
+
result = resolved?.error || !resolved?.auth ? { ok: false, auth: true, error: "not_authenticated" } : await call(tool, enrich ? { feedback_id: args[2], patch } : report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
|
|
239
|
+
}
|
|
240
|
+
catch { result = { ok: false, status: null, error: "transport_unavailable" }; }
|
|
241
|
+
if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || result.data?.ok !== true || typeof result.data.feedback_id !== "string" || !UUID.test(result.data.feedback_id) ||
|
|
242
|
+
typeof result.data.occurrence_id !== "string" || !UUID.test(result.data.occurrence_id) || !["complete", "needs_context"].includes(result.data.capture_state)) {
|
|
243
|
+
const reported = result?.auth ? "authentication_failed" : result?.data?.error ?? result?.code ?? result?.error;
|
|
244
|
+
const code = typeof reported === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(reported) ? reported : "feedback_receipt_unavailable";
|
|
245
|
+
const retryArgv = enrich ? null : ["bb", "feedback", "submit", "--file", filename];
|
|
246
|
+
const retryCommand = retryArgv ? retryArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") : null;
|
|
247
|
+
const existingId = code === "source_already_reported" && typeof result?.data?.existing_feedback_id === "string" && UUID.test(result.data.existing_feedback_id) ? result.data.existing_feedback_id : null;
|
|
248
|
+
const existingEnrichArgv = existingId ? ["bb", "feedback", "enrich", "--feedback-id", existingId, "--file", filename] : null;
|
|
249
|
+
let queued = null, outboxReason = null;
|
|
250
|
+
if (!enrich && !result?.auth && !result?.isError && (result?.status == null || result.status >= 500)) {
|
|
251
|
+
try { const binding = await cachedBinding(resolved); if (binding) queued = await queue({ root: await rootFor(cwd), ...binding, report }); else outboxReason = "reporter_identity_unavailable"; }
|
|
252
|
+
catch (error) { outboxReason = typeof error?.message === "string" && /^outbox_[a-z_]+$/.test(error.message) ? error.message : "outbox_unavailable"; }
|
|
253
|
+
}
|
|
254
|
+
log(JSON.stringify({ ok: false, error: code, recorded: existingId ? "not_changed" : "unknown", retry_source: filename, retry_argv: retryArgv, retry_command: retryCommand,
|
|
255
|
+
...(queued ? { queued_locally: true, flush_command: queued.flush_command } : {}),
|
|
256
|
+
...(outboxReason ? { queued_locally: false, outbox_reason: outboxReason } : {}),
|
|
257
|
+
...(existingEnrichArgv ? { existing_feedback_id: existingId, enrich_argv: existingEnrichArgv, enrich_command: existingEnrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}),
|
|
258
|
+
...(result?.auth ? { diagnostic_argv: ["bb", "doctor"], diagnostic_command: "bb doctor" } : {}) }));
|
|
259
|
+
errorLog(existingId ? "This source already has another report. The update was not applied; use the exact enrich command in the receipt to update that report." : enrich ? "The update was not confirmed. Read the feedback before deciding whether to apply it again." : "Feedback was not confirmed. Preserve the file and idempotency_key; retry using the same reporter and tenant.");
|
|
260
|
+
return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
|
|
261
|
+
}
|
|
262
|
+
const sourceDuplicate = result.data.source_duplicate === true && result.data.submitted_context_saved === false;
|
|
263
|
+
const enrichArgv = result.data.capture_state === "needs_context" || sourceDuplicate ? ["bb", "feedback", "enrich", "--feedback-id", result.data.feedback_id, "--file", filename] : null;
|
|
264
|
+
const statusArgv = ["bb", "feedback", "status", "--feedback-id", result.data.feedback_id];
|
|
265
|
+
const updatesArgv = ["bb", "feedback", "updates", "--feedback-id", result.data.feedback_id];
|
|
266
|
+
log(JSON.stringify({ ...result.data, ...(sourceDuplicate ? { ok: false, error: "source_already_reported" } : {}), status_argv: statusArgv, status_command: statusArgv.join(" "), updates_argv: updatesArgv, updates_command: updatesArgv.join(" "), ...(enrichArgv ? { enrich_argv: enrichArgv, enrich_command: enrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}) }));
|
|
267
|
+
if (sourceDuplicate) errorLog("This source already has feedback. The new file was not applied; use the exact enrich command in the receipt to add its context to the existing report.");
|
|
268
|
+
return sourceDuplicate ? CALL_EXIT.ERROR : CALL_EXIT.OK;
|
|
269
|
+
}
|
package/src/locks.mjs
CHANGED
|
@@ -152,3 +152,108 @@ export function buildAcquireResourcesPayload(args, options = {}) {
|
|
|
152
152
|
...(environment ? { environment } : {}),
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
+
|
|
156
|
+
// BOT-2994: `bb lock`, `bb locks` and `bb unlock` exit by what the server did, not
|
|
157
|
+
// by whether it answered. Before this every one of them exited 0, so a shell loop
|
|
158
|
+
// that trusted the exit code drove another agent's Playwright lane (ENT-6416).
|
|
159
|
+
// 0 = acquired (or released / withdrawn); 2 = queued behind a holder (you hold
|
|
160
|
+
// nothing yet; wait for the grant or withdraw); 1 = refused or not understood.
|
|
161
|
+
// Auth (3) and transport (5) exits from callTool pass through unchanged.
|
|
162
|
+
export const LOCK_EXIT = Object.freeze({ ACQUIRED: 0, REFUSED: 1, QUEUED: 2 });
|
|
163
|
+
|
|
164
|
+
function parseJson(text) {
|
|
165
|
+
try { return JSON.parse(text); } catch { return undefined; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toolText(response) {
|
|
169
|
+
const content = response?.result?.content;
|
|
170
|
+
if (!Array.isArray(content)) return null;
|
|
171
|
+
const text = content.map((part) => part?.text ?? "").join("\n");
|
|
172
|
+
return text || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Classify a lock tool response (acquire_lock, acquire_resources, release_lock,
|
|
177
|
+
* retire_queued_lock_attempt) as returned by callTool: the JSON-RPC body, or a
|
|
178
|
+
* numeric exit when callTool already classified an auth/transport failure.
|
|
179
|
+
* Fails closed: an error flag, `success: false`, or an unreadable result is
|
|
180
|
+
* never "acquired".
|
|
181
|
+
*/
|
|
182
|
+
export function classifyLockResponse(response) {
|
|
183
|
+
if (typeof response === "number") return { exit: response, results: [] };
|
|
184
|
+
const isError = response?.result?.isError === true;
|
|
185
|
+
const text = toolText(response);
|
|
186
|
+
if (text === null) return { exit: LOCK_EXIT.REFUSED, results: [] };
|
|
187
|
+
const parsed = parseJson(text);
|
|
188
|
+
let results;
|
|
189
|
+
if (parsed && Array.isArray(parsed.results)) {
|
|
190
|
+
results = parsed.results.map((r) => ({ ...r, success: r?.success === true }));
|
|
191
|
+
} else if (parsed && typeof parsed === "object" && "success" in parsed) {
|
|
192
|
+
results = [{ ...parsed, resource_name: parsed.resource_name ?? parsed.resource, success: parsed.success === true && !isError }];
|
|
193
|
+
} else {
|
|
194
|
+
// The human success string (acquire_lock / release_lock) or a plain error text.
|
|
195
|
+
results = [{ success: !isError, message: text }];
|
|
196
|
+
}
|
|
197
|
+
const failed = results.filter((r) => !r.success);
|
|
198
|
+
let exit = LOCK_EXIT.ACQUIRED;
|
|
199
|
+
if (failed.some((r) => r.queued !== true)) exit = LOCK_EXIT.REFUSED;
|
|
200
|
+
else if (failed.length) exit = LOCK_EXIT.QUEUED;
|
|
201
|
+
else if (isError || !results.length) exit = LOCK_EXIT.REFUSED;
|
|
202
|
+
return { exit, results };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function holderLabel(holder) {
|
|
206
|
+
if (!holder || typeof holder !== "object") return null;
|
|
207
|
+
const who = holder.agent ?? holder.agent_name ?? holder.name ?? null;
|
|
208
|
+
if (!who) return null;
|
|
209
|
+
const ticket = holder.ticket_id ? `, ${holder.ticket_id}` : "";
|
|
210
|
+
return `${who}${ticket}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// `bb wait 'lock:…,claim=true'` only addresses typed resources
|
|
214
|
+
// (`<subtype>:<host>:<slot>`); an untyped name gets no wait line.
|
|
215
|
+
function claimWaitFor(name) {
|
|
216
|
+
const parts = typeof name === "string" ? name.split(":") : [];
|
|
217
|
+
if (parts.length !== 3 || parts.some((p) => !p)) return null;
|
|
218
|
+
const [subtype, host, slot] = parts;
|
|
219
|
+
return `bb wait 'lock:subtype=${subtype},host=${host},slot=${slot},claim=true' --heartbeat`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The stderr lines that tell the caller what it holds and what to do next. */
|
|
223
|
+
export function lockOutcomeLines({ exit, results }) {
|
|
224
|
+
if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
|
|
225
|
+
const lines = [`✗ Lock not acquired (exit ${exit}: ${exit === LOCK_EXIT.QUEUED ? "queued" : "refused"}). Do not use ${results.length > 1 ? "these resources" : "this resource"} until a lock command exits 0.`];
|
|
226
|
+
for (const r of results.filter((x) => !x.success)) {
|
|
227
|
+
const name = r.resource_name ?? r.resource ?? "resource";
|
|
228
|
+
const holder = holderLabel(r.holder);
|
|
229
|
+
if (r.queued === true) {
|
|
230
|
+
lines.push(` ${name}: queued${holder ? ` behind ${holder}` : ""}${r.queue_depth != null ? ` (queue depth ${r.queue_depth})` : ""}.`);
|
|
231
|
+
const wait = claimWaitFor(name);
|
|
232
|
+
if (wait) lines.push(` wait to be handed it: ${wait}`);
|
|
233
|
+
if (r.resource_id && r.resource_lock_id) lines.push(` or withdraw the request: bb unlock --queued ${r.resource_id} ${r.resource_lock_id}`);
|
|
234
|
+
} else {
|
|
235
|
+
lines.push(` ${name}: refused${holder ? ` (held by ${holder})` : ""}${r.message ? ` — ${r.message}` : ""}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const held = results.filter((r) => r.success && r.resource_name);
|
|
239
|
+
if (held.length && results.length > 1) {
|
|
240
|
+
lines.push(" Acquired in this batch (release what you will not use):");
|
|
241
|
+
for (const r of held) lines.push(` bb unlock ${r.resource_name}`);
|
|
242
|
+
}
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `bb unlock` refused: say how a queued caller withdraws instead. */
|
|
247
|
+
export function unlockOutcomeLines({ exit, results }, { queued = false } = {}) {
|
|
248
|
+
if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
|
|
249
|
+
// The first line is the server's reason; later lines are generic guidance.
|
|
250
|
+
const why = results.find((r) => r.message)?.message?.split("\n")[0];
|
|
251
|
+
if (queued) return [`✗ Queued request not withdrawn${why ? `: ${why}` : ""}.`];
|
|
252
|
+
return [
|
|
253
|
+
`✗ Lock not released${why ? `: ${why}` : ""}.`,
|
|
254
|
+
" bb unlock <name> releases a lock you hold; it does not withdraw a queued request.",
|
|
255
|
+
" To withdraw one, use the ids from the queued lock result: bb unlock --queued <resource_id> <resource_lock_id>",
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const UNLOCK_QUEUED_USAGE = "Usage: botbuddy unlock --queued <resource_id> <resource_lock_id>";
|
package/src/pw/run.mjs
CHANGED
|
@@ -92,7 +92,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
92
92
|
// callerIds/holder let staging-auth cleanup touch only what this caller installed.
|
|
93
93
|
const callerIds = [...selfAgentIds];
|
|
94
94
|
if (status.held && selfAgentIds.has(status.heldBy)) return { allowed: true, coordinator, canonicalHost, holderMatch: "self", holder: status.heldBy, callerIds };
|
|
95
|
-
if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, callerIds };
|
|
95
|
+
if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, holderName: status.heldByName ?? null, callerIds };
|
|
96
96
|
// Distinguish an expired/free lane from one another operator holds so the
|
|
97
97
|
// operator knows whether to re-acquire vs. wait for a handover (BOT-660). Name it
|
|
98
98
|
// by the server's canonical host so a not-held message matches the dashboard even
|
|
@@ -296,6 +296,23 @@ async function runPwInner(argv, deps = {}) {
|
|
|
296
296
|
// Report the host the lock service actually stored (alias-collapsed), so the
|
|
297
297
|
// status view and lane telemetry match the server, not the local string (AC-4).
|
|
298
298
|
const effHost = auth.canonicalHost ?? host;
|
|
299
|
+
// BOT-2994: a lane is driven only by the agent that holds it. The gate also
|
|
300
|
+
// passes a same-owner holder (BOT-1490 owner_is_caller), and every agent one
|
|
301
|
+
// person runs shares that owner, so an agent whose `bb lock` had only queued
|
|
302
|
+
// drove another agent's browser (ENT-6416, 2026-11-03). Resolve an owner-only
|
|
303
|
+
// match to the exact agent once (server whoami); only read-only `status` may
|
|
304
|
+
// proceed without it. `auth` keeps its own staging refusal receipt below.
|
|
305
|
+
let holderMatch = auth.holderMatch;
|
|
306
|
+
if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
|
|
307
|
+
const me = await authCtx.resolveCallerId().catch(() => null);
|
|
308
|
+
if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
|
|
309
|
+
}
|
|
310
|
+
if (holderMatch === "owner" && plan.mode !== "status" && plan.mode !== "auth") {
|
|
311
|
+
const lane = `playwright_lane:${effHost}:${plan.lane}`;
|
|
312
|
+
const who = `${auth.holder ?? "unknown"}${auth.holderName ? ` (${auth.holderName})` : ""}`;
|
|
313
|
+
stderr.write(`bb-pw: lane lock ${lane} is held by ${who}, another agent session of your owner, not this agent session. A lane is driven only by the agent that holds it. Claim a lane from this session with bb locks -m --lane <n> --ticket <ticket> and use it only after that exits 0, or wait to be handed this one: bb wait 'lock:subtype=playwright_lane,host=${effHost},slot=${plan.lane},claim=true' --heartbeat\n`);
|
|
314
|
+
return 3;
|
|
315
|
+
}
|
|
299
316
|
// BOT-1318: resolve the lane's browser-acceptance tracker AFTER the gate (a
|
|
300
317
|
// refused/unverified lane writes no lifecycle) and never for unlocked local mode.
|
|
301
318
|
let tracker = null;
|
|
@@ -312,11 +329,6 @@ async function runPwInner(argv, deps = {}) {
|
|
|
312
329
|
if (tracker && envResolution.error) { stderr.write(`${envResolution.error}\n`); return 2; }
|
|
313
330
|
const environment = envResolution.environment ?? null;
|
|
314
331
|
if (plan.mode === "auth") {
|
|
315
|
-
let holderMatch = auth.holderMatch;
|
|
316
|
-
if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
|
|
317
|
-
const me = await authCtx.resolveCallerId().catch(() => null);
|
|
318
|
-
if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
|
|
319
|
-
}
|
|
320
332
|
if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
|
|
321
333
|
if (tracker) {
|
|
322
334
|
const browserSession = await tracker.status();
|