agent-dealer 1.3.0 → 1.3.2

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.
Files changed (44) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  2. package/bundle/server/dist/adapters/github.js +50 -0
  3. package/bundle/server/dist/adapters/github.test.js +89 -1
  4. package/bundle/server/dist/coordinator/admission.test.js +115 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  6. package/bundle/server/dist/coordinator/commands.js +89 -9
  7. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  8. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  9. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  10. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  11. package/bundle/server/dist/coordinator/prompts.js +57 -2
  12. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1083 -0
  14. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -0
  15. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  16. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  18. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  19. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  20. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  21. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  22. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  23. package/bundle/server/dist/coordinator/worker-loop.js +1 -1
  24. package/bundle/server/dist/paths.js +24 -0
  25. package/bundle/server/dist/repository/admission-settings.js +5 -5
  26. package/bundle/server/dist/repository/admission-settings.test.js +71 -15
  27. package/bundle/server/dist/repository/issues.js +8 -1
  28. package/bundle/server/dist/repository/issues.test.js +34 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  31. package/bundle/server/dist/routes/issues.js +23 -8
  32. package/bundle/server/dist/routes/issues.test.js +80 -0
  33. package/bundle/server/dist/routes/queue.js +1 -1
  34. package/bundle/server/dist/routes/queue.test.js +55 -20
  35. package/bundle/server/dist/runners/process-registry.js +1 -1
  36. package/bundle/server/package.json +2 -2
  37. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  38. package/bundle/server/static-ui/index.html +1 -1
  39. package/bundle/shared/dist/queue-entries.d.ts +5 -4
  40. package/bundle/shared/dist/queue-entries.js +5 -4
  41. package/bundle/shared/package.json +1 -1
  42. package/package.json +1 -1
  43. package/templates/prod.env.example +5 -2
  44. package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
@@ -1,6 +1,7 @@
1
1
  // NOT-364: atomic Linear attachment import and reload reconciliation.
2
2
  import { test, before, after, beforeEach } from "node:test";
3
3
  import assert from "node:assert/strict";
4
+ import { execFileSync } from "node:child_process";
4
5
  import { createHash } from "node:crypto";
5
6
  import fs from "node:fs";
6
7
  import os from "node:os";
@@ -445,3 +446,279 @@ test("failed reload preserves the old task text and attachment snapshot", async
445
446
  assert.equal(createHash("sha256").update(fs.readFileSync(afterRows[0].blobPath)).digest("hex"), beforeBlobHash, "old blob bytes intact");
446
447
  await app.close();
447
448
  });
449
+ // NOT-422: parked scope reload — the same atomic reconciliation as the
450
+ // pre-execution path, preserving the active workflow and the open action.
451
+ const PARKED_CONTRACT_V1 = [
452
+ "Ship the friend-path run command.",
453
+ "",
454
+ "## Builder execution mode",
455
+ "feature",
456
+ "",
457
+ "## Non-goals",
458
+ "- Rewriting the installer",
459
+ "",
460
+ "## Exit predicate",
461
+ "The friend-path run command works on a clean machine with operator proof.",
462
+ "",
463
+ "## One-PR stopping point",
464
+ "Stop after the run command lands with its regression test.",
465
+ "",
466
+ "## Acceptance criteria",
467
+ "- [ ] Friend-path run works",
468
+ " Evidence: run fixtures | run the tests | green",
469
+ "- [ ] [operator] Clean-machine proof attached",
470
+ " Evidence: operator run | paste the output | reviewed",
471
+ "",
472
+ ].join("\n");
473
+ const PARKED_CONTRACT_V2 = [
474
+ "Ship the friend-path run command.",
475
+ "",
476
+ "## Builder execution mode",
477
+ "feature",
478
+ "",
479
+ "## Non-goals",
480
+ "- Rewriting the installer",
481
+ "",
482
+ "## Exit predicate",
483
+ "The friend-path run command works with simulator proof; clean-machine proof moved out.",
484
+ "",
485
+ "## One-PR stopping point",
486
+ "Stop after the run command lands with its regression test.",
487
+ "",
488
+ "## Acceptance criteria",
489
+ "- [ ] Friend-path run works",
490
+ " Evidence: run fixtures | run the tests | green",
491
+ "- [ ] Simulator proof attached",
492
+ " Evidence: simulator run | paste the output | reviewed",
493
+ "",
494
+ ].join("\n");
495
+ function parkedNodeWith(description, attachments) {
496
+ return { ...linearNodeWith(attachments), description };
497
+ }
498
+ let scopeAttachCounter = 0;
499
+ async function parkScopeWithAttachments(app) {
500
+ const { startWorkflow, applyCompletion } = await import("../coordinator/commands.js");
501
+ const { claimWorkItem } = await import("../repository/work-items.js");
502
+ const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
503
+ const { getIssue } = await import("../repository/issues.js");
504
+ scopeAttachCounter += 1;
505
+ const created = (await (await app.inject({
506
+ method: "POST",
507
+ url: "/api/issues",
508
+ payload: {
509
+ ...BASE_LINEAR,
510
+ externalId: `linear-uuid-scope-att-${scopeAttachCounter}`,
511
+ description: PARKED_CONTRACT_V1,
512
+ linearAttachments: [FILE_ATT, LINK_ATT],
513
+ },
514
+ })).json());
515
+ assert.equal(startWorkflow(created.id).ok, true);
516
+ const dev = claimWorkItem("scope-att-test", { leaseMs: 60_000 });
517
+ await applyCompletion(dev.id, dev.leaseToken, {
518
+ kind: "clean_handoff",
519
+ branch: "b",
520
+ headSha: "abc",
521
+ baseSha: "base",
522
+ prNumber: 1,
523
+ prUrl: "https://gh/pr/1",
524
+ });
525
+ const rev = claimWorkItem("scope-att-test", { leaseMs: 60_000 });
526
+ await applyCompletion(rev.id, rev.leaseToken, {
527
+ kind: "verdict",
528
+ result: ReviewerResult.parse({
529
+ verdict: "escalated",
530
+ productScopeQuestion: "Is clean-machine proof still required?",
531
+ baseSha: "b",
532
+ headSha: "h",
533
+ acceptanceCriteriaAssessment: "ok",
534
+ evidenceAssessment: "ok",
535
+ findings: [],
536
+ risks: [],
537
+ }),
538
+ });
539
+ assert.equal(getIssue(created.id).status, "needs_human");
540
+ return created.id;
541
+ }
542
+ test("NOT-422: parked scope reload recompiles the contract, reconciles attachments, and preserves the workflow", async () => {
543
+ downloadBytes.set(FILE_URL, TARBALL_V1);
544
+ const app = await buildApp();
545
+ const id = await parkScopeWithAttachments(app);
546
+ const { getIssue } = await import("../repository/issues.js");
547
+ const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
548
+ const { getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
549
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
550
+ const { getTaskSnapshot } = await import("../coordinator/commands.js");
551
+ const before = getIssue(id);
552
+ const instanceBefore = getActiveWorkflowInstance(id);
553
+ const actionBefore = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
554
+ const sessionsBefore = listWorkerSessionsForIssue(id);
555
+ const frozenBefore = getTaskSnapshot(before);
556
+ const oldBlob = listSourceAttachments(id).find((r) => r.kind === "file").blobPath;
557
+ assert.ok(fs.existsSync(oldBlob));
558
+ // Linear now: amended contract (the [operator] criterion is gone), the same
559
+ // file id with new bytes, a brand-new file, and the link removed.
560
+ const FILE_B_URL = "https://uploads.linear.app/abc/b.bin";
561
+ const TARBALL_B = Buffer.from("brand-new-bytes");
562
+ downloadBytes.set(FILE_URL, TARBALL_V2);
563
+ downloadBytes.set(FILE_B_URL, TARBALL_B);
564
+ linearNode = parkedNodeWith(PARKED_CONTRACT_V2, [
565
+ { id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
566
+ { id: "att-file-3", title: "b.bin", url: FILE_B_URL },
567
+ ]);
568
+ const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
569
+ assert.equal(res.statusCode, 200, res.body);
570
+ const reloaded = res.json();
571
+ assert.equal(reloaded.title, "NOT-1: Reloaded title");
572
+ assert.equal(reloaded.description, PARKED_CONTRACT_V2);
573
+ assert.equal(reloaded.executionContract.exitPredicate, "The friend-path run command works with simulator proof; clean-machine proof moved out.");
574
+ assert.ok(!reloaded.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
575
+ assert.ok(reloaded.acceptanceCriteria.includes("Simulator proof attached"));
576
+ // Attachments reconciled atomically: changed bytes land, the new file
577
+ // appears, and the removed link disappears. The replaced blob is retained —
578
+ // the frozen snapshot still references it until Resume re-freezes.
579
+ const rows = listSourceAttachments(id);
580
+ assert.deepEqual(rows.map((r) => r.linearAttachmentId).sort(), ["att-file-1", "att-file-3"]);
581
+ const changed = rows.find((r) => r.linearAttachmentId === "att-file-1");
582
+ assert.equal(changed.sha256, createHash("sha256").update(TARBALL_V2).digest("hex"));
583
+ assert.deepEqual(fs.readFileSync(changed.blobPath), TARBALL_V2);
584
+ assert.ok(fs.existsSync(oldBlob), "blob still referenced by the frozen snapshot is retained");
585
+ // Everything else is preserved: repository/configuration, workflow
586
+ // instance, PR/head/branch, rounds, counters, sessions, and the open action.
587
+ const after = getIssue(id);
588
+ const preserved = (issue) => ({
589
+ repo: issue.repo,
590
+ baseBranch: issue.baseBranch,
591
+ developerAgentId: issue.developerAgentId,
592
+ reviewerAgentId: issue.reviewerAgentId,
593
+ maxReviewRounds: issue.maxReviewRounds,
594
+ maxInfraAttempts: issue.maxInfraAttempts,
595
+ maxCiAttempts: issue.maxCiAttempts,
596
+ autoMerge: issue.autoMerge,
597
+ status: issue.status,
598
+ currentRound: issue.currentRound,
599
+ infraAttempts: issue.infraAttempts,
600
+ ciAttempts: issue.ciAttempts,
601
+ branch: issue.branch,
602
+ baseSha: issue.baseSha,
603
+ headSha: issue.headSha,
604
+ prNumber: issue.prNumber,
605
+ prUrl: issue.prUrl,
606
+ source: issue.source,
607
+ externalId: issue.externalId,
608
+ externalLabel: issue.externalLabel,
609
+ externalUrl: issue.externalUrl,
610
+ });
611
+ assert.deepEqual(preserved(after), preserved(before));
612
+ assert.equal(getActiveWorkflowInstance(id).id, instanceBefore.id);
613
+ assert.deepEqual(getHumanAction(actionBefore.id), { ...actionBefore });
614
+ assert.deepEqual(listWorkerSessionsForIssue(id), sessionsBefore);
615
+ assert.equal(listWorkflowEventsForIssue(id).filter((e) => e.type === "issue.source_reloaded").length, 1);
616
+ // The frozen snapshot still carries the old text AND the old manifest until Resume.
617
+ const frozenAfter = getTaskSnapshot(after);
618
+ assert.equal(frozenAfter.description, PARKED_CONTRACT_V1);
619
+ assert.ok(frozenAfter.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
620
+ assert.deepEqual(frozenAfter.sourceAttachments, frozenBefore.sourceAttachments);
621
+ assert.ok(frozenAfter.sourceAttachments.some((a) => a.linearAttachmentId === "att-link-1"));
622
+ await app.close();
623
+ });
624
+ test("NOT-422: attachment-only parked reload shows sourceAttachments pending and Resume re-freezes the manifest", async () => {
625
+ downloadBytes.set(FILE_URL, TARBALL_V1);
626
+ const app = await buildApp();
627
+ const id = await parkScopeWithAttachments(app);
628
+ const { getIssue } = await import("../repository/issues.js");
629
+ const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
630
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
631
+ const { getTaskSnapshot, resolveHumanActionAndAdvance, TASK_SNAPSHOT_ARTIFACT_KIND } = await import("../coordinator/commands.js");
632
+ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
633
+ const { materializeSourceAttachments } = await import("../coordinator/source-attachments.js");
634
+ const oldBlob = listSourceAttachments(id).find((r) => r.kind === "file").blobPath;
635
+ const snapshotsBefore = listArtifactsForIssue(id).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND).length;
636
+ assert.equal(snapshotsBefore, 1);
637
+ // Linear changed only the inputs: identical contract text (and the same
638
+ // derived title), new bytes for the file, the link removed.
639
+ downloadBytes.set(FILE_URL, TARBALL_V2);
640
+ linearNode = {
641
+ ...parkedNodeWith(PARKED_CONTRACT_V1, [{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL }]),
642
+ title: "import me",
643
+ };
644
+ const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
645
+ assert.equal(res.statusCode, 200, res.body);
646
+ // Text byte-identical, rows swapped, the replaced blob retained for the
647
+ // frozen snapshot that still references it.
648
+ const parked = getIssue(id);
649
+ assert.equal(parked.title, BASE_LINEAR.title);
650
+ assert.equal(parked.description, PARKED_CONTRACT_V1);
651
+ assert.ok(parked.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
652
+ assert.deepEqual(listSourceAttachments(id).map((r) => r.linearAttachmentId), ["att-file-1"]);
653
+ assert.ok(fs.existsSync(oldBlob), "replaced blob retained while the frozen snapshot references it");
654
+ assert.ok(getTaskSnapshot(parked).sourceAttachments.some((a) => a.linearAttachmentId === "att-link-1"));
655
+ // Issue Detail reads the amended inputs as pending for the next snapshot —
656
+ // and only the manifest is pending.
657
+ const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
658
+ assert.deepEqual(detail.pendingSnapshotChangedFields, ["sourceAttachments"]);
659
+ // Resume re-freezes exactly one new snapshot carrying the amended manifest.
660
+ const action = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
661
+ const resolved = resolveHumanActionAndAdvance(action.id, "operator", "resume");
662
+ assert.equal(resolved.ok, true);
663
+ const snapshots = listArtifactsForIssue(id).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND);
664
+ assert.equal(snapshots.length, snapshotsBefore + 1);
665
+ const events = listWorkflowEventsForIssue(id);
666
+ const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
667
+ assert.equal(refreshed.length, 1);
668
+ assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["sourceAttachments"]);
669
+ const types = events.map((e) => e.type);
670
+ assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
671
+ const snap = getTaskSnapshot(getIssue(id));
672
+ assert.equal(snap.sourceAttachments.length, 1);
673
+ assert.equal(snap.sourceAttachments[0].sha256, createHash("sha256").update(TARBALL_V2).digest("hex"));
674
+ // The next round materializes the amended bytes — never "stored bytes are missing".
675
+ const worktree = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-scope-att-only-"));
676
+ execFileSync("git", ["init", "-q"], { cwd: worktree });
677
+ const materialized = materializeSourceAttachments(worktree, snap.sourceAttachments);
678
+ assert.equal(materialized.length, 1);
679
+ assert.deepEqual(fs.readFileSync(path.join(worktree, materialized[0].relativePath)), TARBALL_V2);
680
+ await app.close();
681
+ });
682
+ test("NOT-422: failed parked reloads preserve prior text, attachments, workflow, and the open action", async () => {
683
+ const { getIssue } = await import("../repository/issues.js");
684
+ const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
685
+ const { getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
686
+ async function expectIntact(id, label) {
687
+ const issue = getIssue(id);
688
+ assert.equal(issue.title, BASE_LINEAR.title, `${label}: title intact`);
689
+ assert.equal(issue.description, PARKED_CONTRACT_V1, `${label}: description intact`);
690
+ assert.ok(issue.acceptanceCriteria.includes("[operator] Clean-machine proof attached"), `${label}: criteria intact`);
691
+ const rows = listSourceAttachments(id);
692
+ assert.deepEqual(rows.map((r) => r.linearAttachmentId).sort(), ["att-file-1", "att-link-1"], `${label}: attachment rows intact`);
693
+ const file = rows.find((r) => r.kind === "file");
694
+ assert.deepEqual(fs.readFileSync(file.blobPath), TARBALL_V1, `${label}: blob bytes intact`);
695
+ const action = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
696
+ assert.equal(getHumanAction(action.id).status, "open", `${label}: scope action still open`);
697
+ assert.ok(getActiveWorkflowInstance(id), `${label}: workflow still active`);
698
+ assert.equal(issue.status, "needs_human", `${label}: still parked`);
699
+ assert.ok(!listWorkflowEventsForIssue(id).some((e) => e.type === "issue.source_reloaded"), `${label}: no reload event`);
700
+ }
701
+ // A new file whose download fails: 502, prior snapshot untouched.
702
+ downloadBytes.set(FILE_URL, TARBALL_V1);
703
+ const app = await buildApp();
704
+ const failedId = await parkScopeWithAttachments(app);
705
+ const BAD_URL = "https://uploads.linear.app/abc/missing.bin";
706
+ downloadBytes.set(BAD_URL, "http500");
707
+ linearNode = parkedNodeWith(PARKED_CONTRACT_V2, [
708
+ { id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
709
+ { id: "att-file-9", title: "missing.bin", url: BAD_URL },
710
+ ]);
711
+ const failed = await app.inject({ method: "POST", url: `/api/issues/${failedId}/reload-source` });
712
+ assert.equal(failed.statusCode, 502, failed.body);
713
+ await expectIntact(failedId, "download failure");
714
+ // A malformed amended contract: actionable 400, prior snapshot untouched.
715
+ const badId = await parkScopeWithAttachments(app);
716
+ const malformed = PARKED_CONTRACT_V2.replace("feature", "teleport");
717
+ downloadBytes.set(FILE_URL, TARBALL_V1);
718
+ linearNode = parkedNodeWith(malformed, [{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL }]);
719
+ const bad = await app.inject({ method: "POST", url: `/api/issues/${badId}/reload-source` });
720
+ assert.equal(bad.statusCode, 400, bad.body);
721
+ assert.match(bad.json().error, /unknown execution mode/);
722
+ await expectIntact(badId, "contract failure");
723
+ await app.close();
724
+ });
@@ -10,7 +10,7 @@ import { listUsageEventsForIssue, summarizeIssueUsage } from "../repository/usag
10
10
  import { listWorkflowEventsForIssue, appendWorkflowEvent, listWorkflowInstancesForIssue, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
11
11
  import { listHumanActionsForIssue, listOpenHumanActions } from "../repository/human-actions.js";
12
12
  import { listFindingsForIssue } from "../repository/findings.js";
13
- import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, parkCapWait, PARKED_EDITABLE_FIELDS } from "../coordinator/commands.js";
13
+ import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, diffTaskSnapshotFromLive, parkCapWait, parkedEditableFields, parkedEditableFieldsLabel } from "../coordinator/commands.js";
14
14
  import { deleteDealerIssue } from "../coordinator/delete-issue.js";
15
15
  import { getAgent } from "../repository/agents.js";
16
16
  import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
@@ -82,6 +82,10 @@ export async function registerIssueRoutes(app) {
82
82
  const statuses = requested.length > 0
83
83
  ? requested
84
84
  : IssueStatus.options.filter((s) => s !== "closed");
85
+ // NOT-385: the direction is allowlisted — only an explicit `asc` reverses
86
+ // to oldest-first; an absent or unknown value renders newest-first
87
+ // instead of failing the list.
88
+ const direction = query.direction?.trim() === "asc" ? "asc" : "desc";
85
89
  const result = queryIssues({
86
90
  search: query.q,
87
91
  status: statuses,
@@ -89,6 +93,7 @@ export async function registerIssueRoutes(app) {
89
93
  needsAttention: query.needsAttention === "1" || query.needsAttention === "true",
90
94
  page: query.page !== undefined ? Number(query.page) : undefined,
91
95
  limit: query.limit !== undefined ? Number(query.limit) : undefined,
96
+ direction,
92
97
  });
93
98
  const openActionIssueIds = new Set(listOpenHumanActions().map((a) => a.issueId));
94
99
  return {
@@ -154,6 +159,10 @@ export async function registerIssueRoutes(app) {
154
159
  // NOT-364: durable Linear source attachments — files with safe name, size
155
160
  // and checksum plus Dealer blob path; links as labeled metadata only.
156
161
  sourceAttachments: listSourceAttachments(id),
162
+ // NOT-422: live-vs-frozen task diff — the fields a parked resume would
163
+ // re-freeze into a new task snapshot (empty = in sync, or pre-freeze).
164
+ // Issue Detail renders the pending-snapshot notice off this.
165
+ pendingSnapshotChangedFields: diffTaskSnapshotFromLive(issue),
157
166
  };
158
167
  });
159
168
  /** NOT-173: stable issue-level execution read model. The existing
@@ -348,18 +357,21 @@ export async function registerIssueRoutes(app) {
348
357
  if (getActiveWorkerSessionForIssue(id)) {
349
358
  return "Cannot edit an issue with an actively running session";
350
359
  }
351
- // NOT-185: the one active-workflow exception — parked at an open attempts_exhausted
352
- // or policy_escalation action with nothing pending/leased. A resume re-freezes the
353
- // snapshot (task fields) and the next round's profile snapshot (agent fields).
360
+ // NOT-185: the active-workflow exception — parked at an open attempts_exhausted,
361
+ // policy_escalation, or (NOT-422) product_scope_decision action with nothing
362
+ // pending/leased. A resume re-freezes the snapshot (task fields) and, at an
363
+ // attempts/policy park, the next round's profile snapshot (agent fields).
354
364
  if (getActiveWorkflowInstance(id)) {
355
365
  if (!canEditParkedIssue(fresh)) {
356
366
  return "Cannot edit an issue with an active workflow";
357
367
  }
358
368
  // Only the re-frozen inputs may change at the park: repo/base branch, the review
359
- // budget and autoMerge stay as the running workflow saw them.
360
- const blocked = Object.keys(parsed.data).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
369
+ // budget and autoMerge stay as the running workflow saw them — and at a pure
370
+ // product-scope pause the agents stay too (task text only).
371
+ const allowed = parkedEditableFields(id);
372
+ const blocked = Object.keys(parsed.data).filter((k) => !allowed.has(k));
361
373
  if (blocked.length > 0) {
362
- return `Only title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`;
374
+ return `Only ${parkedEditableFieldsLabel(id)} can be edited while parked (got: ${blocked.join(", ")})`;
363
375
  }
364
376
  }
365
377
  return null;
@@ -453,7 +465,10 @@ export async function registerIssueRoutes(app) {
453
465
  /**
454
466
  * NOT-363: reload a Linear-sourced issue's task text from its linked ticket.
455
467
  * Only while `ready` with no active workflow or running worker — a race
456
- * with admission fails closed with 409. Replaces title + description
468
+ * with admission fails closed with 409. NOT-422 adds the parked exception:
469
+ * a `needs_human` product-scope pause (open `product_scope_decision`, no
470
+ * running session, nothing pending/leased) reloads the same way and keeps
471
+ * the action open for an explicit Resume. Replaces title + description
457
472
  * atomically, recompiles the execution contract, and swaps the acceptance
458
473
  * criteria for the freshly derived ones; repo, agents, policy fields,
459
474
  * auto-merge, queue position, and coordinator-owned fields are preserved.
@@ -735,6 +735,86 @@ test("NOT-228: limit clamps to 100 and out-of-range pages keep totals", async ()
735
735
  assert.equal(pastEnd.page, 9);
736
736
  await app.close();
737
737
  });
738
+ // NOT-385: Latest with no direction renders newest-first; the ascending
739
+ // reverse renders the same cohort oldest-first, and an unknown direction
740
+ // falls back to newest-first instead of failing.
741
+ test("NOT-385: direction defaults to newest-first, asc reverses, unknown falls back", async () => {
742
+ const app = await buildApp();
743
+ const oldIssue = await r228Seed(app, "R385 order old");
744
+ const newIssue = await r228Seed(app, "R385 order new");
745
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", oldIssue.id);
746
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", newIssue.id);
747
+ const q = encodeURIComponent("R385 order");
748
+ const newest = await r228Get(app, `?q=${q}&page=1&limit=10`);
749
+ assert.deepEqual(newest.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
750
+ const explicitDesc = await r228Get(app, `?q=${q}&page=1&limit=10&direction=desc`);
751
+ assert.deepEqual(explicitDesc.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
752
+ const oldest = await r228Get(app, `?q=${q}&page=1&limit=10&direction=asc`);
753
+ assert.deepEqual(oldest.issues.map((i) => i.id), [oldIssue.id, newIssue.id]);
754
+ assert.equal(oldest.total, newest.total);
755
+ const fallback = await r228Get(app, `?q=${q}&page=1&limit=10&direction=sideways`);
756
+ assert.deepEqual(fallback.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
757
+ await app.close();
758
+ });
759
+ // NOT-385: equal `updated_at` rows paginate without duplicates or skips in
760
+ // either direction — the rowid tie-breaker follows the timestamp direction
761
+ // before LIMIT/OFFSET applies.
762
+ test("NOT-385: equal timestamps paginate repeatably in both directions", async () => {
763
+ const app = await buildApp();
764
+ const ids = [];
765
+ for (const title of ["R385 tie one", "R385 tie two", "R385 tie three"]) {
766
+ ids.push((await r228Seed(app, title)).id);
767
+ }
768
+ for (const id of ids) {
769
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", id);
770
+ }
771
+ const q = encodeURIComponent("R385 tie");
772
+ const descFirst = await r228Get(app, `?q=${q}&limit=2&page=1`);
773
+ const descSecond = await r228Get(app, `?q=${q}&limit=2&page=2`);
774
+ assert.deepEqual(descFirst.issues.map((i) => i.id), [ids[2], ids[1]]);
775
+ assert.deepEqual(descSecond.issues.map((i) => i.id), [ids[0]]);
776
+ const ascFirst = await r228Get(app, `?q=${q}&limit=2&page=1&direction=asc`);
777
+ const ascSecond = await r228Get(app, `?q=${q}&limit=2&page=2&direction=asc`);
778
+ assert.deepEqual(ascFirst.issues.map((i) => i.id), [ids[0], ids[1]]);
779
+ assert.deepEqual(ascSecond.issues.map((i) => i.id), [ids[2]]);
780
+ for (const [first, second] of [[descFirst, descSecond], [ascFirst, ascSecond]]) {
781
+ assert.equal(first.total, 3);
782
+ assert.equal(first.totalPages, 2);
783
+ assert.deepEqual(new Set([...first.issues, ...second.issues].map((i) => i.id)).size, 3, "pages neither duplicate nor skip rows");
784
+ }
785
+ await app.close();
786
+ });
787
+ // NOT-385: the direction composes with every filter — the reverse renders the
788
+ // same result set, only oldest-first.
789
+ test("NOT-385: direction coexists with the search, status, repo, and attention filters", async () => {
790
+ const app = await buildApp();
791
+ const repo = "github.com/r385/filtered";
792
+ const keep = await r228Seed(app, "R385 filter keep me", { repo });
793
+ const moved = await r228Seed(app, "R385 filter moved on", { repo });
794
+ await r228Seed(app, "R385 filter other repo", { repo: "github.com/r385/elsewhere" });
795
+ transitionIssue(moved.id, "developing");
796
+ createHumanAction({
797
+ issueId: moved.id,
798
+ actionType: "final_review",
799
+ reason: "Reviewer approved",
800
+ question: "Accept?",
801
+ responseOptions: ["complete"],
802
+ });
803
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", keep.id);
804
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", moved.id);
805
+ const q = encodeURIComponent("R385 filter");
806
+ const repoParam = encodeURIComponent(repo);
807
+ const newest = await r228Get(app, `?q=${q}&repo=${repoParam}&page=1&limit=10`);
808
+ assert.deepEqual(newest.issues.map((i) => i.id), [moved.id, keep.id]);
809
+ const oldest = await r228Get(app, `?q=${q}&repo=${repoParam}&page=1&limit=10&direction=asc`);
810
+ assert.deepEqual(oldest.issues.map((i) => i.id), [keep.id, moved.id]);
811
+ assert.equal(oldest.total, newest.total);
812
+ const byStatus = await r228Get(app, `?q=${q}&status=developing&page=1&limit=10&direction=asc`);
813
+ assert.deepEqual(byStatus.issues.map((i) => i.id), [moved.id]);
814
+ const attention = await r228Get(app, `?q=${q}&needsAttention=1&page=1&limit=10&direction=asc`);
815
+ assert.deepEqual(attention.issues.map((i) => i.id), [moved.id]);
816
+ await app.close();
817
+ });
738
818
  test("NOT-217: PATCH reviewer-only edit records before/after reviewers without touching the developer", async () => {
739
819
  const app = await buildApp();
740
820
  const created = (await app.inject({
@@ -17,7 +17,7 @@ export async function registerQueueRoutes(app) {
17
17
  return getAdmissionStatus();
18
18
  });
19
19
  /**
20
- * NOT-215: operator-chosen active-issue limit (1–2, never above the effective
20
+ * NOT-215: operator-chosen active-issue limit (1–5, never above the effective
21
21
  * worker/spawn ceiling). Persisted in the DB — survives page reload and server
22
22
  * restart. Raising fills free slots on the next tick; lowering only pauses new
23
23
  * admissions, never interrupts running work.
@@ -1,5 +1,5 @@
1
1
  // packages/server/src/routes/queue.test.ts
2
- import { test, before, beforeEach } from "node:test";
2
+ import { test, before, beforeEach, afterEach } from "node:test";
3
3
  import assert from "node:assert/strict";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
@@ -12,6 +12,10 @@ const { createIssue } = await import("../repository/issues.js");
12
12
  const { registerQueueRoutes } = await import("./queue.js");
13
13
  const { startWorkflow } = await import("../coordinator/commands.js");
14
14
  before(() => migrate());
15
+ const savedEnv = {
16
+ MAX_COORDINATOR_CONCURRENCY: process.env.MAX_COORDINATOR_CONCURRENCY,
17
+ MAX_CONCURRENT_RUNS: process.env.MAX_CONCURRENT_RUNS,
18
+ };
15
19
  beforeEach(() => {
16
20
  getDb().exec(`
17
21
  DELETE FROM queue_entries;
@@ -24,6 +28,17 @@ beforeEach(() => {
24
28
  `);
25
29
  // NOT-215: the persisted concurrency setting must not leak between tests.
26
30
  getDb().prepare("DELETE FROM intake_settings WHERE key = 'admission.maxActiveIssues'").run();
31
+ // NOT-378: ceiling assertions assume no environment overrides.
32
+ delete process.env.MAX_COORDINATOR_CONCURRENCY;
33
+ delete process.env.MAX_CONCURRENT_RUNS;
34
+ });
35
+ afterEach(() => {
36
+ for (const [key, value] of Object.entries(savedEnv)) {
37
+ if (value === undefined)
38
+ delete process.env[key];
39
+ else
40
+ process.env[key] = value;
41
+ }
27
42
  });
28
43
  async function app() {
29
44
  const f = Fastify();
@@ -87,26 +102,28 @@ test("NOT-215: GET /api/queue/status reports the truthful admission read model",
87
102
  waiting: 0,
88
103
  limit: 1,
89
104
  maxActiveIssues: 1,
90
- ceiling: 2,
91
- options: [1, 2],
105
+ // NOT-378: default five-process ceiling, options 1–5.
106
+ ceiling: 5,
107
+ options: [1, 2, 3, 4, 5],
92
108
  overCap: false,
93
109
  });
94
110
  await f.close();
95
111
  });
96
- test("NOT-215: PUT /api/queue/settings persists 1..2 and the status reflects it", async () => {
112
+ test("NOT-215: PUT /api/queue/settings persists 1..5 and the status reflects it", async () => {
97
113
  const f = await app();
98
- const put = await f.inject({
99
- method: "PUT",
100
- url: "/api/queue/settings",
101
- payload: { maxActiveIssues: 2 },
102
- });
103
- assert.equal(put.statusCode, 200);
104
- const body = put.json();
105
- assert.equal(body.maxActiveIssues, 2);
106
- assert.equal(body.limit, 2);
114
+ for (const value of [1, 2, 3, 4, 5]) {
115
+ const put = await f.inject({
116
+ method: "PUT",
117
+ url: "/api/queue/settings",
118
+ payload: { maxActiveIssues: value },
119
+ });
120
+ assert.equal(put.statusCode, 200, `accepts ${value}`);
121
+ assert.equal(put.json().maxActiveIssues, value);
122
+ assert.equal(put.json().limit, value);
123
+ }
107
124
  const status = await f.inject({ method: "GET", url: "/api/queue/status" });
108
- assert.equal(status.json().maxActiveIssues, 2);
109
- assert.equal(status.json().limit, 2);
125
+ assert.equal(status.json().maxActiveIssues, 5);
126
+ assert.equal(status.json().limit, 5);
110
127
  await f.close();
111
128
  });
112
129
  test("NOT-215: the persisted limit survives a server restart (fresh app, same DB)", async () => {
@@ -114,30 +131,48 @@ test("NOT-215: the persisted limit survives a server restart (fresh app, same DB
114
131
  const put = await f.inject({
115
132
  method: "PUT",
116
133
  url: "/api/queue/settings",
117
- payload: { maxActiveIssues: 2 },
134
+ payload: { maxActiveIssues: 5 },
118
135
  });
119
136
  assert.equal(put.statusCode, 200);
120
137
  await f.close();
121
138
  const restarted = await app();
122
139
  const status = await restarted.inject({ method: "GET", url: "/api/queue/status" });
123
- assert.equal(status.json().maxActiveIssues, 2);
124
- assert.equal(status.json().limit, 2);
140
+ assert.equal(status.json().maxActiveIssues, 5);
141
+ assert.equal(status.json().limit, 5);
125
142
  await restarted.close();
126
143
  });
127
- test("NOT-215: PUT /api/queue/settings rejects values outside 1..2", async () => {
144
+ test("NOT-215: PUT /api/queue/settings rejects values outside 1..5", async () => {
128
145
  const f = await app();
129
146
  for (const payload of [
130
147
  { maxActiveIssues: 0 },
131
- { maxActiveIssues: 3 },
148
+ { maxActiveIssues: 6 },
149
+ { maxActiveIssues: 99 },
132
150
  { maxActiveIssues: 1.5 },
133
151
  { maxActiveIssues: "x" },
134
152
  {},
135
153
  ]) {
136
154
  const res = await f.inject({ method: "PUT", url: "/api/queue/settings", payload });
137
155
  assert.equal(res.statusCode, 400, `rejects ${JSON.stringify(payload)}`);
156
+ assert.equal(typeof res.json().error, "string");
138
157
  }
139
158
  // A rejected write leaves the stored value alone.
140
159
  const status = await f.inject({ method: "GET", url: "/api/queue/status" });
141
160
  assert.equal(status.json().maxActiveIssues, 1);
142
161
  await f.close();
143
162
  });
163
+ test("NOT-378: PUT /api/queue/settings rejects a value above the effective ceiling", async () => {
164
+ process.env.MAX_COORDINATOR_CONCURRENCY = "3";
165
+ const f = await app();
166
+ const res = await f.inject({
167
+ method: "PUT",
168
+ url: "/api/queue/settings",
169
+ payload: { maxActiveIssues: 4 },
170
+ });
171
+ assert.equal(res.statusCode, 400);
172
+ assert.equal(res.json().error, "maxActiveIssues 4 exceeds the effective worker/spawn ceiling of 3");
173
+ const status = await f.inject({ method: "GET", url: "/api/queue/status" });
174
+ assert.equal(status.json().maxActiveIssues, 1, "a rejected write leaves the stored value alone");
175
+ assert.equal(status.json().ceiling, 3);
176
+ assert.deepEqual(status.json().options, [1, 2, 3]);
177
+ await f.close();
178
+ });
@@ -3,7 +3,7 @@ const activeLogPaths = new Map();
3
3
  let activeSpawnCount = 0;
4
4
  const spawnWaiters = [];
5
5
  export function maxConcurrentSpawns() {
6
- return Number(process.env.MAX_CONCURRENT_RUNS ?? 2);
6
+ return Number(process.env.MAX_CONCURRENT_RUNS ?? 5);
7
7
  }
8
8
  export async function acquireSpawnSlot() {
9
9
  const max = maxConcurrentSpawns();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "test": "AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 npm run build -w @agent-dealer/shared && AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 node ../../scripts/run-workspace-tests.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@agent-dealer/shared": "1.3.0",
26
+ "@agent-dealer/shared": "1.3.2",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@fastify/static": "^8.2.0",
29
29
  "@modelcontextprotocol/sdk": "^1.29.0",