@tickernelz/paperclip-pro-server 2026.930.1 → 2026.930.3

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 (77) hide show
  1. package/dist/build-info.json +1 -1
  2. package/dist/modules/run-dispatch/adapters/postgres.d.ts +5 -1
  3. package/dist/modules/run-dispatch/adapters/postgres.d.ts.map +1 -1
  4. package/dist/modules/run-dispatch/adapters/postgres.js +2 -1
  5. package/dist/modules/run-dispatch/adapters/postgres.js.map +1 -1
  6. package/dist/modules/run-dispatch/index.d.ts +2 -0
  7. package/dist/modules/run-dispatch/index.d.ts.map +1 -1
  8. package/dist/modules/run-dispatch/index.js +1 -1
  9. package/dist/modules/run-dispatch/index.js.map +1 -1
  10. package/dist/modules/wake-queue/adapters/postgres.d.ts.map +1 -1
  11. package/dist/modules/wake-queue/adapters/postgres.js +16 -3
  12. package/dist/modules/wake-queue/adapters/postgres.js.map +1 -1
  13. package/dist/modules/wake-queue/adapters/postgres.test.js +33 -0
  14. package/dist/modules/wake-queue/adapters/postgres.test.js.map +1 -1
  15. package/dist/modules/wake-queue/application/ports.d.ts +3 -0
  16. package/dist/modules/wake-queue/application/ports.d.ts.map +1 -1
  17. package/dist/modules/wake-queue/application/ports.js.map +1 -1
  18. package/dist/modules/wake-queue/application/use-cases.d.ts.map +1 -1
  19. package/dist/modules/wake-queue/application/use-cases.js +6 -3
  20. package/dist/modules/wake-queue/application/use-cases.js.map +1 -1
  21. package/dist/modules/wake-queue/application/use-cases.test.js +52 -2
  22. package/dist/modules/wake-queue/application/use-cases.test.js.map +1 -1
  23. package/dist/routes/issues.d.ts +6 -0
  24. package/dist/routes/issues.d.ts.map +1 -1
  25. package/dist/routes/issues.js +323 -90
  26. package/dist/routes/issues.js.map +1 -1
  27. package/dist/routes/openapi.js +1 -1
  28. package/dist/routes/openapi.js.map +1 -1
  29. package/dist/services/heartbeat.d.ts +1 -0
  30. package/dist/services/heartbeat.d.ts.map +1 -1
  31. package/dist/services/heartbeat.js +100 -20
  32. package/dist/services/heartbeat.js.map +1 -1
  33. package/dist/services/native-runtime/native-session-executor.d.ts +7 -2
  34. package/dist/services/native-runtime/native-session-executor.d.ts.map +1 -1
  35. package/dist/services/native-runtime/native-session-executor.js +35 -14
  36. package/dist/services/native-runtime/native-session-executor.js.map +1 -1
  37. package/dist/services/native-runtime/native-session-executor.test.js +25 -2
  38. package/dist/services/native-runtime/native-session-executor.test.js.map +1 -1
  39. package/dist/services/recovery/service.d.ts +1 -0
  40. package/dist/services/recovery/service.d.ts.map +1 -1
  41. package/dist/services/recovery/service.js +32 -0
  42. package/dist/services/recovery/service.js.map +1 -1
  43. package/dist/services/run-identity.d.ts +1 -0
  44. package/dist/services/run-identity.d.ts.map +1 -1
  45. package/dist/services/run-identity.js +13 -3
  46. package/dist/services/run-identity.js.map +1 -1
  47. package/dist/services/steering-acknowledgements.d.ts +1 -0
  48. package/dist/services/steering-acknowledgements.d.ts.map +1 -1
  49. package/dist/services/steering-acknowledgements.js +6 -0
  50. package/dist/services/steering-acknowledgements.js.map +1 -1
  51. package/dist/services/systemd-notify.d.ts.map +1 -1
  52. package/dist/services/systemd-notify.js +6 -2
  53. package/dist/services/systemd-notify.js.map +1 -1
  54. package/dist/services/systemd-notify.test.d.ts +2 -0
  55. package/dist/services/systemd-notify.test.d.ts.map +1 -0
  56. package/dist/services/systemd-notify.test.js +35 -0
  57. package/dist/services/systemd-notify.test.js.map +1 -0
  58. package/package.json +21 -21
  59. package/skills/paperclip/SKILL.md +3 -1
  60. package/skills/paperclip/references/api-reference.md +2 -0
  61. package/ui-dist/assets/{Agents.production-BDXEK4Q0.js → Agents.production-VItybUXr.js} +2 -2
  62. package/ui-dist/assets/{CompanyActivity.production-AoQ1Xzp6.js → CompanyActivity.production-CwZwgQQV.js} +1 -1
  63. package/ui-dist/assets/{CompanyExport-Bgynl5QC.js → CompanyExport-DD7Rya_d.js} +1 -1
  64. package/ui-dist/assets/{CompanySkills.production-BtKXe0Dw.js → CompanySkills.production-Bwg9FRuw.js} +1 -1
  65. package/ui-dist/assets/{Costs.production-dqa_yTCM.js → Costs.production-lULBU1r3.js} +1 -1
  66. package/ui-dist/assets/{MarkdownEditor-DfrwLv1M.js → MarkdownEditor-BN7aGwIv.js} +1 -1
  67. package/ui-dist/assets/{OrgChart.production-Ca77vSHO.js → OrgChart.production-C7exT9VZ.js} +1 -1
  68. package/ui-dist/assets/{RoutineDetail.production-BBuPjKy1.js → RoutineDetail.production-DsZSvOON.js} +1 -1
  69. package/ui-dist/assets/{RoutineRunVariablesDialog-B1KRZJIl.js → RoutineRunVariablesDialog-DO1hY6dt.js} +1 -1
  70. package/ui-dist/assets/{Routines.production-C5HqDjoO.js → Routines.production-BsmVAcGo.js} +1 -1
  71. package/ui-dist/assets/{checkbox-757DLJRS.js → checkbox-D66RYMkN.js} +1 -1
  72. package/ui-dist/assets/{definition-Cfy5D9lX.js → definition-RMjpvqXu.js} +1 -1
  73. package/ui-dist/assets/{index-B3vYH8Jq.js → index-DU74KtHt.js} +6 -6
  74. package/ui-dist/assets/{label-BueB30Ch.js → label-BjzFqp93.js} +1 -1
  75. package/ui-dist/assets/{sheet-CQJe5sfn.js → sheet-ASM5ba9D.js} +1 -1
  76. package/ui-dist/index.html +6 -6
  77. package/ui-dist/sw.js +1 -1
@@ -8,7 +8,7 @@ import { releaseDependencyGateRecoveryHold } from "../services/dependency-gate-r
8
8
  import { documentExportFileName, extractIssueReferenceIdentifiers, requiresExecutionReconciliation } from "@tickernelz/paperclip-pro-shared";
9
9
  import { renderDocumentPdf } from "../services/document-pdf.js";
10
10
  import { validateExecutionReconciliation, markExecutionReconciliation, } from "../services/execution-recovery-resolution.js";
11
- import { storedSteeringAcknowledgement, reconcileSteeredIdentity, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity, } from "../services/run-identity.js";
11
+ import { storedSteeringAcknowledgement, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity, } from "../services/run-identity.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
13
  import { Router } from "express";
14
14
  import multer from "multer";
@@ -74,8 +74,17 @@ import { evaluateIssueThreadInteractionResolverAudience, issueThreadInteractionA
74
74
  import { resolveSelectedSuggestedTasks } from "../services/issue-thread-interactions.js";
75
75
  import { crossIssueInfluenceLimitError, crossIssueInfluenceRunContextError, observeCrossIssueInfluence, } from "../services/cross-issue-influence-limit.js";
76
76
  import { getNativeSessionSteeringState, hasLiveAdapterSteering, NativeSessionSteeringError, steerNativeSession, } from "../services/native-runtime/native-session-executor.js";
77
- import { buildQueuedCommentQueueSnapshot, decideQueuedCommentQueueSteering, queuedCommentIdsFromWakePayload, withQueuedCommentIdsInWakePayload, } from "../services/issue-queued-comment-queue.js";
77
+ import { buildQueuedCommentQueueSnapshot, decideQueuedCommentQueueSteering, queuedCommentIdsFromWakePayload, withQueuedCommentIdsInRunContext, withQueuedCommentIdsInWakePayload, } from "../services/issue-queued-comment-queue.js";
78
+ import { resultJsonWithSteeringAcknowledgement } from "../services/steering-acknowledgements.js";
79
+ import { setTimeout as sleep } from "node:timers/promises";
78
80
  const MAX_ISSUE_COMMENT_LIMIT = 500;
81
+ const DEFAULT_STEERING_RETRY = { delaysMs: [1_000, 3_000], budgetMs: 10_000, minAttemptMs: 2_000 };
82
+ const RETRYABLE_STEERING_CODES = {
83
+ steering_rejected: true,
84
+ steering_stale_turn: true,
85
+ steering_temporarily_unavailable: true,
86
+ steering_identity_pending: true,
87
+ };
79
88
  const updateIssueRouteSchema = updateIssueSchema.extend({
80
89
  interrupt: z.boolean().optional(),
81
90
  });
@@ -4814,13 +4823,15 @@ export function issueRoutes(db, storage, opts = {}) {
4814
4823
  const activeRunId = state === "deferred"
4815
4824
  ? (input.targetRunId ?? input.issue.executionRunId ?? null)
4816
4825
  : null;
4817
- const activeRun = activeRunId
4818
- ? await input.tx
4826
+ const activeRunQuery = activeRunId
4827
+ ? input.tx
4819
4828
  .select()
4820
4829
  .from(heartbeatRuns)
4821
4830
  .where(and(eq(heartbeatRuns.id, activeRunId), eq(heartbeatRuns.companyId, input.issue.companyId), input.allowStoppedTarget ? undefined : eq(heartbeatRuns.status, "running")))
4822
- .for("update")
4823
4831
  .limit(1)
4832
+ : null;
4833
+ const activeRun = activeRunQuery
4834
+ ? await (input.lockActiveRun === false ? activeRunQuery : activeRunQuery.for("update"))
4824
4835
  .then((rows) => rows[0] ?? null)
4825
4836
  : null;
4826
4837
  if (input.targetRunId) {
@@ -11549,18 +11560,156 @@ export function issueRoutes(db, storage, opts = {}) {
11549
11560
  });
11550
11561
  res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, queue));
11551
11562
  });
11563
+ const steeringRetry = opts.steeringRetry ?? DEFAULT_STEERING_RETRY;
11564
+ async function withdrawSteeredComment(input) {
11565
+ const { tx, wake, queueRun, commentId } = input;
11566
+ const remainingIds = input.queue.entries
11567
+ .map((candidate) => candidate.comment.id)
11568
+ .filter((candidateId) => candidateId !== commentId);
11569
+ const now = new Date();
11570
+ if (remainingIds.length === 0) {
11571
+ await tx
11572
+ .update(agentWakeupRequests)
11573
+ .set({ status: "cancelled", finishedAt: now, updatedAt: now })
11574
+ .where(eq(agentWakeupRequests.id, wake.id));
11575
+ if (queueRun) {
11576
+ await tx
11577
+ .update(heartbeatRuns)
11578
+ .set({
11579
+ status: "cancelled",
11580
+ finishedAt: now,
11581
+ error: "Queued message was steered into the running turn",
11582
+ errorCode: "queued_comment_steered",
11583
+ updatedAt: now,
11584
+ })
11585
+ .where(and(eq(heartbeatRuns.id, queueRun.id), eq(heartbeatRuns.status, "queued")));
11586
+ await tx
11587
+ .update(issueRows)
11588
+ .set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: now })
11589
+ .where(and(eq(issueRows.id, input.issueId), eq(issueRows.executionRunId, queueRun.id)));
11590
+ }
11591
+ return null;
11592
+ }
11593
+ if (queueRun) {
11594
+ await tx
11595
+ .update(heartbeatRuns)
11596
+ .set({
11597
+ contextSnapshot: withQueuedCommentIdsInRunContext(queueRun.contextSnapshot, remainingIds),
11598
+ updatedAt: now,
11599
+ })
11600
+ .where(and(eq(heartbeatRuns.id, queueRun.id), eq(heartbeatRuns.status, "queued")));
11601
+ }
11602
+ return tx
11603
+ .update(agentWakeupRequests)
11604
+ .set({
11605
+ payload: withQueuedCommentIdsInWakePayload(wake.payload, remainingIds),
11606
+ updatedAt: now,
11607
+ })
11608
+ .where(eq(agentWakeupRequests.id, wake.id))
11609
+ .returning()
11610
+ .then((rows) => rows[0] ?? wake);
11611
+ }
11612
+ async function recordSteeringAcknowledgement(input) {
11613
+ const now = new Date();
11614
+ await input.tx
11615
+ .update(heartbeatRuns)
11616
+ .set({
11617
+ resultJson: resultJsonWithSteeringAcknowledgement(input.commentId, {
11618
+ status: "acknowledged",
11619
+ queueId: input.queueId,
11620
+ turnId: input.turnId,
11621
+ acknowledgedAt: now.toISOString(),
11622
+ }),
11623
+ updatedAt: now,
11624
+ })
11625
+ .where(eq(heartbeatRuns.id, input.runId));
11626
+ }
11627
+ async function settleLateSteeringAcknowledgement(input) {
11628
+ await db.transaction(async (tx) => {
11629
+ await tx
11630
+ .select({ id: issueRows.id })
11631
+ .from(issueRows)
11632
+ .where(and(eq(issueRows.id, input.issue.id), eq(issueRows.companyId, input.issue.companyId)))
11633
+ .for("update");
11634
+ const run = await tx
11635
+ .select({ resultJson: heartbeatRuns.resultJson })
11636
+ .from(heartbeatRuns)
11637
+ .where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.issue.companyId)))
11638
+ .then((rows) => rows[0] ?? null);
11639
+ if (!run)
11640
+ return;
11641
+ if (input.identity)
11642
+ await acceptSteeredIdentity(tx, input.identity);
11643
+ const recorded = readObject(readObject(readObject(run.resultJson).queuedSteeringAcknowledgements)[input.commentId]);
11644
+ if (recorded.status === "acknowledged")
11645
+ return;
11646
+ const pendingWakes = await tx
11647
+ .select({ id: agentWakeupRequests.id, payload: agentWakeupRequests.payload })
11648
+ .from(agentWakeupRequests)
11649
+ .where(and(eq(agentWakeupRequests.companyId, input.issue.companyId), inArray(agentWakeupRequests.status, ["deferred_issue_execution", "queued"]), sql `${agentWakeupRequests.payload}->>'issueId' = ${input.issue.id}`));
11650
+ for (const pending of pendingWakes) {
11651
+ if (!queuedCommentIdsFromWakePayload(pending.payload).includes(input.commentId))
11652
+ continue;
11653
+ const locked = await lockQueuedCommentState({
11654
+ tx,
11655
+ issue: input.issue,
11656
+ actor: input.actor,
11657
+ queueId: pending.id,
11658
+ lockActiveRun: false,
11659
+ }).catch((error) => {
11660
+ if (error instanceof HttpError)
11661
+ return null;
11662
+ throw error;
11663
+ });
11664
+ if (!locked?.queue.entries.some((entry) => entry.comment.id === input.commentId))
11665
+ continue;
11666
+ await withdrawSteeredComment({
11667
+ tx,
11668
+ issueId: input.issue.id,
11669
+ queue: locked.queue,
11670
+ wake: locked.wake,
11671
+ queueRun: locked.queueRun,
11672
+ commentId: input.commentId,
11673
+ });
11674
+ }
11675
+ await recordSteeringAcknowledgement({
11676
+ tx,
11677
+ runId: input.runId,
11678
+ commentId: input.commentId,
11679
+ queueId: input.queueId,
11680
+ turnId: input.turnId,
11681
+ });
11682
+ });
11683
+ }
11552
11684
  async function steerQueuedCommentInTransaction(input) {
11553
11685
  const { issue, actor, commentId } = input;
11554
11686
  const responseWake = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.id, input.queueId), eq(agentWakeupRequests.companyId, issue.companyId))).then(rows => rows[0]);
11555
11687
  const response = responseWake
11556
11688
  ? await readQueuedInteractionResponse(db, issue.companyId, issue.id, responseWake.payload)
11557
11689
  : null;
11690
+ const steeringTargetRun = await db
11691
+ .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId })
11692
+ .from(heartbeatRuns)
11693
+ .where(and(eq(heartbeatRuns.id, input.targetRunId), eq(heartbeatRuns.companyId, issue.companyId)))
11694
+ .then((rows) => rows[0] ?? null);
11695
+ const steeringTargetIssue = await db
11696
+ .select({
11697
+ companyId: issueRows.companyId,
11698
+ projectId: issueRows.projectId,
11699
+ executionPolicy: issueRows.executionPolicy,
11700
+ })
11701
+ .from(issueRows)
11702
+ .where(and(eq(issueRows.id, issue.id), eq(issueRows.companyId, issue.companyId)))
11703
+ .then((rows) => rows[0] ?? null);
11704
+ const steeringExposesLowTrustRaw = steeringTargetRun !== null &&
11705
+ (await resolveAgentTrustForIssue({ agentId: steeringTargetRun.agentId, runId: steeringTargetRun.id }, issue.companyId, steeringTargetIssue))?.kind === "low_trust_review";
11558
11706
  const steeringIdentity = await reserveSteeredIdentity(db, {
11559
11707
  companyId: issue.companyId,
11560
11708
  runId: input.targetRunId,
11561
11709
  issueId: issue.id,
11562
11710
  messageId: commentId,
11563
11711
  source: response?.comment.id === commentId ? "interaction" : "comment",
11712
+ actorUserId: actor.actorType === "user" ? actor.actorId : null,
11564
11713
  });
11565
11714
  let steeringDeliveryAttempted = false;
11566
11715
  let acknowledgedTurnId = null;
@@ -11587,7 +11736,6 @@ export function issueRoutes(db, storage, opts = {}) {
11587
11736
  .select()
11588
11737
  .from(heartbeatRuns)
11589
11738
  .where(and(eq(heartbeatRuns.id, input.targetRunId), eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.agentId, retryWake.agentId)))
11590
- .for("update")
11591
11739
  .limit(1)
11592
11740
  .then((rows) => rows[0] ?? null)
11593
11741
  : null;
@@ -11618,6 +11766,7 @@ export function issueRoutes(db, storage, opts = {}) {
11618
11766
  actor,
11619
11767
  queueId: input.queueId,
11620
11768
  targetRunId: input.targetRunId,
11769
+ lockActiveRun: false,
11621
11770
  });
11622
11771
  if (!locked.activeRun) {
11623
11772
  throw conflict("The queued message targets a stale run", {
@@ -11664,58 +11813,52 @@ export function issueRoutes(db, storage, opts = {}) {
11664
11813
  });
11665
11814
  }
11666
11815
  steeringDeliveryAttempted = true;
11816
+ const activeRunId = locked.activeRun.id;
11667
11817
  const acknowledgement = (await storedSteeringAcknowledgement(tx, steeringIdentity ?? {
11668
- companyId: issue.companyId, runId: locked.activeRun.id, messageId: commentId,
11818
+ companyId: issue.companyId, runId: activeRunId, messageId: commentId,
11669
11819
  })) ??
11670
11820
  (await steerNativeSession({
11671
- runId: locked.activeRun.id,
11672
- message: entry.comment.body,
11821
+ runId: activeRunId,
11822
+ message: steeringExposesLowTrustRaw
11823
+ ? entry.comment.body
11824
+ : sanitizeQuarantinedCommentForHigherTrust({
11825
+ body: entry.comment.body,
11826
+ sourceTrust: entry.comment.sourceTrust ?? null,
11827
+ }).body,
11673
11828
  correlationId: commentId,
11674
- onAcknowledged: steeringIdentity
11675
- ? () => reconcileSteeredIdentity(db, steeringIdentity)
11676
- : undefined,
11829
+ timeoutMs: input.timeoutMs,
11830
+ onAcknowledged: ({ turnId }) => settleLateSteeringAcknowledgement({
11831
+ issue,
11832
+ actor,
11833
+ commentId,
11834
+ queueId: input.queueId,
11835
+ runId: activeRunId,
11836
+ turnId,
11837
+ identity: steeringIdentity,
11838
+ }),
11839
+ onUnacknowledged: async () => {
11840
+ if (steeringIdentity)
11841
+ await rejectSteeredIdentity(db, steeringIdentity);
11842
+ },
11677
11843
  }));
11678
11844
  if (steeringIdentity)
11679
11845
  await acceptSteeredIdentity(tx, steeringIdentity);
11680
11846
  acknowledgedTurnId = acknowledgement.turnId;
11681
- const remainingIds = locked.queue.entries
11682
- .map((candidate) => candidate.comment.id)
11683
- .filter((candidateId) => candidateId !== commentId);
11684
- const now = new Date();
11685
- const nextWake = remainingIds.length === 0
11686
- ? await tx
11687
- .update(agentWakeupRequests)
11688
- .set({ status: "cancelled", finishedAt: now, updatedAt: now })
11689
- .where(eq(agentWakeupRequests.id, locked.wake.id))
11690
- .returning()
11691
- .then(() => null)
11692
- : await tx
11693
- .update(agentWakeupRequests)
11694
- .set({
11695
- payload: withQueuedCommentIdsInWakePayload(locked.wake.payload, remainingIds),
11696
- updatedAt: now,
11697
- })
11698
- .where(eq(agentWakeupRequests.id, locked.wake.id))
11699
- .returning()
11700
- .then((rows) => rows[0] ?? locked.wake);
11701
- await tx
11702
- .update(heartbeatRuns)
11703
- .set({
11704
- resultJson: {
11705
- ...runResult,
11706
- queuedSteeringAcknowledgements: {
11707
- ...acknowledgements,
11708
- [commentId]: {
11709
- status: "acknowledged",
11710
- queueId: input.queueId,
11711
- turnId: acknowledgement.turnId,
11712
- acknowledgedAt: now.toISOString(),
11713
- },
11714
- },
11715
- },
11716
- updatedAt: now,
11717
- })
11718
- .where(eq(heartbeatRuns.id, locked.activeRun.id));
11847
+ const nextWake = await withdrawSteeredComment({
11848
+ tx,
11849
+ issueId: issue.id,
11850
+ queue: locked.queue,
11851
+ wake: locked.wake,
11852
+ queueRun: null,
11853
+ commentId,
11854
+ });
11855
+ await recordSteeringAcknowledgement({
11856
+ tx,
11857
+ runId: activeRunId,
11858
+ commentId,
11859
+ queueId: input.queueId,
11860
+ turnId: acknowledgement.turnId,
11861
+ });
11719
11862
  return buildQueuedCommentQueue({
11720
11863
  executor: tx,
11721
11864
  issue,
@@ -11734,22 +11877,96 @@ export function issueRoutes(db, storage, opts = {}) {
11734
11877
  if (steeringIdentity && !uncertain)
11735
11878
  await rejectSteeredIdentity(db, steeringIdentity);
11736
11879
  if (error instanceof NativeSessionSteeringError) {
11737
- throw conflict(error.message, { code: error.code, retryable: true });
11880
+ throw conflict(error.message, {
11881
+ code: error.code,
11882
+ retryable: true,
11883
+ ...(error.detail ? { detail: error.detail } : {}),
11884
+ });
11738
11885
  }
11739
11886
  throw error;
11740
11887
  }
11741
11888
  return { queue, duplicate, acknowledgedTurnId };
11742
11889
  }
11890
+ function steeringErrorCode(error) {
11891
+ if (!(error instanceof HttpError))
11892
+ return null;
11893
+ const details = readObject(error.details);
11894
+ return typeof details.code === "string" ? details.code : null;
11895
+ }
11896
+ async function resolveQueuedSteeringTarget(input) {
11897
+ try {
11898
+ const issue = await svc.getById(input.issue.id);
11899
+ const activeRun = issue ? await resolveActiveIssueRun(issue) : null;
11900
+ const queue = await buildQueuedCommentQueue({
11901
+ executor: db,
11902
+ issue: issue ?? input.issue,
11903
+ activeRun,
11904
+ actor: input.actor,
11905
+ steeringDisposition: "temporarily_unavailable",
11906
+ });
11907
+ const entry = queue.entries.find((candidate) => candidate.comment.id === input.commentId);
11908
+ if (entry && queue.protocol !== "paperclip_runner_v1") {
11909
+ return { kind: "unavailable", reason: "legacy_protocol" };
11910
+ }
11911
+ return entry && queue.queueId && queue.targetRunId
11912
+ ? {
11913
+ kind: "target",
11914
+ target: { queueId: queue.queueId, targetRunId: queue.targetRunId, revision: queue.revision },
11915
+ }
11916
+ : { kind: "none" };
11917
+ }
11918
+ catch (err) {
11919
+ logger.warn({ err, issueId: input.issue.id, commentId: input.commentId }, "failed to resolve the steering target for a posted comment");
11920
+ return { kind: "unavailable", reason: "steering_failed" };
11921
+ }
11922
+ }
11923
+ async function steerQueuedCommentWithRetry(input) {
11924
+ const deadline = Date.now() + steeringRetry.budgetMs;
11925
+ let target = input.target;
11926
+ for (let attempt = 0;; attempt += 1) {
11927
+ try {
11928
+ return await steerQueuedCommentInTransaction({
11929
+ issue: input.issue,
11930
+ actor: input.actor,
11931
+ commentId: input.commentId,
11932
+ queueId: target.queueId,
11933
+ targetRunId: target.targetRunId,
11934
+ revision: target.revision,
11935
+ timeoutMs: Math.max(steeringRetry.minAttemptMs, deadline - Date.now()),
11936
+ });
11937
+ }
11938
+ catch (error) {
11939
+ const code = steeringErrorCode(error);
11940
+ const delayMs = steeringRetry.delaysMs[attempt];
11941
+ const retryable = code !== null &&
11942
+ (RETRYABLE_STEERING_CODES[code] === true ||
11943
+ (input.targetIsServerResolved && code === "queued_comment_revision_conflict"));
11944
+ if (!retryable ||
11945
+ delayMs === undefined ||
11946
+ deadline - Date.now() - delayMs < steeringRetry.minAttemptMs) {
11947
+ throw error;
11948
+ }
11949
+ logger.warn({
11950
+ issueId: input.issue.id,
11951
+ commentId: input.commentId,
11952
+ targetRunId: target.targetRunId,
11953
+ attempt: attempt + 1,
11954
+ code,
11955
+ }, "retrying a transiently refused steer");
11956
+ await sleep(delayMs);
11957
+ const next = await resolveQueuedSteeringTarget(input);
11958
+ if (next.kind !== "target" || next.target.targetRunId !== target.targetRunId)
11959
+ throw error;
11960
+ target = next.target;
11961
+ }
11962
+ }
11963
+ }
11743
11964
  function steeringUnavailableFromError(error) {
11744
- const code = error instanceof HttpError &&
11745
- error.details &&
11746
- typeof error.details === "object" &&
11747
- !Array.isArray(error.details) &&
11748
- typeof error.details.code === "string"
11749
- ? error.details.code
11750
- : null;
11965
+ const code = steeringErrorCode(error);
11751
11966
  if (code === "steering_unsupported")
11752
11967
  return "legacy_protocol";
11968
+ if (code === "steering_identity_mismatch")
11969
+ return "identity_mismatch";
11753
11970
  if (code === "queued_comment_stale_target" ||
11754
11971
  code === "queued_comment_not_pending" ||
11755
11972
  code === "queued_comment_already_dispatching" ||
@@ -11775,6 +11992,25 @@ export function issueRoutes(db, storage, opts = {}) {
11775
11992
  .limit(1);
11776
11993
  return !earlier;
11777
11994
  }
11995
+ async function agentSteeringBlock(input) {
11996
+ const [run] = await db
11997
+ .select({ agentId: heartbeatRuns.agentId, responsibleUserId: heartbeatRuns.responsibleUserId })
11998
+ .from(heartbeatRuns)
11999
+ .where(and(eq(heartbeatRuns.id, input.targetRunId), eq(heartbeatRuns.companyId, input.companyId)));
12000
+ if (!run)
12001
+ return "no_active_run";
12002
+ if (input.actorAgentId && run.agentId === input.actorAgentId)
12003
+ return "board_only";
12004
+ const [comment] = await db
12005
+ .select({ authorUserId: issueComments.authorUserId, onBehalfOfUserId: issueComments.onBehalfOfUserId })
12006
+ .from(issueComments)
12007
+ .where(and(eq(issueComments.id, input.commentId), eq(issueComments.companyId, input.companyId)));
12008
+ if (!comment)
12009
+ return "steering_failed";
12010
+ const matchesRunIdentity = comment.authorUserId !== null ||
12011
+ (comment.onBehalfOfUserId !== null && comment.onBehalfOfUserId === run.responsibleUserId);
12012
+ return matchesRunIdentity ? null : "identity_mismatch";
12013
+ }
11778
12014
  /**
11779
12015
  * Resolve how a freshly posted message was actually delivered, and steer it
11780
12016
  * when the resolved mode is `"steer"`.
@@ -11789,7 +12025,11 @@ export function issueRoutes(db, storage, opts = {}) {
11789
12025
  if (effective === "queue") {
11790
12026
  return { deliveredAs: "queued", steeringUnavailable: "not_requested" };
11791
12027
  }
11792
- if (input.actor.actorType === "agent" || !input.boardUserId) {
12028
+ if (input.actor.actorType !== "agent" && !input.boardUserId) {
12029
+ return { deliveredAs: "queued", steeringUnavailable: "board_only" };
12030
+ }
12031
+ if (input.actor.actorType === "agent" &&
12032
+ (!input.actor.agentId || input.actor.agentId === input.issue.assigneeAgentId)) {
11793
12033
  return { deliveredAs: "queued", steeringUnavailable: "board_only" };
11794
12034
  }
11795
12035
  if (input.issue.conversationAgentId) {
@@ -11806,45 +12046,35 @@ export function issueRoutes(db, storage, opts = {}) {
11806
12046
  catch (err) {
11807
12047
  logger.warn({ err, issueId: input.issue.id, commentId: input.commentId }, "failed to complete the comment wake dispatch before steering");
11808
12048
  }
11809
- let target = null;
11810
- try {
11811
- const issue = await svc.getById(input.issue.id);
11812
- const activeRun = issue ? await resolveActiveIssueRun(issue) : null;
11813
- const queue = await buildQueuedCommentQueue({
11814
- executor: db,
11815
- issue: issue ?? input.issue,
11816
- activeRun,
11817
- actor: input.actor,
11818
- steeringDisposition: "temporarily_unavailable",
11819
- });
11820
- const entry = queue.entries.find((candidate) => candidate.comment.id === input.commentId);
11821
- if (entry && queue.protocol !== "paperclip_runner_v1") {
11822
- return { deliveredAs: "queued", steeringUnavailable: "legacy_protocol" };
11823
- }
11824
- target =
11825
- entry && queue.queueId && queue.targetRunId
11826
- ? { queueId: queue.queueId, targetRunId: queue.targetRunId, revision: queue.revision }
11827
- : null;
11828
- }
11829
- catch (err) {
11830
- logger.warn({ err, issueId: input.issue.id, commentId: input.commentId }, "failed to resolve the steering target for a posted comment");
11831
- return { deliveredAs: "queued", steeringUnavailable: "steering_failed" };
12049
+ const resolved = await resolveQueuedSteeringTarget(input);
12050
+ if (resolved.kind === "unavailable") {
12051
+ return { deliveredAs: "queued", steeringUnavailable: resolved.reason };
11832
12052
  }
12053
+ const target = resolved.kind === "target" ? resolved.target : null;
11833
12054
  if (!target) {
11834
12055
  return {
11835
12056
  deliveredAs: "queued",
11836
12057
  steeringUnavailable: input.requested === "steer" ? "no_active_run" : "not_requested",
11837
12058
  };
11838
12059
  }
12060
+ if (input.actor.actorType === "agent") {
12061
+ const blocked = await agentSteeringBlock({
12062
+ companyId: input.issue.companyId,
12063
+ commentId: input.commentId,
12064
+ actorAgentId: input.actor.agentId ?? null,
12065
+ targetRunId: target.targetRunId,
12066
+ });
12067
+ if (blocked)
12068
+ return { deliveredAs: "queued", steeringUnavailable: blocked };
12069
+ }
11839
12070
  let steered;
11840
12071
  try {
11841
- steered = await steerQueuedCommentInTransaction({
12072
+ steered = await steerQueuedCommentWithRetry({
11842
12073
  issue: input.issue,
11843
12074
  actor: input.actor,
11844
12075
  commentId: input.commentId,
11845
- queueId: target.queueId,
11846
- targetRunId: target.targetRunId,
11847
- revision: target.revision,
12076
+ target,
12077
+ targetIsServerResolved: true,
11848
12078
  });
11849
12079
  }
11850
12080
  catch (error) {
@@ -11905,13 +12135,16 @@ export function issueRoutes(db, storage, opts = {}) {
11905
12135
  if (!decision.allowed)
11906
12136
  throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
11907
12137
  const actor = getActorInfo(req);
11908
- const { queue, duplicate, acknowledgedTurnId } = await steerQueuedCommentInTransaction({
12138
+ const { queue, duplicate, acknowledgedTurnId } = await steerQueuedCommentWithRetry({
11909
12139
  issue,
11910
12140
  actor,
11911
12141
  commentId,
11912
- queueId: req.body.queueId,
11913
- targetRunId: req.body.targetRunId,
11914
- revision: req.body.revision,
12142
+ target: {
12143
+ queueId: req.body.queueId,
12144
+ targetRunId: req.body.targetRunId,
12145
+ revision: req.body.revision,
12146
+ },
12147
+ targetIsServerResolved: false,
11915
12148
  });
11916
12149
  await logQueuedCommentSteered({
11917
12150
  issue,