Skip to content

Commit 180a970

Browse files
bdjbenBenjamin Badejosteipete
authored
fix(heartbeat): scope commitment fan-out prompts (#98169)
* fix(heartbeat): scope commitment fan-out prompts * fix(heartbeat): isolate commitment fan-out runs * fix(heartbeat): isolate commitment fan-out runs --------- Co-authored-by: Benjamin Badejo <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 8c5d1dc commit 180a970

36 files changed

Lines changed: 801 additions & 141 deletions

extensions/codex/src/app-server/run-attempt.context-engine.test.ts

Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,54 +1824,71 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
18241824
await run;
18251825
});
18261826

1827-
it("calls afterTurn with the mirrored transcript and runs turn maintenance", async () => {
1828-
const sessionFile = path.join(tempDir, "session.jsonl");
1829-
const workspaceDir = path.join(tempDir, "workspace");
1830-
const afterTurn = vi.fn(
1831-
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
1832-
);
1833-
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
1834-
const contextEngine = createContextEngine({ afterTurn, maintain, bootstrap: undefined });
1835-
const harness = createStartedThreadHarness();
1836-
const params = createParams(sessionFile, workspaceDir);
1837-
params.contextEngine = contextEngine;
1838-
params.contextTokenBudget = 111;
1839-
params.requestedModelId = "gpt-5.4-codex-primary";
1840-
params.fallbackReason = "provider_unavailable";
1841-
params.degradedReason = "context_overflow";
1842-
1843-
const run = runCodexAppServerAttempt(params);
1844-
await harness.waitForMethod("turn/start");
1845-
await harness.completeTurn();
1846-
await run;
1827+
it.each([
1828+
{
1829+
name: "commitment-only",
1830+
trigger: "heartbeat",
1831+
bootstrapContextRunKind: "commitment-only",
1832+
},
1833+
{
1834+
name: "Gateway-routed heartbeat",
1835+
trigger: "user",
1836+
bootstrapContextRunKind: "heartbeat",
1837+
},
1838+
] as const)(
1839+
"keeps $name turns heartbeat-classified through afterTurn maintenance",
1840+
async (testCase) => {
1841+
const sessionFile = path.join(tempDir, "session.jsonl");
1842+
const workspaceDir = path.join(tempDir, "workspace");
1843+
const afterTurn = vi.fn(
1844+
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
1845+
);
1846+
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
1847+
const contextEngine = createContextEngine({ afterTurn, maintain, bootstrap: undefined });
1848+
const harness = createStartedThreadHarness();
1849+
const params = createParams(sessionFile, workspaceDir);
1850+
params.contextEngine = contextEngine;
1851+
params.trigger = testCase.trigger;
1852+
params.bootstrapContextRunKind = testCase.bootstrapContextRunKind;
1853+
params.contextTokenBudget = 111;
1854+
params.requestedModelId = "gpt-5.4-codex-primary";
1855+
params.fallbackReason = "provider_unavailable";
1856+
params.degradedReason = "context_overflow";
1857+
1858+
const run = runCodexAppServerAttempt(params);
1859+
await harness.waitForMethod("turn/start");
1860+
await harness.completeTurn();
1861+
await run;
18471862

1848-
expect(afterTurn).toHaveBeenCalledTimes(1);
1849-
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
1850-
NonNullable<ContextEngine["afterTurn"]>
1851-
>[0];
1852-
expect(afterTurnCall.sessionId).toBe("session-1");
1853-
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
1854-
expect(afterTurnCall.prePromptMessageCount).toBe(0);
1855-
expect(afterTurnCall.tokenBudget).toBe(111);
1856-
expect(afterTurnCall.runtimeSettings).toMatchObject({
1857-
runtime: { mode: "degraded" },
1858-
model: {
1859-
requested: "gpt-5.4-codex-primary",
1860-
resolved: "gpt-5.4-codex",
1861-
},
1862-
diagnostics: {
1863-
fallbackReason: "provider_unavailable",
1864-
degradedReason: "context_overflow",
1865-
},
1866-
});
1867-
expect(afterTurnCall.messages.some((message) => message.role === "user")).toBe(true);
1868-
expect(afterTurnCall.messages.some((message) => message.role === "assistant")).toBe(true);
1869-
expect(maintain).toHaveBeenCalledTimes(1);
1870-
const maintainCall = requireFirstCallArg(maintain, "maintain") as Parameters<
1871-
NonNullable<ContextEngine["maintain"]>
1872-
>[0];
1873-
expect(maintainCall.runtimeSettings).toBe(afterTurnCall.runtimeSettings);
1874-
});
1863+
expect(afterTurn).toHaveBeenCalledTimes(1);
1864+
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
1865+
NonNullable<ContextEngine["afterTurn"]>
1866+
>[0];
1867+
expect(afterTurnCall.sessionId).toBe("session-1");
1868+
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
1869+
expect(afterTurnCall.prePromptMessageCount).toBe(0);
1870+
expect(afterTurnCall.tokenBudget).toBe(111);
1871+
expect(afterTurnCall.isHeartbeat).toBe(true);
1872+
expect(afterTurnCall.runtimeSettings).toMatchObject({
1873+
runtime: { mode: "degraded" },
1874+
model: {
1875+
requested: "gpt-5.4-codex-primary",
1876+
resolved: "gpt-5.4-codex",
1877+
},
1878+
diagnostics: {
1879+
fallbackReason: "provider_unavailable",
1880+
degradedReason: "context_overflow",
1881+
},
1882+
});
1883+
expect(afterTurnCall.messages.some((message) => message.role === "user")).toBe(true);
1884+
expect(afterTurnCall.messages.some((message) => message.role === "assistant")).toBe(true);
1885+
expect(maintain).toHaveBeenCalledTimes(1);
1886+
const maintainCall = requireFirstCallArg(maintain, "maintain") as Parameters<
1887+
NonNullable<ContextEngine["maintain"]>
1888+
>[0];
1889+
expect(maintainCall.runtimeSettings).toBe(afterTurnCall.runtimeSettings);
1890+
},
1891+
);
18751892

18761893
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
18771894
const sessionFile = path.join(tempDir, "session.jsonl");

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,7 @@ export async function runCodexAppServerAttempt(
10661066
developerInstructions,
10671067
messages: codexModelInputHistoryMessages,
10681068
ctx: hookContext,
1069+
bootstrapContextRunKind: params.bootstrapContextRunKind,
10691070
...("beforeAgentStartResult" in params
10701071
? { beforeAgentStartResult: params.beforeAgentStartResult }
10711072
: {}),
@@ -3098,6 +3099,11 @@ export async function runCodexAppServerAttempt(
30983099
if (activeContextEngine) {
30993100
const activeContextEnginePluginIdLocal =
31003101
resolveContextEngineOwnerPluginId(activeContextEngine);
3102+
// Gateway command runs use a generic `user` trigger, so the bootstrap run
3103+
// kind is the canonical heartbeat lifecycle signal at this boundary.
3104+
const isHeartbeatLifecycleRun =
3105+
params.bootstrapContextRunKind === "heartbeat" ||
3106+
params.bootstrapContextRunKind === "commitment-only";
31013107
const finalMessages =
31023108
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
31033109
historyMessages.concat(result.messagesSnapshot);
@@ -3132,7 +3138,7 @@ export async function runCodexAppServerAttempt(
31323138
runMaintenance: runHarnessContextEngineMaintenance,
31333139
config: params.config,
31343140
warn: (message) => embeddedAgentLog.warn(message),
3135-
isHeartbeat: params.bootstrapContextRunKind === "heartbeat",
3141+
isHeartbeat: isHeartbeatLifecycleRun,
31363142
});
31373143
}
31383144
runAgentHarnessLlmOutputHook({

extensions/codex/src/app-server/thread-lifecycle.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,25 @@ describe("Codex app-server turn params", () => {
955955
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
956956
);
957957

958+
params.bootstrapContextRunKind = "commitment-only";
959+
const commitmentCollaborationMode = buildTurnCollaborationMode(params, {
960+
turnScopedDeveloperInstructions: "Turn-only workspace instructions.",
961+
heartbeatCollaborationInstructions:
962+
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
963+
});
964+
expect(commitmentCollaborationMode.settings.developer_instructions).toContain(
965+
"# Collaboration Mode: Default",
966+
);
967+
expect(commitmentCollaborationMode.settings.developer_instructions).toContain(
968+
"Turn-only workspace instructions.",
969+
);
970+
expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
971+
"This is an OpenClaw heartbeat turn",
972+
);
973+
expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
974+
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
975+
);
976+
958977
params.trigger = "user";
959978
expect(
960979
buildTurnCollaborationMode(params, {

extensions/codex/src/app-server/thread-lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,7 @@ function buildTurnScopedCollaborationInstructions(
15241524
if (params.trigger === "cron") {
15251525
return joinPresentSections(buildCronCollaborationInstructions(), contextInstructions);
15261526
}
1527-
if (params.trigger === "heartbeat") {
1527+
if (params.trigger === "heartbeat" && params.bootstrapContextRunKind !== "commitment-only") {
15281528
return joinPresentSections(
15291529
buildHeartbeatCollaborationInstructions(),
15301530
contextInstructions,

extensions/copilot/src/attempt.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ export async function runCopilotAttempt(
716716
developerInstructions: originalDeveloperInstructions,
717717
messages,
718718
ctx: hookContext,
719+
bootstrapContextRunKind: input.bootstrapContextRunKind,
719720
...("beforeAgentStartResult" in input
720721
? { beforeAgentStartResult: input.beforeAgentStartResult }
721722
: {}),
@@ -1613,9 +1614,7 @@ export function resolveModelRef(params: AttemptParamsLike): ModelRef {
16131614
readString((params as { provider?: unknown }).provider) ??
16141615
"unknown-provider",
16151616
baseUrl: readString(model.baseUrl),
1616-
azureApiVersion: readString(
1617-
model.azureApiVersion ?? model.params?.azureApiVersion,
1618-
),
1617+
azureApiVersion: readString(model.azureApiVersion ?? model.params?.azureApiVersion),
16191618
headers: model.headers,
16201619
authHeader: model.authHeader,
16211620
requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode),

packages/gateway-protocol/src/schema/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export const AgentParamsSchema = Type.Object(
217217
bootstrapContextMode: Type.Optional(
218218
Type.Union([Type.Literal("full"), Type.Literal("lightweight")]),
219219
),
220+
// Commitment fan-out scope is scheduler-internal and cannot be selected over Gateway RPC.
220221
bootstrapContextRunKind: Type.Optional(
221222
Type.Union([Type.Literal("default"), Type.Literal("heartbeat"), Type.Literal("cron")]),
222223
),

src/agents/agent-command.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
import { isStoredCredentialCompatibleWithAuthProvider } from "./auth-profiles/order.js";
8989
import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
9090
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
91+
import { isHeartbeatLifecycleRunKind } from "./bootstrap-mode.js";
9192
import {
9293
createAgentAttemptLifecycleCallbacks,
9394
type AgentAttemptLifecycleState,
@@ -2154,6 +2155,7 @@ async function agentCommandInternal(
21542155

21552156
// Update token+model fields in the session store.
21562157
if (sessionStore && sessionKey && !suppressVisibleSessionEffects) {
2158+
const isHeartbeatLifecycleRun = isHeartbeatLifecycleRunKind(opts.bootstrapContextRunKind);
21572159
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
21582160
await updateSessionStoreAfterAgentRun({
21592161
cfg,
@@ -2169,12 +2171,10 @@ async function agentCommandInternal(
21692171
result,
21702172
touchInteraction:
21712173
opts.bootstrapContextRunKind !== "cron" &&
2172-
opts.bootstrapContextRunKind !== "heartbeat" &&
2174+
!isHeartbeatLifecycleRun &&
21732175
!opts.internalEvents?.length,
21742176
preserveRuntimeModel:
2175-
fallbackExhausted ||
2176-
opts.bootstrapContextRunKind === "heartbeat" ||
2177-
preserveUserFacingSessionModelState,
2177+
fallbackExhausted || isHeartbeatLifecycleRun || preserveUserFacingSessionModelState,
21782178
preserveUserFacingSessionModelState,
21792179
});
21802180
sessionEntry = sessionStore[sessionKey] ?? sessionEntry;

src/agents/bootstrap-files.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,20 @@ describe("resolveBootstrapContextForRun", () => {
400400
expect(files).toStrictEqual([]);
401401
});
402402

403+
it("excludes HEARTBEAT.md from commitment-only context", async () => {
404+
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
405+
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "global work", "utf8");
406+
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "persona", "utf8");
407+
408+
const files = await resolveBootstrapFilesForRun({
409+
workspaceDir,
410+
runKind: "commitment-only",
411+
});
412+
413+
expect(files.map((file) => file.name)).not.toContain("HEARTBEAT.md");
414+
expect(files.map((file) => file.name)).toContain("SOUL.md");
415+
});
416+
403417
it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat prompt section is disabled", async () => {
404418
const workspaceDir = await createHeartbeatAgentsWorkspace();
405419

src/agents/bootstrap-files.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolveUserPath } from "../utils.js";
1111
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
1212
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
1313
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
14+
import type { BootstrapContextRunKind } from "./bootstrap-mode.js";
1415
import type { EmbeddedContextFile } from "./embedded-agent-helpers.js";
1516
import {
1617
buildBootstrapContextFiles,
@@ -28,7 +29,6 @@ import {
2829
} from "./workspace.js";
2930

3031
export type BootstrapContextMode = "full" | "lightweight";
31-
type BootstrapContextRunKind = "default" | "heartbeat" | "cron";
3232

3333
const CONTINUATION_SCAN_MAX_TAIL_BYTES = 256 * 1024;
3434
const CONTINUATION_SCAN_MAX_RECORDS = 500;
@@ -222,6 +222,9 @@ function shouldExcludeHeartbeatBootstrapFile(params: {
222222
agentId?: string;
223223
runKind?: BootstrapContextRunKind;
224224
}): boolean {
225+
if (params.runKind === "commitment-only") {
226+
return true;
227+
}
225228
if (!params.config || params.runKind === "heartbeat") {
226229
return false;
227230
}

src/agents/bootstrap-mode.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
/** Tests bootstrap mode selection for primary, cron, heartbeat, and sandboxed runs. */
22
import { describe, expect, it } from "vitest";
3-
import { resolveBootstrapMode } from "./bootstrap-mode.js";
3+
import { isHeartbeatLifecycleRunKind, resolveBootstrapMode } from "./bootstrap-mode.js";
44

55
describe("resolveBootstrapMode", () => {
6+
it("classifies global and commitment-only runs as heartbeat lifecycle turns", () => {
7+
expect(isHeartbeatLifecycleRunKind("heartbeat")).toBe(true);
8+
expect(isHeartbeatLifecycleRunKind("commitment-only")).toBe(true);
9+
expect(isHeartbeatLifecycleRunKind("cron")).toBe(false);
10+
expect(isHeartbeatLifecycleRunKind("default")).toBe(false);
11+
});
12+
613
it("returns none when bootstrap is not pending", () => {
714
expect(
815
resolveBootstrapMode({
@@ -42,7 +49,7 @@ describe("resolveBootstrapMode", () => {
4249
).toBe("limited");
4350
});
4451

45-
it("returns none for cron, heartbeat, and non-primary runs", () => {
52+
it("returns none for background and non-primary runs", () => {
4653
expect(
4754
resolveBootstrapMode({
4855
bootstrapPending: true,
@@ -63,6 +70,16 @@ describe("resolveBootstrapMode", () => {
6370
hasBootstrapFileAccess: true,
6471
}),
6572
).toBe("none");
73+
expect(
74+
resolveBootstrapMode({
75+
bootstrapPending: true,
76+
runKind: "commitment-only",
77+
isInteractiveUserFacing: true,
78+
isPrimaryRun: true,
79+
isCanonicalWorkspace: true,
80+
hasBootstrapFileAccess: true,
81+
}),
82+
).toBe("none");
6683
expect(
6784
resolveBootstrapMode({
6885
bootstrapPending: true,

0 commit comments

Comments
 (0)