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:
Dotta 2026-07-10 16:11:26 -05:00 committed by GitHub
parent 23f34491e2
commit 70ce005bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1082 additions and 69 deletions

View File

@ -70,6 +70,16 @@ export interface InstanceExperimentalSettings {
* runs actually execute inside the preview. Ignored outside a worktree.
*/
enableWorktreeRunExecution: boolean;
/**
* Server-managed cutoff recorded when worktree run execution is enabled in
* this instance. Client PATCH payloads must not control this value.
*/
worktreeRunExecutionActivatedAt: string | null;
/**
* Server-managed instance id captured with the cutoff so copied settings rows
* from another instance fail closed.
*/
worktreeRunExecutionActivationInstanceId: string | null;
issueGraphLivenessAutoRecoveryLookbackHours: number;
}

View File

@ -28,6 +28,20 @@ describe("instance experimental settings validators", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableWorktreeRunExecution).toBe(false);
expect(settings.worktreeRunExecutionActivatedAt).toBeNull();
expect(settings.worktreeRunExecutionActivationInstanceId).toBeNull();
});
it("strips server-managed worktree run execution fields from patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "copied-instance",
}),
).toEqual({
enableWorktreeRunExecution: true,
});
});
it("defaults built-in agents off", () => {

View File

@ -58,6 +58,8 @@ export const instanceExperimentalSettingsSchema = z.object({
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
enableWorktreeRunExecution: z.boolean().default(false),
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null),
issueGraphLivenessAutoRecoveryLookbackHours: z
.number()
.int()
@ -66,7 +68,13 @@ export const instanceExperimentalSettingsSchema = z.object({
.default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS),
}).strict();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema
.omit({
worktreeRunExecutionActivatedAt: true,
worktreeRunExecutionActivationInstanceId: true,
})
.partial()
.strip();
export const patchInstanceSettingsSchema = z.object({
defaultEnvironmentId: z.string().uuid().nullable().optional(),

View File

@ -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);
});
});

View File

@ -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

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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(

View File

@ -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 });
});
});

View File

@ -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",

View File

@ -48,6 +48,7 @@ import {
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
reconcileAdapterAvailability,
@ -838,6 +839,16 @@ export async function startServer(): Promise<StartedServer> {
drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown;
const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager });
const routines = routineService(db as any, { pluginWorkerManager });
const worktreeRunExecutionActivation = await resolveWorktreeRunExecutionActivationState({
getExperimental: () => instanceSettingsService(db).getExperimental(),
});
logger.info(
{
state: worktreeRunExecutionActivation.armed ? "armed" : "disarmed",
cutoff: worktreeRunExecutionActivation.cutoff,
},
"worktree run-execution cutoff state",
);
const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression();
// Reap orphaned runs before timer ticks start so wakeups cannot coalesce

View File

@ -83,7 +83,11 @@ import {
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
instanceSettingsService,
isTruthyRuntimeEnvValue,
resolveWorktreeRunExecutionActivationState,
} from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
@ -2042,14 +2046,23 @@ export function agentRoutes(
includeRoutineExecutions: true,
limit: ISSUE_LIST_DEFAULT_LIMIT,
});
const issueIds = rows.map((issue) => issue.id);
const worktreeActivation = await resolveWorktreeRunExecutionActivationState({
getExperimental: () => instanceSettingsService(db).getExperimental(),
});
const isWorktreeRuntime = isTruthyRuntimeEnvValue(process.env.PAPERCLIP_IN_WORKTREE);
const eligibleRows = !isWorktreeRuntime
? rows
: worktreeActivation.armed
? rows.filter((issue) => new Date(issue.createdAt) >= new Date(worktreeActivation.cutoff))
: [];
const issueIds = eligibleRows.map((issue) => issue.id);
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
]);
res.json(
rows.map((issue) => ({
eligibleRows.map((issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,

View File

@ -161,7 +161,10 @@ import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import {
instanceSettingsService,
resolveWorktreeRunExecutionActivation,
} from "./instance-settings.js";
import {
evaluateExecutionAllowlist,
isExecutionForcedToKubernetes,
@ -5018,30 +5021,45 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
// Only worktree runtimes ever read the setting; a short TTL keeps the hot-path
// suppression checks off the DB, and a read failure falls back to prior/default
// (fail closed to suppression).
let cachedWorktreeRunExecutionOverride: { value: boolean; at: number } = { value: false, at: 0 };
let cachedWorktreeRunExecutionOverride: { allowed: boolean; cutoff: Date | null; at: number } = {
allowed: false,
cutoff: null,
at: 0,
};
const WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS = 3_000;
const resolveWorktreeRunExecutionOverride = async (): Promise<boolean> => {
if (!inWorktreeRuntime) return false;
const resolveWorktreeRunExecutionOverride = async () => {
if (!inWorktreeRuntime) return { allowed: false, cutoff: null };
const now = Date.now();
if (now - cachedWorktreeRunExecutionOverride.at < WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS) {
return cachedWorktreeRunExecutionOverride.value;
return cachedWorktreeRunExecutionOverride;
}
try {
const experimental = await instanceSettings.getExperimental();
const activation = resolveWorktreeRunExecutionActivation(
await instanceSettings.getExperimental(),
runtimeEnv.PAPERCLIP_INSTANCE_ID?.trim() || null,
);
const cutoff = activation.armed ? new Date(activation.cutoff) : null;
cachedWorktreeRunExecutionOverride = {
value: experimental.enableWorktreeRunExecution === true,
allowed: Boolean(activation.armed && cutoff && !Number.isNaN(cutoff.getTime())),
cutoff: cutoff && !Number.isNaN(cutoff.getTime()) ? cutoff : null,
at: now,
};
} catch {
// Keep the prior (default-false) value so a settings read failure fails
// closed to the safe suppressed state.
}
return cachedWorktreeRunExecutionOverride.value;
return cachedWorktreeRunExecutionOverride;
};
const getSchedulingSuppression = async () =>
resolveHeartbeatSchedulingSuppression(runtimeEnv, {
allowWorktreeRunExecution: await resolveWorktreeRunExecutionOverride(),
const getSchedulingSuppression = async () => {
const override = await resolveWorktreeRunExecutionOverride();
return resolveHeartbeatSchedulingSuppression(runtimeEnv, {
allowWorktreeRunExecution: override.allowed,
});
};
const getWorktreeExecutionCutoff = async () => {
const override = await resolveWorktreeRunExecutionOverride();
return override.allowed ? override.cutoff : null;
};
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
@ -9462,6 +9480,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
async function promoteDueScheduledRetries(now = new Date()) {
const cutoff = await getWorktreeExecutionCutoff();
const dueRuns = await db
.select()
.from(heartbeatRuns)
@ -9469,6 +9488,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
and(
eq(heartbeatRuns.status, "scheduled_retry"),
lte(heartbeatRuns.scheduledRetryAt, now),
cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined,
),
)
.orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id))
@ -10745,6 +10765,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
async function resumeQueuedRuns() {
if ((await getSchedulingSuppression()).suppressed) return;
const cutoff = await getWorktreeExecutionCutoff();
const queuedRuns = await db
.select({ agentId: heartbeatRuns.agentId })
@ -10753,6 +10774,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.where(and(
eq(heartbeatRuns.status, "queued"),
eq(companies.status, "active"),
cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined,
));
const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))];
@ -10762,7 +10784,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
async function reconcileStrandedAssignedIssues() {
return recovery.reconcileStrandedAssignedIssues();
return recovery.reconcileStrandedAssignedIssues({ issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}
async function sweepStaleIssueLocks() {
@ -10783,15 +10805,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string }) {
return recovery.scanSilentActiveRuns(opts);
return recovery.scanSilentActiveRuns({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}
async function reconcileProductivityReviews(opts?: { now?: Date; companyId?: string }) {
return productivityReviews.reconcileProductivityReviews(opts);
return productivityReviews.reconcileProductivityReviews({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}
async function reconcileTaskWatchdogs(opts?: { companyId?: string | null; runId?: string | null }) {
return taskWatchdogs.reconcileTaskWatchdogs(opts);
return taskWatchdogs.reconcileTaskWatchdogs({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}
async function buildRunOutputSilence(
@ -10813,7 +10835,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
force?: boolean;
lookbackHours?: number;
}) {
return recovery.reconcileIssueGraphLiveness(opts);
return recovery.reconcileIssueGraphLiveness({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}
async function updateRuntimeState(
@ -10873,6 +10895,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
async function startNextQueuedRunForAgent(agentId: string) {
if ((await getSchedulingSuppression()).suppressed) return [];
const cutoff = await getWorktreeExecutionCutoff();
return withAgentStartLock(agentId, async () => {
const agent = await getAgent(agentId);
@ -10892,7 +10915,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const queuedRuns = await db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "queued")))
.where(and(
eq(heartbeatRuns.agentId, agentId),
eq(heartbeatRuns.status, "queued"),
cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined,
))
.orderBy(asc(heartbeatRuns.createdAt));
if (queuedRuns.length === 0) return [];
@ -14304,6 +14331,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return null;
}
const worktreeExecutionCutoff = opts.requestedByActorType === "user"
? null
: await getWorktreeExecutionCutoff();
const company = await db
.select({ status: companies.status })
.from(companies)
@ -14369,11 +14400,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
? or(eq(issues.id, issueId), eq(issues.identifier, issueId.toUpperCase()))
: eq(issues.identifier, issueId.toUpperCase());
const resolvedIssue = await db
.select({ id: issues.id, projectId: issues.projectId })
.select({ id: issues.id, projectId: issues.projectId, createdAt: issues.createdAt })
.from(issues)
.where(and(eq(issues.companyId, agent.companyId), idMatch))
.then((rows) => rows[0] ?? null);
if (resolvedIssue) {
if (worktreeExecutionCutoff && resolvedIssue.createdAt < worktreeExecutionCutoff) {
await writeSkippedHeartbeatRequest("heartbeat.worktree_execution_cutoff", {
reason: "worktree_execution_cutoff",
cutoff: worktreeExecutionCutoff.toISOString(),
issueId: resolvedIssue.id,
});
return null;
}
projectId = resolvedIssue.projectId ?? null;
// Canonicalize context to the UUID so downstream lookups always use UUID
if (resolvedIssue.id !== issueId) {
@ -14548,6 +14587,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
assigneeAgentId: issues.assigneeAgentId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
createdAt: issues.createdAt,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
@ -14570,6 +14610,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "skipped" as const };
}
if (worktreeExecutionCutoff && issue.createdAt < worktreeExecutionCutoff) {
await tx.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
source,
triggerDetail,
reason: "heartbeat.worktree_execution_cutoff",
payload: {
...(payload ?? {}),
heartbeatSkip: {
reason: "worktree_execution_cutoff",
cutoff: worktreeExecutionCutoff.toISOString(),
issueId: issue.id,
},
},
status: "skipped",
requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null,
finishedAt: new Date(),
});
return { kind: "skipped" as const };
}
const cancelStaleScheduledRetry = async (scheduledRun: typeof heartbeatRuns.$inferSelect) => {
const issueCancelled = issue.status === "cancelled";
if (
@ -15952,6 +16016,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
skipped: 0,
};
}
const cutoff = await getWorktreeExecutionCutoff();
const allAgents = await db
.select({ ...getTableColumns(agents) })
@ -15969,6 +16034,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const policy = parseHeartbeatPolicy(agent);
if (!policy.enabled || policy.intervalSec <= 0) continue;
if (cutoff) {
const eligibleIssue = await db
.select({ id: issues.id })
.from(issues)
.where(and(
eq(issues.companyId, agent.companyId),
eq(issues.assigneeAgentId, agent.id),
inArray(issues.status, ["todo", "in_progress"]),
gte(issues.createdAt, cutoff),
))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!eligibleIssue) continue;
}
checked += 1;
const baseline = new Date(agent.lastHeartbeatAt ?? agent.createdAt).getTime();
const elapsedMs = now.getTime() - baseline;

View File

@ -18,6 +18,164 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
interface InstanceSettingsServiceOptions {
runtimeEnv?: Record<string, string | undefined>;
now?: () => Date;
}
type WorktreeRunExecutionSuppressedReason =
| "not_worktree_runtime"
| "flag_disabled"
| "missing_cutoff"
| "missing_instance_id"
| "instance_id_mismatch"
| "settings_read_error";
export type WorktreeRunExecutionActivationState =
| {
armed: true;
cutoff: string;
activationInstanceId: string;
reason: null;
}
| {
armed: false;
cutoff: null;
activationInstanceId: string | null;
reason: WorktreeRunExecutionSuppressedReason;
};
export function isTruthyRuntimeEnvValue(value: string | undefined) {
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
}
function getRuntimeInstanceId(env: Record<string, string | undefined>) {
const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim();
return instanceId ? instanceId : null;
}
function stripServerManagedExperimentalPatchFields(
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
): PatchInstanceExperimentalSettings {
const {
worktreeRunExecutionActivatedAt: _ignoredActivatedAt,
worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId,
...patchable
} = patch as Record<string, unknown>;
return patchable as PatchInstanceExperimentalSettings;
}
export function applyExperimentalSettingsPatch(
current: unknown,
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
options: InstanceSettingsServiceOptions = {},
): InstanceExperimentalSettings {
const previousExperimental = normalizeExperimentalSettings(current);
const patchable = stripServerManagedExperimentalPatchFields(patch);
const nextExperimental = normalizeExperimentalSettings({
...previousExperimental,
...patchable,
});
const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call(
patchable,
"enableWorktreeRunExecution",
);
if (!hasWorktreeRunExecutionPatch) {
return nextExperimental;
}
if (nextExperimental.enableWorktreeRunExecution !== true) {
return {
...nextExperimental,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
};
}
if (previousExperimental.enableWorktreeRunExecution === true) {
return nextExperimental;
}
const runtimeEnv = options.runtimeEnv ?? process.env;
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
return nextExperimental;
}
return {
...nextExperimental,
worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(),
worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv),
};
}
function suppressWorktreeRunExecution(
reason: WorktreeRunExecutionSuppressedReason,
activationInstanceId: string | null = null,
): WorktreeRunExecutionActivationState {
return {
armed: false,
cutoff: null,
activationInstanceId,
reason,
};
}
export function resolveWorktreeRunExecutionActivation(
experimental: InstanceExperimentalSettings,
currentInstanceId: string | null | undefined,
): WorktreeRunExecutionActivationState {
if (experimental.enableWorktreeRunExecution !== true) {
return suppressWorktreeRunExecution(
"flag_disabled",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (!experimental.worktreeRunExecutionActivatedAt) {
return suppressWorktreeRunExecution(
"missing_cutoff",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (!currentInstanceId) {
return suppressWorktreeRunExecution(
"missing_instance_id",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
return suppressWorktreeRunExecution(
"instance_id_mismatch",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
return {
armed: true,
cutoff: experimental.worktreeRunExecutionActivatedAt,
activationInstanceId: currentInstanceId,
reason: null,
};
}
export async function resolveWorktreeRunExecutionActivationState(options: {
getExperimental: () => Promise<InstanceExperimentalSettings>;
runtimeEnv?: Record<string, string | undefined>;
}): Promise<WorktreeRunExecutionActivationState> {
const runtimeEnv = options.runtimeEnv ?? process.env;
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
return suppressWorktreeRunExecution("not_worktree_runtime");
}
try {
return resolveWorktreeRunExecutionActivation(
await options.getExperimental(),
getRuntimeInstanceId(runtimeEnv),
);
} catch {
return suppressWorktreeRunExecution("settings_read_error");
}
}
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
@ -63,6 +221,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
worktreeRunExecutionActivationInstanceId:
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
issueGraphLivenessAutoRecoveryLookbackHours:
parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ??
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
@ -88,6 +249,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours:
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
};
@ -104,7 +267,7 @@ function toInstanceSettings(row: typeof instanceSettings.$inferSelect): Instance
} as InstanceSettings;
}
export function instanceSettingsService(db: Db) {
export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) {
async function getOrCreateRow() {
const existing = await db
.select()
@ -192,10 +355,7 @@ export function instanceSettingsService(db: Db) {
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({
...normalizeExperimentalSettings(current.experimental),
...patch,
});
const nextExperimental = applyExperimentalSettingsPatch(current.experimental, patch, options);
const now = new Date();
const [updated] = await db
.update(instanceSettings)

View File

@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { clampIssueRequestDepth } from "@paperclipai/shared";
import {
@ -763,6 +763,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
now?: Date;
companyId?: string;
thresholds?: Partial<ProductivityReviewThresholds>;
issueCreatedAtGte?: Date | null;
}) {
const now = opts?.now ?? new Date();
const thresholds = buildThresholds(opts?.thresholds);
@ -777,6 +778,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,
sql`${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`,
opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined,
),
)
.orderBy(asc(issues.updatedAt), asc(issues.id))

View File

@ -2009,10 +2009,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return { kind: "created" as const, evaluationIssueId: evaluation.id };
}
async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string }) {
async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string; issueCreatedAtGte?: Date | null }) {
const now = opts?.now ?? new Date();
const suspicionBefore = new Date(now.getTime() - ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS);
const candidates = await db
let candidates = await db
.select()
.from(heartbeatRuns)
.where(
@ -2025,6 +2025,27 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.orderBy(asc(heartbeatRuns.createdAt))
.limit(100);
if (opts?.issueCreatedAtGte) {
const issueIds = [...new Set(candidates.flatMap((run) => {
const context = parseObject(run.contextSnapshot);
const issueId = context.issueId ?? context.taskId;
return typeof issueId === "string" && issueId.length > 0 ? [issueId] : [];
}))];
const eligibleIssueIds = new Set(
issueIds.length > 0
? (await db.select({ id: issues.id }).from(issues).where(and(
inArray(issues.id, issueIds),
gte(issues.createdAt, opts.issueCreatedAtGte),
))).map((issue) => issue.id)
: [],
);
candidates = candidates.filter((run) => {
const context = parseObject(run.contextSnapshot);
const issueId = context.issueId ?? context.taskId;
return typeof issueId === "string" && eligibleIssueIds.has(issueId);
});
}
const result = {
scanned: candidates.length,
created: 0,
@ -2955,7 +2976,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return updated;
}
async function reconcileStrandedAssignedIssues() {
async function reconcileStrandedAssignedIssues(opts?: { issueCreatedAtGte?: Date | null }) {
const candidates = await db
.select()
.from(issues)
@ -2967,6 +2988,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
sql`${issues.assigneeAgentId} is not null`,
eq(issues.status, "in_review"),
),
opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined,
),
);
@ -4488,8 +4510,25 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
runId?: string | null;
force?: boolean;
lookbackHours?: number;
issueCreatedAtGte?: Date | null;
}) {
const findings = await collectIssueGraphLivenessFindings();
let findings = await collectIssueGraphLivenessFindings();
if (opts?.issueCreatedAtGte) {
const findingIssueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))];
const eligibleIssueIds = new Set(
findingIssueIds.length === 0
? []
: (await db
.select({ id: issues.id })
.from(issues)
.where(and(
inArray(issues.id, findingIssueIds),
gte(issues.createdAt, opts.issueCreatedAtGte),
)))
.map((issue) => issue.id),
);
findings = findings.filter((finding) => eligibleIssueIds.has(finding.recoveryIssueId));
}
const experimentalSettings = await instanceSettings.getExperimental();
const autoRecoveryEnabled = asBoolean(
experimentalSettings.enableIssueGraphLivenessAutoRecovery,

View File

@ -65,6 +65,12 @@ import { secretService } from "./secrets.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
import { parseCron, validateCron } from "./cron.js";
import { heartbeatService } from "./heartbeat.js";
import {
instanceSettingsService,
isTruthyRuntimeEnvValue,
resolveWorktreeRunExecutionActivationState,
type WorktreeRunExecutionActivationState,
} from "./instance-settings.js";
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
import { logActivity } from "./activity-log.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
@ -592,10 +598,13 @@ export function routineService(
deps: {
heartbeat?: IssueAssignmentWakeupDeps;
pluginWorkerManager?: PluginWorkerManager;
runtimeEnv?: Record<string, string | undefined>;
} = {},
) {
const issueSvc = issueService(db);
const secretsSvc = secretService(db);
const instanceSettings = instanceSettingsService(db);
const runtimeEnv = deps.runtimeEnv ?? process.env;
const heartbeat = deps.heartbeat ?? heartbeatService(db, {
pluginWorkerManager: deps.pluginWorkerManager,
});
@ -1155,14 +1164,32 @@ export function routineService(
}
}
// Records a skipped scheduled firing without creating an execution issue. Used when the
// routine's project is paused: the tick is still claimed/advanced upstream (no backfill),
// and run history + trigger audit reflect the pause-specific skip.
async function recordSuppressedScheduleRun(input: {
async function getAutomaticRoutineDispatchEligibility(
routine: typeof routines.$inferSelect,
activation?: WorktreeRunExecutionActivationState,
) {
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) return { eligible: true };
const resolvedActivation = activation ?? await resolveWorktreeRunExecutionActivationState({
getExperimental: instanceSettings.getExperimental,
runtimeEnv,
});
if (!resolvedActivation.armed) return { eligible: false };
const cutoff = new Date(resolvedActivation.cutoff);
if (Number.isNaN(cutoff.getTime()) || routine.createdAt < cutoff) return { eligible: false };
return { eligible: true };
}
// Records an automatic firing that was claimed but intentionally not dispatched. The
// scheduler advances its tick before calling this helper, so suppressed work is never
// replayed after a setting or project state changes.
async function recordSuppressedAutomaticRun(input: {
routine: typeof routines.$inferSelect;
trigger: typeof routineTriggers.$inferSelect;
source: "schedule" | "webhook";
reason: string;
nextRunAt: Date | null;
nextRunAt?: Date | null;
}) {
const triggeredAt = new Date();
const run = await db.transaction(async (tx) => {
@ -1173,7 +1200,7 @@ export function routineService(
companyId: input.routine.companyId,
routineId: input.routine.id,
triggerId: input.trigger.id,
source: "schedule",
source: input.source,
status: "skipped",
triggeredAt,
failureReason: input.reason,
@ -1187,7 +1214,7 @@ export function routineService(
routineId: input.routine.id,
triggerId: input.trigger.id,
triggeredAt,
status: "skipped_paused",
status: input.reason === "paused" ? "skipped_paused" : "skipped_worktree_execution_cutoff",
nextRunAt: input.nextRunAt,
}, txDb);
return createdRun;
@ -1197,14 +1224,14 @@ export function routineService(
await logActivity(db, {
companyId: input.routine.companyId,
actorType: "system",
actorId: "routine-scheduler",
actorId: input.source === "schedule" ? "routine-scheduler" : "routine-webhook",
action: "routine.run_skipped",
entityType: "routine_run",
entityId: run.id,
details: {
routineId: input.routine.id,
triggerId: input.trigger.id,
source: "schedule",
source: input.source,
status: "skipped",
reason: input.reason,
},
@ -2645,6 +2672,16 @@ export function routineService(
if (!valid) throw unauthorized();
}
const eligibility = await getAutomaticRoutineDispatchEligibility(routine);
if (!eligibility.eligible) {
return recordSuppressedAutomaticRun({
routine,
trigger,
source: "webhook",
reason: "worktree_execution_cutoff",
});
}
return dispatchRoutineRun({
routine,
trigger,
@ -2732,6 +2769,12 @@ export function routineService(
},
tickScheduledTriggers: async (now: Date = new Date()) => {
const worktreeActivation = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)
? await resolveWorktreeRunExecutionActivationState({
getExperimental: instanceSettings.getExperimental,
runtimeEnv,
})
: undefined;
const due = await db
.select({
trigger: routineTriggers,
@ -2761,11 +2804,13 @@ export function routineService(
// at the next cron boundary instead of replaying missed firings. Routines with no
// project are never suppressed here.
const projectPaused = !!(row.routine.projectId && row.projectPausedAt);
const automaticEligibility = await getAutomaticRoutineDispatchEligibility(row.routine, worktreeActivation);
const worktreeSuppressed = !automaticEligibility.eligible;
let runCount = 1;
let claimedNextRunAt = nextCronTickInTimeZone(row.trigger.cronExpression, row.trigger.timezone, now);
if (!projectPaused && row.routine.catchUpPolicy === "enqueue_missed_with_cap") {
if (!projectPaused && !worktreeSuppressed && row.routine.catchUpPolicy === "enqueue_missed_with_cap") {
let cursor: Date | null = row.trigger.nextRunAt;
runCount = 0;
while (cursor && cursor <= now && runCount < MAX_CATCH_UP_RUNS) {
@ -2792,11 +2837,12 @@ export function routineService(
.then((rows) => rows[0] ?? null);
if (!claimed) continue;
if (projectPaused) {
await recordSuppressedScheduleRun({
if (projectPaused || worktreeSuppressed) {
await recordSuppressedAutomaticRun({
routine: row.routine,
trigger: row.trigger,
reason: "paused",
source: "schedule",
reason: worktreeSuppressed ? "worktree_execution_cutoff" : "paused",
nextRunAt: claimedNextRunAt,
});
continue;

View File

@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
@ -1560,8 +1560,28 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
return toIssueWatchdog(updated);
},
reconcileTaskWatchdogs: async (opts: { companyId?: string | null; runId?: string | null } = {}) => {
const rows = await listActiveWatchdogsForCompany(opts.companyId ?? null);
reconcileTaskWatchdogs: async (opts: {
companyId?: string | null;
runId?: string | null;
issueCreatedAtGte?: Date | null;
} = {}) => {
let rows = await listActiveWatchdogsForCompany(opts.companyId ?? null);
if (opts.issueCreatedAtGte) {
const watchdogIssueIds = [...new Set(rows.map((row) => row.issueId))];
const eligibleIssueIds = new Set(
watchdogIssueIds.length === 0
? []
: (await db
.select({ id: issues.id })
.from(issues)
.where(and(
inArray(issues.id, watchdogIssueIds),
gte(issues.createdAt, opts.issueCreatedAtGte),
)))
.map((issue) => issue.id),
);
rows = rows.filter((row) => eligibleIssueIds.has(row.issueId));
}
const result = {
checked: 0,
triggered: 0,

View File

@ -16,6 +16,13 @@ export type WorktreeUiBranding = {
color: string | null;
textColor: string | null;
faviconHref: string | null;
/**
* Runtime instance id for this worktree preview. Surfaced to the client so
* the experimental "Run tasks in this worktree" card can fail closed when a
* copied settings row was armed in a different instance. Null outside a
* worktree or when the runtime id is unset.
*/
instanceId: string | null;
};
function isTruthyEnvValue(value: string | undefined): boolean {
@ -152,6 +159,7 @@ export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): Wor
color: null,
textColor: null,
faviconHref: null,
instanceId: null,
};
}
@ -165,6 +173,7 @@ export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): Wor
color,
textColor,
faviconHref: createFaviconDataUrl(color, textColor),
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID),
};
}
@ -181,12 +190,16 @@ export function renderFaviconLinks(branding: WorktreeUiBranding): string {
export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string {
if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return "";
return [
const tags = [
'<meta name="paperclip-worktree-enabled" content="true" />',
`<meta name="paperclip-worktree-name" content="${escapeHtmlAttribute(branding.name)}" />`,
`<meta name="paperclip-worktree-color" content="${escapeHtmlAttribute(branding.color)}" />`,
`<meta name="paperclip-worktree-text-color" content="${escapeHtmlAttribute(branding.textColor)}" />`,
].join("\n");
];
if (branding.instanceId) {
tags.push(`<meta name="paperclip-instance-id" content="${escapeHtmlAttribute(branding.instanceId)}" />`);
}
return tags.join("\n");
}
function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string {

View File

@ -61,6 +61,17 @@ export function isWorktreeRuntime(): boolean {
return readMetaContent("paperclip-worktree-enabled") === "true";
}
/**
* Runtime instance id of the worktree preview serving this UI, injected by the
* server as a `<meta name="paperclip-instance-id">` tag. Returns null outside a
* worktree or when the server did not surface the id. Used by the experimental
* "Run tasks in this worktree" card to fail closed when a copied settings row
* was armed in a different instance.
*/
export function getWorktreeInstanceId(): string | null {
return readMetaContent("paperclip-instance-id");
}
export function getWorktreeUiBranding(): WorktreeUiBranding | null {
if (readMetaContent("paperclip-worktree-enabled") !== "true") return null;

View File

@ -73,6 +73,8 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
};
}
@ -94,6 +96,21 @@ function setWorktreeRuntimeMeta(enabled: boolean) {
}
}
function setWorktreeInstanceIdMeta(instanceId: string | null) {
const name = "paperclip-instance-id";
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
if (instanceId) {
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", name);
document.head.appendChild(meta);
}
meta.setAttribute("content", instanceId);
} else if (meta) {
meta.remove();
}
}
describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
@ -134,6 +151,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
root = null;
container.remove();
setWorktreeRuntimeMeta(false);
setWorktreeInstanceIdMeta(null);
vi.clearAllMocks();
});
@ -279,6 +297,69 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("shows the cutoff-copy for the worktree run-execution toggle when off", async () => {
setWorktreeRuntimeMeta(true);
await renderPage();
expect(container.textContent).toContain(
"Only tasks created after enabling will run automatically",
);
expect(container.textContent).toContain("Toggling off and on resets the cutoff.");
// Off => no armed banner and no fail-closed hint.
expect(container.textContent).not.toContain("Running tasks created after");
expect(container.textContent).not.toContain("Execution is suppressed");
});
it("shows the armed timestamp when the flag matches the current instance", async () => {
setWorktreeRuntimeMeta(true);
setWorktreeInstanceIdMeta("inst-current");
currentExperimentalSettings = {
...currentExperimentalSettings,
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
worktreeRunExecutionActivationInstanceId: "inst-current",
};
await renderPage();
expect(container.textContent).toContain("Running tasks created after");
expect(container.textContent).not.toContain("Execution is suppressed");
const toggle = container.querySelector<HTMLButtonElement>(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("fails closed with a re-enable hint when the flag was armed in another instance", async () => {
setWorktreeRuntimeMeta(true);
setWorktreeInstanceIdMeta("inst-current");
currentExperimentalSettings = {
...currentExperimentalSettings,
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
worktreeRunExecutionActivationInstanceId: "inst-other",
};
await renderPage();
expect(container.textContent).toContain("Execution is suppressed");
expect(container.textContent).toContain("armed in a different instance");
expect(container.textContent).toContain("Toggle it off and back on");
expect(container.textContent).not.toContain("Running tasks created after");
});
it("fails closed with a re-enable hint when the activation cutoff is missing", async () => {
setWorktreeRuntimeMeta(true);
setWorktreeInstanceIdMeta("inst-current");
currentExperimentalSettings = {
...currentExperimentalSettings,
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
};
await renderPage();
expect(container.textContent).toContain("Execution is suppressed");
expect(container.textContent).toContain("missing its activation cutoff");
expect(container.textContent).not.toContain("Running tasks created after");
});
it("renders and patches the Built-in Agents experimental toggle", async () => {
await renderPage();

View File

@ -7,7 +7,7 @@ import type {
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { isWorktreeRuntime } from "../lib/worktree-branding";
import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@ -34,6 +34,43 @@ function formatRecoveryState(state: string) {
return state.replace(/_/g, " ");
}
type WorktreeRunExecutionDisplayState =
| { kind: "off" }
| { kind: "armed"; activatedAt: string }
| { kind: "fail_closed"; reason: "missing_cutoff" | "missing_instance_id" | "instance_mismatch" };
/**
* Mirror of the server's `resolveWorktreeRunExecutionActivation` fail-closed
* ladder (server/src/services/instance-settings.ts) so the card never claims a
* copied/legacy row is arming execution. The derived fields are display-only
* the PATCH the toggle sends still writes just the boolean.
*/
function resolveWorktreeRunExecutionDisplayState(
settings:
| Pick<
InstanceExperimentalSettings,
| "enableWorktreeRunExecution"
| "worktreeRunExecutionActivatedAt"
| "worktreeRunExecutionActivationInstanceId"
>
| undefined,
currentInstanceId: string | null,
): WorktreeRunExecutionDisplayState {
if (settings?.enableWorktreeRunExecution !== true) return { kind: "off" };
if (!settings.worktreeRunExecutionActivatedAt) return { kind: "fail_closed", reason: "missing_cutoff" };
if (!currentInstanceId) return { kind: "fail_closed", reason: "missing_instance_id" };
if (settings.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
return { kind: "fail_closed", reason: "instance_mismatch" };
}
return { kind: "armed", activatedAt: settings.worktreeRunExecutionActivatedAt };
}
function formatActivationTimestamp(iso: string): string {
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return iso;
return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
// PAP-11233: keep Conference Room code intact, but hide the user-facing opt-in for now.
const SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING = false;
@ -236,6 +273,10 @@ export function InstanceExperimentalSettings() {
const inWorktree = isWorktreeRuntime();
const enableWorktreeRunExecution = experimentalQuery.data?.enableWorktreeRunExecution === true;
const worktreeRunExecutionState = resolveWorktreeRunExecutionDisplayState(
experimentalQuery.data,
getWorktreeInstanceId(),
);
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
@ -327,23 +368,53 @@ export function InstanceExperimentalSettings() {
{inWorktree ? (
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Run tasks in this worktree</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
This is an isolated git-worktree preview instance. By default it does not execute agent runs; tasks stay
queued so previews never self-run work. Turn this on to let the heartbeat scheduler execute runs here.
This setting only affects this worktree instance and is ignored outside a worktree.
</p>
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Run tasks in this worktree</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs
here. Only tasks created after enabling will run automatically copied/pre-existing tasks stay
parked. Toggling off and on resets the cutoff.
</p>
</div>
<ToggleSwitch
checked={enableWorktreeRunExecution}
onCheckedChange={(checked) =>
toggleMutation.mutate({ enableWorktreeRunExecution: checked })
}
disabled={toggleMutation.isPending}
aria-label="Toggle worktree run execution setting"
/>
</div>
<ToggleSwitch
checked={enableWorktreeRunExecution}
onCheckedChange={(checked) =>
toggleMutation.mutate({ enableWorktreeRunExecution: checked })
}
disabled={toggleMutation.isPending}
aria-label="Toggle worktree run execution setting"
/>
{worktreeRunExecutionState.kind === "armed" ? (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-foreground">
<Play className="h-4 w-4 shrink-0 text-emerald-600" />
<span>
Running tasks created after{" "}
<span className="font-medium">
{formatActivationTimestamp(worktreeRunExecutionState.activatedAt)}
</span>
.
</span>
</div>
) : null}
{worktreeRunExecutionState.kind === "fail_closed" ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-0.5">
<p className="font-medium text-foreground">Execution is suppressed effectively off.</p>
<p className="text-muted-foreground">
{worktreeRunExecutionState.reason === "instance_mismatch"
? "This setting was armed in a different instance and copied here, so no tasks run automatically."
: "This setting is missing its activation cutoff, so no tasks run automatically."}{" "}
Toggle it off and back on to arm execution for tasks created here.
</p>
</div>
</div>
) : null}
</div>
</Card>
) : null}