@tablation/crew 0.69.0 → 0.70.0
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 +105 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6099,13 +6099,13 @@ var Tracker = class {
|
|
|
6099
6099
|
* whatever wording the caller already used. Attributed to the seat's Crew
|
|
6100
6100
|
* row, with the posting ship in `metadata`.
|
|
6101
6101
|
*/
|
|
6102
|
-
async postEvent(ticketId, body, memberId, type = "note") {
|
|
6102
|
+
async postEvent(ticketId, body, memberId, type = "note", extra) {
|
|
6103
6103
|
const auth = await this.authorshipProbe();
|
|
6104
6104
|
const res = await this.eventsFetch("POST", `/records/${ticketId}/events`, {
|
|
6105
6105
|
type,
|
|
6106
6106
|
message: body,
|
|
6107
6107
|
actorRecordId: memberId,
|
|
6108
|
-
...auth.shipId ? { metadata: { ship_id: auth.shipId } } : {}
|
|
6108
|
+
...auth.shipId || extra ? { metadata: { ...auth.shipId ? { ship_id: auth.shipId } : {}, ...extra } } : {}
|
|
6109
6109
|
});
|
|
6110
6110
|
if (res.status === 404) throw new ConfigError(EVENT_LOG_MISSING);
|
|
6111
6111
|
if (!res.ok) throw new Error(`posting a ${type} event failed: HTTP ${res.status}`);
|
|
@@ -10095,6 +10095,17 @@ var priorBounces = (ticket, events) => {
|
|
|
10095
10095
|
(e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))
|
|
10096
10096
|
).length;
|
|
10097
10097
|
};
|
|
10098
|
+
var baseShaOf = (e) => {
|
|
10099
|
+
const m = e.metadata?.base_sha;
|
|
10100
|
+
if (typeof m === "string" && m) return m;
|
|
10101
|
+
return /Base tip: `([0-9a-f]{7,40})`/.exec(e.message ?? "")?.[1] ?? null;
|
|
10102
|
+
};
|
|
10103
|
+
var lastActedHandBack = (ticket, events) => {
|
|
10104
|
+
const mine = events.filter((e) => e.record_id === ticket.id);
|
|
10105
|
+
const pushes = mine.filter((e) => e.event_type === "pushed").map(stampOf);
|
|
10106
|
+
return mine.filter((e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))).sort((a, b) => stampOf(b).localeCompare(stampOf(a)))[0];
|
|
10107
|
+
};
|
|
10108
|
+
var MAX_ABSORBED_HANDBACKS = 2;
|
|
10098
10109
|
function planConflictBounce(cwd, ticket, branch, base, paths, events) {
|
|
10099
10110
|
return {
|
|
10100
10111
|
ticket,
|
|
@@ -10102,7 +10113,10 @@ function planConflictBounce(cwd, ticket, branch, base, paths, events) {
|
|
|
10102
10113
|
base,
|
|
10103
10114
|
paths,
|
|
10104
10115
|
worktree: worktreeForBranch(cwd, branch),
|
|
10105
|
-
priorBounces: priorBounces(ticket, events)
|
|
10116
|
+
priorBounces: priorBounces(ticket, events),
|
|
10117
|
+
cwd,
|
|
10118
|
+
baseSha: gitOk(cwd, ["rev-parse", "--verify", "-q", base]) || null,
|
|
10119
|
+
priorBaseSha: baseShaOf(lastActedHandBack(ticket, events) ?? { record_id: "", event_type: "" })
|
|
10106
10120
|
};
|
|
10107
10121
|
}
|
|
10108
10122
|
function refreshBranch(worktree, base) {
|
|
@@ -10135,20 +10149,29 @@ function handBackComment(b, merged) {
|
|
|
10135
10149
|
|
|
10136
10150
|
${merged ? `\`${b.base}\` has been merged into \`${b.branch}\` in its worktree and the conflict is left in place, so \`git status\` there shows exactly what disagrees. Resolve it, commit, push as a plain fast-forward (the branch is pushed, so rebasing it is not an option) and hand back to QA as usual \u2014 \`git merge --abort\` backs the merge out if you would rather start differently.` : `This ship has no worktree for \`${b.branch}\`, so nothing has been merged for you. Merge \`${b.base}\` into the branch wherever it lives, resolve the conflict, and commit.`}
|
|
10137
10151
|
|
|
10138
|
-
|
|
10152
|
+
${b.baseSha ? `Base tip: \`${b.baseSha}\`
|
|
10153
|
+
|
|
10154
|
+
` : ""}Nothing was written to \`${b.base}\`: the failed merge was rewound, and the rest of the release shipped without this ticket. Re-verifying is what puts it back in the merge queue.`;
|
|
10139
10155
|
}
|
|
10140
10156
|
function escalateComment(b) {
|
|
10157
|
+
const where = b.paths.length ? ` (${b.paths.map((p) => `\`${p}\``).join(", ")})` : "";
|
|
10158
|
+
const absorbed = b.priorBounces >= MAX_ABSORBED_HANDBACKS;
|
|
10141
10159
|
return `${BOUNCE_MARKER}
|
|
10142
10160
|
|
|
10143
10161
|
**Conflicting again after a hand-back \u2014 stopping rather than looping.**
|
|
10144
10162
|
|
|
10145
|
-
\`${b.branch}\` was handed back once for a merge conflict, verified again, and conflicts against \`${b.base}\`
|
|
10163
|
+
\`${b.branch}\` was handed back ${b.priorBounces === 1 ? "once" : `${b.priorBounces} times`} for a merge conflict, verified again, and conflicts against \`${b.base}\` ${b.priorBounces === 1 ? "a second time" : `again (conflict ${b.priorBounces + 1})`}${where}.
|
|
10146
10164
|
|
|
10147
|
-
|
|
10165
|
+
${absorbed ? `Each earlier hand-back was answered by merging \`${b.base}\` in, and \`${b.base}\` moved underneath the branch every time. This file is changing faster than the lanes can absorb it, so it needs a person to sequence this ticket against the others touching it.` : `The branch ${b.priorBaseSha ? `does not contain \`${b.priorBaseSha}\`, the \`${b.base}\` tip it was last handed back for` : "was pushed"} \u2014 the hand-back was answered without merging \`${b.base}\` in, or this ticket overlaps work that has already shipped and needs rethinking rather than merging.`}
|
|
10166
|
+
That is a call for a person, not another lap through the lanes.`;
|
|
10148
10167
|
}
|
|
10149
10168
|
async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
|
|
10150
10169
|
const t = { ticket: b.ticket.issue_id, step: "merge" };
|
|
10151
|
-
|
|
10170
|
+
const absorbed = b.priorBounces > 0 && b.priorBounces < MAX_ABSORBED_HANDBACKS && !!b.priorBaseSha && isAncestor(b.worktree ?? b.cwd, b.priorBaseSha, b.branch);
|
|
10171
|
+
if (absorbed) {
|
|
10172
|
+
log.emit(`${b.ticket.issue_id} conflicts again, but ${b.base} moved since the hand-back was answered \u2014 handing back again`, t);
|
|
10173
|
+
}
|
|
10174
|
+
if (b.priorBounces > 0 && !absorbed) {
|
|
10152
10175
|
if (dryRun) {
|
|
10153
10176
|
log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 handed back before`, t);
|
|
10154
10177
|
return { kind: "escalated" };
|
|
@@ -10196,7 +10219,13 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
|
|
|
10196
10219
|
{ status: contract.statuses.building, assignee_id: null },
|
|
10197
10220
|
b.ticket.updated_at
|
|
10198
10221
|
);
|
|
10199
|
-
await writer.postEvent(
|
|
10222
|
+
await writer.postEvent(
|
|
10223
|
+
b.ticket.id,
|
|
10224
|
+
handBackComment(b, merged),
|
|
10225
|
+
memberId,
|
|
10226
|
+
"conflict_handed_back",
|
|
10227
|
+
b.baseSha ? { base_sha: b.baseSha } : void 0
|
|
10228
|
+
).catch(() => {
|
|
10200
10229
|
log.warn("handed back, but the note failed to post", t);
|
|
10201
10230
|
});
|
|
10202
10231
|
log.emit(
|
|
@@ -15236,6 +15265,37 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
|
15236
15265
|
return endpoints;
|
|
15237
15266
|
}
|
|
15238
15267
|
|
|
15268
|
+
// src/outage.ts
|
|
15269
|
+
var TRANSIENT_BACKOFF_MS = [15e3, 3e4, 6e4];
|
|
15270
|
+
var OUTAGE_ESCALATE_AFTER = 5;
|
|
15271
|
+
var TRACKER_OUTAGE_KEY = "tracker_unreachable";
|
|
15272
|
+
var NETWORK_CODES = ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"];
|
|
15273
|
+
function transientBackoffMs(n) {
|
|
15274
|
+
return TRANSIENT_BACKOFF_MS[Math.min(Math.max(n, 1), TRANSIENT_BACKOFF_MS.length) - 1];
|
|
15275
|
+
}
|
|
15276
|
+
function codeOf(e) {
|
|
15277
|
+
const code = e?.code;
|
|
15278
|
+
return typeof code === "string" ? code : void 0;
|
|
15279
|
+
}
|
|
15280
|
+
function isTransientError(err) {
|
|
15281
|
+
if (!(err instanceof Error)) return false;
|
|
15282
|
+
const msg = err.message;
|
|
15283
|
+
if (msg === "fetch failed" || msg.startsWith("fetch failed")) return true;
|
|
15284
|
+
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
|
15285
|
+
const code = codeOf(e);
|
|
15286
|
+
if (code && NETWORK_CODES.includes(code)) return true;
|
|
15287
|
+
}
|
|
15288
|
+
if (NETWORK_CODES.some((c) => msg.includes(c))) return true;
|
|
15289
|
+
if (/\b(?:HTTP|status)\s*:?\s*(502|503|504)\b/i.test(msg) || /^(502|503|504)\b/.test(msg)) return true;
|
|
15290
|
+
if (err instanceof SyntaxError && /Unexpected token '?<|is not valid JSON|Unexpected end of JSON/.test(msg)) return true;
|
|
15291
|
+
return false;
|
|
15292
|
+
}
|
|
15293
|
+
function describeCause(err) {
|
|
15294
|
+
const first = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
15295
|
+
const code = codeOf(err?.cause);
|
|
15296
|
+
return code && !first.includes(code) ? `${first} (${code})` : first;
|
|
15297
|
+
}
|
|
15298
|
+
|
|
15239
15299
|
// src/daemon.ts
|
|
15240
15300
|
import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync26, readdirSync as readdirSync13, rmSync as rmSync13, statSync as statSync9, writeFileSync as writeFileSync20 } from "fs";
|
|
15241
15301
|
import { join as join33 } from "path";
|
|
@@ -15335,12 +15395,32 @@ async function runDaemonLoop(o) {
|
|
|
15335
15395
|
const sleep = o.sleep ?? defaultSleep;
|
|
15336
15396
|
let backoff = floor;
|
|
15337
15397
|
let passes = 0;
|
|
15398
|
+
const now = o.now ?? Date.now;
|
|
15399
|
+
let transientRun = 0;
|
|
15400
|
+
let downSince = 0;
|
|
15338
15401
|
for (; ; ) {
|
|
15339
15402
|
if (o.signal?.aborted) return "aborted";
|
|
15340
15403
|
let result;
|
|
15341
15404
|
try {
|
|
15342
15405
|
result = await o.runPass();
|
|
15406
|
+
if (transientRun > 0) {
|
|
15407
|
+
o.onRecovered?.({ downMs: now() - downSince });
|
|
15408
|
+
transientRun = 0;
|
|
15409
|
+
}
|
|
15343
15410
|
} catch (err) {
|
|
15411
|
+
if (isTransientError(err)) {
|
|
15412
|
+
passes++;
|
|
15413
|
+
if (transientRun === 0) downSince = now();
|
|
15414
|
+
transientRun++;
|
|
15415
|
+
const wait = transientBackoffMs(transientRun);
|
|
15416
|
+
const downMs = now() - downSince;
|
|
15417
|
+
o.onTransient?.({ err, consecutive: transientRun, backoffMs: wait, downMs });
|
|
15418
|
+
if (transientRun === OUTAGE_ESCALATE_AFTER) o.onOutage?.({ err, consecutive: transientRun, downMs });
|
|
15419
|
+
if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
|
|
15420
|
+
if (o.signal?.aborted) return "aborted";
|
|
15421
|
+
await sleep(wait);
|
|
15422
|
+
continue;
|
|
15423
|
+
}
|
|
15344
15424
|
passes++;
|
|
15345
15425
|
o.onPassError?.(err);
|
|
15346
15426
|
if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
|
|
@@ -17437,6 +17517,23 @@ ${changed2.map((c) => ` - ${c}
|
|
|
17437
17517
|
`)
|
|
17438
17518
|
})
|
|
17439
17519
|
}),
|
|
17520
|
+
onTransient: ({ err, consecutive, backoffMs }) => emit.warn(
|
|
17521
|
+
`tracker unavailable (${describeCause(err)}); attempt ${consecutive}, retrying in ${Math.round(backoffMs / 1e3)}s`
|
|
17522
|
+
),
|
|
17523
|
+
onOutage: ({ err, downMs }) => {
|
|
17524
|
+
const msg = `tracker unreachable for ${Math.max(1, Math.round(downMs / 6e4))} minutes: ${describeCause(err)}`;
|
|
17525
|
+
emit.error(msg);
|
|
17526
|
+
void raiseShipAttention({ state, emit, route, ship: cfg.ship }, {
|
|
17527
|
+
kind: "tracker_unreachable",
|
|
17528
|
+
key: TRACKER_OUTAGE_KEY,
|
|
17529
|
+
message: msg,
|
|
17530
|
+
since: (/* @__PURE__ */ new Date()).toISOString()
|
|
17531
|
+
});
|
|
17532
|
+
},
|
|
17533
|
+
onRecovered: ({ downMs }) => {
|
|
17534
|
+
clearShipAttention(state, TRACKER_OUTAGE_KEY);
|
|
17535
|
+
emit.emit(`tracker is back after ${Math.max(1, Math.round(downMs / 1e3))}s`);
|
|
17536
|
+
},
|
|
17440
17537
|
onPassError: (err) => emit.error(
|
|
17441
17538
|
`daemon pass failed, continuing: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
|
|
17442
17539
|
),
|
package/package.json
CHANGED