lockage 0.0.0-stage → 0.9.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/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 +42 -3
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { ulid } from 'ulid';
|
|
2
|
+
import { unixNow } from '../db/index.js';
|
|
3
|
+
// Task lease (the grace period before heartbeat lapse returns it to open). Distinct from claims' lease_ttl.
|
|
4
|
+
export const TASK_LEASE_SEC = 15 * 60; // 15 minutes
|
|
5
|
+
export function createTask(cdb, args) {
|
|
6
|
+
const dependsOn = args.dependsOn ?? [];
|
|
7
|
+
// If there is an existing cycle among depends_on, reject before adding edges.
|
|
8
|
+
// Invariant: the new task T has no edges yet, so "a cycle forms after adding T" happens only if
|
|
9
|
+
// there is an existing path D_i → … → D_j among depends_on and D_j also blocks T (= D_j is also in
|
|
10
|
+
// depends_on). Therefore it suffices to check, for every ordered pair (d, other) of depends_on,
|
|
11
|
+
// only "whether there is an existing path from other to d" (no need to traverse T itself).
|
|
12
|
+
for (const d of dependsOn) {
|
|
13
|
+
for (const other of dependsOn) {
|
|
14
|
+
if (d === other)
|
|
15
|
+
continue;
|
|
16
|
+
// Reachable from d to other and other also tries to block d → the seed of a cycle
|
|
17
|
+
if (isReachable(cdb.db, other, d)) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
reason: 'cycle',
|
|
21
|
+
detail: `depends_on would create a dependency cycle (${other} ⇄ ${d})`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const now = unixNow();
|
|
27
|
+
const id = ulid();
|
|
28
|
+
const ticketId = args.ticketId ?? null;
|
|
29
|
+
// Fix option A (F2 re-arm): when adding a task under a ticket, if the parent ticket already emitted
|
|
30
|
+
// ready (ticket_ready_at IS NOT NULL), reset ticket_ready_at to NULL to make ticket_ready re-emit.
|
|
31
|
+
// A task created by createTask is always open (unfinished), so the trigger condition is only "the
|
|
32
|
+
// ticket already emitted ready". The task INSERT and the ticket re-arm are atomized in the same
|
|
33
|
+
// BEGIN IMMEDIATE transaction (so consistency does not break if only one succeeds). The reverse CAS
|
|
34
|
+
// symmetric to tickets.ts's rearmTicketReady is written directly here to unify the transaction
|
|
35
|
+
// boundary (importing claim.ts→tickets.ts would not be a cycle, but a separate transaction would break atomicity).
|
|
36
|
+
const db = cdb.db;
|
|
37
|
+
db.exec('BEGIN IMMEDIATE');
|
|
38
|
+
try {
|
|
39
|
+
db.prepare(`INSERT INTO tasks (id, ticket_id, repo, title, description, status, assignee,
|
|
40
|
+
priority, version, heartbeat_at, created_at, updated_at, host_id, origin)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, 'open', NULL, ?, 0, NULL, ?, ?, ?, 'local')`).run(id, ticketId, args.repo, args.title, args.description ?? null, args.priority ?? 0, now, now, cdb.hostId);
|
|
42
|
+
if (ticketId !== null) {
|
|
43
|
+
db.prepare(`UPDATE tickets SET ticket_ready_at = NULL, updated_at = ?
|
|
44
|
+
WHERE id = ? AND ticket_ready_at IS NOT NULL`).run(now, ticketId);
|
|
45
|
+
}
|
|
46
|
+
// For each depends_on D, add D→T (D blocks T). T is new, so no cycle occurs.
|
|
47
|
+
for (const d of dependsOn) {
|
|
48
|
+
db.prepare(`INSERT OR IGNORE INTO task_edges (from_task, to_task, kind, host_id, origin)
|
|
49
|
+
VALUES (?, ?, 'blocks', ?, 'local')`).run(d, id, cdb.hostId);
|
|
50
|
+
}
|
|
51
|
+
db.exec('COMMIT');
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
try {
|
|
55
|
+
db.exec('ROLLBACK');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* already closed */
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
return getTask(cdb.db, id);
|
|
63
|
+
}
|
|
64
|
+
/** Traverse task_edges (blocks) in the from→to direction and DFS-decide whether to is reachable from from. */
|
|
65
|
+
function isReachable(db, from, to) {
|
|
66
|
+
if (from === to)
|
|
67
|
+
return true;
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const stack = [from];
|
|
70
|
+
while (stack.length > 0) {
|
|
71
|
+
const cur = stack.pop();
|
|
72
|
+
if (cur === to)
|
|
73
|
+
return true;
|
|
74
|
+
if (seen.has(cur))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(cur);
|
|
77
|
+
const next = db
|
|
78
|
+
.prepare(`SELECT to_task FROM task_edges WHERE from_task = ? AND kind = 'blocks'`)
|
|
79
|
+
.all(cur);
|
|
80
|
+
for (const n of next)
|
|
81
|
+
stack.push(n.to_task);
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/** Add one blocks edge (from blocks to). Ignore duplicates (idempotent via PK). */
|
|
86
|
+
function insertEdge(cdb, fromTask, toTask) {
|
|
87
|
+
cdb.db
|
|
88
|
+
.prepare(`INSERT OR IGNORE INTO task_edges (from_task, to_task, kind, host_id, origin)
|
|
89
|
+
VALUES (?, ?, 'blocks', ?, 'local')`)
|
|
90
|
+
.run(fromTask, toTask, cdb.hostId);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Add a dependency (blocks) edge between existing tasks: fromTask blocks toTask
|
|
94
|
+
* (= toTask waits for fromTask to finish). The after-the-fact version of create_task(depends_on).
|
|
95
|
+
* It cycle-checks before adding the edge and rejects with reason:'cycle' if it would form a cycle (§4.8 DAG invariant).
|
|
96
|
+
*/
|
|
97
|
+
export function addDependency(cdb, fromTask, toTask) {
|
|
98
|
+
if (fromTask === toTask) {
|
|
99
|
+
return { ok: false, reason: 'self', detail: 'a task cannot block itself' };
|
|
100
|
+
}
|
|
101
|
+
const db = cdb.db;
|
|
102
|
+
const exists = (id) => !!db.prepare(`SELECT 1 FROM tasks WHERE id = ?`).get(id);
|
|
103
|
+
if (!exists(fromTask) || !exists(toTask)) {
|
|
104
|
+
return { ok: false, reason: 'not_found', detail: 'from_task or to_task does not exist' };
|
|
105
|
+
}
|
|
106
|
+
// Adding from→to forms a cycle if to is already reachable from from.
|
|
107
|
+
if (isReachable(db, toTask, fromTask)) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: 'cycle',
|
|
111
|
+
detail: `edge ${fromTask}→${toTask} would create a dependency cycle`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
insertEdge(cdb, fromTask, toTask);
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
/** Fetch one (undefined if it does not exist). */
|
|
118
|
+
export function getTask(db, id) {
|
|
119
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
120
|
+
}
|
|
121
|
+
export function listTasks(cdb, args = {}) {
|
|
122
|
+
const db = cdb.db;
|
|
123
|
+
if (args.status === 'blocked') {
|
|
124
|
+
const rows = listBlockedTasks(cdb, {
|
|
125
|
+
repo: args.repo,
|
|
126
|
+
assignee: args.assignee,
|
|
127
|
+
ticketId: args.ticketId,
|
|
128
|
+
order: 'priority',
|
|
129
|
+
});
|
|
130
|
+
return args.readyOnly ? rows.filter((t) => taskIsReady(db, t.id)) : rows;
|
|
131
|
+
}
|
|
132
|
+
const clauses = [];
|
|
133
|
+
const params = [];
|
|
134
|
+
if (args.status) {
|
|
135
|
+
clauses.push('status = ?');
|
|
136
|
+
params.push(args.status);
|
|
137
|
+
}
|
|
138
|
+
if (args.assignee) {
|
|
139
|
+
clauses.push('assignee = ?');
|
|
140
|
+
params.push(args.assignee);
|
|
141
|
+
}
|
|
142
|
+
if (args.ticketId) {
|
|
143
|
+
clauses.push('ticket_id = ?');
|
|
144
|
+
params.push(args.ticketId);
|
|
145
|
+
}
|
|
146
|
+
if (args.repo) {
|
|
147
|
+
clauses.push('repo = ?');
|
|
148
|
+
params.push(args.repo);
|
|
149
|
+
}
|
|
150
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
151
|
+
const rows = db
|
|
152
|
+
.prepare(`SELECT * FROM tasks ${where} ORDER BY priority DESC, created_at ASC`)
|
|
153
|
+
.all(...params);
|
|
154
|
+
// ready_only: exclude tasks that have any unfinished blocker.
|
|
155
|
+
return args.readyOnly ? rows.filter((t) => taskIsReady(db, t.id)) : rows;
|
|
156
|
+
}
|
|
157
|
+
/** Whether all from_task blockers (blocks edges) are done. Same meaning as board.isReady. */
|
|
158
|
+
function taskIsReady(db, taskId) {
|
|
159
|
+
const blockers = db
|
|
160
|
+
.prepare(`SELECT t.status AS status
|
|
161
|
+
FROM task_edges e JOIN tasks t ON t.id = e.from_task
|
|
162
|
+
WHERE e.to_task = ? AND e.kind = 'blocks'`)
|
|
163
|
+
.all(taskId);
|
|
164
|
+
return blockers.every((b) => b.status === 'done');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* §4.1 atomic claim — SQLite CAS.
|
|
168
|
+
* Take an immediate write lock with BEGIN IMMEDIATE, put all current-value conditions in WHERE, and
|
|
169
|
+
* treat changes==0 as a conflict. expected_version also detects the "someone touched it between read
|
|
170
|
+
* and claim" conflict.
|
|
171
|
+
*/
|
|
172
|
+
export function claimTask(cdb, taskId, agentId, expectedVersion) {
|
|
173
|
+
const db = cdb.db;
|
|
174
|
+
const now = unixNow();
|
|
175
|
+
db.exec('BEGIN IMMEDIATE');
|
|
176
|
+
try {
|
|
177
|
+
const info = db
|
|
178
|
+
.prepare(`UPDATE tasks
|
|
179
|
+
SET assignee = ?,
|
|
180
|
+
status = 'in_progress',
|
|
181
|
+
version = version + 1,
|
|
182
|
+
heartbeat_at = ?,
|
|
183
|
+
updated_at = ?
|
|
184
|
+
WHERE id = ?
|
|
185
|
+
AND assignee IS NULL
|
|
186
|
+
AND status = 'open'
|
|
187
|
+
AND version = ?`)
|
|
188
|
+
.run(agentId, now, now, taskId, expectedVersion);
|
|
189
|
+
if (info.changes === 0) {
|
|
190
|
+
// Conflict or absence. Read the current value to distinguish the reason (read in the same transaction).
|
|
191
|
+
const current = db
|
|
192
|
+
.prepare('SELECT assignee, status, version FROM tasks WHERE id = ?')
|
|
193
|
+
.get(taskId);
|
|
194
|
+
db.exec('ROLLBACK');
|
|
195
|
+
if (!current)
|
|
196
|
+
return { ok: false, reason: 'not_found' };
|
|
197
|
+
return { ok: false, reason: 'conflict', current };
|
|
198
|
+
}
|
|
199
|
+
const task = getTask(db, taskId);
|
|
200
|
+
// 1A: when any task under it is claimed, auto-transition the parent ticket open→in_progress.
|
|
201
|
+
// Only when ticket_id is non-null (a minor task = ticket_id null has no ticket, so do nothing).
|
|
202
|
+
// For atomicity, bundle the claim success (tasks CAS) and the ticket transition in the same
|
|
203
|
+
// BEGIN IMMEDIATE transaction. With the same policy as createTask ("write the tasks INSERT +
|
|
204
|
+
// ticket re-arm SQL directly within the transaction boundary"), write here the same local CAS as
|
|
205
|
+
// tickets.ts's markTicketInProgressOnClaim (importing claim.ts→tickets.ts would not be a cycle,
|
|
206
|
+
// but a separate transaction would break the atomicity of claim success and the ticket transition).
|
|
207
|
+
// A one-way CAS that does not bump the version (WHERE status='open'): a ticket already
|
|
208
|
+
// in_progress/closed is untouched (idempotent = spec 2A's "do not automatically return to open").
|
|
209
|
+
// Do not use version-CAS so a user's concurrent edit of body etc. is not lost-updated (same idea as D-B1).
|
|
210
|
+
if (task.ticket_id !== null) {
|
|
211
|
+
db.prepare(`UPDATE tickets SET status = 'in_progress', updated_at = ?
|
|
212
|
+
WHERE id = ? AND status = 'open'`).run(now, task.ticket_id);
|
|
213
|
+
}
|
|
214
|
+
db.exec('COMMIT');
|
|
215
|
+
// The ticket transition's success/failure is not included in ClaimResult (backward compatible).
|
|
216
|
+
// The claim success is the primary contract, and the ticket transition is an idempotent side
|
|
217
|
+
// effect (no-op if already in_progress/closed), so the return contract is unchanged.
|
|
218
|
+
return { ok: true, task };
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
// Always roll back on exception (do not exit while holding the lock).
|
|
222
|
+
try {
|
|
223
|
+
db.exec('ROLLBACK');
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* ignore if already closed */
|
|
227
|
+
}
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Explicit release. Only the claim holder can transition (the assignee match is a CAS condition).
|
|
233
|
+
* outcome='open' (default) = return to open (abandon so it can be re-claimed).
|
|
234
|
+
* outcome='abandoned' = make it abandoned (no longer worked on; not a re-claim target).
|
|
235
|
+
* The done transition is a separate path (the merge gate = task_done), so it is not handled here.
|
|
236
|
+
*/
|
|
237
|
+
export function releaseTask(cdb, taskId, agentId, outcome = 'open') {
|
|
238
|
+
const now = unixNow();
|
|
239
|
+
const newStatus = outcome === 'abandoned' ? 'abandoned' : 'open';
|
|
240
|
+
const info = cdb.db
|
|
241
|
+
.prepare(`UPDATE tasks
|
|
242
|
+
SET assignee = NULL, status = ?, version = version + 1, heartbeat_at = NULL, updated_at = ?
|
|
243
|
+
WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
|
|
244
|
+
.run(newStatus, now, taskId, agentId);
|
|
245
|
+
// Issue #45 item 2: when a task is released (returned to open / abandoned), also release the active
|
|
246
|
+
// claims that have that task_id together. release is a "give up the work" operation, so leaving the
|
|
247
|
+
// tied claims would keep them appearing in overlaps as "finished work". Release for both open and
|
|
248
|
+
// abandoned. claim.ts cannot import files.ts (which exports sweepExpired) due to a cycle, so, like
|
|
249
|
+
// sweepExpired and heartbeat, write a raw UPDATE claims directly. Release by task_id match regardless of holder.
|
|
250
|
+
if (info.changes > 0) {
|
|
251
|
+
cdb.db
|
|
252
|
+
.prepare(`UPDATE claims SET released_at = ?
|
|
253
|
+
WHERE task_id = ? AND released_at IS NULL`)
|
|
254
|
+
.run(now, taskId);
|
|
255
|
+
// Issue #49 auto-resolution (main path, abandoned side): only when the task is dropped to
|
|
256
|
+
// abandoned (outcome='abandoned' and it actually transitioned, info.changes>0), auto-resolve the
|
|
257
|
+
// unresolved questions that have that task as ref_task_id. On a return to open (outcome='open'),
|
|
258
|
+
// the task is not finished yet, so do not resolve. resolvedBy is the releasing subject (agentId).
|
|
259
|
+
// Only unresolved ones are targeted, so it is idempotent (a double resolve is a no-op). board.ts
|
|
260
|
+
// imports claim.ts's sweepExpired, so importing claim.ts→board.ts would be a cycle. So instead of
|
|
261
|
+
// calling board.resolveQuestionsForTask, write the equivalent raw UPDATE here (the same style as
|
|
262
|
+
// releaseTask writing a raw UPDATE to claims).
|
|
263
|
+
if (outcome === 'abandoned') {
|
|
264
|
+
cdb.db
|
|
265
|
+
.prepare(`UPDATE board_messages
|
|
266
|
+
SET resolved_at = ?, resolved_by = ?
|
|
267
|
+
WHERE ref_task_id = ? AND kind = 'question' AND resolved_at IS NULL`)
|
|
268
|
+
.run(now, agentId, taskId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { ok: info.changes > 0 };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* §4.x block_task — suspend to wait for a human decision (in_progress → blocked).
|
|
275
|
+
*
|
|
276
|
+
* Called before exiting when a headless worker cannot finish due to lacking permission / waiting for
|
|
277
|
+
* a design decision, etc. The decisive difference from release_task (return to open) / complete_task
|
|
278
|
+
* (done) is that it **keeps the assignee and heartbeat_at**: even suspended, it leaves "who got stuck
|
|
279
|
+
* in which worktree" in the state and preserves the worktree (assignment continuity = option (b)).
|
|
280
|
+
*
|
|
281
|
+
* blocked is excluded from the lease sweep (sweepExpired reclaims only status='in_progress'), so even
|
|
282
|
+
* if the worker process dies and heartbeat stops, it is **kept indefinitely** (the machine does not
|
|
283
|
+
* return it on its own). It is returned only by a human-decided resume_task (same-agent resume) /
|
|
284
|
+
* handoff (hand to another worker) / release_task (abandoned = cancel).
|
|
285
|
+
*
|
|
286
|
+
* The stuck reason is externalized to the board (post_board kind='question', ref_task_id) rather than
|
|
287
|
+
* adding a column to the task itself. Worker convention: leave the question first, then call
|
|
288
|
+
* block_task (since heartbeat stops at blocked, if it dies before writing the reason the way it got stuck is unknown).
|
|
289
|
+
*
|
|
290
|
+
* CAS: conditioned on "assignee = agentId and status = 'in_progress'". If it does not match, ok:false.
|
|
291
|
+
*
|
|
292
|
+
* Not taking expected_version is asymmetric with claimTask but intentional: claim needs version to
|
|
293
|
+
* detect the conflict where "multiple unassigned agents read the same open task and someone snatched
|
|
294
|
+
* it before claim". block/resume/handoff are transitions on a task whose assignee is already fixed to
|
|
295
|
+
* a single subject, and only that assignee is eligible. The only real conflict leaving blocked
|
|
296
|
+
* (concurrent resume ⇄ handoff) is serialized by the status condition + handoff's BEGIN IMMEDIATE,
|
|
297
|
+
* functioning as "status itself is the CAS" (leaving blocked happens only once). resume has no
|
|
298
|
+
* "freshness at read time" dependency like claim, so ABA is not harmful either.
|
|
299
|
+
*/
|
|
300
|
+
export function blockTask(cdb, taskId, agentId) {
|
|
301
|
+
const now = unixNow();
|
|
302
|
+
const info = cdb.db
|
|
303
|
+
.prepare(`UPDATE tasks
|
|
304
|
+
SET status = 'blocked', version = version + 1, updated_at = ?
|
|
305
|
+
WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
|
|
306
|
+
.run(now, taskId, agentId);
|
|
307
|
+
// assignee / heartbeat_at are intentionally not updated (kept).
|
|
308
|
+
return { ok: info.changes > 0 };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* §4.x resume_task — same-agent resume when a human says "continue" (blocked → in_progress).
|
|
312
|
+
*
|
|
313
|
+
* Since blockTask keeps the claim, the same logical agent can return it with an assignee match. The
|
|
314
|
+
* worktree stays as-is. heartbeat_at is updated to now to revive the lease (afterward it returns to
|
|
315
|
+
* being under sweep supervision as a normal in_progress). Handing to another worker is done not by
|
|
316
|
+
* resume but by handoff (only when a human says so explicitly).
|
|
317
|
+
*
|
|
318
|
+
* CAS: conditioned on "assignee = agentId and status = 'blocked'". If it does not match, ok:false
|
|
319
|
+
* (held by another agent / already in_progress/open, etc.).
|
|
320
|
+
*
|
|
321
|
+
* Like blockTask, it does not take expected_version (intentional; see blockTask's doc for the
|
|
322
|
+
* reason). Even if resume ⇄ handoff run concurrently, the status='blocked' in both WHERE clauses lets
|
|
323
|
+
* only one win first and the other gets changes==0, so it is serialized without needing version.
|
|
324
|
+
*/
|
|
325
|
+
export function resumeTask(cdb, taskId, agentId) {
|
|
326
|
+
const now = unixNow();
|
|
327
|
+
const info = cdb.db
|
|
328
|
+
.prepare(`UPDATE tasks
|
|
329
|
+
SET status = 'in_progress', heartbeat_at = ?, version = version + 1, updated_at = ?
|
|
330
|
+
WHERE id = ? AND assignee = ? AND status = 'blocked'`)
|
|
331
|
+
.run(now, now, taskId, agentId);
|
|
332
|
+
return { ok: info.changes > 0 };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* §4.7 handoff — **move a blocked task to another worker per worktree, all at once** (briefing D-3 decided).
|
|
336
|
+
*
|
|
337
|
+
* Adopted operation: resuming from suspension is, in principle, resume_task (same agent). Handing to
|
|
338
|
+
* another worker is **only when a human says so explicitly**, and this handoff is the sole path. So
|
|
339
|
+
* when this function is called, a human decision has already been made (the gate is outside the MCP
|
|
340
|
+
* tool = on the orchestrator/human side).
|
|
341
|
+
*
|
|
342
|
+
* "Per-worktree bulk move" means reassigning the following that the stuck agent A held to agent B, all
|
|
343
|
+
* in the **same CAS transaction**:
|
|
344
|
+
* 1. the task itself : blocked(assignee=A) → in_progress(assignee=B), heartbeat revived
|
|
345
|
+
* 2. worktree_assignments : this task's active worktree from agent A → B (branch/path unchanged = physical side as-is)
|
|
346
|
+
* 3. claims (glob lease) : active claims with task_id=this task and holder=A to holder B
|
|
347
|
+
* 4. dirty_sets : the (agent_id=A) rows of the moved worktree to agent_id=B
|
|
348
|
+
* This way B inherits "the worktree A physically created, the claims it held, and the published dirty
|
|
349
|
+
* set" wholesale and can continue from where the predecessor's work surface left off (the process
|
|
350
|
+
* cannot be switched over, but the work can).
|
|
351
|
+
*
|
|
352
|
+
* Preventing double handoff: BEGIN IMMEDIATE + a CAS of "assignee=fromAgent and status='blocked'".
|
|
353
|
+
* If it was already moved to another agent / resumed / is held by someone other than A, changes==0 →
|
|
354
|
+
* conflict and nothing moves. No new exclusion mechanism is added (follows the existing CAS style).
|
|
355
|
+
*
|
|
356
|
+
* worktree_path/branch are not changed: the physical worktree stays as-is on the FS, and only the
|
|
357
|
+
* owner (agent_id) is reassigned. B cd's into this worktree path and continues. As a measure for
|
|
358
|
+
* dirty_sets' UNIQUE(agent_id, worktree_path), in case B previously left a dirty row for the same
|
|
359
|
+
* worktree, DELETE B's leftover for the same worktree in the same transaction before the UPDATE
|
|
360
|
+
* (since a history of A↔B going back and forth over the same deterministic worktree via §4.5
|
|
361
|
+
* reconnection could make (B, path) duplicate).
|
|
362
|
+
*
|
|
363
|
+
* Limiting the entry condition to blocked is intentional: being able to snatch-move an in_progress
|
|
364
|
+
* task would be a "steal work from a live worker" accident. A move always follows the order "suspend
|
|
365
|
+
* (blocked), then a human names another worker".
|
|
366
|
+
*/
|
|
367
|
+
export function handoffTask(cdb, args) {
|
|
368
|
+
const db = cdb.db;
|
|
369
|
+
const now = unixNow();
|
|
370
|
+
const { taskId, fromAgent, toAgent } = args;
|
|
371
|
+
db.exec('BEGIN IMMEDIATE');
|
|
372
|
+
try {
|
|
373
|
+
// 1) Move the task itself A→B by CAS (only blocked and assignee=A). This is the gate against double handoff.
|
|
374
|
+
const taskInfo = db
|
|
375
|
+
.prepare(`UPDATE tasks
|
|
376
|
+
SET assignee = ?, status = 'in_progress', heartbeat_at = ?,
|
|
377
|
+
version = version + 1, updated_at = ?
|
|
378
|
+
WHERE id = ? AND assignee = ? AND status = 'blocked'`)
|
|
379
|
+
.run(toAgent, now, now, taskId, fromAgent);
|
|
380
|
+
if (taskInfo.changes === 0) {
|
|
381
|
+
const current = db
|
|
382
|
+
.prepare('SELECT assignee, status, version FROM tasks WHERE id = ?')
|
|
383
|
+
.get(taskId);
|
|
384
|
+
db.exec('ROLLBACK');
|
|
385
|
+
if (!current)
|
|
386
|
+
return { ok: false, reason: 'not_found' };
|
|
387
|
+
return { ok: false, reason: 'conflict', current };
|
|
388
|
+
}
|
|
389
|
+
// 2) Move this task's active worktree A→B. Pick up the paths first, then reassign dirty_sets.
|
|
390
|
+
const wtRows = db
|
|
391
|
+
.prepare(`SELECT worktree_path FROM worktree_assignments
|
|
392
|
+
WHERE agent_id = ? AND task_id = ? AND status = 'active'`)
|
|
393
|
+
.all(fromAgent, taskId);
|
|
394
|
+
const worktrees = wtRows.map((r) => r.worktree_path);
|
|
395
|
+
const wtInfo = db
|
|
396
|
+
.prepare(`UPDATE worktree_assignments
|
|
397
|
+
SET agent_id = ?
|
|
398
|
+
WHERE agent_id = ? AND task_id = ? AND status = 'active'`)
|
|
399
|
+
.run(toAgent, fromAgent, taskId);
|
|
400
|
+
// 3) Move active claims tied to this task (holder=A) to B. Narrow by task_id match
|
|
401
|
+
// (do not sweep in claims A holds for another task = faithful to a per-worktree move).
|
|
402
|
+
const claimInfo = db
|
|
403
|
+
.prepare(`UPDATE claims
|
|
404
|
+
SET holder = ?
|
|
405
|
+
WHERE holder = ? AND task_id = ? AND released_at IS NULL`)
|
|
406
|
+
.run(toAgent, fromAgent, taskId);
|
|
407
|
+
// 4) Move the moved worktree's dirty_sets A→B. Per-worktree, so only (agent_id=A and
|
|
408
|
+
// worktree_path IN moved-worktrees).
|
|
409
|
+
// UNIQUE(agent_id, worktree_path) measure: if B previously left a dirty_set for the same
|
|
410
|
+
// worktree_path (a history of A↔B going back and forth over the same deterministic worktree via
|
|
411
|
+
// §4.5 reconnection), the moment agent_id is rewritten A→B, (B, path) would duplicate, SQLite
|
|
412
|
+
// raises an exception → full rollback. So before the reassignment, clean up B's leftover for
|
|
413
|
+
// the same worktree in the same transaction, then UPDATE. The cleanup target is limited to "the
|
|
414
|
+
// worktree currently being moved from A to B" (B's unrelated dirty_set is preserved).
|
|
415
|
+
let dirtyMoved = 0;
|
|
416
|
+
if (worktrees.length > 0) {
|
|
417
|
+
const placeholders = worktrees.map(() => '?').join(',');
|
|
418
|
+
db.prepare(`DELETE FROM dirty_sets
|
|
419
|
+
WHERE agent_id = ? AND worktree_path IN (${placeholders})`).run(toAgent, ...worktrees);
|
|
420
|
+
const dirtyInfo = db
|
|
421
|
+
.prepare(`UPDATE dirty_sets
|
|
422
|
+
SET agent_id = ?
|
|
423
|
+
WHERE agent_id = ? AND worktree_path IN (${placeholders})`)
|
|
424
|
+
.run(toAgent, fromAgent, ...worktrees);
|
|
425
|
+
dirtyMoved = Number(dirtyInfo.changes);
|
|
426
|
+
}
|
|
427
|
+
db.exec('COMMIT');
|
|
428
|
+
return {
|
|
429
|
+
ok: true,
|
|
430
|
+
moved: {
|
|
431
|
+
worktrees: Number(wtInfo.changes),
|
|
432
|
+
claims: Number(claimInfo.changes),
|
|
433
|
+
dirty_sets: dirtyMoved,
|
|
434
|
+
},
|
|
435
|
+
worktrees,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
try {
|
|
440
|
+
db.exec('ROLLBACK');
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* already closed */
|
|
444
|
+
}
|
|
445
|
+
throw e;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Set a task to done (called from the merge gate on a successful merge).
|
|
450
|
+
* The same CAS style as claimTask: conditioned on "assignee = agentId and status = 'in_progress'",
|
|
451
|
+
* update status='done', version+1, and updated_at.
|
|
452
|
+
*
|
|
453
|
+
* The only legitimate basis for the done transition is the fact of "merged into the main line", so
|
|
454
|
+
* this function is called only at merge.ts's merge-success point (follows the role separation from
|
|
455
|
+
* release_task = return-to-open only). If it does not match, ok:false (held by another agent /
|
|
456
|
+
* already done/open, etc.).
|
|
457
|
+
*/
|
|
458
|
+
export function completeTask(cdb, taskId, agentId) {
|
|
459
|
+
const now = unixNow();
|
|
460
|
+
const info = cdb.db
|
|
461
|
+
.prepare(`UPDATE tasks
|
|
462
|
+
SET status = 'done', assignee = NULL, version = version + 1,
|
|
463
|
+
heartbeat_at = NULL, updated_at = ?
|
|
464
|
+
WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
|
|
465
|
+
.run(now, taskId, agentId);
|
|
466
|
+
return { ok: info.changes > 0 };
|
|
467
|
+
}
|
|
468
|
+
export function listBlockedTasks(cdb, args = {}) {
|
|
469
|
+
const db = cdb.db;
|
|
470
|
+
const now = args.now ?? unixNow();
|
|
471
|
+
const clauses = [`t.status = 'blocked'`];
|
|
472
|
+
const params = [];
|
|
473
|
+
if (args.repo) {
|
|
474
|
+
clauses.push('t.repo = ?');
|
|
475
|
+
params.push(args.repo);
|
|
476
|
+
}
|
|
477
|
+
if (args.assignee) {
|
|
478
|
+
clauses.push('t.assignee = ?');
|
|
479
|
+
params.push(args.assignee);
|
|
480
|
+
}
|
|
481
|
+
if (args.ticketId) {
|
|
482
|
+
clauses.push('t.ticket_id = ?');
|
|
483
|
+
params.push(args.ticketId);
|
|
484
|
+
}
|
|
485
|
+
const orderBy = args.order === 'priority' ? 't.priority DESC, t.created_at ASC' : 't.updated_at ASC';
|
|
486
|
+
const rows = db
|
|
487
|
+
.prepare(`SELECT t.*,
|
|
488
|
+
a.last_seen_at AS _agent_last_seen,
|
|
489
|
+
-- Issue #49 design decision: do not keep emitting a resolved question as last_question
|
|
490
|
+
-- (resolved_at IS NULL = only unresolved). last_question's meaning is unchanged = the
|
|
491
|
+
-- latest unresolved question body (null if none). A resolved question is no longer the
|
|
492
|
+
-- "stuck reason" in the orphan-visualization context.
|
|
493
|
+
(SELECT b.body FROM board_messages b
|
|
494
|
+
WHERE b.ref_task_id = t.id AND b.kind = 'question' AND b.resolved_at IS NULL
|
|
495
|
+
ORDER BY b.seq DESC LIMIT 1) AS _last_question
|
|
496
|
+
FROM tasks t
|
|
497
|
+
LEFT JOIN agents a ON a.agent_id = t.assignee
|
|
498
|
+
WHERE ${clauses.join(' AND ')}
|
|
499
|
+
ORDER BY ${orderBy}`)
|
|
500
|
+
.all(...params);
|
|
501
|
+
return rows.map((r) => {
|
|
502
|
+
const { _agent_last_seen, _last_question, ...task } = r;
|
|
503
|
+
return {
|
|
504
|
+
...task,
|
|
505
|
+
blocked_age_sec: now - task.updated_at,
|
|
506
|
+
agent_silent_sec: _agent_last_seen == null ? null : now - _agent_last_seen,
|
|
507
|
+
last_question: _last_question ?? null,
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* §4.2 lease sweep — reclaim expired claims + return heartbeat-lapsed tasks to open.
|
|
513
|
+
* Assumed to run piggybacked at the head of each tool call rather than a resident cron.
|
|
514
|
+
*/
|
|
515
|
+
export function sweepExpired(cdb, now = unixNow()) {
|
|
516
|
+
const db = cdb.db;
|
|
517
|
+
// 1) Logically reclaim expired claims (no physical DELETE = for provenance tracking)
|
|
518
|
+
db.prepare(`UPDATE claims SET released_at = ?
|
|
519
|
+
WHERE released_at IS NULL AND expires_at < ?`).run(now, now);
|
|
520
|
+
// 2) Return heartbeat-lapsed in_progress tasks to open
|
|
521
|
+
db.prepare(`UPDATE tasks
|
|
522
|
+
SET status = 'open', assignee = NULL, version = version + 1, updated_at = ?
|
|
523
|
+
WHERE status = 'in_progress' AND heartbeat_at < ?`).run(now, now - TASK_LEASE_SEC);
|
|
524
|
+
// 3) agents' liveness is judged by last_seen (for worktree orphan detection). No auto release.
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The heartbeat piggybacked onto every tool call. Updates the worker's own liveness time
|
|
528
|
+
* (agents.last_seen) and the lease of held tasks/claims.
|
|
529
|
+
*
|
|
530
|
+
* The 3rd argument also accepts a taskId string for backward compatibility (= same as { taskId }).
|
|
531
|
+
* Passing touchAgent=false suppresses the extension of agents.last_seen and the claims lease
|
|
532
|
+
* (for operations invoked by non-owners = the orchestrator/human; prevents polluting orphan detection).
|
|
533
|
+
*
|
|
534
|
+
* Issue #51 handling: even on a piggyback call that passes no taskId (heartbeat(cdb, agentId)), if
|
|
535
|
+
* touchOwnedTasks (default = touchAgent) is true, extend the heartbeat_at of all in_progress tasks
|
|
536
|
+
* assigned to that agent to now. This way the task lease is maintained by the piggyback of each tool
|
|
537
|
+
* call alone, even without calling the heartbeat tool (with taskId), and prevents wrongful
|
|
538
|
+
* reclamation by sweepExpired.
|
|
539
|
+
*/
|
|
540
|
+
export function heartbeat(cdb, agentId, opts) {
|
|
541
|
+
const db = cdb.db;
|
|
542
|
+
const now = unixNow();
|
|
543
|
+
const o = typeof opts === 'string' ? { taskId: opts } : (opts ?? {});
|
|
544
|
+
const touchAgent = o.touchAgent ?? true;
|
|
545
|
+
// Owned tasks' lease extension only on the owner's liveness signal (do not freshen for non-owner =
|
|
546
|
+
// touchAgent:false operations). So align the default to touchAgent. An explicit value takes precedence.
|
|
547
|
+
const touchOwnedTasks = o.touchOwnedTasks ?? touchAgent;
|
|
548
|
+
if (touchAgent) {
|
|
549
|
+
// Update agents.last_seen (upsert if absent). Only on the worker's own liveness signal.
|
|
550
|
+
db.prepare(`INSERT INTO agents (agent_id, label, last_seen_at, created_at, host_id, origin)
|
|
551
|
+
VALUES (?, NULL, ?, ?, ?, 'local')
|
|
552
|
+
ON CONFLICT(agent_id) DO UPDATE SET last_seen_at = excluded.last_seen_at`).run(agentId, now, now, cdb.hostId);
|
|
553
|
+
}
|
|
554
|
+
if (o.taskId) {
|
|
555
|
+
// When taskId is explicit, update only "that one (assignee match)" as before.
|
|
556
|
+
db.prepare(`UPDATE tasks SET heartbeat_at = ? WHERE id = ? AND assignee = ?`).run(now, o.taskId, agentId);
|
|
557
|
+
}
|
|
558
|
+
else if (touchOwnedTasks) {
|
|
559
|
+
// Issue #51: even without taskId (= the piggyback of each tool call), extend the lease of all
|
|
560
|
+
// in_progress tasks held by this agent. blocked is kept indefinitely and excluded from sweep, so
|
|
561
|
+
// do not touch it. claim.ts does not import board/files (to avoid a cycle), so write the equivalent raw UPDATE directly.
|
|
562
|
+
db.prepare(`UPDATE tasks SET heartbeat_at = ? WHERE assignee = ? AND status = 'in_progress'`).run(now, agentId);
|
|
563
|
+
}
|
|
564
|
+
if (touchAgent) {
|
|
565
|
+
// Extend the lease of held claims (tied to the owner's liveness, so linked to touchAgent).
|
|
566
|
+
//
|
|
567
|
+
// Issue #45 item 4: narrow the extension target to "claims not tied to a task (task_id IS NULL)"
|
|
568
|
+
// or "claims of still-live tasks (in_progress / blocked)". Previously all active claims the holder
|
|
569
|
+
// had were extended without narrowing by task_id, so as long as the agent continued another task,
|
|
570
|
+
// expires_at kept extending even for claims of tasks already dropped to done / abandoned / open
|
|
571
|
+
// and they were not reclaimed even by TTL (the cause of finished work's claims lingering in
|
|
572
|
+
// overlaps). Exclude claims tied to done / abandoned / open tasks from the extension target and
|
|
573
|
+
// leave them to the item 1-3 release or TTL expiry.
|
|
574
|
+
db.prepare(`UPDATE claims SET heartbeat_at = ?, expires_at = ? + lease_ttl_sec
|
|
575
|
+
WHERE holder = ? AND released_at IS NULL
|
|
576
|
+
AND (task_id IS NULL
|
|
577
|
+
OR task_id IN (SELECT id FROM tasks WHERE status IN ('in_progress', 'blocked')))`).run(now, now, agentId);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
//# sourceMappingURL=claim.js.map
|