@tablation/crew 0.12.1 → 0.13.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 +230 -115
- package/package.json +1 -1
- package/prompts/default/personas/lane-qa.md +20 -7
- package/prompts/dev-qa/personas/lane-qa.md +20 -7
package/dist/cli.js
CHANGED
|
@@ -1163,6 +1163,90 @@ function ticketsByRepo(r, tickets) {
|
|
|
1163
1163
|
return { byRepo, unplaceable };
|
|
1164
1164
|
}
|
|
1165
1165
|
|
|
1166
|
+
// src/roster.ts
|
|
1167
|
+
function memberByIdentity(roster, identityId) {
|
|
1168
|
+
if (!identityId) return void 0;
|
|
1169
|
+
for (const m of roster.values()) if (m.userId === identityId) return m;
|
|
1170
|
+
return void 0;
|
|
1171
|
+
}
|
|
1172
|
+
function crewLabel(m) {
|
|
1173
|
+
if (!m) return null;
|
|
1174
|
+
if (!m.role) return m.name;
|
|
1175
|
+
if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
|
|
1176
|
+
return `${m.name} (${m.role})`;
|
|
1177
|
+
}
|
|
1178
|
+
function isHold(roster, id) {
|
|
1179
|
+
if (!id) return false;
|
|
1180
|
+
return roster.get(id)?.kind === "hold";
|
|
1181
|
+
}
|
|
1182
|
+
function holdIds(roster) {
|
|
1183
|
+
return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
|
|
1184
|
+
}
|
|
1185
|
+
function seats(roster) {
|
|
1186
|
+
return [...roster.values()].filter((m) => m.kind === "seat");
|
|
1187
|
+
}
|
|
1188
|
+
function holds(roster) {
|
|
1189
|
+
return [...roster.values()].filter((m) => m.kind === "hold");
|
|
1190
|
+
}
|
|
1191
|
+
function buildRoster(configured, crewRows) {
|
|
1192
|
+
const rows = new Map(crewRows.map((r) => [r.id, r]));
|
|
1193
|
+
const roster = /* @__PURE__ */ new Map();
|
|
1194
|
+
for (const c of configured) {
|
|
1195
|
+
if (!c.id) continue;
|
|
1196
|
+
const row = rows.get(c.id);
|
|
1197
|
+
const name = (row?.name ?? "").trim();
|
|
1198
|
+
roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
|
|
1199
|
+
}
|
|
1200
|
+
return roster;
|
|
1201
|
+
}
|
|
1202
|
+
function rosterMarkdown(roster, meId) {
|
|
1203
|
+
const byRole = (a, b) => a.role.localeCompare(b.role);
|
|
1204
|
+
const me = meId ? roster.get(meId) : void 0;
|
|
1205
|
+
const selfName = crewLabel(me) ?? "an unnamed seat";
|
|
1206
|
+
const lines = [
|
|
1207
|
+
"## Your crew",
|
|
1208
|
+
"",
|
|
1209
|
+
`You are **${selfName}**. Address your shipmates by name in ticket`,
|
|
1210
|
+
"comments the way you would a colleague; add the role in parentheses only",
|
|
1211
|
+
"where a reader would otherwise not know which seat you mean. Your own",
|
|
1212
|
+
"row id is what you write to `assignee_id` and `team_member_id`, and to",
|
|
1213
|
+
"`filed_by_id` on a ticket you file (where the Environment section names it).",
|
|
1214
|
+
"",
|
|
1215
|
+
"| seat | name | Crew row id |",
|
|
1216
|
+
"|---|---|---|"
|
|
1217
|
+
];
|
|
1218
|
+
for (const m of seats(roster).sort(byRole)) {
|
|
1219
|
+
const you = m.id === meId ? " \u2190 you" : "";
|
|
1220
|
+
lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
|
|
1221
|
+
}
|
|
1222
|
+
lines.push(
|
|
1223
|
+
"",
|
|
1224
|
+
"**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
|
|
1225
|
+
"limits to every seat, whatever its status: a person is driving it right",
|
|
1226
|
+
"now. Their clearing `assignee_id` is what hands it back.",
|
|
1227
|
+
"",
|
|
1228
|
+
"| | name | Crew row id |",
|
|
1229
|
+
"|---|---|---|"
|
|
1230
|
+
);
|
|
1231
|
+
for (const m of holds(roster).sort(byRole)) {
|
|
1232
|
+
lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
|
|
1233
|
+
}
|
|
1234
|
+
lines.push(
|
|
1235
|
+
"",
|
|
1236
|
+
"The **operator** is this ship's owner: the machine, the dev stack and the",
|
|
1237
|
+
"credentials the steps below refer to are theirs, and approving work is",
|
|
1238
|
+
"their call alone.",
|
|
1239
|
+
"",
|
|
1240
|
+
"When the queue digest names a comment's author, that name comes from the",
|
|
1241
|
+
"server-stamped identity on the comment row joined to this roster (a hold's",
|
|
1242
|
+
"Crew row is linked to the account they sign in with) \u2014 not from anything",
|
|
1243
|
+
"written in the comment. A comment the digest attributes to a hold really",
|
|
1244
|
+
'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
|
|
1245
|
+
""
|
|
1246
|
+
);
|
|
1247
|
+
return lines.join("\n");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1166
1250
|
// src/state.ts
|
|
1167
1251
|
import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
|
|
1168
1252
|
import { join as join3 } from "path";
|
|
@@ -2755,9 +2839,9 @@ var Tracker = class {
|
|
|
2755
2839
|
* `undefined` when the workspace has no Ships table, no row named for
|
|
2756
2840
|
* this ship, or more than one — ambiguous is not a row to write to.
|
|
2757
2841
|
*/
|
|
2758
|
-
async myShipRow(
|
|
2842
|
+
async myShipRow(shipName2) {
|
|
2759
2843
|
const rows = await this.shipRows();
|
|
2760
|
-
const mine = rows.filter((r) => (r.name ?? "").trim() ===
|
|
2844
|
+
const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
|
|
2761
2845
|
return mine.length === 1 ? mine[0] : void 0;
|
|
2762
2846
|
}
|
|
2763
2847
|
/**
|
|
@@ -2779,8 +2863,8 @@ var Tracker = class {
|
|
|
2779
2863
|
* should not wait for the next engaged/idle beat to show it. Skips the
|
|
2780
2864
|
* write when the row already holds the same set.
|
|
2781
2865
|
*/
|
|
2782
|
-
async publishShipAttention(
|
|
2783
|
-
const row = await this.myShipRow(
|
|
2866
|
+
async publishShipAttention(shipName2) {
|
|
2867
|
+
const row = await this.myShipRow(shipName2);
|
|
2784
2868
|
if (!row) return;
|
|
2785
2869
|
const patch2 = this.attentionPatch(row);
|
|
2786
2870
|
if (!("attention" in patch2)) return;
|
|
@@ -2805,8 +2889,8 @@ var Tracker = class {
|
|
|
2805
2889
|
* A no-op, like `shipRows()`, on a workspace with no Ships table or no
|
|
2806
2890
|
* row for this ship's name.
|
|
2807
2891
|
*/
|
|
2808
|
-
async beatShip(
|
|
2809
|
-
const row = await this.myShipRow(
|
|
2892
|
+
async beatShip(shipName2) {
|
|
2893
|
+
const row = await this.myShipRow(shipName2);
|
|
2810
2894
|
if (!row) return;
|
|
2811
2895
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2812
2896
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2822,8 +2906,8 @@ var Tracker = class {
|
|
|
2822
2906
|
* `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
|
|
2823
2907
|
* working/idle pair, one level up (ship, not seat).
|
|
2824
2908
|
*/
|
|
2825
|
-
async beatEngaged(
|
|
2826
|
-
const row = await this.myShipRow(
|
|
2909
|
+
async beatEngaged(shipName2, connectionName, ticketRecordId) {
|
|
2910
|
+
const row = await this.myShipRow(shipName2);
|
|
2827
2911
|
if (!row) return;
|
|
2828
2912
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2829
2913
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2845,14 +2929,14 @@ var Tracker = class {
|
|
|
2845
2929
|
* tunnel client itself still runs the tunnel either way, it just has
|
|
2846
2930
|
* nowhere on the board to report the state.
|
|
2847
2931
|
*/
|
|
2848
|
-
async updateTunnelState(
|
|
2849
|
-
const row = await this.myShipRow(
|
|
2932
|
+
async updateTunnelState(shipName2, patch2) {
|
|
2933
|
+
const row = await this.myShipRow(shipName2);
|
|
2850
2934
|
if (!row) return;
|
|
2851
2935
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
|
|
2852
2936
|
}
|
|
2853
2937
|
/** The other half of `beatEngaged` — clears engagement back to idle. */
|
|
2854
|
-
async beatIdle(
|
|
2855
|
-
const row = await this.myShipRow(
|
|
2938
|
+
async beatIdle(shipName2) {
|
|
2939
|
+
const row = await this.myShipRow(shipName2);
|
|
2856
2940
|
if (!row) return;
|
|
2857
2941
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2858
2942
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -3084,90 +3168,6 @@ var Tracker = class {
|
|
|
3084
3168
|
}
|
|
3085
3169
|
};
|
|
3086
3170
|
|
|
3087
|
-
// src/roster.ts
|
|
3088
|
-
function memberByIdentity(roster, identityId) {
|
|
3089
|
-
if (!identityId) return void 0;
|
|
3090
|
-
for (const m of roster.values()) if (m.userId === identityId) return m;
|
|
3091
|
-
return void 0;
|
|
3092
|
-
}
|
|
3093
|
-
function crewLabel(m) {
|
|
3094
|
-
if (!m) return null;
|
|
3095
|
-
if (!m.role) return m.name;
|
|
3096
|
-
if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
|
|
3097
|
-
return `${m.name} (${m.role})`;
|
|
3098
|
-
}
|
|
3099
|
-
function isHold(roster, id) {
|
|
3100
|
-
if (!id) return false;
|
|
3101
|
-
return roster.get(id)?.kind === "hold";
|
|
3102
|
-
}
|
|
3103
|
-
function holdIds(roster) {
|
|
3104
|
-
return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
|
|
3105
|
-
}
|
|
3106
|
-
function seats(roster) {
|
|
3107
|
-
return [...roster.values()].filter((m) => m.kind === "seat");
|
|
3108
|
-
}
|
|
3109
|
-
function holds(roster) {
|
|
3110
|
-
return [...roster.values()].filter((m) => m.kind === "hold");
|
|
3111
|
-
}
|
|
3112
|
-
function buildRoster(configured, crewRows) {
|
|
3113
|
-
const rows = new Map(crewRows.map((r) => [r.id, r]));
|
|
3114
|
-
const roster = /* @__PURE__ */ new Map();
|
|
3115
|
-
for (const c of configured) {
|
|
3116
|
-
if (!c.id) continue;
|
|
3117
|
-
const row = rows.get(c.id);
|
|
3118
|
-
const name = (row?.name ?? "").trim();
|
|
3119
|
-
roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
|
|
3120
|
-
}
|
|
3121
|
-
return roster;
|
|
3122
|
-
}
|
|
3123
|
-
function rosterMarkdown(roster, meId) {
|
|
3124
|
-
const byRole = (a, b) => a.role.localeCompare(b.role);
|
|
3125
|
-
const me = meId ? roster.get(meId) : void 0;
|
|
3126
|
-
const selfName = crewLabel(me) ?? "an unnamed seat";
|
|
3127
|
-
const lines = [
|
|
3128
|
-
"## Your crew",
|
|
3129
|
-
"",
|
|
3130
|
-
`You are **${selfName}**. Address your shipmates by name in ticket`,
|
|
3131
|
-
"comments the way you would a colleague; add the role in parentheses only",
|
|
3132
|
-
"where a reader would otherwise not know which seat you mean. Your own",
|
|
3133
|
-
"row id is what you write to `assignee_id` and `team_member_id`, and to",
|
|
3134
|
-
"`filed_by_id` on a ticket you file (where the Environment section names it).",
|
|
3135
|
-
"",
|
|
3136
|
-
"| seat | name | Crew row id |",
|
|
3137
|
-
"|---|---|---|"
|
|
3138
|
-
];
|
|
3139
|
-
for (const m of seats(roster).sort(byRole)) {
|
|
3140
|
-
const you = m.id === meId ? " \u2190 you" : "";
|
|
3141
|
-
lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
|
|
3142
|
-
}
|
|
3143
|
-
lines.push(
|
|
3144
|
-
"",
|
|
3145
|
-
"**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
|
|
3146
|
-
"limits to every seat, whatever its status: a person is driving it right",
|
|
3147
|
-
"now. Their clearing `assignee_id` is what hands it back.",
|
|
3148
|
-
"",
|
|
3149
|
-
"| | name | Crew row id |",
|
|
3150
|
-
"|---|---|---|"
|
|
3151
|
-
);
|
|
3152
|
-
for (const m of holds(roster).sort(byRole)) {
|
|
3153
|
-
lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
|
|
3154
|
-
}
|
|
3155
|
-
lines.push(
|
|
3156
|
-
"",
|
|
3157
|
-
"The **operator** is this ship's owner: the machine, the dev stack and the",
|
|
3158
|
-
"credentials the steps below refer to are theirs, and approving work is",
|
|
3159
|
-
"their call alone.",
|
|
3160
|
-
"",
|
|
3161
|
-
"When the queue digest names a comment's author, that name comes from the",
|
|
3162
|
-
"server-stamped identity on the comment row joined to this roster (a hold's",
|
|
3163
|
-
"Crew row is linked to the account they sign in with) \u2014 not from anything",
|
|
3164
|
-
"written in the comment. A comment the digest attributes to a hold really",
|
|
3165
|
-
'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
|
|
3166
|
-
""
|
|
3167
|
-
);
|
|
3168
|
-
return lines.join("\n");
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3171
3171
|
// src/blocked.ts
|
|
3172
3172
|
import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
|
|
3173
3173
|
function missingBlockerIds(tickets) {
|
|
@@ -3364,7 +3364,7 @@ function ticketHolderShipId(t, contract) {
|
|
|
3364
3364
|
}
|
|
3365
3365
|
function heldByOtherShip(t, a) {
|
|
3366
3366
|
const statuses = a.contract.statuses;
|
|
3367
|
-
if (
|
|
3367
|
+
if (t.status === statuses.handoff) return false;
|
|
3368
3368
|
return heldByLiveOtherShip(t, a);
|
|
3369
3369
|
}
|
|
3370
3370
|
function heldByLiveOtherShip(t, a) {
|
|
@@ -3374,6 +3374,10 @@ function heldByLiveOtherShip(t, a) {
|
|
|
3374
3374
|
const owner = a.ships.find((s) => s.id === holder);
|
|
3375
3375
|
return shipIsAlive(owner, a.now ?? Date.now());
|
|
3376
3376
|
}
|
|
3377
|
+
function shipName(ships, id) {
|
|
3378
|
+
if (!id) return "an unknown ship";
|
|
3379
|
+
return ships.find((s) => s.id === id)?.name?.trim() || id;
|
|
3380
|
+
}
|
|
3377
3381
|
function shipIdByName(ships, name) {
|
|
3378
3382
|
const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
|
|
3379
3383
|
return mine.length === 1 ? mine[0].id : null;
|
|
@@ -3406,6 +3410,46 @@ async function applyHoldReleases(writer, tickets, contract, emit) {
|
|
|
3406
3410
|
}
|
|
3407
3411
|
return cleared;
|
|
3408
3412
|
}
|
|
3413
|
+
async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
|
|
3414
|
+
if (!result.tookOverFrom || !result.ticket) return;
|
|
3415
|
+
let sha = null;
|
|
3416
|
+
try {
|
|
3417
|
+
sha = continuingFrom(result.ticket);
|
|
3418
|
+
} catch {
|
|
3419
|
+
}
|
|
3420
|
+
try {
|
|
3421
|
+
await tracker.postEvent(
|
|
3422
|
+
result.ticket.id,
|
|
3423
|
+
`Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
|
|
3424
|
+
seat
|
|
3425
|
+
);
|
|
3426
|
+
} catch {
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
function originBranchSha(dir, t, rev) {
|
|
3430
|
+
const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
|
|
3431
|
+
const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
|
|
3432
|
+
for (const n of names) {
|
|
3433
|
+
const sha = rev(dir, `origin/${n}`);
|
|
3434
|
+
if (sha) return sha.slice(0, 7);
|
|
3435
|
+
}
|
|
3436
|
+
return null;
|
|
3437
|
+
}
|
|
3438
|
+
function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
|
|
3439
|
+
const inFlight = [contract.statuses.building, contract.statuses.verifying];
|
|
3440
|
+
const out = [];
|
|
3441
|
+
for (const t of tickets) {
|
|
3442
|
+
if (!inFlight.includes(t.status)) continue;
|
|
3443
|
+
if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
|
|
3444
|
+
const holder = ticketHolderShipId(t, contract);
|
|
3445
|
+
if (!holder) {
|
|
3446
|
+
out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
|
|
3447
|
+
} else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
|
|
3448
|
+
out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
return out;
|
|
3452
|
+
}
|
|
3409
3453
|
|
|
3410
3454
|
// src/select.ts
|
|
3411
3455
|
var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
|
|
@@ -3463,7 +3507,7 @@ function buildingRoleHasWork(role, i) {
|
|
|
3463
3507
|
}
|
|
3464
3508
|
function qaRoleHasWork(i) {
|
|
3465
3509
|
if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
3466
|
-
const n = qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds)).length;
|
|
3510
|
+
const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
|
|
3467
3511
|
return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
|
|
3468
3512
|
}
|
|
3469
3513
|
function triageRoleHasWork(i) {
|
|
@@ -3479,7 +3523,7 @@ function roleHasWork(role, i) {
|
|
|
3479
3523
|
return buildingRoleHasWork(role, i);
|
|
3480
3524
|
}
|
|
3481
3525
|
function roleCandidates(role, i) {
|
|
3482
|
-
if (role === "qa") return qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds));
|
|
3526
|
+
if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
|
|
3483
3527
|
if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
|
|
3484
3528
|
if (role === "pair") return [];
|
|
3485
3529
|
const me = i.seats[role];
|
|
@@ -3488,6 +3532,9 @@ function roleCandidates(role, i) {
|
|
|
3488
3532
|
(t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
|
|
3489
3533
|
);
|
|
3490
3534
|
}
|
|
3535
|
+
function claimStatuses(role, c) {
|
|
3536
|
+
return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
|
|
3537
|
+
}
|
|
3491
3538
|
function rankedCandidates(role, i) {
|
|
3492
3539
|
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
3493
3540
|
return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
|
|
@@ -3695,6 +3742,14 @@ function newFromOthers(i, ticketId) {
|
|
|
3695
3742
|
).length;
|
|
3696
3743
|
}
|
|
3697
3744
|
function who(t, i) {
|
|
3745
|
+
return whoAssigned(t, i) + heldBySuffix(t, i);
|
|
3746
|
+
}
|
|
3747
|
+
function heldBySuffix(t, i) {
|
|
3748
|
+
if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
|
|
3749
|
+
const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
|
|
3750
|
+
return ` (held by ${name || "an unknown ship"})`;
|
|
3751
|
+
}
|
|
3752
|
+
function whoAssigned(t, i) {
|
|
3698
3753
|
if (!t.assignee_id) return "unassigned";
|
|
3699
3754
|
if (t.assignee_id === i.me) return "you";
|
|
3700
3755
|
const label = crewLabel(i.roster.get(t.assignee_id));
|
|
@@ -4490,6 +4545,10 @@ function rosterFor(d, route, role) {
|
|
|
4490
4545
|
|
|
4491
4546
|
// src/claim.ts
|
|
4492
4547
|
import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
|
|
4548
|
+
function displacedHolder(t, affinity) {
|
|
4549
|
+
const v = affinity ? t[affinity.column] : null;
|
|
4550
|
+
return typeof v === "string" && v && v !== affinity.shipId ? v : null;
|
|
4551
|
+
}
|
|
4493
4552
|
async function stampResumption(tracker, t, affinity) {
|
|
4494
4553
|
let current = t;
|
|
4495
4554
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -4525,17 +4584,17 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4525
4584
|
}
|
|
4526
4585
|
if (t.status !== approvedStatus) {
|
|
4527
4586
|
if (affinity?.isLiveHoldElsewhere?.(t)) {
|
|
4528
|
-
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4587
|
+
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4529
4588
|
}
|
|
4530
4589
|
if (affinity && t[affinity.column] !== affinity.shipId) {
|
|
4531
4590
|
const stamp = await stampResumption(tracker, t, affinity);
|
|
4532
4591
|
if (stamp.outcome === "stamped") {
|
|
4533
|
-
return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4592
|
+
return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
|
|
4534
4593
|
}
|
|
4535
4594
|
(stamp.outcome === "held" ? held : contended).push(t.issue_id);
|
|
4536
4595
|
continue;
|
|
4537
4596
|
}
|
|
4538
|
-
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4597
|
+
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4539
4598
|
}
|
|
4540
4599
|
try {
|
|
4541
4600
|
const claimed = await tracker.updateTicket(
|
|
@@ -4548,7 +4607,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4548
4607
|
t.updated_at
|
|
4549
4608
|
);
|
|
4550
4609
|
const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
|
|
4551
|
-
return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
|
|
4610
|
+
return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
|
|
4552
4611
|
} catch (e) {
|
|
4553
4612
|
if (e instanceof StaleWriteError4) {
|
|
4554
4613
|
contended.push(t.issue_id);
|
|
@@ -4557,7 +4616,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4557
4616
|
throw e;
|
|
4558
4617
|
}
|
|
4559
4618
|
}
|
|
4560
|
-
return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4619
|
+
return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4561
4620
|
}
|
|
4562
4621
|
|
|
4563
4622
|
// src/conflict.ts
|
|
@@ -4944,6 +5003,15 @@ function renderEnvironment(i) {
|
|
|
4944
5003
|
"impossible."
|
|
4945
5004
|
);
|
|
4946
5005
|
}
|
|
5006
|
+
if (i.shipName) {
|
|
5007
|
+
lines.push(
|
|
5008
|
+
"",
|
|
5009
|
+
"## Ships",
|
|
5010
|
+
"",
|
|
5011
|
+
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
5012
|
+
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
5013
|
+
);
|
|
5014
|
+
}
|
|
4947
5015
|
lines.push(
|
|
4948
5016
|
"",
|
|
4949
5017
|
"## What the columns are called here",
|
|
@@ -9178,7 +9246,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9178
9246
|
const tracker = new Tracker(route, ship);
|
|
9179
9247
|
let ticketHint = decision.actionable.top?.issue_id;
|
|
9180
9248
|
let workingId = decision.actionable.top?.id ?? null;
|
|
9181
|
-
if (role === "dev" || role === "design") {
|
|
9249
|
+
if (role === "dev" || role === "design" || role === "qa") {
|
|
9182
9250
|
const seat = route.resolved?.seats[role];
|
|
9183
9251
|
if (seat) {
|
|
9184
9252
|
const candidates = rankedCandidates(role, decision.selectionInput);
|
|
@@ -9187,8 +9255,8 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9187
9255
|
tracker,
|
|
9188
9256
|
candidates,
|
|
9189
9257
|
seat,
|
|
9190
|
-
contract.
|
|
9191
|
-
contract.
|
|
9258
|
+
claimStatuses(role, contract).approved,
|
|
9259
|
+
claimStatuses(role, contract).building,
|
|
9192
9260
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
9193
9261
|
withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
|
|
9194
9262
|
);
|
|
@@ -9208,6 +9276,17 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9208
9276
|
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
|
|
9209
9277
|
}
|
|
9210
9278
|
await noteReaccepted(tracker, result, seat, ship.name);
|
|
9279
|
+
await noteTakeover(
|
|
9280
|
+
tracker,
|
|
9281
|
+
result,
|
|
9282
|
+
seat,
|
|
9283
|
+
decision.selectionInput.ships?.rows ?? [],
|
|
9284
|
+
ship.name,
|
|
9285
|
+
(t) => {
|
|
9286
|
+
const d = dirForRepo(route, t.repo_id);
|
|
9287
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
9288
|
+
}
|
|
9289
|
+
);
|
|
9211
9290
|
if (!result.ticket) {
|
|
9212
9291
|
emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
9213
9292
|
return;
|
|
@@ -9296,7 +9375,7 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
9296
9375
|
};
|
|
9297
9376
|
});
|
|
9298
9377
|
const authorship = await tracker.authorshipProbe().catch(() => null);
|
|
9299
|
-
return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
|
|
9378
|
+
return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, shipName: ship.name });
|
|
9300
9379
|
}
|
|
9301
9380
|
|
|
9302
9381
|
// src/cli.ts
|
|
@@ -9913,7 +9992,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9913
9992
|
repos: await resolvedRepos(c),
|
|
9914
9993
|
contract,
|
|
9915
9994
|
sourceTicket: ticket,
|
|
9916
|
-
authorship: await tracker.authorshipProbe().catch(() => null)
|
|
9995
|
+
authorship: await tracker.authorshipProbe().catch(() => null),
|
|
9996
|
+
shipName: cfg.ship.name
|
|
9917
9997
|
});
|
|
9918
9998
|
}
|
|
9919
9999
|
const unplaceableReported = /* @__PURE__ */ new Set();
|
|
@@ -10239,7 +10319,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10239
10319
|
const fleetTracker = new Tracker(w.route, cfg.ship);
|
|
10240
10320
|
let fleetTicketHint = w.decision.actionable.top?.issue_id;
|
|
10241
10321
|
let fleetWorkingId = w.decision.actionable.top?.id ?? null;
|
|
10242
|
-
if (w.role === "dev" || w.role === "design") {
|
|
10322
|
+
if (w.role === "dev" || w.role === "design" || w.role === "qa") {
|
|
10243
10323
|
const seat = w.route.resolved?.seats[w.role];
|
|
10244
10324
|
if (seat) {
|
|
10245
10325
|
const candidates = rankedCandidates(w.role, w.decision.selectionInput);
|
|
@@ -10248,8 +10328,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10248
10328
|
fleetTracker,
|
|
10249
10329
|
candidates,
|
|
10250
10330
|
seat,
|
|
10251
|
-
contract.
|
|
10252
|
-
contract.
|
|
10331
|
+
claimStatuses(w.role, contract).approved,
|
|
10332
|
+
claimStatuses(w.role, contract).building,
|
|
10253
10333
|
(t) => dirForRepo(w.route, t.repo_id) !== null,
|
|
10254
10334
|
withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
|
|
10255
10335
|
);
|
|
@@ -10269,6 +10349,17 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10269
10349
|
wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
|
|
10270
10350
|
}
|
|
10271
10351
|
await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
|
|
10352
|
+
await noteTakeover(
|
|
10353
|
+
fleetTracker,
|
|
10354
|
+
result,
|
|
10355
|
+
seat,
|
|
10356
|
+
w.decision.selectionInput.ships?.rows ?? [],
|
|
10357
|
+
cfg.ship.name,
|
|
10358
|
+
(t) => {
|
|
10359
|
+
const d = dirForRepo(w.route, t.repo_id);
|
|
10360
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
10361
|
+
}
|
|
10362
|
+
);
|
|
10272
10363
|
if (!result.ticket) {
|
|
10273
10364
|
wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10274
10365
|
if (!skipInlineRelease) await releaseFleet();
|
|
@@ -10420,7 +10511,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10420
10511
|
}
|
|
10421
10512
|
let ticketHint = decision.actionable.top?.issue_id;
|
|
10422
10513
|
let workingId = decision.actionable.top?.id ?? null;
|
|
10423
|
-
if (!dryRun && (current === "dev" || current === "design")) {
|
|
10514
|
+
if (!dryRun && (current === "dev" || current === "design" || current === "qa")) {
|
|
10424
10515
|
const seat = route.resolved?.seats[current];
|
|
10425
10516
|
if (seat) {
|
|
10426
10517
|
const candidates = rankedCandidates(current, decision.selectionInput);
|
|
@@ -10429,8 +10520,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10429
10520
|
tracker2,
|
|
10430
10521
|
candidates,
|
|
10431
10522
|
seat,
|
|
10432
|
-
contract.
|
|
10433
|
-
contract.
|
|
10523
|
+
claimStatuses(current, contract).approved,
|
|
10524
|
+
claimStatuses(current, contract).building,
|
|
10434
10525
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
10435
10526
|
withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
|
|
10436
10527
|
);
|
|
@@ -10450,6 +10541,17 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10450
10541
|
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
|
|
10451
10542
|
}
|
|
10452
10543
|
await noteReaccepted(tracker2, result, seat, cfg.ship.name);
|
|
10544
|
+
await noteTakeover(
|
|
10545
|
+
tracker2,
|
|
10546
|
+
result,
|
|
10547
|
+
seat,
|
|
10548
|
+
decision.selectionInput.ships?.rows ?? [],
|
|
10549
|
+
cfg.ship.name,
|
|
10550
|
+
(t) => {
|
|
10551
|
+
const d = dirForRepo(route, t.repo_id);
|
|
10552
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
10553
|
+
}
|
|
10554
|
+
);
|
|
10453
10555
|
if (!result.ticket) {
|
|
10454
10556
|
emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10455
10557
|
dropLock();
|
|
@@ -11919,6 +12021,19 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
|
|
|
11919
12021
|
);
|
|
11920
12022
|
}
|
|
11921
12023
|
}
|
|
12024
|
+
if (ships.length > 0) {
|
|
12025
|
+
try {
|
|
12026
|
+
const humanHolds = new Set(holdIds(buildRoster(configuredMembers(route), crewRows)));
|
|
12027
|
+
const warnings = holdWarnings(await tracker.openTickets(), ships, tracker.contract, humanHolds);
|
|
12028
|
+
process.stdout.write(
|
|
12029
|
+
warnings.length === 0 ? "ship holds: every in-flight ticket is held by a live ship\n" : warnings.map((w) => `ship holds: WARNING ${w}
|
|
12030
|
+
`).join("")
|
|
12031
|
+
);
|
|
12032
|
+
} catch (e) {
|
|
12033
|
+
process.stdout.write(`ship holds: could not check \u2014 ${e.message}
|
|
12034
|
+
`);
|
|
12035
|
+
}
|
|
12036
|
+
}
|
|
11922
12037
|
const schedulerPath = pathFor(cfg.ship);
|
|
11923
12038
|
const agentBinPath = findOnPath(cfg.ship.agent.bin, schedulerPath);
|
|
11924
12039
|
const gitPath = findOnPath("git", schedulerPath);
|
package/package.json
CHANGED
|
@@ -38,8 +38,14 @@ human review, so "probably fine" is not a pass.
|
|
|
38
38
|
unfinished) always comes before any `fixed` one. One ticket per run;
|
|
39
39
|
the loop gives your lane the next cycle too while your queue is
|
|
40
40
|
non-empty.
|
|
41
|
-
2. **Claim it
|
|
42
|
-
|
|
41
|
+
2. **Claim it.** The crew has usually already done this for you: a ticket
|
|
42
|
+
it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
|
|
43
|
+
this ship — that stamp is what keeps another ship's QA seat off the same
|
|
44
|
+
ticket (a second verdict on one branch has let a defective one merge). If
|
|
45
|
+
the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
|
|
46
|
+
yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
|
|
47
|
+
the ticket: if `held_by_ship_id` now names another ship, stop — that ship
|
|
48
|
+
is verifying it. Post a short comment saying you've started verifying.
|
|
43
49
|
3. **Read the case, not just the ticket.** Fetch the full record
|
|
44
50
|
(`description`, `repro_steps`, `resolution_note`) *and* every comment on
|
|
45
51
|
it. The builder's own closing comment — what it changed, how it says it
|
|
@@ -107,6 +113,13 @@ human review, so "probably fine" is not a pass.
|
|
|
107
113
|
8. **Then give the verdict**, with a comment that shows your work — what
|
|
108
114
|
you ran, what you saw, what you deliberately didn't cover:
|
|
109
115
|
|
|
116
|
+
**Before you write it, re-read the ticket.** If `updated_at` has moved
|
|
117
|
+
since you claimed it, or `held_by_ship_id` no longer names this ship,
|
|
118
|
+
someone else has acted on it (a bounce, a verdict, a takeover): do not
|
|
119
|
+
overwrite their write — say so in a comment and stop. Send the verdict
|
|
120
|
+
write itself conditional on the `updated_at` you just read, with an
|
|
121
|
+
`X-Expected-Updated-At` header; a 409 means the same thing.
|
|
122
|
+
|
|
110
123
|
- **It holds up** → `status` = `verified`, and clear `assignee_id`. That
|
|
111
124
|
is the merge trigger: the release phase will squash-merge the branch,
|
|
112
125
|
bump the version, and deploy this cycle or the next. Say in
|
|
@@ -114,11 +127,11 @@ human review, so "probably fine" is not a pass.
|
|
|
114
127
|
covered.
|
|
115
128
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
|
|
116
129
|
lane's own Crew row id from the roster above — the dev seat if
|
|
117
|
-
`needs_design` is false or null, the design seat if true. **Exception:** when the
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`in_progress` ticket
|
|
121
|
-
|
|
130
|
+
`needs_design` is false or null, the design seat if true. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
|
|
131
|
+
hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
|
|
132
|
+
naming this ship's seat would hand the bounce to a ship that never built
|
|
133
|
+
it. An unassigned, unheld `in_progress` ticket is picked up by whichever
|
|
134
|
+
ship's building seat polls next, from the branch on origin. The comment
|
|
122
135
|
must be actionable:
|
|
123
136
|
exactly what you did, what you expected, what happened, with a
|
|
124
137
|
screenshot or the failing output. This includes a red suite, a
|
|
@@ -36,8 +36,14 @@ human review, so "probably fine" is not a pass.
|
|
|
36
36
|
unfinished) always comes before any `fixed` one. One ticket per run;
|
|
37
37
|
the loop gives your lane the next cycle too while your queue is
|
|
38
38
|
non-empty.
|
|
39
|
-
2. **Claim it
|
|
40
|
-
|
|
39
|
+
2. **Claim it.** The crew has usually already done this for you: a ticket
|
|
40
|
+
it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
|
|
41
|
+
this ship — that stamp is what keeps another ship's QA seat off the same
|
|
42
|
+
ticket (a second verdict on one branch has let a defective one merge). If
|
|
43
|
+
the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
|
|
44
|
+
yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
|
|
45
|
+
the ticket: if `held_by_ship_id` now names another ship, stop — that ship
|
|
46
|
+
is verifying it. Post a short comment saying you've started verifying.
|
|
41
47
|
3. **Read the case, not just the ticket.** Fetch the full record
|
|
42
48
|
(`description`, `repro_steps`, `resolution_note`) *and* every comment on
|
|
43
49
|
it. The builder's own closing comment — what it changed, how it says it
|
|
@@ -98,17 +104,24 @@ human review, so "probably fine" is not a pass.
|
|
|
98
104
|
8. **Then give the verdict**, with a comment that shows your work — what
|
|
99
105
|
you ran, what you saw, what you deliberately didn't cover:
|
|
100
106
|
|
|
107
|
+
**Before you write it, re-read the ticket.** If `updated_at` has moved
|
|
108
|
+
since you claimed it, or `held_by_ship_id` no longer names this ship,
|
|
109
|
+
someone else has acted on it (a bounce, a verdict, a takeover): do not
|
|
110
|
+
overwrite their write — say so in a comment and stop. Send the verdict
|
|
111
|
+
write itself conditional on the `updated_at` you just read, with an
|
|
112
|
+
`X-Expected-Updated-At` header; a 409 means the same thing.
|
|
113
|
+
|
|
101
114
|
- **It holds up** → `status` = `verified`, and clear `assignee_id`. That
|
|
102
115
|
is the merge trigger: the release phase will squash-merge the branch,
|
|
103
116
|
bump the version, and deploy this cycle or the next. Say in
|
|
104
117
|
the comment what you exercised, so the record shows what "verified"
|
|
105
118
|
covered.
|
|
106
119
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
|
|
107
|
-
seat's own Crew row id from the roster above. **Exception:** when the
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`in_progress` ticket
|
|
111
|
-
|
|
120
|
+
seat's own Crew row id from the roster above. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
|
|
121
|
+
hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
|
|
122
|
+
naming this ship's seat would hand the bounce to a ship that never built
|
|
123
|
+
it. An unassigned, unheld `in_progress` ticket is picked up by whichever
|
|
124
|
+
ship's building seat polls next, from the branch on origin. The comment must be
|
|
112
125
|
actionable:
|
|
113
126
|
exactly what you did, what you expected, what happened, with a
|
|
114
127
|
screenshot or the failing output. This includes a red suite, a
|