mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +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
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user