Repository navigation
feat(cli): Add agent view PTY workers #7800
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
Merged
Merged
Changes from all commits
Commits
Show all changes
63 commits
Select commit
Hold shift + click to select a range
44036f4
feat(cli): add agent view supervisor runtime
1b994c1
feat(cli): add agent view pty workers
b469884
fix(cli): harden agent view supervisor runtime
f101d28
fix(cli): harden agent view pty workers
c84e1dc
fix(cli): surface supervisor startup failures
eca1fc4
fix(cli): handle supervisor subscription failures
5217c6f
Merge branch 'main' into agent/agent-view-supervisor-runtime
qwen-code-dev-bot ee693b3
fix(cli): address review feedback on agent-view supervisor (#7799)
d4c3f0f
Merge branch 'main' into agent/agent-view-supervisor-runtime
qwen-code-dev-bot 214a1cf
fix(cli): address review feedback on agent-view supervisor (#7799)
qwen-code-ci-bot a901609
fix(cli): address review feedback on agent-view supervisor (#7799)
qwen-code-ci-bot e149bc1
fix(cli): address review feedback on agent-view supervisor (#7799)
069b78b
Merge branch 'main' into agent/agent-view-supervisor-runtime
qwen-code-dev-bot b3caf9b
Merge branch 'main' into agent/agent-view-supervisor-runtime
ZijianZhang989 4e9ac53
fix(cli): bind streaming supervisor handlers and keep attach stream 8…
qwen-code-ci-bot 437522e
fix(cli): pause attach socket during leftover write and harden error …
f263b18
fix(cli): harden supervisor shutdown tests and clean up signal listen…
acef632
fix(cli): guard fire-and-forget promises and add supervisor timeout t…
qwen-code-ci-bot 6302b8e
fix(cli): normalize socket path test prefix and force credential file…
qwen-code-ci-bot e5f150a
fix(cli): address agent view runtime review feedback
4a9aa30
test(cli): cover agent view bridge edge cases
09f6d44
test(core): isolate worktree symlink traversal fixture
678f884
Revert "test(core): isolate worktree symlink traversal fixture"
5b69eae
Merge branch 'main' into agent/agent-view-supervisor-runtime
qwen-code-dev-bot 5776467
fix(cli): harden agent view supervisor store, auth, and streaming (#7…
qwen-code-dev-bot 9203dbd
fix(cli): harden agent view store joins, writes, and coverage (#7799)
qwen-code-dev-bot 1bcf793
fix(cli): harden agent view credential writes and subscription handsh…
qwen-code-dev-bot 1ae6b75
fix(cli): harden agent view store normalization, roster safety, and b…
0264edb
fix(cli): guard response serialization in supervisor server (#7799)
qwen-code-ci-bot 0dd7980
fix(cli): harden agent view pty host teardown
b10135f
fix(cli): fail fast on pty host auth rejection
f0ca335
Merge remote-tracking branch 'origin/agent/agent-view-supervisor-runt…
22280ee
test(cli): cover pty host remote exit polling
e811cb4
fix(cli): address agent view pty worker review nits
d1eb5d5
Merge remote-tracking branch 'origin/main' into agent/agent-view-pty-…
79df20e
fix(cli): harden pty host socket fallback
bcc08f4
Merge remote-tracking branch 'origin/main' into agent/agent-view-pty-…
016322c
fix(cli): harden agent view pty workers
c96c934
fix(cli): harden agent view pty workers
8c76fd2
test(cli): cover pty host spawn contract
c3a6016
fix(cli): guard agent view pty host socket takeover and races
qwen-code-ci-bot 6b85924
fix(cli): fit agent view pty logs under wire cap, strip host token af…
qwen-code-ci-bot b2deec7
Merge branch 'main' into agent/agent-view-pty-workers
wenshao eca1f8f
fix(cli): harden pty host fallback selection
f002b19
fix(cli): address agent view worker review blockers
da92f43
fix(cli): bound attach lease ttl and surface pty host input/listen fa…
e94dde0
fix(cli): serialize pty host socket ownership and keep attach transpo…
012c4db
fix(cli): escalate pty host shutdown and harden socket path fallback
b61917a
fix(cli): harden win32 kill, decoder reset, and exit tracker
244d162
fix(cli): skip POSIX signal tests on Windows and reclaim empty lock f…
07513f9
fix(cli): remove abort listener on delay timeout path
93fad08
fix(cli): don't optimistically resolve exited on SIGINT for remote ha…
22f610c
Merge remote-tracking branch 'origin/main' into codex/qwen-code-pr-7800
c56cf4a
test(cli): add SIGINT carve-out test for remote handle kill
49fe189
fix(cli): Only resolve exited on SIGKILL for remote handle kill
19e1c3d
Merge remote-tracking branch 'origin/main' into codex/qwen-code-pr-7800
8a3f89f
fix(cli): harden agent view PTY host socket lock and kill default
99c493f
Merge remote-tracking branch 'origin/main' into codex/qwen-code-pr-7800
640d437
fix(cli): fix agent view PTY host dispose signal, ring trim, and lock…
38b1073
fix(cli): settle remote pty host exit only after the RPC lands
7e8d984
fix(cli): optional-call shutdown in the lost-RPC regression test
8572f36
Merge remote-tracking branch 'origin/main' into codex/qwen-code-pr-7800
3c63475
fix(cli): wait for PTY host endpoint shutdown
yiliang114 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| AgentViewAttachLeaseManager, | ||
| MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS, | ||
| } from './attach-lease.js'; | ||
|
|
||
| describe('AgentViewAttachLeaseManager', () => { | ||
| it('acquires a lease for an unattached session', () => { | ||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| now: clock.now, | ||
| createLeaseId: () => 'lease-1', | ||
| defaultTtlMs: 1000, | ||
| }); | ||
|
|
||
| expect(manager.acquire('session-1', { clientId: 'terminal-1' })).toEqual({ | ||
| ok: true, | ||
| lease: { | ||
| sessionId: 'session-1', | ||
| leaseId: 'lease-1', | ||
| clientId: 'terminal-1', | ||
| acquiredAt: '2026-07-17T00:00:00.000Z', | ||
| lastHeartbeatAt: '2026-07-17T00:00:00.000Z', | ||
| expiresAt: '2026-07-17T00:00:01.000Z', | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it('uses a random lease id when no id factory is provided', () => { | ||
| const manager = new AgentViewAttachLeaseManager(); | ||
| const result = manager.acquire('session-1'); | ||
|
|
||
| expect(result).toMatchObject({ | ||
| ok: true, | ||
| lease: { | ||
| sessionId: 'session-1', | ||
| }, | ||
| }); | ||
| if (result.ok) { | ||
| expect(result.lease.leaseId).toMatch( | ||
| /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('isolates leases across sessions', () => { | ||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| let idCounter = 0; | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| now: clock.now, | ||
| createLeaseId: () => `lease-${++idCounter}`, | ||
| }); | ||
|
|
||
| const first = manager.acquire('session-a', { clientId: 'terminal-a' }); | ||
| const second = manager.acquire('session-b', { clientId: 'terminal-b' }); | ||
|
|
||
| expect(first.ok && first.lease.leaseId).toBe('lease-1'); | ||
| expect(second.ok && second.lease.leaseId).toBe('lease-2'); | ||
|
|
||
| // Session B's lease must not disturb session A's. | ||
| expect(manager.get('session-a')?.leaseId).toBe('lease-1'); | ||
| expect(manager.heartbeat('session-a', 'lease-1')?.leaseId).toBe('lease-1'); | ||
|
|
||
| // Releasing session A must not affect session B. | ||
| expect(manager.release('session-a', 'lease-1')).toBe(true); | ||
| expect(manager.get('session-a')).toBeUndefined(); | ||
| expect(manager.get('session-b')?.leaseId).toBe('lease-2'); | ||
| }); | ||
|
|
||
| it('rejects a second acquire while a lease is active', () => { | ||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| now: clock.now, | ||
| createLeaseId: () => 'lease-1', | ||
| }); | ||
| const first = manager.acquire('session-1'); | ||
|
|
||
| expect( | ||
| manager.acquire('session-1', { | ||
| leaseId: 'lease-2', | ||
| }), | ||
| ).toEqual({ | ||
| ok: false, | ||
| reason: 'already_attached', | ||
| lease: { | ||
| sessionId: first.lease.sessionId, | ||
| acquiredAt: first.lease.acquiredAt, | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| lastHeartbeatAt: first.lease.lastHeartbeatAt, | ||
| expiresAt: first.lease.expiresAt, | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it('does not disclose the active lease id on contested acquire', () => { | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| createLeaseId: () => 'lease-1', | ||
| }); | ||
| manager.acquire('session-1'); | ||
|
|
||
| const result = manager.acquire('session-1', { leaseId: 'lease-2' }); | ||
|
|
||
| expect(result.ok).toBe(false); | ||
| if (!result.ok) { | ||
| expect('leaseId' in result.lease).toBe(false); | ||
| } | ||
| }); | ||
|
|
||
| it('generates a lease id when the provided id is empty', () => { | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| createLeaseId: () => 'generated-lease', | ||
| }); | ||
|
|
||
| expect(manager.acquire('session-1', { leaseId: '' })).toMatchObject({ | ||
| ok: true, | ||
| lease: { | ||
| leaseId: 'generated-lease', | ||
| }, | ||
| }); | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| }); | ||
|
|
||
| it('releases only the matching lease', () => { | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| createLeaseId: () => 'lease-1', | ||
| }); | ||
| manager.acquire('session-1'); | ||
|
|
||
| expect(manager.release('session-1', 'other-lease')).toBe(false); | ||
| expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); | ||
| expect(manager.release('session-1', 'lease-1')).toBe(true); | ||
| expect(manager.get('session-1')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects non-positive, oversized, and non-finite ttls', () => { | ||
| const manager = new AgentViewAttachLeaseManager(); | ||
|
|
||
| expect(() => manager.acquire('session-1', { ttlMs: 0 })).toThrow( | ||
| 'Attach lease ttlMs must be positive.', | ||
| ); | ||
| expect(() => manager.acquire('session-1', { ttlMs: Number.NaN })).toThrow( | ||
| RangeError, | ||
| ); | ||
| expect(() => | ||
| manager.acquire('session-1', { | ||
| ttlMs: MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS + 1, | ||
| }), | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| ).toThrow('Attach lease ttlMs must not exceed'); | ||
| expect(() => manager.acquire('')).toThrow( | ||
| 'Agent View session id is required.', | ||
| ); | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| expect(manager.get('session-1')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('honors per-call ttls and expires lazily through get and release', () => { | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| now: clock.now, | ||
| createLeaseId: () => 'lease-1', | ||
| defaultTtlMs: 1000, | ||
| }); | ||
| manager.acquire('session-1', { ttlMs: 2000 }); | ||
|
|
||
| clock.advance(1500); | ||
| expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); | ||
| expect( | ||
| manager.heartbeat('session-1', 'lease-1', { ttlMs: 3000 }), | ||
| ).toMatchObject({ expiresAt: '2026-07-17T00:00:04.500Z' }); | ||
|
|
||
| clock.advance(3000); | ||
| expect(manager.get('session-1')).toBeUndefined(); | ||
| expect(manager.release('session-1', 'lease-1')).toBe(false); | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| }); | ||
|
|
||
| it('expires stale leases and allows reacquire', () => { | ||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| const leaseIds = ['lease-1', 'lease-2']; | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| now: clock.now, | ||
| createLeaseId: () => leaseIds.shift() ?? 'missing-lease', | ||
| defaultTtlMs: 1000, | ||
| }); | ||
| const first = manager.acquire('session-1'); | ||
|
|
||
| clock.advance(1000); | ||
|
|
||
| expect(manager.expire()).toEqual([first.lease]); | ||
| expect(manager.acquire('session-1')).toMatchObject({ | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| ok: true, | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| lease: { | ||
| leaseId: 'lease-2', | ||
| acquiredAt: '2026-07-17T00:00:01.000Z', | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it('expires corrupted leases with invalid timestamps', () => { | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
| createLeaseId: () => 'lease-1', | ||
| }); | ||
| manager.acquire('session-1'); | ||
| ( | ||
| manager as unknown as { | ||
| leases: Map<string, { expiresAt: string }>; | ||
| } | ||
| ).leases.get('session-1')!.expiresAt = 'not-a-date'; | ||
|
|
||
| expect(manager.expire()).toEqual([ | ||
| expect.objectContaining({ | ||
| sessionId: 'session-1', | ||
| leaseId: 'lease-1', | ||
| expiresAt: 'not-a-date', | ||
| }), | ||
| ]); | ||
| expect(manager.get('session-1')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('heartbeat extends the matching lease', () => { | ||
| const clock = fakeClock('2026-07-17T00:00:00.000Z'); | ||
| const manager = new AgentViewAttachLeaseManager({ | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| now: clock.now, | ||
| createLeaseId: () => 'lease-1', | ||
| defaultTtlMs: 1000, | ||
| }); | ||
| manager.acquire('session-1'); | ||
| clock.advance(500); | ||
|
|
||
| expect(manager.heartbeat('session-1', 'wrong-lease')).toBeUndefined(); | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| expect(manager.heartbeat('session-1', 'lease-1')).toMatchObject({ | ||
| sessionId: 'session-1', | ||
| leaseId: 'lease-1', | ||
| acquiredAt: '2026-07-17T00:00:00.000Z', | ||
| lastHeartbeatAt: '2026-07-17T00:00:00.500Z', | ||
| expiresAt: '2026-07-17T00:00:01.500Z', | ||
| }); | ||
|
|
||
| clock.advance(999); | ||
| expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); | ||
| }); | ||
| }); | ||
|
|
||
| function fakeClock(start: string): { | ||
| now: () => Date; | ||
| advance: (ms: number) => void; | ||
| } { | ||
| let nowMs = Date.parse(start); | ||
| return { | ||
| now: () => new Date(nowMs), | ||
| advance: (ms: number) => { | ||
| nowMs += ms; | ||
| }, | ||
| }; | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.