[codex] Improve agent runtime recovery and governance (#4086)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The heartbeat runtime, agent import path, and agent configuration
defaults determine whether work is dispatched safely and predictably.
> - Several accumulated fixes all touched agent execution recovery, wake
routing, import behavior, and runtime concurrency defaults.
> - Those changes need to land together so the heartbeat service and
agent creation defaults stay internally consistent.
> - This pull request groups the runtime/governance changes from the
split branch into one standalone branch.
> - The benefit is safer recovery for stranded runs, bounded high-volume
reads, imported-agent approval correctness, skill-template support, and
a clearer default concurrency policy.

## What Changed

- Fixed stranded continuation recovery so successful automatic retries
are requeued instead of incorrectly blocking the issue.
- Bounded high-volume issue/log reads across issue, heartbeat, agent,
project, and workspace paths.
- Fixed imported-agent approval and instruction-path permission
handling.
- Quarantined seeded worktree execution state during worktree
provisioning.
- Queued approval follow-up wakes and hardened SQL_ASCII heartbeat
output handling.
- Added reusable agent instruction templates for hiring flows.
- Set the default max concurrent agent runs to five and updated related
UI/tests/docs.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
server/src/__tests__/heartbeat-list.test.ts
server/src/__tests__/issues-service.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
packages/adapter-utils/src/server-utils.test.ts
ui/src/lib/new-agent-runtime-config.test.ts`
- Split integration check: merged this branch first, followed by the
other [PAP-1614](/PAP/issues/PAP-1614) branches, with no merge
conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.

## Risks

- Medium risk: touches heartbeat recovery, queueing, and issue list
bounds in central runtime paths.
- Imported-agent and concurrency default behavior changes may affect
existing automation that assumes one-at-a-time default runs.
- No database migrations are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-20 06:19:48 -05:00 committed by GitHub
parent 057fee4836
commit 16b2b84d84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1569 additions and 240 deletions

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
appendWithByteCap,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
renderPaperclipWakePrompt,
runChildProcess,
@ -111,6 +112,16 @@ describe("runChildProcess", () => {
});
});
describe("appendWithByteCap", () => {
it("keeps valid UTF-8 when trimming through multibyte text", () => {
const output = appendWithByteCap("prefix ", "hello — world", 7);
expect(output).not.toContain("\uFFFD");
expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output);
expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7);
});
});
describe("renderPaperclipWakePrompt", () => {
it("keeps the default local-agent prompt action-oriented", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");

View file

@ -191,6 +191,22 @@ export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYT
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
}
export function appendWithByteCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
const combined = prev + chunk;
const bytes = Buffer.byteLength(combined, "utf8");
if (bytes <= cap) return combined;
const buffer = Buffer.from(combined, "utf8");
let start = Math.max(0, bytes - cap);
while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1;
return buffer.subarray(start).toString("utf8");
}
function resumeReadable(readable: { resume: () => unknown; destroyed?: boolean } | null | undefined) {
if (!readable || readable.destroyed) return;
readable.resume();
}
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
const parts = dottedPath.split(".");
let cursor: unknown = obj;
@ -1283,19 +1299,27 @@ export async function runChildProcess(
: null;
child.stdout?.on("data", (chunk: unknown) => {
const readable = child.stdout;
if (!readable) return;
readable.pause();
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"))
.finally(() => resumeReadable(readable));
});
child.stderr?.on("data", (chunk: unknown) => {
const readable = child.stderr;
if (!readable) return;
readable.pause();
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"))
.finally(() => resumeReadable(readable));
});
const stdin = child.stdin;

View file

@ -66,6 +66,8 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
general: "General",
};
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5;
export const AGENT_ICON_NAMES = [
"bot",
"cpu",

View file

@ -9,6 +9,7 @@ export {
AGENT_ADAPTER_TYPES,
AGENT_ROLES,
AGENT_ROLE_LABELS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,