mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Ensure worktree execution starts only after activation (#9374)
## Thinking Path > - Paperclip is the open-source control plane people use to manage AI agents and their work. > - Its scheduler, routines, and heartbeat services decide when agents automatically begin work. > - Experimental per-worktree execution is useful for isolated development, but enabling it previously allowed automatic services to consider an existing backlog. > - A worktree activation must therefore create a durable eligibility boundary rather than merely toggle execution on. > - This pull request records an activation cutoff and applies it consistently to automatic routine and heartbeat dispatch. > - The result is that an enabled worktree executes only work created after its own activation, while non-worktree behavior remains unchanged. ## Linked Issues or Issue Description **Problem type:** Bug / safety regression **Summary:** Enabling experimental run execution in an existing worktree could start automatic scheduler, routine, watchdog, and heartbeat activity for work created before that worktree was explicitly armed. **Expected behavior:** A worktree that has execution enabled only considers automatically dispatched work created on or after its activation timestamp. Ambiguous activation state fails closed. Non-worktree instances keep their existing behavior. **Related public work:** Refs #8275 (runtime worktree policy gating); this PR adds an activation-time boundary for automatic execution rather than changing the general runtime policy. ## What Changed - Persist a worktree execution activation timestamp and originating instance ID; stamp them only when the experimental toggle changes from disabled to enabled. - Resolve activation state fail-closed when the cutoff is missing, invalid, disabled, or belongs to another instance. - Gate automatic routine scheduling, webhooks, watchdog activity, and heartbeat selection at the activation cutoff; manual runs remain available. - Share the canonical worktree truthy-environment helper across routine dispatch and agent inbox filtering. - Add cutoff and truthy-runtime regression coverage, plus experimental-settings UI states that explain armed and suppressed execution. ## Verification - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts server/src/__tests__/instance-settings-service.test.ts` — passes: 2 files, 60 tests. - `pnpm --filter @paperclipai/server typecheck` — passes. - Existing CI completed successfully before the follow-up review fixes; this branch was rebased onto the latest `origin/master` before retesting. ## Risks - **Behavioral:** Automatic worktree execution is intentionally more restrictive; pre-existing work is suppressed until newly created after activation. - **Operational:** A malformed or cross-instance activation record fails closed, requiring an operator to disable and re-enable the experimental toggle on the intended worktree. - **Compatibility:** The worktree environment now accepts all canonical truthy values (`1`, `true`, `yes`, and `on`) consistently; non-worktree instances are unaffected. - **Branch metadata:** This existing execution-workspace branch predates the current naming rule and cannot be renamed under this task's workspace contract; the code and PR title do not include internal ticket references. > `ROADMAP.md` was checked; this targeted execution-safety fix does not duplicate planned core work. ## Model Used - Anthropic Claude Code — assisted with the original implementation; exact model identifier and context window were not recorded in the repository metadata. - OpenAI Codex CLI — assisted with PR preparation and review fixes; exact model identifier and context window are not exposed in this execution environment. Used with terminal tooling, code editing, and targeted test execution. ## 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) - [ ] 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 - [ ] All Paperclip CI gates are green - [ ] 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22 files changed
+1082
-69
No files matched your search
@@ -1249,4 +1249,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
|
||||
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles an armed cutoff when no liveness findings exist", async () => {
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness({
|
||||
issueCreatedAtGte: new Date(),
|
||||
});
|
||||
|
||||
expect(result.findings).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,16 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
||||
return { companyId, agentId, issueId };
|
||||
}
|
||||
|
||||
async function armWorktreeRunExecution(cutoff: Date) {
|
||||
await instanceSettingsService(db, {
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "test-worktree",
|
||||
},
|
||||
now: () => cutoff,
|
||||
}).updateExperimental({ enableWorktreeRunExecution: true });
|
||||
}
|
||||
|
||||
async function waitForCompletedRun(runId: string, agentId: string) {
|
||||
let latestStatus: string | null = null;
|
||||
let latestLastRunId: string | null = null;
|
||||
@@ -243,6 +253,48 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
||||
expect(runningCount).toBe(0);
|
||||
});
|
||||
|
||||
it("skips pre-cutoff system wakes but allows user wakes in an armed worktree", async () => {
|
||||
const { agentId, issueId } = await insertAgentAndIssue();
|
||||
await armWorktreeRunExecution(new Date(Date.now() + 1_000));
|
||||
const heartbeat = heartbeatService(db, {
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "test-worktree",
|
||||
},
|
||||
});
|
||||
|
||||
const systemRun = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
payload: { issueId },
|
||||
contextSnapshot: { issueId },
|
||||
requestedByActorType: "system",
|
||||
});
|
||||
expect(systemRun).toBeNull();
|
||||
|
||||
const skippedWake = await db
|
||||
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
|
||||
.from(agentWakeupRequests)
|
||||
.orderBy(sql`${agentWakeupRequests.createdAt} desc`)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(skippedWake).toMatchObject({ reason: "heartbeat.worktree_execution_cutoff" });
|
||||
expect(skippedWake?.payload).toMatchObject({
|
||||
heartbeatSkip: { reason: "worktree_execution_cutoff", issueId },
|
||||
});
|
||||
|
||||
const userRun = await heartbeat.wakeup(agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "user",
|
||||
payload: { issueId },
|
||||
contextSnapshot: { issueId, skipIssueComment: true },
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "operator",
|
||||
});
|
||||
expect(userRun).not.toBeNull();
|
||||
await heartbeat.waitForRunExecutionDrain(userRun!.id);
|
||||
}, 10_000);
|
||||
|
||||
it("still creates live-plane assignment runs when suppression is not active", async () => {
|
||||
const { agentId, issueId } = await insertAgentAndIssue();
|
||||
await db
|
||||
|
||||
@@ -89,6 +89,9 @@ describe("instance settings routes", () => {
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
@@ -114,6 +117,9 @@ describe("instance settings routes", () => {
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
mockInstanceSettingsService.update.mockResolvedValue({
|
||||
@@ -138,6 +144,9 @@ describe("instance settings routes", () => {
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
@@ -168,6 +177,9 @@ describe("instance settings routes", () => {
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
});
|
||||
@@ -226,6 +238,9 @@ describe("instance settings routes", () => {
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
|
||||
@@ -240,6 +255,28 @@ describe("instance settings routes", () => {
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
}, 10_000);
|
||||
|
||||
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "copied-instance",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableWorktreeRunExecution: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local board users to read and update the instance default environment", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeExperimentalSettings } from "../services/instance-settings.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { InstanceExperimentalSettings } from "@paperclipai/shared";
|
||||
import {
|
||||
applyExperimentalSettingsPatch,
|
||||
normalizeExperimentalSettings,
|
||||
resolveWorktreeRunExecutionActivationState,
|
||||
} from "../services/instance-settings.js";
|
||||
|
||||
describe("instance settings service", () => {
|
||||
it("ignores retired experimental flags without resetting current settings", () => {
|
||||
@@ -39,6 +44,8 @@ describe("instance settings service", () => {
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: false,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
});
|
||||
});
|
||||
@@ -115,4 +122,208 @@ describe("instance settings service", () => {
|
||||
expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false);
|
||||
expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false);
|
||||
});
|
||||
|
||||
it("sets worktree run execution activation fields on a false to true transition", () => {
|
||||
const activatedAt = new Date("2026-07-10T12:00:00.000Z");
|
||||
|
||||
const next = applyExperimentalSettingsPatch(
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{ enableWorktreeRunExecution: true },
|
||||
{
|
||||
now: () => activatedAt,
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(next.enableWorktreeRunExecution).toBe(true);
|
||||
expect(next.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:00:00.000Z");
|
||||
expect(next.worktreeRunExecutionActivationInstanceId).toBe("worktree-instance");
|
||||
});
|
||||
|
||||
it("clears worktree run execution activation fields on a true to false transition", () => {
|
||||
const next = applyExperimentalSettingsPatch(
|
||||
{
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "worktree-instance",
|
||||
},
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(next.enableWorktreeRunExecution).toBe(false);
|
||||
expect(next.worktreeRunExecutionActivatedAt).toBeNull();
|
||||
expect(next.worktreeRunExecutionActivationInstanceId).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes the activation cutoff when worktree run execution is re-toggled", () => {
|
||||
const firstActivation = applyExperimentalSettingsPatch(
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{ enableWorktreeRunExecution: true },
|
||||
{
|
||||
now: () => new Date("2026-07-10T12:00:00.000Z"),
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
const disabled = applyExperimentalSettingsPatch(
|
||||
firstActivation,
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const secondActivation = applyExperimentalSettingsPatch(
|
||||
disabled,
|
||||
{ enableWorktreeRunExecution: true },
|
||||
{
|
||||
now: () => new Date("2026-07-10T12:05:00.000Z"),
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(secondActivation.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:05:00.000Z");
|
||||
expect(secondActivation.worktreeRunExecutionActivatedAt).not.toBe(
|
||||
firstActivation.worktreeRunExecutionActivatedAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips client-supplied activation fields before applying experimental patches", () => {
|
||||
const next = applyExperimentalSettingsPatch(
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "copied-instance",
|
||||
},
|
||||
{
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(next.worktreeRunExecutionActivatedAt).toBeNull();
|
||||
expect(next.worktreeRunExecutionActivationInstanceId).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves worktree run execution as armed only when the cutoff matches the current instance", async () => {
|
||||
const experimental = normalizeExperimentalSettings({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "worktree-instance",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental: async () => experimental,
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
armed: true,
|
||||
cutoff: "2026-07-10T12:00:00.000Z",
|
||||
activationInstanceId: "worktree-instance",
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when worktree run execution is missing a cutoff", async () => {
|
||||
const experimental = normalizeExperimentalSettings({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivationInstanceId: "worktree-instance",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental: async () => experimental,
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
reason: "missing_cutoff",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when worktree run execution was activated by another instance", async () => {
|
||||
const experimental = normalizeExperimentalSettings({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "source-instance",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental: async () => experimental,
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "target-instance",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
activationInstanceId: "source-instance",
|
||||
reason: "instance_id_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on settings read errors and avoids reads outside worktree runtimes", async () => {
|
||||
await expect(
|
||||
resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental: async () => {
|
||||
throw new Error("settings unavailable");
|
||||
},
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
reason: "settings_read_error",
|
||||
});
|
||||
|
||||
const getExperimental = vi.fn<() => Promise<InstanceExperimentalSettings>>();
|
||||
await expect(
|
||||
resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental,
|
||||
runtimeEnv: {
|
||||
PAPERCLIP_IN_WORKTREE: "false",
|
||||
PAPERCLIP_INSTANCE_ID: "worktree-instance",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
reason: "not_worktree_runtime",
|
||||
});
|
||||
expect(getExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -88,6 +88,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
});
|
||||
|
||||
async function seedFixture(opts?: {
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
wakeup?: (
|
||||
agentId: string,
|
||||
wakeupOpts: {
|
||||
@@ -147,6 +148,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
});
|
||||
|
||||
const svc = routineService(db, {
|
||||
runtimeEnv: opts?.runtimeEnv,
|
||||
heartbeat: {
|
||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
|
||||
@@ -204,6 +206,18 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||
}
|
||||
|
||||
async function armWorktreeExecution(cutoff: Date, instanceId = "worktree-routines-test") {
|
||||
await db.insert(instanceSettings).values({
|
||||
singletonKey: "default",
|
||||
general: {},
|
||||
experimental: {
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: cutoff.toISOString(),
|
||||
worktreeRunExecutionActivationInstanceId: instanceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("filters listed routines by project", async () => {
|
||||
const { companyId, agentId, projectId, routine, svc } = await seedFixture();
|
||||
const otherProjectId = randomUUID();
|
||||
@@ -1746,6 +1760,80 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(run.status).toBe("issue_created");
|
||||
});
|
||||
|
||||
it("records suppressed automatic runs when worktree execution is disabled while allowing manual runs", async () => {
|
||||
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "yes", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
|
||||
const { companyId, routine, svc } = await seedFixture({ runtimeEnv });
|
||||
const { trigger: scheduleTrigger } = await svc.createTrigger(
|
||||
routine.id,
|
||||
{ kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" },
|
||||
{},
|
||||
);
|
||||
const { trigger: webhookTrigger } = await svc.createTrigger(
|
||||
routine.id,
|
||||
{ kind: "webhook", signingMode: "none" },
|
||||
{},
|
||||
);
|
||||
const pastDue = new Date("2020-01-01T00:00:00.000Z");
|
||||
await db.update(routineTriggers).set({ nextRunAt: pastDue }).where(eq(routineTriggers.id, scheduleTrigger.id));
|
||||
|
||||
expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 0 });
|
||||
const webhookRun = await svc.firePublicTrigger(webhookTrigger.publicId!, { payload: { event: "created" } });
|
||||
expect(webhookRun).toMatchObject({ source: "webhook", status: "skipped", failureReason: "worktree_execution_cutoff" });
|
||||
|
||||
const manualRun = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
expect(manualRun.status).toBe("issue_created");
|
||||
|
||||
const automatedRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, routine.id));
|
||||
expect(automatedRuns.filter((run) => run.failureReason === "worktree_execution_cutoff")).toHaveLength(2);
|
||||
expect(automatedRuns.filter((run) => run.linkedIssueId)).toHaveLength(1);
|
||||
const scheduleAfter = await db.select().from(routineTriggers).where(eq(routineTriggers.id, scheduleTrigger.id)).then((rows) => rows[0]);
|
||||
expect(scheduleAfter!.nextRunAt!.getTime()).toBeGreaterThan(pastDue.getTime());
|
||||
expect((await db.select().from(issues).where(eq(issues.companyId, companyId))).filter((issue) => issue.originKind === "routine_execution")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatches only post-cutoff scheduled routines in an armed worktree", async () => {
|
||||
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
|
||||
const { companyId, agentId, projectId, routine: oldRoutine, svc } = await seedFixture({ runtimeEnv });
|
||||
const cutoff = new Date("2025-01-01T00:00:00.000Z");
|
||||
await armWorktreeExecution(cutoff);
|
||||
const newRoutine = await svc.create(companyId, {
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "new routine",
|
||||
description: null,
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
}, {});
|
||||
await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, oldRoutine.id));
|
||||
await db.update(routines).set({ createdAt: new Date("2025-01-01T00:00:01.000Z") }).where(eq(routines.id, newRoutine.id));
|
||||
const { trigger: oldTrigger } = await svc.createTrigger(oldRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
||||
const { trigger: newTrigger } = await svc.createTrigger(newRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
||||
await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, oldTrigger.id));
|
||||
await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, newTrigger.id));
|
||||
|
||||
expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 1 });
|
||||
const oldRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, oldRoutine.id));
|
||||
expect(oldRuns).toMatchObject([{ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null }]);
|
||||
const newRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, newRoutine.id));
|
||||
expect(newRuns).toMatchObject([{ status: "issue_created" }]);
|
||||
});
|
||||
|
||||
it("applies the armed cutoff to webhook dispatch but not manual API runs", async () => {
|
||||
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
|
||||
const { routine, svc } = await seedFixture({ runtimeEnv });
|
||||
await armWorktreeExecution(new Date("2025-01-01T00:00:00.000Z"));
|
||||
await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, routine.id));
|
||||
const { trigger } = await svc.createTrigger(routine.id, { kind: "webhook", signingMode: "none" }, {});
|
||||
|
||||
const webhookRun = await svc.firePublicTrigger(trigger.publicId!, { payload: { event: "created" } });
|
||||
expect(webhookRun).toMatchObject({ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null });
|
||||
expect((await svc.runRoutine(routine.id, { source: "api" })).status).toBe("issue_created");
|
||||
});
|
||||
|
||||
it("suppresses scheduled ticks while the routine project is paused, then resumes when unpaused", async () => {
|
||||
const { companyId, projectId, routine, svc } = await seedFixture();
|
||||
const { trigger } = await svc.createTrigger(
|
||||
|
||||
@@ -663,4 +663,16 @@ describeEmbeddedPostgres("task watchdog scheduler", () => {
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
|
||||
expect(watchdogIssues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles an armed cutoff when no watchdogs are active", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.reconcileTaskWatchdogs({
|
||||
companyId,
|
||||
issueCreatedAtGte: new Date(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ checked: 0, triggered: 0 });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,30 @@ describe("ui branding", () => {
|
||||
expect(meta).toContain('name="paperclip-worktree-color"');
|
||||
});
|
||||
|
||||
it("surfaces the runtime instance id so the UI can fail closed on copied rows", () => {
|
||||
const branding = getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
PAPERCLIP_INSTANCE_ID: "inst-abc123",
|
||||
});
|
||||
expect(branding.instanceId).toBe("inst-abc123");
|
||||
|
||||
const meta = renderRuntimeBrandingMeta(branding);
|
||||
expect(meta).toContain('name="paperclip-instance-id"');
|
||||
expect(meta).toContain('content="inst-abc123"');
|
||||
});
|
||||
|
||||
it("omits the instance-id meta when the runtime id is unset", () => {
|
||||
const branding = getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
});
|
||||
expect(branding.instanceId).toBeNull();
|
||||
expect(renderRuntimeBrandingMeta(branding)).not.toContain('name="paperclip-instance-id"');
|
||||
});
|
||||
|
||||
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
|
||||
const branded = applyUiBranding(TEMPLATE, {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
|
||||
Reference in new issue
Block a user