fix(acp): implicit streamToParent for mode=run without thread (#42404)

* fix(acp): implicit streamToParent for mode=run without thread

When spawning ACP sessions with mode=run and no thread binding,
automatically route output to parent session instead of Discord.
This enables agent-to-agent supervision patterns where the spawning
agent wants results returned programmatically, not posted as chat.

The change makes sessions_spawn with runtime=acp and thread=false
behave like direct acpx invocation - output goes to the spawning
session, not to Discord.

Fixes the issue where mode=run without thread still posted to Discord
because hasDeliveryTarget was true when called from a Discord context.

* fix: use resolved spawnMode instead of params.mode

Move implicit streamToParent check to after resolveSpawnMode so that
both explicit mode="run" and omitted mode (which defaults to "run"
when thread is false) correctly trigger parent routing.

This fixes the issue where callers that rely on default mode selection
would not get the intended parent streaming behavior.

* fix: tighten implicit ACP parent relay gating (#42404) (thanks @davidguttman)

---------

Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
David Guttman
2026-03-10 13:42:15 -07:00
committed by GitHub
parent f209a9be80
commit 9f5dee32f6
8 changed files with 483 additions and 17 deletions

View File

@@ -19,6 +19,7 @@ describe("heartbeat-reason", () => {
expect(resolveHeartbeatReasonKind("manual")).toBe("manual");
expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event");
expect(resolveHeartbeatReasonKind("wake")).toBe("wake");
expect(resolveHeartbeatReasonKind("acp:spawn:stream")).toBe("wake");
expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron");
expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook");
expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook");
@@ -35,6 +36,7 @@ describe("heartbeat-reason", () => {
expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true);
expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true);
expect(isHeartbeatEventDrivenReason("wake")).toBe(true);
expect(isHeartbeatEventDrivenReason("acp:spawn:stream")).toBe(true);
expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true);
expect(isHeartbeatEventDrivenReason("interval")).toBe(false);
expect(isHeartbeatEventDrivenReason("manual")).toBe(false);

View File

@@ -34,6 +34,9 @@ export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind
if (trimmed === "wake") {
return "wake";
}
if (trimmed.startsWith("acp:spawn:")) {
return "wake";
}
if (trimmed.startsWith("cron:")) {
return "cron";
}

View File

@@ -38,7 +38,11 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import {
normalizeAgentId,
parseAgentSessionKey,
toAgentStoreSessionKey,
} from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { escapeRegExp } from "../utils.js";
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
@@ -53,9 +57,11 @@ import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
import {
areHeartbeatsEnabled,
type HeartbeatRunResult,
type HeartbeatWakeHandler,
requestHeartbeatNow,
setHeartbeatsEnabled,
setHeartbeatWakeHandler,
} from "./heartbeat-wake.js";
import type { OutboundSendDeps } from "./outbound/deliver.js";
@@ -75,11 +81,8 @@ export type HeartbeatDeps = OutboundSendDeps &
};
const log = createSubsystemLogger("gateway/heartbeat");
let heartbeatsEnabled = true;
export function setHeartbeatsEnabled(enabled: boolean) {
heartbeatsEnabled = enabled;
}
export { areHeartbeatsEnabled, setHeartbeatsEnabled };
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
type HeartbeatAgent = {
@@ -611,9 +614,14 @@ export async function runHeartbeatOnce(opts: {
deps?: HeartbeatDeps;
}): Promise<HeartbeatRunResult> {
const cfg = opts.cfg ?? loadConfig();
const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg));
const explicitAgentId = typeof opts.agentId === "string" ? opts.agentId.trim() : "";
const forcedSessionAgentId =
explicitAgentId.length > 0 ? undefined : parseAgentSessionKey(opts.sessionKey)?.agentId;
const agentId = normalizeAgentId(
explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg),
);
const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
if (!heartbeatsEnabled) {
if (!areHeartbeatsEnabled()) {
return { status: "skipped", reason: "disabled" };
}
if (!isHeartbeatEnabledForAgent(cfg, agentId)) {
@@ -1114,7 +1122,7 @@ export function startHeartbeatRunner(opts: {
reason: "disabled",
} satisfies HeartbeatRunResult;
}
if (!heartbeatsEnabled) {
if (!areHeartbeatsEnabled()) {
return {
status: "skipped",
reason: "disabled",

View File

@@ -15,6 +15,16 @@ export type HeartbeatWakeHandler = (opts: {
sessionKey?: string;
}) => Promise<HeartbeatRunResult>;
let heartbeatsEnabled = true;
export function setHeartbeatsEnabled(enabled: boolean) {
heartbeatsEnabled = enabled;
}
export function areHeartbeatsEnabled(): boolean {
return heartbeatsEnabled;
}
type WakeTimerKind = "normal" | "retry";
type PendingWakeReason = {
reason: string;