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,307 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type CoordDb } from '../db/index.js';
|
|
3
|
+
export declare const TASK_LEASE_SEC: number;
|
|
4
|
+
export interface Task {
|
|
5
|
+
id: string;
|
|
6
|
+
ticket_id: string | null;
|
|
7
|
+
repo: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
status: 'open' | 'in_progress' | 'done' | 'abandoned' | 'blocked';
|
|
11
|
+
assignee: string | null;
|
|
12
|
+
priority: number;
|
|
13
|
+
version: number;
|
|
14
|
+
heartbeat_at: number | null;
|
|
15
|
+
created_at: number;
|
|
16
|
+
updated_at: number;
|
|
17
|
+
host_id: string;
|
|
18
|
+
origin: string;
|
|
19
|
+
}
|
|
20
|
+
export type ClaimResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
task: Task;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: 'conflict' | 'not_found';
|
|
26
|
+
current?: Pick<Task, 'assignee' | 'status' | 'version'>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Create a task. ticket_id is nullable (model 3: minor tasks without a ticket are allowed).
|
|
30
|
+
* Passing depends_on adds edges to the dependency DAG (blocks). It cycle-checks before adding edges
|
|
31
|
+
* and rejects depends_on that would form a cycle with { ok:false, reason:'cycle' } (§4.8 DAG invariant).
|
|
32
|
+
*/
|
|
33
|
+
export interface CycleError {
|
|
34
|
+
ok: false;
|
|
35
|
+
reason: 'cycle';
|
|
36
|
+
detail: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function createTask(cdb: CoordDb, args: {
|
|
39
|
+
repo: string;
|
|
40
|
+
title: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
ticketId?: string | null;
|
|
43
|
+
priority?: number;
|
|
44
|
+
}): Task;
|
|
45
|
+
export declare function createTask(cdb: CoordDb, args: {
|
|
46
|
+
repo: string;
|
|
47
|
+
title: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
ticketId?: string | null;
|
|
50
|
+
priority?: number;
|
|
51
|
+
dependsOn: string[];
|
|
52
|
+
}): Task | CycleError;
|
|
53
|
+
export type AddDependencyResult = {
|
|
54
|
+
ok: true;
|
|
55
|
+
} | {
|
|
56
|
+
ok: false;
|
|
57
|
+
reason: 'cycle' | 'not_found' | 'self';
|
|
58
|
+
detail: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Add a dependency (blocks) edge between existing tasks: fromTask blocks toTask
|
|
62
|
+
* (= toTask waits for fromTask to finish). The after-the-fact version of create_task(depends_on).
|
|
63
|
+
* It cycle-checks before adding the edge and rejects with reason:'cycle' if it would form a cycle (§4.8 DAG invariant).
|
|
64
|
+
*/
|
|
65
|
+
export declare function addDependency(cdb: CoordDb, fromTask: string, toTask: string): AddDependencyResult;
|
|
66
|
+
/** Fetch one (undefined if it does not exist). */
|
|
67
|
+
export declare function getTask(db: DatabaseSync, id: string): Task | undefined;
|
|
68
|
+
/** List of open tasks (priority descending → creation order). The client reads version and passes it to claim. */
|
|
69
|
+
export interface ListTasksArgs {
|
|
70
|
+
status?: Task['status'];
|
|
71
|
+
assignee?: string;
|
|
72
|
+
ticketId?: string;
|
|
73
|
+
repo?: string;
|
|
74
|
+
readyOnly?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* §2.1 list_tasks — list tasks (priority descending → creation order).
|
|
78
|
+
* Filter by status/assignee/ticket_id/repo. With ready_only=true, return only tasks whose
|
|
79
|
+
* from_task blockers are all done (unblocked).
|
|
80
|
+
* When status='blocked', return each row in listBlockedTasks' shape (with blocked_age_sec, etc.).
|
|
81
|
+
*
|
|
82
|
+
* The ready decision is the same logic as board.isReady, but it is kept locally here to avoid a
|
|
83
|
+
* claim.ts→board.ts import cycle (because board.ts imports claim.sweepExpired).
|
|
84
|
+
*/
|
|
85
|
+
export declare function listTasks(cdb: CoordDb, args: ListTasksArgs & {
|
|
86
|
+
status: 'blocked';
|
|
87
|
+
}): BlockedTaskView[];
|
|
88
|
+
export declare function listTasks(cdb: CoordDb, args?: ListTasksArgs): Task[];
|
|
89
|
+
/**
|
|
90
|
+
* §4.1 atomic claim — SQLite CAS.
|
|
91
|
+
* Take an immediate write lock with BEGIN IMMEDIATE, put all current-value conditions in WHERE, and
|
|
92
|
+
* treat changes==0 as a conflict. expected_version also detects the "someone touched it between read
|
|
93
|
+
* and claim" conflict.
|
|
94
|
+
*/
|
|
95
|
+
export declare function claimTask(cdb: CoordDb, taskId: string, agentId: string, expectedVersion: number): ClaimResult;
|
|
96
|
+
/**
|
|
97
|
+
* Explicit release. Only the claim holder can transition (the assignee match is a CAS condition).
|
|
98
|
+
* outcome='open' (default) = return to open (abandon so it can be re-claimed).
|
|
99
|
+
* outcome='abandoned' = make it abandoned (no longer worked on; not a re-claim target).
|
|
100
|
+
* The done transition is a separate path (the merge gate = task_done), so it is not handled here.
|
|
101
|
+
*/
|
|
102
|
+
export declare function releaseTask(cdb: CoordDb, taskId: string, agentId: string, outcome?: 'open' | 'abandoned'): {
|
|
103
|
+
ok: boolean;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* §4.x block_task — suspend to wait for a human decision (in_progress → blocked).
|
|
107
|
+
*
|
|
108
|
+
* Called before exiting when a headless worker cannot finish due to lacking permission / waiting for
|
|
109
|
+
* a design decision, etc. The decisive difference from release_task (return to open) / complete_task
|
|
110
|
+
* (done) is that it **keeps the assignee and heartbeat_at**: even suspended, it leaves "who got stuck
|
|
111
|
+
* in which worktree" in the state and preserves the worktree (assignment continuity = option (b)).
|
|
112
|
+
*
|
|
113
|
+
* blocked is excluded from the lease sweep (sweepExpired reclaims only status='in_progress'), so even
|
|
114
|
+
* if the worker process dies and heartbeat stops, it is **kept indefinitely** (the machine does not
|
|
115
|
+
* return it on its own). It is returned only by a human-decided resume_task (same-agent resume) /
|
|
116
|
+
* handoff (hand to another worker) / release_task (abandoned = cancel).
|
|
117
|
+
*
|
|
118
|
+
* The stuck reason is externalized to the board (post_board kind='question', ref_task_id) rather than
|
|
119
|
+
* adding a column to the task itself. Worker convention: leave the question first, then call
|
|
120
|
+
* block_task (since heartbeat stops at blocked, if it dies before writing the reason the way it got stuck is unknown).
|
|
121
|
+
*
|
|
122
|
+
* CAS: conditioned on "assignee = agentId and status = 'in_progress'". If it does not match, ok:false.
|
|
123
|
+
*
|
|
124
|
+
* Not taking expected_version is asymmetric with claimTask but intentional: claim needs version to
|
|
125
|
+
* detect the conflict where "multiple unassigned agents read the same open task and someone snatched
|
|
126
|
+
* it before claim". block/resume/handoff are transitions on a task whose assignee is already fixed to
|
|
127
|
+
* a single subject, and only that assignee is eligible. The only real conflict leaving blocked
|
|
128
|
+
* (concurrent resume ⇄ handoff) is serialized by the status condition + handoff's BEGIN IMMEDIATE,
|
|
129
|
+
* functioning as "status itself is the CAS" (leaving blocked happens only once). resume has no
|
|
130
|
+
* "freshness at read time" dependency like claim, so ABA is not harmful either.
|
|
131
|
+
*/
|
|
132
|
+
export declare function blockTask(cdb: CoordDb, taskId: string, agentId: string): {
|
|
133
|
+
ok: boolean;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* §4.x resume_task — same-agent resume when a human says "continue" (blocked → in_progress).
|
|
137
|
+
*
|
|
138
|
+
* Since blockTask keeps the claim, the same logical agent can return it with an assignee match. The
|
|
139
|
+
* worktree stays as-is. heartbeat_at is updated to now to revive the lease (afterward it returns to
|
|
140
|
+
* being under sweep supervision as a normal in_progress). Handing to another worker is done not by
|
|
141
|
+
* resume but by handoff (only when a human says so explicitly).
|
|
142
|
+
*
|
|
143
|
+
* CAS: conditioned on "assignee = agentId and status = 'blocked'". If it does not match, ok:false
|
|
144
|
+
* (held by another agent / already in_progress/open, etc.).
|
|
145
|
+
*
|
|
146
|
+
* Like blockTask, it does not take expected_version (intentional; see blockTask's doc for the
|
|
147
|
+
* reason). Even if resume ⇄ handoff run concurrently, the status='blocked' in both WHERE clauses lets
|
|
148
|
+
* only one win first and the other gets changes==0, so it is serialized without needing version.
|
|
149
|
+
*/
|
|
150
|
+
export declare function resumeTask(cdb: CoordDb, taskId: string, agentId: string): {
|
|
151
|
+
ok: boolean;
|
|
152
|
+
};
|
|
153
|
+
export type HandoffResult = {
|
|
154
|
+
ok: true;
|
|
155
|
+
/** Counts of the accompanying state that was moved (for the caller's confirmation/logs). */
|
|
156
|
+
moved: {
|
|
157
|
+
worktrees: number;
|
|
158
|
+
claims: number;
|
|
159
|
+
dirty_sets: number;
|
|
160
|
+
};
|
|
161
|
+
/** The list of worktree paths after the move (where the new agent cd's to). */
|
|
162
|
+
worktrees: string[];
|
|
163
|
+
} | {
|
|
164
|
+
ok: false;
|
|
165
|
+
reason: 'not_found' | 'conflict';
|
|
166
|
+
current?: Pick<Task, 'assignee' | 'status' | 'version'>;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* §4.7 handoff — **move a blocked task to another worker per worktree, all at once** (briefing D-3 decided).
|
|
170
|
+
*
|
|
171
|
+
* Adopted operation: resuming from suspension is, in principle, resume_task (same agent). Handing to
|
|
172
|
+
* another worker is **only when a human says so explicitly**, and this handoff is the sole path. So
|
|
173
|
+
* when this function is called, a human decision has already been made (the gate is outside the MCP
|
|
174
|
+
* tool = on the orchestrator/human side).
|
|
175
|
+
*
|
|
176
|
+
* "Per-worktree bulk move" means reassigning the following that the stuck agent A held to agent B, all
|
|
177
|
+
* in the **same CAS transaction**:
|
|
178
|
+
* 1. the task itself : blocked(assignee=A) → in_progress(assignee=B), heartbeat revived
|
|
179
|
+
* 2. worktree_assignments : this task's active worktree from agent A → B (branch/path unchanged = physical side as-is)
|
|
180
|
+
* 3. claims (glob lease) : active claims with task_id=this task and holder=A to holder B
|
|
181
|
+
* 4. dirty_sets : the (agent_id=A) rows of the moved worktree to agent_id=B
|
|
182
|
+
* This way B inherits "the worktree A physically created, the claims it held, and the published dirty
|
|
183
|
+
* set" wholesale and can continue from where the predecessor's work surface left off (the process
|
|
184
|
+
* cannot be switched over, but the work can).
|
|
185
|
+
*
|
|
186
|
+
* Preventing double handoff: BEGIN IMMEDIATE + a CAS of "assignee=fromAgent and status='blocked'".
|
|
187
|
+
* If it was already moved to another agent / resumed / is held by someone other than A, changes==0 →
|
|
188
|
+
* conflict and nothing moves. No new exclusion mechanism is added (follows the existing CAS style).
|
|
189
|
+
*
|
|
190
|
+
* worktree_path/branch are not changed: the physical worktree stays as-is on the FS, and only the
|
|
191
|
+
* owner (agent_id) is reassigned. B cd's into this worktree path and continues. As a measure for
|
|
192
|
+
* dirty_sets' UNIQUE(agent_id, worktree_path), in case B previously left a dirty row for the same
|
|
193
|
+
* worktree, DELETE B's leftover for the same worktree in the same transaction before the UPDATE
|
|
194
|
+
* (since a history of A↔B going back and forth over the same deterministic worktree via §4.5
|
|
195
|
+
* reconnection could make (B, path) duplicate).
|
|
196
|
+
*
|
|
197
|
+
* Limiting the entry condition to blocked is intentional: being able to snatch-move an in_progress
|
|
198
|
+
* task would be a "steal work from a live worker" accident. A move always follows the order "suspend
|
|
199
|
+
* (blocked), then a human names another worker".
|
|
200
|
+
*/
|
|
201
|
+
export declare function handoffTask(cdb: CoordDb, args: {
|
|
202
|
+
taskId: string;
|
|
203
|
+
fromAgent: string;
|
|
204
|
+
toAgent: string;
|
|
205
|
+
}): HandoffResult;
|
|
206
|
+
/**
|
|
207
|
+
* Set a task to done (called from the merge gate on a successful merge).
|
|
208
|
+
* The same CAS style as claimTask: conditioned on "assignee = agentId and status = 'in_progress'",
|
|
209
|
+
* update status='done', version+1, and updated_at.
|
|
210
|
+
*
|
|
211
|
+
* The only legitimate basis for the done transition is the fact of "merged into the main line", so
|
|
212
|
+
* this function is called only at merge.ts's merge-success point (follows the role separation from
|
|
213
|
+
* release_task = return-to-open only). If it does not match, ok:false (held by another agent /
|
|
214
|
+
* already done/open, etc.).
|
|
215
|
+
*/
|
|
216
|
+
export declare function completeTask(cdb: CoordDb, taskId: string, agentId: string): {
|
|
217
|
+
ok: boolean;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* §4.x list_tasks(status='blocked') — orphan visualization of blocked tasks.
|
|
221
|
+
*
|
|
222
|
+
* blocked is excluded from the lease sweep = kept indefinitely, so a "worker stuck and the human
|
|
223
|
+
* forgot too" orphan can arise. This is **picked up by visualization rather than auto-reclamation**
|
|
224
|
+
* (the adopted operation policy). No new table is added; it is provided as a join view over existing
|
|
225
|
+
* data (tasks.updated_at/assignee + agents.last_seen_at + board_messages).
|
|
226
|
+
*
|
|
227
|
+
* Derived values attached to each row:
|
|
228
|
+
* - blocked_age_sec = now - tasks.updated_at (elapsed since it dropped to blocked; older = longer neglected)
|
|
229
|
+
* - agent_silent_sec = now - agents.last_seen_at (the holder's silence time; null if agent unknown)
|
|
230
|
+
* - last_question = the latest question body with this task as ref_task_id (the stuck reason; null if none)
|
|
231
|
+
*
|
|
232
|
+
* The threshold (how many seconds counts as an orphan) is not hardcoded; the caller/operation decides
|
|
233
|
+
* by looking at blocked_age_sec and agent_silent_sec.
|
|
234
|
+
*
|
|
235
|
+
* Order: the default is updated_at ascending (longest neglected first). list_tasks(status='blocked')
|
|
236
|
+
* calls it with order='priority' to align with the same priority order as other statuses (a caller
|
|
237
|
+
* wanting longest-neglected-first sorts by blocked_age_sec).
|
|
238
|
+
*/
|
|
239
|
+
export interface BlockedTaskView extends Task {
|
|
240
|
+
blocked_age_sec: number;
|
|
241
|
+
agent_silent_sec: number | null;
|
|
242
|
+
last_question: string | null;
|
|
243
|
+
}
|
|
244
|
+
export declare function listBlockedTasks(cdb: CoordDb, args?: {
|
|
245
|
+
repo?: string;
|
|
246
|
+
assignee?: string;
|
|
247
|
+
ticketId?: string;
|
|
248
|
+
now?: number;
|
|
249
|
+
order?: 'oldest_first' | 'priority';
|
|
250
|
+
}): BlockedTaskView[];
|
|
251
|
+
/**
|
|
252
|
+
* §4.2 lease sweep — reclaim expired claims + return heartbeat-lapsed tasks to open.
|
|
253
|
+
* Assumed to run piggybacked at the head of each tool call rather than a resident cron.
|
|
254
|
+
*/
|
|
255
|
+
export declare function sweepExpired(cdb: CoordDb, now?: number): void;
|
|
256
|
+
export interface HeartbeatOpts {
|
|
257
|
+
/** Also update the held task's heartbeat_at (a claiming worker specifies its own task). */
|
|
258
|
+
taskId?: string;
|
|
259
|
+
/**
|
|
260
|
+
* Whether to update agents.last_seen_at to now (default true).
|
|
261
|
+
*
|
|
262
|
+
* Set to false for tools where "someone who is not the worker process itself operates the ledger"
|
|
263
|
+
* (block_task / resume_task / handoff_task). These are invoked by the orchestrator/human, so
|
|
264
|
+
* freshening agents.last_seen with that operation time would pollute a blocked task's
|
|
265
|
+
* agent_silent_sec (= the metric for the worker process's liveness) with the "operation time" and
|
|
266
|
+
* skew orphan detection. So, to not confuse "operation time" with "worker liveness time", do not
|
|
267
|
+
* touch the agent's last_seen for operations by non-owners. The claims lease extension is also tied
|
|
268
|
+
* to the owner's liveness, so suppress it likewise.
|
|
269
|
+
*/
|
|
270
|
+
touchAgent?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Whether to update the heartbeat_at of all in_progress tasks assigned to this agent to now
|
|
273
|
+
* (default equals touchAgent = true only on the owner's liveness signal).
|
|
274
|
+
*
|
|
275
|
+
* Issue #51 handling. Previously heartbeat() updated tasks.heartbeat_at only when opts.taskId was
|
|
276
|
+
* explicit. But the piggyback call at the head of each tool handler passes heartbeat(cdb, agentId)
|
|
277
|
+
* without taskId, so agents.last_seen and the claims lease extended but only the task lease did
|
|
278
|
+
* not; if after claiming you kept calling only publish_dirty etc. at intervals under
|
|
279
|
+
* TASK_LEASE_SEC without calling the heartbeat tool, the sweepExpired running at the head of
|
|
280
|
+
* another agent's tool call would return the task to open — an accident. So, to make the
|
|
281
|
+
* description "heartbeat is piggybacked onto every tool call" (proposal 4) match actual behavior,
|
|
282
|
+
* add a path that extends the lease of owned in_progress tasks even on a piggyback that passes no taskId.
|
|
283
|
+
*
|
|
284
|
+
* For operations invoked by non-owners (block_task / resume_task / handoff_task = touchAgent:false),
|
|
285
|
+
* the task lease must not be freshened (so as not to pollute blocked tasks' orphan decision). So the
|
|
286
|
+
* default is aligned to touchAgent, and if touchAgent=false, touchOwnedTasks is automatically
|
|
287
|
+
* treated as false (the simplest policy to link the owner-liveness-tied behaviors together).
|
|
288
|
+
* blocked tasks are kept indefinitely and excluded from the lease sweep, so they are intentionally
|
|
289
|
+
* excluded (not touched).
|
|
290
|
+
*/
|
|
291
|
+
touchOwnedTasks?: boolean;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The heartbeat piggybacked onto every tool call. Updates the worker's own liveness time
|
|
295
|
+
* (agents.last_seen) and the lease of held tasks/claims.
|
|
296
|
+
*
|
|
297
|
+
* The 3rd argument also accepts a taskId string for backward compatibility (= same as { taskId }).
|
|
298
|
+
* Passing touchAgent=false suppresses the extension of agents.last_seen and the claims lease
|
|
299
|
+
* (for operations invoked by non-owners = the orchestrator/human; prevents polluting orphan detection).
|
|
300
|
+
*
|
|
301
|
+
* Issue #51 handling: even on a piggyback call that passes no taskId (heartbeat(cdb, agentId)), if
|
|
302
|
+
* touchOwnedTasks (default = touchAgent) is true, extend the heartbeat_at of all in_progress tasks
|
|
303
|
+
* assigned to that agent to now. This way the task lease is maintained by the piggyback of each tool
|
|
304
|
+
* call alone, even without calling the heartbeat tool (with taskId), and prevents wrongful
|
|
305
|
+
* reclamation by sweepExpired.
|
|
306
|
+
*/
|
|
307
|
+
export declare function heartbeat(cdb: CoordDb, agentId: string, opts?: string | HeartbeatOpts): void;
|