@tablation/crew 0.11.0 → 0.12.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
CHANGED
|
@@ -1682,7 +1682,8 @@ var DEFAULT_CONTRACT = {
|
|
|
1682
1682
|
reportType: "report_type",
|
|
1683
1683
|
epic: "epic_id",
|
|
1684
1684
|
author: "filed_by_id",
|
|
1685
|
-
ship: "ship_id"
|
|
1685
|
+
ship: "ship_id",
|
|
1686
|
+
heldBy: "held_by_ship_id"
|
|
1686
1687
|
},
|
|
1687
1688
|
comments: {
|
|
1688
1689
|
parent: "ticket_id",
|
|
@@ -2971,6 +2972,20 @@ var Tracker = class {
|
|
|
2971
2972
|
...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
|
|
2972
2973
|
});
|
|
2973
2974
|
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Ship affinity for a claim (CREW-1386): stamp this ship's Ships row on the
|
|
2977
|
+
* ticket in the claim's own write, in the `held_by_ship_id` column (not
|
|
2978
|
+
* `ship_id`, which means "filed from"). `undefined` — claim exactly as
|
|
2979
|
+
* before — when the Issues table has no hold column or this ship has no row.
|
|
2980
|
+
*/
|
|
2981
|
+
async claimAffinity() {
|
|
2982
|
+
const auth = await this.authorshipProbe();
|
|
2983
|
+
if (!auth.issueHeld || !auth.shipId) return void 0;
|
|
2984
|
+
return {
|
|
2985
|
+
column: this.contract.columns.heldBy,
|
|
2986
|
+
shipId: auth.shipId
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2974
2989
|
/**
|
|
2975
2990
|
* The authorship probe (CREW-1371), read once per process: which optional
|
|
2976
2991
|
* `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
|
|
@@ -2992,6 +3007,7 @@ var Tracker = class {
|
|
|
2992
3007
|
return {
|
|
2993
3008
|
issueAuthor: issueFields.has(this.contract.columns.author),
|
|
2994
3009
|
issueShip: issueFields.has(this.contract.columns.ship),
|
|
3010
|
+
issueHeld: issueFields.has(this.contract.columns.heldBy),
|
|
2995
3011
|
commentShip: commentFields.has(this.contract.comments.ship),
|
|
2996
3012
|
shipId: shipRow?.id ?? null
|
|
2997
3013
|
};
|
|
@@ -3013,6 +3029,7 @@ var Tracker = class {
|
|
|
3013
3029
|
const missing = [];
|
|
3014
3030
|
if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
|
|
3015
3031
|
if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
|
|
3032
|
+
if (!auth.issueHeld) missing.push(`Issues.${this.contract.columns.heldBy}`);
|
|
3016
3033
|
if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
|
|
3017
3034
|
return missing;
|
|
3018
3035
|
}
|
|
@@ -3333,6 +3350,60 @@ function agedRankScalar(rawRank, aging, now) {
|
|
|
3333
3350
|
return Math.round(rawRank * (1 - fraction));
|
|
3334
3351
|
}
|
|
3335
3352
|
|
|
3353
|
+
// src/ship-affinity.ts
|
|
3354
|
+
var CYCLE_SECONDS = 120;
|
|
3355
|
+
var SHIP_DEAD_AFTER_CYCLES = 10;
|
|
3356
|
+
function shipIsAlive(ship, now) {
|
|
3357
|
+
if (!ship?.last_seen) return false;
|
|
3358
|
+
const seen = Date.parse(ship.last_seen);
|
|
3359
|
+
return Number.isFinite(seen) && now - seen <= SHIP_DEAD_AFTER_CYCLES * CYCLE_SECONDS * 1e3;
|
|
3360
|
+
}
|
|
3361
|
+
function ticketHolderShipId(t, contract) {
|
|
3362
|
+
const v = t[contract.columns.heldBy];
|
|
3363
|
+
return typeof v === "string" && v ? v : null;
|
|
3364
|
+
}
|
|
3365
|
+
function heldByOtherShip(t, a) {
|
|
3366
|
+
if (!a.myShipId) return false;
|
|
3367
|
+
const statuses = a.contract.statuses;
|
|
3368
|
+
if ([statuses.handoff, statuses.verifying].includes(t.status)) return false;
|
|
3369
|
+
const holder = ticketHolderShipId(t, a.contract);
|
|
3370
|
+
if (!holder || holder === a.myShipId) return false;
|
|
3371
|
+
const owner = a.ships.find((s) => s.id === holder);
|
|
3372
|
+
return shipIsAlive(owner, a.now ?? Date.now());
|
|
3373
|
+
}
|
|
3374
|
+
function shipIdByName(ships, name) {
|
|
3375
|
+
const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
|
|
3376
|
+
return mine.length === 1 ? mine[0].id : null;
|
|
3377
|
+
}
|
|
3378
|
+
async function noteReaccepted(tracker, result, seat, shipLabel) {
|
|
3379
|
+
if (!result.reaccepted || !result.ticket) return;
|
|
3380
|
+
try {
|
|
3381
|
+
await tracker.postEvent(
|
|
3382
|
+
result.ticket.id,
|
|
3383
|
+
`Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
|
|
3384
|
+
seat
|
|
3385
|
+
);
|
|
3386
|
+
} catch {
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
function planHoldReleases(tickets, contract) {
|
|
3390
|
+
const ends = [contract.statuses.handoff, contract.statuses.verified];
|
|
3391
|
+
return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
|
|
3392
|
+
}
|
|
3393
|
+
async function applyHoldReleases(writer, tickets, contract, emit) {
|
|
3394
|
+
let cleared = 0;
|
|
3395
|
+
for (const t of tickets) {
|
|
3396
|
+
try {
|
|
3397
|
+
await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
|
|
3398
|
+
cleared++;
|
|
3399
|
+
emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
|
|
3400
|
+
} catch (e) {
|
|
3401
|
+
emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
return cleared;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3336
3407
|
// src/select.ts
|
|
3337
3408
|
var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
|
|
3338
3409
|
var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
|
|
@@ -3344,10 +3415,23 @@ function sliceFor(tickets, role) {
|
|
|
3344
3415
|
return buildingSlice(tickets, role);
|
|
3345
3416
|
}
|
|
3346
3417
|
var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
|
|
3418
|
+
function affinityContext(i) {
|
|
3419
|
+
return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
|
|
3420
|
+
}
|
|
3421
|
+
function withHoldCheck(affinity, i) {
|
|
3422
|
+
if (!affinity) return void 0;
|
|
3423
|
+
const a = affinityContext(i);
|
|
3424
|
+
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a) } : affinity;
|
|
3425
|
+
}
|
|
3426
|
+
function notHeldByOtherShip(slice, i) {
|
|
3427
|
+
if (!i.ships) return slice;
|
|
3428
|
+
const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
|
|
3429
|
+
return slice.filter((t) => !heldByOtherShip(t, a));
|
|
3430
|
+
}
|
|
3347
3431
|
function buildingRoleHasWork(role, i) {
|
|
3348
3432
|
const me = i.seats[role];
|
|
3349
3433
|
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
3350
|
-
const mine = sliceFor(i.tickets, role);
|
|
3434
|
+
const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
|
|
3351
3435
|
const startable = mine.filter(
|
|
3352
3436
|
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
3353
3437
|
);
|
|
@@ -3397,7 +3481,7 @@ function roleCandidates(role, i) {
|
|
|
3397
3481
|
if (role === "pair") return [];
|
|
3398
3482
|
const me = i.seats[role];
|
|
3399
3483
|
if (!me) return [];
|
|
3400
|
-
return sliceFor(i.tickets, role).filter(
|
|
3484
|
+
return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
|
|
3401
3485
|
(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)
|
|
3402
3486
|
);
|
|
3403
3487
|
}
|
|
@@ -4220,6 +4304,7 @@ async function decideCycle(o) {
|
|
|
4220
4304
|
emit.warn(`ticket(s) blocking themselves, parked permanently: ${diag.selfBlocked.join(", ")}`);
|
|
4221
4305
|
}
|
|
4222
4306
|
const sweep = planSweep(tickets, info, blocked);
|
|
4307
|
+
const holdReleases = planHoldReleases(tickets, tracker.contract);
|
|
4223
4308
|
const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
|
|
4224
4309
|
emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
|
|
4225
4310
|
const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
|
|
@@ -4287,7 +4372,8 @@ async function decideCycle(o) {
|
|
|
4287
4372
|
holds: holds2,
|
|
4288
4373
|
seats: route.resolved.seats,
|
|
4289
4374
|
paused: state.pausedRoles(["dev", "design", "qa"]),
|
|
4290
|
-
contract: tracker.contract
|
|
4375
|
+
contract: tracker.contract,
|
|
4376
|
+
ships: { myShipId: shipIdByName(ships, ship.name), rows: ships }
|
|
4291
4377
|
};
|
|
4292
4378
|
const selection = selectRole(selectionInput);
|
|
4293
4379
|
const actionable = actionableSummary(selectionInput, selection.pending);
|
|
@@ -4300,6 +4386,7 @@ async function decideCycle(o) {
|
|
|
4300
4386
|
info,
|
|
4301
4387
|
sweep,
|
|
4302
4388
|
epicSync,
|
|
4389
|
+
holdReleases,
|
|
4303
4390
|
stranded,
|
|
4304
4391
|
selection,
|
|
4305
4392
|
actionable,
|
|
@@ -4400,22 +4487,62 @@ function rosterFor(d, route, role) {
|
|
|
4400
4487
|
|
|
4401
4488
|
// src/claim.ts
|
|
4402
4489
|
import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
|
|
4403
|
-
async function
|
|
4490
|
+
async function stampResumption(tracker, t, affinity) {
|
|
4491
|
+
let current = t;
|
|
4492
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4493
|
+
try {
|
|
4494
|
+
const stamped = await tracker.updateTicket(current.id, { [affinity.column]: affinity.shipId }, current.updated_at);
|
|
4495
|
+
return { outcome: "stamped", ticket: { ...current, ...stamped } };
|
|
4496
|
+
} catch (e) {
|
|
4497
|
+
if (!(e instanceof StaleWriteError4)) throw e;
|
|
4498
|
+
}
|
|
4499
|
+
if (!tracker.ticket) return { outcome: "unknown" };
|
|
4500
|
+
try {
|
|
4501
|
+
current = await tracker.ticket(t.id);
|
|
4502
|
+
} catch {
|
|
4503
|
+
return { outcome: "unknown" };
|
|
4504
|
+
}
|
|
4505
|
+
if (affinity.isHeldElsewhere?.(current)) return { outcome: "held" };
|
|
4506
|
+
if (current[affinity.column] === affinity.shipId) return { outcome: "stamped", ticket: current };
|
|
4507
|
+
}
|
|
4508
|
+
return { outcome: "unknown" };
|
|
4509
|
+
}
|
|
4510
|
+
async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity) {
|
|
4404
4511
|
const contended = [];
|
|
4405
4512
|
const unservable = [];
|
|
4513
|
+
const held = [];
|
|
4406
4514
|
for (const t of candidates) {
|
|
4515
|
+
if (affinity?.isHeldElsewhere?.(t)) {
|
|
4516
|
+
held.push(t.issue_id);
|
|
4517
|
+
continue;
|
|
4518
|
+
}
|
|
4407
4519
|
if (isServable && !isServable(t)) {
|
|
4408
4520
|
unservable.push(t.issue_id);
|
|
4409
4521
|
continue;
|
|
4410
4522
|
}
|
|
4411
|
-
if (t.status !== approvedStatus)
|
|
4523
|
+
if (t.status !== approvedStatus) {
|
|
4524
|
+
if (affinity && t[affinity.column] !== affinity.shipId) {
|
|
4525
|
+
const stamp = await stampResumption(tracker, t, affinity);
|
|
4526
|
+
if (stamp.outcome === "stamped") {
|
|
4527
|
+
return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4528
|
+
}
|
|
4529
|
+
(stamp.outcome === "held" ? held : contended).push(t.issue_id);
|
|
4530
|
+
continue;
|
|
4531
|
+
}
|
|
4532
|
+
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4533
|
+
}
|
|
4412
4534
|
try {
|
|
4413
4535
|
const claimed = await tracker.updateTicket(
|
|
4414
4536
|
t.id,
|
|
4415
|
-
{
|
|
4537
|
+
{
|
|
4538
|
+
status: buildingStatus,
|
|
4539
|
+
assignee_id: seat,
|
|
4540
|
+
...affinity ? { [affinity.column]: affinity.shipId } : {}
|
|
4541
|
+
},
|
|
4416
4542
|
t.updated_at
|
|
4417
4543
|
);
|
|
4418
|
-
|
|
4544
|
+
const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
|
|
4545
|
+
return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
|
|
4419
4546
|
} catch (e) {
|
|
4420
4547
|
if (e instanceof StaleWriteError4) {
|
|
4421
4548
|
contended.push(t.issue_id);
|
|
@@ -4424,7 +4551,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4424
4551
|
throw e;
|
|
4425
4552
|
}
|
|
4426
4553
|
}
|
|
4427
|
-
return { ticket: null, claimed: false, contended, unservable };
|
|
4554
|
+
return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4428
4555
|
}
|
|
4429
4556
|
|
|
4430
4557
|
// src/conflict.ts
|
|
@@ -8976,6 +9103,10 @@ async function runOnePass(o) {
|
|
|
8976
9103
|
emit.warn(`could not sweep: ${e.message}`, { step: "sweep" });
|
|
8977
9104
|
}
|
|
8978
9105
|
}
|
|
9106
|
+
if (decision.holdReleases?.length) {
|
|
9107
|
+
const tracker = new Tracker(o.route, o.ship);
|
|
9108
|
+
await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
|
|
9109
|
+
}
|
|
8979
9110
|
const order = [...decision.selection.pending].sort(
|
|
8980
9111
|
(a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
|
|
8981
9112
|
);
|
|
@@ -9052,7 +9183,8 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9052
9183
|
seat,
|
|
9053
9184
|
contract.statuses.approved,
|
|
9054
9185
|
contract.statuses.building,
|
|
9055
|
-
(t) => dirForRepo(route, t.repo_id) !== null
|
|
9186
|
+
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
9187
|
+
withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
|
|
9056
9188
|
);
|
|
9057
9189
|
if (result.contended.length) {
|
|
9058
9190
|
emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
@@ -9066,6 +9198,10 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9066
9198
|
role
|
|
9067
9199
|
});
|
|
9068
9200
|
}
|
|
9201
|
+
if (result.held.length) {
|
|
9202
|
+
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
|
|
9203
|
+
}
|
|
9204
|
+
await noteReaccepted(tracker, result, seat, ship.name);
|
|
9069
9205
|
if (!result.ticket) {
|
|
9070
9206
|
emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
9071
9207
|
return;
|
|
@@ -9160,12 +9296,12 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
9160
9296
|
// src/cli.ts
|
|
9161
9297
|
var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
|
|
9162
9298
|
var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
|
|
9163
|
-
var
|
|
9299
|
+
var CYCLE_SECONDS2 = 120;
|
|
9164
9300
|
var STALE_CYCLES = 3;
|
|
9165
9301
|
function renderShipLine(s, issueLabelById) {
|
|
9166
9302
|
const name = s.name ?? "(unnamed)";
|
|
9167
9303
|
if (!s.last_seen) return `${name.padEnd(20)} never seen`;
|
|
9168
|
-
const staleAfterMs = STALE_CYCLES *
|
|
9304
|
+
const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS2 * 1e3;
|
|
9169
9305
|
const ageMs = Date.now() - Date.parse(s.last_seen);
|
|
9170
9306
|
if (ageMs > staleAfterMs) {
|
|
9171
9307
|
return `${name.padEnd(20)} not seen recently \u2014 last seen ${since(s.last_seen)} ago`;
|
|
@@ -10012,6 +10148,11 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10012
10148
|
const tracker = new Tracker(e.route, cfg.ship);
|
|
10013
10149
|
await applyEpicSync(tracker, e.decision.epicSync, tracker.contract, remit);
|
|
10014
10150
|
}
|
|
10151
|
+
for (const e of fleet.entries) {
|
|
10152
|
+
if (dryRun || e.error || !e.decision?.holdReleases?.length) continue;
|
|
10153
|
+
const tracker = new Tracker(e.route, cfg.ship);
|
|
10154
|
+
await applyHoldReleases(tracker, e.decision.holdReleases, tracker.contract, emit.forRoute(e.route.route));
|
|
10155
|
+
}
|
|
10015
10156
|
for (const e of fleet.entries) {
|
|
10016
10157
|
if (e.error || !e.decision || !e.decision.sweep.length) continue;
|
|
10017
10158
|
const remit = emit.forRoute(e.route.route);
|
|
@@ -10103,7 +10244,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10103
10244
|
seat,
|
|
10104
10245
|
contract.statuses.approved,
|
|
10105
10246
|
contract.statuses.building,
|
|
10106
|
-
(t) => dirForRepo(w.route, t.repo_id) !== null
|
|
10247
|
+
(t) => dirForRepo(w.route, t.repo_id) !== null,
|
|
10248
|
+
withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
|
|
10107
10249
|
);
|
|
10108
10250
|
if (result.contended.length) {
|
|
10109
10251
|
wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
@@ -10117,6 +10259,10 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10117
10259
|
{ step: "select", role: w.role }
|
|
10118
10260
|
);
|
|
10119
10261
|
}
|
|
10262
|
+
if (result.held.length) {
|
|
10263
|
+
wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
|
|
10264
|
+
}
|
|
10265
|
+
await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
|
|
10120
10266
|
if (!result.ticket) {
|
|
10121
10267
|
wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10122
10268
|
if (!skipInlineRelease) await releaseFleet();
|
|
@@ -10227,6 +10373,10 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10227
10373
|
await applyEpicSync(tracker, decision.epicSync, tracker.contract, emit);
|
|
10228
10374
|
}
|
|
10229
10375
|
}
|
|
10376
|
+
if (!dryRun && decision.holdReleases?.length) {
|
|
10377
|
+
const tracker = new Tracker(route, cfg.ship);
|
|
10378
|
+
await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
|
|
10379
|
+
}
|
|
10230
10380
|
const role = value("role") ?? decision.selection.selected;
|
|
10231
10381
|
if (command === "poll") break;
|
|
10232
10382
|
if (!dryRun) {
|
|
@@ -10275,7 +10425,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10275
10425
|
seat,
|
|
10276
10426
|
contract.statuses.approved,
|
|
10277
10427
|
contract.statuses.building,
|
|
10278
|
-
(t) => dirForRepo(route, t.repo_id) !== null
|
|
10428
|
+
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
10429
|
+
withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
|
|
10279
10430
|
);
|
|
10280
10431
|
if (result.contended.length) {
|
|
10281
10432
|
emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
@@ -10289,6 +10440,10 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10289
10440
|
{ step: "select", role: current }
|
|
10290
10441
|
);
|
|
10291
10442
|
}
|
|
10443
|
+
if (result.held.length) {
|
|
10444
|
+
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
|
|
10445
|
+
}
|
|
10446
|
+
await noteReaccepted(tracker2, result, seat, cfg.ship.name);
|
|
10292
10447
|
if (!result.ticket) {
|
|
10293
10448
|
emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10294
10449
|
dropLock();
|
package/package.json
CHANGED
|
@@ -198,7 +198,7 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
198
198
|
the remote has moved), stop: comment on the ticket with what it printed and
|
|
199
199
|
set `needs_info`; do not merge or rebase it yourself.
|
|
200
200
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
201
|
-
reassigned to your row, with a comment saying what still fails. Treat
|
|
201
|
+
reassigned to your row (or unassigned, when your ship holds it), with a comment saying what still fails. Treat
|
|
202
202
|
that exactly like new direction from a hold: read the comment, fix what
|
|
203
203
|
it names, and take it back to `fixed` when it's genuinely right. QA
|
|
204
204
|
bouncing a ticket is the system working, not an accusation.
|
|
@@ -532,6 +532,10 @@ if `report_type` is either of those.
|
|
|
532
532
|
part).
|
|
533
533
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
534
534
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
535
|
+
If the Issues table has a `held_by_ship_id` column, clear it in the same
|
|
536
|
+
write (the hold is for the building roles; QA on any ship may take a `fixed`
|
|
537
|
+
ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
|
|
538
|
+
when it sets `verified`.
|
|
535
539
|
**Push the branch first, in the same step:**
|
|
536
540
|
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
537
541
|
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
@@ -114,7 +114,11 @@ human review, so "probably fine" is not a pass.
|
|
|
114
114
|
covered.
|
|
115
115
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
|
|
116
116
|
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.
|
|
117
|
+
`needs_design` is false or null, the design seat if true. **Exception:** when the ticket carries a `held_by_ship_id`, leave that column
|
|
118
|
+
untouched and **clear** `assignee_id` instead of naming a seat — seats are
|
|
119
|
+
per ship, and the ship that holds the branch resumes an unassigned
|
|
120
|
+
`in_progress` ticket because it is the holder. Naming this ship's seat
|
|
121
|
+
would hand the bounce to a ship that never built it. The comment
|
|
118
122
|
must be actionable:
|
|
119
123
|
exactly what you did, what you expected, what happened, with a
|
|
120
124
|
screenshot or the failing output. This includes a red suite, a
|
|
@@ -179,7 +179,7 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
179
179
|
the remote has moved), stop: comment on the ticket with what it printed and
|
|
180
180
|
set `needs_info`; do not merge or rebase it yourself.
|
|
181
181
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
182
|
-
reassigned to your row, with a comment saying what still fails. Treat
|
|
182
|
+
reassigned to your row (or unassigned, when your ship holds it), with a comment saying what still fails. Treat
|
|
183
183
|
that exactly like new direction from a hold: read the comment, fix what
|
|
184
184
|
it names, and take it back to `fixed` when it's genuinely right. QA
|
|
185
185
|
bouncing a ticket is the system working, not an accusation.
|
|
@@ -514,6 +514,10 @@ if `report_type` is either of those.
|
|
|
514
514
|
part).
|
|
515
515
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
516
516
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
517
|
+
If the Issues table has a `held_by_ship_id` column, clear it in the same
|
|
518
|
+
write (the hold is for the building roles; QA on any ship may take a `fixed`
|
|
519
|
+
ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
|
|
520
|
+
when it sets `verified`.
|
|
517
521
|
**Push the branch first, in the same step:**
|
|
518
522
|
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
519
523
|
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
@@ -104,7 +104,11 @@ human review, so "probably fine" is not a pass.
|
|
|
104
104
|
the comment what you exercised, so the record shows what "verified"
|
|
105
105
|
covered.
|
|
106
106
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
|
|
107
|
-
seat's own Crew row id from the roster above.
|
|
107
|
+
seat's own Crew row id from the roster above. **Exception:** when the ticket carries a `held_by_ship_id`, leave that column
|
|
108
|
+
untouched and **clear** `assignee_id` instead of naming a seat — seats are
|
|
109
|
+
per ship, and the ship that holds the branch resumes an unassigned
|
|
110
|
+
`in_progress` ticket because it is the holder. Naming this ship's seat
|
|
111
|
+
would hand the bounce to a ship that never built it. The comment must be
|
|
108
112
|
actionable:
|
|
109
113
|
exactly what you did, what you expected, what happened, with a
|
|
110
114
|
screenshot or the failing output. This includes a red suite, a
|