@tablation/crew 0.127.10 → 0.128.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js
CHANGED
|
@@ -8280,897 +8280,371 @@ var BeforeFirstEditTracker = class {
|
|
|
8280
8280
|
};
|
|
8281
8281
|
|
|
8282
8282
|
// src/agent-log.ts
|
|
8283
|
-
import { existsSync as
|
|
8284
|
-
import { join as
|
|
8283
|
+
import { existsSync as existsSync23, mkdirSync as mkdirSync16, readdirSync as readdirSync7, readFileSync as readFileSync23, rmSync as rmSync13, writeFileSync as writeFileSync17 } from "fs";
|
|
8284
|
+
import { join as join25 } from "path";
|
|
8285
8285
|
|
|
8286
8286
|
// src/pending-backoff.ts
|
|
8287
8287
|
var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
|
|
8288
8288
|
|
|
8289
|
-
// src/
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8289
|
+
// src/ticket-branch.ts
|
|
8290
|
+
import { basename as basename7 } from "path";
|
|
8291
|
+
|
|
8292
|
+
// src/tracker.ts
|
|
8293
|
+
import { hostname } from "os";
|
|
8294
|
+
import { TablationClient } from "@tablation/client";
|
|
8295
|
+
|
|
8296
|
+
// src/work-mode.ts
|
|
8297
|
+
function projectWorkModes(rows, contract) {
|
|
8298
|
+
const out = /* @__PURE__ */ new Map();
|
|
8299
|
+
for (const r of rows) {
|
|
8300
|
+
const v = r[contract.columns.workMode];
|
|
8301
|
+
out.set(r.id, v === contract.manualWorkMode ? "manual" : v === contract.hybridWorkMode ? "hybrid" : "automatic");
|
|
8299
8302
|
}
|
|
8300
|
-
|
|
8301
|
-
async function request(target, method, path, body) {
|
|
8302
|
-
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
8303
|
-
const res = await fetch(url, {
|
|
8304
|
-
method,
|
|
8305
|
-
headers: {
|
|
8306
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
8307
|
-
"User-Agent": target.userAgent,
|
|
8308
|
-
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
8309
|
-
},
|
|
8310
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
8311
|
-
});
|
|
8312
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
8313
|
-
return await res.json();
|
|
8303
|
+
return out;
|
|
8314
8304
|
}
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
8318
|
-
let p = issueColumns.get(key);
|
|
8319
|
-
if (!p) {
|
|
8320
|
-
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
8321
|
-
p.catch(() => issueColumns.delete(key));
|
|
8322
|
-
issueColumns.set(key, p);
|
|
8323
|
-
}
|
|
8324
|
-
return p;
|
|
8305
|
+
function agentAssigneeIds(crewRows, isHoldRow) {
|
|
8306
|
+
return new Set(crewRows.filter((r) => !!r.agent_id && !isHoldRow(r.id)).map((r) => r.id));
|
|
8325
8307
|
}
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
conditions: [
|
|
8330
|
-
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
8331
|
-
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
8332
|
-
]
|
|
8333
|
-
};
|
|
8334
|
-
const rows = await request(
|
|
8335
|
-
target,
|
|
8336
|
-
"GET",
|
|
8337
|
-
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
8338
|
-
);
|
|
8339
|
-
return rows.length === 1 ? rows[0].id : void 0;
|
|
8308
|
+
function modeOf(t, wm) {
|
|
8309
|
+
if (!t.project_id) return "manual";
|
|
8310
|
+
return wm.modes.get(t.project_id) ?? "automatic";
|
|
8340
8311
|
}
|
|
8341
|
-
function
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
|
|
8352
|
-
|
|
8312
|
+
function withinWorkMode(tickets, wm) {
|
|
8313
|
+
if (!wm || wm.modes.size === 0) return tickets;
|
|
8314
|
+
return tickets.filter((t) => {
|
|
8315
|
+
const mode = modeOf(t, wm);
|
|
8316
|
+
if (mode === "automatic") return true;
|
|
8317
|
+
if (mode === "manual") return false;
|
|
8318
|
+
return !!t.assignee_id && wm.agentAssignees.has(t.assignee_id);
|
|
8319
|
+
});
|
|
8320
|
+
}
|
|
8321
|
+
function isAutomatic(t, wm) {
|
|
8322
|
+
if (!wm || wm.modes.size === 0) return true;
|
|
8323
|
+
return modeOf(t, wm) === "automatic";
|
|
8324
|
+
}
|
|
8325
|
+
function skippedByMode(tickets, wm) {
|
|
8326
|
+
const out = { manual: 0, hybrid: 0 };
|
|
8327
|
+
if (!wm || wm.modes.size === 0) return out;
|
|
8328
|
+
const kept = new Set(withinWorkMode(tickets, wm));
|
|
8329
|
+
for (const t of tickets) {
|
|
8330
|
+
if (kept.has(t)) continue;
|
|
8331
|
+
out[modeOf(t, wm) === "manual" ? "manual" : "hybrid"]++;
|
|
8353
8332
|
}
|
|
8354
8333
|
return out;
|
|
8355
8334
|
}
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8335
|
+
function withoutManualVerified(tickets, verifiedStatus, wm) {
|
|
8336
|
+
if (!wm || wm.modes.size === 0) return tickets;
|
|
8337
|
+
return tickets.filter((t) => t.status !== verifiedStatus || modeOf(t, wm) !== "manual");
|
|
8338
|
+
}
|
|
8339
|
+
function projectModeLabels(rows) {
|
|
8340
|
+
return rows.map((r) => {
|
|
8341
|
+
const name = r.name ?? r.id;
|
|
8342
|
+
return { id: r.id, name, mode: r.mode, label: `${name} (${r.mode})` };
|
|
8343
|
+
});
|
|
8344
|
+
}
|
|
8345
|
+
|
|
8346
|
+
// src/board-lock.ts
|
|
8347
|
+
import { StaleWriteError } from "@tablation/client";
|
|
8348
|
+
function releaseLockScope(route2, repo) {
|
|
8349
|
+
return `${route2}/${repo}`;
|
|
8350
|
+
}
|
|
8351
|
+
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs, log = {}) {
|
|
8352
|
+
if (!modelId) return { ok: true, release: async () => {
|
|
8353
|
+
}, exclusion: "off" };
|
|
8354
|
+
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
8355
|
+
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
8356
|
+
const row = rows[0];
|
|
8357
|
+
if (!row) return { ok: false, reason: "missing" };
|
|
8358
|
+
const age = Date.now() - new Date(row.updated_at).getTime();
|
|
8359
|
+
const staleOwn = !!row.holder && row.holder !== holderLabel && isDeadOwnClaim(row.holder, holderLabel);
|
|
8360
|
+
const free = !row.holder || row.holder === holderLabel || age > ttlMs || staleOwn;
|
|
8361
|
+
if (staleOwn) log.warn?.(`reclaimed stale own claim ${row.holder} on ${scope} (process is gone)`);
|
|
8362
|
+
if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
|
|
8363
|
+
let claimed;
|
|
8364
|
+
try {
|
|
8365
|
+
claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
|
|
8366
|
+
} catch (e) {
|
|
8367
|
+
if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
|
|
8368
|
+
throw e;
|
|
8369
|
+
}
|
|
8364
8370
|
return {
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8371
|
+
ok: true,
|
|
8372
|
+
exclusion: "taken",
|
|
8373
|
+
release: async () => {
|
|
8374
|
+
try {
|
|
8375
|
+
try {
|
|
8376
|
+
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
8377
|
+
} catch (e) {
|
|
8378
|
+
if (!(e instanceof StaleWriteError)) throw e;
|
|
8379
|
+
const [now] = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
8380
|
+
if (now?.holder === holderLabel) await client2.update(modelId, row.id, { holder: "" }, now.updated_at);
|
|
8381
|
+
}
|
|
8382
|
+
log.debug?.(`lock released for ${scope}`);
|
|
8383
|
+
} catch (e) {
|
|
8384
|
+
log.warn?.(`lock release failed for ${scope}: ${e.message}`);
|
|
8385
|
+
}
|
|
8386
|
+
}
|
|
8370
8387
|
};
|
|
8371
8388
|
}
|
|
8372
|
-
|
|
8389
|
+
function isDeadOwnClaim(holder, mine) {
|
|
8390
|
+
const split = (l) => {
|
|
8391
|
+
const i = l.lastIndexOf(":");
|
|
8392
|
+
return i < 0 ? null : { ship: l.slice(0, i), pid: Number(l.slice(i + 1)) };
|
|
8393
|
+
};
|
|
8394
|
+
const h = split(holder), m = split(mine);
|
|
8395
|
+
if (!h || !m || h.ship !== m.ship || !Number.isInteger(h.pid) || h.pid <= 0) return false;
|
|
8373
8396
|
try {
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
);
|
|
8379
|
-
const have = await columnsOf(target, model.id);
|
|
8380
|
-
const body = Object.fromEntries(Object.entries(beforeFirstEditBody(b)).filter(([column]) => have.has(column)));
|
|
8381
|
-
if (Object.keys(body).length === 0) return;
|
|
8382
|
-
await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, body);
|
|
8383
|
-
} catch {
|
|
8397
|
+
process.kill(h.pid, 0);
|
|
8398
|
+
return false;
|
|
8399
|
+
} catch (e) {
|
|
8400
|
+
return e.code === "ESRCH";
|
|
8384
8401
|
}
|
|
8385
8402
|
}
|
|
8386
|
-
async function
|
|
8387
|
-
const
|
|
8388
|
-
const
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
8392
|
-
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
8393
|
-
"User-Agent": target.userAgent,
|
|
8394
|
-
"Content-Type": "application/json"
|
|
8395
|
-
},
|
|
8396
|
-
body: JSON.stringify(body)
|
|
8397
|
-
});
|
|
8398
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
8399
|
-
return await res.json();
|
|
8403
|
+
async function lockRowState(client2, modelId, scope) {
|
|
8404
|
+
const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
8405
|
+
const row = rows[0];
|
|
8406
|
+
if (!row) return { exists: false };
|
|
8407
|
+
return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
|
|
8400
8408
|
}
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8409
|
+
var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
8410
|
+
async function ensureLockRows(client2, modelId, scopes) {
|
|
8411
|
+
const created = [];
|
|
8412
|
+
for (const scope of scopes) {
|
|
8413
|
+
const filters = scopeFilter(scope);
|
|
8414
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
8415
|
+
let mine;
|
|
8404
8416
|
try {
|
|
8405
|
-
|
|
8406
|
-
} catch {
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
ticketReference: run3.ticketReference,
|
|
8412
|
-
outcome: run3.outcome,
|
|
8413
|
-
startedAt: run3.startedAt,
|
|
8414
|
-
finishedAt: run3.finishedAt,
|
|
8415
|
-
source: "client",
|
|
8416
|
-
client: "crew",
|
|
8417
|
-
model: run3.model,
|
|
8418
|
-
tokensIn: run3.tokensIn,
|
|
8419
|
-
tokensOut: run3.tokensOut,
|
|
8420
|
-
cacheReadTokens: run3.cacheReadTokens,
|
|
8421
|
-
cacheWriteTokens: run3.cacheWriteTokens,
|
|
8422
|
-
costUsd: run3.costUsd,
|
|
8423
|
-
numTurns: run3.numTurns,
|
|
8424
|
-
prefixTokens: run3.prefixTokens,
|
|
8425
|
-
promptVersion: run3.promptVersion,
|
|
8426
|
-
promptSha: run3.promptSha
|
|
8427
|
-
};
|
|
8428
|
-
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
8429
|
-
let entry;
|
|
8430
|
-
try {
|
|
8431
|
-
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run3.harness, provider: run3.provider });
|
|
8432
|
-
} catch (e) {
|
|
8433
|
-
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
8434
|
-
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
8435
|
-
if (e.status !== 400 || !run3.harness && !run3.provider && !ticketRecordId) {
|
|
8436
|
-
e.row = true;
|
|
8417
|
+
mine = await client2.create(modelId, { scope, holder: "" });
|
|
8418
|
+
} catch (e) {
|
|
8419
|
+
const status2 = e.status;
|
|
8420
|
+
if (status2 === 409 || status2 === 400) {
|
|
8421
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
8422
|
+
}
|
|
8437
8423
|
throw e;
|
|
8438
8424
|
}
|
|
8439
|
-
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
throw e2;
|
|
8444
|
-
}
|
|
8445
|
-
}
|
|
8446
|
-
for (const cycle of run3.cycles) {
|
|
8447
|
-
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
8448
|
-
cycleIndex: cycle.cycleIndex,
|
|
8449
|
-
occurredAt: cycle.occurredAt,
|
|
8450
|
-
thinking: cycle.thinking,
|
|
8451
|
-
...cycle.role ? { role: cycle.role } : {}
|
|
8452
|
-
});
|
|
8453
|
-
}
|
|
8454
|
-
if (run3.beforeFirstEdit) await writeBeforeFirstEdit(target, entry.id, run3.beforeFirstEdit);
|
|
8455
|
-
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run3) : void 0;
|
|
8456
|
-
return {
|
|
8457
|
-
agentLogId: entry.id,
|
|
8458
|
-
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
8459
|
-
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
8460
|
-
};
|
|
8461
|
-
}
|
|
8462
|
-
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
8463
|
-
async function writeCounters(target, w) {
|
|
8464
|
-
const have = await columnsOf(target, w.issuesModelId);
|
|
8465
|
-
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
8466
|
-
if (Object.keys(body).length === 0) return;
|
|
8467
|
-
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
8468
|
-
}
|
|
8469
|
-
async function addRunToTicket(target, t, recordId, run3) {
|
|
8470
|
-
const write = {
|
|
8471
|
-
issuesModelId: t.issuesModelId,
|
|
8472
|
-
recordId,
|
|
8473
|
-
ticketReference: run3.ticketReference,
|
|
8474
|
-
counters: t.counters,
|
|
8475
|
-
body: counterIncrements(run3, t.counters, new Set(Object.values(t.counters)))
|
|
8476
|
-
};
|
|
8477
|
-
try {
|
|
8478
|
-
await writeCounters(target, write);
|
|
8479
|
-
return {};
|
|
8480
|
-
} catch (e) {
|
|
8481
|
-
return {
|
|
8482
|
-
warning: `could not add run spend to ${run3.ticketReference}: ${e.message}`,
|
|
8483
|
-
...isRetryable(e) ? { retry: write } : {}
|
|
8484
|
-
};
|
|
8485
|
-
}
|
|
8486
|
-
}
|
|
8487
|
-
var queueDir = (stateDir) => join23(stateDir, "pending-agent-logs");
|
|
8488
|
-
function queueAgentRun(stateDir, name, q2) {
|
|
8489
|
-
mkdirSync15(queueDir(stateDir), { recursive: true });
|
|
8490
|
-
writeFileSync16(join23(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
|
|
8491
|
-
}
|
|
8492
|
-
async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
8493
|
-
const dir = queueDir(stateDir);
|
|
8494
|
-
if (!existsSync21(dir)) return 0;
|
|
8495
|
-
let sent = 0;
|
|
8496
|
-
for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
8497
|
-
const path = join23(dir, f);
|
|
8498
|
-
let q2;
|
|
8499
|
-
try {
|
|
8500
|
-
q2 = JSON.parse(readFileSync22(path, "utf8"));
|
|
8501
|
-
} catch {
|
|
8502
|
-
continue;
|
|
8503
|
-
}
|
|
8504
|
-
if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
|
|
8505
|
-
try {
|
|
8506
|
-
const apiKey = await keyFor(q2.role);
|
|
8507
|
-
if (!apiKey) continue;
|
|
8508
|
-
const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
|
|
8509
|
-
if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
|
|
8510
|
-
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
8511
|
-
version: 1,
|
|
8512
|
-
route: q2.route,
|
|
8513
|
-
role: q2.role,
|
|
8514
|
-
target: q2.target,
|
|
8515
|
-
write: sentRun.counterRetry,
|
|
8516
|
-
attempts: 0,
|
|
8517
|
-
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8425
|
+
const all = await client2.list(modelId, { filters, limit: 20 });
|
|
8426
|
+
const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
|
|
8427
|
+
if (keeper && keeper.id !== mine.id) {
|
|
8428
|
+
await client2.remove(modelId, mine.id).catch(() => {
|
|
8518
8429
|
});
|
|
8519
|
-
rmSync12(path, { force: true });
|
|
8520
|
-
sent += 1;
|
|
8521
|
-
log.emit(`sent a queued Agent Log report (${f})`);
|
|
8522
|
-
} catch (e) {
|
|
8523
|
-
if (!(e instanceof AgentLogUnreachableError)) {
|
|
8524
|
-
rmSync12(path, { force: true });
|
|
8525
|
-
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
8526
|
-
continue;
|
|
8527
|
-
}
|
|
8528
|
-
const attempts = q2.attempts + 1;
|
|
8529
|
-
writeFileSync16(path, JSON.stringify({
|
|
8530
|
-
...q2,
|
|
8531
|
-
attempts,
|
|
8532
|
-
warned: true,
|
|
8533
|
-
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
8534
|
-
}));
|
|
8535
|
-
if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
8536
|
-
}
|
|
8537
|
-
}
|
|
8538
|
-
return sent;
|
|
8539
|
-
}
|
|
8540
|
-
var countersDir = (stateDir) => join23(stateDir, "pending-ticket-counters");
|
|
8541
|
-
function queueTicketCounters(stateDir, name, q2) {
|
|
8542
|
-
mkdirSync15(countersDir(stateDir), { recursive: true });
|
|
8543
|
-
writeFileSync16(join23(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
|
|
8544
|
-
}
|
|
8545
|
-
async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
8546
|
-
const dir = countersDir(stateDir);
|
|
8547
|
-
if (!existsSync21(dir)) return 0;
|
|
8548
|
-
let sent = 0;
|
|
8549
|
-
for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
8550
|
-
const path = join23(dir, f);
|
|
8551
|
-
let q2;
|
|
8552
|
-
try {
|
|
8553
|
-
q2 = JSON.parse(readFileSync22(path, "utf8"));
|
|
8554
|
-
} catch {
|
|
8555
8430
|
continue;
|
|
8556
8431
|
}
|
|
8557
|
-
|
|
8558
|
-
try {
|
|
8559
|
-
const apiKey = await keyFor(q2.role);
|
|
8560
|
-
if (!apiKey) continue;
|
|
8561
|
-
await writeCounters({ ...q2.target, apiKey }, q2.write);
|
|
8562
|
-
rmSync12(path, { force: true });
|
|
8563
|
-
sent += 1;
|
|
8564
|
-
log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
|
|
8565
|
-
} catch (e) {
|
|
8566
|
-
if (!isRetryable(e)) {
|
|
8567
|
-
rmSync12(path, { force: true });
|
|
8568
|
-
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
8569
|
-
continue;
|
|
8570
|
-
}
|
|
8571
|
-
const attempts = q2.attempts + 1;
|
|
8572
|
-
writeFileSync16(path, JSON.stringify({
|
|
8573
|
-
...q2,
|
|
8574
|
-
attempts,
|
|
8575
|
-
warned: true,
|
|
8576
|
-
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
8577
|
-
}));
|
|
8578
|
-
if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
8579
|
-
}
|
|
8432
|
+
created.push(scope);
|
|
8580
8433
|
}
|
|
8581
|
-
return
|
|
8434
|
+
return created;
|
|
8582
8435
|
}
|
|
8583
8436
|
|
|
8584
|
-
// src/
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8437
|
+
// src/tracker.ts
|
|
8438
|
+
function ticketBranchContext(t) {
|
|
8439
|
+
const tag = t.issue_tag ?? void 0;
|
|
8440
|
+
if (!tag) return {};
|
|
8441
|
+
const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
|
|
8442
|
+
return { tag, prefix };
|
|
8588
8443
|
}
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8444
|
+
function displayKey(t) {
|
|
8445
|
+
return t.issue_tag ?? t.issue_id;
|
|
8446
|
+
}
|
|
8447
|
+
function referenceKeys(t) {
|
|
8448
|
+
return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
|
|
8449
|
+
}
|
|
8450
|
+
var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
|
|
8451
|
+
var encodeFilters = (f) => JSON.stringify(f);
|
|
8452
|
+
var Tracker = class {
|
|
8453
|
+
// NB: explicit fields, not TypeScript parameter properties — node's
|
|
8454
|
+
// --experimental-strip-types cannot transform those, and the test runner
|
|
8455
|
+
// uses it. The same rule applies everywhere in src/.
|
|
8456
|
+
route;
|
|
8457
|
+
/** This workspace's rules. A ship holds several, one per route. */
|
|
8458
|
+
contract;
|
|
8459
|
+
client;
|
|
8460
|
+
models;
|
|
8461
|
+
userAgent;
|
|
8462
|
+
shipName;
|
|
8596
8463
|
/**
|
|
8597
|
-
* The
|
|
8598
|
-
*
|
|
8599
|
-
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
8600
|
-
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
8464
|
+
* The once-per-process authorship probe (CREW-1371), memoised as a promise
|
|
8465
|
+
* so concurrent first writes share one round of field-metadata reads.
|
|
8601
8466
|
*/
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
/**
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
/**
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
if (
|
|
8615
|
-
|
|
8616
|
-
|
|
8467
|
+
authorship;
|
|
8468
|
+
attachments;
|
|
8469
|
+
stateDir;
|
|
8470
|
+
/**
|
|
8471
|
+
* `opts.apiKey` overrides the route's ship key (CREW-1603): a lane's own
|
|
8472
|
+
* writes pass its seat's agent key so the event log and rule context see an
|
|
8473
|
+
* agent, not the ship's human principal.
|
|
8474
|
+
*/
|
|
8475
|
+
/** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
|
|
8476
|
+
onReadFailure;
|
|
8477
|
+
constructor(route2, ship, opts = {}) {
|
|
8478
|
+
this.route = route2;
|
|
8479
|
+
if (!route2.resolved) {
|
|
8480
|
+
throw new ConfigError(
|
|
8481
|
+
`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
|
|
8482
|
+
);
|
|
8483
|
+
}
|
|
8484
|
+
this.models = route2.resolved.models;
|
|
8485
|
+
this.contract = resolveContract(route2.contract);
|
|
8486
|
+
this.userAgent = ship.userAgent;
|
|
8487
|
+
this.shipName = ship.name;
|
|
8488
|
+
this.stateDir = ship.stateDir;
|
|
8489
|
+
this.client = new TablationClient({
|
|
8490
|
+
baseUrl: `${route2.baseUrl}/api`,
|
|
8491
|
+
apiKey: opts.apiKey ?? resolveApiKey(route2),
|
|
8492
|
+
// Cloudflare 403s (error 1010) default agents on this host.
|
|
8493
|
+
headers: { "User-Agent": ship.userAgent }
|
|
8494
|
+
});
|
|
8617
8495
|
}
|
|
8618
8496
|
/**
|
|
8619
|
-
*
|
|
8620
|
-
*
|
|
8621
|
-
*
|
|
8622
|
-
*
|
|
8623
|
-
*
|
|
8497
|
+
* Every non-terminal ticket for this route's area, in one call.
|
|
8498
|
+
*
|
|
8499
|
+
* The area filter is what keeps two routes on one tracker out of each
|
|
8500
|
+
* other's queue: a ship working `synthesis` and `tablation-js` from the same
|
|
8501
|
+
* Issues project must not have either seat pick up the other's tickets. A
|
|
8502
|
+
* route with no area sees everything, which is right for a tracker
|
|
8503
|
+
* nobody has sliced yet.
|
|
8624
8504
|
*/
|
|
8625
|
-
|
|
8626
|
-
const
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
if (
|
|
8632
|
-
this
|
|
8505
|
+
async openTickets() {
|
|
8506
|
+
const c = this.contract;
|
|
8507
|
+
const filters = [
|
|
8508
|
+
{ columnName: c.columns.status, operator: "IN", value: c.statuses.open }
|
|
8509
|
+
];
|
|
8510
|
+
const areaId = this.route.resolved?.areaId;
|
|
8511
|
+
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
8512
|
+
return this.client.records.list(this.models.issues, {
|
|
8513
|
+
filters: encodeFilters(filters),
|
|
8514
|
+
limit: 500
|
|
8515
|
+
});
|
|
8633
8516
|
}
|
|
8634
8517
|
/**
|
|
8635
|
-
*
|
|
8636
|
-
*
|
|
8637
|
-
* working never displaces the claimed one), and any write beats everything
|
|
8638
|
-
* below. Next, `hintMoved` — the post-run read-back showed the hinted
|
|
8639
|
-
* ticket changed status or assignee during the run — is a hand-off signal
|
|
8640
|
-
* for a run that wrote through MCP tools crew cannot see. Then the
|
|
8641
|
-
* mention-token heuristic, which with a hint may override it only when a
|
|
8642
|
-
* different ticket holds a strict majority of the credited tokens (a single
|
|
8643
|
-
* credited turn, like a lone end-of-run summary naming another ticket,
|
|
8644
|
-
* must not). A run with no hint
|
|
8645
|
-
* keeps the plain winner. The pre-run hint is last.
|
|
8518
|
+
* Tickets at the hand-off status whose row changed at or after `since`,
|
|
8519
|
+
* other than `exceptId` (CREW-1759: a run's stray hand-offs).
|
|
8646
8520
|
*/
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8521
|
+
async handedOffSince(since2, exceptId) {
|
|
8522
|
+
const c = this.contract;
|
|
8523
|
+
const rows = await this.client.records.list(this.models.issues, {
|
|
8524
|
+
filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
|
|
8525
|
+
limit: 100
|
|
8526
|
+
});
|
|
8527
|
+
const from = Date.parse(since2);
|
|
8528
|
+
return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
|
|
8529
|
+
}
|
|
8530
|
+
/**
|
|
8531
|
+
* The tickets behind a set of ticket numbers — the cleanup sweeps' input
|
|
8532
|
+
* (CREW-1593).
|
|
8533
|
+
*
|
|
8534
|
+
* The sweeps used to fetch "every closed ticket, newest `updated_at` first,
|
|
8535
|
+
* capped at 500" and hope the tickets they cared about were in the page. They
|
|
8536
|
+
* were not once the board held more than 500 closed tickets, and a bulk
|
|
8537
|
+
* write gave 1457 of them one identical `updated_at`, so which ones made the
|
|
8538
|
+
* page was arbitrary and a deployed ticket's worktree/branch was silently
|
|
8539
|
+
* never swept. The caller now names the numbers it actually has a worktree
|
|
8540
|
+
* or remote branch for, so the request is bounded by those and not by how
|
|
8541
|
+
* many tickets are closed.
|
|
8542
|
+
*
|
|
8543
|
+
* Matched on `issue_id`, in the raw, unpadded and 3-digit-padded spellings a
|
|
8544
|
+
* number has been stored under. `onTruncated` is told when a page came back
|
|
8545
|
+
* full, since anything past it would be missing without a trace.
|
|
8546
|
+
*/
|
|
8547
|
+
async ticketsByNumbers(nums, onTruncated) {
|
|
8548
|
+
const c = this.contract;
|
|
8549
|
+
const keys = /* @__PURE__ */ new Set();
|
|
8550
|
+
for (const raw of nums) {
|
|
8551
|
+
const unpadded = String(Number.parseInt(raw, 10));
|
|
8552
|
+
for (const n of [raw, unpadded, unpadded.padStart(3, "0")]) keys.add(`ISSUE-${n}`);
|
|
8651
8553
|
}
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8554
|
+
const all = [...keys];
|
|
8555
|
+
const out = [];
|
|
8556
|
+
const limit = 500;
|
|
8557
|
+
for (let i = 0; i < all.length; i += 100) {
|
|
8558
|
+
const page = await this.client.records.list(this.models.issues, {
|
|
8559
|
+
filters: encodeFilters([{ columnName: c.columns.key, operator: "IN", value: all.slice(i, i + 100) }]),
|
|
8560
|
+
limit
|
|
8561
|
+
});
|
|
8562
|
+
if (page.length >= limit) onTruncated?.(limit);
|
|
8563
|
+
out.push(...page);
|
|
8661
8564
|
}
|
|
8662
|
-
return
|
|
8565
|
+
return out;
|
|
8663
8566
|
}
|
|
8664
8567
|
/**
|
|
8665
|
-
*
|
|
8666
|
-
*
|
|
8667
|
-
* administrative run that touches the tracker without discussing a
|
|
8668
|
-
* specific ticket in its visible text still has to cite something.
|
|
8568
|
+
* Tickets by id, for blockers that have already closed and so are absent
|
|
8569
|
+
* from the open set. Bounded to the ids actually referenced.
|
|
8669
8570
|
*/
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
bestTokens = tokens;
|
|
8677
|
-
}
|
|
8678
|
-
}
|
|
8679
|
-
return best;
|
|
8571
|
+
async ticketsByIds(ids) {
|
|
8572
|
+
if (ids.length === 0) return [];
|
|
8573
|
+
return this.client.records.list(this.models.issues, {
|
|
8574
|
+
filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
|
|
8575
|
+
limit: 500
|
|
8576
|
+
});
|
|
8680
8577
|
}
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
8684
|
-
const body = input?.body;
|
|
8685
|
-
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
8686
|
-
}
|
|
8687
|
-
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
8688
|
-
function classifyWrite(tool, input) {
|
|
8689
|
-
if (!tool) return void 0;
|
|
8690
|
-
const body = input?.body;
|
|
8691
|
-
if (typeof body !== "object" || body === null) return void 0;
|
|
8692
|
-
const b = body;
|
|
8693
|
-
if (/records_controller_update$/.test(tool)) {
|
|
8694
|
-
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
8695
|
-
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
8696
|
-
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
8578
|
+
async ticket(id) {
|
|
8579
|
+
return this.client.records.get(this.models.issues, id);
|
|
8697
8580
|
}
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
}
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8581
|
+
async comments(limit = 500) {
|
|
8582
|
+
return this.client.records.list(this.models.comments, { limit });
|
|
8583
|
+
}
|
|
8584
|
+
async crewRows() {
|
|
8585
|
+
return this.client.records.list(this.models.crew, { limit: 200 });
|
|
8586
|
+
}
|
|
8587
|
+
/**
|
|
8588
|
+
* One row of the `Repos` table, for whatever it can tell the crew that
|
|
8589
|
+
* isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
|
|
8590
|
+
* particular. `undefined` on a workspace with no Repos table, or if the
|
|
8591
|
+
* row is gone; neither is fatal to the caller.
|
|
8592
|
+
*/
|
|
8593
|
+
async repoRow(id) {
|
|
8594
|
+
const model = this.route.resolved?.reposModelId;
|
|
8595
|
+
if (!model) return void 0;
|
|
8711
8596
|
try {
|
|
8712
|
-
|
|
8713
|
-
if (doc && typeof doc === "object") {
|
|
8714
|
-
const code = doc.statusCode;
|
|
8715
|
-
return typeof code === "number" && code >= 500;
|
|
8716
|
-
}
|
|
8597
|
+
return await this.client.records.get(model, id);
|
|
8717
8598
|
} catch {
|
|
8599
|
+
return void 0;
|
|
8718
8600
|
}
|
|
8719
8601
|
}
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8602
|
+
/**
|
|
8603
|
+
* One row of the `Projects` (area) table. `undefined` on a workspace
|
|
8604
|
+
* with no such table, or if the row is gone.
|
|
8605
|
+
*/
|
|
8606
|
+
async projectRow(id) {
|
|
8607
|
+
const model = this.route.resolved?.areaModelId;
|
|
8608
|
+
if (!model) return void 0;
|
|
8726
8609
|
try {
|
|
8727
|
-
|
|
8728
|
-
return !!doc && typeof doc === "object" && doc.statusCode === 403;
|
|
8610
|
+
return await this.client.records.get(model, id);
|
|
8729
8611
|
} catch {
|
|
8612
|
+
return void 0;
|
|
8730
8613
|
}
|
|
8731
8614
|
}
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
forbiddenWrites = /* @__PURE__ */ new Map();
|
|
8747
|
-
statusLanded = false;
|
|
8748
|
-
lastText = "";
|
|
8749
|
-
resultLine = "";
|
|
8750
|
-
feed(parsed) {
|
|
8751
|
-
const line = parsed;
|
|
8752
|
-
if (!line || typeof line !== "object") return;
|
|
8753
|
-
if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
|
|
8754
|
-
const content = line.message?.content;
|
|
8755
|
-
if (!Array.isArray(content)) return;
|
|
8756
|
-
for (const b of content) {
|
|
8757
|
-
if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
|
|
8758
|
-
else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
|
|
8759
|
-
const w = classifyWrite(b.name, b.input);
|
|
8760
|
-
const body = b.input?.body;
|
|
8761
|
-
if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
|
|
8762
|
-
this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
|
|
8763
|
-
}
|
|
8764
|
-
} else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
|
|
8765
|
-
const w = this.open.get(b.tool_use_id);
|
|
8766
|
-
if (!w) continue;
|
|
8767
|
-
this.open.delete(b.tool_use_id);
|
|
8768
|
-
const text2 = resultText(b.content);
|
|
8769
|
-
if (w.kind === "handoff") {
|
|
8770
|
-
const refused = isForbiddenWrite(b.is_error === true, text2);
|
|
8771
|
-
if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
|
|
8772
|
-
else {
|
|
8773
|
-
this.forbiddenWrites.delete(w.recordId);
|
|
8774
|
-
if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
|
|
8775
|
-
}
|
|
8776
|
-
}
|
|
8777
|
-
const failed = isTransportFailure(b.is_error === true, text2);
|
|
8778
|
-
if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
|
|
8779
|
-
else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
|
|
8780
|
-
else if (!failed) {
|
|
8781
|
-
this.comment.delete(w.recordId);
|
|
8782
|
-
this.landed.add(w.recordId);
|
|
8783
|
-
}
|
|
8784
|
-
}
|
|
8615
|
+
/**
|
|
8616
|
+
* Every Projects (area) row's work mode (CREW-1445, CREW-1493). One extra
|
|
8617
|
+
* read per cycle. A workspace with no Projects table, no `work_mode` column
|
|
8618
|
+
* or an unreadable table yields the empty map, which the selection reads as
|
|
8619
|
+
* "no filtering"; a null value is `automatic`.
|
|
8620
|
+
*/
|
|
8621
|
+
async projectWorkModes() {
|
|
8622
|
+
const model = this.route.resolved?.areaModelId;
|
|
8623
|
+
if (!model) return /* @__PURE__ */ new Map();
|
|
8624
|
+
try {
|
|
8625
|
+
const rows = await this.client.records.list(model, { limit: 200 });
|
|
8626
|
+
return projectWorkModes(rows, this.contract);
|
|
8627
|
+
} catch {
|
|
8628
|
+
return /* @__PURE__ */ new Map();
|
|
8785
8629
|
}
|
|
8786
8630
|
}
|
|
8787
|
-
/**
|
|
8788
|
-
|
|
8789
|
-
const
|
|
8790
|
-
|
|
8631
|
+
/** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
|
|
8632
|
+
async projectRows() {
|
|
8633
|
+
const model = this.route.resolved?.areaModelId;
|
|
8634
|
+
if (!model) return [];
|
|
8635
|
+
return this.client.records.list(model, { limit: 200 });
|
|
8791
8636
|
}
|
|
8792
|
-
/**
|
|
8793
|
-
|
|
8794
|
-
|
|
8637
|
+
/** Every Projects row with its raw `work_mode`, for `crew status`. */
|
|
8638
|
+
async projectRowsWithModes() {
|
|
8639
|
+
const rows = await this.projectRows();
|
|
8640
|
+
const modes = await this.projectWorkModes();
|
|
8641
|
+
return rows.map((r) => ({ ...r, mode: modes.get(r.id) ?? "automatic" }));
|
|
8795
8642
|
}
|
|
8796
|
-
/**
|
|
8797
|
-
|
|
8798
|
-
const
|
|
8799
|
-
if (!
|
|
8800
|
-
|
|
8801
|
-
return {
|
|
8802
|
-
ticketRecordId,
|
|
8803
|
-
patch: s.patch,
|
|
8804
|
-
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
8805
|
-
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
8806
|
-
finalText: this.lastText || this.resultLine
|
|
8807
|
-
};
|
|
8808
|
-
}
|
|
8809
|
-
};
|
|
8810
|
-
|
|
8811
|
-
// src/pending-handoff.ts
|
|
8812
|
-
import { existsSync as existsSync23, mkdirSync as mkdirSync16, readdirSync as readdirSync7, readFileSync as readFileSync23, rmSync as rmSync13, writeFileSync as writeFileSync17 } from "fs";
|
|
8813
|
-
import { join as join25 } from "path";
|
|
8814
|
-
|
|
8815
|
-
// src/ticket-branch.ts
|
|
8816
|
-
import { basename as basename7 } from "path";
|
|
8817
|
-
|
|
8818
|
-
// src/tracker.ts
|
|
8819
|
-
import { hostname } from "os";
|
|
8820
|
-
import { TablationClient } from "@tablation/client";
|
|
8821
|
-
|
|
8822
|
-
// src/work-mode.ts
|
|
8823
|
-
function projectWorkModes(rows, contract) {
|
|
8824
|
-
const out = /* @__PURE__ */ new Map();
|
|
8825
|
-
for (const r of rows) {
|
|
8826
|
-
const v = r[contract.columns.workMode];
|
|
8827
|
-
out.set(r.id, v === contract.manualWorkMode ? "manual" : v === contract.hybridWorkMode ? "hybrid" : "automatic");
|
|
8828
|
-
}
|
|
8829
|
-
return out;
|
|
8830
|
-
}
|
|
8831
|
-
function agentAssigneeIds(crewRows, isHoldRow) {
|
|
8832
|
-
return new Set(crewRows.filter((r) => !!r.agent_id && !isHoldRow(r.id)).map((r) => r.id));
|
|
8833
|
-
}
|
|
8834
|
-
function modeOf(t, wm) {
|
|
8835
|
-
if (!t.project_id) return "manual";
|
|
8836
|
-
return wm.modes.get(t.project_id) ?? "automatic";
|
|
8837
|
-
}
|
|
8838
|
-
function withinWorkMode(tickets, wm) {
|
|
8839
|
-
if (!wm || wm.modes.size === 0) return tickets;
|
|
8840
|
-
return tickets.filter((t) => {
|
|
8841
|
-
const mode = modeOf(t, wm);
|
|
8842
|
-
if (mode === "automatic") return true;
|
|
8843
|
-
if (mode === "manual") return false;
|
|
8844
|
-
return !!t.assignee_id && wm.agentAssignees.has(t.assignee_id);
|
|
8845
|
-
});
|
|
8846
|
-
}
|
|
8847
|
-
function isAutomatic(t, wm) {
|
|
8848
|
-
if (!wm || wm.modes.size === 0) return true;
|
|
8849
|
-
return modeOf(t, wm) === "automatic";
|
|
8850
|
-
}
|
|
8851
|
-
function skippedByMode(tickets, wm) {
|
|
8852
|
-
const out = { manual: 0, hybrid: 0 };
|
|
8853
|
-
if (!wm || wm.modes.size === 0) return out;
|
|
8854
|
-
const kept = new Set(withinWorkMode(tickets, wm));
|
|
8855
|
-
for (const t of tickets) {
|
|
8856
|
-
if (kept.has(t)) continue;
|
|
8857
|
-
out[modeOf(t, wm) === "manual" ? "manual" : "hybrid"]++;
|
|
8858
|
-
}
|
|
8859
|
-
return out;
|
|
8860
|
-
}
|
|
8861
|
-
function withoutManualVerified(tickets, verifiedStatus, wm) {
|
|
8862
|
-
if (!wm || wm.modes.size === 0) return tickets;
|
|
8863
|
-
return tickets.filter((t) => t.status !== verifiedStatus || modeOf(t, wm) !== "manual");
|
|
8864
|
-
}
|
|
8865
|
-
function projectModeLabels(rows) {
|
|
8866
|
-
return rows.map((r) => {
|
|
8867
|
-
const name = r.name ?? r.id;
|
|
8868
|
-
return { id: r.id, name, mode: r.mode, label: `${name} (${r.mode})` };
|
|
8869
|
-
});
|
|
8870
|
-
}
|
|
8871
|
-
|
|
8872
|
-
// src/board-lock.ts
|
|
8873
|
-
import { StaleWriteError } from "@tablation/client";
|
|
8874
|
-
function releaseLockScope(route2, repo) {
|
|
8875
|
-
return `${route2}/${repo}`;
|
|
8876
|
-
}
|
|
8877
|
-
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs, log = {}) {
|
|
8878
|
-
if (!modelId) return { ok: true, release: async () => {
|
|
8879
|
-
}, exclusion: "off" };
|
|
8880
|
-
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
8881
|
-
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
8882
|
-
const row = rows[0];
|
|
8883
|
-
if (!row) return { ok: false, reason: "missing" };
|
|
8884
|
-
const age = Date.now() - new Date(row.updated_at).getTime();
|
|
8885
|
-
const staleOwn = !!row.holder && row.holder !== holderLabel && isDeadOwnClaim(row.holder, holderLabel);
|
|
8886
|
-
const free = !row.holder || row.holder === holderLabel || age > ttlMs || staleOwn;
|
|
8887
|
-
if (staleOwn) log.warn?.(`reclaimed stale own claim ${row.holder} on ${scope} (process is gone)`);
|
|
8888
|
-
if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
|
|
8889
|
-
let claimed;
|
|
8890
|
-
try {
|
|
8891
|
-
claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
|
|
8892
|
-
} catch (e) {
|
|
8893
|
-
if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
|
|
8894
|
-
throw e;
|
|
8895
|
-
}
|
|
8896
|
-
return {
|
|
8897
|
-
ok: true,
|
|
8898
|
-
exclusion: "taken",
|
|
8899
|
-
release: async () => {
|
|
8900
|
-
try {
|
|
8901
|
-
try {
|
|
8902
|
-
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
8903
|
-
} catch (e) {
|
|
8904
|
-
if (!(e instanceof StaleWriteError)) throw e;
|
|
8905
|
-
const [now] = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
8906
|
-
if (now?.holder === holderLabel) await client2.update(modelId, row.id, { holder: "" }, now.updated_at);
|
|
8907
|
-
}
|
|
8908
|
-
log.debug?.(`lock released for ${scope}`);
|
|
8909
|
-
} catch (e) {
|
|
8910
|
-
log.warn?.(`lock release failed for ${scope}: ${e.message}`);
|
|
8911
|
-
}
|
|
8912
|
-
}
|
|
8913
|
-
};
|
|
8914
|
-
}
|
|
8915
|
-
function isDeadOwnClaim(holder, mine) {
|
|
8916
|
-
const split = (l) => {
|
|
8917
|
-
const i = l.lastIndexOf(":");
|
|
8918
|
-
return i < 0 ? null : { ship: l.slice(0, i), pid: Number(l.slice(i + 1)) };
|
|
8919
|
-
};
|
|
8920
|
-
const h = split(holder), m = split(mine);
|
|
8921
|
-
if (!h || !m || h.ship !== m.ship || !Number.isInteger(h.pid) || h.pid <= 0) return false;
|
|
8922
|
-
try {
|
|
8923
|
-
process.kill(h.pid, 0);
|
|
8924
|
-
return false;
|
|
8925
|
-
} catch (e) {
|
|
8926
|
-
return e.code === "ESRCH";
|
|
8927
|
-
}
|
|
8928
|
-
}
|
|
8929
|
-
async function lockRowState(client2, modelId, scope) {
|
|
8930
|
-
const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
8931
|
-
const row = rows[0];
|
|
8932
|
-
if (!row) return { exists: false };
|
|
8933
|
-
return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
|
|
8934
|
-
}
|
|
8935
|
-
var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
8936
|
-
async function ensureLockRows(client2, modelId, scopes) {
|
|
8937
|
-
const created = [];
|
|
8938
|
-
for (const scope of scopes) {
|
|
8939
|
-
const filters = scopeFilter(scope);
|
|
8940
|
-
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
8941
|
-
let mine;
|
|
8942
|
-
try {
|
|
8943
|
-
mine = await client2.create(modelId, { scope, holder: "" });
|
|
8944
|
-
} catch (e) {
|
|
8945
|
-
const status2 = e.status;
|
|
8946
|
-
if (status2 === 409 || status2 === 400) {
|
|
8947
|
-
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
8948
|
-
}
|
|
8949
|
-
throw e;
|
|
8950
|
-
}
|
|
8951
|
-
const all = await client2.list(modelId, { filters, limit: 20 });
|
|
8952
|
-
const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
|
|
8953
|
-
if (keeper && keeper.id !== mine.id) {
|
|
8954
|
-
await client2.remove(modelId, mine.id).catch(() => {
|
|
8955
|
-
});
|
|
8956
|
-
continue;
|
|
8957
|
-
}
|
|
8958
|
-
created.push(scope);
|
|
8959
|
-
}
|
|
8960
|
-
return created;
|
|
8961
|
-
}
|
|
8962
|
-
|
|
8963
|
-
// src/tracker.ts
|
|
8964
|
-
function ticketBranchContext(t) {
|
|
8965
|
-
const tag = t.issue_tag ?? void 0;
|
|
8966
|
-
if (!tag) return {};
|
|
8967
|
-
const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
|
|
8968
|
-
return { tag, prefix };
|
|
8969
|
-
}
|
|
8970
|
-
function displayKey(t) {
|
|
8971
|
-
return t.issue_tag ?? t.issue_id;
|
|
8972
|
-
}
|
|
8973
|
-
function referenceKeys(t) {
|
|
8974
|
-
return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
|
|
8975
|
-
}
|
|
8976
|
-
var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
|
|
8977
|
-
var encodeFilters = (f) => JSON.stringify(f);
|
|
8978
|
-
var Tracker = class {
|
|
8979
|
-
// NB: explicit fields, not TypeScript parameter properties — node's
|
|
8980
|
-
// --experimental-strip-types cannot transform those, and the test runner
|
|
8981
|
-
// uses it. The same rule applies everywhere in src/.
|
|
8982
|
-
route;
|
|
8983
|
-
/** This workspace's rules. A ship holds several, one per route. */
|
|
8984
|
-
contract;
|
|
8985
|
-
client;
|
|
8986
|
-
models;
|
|
8987
|
-
userAgent;
|
|
8988
|
-
shipName;
|
|
8989
|
-
/**
|
|
8990
|
-
* The once-per-process authorship probe (CREW-1371), memoised as a promise
|
|
8991
|
-
* so concurrent first writes share one round of field-metadata reads.
|
|
8992
|
-
*/
|
|
8993
|
-
authorship;
|
|
8994
|
-
attachments;
|
|
8995
|
-
stateDir;
|
|
8996
|
-
/**
|
|
8997
|
-
* `opts.apiKey` overrides the route's ship key (CREW-1603): a lane's own
|
|
8998
|
-
* writes pass its seat's agent key so the event log and rule context see an
|
|
8999
|
-
* agent, not the ship's human principal.
|
|
9000
|
-
*/
|
|
9001
|
-
/** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
|
|
9002
|
-
onReadFailure;
|
|
9003
|
-
constructor(route2, ship, opts = {}) {
|
|
9004
|
-
this.route = route2;
|
|
9005
|
-
if (!route2.resolved) {
|
|
9006
|
-
throw new ConfigError(
|
|
9007
|
-
`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
|
|
9008
|
-
);
|
|
9009
|
-
}
|
|
9010
|
-
this.models = route2.resolved.models;
|
|
9011
|
-
this.contract = resolveContract(route2.contract);
|
|
9012
|
-
this.userAgent = ship.userAgent;
|
|
9013
|
-
this.shipName = ship.name;
|
|
9014
|
-
this.stateDir = ship.stateDir;
|
|
9015
|
-
this.client = new TablationClient({
|
|
9016
|
-
baseUrl: `${route2.baseUrl}/api`,
|
|
9017
|
-
apiKey: opts.apiKey ?? resolveApiKey(route2),
|
|
9018
|
-
// Cloudflare 403s (error 1010) default agents on this host.
|
|
9019
|
-
headers: { "User-Agent": ship.userAgent }
|
|
9020
|
-
});
|
|
9021
|
-
}
|
|
9022
|
-
/**
|
|
9023
|
-
* Every non-terminal ticket for this route's area, in one call.
|
|
9024
|
-
*
|
|
9025
|
-
* The area filter is what keeps two routes on one tracker out of each
|
|
9026
|
-
* other's queue: a ship working `synthesis` and `tablation-js` from the same
|
|
9027
|
-
* Issues project must not have either seat pick up the other's tickets. A
|
|
9028
|
-
* route with no area sees everything, which is right for a tracker
|
|
9029
|
-
* nobody has sliced yet.
|
|
9030
|
-
*/
|
|
9031
|
-
async openTickets() {
|
|
9032
|
-
const c = this.contract;
|
|
9033
|
-
const filters = [
|
|
9034
|
-
{ columnName: c.columns.status, operator: "IN", value: c.statuses.open }
|
|
9035
|
-
];
|
|
9036
|
-
const areaId = this.route.resolved?.areaId;
|
|
9037
|
-
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
9038
|
-
return this.client.records.list(this.models.issues, {
|
|
9039
|
-
filters: encodeFilters(filters),
|
|
9040
|
-
limit: 500
|
|
9041
|
-
});
|
|
9042
|
-
}
|
|
9043
|
-
/**
|
|
9044
|
-
* Tickets at the hand-off status whose row changed at or after `since`,
|
|
9045
|
-
* other than `exceptId` (CREW-1759: a run's stray hand-offs).
|
|
9046
|
-
*/
|
|
9047
|
-
async handedOffSince(since2, exceptId) {
|
|
9048
|
-
const c = this.contract;
|
|
9049
|
-
const rows = await this.client.records.list(this.models.issues, {
|
|
9050
|
-
filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
|
|
9051
|
-
limit: 100
|
|
9052
|
-
});
|
|
9053
|
-
const from = Date.parse(since2);
|
|
9054
|
-
return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
|
|
9055
|
-
}
|
|
9056
|
-
/**
|
|
9057
|
-
* The tickets behind a set of ticket numbers — the cleanup sweeps' input
|
|
9058
|
-
* (CREW-1593).
|
|
9059
|
-
*
|
|
9060
|
-
* The sweeps used to fetch "every closed ticket, newest `updated_at` first,
|
|
9061
|
-
* capped at 500" and hope the tickets they cared about were in the page. They
|
|
9062
|
-
* were not once the board held more than 500 closed tickets, and a bulk
|
|
9063
|
-
* write gave 1457 of them one identical `updated_at`, so which ones made the
|
|
9064
|
-
* page was arbitrary and a deployed ticket's worktree/branch was silently
|
|
9065
|
-
* never swept. The caller now names the numbers it actually has a worktree
|
|
9066
|
-
* or remote branch for, so the request is bounded by those and not by how
|
|
9067
|
-
* many tickets are closed.
|
|
9068
|
-
*
|
|
9069
|
-
* Matched on `issue_id`, in the raw, unpadded and 3-digit-padded spellings a
|
|
9070
|
-
* number has been stored under. `onTruncated` is told when a page came back
|
|
9071
|
-
* full, since anything past it would be missing without a trace.
|
|
9072
|
-
*/
|
|
9073
|
-
async ticketsByNumbers(nums, onTruncated) {
|
|
9074
|
-
const c = this.contract;
|
|
9075
|
-
const keys = /* @__PURE__ */ new Set();
|
|
9076
|
-
for (const raw of nums) {
|
|
9077
|
-
const unpadded = String(Number.parseInt(raw, 10));
|
|
9078
|
-
for (const n of [raw, unpadded, unpadded.padStart(3, "0")]) keys.add(`ISSUE-${n}`);
|
|
9079
|
-
}
|
|
9080
|
-
const all = [...keys];
|
|
9081
|
-
const out = [];
|
|
9082
|
-
const limit = 500;
|
|
9083
|
-
for (let i = 0; i < all.length; i += 100) {
|
|
9084
|
-
const page = await this.client.records.list(this.models.issues, {
|
|
9085
|
-
filters: encodeFilters([{ columnName: c.columns.key, operator: "IN", value: all.slice(i, i + 100) }]),
|
|
9086
|
-
limit
|
|
9087
|
-
});
|
|
9088
|
-
if (page.length >= limit) onTruncated?.(limit);
|
|
9089
|
-
out.push(...page);
|
|
9090
|
-
}
|
|
9091
|
-
return out;
|
|
9092
|
-
}
|
|
9093
|
-
/**
|
|
9094
|
-
* Tickets by id, for blockers that have already closed and so are absent
|
|
9095
|
-
* from the open set. Bounded to the ids actually referenced.
|
|
9096
|
-
*/
|
|
9097
|
-
async ticketsByIds(ids) {
|
|
9098
|
-
if (ids.length === 0) return [];
|
|
9099
|
-
return this.client.records.list(this.models.issues, {
|
|
9100
|
-
filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
|
|
9101
|
-
limit: 500
|
|
9102
|
-
});
|
|
9103
|
-
}
|
|
9104
|
-
async ticket(id) {
|
|
9105
|
-
return this.client.records.get(this.models.issues, id);
|
|
9106
|
-
}
|
|
9107
|
-
async comments(limit = 500) {
|
|
9108
|
-
return this.client.records.list(this.models.comments, { limit });
|
|
9109
|
-
}
|
|
9110
|
-
async crewRows() {
|
|
9111
|
-
return this.client.records.list(this.models.crew, { limit: 200 });
|
|
9112
|
-
}
|
|
9113
|
-
/**
|
|
9114
|
-
* One row of the `Repos` table, for whatever it can tell the crew that
|
|
9115
|
-
* isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
|
|
9116
|
-
* particular. `undefined` on a workspace with no Repos table, or if the
|
|
9117
|
-
* row is gone; neither is fatal to the caller.
|
|
9118
|
-
*/
|
|
9119
|
-
async repoRow(id) {
|
|
9120
|
-
const model = this.route.resolved?.reposModelId;
|
|
9121
|
-
if (!model) return void 0;
|
|
9122
|
-
try {
|
|
9123
|
-
return await this.client.records.get(model, id);
|
|
9124
|
-
} catch {
|
|
9125
|
-
return void 0;
|
|
9126
|
-
}
|
|
9127
|
-
}
|
|
9128
|
-
/**
|
|
9129
|
-
* One row of the `Projects` (area) table. `undefined` on a workspace
|
|
9130
|
-
* with no such table, or if the row is gone.
|
|
9131
|
-
*/
|
|
9132
|
-
async projectRow(id) {
|
|
9133
|
-
const model = this.route.resolved?.areaModelId;
|
|
9134
|
-
if (!model) return void 0;
|
|
9135
|
-
try {
|
|
9136
|
-
return await this.client.records.get(model, id);
|
|
9137
|
-
} catch {
|
|
9138
|
-
return void 0;
|
|
9139
|
-
}
|
|
9140
|
-
}
|
|
9141
|
-
/**
|
|
9142
|
-
* Every Projects (area) row's work mode (CREW-1445, CREW-1493). One extra
|
|
9143
|
-
* read per cycle. A workspace with no Projects table, no `work_mode` column
|
|
9144
|
-
* or an unreadable table yields the empty map, which the selection reads as
|
|
9145
|
-
* "no filtering"; a null value is `automatic`.
|
|
9146
|
-
*/
|
|
9147
|
-
async projectWorkModes() {
|
|
9148
|
-
const model = this.route.resolved?.areaModelId;
|
|
9149
|
-
if (!model) return /* @__PURE__ */ new Map();
|
|
9150
|
-
try {
|
|
9151
|
-
const rows = await this.client.records.list(model, { limit: 200 });
|
|
9152
|
-
return projectWorkModes(rows, this.contract);
|
|
9153
|
-
} catch {
|
|
9154
|
-
return /* @__PURE__ */ new Map();
|
|
9155
|
-
}
|
|
9156
|
-
}
|
|
9157
|
-
/** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
|
|
9158
|
-
async projectRows() {
|
|
9159
|
-
const model = this.route.resolved?.areaModelId;
|
|
9160
|
-
if (!model) return [];
|
|
9161
|
-
return this.client.records.list(model, { limit: 200 });
|
|
9162
|
-
}
|
|
9163
|
-
/** Every Projects row with its raw `work_mode`, for `crew status`. */
|
|
9164
|
-
async projectRowsWithModes() {
|
|
9165
|
-
const rows = await this.projectRows();
|
|
9166
|
-
const modes = await this.projectWorkModes();
|
|
9167
|
-
return rows.map((r) => ({ ...r, mode: modes.get(r.id) ?? "automatic" }));
|
|
9168
|
-
}
|
|
9169
|
-
/** Create a `Repos` row. Throws when the workspace has no Repos table. */
|
|
9170
|
-
async createRepoRow(fields) {
|
|
9171
|
-
const model = this.route.resolved?.reposModelId;
|
|
9172
|
-
if (!model) throw new Error("this workspace has no Repos table");
|
|
9173
|
-
return this.client.records.create(model, fields);
|
|
8643
|
+
/** Create a `Repos` row. Throws when the workspace has no Repos table. */
|
|
8644
|
+
async createRepoRow(fields) {
|
|
8645
|
+
const model = this.route.resolved?.reposModelId;
|
|
8646
|
+
if (!model) throw new Error("this workspace has no Repos table");
|
|
8647
|
+
return this.client.records.create(model, fields);
|
|
9174
8648
|
}
|
|
9175
8649
|
/** Every `Repos` row as `{ id, name }`, for finding one by name. */
|
|
9176
8650
|
async repoRows() {
|
|
@@ -9872,6 +9346,96 @@ function materializeBranchForTicket(dir, cfg, t, role, onProblem, onReset) {
|
|
|
9872
9346
|
return loc.branch;
|
|
9873
9347
|
}
|
|
9874
9348
|
|
|
9349
|
+
// src/pending-handoff.ts
|
|
9350
|
+
import { existsSync as existsSync21, mkdirSync as mkdirSync15, readdirSync as readdirSync6, readFileSync as readFileSync22, rmSync as rmSync12, writeFileSync as writeFileSync16 } from "fs";
|
|
9351
|
+
import { join as join23 } from "path";
|
|
9352
|
+
var PENDING_MAX_AGE_MS = 48 * 36e5;
|
|
9353
|
+
var pendingDirFor = (stateDir) => join23(stateDir, "pending-handoffs");
|
|
9354
|
+
var stateDirOfStream = (streamPath) => join23(streamPath, "..", "..");
|
|
9355
|
+
function writePendingHandoff(stateDir, rec) {
|
|
9356
|
+
const dir = pendingDirFor(stateDir);
|
|
9357
|
+
mkdirSync15(dir, { recursive: true });
|
|
9358
|
+
writeFileSync16(join23(dir, `${rec.ticketRecordId}.json`), JSON.stringify(rec, null, 2));
|
|
9359
|
+
}
|
|
9360
|
+
function listPendingHandoffs(stateDir, route2) {
|
|
9361
|
+
const dir = pendingDirFor(stateDir);
|
|
9362
|
+
if (!existsSync21(dir)) return [];
|
|
9363
|
+
const out = [];
|
|
9364
|
+
for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
9365
|
+
try {
|
|
9366
|
+
const rec = JSON.parse(readFileSync22(join23(dir, f), "utf8"));
|
|
9367
|
+
if (rec.version === 1 && rec.ticketRecordId && (!route2 || rec.route === route2)) out.push(rec);
|
|
9368
|
+
} catch {
|
|
9369
|
+
}
|
|
9370
|
+
}
|
|
9371
|
+
return out;
|
|
9372
|
+
}
|
|
9373
|
+
function removePendingHandoff(stateDir, ticketRecordId) {
|
|
9374
|
+
rmSync12(join23(pendingDirFor(stateDir), `${ticketRecordId}.json`), { force: true });
|
|
9375
|
+
}
|
|
9376
|
+
function pendingHandoffFor(stateDir, ticketRecordId) {
|
|
9377
|
+
return listPendingHandoffs(stateDir).find((r) => r.ticketRecordId === ticketRecordId);
|
|
9378
|
+
}
|
|
9379
|
+
var firstLine = (s) => s.trim().split("\n")[0] ?? "";
|
|
9380
|
+
async function replayPendingHandoffs(i) {
|
|
9381
|
+
const now = i.now ?? /* @__PURE__ */ new Date();
|
|
9382
|
+
const landed = [];
|
|
9383
|
+
for (const rec of listPendingHandoffs(i.stateDir, i.route).filter((r) => !i.role || r.role === i.role)) {
|
|
9384
|
+
const at = { ticket: rec.ticket, step: "handoff" };
|
|
9385
|
+
if (now.getTime() - Date.parse(rec.createdAt) > PENDING_MAX_AGE_MS) {
|
|
9386
|
+
i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: dropped a pending hand-off older than ${PENDING_MAX_AGE_MS / 36e5}h`, at);
|
|
9387
|
+
removePendingHandoff(i.stateDir, rec.ticketRecordId);
|
|
9388
|
+
continue;
|
|
9389
|
+
}
|
|
9390
|
+
if (now.toISOString() < rec.nextAttemptAt) continue;
|
|
9391
|
+
const seatId = i.seats[rec.role];
|
|
9392
|
+
try {
|
|
9393
|
+
const t = await i.writer.ticket(rec.ticketRecordId);
|
|
9394
|
+
const st = i.contract.statuses;
|
|
9395
|
+
if (!seatId || t.status !== st.building || rec.patch.status !== st.handoff) {
|
|
9396
|
+
i.log.emit(`${t.issue_id}: pending hand-off no longer applies (ticket is at ${t.status}) \u2014 dropped`, at);
|
|
9397
|
+
removePendingHandoff(i.stateDir, rec.ticketRecordId);
|
|
9398
|
+
continue;
|
|
9399
|
+
}
|
|
9400
|
+
const co = i.checkout(t);
|
|
9401
|
+
const v = co ? inspectHandoff(co.dir, co.cfg, t, rec.role) : null;
|
|
9402
|
+
if (!v || v.kind === "bounce") {
|
|
9403
|
+
i.log.warn(`${t.issue_id}: pending hand-off dropped \u2014 ${v ? v.reason : "this ship has no checkout of the ticket's repo"}`, at);
|
|
9404
|
+
removePendingHandoff(i.stateDir, rec.ticketRecordId);
|
|
9405
|
+
continue;
|
|
9406
|
+
}
|
|
9407
|
+
if (v.kind === "unverified") throw new Error(v.why);
|
|
9408
|
+
const text2 = rec.comment ?? (rec.commentLanded ? "" : rec.finalText);
|
|
9409
|
+
if (text2) {
|
|
9410
|
+
const head = firstLine(text2);
|
|
9411
|
+
const cc = i.contract.comments;
|
|
9412
|
+
const seen = (await i.writer.comments()).some((c) => {
|
|
9413
|
+
const row = c;
|
|
9414
|
+
return row[cc.parent] === t.id && row[cc.author] === seatId && firstLine(String(row[cc.body] ?? "")) === head;
|
|
9415
|
+
});
|
|
9416
|
+
if (!seen) await i.writer.postComment(t.id, text2, seatId);
|
|
9417
|
+
}
|
|
9418
|
+
await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), seatId, "pushed");
|
|
9419
|
+
await i.writer.updateTicket(t.id, rec.patch);
|
|
9420
|
+
removePendingHandoff(i.stateDir, rec.ticketRecordId);
|
|
9421
|
+
i.log.emit(`${t.issue_id}: replayed a pending hand-off at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, at);
|
|
9422
|
+
landed.push(t.id);
|
|
9423
|
+
} catch (e) {
|
|
9424
|
+
const attempts = rec.attempts + 1;
|
|
9425
|
+
writePendingHandoff(i.stateDir, {
|
|
9426
|
+
...rec,
|
|
9427
|
+
attempts,
|
|
9428
|
+
warned: true,
|
|
9429
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
9430
|
+
});
|
|
9431
|
+
if (!rec.warned) {
|
|
9432
|
+
i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: a hand-off is pending and could not be replayed yet (${e.message}); will retry`, at);
|
|
9433
|
+
}
|
|
9434
|
+
}
|
|
9435
|
+
}
|
|
9436
|
+
return landed;
|
|
9437
|
+
}
|
|
9438
|
+
|
|
9875
9439
|
// src/ship-affinity.ts
|
|
9876
9440
|
var CYCLE_SECONDS = 120;
|
|
9877
9441
|
var SHIP_DEAD_AFTER_CYCLES = 10;
|
|
@@ -10130,6 +9694,11 @@ function acceptanceLines(description) {
|
|
|
10130
9694
|
}
|
|
10131
9695
|
return out;
|
|
10132
9696
|
}
|
|
9697
|
+
function acceptanceChecklistBlock(lines2) {
|
|
9698
|
+
if (lines2.length === 0) return "";
|
|
9699
|
+
return `## Acceptance checklist (copy into your last comment and fill each result)
|
|
9700
|
+
${lines2.map((l) => `- ${l}: <test, command or manual check, and its result>`).join("\n")}`;
|
|
9701
|
+
}
|
|
10133
9702
|
var flat = (s) => s.replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
10134
9703
|
function unquotedAcceptance(lines2, comment) {
|
|
10135
9704
|
const body = flat(comment);
|
|
@@ -10219,16 +9788,16 @@ async function missingAcceptance(i, t) {
|
|
|
10219
9788
|
const from = i.since ? Date.parse(i.since) : NaN;
|
|
10220
9789
|
const recent = Number.isNaN(from) ? [] : mine.filter((r) => Date.parse(String(r[cc.createdAt] ?? "")) >= from);
|
|
10221
9790
|
const candidates2 = recent.length > 0 ? recent : mine.slice(-1);
|
|
10222
|
-
if (candidates2.length === 0) return { missing: lines2, judged: null };
|
|
9791
|
+
if (candidates2.length === 0) return { missing: lines2, judged: null, all: lines2 };
|
|
10223
9792
|
let best = null;
|
|
10224
9793
|
for (const c of candidates2) {
|
|
10225
9794
|
const missing = unquotedAcceptance(lines2, String(c[cc.body] ?? ""));
|
|
10226
9795
|
if (missing.length === 0) return null;
|
|
10227
|
-
if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null };
|
|
9796
|
+
if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null, all: lines2 };
|
|
10228
9797
|
}
|
|
10229
9798
|
return best;
|
|
10230
9799
|
}
|
|
10231
|
-
function acceptanceBounceComment(missing, shipName2, judged) {
|
|
9800
|
+
function acceptanceBounceComment(missing, shipName2, judged, all = []) {
|
|
10232
9801
|
return `${ACCEPTANCE_BOUNCED_MARKER}
|
|
10233
9802
|
|
|
10234
9803
|
**Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
|
|
@@ -10240,7 +9809,13 @@ ${shipName2 ? `Ship: ${shipName2}
|
|
|
10240
9809
|
` : "This seat has no comment on the ticket.\n\n"}Not quoted verbatim:
|
|
10241
9810
|
${missing.map((l) => `- ${l}`).join("\n")}
|
|
10242
9811
|
|
|
10243
|
-
Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed
|
|
9812
|
+
Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.${all.length > 0 ? `
|
|
9813
|
+
|
|
9814
|
+
Copy this block into the comment and fill in each result:
|
|
9815
|
+
|
|
9816
|
+
\`\`\`
|
|
9817
|
+
${acceptanceChecklistBlock(all)}
|
|
9818
|
+
\`\`\`` : ""}`;
|
|
10244
9819
|
}
|
|
10245
9820
|
async function enforceHandoff(i) {
|
|
10246
9821
|
try {
|
|
@@ -10271,7 +9846,7 @@ async function enforceHandoff(i) {
|
|
|
10271
9846
|
assignee_id: i.seatId,
|
|
10272
9847
|
...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
|
|
10273
9848
|
});
|
|
10274
|
-
await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged), i.seatId, "bounced").catch(() => {
|
|
9849
|
+
await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged, gap.all), i.seatId, "bounced").catch(() => {
|
|
10275
9850
|
i.log.warn("bounced, but the note failed to post", log);
|
|
10276
9851
|
});
|
|
10277
9852
|
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
|
|
@@ -10317,276 +9892,740 @@ async function enforceHandoff(i) {
|
|
|
10317
9892
|
i.log.emit(`${t.issue_id}: pushed ${again.branch} to ${again.remote} on the seat's behalf at ${again.sha.slice(0, 7)} \u2014 handed on to QA`, log);
|
|
10318
9893
|
return { kind: "pushed", sha: again.sha };
|
|
10319
9894
|
}
|
|
10320
|
-
pushError = `the push succeeded but ${again.kind === "bounce" ? again.reason : again.why}`;
|
|
10321
|
-
} catch (e) {
|
|
10322
|
-
pushError = e.message;
|
|
9895
|
+
pushError = `the push succeeded but ${again.kind === "bounce" ? again.reason : again.why}`;
|
|
9896
|
+
} catch (e) {
|
|
9897
|
+
pushError = e.message;
|
|
9898
|
+
}
|
|
9899
|
+
i.log.warn(`${t.issue_id}: the runner's own push failed: ${pushError}`, log);
|
|
9900
|
+
}
|
|
9901
|
+
await i.writer.updateTicket(t.id, {
|
|
9902
|
+
status: i.contract.statuses.building,
|
|
9903
|
+
assignee_id: i.seatId,
|
|
9904
|
+
...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
|
|
9905
|
+
});
|
|
9906
|
+
await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
|
|
9907
|
+
i.log.warn("bounced, but the note failed to post", log);
|
|
9908
|
+
});
|
|
9909
|
+
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
|
|
9910
|
+
return { kind: "bounced" };
|
|
9911
|
+
} catch (e) {
|
|
9912
|
+
i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
9913
|
+
return { kind: "failed", why: e.message };
|
|
9914
|
+
}
|
|
9915
|
+
}
|
|
9916
|
+
async function guardRunHandoff(route2, ship, tracker, role, workingId, log, since2) {
|
|
9917
|
+
const replayed = await replayRoutePending(route2, ship, tracker, log, role);
|
|
9918
|
+
if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
|
|
9919
|
+
const seatId = route2.resolved?.seats[role];
|
|
9920
|
+
if (!seatId) return null;
|
|
9921
|
+
try {
|
|
9922
|
+
const t = await tracker.ticket(workingId);
|
|
9923
|
+
const resolved = repoConfigFor(route2, t);
|
|
9924
|
+
if (!resolved) return null;
|
|
9925
|
+
const { target, cfg } = resolved;
|
|
9926
|
+
if (role === "qa") {
|
|
9927
|
+
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
9928
|
+
return null;
|
|
9929
|
+
}
|
|
9930
|
+
const affinity = await tracker.claimAffinity();
|
|
9931
|
+
const outcome = replayed.includes(workingId) ? { kind: "pushed", sha: "" } : await enforceHandoff({
|
|
9932
|
+
writer: tracker,
|
|
9933
|
+
contract: tracker.contract,
|
|
9934
|
+
ticketId: workingId,
|
|
9935
|
+
role,
|
|
9936
|
+
dir: target.dir,
|
|
9937
|
+
cfg,
|
|
9938
|
+
seatId,
|
|
9939
|
+
shipName: ship.name,
|
|
9940
|
+
hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
|
|
9941
|
+
since: since2,
|
|
9942
|
+
log
|
|
9943
|
+
});
|
|
9944
|
+
if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
9945
|
+
else {
|
|
9946
|
+
const branch = existingBranchForTicket(target.dir, cfg, t, role);
|
|
9947
|
+
if (branch && dropEmptyRunBranch(target.dir, cfg, branch)) {
|
|
9948
|
+
log.emit(`${t.issue_id}: dropped ${branch} \u2014 the run made no commits beyond ${cfg.branch.base}`, { ticket: t.issue_id, step: "handoff" });
|
|
9949
|
+
}
|
|
9950
|
+
}
|
|
9951
|
+
if (since2) await guardRunStrays(route2, ship, tracker, role, workingId, since2, log);
|
|
9952
|
+
return outcome;
|
|
9953
|
+
} catch (e) {
|
|
9954
|
+
log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
9955
|
+
return null;
|
|
9956
|
+
}
|
|
9957
|
+
}
|
|
9958
|
+
async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
|
|
9959
|
+
try {
|
|
9960
|
+
const branch = existingBranchForTicket(dir, cfg, t, role);
|
|
9961
|
+
if (!branch) return;
|
|
9962
|
+
const o = await finishRunWorktree(dir, cfg, branch);
|
|
9963
|
+
const at = { ticket: t.issue_id, step: "handoff" };
|
|
9964
|
+
if (o.kind === "removed") log.emit(`${t.issue_id}: removed the run's worktree ${o.path} \u2014 the branch is on ${cfg.branch.remote}`, at);
|
|
9965
|
+
else if (o.kind === "kept-handoff") {
|
|
9966
|
+
log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
|
|
9967
|
+
await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId, "worktree_kept");
|
|
9968
|
+
} else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
|
|
9969
|
+
else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
|
|
9970
|
+
} catch (e) {
|
|
9971
|
+
log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
|
|
9972
|
+
}
|
|
9973
|
+
}
|
|
9974
|
+
function repoConfigFor(route2, t) {
|
|
9975
|
+
const target = repoTargetFor(route2, t.repo_id);
|
|
9976
|
+
if (!target) return null;
|
|
9977
|
+
const o = route2.repoOverrides[target.name];
|
|
9978
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
9979
|
+
hooks: { ...route2.hooks, ...o?.hooks },
|
|
9980
|
+
labels: { ...route2.labels, ...o?.labels },
|
|
9981
|
+
branch: { ...route2.branch, ...o?.branch },
|
|
9982
|
+
worktrees: { nameOverride: o?.worktreeName }
|
|
9983
|
+
}, target.dir);
|
|
9984
|
+
return { target, cfg };
|
|
9985
|
+
}
|
|
9986
|
+
async function replayRoutePending(route2, ship, tracker, log, role) {
|
|
9987
|
+
try {
|
|
9988
|
+
return await replayPendingHandoffs({
|
|
9989
|
+
writer: tracker,
|
|
9990
|
+
contract: tracker.contract,
|
|
9991
|
+
stateDir: ship.stateDir,
|
|
9992
|
+
route: routeSlug(route2.route),
|
|
9993
|
+
role,
|
|
9994
|
+
seats: route2.resolved?.seats ?? {},
|
|
9995
|
+
checkout: (t) => {
|
|
9996
|
+
const r = repoConfigFor(route2, t);
|
|
9997
|
+
return r ? { dir: r.target.dir, cfg: r.cfg } : null;
|
|
9998
|
+
},
|
|
9999
|
+
log
|
|
10000
|
+
});
|
|
10001
|
+
} catch (e) {
|
|
10002
|
+
log.warn(`could not replay pending hand-offs: ${e.message}`, { step: "handoff" });
|
|
10003
|
+
return [];
|
|
10004
|
+
}
|
|
10005
|
+
}
|
|
10006
|
+
var STRAY_HANDOFF_MARKER = calloutHeader("WARNING", "Stray Hand-off");
|
|
10007
|
+
async function guardStrayHandoffs(i) {
|
|
10008
|
+
const st = i.contract.statuses;
|
|
10009
|
+
if (i.role === "qa") return;
|
|
10010
|
+
for (const t of i.candidates) {
|
|
10011
|
+
const at = { ticket: t.issue_id, step: "handoff" };
|
|
10012
|
+
try {
|
|
10013
|
+
const holder = i.myShipId ? ticketHolderShipId(t, i.contract) : null;
|
|
10014
|
+
if (holder && holder !== i.myShipId) {
|
|
10015
|
+
await i.writer.updateTicket(t.id, { status: st.building });
|
|
10016
|
+
await i.writer.postEvent(
|
|
10017
|
+
t.id,
|
|
10018
|
+
`${STRAY_HANDOFF_MARKER}
|
|
10019
|
+
|
|
10020
|
+
**Hand-off bounced: this ticket is held by another ship.**
|
|
10021
|
+
|
|
10022
|
+
${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
|
|
10023
|
+
i.seatId,
|
|
10024
|
+
"bounced"
|
|
10025
|
+
).catch(() => i.log.warn("bounced, but the note failed to post", at));
|
|
10026
|
+
i.log.warn(`${t.issue_id} was handed off by a run that did not claim it, while held by another ship \u2014 bounced to ${st.building}`, at);
|
|
10027
|
+
continue;
|
|
10028
|
+
}
|
|
10029
|
+
const co = i.checkout(t);
|
|
10030
|
+
if (!co || !existingBranchForTicket(co.dir, co.cfg, t, i.role)) {
|
|
10031
|
+
i.log.warn(`${t.issue_id} moved to ${st.handoff} during a run that did not claim it; no local branch to verify, left alone`, at);
|
|
10032
|
+
continue;
|
|
10033
|
+
}
|
|
10034
|
+
const v = inspectHandoff(co.dir, co.cfg, t, i.role);
|
|
10035
|
+
i.log.warn(`${t.issue_id} was handed off by a run that did not claim it`, at);
|
|
10036
|
+
if (v.kind === "pushed") {
|
|
10037
|
+
await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
|
|
10038
|
+
} else if (v.kind === "bounce") {
|
|
10039
|
+
if (v.origin && v.local && !isAncestor(co.dir, v.origin, v.local)) {
|
|
10040
|
+
i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
|
|
10041
|
+
continue;
|
|
10042
|
+
}
|
|
10043
|
+
await i.writer.updateTicket(t.id, { status: st.building, assignee_id: i.seatId });
|
|
10044
|
+
await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
|
|
10045
|
+
});
|
|
10323
10046
|
}
|
|
10324
|
-
|
|
10047
|
+
} catch (e) {
|
|
10048
|
+
i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
|
|
10325
10049
|
}
|
|
10326
|
-
await i.writer.updateTicket(t.id, {
|
|
10327
|
-
status: i.contract.statuses.building,
|
|
10328
|
-
assignee_id: i.seatId,
|
|
10329
|
-
...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
|
|
10330
|
-
});
|
|
10331
|
-
await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
|
|
10332
|
-
i.log.warn("bounced, but the note failed to post", log);
|
|
10333
|
-
});
|
|
10334
|
-
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
|
|
10335
|
-
return { kind: "bounced" };
|
|
10336
|
-
} catch (e) {
|
|
10337
|
-
i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
10338
|
-
return { kind: "failed", why: e.message };
|
|
10339
10050
|
}
|
|
10340
10051
|
}
|
|
10341
|
-
async function
|
|
10342
|
-
const replayed = await replayRoutePending(route2, ship, tracker, log, role);
|
|
10343
|
-
if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
|
|
10052
|
+
async function guardRunStrays(route2, ship, tracker, role, workingId, since2, log) {
|
|
10344
10053
|
const seatId = route2.resolved?.seats[role];
|
|
10345
|
-
if (!seatId) return
|
|
10054
|
+
if (!seatId) return;
|
|
10346
10055
|
try {
|
|
10347
|
-
const t = await tracker.ticket(workingId);
|
|
10348
|
-
const resolved = repoConfigFor(route2, t);
|
|
10349
|
-
if (!resolved) return null;
|
|
10350
|
-
const { target, cfg } = resolved;
|
|
10351
|
-
if (role === "qa") {
|
|
10352
|
-
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
10353
|
-
return null;
|
|
10354
|
-
}
|
|
10355
10056
|
const affinity = await tracker.claimAffinity();
|
|
10356
|
-
const
|
|
10057
|
+
const candidates2 = await tracker.handedOffSince(since2, workingId);
|
|
10058
|
+
if (candidates2.length === 0) return;
|
|
10059
|
+
await guardStrayHandoffs({
|
|
10357
10060
|
writer: tracker,
|
|
10358
10061
|
contract: tracker.contract,
|
|
10359
|
-
|
|
10360
|
-
role,
|
|
10361
|
-
dir: target.dir,
|
|
10362
|
-
cfg,
|
|
10062
|
+
candidates: candidates2,
|
|
10363
10063
|
seatId,
|
|
10064
|
+
role,
|
|
10065
|
+
myShipId: affinity?.shipId ?? null,
|
|
10364
10066
|
shipName: ship.name,
|
|
10365
|
-
|
|
10366
|
-
|
|
10067
|
+
checkout: (t) => {
|
|
10068
|
+
const r = repoConfigFor(route2, t);
|
|
10069
|
+
return r ? { dir: r.target.dir, cfg: r.cfg } : null;
|
|
10070
|
+
},
|
|
10367
10071
|
log
|
|
10368
10072
|
});
|
|
10369
|
-
if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
10370
|
-
else {
|
|
10371
|
-
const branch = existingBranchForTicket(target.dir, cfg, t, role);
|
|
10372
|
-
if (branch && dropEmptyRunBranch(target.dir, cfg, branch)) {
|
|
10373
|
-
log.emit(`${t.issue_id}: dropped ${branch} \u2014 the run made no commits beyond ${cfg.branch.base}`, { ticket: t.issue_id, step: "handoff" });
|
|
10374
|
-
}
|
|
10375
|
-
}
|
|
10376
|
-
if (since2) await guardRunStrays(route2, ship, tracker, role, workingId, since2, log);
|
|
10377
|
-
return outcome;
|
|
10378
10073
|
} catch (e) {
|
|
10379
|
-
log.warn(`could not check
|
|
10074
|
+
log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
|
|
10075
|
+
}
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
// src/agent-log.ts
|
|
10079
|
+
var AgentLogUnreachableError = class extends Error {
|
|
10080
|
+
};
|
|
10081
|
+
var AgentLogHttpError = class extends Error {
|
|
10082
|
+
status;
|
|
10083
|
+
/** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
|
|
10084
|
+
row = false;
|
|
10085
|
+
constructor(message, status2) {
|
|
10086
|
+
super(message);
|
|
10087
|
+
this.status = status2;
|
|
10088
|
+
}
|
|
10089
|
+
};
|
|
10090
|
+
async function request(target, method, path, body) {
|
|
10091
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
10092
|
+
const res = await fetch(url, {
|
|
10093
|
+
method,
|
|
10094
|
+
headers: {
|
|
10095
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
10096
|
+
"User-Agent": target.userAgent,
|
|
10097
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
10098
|
+
},
|
|
10099
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
10100
|
+
});
|
|
10101
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
10102
|
+
return await res.json();
|
|
10103
|
+
}
|
|
10104
|
+
var issueColumns = /* @__PURE__ */ new Map();
|
|
10105
|
+
function columnsOf(target, modelId) {
|
|
10106
|
+
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
10107
|
+
let p = issueColumns.get(key);
|
|
10108
|
+
if (!p) {
|
|
10109
|
+
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
10110
|
+
p.catch(() => issueColumns.delete(key));
|
|
10111
|
+
issueColumns.set(key, p);
|
|
10112
|
+
}
|
|
10113
|
+
return p;
|
|
10114
|
+
}
|
|
10115
|
+
async function findTicketRecord(target, t, ref) {
|
|
10116
|
+
const filters = {
|
|
10117
|
+
combinator: "OR",
|
|
10118
|
+
conditions: [
|
|
10119
|
+
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
10120
|
+
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
10121
|
+
]
|
|
10122
|
+
};
|
|
10123
|
+
const rows = await request(
|
|
10124
|
+
target,
|
|
10125
|
+
"GET",
|
|
10126
|
+
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
10127
|
+
);
|
|
10128
|
+
return rows.length === 1 ? rows[0].id : void 0;
|
|
10129
|
+
}
|
|
10130
|
+
function counterIncrements(run3, cols, have) {
|
|
10131
|
+
const figures = [
|
|
10132
|
+
[cols.agentRuns, 1],
|
|
10133
|
+
[cols.tokensIn, run3.tokensIn],
|
|
10134
|
+
[cols.tokensOut, run3.tokensOut],
|
|
10135
|
+
[cols.cacheReadTokens, run3.cacheReadTokens],
|
|
10136
|
+
[cols.cacheWriteTokens, run3.cacheWriteTokens],
|
|
10137
|
+
[cols.costUsd, run3.costUsd]
|
|
10138
|
+
];
|
|
10139
|
+
const out = {};
|
|
10140
|
+
for (const [column, value2] of figures) {
|
|
10141
|
+
if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
|
|
10142
|
+
}
|
|
10143
|
+
return out;
|
|
10144
|
+
}
|
|
10145
|
+
var BEFORE_EDIT_COLUMNS = {
|
|
10146
|
+
search: "calls_before_edit_search",
|
|
10147
|
+
symbol: "calls_before_edit_symbol",
|
|
10148
|
+
other: "calls_before_edit_other",
|
|
10149
|
+
seconds: "seconds_to_first_edit",
|
|
10150
|
+
edited: "edited"
|
|
10151
|
+
};
|
|
10152
|
+
function beforeFirstEditBody(b) {
|
|
10153
|
+
return {
|
|
10154
|
+
[BEFORE_EDIT_COLUMNS.search]: b.calls.search,
|
|
10155
|
+
[BEFORE_EDIT_COLUMNS.symbol]: b.calls.symbol,
|
|
10156
|
+
[BEFORE_EDIT_COLUMNS.other]: b.calls.other,
|
|
10157
|
+
[BEFORE_EDIT_COLUMNS.edited]: b.edited,
|
|
10158
|
+
...b.secondsToFirstEdit !== void 0 ? { [BEFORE_EDIT_COLUMNS.seconds]: b.secondsToFirstEdit } : {}
|
|
10159
|
+
};
|
|
10160
|
+
}
|
|
10161
|
+
async function writeBeforeFirstEdit(target, rowId, b) {
|
|
10162
|
+
try {
|
|
10163
|
+
const model = await request(
|
|
10164
|
+
target,
|
|
10165
|
+
"GET",
|
|
10166
|
+
`/data-models/agent_log?workspaceId=${encodeURIComponent(target.workspaceId)}`
|
|
10167
|
+
);
|
|
10168
|
+
const have = await columnsOf(target, model.id);
|
|
10169
|
+
const body = Object.fromEntries(Object.entries(beforeFirstEditBody(b)).filter(([column]) => have.has(column)));
|
|
10170
|
+
if (Object.keys(body).length === 0) return;
|
|
10171
|
+
await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, body);
|
|
10172
|
+
} catch {
|
|
10173
|
+
}
|
|
10174
|
+
}
|
|
10175
|
+
var HANDOFF_BOUNCES_COLUMN = "handoff_bounces";
|
|
10176
|
+
async function countAcceptanceBounces(target, issuesModelId, ticketRecordId) {
|
|
10177
|
+
try {
|
|
10178
|
+
const filters = encodeURIComponent(JSON.stringify([{ columnName: "event_type", operator: "EQ", value: "bounced" }]));
|
|
10179
|
+
const rows = await request(
|
|
10180
|
+
target,
|
|
10181
|
+
"GET",
|
|
10182
|
+
`/data-models/${issuesModelId}/events?limit=500&filters=${filters}`
|
|
10183
|
+
);
|
|
10184
|
+
return rows.filter((e) => e.record_id === ticketRecordId && (e.message ?? "").includes(ACCEPTANCE_BOUNCED_MARKER)).length;
|
|
10185
|
+
} catch {
|
|
10380
10186
|
return null;
|
|
10381
10187
|
}
|
|
10382
10188
|
}
|
|
10383
|
-
async function
|
|
10189
|
+
async function writeHandoffBounces(target, rowId, count) {
|
|
10190
|
+
try {
|
|
10191
|
+
const model = await request(
|
|
10192
|
+
target,
|
|
10193
|
+
"GET",
|
|
10194
|
+
`/data-models/agent_log?workspaceId=${encodeURIComponent(target.workspaceId)}`
|
|
10195
|
+
);
|
|
10196
|
+
if (!(await columnsOf(target, model.id)).has(HANDOFF_BOUNCES_COLUMN)) return;
|
|
10197
|
+
await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, { [HANDOFF_BOUNCES_COLUMN]: count });
|
|
10198
|
+
} catch {
|
|
10199
|
+
}
|
|
10200
|
+
}
|
|
10201
|
+
async function post(target, path, body) {
|
|
10202
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
|
|
10203
|
+
const res = await fetch(url, {
|
|
10204
|
+
method: "POST",
|
|
10205
|
+
headers: {
|
|
10206
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
10207
|
+
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
10208
|
+
"User-Agent": target.userAgent,
|
|
10209
|
+
"Content-Type": "application/json"
|
|
10210
|
+
},
|
|
10211
|
+
body: JSON.stringify(body)
|
|
10212
|
+
});
|
|
10213
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
10214
|
+
return await res.json();
|
|
10215
|
+
}
|
|
10216
|
+
async function reportAgentRun(target, run3) {
|
|
10217
|
+
let ticketRecordId;
|
|
10218
|
+
if (target.tickets && run3.ticketReference) {
|
|
10219
|
+
try {
|
|
10220
|
+
ticketRecordId = await findTicketRecord(target, target.tickets, run3.ticketReference);
|
|
10221
|
+
} catch {
|
|
10222
|
+
}
|
|
10223
|
+
}
|
|
10224
|
+
const body = {
|
|
10225
|
+
agentId: target.agentId,
|
|
10226
|
+
ticketReference: run3.ticketReference,
|
|
10227
|
+
outcome: run3.outcome,
|
|
10228
|
+
startedAt: run3.startedAt,
|
|
10229
|
+
finishedAt: run3.finishedAt,
|
|
10230
|
+
source: "client",
|
|
10231
|
+
client: "crew",
|
|
10232
|
+
model: run3.model,
|
|
10233
|
+
tokensIn: run3.tokensIn,
|
|
10234
|
+
tokensOut: run3.tokensOut,
|
|
10235
|
+
cacheReadTokens: run3.cacheReadTokens,
|
|
10236
|
+
cacheWriteTokens: run3.cacheWriteTokens,
|
|
10237
|
+
costUsd: run3.costUsd,
|
|
10238
|
+
numTurns: run3.numTurns,
|
|
10239
|
+
prefixTokens: run3.prefixTokens,
|
|
10240
|
+
promptVersion: run3.promptVersion,
|
|
10241
|
+
promptSha: run3.promptSha
|
|
10242
|
+
};
|
|
10243
|
+
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
10244
|
+
let entry;
|
|
10384
10245
|
try {
|
|
10385
|
-
|
|
10386
|
-
if (!branch) return;
|
|
10387
|
-
const o = await finishRunWorktree(dir, cfg, branch);
|
|
10388
|
-
const at = { ticket: t.issue_id, step: "handoff" };
|
|
10389
|
-
if (o.kind === "removed") log.emit(`${t.issue_id}: removed the run's worktree ${o.path} \u2014 the branch is on ${cfg.branch.remote}`, at);
|
|
10390
|
-
else if (o.kind === "kept-handoff") {
|
|
10391
|
-
log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
|
|
10392
|
-
await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId, "worktree_kept");
|
|
10393
|
-
} else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
|
|
10394
|
-
else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
|
|
10246
|
+
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run3.harness, provider: run3.provider });
|
|
10395
10247
|
} catch (e) {
|
|
10396
|
-
|
|
10248
|
+
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
10249
|
+
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
10250
|
+
if (e.status !== 400 || !run3.harness && !run3.provider && !ticketRecordId) {
|
|
10251
|
+
e.row = true;
|
|
10252
|
+
throw e;
|
|
10253
|
+
}
|
|
10254
|
+
try {
|
|
10255
|
+
entry = await post(target, "/agents/log", body);
|
|
10256
|
+
} catch (e2) {
|
|
10257
|
+
if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
|
|
10258
|
+
throw e2;
|
|
10259
|
+
}
|
|
10260
|
+
}
|
|
10261
|
+
for (const cycle of run3.cycles) {
|
|
10262
|
+
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
10263
|
+
cycleIndex: cycle.cycleIndex,
|
|
10264
|
+
occurredAt: cycle.occurredAt,
|
|
10265
|
+
thinking: cycle.thinking,
|
|
10266
|
+
...cycle.role ? { role: cycle.role } : {}
|
|
10267
|
+
});
|
|
10268
|
+
}
|
|
10269
|
+
if (run3.beforeFirstEdit) await writeBeforeFirstEdit(target, entry.id, run3.beforeFirstEdit);
|
|
10270
|
+
if (target.tickets && ticketRecordId) {
|
|
10271
|
+
const bounces = await countAcceptanceBounces(target, target.tickets.issuesModelId, ticketRecordId);
|
|
10272
|
+
if (bounces !== null) await writeHandoffBounces(target, entry.id, bounces);
|
|
10397
10273
|
}
|
|
10274
|
+
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run3) : void 0;
|
|
10275
|
+
return {
|
|
10276
|
+
agentLogId: entry.id,
|
|
10277
|
+
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
10278
|
+
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
10279
|
+
};
|
|
10398
10280
|
}
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
const
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
labels: { ...route2.labels, ...o?.labels },
|
|
10406
|
-
branch: { ...route2.branch, ...o?.branch },
|
|
10407
|
-
worktrees: { nameOverride: o?.worktreeName }
|
|
10408
|
-
}, target.dir);
|
|
10409
|
-
return { target, cfg };
|
|
10281
|
+
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
10282
|
+
async function writeCounters(target, w) {
|
|
10283
|
+
const have = await columnsOf(target, w.issuesModelId);
|
|
10284
|
+
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
10285
|
+
if (Object.keys(body).length === 0) return;
|
|
10286
|
+
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
10410
10287
|
}
|
|
10411
|
-
async function
|
|
10288
|
+
async function addRunToTicket(target, t, recordId, run3) {
|
|
10289
|
+
const write = {
|
|
10290
|
+
issuesModelId: t.issuesModelId,
|
|
10291
|
+
recordId,
|
|
10292
|
+
ticketReference: run3.ticketReference,
|
|
10293
|
+
counters: t.counters,
|
|
10294
|
+
body: counterIncrements(run3, t.counters, new Set(Object.values(t.counters)))
|
|
10295
|
+
};
|
|
10412
10296
|
try {
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
contract: tracker.contract,
|
|
10416
|
-
stateDir: ship.stateDir,
|
|
10417
|
-
route: routeSlug(route2.route),
|
|
10418
|
-
role,
|
|
10419
|
-
seats: route2.resolved?.seats ?? {},
|
|
10420
|
-
checkout: (t) => {
|
|
10421
|
-
const r = repoConfigFor(route2, t);
|
|
10422
|
-
return r ? { dir: r.target.dir, cfg: r.cfg } : null;
|
|
10423
|
-
},
|
|
10424
|
-
log
|
|
10425
|
-
});
|
|
10297
|
+
await writeCounters(target, write);
|
|
10298
|
+
return {};
|
|
10426
10299
|
} catch (e) {
|
|
10427
|
-
|
|
10428
|
-
|
|
10300
|
+
return {
|
|
10301
|
+
warning: `could not add run spend to ${run3.ticketReference}: ${e.message}`,
|
|
10302
|
+
...isRetryable(e) ? { retry: write } : {}
|
|
10303
|
+
};
|
|
10429
10304
|
}
|
|
10430
10305
|
}
|
|
10431
|
-
var
|
|
10432
|
-
|
|
10433
|
-
|
|
10434
|
-
|
|
10435
|
-
|
|
10436
|
-
|
|
10306
|
+
var queueDir = (stateDir) => join25(stateDir, "pending-agent-logs");
|
|
10307
|
+
function queueAgentRun(stateDir, name, q2) {
|
|
10308
|
+
mkdirSync16(queueDir(stateDir), { recursive: true });
|
|
10309
|
+
writeFileSync17(join25(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
|
|
10310
|
+
}
|
|
10311
|
+
async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
10312
|
+
const dir = queueDir(stateDir);
|
|
10313
|
+
if (!existsSync23(dir)) return 0;
|
|
10314
|
+
let sent = 0;
|
|
10315
|
+
for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
10316
|
+
const path = join25(dir, f);
|
|
10317
|
+
let q2;
|
|
10437
10318
|
try {
|
|
10438
|
-
|
|
10439
|
-
|
|
10440
|
-
|
|
10441
|
-
|
|
10442
|
-
|
|
10443
|
-
|
|
10444
|
-
|
|
10445
|
-
|
|
10446
|
-
|
|
10447
|
-
|
|
10448
|
-
|
|
10449
|
-
|
|
10450
|
-
|
|
10451
|
-
|
|
10319
|
+
q2 = JSON.parse(readFileSync23(path, "utf8"));
|
|
10320
|
+
} catch {
|
|
10321
|
+
continue;
|
|
10322
|
+
}
|
|
10323
|
+
if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
|
|
10324
|
+
try {
|
|
10325
|
+
const apiKey = await keyFor(q2.role);
|
|
10326
|
+
if (!apiKey) continue;
|
|
10327
|
+
const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
|
|
10328
|
+
if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
|
|
10329
|
+
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
10330
|
+
version: 1,
|
|
10331
|
+
route: q2.route,
|
|
10332
|
+
role: q2.role,
|
|
10333
|
+
target: q2.target,
|
|
10334
|
+
write: sentRun.counterRetry,
|
|
10335
|
+
attempts: 0,
|
|
10336
|
+
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
10337
|
+
});
|
|
10338
|
+
rmSync13(path, { force: true });
|
|
10339
|
+
sent += 1;
|
|
10340
|
+
log.emit(`sent a queued Agent Log report (${f})`);
|
|
10341
|
+
} catch (e) {
|
|
10342
|
+
if (!(e instanceof AgentLogUnreachableError)) {
|
|
10343
|
+
rmSync13(path, { force: true });
|
|
10344
|
+
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
10452
10345
|
continue;
|
|
10453
10346
|
}
|
|
10454
|
-
const
|
|
10455
|
-
|
|
10456
|
-
|
|
10347
|
+
const attempts = q2.attempts + 1;
|
|
10348
|
+
writeFileSync17(path, JSON.stringify({
|
|
10349
|
+
...q2,
|
|
10350
|
+
attempts,
|
|
10351
|
+
warned: true,
|
|
10352
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
10353
|
+
}));
|
|
10354
|
+
if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
10355
|
+
}
|
|
10356
|
+
}
|
|
10357
|
+
return sent;
|
|
10358
|
+
}
|
|
10359
|
+
var countersDir = (stateDir) => join25(stateDir, "pending-ticket-counters");
|
|
10360
|
+
function queueTicketCounters(stateDir, name, q2) {
|
|
10361
|
+
mkdirSync16(countersDir(stateDir), { recursive: true });
|
|
10362
|
+
writeFileSync17(join25(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
|
|
10363
|
+
}
|
|
10364
|
+
async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
10365
|
+
const dir = countersDir(stateDir);
|
|
10366
|
+
if (!existsSync23(dir)) return 0;
|
|
10367
|
+
let sent = 0;
|
|
10368
|
+
for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
10369
|
+
const path = join25(dir, f);
|
|
10370
|
+
let q2;
|
|
10371
|
+
try {
|
|
10372
|
+
q2 = JSON.parse(readFileSync23(path, "utf8"));
|
|
10373
|
+
} catch {
|
|
10374
|
+
continue;
|
|
10375
|
+
}
|
|
10376
|
+
if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
|
|
10377
|
+
try {
|
|
10378
|
+
const apiKey = await keyFor(q2.role);
|
|
10379
|
+
if (!apiKey) continue;
|
|
10380
|
+
await writeCounters({ ...q2.target, apiKey }, q2.write);
|
|
10381
|
+
rmSync13(path, { force: true });
|
|
10382
|
+
sent += 1;
|
|
10383
|
+
log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
|
|
10384
|
+
} catch (e) {
|
|
10385
|
+
if (!isRetryable(e)) {
|
|
10386
|
+
rmSync13(path, { force: true });
|
|
10387
|
+
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
10457
10388
|
continue;
|
|
10458
10389
|
}
|
|
10459
|
-
const
|
|
10460
|
-
|
|
10461
|
-
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10390
|
+
const attempts = q2.attempts + 1;
|
|
10391
|
+
writeFileSync17(path, JSON.stringify({
|
|
10392
|
+
...q2,
|
|
10393
|
+
attempts,
|
|
10394
|
+
warned: true,
|
|
10395
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
10396
|
+
}));
|
|
10397
|
+
if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
10398
|
+
}
|
|
10399
|
+
}
|
|
10400
|
+
return sent;
|
|
10401
|
+
}
|
|
10402
|
+
|
|
10403
|
+
// src/ticket-attribution.ts
|
|
10404
|
+
var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
|
|
10405
|
+
function ticketMentions(text2) {
|
|
10406
|
+
return [...text2.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
|
|
10407
|
+
}
|
|
10408
|
+
var TicketAttributionTracker = class {
|
|
10409
|
+
#tokensByTicket = /* @__PURE__ */ new Map();
|
|
10410
|
+
#turns = 0;
|
|
10411
|
+
#current;
|
|
10412
|
+
/** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
|
|
10413
|
+
#keyByRecord = /* @__PURE__ */ new Map();
|
|
10414
|
+
#lastBy = {};
|
|
10415
|
+
/**
|
|
10416
|
+
* The ticket key most recently named in the transcript, or undefined before any.
|
|
10417
|
+
* Only the FIRST key in a turn counts: a closing summary names its own
|
|
10418
|
+
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
10419
|
+
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
10420
|
+
*/
|
|
10421
|
+
get current() {
|
|
10422
|
+
return this.#current;
|
|
10423
|
+
}
|
|
10424
|
+
/** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
|
|
10425
|
+
note(text2) {
|
|
10426
|
+
const mentions = ticketMentions(text2);
|
|
10427
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
10428
|
+
}
|
|
10429
|
+
/** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
|
|
10430
|
+
add(text2, tokens) {
|
|
10431
|
+
const mentions = ticketMentions(text2);
|
|
10432
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
10433
|
+
if (this.#current === void 0 || tokens <= 0) return;
|
|
10434
|
+
this.#turns++;
|
|
10435
|
+
this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
|
|
10436
|
+
}
|
|
10437
|
+
/**
|
|
10438
|
+
* Record a tool call if it is a ticket-targeted tracker write (claim,
|
|
10439
|
+
* hand-off or comment). The ticket key comes from a record uuid seen on an
|
|
10440
|
+
* earlier write when known, else from the key last named in the transcript
|
|
10441
|
+
* (a claim or hand-off announces its ticket). A comment on a record never
|
|
10442
|
+
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
10443
|
+
*/
|
|
10444
|
+
noteWrite(tool, input) {
|
|
10445
|
+
const w = classifyWrite(tool, input);
|
|
10446
|
+
if (!w) return;
|
|
10447
|
+
let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
|
|
10448
|
+
if (w.kind !== "comment") key ??= this.#current;
|
|
10449
|
+
if (!key) return;
|
|
10450
|
+
if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
|
|
10451
|
+
this.#lastBy[w.kind] = key;
|
|
10452
|
+
}
|
|
10453
|
+
/**
|
|
10454
|
+
* How the attribution is decided: a hand-off write beats a claim, a claim
|
|
10455
|
+
* beats a comment (so a follow-up ticket filed or commented on while
|
|
10456
|
+
* working never displaces the claimed one), and any write beats everything
|
|
10457
|
+
* below. Next, `hintMoved` — the post-run read-back showed the hinted
|
|
10458
|
+
* ticket changed status or assignee during the run — is a hand-off signal
|
|
10459
|
+
* for a run that wrote through MCP tools crew cannot see. Then the
|
|
10460
|
+
* mention-token heuristic, which with a hint may override it only when a
|
|
10461
|
+
* different ticket holds a strict majority of the credited tokens (a single
|
|
10462
|
+
* credited turn, like a lone end-of-run summary naming another ticket,
|
|
10463
|
+
* must not). A run with no hint
|
|
10464
|
+
* keeps the plain winner. The pre-run hint is last.
|
|
10465
|
+
*/
|
|
10466
|
+
decide(hint, opts = {}) {
|
|
10467
|
+
for (const by of ["handoff", "claim", "comment"]) {
|
|
10468
|
+
const ticket = this.#lastBy[by];
|
|
10469
|
+
if (ticket) return { ticket, by };
|
|
10470
|
+
}
|
|
10471
|
+
if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
|
|
10472
|
+
if (this.#tokensByTicket.size > 0) {
|
|
10473
|
+
if (!hint) return { ticket: this.winner(), by: "mentions" };
|
|
10474
|
+
const winner = this.winner(hint);
|
|
10475
|
+
if (winner === hint) return { ticket: hint, by: "mentions" };
|
|
10476
|
+
let total = 0;
|
|
10477
|
+
for (const n of this.#tokensByTicket.values()) total += n;
|
|
10478
|
+
const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
|
|
10479
|
+
return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
|
|
10480
|
+
}
|
|
10481
|
+
return { ticket: hint, by: "hint" };
|
|
10482
|
+
}
|
|
10483
|
+
/**
|
|
10484
|
+
* The ticket key the run spent the most tokens on, or `fallback` (the
|
|
10485
|
+
* pre-run poll hint) when the transcript never named one — an
|
|
10486
|
+
* administrative run that touches the tracker without discussing a
|
|
10487
|
+
* specific ticket in its visible text still has to cite something.
|
|
10488
|
+
*/
|
|
10489
|
+
winner(fallback) {
|
|
10490
|
+
let best = fallback;
|
|
10491
|
+
let bestTokens = -1;
|
|
10492
|
+
for (const [ticket, tokens] of this.#tokensByTicket) {
|
|
10493
|
+
if (tokens > bestTokens) {
|
|
10494
|
+
best = ticket;
|
|
10495
|
+
bestTokens = tokens;
|
|
10471
10496
|
}
|
|
10472
|
-
} catch (e) {
|
|
10473
|
-
i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
|
|
10474
10497
|
}
|
|
10498
|
+
return best;
|
|
10475
10499
|
}
|
|
10500
|
+
};
|
|
10501
|
+
function isClaimWrite(tool, input) {
|
|
10502
|
+
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
10503
|
+
const body = input?.body;
|
|
10504
|
+
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
10476
10505
|
}
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
if (!
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
candidates: candidates2,
|
|
10488
|
-
seatId,
|
|
10489
|
-
role,
|
|
10490
|
-
myShipId: affinity?.shipId ?? null,
|
|
10491
|
-
shipName: ship.name,
|
|
10492
|
-
checkout: (t) => {
|
|
10493
|
-
const r = repoConfigFor(route2, t);
|
|
10494
|
-
return r ? { dir: r.target.dir, cfg: r.cfg } : null;
|
|
10495
|
-
},
|
|
10496
|
-
log
|
|
10497
|
-
});
|
|
10498
|
-
} catch (e) {
|
|
10499
|
-
log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
|
|
10506
|
+
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
10507
|
+
function classifyWrite(tool, input) {
|
|
10508
|
+
if (!tool) return void 0;
|
|
10509
|
+
const body = input?.body;
|
|
10510
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
10511
|
+
const b = body;
|
|
10512
|
+
if (/records_controller_update$/.test(tool)) {
|
|
10513
|
+
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
10514
|
+
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
10515
|
+
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
10500
10516
|
}
|
|
10517
|
+
if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
|
|
10518
|
+
return void 0;
|
|
10501
10519
|
}
|
|
10502
10520
|
|
|
10503
|
-
// src/
|
|
10504
|
-
var
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
mkdirSync16(dir, { recursive: true });
|
|
10510
|
-
writeFileSync17(join25(dir, `${rec.ticketRecordId}.json`), JSON.stringify(rec, null, 2));
|
|
10521
|
+
// src/handoff-transcript.ts
|
|
10522
|
+
var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
|
|
10523
|
+
function resultText(content) {
|
|
10524
|
+
if (typeof content === "string") return content;
|
|
10525
|
+
if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
|
|
10526
|
+
return "";
|
|
10511
10527
|
}
|
|
10512
|
-
function
|
|
10513
|
-
|
|
10514
|
-
if (!existsSync23(dir)) return [];
|
|
10515
|
-
const out = [];
|
|
10516
|
-
for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
10528
|
+
function isTransportFailure(isError, text2) {
|
|
10529
|
+
if (!isError) {
|
|
10517
10530
|
try {
|
|
10518
|
-
const
|
|
10519
|
-
if (
|
|
10531
|
+
const doc = JSON.parse(text2);
|
|
10532
|
+
if (doc && typeof doc === "object") {
|
|
10533
|
+
const code = doc.statusCode;
|
|
10534
|
+
return typeof code === "number" && code >= 500;
|
|
10535
|
+
}
|
|
10520
10536
|
} catch {
|
|
10521
10537
|
}
|
|
10522
10538
|
}
|
|
10523
|
-
return
|
|
10539
|
+
return TRANSPORT_FAILURE.test(text2);
|
|
10524
10540
|
}
|
|
10525
|
-
|
|
10526
|
-
|
|
10541
|
+
var FORBIDDEN_WRITE = /\b403\b[^]*?No WRITE access/i;
|
|
10542
|
+
function isForbiddenWrite(isError, text2) {
|
|
10543
|
+
if (FORBIDDEN_WRITE.test(text2)) return true;
|
|
10544
|
+
if (!isError) {
|
|
10545
|
+
try {
|
|
10546
|
+
const doc = JSON.parse(text2);
|
|
10547
|
+
return !!doc && typeof doc === "object" && doc.statusCode === 403;
|
|
10548
|
+
} catch {
|
|
10549
|
+
}
|
|
10550
|
+
}
|
|
10551
|
+
return false;
|
|
10527
10552
|
}
|
|
10528
|
-
function
|
|
10529
|
-
|
|
10553
|
+
function forbiddenDetail403(text2) {
|
|
10554
|
+
const m = /No WRITE access to (?:"[^"]+"|this model|[\w-]+)/i.exec(text2);
|
|
10555
|
+
return m ? m[0] : "a hand-off write was refused";
|
|
10530
10556
|
}
|
|
10531
|
-
var
|
|
10532
|
-
|
|
10533
|
-
|
|
10534
|
-
|
|
10535
|
-
|
|
10536
|
-
|
|
10537
|
-
|
|
10538
|
-
|
|
10539
|
-
|
|
10540
|
-
|
|
10541
|
-
|
|
10542
|
-
|
|
10543
|
-
|
|
10544
|
-
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10548
|
-
|
|
10549
|
-
|
|
10550
|
-
|
|
10551
|
-
|
|
10552
|
-
|
|
10553
|
-
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
10560
|
-
|
|
10561
|
-
|
|
10562
|
-
|
|
10563
|
-
|
|
10564
|
-
|
|
10565
|
-
|
|
10566
|
-
|
|
10567
|
-
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
|
|
10572
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10575
|
-
|
|
10576
|
-
|
|
10577
|
-
writePendingHandoff(i.stateDir, {
|
|
10578
|
-
...rec,
|
|
10579
|
-
attempts,
|
|
10580
|
-
warned: true,
|
|
10581
|
-
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
10582
|
-
});
|
|
10583
|
-
if (!rec.warned) {
|
|
10584
|
-
i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: a hand-off is pending and could not be replayed yet (${e.message}); will retry`, at);
|
|
10557
|
+
var HandoffTranscriptTracker = class {
|
|
10558
|
+
open = /* @__PURE__ */ new Map();
|
|
10559
|
+
/** Per ticket record: the last hand-off status write's outcome. */
|
|
10560
|
+
status = /* @__PURE__ */ new Map();
|
|
10561
|
+
/** Per ticket record: the last hand-off comment that failed after the status write's turn. */
|
|
10562
|
+
comment = /* @__PURE__ */ new Map();
|
|
10563
|
+
landed = /* @__PURE__ */ new Set();
|
|
10564
|
+
/** Per ticket record: the last hand-off status write, when the tracker refused it with 403. */
|
|
10565
|
+
forbiddenWrites = /* @__PURE__ */ new Map();
|
|
10566
|
+
statusLanded = false;
|
|
10567
|
+
lastText = "";
|
|
10568
|
+
resultLine = "";
|
|
10569
|
+
feed(parsed) {
|
|
10570
|
+
const line = parsed;
|
|
10571
|
+
if (!line || typeof line !== "object") return;
|
|
10572
|
+
if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
|
|
10573
|
+
const content = line.message?.content;
|
|
10574
|
+
if (!Array.isArray(content)) return;
|
|
10575
|
+
for (const b of content) {
|
|
10576
|
+
if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
|
|
10577
|
+
else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
|
|
10578
|
+
const w = classifyWrite(b.name, b.input);
|
|
10579
|
+
const body = b.input?.body;
|
|
10580
|
+
if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
|
|
10581
|
+
this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
|
|
10582
|
+
}
|
|
10583
|
+
} else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
|
|
10584
|
+
const w = this.open.get(b.tool_use_id);
|
|
10585
|
+
if (!w) continue;
|
|
10586
|
+
this.open.delete(b.tool_use_id);
|
|
10587
|
+
const text2 = resultText(b.content);
|
|
10588
|
+
if (w.kind === "handoff") {
|
|
10589
|
+
const refused = isForbiddenWrite(b.is_error === true, text2);
|
|
10590
|
+
if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
|
|
10591
|
+
else {
|
|
10592
|
+
this.forbiddenWrites.delete(w.recordId);
|
|
10593
|
+
if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
|
|
10594
|
+
}
|
|
10595
|
+
}
|
|
10596
|
+
const failed = isTransportFailure(b.is_error === true, text2);
|
|
10597
|
+
if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
|
|
10598
|
+
else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
|
|
10599
|
+
else if (!failed) {
|
|
10600
|
+
this.comment.delete(w.recordId);
|
|
10601
|
+
this.landed.add(w.recordId);
|
|
10602
|
+
}
|
|
10585
10603
|
}
|
|
10586
10604
|
}
|
|
10587
10605
|
}
|
|
10588
|
-
|
|
10589
|
-
|
|
10606
|
+
/** The newest hand-off write the tracker refused with 403 and no later success for that ticket, else null. */
|
|
10607
|
+
forbidden() {
|
|
10608
|
+
const last = [...this.forbiddenWrites].at(-1);
|
|
10609
|
+
return last ? { ticketRecordId: last[0], detail: last[1] } : null;
|
|
10610
|
+
}
|
|
10611
|
+
/** A hand-off status write went through, so the seat can write what a hand-off needs. */
|
|
10612
|
+
handoffLanded() {
|
|
10613
|
+
return this.statusLanded && this.forbiddenWrites.size === 0;
|
|
10614
|
+
}
|
|
10615
|
+
/** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
|
|
10616
|
+
pending() {
|
|
10617
|
+
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
10618
|
+
if (!failed) return null;
|
|
10619
|
+
const [ticketRecordId, s] = failed;
|
|
10620
|
+
return {
|
|
10621
|
+
ticketRecordId,
|
|
10622
|
+
patch: s.patch,
|
|
10623
|
+
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
10624
|
+
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
10625
|
+
finalText: this.lastText || this.resultLine
|
|
10626
|
+
};
|
|
10627
|
+
}
|
|
10628
|
+
};
|
|
10590
10629
|
|
|
10591
10630
|
// src/skill-include.ts
|
|
10592
10631
|
var SkillIncludeError = class extends Error {
|
|
@@ -13374,6 +13413,9 @@ var CREW_ENV = {
|
|
|
13374
13413
|
XDG_CACHE_HOME: `${CREW_PREFIX}/cache`,
|
|
13375
13414
|
npm_config_cache: `${CREW_PREFIX}/cache/npm`,
|
|
13376
13415
|
npm_config_store_dir: `${CREW_PREFIX}/pnpm-store`,
|
|
13416
|
+
// pnpm 11 ignores npm_config_store_dir and reads only this spelling (CREW-2973): without it `pnpm fetch`
|
|
13417
|
+
// at build time fills the builder's home store, and a run's offline install finds /opt/crew/pnpm-store empty.
|
|
13418
|
+
pnpm_config_store_dir: `${CREW_PREFIX}/pnpm-store`,
|
|
13377
13419
|
PNPM_HOME: `${CREW_PREFIX}/pnpm`,
|
|
13378
13420
|
// A run cannot reach the download host, so an in-run update attempt is only noise.
|
|
13379
13421
|
DISABLE_AUTOUPDATER: "1"
|
|
@@ -17037,7 +17079,13 @@ function otherRunsRemoved(tickets, own) {
|
|
|
17037
17079
|
return tickets.filter((t) => t.id === own.ticket.id || !own.inFlight(t.id));
|
|
17038
17080
|
}
|
|
17039
17081
|
function ownRunBanner(own) {
|
|
17040
|
-
|
|
17082
|
+
const banner = `**Your ticket for this run: ${own.ticket.issue_id}.** Work this one. Any other ticket a live run on this ship is already working has been left out of this digest: it belongs to that run.`;
|
|
17083
|
+
const block = acceptanceChecklistBlock(acceptanceLines(own.ticket.description));
|
|
17084
|
+
return block ? `${banner}
|
|
17085
|
+
|
|
17086
|
+
\`\`\`
|
|
17087
|
+
${block}
|
|
17088
|
+
\`\`\`` : banner;
|
|
17041
17089
|
}
|
|
17042
17090
|
function writeDigest(o, d, role, stateDir, runId, own) {
|
|
17043
17091
|
const me = o.route.resolved?.seats[role];
|
|
@@ -21001,6 +21049,10 @@ async function listLogEntries(route2, opts = {}) {
|
|
|
21001
21049
|
...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
|
|
21002
21050
|
});
|
|
21003
21051
|
const newestFirst = [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
|
|
21052
|
+
if (opts.bounces) {
|
|
21053
|
+
const n = (e) => e.handoff_bounces ?? -1;
|
|
21054
|
+
return newestFirst.sort((a, b) => n(b) - n(a));
|
|
21055
|
+
}
|
|
21004
21056
|
if (!opts.beforeEdit) return newestFirst;
|
|
21005
21057
|
const searches = (e) => e.calls_before_edit_search ?? -1;
|
|
21006
21058
|
return newestFirst.sort((a, b) => searches(b) - searches(a));
|
|
@@ -22535,6 +22587,7 @@ async function runPeriscopeIdlePass(d) {
|
|
|
22535
22587
|
if (decision.action === "none") continue;
|
|
22536
22588
|
if (decision.action === "refresh") {
|
|
22537
22589
|
await d.update(row.id, { last_activity_at: decision.lastActivityAt, expires_at: decision.expiresAt });
|
|
22590
|
+
d.recordActivity?.(row, decision.lastActivityAt, decision.expiresAt);
|
|
22538
22591
|
d.clear(periscopeIdleKey(t.tag));
|
|
22539
22592
|
result.refreshed++;
|
|
22540
22593
|
continue;
|
|
@@ -23654,6 +23707,10 @@ async function runOnePassUnguarded(o) {
|
|
|
23654
23707
|
listLive: () => tracker.liveShipPeriscopes(),
|
|
23655
23708
|
mtimeOf: newestWorktreeMtime,
|
|
23656
23709
|
update: (id, fields) => tracker.updatePeriscope(id, fields),
|
|
23710
|
+
recordActivity: (row, lastActivityAt, expiresAt) => {
|
|
23711
|
+
const sc = readPeriscopeSidecars(o.ship.stateDir).find((s) => s.ticketRecordId === row.issue_id);
|
|
23712
|
+
if (sc) writePeriscopeSidecar(o.ship.stateDir, { ...sc, lastActivityAt, expiresAt });
|
|
23713
|
+
},
|
|
23657
23714
|
ticketOf: async (row) => {
|
|
23658
23715
|
const t = await tracker.ticket(row.issue_id);
|
|
23659
23716
|
return {
|
|
@@ -25430,7 +25487,7 @@ function usage(problem, requested = false) {
|
|
|
25430
25487
|
crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
|
|
25431
25488
|
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--agent, --write route|repo, --recipes, --json)
|
|
25432
25489
|
crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches (--json: every Repos row too)
|
|
25433
|
-
crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket; --before-edit sorts by searches before the first edit
|
|
25490
|
+
crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket; --before-edit sorts by searches before the first edit, --bounces by acceptance bounces
|
|
25434
25491
|
crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
|
|
25435
25492
|
crew passengers [route] sync Host Passengers containers/tunnels once; what the passengers unit invokes
|
|
25436
25493
|
crew install write and load this platform's scheduler unit
|
|
@@ -28197,7 +28254,7 @@ ${cu.tail.trimEnd()}
|
|
|
28197
28254
|
const sub = positional[1];
|
|
28198
28255
|
if (sub !== "list" && sub !== "show") {
|
|
28199
28256
|
process.stderr.write(
|
|
28200
|
-
"crew logbook list [route] [--role R] [--ticket T] [--limit N] [--before-edit]\ncrew logbook show [route] <entryId> [--prompt]\n"
|
|
28257
|
+
"crew logbook list [route] [--role R] [--ticket T] [--limit N] [--before-edit] [--bounces]\ncrew logbook show [route] <entryId> [--prompt]\n"
|
|
28201
28258
|
);
|
|
28202
28259
|
process.exit(2);
|
|
28203
28260
|
}
|
|
@@ -28225,7 +28282,8 @@ ${cu.tail.trimEnd()}
|
|
|
28225
28282
|
role: value("role"),
|
|
28226
28283
|
ticket: value("ticket"),
|
|
28227
28284
|
limit: value("limit") ? Number(value("limit")) : void 0,
|
|
28228
|
-
beforeEdit: flag("before-edit")
|
|
28285
|
+
beforeEdit: flag("before-edit"),
|
|
28286
|
+
bounces: flag("bounces")
|
|
28229
28287
|
});
|
|
28230
28288
|
if (entries.length === 0) {
|
|
28231
28289
|
process.stdout.write("(no Agent Log entries match)\n");
|