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,229 @@
|
|
|
1
|
+
import { type CoordDb } from '../db/index.js';
|
|
2
|
+
/** Default TTL of an advisory lease (8h; matches the schema default). */
|
|
3
|
+
export declare const DEFAULT_CLAIM_TTL_SEC: number;
|
|
4
|
+
/**
|
|
5
|
+
* The minimum interval (seconds) for throttling git reads against the same worktree in the dirty
|
|
6
|
+
* auto-compute (reconcileDirtyFromWorktrees). The default matches Issue #52 proposal 2's "e.g. 30 seconds".
|
|
7
|
+
*
|
|
8
|
+
* Since dirty recompute is called from multiple paths such as heartbeat / claim_files / task_done /
|
|
9
|
+
* publish_dirty, hitting `git status` / `git diff` every time is wasteful under high-frequency tool
|
|
10
|
+
* calls. If the latest dirty_sets.updated_at is newer than now - this many seconds, skip the git read
|
|
11
|
+
* and use the existing dirty as-is (do not break the already-published content).
|
|
12
|
+
*/
|
|
13
|
+
export declare const DIRTY_RECONCILE_MIN_INTERVAL_SEC = 30;
|
|
14
|
+
export interface ClaimRow {
|
|
15
|
+
id: string;
|
|
16
|
+
holder: string;
|
|
17
|
+
globs: string;
|
|
18
|
+
task_id: string | null;
|
|
19
|
+
lease_ttl_sec: number;
|
|
20
|
+
heartbeat_at: number;
|
|
21
|
+
expires_at: number;
|
|
22
|
+
released_at: number | null;
|
|
23
|
+
created_at: number;
|
|
24
|
+
host_id: string;
|
|
25
|
+
origin: string;
|
|
26
|
+
}
|
|
27
|
+
export interface GlobOverlap {
|
|
28
|
+
agent_id: string;
|
|
29
|
+
globs: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface FileOverlap {
|
|
32
|
+
agent_id: string;
|
|
33
|
+
files: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface ClaimFilesResult {
|
|
36
|
+
claim_id: string;
|
|
37
|
+
/** Overlap with another agent's active claim (warning only; does not block). */
|
|
38
|
+
overlaps: GlobOverlap[];
|
|
39
|
+
}
|
|
40
|
+
export interface DirtyOverlap {
|
|
41
|
+
/** Overlap between dirty sets (per file). */
|
|
42
|
+
dirty: FileOverlap[];
|
|
43
|
+
/** Overlap with an active claim (glob). */
|
|
44
|
+
claims: GlobOverlap[];
|
|
45
|
+
}
|
|
46
|
+
export interface PublishDirtyResult {
|
|
47
|
+
ok: true;
|
|
48
|
+
overlaps: DirtyOverlap;
|
|
49
|
+
/** The peers woken by an A2A wake (kind='overlap') because of an overlap. Their wait_for_unblock returns. */
|
|
50
|
+
wake_targets: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Of the dirty files, those that match none of your (agentId's) active claim globs (Issue #52 proposal 3).
|
|
53
|
+
* Lets the caller (agent) notice it is "touching outside the claim scope".
|
|
54
|
+
* If you have no claim at all, all dirty files become unclaimed.
|
|
55
|
+
*/
|
|
56
|
+
unclaimed: string[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The return of reconcileDirtyFromWorktrees (aggregated form). Each tool handler in index.ts puts it
|
|
60
|
+
* on the response. Keeps the same shape as publishDirty's result while also returning which worktrees
|
|
61
|
+
* were recomputed and the dirty_files after union.
|
|
62
|
+
*/
|
|
63
|
+
export interface ReconcileDirtyResult {
|
|
64
|
+
ok: true;
|
|
65
|
+
overlaps: DirtyOverlap;
|
|
66
|
+
/** The aggregation of peers woken by an A2A wake (kind='overlap') because of an overlap. */
|
|
67
|
+
wake_targets: string[];
|
|
68
|
+
/** Of the dirty files, those outside your active claim globs (union of all target worktrees). */
|
|
69
|
+
unclaimed: string[];
|
|
70
|
+
/** The dirty files after recompute (union of all target worktrees; normalized and deduplicated). */
|
|
71
|
+
dirty_files: string[];
|
|
72
|
+
/** The worktree paths actually git-read (does not include ones throttled by the minimum interval). */
|
|
73
|
+
reconciled_worktrees: string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* §4.3 claim_files — warn, never block (advisory).
|
|
77
|
+
*
|
|
78
|
+
* Real file conflicts are prevented by worktree physical isolation (§4.5), so this sticks to the
|
|
79
|
+
* logical visualization of "who is responsible for where". Overlaps are detected and returned to
|
|
80
|
+
* `overlaps` as a warning only, not an error. Since they auto-expire by lease (TTL+heartbeat), there
|
|
81
|
+
* is no permanent lock or deadlock (hard locks not adopted, requirement §9.2).
|
|
82
|
+
*/
|
|
83
|
+
export declare function claimFiles(cdb: CoordDb, args: {
|
|
84
|
+
agentId: string;
|
|
85
|
+
globs: string[];
|
|
86
|
+
ttlSec?: number;
|
|
87
|
+
taskId?: string | null;
|
|
88
|
+
}): ClaimFilesResult;
|
|
89
|
+
/**
|
|
90
|
+
* Explicit release of a claim (§2.3 release_claim). Only the holder can logically release (no physical DELETE = for provenance tracking).
|
|
91
|
+
*/
|
|
92
|
+
export declare function releaseClaim(cdb: CoordDb, claimId: string, agentId: string): {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Issue #45 item 1/2: in step with a task's end, logically release the active claims that have that
|
|
97
|
+
* task_id together (no physical DELETE = for provenance tracking).
|
|
98
|
+
*
|
|
99
|
+
* Regardless of holder: even after a handoff_task move to another worker, release them without
|
|
100
|
+
* leftovers as long as task_id matches (the aim is to close the structural miss of "no trigger to
|
|
101
|
+
* release", not "a specific agent's procedural omission"). Claims with task_id NULL (declarations not
|
|
102
|
+
* tied to a task) are not targeted (those are handled by release_claim and TTL).
|
|
103
|
+
*
|
|
104
|
+
* Assumed to be called from both task_done (via merge.ts's finalizeMergedTask) and release_task
|
|
105
|
+
* (claim.ts). It only sets released_at, so it does not affect already-released claims (WHERE released_at IS NULL).
|
|
106
|
+
*
|
|
107
|
+
* @returns released_count = the number of claims released.
|
|
108
|
+
*/
|
|
109
|
+
export declare function releaseClaimsForTask(cdb: CoordDb, taskId: string, now?: number): {
|
|
110
|
+
released_count: number;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Issue #45 item 3 (the claim version of the #32 worktree pattern): logically release the active
|
|
114
|
+
* claims tied to done / abandoned tasks under the specified ticket, together.
|
|
115
|
+
*
|
|
116
|
+
* A reclamation path called from reconcile (lockage-publish's MERGED path). It follows up to tidy
|
|
117
|
+
* leftovers created before item 1 (task_done) and claims of tasks that ended without going through
|
|
118
|
+
* task_done. It targets only claims tied to tasks whose tasks.status is done / abandoned, and
|
|
119
|
+
* preserves claims of still-ongoing (in_progress / blocked) or not-started (open) tasks.
|
|
120
|
+
*
|
|
121
|
+
* Logical release that only sets released_at (no physical DELETE). It does not affect
|
|
122
|
+
* already-released/expired claims (WHERE released_at IS NULL). It is idempotent on the presence of
|
|
123
|
+
* active rows, so even if reconcile runs twice it does not double-release.
|
|
124
|
+
*
|
|
125
|
+
* @param ticketId the target ticket (matched against tasks.ticket_id).
|
|
126
|
+
* @returns released_count = the number of claims released.
|
|
127
|
+
*/
|
|
128
|
+
export declare function releaseClaimsForFinishedTicketTasks(cdb: CoordDb, args: {
|
|
129
|
+
ticketId: string;
|
|
130
|
+
now?: number;
|
|
131
|
+
}): {
|
|
132
|
+
released_count: number;
|
|
133
|
+
};
|
|
134
|
+
/** The holder's active claim list. */
|
|
135
|
+
export declare function listClaims(cdb: CoordDb, holder?: string): ClaimRow[];
|
|
136
|
+
export interface DirtyRow {
|
|
137
|
+
id: string;
|
|
138
|
+
agent_id: string;
|
|
139
|
+
worktree_path: string;
|
|
140
|
+
files: string;
|
|
141
|
+
updated_at: number;
|
|
142
|
+
host_id: string;
|
|
143
|
+
origin: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A pure helper that computes, of the dirty files, those matching none of your (agentId's) active
|
|
147
|
+
* claim globs (Issue #52 proposal 3's unclaimed computation).
|
|
148
|
+
*
|
|
149
|
+
* Collect all globs of your (holder=agentId) active claims (released_at IS NULL and expires_at >=
|
|
150
|
+
* now), and use globs.ts's globsMatchAny to return "files matching none of your globs" as unclaimed.
|
|
151
|
+
* If you have no claim at all, all dirty files become unclaimed (= touching with zero scope declaration).
|
|
152
|
+
*
|
|
153
|
+
* The return is a file set normalized and deduplicated by normalizePath. A pure function (DB read only, no side effects).
|
|
154
|
+
*/
|
|
155
|
+
export declare function computeUnclaimed(cdb: CoordDb, agentId: string, files: string[], now: number): string[];
|
|
156
|
+
/**
|
|
157
|
+
* The merged result for the publish_dirty handler (overlaps / wake_targets / unclaimed only).
|
|
158
|
+
* The form that merges publishDirty's (explicit publish) result and reconcileDirtyFromWorktrees'
|
|
159
|
+
* (worktree auto-compute) result. Does not include reconcile-specific aggregates such as dirty_files
|
|
160
|
+
* (narrowed to the 3 items the handler needs to put on the response).
|
|
161
|
+
*/
|
|
162
|
+
export interface MergedDirtyResult {
|
|
163
|
+
overlaps: DirtyOverlap;
|
|
164
|
+
wake_targets: string[];
|
|
165
|
+
unclaimed: string[];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A pure helper that "merges" rather than "swallows" the explicit-publish (publishDirty) result res
|
|
169
|
+
* and the worktree auto-compute (reconcileDirtyFromWorktrees) result rc (Issue #52's regression fix).
|
|
170
|
+
*
|
|
171
|
+
* The publish_dirty handler used to overwrite res wholesale with rc, so when the worktree_path passed
|
|
172
|
+
* to publish_dirty was not in worktree_assignments' active rows (e.g. no assign_worktree), rc was
|
|
173
|
+
* empty (overlaps/wake/unclaimed all empty) and the overlap/wake/unclaimed res actually detected
|
|
174
|
+
* disappeared from the response. By unioning the two, res's detection always remains even if
|
|
175
|
+
* reconcile cannot find the worktree.
|
|
176
|
+
*
|
|
177
|
+
* - overlaps.dirty / overlaps.claims: unify both res and rc per agent_id and deduplicate files/globs
|
|
178
|
+
* (no double counting even in the normal case where both return the same overlap).
|
|
179
|
+
* - wake_targets: the union of res and rc (deduplicated).
|
|
180
|
+
* - unclaimed: the union of res and rc (deduplicated).
|
|
181
|
+
*
|
|
182
|
+
* Receives the shape applyDirtyOverlaps / computeUnclaimed return (DirtyOverlap, string[]) and
|
|
183
|
+
* returns the same shape. A pure function (DB-independent, no side effects).
|
|
184
|
+
*/
|
|
185
|
+
export declare function mergeDirtyResults(res: {
|
|
186
|
+
overlaps: DirtyOverlap;
|
|
187
|
+
wake_targets: string[];
|
|
188
|
+
unclaimed: string[];
|
|
189
|
+
}, rc: {
|
|
190
|
+
overlaps: DirtyOverlap;
|
|
191
|
+
wake_targets: string[];
|
|
192
|
+
unclaimed: string[];
|
|
193
|
+
}): MergedDirtyResult;
|
|
194
|
+
export declare function publishDirty(cdb: CoordDb, args: {
|
|
195
|
+
agentId: string;
|
|
196
|
+
worktreePath: string;
|
|
197
|
+
files: string[];
|
|
198
|
+
}): PublishDirtyResult;
|
|
199
|
+
/**
|
|
200
|
+
* A public function that auto-recomputes the dirty set from the assigned worktree's git state (Issue #52 proposals 2 & 3).
|
|
201
|
+
*
|
|
202
|
+
* Even without the agent explicitly calling publish_dirty, lockage reflects into the dirty set the
|
|
203
|
+
* union of the worktree's `git status --porcelain` (working-tree changes) and
|
|
204
|
+
* `git diff --name-only <base_sha>...HEAD` (committed diff from base_sha). It also unions with the
|
|
205
|
+
* existing explicit-publish content (the announcement) so nothing breaks. Called from index.ts's
|
|
206
|
+
* heartbeat / claim_files / publish_dirty / task_done paths.
|
|
207
|
+
*
|
|
208
|
+
* Processing:
|
|
209
|
+
* (1) Fetch the target worktrees from worktree_assignments (agent_id match, status='active').
|
|
210
|
+
* When worktreePath is specified, that one; otherwise all of the agent's active worktrees.
|
|
211
|
+
* (2) Throttle per worktree by the minimum interval (default DIRTY_RECONCILE_MIN_INTERVAL_SEC): if
|
|
212
|
+
* the latest dirty_sets.updated_at is newer than now - minIntervalSec, skip the git read and use
|
|
213
|
+
* that worktree's existing dirty as-is (do not break the already-published content).
|
|
214
|
+
* (3) For a non-throttled worktree, take workingTreeChangedFiles ∪ committedChangedFiles and union
|
|
215
|
+
* with the existing dirty_sets.files (the explicit-publish announcement) (normalizePath
|
|
216
|
+
* normalization, deduplication) and UPSERT dirty_sets (the same UPSERT statement as publishDirty).
|
|
217
|
+
* (4) Call applyDirtyOverlaps on the union files for overlap detection + wake.
|
|
218
|
+
* (5) Compute unclaimed with computeUnclaimed.
|
|
219
|
+
*
|
|
220
|
+
* The return is the aggregated form over all target worktrees (overlaps / wake_targets / unclaimed / dirty_files).
|
|
221
|
+
*/
|
|
222
|
+
export declare function reconcileDirtyFromWorktrees(cdb: CoordDb, args: {
|
|
223
|
+
agentId: string;
|
|
224
|
+
worktreePath?: string;
|
|
225
|
+
now?: number;
|
|
226
|
+
minIntervalSec?: number;
|
|
227
|
+
}): ReconcileDirtyResult;
|
|
228
|
+
/** The dirty set list (filter by agent). */
|
|
229
|
+
export declare function listDirty(cdb: CoordDb, agentId?: string): DirtyRow[];
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { ulid } from 'ulid';
|
|
2
|
+
import { unixNow } from '../db/index.js';
|
|
3
|
+
import { sweepExpired } from './claim.js';
|
|
4
|
+
import { fileIntersect, globsMatchAny, globsOverlap, normalizePath } from './globs.js';
|
|
5
|
+
import { wake } from './board.js';
|
|
6
|
+
import { committedChangedFiles, workingTreeChangedFiles } from './git.js';
|
|
7
|
+
/** Default TTL of an advisory lease (8h; matches the schema default). */
|
|
8
|
+
export const DEFAULT_CLAIM_TTL_SEC = 8 * 60 * 60;
|
|
9
|
+
/**
|
|
10
|
+
* The minimum interval (seconds) for throttling git reads against the same worktree in the dirty
|
|
11
|
+
* auto-compute (reconcileDirtyFromWorktrees). The default matches Issue #52 proposal 2's "e.g. 30 seconds".
|
|
12
|
+
*
|
|
13
|
+
* Since dirty recompute is called from multiple paths such as heartbeat / claim_files / task_done /
|
|
14
|
+
* publish_dirty, hitting `git status` / `git diff` every time is wasteful under high-frequency tool
|
|
15
|
+
* calls. If the latest dirty_sets.updated_at is newer than now - this many seconds, skip the git read
|
|
16
|
+
* and use the existing dirty as-is (do not break the already-published content).
|
|
17
|
+
*/
|
|
18
|
+
export const DIRTY_RECONCILE_MIN_INTERVAL_SEC = 30;
|
|
19
|
+
function parseGlobs(json) {
|
|
20
|
+
try {
|
|
21
|
+
const v = JSON.parse(json);
|
|
22
|
+
return Array.isArray(v) ? v : [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** List active (unreleased and unexpired) claims. */
|
|
29
|
+
function activeClaims(cdb, now, excludeHolder) {
|
|
30
|
+
const rows = cdb.db
|
|
31
|
+
.prepare(`SELECT * FROM claims
|
|
32
|
+
WHERE released_at IS NULL AND expires_at >= ?
|
|
33
|
+
ORDER BY created_at ASC`)
|
|
34
|
+
.all(now);
|
|
35
|
+
return excludeHolder ? rows.filter((r) => r.holder !== excludeHolder) : rows;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* §4.3 claim_files — warn, never block (advisory).
|
|
39
|
+
*
|
|
40
|
+
* Real file conflicts are prevented by worktree physical isolation (§4.5), so this sticks to the
|
|
41
|
+
* logical visualization of "who is responsible for where". Overlaps are detected and returned to
|
|
42
|
+
* `overlaps` as a warning only, not an error. Since they auto-expire by lease (TTL+heartbeat), there
|
|
43
|
+
* is no permanent lock or deadlock (hard locks not adopted, requirement §9.2).
|
|
44
|
+
*/
|
|
45
|
+
export function claimFiles(cdb, args) {
|
|
46
|
+
const now = unixNow();
|
|
47
|
+
sweepExpired(cdb, now);
|
|
48
|
+
const ttl = args.ttlSec ?? DEFAULT_CLAIM_TTL_SEC;
|
|
49
|
+
// Detect overlaps but do not block (advisory)
|
|
50
|
+
const overlaps = [];
|
|
51
|
+
for (const c of activeClaims(cdb, now, args.agentId)) {
|
|
52
|
+
const cg = parseGlobs(c.globs);
|
|
53
|
+
if (globsOverlap(cg, args.globs)) {
|
|
54
|
+
overlaps.push({ agent_id: c.holder, globs: cg });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const claimId = ulid();
|
|
58
|
+
cdb.db
|
|
59
|
+
.prepare(`INSERT INTO claims
|
|
60
|
+
(id, holder, globs, task_id, lease_ttl_sec, heartbeat_at, expires_at, created_at, host_id, origin)
|
|
61
|
+
VALUES (?, ?, json(?), ?, ?, ?, ?, ?, ?, 'local')`)
|
|
62
|
+
.run(claimId, args.agentId, JSON.stringify(args.globs), args.taskId ?? null, ttl, now, now + ttl, now, cdb.hostId);
|
|
63
|
+
return { claim_id: claimId, overlaps };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Explicit release of a claim (§2.3 release_claim). Only the holder can logically release (no physical DELETE = for provenance tracking).
|
|
67
|
+
*/
|
|
68
|
+
export function releaseClaim(cdb, claimId, agentId) {
|
|
69
|
+
const now = unixNow();
|
|
70
|
+
const info = cdb.db
|
|
71
|
+
.prepare(`UPDATE claims SET released_at = ?
|
|
72
|
+
WHERE id = ? AND holder = ? AND released_at IS NULL`)
|
|
73
|
+
.run(now, claimId, agentId);
|
|
74
|
+
return { ok: info.changes > 0 };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Issue #45 item 1/2: in step with a task's end, logically release the active claims that have that
|
|
78
|
+
* task_id together (no physical DELETE = for provenance tracking).
|
|
79
|
+
*
|
|
80
|
+
* Regardless of holder: even after a handoff_task move to another worker, release them without
|
|
81
|
+
* leftovers as long as task_id matches (the aim is to close the structural miss of "no trigger to
|
|
82
|
+
* release", not "a specific agent's procedural omission"). Claims with task_id NULL (declarations not
|
|
83
|
+
* tied to a task) are not targeted (those are handled by release_claim and TTL).
|
|
84
|
+
*
|
|
85
|
+
* Assumed to be called from both task_done (via merge.ts's finalizeMergedTask) and release_task
|
|
86
|
+
* (claim.ts). It only sets released_at, so it does not affect already-released claims (WHERE released_at IS NULL).
|
|
87
|
+
*
|
|
88
|
+
* @returns released_count = the number of claims released.
|
|
89
|
+
*/
|
|
90
|
+
export function releaseClaimsForTask(cdb, taskId, now = unixNow()) {
|
|
91
|
+
const info = cdb.db
|
|
92
|
+
.prepare(`UPDATE claims SET released_at = ?
|
|
93
|
+
WHERE task_id = ? AND released_at IS NULL`)
|
|
94
|
+
.run(now, taskId);
|
|
95
|
+
return { released_count: Number(info.changes) };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Issue #45 item 3 (the claim version of the #32 worktree pattern): logically release the active
|
|
99
|
+
* claims tied to done / abandoned tasks under the specified ticket, together.
|
|
100
|
+
*
|
|
101
|
+
* A reclamation path called from reconcile (lockage-publish's MERGED path). It follows up to tidy
|
|
102
|
+
* leftovers created before item 1 (task_done) and claims of tasks that ended without going through
|
|
103
|
+
* task_done. It targets only claims tied to tasks whose tasks.status is done / abandoned, and
|
|
104
|
+
* preserves claims of still-ongoing (in_progress / blocked) or not-started (open) tasks.
|
|
105
|
+
*
|
|
106
|
+
* Logical release that only sets released_at (no physical DELETE). It does not affect
|
|
107
|
+
* already-released/expired claims (WHERE released_at IS NULL). It is idempotent on the presence of
|
|
108
|
+
* active rows, so even if reconcile runs twice it does not double-release.
|
|
109
|
+
*
|
|
110
|
+
* @param ticketId the target ticket (matched against tasks.ticket_id).
|
|
111
|
+
* @returns released_count = the number of claims released.
|
|
112
|
+
*/
|
|
113
|
+
export function releaseClaimsForFinishedTicketTasks(cdb, args) {
|
|
114
|
+
const now = args.now ?? unixNow();
|
|
115
|
+
const info = cdb.db
|
|
116
|
+
.prepare(`UPDATE claims SET released_at = ?
|
|
117
|
+
WHERE released_at IS NULL
|
|
118
|
+
AND task_id IN (
|
|
119
|
+
SELECT id FROM tasks
|
|
120
|
+
WHERE ticket_id = ? AND status IN ('done', 'abandoned')
|
|
121
|
+
)`)
|
|
122
|
+
.run(now, args.ticketId);
|
|
123
|
+
return { released_count: Number(info.changes) };
|
|
124
|
+
}
|
|
125
|
+
/** The holder's active claim list. */
|
|
126
|
+
export function listClaims(cdb, holder) {
|
|
127
|
+
const now = unixNow();
|
|
128
|
+
// When holder is specified, narrow in the SQL WHERE (leverage idx_claims_holder; avoid an in-memory filter).
|
|
129
|
+
if (holder) {
|
|
130
|
+
return cdb.db
|
|
131
|
+
.prepare(`SELECT * FROM claims
|
|
132
|
+
WHERE released_at IS NULL AND expires_at >= ? AND holder = ?
|
|
133
|
+
ORDER BY created_at ASC`)
|
|
134
|
+
.all(now, holder);
|
|
135
|
+
}
|
|
136
|
+
return activeClaims(cdb, now);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* §4.4 publish_dirty — publish the dirty set and early overlap warning (requirement FR-1.5).
|
|
140
|
+
*
|
|
141
|
+
* Against claim (intent), the dirty set is "the fact that it was already changed". Publishing
|
|
142
|
+
* pre-commit changes as they go moves conflict discovery forward from "the final merge" to "before
|
|
143
|
+
* you start writing". Overlaps are returned as a warning only and do not stop the write. The dirty
|
|
144
|
+
* set is not cleared by commit alone; it is cleared at the merge gate's merged or at worktree
|
|
145
|
+
* disposal (§4.5). The granularity is per file (D-2).
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* The core of dirty-set overlap detection + A2A wake (§4.4, FR-1.5.1).
|
|
149
|
+
*
|
|
150
|
+
* An internal helper carved out of publishDirty. It is centralized here so that explicit publish
|
|
151
|
+
* (publishDirty) and auto-compute (reconcileDirtyFromWorktrees) share the logic of "detecting files
|
|
152
|
+
* overlapping others' dirty / claims" and "waking the overlapping peer with an overlap wake". The
|
|
153
|
+
* behavior is identical to the former in-publishDirty implementation (= the existing files.test.ts passes).
|
|
154
|
+
*
|
|
155
|
+
* - The intersection (fileIntersect) of another agent's dirty set and files is a dirty overlap.
|
|
156
|
+
* - If a non-self active claim's glob matches any of files (globsMatchAny), it is a claim overlap.
|
|
157
|
+
* - Wake the overlapping peer with an overlap wake (kind='overlap') (move conflict discovery forward to before writing).
|
|
158
|
+
*
|
|
159
|
+
* @param files normalization not required beforehand (fileIntersect/globsMatchAny normalizePath internally).
|
|
160
|
+
*/
|
|
161
|
+
function applyDirtyOverlaps(cdb, agentId, files, now) {
|
|
162
|
+
// Detect files overlapping another agent's dirty set / claim (do not block)
|
|
163
|
+
const dirtyOverlaps = [];
|
|
164
|
+
const otherDirty = cdb.db
|
|
165
|
+
.prepare(`SELECT * FROM dirty_sets WHERE agent_id != ?`)
|
|
166
|
+
.all(agentId);
|
|
167
|
+
for (const d of otherDirty) {
|
|
168
|
+
const inter = fileIntersect(parseGlobs(d.files), files);
|
|
169
|
+
if (inter.length > 0)
|
|
170
|
+
dirtyOverlaps.push({ agent_id: d.agent_id, files: inter });
|
|
171
|
+
}
|
|
172
|
+
const claimOverlaps = [];
|
|
173
|
+
for (const c of activeClaims(cdb, now, agentId)) {
|
|
174
|
+
const cg = parseGlobs(c.globs);
|
|
175
|
+
if (globsMatchAny(cg, files)) {
|
|
176
|
+
claimOverlaps.push({ agent_id: c.holder, globs: cg });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// If there is an overlap, wake the relevant agent with an A2A wake (FR-1.5.1).
|
|
180
|
+
// Push the overlap wake onto the wakes queue → the peer's wait_for_unblock returns with woke_by:"overlap".
|
|
181
|
+
const wakeTargets = [
|
|
182
|
+
...new Set([
|
|
183
|
+
...dirtyOverlaps.map((o) => o.agent_id),
|
|
184
|
+
...claimOverlaps.map((o) => o.agent_id),
|
|
185
|
+
]),
|
|
186
|
+
];
|
|
187
|
+
for (const target of wakeTargets) {
|
|
188
|
+
const wfiles = [
|
|
189
|
+
...new Set([
|
|
190
|
+
...dirtyOverlaps.filter((o) => o.agent_id === target).flatMap((o) => o.files),
|
|
191
|
+
...claimOverlaps.filter((o) => o.agent_id === target).flatMap((o) => o.globs),
|
|
192
|
+
]),
|
|
193
|
+
];
|
|
194
|
+
wake(cdb, target, 'overlap', { by: agentId, files: wfiles });
|
|
195
|
+
}
|
|
196
|
+
return { overlaps: { dirty: dirtyOverlaps, claims: claimOverlaps }, wakeTargets };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A pure helper that computes, of the dirty files, those matching none of your (agentId's) active
|
|
200
|
+
* claim globs (Issue #52 proposal 3's unclaimed computation).
|
|
201
|
+
*
|
|
202
|
+
* Collect all globs of your (holder=agentId) active claims (released_at IS NULL and expires_at >=
|
|
203
|
+
* now), and use globs.ts's globsMatchAny to return "files matching none of your globs" as unclaimed.
|
|
204
|
+
* If you have no claim at all, all dirty files become unclaimed (= touching with zero scope declaration).
|
|
205
|
+
*
|
|
206
|
+
* The return is a file set normalized and deduplicated by normalizePath. A pure function (DB read only, no side effects).
|
|
207
|
+
*/
|
|
208
|
+
export function computeUnclaimed(cdb, agentId, files, now) {
|
|
209
|
+
// Collect all globs of your active claims.
|
|
210
|
+
const myGlobs = [];
|
|
211
|
+
for (const c of listClaims(cdb, agentId)) {
|
|
212
|
+
myGlobs.push(...parseGlobs(c.globs));
|
|
213
|
+
}
|
|
214
|
+
void now; // listClaims(holder) narrows active by unixNow() internally, so now is unused (received for future expansion).
|
|
215
|
+
const unclaimed = [];
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
for (const f of files) {
|
|
218
|
+
const nf = normalizePath(f);
|
|
219
|
+
if (seen.has(nf))
|
|
220
|
+
continue;
|
|
221
|
+
seen.add(nf);
|
|
222
|
+
// Unclaimed if it matches none of your globs. If there is no claim, myGlobs is empty = all unclaimed.
|
|
223
|
+
if (!globsMatchAny(myGlobs, [nf]))
|
|
224
|
+
unclaimed.push(nf);
|
|
225
|
+
}
|
|
226
|
+
return unclaimed;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A pure helper that merges and deduplicates per-agent_id overlap (FileOverlap / GlobOverlap) arrays.
|
|
230
|
+
*
|
|
231
|
+
* applyDirtyOverlaps pushes only one row per agent_id within "a single detection", but adding results
|
|
232
|
+
* detected separately on the publishDirty side and the reconcileDirtyFromWorktrees side can make the
|
|
233
|
+
* same agent_id's row appear in both. So, keyed by agent_id, unify files / globs (both string[], so
|
|
234
|
+
* handled by the same logic) with a Set into a single deduplicated row. Preserves input order and
|
|
235
|
+
* returns in first-seen agent_id order.
|
|
236
|
+
*
|
|
237
|
+
* A pure function (DB-independent, no side effects). Because FileOverlap (files) and GlobOverlap
|
|
238
|
+
* (globs) have different key names, the extract/build are received from the caller as functions.
|
|
239
|
+
*/
|
|
240
|
+
function mergeAgentOverlaps(a, b, getItems, build) {
|
|
241
|
+
const order = [];
|
|
242
|
+
const byAgent = new Map();
|
|
243
|
+
for (const o of [...a, ...b]) {
|
|
244
|
+
let set = byAgent.get(o.agent_id);
|
|
245
|
+
if (!set) {
|
|
246
|
+
set = new Set();
|
|
247
|
+
byAgent.set(o.agent_id, set);
|
|
248
|
+
order.push(o.agent_id);
|
|
249
|
+
}
|
|
250
|
+
for (const item of getItems(o))
|
|
251
|
+
set.add(item);
|
|
252
|
+
}
|
|
253
|
+
return order.map((agentId) => build(agentId, [...byAgent.get(agentId)]));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A pure helper that "merges" rather than "swallows" the explicit-publish (publishDirty) result res
|
|
257
|
+
* and the worktree auto-compute (reconcileDirtyFromWorktrees) result rc (Issue #52's regression fix).
|
|
258
|
+
*
|
|
259
|
+
* The publish_dirty handler used to overwrite res wholesale with rc, so when the worktree_path passed
|
|
260
|
+
* to publish_dirty was not in worktree_assignments' active rows (e.g. no assign_worktree), rc was
|
|
261
|
+
* empty (overlaps/wake/unclaimed all empty) and the overlap/wake/unclaimed res actually detected
|
|
262
|
+
* disappeared from the response. By unioning the two, res's detection always remains even if
|
|
263
|
+
* reconcile cannot find the worktree.
|
|
264
|
+
*
|
|
265
|
+
* - overlaps.dirty / overlaps.claims: unify both res and rc per agent_id and deduplicate files/globs
|
|
266
|
+
* (no double counting even in the normal case where both return the same overlap).
|
|
267
|
+
* - wake_targets: the union of res and rc (deduplicated).
|
|
268
|
+
* - unclaimed: the union of res and rc (deduplicated).
|
|
269
|
+
*
|
|
270
|
+
* Receives the shape applyDirtyOverlaps / computeUnclaimed return (DirtyOverlap, string[]) and
|
|
271
|
+
* returns the same shape. A pure function (DB-independent, no side effects).
|
|
272
|
+
*/
|
|
273
|
+
export function mergeDirtyResults(res, rc) {
|
|
274
|
+
const dirty = mergeAgentOverlaps(res.overlaps.dirty, rc.overlaps.dirty, (o) => o.files, (agent_id, files) => ({ agent_id, files }));
|
|
275
|
+
const claims = mergeAgentOverlaps(res.overlaps.claims, rc.overlaps.claims, (o) => o.globs, (agent_id, globs) => ({ agent_id, globs }));
|
|
276
|
+
return {
|
|
277
|
+
overlaps: { dirty, claims },
|
|
278
|
+
wake_targets: [...new Set([...res.wake_targets, ...rc.wake_targets])],
|
|
279
|
+
unclaimed: [...new Set([...res.unclaimed, ...rc.unclaimed])],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export function publishDirty(cdb, args) {
|
|
283
|
+
const now = unixNow();
|
|
284
|
+
sweepExpired(cdb, now);
|
|
285
|
+
// 1) Overwrite-save the dirty set (1 worktree = 1 row, UPSERT)
|
|
286
|
+
cdb.db
|
|
287
|
+
.prepare(`INSERT INTO dirty_sets
|
|
288
|
+
(id, agent_id, worktree_path, files, updated_at, host_id, origin)
|
|
289
|
+
VALUES (?, ?, ?, json(?), ?, ?, 'local')
|
|
290
|
+
ON CONFLICT(agent_id, worktree_path)
|
|
291
|
+
DO UPDATE SET files = json(excluded.files), updated_at = excluded.updated_at`)
|
|
292
|
+
.run(ulid(), args.agentId, args.worktreePath, JSON.stringify(args.files), now, cdb.hostId);
|
|
293
|
+
// 2) overlap detection + wake (via applyDirtyOverlaps, shared with the auto-compute side).
|
|
294
|
+
const { overlaps, wakeTargets } = applyDirtyOverlaps(cdb, args.agentId, args.files, now);
|
|
295
|
+
// 3) Of the explicitly published files, those outside your claim (Issue #52 proposal 3).
|
|
296
|
+
const unclaimed = computeUnclaimed(cdb, args.agentId, args.files, now);
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
overlaps,
|
|
300
|
+
wake_targets: wakeTargets,
|
|
301
|
+
unclaimed,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A public function that auto-recomputes the dirty set from the assigned worktree's git state (Issue #52 proposals 2 & 3).
|
|
306
|
+
*
|
|
307
|
+
* Even without the agent explicitly calling publish_dirty, lockage reflects into the dirty set the
|
|
308
|
+
* union of the worktree's `git status --porcelain` (working-tree changes) and
|
|
309
|
+
* `git diff --name-only <base_sha>...HEAD` (committed diff from base_sha). It also unions with the
|
|
310
|
+
* existing explicit-publish content (the announcement) so nothing breaks. Called from index.ts's
|
|
311
|
+
* heartbeat / claim_files / publish_dirty / task_done paths.
|
|
312
|
+
*
|
|
313
|
+
* Processing:
|
|
314
|
+
* (1) Fetch the target worktrees from worktree_assignments (agent_id match, status='active').
|
|
315
|
+
* When worktreePath is specified, that one; otherwise all of the agent's active worktrees.
|
|
316
|
+
* (2) Throttle per worktree by the minimum interval (default DIRTY_RECONCILE_MIN_INTERVAL_SEC): if
|
|
317
|
+
* the latest dirty_sets.updated_at is newer than now - minIntervalSec, skip the git read and use
|
|
318
|
+
* that worktree's existing dirty as-is (do not break the already-published content).
|
|
319
|
+
* (3) For a non-throttled worktree, take workingTreeChangedFiles ∪ committedChangedFiles and union
|
|
320
|
+
* with the existing dirty_sets.files (the explicit-publish announcement) (normalizePath
|
|
321
|
+
* normalization, deduplication) and UPSERT dirty_sets (the same UPSERT statement as publishDirty).
|
|
322
|
+
* (4) Call applyDirtyOverlaps on the union files for overlap detection + wake.
|
|
323
|
+
* (5) Compute unclaimed with computeUnclaimed.
|
|
324
|
+
*
|
|
325
|
+
* The return is the aggregated form over all target worktrees (overlaps / wake_targets / unclaimed / dirty_files).
|
|
326
|
+
*/
|
|
327
|
+
export function reconcileDirtyFromWorktrees(cdb, args) {
|
|
328
|
+
const now = args.now ?? unixNow();
|
|
329
|
+
const minInterval = args.minIntervalSec ?? DIRTY_RECONCILE_MIN_INTERVAL_SEC;
|
|
330
|
+
sweepExpired(cdb, now);
|
|
331
|
+
// (1) Fetch the target worktrees (active only). When worktree_path is specified, narrow to that one.
|
|
332
|
+
const rows = (args.worktreePath
|
|
333
|
+
? cdb.db
|
|
334
|
+
.prepare(`SELECT worktree_path, repo_path, base_sha FROM worktree_assignments
|
|
335
|
+
WHERE agent_id = ? AND status = 'active' AND worktree_path = ?`)
|
|
336
|
+
.all(args.agentId, args.worktreePath)
|
|
337
|
+
: cdb.db
|
|
338
|
+
.prepare(`SELECT worktree_path, repo_path, base_sha FROM worktree_assignments
|
|
339
|
+
WHERE agent_id = ? AND status = 'active'`)
|
|
340
|
+
.all(args.agentId));
|
|
341
|
+
// The union of dirty files across all worktrees (for the aggregated return).
|
|
342
|
+
const allDirty = new Set();
|
|
343
|
+
const reconciledWorktrees = [];
|
|
344
|
+
for (const r of rows) {
|
|
345
|
+
// Read that worktree's existing dirty row (the explicit-publish announcement).
|
|
346
|
+
const existing = cdb.db
|
|
347
|
+
.prepare(`SELECT files, updated_at FROM dirty_sets WHERE agent_id = ? AND worktree_path = ?`)
|
|
348
|
+
.get(args.agentId, r.worktree_path);
|
|
349
|
+
const existingFiles = existing ? parseGlobs(existing.files) : [];
|
|
350
|
+
// (2) Throttle by the minimum interval: if the latest update is fresh enough, skip the git read and use the existing as-is.
|
|
351
|
+
const fresh = existing != null && existing.updated_at > now - minInterval;
|
|
352
|
+
if (fresh) {
|
|
353
|
+
for (const f of existingFiles)
|
|
354
|
+
allDirty.add(normalizePath(f));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// (3) Collect files from the git state and union with the existing (announcement).
|
|
358
|
+
// git status / diff run against worktree_path (following classifyRemoveFailure).
|
|
359
|
+
const working = workingTreeChangedFiles(r.worktree_path);
|
|
360
|
+
const committed = committedChangedFiles(r.worktree_path, r.base_sha);
|
|
361
|
+
const union = new Set();
|
|
362
|
+
for (const f of [...existingFiles, ...working, ...committed]) {
|
|
363
|
+
union.add(normalizePath(f));
|
|
364
|
+
}
|
|
365
|
+
const unionFiles = [...union];
|
|
366
|
+
for (const f of unionFiles)
|
|
367
|
+
allDirty.add(f);
|
|
368
|
+
reconciledWorktrees.push(r.worktree_path);
|
|
369
|
+
// UPSERT the union into dirty_sets (the same UPSERT statement as publishDirty).
|
|
370
|
+
cdb.db
|
|
371
|
+
.prepare(`INSERT INTO dirty_sets
|
|
372
|
+
(id, agent_id, worktree_path, files, updated_at, host_id, origin)
|
|
373
|
+
VALUES (?, ?, ?, json(?), ?, ?, 'local')
|
|
374
|
+
ON CONFLICT(agent_id, worktree_path)
|
|
375
|
+
DO UPDATE SET files = json(excluded.files), updated_at = excluded.updated_at`)
|
|
376
|
+
.run(ulid(), args.agentId, r.worktree_path, JSON.stringify(unionFiles), now, cdb.hostId);
|
|
377
|
+
}
|
|
378
|
+
const dirtyFiles = [...allDirty];
|
|
379
|
+
// (4) overlap detection + wake on the union files.
|
|
380
|
+
const { overlaps, wakeTargets } = applyDirtyOverlaps(cdb, args.agentId, dirtyFiles, now);
|
|
381
|
+
// (5) Compute unclaimed.
|
|
382
|
+
const unclaimed = computeUnclaimed(cdb, args.agentId, dirtyFiles, now);
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
overlaps,
|
|
386
|
+
wake_targets: wakeTargets,
|
|
387
|
+
unclaimed,
|
|
388
|
+
dirty_files: dirtyFiles,
|
|
389
|
+
reconciled_worktrees: reconciledWorktrees,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** The dirty set list (filter by agent). */
|
|
393
|
+
export function listDirty(cdb, agentId) {
|
|
394
|
+
if (agentId) {
|
|
395
|
+
return cdb.db
|
|
396
|
+
.prepare(`SELECT * FROM dirty_sets WHERE agent_id = ? ORDER BY updated_at DESC`)
|
|
397
|
+
.all(agentId);
|
|
398
|
+
}
|
|
399
|
+
return cdb.db
|
|
400
|
+
.prepare(`SELECT * FROM dirty_sets ORDER BY updated_at DESC`)
|
|
401
|
+
.all();
|
|
402
|
+
}
|
|
403
|
+
//# sourceMappingURL=files.js.map
|