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,247 @@
|
|
|
1
|
+
import { type CoordDb } from '../db/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* §2.4 asynchronous board (board) + §4.8 A2A wake (long-poll).
|
|
4
|
+
*
|
|
5
|
+
* - board_messages: the shared board / directed handoff. Diff-fetched by seq (a monotonically increasing cursor).
|
|
6
|
+
* - wakes: a pending queue for transient resume signals such as overlap (not left on the board).
|
|
7
|
+
* - wait_for_unblock: a long-poll that blocks in a single call until one of "waited task ready /
|
|
8
|
+
* board addressed to self / overlap / timeout". Removes client-side polling (A2A wake).
|
|
9
|
+
*/
|
|
10
|
+
export interface BoardMessage {
|
|
11
|
+
id: string;
|
|
12
|
+
seq: number;
|
|
13
|
+
author: string;
|
|
14
|
+
to_agent: string | null;
|
|
15
|
+
kind: 'note' | 'handoff' | 'question' | 'ticket_ready' | 'pr_opened' | 'pr_updated' | 'pr_merged';
|
|
16
|
+
body: string;
|
|
17
|
+
ref_task_id: string | null;
|
|
18
|
+
ref_ticket_id: string | null;
|
|
19
|
+
resolved_at: number | null;
|
|
20
|
+
resolved_by: string | null;
|
|
21
|
+
created_at: number;
|
|
22
|
+
host_id: string;
|
|
23
|
+
origin: string;
|
|
24
|
+
}
|
|
25
|
+
export interface WakeRow {
|
|
26
|
+
id: string;
|
|
27
|
+
target: string;
|
|
28
|
+
kind: 'overlap' | 'task_ready' | 'board';
|
|
29
|
+
payload: string | null;
|
|
30
|
+
created_at: number;
|
|
31
|
+
consumed_at: number | null;
|
|
32
|
+
host_id: string;
|
|
33
|
+
origin: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PostBoardArgs {
|
|
36
|
+
agentId: string;
|
|
37
|
+
kind?: 'note' | 'handoff' | 'question' | 'ticket_ready' | 'pr_opened' | 'pr_updated' | 'pr_merged';
|
|
38
|
+
body: string;
|
|
39
|
+
toAgent?: string | null;
|
|
40
|
+
refTaskId?: string | null;
|
|
41
|
+
refTicketId?: string | null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* §2.4 post_board — post to the shared board. Specifying to_agent makes it a directed handoff.
|
|
45
|
+
* seq is assigned as MAX+1 within BEGIN IMMEDIATE (the discipline shared by board and merge_queue).
|
|
46
|
+
* handoff/question wake the target agent via wait_for_unblock's board path.
|
|
47
|
+
*/
|
|
48
|
+
export declare function postBoard(cdb: CoordDb, args: PostBoardArgs): {
|
|
49
|
+
message_id: string;
|
|
50
|
+
};
|
|
51
|
+
/** The return of resolveQuestion. Aligned with the existing tools' `{ ok: ... }` / `{ ok:false, reason }` convention. */
|
|
52
|
+
export type ResolveQuestionResult = {
|
|
53
|
+
ok: true;
|
|
54
|
+
already_resolved: boolean;
|
|
55
|
+
} | {
|
|
56
|
+
ok: false;
|
|
57
|
+
reason: 'not_found' | 'not_question';
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Issue #49 manual resolution (complement, option A) — resolve_question.
|
|
61
|
+
* Mark a question resolved. As a complement to auto-resolution (when a task reaches done/abandoned,
|
|
62
|
+
* auto-resolve the unresolved questions that have that task as ref_task_id), a path to explicitly
|
|
63
|
+
* close a question with no ref_task_id, or a question a human wants to close with just an answer
|
|
64
|
+
* without resuming the task.
|
|
65
|
+
*
|
|
66
|
+
* Within BEGIN IMMEDIATE, check the target row and:
|
|
67
|
+
* - a nonexistent message_id → { ok:false, reason:'not_found' }
|
|
68
|
+
* - kind !== 'question' → { ok:false, reason:'not_question' }
|
|
69
|
+
* - already resolved_at IS NOT NULL → no-op, { ok:true, already_resolved:true } (idempotent)
|
|
70
|
+
* - otherwise → UPDATE resolved_at=unixNow(), resolved_by=agentId and
|
|
71
|
+
* { ok:true, already_resolved:false }
|
|
72
|
+
* Aligned with the same try/catch + ROLLBACK pattern as postBoard / takePendingWakes.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveQuestion(cdb: CoordDb, args: {
|
|
75
|
+
agentId: string;
|
|
76
|
+
messageId: string;
|
|
77
|
+
}): ResolveQuestionResult;
|
|
78
|
+
/**
|
|
79
|
+
* Issue #49 auto-resolution (main path) — when a task drops to done or abandoned, mark the
|
|
80
|
+
* unresolved questions (resolved_at IS NULL) that have that task as ref_task_id resolved, together.
|
|
81
|
+
*
|
|
82
|
+
* Only unresolved ones are targeted, so it is idempotent (even if called twice, already-resolved rows
|
|
83
|
+
* are filtered out by WHERE and it is a no-op). A single UPDATE, so no BEGIN IMMEDIATE here (to not
|
|
84
|
+
* nest if the caller invokes it within its own transaction; the current callers finalizeMergedTask /
|
|
85
|
+
* releaseTask invoke it outside a transaction).
|
|
86
|
+
*
|
|
87
|
+
* The basis for resolvedBy: the done path is the assignee that confirmed completion (finalizeMergedTask's `by`),
|
|
88
|
+
* the abandoned path is the releasing subject (releaseTask's agentId). Both represent "the subject that answered and closed the task".
|
|
89
|
+
*
|
|
90
|
+
* @returns resolved = the number of questions resolved this time (for the caller's logs).
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveQuestionsForTask(cdb: CoordDb, args: {
|
|
93
|
+
taskId: string;
|
|
94
|
+
resolvedBy: string;
|
|
95
|
+
}): {
|
|
96
|
+
resolved: number;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* §2.4 read_board — read the board. Diff-fetched by since (seq cursor).
|
|
100
|
+
* When to_agent is specified, return "addressed to self (to_agent) + addressed to everyone (NULL)".
|
|
101
|
+
*/
|
|
102
|
+
export declare function readBoard(cdb: CoordDb, args?: {
|
|
103
|
+
since?: number;
|
|
104
|
+
toAgent?: string;
|
|
105
|
+
kind?: BoardMessage['kind'];
|
|
106
|
+
limit?: number;
|
|
107
|
+
unresolvedOnly?: boolean;
|
|
108
|
+
}): {
|
|
109
|
+
messages: BoardMessage[];
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The read-all limit for the board. The board is a cumulative log whose rows remain even after
|
|
113
|
+
* ticket_ready / pr_opened / pr_merged are reconciled, so this is the limit for both the publisher's
|
|
114
|
+
* observation (computePublisherStatus) and execution (publishReadyTickets / reconcileMergedPrs) paths
|
|
115
|
+
* to read "the same reference range = all, up to a realistic count ceiling". This prevents the latest
|
|
116
|
+
* ticket_ready from spilling out of the default limit=100 (seq ascending = the oldest 100) window.
|
|
117
|
+
* On a Phase 1 single host, 1 million is enough (another operational measure kicks in before board
|
|
118
|
+
* rows pile up this far). Since-cursor paging is not adopted: the board has no concept of a
|
|
119
|
+
* reconciliation cursor, and adopting it would require changing the reconciliation model itself. That
|
|
120
|
+
* is excessive for Phase 1, so align to the simple read-all approach.
|
|
121
|
+
*/
|
|
122
|
+
export declare const BOARD_READ_ALL_LIMIT = 1000000;
|
|
123
|
+
/** One entry of publisher_status: display info for an unprocessed (awaiting PR) ticket. */
|
|
124
|
+
export interface PendingTicket {
|
|
125
|
+
/** The id of the ticket ticket_ready points at (board_messages.ref_ticket_id). */
|
|
126
|
+
ref_ticket_id: string;
|
|
127
|
+
/** The ticket's integration branch name (the publisher pushes it). Supplemented from the ticket. null if not generated/absent. */
|
|
128
|
+
integration_branch: string | null;
|
|
129
|
+
}
|
|
130
|
+
/** The observed publisher state returned by publisher_status. */
|
|
131
|
+
export interface PublisherStatus {
|
|
132
|
+
/**
|
|
133
|
+
* The list of tickets purely awaiting a PR (= ticket_ready was emitted but there is no
|
|
134
|
+
* corresponding pr_opened at all). For backward compatibility this field keeps its original meaning
|
|
135
|
+
* of "tickets with no pr_opened" only.
|
|
136
|
+
*/
|
|
137
|
+
pending_tickets: PendingTicket[];
|
|
138
|
+
/** pending_tickets.length. The count purely awaiting a PR. */
|
|
139
|
+
pending_count: number;
|
|
140
|
+
/**
|
|
141
|
+
* The list of tickets awaiting an update (= already pr_opened, but a newer ticket_ready was stacked
|
|
142
|
+
* later and it has not been follow-up pushed yet). After re-arming, a "follow-up push wait" where
|
|
143
|
+
* ticket_ready is stacked "after the latest pr_opened / pr_updated (larger seq)" qualifies. When the
|
|
144
|
+
* follow-up push succeeds, the publisher leaves pr_updated on the board, whose seq exceeds
|
|
145
|
+
* ticket_ready and drops it from this list (= a finished ticket does not linger in observation).
|
|
146
|
+
* Computed by the same seq comparison that publishReadyTickets uses to decide an existing PR should
|
|
147
|
+
* be follow-up pushed. Already pr_merged (after a merge there is no integration branch and a
|
|
148
|
+
* follow-up push is impossible) and closed tickets are excluded.
|
|
149
|
+
*/
|
|
150
|
+
pending_update_tickets: PendingTicket[];
|
|
151
|
+
/** pending_update_tickets.length. The count awaiting a follow-up push (awaiting an existing PR's update). */
|
|
152
|
+
pending_update_count: number;
|
|
153
|
+
/**
|
|
154
|
+
* The list of tickets awaiting a merge (= already pr_opened but no corresponding pr_merged yet).
|
|
155
|
+
* A "cleanup wait" where the PR was created but not yet merged/reconciled on GitHub qualifies.
|
|
156
|
+
* Without changing pending_count's (purely awaiting a PR) meaning, this surfaces "PR created,
|
|
157
|
+
* awaiting merge" as a separate axis. When this is >0, the agent runs
|
|
158
|
+
* `lockage-publish --mode reconcile` to reclaim merged PRs.
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ The 3 axes (pending / pending_update / awaiting_merge) are not mutually exclusive. They are
|
|
161
|
+
* observation "separate axes", not a partition of tickets, so simply summing counts like
|
|
162
|
+
* `pending_count + pending_update_count + awaiting_merge_count` double-counts (it is not the unique
|
|
163
|
+
* ticket count). The relationship of each axis's population:
|
|
164
|
+
* - pending : no pr_opened at all → mutually exclusive with awaiting_merge (has pr_opened).
|
|
165
|
+
* - pending_update : has pr_opened ∧ a subsequent ticket_ready was stacked (awaiting a follow-up push).
|
|
166
|
+
* - awaiting_merge : has pr_opened ∧ no pr_merged yet (awaiting merge/cleanup).
|
|
167
|
+
* pending_update and awaiting_merge have overlapping populations, and the same ticket can appear in
|
|
168
|
+
* both at once (a ticket already pr_opened, with a subsequent ticket_ready, and unmerged). Use each axis independently for decisions.
|
|
169
|
+
*/
|
|
170
|
+
awaiting_merge_tickets: PendingTicket[];
|
|
171
|
+
/** awaiting_merge_tickets.length. The count awaiting a merge (pr_opened ∧ ¬pr_merged). Not mutually exclusive with pending/pending_update (see the note above). */
|
|
172
|
+
awaiting_merge_count: number;
|
|
173
|
+
/** The latest created_at (unix sec) of pr_opened. null if there is none. */
|
|
174
|
+
last_pr_opened_at: number | null;
|
|
175
|
+
/** The latest created_at (unix sec) of pr_merged. null if there is none. */
|
|
176
|
+
last_pr_merged_at: number | null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Fix proposal B (F1 / F3) — observe the publisher's liveness, last-run traces, and unprocessed
|
|
180
|
+
* ticket_ready from the board. Does not start publish (observation only).
|
|
181
|
+
*
|
|
182
|
+
* A pure function computed only from board_messages (kind=ticket_ready / pr_opened / pr_merged), with no
|
|
183
|
+
* side effects (read only). Adds no new table.
|
|
184
|
+
*
|
|
185
|
+
* What it computes:
|
|
186
|
+
* (1) pending_tickets = the set of tickets that have a ticket_ready but no pr_opened for the same
|
|
187
|
+
* ref_ticket_id ever (= purely waiting to become a PR). Each ticket is bundled with
|
|
188
|
+
* integration_branch filled in from the ticket table (this is a core DB read, not starting publish).
|
|
189
|
+
* (2) pending_update_tickets = the set of tickets that already have pr_opened but a newer ticket_ready
|
|
190
|
+
* was stacked afterward and has not yet been follow-up pushed (= latest ticket_ready seq > max(latest
|
|
191
|
+
* pr_opened, latest pr_updated) seq) (= waiting for a follow-up push / waiting to update an existing
|
|
192
|
+
* PR). When a follow-up push succeeds the publisher leaves a pr_updated, so its seq exceeds
|
|
193
|
+
* ticket_ready and it drops from this set thereafter (completed tickets do not keep showing up in
|
|
194
|
+
* observation). Computed with the same seq comparison publishReadyTickets uses to decide an existing
|
|
195
|
+
* PR should be updated by a follow-up push, closing the observation blind spot where it looks like 0
|
|
196
|
+
* unprocessed just because pr_opened exists. Note that already-pr_merged (after merge there is no
|
|
197
|
+
* integration branch and no follow-up push is possible) and already-closed tickets are excluded from
|
|
198
|
+
* this set (like awaiting_merge, do not keep "finished work" waiting for an update).
|
|
199
|
+
* (3) awaiting_merge_tickets = the set of tickets that already have pr_opened but no pr_merged yet (=
|
|
200
|
+
* waiting for merge / cleanup). (1)(2)(3) are different observation axes and not exclusive: a simple
|
|
201
|
+
* sum of counts double-counts (see the note on PublisherStatus's awaiting_merge_tickets). In
|
|
202
|
+
* particular (2) and (3) have overlapping populations and the same ticket can appear in both.
|
|
203
|
+
* (4) last_pr_opened_at / last_pr_merged_at = the latest created_at of pr_opened / pr_merged (null if none).
|
|
204
|
+
* (5) pending_count = pending_tickets.length / pending_update_count = pending_update_tickets.length /
|
|
205
|
+
* awaiting_merge_count = awaiting_merge_tickets.length.
|
|
206
|
+
*/
|
|
207
|
+
export declare function computePublisherStatus(cdb: CoordDb): PublisherStatus;
|
|
208
|
+
/** The current maximum seq of the board (the initial cursor value for wait_for_unblock). */
|
|
209
|
+
export declare function currentMaxBoardSeq(cdb: CoordDb): number;
|
|
210
|
+
/**
|
|
211
|
+
* §4.8 wake — push a transient resume signal onto the pending queue.
|
|
212
|
+
* Used for notifications we do not want to leave on the board, such as overlap (someone else steps into my area).
|
|
213
|
+
* Called from publishDirty / the merge gate's needs_review, connecting the existing wake_targets enumeration to real delivery.
|
|
214
|
+
*/
|
|
215
|
+
export declare function wake(cdb: CoordDb, target: string, kind: WakeRow['kind'], payload?: Record<string, unknown>): void;
|
|
216
|
+
/**
|
|
217
|
+
* §4.8 take_pending_wakes — read pending wakes and mark them consumed (consume them).
|
|
218
|
+
* BEGIN IMMEDIATE serializes "read and immediately mark consumed" so the same wake is not consumed twice.
|
|
219
|
+
*/
|
|
220
|
+
export declare function takePendingWakes(cdb: CoordDb, target: string, kind: WakeRow['kind']): WakeRow[];
|
|
221
|
+
/**
|
|
222
|
+
* §4.8 is_ready — walk task_edges (blocks) and decide whether "every from_task that blocks me is done".
|
|
223
|
+
* ready if there are no block edges.
|
|
224
|
+
*/
|
|
225
|
+
export declare function isReady(cdb: CoordDb, taskId: string): boolean;
|
|
226
|
+
export type WokeBy = 'task_ready' | 'board' | 'overlap' | 'timeout';
|
|
227
|
+
export interface UnblockResult {
|
|
228
|
+
woke_by: WokeBy;
|
|
229
|
+
payload?: unknown;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* §4.8 wait_for_unblock — long-poll. In a single call, block until the event that should wake it occurs.
|
|
233
|
+
* Internally it short-interval polls (sufficient for Phase 1 / a single process). Client-side polling disappears.
|
|
234
|
+
*
|
|
235
|
+
* a) the waited task became ready (all deps done) → woke_by:"task_ready"
|
|
236
|
+
* b) a new board message for me or for everyone arrived → woke_by:"board"
|
|
237
|
+
* c) overlap in my area (someone else's dirty/claim overlaps) → woke_by:"overlap"
|
|
238
|
+
* d) timeout → woke_by:"timeout"
|
|
239
|
+
*
|
|
240
|
+
* @param pollMs internal polling interval (shortenable in tests; defaults to 500ms).
|
|
241
|
+
*/
|
|
242
|
+
export declare function waitForUnblock(cdb: CoordDb, args: {
|
|
243
|
+
agentId: string;
|
|
244
|
+
taskId?: string;
|
|
245
|
+
timeoutSec?: number;
|
|
246
|
+
pollMs?: number;
|
|
247
|
+
}): Promise<UnblockResult>;
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { ulid } from 'ulid';
|
|
2
|
+
import { unixNow } from '../db/index.js';
|
|
3
|
+
import { sweepExpired } from './claim.js';
|
|
4
|
+
/**
|
|
5
|
+
* §2.4 post_board — post to the shared board. Specifying to_agent makes it a directed handoff.
|
|
6
|
+
* seq is assigned as MAX+1 within BEGIN IMMEDIATE (the discipline shared by board and merge_queue).
|
|
7
|
+
* handoff/question wake the target agent via wait_for_unblock's board path.
|
|
8
|
+
*/
|
|
9
|
+
export function postBoard(cdb, args) {
|
|
10
|
+
const db = cdb.db;
|
|
11
|
+
const now = unixNow();
|
|
12
|
+
const id = ulid();
|
|
13
|
+
const kind = args.kind ?? 'note';
|
|
14
|
+
db.exec('BEGIN IMMEDIATE');
|
|
15
|
+
try {
|
|
16
|
+
const row = db
|
|
17
|
+
.prepare(`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM board_messages`)
|
|
18
|
+
.get();
|
|
19
|
+
db.prepare(`INSERT INTO board_messages
|
|
20
|
+
(id, seq, author, to_agent, kind, body, ref_task_id, ref_ticket_id, created_at, host_id, origin)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')`).run(id, row.next, args.agentId, args.toAgent ?? null, kind, args.body, args.refTaskId ?? null, args.refTicketId ?? null, now, cdb.hostId);
|
|
22
|
+
db.exec('COMMIT');
|
|
23
|
+
return { message_id: id };
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
try {
|
|
27
|
+
db.exec('ROLLBACK');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* already closed */
|
|
31
|
+
}
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Issue #49 manual resolution (complement, option A) — resolve_question.
|
|
37
|
+
* Mark a question resolved. As a complement to auto-resolution (when a task reaches done/abandoned,
|
|
38
|
+
* auto-resolve the unresolved questions that have that task as ref_task_id), a path to explicitly
|
|
39
|
+
* close a question with no ref_task_id, or a question a human wants to close with just an answer
|
|
40
|
+
* without resuming the task.
|
|
41
|
+
*
|
|
42
|
+
* Within BEGIN IMMEDIATE, check the target row and:
|
|
43
|
+
* - a nonexistent message_id → { ok:false, reason:'not_found' }
|
|
44
|
+
* - kind !== 'question' → { ok:false, reason:'not_question' }
|
|
45
|
+
* - already resolved_at IS NOT NULL → no-op, { ok:true, already_resolved:true } (idempotent)
|
|
46
|
+
* - otherwise → UPDATE resolved_at=unixNow(), resolved_by=agentId and
|
|
47
|
+
* { ok:true, already_resolved:false }
|
|
48
|
+
* Aligned with the same try/catch + ROLLBACK pattern as postBoard / takePendingWakes.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveQuestion(cdb, args) {
|
|
51
|
+
const db = cdb.db;
|
|
52
|
+
const now = unixNow();
|
|
53
|
+
db.exec('BEGIN IMMEDIATE');
|
|
54
|
+
try {
|
|
55
|
+
const row = db
|
|
56
|
+
.prepare(`SELECT kind, resolved_at FROM board_messages WHERE id = ?`)
|
|
57
|
+
.get(args.messageId);
|
|
58
|
+
if (!row) {
|
|
59
|
+
db.exec('ROLLBACK');
|
|
60
|
+
return { ok: false, reason: 'not_found' };
|
|
61
|
+
}
|
|
62
|
+
if (row.kind !== 'question') {
|
|
63
|
+
db.exec('ROLLBACK');
|
|
64
|
+
return { ok: false, reason: 'not_question' };
|
|
65
|
+
}
|
|
66
|
+
if (row.resolved_at !== null) {
|
|
67
|
+
// Idempotent: if already resolved, do nothing and return success.
|
|
68
|
+
db.exec('COMMIT');
|
|
69
|
+
return { ok: true, already_resolved: true };
|
|
70
|
+
}
|
|
71
|
+
db.prepare(`UPDATE board_messages SET resolved_at = ?, resolved_by = ? WHERE id = ?`).run(now, args.agentId, args.messageId);
|
|
72
|
+
db.exec('COMMIT');
|
|
73
|
+
return { ok: true, already_resolved: false };
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
try {
|
|
77
|
+
db.exec('ROLLBACK');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* already closed */
|
|
81
|
+
}
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Issue #49 auto-resolution (main path) — when a task drops to done or abandoned, mark the
|
|
87
|
+
* unresolved questions (resolved_at IS NULL) that have that task as ref_task_id resolved, together.
|
|
88
|
+
*
|
|
89
|
+
* Only unresolved ones are targeted, so it is idempotent (even if called twice, already-resolved rows
|
|
90
|
+
* are filtered out by WHERE and it is a no-op). A single UPDATE, so no BEGIN IMMEDIATE here (to not
|
|
91
|
+
* nest if the caller invokes it within its own transaction; the current callers finalizeMergedTask /
|
|
92
|
+
* releaseTask invoke it outside a transaction).
|
|
93
|
+
*
|
|
94
|
+
* The basis for resolvedBy: the done path is the assignee that confirmed completion (finalizeMergedTask's `by`),
|
|
95
|
+
* the abandoned path is the releasing subject (releaseTask's agentId). Both represent "the subject that answered and closed the task".
|
|
96
|
+
*
|
|
97
|
+
* @returns resolved = the number of questions resolved this time (for the caller's logs).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveQuestionsForTask(cdb, args) {
|
|
100
|
+
const info = cdb.db
|
|
101
|
+
.prepare(`UPDATE board_messages
|
|
102
|
+
SET resolved_at = ?, resolved_by = ?
|
|
103
|
+
WHERE ref_task_id = ? AND kind = 'question' AND resolved_at IS NULL`)
|
|
104
|
+
.run(unixNow(), args.resolvedBy, args.taskId);
|
|
105
|
+
return { resolved: Number(info.changes) };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* §2.4 read_board — read the board. Diff-fetched by since (seq cursor).
|
|
109
|
+
* When to_agent is specified, return "addressed to self (to_agent) + addressed to everyone (NULL)".
|
|
110
|
+
*/
|
|
111
|
+
export function readBoard(cdb, args = {}) {
|
|
112
|
+
const since = args.since ?? 0;
|
|
113
|
+
const limit = args.limit ?? 100;
|
|
114
|
+
const clauses = ['seq > ?'];
|
|
115
|
+
const params = [since];
|
|
116
|
+
if (args.toAgent) {
|
|
117
|
+
clauses.push('(to_agent = ? OR to_agent IS NULL)');
|
|
118
|
+
params.push(args.toAgent);
|
|
119
|
+
}
|
|
120
|
+
if (args.kind) {
|
|
121
|
+
clauses.push('kind = ?');
|
|
122
|
+
params.push(args.kind);
|
|
123
|
+
}
|
|
124
|
+
// Issue #49: unresolved only (resolved_at IS NULL). Kinds other than question always keep
|
|
125
|
+
// resolved_at NULL, so this condition effectively becomes "unresolved questions + all non-questions" (harmless). Not added to params.
|
|
126
|
+
if (args.unresolvedOnly) {
|
|
127
|
+
clauses.push('resolved_at IS NULL');
|
|
128
|
+
}
|
|
129
|
+
const rows = cdb.db
|
|
130
|
+
.prepare(`SELECT * FROM board_messages WHERE ${clauses.join(' AND ')} ORDER BY seq ASC LIMIT ?`)
|
|
131
|
+
.all(...params, limit);
|
|
132
|
+
return { messages: rows };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The read-all limit for the board. The board is a cumulative log whose rows remain even after
|
|
136
|
+
* ticket_ready / pr_opened / pr_merged are reconciled, so this is the limit for both the publisher's
|
|
137
|
+
* observation (computePublisherStatus) and execution (publishReadyTickets / reconcileMergedPrs) paths
|
|
138
|
+
* to read "the same reference range = all, up to a realistic count ceiling". This prevents the latest
|
|
139
|
+
* ticket_ready from spilling out of the default limit=100 (seq ascending = the oldest 100) window.
|
|
140
|
+
* On a Phase 1 single host, 1 million is enough (another operational measure kicks in before board
|
|
141
|
+
* rows pile up this far). Since-cursor paging is not adopted: the board has no concept of a
|
|
142
|
+
* reconciliation cursor, and adopting it would require changing the reconciliation model itself. That
|
|
143
|
+
* is excessive for Phase 1, so align to the simple read-all approach.
|
|
144
|
+
*/
|
|
145
|
+
export const BOARD_READ_ALL_LIMIT = 1_000_000;
|
|
146
|
+
/**
|
|
147
|
+
* Fix proposal B (F1 / F3) — observe the publisher's liveness, last-run traces, and unprocessed
|
|
148
|
+
* ticket_ready from the board. Does not start publish (observation only).
|
|
149
|
+
*
|
|
150
|
+
* A pure function computed only from board_messages (kind=ticket_ready / pr_opened / pr_merged), with no
|
|
151
|
+
* side effects (read only). Adds no new table.
|
|
152
|
+
*
|
|
153
|
+
* What it computes:
|
|
154
|
+
* (1) pending_tickets = the set of tickets that have a ticket_ready but no pr_opened for the same
|
|
155
|
+
* ref_ticket_id ever (= purely waiting to become a PR). Each ticket is bundled with
|
|
156
|
+
* integration_branch filled in from the ticket table (this is a core DB read, not starting publish).
|
|
157
|
+
* (2) pending_update_tickets = the set of tickets that already have pr_opened but a newer ticket_ready
|
|
158
|
+
* was stacked afterward and has not yet been follow-up pushed (= latest ticket_ready seq > max(latest
|
|
159
|
+
* pr_opened, latest pr_updated) seq) (= waiting for a follow-up push / waiting to update an existing
|
|
160
|
+
* PR). When a follow-up push succeeds the publisher leaves a pr_updated, so its seq exceeds
|
|
161
|
+
* ticket_ready and it drops from this set thereafter (completed tickets do not keep showing up in
|
|
162
|
+
* observation). Computed with the same seq comparison publishReadyTickets uses to decide an existing
|
|
163
|
+
* PR should be updated by a follow-up push, closing the observation blind spot where it looks like 0
|
|
164
|
+
* unprocessed just because pr_opened exists. Note that already-pr_merged (after merge there is no
|
|
165
|
+
* integration branch and no follow-up push is possible) and already-closed tickets are excluded from
|
|
166
|
+
* this set (like awaiting_merge, do not keep "finished work" waiting for an update).
|
|
167
|
+
* (3) awaiting_merge_tickets = the set of tickets that already have pr_opened but no pr_merged yet (=
|
|
168
|
+
* waiting for merge / cleanup). (1)(2)(3) are different observation axes and not exclusive: a simple
|
|
169
|
+
* sum of counts double-counts (see the note on PublisherStatus's awaiting_merge_tickets). In
|
|
170
|
+
* particular (2) and (3) have overlapping populations and the same ticket can appear in both.
|
|
171
|
+
* (4) last_pr_opened_at / last_pr_merged_at = the latest created_at of pr_opened / pr_merged (null if none).
|
|
172
|
+
* (5) pending_count = pending_tickets.length / pending_update_count = pending_update_tickets.length /
|
|
173
|
+
* awaiting_merge_count = awaiting_merge_tickets.length.
|
|
174
|
+
*/
|
|
175
|
+
export function computePublisherStatus(cdb) {
|
|
176
|
+
// Read all rows from the board by kind (BOARD_READ_ALL_LIMIT picks up everything unprocessed).
|
|
177
|
+
const ticketReady = readBoard(cdb, { kind: 'ticket_ready', limit: BOARD_READ_ALL_LIMIT }).messages;
|
|
178
|
+
const prOpened = readBoard(cdb, { kind: 'pr_opened', limit: BOARD_READ_ALL_LIMIT }).messages;
|
|
179
|
+
const prUpdated = readBoard(cdb, { kind: 'pr_updated', limit: BOARD_READ_ALL_LIMIT }).messages;
|
|
180
|
+
const prMerged = readBoard(cdb, { kind: 'pr_merged', limit: BOARD_READ_ALL_LIMIT }).messages;
|
|
181
|
+
// The set of tickets cleared by pr_opened (only those that carry a ref_ticket_id).
|
|
182
|
+
const openedTicketIds = new Set();
|
|
183
|
+
for (const m of prOpened) {
|
|
184
|
+
if (m.ref_ticket_id)
|
|
185
|
+
openedTicketIds.add(m.ref_ticket_id);
|
|
186
|
+
}
|
|
187
|
+
// The set of already-pr_merged tickets (= cleanup complete. drop from waiting-for-merge).
|
|
188
|
+
const mergedTicketIds = new Set();
|
|
189
|
+
for (const m of prMerged) {
|
|
190
|
+
if (m.ref_ticket_id)
|
|
191
|
+
mergedTicketIds.add(m.ref_ticket_id);
|
|
192
|
+
}
|
|
193
|
+
// The latest seq per ticket (ticket_ready / pr_opened / pr_updated). Kept to decide, with the same seq
|
|
194
|
+
// comparison idea as publishReadyTickets, "a ticket_ready re-emitted after max(pr_opened, pr_updated) (=
|
|
195
|
+
// waiting for a follow-up push)". When a follow-up push succeeds the pr_updated seq rises, so completed
|
|
196
|
+
// tickets drop from this decision.
|
|
197
|
+
const latestReadySeq = latestSeqByTicket(ticketReady);
|
|
198
|
+
const latestOpenedSeq = latestSeqByTicket(prOpened);
|
|
199
|
+
const latestUpdatedSeq = latestSeqByTicket(prUpdated);
|
|
200
|
+
// The per-ticket "seq last reflected into the PR" = max(latest pr_opened, latest pr_updated).
|
|
201
|
+
const latestPrSeq = (id) => Math.max(latestOpenedSeq.get(id) ?? 0, latestUpdatedSeq.get(id) ?? 0);
|
|
202
|
+
// The set of closed tickets (closed by reconcile after merge. drop from waiting-for-update).
|
|
203
|
+
const closedTicketIds = new Set();
|
|
204
|
+
for (const r of cdb.db
|
|
205
|
+
.prepare(`SELECT id FROM tickets WHERE status = 'closed'`)
|
|
206
|
+
.all()) {
|
|
207
|
+
closedTicketIds.add(r.id);
|
|
208
|
+
}
|
|
209
|
+
// Tickets that have a ticket_ready but were never cleared by pr_opened (= purely waiting to become a PR; dedup).
|
|
210
|
+
const pendingIds = [];
|
|
211
|
+
// Tickets that already have pr_opened but a newer ticket_ready was stacked afterward (= waiting for a follow-up push; dedup).
|
|
212
|
+
const pendingUpdateIds = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
for (const m of ticketReady) {
|
|
215
|
+
const id = m.ref_ticket_id;
|
|
216
|
+
if (!id)
|
|
217
|
+
continue;
|
|
218
|
+
if (seen.has(id))
|
|
219
|
+
continue;
|
|
220
|
+
seen.add(id);
|
|
221
|
+
if (!openedTicketIds.has(id)) {
|
|
222
|
+
pendingIds.push(id); // never pr_opened = purely waiting to become a PR
|
|
223
|
+
}
|
|
224
|
+
else if (
|
|
225
|
+
// already-pr_merged / closed is "finished work" = no follow-up push possible (no integration branch), so exclude.
|
|
226
|
+
!mergedTicketIds.has(id) &&
|
|
227
|
+
!closedTicketIds.has(id) &&
|
|
228
|
+
(latestReadySeq.get(id) ?? 0) > latestPrSeq(id)) {
|
|
229
|
+
// ticket_ready after max(pr_opened, pr_updated) = waiting for an update not yet follow-up pushed.
|
|
230
|
+
pendingUpdateIds.push(id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Waiting for merge (= tickets that already have pr_opened but no pr_merged; dedup).
|
|
234
|
+
// An axis independent of pending/pending_update. The PR was created but not yet merged/reconciled ("waiting for cleanup").
|
|
235
|
+
const awaitingMergeIds = [];
|
|
236
|
+
const awaitingSeen = new Set();
|
|
237
|
+
for (const m of prOpened) {
|
|
238
|
+
const id = m.ref_ticket_id;
|
|
239
|
+
if (!id)
|
|
240
|
+
continue;
|
|
241
|
+
if (awaitingSeen.has(id))
|
|
242
|
+
continue;
|
|
243
|
+
awaitingSeen.add(id);
|
|
244
|
+
if (!mergedTicketIds.has(id))
|
|
245
|
+
awaitingMergeIds.push(id); // pr_opened ∧ ¬pr_merged
|
|
246
|
+
}
|
|
247
|
+
// Fill in integration_branch from the ticket table (not obtainable from the board alone).
|
|
248
|
+
const toPendingTickets = (ids) => ids.map((id) => {
|
|
249
|
+
const row = cdb.db
|
|
250
|
+
.prepare('SELECT integration_branch FROM tickets WHERE id = ?')
|
|
251
|
+
.get(id);
|
|
252
|
+
return { ref_ticket_id: id, integration_branch: row?.integration_branch ?? null };
|
|
253
|
+
});
|
|
254
|
+
const pending_tickets = toPendingTickets(pendingIds);
|
|
255
|
+
const pending_update_tickets = toPendingTickets(pendingUpdateIds);
|
|
256
|
+
const awaiting_merge_tickets = toPendingTickets(awaitingMergeIds);
|
|
257
|
+
// The latest created_at of each kind (readBoard is seq ascending, so the last element is the newest).
|
|
258
|
+
const lastCreatedAt = (msgs) => msgs.length > 0 ? msgs[msgs.length - 1].created_at : null;
|
|
259
|
+
return {
|
|
260
|
+
pending_tickets,
|
|
261
|
+
pending_count: pending_tickets.length,
|
|
262
|
+
pending_update_tickets,
|
|
263
|
+
pending_update_count: pending_update_tickets.length,
|
|
264
|
+
awaiting_merge_tickets,
|
|
265
|
+
awaiting_merge_count: awaiting_merge_tickets.length,
|
|
266
|
+
last_pr_opened_at: lastCreatedAt(prOpened),
|
|
267
|
+
last_pr_merged_at: lastCreatedAt(prMerged),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Collect the latest seq per ticket from a set of board messages (used by publisher_status's seq comparison).
|
|
272
|
+
* Same idea as publish.ts's same-named helper, but kept self-contained here because board is used by multiple modules.
|
|
273
|
+
*/
|
|
274
|
+
function latestSeqByTicket(messages) {
|
|
275
|
+
const m = new Map();
|
|
276
|
+
for (const msg of messages) {
|
|
277
|
+
if (!msg.ref_ticket_id)
|
|
278
|
+
continue;
|
|
279
|
+
const prev = m.get(msg.ref_ticket_id) ?? 0;
|
|
280
|
+
if (msg.seq > prev)
|
|
281
|
+
m.set(msg.ref_ticket_id, msg.seq);
|
|
282
|
+
}
|
|
283
|
+
return m;
|
|
284
|
+
}
|
|
285
|
+
/** The current maximum seq of the board (the initial cursor value for wait_for_unblock). */
|
|
286
|
+
export function currentMaxBoardSeq(cdb) {
|
|
287
|
+
const row = cdb.db
|
|
288
|
+
.prepare(`SELECT COALESCE(MAX(seq), 0) AS m FROM board_messages`)
|
|
289
|
+
.get();
|
|
290
|
+
return row.m;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* §4.8 wake — push a transient resume signal onto the pending queue.
|
|
294
|
+
* Used for notifications we do not want to leave on the board, such as overlap (someone else steps into my area).
|
|
295
|
+
* Called from publishDirty / the merge gate's needs_review, connecting the existing wake_targets enumeration to real delivery.
|
|
296
|
+
*/
|
|
297
|
+
export function wake(cdb, target, kind, payload) {
|
|
298
|
+
cdb.db
|
|
299
|
+
.prepare(`INSERT INTO wakes (id, target, kind, payload, created_at, consumed_at, host_id, origin)
|
|
300
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, 'local')`)
|
|
301
|
+
.run(ulid(), target, kind, payload ? JSON.stringify(payload) : null, unixNow(), cdb.hostId);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* §4.8 take_pending_wakes — read pending wakes and mark them consumed (consume them).
|
|
305
|
+
* BEGIN IMMEDIATE serializes "read and immediately mark consumed" so the same wake is not consumed twice.
|
|
306
|
+
*/
|
|
307
|
+
export function takePendingWakes(cdb, target, kind) {
|
|
308
|
+
const db = cdb.db;
|
|
309
|
+
const now = unixNow();
|
|
310
|
+
db.exec('BEGIN IMMEDIATE');
|
|
311
|
+
try {
|
|
312
|
+
const rows = db
|
|
313
|
+
.prepare(`SELECT * FROM wakes WHERE target = ? AND kind = ? AND consumed_at IS NULL ORDER BY created_at ASC`)
|
|
314
|
+
.all(target, kind);
|
|
315
|
+
if (rows.length > 0) {
|
|
316
|
+
db.prepare(`UPDATE wakes SET consumed_at = ? WHERE target = ? AND kind = ? AND consumed_at IS NULL`).run(now, target, kind);
|
|
317
|
+
}
|
|
318
|
+
db.exec('COMMIT');
|
|
319
|
+
return rows;
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
try {
|
|
323
|
+
db.exec('ROLLBACK');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* already closed */
|
|
327
|
+
}
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* §4.8 is_ready — walk task_edges (blocks) and decide whether "every from_task that blocks me is done".
|
|
333
|
+
* ready if there are no block edges.
|
|
334
|
+
*/
|
|
335
|
+
export function isReady(cdb, taskId) {
|
|
336
|
+
const blockers = cdb.db
|
|
337
|
+
.prepare(`SELECT t.status AS status
|
|
338
|
+
FROM task_edges e JOIN tasks t ON t.id = e.from_task
|
|
339
|
+
WHERE e.to_task = ? AND e.kind = 'blocks'`)
|
|
340
|
+
.all(taskId);
|
|
341
|
+
return blockers.every((b) => b.status === 'done');
|
|
342
|
+
}
|
|
343
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
344
|
+
/**
|
|
345
|
+
* §4.8 wait_for_unblock — long-poll. In a single call, block until the event that should wake it occurs.
|
|
346
|
+
* Internally it short-interval polls (sufficient for Phase 1 / a single process). Client-side polling disappears.
|
|
347
|
+
*
|
|
348
|
+
* a) the waited task became ready (all deps done) → woke_by:"task_ready"
|
|
349
|
+
* b) a new board message for me or for everyone arrived → woke_by:"board"
|
|
350
|
+
* c) overlap in my area (someone else's dirty/claim overlaps) → woke_by:"overlap"
|
|
351
|
+
* d) timeout → woke_by:"timeout"
|
|
352
|
+
*
|
|
353
|
+
* @param pollMs internal polling interval (shortenable in tests; defaults to 500ms).
|
|
354
|
+
*/
|
|
355
|
+
export async function waitForUnblock(cdb, args) {
|
|
356
|
+
const timeoutSec = args.timeoutSec ?? 60;
|
|
357
|
+
const pollMs = args.pollMs ?? 500;
|
|
358
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
359
|
+
const cursor = currentMaxBoardSeq(cdb);
|
|
360
|
+
// eslint-disable-next-line no-constant-condition
|
|
361
|
+
while (true) {
|
|
362
|
+
sweepExpired(cdb);
|
|
363
|
+
// a) did the waited task become ready (evaluated each time)? Or consume a task_ready wake if stacked
|
|
364
|
+
// (finalizeMergedTask stacks it on unblock. insurance against missing an event that occurred before the long-poll started).
|
|
365
|
+
if (args.taskId && isReady(cdb, args.taskId)) {
|
|
366
|
+
const task = cdb.db
|
|
367
|
+
.prepare(`SELECT * FROM tasks WHERE id = ?`)
|
|
368
|
+
.get(args.taskId);
|
|
369
|
+
return { woke_by: 'task_ready', payload: task };
|
|
370
|
+
}
|
|
371
|
+
const readyWakes = takePendingWakes(cdb, args.agentId, 'task_ready');
|
|
372
|
+
if (readyWakes.length > 0) {
|
|
373
|
+
return {
|
|
374
|
+
woke_by: 'task_ready',
|
|
375
|
+
payload: readyWakes.map((w) => (w.payload ? JSON.parse(w.payload) : null)),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// b) a new board message for me / for everyone (seq delta). Or consume a board wake if stacked
|
|
379
|
+
// (the merge gate's needs_review stacks it. the board_messages side can also pick it up, but insurance against misses).
|
|
380
|
+
const msgs = cdb.db
|
|
381
|
+
.prepare(`SELECT * FROM board_messages
|
|
382
|
+
WHERE seq > ? AND (to_agent = ? OR to_agent IS NULL)
|
|
383
|
+
ORDER BY seq ASC`)
|
|
384
|
+
.all(cursor, args.agentId);
|
|
385
|
+
if (msgs.length > 0)
|
|
386
|
+
return { woke_by: 'board', payload: msgs };
|
|
387
|
+
const boardWakes = takePendingWakes(cdb, args.agentId, 'board');
|
|
388
|
+
if (boardWakes.length > 0) {
|
|
389
|
+
return {
|
|
390
|
+
woke_by: 'board',
|
|
391
|
+
payload: boardWakes.map((w) => (w.payload ? JSON.parse(w.payload) : null)),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// c) overlap in my area (consume the pending wake)
|
|
395
|
+
const pending = takePendingWakes(cdb, args.agentId, 'overlap');
|
|
396
|
+
if (pending.length > 0) {
|
|
397
|
+
return {
|
|
398
|
+
woke_by: 'overlap',
|
|
399
|
+
payload: pending.map((w) => (w.payload ? JSON.parse(w.payload) : null)),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (Date.now() >= deadline)
|
|
403
|
+
return { woke_by: 'timeout' };
|
|
404
|
+
await sleep(pollMs);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
//# sourceMappingURL=board.js.map
|