lockage 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +355 -2
- package/dist/db/index.d.ts +52 -0
- package/dist/db/index.js +238 -0
- package/dist/db/schema.sql +238 -0
- package/dist/hook/clients.d.ts +56 -0
- package/dist/hook/clients.js +258 -0
- package/dist/hook/context.d.ts +22 -0
- package/dist/hook/context.js +40 -0
- package/dist/hook/dispatch.d.ts +21 -0
- package/dist/hook/dispatch.js +212 -0
- package/dist/hook/guard.d.ts +34 -0
- package/dist/hook/guard.js +106 -0
- package/dist/hook/repo.d.ts +40 -0
- package/dist/hook/repo.js +173 -0
- package/dist/hook/setup-kiro.d.ts +30 -0
- package/dist/hook/setup-kiro.js +118 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +694 -0
- package/dist/launcher/client.d.ts +22 -0
- package/dist/launcher/client.js +57 -0
- package/dist/launcher/publish-adapter.d.ts +88 -0
- package/dist/launcher/publish-adapter.js +233 -0
- package/dist/launcher/publish-cli.d.ts +2 -0
- package/dist/launcher/publish-cli.js +123 -0
- package/dist/launcher/publish.d.ts +140 -0
- package/dist/launcher/publish.js +522 -0
- package/dist/lockage/board.d.ts +247 -0
- package/dist/lockage/board.js +407 -0
- package/dist/lockage/claim.d.ts +307 -0
- package/dist/lockage/claim.js +580 -0
- package/dist/lockage/files.d.ts +229 -0
- package/dist/lockage/files.js +403 -0
- package/dist/lockage/git.d.ts +92 -0
- package/dist/lockage/git.js +183 -0
- package/dist/lockage/globs.d.ts +26 -0
- package/dist/lockage/globs.js +125 -0
- package/dist/lockage/merge.d.ts +132 -0
- package/dist/lockage/merge.js +831 -0
- package/dist/lockage/publish-lock.d.ts +38 -0
- package/dist/lockage/publish-lock.js +73 -0
- package/dist/lockage/tickets.d.ts +213 -0
- package/dist/lockage/tickets.js +340 -0
- package/dist/lockage/worktree.d.ts +212 -0
- package/dist/lockage/worktree.js +563 -0
- package/package.json +43 -4
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ③ publish body (problem B, B-2) — on ticket_ready, publish the integration branch + create a PR.
|
|
3
|
+
*
|
|
4
|
+
* design v1 §2 / §4.4. Upholds the loose coupling "the core does not publish" and talks to the core
|
|
5
|
+
* via the board:
|
|
6
|
+
* - read: read_board(kind='ticket_ready' | 'pr_opened') / read_ticket / list_tasks(ticket_id)
|
|
7
|
+
* - write: post_board(kind='pr_opened', ref_ticket_id) ← the source of truth for idempotency
|
|
8
|
+
*
|
|
9
|
+
* Idempotency (§3 point 5, do not create duplicate PRs) is layered:
|
|
10
|
+
* 1. board diff: skip tickets that already have pr_opened.
|
|
11
|
+
* 2. findPrByHead before openPr: pick up an existing PR and do not create one even if the board lacks it (crash resilience).
|
|
12
|
+
* 3. the pr_opened post happens after the PR is created successfully (after the URL/number is confirmed).
|
|
13
|
+
*
|
|
14
|
+
* publisher assumes the same host and same repo path as the worker (§3 point 9). The integration
|
|
15
|
+
* branch's entity exists only locally on the host that created it, so an integration branch that
|
|
16
|
+
* does not exist locally is skipped without pushing.
|
|
17
|
+
*
|
|
18
|
+
* The per-ticket advisory lock (acquire_publish_lock/release_publish_lock) is a front-stage
|
|
19
|
+
* concurrency mechanism that blocks the read→act race where two processes pick up the same ticket at
|
|
20
|
+
* once; it does not replace the above 3 idempotency layers (lock + the 3 layers together prevent
|
|
21
|
+
* duplicate PRs).
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { PublishNoDiffError } from './publish-adapter.js';
|
|
28
|
+
// The board is a cumulative log (reconciled rows remain too), so at the default limit=100 (seq
|
|
29
|
+
// ascending = the oldest 100) the latest ticket_ready falls out of the window in proportion to
|
|
30
|
+
// operation time. The publisher reads the same reference range (all) as the observation tool
|
|
31
|
+
// computePublisherStatus, so it does not miss the latest ticket_ready re-emitted by re-arming within
|
|
32
|
+
// a 100-item window. Both the observation and execution paths share the same constant
|
|
33
|
+
// BOARD_READ_ALL_LIMIT.
|
|
34
|
+
import { BOARD_READ_ALL_LIMIT } from '../lockage/board.js';
|
|
35
|
+
/** Whether the branch (refs/heads/<branch>) exists locally. */
|
|
36
|
+
function defaultLocalBranchExists(repoPath, branch) {
|
|
37
|
+
try {
|
|
38
|
+
execFileSync('git', ['-C', repoPath, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Build the PR body (§3 point 7; Phase 1 omits Closes = recommended scope D-B2). */
|
|
48
|
+
export function renderPrBody(ticket, tasks) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
if (ticket.body && ticket.body.trim())
|
|
51
|
+
lines.push(ticket.body.trim(), '');
|
|
52
|
+
lines.push('## Tasks included in this PR');
|
|
53
|
+
if (tasks.length === 0) {
|
|
54
|
+
lines.push('- (none)');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
for (const t of tasks)
|
|
58
|
+
lines.push(`- ${t.title} (${t.id})`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('', '---', `integration branch: ${ticket.integration_branch ?? '(unknown)'}`, `lockage ticket: ${ticket.id}`);
|
|
61
|
+
return lines.join('\n') + '\n';
|
|
62
|
+
}
|
|
63
|
+
/** Write the PR body to a temp file and return {path, dir} (the caller rm's dir). */
|
|
64
|
+
function writeBodyTmp(body) {
|
|
65
|
+
const dir = mkdtempSync(join(process.env.KIROCREW_SCRATCH ?? tmpdir(), 'lockage-prbody-'));
|
|
66
|
+
const path = join(dir, 'body.md');
|
|
67
|
+
writeFileSync(path, body, 'utf8');
|
|
68
|
+
return { path, dir };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Publish unprocessed ticket_ready (the implementation of the §4.4 pseudocode).
|
|
72
|
+
* With board's pr_opened as the source of truth, skip tickets that already have a PR.
|
|
73
|
+
* @returns the processing result of each ticket (for logs/tests).
|
|
74
|
+
*/
|
|
75
|
+
export async function publishReadyTickets(client, adapter, args) {
|
|
76
|
+
const localExists = args.localBranchExists ?? defaultLocalBranchExists;
|
|
77
|
+
// Read all board entries (the same reference range as computePublisherStatus). A limited limit would miss the latest ticket_ready.
|
|
78
|
+
const ready = (await client.callTool('read_board', {
|
|
79
|
+
kind: 'ticket_ready',
|
|
80
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
81
|
+
}));
|
|
82
|
+
const opened = (await client.callTool('read_board', {
|
|
83
|
+
kind: 'pr_opened',
|
|
84
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
85
|
+
}));
|
|
86
|
+
// Trace of a successful follow-up push (pr_updated). Used as max(pr_opened, pr_updated) in the ticket_ready seq comparison.
|
|
87
|
+
const updated = (await client.callTool('read_board', {
|
|
88
|
+
kind: 'pr_updated',
|
|
89
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
90
|
+
}));
|
|
91
|
+
// The set of tickets already pr_merged (after a merge there is no integration branch and a follow-up push is impossible). Not entered into the update path.
|
|
92
|
+
const merged = (await client.callTool('read_board', {
|
|
93
|
+
kind: 'pr_merged',
|
|
94
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
95
|
+
}));
|
|
96
|
+
const openedTicketIds = new Set(opened.messages.map((m) => m.ref_ticket_id).filter((x) => !!x));
|
|
97
|
+
const mergedTicketIds = new Set(merged.messages.map((m) => m.ref_ticket_id).filter((x) => !!x));
|
|
98
|
+
// Fix option A + this fix: keep the "latest pr_opened seq", "latest pr_updated seq", and "latest
|
|
99
|
+
// ticket_ready seq" per ticket. If ticket_ready is after max(pr_opened, pr_updated) (larger seq),
|
|
100
|
+
// it is a new ready re-emitted by re-arming that has not yet been follow-up pushed, so update the
|
|
101
|
+
// existing PR with a follow-up push. Otherwise (= a mere re-run / already follow-up pushed), skip
|
|
102
|
+
// with already_opened as before. By leaving pr_updated on a successful follow-up push, the
|
|
103
|
+
// decision matches computePublisherStatus's pending_update and the same push is not redone each time.
|
|
104
|
+
const latestOpenedSeq = latestSeqByTicket(opened.messages);
|
|
105
|
+
const latestUpdatedSeq = latestSeqByTicket(updated.messages);
|
|
106
|
+
const latestReadySeq = latestSeqByTicket(ready.messages);
|
|
107
|
+
// The seq last reflected into the PR = max(latest pr_opened, latest pr_updated).
|
|
108
|
+
const latestPrSeq = (id) => Math.max(latestOpenedSeq.get(id) ?? 0, latestUpdatedSeq.get(id) ?? 0);
|
|
109
|
+
const outcomes = [];
|
|
110
|
+
// Process each ticket only once even if multiple ticket_ready arrive for it (problem A is idempotent, but a double safeguard).
|
|
111
|
+
const handled = new Set();
|
|
112
|
+
for (const msg of ready.messages) {
|
|
113
|
+
const ticketId = msg.ref_ticket_id;
|
|
114
|
+
if (!ticketId)
|
|
115
|
+
continue; // §3 point 10/D-B7: do not parse the body; reference only ref_ticket_id
|
|
116
|
+
if (args.onlyTicket && ticketId !== args.onlyTicket)
|
|
117
|
+
continue;
|
|
118
|
+
if (handled.has(ticketId))
|
|
119
|
+
continue;
|
|
120
|
+
handled.add(ticketId);
|
|
121
|
+
// Acquire the per-ticket advisory lock. If another process is processing the same ticket (holding
|
|
122
|
+
// the lock), fold this ticket into skipped(reason:'locked') to avoid the read→act race and move
|
|
123
|
+
// to the next ticket (other tickets are not blocked, so the batch continues). The existing 3
|
|
124
|
+
// idempotency layers are preserved behind this front-stage lock.
|
|
125
|
+
const lock = (await client.callTool('acquire_publish_lock', { ticket_id: ticketId }));
|
|
126
|
+
if (!lock.ok) {
|
|
127
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'locked' });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// Catch per ticket: so that one ticket's publish failure (gh error, etc.) does not crash the whole batch.
|
|
131
|
+
try {
|
|
132
|
+
// C (TOCTOU fix): the openedTicketIds set is a one-time snapshot taken at the top of this
|
|
133
|
+
// function, before any per-ticket lock was acquired. Two concurrent publisher processes can
|
|
134
|
+
// both snapshot "pr_opened is empty" and both decide to create a new PR, so the lock alone
|
|
135
|
+
// does not prevent duplicate PRs when the lock windows do not overlap (A acquires → releases →
|
|
136
|
+
// B acquires). To close that read→act race, re-read pr_opened *while holding the lock* and
|
|
137
|
+
// refresh both openedTicketIds and this ticket's latestOpenedSeq. read_board is a live SQL read
|
|
138
|
+
// of the shared file DB, so a pr_opened committed by another process (via post_board) is
|
|
139
|
+
// observed here deterministically.
|
|
140
|
+
// Only pr_opened is re-read: it is the sole idempotency source for duplicate-publish
|
|
141
|
+
// prevention, AND — by also refreshing latestOpenedSeq from the same re-read — it is enough to
|
|
142
|
+
// make the lost-race loser converge cleanly to already_opened. pr_updated/pr_merged/ticket_ready
|
|
143
|
+
// drive the re-arm follow-up path and are a separate axis from concurrent duplicate publish, so
|
|
144
|
+
// they are not re-read (no new board kind is introduced; the opened-seq is simply picked up from
|
|
145
|
+
// the pr_opened messages already fetched here).
|
|
146
|
+
// Why also refresh the seq: the openedTicketIds branch below computes
|
|
147
|
+
// reEmitted = latestReadySeq > max(latestOpenedSeq, latestUpdatedSeq). If only the set were
|
|
148
|
+
// refreshed, the loser's stale latestOpenedSeq === 0 would make reEmitted true, so it would do a
|
|
149
|
+
// real pushBranch + post pr_updated — no duplicate PR, but a spurious follow-up push and a
|
|
150
|
+
// pr_updated board entry that does not correspond to any real re-arm (it pollutes the board as a
|
|
151
|
+
// source of truth, e.g. computePublisherStatus's pending_update). Refreshing latestOpenedSeq to
|
|
152
|
+
// the committed pr_opened seq makes reEmitted false, so the loser ends in already_opened without
|
|
153
|
+
// the spurious push. In a single process nothing posts pr_opened during the lock window, so this
|
|
154
|
+
// re-read returns the top snapshot unchanged (no-op). The existing findPrByHead check before
|
|
155
|
+
// openPr is still kept as another defense layer.
|
|
156
|
+
if (!openedTicketIds.has(ticketId)) {
|
|
157
|
+
const reopened = (await client.callTool('read_board', {
|
|
158
|
+
kind: 'pr_opened',
|
|
159
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
160
|
+
}));
|
|
161
|
+
let reopenedSeq = 0;
|
|
162
|
+
for (const m of reopened.messages) {
|
|
163
|
+
if (m.ref_ticket_id === ticketId && m.seq > reopenedSeq)
|
|
164
|
+
reopenedSeq = m.seq;
|
|
165
|
+
}
|
|
166
|
+
if (reopenedSeq > 0) {
|
|
167
|
+
openedTicketIds.add(ticketId);
|
|
168
|
+
// Fold the committed pr_opened seq into latestOpenedSeq so latestPrSeq(ticketId) reflects
|
|
169
|
+
// it and reEmitted below evaluates false → the loser converges to already_opened (no
|
|
170
|
+
// spurious follow-up push / pr_updated).
|
|
171
|
+
if ((latestOpenedSeq.get(ticketId) ?? 0) < reopenedSeq) {
|
|
172
|
+
latestOpenedSeq.set(ticketId, reopenedSeq);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Fix option A: a ticket that already has pr_opened. When ticket_ready is re-emitted (= B was
|
|
177
|
+
// added by re-arming), ticket_ready itself is a skip, but instead of "creating a new PR" we
|
|
178
|
+
// "update the existing PR with a follow-up push of the integration branch". Since pr_opened
|
|
179
|
+
// does not disappear even when ticket_ready is re-emitted, updating the PR by push is the
|
|
180
|
+
// correct behavior (this chain = re-arm → re-emit → update existing PR is the core of the F2
|
|
181
|
+
// fix). No force push: the integration branch is only derived from base and appended to, so a
|
|
182
|
+
// normal push auto-updates the PR to follow. If the branch is missing / not local, fold into
|
|
183
|
+
// already_opened as before.
|
|
184
|
+
if (openedTicketIds.has(ticketId)) {
|
|
185
|
+
// Already pr_merged = after merge. There is no integration branch and a follow-up push is
|
|
186
|
+
// impossible, so do not enter the update path (previously it fell into already_opened via a
|
|
187
|
+
// NULL integration_branch; here we make the intent explicit).
|
|
188
|
+
if (mergedTicketIds.has(ticketId)) {
|
|
189
|
+
outcomes.push({ ticketId, result: 'already_opened' }); // point 5-1: already merged (no follow-up push needed)
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// ticket_ready comes after max(pr_opened, pr_updated) (larger seq) = a new ready re-emitted
|
|
193
|
+
// by re-arming that has not yet been follow-up pushed. Otherwise (a mere re-run / already
|
|
194
|
+
// follow-up pushed) skip with already_opened as before (idempotent).
|
|
195
|
+
const reEmitted = (latestReadySeq.get(ticketId) ?? 0) > latestPrSeq(ticketId);
|
|
196
|
+
if (!reEmitted) {
|
|
197
|
+
outcomes.push({ ticketId, result: 'already_opened' }); // point 5-1: already has a PR (no re-emit / already follow-up pushed)
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const res = (await client.callTool('read_ticket', { ticket_id: ticketId }));
|
|
201
|
+
const branch = res.ticket?.integration_branch ?? null;
|
|
202
|
+
if (!branch) {
|
|
203
|
+
// The integration branch cannot be fetched (already NULL-reset, etc.) = a follow-up push is impossible. Already has a PR, so idempotent skip.
|
|
204
|
+
outcomes.push({ ticketId, result: 'already_opened' }); // point 5-1: already has a PR (nothing to push)
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// §3 point 9: if the integration branch does not exist locally, a push is impossible. If we
|
|
208
|
+
// decided it should be updated but silently fold into already_opened, the B we want to follow
|
|
209
|
+
// is not reflected silently (silent skip). Align with the branch_not_local handling at new PR
|
|
210
|
+
// creation and surface it with a distinguishable outcome.
|
|
211
|
+
if (!localExists(args.repoPath, branch)) {
|
|
212
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'branch_not_local' });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
await adapter.pushBranch(args.repoPath, branch); // update the existing PR with a follow-up push
|
|
216
|
+
// Leave a trace of the successful follow-up push on the board. This makes max(pr_opened,
|
|
217
|
+
// pr_updated)'s seq exceed ticket_ready, drops this ticket from computePublisherStatus's
|
|
218
|
+
// pending_update, and avoids redoing the same push afterward. If the push fails we do not
|
|
219
|
+
// reach here (we go to catch), so pr_updated is not left and it is re-evaluated as still
|
|
220
|
+
// awaiting an update.
|
|
221
|
+
await client.callTool('post_board', {
|
|
222
|
+
agent_id: args.agentId,
|
|
223
|
+
kind: 'pr_updated',
|
|
224
|
+
ref_ticket_id: ticketId,
|
|
225
|
+
body: `Updated the existing PR with a follow-up push (integration branch ${branch})`,
|
|
226
|
+
});
|
|
227
|
+
outcomes.push({ ticketId, result: 'updated', branch });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const res = (await client.callTool('read_ticket', { ticket_id: ticketId }));
|
|
231
|
+
const ticket = res.ticket;
|
|
232
|
+
const branch = ticket?.integration_branch ?? null;
|
|
233
|
+
if (!ticket || !branch) {
|
|
234
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'no_branch' });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// §3 point 9: an integration branch created on another host with no local entity cannot be pushed → skip
|
|
238
|
+
if (!localExists(args.repoPath, branch)) {
|
|
239
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'branch_not_local' });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// point 5-2: detect an existing PR before openPr (do not create a duplicate even if the board lacks pr_opened)
|
|
243
|
+
const existing = await adapter.findPrByHead(args.repoPath, branch);
|
|
244
|
+
if (existing) {
|
|
245
|
+
await client.callTool('post_board', {
|
|
246
|
+
agent_id: args.agentId,
|
|
247
|
+
kind: 'pr_opened',
|
|
248
|
+
ref_ticket_id: ticketId,
|
|
249
|
+
body: `PR #${existing.number} ${existing.url}`,
|
|
250
|
+
});
|
|
251
|
+
outcomes.push({ ticketId, result: 'existing_pr', prNumber: existing.number, url: existing.url });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// push → create PR → post pr_opened after creation succeeds (point 5-3)
|
|
255
|
+
await adapter.pushBranch(args.repoPath, branch);
|
|
256
|
+
const tasksRes = (await client.callTool('list_tasks', { ticket_id: ticketId }));
|
|
257
|
+
const tasks = Array.isArray(tasksRes) ? tasksRes : tasksRes.tasks;
|
|
258
|
+
const { path: bodyFile, dir: bodyDir } = writeBodyTmp(renderPrBody(ticket, tasks ?? []));
|
|
259
|
+
let pr;
|
|
260
|
+
try {
|
|
261
|
+
pr = await adapter.openPr({
|
|
262
|
+
repo: args.repoPath,
|
|
263
|
+
base: args.base,
|
|
264
|
+
head: branch,
|
|
265
|
+
title: ticket.title,
|
|
266
|
+
bodyFile,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
rmSync(bodyDir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
await client.callTool('post_board', {
|
|
273
|
+
agent_id: args.agentId,
|
|
274
|
+
kind: 'pr_opened',
|
|
275
|
+
ref_ticket_id: ticketId,
|
|
276
|
+
body: `PR #${pr.number} ${pr.url}`,
|
|
277
|
+
});
|
|
278
|
+
outcomes.push({ ticketId, result: 'published', prNumber: pr.number, url: pr.url });
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
// No diff from base (empty aggregation / already merged) folds into skip (not an anomaly).
|
|
282
|
+
if (e instanceof PublishNoDiffError) {
|
|
283
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'no_diff' });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// Anything else is recorded as a per-ticket failure and we continue to the next ticket (do not stop the batch).
|
|
287
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
288
|
+
console.error(`[publish] failed to publish ticket ${ticketId}: ${error}`);
|
|
289
|
+
outcomes.push({ ticketId, result: 'failed', error });
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
// Always release the lock (all of published/updated/existing_pr/already_opened/skipped/failed).
|
|
293
|
+
// Delete only on holder match, so a lock stolen by another process on TTL expiry is not deleted by mistake.
|
|
294
|
+
await client.callTool('release_publish_lock', { ticket_id: ticketId, holder: lock.holder });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return outcomes;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Fix option A: collect the latest seq per ticket from a group of board messages.
|
|
301
|
+
* Used to compare ticket_ready / pr_opened seqs to decide "a ready re-emitted after PR creation".
|
|
302
|
+
*/
|
|
303
|
+
function latestSeqByTicket(messages) {
|
|
304
|
+
const m = new Map();
|
|
305
|
+
for (const msg of messages) {
|
|
306
|
+
if (!msg.ref_ticket_id)
|
|
307
|
+
continue;
|
|
308
|
+
const prev = m.get(msg.ref_ticket_id) ?? 0;
|
|
309
|
+
if (msg.seq > prev)
|
|
310
|
+
m.set(msg.ref_ticket_id, msg.seq);
|
|
311
|
+
}
|
|
312
|
+
return m;
|
|
313
|
+
}
|
|
314
|
+
/** Extract the PR number from a `PR #<n> <url>`-format pr_opened body. null on failure. */
|
|
315
|
+
function parsePrNumber(body) {
|
|
316
|
+
const m = body.match(/^PR #(\d+)\b/);
|
|
317
|
+
return m ? Number(m[1]) : null;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Merge-detection cleanup (problem B, B-3, design v1 §3 point 6).
|
|
321
|
+
* For tickets that are "pr_opened ∧ not pr_merged", check prState and, only for MERGED ones:
|
|
322
|
+
* ① delete the remote integration branch (deleteRemoteBranch)
|
|
323
|
+
* ② delete the local integration branch (git branch -D, best effort)
|
|
324
|
+
* ③ reset tickets.integration_branch to NULL (clear_integration_branch, with the expected_branch guard)
|
|
325
|
+
* ④' update tickets.status to closed (close_ticket_on_merge; a dedicated path that does not bump
|
|
326
|
+
* the version flips status one-way to closed = no version conflict. Does not leave merged work
|
|
327
|
+
* in list_tickets(status:"open"). Does nothing if already closed)
|
|
328
|
+
* ④ post pr_merged(ref_ticket_id) to the board ← this ticket is not re-processed afterward (the source of truth for idempotency)
|
|
329
|
+
* The skipped_unmerged_tasks path (unfinished tasks remain) does not do ③④'④ (= the ticket is not closed).
|
|
330
|
+
* It does not merge itself (that is a human/CI). No immediacy is needed = reclaim in bulk on the next run.
|
|
331
|
+
*/
|
|
332
|
+
export async function reconcileMergedPrs(client, adapter, args) {
|
|
333
|
+
const delLocal = args.deleteLocalBranch ?? defaultDeleteLocalBranch;
|
|
334
|
+
// Read all board entries (the same reference range as computePublisherStatus). A limited limit would miss the latest pr_opened/pr_merged.
|
|
335
|
+
const opened = (await client.callTool('read_board', {
|
|
336
|
+
kind: 'pr_opened',
|
|
337
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
338
|
+
}));
|
|
339
|
+
const merged = (await client.callTool('read_board', {
|
|
340
|
+
kind: 'pr_merged',
|
|
341
|
+
limit: BOARD_READ_ALL_LIMIT,
|
|
342
|
+
}));
|
|
343
|
+
const mergedTicketIds = new Set(merged.messages.map((m) => m.ref_ticket_id).filter((x) => !!x));
|
|
344
|
+
const outcomes = [];
|
|
345
|
+
const handled = new Set();
|
|
346
|
+
for (const msg of opened.messages) {
|
|
347
|
+
const ticketId = msg.ref_ticket_id;
|
|
348
|
+
if (!ticketId)
|
|
349
|
+
continue;
|
|
350
|
+
if (args.onlyTicket && ticketId !== args.onlyTicket)
|
|
351
|
+
continue;
|
|
352
|
+
if (mergedTicketIds.has(ticketId))
|
|
353
|
+
continue; // already cleaned up (pr_merged present)
|
|
354
|
+
if (handled.has(ticketId))
|
|
355
|
+
continue;
|
|
356
|
+
handled.add(ticketId);
|
|
357
|
+
// The same per-ticket advisory lock as publishReadyTickets. If another process is processing the
|
|
358
|
+
// same ticket, avoid the read→act race and move to the next with skipped(reason:'locked') (other tickets are not blocked).
|
|
359
|
+
const lock = (await client.callTool('acquire_publish_lock', { ticket_id: ticketId }));
|
|
360
|
+
if (!lock.ok) {
|
|
361
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'locked' });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const prNumber = parsePrNumber(msg.body);
|
|
365
|
+
if (prNumber == null) {
|
|
366
|
+
outcomes.push({ ticketId, result: 'skipped', reason: 'no_pr_number' });
|
|
367
|
+
// Release the lock before moving to the next ticket (always return a lock we acquired, even for no_pr_number).
|
|
368
|
+
await client.callTool('release_publish_lock', { ticket_id: ticketId, holder: lock.holder });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const state = await adapter.prState(args.repoPath, prNumber);
|
|
373
|
+
if (state !== 'MERGED') {
|
|
374
|
+
outcomes.push({ ticketId, prNumber, result: 'still_open' });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// Fix option A (B-loss guard): even if the PR is MERGED, protect the integration branch if an
|
|
378
|
+
// unfinished task remains under the ticket. If A's PR is merged while a B added later by
|
|
379
|
+
// re-arming is still mid-way being stacked onto the integration branch, deleting the branch /
|
|
380
|
+
// NULL-resetting integration_branch would wipe out B entirely (data loss). Use "whether an
|
|
381
|
+
// unfinished task exists" as the criterion (whether or not re-arming has reset ticket_ready_at
|
|
382
|
+
// to NULL, protecting the branch as long as an unfinished task exists is the safe side). If one
|
|
383
|
+
// remains, do no deletion/NULL-reset/pr_merged at all; skip and re-evaluate on the next run.
|
|
384
|
+
const tasksRes = (await client.callTool('list_tasks', { ticket_id: ticketId }));
|
|
385
|
+
const tasks = Array.isArray(tasksRes) ? tasksRes : (tasksRes.tasks ?? []);
|
|
386
|
+
if (tasks.some((t) => t.status !== 'done')) {
|
|
387
|
+
outcomes.push({ ticketId, prNumber, result: 'skipped_unmerged_tasks' });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// The integration branch name and the current status/version are fetched from the ticket (they are not in the pr_opened body).
|
|
391
|
+
const res = (await client.callTool('read_ticket', { ticket_id: ticketId }));
|
|
392
|
+
const branch = res.ticket?.integration_branch ?? null;
|
|
393
|
+
let branchDeleted = false;
|
|
394
|
+
if (branch) {
|
|
395
|
+
// ① remote deletion (may fail if GitHub's auto-delete already removed it = swallow and log)
|
|
396
|
+
try {
|
|
397
|
+
await adapter.deleteRemoteBranch(args.repoPath, branch);
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
console.error(`[reconcile] failed to delete the remote branch (may already be deleted) ${branch}: ${errText(e)}`);
|
|
401
|
+
}
|
|
402
|
+
// ② local deletion (best effort)
|
|
403
|
+
branchDeleted = delLocal(args.repoPath, branch);
|
|
404
|
+
}
|
|
405
|
+
// ③ reset integration_branch to NULL (the expected_branch guard avoids wrongly deleting a re-aggregated new branch)
|
|
406
|
+
let cleared = false;
|
|
407
|
+
if (branch) {
|
|
408
|
+
const cr = (await client.callTool('clear_integration_branch', {
|
|
409
|
+
ticket_id: ticketId,
|
|
410
|
+
expected_branch: branch,
|
|
411
|
+
}));
|
|
412
|
+
cleared = !!cr.cleared;
|
|
413
|
+
}
|
|
414
|
+
// ④' set the ticket to closed (do not leave merged work in list_tickets(status:"open")).
|
|
415
|
+
// A dedicated path that does not bump the version (close_ticket_on_merge) flips status
|
|
416
|
+
// one-way to closed. A version conflict cannot occur structurally (no conflict retry needed).
|
|
417
|
+
// Does nothing if already closed. The skipped_unmerged_tasks path does not reach here (= in that case it is not closed).
|
|
418
|
+
const ticketClosed = await closeTicketAfterMerge(client, ticketId);
|
|
419
|
+
// ④ record pr_merged (this ticket is dropped from reconcile targets afterward = idempotent)
|
|
420
|
+
await client.callTool('post_board', {
|
|
421
|
+
agent_id: args.agentId,
|
|
422
|
+
kind: 'pr_merged',
|
|
423
|
+
ref_ticket_id: ticketId,
|
|
424
|
+
body: `PR #${prNumber} merged`,
|
|
425
|
+
});
|
|
426
|
+
// ⑤ #32 option 1: this is after passing the skipped_unmerged_tasks guard (= all tasks done, PR
|
|
427
|
+
// merged), so all the work is in the default branch. Release the done-task worktrees under
|
|
428
|
+
// the ticket (keep_branch defaults to false, so task branches are tidied too). A worktree
|
|
429
|
+
// with uncommitted changes is not released and stays active per FEAT-002/#33 (does not become orphaned).
|
|
430
|
+
// Call it loosely coupled via client.callTool rather than importing the core directly (§design separation).
|
|
431
|
+
// A worktree-release failure is swallowed best-effort and does not crash the whole per-ticket
|
|
432
|
+
// post-processing (pr_merged is already posted, so it is idempotent; even if re-processed,
|
|
433
|
+
// release_ticket_worktrees is idempotent on the presence of active rows = no double release).
|
|
434
|
+
let worktreesReleased = 0;
|
|
435
|
+
try {
|
|
436
|
+
const rel = (await client.callTool('release_ticket_worktrees', {
|
|
437
|
+
ticket_id: ticketId,
|
|
438
|
+
repo_path: args.repoPath,
|
|
439
|
+
}));
|
|
440
|
+
worktreesReleased = rel.removed_count ?? 0;
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
console.error(`[reconcile] failed to release worktrees for ticket ${ticketId} (post-processing continues): ${errText(e)}`);
|
|
444
|
+
}
|
|
445
|
+
// ⑥ Issue #45: with the same idea as worktrees, also reclaim active claims tied to done /
|
|
446
|
+
// abandoned tasks under the ticket (the claim version of the #32 worktree pattern). Tidy up
|
|
447
|
+
// here tasks that ended without going through task_done / release_task, or leftovers from
|
|
448
|
+
// before item 1 was introduced. Like worktree release, it is a loosely coupled call via
|
|
449
|
+
// client.callTool, and failures are swallowed best-effort so the whole post-processing does
|
|
450
|
+
// not crash (idempotent on the presence of active rows = no double release).
|
|
451
|
+
let claimsReleased = 0;
|
|
452
|
+
try {
|
|
453
|
+
const relClaims = (await client.callTool('release_ticket_claims', {
|
|
454
|
+
ticket_id: ticketId,
|
|
455
|
+
}));
|
|
456
|
+
claimsReleased = relClaims.released_count ?? 0;
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
console.error(`[reconcile] failed to release claims for ticket ${ticketId} (post-processing continues): ${errText(e)}`);
|
|
460
|
+
}
|
|
461
|
+
outcomes.push({
|
|
462
|
+
ticketId,
|
|
463
|
+
prNumber,
|
|
464
|
+
result: 'merged',
|
|
465
|
+
branchDeleted,
|
|
466
|
+
cleared,
|
|
467
|
+
ticketClosed,
|
|
468
|
+
worktreesReleased,
|
|
469
|
+
claimsReleased,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
474
|
+
console.error(`[reconcile] failed to post-process ticket ${ticketId}: ${error}`);
|
|
475
|
+
outcomes.push({ ticketId, prNumber, result: 'failed', error });
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
// Always release the lock (all of merged/still_open/skipped_unmerged_tasks/failed).
|
|
479
|
+
await client.callTool('release_publish_lock', { ticket_id: ticketId, holder: lock.holder });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return outcomes;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Set the ticket to closed after merge (called only from reconcile's MERGED path).
|
|
486
|
+
*
|
|
487
|
+
* close is done via the dedicated path close_ticket_on_merge (the core's closeTicketOnMerge) that
|
|
488
|
+
* does not bump the version. With the same idea as clearIntegrationBranch (D-B1), it aligns
|
|
489
|
+
* reconcile's cleanup (integration_branch release / status close) symmetrically as "system
|
|
490
|
+
* operations that do not bump the version". As a result:
|
|
491
|
+
* - A local CAS that flips status one-way to closed (WHERE status != 'closed'), so a version
|
|
492
|
+
* conflict cannot occur structurally (= no conflict retry). It also does not steal the version
|
|
493
|
+
* of a human/other agent's concurrent edit (body, etc.).
|
|
494
|
+
* - Does nothing if already closed (changes==0) → 'already_closed'.
|
|
495
|
+
* - A missing ticket is 'not_found' (rarely reached since reconcile just ran read_ticket).
|
|
496
|
+
*
|
|
497
|
+
* The source of truth for idempotency remains the pr_merged posted by the caller reconcile. Since
|
|
498
|
+
* pr_merged is posted regardless of whether close succeeds, this ticket is dropped from reconcile
|
|
499
|
+
* targets on the next run, and no double close/cleanup happens.
|
|
500
|
+
*/
|
|
501
|
+
export async function closeTicketAfterMerge(client, ticketId) {
|
|
502
|
+
const res = (await client.callTool('close_ticket_on_merge', { ticket_id: ticketId }));
|
|
503
|
+
if (!res.ok)
|
|
504
|
+
return 'not_found';
|
|
505
|
+
return res.closed ? 'closed' : 'already_closed';
|
|
506
|
+
}
|
|
507
|
+
/** Delete the local integration branch (git branch -D). true if deleted, false if not. */
|
|
508
|
+
function defaultDeleteLocalBranch(repoPath, branch) {
|
|
509
|
+
try {
|
|
510
|
+
execFileSync('git', ['-C', repoPath, 'branch', '-D', branch], { stdio: 'ignore' });
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return false; // already gone / another branch checked out, etc. Not essential to post-processing, so swallow
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Extract a short message from an exception (includes execFileSync's stderr/stdout). */
|
|
518
|
+
function errText(e) {
|
|
519
|
+
const err = e;
|
|
520
|
+
return (err?.stderr?.toString() || err?.message || String(e)).trim();
|
|
521
|
+
}
|
|
522
|
+
//# sourceMappingURL=publish.js.map
|