Merge origin/master into provider routing connections

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* origin/master:
  fix(ui): let a selected saved subscription be used without a tile click (#14996)
  fix: report pending execution phase at Stop timeout (#14990)
  fix: retain resolved ACP execution timeout metadata (#14986)
  fix(agents): reduce hiring templates to role descriptions (#14985)
This commit is contained in:
DottaandPaperclip committed 2026-10-02 21:56:32 -05:00
commit cba9868517
60 files changed
+2587 -1065

No files matched your search

@@ -1,4 +1,5 @@
import express from "express";
import { readFile } from "node:fs/promises";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -113,7 +114,9 @@ vi.mock("../services/instance-settings.js", async (importOriginal) => ({
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
? { ...mockAdapter, supportsInstructionsBundle: true }
: mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
@@ -156,7 +159,9 @@ function registerModuleMocks() {
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
? { ...mockAdapter, supportsInstructionsBundle: true }
: mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
@@ -1117,34 +1122,68 @@ describe.sequential("agent skill routes", () => {
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
))("materializes only the CEO entry file for $adapterType via $route", async ({ adapterType, route }) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableBetaSkills: false,
enableNativeRunner: true,
});
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
.post(`/api/companies/company-1/${route}`)
.send({
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
adapterType,
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
}));
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
const createdAgentId = expectResponseId(res.body.id);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: createdAgentId,
role: "ceo",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
}),
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
const entry = await readFile(new URL("../onboarding-assets/ceo/AGENTS.md", import.meta.url), "utf8");
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
{ "AGENTS.md": entry },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
))("preserves an explicit CEO instruction bundle for $adapterType via $route", async ({ adapterType, route }) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableBetaSkills: false,
enableNativeRunner: true,
});
const customFiles = {
"AGENTS.md": "You lead the bespoke research company. Read NOTES.md for its current focus.",
"NOTES.md": "Focus on the board's research questions.",
};
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post(`/api/companies/company-1/${route}`)
.send({
name: "Research Lead",
role: "ceo",
adapterType,
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
instructionsBundle: { entryFile: "AGENTS.md", files: customFiles },
}));
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
customFiles,
{ entryFile: "AGENTS.md", replaceExisting: false },
);
const returnedAgent = route === "agents" ? res.body : res.body.agent;
expect(returnedAgent.adapterConfig).toMatchObject({
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`,
});
expect(returnedAgent.adapterConfig).not.toHaveProperty("promptTemplate");
});
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
@@ -35,7 +35,11 @@ afterEach(async () => {
});
describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => {
it("keeps unconfirmed Stop context off unrelated events and drops arbitrary error fields", async () => {
it.each([
{ phase: "workspace_restore", phaseElapsedMs: 60_123, expectedPhase: "workspace_restore", expectedElapsedMs: 60_123 },
{ phase: "private-provider-phase", phaseElapsedMs: 100, expectedPhase: "unknown", expectedElapsedMs: null },
{ phase: "workspace_restore", phaseElapsedMs: Infinity, expectedPhase: "workspace_restore", expectedElapsedMs: null },
])("keeps bounded unconfirmed Stop context off unrelated events ($expectedPhase)", async ({ phase, phaseElapsedMs, expectedPhase, expectedElapsedMs }) => {
const Sentry = sentryPackage!;
const events: Array<Record<string, unknown>> = [];
vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1");
@@ -55,6 +59,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
const timeout = Object.assign(new AdapterStopTimeoutError(60_000, {
runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor",
runtimeMode: "legacy", abortRequested: true,
phase, phaseElapsedMs,
}), {
cause: new Error("private-provider-cause"),
providerResponse: { headers: "private-provider-headers", body: "private-provider-body" },
@@ -72,6 +77,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
contexts: { adapter_stop: {
runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor",
runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000,
phase: expectedPhase, phaseElapsedMs: expectedElapsedMs,
} },
});
expect(JSON.stringify(events)).not.toContain("private-provider-");
+6 -2
View File
@@ -136,6 +136,7 @@ describe("captureException", () => {
const error = new AdapterStopTimeoutError(60_000, {
runId: "11111111-1111-4111-8111-111111111111",
adapterType: "cursor", runtimeMode: "legacy", abortRequested: true,
phase: "instruction_collection", phaseElapsedMs: 60_321,
});
Object.assign(error, { providerResponse: "private fixture payload" });
captureException(error);
@@ -144,7 +145,8 @@ describe("captureException", () => {
expect(sdk.captureException.mock.calls[0]).toEqual([
expect.objectContaining({ message: error.message, stack: error.stack }),
{ tags: { error_code: "adapter_stop_unconfirmed" }, fingerprint: ["{{ default }}"], contexts: {
adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000 },
adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000,
phase: "instruction_collection", phaseElapsedMs: 60_321 },
} },
]);
expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private fixture payload");
@@ -159,10 +161,12 @@ describe("captureException", () => {
await sentryReady;
captureException(new AdapterStopTimeoutError(NaN, {
runId: "private fixture payload", adapterType: "private fixture payload", runtimeMode: "private fixture payload",
phase: "private fixture payload", phaseElapsedMs: Infinity,
}));
expect(JSON.stringify(sdk.captureException.mock.calls)).not.toContain("private fixture payload");
expect(sdk.captureException.mock.calls[0]).toEqual([expect.any(Error), expect.objectContaining({ contexts: {
adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null },
adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null,
phase: "unknown", phaseElapsedMs: null },
} })]);
});
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter";
import type { CatalogTeam } from "@paperclipai/shared";
const mockAgentService = vi.hoisted(() => ({
@@ -34,12 +36,13 @@ vi.mock("../services/activity-log.js", () => ({
const {
collectCatalogTeamSkillPreparations,
listCatalogTeams,
readCatalogTeamProvenance,
teamsCatalogService,
} = await import("../services/teams-catalog.js");
const CORE_EXEC_TEAM_ID = "paperclipai:bundled:company-defaults:core-exec-team";
const CORE_EXEC_TEAM_HASH = "sha256:0f20e9d56124c1dc90a1e4b128fabd863538bcc935117220f719d9620f7c89f1";
const CORE_EXEC_TEAM_HASH = (await listCatalogTeams()).find((team) => team.id === CORE_EXEC_TEAM_ID)!.contentHash;
function agentWithCatalogTeam(originHash: string | null, extra: Record<string, unknown> = {}) {
return {
@@ -113,6 +116,39 @@ describe("teamsCatalogService", () => {
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
});
it.each(["core-exec-team", "product-engineering", "product-design", "content-machine"])(
"preserves %s role files and canonical skill references through catalog import",
async (teamRef) => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", teamRef);
expect(prepared.errors).toEqual([]);
for (const slug of prepared.team.agentSlugs) {
const filePath = `agents/${slug}/AGENTS.md`;
const original = parseFrontmatterMarkdown(await readFile(
new URL(`../../../packages/teams-catalog/${prepared.team.path}/${filePath}`, import.meta.url),
"utf8",
));
const imported = parseFrontmatterMarkdown(prepared.source.files[filePath] as string);
const requiredSkills = (original.frontmatter.skills as string[]).map((ref) => {
const requirement = prepared.team.requiredSkills.find((skill) => skill.ref === ref && skill.agentSlugs.includes(slug));
expect(requirement, `${filePath}: ${ref}`).toMatchObject({ resolved: true });
return requirement?.type === "catalog" ? requirement.catalogSkillKey : ref;
});
expect(imported.frontmatter, filePath).toEqual({ ...original.frontmatter, skills: requiredSkills });
expect(imported.body, filePath).toBe(original.body);
}
await svc.installCatalogTeam("company-1", teamRef);
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.source.files).toEqual(prepared.source.files);
const requiredCatalogIds = prepared.skillPreparations
.filter((skill) => skill.action === "catalog_install_required")
.map((skill) => skill.catalogSkillId).sort();
expect(mockCompanySkillService.installFromCatalog.mock.calls.map((call) => call[1].catalogSkillId).sort())
.toEqual(requiredCatalogIds);
},
);
it("resolves target-manager slug against same-company agents before rendering reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([
{ id: "manager-1", companyId: "company-1", name: "CEO" },
+1 -64
View File
@@ -1,64 +1 @@
You are the CEO. Your job is to lead the company, not to do individual contributor work. You own strategy, prioritization, and cross-functional coordination.
Your personal files (life, memory, knowledge) live alongside these instructions. Other agents may have their own folders and you may update them when necessary.
Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory.
## Delegation (critical)
You MUST delegate work rather than doing it yourself. When a task is assigned to you:
1. **Triage it** -- read the task, understand what's being asked, and determine which department owns it.
2. **Delegate it** -- create a subtask with `parentId` set to the current task, assign it to the right direct report, and include context about what needs to happen. Use these routing rules:
- **Code, bugs, features, infra, devtools, technical tasks** → CTO
- **Marketing, content, social media, growth, devrel** → CMO
- **UX, design, user research, design-system** → UXDesigner
- **Cross-functional or unclear** → break into separate subtasks for each department, or assign to the CTO if it's primarily technical with a design component
- If the right report doesn't exist yet, use the `paperclip-create-agent` skill to hire one before delegating.
3. **Do NOT write code, implement features, or fix bugs yourself.** Your reports exist for this. Even if a task seems small or quick, delegate it.
4. **Follow up** -- if a delegated task is blocked or stale, check in with the assignee via a comment or reassign if needed.
## What you DO personally
- Set priorities and make product decisions
- Resolve cross-team conflicts or ambiguity
- Communicate with the board (human users)
- Approve or reject proposals from your reports
- Hire new agents when the team needs capacity
- Unblock your direct reports when they escalate to you
## Keeping work moving
- Don't let tasks sit idle. If you delegate something, check that it's progressing.
- If a report is blocked, help unblock them -- escalate to the board if needed.
- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work.
- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop.
- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue.
- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. Before presenting a plan for review, you MUST complete this publish contract:
1. `PUT /issues/{id}/documents/plan` with `{ format: 'markdown', body, changeSummary }`.
2. Re-`GET /documents/plan`, assert it returns `200`, and capture its `latestRevisionId`.
3. Only then create `request_confirmation` with `target={ type: 'issue_document', key: 'plan', revisionId: latestRevisionId }` and `idempotencyKey=confirmation:{issueId}:plan:{revisionId}`.
4. Put the source issue in `in_review` and wait for acceptance before delegating implementation subtasks.
Never present a plan only in a thread comment or through `ask_user_questions`; comments are supporting context and questions are for gathering input, not plan review.
- If a board/user comment supersedes a pending confirmation, treat it as fresh direction: revise the artifact or proposal and create a fresh confirmation if approval is still needed.
- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action.
- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why).
## Memory and Planning
You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions.
Invoke it whenever you need to remember, retrieve, or organize anything.
## Safety Considerations
- Never exfiltrate secrets or private data.
- Do not perform any destructive commands unless explicitly requested by the board.
## References
These files are essential. Read them.
- `./HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat.
- `./SOUL.md` -- who you are and how you should act.
- `./TOOLS.md` -- tools you have access to
You are the CEO of a Paperclip company. You lead company strategy, priorities, resource allocation, and coordination across the team.
@@ -1,15 +1 @@
# Role
You are {{agentName}}, chief of staff for {{organizationName}}. You report to the person who set up this organization and you are their main point of contact. Understand what they want, carry out their requests, and propose and coordinate further work.
# Working with the user
- Be conversational. Act on clear requests; propose choices that need the user's decision.
- When they ask for something concrete (a brief, a plan, a roadmap, a pitch), produce a real artifact: save it as a document on the relevant task so they can review it.
# Chat hygiene
- Everything you post is read by the user. Keep it terse and written for them. Speak simply and be easy to understand. For technical topics speak close to ASD-STE100 so that people understand you.
- Lead with the answer. Never narrate tool calls, API steps, or your own thinking.
- Ask about material ambiguity that prevents useful work.
- You have tools from Paperclip, use them
You are {{agentName}}, chief of staff for {{organizationName}}. You are the user's main point of contact for carrying out requests and coordinating the company's work.
@@ -10,6 +10,16 @@ import {
afterEach(() => vi.useRealTimers());
async function stopTimeout(pending: Promise<void>): Promise<AdapterStopTimeoutError> {
try {
await pending;
} catch (error) {
expect(error).toBeInstanceOf(AdapterStopTimeoutError);
return error as AdapterStopTimeoutError;
}
throw new Error("Stop unexpectedly settled");
}
it("holds readiness for every exact-run no-owner Stop and releases owners idempotently", async () => {
const runId = "registration-after-two-stops";
const first = captureAdapterStopOwnership(runId);
@@ -139,6 +149,7 @@ it("records the exact unconfirmed run without finishing or removing its control"
expect(error).toBeInstanceOf(AdapterStopTimeoutError);
expect((error as AdapterStopTimeoutError).diagnostics).toEqual({
runId, adapterType: "claude_local", runtimeMode: "legacy", abortRequested: true, timeoutMs: 1000,
phase: "unknown", phaseElapsedMs: null,
});
expect(adapterExecutionControls.get(runId)).toBe(control);
expect(finished).toBe(false);
@@ -151,3 +162,45 @@ it("records the exact unconfirmed run without finishing or removing its control"
adapterExecutionControls.delete(runId);
}
});
it("samples the pending phase at each Stop timeout without settling the executor", async () => {
vi.useFakeTimers();
const runId = "11111111-1111-4111-8111-111111111111";
const control = createAdapterExecutionControl();
await registerAdapterExecutionControl(runId, control);
const read = vi.spyOn(control.phases, "snapshot");
const pending = stopTimeout(waitForAdapterStop(control.settled, 1000, { runId }, control));
expect(read).not.toHaveBeenCalled();
control.phases.enter("instruction_collection");
await vi.advanceTimersByTimeAsync(1000);
const first = await pending;
expect(first.diagnostics).toMatchObject({ phase: "instruction_collection", phaseElapsedMs: expect.any(Number) });
const secondWait = stopTimeout(waitForAdapterStop(control.settled, 1000, { runId }, control));
control.phases.enter("lease_release");
await vi.advanceTimersByTimeAsync(1000);
expect((await secondWait).diagnostics.phase).toBe("lease_release");
expect(first.diagnostics.phase).toBe("instruction_collection");
expect(Object.isFrozen(first.diagnostics)).toBe(true);
expect(adapterExecutionControls.get(runId)).toBe(control);
control.finish();
adapterExecutionControls.delete(runId);
});
it.each(["replaced", "wrong_run", "wrong_promise", "throwing_reader"])("omits pending phase for a %s control", async (scenario) => {
vi.useFakeTimers();
const runId = "11111111-1111-4111-8111-111111111111";
const owner = createAdapterExecutionControl();
const other = createAdapterExecutionControl();
await registerAdapterExecutionControl(runId, owner);
owner.phases.enter("instruction_collection");
const pending = stopTimeout(waitForAdapterStop(scenario === "wrong_promise" ? other.settled : owner.settled, 1000,
{ runId: scenario === "wrong_run" ? "22222222-2222-4222-8222-222222222222" : runId }, owner));
if (scenario === "replaced") await registerAdapterExecutionControl(runId, other);
if (scenario === "throwing_reader") vi.spyOn(owner.phases, "snapshot").mockImplementation(() => { throw new Error("private fixture payload"); });
await vi.advanceTimersByTimeAsync(1000);
expect((await pending).diagnostics).toMatchObject({ phase: "unknown", phaseElapsedMs: null });
expect(JSON.stringify((await pending).diagnostics)).not.toContain("private fixture payload");
owner.finish();
other.finish();
adapterExecutionControls.delete(runId);
});
@@ -1,13 +1,15 @@
import { AdapterStopTimeoutError, type AdapterStopContext } from "./adapter-stop-timeout.js";
import { createAdapterExecutionPhaseTracker } from "./adapter-execution-phase.js";
/** Live adapter ownership shared by routes and scheduler service instances. */
export function createAdapterExecutionControl() {
const controller = new AbortController();
const phases = createAdapterExecutionPhaseTracker();
let finish!: () => void;
const settled = new Promise<void>((resolve) => {
finish = resolve;
});
return { controller, settled, finish };
return { controller, settled, phases, finish: () => { phases.finish(); finish(); } };
}
export const adapterExecutionControls = new Map<
@@ -70,7 +72,17 @@ export async function waitForAdapterStop(
settled: Promise<void>,
timeoutMs = 60_000,
diagnostics?: AdapterStopContext,
owner?: ReturnType<typeof createAdapterExecutionControl>,
) {
const currentPhase = () => {
try {
return owner && owner.settled === settled && diagnostics?.runId
&& adapterExecutionControls.get(diagnostics.runId) === owner
? owner.phases.snapshot() : null;
} catch {
return null;
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
@@ -79,7 +91,12 @@ export async function waitForAdapterStop(
timer = setTimeout(
() =>
reject(
new AdapterStopTimeoutError(timeoutMs, diagnostics),
new AdapterStopTimeoutError(timeoutMs, {
...diagnostics,
phase: undefined,
phaseElapsedMs: undefined,
...currentPhase(),
}),
),
timeoutMs,
);
@@ -0,0 +1,60 @@
import { expect, it } from "vitest";
import { createAdapterExecutionPhaseTracker, MAX_EXECUTION_PHASE_ELAPSED_MS } from "./adapter-execution-phase.js";
it("returns the latest pending scope and resumes the outer scope's original age", () => {
let now = 10;
const phases = createAdapterExecutionPhaseTracker(() => now);
const outer = phases.enter("sync_back")!;
now = 30;
const inner = phases.enter("instruction_collection")!;
now = 80;
expect(phases.snapshot()).toEqual({ phase: "instruction_collection", phaseElapsedMs: 50 });
inner();
expect(phases.snapshot()).toEqual({ phase: "sync_back", phaseElapsedMs: 70 });
outer();
expect(phases.snapshot()).toBeNull();
});
it("late and repeated releases never clear a newer scope or revive a completed scope", () => {
const phases = createAdapterExecutionPhaseTracker(() => 20);
const first = phases.enter("end_session")!;
const second = phases.enter("close_session")!;
first();
first();
expect(phases.snapshot()?.phase).toBe("close_session");
second();
expect(phases.snapshot()).toBeNull();
});
it("isolates trackers and refuses late updates after the executor finishes", () => {
const first = createAdapterExecutionPhaseTracker();
const second = createAdapterExecutionPhaseTracker();
first.enter("workspace_restore");
second.enter("instruction_cleanup");
first.finish();
first.enter("lease_release");
expect(first.snapshot()).toBeNull();
expect(second.snapshot()?.phase).toBe("instruction_cleanup");
});
it("bounds retained scopes and fails closed on overflow or arbitrary labels", () => {
const phases = createAdapterExecutionPhaseTracker();
phases.enter("private fixture payload" as "sync_back");
expect(phases.snapshot()).toBeNull();
for (let i = 0; i < 100; i++) phases.enter("sync_back");
expect(phases.snapshot()).toBeNull();
phases.finish();
expect(phases.snapshot()).toBeNull();
});
it("uses a finite nonnegative capped elapsed time", () => {
let now = 10;
const phases = createAdapterExecutionPhaseTracker(() => now);
phases.enter("close_session");
now = Infinity;
expect(phases.snapshot()).toBeNull();
now = 9;
expect(phases.snapshot()).toBeNull();
now = MAX_EXECUTION_PHASE_ELAPSED_MS * 2;
expect(phases.snapshot()).toEqual({ phase: "close_session", phaseElapsedMs: MAX_EXECUTION_PHASE_ELAPSED_MS });
});
@@ -0,0 +1,44 @@
import { performance } from "node:perf_hooks";
import {
isAdapterExecutionPhase,
type AdapterExecutionPhase,
} from "@paperclipai/adapter-utils/execution-phase";
export const MAX_EXECUTION_PHASE_ELAPSED_MS = 86_400_000;
const MAX_ACTIVE_PHASES = 16;
/** Owned by one executor, never looked up by agent, task, or provider identity. */
export function createAdapterExecutionPhaseTracker(now = () => performance.now()) {
const scopes = new Map<symbol, { phase: AdapterExecutionPhase; startedAt: number }>();
let finished = false;
let overflow = false;
return {
enter(phase: AdapterExecutionPhase) {
if (finished || !isAdapterExecutionPhase(phase)) return;
if (scopes.size >= MAX_ACTIVE_PHASES) {
// Refuse further attribution rather than retain an unbounded history or
// describe an older scope as the current one. finish clears the tracker.
overflow = true;
return;
}
const token = Symbol();
let startedAt: number;
try { startedAt = now(); } catch { return; }
scopes.set(token, { phase, startedAt });
return () => { scopes.delete(token); };
},
snapshot() {
if (finished || overflow) return null;
const current = [...scopes.values()].at(-1);
if (!current) return null;
let elapsed: number;
try { elapsed = now() - current.startedAt; } catch { return null; }
if (!Number.isFinite(elapsed) || elapsed < 0) return null;
return { phase: current.phase, phaseElapsedMs: Math.min(Math.floor(elapsed), MAX_EXECUTION_PHASE_ELAPSED_MS) };
},
finish() {
finished = true;
scopes.clear();
},
};
}
@@ -1,10 +1,14 @@
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import { isAdapterExecutionPhase } from "@paperclipai/adapter-utils/execution-phase";
import { MAX_EXECUTION_PHASE_ELAPSED_MS } from "./adapter-execution-phase.js";
export interface AdapterStopContext {
runId?: string;
adapterType?: string;
runtimeMode?: string;
abortRequested?: boolean;
phase?: string;
phaseElapsedMs?: number;
}
/** Diagnostic identity only. This error never acknowledges termination. */
@@ -22,6 +26,10 @@ export class AdapterStopTimeoutError extends Error {
adapterType: AGENT_ADAPTER_TYPES.some((type) => type === context?.adapterType) ? context!.adapterType! : "unknown",
runtimeMode: context?.runtimeMode === "native" || context?.runtimeMode === "legacy" ? context.runtimeMode : "unknown",
abortRequested: typeof context?.abortRequested === "boolean" ? context.abortRequested : null,
phase: isAdapterExecutionPhase(context?.phase) ? context.phase : "unknown",
phaseElapsedMs: isAdapterExecutionPhase(context?.phase) && Number.isSafeInteger(context?.phaseElapsedMs)
&& context!.phaseElapsedMs! >= 0 && context!.phaseElapsedMs! <= MAX_EXECUTION_PHASE_ELAPSED_MS
? context!.phaseElapsedMs! : null,
});
}
}
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
const DEFAULT_AGENT_BUNDLE_FILES = {
default: ["AGENTS.md"],
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
ceo: ["AGENTS.md"],
} as const;
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveAdapterExecutionTargetTimeout } from "@paperclipai/adapter-utils/execution-target";
import {
buildHeartbeatRunStopMetadata,
mergeHeartbeatRunStopMetadata,
@@ -42,6 +43,50 @@ describe("heartbeat stop metadata", () => {
});
});
it.each([
{ target: "sandbox", configured: undefined, seconds: 14400, source: "sandbox_default", explicit: false },
{ target: "sandbox", configured: 0, seconds: 14400, source: "sandbox_default", explicit: false },
{ target: "sandbox", configured: 90, seconds: 90, source: "configured", explicit: true },
{ target: "sandbox", configured: 0.5, seconds: 0.5, source: "configured", explicit: true },
{ target: "sandbox", configured: -1, seconds: 0, source: "configured", explicit: true },
{ target: "local", configured: 0, seconds: 0, source: "unlimited", explicit: false },
])("retains the resolved $target timeout for config $configured", ({ target, configured, seconds, source, explicit }) => {
const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout(
target === "sandbox" ? { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } : { kind: "local" },
configured,
);
const result = mergeHeartbeatRunStopMetadata(
{ adapterExecutionTimeout, summary: "retained", executionCancellation: { state: "unconfirmed" } },
buildHeartbeatRunStopMetadata({
adapterType: "claude_local", adapterConfig: { timeoutSec: configured }, outcome: "timed_out",
}),
);
expect(result).toMatchObject({
effectiveTimeoutSec: seconds, timeoutSource: source, timeoutConfigured: explicit,
stopReason: "timeout", timeoutFired: true, summary: "retained",
executionCancellation: { state: "unconfirmed" },
});
});
it.each([
null, [], { timeoutSec: 90 }, { timeoutSec: "90", source: "configured" },
{ timeoutSec: -1, source: "configured" }, { timeoutSec: Infinity, source: "configured" },
{ timeoutSec: 90, source: "unknown" }, { timeoutSec: 90, source: "unlimited" },
{ timeoutSec: 0, source: "sandbox_default" },
])("falls back to config for an invalid adapter resolution: %j", (adapterExecutionTimeout) => {
expect(mergeHeartbeatRunStopMetadata(
{ adapterExecutionTimeout },
buildHeartbeatRunStopMetadata({ adapterType: "claude_local", adapterConfig: { timeoutSec: 45 }, outcome: "failed" }),
)).toMatchObject({ effectiveTimeoutSec: 45, timeoutConfigured: true, timeoutSource: "config", timeoutFired: false });
});
it("keeps the HTTP millisecond policy internally consistent", () => {
expect(mergeHeartbeatRunStopMetadata(
{ adapterExecutionTimeout: { timeoutSec: 90, source: "configured" } },
buildHeartbeatRunStopMetadata({ adapterType: "http", adapterConfig: { timeoutMs: 2500 }, outcome: "failed" }),
)).toMatchObject({ effectiveTimeoutSec: 2.5, effectiveTimeoutMs: 2500, timeoutSource: "config", timeoutFired: false });
});
it("distinguishes budget cancellation from manual cancellation", () => {
expect(
buildHeartbeatRunStopMetadata({
+20 -4
View File
@@ -16,7 +16,7 @@ export interface HeartbeatRunTimeoutPolicy {
effectiveTimeoutSec: number | null;
effectiveTimeoutMs?: number | null;
timeoutConfigured: boolean;
timeoutSource: "config" | "default" | "unknown";
timeoutSource: "config" | "default" | "unknown" | "configured" | "sandbox_default" | "unlimited";
}
export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy {
@@ -121,12 +121,28 @@ export function mergeHeartbeatRunStopMetadata(
metadata: HeartbeatRunStopMetadata,
): Record<string, unknown> {
const existingMaxTurnStopReason = normalizeMaxTurnStopReason(resultJson?.stopReason);
// Only a complete, valid adapter resolution overrides the config fallback.
// Older adapters and persisted rows retain their existing interpretation.
const resolution = resultJson?.adapterExecutionTimeout;
let timeoutPolicy: HeartbeatRunTimeoutPolicy = metadata;
if (metadata.effectiveTimeoutMs == null && resolution && typeof resolution === "object" && !Array.isArray(resolution)) {
const { timeoutSec, source } = resolution as Record<string, unknown>;
if (typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec >= 0 &&
(source === "configured" || (source === "sandbox_default" && timeoutSec > 0) ||
(source === "unlimited" && timeoutSec === 0))) {
timeoutPolicy = {
effectiveTimeoutSec: timeoutSec,
timeoutConfigured: source === "configured",
timeoutSource: source,
};
}
}
return {
...(resultJson ?? {}),
stopReason: existingMaxTurnStopReason ?? metadata.stopReason,
effectiveTimeoutSec: metadata.effectiveTimeoutSec,
timeoutConfigured: metadata.timeoutConfigured,
timeoutSource: metadata.timeoutSource,
effectiveTimeoutSec: timeoutPolicy.effectiveTimeoutSec,
timeoutConfigured: timeoutPolicy.timeoutConfigured,
timeoutSource: timeoutPolicy.timeoutSource,
timeoutFired: metadata.timeoutFired,
...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}),
};
+13 -7
View File
@@ -24,6 +24,7 @@ import { githubBotConnectionIdsForRun } from "./chat-github-tools.js";
import { isBrowserUseConnection } from "./browser-use-client.js";
import { readQueuedInteractionResponse } from "./queued-interaction-response.js";
import { AGENT_CHAT_DIRECTIVE, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js";
import { withAdapterExecutionPhase } from "@paperclipai/adapter-utils/execution-phase";
import { getConversationConfirmationContext, type ConversationConfirmationContext } from "./conversation-confirmation-context.js";
import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent, isAcknowledgedNativeStop, acknowledgedNativeStopExecutionHasStopped } from "./acknowledged-native-stop.js";
@@ -20422,6 +20423,10 @@ export function heartbeatService(
run.controllerBootId !== legacyControllerBootId) return;
activeRunExecutions.add(run.id);
const executionControl = createAdapterExecutionControl();
// This coarse scope also covers host finalization after the adapter returns.
// Nested scopes refine the label without making any termination claim.
executionControl.phases.enter("host_execution");
const executionPhaseContext = { onExecutionPhase: executionControl.phases.enter };
const controllerLease = watchLegacyControllerLease(db, run, executionControl.controller);
let runScratch: HeartbeatRunScratch | null = null;
let githubLauncherLocation:
@@ -24909,7 +24914,7 @@ export function heartbeatService(
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) => {
legacyAdapterEntered = true;
return adapter.execute({
return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({
getFreshSessionHandoff,
runId: run.id,
agent,
@@ -24933,6 +24938,7 @@ export function heartbeatService(
onLog,
onMeta: onAdapterMeta,
onEvent: onAdapterEvent,
onExecutionPhase: executionControl.phases.enter,
startupTraceContext: getStartupTraceContext(),
onRuntimeProgress: async (progress) => {
await recordCurrentHeartbeatRunRuntimeProgress(
@@ -24984,7 +24990,7 @@ export function heartbeatService(
});
},
authToken: authToken ?? undefined,
});
}));
},
);
if (!guardedDispatch.dispatched) return;
@@ -25227,7 +25233,7 @@ export function heartbeatService(
);
}
await nativeInstructionReservation?.release();
await releaseInstructionCopy();
await withAdapterExecutionPhase(executionPhaseContext, "instruction_cleanup", releaseInstructionCopy);
}
// Reconcile the referenced-project set against the real remote staging outcome. A referenced
// project can pass authorization and clone locally at run prep, then fail to stage into the
@@ -26633,8 +26639,8 @@ export function heartbeatService(
message: "Instruction edits could not be recovered before environment release. No instruction save is claimed.",
payload: { state: "unavailable", code: uncapturedInstructions.errorCode } });
}
await releaseInstructionCopy();
await releaseEnvironmentLeasesForRun({
await withAdapterExecutionPhase(executionPhaseContext, "instruction_cleanup", releaseInstructionCopy);
await withAdapterExecutionPhase(executionPhaseContext, "lease_release", () => releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,
agentId: run.agentId,
@@ -26642,7 +26648,7 @@ export function heartbeatService(
failureReason: latestRun?.error ?? undefined,
providerResourceDisposition: providerResourceDispositionForRun,
nativeLifecycleTelemetry: nativeLifecycleTelemetryForRun,
});
}));
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
}
if (
@@ -29614,7 +29620,7 @@ export function heartbeatService(
adapterType: agent?.adapterType,
runtimeMode: run.runtimeMode,
abortRequested: control.controller.signal.aborted,
});
}, control);
const stopped = await getRun(run.id);
if (stopped && isHeartbeatRunTerminalStatus(stopped.status)) {
if (
@@ -102,7 +102,6 @@ describe("chief-of-staff persona", () => {
organizationName: "Acme",
});
expect(persona).toContain("You are Ada, chief of staff for Acme.");
expect(persona).toContain("# Working with the user");
expect(persona).not.toContain("{{agentName}}");
expect(persona).not.toContain("{{organizationName}}");
});
@@ -113,6 +112,8 @@ describe("chief-of-staff persona", () => {
organizationName: "Acme",
});
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.files["AGENTS.md"]).toContain("You are Ada, chief of staff for Acme.");
expect(bundle.files).toEqual({
"AGENTS.md": await renderChiefOfStaffPersona({ agentName: "Ada", organizationName: "Acme" }),
});
});
});