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
|
@@ -1042,6 +1042,21 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
1042
1042
|
ref = `origin/${opts.branchName}`;
|
|
1043
1043
|
newBranch = opts.branchName;
|
|
1044
1044
|
}
|
|
1045
|
+
else if (reuseRemoteSha === null && !(await branchExists(opts.repo, opts.branchName))) {
|
|
1046
|
+
// NOT-362: the reuse path was taken for a branch that exists nowhere — a
|
|
1047
|
+
// freshly seeded Linear `branchName` (or `issue-<id>` fallback) has no
|
|
1048
|
+
// local ref and the remote never saw it (`git worktree add <path>
|
|
1049
|
+
// <branch>` would reject the unknown ref). Cut it fresh from the base
|
|
1050
|
+
// instead of checking out a branch that does not exist.
|
|
1051
|
+
const fresh = await fetchFreshBase(opts.repo, opts.baseBranch, opts.fetchTimeoutMs);
|
|
1052
|
+
if (!fresh.ok) {
|
|
1053
|
+
return { kind: "base_unavailable", reason: fresh.reason };
|
|
1054
|
+
}
|
|
1055
|
+
ref = fresh.ref;
|
|
1056
|
+
newBranch = opts.branchName;
|
|
1057
|
+
baseSha = fresh.sha;
|
|
1058
|
+
baseRef = fresh.ref;
|
|
1059
|
+
}
|
|
1045
1060
|
else {
|
|
1046
1061
|
// NOT-219: when the remote tip is known and the local ref is strictly behind
|
|
1047
1062
|
// it (an ancestor of it), advance the local ref to the pushed tip before
|
|
@@ -1183,6 +1183,36 @@ test("NOT-197: a fresh developer branch starts from the fetched origin/<base>, n
|
|
|
1183
1183
|
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
1184
1184
|
}
|
|
1185
1185
|
});
|
|
1186
|
+
test("NOT-362: a seeded Linear branch name that exists nowhere starts round 1 from a fresh base cut", async () => {
|
|
1187
|
+
// Round-1 shape after intake seeding: developer-effect computes
|
|
1188
|
+
// reuseBranch = true from the seeded issue.branch, but the branch has no
|
|
1189
|
+
// local ref and the remote never saw it. Pre-fix this reached
|
|
1190
|
+
// `git worktree add <path> <branch>` with no newBranch and git rejected the
|
|
1191
|
+
// unknown ref; now it cuts the seeded name fresh from the fetched base.
|
|
1192
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("362seed");
|
|
1193
|
+
const branch = "yusukemuraoka/ate-4-some-work";
|
|
1194
|
+
try {
|
|
1195
|
+
assert.equal(await branchExists(isoRepo, branch), false);
|
|
1196
|
+
const resolved = await resolveDeveloperWorktree({
|
|
1197
|
+
repo: isoRepo,
|
|
1198
|
+
sessionId: "s-362-seed",
|
|
1199
|
+
branchName: branch,
|
|
1200
|
+
baseBranch: "main",
|
|
1201
|
+
reuseBranch: true,
|
|
1202
|
+
});
|
|
1203
|
+
assert.equal(resolved.kind, "created");
|
|
1204
|
+
if (resolved.kind !== "created")
|
|
1205
|
+
return;
|
|
1206
|
+
const originTip = git(isoRepo, "rev-parse", "origin/main");
|
|
1207
|
+
assert.equal(git(resolved.path, "rev-parse", "HEAD"), originTip);
|
|
1208
|
+
assert.equal(git(isoRepo, "rev-parse", branch), originTip);
|
|
1209
|
+
await removeIsoWorktree(isoRepo, resolved.path, branch);
|
|
1210
|
+
}
|
|
1211
|
+
finally {
|
|
1212
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
1213
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1186
1216
|
test("NOT-197: a failed pre-branch fetch returns base_unavailable and creates no branch", async () => {
|
|
1187
1217
|
const { isoRepo, isoRemote } = makeIsoRepoPair("fail");
|
|
1188
1218
|
try {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { resolveLinearRepoWithMappings } from "@agent-dealer/shared";
|
|
2
|
+
import { getDb } from "../db/index.js";
|
|
3
|
+
import { getIssue, listIssuesByExternalId } from "../repository/issues.js";
|
|
2
4
|
import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
3
5
|
import { listRepositoryMappings } from "../repository/repository-mappings.js";
|
|
4
6
|
import { LinearApiKeyMissingError, linearGraphqlRequest } from "./linear-graphql.js";
|
|
@@ -23,8 +25,45 @@ async function linearQuery(operation, query, variables, opts) {
|
|
|
23
25
|
* deterministic (one valid label resolves, zero is unresolved, several
|
|
24
26
|
* conflict, a bad value is invalid) and never guesses from anything else.
|
|
25
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* NOT-364: the issue's own `attachments` connection — file uploads and
|
|
30
|
+
* external links attached to the ticket itself. Comment bodies and files
|
|
31
|
+
* uploaded only inside comments are never queried and never snapshotted.
|
|
32
|
+
* `source` is Linear's own metadata scalar; non-string values are coerced to
|
|
33
|
+
* a short string so the contract stays stable regardless of shape.
|
|
34
|
+
*/
|
|
35
|
+
export function nodeToAttachment(a) {
|
|
36
|
+
if (!a || typeof a.id !== "string" || !a.id)
|
|
37
|
+
return null;
|
|
38
|
+
if (typeof a.url !== "string" || !a.url)
|
|
39
|
+
return null;
|
|
40
|
+
const out = {
|
|
41
|
+
id: a.id,
|
|
42
|
+
title: typeof a.title === "string" && a.title ? a.title : a.id,
|
|
43
|
+
url: a.url,
|
|
44
|
+
};
|
|
45
|
+
if (typeof a.subtitle === "string" && a.subtitle)
|
|
46
|
+
out.subtitle = a.subtitle;
|
|
47
|
+
if (typeof a.source === "string" && a.source) {
|
|
48
|
+
out.source = a.source;
|
|
49
|
+
}
|
|
50
|
+
else if (a.source != null && typeof a.source === "object") {
|
|
51
|
+
try {
|
|
52
|
+
const rendered = JSON.stringify(a.source);
|
|
53
|
+
if (rendered && rendered !== "{}")
|
|
54
|
+
out.source = rendered;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore unserializable source metadata
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
26
62
|
export function nodeToCandidate(n, mappings) {
|
|
27
63
|
const labels = n.labels?.nodes.map((l) => l.name) ?? [];
|
|
64
|
+
const attachments = (n.attachments?.nodes ?? [])
|
|
65
|
+
.map(nodeToAttachment)
|
|
66
|
+
.filter((a) => a !== null);
|
|
28
67
|
return {
|
|
29
68
|
id: n.id,
|
|
30
69
|
identifier: n.identifier,
|
|
@@ -33,8 +72,15 @@ export function nodeToCandidate(n, mappings) {
|
|
|
33
72
|
url: n.url,
|
|
34
73
|
state: n.state?.name,
|
|
35
74
|
teamId: n.team?.id,
|
|
75
|
+
// NOT-362: Linear's suggested branch name, when it returns one.
|
|
76
|
+
...(typeof n.branchName === "string" && n.branchName.trim()
|
|
77
|
+
? { branchName: n.branchName }
|
|
78
|
+
: {}),
|
|
36
79
|
labels,
|
|
37
80
|
repoResolution: resolveLinearRepoWithMappings(labels, mappings ?? []),
|
|
81
|
+
// Always present (possibly empty) so the UI can distinguish "no
|
|
82
|
+
// attachments" from "queried before attachments existed".
|
|
83
|
+
attachments,
|
|
38
84
|
};
|
|
39
85
|
}
|
|
40
86
|
const ISSUE_FIELDS = `
|
|
@@ -43,9 +89,11 @@ const ISSUE_FIELDS = `
|
|
|
43
89
|
title
|
|
44
90
|
description
|
|
45
91
|
url
|
|
92
|
+
branchName
|
|
46
93
|
state { name }
|
|
47
94
|
team { id }
|
|
48
95
|
labels { nodes { name } }
|
|
96
|
+
attachments { nodes { id title url subtitle source } }
|
|
49
97
|
`;
|
|
50
98
|
export async function getLinearViewer() {
|
|
51
99
|
if (!hasApiKey())
|
|
@@ -339,3 +387,52 @@ export async function lookupLinearIssue(raw) {
|
|
|
339
387
|
return null;
|
|
340
388
|
return getLinearIssue(id);
|
|
341
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* NOT-362: seed a freshly imported Linear issue's branch from Linear's own
|
|
392
|
+
* `branchName`, so published branches carry the Linear identifier on
|
|
393
|
+
* repositories Linear's GitHub integration covers.
|
|
394
|
+
*
|
|
395
|
+
* Runs once at intake (POST /api/issues, source `linear`): a branch the
|
|
396
|
+
* workflow already wrote (clean_handoff ground truth) is never overwritten,
|
|
397
|
+
* manual (`source != 'linear'`) issues are untouched, and a Linear issue with
|
|
398
|
+
* no branch name falls back to the conventional `issue-<id>` — the same value
|
|
399
|
+
* `developerBranchName` would derive, materialized so the stored row says it.
|
|
400
|
+
* First pass only: a second Dealer issue for the same ticket (NOT-141) keeps
|
|
401
|
+
* `issue-<id>` so it never reuses the first pass's branch.
|
|
402
|
+
* Best-effort: an unreadable Linear (no key, outage) leaves the branch null
|
|
403
|
+
* and never fails the import.
|
|
404
|
+
*/
|
|
405
|
+
export async function persistLinearBranchForIssue(issueId) {
|
|
406
|
+
try {
|
|
407
|
+
const issue = getIssue(issueId);
|
|
408
|
+
if (!issue || issue.source !== "linear" || !issue.externalId)
|
|
409
|
+
return issue?.branch ?? null;
|
|
410
|
+
if (issue.branch)
|
|
411
|
+
return issue.branch;
|
|
412
|
+
// NOT-362 round 3: Linear's branchName is per Linear ticket, but each Dealer
|
|
413
|
+
// issue needs a unique branch per pass. routes/issues.ts (NOT-141) creates a
|
|
414
|
+
// fresh Dealer issue when an earlier pass for the same ticket is done/closed,
|
|
415
|
+
// and auto-merge squashes without deleting the branch — so a second pass
|
|
416
|
+
// seeded with the same Linear name would check out the first pass's branch
|
|
417
|
+
// (still holding pre-squash commits) and `gh pr view <branch>` could return
|
|
418
|
+
// the old MERGED PR. Seed the Linear name only for the first pass; later
|
|
419
|
+
// passes keep the conventional `issue-<id>`.
|
|
420
|
+
const priorPass = listIssuesByExternalId(issue.source, issue.externalId).some((row) => row.id !== issue.id);
|
|
421
|
+
if (priorPass) {
|
|
422
|
+
const fallback = `issue-${issue.id}`;
|
|
423
|
+
getDb()
|
|
424
|
+
.prepare("UPDATE issues SET branch = ?, updated_at = ? WHERE id = ? AND branch IS NULL")
|
|
425
|
+
.run(fallback, new Date().toISOString(), issue.id);
|
|
426
|
+
return getIssue(issue.id)?.branch ?? fallback;
|
|
427
|
+
}
|
|
428
|
+
const candidate = await getLinearIssue(issue.externalId);
|
|
429
|
+
const branch = candidate?.branchName?.trim() || `issue-${issue.id}`;
|
|
430
|
+
getDb()
|
|
431
|
+
.prepare("UPDATE issues SET branch = ?, updated_at = ? WHERE id = ? AND branch IS NULL")
|
|
432
|
+
.run(branch, new Date().toISOString(), issue.id);
|
|
433
|
+
return getIssue(issue.id)?.branch ?? branch;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return getIssue(issueId)?.branch ?? null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { buildIssueFilter, LINEAR_CANDIDATE_PAGE_SIZE, listLinearCandidates, fetchLinearIntakeMetadata, nodeToCandidate, parseLinearIssueRef, } from "./linear-inbox.js";
|
|
6
|
+
import { buildIssueFilter, getLinearIssue, LINEAR_CANDIDATE_PAGE_SIZE, listLinearCandidates, fetchLinearIntakeMetadata, nodeToCandidate, parseLinearIssueRef, } from "./linear-inbox.js";
|
|
7
7
|
test("parseLinearIssueRef accepts identifier, URL, and UUID", () => {
|
|
8
8
|
assert.equal(parseLinearIssueRef("NOT-103"), "NOT-103");
|
|
9
9
|
assert.equal(parseLinearIssueRef(" not-90 "), "NOT-90");
|
|
@@ -263,3 +263,301 @@ test("fetchLinearIntakeMetadata fails clearly without LINEAR_API_KEY", async ()
|
|
|
263
263
|
delete process.env.LINEAR_API_KEY;
|
|
264
264
|
await assert.rejects(() => fetchLinearIntakeMetadata(), /LINEAR_API_KEY/);
|
|
265
265
|
});
|
|
266
|
+
// NOT-364: issue attachment metadata on candidates — hosted files and external
|
|
267
|
+
// links, without regressing labels, repo resolution, or the 50-item page bound.
|
|
268
|
+
test("nodeToCandidate carries file and link attachments with stable ids", () => {
|
|
269
|
+
const c = nodeToCandidate({
|
|
270
|
+
id: "uuid-1",
|
|
271
|
+
identifier: "NOT-364",
|
|
272
|
+
title: "t",
|
|
273
|
+
url: "https://linear.app/x/issue/NOT-364/t",
|
|
274
|
+
labels: { nodes: [{ name: "repo:github.com/not-so-fat/agent-dealer" }] },
|
|
275
|
+
attachments: {
|
|
276
|
+
nodes: [
|
|
277
|
+
{
|
|
278
|
+
id: "att-file-1",
|
|
279
|
+
title: "repro.tar.gz",
|
|
280
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "att-link-1",
|
|
284
|
+
title: "Design doc",
|
|
285
|
+
url: "https://docs.example.com/x",
|
|
286
|
+
subtitle: "Spec",
|
|
287
|
+
source: "google-docs",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
assert.equal(c.attachments?.length, 2);
|
|
293
|
+
assert.deepEqual(c.attachments?.[0], {
|
|
294
|
+
id: "att-file-1",
|
|
295
|
+
title: "repro.tar.gz",
|
|
296
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
297
|
+
});
|
|
298
|
+
assert.deepEqual(c.attachments?.[1], {
|
|
299
|
+
id: "att-link-1",
|
|
300
|
+
title: "Design doc",
|
|
301
|
+
url: "https://docs.example.com/x",
|
|
302
|
+
subtitle: "Spec",
|
|
303
|
+
source: "google-docs",
|
|
304
|
+
});
|
|
305
|
+
// Labels and repo resolution are untouched by the attachment connection.
|
|
306
|
+
assert.deepEqual(c.labels, ["repo:github.com/not-so-fat/agent-dealer"]);
|
|
307
|
+
assert.equal(c.repoResolution?.status, "resolved");
|
|
308
|
+
});
|
|
309
|
+
test("nodeToCandidate drops malformed attachments and coerces source metadata", () => {
|
|
310
|
+
const c = nodeToCandidate({
|
|
311
|
+
id: "uuid-1",
|
|
312
|
+
identifier: "NOT-364",
|
|
313
|
+
title: "t",
|
|
314
|
+
url: "https://linear.app/x/issue/NOT-364/t",
|
|
315
|
+
labels: { nodes: [] },
|
|
316
|
+
attachments: {
|
|
317
|
+
nodes: [
|
|
318
|
+
{ id: "", title: "no id", url: "https://uploads.linear.app/x" },
|
|
319
|
+
{ id: "att-no-url", title: "no url", url: "" },
|
|
320
|
+
{ id: "att-obj-source", title: "f", url: "https://uploads.linear.app/f", source: { kind: "slack" } },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
assert.equal(c.attachments?.length, 1);
|
|
325
|
+
assert.equal(c.attachments?.[0]?.id, "att-obj-source");
|
|
326
|
+
assert.equal(c.attachments?.[0]?.source, `{"kind":"slack"}`);
|
|
327
|
+
});
|
|
328
|
+
test("getLinearIssue carries attachment metadata on exact lookup", async () => {
|
|
329
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
330
|
+
const realFetch = globalThis.fetch;
|
|
331
|
+
globalThis.fetch = (async (_url) => ({
|
|
332
|
+
ok: true,
|
|
333
|
+
status: 200,
|
|
334
|
+
headers: new Headers(),
|
|
335
|
+
text: async () => JSON.stringify({
|
|
336
|
+
data: {
|
|
337
|
+
issue: {
|
|
338
|
+
id: "linear-uuid-9",
|
|
339
|
+
identifier: "NOT-9",
|
|
340
|
+
title: "exact",
|
|
341
|
+
url: "https://linear.app/x/issue/NOT-9/exact",
|
|
342
|
+
state: { name: "Todo" },
|
|
343
|
+
team: { id: "team-1" },
|
|
344
|
+
labels: { nodes: [] },
|
|
345
|
+
attachments: {
|
|
346
|
+
nodes: [
|
|
347
|
+
{ id: "a1", title: "repro.tar.gz", url: "https://uploads.linear.app/a/repro.tar.gz" },
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
}));
|
|
354
|
+
try {
|
|
355
|
+
const found = await getLinearIssue("NOT-9");
|
|
356
|
+
assert.equal(found?.identifier, "NOT-9");
|
|
357
|
+
assert.equal(found?.attachments?.length, 1);
|
|
358
|
+
assert.equal(found?.attachments?.[0]?.title, "repro.tar.gz");
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
globalThis.fetch = realFetch;
|
|
362
|
+
delete process.env.LINEAR_API_KEY;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
test("nodeToCandidate defaults missing attachments to an empty list", () => {
|
|
366
|
+
const c = nodeToCandidate({
|
|
367
|
+
id: "uuid-1",
|
|
368
|
+
identifier: "NOT-1",
|
|
369
|
+
title: "t",
|
|
370
|
+
url: "https://linear.app/x/issue/NOT-1/t",
|
|
371
|
+
labels: { nodes: [] },
|
|
372
|
+
});
|
|
373
|
+
assert.deepEqual(c.attachments, []);
|
|
374
|
+
});
|
|
375
|
+
// NOT-364: the candidate page still requests one bounded page and now carries
|
|
376
|
+
// the attachments connection on every issue selection.
|
|
377
|
+
test("listLinearCandidates queries attachments within the single 50-item page", async () => {
|
|
378
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-linear-inbox-att-"));
|
|
379
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
380
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
381
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
382
|
+
const { migrate } = await import("../db/index.js");
|
|
383
|
+
migrate();
|
|
384
|
+
const { patchLinearIntakeConfig } = await import("../repository/intake-settings.js");
|
|
385
|
+
patchLinearIntakeConfig({ stateFilter: ["Todo"], teamId: null, assigneeMe: false });
|
|
386
|
+
const requests = [];
|
|
387
|
+
const realFetch = globalThis.fetch;
|
|
388
|
+
globalThis.fetch = (async (_url, init) => {
|
|
389
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
390
|
+
requests.push({ query: body.query ?? "" });
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
status: 200,
|
|
394
|
+
headers: new Headers(),
|
|
395
|
+
text: async () => JSON.stringify({
|
|
396
|
+
data: {
|
|
397
|
+
issues: {
|
|
398
|
+
nodes: [
|
|
399
|
+
{
|
|
400
|
+
id: "i1",
|
|
401
|
+
identifier: "NOT-1",
|
|
402
|
+
title: "one",
|
|
403
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
404
|
+
state: { name: "Todo" },
|
|
405
|
+
team: { id: "team-9" },
|
|
406
|
+
labels: { nodes: [] },
|
|
407
|
+
attachments: {
|
|
408
|
+
nodes: [
|
|
409
|
+
{ id: "a1", title: "repro.tar.gz", url: "https://uploads.linear.app/a/repro.tar.gz" },
|
|
410
|
+
{ id: "a2", title: "Doc", url: "https://docs.example.com/x", subtitle: "s" },
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
pageInfo: { hasNextPage: false },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
const page = await listLinearCandidates();
|
|
423
|
+
assert.equal(page.candidates.length, 1);
|
|
424
|
+
assert.equal(requests.length, 1, "still a single page — no cursor walk");
|
|
425
|
+
assert.match(requests[0].query, /attachments\s*\{\s*nodes\s*\{\s*id title url subtitle source\s*\}\s*\}/);
|
|
426
|
+
assert.match(requests[0].query, new RegExp(`first:\\s*${LINEAR_CANDIDATE_PAGE_SIZE}`));
|
|
427
|
+
assert.equal(page.candidates[0].attachments?.length, 2);
|
|
428
|
+
assert.equal(page.candidates[0].attachments?.[0]?.id, "a1");
|
|
429
|
+
assert.equal(page.candidates[0].attachments?.[1]?.subtitle, "s");
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
globalThis.fetch = realFetch;
|
|
433
|
+
delete process.env.LINEAR_API_KEY;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
// NOT-362: intake persists Linear's branchName as the Dealer issue's branch.
|
|
437
|
+
async function stubGetLinearIssue(node) {
|
|
438
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
439
|
+
const { migrate } = await import("../db/index.js");
|
|
440
|
+
migrate();
|
|
441
|
+
const realFetch = globalThis.fetch;
|
|
442
|
+
globalThis.__not362RestoreFetch = realFetch;
|
|
443
|
+
globalThis.fetch = (async (_url) => {
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
status: 200,
|
|
447
|
+
headers: new Headers(),
|
|
448
|
+
text: async () => JSON.stringify({ data: { issue: node } }),
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function restoreFetch() {
|
|
453
|
+
const saved = globalThis.__not362RestoreFetch;
|
|
454
|
+
if (saved)
|
|
455
|
+
globalThis.fetch = saved;
|
|
456
|
+
delete process.env.LINEAR_API_KEY;
|
|
457
|
+
}
|
|
458
|
+
async function makeStoredIssue(source, externalId) {
|
|
459
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
460
|
+
const { createIssue } = await import("../repository/issues.js");
|
|
461
|
+
return createIssue({
|
|
462
|
+
title: "branch seed",
|
|
463
|
+
repo: "acme/app",
|
|
464
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
465
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
466
|
+
baseBranch: "main",
|
|
467
|
+
maxReviewRounds: 3,
|
|
468
|
+
maxInfraAttempts: 3,
|
|
469
|
+
source,
|
|
470
|
+
...(externalId ? { externalId } : {}),
|
|
471
|
+
}).id;
|
|
472
|
+
}
|
|
473
|
+
test("persistLinearBranchForIssue stores Linear's branchName on the issue", async () => {
|
|
474
|
+
const { getLinearIssue, persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
475
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
476
|
+
await stubGetLinearIssue({
|
|
477
|
+
id: "lin-1",
|
|
478
|
+
identifier: "NOT-1",
|
|
479
|
+
title: "one",
|
|
480
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
481
|
+
branchName: "yusukemuraoka/ate-4-some-work",
|
|
482
|
+
state: { name: "Todo" },
|
|
483
|
+
team: { id: "team-9" },
|
|
484
|
+
labels: { nodes: [] },
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
const candidate = await getLinearIssue("lin-1");
|
|
488
|
+
assert.equal(candidate?.branchName, "yusukemuraoka/ate-4-some-work");
|
|
489
|
+
const issueId = await makeStoredIssue("linear", "lin-1");
|
|
490
|
+
assert.equal(getIssue(issueId).branch, null);
|
|
491
|
+
await persistLinearBranchForIssue(issueId);
|
|
492
|
+
assert.equal(getIssue(issueId).branch, "yusukemuraoka/ate-4-some-work");
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
restoreFetch();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
test("persistLinearBranchForIssue falls back to issue-<id> with no Linear branch name", async () => {
|
|
499
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
500
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
501
|
+
await stubGetLinearIssue({
|
|
502
|
+
id: "lin-2",
|
|
503
|
+
identifier: "NOT-2",
|
|
504
|
+
title: "two",
|
|
505
|
+
url: "https://linear.app/x/issue/NOT-2/two",
|
|
506
|
+
state: { name: "Todo" },
|
|
507
|
+
team: { id: "team-9" },
|
|
508
|
+
labels: { nodes: [] },
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
const issueId = await makeStoredIssue("linear", "lin-2");
|
|
512
|
+
await persistLinearBranchForIssue(issueId);
|
|
513
|
+
assert.equal(getIssue(issueId).branch, `issue-${issueId}`);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
restoreFetch();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
test("persistLinearBranchForIssue seeds issue-<id> on a second pass for the same ticket", async () => {
|
|
520
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
521
|
+
const { getIssue, transitionIssue } = await import("../repository/issues.js");
|
|
522
|
+
await stubGetLinearIssue({
|
|
523
|
+
id: "lin-3",
|
|
524
|
+
identifier: "NOT-3",
|
|
525
|
+
title: "three",
|
|
526
|
+
url: "https://linear.app/x/issue/NOT-3/three",
|
|
527
|
+
branchName: "owner/ate-3-shared-linear-branch",
|
|
528
|
+
state: { name: "Todo" },
|
|
529
|
+
team: { id: "team-9" },
|
|
530
|
+
labels: { nodes: [] },
|
|
531
|
+
});
|
|
532
|
+
try {
|
|
533
|
+
const firstId = await makeStoredIssue("linear", "lin-3");
|
|
534
|
+
await persistLinearBranchForIssue(firstId);
|
|
535
|
+
assert.equal(getIssue(firstId).branch, "owner/ate-3-shared-linear-branch");
|
|
536
|
+
// The first pass ran to completion (NOT-141): the ticket is re-imported as
|
|
537
|
+
// a fresh Dealer issue. Reusing the Linear branch would check out the old
|
|
538
|
+
// branch (still holding pre-squash commits) and `gh pr view <branch>` could
|
|
539
|
+
// return the old MERGED PR — so the second pass must not reuse it.
|
|
540
|
+
transitionIssue(firstId, "closed");
|
|
541
|
+
const secondId = await makeStoredIssue("linear", "lin-3");
|
|
542
|
+
await persistLinearBranchForIssue(secondId);
|
|
543
|
+
assert.equal(getIssue(secondId).branch, `issue-${secondId}`);
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
restoreFetch();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
test("persistLinearBranchForIssue leaves manual issues unchanged", async () => {
|
|
550
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
551
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
552
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
553
|
+
const { migrate } = await import("../db/index.js");
|
|
554
|
+
migrate();
|
|
555
|
+
try {
|
|
556
|
+
const issueId = await makeStoredIssue("manual");
|
|
557
|
+
await persistLinearBranchForIssue(issueId);
|
|
558
|
+
assert.equal(getIssue(issueId).branch, null);
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
delete process.env.LINEAR_API_KEY;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
@@ -7,9 +7,17 @@ const STATE_BY_EVENT = {
|
|
|
7
7
|
done: "Done",
|
|
8
8
|
};
|
|
9
9
|
const workflowStateCache = new Map();
|
|
10
|
+
/** Test hook — drop cached team workflow states between cases. */
|
|
11
|
+
export function clearWorkflowStateCacheForTests() {
|
|
12
|
+
workflowStateCache.clear();
|
|
13
|
+
}
|
|
10
14
|
function webBaseUrl() {
|
|
11
15
|
return process.env.AGENT_DEALER_WEB_URL ?? "http://localhost:2222";
|
|
12
16
|
}
|
|
17
|
+
/** Link back to a Dealer issue for Linear comments. */
|
|
18
|
+
export function dealerIssueUrl(issueId) {
|
|
19
|
+
return `${webBaseUrl()}/?issue=${issueId}`;
|
|
20
|
+
}
|
|
13
21
|
async function linearMutate(operation, query, variables) {
|
|
14
22
|
return linearGraphqlRequest({ operation, query, variables });
|
|
15
23
|
}
|
|
@@ -29,25 +37,41 @@ async function getWorkflowStates(teamId) {
|
|
|
29
37
|
workflowStateCache.set(teamId, map);
|
|
30
38
|
return map;
|
|
31
39
|
}
|
|
32
|
-
|
|
40
|
+
/** Post a comment on a Linear issue — shared by the run-scoped delivery path and
|
|
41
|
+
* NOT-362's post-merge fallback (which reuses this writer instead of adding a second). */
|
|
42
|
+
export async function postLinearIssueComment(issueId, body) {
|
|
33
43
|
await linearMutate("commentCreate", `mutation Comment($issueId: String!, $body: String!) {
|
|
34
44
|
commentCreate(input: { issueId: $issueId, body: $body }) { success }
|
|
35
45
|
}`, { issueId, body });
|
|
36
46
|
}
|
|
37
|
-
|
|
47
|
+
/** Move a Linear issue to a workflow state by id — shared writer, see above. */
|
|
48
|
+
export async function setLinearIssueState(issueId, stateId) {
|
|
38
49
|
await linearMutate("issueUpdateState", `mutation UpdateIssue($issueId: String!, $stateId: String!) {
|
|
39
50
|
issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
|
|
40
51
|
}`, { issueId, stateId });
|
|
41
52
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the team's completed-state id for the terminal event — the one
|
|
55
|
+
* STATE_BY_EVENT mapping (NOT-362 reuses this resolution for the fallback
|
|
56
|
+
* instead of adding a second writer). Null when the team has no matching state.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveCompletedStateId(teamId) {
|
|
59
|
+
const states = await getWorkflowStates(teamId);
|
|
60
|
+
return states.get(STATE_BY_EVENT.done.toLowerCase()) ?? null;
|
|
61
|
+
}
|
|
62
|
+
/** The Dealer comment posted alongside the terminal write — one voice for the
|
|
63
|
+
* run-scoped delivery path and NOT-362's post-merge fallback. */
|
|
64
|
+
export function buildDoneComment(label, link, linkLabel = "View run") {
|
|
45
65
|
return [
|
|
46
66
|
`**agent-dealer** — approved and marked done (${label})`,
|
|
47
67
|
``,
|
|
48
|
-
`[
|
|
68
|
+
`[${linkLabel}](${link})`,
|
|
49
69
|
].join("\n");
|
|
50
70
|
}
|
|
71
|
+
function buildComment(run) {
|
|
72
|
+
const label = run.externalLabel ?? run.externalId ?? run.id;
|
|
73
|
+
return buildDoneComment(label, `${webBaseUrl()}/?run=${run.id}`);
|
|
74
|
+
}
|
|
51
75
|
function recordSyncAttempt(run, event, ok, detail) {
|
|
52
76
|
addArtifact(run.id, "linear_sync", { event, ok, at: new Date().toISOString(), ...detail }, "system");
|
|
53
77
|
}
|
|
@@ -63,12 +87,11 @@ export async function syncLinearForRun(run, event) {
|
|
|
63
87
|
return;
|
|
64
88
|
}
|
|
65
89
|
const targetStateName = STATE_BY_EVENT[event];
|
|
66
|
-
const
|
|
67
|
-
const stateId = states.get(targetStateName.toLowerCase());
|
|
90
|
+
const stateId = await resolveCompletedStateId(issue.teamId);
|
|
68
91
|
try {
|
|
69
|
-
await
|
|
92
|
+
await postLinearIssueComment(run.externalId, buildComment(run));
|
|
70
93
|
if (stateId) {
|
|
71
|
-
await
|
|
94
|
+
await setLinearIssueState(run.externalId, stateId);
|
|
72
95
|
}
|
|
73
96
|
else {
|
|
74
97
|
recordSyncAttempt(run, event, false, {
|
|
@@ -319,6 +319,15 @@ export function extractClaudeCapacityFromEvents(events, nowMs = Date.now(), fall
|
|
|
319
319
|
return null;
|
|
320
320
|
return { runtime: CLAUDE_RUNTIME, windows: [...byKey.values()], unavailable: [] };
|
|
321
321
|
}
|
|
322
|
+
let claudeCriticalReadingSeq = 0;
|
|
323
|
+
/**
|
|
324
|
+
* Monotonic count of successful Claude 5H/1W writes from any source (probe,
|
|
325
|
+
* local cache, live session). The probe's in-memory failure streak compares
|
|
326
|
+
* against it so a non-probe success breaks the streak too (NOT-366).
|
|
327
|
+
*/
|
|
328
|
+
export function claudeCriticalReadingWriteSeq() {
|
|
329
|
+
return claudeCriticalReadingSeq;
|
|
330
|
+
}
|
|
322
331
|
/**
|
|
323
332
|
* Persist Claude window readings as normalized capacity snapshots
|
|
324
333
|
* (per-window upserts — siblings not in this observation are left untouched,
|
|
@@ -338,21 +347,32 @@ export function recordClaudeWindowReadings(readings, runtime, nowMs = Date.now()
|
|
|
338
347
|
const persistable = normalized.filter((n) => n.unavailableReason === null && n.remainingPercent !== null);
|
|
339
348
|
if (persistable.length === 0)
|
|
340
349
|
return null;
|
|
341
|
-
const
|
|
350
|
+
const stored = new Map(listCapacitySnapshots(runtime).map((row) => [row.windowKey, row]));
|
|
342
351
|
const fresh = persistable.filter((w) => {
|
|
343
|
-
const
|
|
344
|
-
if (!
|
|
352
|
+
const prevRow = stored.get(w.windowKey);
|
|
353
|
+
if (!prevRow)
|
|
345
354
|
return true;
|
|
346
|
-
|
|
355
|
+
// NOT-366: an unavailable row (probe failure recorded over a stale
|
|
356
|
+
// reading) keeps the last-good value and its `observedAt` only for this
|
|
357
|
+
// arbitration. With no last-good value any reading wins; otherwise only
|
|
358
|
+
// a strictly newer one does — re-ingesting the same stale cache sample
|
|
359
|
+
// every poll must not wipe the recorded failure reason.
|
|
360
|
+
const unavailable = prevRow.source === "unavailable" || prevRow.unavailableReason !== null;
|
|
361
|
+
if (unavailable && prevRow.remainingPercent === null)
|
|
362
|
+
return true;
|
|
363
|
+
const prevMs = Date.parse(prevRow.observedAt);
|
|
347
364
|
const nextMs = Date.parse(w.observedAt);
|
|
348
365
|
if (!Number.isFinite(prevMs))
|
|
349
366
|
return true;
|
|
350
367
|
if (!Number.isFinite(nextMs))
|
|
351
368
|
return false;
|
|
352
|
-
return nextMs >= prevMs;
|
|
369
|
+
return unavailable ? nextMs > prevMs : nextMs >= prevMs;
|
|
353
370
|
});
|
|
354
371
|
if (fresh.length === 0)
|
|
355
372
|
return 0;
|
|
373
|
+
if (fresh.some((w) => w.criticalRole === "five_hour" || w.criticalRole === "weekly")) {
|
|
374
|
+
claudeCriticalReadingSeq += 1;
|
|
375
|
+
}
|
|
356
376
|
recordCapacitySnapshots(runtime, fresh.map((w) => ({
|
|
357
377
|
windowKey: w.windowKey,
|
|
358
378
|
providerBucket: w.providerBucket,
|