Repository navigation
fix(orchestration): let mail wake a slept agent, and make slept agents visible #18731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
brennanb2025
wants to merge
38
commits into
main
Choose a base branch
from
brennanb2025/impl-agent-sleep-wake
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+6,426
−560
Open
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
af8e492
feat(orchestration): wake an auto-slept pane when mail arrives for it
89991ff
feat(terminals): list slept panes as resumable and let orchestration …
1b45ebf
fix: guard synthetic handles, keep session listing off the repo inven…
24d2ea9
chore: drop the unrelated pnpm-lock churn a local toolchain run intro…
11feb01
fix(orchestration): wake a slept pane whose handle still resolves
148b09a
fix(orchestration): wake a slept pane whose tab is still mounted
959116f
fix(orchestration): harden slept-agent mail wake
fae2acc
fix(orchestration): preserve slept agent wake recovery
0013746
fix(orchestration): redrive wakes after graph recovery
990e8bd
Fix status bar runtime target render loop (#18685)
Jinwoo-H a793f51
fix(orchestration): deliver mail after Codex wake
31aeb74
fix(orchestration): redrive mail on Codex reattach
2122edb
fix(orchestration): redrive mail when Codex identity arrives
2f0f06f
fix(orchestration): verify Codex pointer submission
5bd7900
fix(orchestration): deliver wake mail to resumed Codex
70ea538
fix(orchestration): scope fresh foreground confirmation
76feffa
fix(orchestration): retry resumed Codex mail delivery
188b87b
fix(orchestration): avoid retrying accepted Codex prompts
f529dee
perf(runtime): preserve ordinary prompt scheduling
e043107
fix(orchestration): redrive resumed run by stable pane
a78f375
refactor(processes): extract Linux start-time capture
ec51482
Merge origin/main into impl-agent-sleep-wake
b27c54b
Merge origin/main into impl-agent-sleep-wake
c939764
fix(renderer): keep process cadence timing browser-safe
5db91a4
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
2293df1
fix orchestration mail wake for resumed Codex
de55469
fix terminal wake scope for sleeping split panes
d3662d1
fix: preserve agent authority when moving terminal panes
9953541
fix(agent-hibernation): restore status after cold resume
85fccd2
test(terminal): include cold restore scope ref
9c7672a
Merge origin/main into brennanb2025/impl-agent-sleep-wake
46ecbb9
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 c7f0714
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 8203d18
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 f322364
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 7395593
test(orchestration): repair merged sleep-wake fixtures
brennanb2025 b83c713
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 9851cc9
refactor(renderer): drop dead planMobileTerminalTabMount wrapper
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
282 changes: 282 additions & 0 deletions
282
src/main/providers/agent-foreground-process-confirmation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const { execFileMock } = vi.hoisted(() => ({ | ||
| execFileMock: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('child_process', () => ({ | ||
| execFile: execFileMock | ||
| })) | ||
|
|
||
| const getAllProcessesMock = vi.fn() | ||
|
|
||
| import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' | ||
| import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' | ||
| import { | ||
| confirmShellForegroundProcess, | ||
| resolveAgentForegroundProcess, | ||
| resolveAgentForegroundProcessWithAvailability | ||
| } from './agent-foreground-process' | ||
|
|
||
| describe('agent foreground confirmation', () => { | ||
| let platform: PropertyDescriptor | undefined | ||
|
|
||
| beforeEach(() => { | ||
| execFileMock.mockReset() | ||
| getAllProcessesMock.mockReset() | ||
| resetProcessTableSnapshotForTests() | ||
| __setWindowsProcessTreeLoaderForTests(() => ({ | ||
| ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, | ||
| getAllProcesses: getAllProcessesMock | ||
| })) | ||
| platform = Object.getOwnPropertyDescriptor(process, 'platform') | ||
| Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| __setWindowsProcessTreeLoaderForTests() | ||
| if (platform) { | ||
| Object.defineProperty(process, 'platform', platform) | ||
| } | ||
| }) | ||
|
|
||
| function mockPs(stdout: string): void { | ||
| execFileMock.mockImplementation( | ||
| (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| callback(null, { stdout, stderr: '' }) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { | ||
| mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') | ||
|
|
||
| await expect( | ||
| resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) | ||
| ).resolves.toEqual({ available: false, processName: 'zsh' }) | ||
| }) | ||
|
|
||
| it('fresh-confirms Codex from its PTY when a whole-host scan would time out', async () => { | ||
| execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| if (args[0] === '-p') { | ||
| callback(null, { | ||
| stdout: '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n', | ||
| stderr: '' | ||
| }) | ||
| return | ||
| } | ||
| if (args[0] === '-t') { | ||
| callback(null, { | ||
| stdout: [ | ||
| '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l', | ||
| '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 node /opt/homebrew/bin/codex', | ||
| '102 101 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 /opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/bin/codex' | ||
| ].join('\n'), | ||
| stderr: '' | ||
| }) | ||
| return | ||
| } | ||
| callback(new Error('whole-host ps timed out'), { stdout: '', stderr: '' }) | ||
| }) | ||
|
|
||
| await expect( | ||
| resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) | ||
| ).resolves.toEqual({ available: true, processName: 'codex' }) | ||
| expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t']) | ||
| }) | ||
|
|
||
| it('rejects a PTY capture when its root process changes between samples', async () => { | ||
| execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| callback(null, { | ||
| stdout: | ||
| args[0] === '-p' | ||
| ? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n' | ||
| : [ | ||
| '100 99 100 101 S ttys060 Fri Sep 4 15:53:35 2026 -/bin/zsh -l', | ||
| '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex' | ||
| ].join('\n'), | ||
| stderr: '' | ||
| }) | ||
| }) | ||
|
|
||
| await expect( | ||
| resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) | ||
| ).resolves.toEqual({ available: false, processName: 'zsh' }) | ||
| expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t']) | ||
| }) | ||
|
|
||
| it('rejects a malformed row in a PTY-scoped confirmation capture', async () => { | ||
| execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| callback(null, { | ||
| stdout: | ||
| args[0] === '-p' | ||
| ? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n' | ||
| : [ | ||
| '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l', | ||
| 'malformed foreground row', | ||
| '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex' | ||
| ].join('\n'), | ||
| stderr: '' | ||
| }) | ||
| }) | ||
|
|
||
| await expect( | ||
| resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) | ||
| ).resolves.toEqual({ available: false, processName: 'zsh' }) | ||
| }) | ||
|
|
||
| it('treats failed POSIX scans as unavailable', async () => { | ||
| execFileMock.mockImplementation( | ||
| (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) | ||
| } | ||
| ) | ||
|
|
||
| await expect( | ||
| resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) | ||
| ).resolves.toEqual({ available: false, processName: 'zsh' }) | ||
| await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ | ||
| available: false, | ||
| processName: 'zsh' | ||
| }) | ||
| await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') | ||
| }) | ||
|
|
||
| it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { | ||
| mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n')) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) | ||
| }) | ||
|
|
||
| it('uses spawned-shell identity instead of a lagging foreground child label', async () => { | ||
| mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n')) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) | ||
| }) | ||
|
|
||
| it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { | ||
| mockPs( | ||
| [ | ||
| '100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh', | ||
| '101 100 101 101 S+ -zsh', | ||
| '102 101 101 101 S+ (zsh)', | ||
| '103 102 101 101 S+ (sed)', | ||
| '104 102 101 101 R+ (git)' | ||
| ].join('\n') | ||
| ) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) | ||
| }) | ||
|
|
||
| it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { | ||
| mockPs( | ||
| [ | ||
| '100 99 100 102 Ss /usr/bin/login -pfl developer /bin/zsh', | ||
| '101 100 101 102 S -zsh', | ||
| '102 101 102 102 S+ agent-tui', | ||
| '103 102 102 102 S+ /bin/zsh -i' | ||
| ].join('\n') | ||
| ) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) | ||
| }) | ||
|
|
||
| it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { | ||
| mockPs( | ||
| [ | ||
| '100 99 100 101 Ss /bin/zsh -l', | ||
| '101 100 101 101 S+ /usr/local/bin/agent-tui', | ||
| '102 101 101 101 S+ /bin/bash -i' | ||
| ].join('\n') | ||
| ) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) | ||
| }) | ||
|
|
||
| it('rejects shell ownership while a stopped TUI remains resumable', async () => { | ||
| mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n')) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) | ||
| }) | ||
|
|
||
| it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { | ||
| // Why pinned: the WSL job object holds only wsl.exe, so a looser | ||
| // isShellProcess would confirm ownership regardless of distro-side state. | ||
| await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) | ||
| expect(execFileMock).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('fails open when fresh shell ownership inspection is unavailable', async () => { | ||
| execFileMock.mockImplementation( | ||
| (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { | ||
| const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void | ||
| callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) | ||
| } | ||
| ) | ||
|
|
||
| await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) | ||
| }) | ||
|
|
||
| it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { | ||
| Object.defineProperty(process, 'platform', { value: 'win32' }) | ||
| const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) | ||
|
|
||
| await expect( | ||
| confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) | ||
| ).resolves.toBe(true) | ||
| expect(getAllProcessesMock).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('rejects Windows shell ownership with a child or unavailable membership', async () => { | ||
| Object.defineProperty(process, 'platform', { value: 'win32' }) | ||
|
|
||
| await expect( | ||
| confirmShellForegroundProcess(100, 'powershell.exe', { | ||
| readWindowsPtyJobProcessIds: async () => new Set([100, 101]) | ||
| }) | ||
| ).resolves.toBe(false) | ||
| await expect( | ||
| confirmShellForegroundProcess(100, 'powershell.exe', { | ||
| readWindowsPtyJobProcessIds: async () => null | ||
| }) | ||
| ).resolves.toBe(false) | ||
| }) | ||
|
|
||
| it('does not report Claude print-mode hook descendants as foreground agents', async () => { | ||
| mockPs( | ||
| [ | ||
| '100 99 Ss bash -i', | ||
| '101 100 S+ claude --print --model haiku Analyze this conversation and determine next work' | ||
| ].join('\n') | ||
| ) | ||
|
|
||
| await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') | ||
| }) | ||
|
|
||
| it('does not report a stopped agent after the shell regains foreground', async () => { | ||
| mockPs( | ||
| ['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join( | ||
| '\n' | ||
| ) | ||
| ) | ||
|
|
||
| await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') | ||
| }) | ||
|
|
||
| it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => { | ||
| // No '+' marker at all (e.g. a detached/daemon descendant tree) — the | ||
| // recognized agent may still be the best available signal. | ||
| mockPs( | ||
| ['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join( | ||
| '\n' | ||
| ) | ||
| ) | ||
|
|
||
| await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mock here (
101 999 S+ node …) is not a well-formed PTY-scopedpsrow, soparseStrictProcessTableRowsthrowsmalformed_rowbefore theroot = rootRows.find(row => row.pid === rootPid)lookup ever runs. The assertion therefore passes via the malformed-row rejection (already covered by therejects a malformed rowcase) rather than the missing-root fallback this test's name promises. Feed the-pcapture a valid row for a different pid — or a root row whosettyis?/-— so the!root?.ttywhole-host fallback branch is actually exercised.