mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Each task has one assignee. Explicit assignment and review requests select who should act. > - An agent mention started another agent on a task it did not own. Native attachment staging then rejected that run. > - Allowing that run through startup could also let two agents work on the same task. > - Mentions should identify relevant context. They should not start work or forward comments to other tasks. > - A personal app installed on a shared agent must also wait until tool use to resolve the current user's grant. > - This pull request removes mention dispatch and keeps missing personal app credentials from blocking startup. ## Linked Issues or Issue Description **What happened?** A native agent mentioned on another agent's task failed with `paperclip_runner_attachment_staging_not_authorized`. The source task could already be complete. A nearby optional-app warning was a separate problem: personal app tools were excluded when their shared health state required attention. **Expected behavior** An agent mention is context only. It does not wake the agent, take ownership, or copy a comment onto another task. Normal feedback still reaches the assignee. Assignment and explicit review requests still dispatch work. An unavailable personal app does not block startup or produce a startup warning. Tool use requests the current user's authorization and never uses another user's grant. **Steps to reproduce** 1. Assign a task to agent A. Post a comment that mentions agent B, including a comment that closes A's task or references B's child task. 2. Confirm the comment retains its agent link and B receives no run or deferred wake. A can still receive normal feedback. 3. Install an active personal MCP connection on B. Give only Alice a grant and leave shared health at `error`. 4. Explicitly assign work to B for another user. Confirm it can finish without using the app. 5. Ask B to use the app. Confirm its tool call shows an inline connection request for the current user. Related work: Refs #11144. This change uses the existing execution-time personal grant resolution. ## What Changed - Remove mention dispatch from standalone comments and issue updates. Remove implicit forwarding of parent comments to a mentioned worker's child task. - Ignore new requests with the legacy mention wake reason before creating a run or deferred request. Preserve already accepted queue entries, which can combine assignments and feedback with a later mention. - Remove the native mention admission, staging, and finalization exceptions from this PR. Native task ownership checks remain intact. - Keep active, installed personal app tools available despite shared health errors. Remove optional-app startup warnings. Tool execution retains the current user's grant and policy checks. - Update agent instructions and product/API docs. Refresh generated capability source anchors. ## Verification - Red: comment-route regressions reproduced extra agent wakes and child comment forwarding. A separate regression proved that cancelling by the last coalesced reason could drop an accepted assignment. - Green: the targeted route, wake queue, heartbeat, workspace, responsible-user, MCP discovery, and HTTP gateway suites passed. The final queue and heartbeat rerun passed 104 tests, the restored queue adapter passed 56, and both comment-route suites passed 135. These include accepted assignment preservation, rejection of new mention requests, and normal assignee feedback. - `pnpm -r typecheck` and `pnpm build` passed locally. The full local `pnpm test:run` attempt was interrupted for review/CI fixes, so it is not claimed as a completed local pass. It exposed a cleanup timing race in the concurrent-mention assertion, now fixed and verified across 10 repetitions. CI also exposed an obsolete test waiting for the removed mention lookup; it was reproduced and fixed, then both comment suites passed. Final full-suite verification is through CI. - Final head `bd9ea4cb05a8f081c54e017760a8999f9ea6ef44`: 54 checks passed, 2 Storybook checks intentionally skipped; no pending or failing checks. Full CI includes general and serialized suites, all 8 browser shards, runner verification, typecheck, build, and canary dry run. Greptile is 5/5 on this exact commit, with no unresolved findings. - One unchanged Cursor adapter test hit its 10-second CI timeout. All 5 tests in that file passed locally; one retry of its CI shard passed all 674 tests (3 skipped). The aggregate verification gate then passed. No code or timeout was changed for that retry. - Live browser check: inserted a structured mention with the picker on a human-owned task. The saved link remained visible. Database checks found zero new runs and zero wake requests. - Live Codex runner check: explicitly assigned that task with the unavailable personal app attached. The run succeeded and committed completion without using the app or creating a connection card. - Live browser follow-up: asked the assignee to call PostHog and mentioned another enabled agent as context. Only the assignee ran. It succeeded and displayed the existing inline connection card. Only Alice's grant existed; the run belonged to a different user. - The HTTP regression covers tool discovery with no provider calls or connection cards, first use returning the current user's authorization request, and successful retry after that user's grant exists. - App checks use an isolated local fixture and a fake MCP provider. They do not use production app credentials. ## Risks - Intentional behavior change: workflows that used mentions to wake agents must use assignment, a bounded child task, or an explicit review request. - Already accepted queue entries retain their prior rules. An old entry can combine assignment or feedback with a later mention; its last reason cannot safely identify mention-only work. New mention requests create no run or deferred wake. - Personal apps with a shared health error remain discoverable. Actual tool use still requires the responsible user's grant and existing policy gates. - No database migration or public API schema change. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact serving model ID and context-window size are not exposed in this session. - Live native-run verification used `gpt-6-astra` through the Codex provider. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
4250 lines
137 KiB
TypeScript
4250 lines
137 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { and, asc, eq, sql } from "drizzle-orm";
|
|
import { WebSocketServer } from "ws";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
agents,
|
|
agentWakeupRequests,
|
|
chatActions,
|
|
chatConversations,
|
|
chatDeliveries,
|
|
chatEndpointResources,
|
|
chatEndpoints,
|
|
chatExternalPrincipals,
|
|
chatIdentityLinks,
|
|
chatMessageLinks,
|
|
chatPublications,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueRecoveryActions,
|
|
issueQuestionResponseDeliveries,
|
|
issueThreadInteractions,
|
|
issues,
|
|
issueTreeHolds,
|
|
nativeRunFinalizations,
|
|
toolApplications,
|
|
toolConnections,
|
|
} from "@paperclipai/db";
|
|
import { runningProcesses } from "../adapters/index.js";
|
|
import { heartbeatService } from "../services/heartbeat.ts";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
import { isRetiredExternalChatQuestionSource, questionResponseDeliveryService } from "../services/question-response-delivery.js";
|
|
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.ts";
|
|
import { parseWakePayloadFromMessage } from "./helpers/wake-message.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
|
? describe
|
|
: describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres heartbeat comment wake batching tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
async function waitFor(
|
|
condition: () => boolean | Promise<boolean>,
|
|
timeoutMs = 10_000,
|
|
intervalMs = 50,
|
|
) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (await condition()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
throw new Error("Timed out waiting for condition");
|
|
}
|
|
|
|
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
|
|
await db?.$client?.end?.({ timeout: 0 });
|
|
}
|
|
|
|
async function createControlledGatewayServer(beforeComplete?: (turn: number) => Promise<void>) {
|
|
const server = createServer();
|
|
const wss = new WebSocketServer({ server });
|
|
const agentPayloads: Array<Record<string, unknown>> = [];
|
|
let firstWaitRelease: (() => void) | null = null;
|
|
let firstWaitGate = new Promise<void>((resolve) => {
|
|
firstWaitRelease = resolve;
|
|
});
|
|
let waitCount = 0;
|
|
|
|
wss.on("connection", (socket) => {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "event",
|
|
event: "connect.challenge",
|
|
payload: { nonce: "nonce-123" },
|
|
}),
|
|
);
|
|
|
|
socket.on("message", async (raw) => {
|
|
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
const frame = JSON.parse(text) as {
|
|
type: string;
|
|
id: string;
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
};
|
|
|
|
if (frame.type !== "req") return;
|
|
|
|
if (frame.method === "connect") {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: {
|
|
type: "hello-ok",
|
|
protocol: 3,
|
|
server: { version: "test", connId: "conn-1" },
|
|
features: {
|
|
methods: ["connect", "agent", "agent.wait"],
|
|
events: ["agent"],
|
|
},
|
|
snapshot: { version: 1, ts: Date.now() },
|
|
policy: {
|
|
maxPayload: 1_000_000,
|
|
maxBufferedBytes: 1_000_000,
|
|
tickIntervalMs: 30_000,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (frame.method === "agent") {
|
|
agentPayloads.push((frame.params ?? {}) as Record<string, unknown>);
|
|
const runId =
|
|
typeof frame.params?.idempotencyKey === "string"
|
|
? frame.params.idempotencyKey
|
|
: `run-${agentPayloads.length}`;
|
|
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: {
|
|
runId,
|
|
status: "accepted",
|
|
acceptedAt: Date.now(),
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (frame.method === "agent.wait") {
|
|
waitCount += 1;
|
|
if (waitCount === 1) {
|
|
await firstWaitGate;
|
|
}
|
|
await beforeComplete?.(waitCount);
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: {
|
|
runId: frame.params?.runId,
|
|
status: "ok",
|
|
startedAt: 1,
|
|
endedAt: 2,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Failed to resolve test server address");
|
|
}
|
|
|
|
return {
|
|
url: `ws://127.0.0.1:${address.port}`,
|
|
getAgentPayloads: () => agentPayloads,
|
|
releaseFirstWait: () => {
|
|
firstWaitRelease?.();
|
|
firstWaitRelease = null;
|
|
firstWaitGate = Promise.resolve();
|
|
},
|
|
close: async () => {
|
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
},
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<
|
|
ReturnType<typeof startEmbeddedPostgresTestDatabase>
|
|
> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-heartbeat-comment-wake-",
|
|
);
|
|
db = createDb(started.connectionString);
|
|
tempDb = started;
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await heartbeatService(db).drainActiveRunExecutions();
|
|
await closeDbClient(db);
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
afterEach(() => {
|
|
runningProcesses.clear();
|
|
});
|
|
|
|
async function readGatewayWakePayload(
|
|
payload: Record<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
const message = String(payload.message ?? "");
|
|
if (message.includes("```json\n")) {
|
|
return parseWakePayloadFromMessage(message);
|
|
}
|
|
const runId = typeof payload.idempotencyKey === "string"
|
|
? payload.idempotencyKey
|
|
: null;
|
|
if (!runId) throw new Error("Gateway payload did not include its run id");
|
|
const run = await db
|
|
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
const context = run?.contextSnapshot;
|
|
const wake = context && typeof context === "object" && !Array.isArray(context)
|
|
? (context as Record<string, unknown>).paperclipWake
|
|
: null;
|
|
if (!wake || typeof wake !== "object" || Array.isArray(wake)) {
|
|
throw new Error("Gateway payload omitted JSON without a structured wake context");
|
|
}
|
|
return wake as Record<string, unknown>;
|
|
}
|
|
|
|
it("defers approval-approved wakes for a running issue so the assignee resumes after the run", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CEO",
|
|
role: "ceo",
|
|
status: "running",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
});
|
|
runningProcesses.set(runId, {
|
|
child: {} as never,
|
|
graceSec: 0,
|
|
processGroupId: null,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Hire an agent",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
executionAgentNameKey: "ceo",
|
|
executionLockedAt: new Date(),
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const followupRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "approval_approved",
|
|
payload: {
|
|
issueId,
|
|
approvalId: "approval-1",
|
|
approvalStatus: "approved",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
approvalId: "approval-1",
|
|
approvalStatus: "approved",
|
|
wakeReason: "approval_approved",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-board",
|
|
});
|
|
|
|
expect(followupRun).toBeNull();
|
|
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(deferred).not.toBeNull();
|
|
expect(deferred?.reason).toBe("issue_execution_deferred");
|
|
expect(deferred?.payload).toMatchObject({
|
|
issueId,
|
|
approvalId: "approval-1",
|
|
approvalStatus: "approved",
|
|
});
|
|
expect(
|
|
(deferred?.payload as Record<string, unknown>)._paperclipWakeContext,
|
|
).toMatchObject({
|
|
issueId,
|
|
taskId: issueId,
|
|
approvalId: "approval-1",
|
|
approvalStatus: "approved",
|
|
wakeReason: "approval_approved",
|
|
});
|
|
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.id).toBe(runId);
|
|
});
|
|
|
|
it("defers recovery hand-back wakes until the resolving run exits", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const recoveryActionId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Recovery owner",
|
|
role: "engineer",
|
|
status: "running",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "source_scoped_recovery_action",
|
|
},
|
|
});
|
|
runningProcesses.set(runId, {
|
|
child: {} as never,
|
|
graceSec: 0,
|
|
processGroupId: null,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Resume handed-back work",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
executionAgentNameKey: "recovery owner",
|
|
executionLockedAt: new Date(),
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const followupRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_recovery_action_restored",
|
|
payload: {
|
|
issueId,
|
|
recoveryActionId,
|
|
mutation: "recovery_action_resolution",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
recoveryActionId,
|
|
wakeReason: "issue_recovery_action_restored",
|
|
source: "issue.recovery_action_resolution",
|
|
},
|
|
requestedByActorType: "agent",
|
|
requestedByActorId: agentId,
|
|
});
|
|
|
|
expect(followupRun).toBeNull();
|
|
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(deferred).toMatchObject({
|
|
reason: "issue_execution_deferred",
|
|
runId: null,
|
|
payload: expect.objectContaining({
|
|
issueId,
|
|
recoveryActionId,
|
|
mutation: "recovery_action_resolution",
|
|
}),
|
|
});
|
|
expect(
|
|
(deferred?.payload as Record<string, unknown>)._paperclipWakeContext,
|
|
).toMatchObject({
|
|
issueId,
|
|
taskId: issueId,
|
|
recoveryActionId,
|
|
wakeReason: "issue_recovery_action_restored",
|
|
source: "issue.recovery_action_resolution",
|
|
});
|
|
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.id).toBe(runId);
|
|
});
|
|
|
|
it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Batch wake comments",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const comment1 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "First comment",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment1.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment1.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
createdByRunId: firstRun?.id ?? null,
|
|
body: "Heartbeat acknowledged",
|
|
});
|
|
|
|
const comment2 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Second comment",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
const comment3 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Third comment",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const secondRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment2.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment2.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
const thirdRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment3.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment3.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(secondRun).toBeNull();
|
|
expect(thirdRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
const deferredWake = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
const deferredContext = (
|
|
deferredWake?.payload as Record<string, unknown> | null
|
|
)?._paperclipWakeContext as Record<string, unknown> | undefined;
|
|
expect(deferredContext?.wakeCommentIds).toEqual([
|
|
comment2.id,
|
|
comment3.id,
|
|
]);
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(() => gateway.getAgentPayloads().length === 2);
|
|
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
|
const secondRunId =
|
|
typeof secondPayload.idempotencyKey === "string"
|
|
? secondPayload.idempotencyKey
|
|
: null;
|
|
if (!secondRunId) {
|
|
throw new Error(
|
|
"Expected forwarded gateway payload to include an idempotencyKey run id",
|
|
);
|
|
}
|
|
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
const statusesByRunId = new Map(
|
|
runs.map((run) => [run.id, run.status]),
|
|
);
|
|
return (
|
|
statusesByRunId.get(firstRun!.id) === "succeeded" &&
|
|
statusesByRunId.get(secondRunId) === "succeeded"
|
|
);
|
|
}, 90_000);
|
|
|
|
const promotedRun = await db
|
|
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, secondRunId))
|
|
.then((rows) => rows[0]);
|
|
const promotedTaskMarkdown = String(
|
|
(promotedRun.contextSnapshot as Record<string, unknown> | null)
|
|
?.paperclipTaskMarkdown ?? "",
|
|
);
|
|
expect(promotedTaskMarkdown).toContain(
|
|
"Pending wake comments (oldest to newest):",
|
|
);
|
|
expect(promotedTaskMarkdown.indexOf("Second comment")).toBeLessThan(
|
|
promotedTaskMarkdown.indexOf("Third comment"),
|
|
);
|
|
expect(promotedTaskMarkdown).not.toContain("First comment");
|
|
|
|
expect(secondPayload.paperclip).toBeUndefined();
|
|
const secondWake = await readGatewayWakePayload(secondPayload);
|
|
expect(secondWake).toMatchObject({
|
|
commentIds: [comment2.id, comment3.id],
|
|
latestCommentId: comment3.id,
|
|
});
|
|
expect(String(secondPayload.message ?? "")).toContain("Second comment");
|
|
expect(String(secondPayload.message ?? "")).toContain("Third comment");
|
|
// A fresh gateway request receives full context; the wake delta stays bounded above.
|
|
expect(String(secondPayload.message ?? "")).toContain("First comment");
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("cancels an empty deferred comment wake instead of promoting deleted input", async () => {
|
|
const gateway = await createControlledGatewayServer(async turn => {
|
|
if (turn === 1) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
});
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: { "x-openclaw-token": "gateway-token" },
|
|
payloadTemplate: { message: "wake now" },
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Discard deferred follow-up",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
const firstComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "First comment",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: firstComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: firstComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const current = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return current?.status === "running";
|
|
});
|
|
|
|
const discardedComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Delete this before the current turn finishes",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
expect(
|
|
await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: discardedComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: discardedComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
}),
|
|
).toBeNull();
|
|
await waitFor(async () =>
|
|
db
|
|
.select({ id: agentWakeupRequests.id })
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => Boolean(rows[0])),
|
|
);
|
|
const deferredWake = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]);
|
|
if (!deferredWake) throw new Error("Expected a deferred comment wake");
|
|
await db
|
|
.delete(issueComments)
|
|
.where(eq(issueComments.id, discardedComment.id));
|
|
|
|
gateway.releaseFirstWait();
|
|
await waitFor(async () => {
|
|
const wake = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, deferredWake.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return wake?.status === "cancelled";
|
|
}, 90_000);
|
|
await heartbeat.drainActiveRunExecutions();
|
|
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const runs = await db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
expect(runs.map((run) => run.id)).toEqual([firstRun!.id]);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await heartbeat.drainActiveRunExecutions();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("retains deferred comments for reconciliation after cancelling an unknown provider outcome", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Interrupt queued comment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 2,
|
|
identifier: `${issuePrefix}-2`,
|
|
});
|
|
|
|
const comment1 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Start work",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment1.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment1.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
const queuedComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorType: "user",
|
|
authorUserId: "user-1",
|
|
body: "Queued follow-up",
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "warning",
|
|
detailsDefaultOpen: false,
|
|
},
|
|
metadata: {
|
|
version: 1,
|
|
sections: [
|
|
{
|
|
rows: [
|
|
{
|
|
type: "key_value",
|
|
label: "Cause",
|
|
value: "successful_run_missing_state",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const followupRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: queuedComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: queuedComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(followupRun).toBeNull();
|
|
|
|
await heartbeat.cancelRun(firstRun!.id);
|
|
|
|
gateway.releaseFirstWait();
|
|
await heartbeat.reconcileStrandedAssignedIssues();
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
|
|
expect(runs).toEqual([expect.objectContaining({ id: firstRun!.id, status: "cancelled" })]);
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
|
|
expect(action).toMatchObject({ cause: "legacy_execution_requires_reconciliation", ownerType: "board", returnOwnerAgentId: agentId });
|
|
const [retained] = await db.select().from(issueComments).where(eq(issueComments.id, queuedComment.id));
|
|
expect(retained?.body).toBe("Queued follow-up");
|
|
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
|
|
expect(wakes.some(wake => wake.payload?.commentId === queuedComment.id && wake.status === "deferred_issue_execution")).toBe(true);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("promotes deferred comment wakes after the active run closes the issue", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Reopen after deferred comment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const comment1 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "First comment",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment1.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment1.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
const comment2 = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Please handle this follow-up after you finish",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const deferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: comment2.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment2.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(deferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "done",
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 2, 90_000);
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
const [initialRun, promotedRun] = runs;
|
|
return (
|
|
initialRun?.id === firstRun?.id &&
|
|
initialRun.status === "succeeded" &&
|
|
promotedRun?.status === "succeeded"
|
|
);
|
|
}, 90_000);
|
|
|
|
const reopenedIssue = await db
|
|
.select({
|
|
status: issues.status,
|
|
completedAt: issues.completedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(reopenedIssue).toMatchObject({
|
|
status: "in_progress",
|
|
completedAt: null,
|
|
});
|
|
|
|
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
|
expect(secondPayload.paperclip).toBeUndefined();
|
|
const secondWake = await readGatewayWakePayload(secondPayload);
|
|
expect(secondWake).toMatchObject({
|
|
reason: "issue_commented",
|
|
commentIds: [comment2.id],
|
|
latestCommentId: comment2.id,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: `${issuePrefix}-1`,
|
|
title: "Reopen after deferred comment",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
},
|
|
});
|
|
expect(String(secondPayload.message ?? "")).toContain(
|
|
"Please handle this follow-up after you finish",
|
|
);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it.each([
|
|
{ caseName: "delivers explicit agent feedback after completion", targetAssignee: true, terminalStatus: "done", explicitResume: true },
|
|
{ caseName: "cancels an assignee continuation without resume intent on completed work", targetAssignee: true, terminalStatus: "done", explicitResume: false },
|
|
{ caseName: "cancels an assignee continuation on cancelled work", targetAssignee: true, terminalStatus: "cancelled", explicitResume: true },
|
|
] as const)("$caseName", async ({ targetAssignee, terminalStatus, explicitResume }) => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const mentionedAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
const targetAgentId = targetAssignee ? assigneeAgentId : mentionedAgentId;
|
|
const shouldReopen = targetAssignee && terminalStatus === "done" && explicitResume;
|
|
const commentingAgentId = targetAssignee ? mentionedAgentId : assigneeAgentId;
|
|
const wakeReason = "issue_commented";
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Primary Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: mentionedAgentId,
|
|
companyId,
|
|
name: "Mentioned Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Agent feedback at completion boundary",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(assigneeAgentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
const comment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: commentingAgentId,
|
|
createdByRunId: targetAssignee ? null : firstRun?.id ?? null,
|
|
body: "@Mentioned Agent please review after I finish",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const deferredRun = await heartbeat.wakeup(targetAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: wakeReason,
|
|
payload: { issueId, commentId: comment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
wakeReason,
|
|
...(explicitResume ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
source: "comment.mention",
|
|
},
|
|
requestedByActorType: "agent",
|
|
requestedByActorId: commentingAgentId,
|
|
});
|
|
|
|
expect(deferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, targetAgentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: terminalStatus,
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
if (targetAssignee && !shouldReopen) {
|
|
await waitFor(async () => {
|
|
const cancelled = await db.select().from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, targetAgentId),
|
|
eq(agentWakeupRequests.status, "cancelled"),
|
|
));
|
|
return cancelled.some((wake) => wake.error === "Deferred execution wake no longer applies to a terminal task");
|
|
});
|
|
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
|
expect(runs).toEqual([expect.objectContaining({ id: firstRun!.id, status: "succeeded" })]);
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const [closedIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
expect(closedIssue).toMatchObject({ status: terminalStatus, executionRunId: null });
|
|
expect(closedIssue.completedAt).not.toBeNull();
|
|
const [retainedComment] = await db.select().from(issueComments).where(eq(issueComments.id, comment.id));
|
|
expect(retainedComment.body).toContain("please review after I finish");
|
|
return;
|
|
}
|
|
|
|
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.companyId, companyId));
|
|
return (
|
|
runs.length === 2 && runs.every((run) => run.status === "succeeded")
|
|
);
|
|
}, 90_000);
|
|
|
|
const continuation = (await db.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, targetAgentId),
|
|
))).find((run) => run.id !== firstRun!.id);
|
|
expect(continuation).toMatchObject({
|
|
agentId: targetAgentId,
|
|
contextSnapshot: expect.objectContaining({ issueId }),
|
|
});
|
|
const issueAfterPromotion = await db
|
|
.select({
|
|
status: issues.status,
|
|
completedAt: issues.completedAt,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(issueAfterPromotion).toMatchObject({
|
|
status: shouldReopen ? "in_progress" : "done",
|
|
assigneeAgentId,
|
|
});
|
|
if (shouldReopen) expect(issueAfterPromotion?.completedAt).toBeNull();
|
|
else expect(issueAfterPromotion?.completedAt).not.toBeNull();
|
|
|
|
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
|
expect(secondPayload.paperclip).toBeUndefined();
|
|
const secondWake = await readGatewayWakePayload(secondPayload);
|
|
expect(secondWake).toMatchObject({
|
|
reason: wakeReason,
|
|
commentIds: [comment.id],
|
|
latestCommentId: comment.id,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: `${issuePrefix}-1`,
|
|
title: "Agent feedback at completion boundary",
|
|
status: shouldReopen ? "in_progress" : "done",
|
|
priority: "medium",
|
|
},
|
|
});
|
|
expect(String(secondPayload.message ?? "")).toContain(
|
|
"please review after I finish",
|
|
);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("cancels a deferred wake containing only a comment authored by the closing run", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Self-comment must not reopen",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
// Local-CLI agents post comments under user auth, but stamp the heartbeat
|
|
// run id on each comment via createdByRunId. Simulate that here: a "user"
|
|
// comment that was actually authored by the run that is about to close
|
|
// the issue. Without the Path A guard this would trigger a reopen.
|
|
const selfComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-cli-user",
|
|
createdByRunId: firstRun?.id ?? null,
|
|
body: "Closing comment from the same run",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const deferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: selfComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: selfComment.id,
|
|
wakeCommentId: selfComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-cli-user",
|
|
});
|
|
|
|
expect(deferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "done",
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
),
|
|
)
|
|
.then(
|
|
(rows) =>
|
|
rows.find((request) => request.status === "cancelled") ?? null,
|
|
);
|
|
return (
|
|
run?.status === "succeeded" &&
|
|
deferred?.error ===
|
|
"Deferred wake contained only comments authored by the finishing run"
|
|
);
|
|
}, 90_000);
|
|
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
expect(runs).toHaveLength(1);
|
|
|
|
const issueAfterPromotion = await db
|
|
.select({
|
|
status: issues.status,
|
|
completedAt: issues.completedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(issueAfterPromotion).toMatchObject({
|
|
status: "done",
|
|
});
|
|
expect(issueAfterPromotion?.completedAt).not.toBeNull();
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("promotes an interaction continuation with its full authoritative source comment after removing a coalesced self-comment", async () => {
|
|
const gateway = await createControlledGatewayServer(async turn => {
|
|
if (turn === 2) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
});
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const interactionId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Interaction continuation survives self-comment filtering",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
|
|
const selfComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-cli-user",
|
|
createdByRunId: firstRun!.id,
|
|
body: "Completion note from the source run",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
const fullSourceInstruction = [
|
|
"Continue the external request after confirmation.",
|
|
"Preserve every requirement from the original provider message, including this deliberately late clause.",
|
|
"TRAILING-INSTRUCTION: reply with the exact final release identifier.",
|
|
].join("\n");
|
|
const sourceComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "external-user",
|
|
body: fullSourceInstruction,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
expect(
|
|
await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: selfComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: selfComment.id,
|
|
wakeCommentId: selfComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-cli-user",
|
|
}),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId,
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
sourceCommentId: sourceComment.id,
|
|
wakeCommentId: sourceComment.id,
|
|
wakeCommentIds: [sourceComment.id],
|
|
mutation: "interaction",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
sourceCommentId: sourceComment.id,
|
|
wakeCommentId: sourceComment.id,
|
|
wakeCommentIds: [sourceComment.id],
|
|
wakeReason: "issue_commented",
|
|
source: "issue.interaction.respond",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
}),
|
|
).toBeNull();
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
return (
|
|
runs.length === 2 &&
|
|
runs[0]?.status === "succeeded" &&
|
|
runs[1]?.status === "succeeded"
|
|
);
|
|
}, 90_000);
|
|
|
|
const promotedRun = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt))
|
|
.then((runs) => runs[1] ?? null);
|
|
expect(promotedRun?.contextSnapshot).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
sourceCommentId: sourceComment.id,
|
|
wakeCommentId: sourceComment.id,
|
|
wakeCommentIds: [sourceComment.id],
|
|
});
|
|
expect(promotedRun?.contextSnapshot).not.toMatchObject({
|
|
commentId: selfComment.id,
|
|
});
|
|
expect(gateway.getAgentPayloads()).toHaveLength(2);
|
|
const continuationWake = await readGatewayWakePayload(
|
|
gateway.getAgentPayloads()[1] ?? {},
|
|
);
|
|
expect(continuationWake?.commentIds).toEqual([sourceComment.id]);
|
|
expect(continuationWake?.comments).toEqual([
|
|
expect.objectContaining({
|
|
id: sourceComment.id,
|
|
issueId,
|
|
body: fullSourceInstruction,
|
|
bodyTruncated: false,
|
|
}),
|
|
]);
|
|
expect(String(gateway.getAgentPayloads()[1]?.message ?? "")).toContain(
|
|
"TRAILING-INSTRUCTION: reply with the exact final release identifier.",
|
|
);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it.each(["linked", "revoked", "restart", "unbound_marker", "ordinary_cancellation"] as const)(
|
|
"cancels a parked native chat source and authorizes its dedicated answer continuation (%s)",
|
|
async (identityStatus) => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const userId = `linked-slack-user-${randomUUID()}`;
|
|
const endpointId = randomUUID();
|
|
const conversationId = randomUUID();
|
|
const principalId = randomUUID();
|
|
const deliveryId = randomUUID();
|
|
const applicationId = randomUUID();
|
|
const connectionId = randomUUID();
|
|
const resourceId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "running",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: { "x-openclaw-token": "gateway-token" },
|
|
payloadTemplate: { message: "wake now" },
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Continue an answered Slack question",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
const sourceComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorType: "user",
|
|
authorUserId: userId,
|
|
body: "Build the release and preserve this full source instruction.",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
// Model an admitted, linked Slack source, not just an untrusted source
|
|
// string. The real dispatch attestor must reconstruct this durable chain.
|
|
await db.insert(toolApplications).values({
|
|
id: applicationId,
|
|
companyId,
|
|
applicationKey: `chat:slack:${endpointId}`,
|
|
name: "Slack question",
|
|
type: "chat",
|
|
status: "active",
|
|
});
|
|
await db.insert(toolConnections).values({
|
|
id: connectionId,
|
|
companyId,
|
|
applicationId,
|
|
name: "Slack question",
|
|
uid: `chat-slack-${endpointId}`,
|
|
connectionPurpose: "channel",
|
|
transport: "chat_sdk",
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
await db.insert(chatEndpoints).values({
|
|
id: endpointId,
|
|
companyId,
|
|
connectionId,
|
|
provider: "slack",
|
|
publicId: randomUUID(),
|
|
assignedAgentId: agentId,
|
|
status: "active",
|
|
providerAccountId: "slack-workspace",
|
|
allowDirectMessages: true,
|
|
allowUnlinkedPeople: false,
|
|
});
|
|
await db.insert(chatEndpointResources).values({
|
|
id: resourceId,
|
|
companyId,
|
|
endpointId,
|
|
type: "direct_message",
|
|
providerResourceId: "slack-person",
|
|
label: "Slack direct message",
|
|
availability: "available",
|
|
enabled: true,
|
|
});
|
|
await db.insert(chatConversations).values({
|
|
id: conversationId,
|
|
companyId,
|
|
endpointId,
|
|
resourceId,
|
|
issueId,
|
|
externalConversationId: "slack-person",
|
|
externalThreadId: "slack:slack-person:1",
|
|
externalLabel: "Slack direct message",
|
|
sessionGeneration: 1,
|
|
isDirectMessage: true,
|
|
communicationGuidance: "## Communication in Slack\nFrozen connection preference.",
|
|
state: "active",
|
|
});
|
|
await db.insert(chatExternalPrincipals).values({
|
|
id: principalId,
|
|
companyId,
|
|
provider: "slack",
|
|
providerAccountId: "slack-workspace",
|
|
externalId: "slack-person",
|
|
kind: "user",
|
|
});
|
|
await db.insert(chatIdentityLinks).values({
|
|
companyId,
|
|
endpointId,
|
|
principalId,
|
|
paperclipUserId: userId,
|
|
status: "linked",
|
|
});
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
});
|
|
await db.insert(chatDeliveries).values({
|
|
id: deliveryId,
|
|
companyId,
|
|
endpointId,
|
|
conversationId,
|
|
principalId,
|
|
providerEventId: "slack-source-message",
|
|
deduplicationKey: "slack-source-message",
|
|
eventKind: "message",
|
|
normalizedEvent: {},
|
|
state: "processed",
|
|
attempts: 1,
|
|
processedAt: new Date(),
|
|
});
|
|
await db.insert(chatMessageLinks).values({
|
|
companyId,
|
|
endpointId,
|
|
conversationId,
|
|
deliveryId,
|
|
commentId: sourceComment.id,
|
|
providerMessageId: "slack-source-message",
|
|
direction: "inbound",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: sourceRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
driverKind: "codex",
|
|
startedAt: new Date(),
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
source: "chat:slack",
|
|
paperclipTaskCommunicationGuidance: "FORGED caller preference",
|
|
commentId: sourceComment.id,
|
|
wakeCommentId: sourceComment.id,
|
|
wakeCommentIds: [sourceComment.id],
|
|
paperclipHarnessCheckedOut: true,
|
|
paperclipWake: {
|
|
externalChatProvider: "slack",
|
|
checkedOutByHarness: true,
|
|
issue: { id: issueId, workMode: "standard" },
|
|
commentIds: [sourceComment.id],
|
|
},
|
|
},
|
|
});
|
|
await db.insert(nativeRunFinalizations).values({
|
|
runId: sourceRunId,
|
|
companyId,
|
|
issueId,
|
|
phase: "observed",
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: sourceRunId,
|
|
executionRunId: sourceRunId,
|
|
executionAgentNameKey: "localcliagent",
|
|
executionLockedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const interactions = issueThreadInteractionService(db);
|
|
const pending = await interactions.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
sourceRunId,
|
|
sourceCommentId: sourceComment.id,
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "release",
|
|
prompt: "Continue the release?",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [{ id: "yes", label: "Yes" }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{ agentId, runId: sourceRunId },
|
|
);
|
|
const answered = await interactions.answerQuestions(
|
|
{ id: issueId, companyId, status: "in_progress" },
|
|
pending.id,
|
|
{ answers: [{ questionId: "release", optionIds: ["yes"] }] },
|
|
{ userId },
|
|
);
|
|
const publicationId = randomUUID();
|
|
await db.insert(chatPublications).values({
|
|
id: publicationId,
|
|
companyId,
|
|
endpointId,
|
|
conversationId,
|
|
issueId,
|
|
state: "published",
|
|
idempotencyKey: `question:${answered.id}`,
|
|
providerMessageId: "slack-question-card",
|
|
publishedAt: new Date(),
|
|
payload: {
|
|
text: "Continue the release?",
|
|
interactionId: answered.id,
|
|
},
|
|
});
|
|
await db.insert(chatActions).values({
|
|
companyId,
|
|
endpointId,
|
|
conversationId,
|
|
principalId,
|
|
kind: "question_answer",
|
|
status: "processed",
|
|
providerActionId: `answer:${answered.id}`,
|
|
payload: {
|
|
version: 1,
|
|
interactionId: answered.id,
|
|
publicationId,
|
|
questionId: "release",
|
|
optionId: "yes",
|
|
},
|
|
result: { interactionId: answered.id, interactionStatus: "answered" },
|
|
});
|
|
if (identityStatus === "revoked") {
|
|
await db
|
|
.update(chatIdentityLinks)
|
|
.set({ status: "revoked" })
|
|
.where(
|
|
and(
|
|
eq(chatIdentityLinks.companyId, companyId),
|
|
eq(chatIdentityLinks.principalId, principalId),
|
|
),
|
|
);
|
|
}
|
|
const retiredScope = { companyId, issueId, agentId, runId: sourceRunId };
|
|
if (identityStatus === "unbound_marker" || identityStatus === "ordinary_cancellation") {
|
|
await heartbeat.cancelRun(sourceRunId, "Fixture cancellation", {
|
|
errorCode: identityStatus === "unbound_marker" ? "external_chat_continuation" : "cancelled",
|
|
resultJson: { interactionId: randomUUID(), externalChatContinuation: true },
|
|
suppressImmediateRecovery: true,
|
|
});
|
|
expect(await isRetiredExternalChatQuestionSource(db, retiredScope)).toBe(false);
|
|
const incidents = await db.select().from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
eq(issueRecoveryActions.sourceIssueId, issueId),
|
|
));
|
|
expect(incidents).toEqual([expect.objectContaining({
|
|
cause: "native_continuation_requires_reconciliation",
|
|
})]);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).toHaveLength(0);
|
|
expect(gateway.getAgentPayloads()).toHaveLength(0);
|
|
return;
|
|
}
|
|
let nativeResolverCalls = 0;
|
|
const resolver = async () => {
|
|
nativeResolverCalls += 1;
|
|
return "queued" as const;
|
|
};
|
|
if (identityStatus === "restart") {
|
|
const interruptedDelivery = questionResponseDeliveryService(db, {
|
|
heartbeat: {
|
|
wakeup: heartbeat.wakeup,
|
|
cancelRun: async (...args) => {
|
|
await heartbeat.cancelRun(...args);
|
|
// A lost caller result after the durable cancellation is a
|
|
// restart boundary, not a new failure incident or permission
|
|
// to promote generic work before the dedicated wake exists.
|
|
throw new Error("fixture_cancel_receipt_lost");
|
|
},
|
|
},
|
|
resolveNativeQuestion: resolver,
|
|
});
|
|
expect(await interruptedDelivery.deliver(answered.id)).toBeNull();
|
|
expect(await isRetiredExternalChatQuestionSource(db, retiredScope)).toBe(true);
|
|
await heartbeatService(db).reconcileStrandedAssignedIssues();
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).toHaveLength(0);
|
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, companyId))).toHaveLength(0);
|
|
}
|
|
const answerDelivery = questionResponseDeliveryService(db, {
|
|
heartbeat,
|
|
resolveNativeQuestion: resolver,
|
|
});
|
|
const outcome = await answerDelivery.deliver(answered.id);
|
|
|
|
expect(nativeResolverCalls).toBe(0);
|
|
expect(outcome).toMatchObject({
|
|
status: "fallback_queued",
|
|
mode: "wake_fallback",
|
|
});
|
|
expect(await isRetiredExternalChatQuestionSource(db, retiredScope)).toBe(true);
|
|
expect(await isRetiredExternalChatQuestionSource(db, { ...retiredScope, agentId: randomUUID() })).toBe(false);
|
|
expect(await isRetiredExternalChatQuestionSource(db, { ...retiredScope, issueId: randomUUID() })).toBe(false);
|
|
expect(await db.select().from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
eq(issueRecoveryActions.fingerprint, `native-continuation:${sourceRunId}`),
|
|
))).toHaveLength(0);
|
|
const duplicateOutcomes = await Promise.all([answerDelivery.deliver(answered.id), answerDelivery.deliver(answered.id)]);
|
|
expect(duplicateOutcomes.every(value => value?.duplicate === true)).toBe(true);
|
|
expect(await db.select().from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.idempotencyKey, `question-response:${answered.id}`),
|
|
))).toHaveLength(1);
|
|
await expect(
|
|
db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, sourceRunId)),
|
|
).resolves.toEqual([{ status: "cancelled" }]);
|
|
|
|
let continuationRunId: string | null = null;
|
|
await waitFor(async () => {
|
|
continuationRunId = await db
|
|
.select({ runId: agentWakeupRequests.runId })
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(
|
|
agentWakeupRequests.idempotencyKey,
|
|
`question-response:${answered.id}`,
|
|
),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]?.runId ?? null);
|
|
return continuationRunId !== null;
|
|
}, 30_000);
|
|
expect(continuationRunId).not.toBe(sourceRunId);
|
|
if (identityStatus === "revoked") {
|
|
await heartbeat.drainActiveRunExecutions();
|
|
const [deniedRun] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, continuationRunId!));
|
|
expect(deniedRun).toMatchObject({
|
|
status: "failed",
|
|
error: "reviewed_chat_execution_binding_not_authorized",
|
|
contextSnapshot: expect.objectContaining({
|
|
interactionId: answered.id,
|
|
sourceRunId,
|
|
}),
|
|
});
|
|
expect(deniedRun?.contextSnapshot).not.toHaveProperty(
|
|
"paperclipExternalChatQuestionResponse",
|
|
);
|
|
expect(gateway.getAgentPayloads()).toHaveLength(0);
|
|
return;
|
|
}
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1, 30_000);
|
|
const continuationRun = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, continuationRunId!))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(continuationRun).toMatchObject({
|
|
status: "running",
|
|
contextSnapshot: expect.objectContaining({
|
|
interactionId: answered.id,
|
|
sourceRunId,
|
|
sourceCommentId: sourceComment.id,
|
|
externalChatContinuation: true,
|
|
wakeCommentId: sourceComment.id,
|
|
wakeCommentIds: [sourceComment.id],
|
|
paperclipExternalChatExecutionBound: true,
|
|
paperclipTaskCommunicationGuidance: "## Communication in Slack\nFrozen connection preference.",
|
|
paperclipExternalChatQuestionResponse: expect.objectContaining({
|
|
schema: "paperclip.external_chat_question_response.v1",
|
|
interactionId: answered.id,
|
|
sourceRunId,
|
|
sourceCommentId: sourceComment.id,
|
|
endpointId,
|
|
conversationId,
|
|
}),
|
|
}),
|
|
});
|
|
expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain(
|
|
"preserve this full source instruction",
|
|
);
|
|
const restoredPrompt = String(gateway.getAgentPayloads()[0]?.message ?? "");
|
|
expect(restoredPrompt.match(/Frozen connection preference\./g)).toHaveLength(1);
|
|
expect(restoredPrompt).not.toContain("FORGED caller preference");
|
|
expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain(
|
|
"Preserve the original request's exact-output constraints literally.",
|
|
);
|
|
expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain(
|
|
"Do not narrate Paperclip workflow, checkout, status, or completion bookkeeping.",
|
|
);
|
|
const continuationWake = await readGatewayWakePayload(
|
|
gateway.getAgentPayloads()[0] ?? {},
|
|
);
|
|
expect(continuationWake).toMatchObject({
|
|
externalChatProvider: "slack",
|
|
externalChatExecutionBound: true,
|
|
externalChatQuestionResponse: expect.objectContaining({
|
|
interactionId: answered.id,
|
|
sourceRunId,
|
|
endpointId,
|
|
conversationId,
|
|
}),
|
|
});
|
|
// The gateway's structured JSON is intentionally omitted when the
|
|
// wake prompt owns comments. The authoritative interaction answer is
|
|
// still carried in the rendered prompt.
|
|
expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain(
|
|
"Continue the release?: Yes",
|
|
);
|
|
|
|
gateway.releaseFirstWait();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, continuationRunId!))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "succeeded";
|
|
}, 30_000);
|
|
await waitFor(async () =>
|
|
db
|
|
.select({ executionRunId: issues.executionRunId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]?.executionRunId === null),
|
|
);
|
|
await expect(
|
|
db
|
|
.select({ status: issueQuestionResponseDeliveries.status })
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(
|
|
eq(issueQuestionResponseDeliveries.interactionId, answered.id),
|
|
),
|
|
).resolves.toEqual([{ status: "fallback_queued" }]);
|
|
await expect(
|
|
db
|
|
.select({ status: issueThreadInteractions.status })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, answered.id)),
|
|
).resolves.toEqual([{ status: "answered" }]);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await heartbeat.drainActiveRunExecutions();
|
|
await gateway.close();
|
|
}
|
|
},
|
|
120_000,
|
|
);
|
|
|
|
it("still reopens a finished issue when a deferred batch mixes self-authored and human comments", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Human follow-up must survive mixed deferred batches",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
|
|
const selfComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-cli-user",
|
|
createdByRunId: firstRun?.id ?? null,
|
|
body: "Closing note from the same run",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const firstDeferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: selfComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: selfComment.id,
|
|
wakeCommentId: selfComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-cli-user",
|
|
});
|
|
|
|
expect(firstDeferredRun).toBeNull();
|
|
|
|
const humanComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Real follow-up from a human after the run closes",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const secondDeferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: humanComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: humanComment.id,
|
|
wakeCommentId: humanComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(secondDeferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "done",
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 2, 90_000);
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
const [initialRun, promotedRun] = runs;
|
|
return (
|
|
initialRun?.id === firstRun?.id &&
|
|
initialRun.status === "succeeded" &&
|
|
promotedRun?.status === "succeeded"
|
|
);
|
|
}, 90_000);
|
|
|
|
const issueAfterPromotion = await db
|
|
.select({
|
|
status: issues.status,
|
|
completedAt: issues.completedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(issueAfterPromotion).toMatchObject({
|
|
status: "in_progress",
|
|
completedAt: null,
|
|
});
|
|
|
|
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
|
expect(secondPayload.paperclip).toBeUndefined();
|
|
const secondWake = await readGatewayWakePayload(secondPayload);
|
|
expect(secondWake).toMatchObject({
|
|
reason: "issue_commented",
|
|
commentIds: [humanComment.id],
|
|
latestCommentId: humanComment.id,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: `${issuePrefix}-1`,
|
|
title: "Human follow-up must survive mixed deferred batches",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
},
|
|
});
|
|
expect(String(secondPayload.message ?? "")).toContain(
|
|
"Real follow-up from a human after the run closes",
|
|
);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("cancels a deferred comment wake when its only queued comment is deleted before promotion", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Deleted follow-up must not reopen",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
const queuedComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Please look at this once you finish",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const deferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: queuedComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: queuedComment.id,
|
|
wakeCommentId: queuedComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(deferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
const deferredWake = await db
|
|
.select({ id: agentWakeupRequests.id })
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const deferredWakeId = deferredWake!.id;
|
|
|
|
// The author retracts the comment before the first run finishes, so the
|
|
// real comment-liveness query must find zero live comments left in the
|
|
// queued batch.
|
|
await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, queuedComment.id));
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "done",
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const [wake] = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, deferredWakeId));
|
|
return wake?.status === "cancelled";
|
|
}, 90_000);
|
|
|
|
const [cancelledWake] = await db
|
|
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, deferredWakeId));
|
|
expect(cancelledWake).toMatchObject({
|
|
status: "cancelled",
|
|
error: "Queued messages were discarded before promotion",
|
|
});
|
|
|
|
// No live comment remained, so the queue must not promote a second run
|
|
// and the issue must stay in the state the first run's completion left it in.
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const issueAfterCompletion = await db
|
|
.select({ status: issues.status, completedAt: issues.completedAt })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issueAfterCompletion).toMatchObject({ status: "done" });
|
|
expect(issueAfterCompletion?.completedAt).not.toBeNull();
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("cancels a deferred comment wake when its only queued comment was authored by the finishing run", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Self-authored note must not reopen",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
|
|
const selfComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-cli-user",
|
|
createdByRunId: firstRun?.id ?? null,
|
|
body: "Closing note from the same run",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const deferredRun = await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: selfComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: selfComment.id,
|
|
wakeCommentId: selfComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-cli-user",
|
|
});
|
|
|
|
expect(deferredRun).toBeNull();
|
|
|
|
await waitFor(async () => {
|
|
const deferred = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(deferred);
|
|
});
|
|
|
|
const queuedWake = await db
|
|
.select({ id: agentWakeupRequests.id })
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
// Running records admission. Wait for provider acceptance before
|
|
// simulating completion by that provider, or startup correctly rejects
|
|
// the already-closed task before this scenario reaches its follow-up.
|
|
await waitFor(() => gateway.getAgentPayloads().length >= 1);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "done",
|
|
completedAt: new Date(),
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const [wake] = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, queuedWake!.id));
|
|
return wake?.status === "cancelled";
|
|
}, 90_000);
|
|
|
|
const [cancelledWake] = await db
|
|
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, queuedWake!.id));
|
|
expect(cancelledWake).toMatchObject({
|
|
status: "cancelled",
|
|
error: "Deferred wake contained only comments authored by the finishing run",
|
|
});
|
|
|
|
// The only queued comment came from the run that just finished, so the
|
|
// queue must not promote a second run for the same agent to re-read its
|
|
// own note, and the issue must stay in the state the run's completion left it in.
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
const issueAfterCompletion = await db
|
|
.select({ status: issues.status, completedAt: issues.completedAt })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issueAfterCompletion).toMatchObject({ status: "done" });
|
|
expect(issueAfterCompletion?.completedAt).not.toBeNull();
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("promotes an interaction continuation after removing a coalesced self-authored comment", async () => {
|
|
const gateway = await createControlledGatewayServer(async turn => {
|
|
if (turn === 2) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
});
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const interactionId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Local CLI Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Interaction continuation survives self-comment filtering",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(async () => {
|
|
const run = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, firstRun!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return run?.status === "running";
|
|
});
|
|
|
|
const selfComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-cli-user",
|
|
createdByRunId: firstRun!.id,
|
|
body: "Completion note from the source run",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
expect(await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: { issueId, commentId: selfComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: selfComment.id,
|
|
wakeCommentId: selfComment.id,
|
|
wakeReason: "issue_commented",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-cli-user",
|
|
})).toBeNull();
|
|
|
|
expect(await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId,
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
mutation: "interaction",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
wakeReason: "issue_commented",
|
|
source: "issue.interaction.respond",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
})).toBeNull();
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
return (
|
|
runs.length === 2 &&
|
|
runs[0]?.status === "succeeded" &&
|
|
runs[1]?.status === "succeeded"
|
|
);
|
|
}, 90_000);
|
|
|
|
const promotedRun = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt))
|
|
.then((runs) => runs[1] ?? null);
|
|
expect(promotedRun?.contextSnapshot).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
});
|
|
expect(promotedRun?.contextSnapshot).not.toMatchObject({
|
|
wakeCommentIds: expect.anything(),
|
|
});
|
|
expect(promotedRun?.contextSnapshot).not.toMatchObject({
|
|
commentId: selfComment.id,
|
|
});
|
|
expect(gateway.getAgentPayloads()).toHaveLength(2);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Require a comment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
const firstPayload = gateway.getAgentPayloads()[0] ?? {};
|
|
expect(firstPayload.paperclip).toBeUndefined();
|
|
expect(String(firstPayload.message ?? "")).toContain(
|
|
"## Paperclip Wake Payload",
|
|
);
|
|
expect(String(firstPayload.message ?? "")).toContain(
|
|
"Do not switch to another issue until you have handled this wake.",
|
|
);
|
|
expect(String(firstPayload.message ?? "")).toContain(
|
|
"- checkout: already claimed by the harness for this run",
|
|
);
|
|
expect(String(firstPayload.message ?? "")).toContain(
|
|
"The harness already checked out this issue for the current run.",
|
|
);
|
|
expect(String(firstPayload.message ?? "")).toContain(
|
|
`${issuePrefix}-1 Require a comment`,
|
|
);
|
|
const firstWake = await readGatewayWakePayload(firstPayload);
|
|
expect(firstWake).toMatchObject({
|
|
reason: "issue_assigned",
|
|
checkedOutByHarness: true,
|
|
commentIds: [],
|
|
issue: {
|
|
id: issueId,
|
|
identifier: `${issuePrefix}-1`,
|
|
},
|
|
});
|
|
const checkedOutIssue = await db
|
|
.select({
|
|
status: issues.status,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(checkedOutIssue).toMatchObject({
|
|
status: "in_progress",
|
|
checkoutRunId: firstRun?.id,
|
|
executionRunId: firstRun?.id,
|
|
});
|
|
gateway.releaseFirstWait();
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
return (
|
|
runs.length === 2 &&
|
|
runs.every((run) => run.status === "succeeded") &&
|
|
runs[0]?.issueCommentStatus === "retry_queued" &&
|
|
runs[1]?.issueCommentStatus === "retry_exhausted"
|
|
);
|
|
});
|
|
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
|
|
expect(runs).toHaveLength(2);
|
|
expect(runs[0]?.issueCommentStatus).toBe("retry_queued");
|
|
expect(runs[1]?.retryOfRunId).toBe(runs[0]?.id);
|
|
expect(runs[1]?.issueCommentStatus).toBe("retry_exhausted");
|
|
|
|
const comments = await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.issueId, issueId));
|
|
expect(comments).toHaveLength(0);
|
|
|
|
await waitFor(async () => {
|
|
const wakeups = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
),
|
|
);
|
|
return wakeups.length >= 2;
|
|
});
|
|
|
|
const payloads = gateway.getAgentPayloads();
|
|
expect(payloads).toHaveLength(2);
|
|
expect(runs[1]?.contextSnapshot).toMatchObject({
|
|
retryReason: "missing_issue_comment",
|
|
});
|
|
expect(runs[1]?.contextSnapshot).not.toHaveProperty("modelProfile");
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 20_000);
|
|
|
|
it("ignores mentions during another agent run without deferring work", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const primaryAgentId = randomUUID();
|
|
const mentionedAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: primaryAgentId,
|
|
companyId,
|
|
name: "Primary Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: mentionedAgentId,
|
|
companyId,
|
|
name: "Mentioned Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Prevent concurrent mention execution",
|
|
status: "todo",
|
|
priority: "high",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: primaryAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const primaryRun = await heartbeat.wakeup(primaryAgentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(primaryRun).not.toBeNull();
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
const mentionComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "@Mentioned Agent please inspect this after the current run.",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const mentionRun = await heartbeat.wakeup(mentionedAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_comment_mentioned",
|
|
payload: { issueId, commentId: mentionComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: mentionComment.id,
|
|
wakeCommentId: mentionComment.id,
|
|
wakeReason: "issue_comment_mentioned",
|
|
source: "comment.mention",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(mentionRun).toBeNull();
|
|
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
|
|
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
|
gateway.releaseFirstWait();
|
|
await waitFor(async () => {
|
|
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, primaryAgentId));
|
|
// Terminal run status is persisted before comment policy and lock
|
|
// cleanup. Observe release before checking the final queue state.
|
|
const [issue] = await db.select({ executionRunId: issues.executionRunId }).from(issues).where(eq(issues.id, issueId));
|
|
return runs.length === 2 && runs.every((run) => run.status === "succeeded") && issue.executionRunId === null;
|
|
}, 90_000);
|
|
const mentionedRuns = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, mentionedAgentId));
|
|
expect(mentionedRuns).toEqual([]);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
|
|
|
|
const issueAfterMention = await db
|
|
.select({
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(issueAfterMention?.assigneeAgentId).toBe(primaryAgentId);
|
|
expect(issueAfterMention?.executionRunId).toBeNull();
|
|
expect(issueAfterMention?.executionAgentNameKey).not.toBe(
|
|
"mentioned agent",
|
|
);
|
|
|
|
const primaryRuns = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, primaryAgentId))
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
expect(primaryRuns).toHaveLength(2);
|
|
expect(primaryRuns[0]?.issueCommentStatus).toBe("retry_queued");
|
|
expect(primaryRuns[1]?.retryOfRunId).toBe(primaryRuns[0]?.id);
|
|
expect(primaryRuns[1]?.issueCommentStatus).toBe("retry_exhausted");
|
|
|
|
const missingCommentRetries = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, primaryAgentId),
|
|
eq(agentWakeupRequests.reason, "missing_issue_comment"),
|
|
),
|
|
);
|
|
expect(missingCommentRetries).toHaveLength(1);
|
|
expect(missingCommentRetries[0]?.payload).not.toHaveProperty(
|
|
"modelProfile",
|
|
);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
it("ignores direct legacy mention wakes without creating a request or executing an adapter", async () => {
|
|
const gateway = await createControlledGatewayServer();
|
|
const companyId = randomUUID();
|
|
const primaryAgentId = randomUUID();
|
|
const mentionedAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: primaryAgentId,
|
|
companyId,
|
|
name: "Primary Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: mentionedAgentId,
|
|
companyId,
|
|
name: "Mentioned Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Mention should not steal execution ownership",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: primaryAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const mentionComment = await db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "@Mentioned Agent please inspect this.",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const mentionRun = await heartbeat.wakeup(mentionedAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_comment_mentioned",
|
|
payload: { issueId, commentId: mentionComment.id },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
commentId: mentionComment.id,
|
|
wakeCommentId: mentionComment.id,
|
|
wakeReason: "issue_comment_mentioned",
|
|
source: "comment.mention",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "user-1",
|
|
});
|
|
|
|
expect(mentionRun).toBeNull();
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, mentionedAgentId))).toEqual([]);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
|
|
expect(gateway.getAgentPayloads()).toEqual([]);
|
|
|
|
const issueDuringMention = await db
|
|
.select({
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(issueDuringMention).toMatchObject({
|
|
assigneeAgentId: primaryAgentId,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
});
|
|
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 120_000);
|
|
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
|
|
const gateway = await createControlledGatewayServer(async turn => {
|
|
if (turn === 2) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
});
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
try {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Gateway Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {
|
|
url: gateway.url,
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token",
|
|
},
|
|
payloadTemplate: {
|
|
message: "wake now",
|
|
},
|
|
waitTimeoutMs: 2_000,
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Use existing comment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
const firstRun = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: { issueId },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
requestedByActorType: "system",
|
|
requestedByActorId: null,
|
|
});
|
|
|
|
expect(firstRun).not.toBeNull();
|
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
authorUserId: null,
|
|
createdByRunId: firstRun!.id,
|
|
body: "Manual completion comment from the run.",
|
|
});
|
|
|
|
gateway.releaseFirstWait();
|
|
|
|
await waitFor(async () => {
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
const sourceRun = runs.find((run) => run.id === firstRun?.id);
|
|
return (
|
|
sourceRun?.status === "succeeded" &&
|
|
sourceRun.issueCommentStatus === "satisfied"
|
|
);
|
|
});
|
|
|
|
const runs = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.agentId, agentId));
|
|
|
|
const sourceRun = runs.find((run) => run.id === firstRun?.id);
|
|
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
|
|
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
|
|
|
|
await waitFor(async () => (await db.select().from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.companyId, companyId)))
|
|
.some(wake => wake.reason === "issue_disposition_repair"));
|
|
await heartbeat.drainActiveRunExecutions();
|
|
|
|
const comments = await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.issueId, issueId))
|
|
.orderBy(asc(issueComments.createdAt));
|
|
|
|
expect(
|
|
comments.some(
|
|
(comment) =>
|
|
comment.body === "Manual completion comment from the run.",
|
|
),
|
|
).toBe(true);
|
|
expect(comments.some(comment => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(false);
|
|
expect(
|
|
comments.every((comment) => !comment.body.startsWith("## Run summary")),
|
|
).toBe(true);
|
|
|
|
const wakeups = await db
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, agentId),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment"),
|
|
).toBe(false);
|
|
expect(
|
|
wakeups.some(
|
|
(wakeup) => wakeup.reason === "issue_disposition_repair",
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
gateway.releaseFirstWait();
|
|
await gateway.close();
|
|
}
|
|
}, 20_000);
|
|
|
|
it("fails a deferred wake whose agent no longer exists, then still promotes the next queued wake", async () => {
|
|
const companyId = randomUUID();
|
|
const finishingAgentId = randomUUID();
|
|
const validAgentId = randomUUID();
|
|
const missingAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
// Pin scheduling suppression off with the runtimeEnv test seam. Do not rely
|
|
// on the ambient PAPERCLIP_IN_WORKTREE value: startNextQueuedRunForAgent
|
|
// no-ops under suppression and would leave the promoted wake at "queued".
|
|
const heartbeat = heartbeatService(db, { runtimeEnv: {} });
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values([
|
|
{
|
|
id: finishingAgentId,
|
|
companyId,
|
|
name: "Finishing Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: validAgentId,
|
|
companyId,
|
|
name: "Assignee Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId: finishingAgentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
runtimeMode: "legacy",
|
|
startedAt: new Date(),
|
|
contextSnapshot: { issueId },
|
|
responsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Continue past a missing deferred agent",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: validAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
executionRunId: runId,
|
|
});
|
|
|
|
const missingAgentWakeId = randomUUID();
|
|
// The agent_id foreign key always holds in the running system, so a wake row
|
|
// can never outlive its agent through the application. Bypass the check for
|
|
// this one insert to pin the release code's defensive branch for that state.
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local session_replication_role = 'replica'`);
|
|
await tx.insert(agentWakeupRequests).values({
|
|
id: missingAgentWakeId,
|
|
companyId,
|
|
agentId: missingAgentId,
|
|
source: "automation",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "test",
|
|
requestedAt: new Date("2026-08-22T15:00:00.000Z"),
|
|
payload: { issueId },
|
|
});
|
|
});
|
|
|
|
const validWakeId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: validWakeId,
|
|
companyId,
|
|
agentId: validAgentId,
|
|
source: "automation",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "test",
|
|
requestedAt: new Date("2026-08-22T15:01:00.000Z"),
|
|
payload: { issueId },
|
|
});
|
|
|
|
// A plain legacy cancel now stops promotion early for board reconciliation
|
|
// (see legacyExecutionNeedsReconciliation in legacy-execution-recovery.ts).
|
|
// Cancel as an in-flight workspace wait instead. That shape still reaches
|
|
// the deferred-wake promotion loop under test.
|
|
await heartbeat.cancelRun(runId, undefined, {
|
|
errorCode: "workspace_busy",
|
|
resultJson: {
|
|
executionRecovery: { kind: "workspace_wait", providerWorkStarted: false },
|
|
},
|
|
});
|
|
|
|
const [missingAgentWake, validWake, issueRow] = await Promise.all([
|
|
db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, missingAgentWakeId)).then((rows) => rows[0]),
|
|
db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, validWakeId)).then((rows) => rows[0]),
|
|
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]),
|
|
]);
|
|
|
|
expect(missingAgentWake).toMatchObject({
|
|
status: "failed",
|
|
error: "Deferred wake could not be promoted: agent is not invokable",
|
|
});
|
|
// The promotion writes "queued", then releaseIssueExecutionAndPromote
|
|
// immediately calls startNextQueuedRunForAgent for the idle promoted
|
|
// agent, which claims the run in the same call. Assert the settled
|
|
// state, not the intermediate one.
|
|
expect(validWake?.status).toBe("claimed");
|
|
expect(validWake?.runId).not.toBeNull();
|
|
expect(issueRow?.executionRunId).toBe(validWake?.runId);
|
|
});
|
|
|
|
it("fails a deferred wake with the same status and error text when its agent belongs to another company", async () => {
|
|
const companyId = randomUUID();
|
|
const otherCompanyId = randomUUID();
|
|
const finishingAgentId = randomUUID();
|
|
const crossCompanyAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const otherIssuePrefix = `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
await db.insert(companies).values([
|
|
{
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
},
|
|
{
|
|
id: otherCompanyId,
|
|
name: "Other Paperclip",
|
|
issuePrefix: otherIssuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
},
|
|
]);
|
|
await db.insert(agents).values([
|
|
{
|
|
id: finishingAgentId,
|
|
companyId,
|
|
name: "Finishing Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: crossCompanyAgentId,
|
|
companyId: otherCompanyId,
|
|
name: "Other Company Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId: finishingAgentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
runtimeMode: "legacy",
|
|
startedAt: new Date(),
|
|
contextSnapshot: { issueId },
|
|
responsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Cross-company deferred agent",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: finishingAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
executionRunId: runId,
|
|
});
|
|
const wakeId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: wakeId,
|
|
companyId,
|
|
agentId: crossCompanyAgentId,
|
|
source: "automation",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "test",
|
|
payload: { issueId },
|
|
});
|
|
|
|
// A plain legacy cancel now stops promotion early for board reconciliation
|
|
// (see legacyExecutionNeedsReconciliation in legacy-execution-recovery.ts).
|
|
// Cancel as an in-flight workspace wait instead. That shape still reaches
|
|
// the deferred-wake promotion loop under test.
|
|
await heartbeat.cancelRun(runId, undefined, {
|
|
errorCode: "workspace_busy",
|
|
resultJson: {
|
|
executionRecovery: { kind: "workspace_wait", providerWorkStarted: false },
|
|
},
|
|
});
|
|
|
|
const wake = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)).then((rows) => rows[0]);
|
|
expect(wake).toMatchObject({
|
|
status: "failed",
|
|
error: "Deferred wake could not be promoted: agent is not invokable",
|
|
});
|
|
});
|
|
|
|
it("cancels a deferred wake under an active pause hold, but promotes a verified hold interaction with the hold context", async () => {
|
|
const companyId = randomUUID();
|
|
const finishingAgentId = randomUUID();
|
|
const holdAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
// Pin scheduling suppression off with the runtimeEnv test seam. Do not rely
|
|
// on the ambient PAPERCLIP_IN_WORKTREE value: startNextQueuedRunForAgent
|
|
// no-ops under suppression and would leave the promoted wake at "queued".
|
|
const heartbeat = heartbeatService(db, { runtimeEnv: {} });
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values([
|
|
{
|
|
id: finishingAgentId,
|
|
companyId,
|
|
name: "Finishing Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: holdAgentId,
|
|
companyId,
|
|
name: "Hold Interaction Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId: finishingAgentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
runtimeMode: "legacy",
|
|
startedAt: new Date(),
|
|
contextSnapshot: { issueId },
|
|
responsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "A pause hold gates a plain wake but not a verified one",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: holdAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
executionRunId: runId,
|
|
});
|
|
const [hold] = await db.insert(issueTreeHolds).values({
|
|
companyId,
|
|
rootIssueId: issueId,
|
|
mode: "pause",
|
|
status: "active",
|
|
reason: "Investigating a regression",
|
|
}).returning();
|
|
|
|
const plainWakeId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: plainWakeId,
|
|
companyId,
|
|
agentId: finishingAgentId,
|
|
source: "automation",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "test",
|
|
requestedAt: new Date("2026-08-22T15:00:00.000Z"),
|
|
payload: { issueId },
|
|
});
|
|
|
|
const holdComment = await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "hold-user",
|
|
body: "Please continue despite the hold",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const verifiedWakeId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: verifiedWakeId,
|
|
companyId,
|
|
agentId: holdAgentId,
|
|
source: "issue_comment",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "hold-user",
|
|
requestedAt: new Date("2026-08-22T15:01:00.000Z"),
|
|
payload: {
|
|
issueId,
|
|
commentId: holdComment.id,
|
|
_paperclipWakeContext: {
|
|
wakeReason: "issue_commented",
|
|
source: "issue.comment",
|
|
wakeCommentIds: [holdComment.id],
|
|
},
|
|
},
|
|
});
|
|
|
|
// A plain legacy cancel now stops promotion early for board reconciliation
|
|
// (see legacyExecutionNeedsReconciliation in legacy-execution-recovery.ts).
|
|
// Cancel as an in-flight workspace wait instead. That shape still reaches
|
|
// the deferred-wake promotion loop under test.
|
|
await heartbeat.cancelRun(runId, undefined, {
|
|
errorCode: "workspace_busy",
|
|
resultJson: {
|
|
executionRecovery: { kind: "workspace_wait", providerWorkStarted: false },
|
|
},
|
|
});
|
|
|
|
const [plainWake, verifiedWake] = await Promise.all([
|
|
db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, plainWakeId)).then((rows) => rows[0]),
|
|
db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, verifiedWakeId)).then((rows) => rows[0]),
|
|
]);
|
|
expect(plainWake).toMatchObject({
|
|
status: "cancelled",
|
|
error: "Deferred wake suppressed by active subtree pause hold",
|
|
});
|
|
// Same settle-then-assert reasoning as the missing-agent test above:
|
|
// the idle promoted agent's run is claimed synchronously.
|
|
expect(verifiedWake?.status).toBe("claimed");
|
|
const promotedRun = await db
|
|
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, verifiedWake!.runId!))
|
|
.then((rows) => rows[0]);
|
|
expect(promotedRun?.contextSnapshot).toMatchObject({
|
|
treeHoldInteraction: true,
|
|
activeTreeHold: {
|
|
holdId: hold!.id,
|
|
rootIssueId: issueId,
|
|
mode: "pause",
|
|
reason: "Investigating a regression",
|
|
interaction: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rolls back the wake row, the run row, and the issue lock together when the responsible user cannot resolve", async () => {
|
|
const companyId = randomUUID();
|
|
const finishingAgentId = randomUUID();
|
|
const deferredAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
// No defaultResponsibleUserId: the company default must not resolve this wake.
|
|
});
|
|
await db.insert(agents).values([
|
|
{
|
|
id: finishingAgentId,
|
|
companyId,
|
|
name: "Finishing Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: deferredAgentId,
|
|
companyId,
|
|
name: "Deferred Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId: finishingAgentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
runtimeMode: "legacy",
|
|
startedAt: new Date(),
|
|
contextSnapshot: { issueId },
|
|
// No responsibleUserId: the finishing run itself must not resolve this wake.
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "No responsible user can be resolved",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: deferredAgentId,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
executionRunId: runId,
|
|
// No responsibleUserId: the issue itself must not resolve this wake.
|
|
});
|
|
const wakeId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: wakeId,
|
|
companyId,
|
|
agentId: deferredAgentId,
|
|
source: "automation",
|
|
reason: "issue_commented",
|
|
status: "deferred_issue_execution",
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "test",
|
|
payload: { issueId },
|
|
});
|
|
|
|
// A plain legacy cancel now stops promotion early for board reconciliation
|
|
// (see legacyExecutionNeedsReconciliation in legacy-execution-recovery.ts).
|
|
// Cancel as an in-flight workspace wait instead. That shape still reaches
|
|
// the deferred-wake promotion loop under test.
|
|
await expect(
|
|
heartbeat.cancelRun(runId, undefined, {
|
|
errorCode: "workspace_busy",
|
|
resultJson: {
|
|
executionRecovery: { kind: "workspace_wait", providerWorkStarted: false },
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({ code: "responsible_user_unresolved" }),
|
|
});
|
|
|
|
const [wake, issueRow, runs] = await Promise.all([
|
|
db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)).then((rows) => rows[0]),
|
|
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]),
|
|
db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)),
|
|
]);
|
|
expect(wake?.status).toBe("deferred_issue_execution");
|
|
expect(issueRow?.executionRunId).toBe(runId);
|
|
expect(runs).toHaveLength(1);
|
|
});
|
|
});
|