Skip to content

Commit 134c8a3

Browse files
alari76claude
andauthored
fix(orchestrator): never inject notifications mid-turn (audit A5) (#589)
* feat(orchestrator): Joe hears loop run events Phase 3: the goal-run event stream now feeds the orchestrator monitor — blocked runs (pointing Joe at pending_prompts/respond_to_prompt), awaiting_human escalations, and failures each produce one notification, deduped per (runId, status) since blocked re-emits on prompt re-broadcasts. Closes the gap where the supervisor only heard workflow events while loops stalled invisibly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(orchestrator): never inject notifications mid-turn (audit A5) All three delivery paths now gate on the orchestrator being idle: sendOrchestratorNotification and the monitor's deliverToOrchestrator route to the persistent outbox when Joe is mid-turn, and the outbox flusher holds its digest under the same gate until the turn ends. Closes the last mid-turn corruption path from the 2026-06 audit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ba740d7 commit 134c8a3

5 files changed

Lines changed: 48 additions & 8 deletions

File tree

server/orchestrator-monitor.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,11 @@ export class OrchestratorMonitor {
324324
if (!orchestratorId) return
325325

326326
const session = this.sessions.get(orchestratorId)
327-
if (!session?.claudeProcess?.isAlive()) {
328-
// Orchestrator not running — hand the notification to the persistent
329-
// outbox so it is replayed (as a digest) when the session comes back,
330-
// instead of rotting in the in-memory buffer forever.
327+
if (!session?.claudeProcess?.isAlive() || session.isProcessing) {
328+
// Orchestrator not running, or mid-turn (injecting now would derail
329+
// its active turn — audit item A5): hand the notification to the
330+
// persistent outbox, whose flusher retries under the same idle gate,
331+
// instead of letting it rot in the in-memory buffer.
331332
getOrchestratorOutbox().enqueue({
332333
label: notification.severity.toUpperCase(),
333334
title: notification.title,

server/orchestrator-notify.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import { sendOrchestratorNotification, type NotificationOutbox } from './orchest
2020
function makeSessions(opts: {
2121
exists: boolean
2222
alive: boolean
23+
isProcessing?: boolean
2324
}) {
2425
const sentInputs: Array<{ id: string; data: string }> = []
2526
const session = opts.exists
2627
? {
28+
isProcessing: opts.isProcessing ?? false,
2729
claudeProcess: opts.alive
2830
? { isAlive: vi.fn(() => true) }
2931
: { isAlive: vi.fn(() => false) },
@@ -64,6 +66,21 @@ describe('sendOrchestratorNotification', () => {
6466
)
6567
})
6668

69+
it('queues to the outbox instead of injecting mid-turn (A5)', () => {
70+
const sessions = makeSessions({ exists: true, alive: true, isProcessing: true })
71+
const outbox = makeOutbox()
72+
const ok = sendOrchestratorNotification(sessions, {
73+
parentSessionId: 'parent-id',
74+
label: 'ACTION',
75+
title: 'busy parent',
76+
body: 'must not be interrupted',
77+
}, outbox)
78+
79+
expect(ok).toBe(true)
80+
expect(sessions.sendInput).not.toHaveBeenCalled()
81+
expect(outbox.enqueue).toHaveBeenCalledWith({ label: 'ACTION', title: 'busy parent', body: 'must not be interrupted' })
82+
})
83+
6784
it('queues to the outbox and returns true when the parent session is missing', () => {
6885
const sessions = makeSessions({ exists: false, alive: false })
6986
const outbox = makeOutbox()

server/orchestrator-notify.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,25 @@ export interface NotificationOutbox {
4040
* Returns true when delivered immediately OR queued in the outbox for
4141
* replay (the outbox owns delivery from that point on). Returns false only
4242
* when queueing itself failed.
43+
*
44+
* Delivery is gated on the parent being idle: input sent while it is
45+
* mid-turn lands inside the active turn and derails it (audit item A5).
46+
* A busy parent gets the notification via the outbox's next flush tick,
47+
* which applies the same idle gate.
4348
*/
4449
export function sendOrchestratorNotification(
4550
sessions: SessionManager,
4651
args: OrchestratorNotifyArgs,
4752
outbox: NotificationOutbox = getOrchestratorOutbox(),
4853
): boolean {
4954
const session = sessions.get(args.parentSessionId)
50-
if (session?.claudeProcess?.isAlive()) {
55+
if (session?.claudeProcess?.isAlive() && !session.isProcessing) {
5156
const message = `[Agent ${getAgentDisplayName()} Notification — ${args.label}]\n${args.title}\n${args.body}`
5257
sessions.sendInput(args.parentSessionId, message)
5358
return true
5459
}
5560

56-
// Parent unreachable — queue for replay when the orchestrator comes back.
61+
// Parent unreachable or mid-turn — queue for replay by the outbox flusher.
5762
try {
5863
outbox.enqueue({ label: args.label, title: args.title, body: args.body })
5964
return true

server/orchestrator-outbox.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ vi.mock('./orchestrator-manager.js', () => ({
1919

2020
import { OrchestratorOutbox } from './orchestrator-outbox.js'
2121

22-
function makeSessions(opts: { alive?: boolean; rateLimited?: boolean } = {}) {
23-
const { alive = true, rateLimited = false } = opts
22+
function makeSessions(opts: { alive?: boolean; rateLimited?: boolean; isProcessing?: boolean } = {}) {
23+
const { alive = true, rateLimited = false, isProcessing = false } = opts
2424
const sentInputs: Array<{ id: string; data: string }> = []
2525
return {
2626
isRateLimited: vi.fn(() => rateLimited),
2727
get: vi.fn(() => ({
28+
isProcessing,
2829
claudeProcess: { isAlive: vi.fn(() => alive) },
2930
})),
3031
sendInput: vi.fn((id: string, data: string) => { sentInputs.push({ id, data }) }),
@@ -110,6 +111,18 @@ describe('OrchestratorOutbox', () => {
110111
expect(outbox.size()).toBe(1)
111112
})
112113

114+
it('flush holds the digest while the orchestrator is mid-turn (A5)', () => {
115+
const outbox = new OrchestratorOutbox(filePath)
116+
outbox.enqueue({ label: 'ACTION', title: 't', body: 'b' })
117+
const sessions = makeSessions({ isProcessing: true })
118+
119+
expect(outbox.flush(sessions)).toBe(0)
120+
expect(sessions.sendInput).not.toHaveBeenCalled()
121+
122+
// Next tick after the turn ends: delivered.
123+
expect(outbox.flush(makeSessions())).toBe(1)
124+
})
125+
113126
it('flush delivers a single-item digest with the original label', () => {
114127
const outbox = new OrchestratorOutbox(filePath)
115128
outbox.enqueue({ label: 'ALERT', title: 'Workflow failed', body: 'Run x failed' })

server/orchestrator-outbox.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export class OrchestratorOutbox {
7171
if (!orchestratorId) return 0
7272
const session = sessions.get(orchestratorId)
7373
if (!session?.claudeProcess?.isAlive()) return 0
74+
// Never inject mid-turn: input sent while the orchestrator is processing
75+
// lands inside its active turn and derails it. Hold the digest for the
76+
// next flush tick instead.
77+
if (session.isProcessing) return 0
7478

7579
const count = this.items.length
7680
const digest = this.buildDigest()

0 commit comments

Comments
 (0)