Skip to content

Commit 109592d

Browse files
stubbiclaude
andauthored
fix: realign rebase-casualty files with upstream master after the 8-PR merge wave (#142)
The 2026-06-11 rebase (#141) onto a master that had just merged our 8 give-back PRs left several bad conflict resolutions: - packages/adapters/codex-local/src/server/runtime-config.ts was TRUNCATED mid-function (TS1005) -> Docker build on main failed, no image rolled. Restored to upstream (paperclipai#7919 is merged; zero delta). - server/src/routes/issues.ts + heartbeat.ts and their test files kept pre-revert paperclipai#7678 code and missed paperclipai#7855 scoped-wake semantics. Restored to upstream (no documented fork delta in these files). - ui/src/pages/IssueDetail.tsx + ui/src/lib/issue-chat-messages.test.ts: same paperclipai#7678 remnants, restored. - tools/agent-shim/stdout_framer.go: dead code upstream removed during the paperclipai#7934 review; deleted here too (main_test.go realigned). Preserved fork deltas untouched: cloud_tenant (middleware/auth.ts, live-events-ws.ts), seed CLI, claude-local execute cwd fix (paperclipai#5823 still open), workspace-restore-merge socket guard, Dockerfile plugin build, paperclipinc registry names, workspace-init, fork CI workflows. Verified: server build exit 0 (the exact CI failure), 58 server tests, 87 codex-local tests, 24 ui tests, go build+test agent-shim all green. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bdee457 commit 109592d

14 files changed

Lines changed: 416 additions & 239 deletions

doc/execution-semantics.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,6 @@ Plain text is not assignment. Writing an agent's name, role, or team label in a
285285

286286
Pause and tree-control previews should make the same distinction visible. They should report whether the affected subtree contains live running work, queued wakes, agent-owned work, or only human-owned/static issues, so a pause after a handoff does not look like it interrupted agent execution when no agent execution path existed.
287287

288-
289288
### Adapter-backed workspace coherence
290289

291290
For adapter-backed execution, an active run or queued wake counts as a live path only when Paperclip can also prove that the selected workspace is coherent for that adapter invocation. A wake that cannot start in the intended workspace is only a failed delivery attempt, not a healthy liveness path.

packages/adapters/codex-local/src/server/runtime-config.ts

Lines changed: 282 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,285 @@ function parseCodexProvidersConfig(
146146
function escapeTomlString(value: string): string {
147147
// TOML 1.0 basic strings require escaping U+0000-U+001F and U+007F (DEL).
148148
return value.replace(/[\\"\u0000-\u001f\u007f]/g, (char) => {
149-
switch (char) {
149+
switch (char) {
150+
case "\\":
151+
return "\\\\";
152+
case '"':
153+
return '\\"';
154+
case "\n":
155+
return "\\n";
156+
case "\r":
157+
return "\\r";
158+
case "\t":
159+
return "\\t";
160+
default:
161+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
162+
}
163+
});
164+
}
165+
166+
const BARE_TOML_KEY_RE = /^[A-Za-z0-9_-]+$/;
167+
168+
function tomlKey(key: string): string {
169+
return BARE_TOML_KEY_RE.test(key) ? key : `"${escapeTomlString(key)}"`;
170+
}
171+
172+
// Hand-emitted TOML for a constrained value space (strings, numbers, booleans,
173+
// arrays of scalars, plain objects as inline tables). Returns null for values
174+
// that cannot be represented, which are then skipped.
175+
function tomlValue(value: unknown): string | null {
176+
if (typeof value === "string") return `"${escapeTomlString(value)}"`;
177+
if (typeof value === "boolean") return value ? "true" : "false";
178+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
179+
if (Array.isArray(value)) {
180+
const entries = value.map((entry) => tomlValue(entry));
181+
if (entries.some((entry) => entry === null)) return null;
182+
return `[${entries.join(", ")}]`;
183+
}
184+
if (isPlainObject(value)) {
185+
const pairs: string[] = [];
186+
for (const [key, entry] of Object.entries(value)) {
187+
const emitted = tomlValue(entry);
188+
if (emitted === null) continue;
189+
pairs.push(`${tomlKey(key)} = ${emitted}`);
190+
}
191+
return `{ ${pairs.join(", ")} }`;
192+
}
193+
return null;
194+
}
195+
196+
function emitProviderTable(name: string, fields: Record<string, unknown>): string[] {
197+
const lines = [`[model_providers.${tomlKey(name)}]`];
198+
for (const [key, value] of Object.entries(fields)) {
199+
const emitted = tomlValue(value);
200+
if (emitted === null) continue;
201+
lines.push(`${tomlKey(key)} = ${emitted}`);
202+
}
203+
return lines;
204+
}
205+
206+
function stripManagedBlock(lines: string[], begin: string, end: string): string[] {
207+
const out: string[] = [];
208+
let inBlock = false;
209+
for (const line of lines) {
210+
const trimmed = line.trim();
211+
if (!inBlock && trimmed === begin) {
212+
inBlock = true;
213+
continue;
214+
}
215+
if (inBlock) {
216+
if (trimmed === end) inBlock = false;
217+
continue;
218+
}
219+
out.push(line);
220+
}
221+
return out;
222+
}
223+
224+
export function stripManagedCodexProviderBlocks(content: string): string {
225+
let lines = content.split("\n");
226+
lines = stripManagedBlock(lines, MANAGED_ROOT_BEGIN, MANAGED_ROOT_END);
227+
lines = stripManagedBlock(lines, MANAGED_TABLES_BEGIN, MANAGED_TABLES_END);
228+
return lines.join("\n");
229+
}
230+
231+
const TABLE_HEADER_RE = /^\s*\[\s*([^\]]*?)\s*\]\s*(?:#.*)?$/;
232+
233+
// Best-effort parse of a TOML table header into its dotted path segments,
234+
// stripping surrounding quotes per segment. Dotted quoted segment names are
235+
// out of scope for this merge (codex provider ids are simple identifiers).
236+
function parseTableHeaderPath(line: string): string[] | null {
237+
const match = TABLE_HEADER_RE.exec(line);
238+
if (!match) return null;
239+
return match[1]
240+
.split(".")
241+
.map((segment) => segment.trim())
242+
.map((segment) => segment.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"));
243+
}
244+
245+
// Remove pre-existing definitions that would conflict with (or override) the
246+
// managed content: [model_providers.<name>] tables (and their subtables) for
247+
// names we are about to define, and the root-level `model_provider` key when
248+
// we set one. Duplicate TOML tables/keys are parse errors in codex, so the
249+
// managed definitions must win by excising the originals.
250+
function stripConflictingDefinitions(
251+
content: string,
252+
providerNames: string[],
253+
removeRootModelProvider: boolean,
254+
): string {
255+
const names = new Set(providerNames);
256+
const lines = content.split("\n");
257+
const out: string[] = [];
258+
let inRootRegion = true;
259+
let skippingSection = false;
260+
for (const line of lines) {
261+
const headerPath = parseTableHeaderPath(line);
262+
if (headerPath) {
263+
inRootRegion = false;
264+
skippingSection =
265+
headerPath.length >= 2 &&
266+
headerPath[0] === "model_providers" &&
267+
names.has(headerPath[1]);
268+
if (skippingSection) continue;
269+
} else if (skippingSection) {
270+
continue;
271+
}
272+
if (inRootRegion && removeRootModelProvider && /^\s*model_provider\s*=/.test(line)) {
273+
continue;
274+
}
275+
out.push(line);
276+
}
277+
return out.join("\n");
278+
}
279+
280+
function buildMergedConfigToml(base: string, parsed: ParsedCodexProvidersConfig): string {
281+
const sections: string[] = [];
282+
if (parsed.modelProvider) {
283+
sections.push(
284+
[
285+
MANAGED_ROOT_BEGIN,
286+
`model_provider = "${escapeTomlString(parsed.modelProvider)}"`,
287+
MANAGED_ROOT_END,
288+
].join("\n"),
289+
);
290+
}
291+
const trimmedBase = base.replace(/^\n+/, "").replace(/\n+$/, "");
292+
if (trimmedBase.length > 0) sections.push(trimmedBase);
293+
const tableLines: string[] = [MANAGED_TABLES_BEGIN];
294+
for (const [name, fields] of Object.entries(parsed.providers)) {
295+
tableLines.push(...emitProviderTable(name, fields), "");
296+
}
297+
while (tableLines[tableLines.length - 1] === "") tableLines.pop();
298+
tableLines.push(MANAGED_TABLES_END);
299+
sections.push(tableLines.join("\n"));
300+
return `${sections.join("\n\n")}\n`;
301+
}
302+
303+
async function readFileOrNull(filePath: string): Promise<string | null> {
304+
return fs.readFile(filePath, "utf8").catch(() => null);
305+
}
306+
307+
// Pre-run backup of the original config.toml, written before the merged file.
308+
// If a run dies without reaching cleanup() (a setup throw between prepare and
309+
// execution, SIGKILL, ...), the next prepare restores the original from this
310+
// backup with full fidelity -- including user [model_providers.*] sections the
311+
// merge excised, which block-stripping alone cannot bring back.
312+
function configTomlBackupPath(configTomlPath: string): string {
313+
return `${configTomlPath}.paperclip-backup`;
314+
}
315+
316+
// Merge custom Codex model providers supplied via PAPERCLIP_CODEX_PROVIDERS
317+
// into the managed CODEX_HOME's config.toml.
318+
//
319+
// Codex has no CLI flag or env var for pointing at a custom OpenAI-compatible
320+
// endpoint: custom endpoints are `[model_providers.<id>]` tables in
321+
// $CODEX_HOME/config.toml, selected by a top-level `model_provider = "<id>"`
322+
// key (the `--model` CLI flag picks the model WITHIN the selected provider).
323+
// We accept the providers as config (not hard-coded) so the gateway URL, key
324+
// indirection, and wire protocol stay declarative.
325+
//
326+
// The merge preserves any existing config.toml content (seeded from the shared
327+
// ~/.codex by prepareManagedCodexHome): managed content lives between marker
328+
// comments and conflicting pre-existing definitions are excised so the managed
329+
// definitions win. cleanup() restores the original file; if a run dies before
330+
// cleanup, the next prepare restores the original from the pre-run backup file
331+
// written alongside config.toml (including when PAPERCLIP_CODEX_PROVIDERS is
332+
// no longer set), falling back to stripping the stale managed blocks.
333+
//
334+
// When the adapter config explicitly sets env.CODEX_HOME (a user-managed home),
335+
// pass codexHome: null -- the file is left untouched and a note is surfaced.
336+
export async function prepareCodexRuntimeConfig(input: {
337+
env: Record<string, string>;
338+
codexHome: string | null;
339+
}): Promise<PreparedCodexRuntimeConfig> {
340+
const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name];
341+
const notes: string[] = [];
342+
const parsed = parseCodexProvidersConfig(
343+
input.env.PAPERCLIP_CODEX_PROVIDERS ?? process.env.PAPERCLIP_CODEX_PROVIDERS,
344+
resolveEnv,
345+
notes,
346+
);
347+
348+
if (!parsed) {
349+
// Self-heal state left behind by a crashed run (cleanup() never ran).
350+
if (input.codexHome) {
351+
const configTomlPath = path.join(input.codexHome, "config.toml");
352+
const reason = notes.length === 0 ? " (PAPERCLIP_CODEX_PROVIDERS is no longer set)" : "";
353+
const backupPath = configTomlBackupPath(configTomlPath);
354+
const backup = await readFileOrNull(backupPath);
355+
if (backup !== null) {
356+
// Full-fidelity restore: the backup is the pre-run original, including
357+
// any user provider sections the crashed run's merge excised.
358+
await fs.writeFile(configTomlPath, backup, "utf8");
359+
await fs.rm(backupPath, { force: true });
360+
return {
361+
notes: [
362+
...notes,
363+
`Restored "${configTomlPath}" from its pre-run backup, removing stale Paperclip-managed model providers left by an interrupted run${reason}.`,
364+
],
365+
cleanup: async () => {},
366+
};
367+
}
368+
// Fallback for pre-backup stale state: strip the managed blocks.
369+
const existing = await readFileOrNull(configTomlPath);
370+
if (existing !== null) {
371+
const stripped = stripManagedCodexProviderBlocks(existing);
372+
if (stripped !== existing) {
373+
await fs.writeFile(configTomlPath, stripped, "utf8");
374+
return {
375+
notes: [
376+
...notes,
377+
`Removed stale Paperclip-managed model provider blocks from "${configTomlPath}"${reason}.`,
378+
],
379+
cleanup: async () => {},
380+
};
381+
}
382+
}
383+
}
384+
return { notes, cleanup: async () => {} };
385+
}
386+
387+
if (!input.codexHome) {
388+
return {
389+
notes: [
390+
...notes,
391+
"PAPERCLIP_CODEX_PROVIDERS is set but the adapter config explicitly sets env.CODEX_HOME; leaving the user-managed Codex home untouched (no model provider merge).",
392+
],
393+
cleanup: async () => {},
394+
};
395+
}
396+
397+
const configTomlPath = path.join(input.codexHome, "config.toml");
398+
const backupPath = configTomlBackupPath(configTomlPath);
399+
// A surviving backup from an interrupted run is the true pre-run content;
400+
// the current config.toml would still carry that run's managed blocks.
401+
const original = (await readFileOrNull(backupPath)) ?? (await readFileOrNull(configTomlPath));
402+
const providerNames = Object.keys(parsed.providers);
403+
const base = stripConflictingDefinitions(
404+
stripManagedCodexProviderBlocks(original ?? ""),
405+
providerNames,
406+
parsed.modelProvider !== null,
407+
);
408+
await fs.mkdir(input.codexHome, { recursive: true });
409+
// Persist the original BEFORE writing the merged file so a run that never
410+
// reaches cleanup() can be restored by the next prepare.
411+
await fs.writeFile(backupPath, original ?? "", "utf8");
412+
await fs.writeFile(configTomlPath, buildMergedConfigToml(base, parsed), "utf8");
413+
414+
return {
415+
notes: [
416+
...notes,
417+
`Merged ${providerNames.length} custom Codex model provider(s) from PAPERCLIP_CODEX_PROVIDERS into "${configTomlPath}": ${providerNames.join(", ")}${
418+
parsed.modelProvider ? `; selected model_provider "${parsed.modelProvider}"` : ""
419+
}.`,
420+
],
421+
cleanup: async () => {
422+
if (original === null) {
423+
await fs.rm(configTomlPath, { force: true });
424+
} else {
425+
await fs.writeFile(configTomlPath, original, "utf8");
426+
}
427+
await fs.rm(backupPath, { force: true });
428+
},
429+
};
430+
}

server/src/__tests__/environment-service.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,6 @@ describeEmbeddedPostgres("environmentService leases", () => {
300300
expect((rows[0]?.metadata as Record<string, unknown>)?.managedKubernetesSandbox).toBe(true);
301301
});
302302

303-
304303
it("does not treat a non-kubernetes sandbox environment as the managed k8s env", async () => {
305304
const companyId = randomUUID();
306305
await db.insert(companies).values({

server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -737,17 +737,6 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
737737
createdAt: new Date(),
738738
updatedAt: new Date(),
739739
});
740-
const planCommentId = randomUUID();
741-
await db.insert(issueComments).values({
742-
id: planCommentId,
743-
companyId,
744-
issueId,
745-
authorUserId: "board-user-1",
746-
authorType: "user",
747-
body: "Keep the accepted plan rollout split into separate child tasks.",
748-
createdAt: new Date("2026-06-07T00:00:00.000Z"),
749-
updatedAt: new Date("2026-06-07T00:00:00.000Z"),
750-
});
751740
await seedAcceptedPlanClaim({
752741
companyId,
753742
issueId,
@@ -814,14 +803,5 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
814803
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
815804
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
816805
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only");
817-
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Comments included with the confirmed plan:");
818-
expect(adapterInput.context.paperclipTaskMarkdown).toContain(planCommentId);
819-
expect(adapterInput.context.paperclipTaskMarkdown).toContain(
820-
"Keep the accepted plan rollout split into separate child tasks.",
821-
);
822-
expect(adapterInput.context.paperclipWake).toEqual(expect.objectContaining({
823-
commentIds: [planCommentId],
824-
commentContextSource: "accepted_plan_confirmation",
825-
}));
826806
}, 20_000);
827807
});

server/src/__tests__/heartbeat-context-summary.test.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -72,34 +72,6 @@ describe("buildPaperclipTaskMarkdown", () => {
7272
expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\"");
7373
});
7474

75-
it("includes confirmed-plan comments in accepted-plan continuation task context", () => {
76-
const acceptedConfirmation = buildPaperclipTaskMarkdown({
77-
issue: {
78-
id: "issue-1",
79-
identifier: "PAP-3404",
80-
title: "Plan first",
81-
workMode: "planning",
82-
description: null,
83-
},
84-
interaction: {
85-
kind: "request_confirmation",
86-
status: "accepted",
87-
},
88-
acceptedPlanComments: [
89-
{
90-
id: "comment-1",
91-
authorType: "user",
92-
body: "Please keep the migration backwards compatible.",
93-
},
94-
],
95-
});
96-
97-
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
98-
expect(acceptedConfirmation).toContain("Comments included with the confirmed plan:");
99-
expect(acceptedConfirmation).toContain("Comment 1 - user - comment-1:");
100-
expect(acceptedConfirmation).toContain("Please keep the migration backwards compatible.");
101-
});
102-
10375
it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => {
10476
const commentWake = buildPaperclipTaskMarkdown({
10577
issue: {

0 commit comments

Comments
 (0)