agent-dealer 1.2.13 → 1.2.15
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/bundle/server/dist/adapters/git-worktree.js +15 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
- package/bundle/server/dist/adapters/linear-inbox.js +97 -0
- package/bundle/server/dist/adapters/linear-inbox.test.js +299 -1
- package/bundle/server/dist/adapters/linear-sync.js +33 -10
- package/bundle/server/dist/capacity/claude-events.js +25 -5
- package/bundle/server/dist/capacity/claude-local-cache.js +159 -13
- package/bundle/server/dist/capacity/claude-local-cache.test.js +191 -3
- package/bundle/server/dist/coordinator/auto-merge.js +10 -0
- package/bundle/server/dist/coordinator/branch-progress.js +5 -4
- package/bundle/server/dist/coordinator/commands.js +37 -3
- package/bundle/server/dist/coordinator/delete-issue.js +473 -0
- package/bundle/server/dist/coordinator/developer-effect.js +33 -6
- package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
- package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
- package/bundle/server/dist/coordinator/prompts.js +9 -1
- package/bundle/server/dist/coordinator/prompts.test.js +93 -0
- package/bundle/server/dist/coordinator/source-attachments.js +496 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +396 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/index.js +4 -0
- package/bundle/server/dist/db/schema.sql +32 -0
- package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
- package/bundle/server/dist/paths.js +12 -0
- package/bundle/server/dist/repository/runtime-capacity.js +19 -3
- package/bundle/server/dist/repository/source-attachments.js +56 -0
- package/bundle/server/dist/routes/human-actions.js +17 -0
- package/bundle/server/dist/routes/issues-delete.test.js +666 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +447 -0
- package/bundle/server/dist/routes/issues.js +109 -1
- package/bundle/server/dist/routes/runtime-capacity.test.js +35 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DVyqblie.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/index.d.ts +108 -2
- package/bundle/shared/dist/index.js +12 -1
- package/bundle/shared/dist/issues.d.ts +43 -2
- package/bundle/shared/dist/issues.js +10 -0
- package/bundle/shared/dist/linear-intake.d.ts +85 -0
- package/bundle/shared/dist/linear-intake.js +56 -0
- package/bundle/shared/dist/linear-intake.test.js +55 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +147 -0
- package/bundle/shared/dist/runtime-capacity.js +15 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +7 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// packages/server/src/coordinator/delete-issue.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-365: Dealer-local hard delete — the destructive counterpart to Close issue
|
|
4
|
+
// (NOT-239) and Abort workflow. Close retains history/evidence as `closed` work;
|
|
5
|
+
// Delete permanently removes the local issue row and every issue-scoped row plus
|
|
6
|
+
// Dealer-owned blobs/logs, and never touches the linked Linear ticket (this
|
|
7
|
+
// module makes no Linear or GitHub call — `source = linear` only changes the
|
|
8
|
+
// confirmation copy in the UI).
|
|
9
|
+
//
|
|
10
|
+
// Eligibility: only `ready`, `done`, or `closed` issues with no active workflow
|
|
11
|
+
// instance, no running worker session, no leased/pending work item, no active
|
|
12
|
+
// authority attempt, and no existing or preserved worktree — recorded or not
|
|
13
|
+
// (deterministic session paths, branch-holding checkouts, and evidence-recorded
|
|
14
|
+
// paths all count). Anything else answers 409 naming the exact blocker and
|
|
15
|
+
// changes nothing.
|
|
16
|
+
//
|
|
17
|
+
// Deletion itself is one SQLite transaction (foreign keys stay enforced —
|
|
18
|
+
// deletes run child-first with the self-referencing `causation_event_id` nulled
|
|
19
|
+
// first). Dealer-owned file paths are collected inside that transaction, and
|
|
20
|
+
// removed from disk only after the commit: each path is canonicalized and must
|
|
21
|
+
// be contained by AGENT_DEALER_HOME, otherwise it is reported as a residual
|
|
22
|
+
// path instead of being followed. A cleanup failure never resurrects the row —
|
|
23
|
+
// the record deletion still succeeds with explicit `residualPaths`.
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { looksLikeLocalRepoPath, parseGitHubRepoInput } from "@agent-dealer/shared";
|
|
27
|
+
import { WORKTREES_DIR_NAME } from "../adapters/git-worktree.js";
|
|
28
|
+
import { managedWorktreePath, managedWorktreesRoot } from "../adapters/managed-repo.js";
|
|
29
|
+
import { getDb, getDataDir } from "../db/index.js";
|
|
30
|
+
import { getIssue } from "../repository/issues.js";
|
|
31
|
+
import { getQueuedEntryForIssue } from "../repository/queue-entries.js";
|
|
32
|
+
import { getActiveWorkflowInstance } from "../repository/workflow-events.js";
|
|
33
|
+
import { getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
|
|
34
|
+
import { listWorkItemsForIssue } from "../repository/work-items.js";
|
|
35
|
+
import { getSourceAttachmentsDir } from "../paths.js";
|
|
36
|
+
/** Issue-scoped tables the delete transaction must empty — kept in sync with
|
|
37
|
+
* schema.sql plus the incremental tables in db/index.ts migrate(). `issues`
|
|
38
|
+
* itself is deleted last; `review_publications` is reached through its work
|
|
39
|
+
* items (its PK is the work-item id). */
|
|
40
|
+
export const ISSUE_SCOPED_TABLES = [
|
|
41
|
+
"issue_source_attachments",
|
|
42
|
+
"artifacts",
|
|
43
|
+
"authority_attempts",
|
|
44
|
+
"failure_causes",
|
|
45
|
+
"session_activity_events",
|
|
46
|
+
"findings",
|
|
47
|
+
"human_actions",
|
|
48
|
+
"queue_entries",
|
|
49
|
+
"review_publications",
|
|
50
|
+
"usage_events",
|
|
51
|
+
"workflow_events",
|
|
52
|
+
"work_items",
|
|
53
|
+
"worker_sessions",
|
|
54
|
+
"workflow_instances",
|
|
55
|
+
"issues",
|
|
56
|
+
];
|
|
57
|
+
const ACTIVE_AUTHORITY_STATUSES = ["acquiring", "active"];
|
|
58
|
+
function countIssueRows(table, issueId) {
|
|
59
|
+
const row = getDb()
|
|
60
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE issue_id = ?`)
|
|
61
|
+
.get(issueId);
|
|
62
|
+
return row.n;
|
|
63
|
+
}
|
|
64
|
+
/** Authority attempts name the issue in their owner key, not an issue_id
|
|
65
|
+
* column: `owner_id` is the issue id itself (reflect) or `${issueId}:${kind}`
|
|
66
|
+
* (developer/reviewer). Issue ids are UUID/hex-shaped — no GLOB metacharacters
|
|
67
|
+
* — so a `GLOB '<id>:*'` suffix match is exact. */
|
|
68
|
+
function listActiveAuthorityAttemptsForIssue(issueId) {
|
|
69
|
+
return getDb()
|
|
70
|
+
.prepare(`SELECT id, status FROM authority_attempts
|
|
71
|
+
WHERE status IN ('acquiring', 'active')
|
|
72
|
+
AND (owner_id = ? OR owner_id GLOB (? || ':*'))`)
|
|
73
|
+
.all(issueId, issueId);
|
|
74
|
+
}
|
|
75
|
+
/** Branch checked out in `dir`, read purely from the filesystem (no git binary,
|
|
76
|
+
* no network): a linked worktree carries a `.git` *file* pointing at its gitdir
|
|
77
|
+
* whose HEAD names the branch; a full checkout carries a `.git` directory. A
|
|
78
|
+
* detached HEAD (reviewer checkouts), a missing `.git`, or anything unreadable
|
|
79
|
+
* reads as null. */
|
|
80
|
+
function worktreeBranch(dir) {
|
|
81
|
+
try {
|
|
82
|
+
const dotGit = path.join(dir, ".git");
|
|
83
|
+
const st = fs.lstatSync(dotGit);
|
|
84
|
+
let gitDir;
|
|
85
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
86
|
+
gitDir = dotGit;
|
|
87
|
+
}
|
|
88
|
+
else if (st.isFile()) {
|
|
89
|
+
const content = fs.readFileSync(dotGit, "utf8");
|
|
90
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(content);
|
|
91
|
+
if (!match?.[1])
|
|
92
|
+
return null;
|
|
93
|
+
gitDir = path.resolve(dir, match[1]);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
99
|
+
return /^ref:\s*refs\/heads\/(.+?)\s*$/.exec(head)?.[1] ?? null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Immediate child directories of a worktrees root — [] when missing/unreadable. */
|
|
106
|
+
function listWorktreeDirs(root) {
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const dirs = [];
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const full = path.join(root, entry.name);
|
|
117
|
+
try {
|
|
118
|
+
if (entry.isDirectory())
|
|
119
|
+
dirs.push(full);
|
|
120
|
+
else if (entry.isSymbolicLink() && fs.statSync(full).isDirectory())
|
|
121
|
+
dirs.push(full);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* raced away — not a checkout */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return dirs;
|
|
128
|
+
}
|
|
129
|
+
/** Absolute `worktreePath` values recorded in one human-action evidence blob
|
|
130
|
+
* (e.g. a rejected push's preserved checkout). Relative strings are in-repo
|
|
131
|
+
* pointers, never checkouts — only absolute paths qualify. An unparseable blob
|
|
132
|
+
* contributes nothing. */
|
|
133
|
+
function worktreePathsFromActionEvidence(evidenceJson) {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(evidenceJson);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const found = [];
|
|
142
|
+
const walk = (node) => {
|
|
143
|
+
if (Array.isArray(node)) {
|
|
144
|
+
for (const item of node)
|
|
145
|
+
walk(item);
|
|
146
|
+
}
|
|
147
|
+
else if (node && typeof node === "object") {
|
|
148
|
+
for (const [key, value] of Object.entries(node)) {
|
|
149
|
+
if (key === "worktreePath" && typeof value === "string" && path.isAbsolute(value)) {
|
|
150
|
+
found.push(value);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
walk(value);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
walk(parsed);
|
|
159
|
+
return found;
|
|
160
|
+
}
|
|
161
|
+
/** Checkout paths that still exist on disk — recorded or not. Four sources:
|
|
162
|
+
* (1) `worker_sessions.worktree_path` rows; (2) the deterministic role-worktree
|
|
163
|
+
* path for each of the issue's session ids in the managed and legacy-local
|
|
164
|
+
* layouts — developer-effect creates or reuses the checkout *before*
|
|
165
|
+
* patchRunningSession records it, so an ensureWorktreeDeps throw or a
|
|
166
|
+
* salvage-checkpoint conflict return leaves an on-disk checkout no row names;
|
|
167
|
+
* (3) any checkout under the issue's worktrees roots whose HEAD still sits on
|
|
168
|
+
* issue.branch (a reused leftover under another session's name); (4) absolute
|
|
169
|
+
* `worktreePath` values recorded in the issue's human-action evidence (e.g. a
|
|
170
|
+
* rejected push's preserved checkout). Existence is checked with lstat semantics
|
|
171
|
+
* (no following): a dangling symlink still names a blocker, and reporting a path
|
|
172
|
+
* never resolves it elsewhere. Pure filesystem reads — no git binary, no
|
|
173
|
+
* network — so the guard's sync in-transaction re-check stays safe. */
|
|
174
|
+
function existingWorktreePaths(issueId) {
|
|
175
|
+
const found = [];
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const consider = (candidate) => {
|
|
178
|
+
if (seen.has(candidate))
|
|
179
|
+
return;
|
|
180
|
+
seen.add(candidate);
|
|
181
|
+
try {
|
|
182
|
+
fs.lstatSync(candidate);
|
|
183
|
+
found.push(candidate);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* gone */
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const rows = getDb()
|
|
190
|
+
.prepare(`SELECT DISTINCT worktree_path AS p FROM worker_sessions
|
|
191
|
+
WHERE issue_id = ? AND worktree_path IS NOT NULL AND worktree_path != ''`)
|
|
192
|
+
.all(issueId);
|
|
193
|
+
for (const row of rows)
|
|
194
|
+
consider(row.p);
|
|
195
|
+
const issue = getIssue(issueId);
|
|
196
|
+
if (!issue)
|
|
197
|
+
return found;
|
|
198
|
+
const sessions = getDb()
|
|
199
|
+
.prepare(`SELECT id, role FROM worker_sessions WHERE issue_id = ?`)
|
|
200
|
+
.all(issueId);
|
|
201
|
+
let identity = null;
|
|
202
|
+
try {
|
|
203
|
+
identity = parseGitHubRepoInput(issue.repo).identity;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
identity = null;
|
|
207
|
+
}
|
|
208
|
+
const legacyRoot = looksLikeLocalRepoPath(issue.repo)
|
|
209
|
+
? path.join(issue.repo, WORKTREES_DIR_NAME)
|
|
210
|
+
: null;
|
|
211
|
+
if (identity) {
|
|
212
|
+
for (const session of sessions) {
|
|
213
|
+
consider(managedWorktreePath(identity, session.id, session.role));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (legacyRoot) {
|
|
217
|
+
for (const session of sessions) {
|
|
218
|
+
consider(path.join(legacyRoot, `${session.id}-${session.role}`));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (issue.branch) {
|
|
222
|
+
const roots = [
|
|
223
|
+
...(identity ? [managedWorktreesRoot(identity)] : []),
|
|
224
|
+
...(legacyRoot ? [legacyRoot] : []),
|
|
225
|
+
];
|
|
226
|
+
for (const root of roots) {
|
|
227
|
+
for (const dir of listWorktreeDirs(root)) {
|
|
228
|
+
if (worktreeBranch(dir) === issue.branch)
|
|
229
|
+
consider(dir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const evidenceRows = getDb()
|
|
234
|
+
.prepare(`SELECT evidence_json AS e FROM human_actions WHERE issue_id = ? AND evidence_json IS NOT NULL`)
|
|
235
|
+
.all(issueId);
|
|
236
|
+
for (const row of evidenceRows) {
|
|
237
|
+
for (const candidate of worktreePathsFromActionEvidence(row.e))
|
|
238
|
+
consider(candidate);
|
|
239
|
+
}
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
function guard(issueId) {
|
|
243
|
+
const issue = getIssue(issueId);
|
|
244
|
+
if (!issue)
|
|
245
|
+
return null;
|
|
246
|
+
if (issue.status !== "ready" && issue.status !== "done" && issue.status !== "closed") {
|
|
247
|
+
return (`Cannot delete an issue that is ${issue.status} — ` +
|
|
248
|
+
`only ready, done, or closed issues can be deleted from Dealer; ` +
|
|
249
|
+
`close or abort it and let it fully settle first`);
|
|
250
|
+
}
|
|
251
|
+
// Leased/pending work before the instance that owns it: when both are live
|
|
252
|
+
// the concrete item (with its lease state) is the more exact blocker.
|
|
253
|
+
const liveItems = listWorkItemsForIssue(issueId).filter((w) => w.status === "pending" || w.status === "leased");
|
|
254
|
+
if (liveItems.length > 0) {
|
|
255
|
+
const kinds = liveItems.map((w) => `${w.status} work item ${w.id}`).join(", ");
|
|
256
|
+
return (`Cannot delete issue with ${kinds} — ` +
|
|
257
|
+
`abort the workflow and let it fully settle first`);
|
|
258
|
+
}
|
|
259
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
260
|
+
if (instance) {
|
|
261
|
+
return (`Cannot delete issue with an active workflow instance ${instance.id} — ` +
|
|
262
|
+
`abort the workflow and let it fully settle first`);
|
|
263
|
+
}
|
|
264
|
+
const running = getActiveWorkerSessionForIssue(issueId);
|
|
265
|
+
if (running) {
|
|
266
|
+
return (`Cannot delete issue with a running worker session ${running.id} — ` +
|
|
267
|
+
`abort the workflow and let it fully settle first`);
|
|
268
|
+
}
|
|
269
|
+
const authorities = listActiveAuthorityAttemptsForIssue(issueId);
|
|
270
|
+
if (authorities.length > 0) {
|
|
271
|
+
const names = authorities.map((a) => `${a.status} authority attempt ${a.id}`).join(", ");
|
|
272
|
+
return `Cannot delete issue with ${names} — wait for the attempt to settle first`;
|
|
273
|
+
}
|
|
274
|
+
const worktrees = existingWorktreePaths(issueId);
|
|
275
|
+
if (worktrees.length > 0) {
|
|
276
|
+
return (`Cannot delete issue with an existing worktree at ${worktrees[0]} — ` +
|
|
277
|
+
`remove the checkout before deleting`);
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
/** Dealer-owned file paths for the issue, read inside the delete transaction
|
|
282
|
+
* before the rows disappear: source-attachment blobs, artifact blobs, worker
|
|
283
|
+
* session logs, and failure-cause log pointers — plus the issue's own
|
|
284
|
+
* source-attachments directory (retained history per NOT-364, until now). */
|
|
285
|
+
function collectDealerPaths(issueId) {
|
|
286
|
+
const db = getDb();
|
|
287
|
+
const paths = [];
|
|
288
|
+
const blobRows = db
|
|
289
|
+
.prepare(`SELECT blob_path AS p FROM issue_source_attachments WHERE issue_id = ? AND blob_path IS NOT NULL`)
|
|
290
|
+
.all(issueId);
|
|
291
|
+
const artifactRows = db
|
|
292
|
+
.prepare(`SELECT blob_path AS p FROM artifacts WHERE issue_id = ? AND blob_path IS NOT NULL`)
|
|
293
|
+
.all(issueId);
|
|
294
|
+
const logRows = db
|
|
295
|
+
.prepare(`SELECT log_path AS p FROM worker_sessions WHERE issue_id = ? AND log_path IS NOT NULL`)
|
|
296
|
+
.all(issueId);
|
|
297
|
+
const failureLogRows = db
|
|
298
|
+
.prepare(`SELECT log_path AS p FROM failure_causes WHERE issue_id = ? AND log_path IS NOT NULL`)
|
|
299
|
+
.all(issueId);
|
|
300
|
+
for (const row of [...blobRows, ...artifactRows, ...logRows, ...failureLogRows]) {
|
|
301
|
+
if (row.p)
|
|
302
|
+
paths.push(row.p);
|
|
303
|
+
}
|
|
304
|
+
paths.push(path.join(getSourceAttachmentsDir(), issueId));
|
|
305
|
+
return [...new Set(paths)];
|
|
306
|
+
}
|
|
307
|
+
/** Remove one collected path after the commit. Removes only strict descendants
|
|
308
|
+
* of AGENT_DEALER_HOME; anything else — `..` escapes, absolute paths elsewhere,
|
|
309
|
+
* symlinks pointing out, and the home root itself — is left untouched and
|
|
310
|
+
* returned as a residual. An in-home symlink resolving in-home is unlinked
|
|
311
|
+
* itself, never followed into its target. Returns the residual path, or null
|
|
312
|
+
* when removed (or already gone). */
|
|
313
|
+
export function removeDealerPathContained(rawPath, homeDir) {
|
|
314
|
+
// Lexical gate first: the raw path itself must name a strict descendant of
|
|
315
|
+
// the home — an absolute path elsewhere, a `..` escape, or the home root
|
|
316
|
+
// itself (a corrupted or blank blob path resolving there must never wipe the
|
|
317
|
+
// whole Dealer home) is refused before anything is followed or removed. A
|
|
318
|
+
// collected link outside the home pointing in is refused here too: neither
|
|
319
|
+
// the link nor its target is ours to remove.
|
|
320
|
+
const lexical = path.resolve(rawPath);
|
|
321
|
+
const homeLexical = path.resolve(homeDir);
|
|
322
|
+
if (!lexical.startsWith(homeLexical + path.sep)) {
|
|
323
|
+
return rawPath;
|
|
324
|
+
}
|
|
325
|
+
// Existence/canonical gate: realpath resolves symlinks AND `..` — a live
|
|
326
|
+
// in-home symlink pointing outside resolves outside and is refused below,
|
|
327
|
+
// never followed. Canonicalize the home root as well: on macOS the temp dir
|
|
328
|
+
// (and a custom AGENT_DEALER_HOME under it) resolves through a
|
|
329
|
+
// `/var -> /private/var` symlink, so comparing a realpath'd candidate
|
|
330
|
+
// against an unresolved home would misread owned files as outside.
|
|
331
|
+
let resolved;
|
|
332
|
+
try {
|
|
333
|
+
resolved = fs.realpathSync(rawPath);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// Nothing exists at (part of) this path — and the lexical gate above
|
|
337
|
+
// already proved it names an in-root location (e.g. an issue's
|
|
338
|
+
// source-attachment dir with no attachments) — so it is already clean.
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
let home;
|
|
342
|
+
try {
|
|
343
|
+
home = fs.realpathSync(homeDir);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
home = homeLexical;
|
|
347
|
+
}
|
|
348
|
+
if (!resolved.startsWith(home + path.sep)) {
|
|
349
|
+
return rawPath;
|
|
350
|
+
}
|
|
351
|
+
// Already gone is already clean — only real removals and real failures report.
|
|
352
|
+
try {
|
|
353
|
+
if (fs.lstatSync(rawPath).isSymbolicLink()) {
|
|
354
|
+
// An in-home link whose target resolved in-home above: remove the link
|
|
355
|
+
// itself, never the file it points at.
|
|
356
|
+
fs.unlinkSync(rawPath);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const st = fs.lstatSync(resolved);
|
|
360
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
361
|
+
fs.rmSync(resolved, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
fs.rmSync(resolved, { force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (err?.code === "ENOENT")
|
|
370
|
+
return null;
|
|
371
|
+
return rawPath;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* NOT-365: hard-delete an eligible Dealer-local issue. Makes no Linear or
|
|
377
|
+
* GitHub call — the linked source ticket is untouched by construction.
|
|
378
|
+
*
|
|
379
|
+
* Guard failures answer 409 with the exact blocker and change no row or file.
|
|
380
|
+
* The row deletion is one transaction; file cleanup runs after the commit and
|
|
381
|
+
* reports anything it could not (or would not) remove as `residualPaths`
|
|
382
|
+
* without resurrecting the row.
|
|
383
|
+
*/
|
|
384
|
+
export function deleteDealerIssue(issueId) {
|
|
385
|
+
const issue = getIssue(issueId);
|
|
386
|
+
if (!issue)
|
|
387
|
+
return { ok: false, code: 404, error: "Issue not found" };
|
|
388
|
+
const blocked = guard(issueId);
|
|
389
|
+
if (blocked)
|
|
390
|
+
return { ok: false, code: 409, error: blocked };
|
|
391
|
+
const homeDir = getDataDir();
|
|
392
|
+
let collected = [];
|
|
393
|
+
let removedQueueEntry = false;
|
|
394
|
+
try {
|
|
395
|
+
const tx = getDb().transaction(() => {
|
|
396
|
+
// Re-check inside the transaction: an admission racing this delete must
|
|
397
|
+
// fail closed instead of landing rows the delete then orphans.
|
|
398
|
+
const fresh = getIssue(issueId);
|
|
399
|
+
if (!fresh)
|
|
400
|
+
throw Object.assign(new Error("Issue not found"), { code: 404 });
|
|
401
|
+
const raced = guard(issueId);
|
|
402
|
+
if (raced)
|
|
403
|
+
throw Object.assign(new Error(raced), { code: 409 });
|
|
404
|
+
const paths = collectDealerPaths(issueId);
|
|
405
|
+
const queued = getQueuedEntryForIssue(issueId) != null;
|
|
406
|
+
const db = getDb();
|
|
407
|
+
// The self-referencing causation edge must go first: siblings commit in
|
|
408
|
+
// the same statement, but row-by-row FK checks can see a parent vanish
|
|
409
|
+
// before its child does.
|
|
410
|
+
db.prepare(`UPDATE workflow_events SET causation_event_id = NULL WHERE issue_id = ?`).run(issueId);
|
|
411
|
+
db.prepare(`DELETE FROM review_publications WHERE work_item_id IN (SELECT id FROM work_items WHERE issue_id = ?)`).run(issueId);
|
|
412
|
+
db.prepare(`DELETE FROM workflow_events WHERE issue_id = ?`).run(issueId);
|
|
413
|
+
db.prepare(`DELETE FROM usage_events WHERE issue_id = ?`).run(issueId);
|
|
414
|
+
db.prepare(`DELETE FROM work_items WHERE issue_id = ?`).run(issueId);
|
|
415
|
+
db.prepare(`DELETE FROM human_actions WHERE issue_id = ?`).run(issueId);
|
|
416
|
+
db.prepare(`DELETE FROM artifacts WHERE issue_id = ?`).run(issueId);
|
|
417
|
+
db.prepare(`DELETE FROM issue_source_attachments WHERE issue_id = ?`).run(issueId);
|
|
418
|
+
db.prepare(`DELETE FROM authority_attempts WHERE owner_id = ? OR owner_id GLOB (? || ':*')`).run(issueId, issueId);
|
|
419
|
+
db.prepare(`DELETE FROM failure_causes WHERE issue_id = ?`).run(issueId);
|
|
420
|
+
db.prepare(`DELETE FROM session_activity_events WHERE issue_id = ?`).run(issueId);
|
|
421
|
+
db.prepare(`DELETE FROM worker_sessions WHERE issue_id = ?`).run(issueId);
|
|
422
|
+
db.prepare(`DELETE FROM workflow_instances WHERE issue_id = ?`).run(issueId);
|
|
423
|
+
db.prepare(`DELETE FROM queue_entries WHERE issue_id = ?`).run(issueId);
|
|
424
|
+
db.prepare(`DELETE FROM findings WHERE issue_id = ?`).run(issueId);
|
|
425
|
+
db.prepare(`DELETE FROM issues WHERE id = ?`).run(issueId);
|
|
426
|
+
return { paths, queued };
|
|
427
|
+
});
|
|
428
|
+
const out = tx();
|
|
429
|
+
collected = out.paths;
|
|
430
|
+
removedQueueEntry = out.queued;
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
const code = err.code;
|
|
434
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
435
|
+
if (code === 404 || code === 409)
|
|
436
|
+
return { ok: false, code, error: message };
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
const residualPaths = [];
|
|
440
|
+
for (const p of collected) {
|
|
441
|
+
const residual = removeDealerPathContained(p, homeDir);
|
|
442
|
+
if (residual)
|
|
443
|
+
residualPaths.push(residual);
|
|
444
|
+
}
|
|
445
|
+
return { ok: true, removedQueueEntry, residualPaths };
|
|
446
|
+
}
|
|
447
|
+
/** Test helper: remaining row counts per issue-scoped table (0 post-delete). */
|
|
448
|
+
export function countIssueScopedRows(issueId) {
|
|
449
|
+
const counts = {};
|
|
450
|
+
for (const table of ISSUE_SCOPED_TABLES) {
|
|
451
|
+
if (table === "review_publications") {
|
|
452
|
+
const row = getDb()
|
|
453
|
+
.prepare(`SELECT COUNT(*) AS n FROM review_publications
|
|
454
|
+
WHERE work_item_id IN (SELECT id FROM work_items WHERE issue_id = ?)`)
|
|
455
|
+
.get(issueId);
|
|
456
|
+
counts[table] = row.n;
|
|
457
|
+
}
|
|
458
|
+
else if (table === "authority_attempts") {
|
|
459
|
+
const row = getDb()
|
|
460
|
+
.prepare(`SELECT COUNT(*) AS n FROM authority_attempts
|
|
461
|
+
WHERE owner_id = ? OR owner_id GLOB (? || ':*')`)
|
|
462
|
+
.get(issueId, issueId);
|
|
463
|
+
counts[table] = row.n;
|
|
464
|
+
}
|
|
465
|
+
else if (table === "issues") {
|
|
466
|
+
counts[table] = getIssue(issueId) ? 1 : 0;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
counts[table] = countIssueRows(table, issueId);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return counts;
|
|
473
|
+
}
|
|
@@ -19,6 +19,7 @@ import path from "node:path";
|
|
|
19
19
|
import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
|
|
20
20
|
import { CONFLICTING_FILES_MAX, runMergeConflictSync } from "./merge-conflict-sync.js";
|
|
21
21
|
import { getTaskSnapshot } from "./commands.js";
|
|
22
|
+
import { materializeSourceAttachments, SourceAttachmentError, } from "./source-attachments.js";
|
|
22
23
|
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
23
24
|
import { buildDeveloperPrompt } from "./prompts.js";
|
|
24
25
|
import { guidanceForNextSession } from "./guidance.js";
|
|
@@ -912,13 +913,16 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
912
913
|
intent,
|
|
913
914
|
payload,
|
|
914
915
|
});
|
|
915
|
-
// issue.branch is
|
|
916
|
-
//
|
|
916
|
+
// issue.branch is ground truth for the branch NAME (written on a verified
|
|
917
|
+
// clean_handoff, or seeded from Linear's branchName at intake — NOT-362), but
|
|
918
|
+
// not for the branch's EXISTENCE: a seeded branch has no local ref and the
|
|
919
|
+
// remote never saw it. A first-round attempt after an earlier retryable
|
|
917
920
|
// failure in the SAME round (no_pr/session_failed/timed_out/checks_failed all remove
|
|
918
921
|
// their worktree but leave the local branch ref behind) would otherwise re-run
|
|
919
922
|
// `git worktree add -b <same name>` and fail with "branch already exists" — a review
|
|
920
923
|
// round reproduced this directly. Check the branch itself, not just issue.branch, and
|
|
921
|
-
// reuse it (preserving whatever local commits it already carries) when it's there
|
|
924
|
+
// reuse it (preserving whatever local commits it already carries) when it's there
|
|
925
|
+
// (resolveDeveloperWorktree cuts a nonexistent reuse-path branch fresh from base).
|
|
922
926
|
const branchName = issue.branch ?? `issue-${issue.id}`;
|
|
923
927
|
let repoPath;
|
|
924
928
|
let desiredWorktreePath;
|
|
@@ -942,7 +946,11 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
942
946
|
catch (err) {
|
|
943
947
|
return { kind: "adapter_failure", reason: `repository checkout failed: ${String(err)}` };
|
|
944
948
|
}
|
|
945
|
-
|
|
949
|
+
// NOT-362: a seeded issue.branch names a branch that exists nowhere yet — the
|
|
950
|
+
// local ref is the only existence signal honest enough for reuse accounting
|
|
951
|
+
// below (a seeded name must not count as "prior commits to reuse").
|
|
952
|
+
const localBranchPreexists = await branchExists(repoPath, branchName);
|
|
953
|
+
const reuseBranch = issue.branch != null || localBranchPreexists;
|
|
946
954
|
let worktreePath;
|
|
947
955
|
// NOT-172: whether the retry-resolution reused an existing checkout (source for
|
|
948
956
|
// the retry-reuse record emitted once the retry begins).
|
|
@@ -1066,6 +1074,25 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1066
1074
|
milestone("worktree.ready", `Developer · worktree ready (round ${round})`, {
|
|
1067
1075
|
worktreePath: shortWorktreePath(worktreePath),
|
|
1068
1076
|
});
|
|
1077
|
+
// NOT-364: re-materialize the FROZEN attachment manifest's durable files
|
|
1078
|
+
// under `.agent-dealer-inputs/linear/` for every attempt — first, repair,
|
|
1079
|
+
// and retry rounds all see the same bytes at the same prompt-listed,
|
|
1080
|
+
// git-ignored path. A missing or corrupt blob fails closed (no spawn on
|
|
1081
|
+
// partial inputs) instead of running the session without its ticket files.
|
|
1082
|
+
try {
|
|
1083
|
+
const materialized = materializeSourceAttachments(worktreePath, taskSnapshot.sourceAttachments);
|
|
1084
|
+
if (materialized.length > 0) {
|
|
1085
|
+
milestone("inputs.materialized", `Developer · source attachments ready (round ${round})`, {
|
|
1086
|
+
files: materialized.map((m) => m.relativePath),
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
const reason = err instanceof SourceAttachmentError
|
|
1092
|
+
? err.message
|
|
1093
|
+
: `source attachment materialization failed: ${String(err)}`;
|
|
1094
|
+
return { kind: "adapter_failure", reason };
|
|
1095
|
+
}
|
|
1069
1096
|
// NOT-172: capture the worktree HEAD as this session's input-SHA baseline before
|
|
1070
1097
|
// the agent spawns — developer work items are never enqueued with an input SHA,
|
|
1071
1098
|
// so without this the sampler's HEAD diff (and the session_end fallback) have
|
|
@@ -1264,7 +1291,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1264
1291
|
worktreePath,
|
|
1265
1292
|
baseRef: `origin/${baseBranch}`,
|
|
1266
1293
|
}).catch(() => null);
|
|
1267
|
-
if ((ahead !== null && ahead > 0) || (ahead === null &&
|
|
1294
|
+
if ((ahead !== null && ahead > 0) || (ahead === null && localBranchPreexists)) {
|
|
1268
1295
|
reuseKinds.push("commit");
|
|
1269
1296
|
}
|
|
1270
1297
|
}
|
|
@@ -1498,7 +1525,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1498
1525
|
worktreePath,
|
|
1499
1526
|
baseRef: `origin/${baseBranch}`,
|
|
1500
1527
|
}).catch(() => null);
|
|
1501
|
-
if ((endAhead !== null && endAhead > 0) || (endAhead === null &&
|
|
1528
|
+
if ((endAhead !== null && endAhead > 0) || (endAhead === null && localBranchPreexists)) {
|
|
1502
1529
|
emitCheckpointObserved({
|
|
1503
1530
|
issueId: issue.id,
|
|
1504
1531
|
workflowInstanceId: instance.id,
|