chore: checkpoint agent launch hardening
This commit is contained in:
parent
5730ddc7af
commit
26baaf6924
26 changed files with 5512 additions and 87 deletions
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# Agent launch architecture comparison research
|
||||||
|
|
||||||
|
Research date: 2026-05-07
|
||||||
|
|
||||||
|
Purpose: record factual research on how different systems launch or execute agents. This is informational context only, not an implementation recommendation.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Systems compared:
|
||||||
|
|
||||||
|
| System | Repository / source | Snapshot |
|
||||||
|
|---|---|---|
|
||||||
|
| Our Agent Teams | Local `claude_team` + `agent_teams_orchestrator` | Local working tree, 2026-05-07 |
|
||||||
|
| Paperclip | `paperclipai` docs/code research from earlier pass | Public docs / local research |
|
||||||
|
| Gastown | `github.com/gastownhall/gastown` | cloned `cfbdf3c` |
|
||||||
|
| GoClaw Enterprise / Teams | `github.com/nextlevelbuilder/goclaw` | cloned `a97e502` |
|
||||||
|
| GoClaw OpenClaw-compatible gateway | `github.com/roelfdiedericks/goclaw` | cloned `6a7ccdb` |
|
||||||
|
|
||||||
|
Primary external references:
|
||||||
|
|
||||||
|
| Topic | Source |
|
||||||
|
|---|---|
|
||||||
|
| Gastown README | https://github.com/gastownhall/gastown/blob/main/README.md |
|
||||||
|
| Gastown agent provider integration | https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md |
|
||||||
|
| GoClaw agent loop | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/01-agent-loop.md |
|
||||||
|
| GoClaw agent teams | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/11-agent-teams.md |
|
||||||
|
| GoClaw team WS events | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/13-ws-team-events.md |
|
||||||
|
| Paperclip agent runtime | https://github.com/paperclipai/docs/blob/main/agents-runtime.md |
|
||||||
|
| Paperclip adapters overview | https://paperclip.inc/docs/adapters/overview/ |
|
||||||
|
|
||||||
|
## Short answer
|
||||||
|
|
||||||
|
There are four distinct launch/execution models:
|
||||||
|
|
||||||
|
| Model | Used by | Essence |
|
||||||
|
|---|---|---|
|
||||||
|
| External live CLI process | Our Agent Teams | App/orchestrator launches real teammate runtimes and tracks bootstrap, PID, stderr, process health, runtime evidence, task/message state. |
|
||||||
|
| Bounded adapter run | Paperclip | A heartbeat or job starts a short agent run, adapter invokes CLI/provider, result is captured, run exits or times out. |
|
||||||
|
| Tmux session orchestration | Gastown | `tmux` is the universal runtime adapter. Agents run in terminal sessions, receive input through tmux, and are observed through panes/session state. |
|
||||||
|
| In-process agent loop | GoClaw Enterprise / Teams | Agent execution is a Go `Loop.Run(ctx, RunRequest)` scheduled through lanes. The agent is a logical loop inside the gateway, not necessarily a separate CLI teammate process. |
|
||||||
|
|
||||||
|
## What “in-process agent loop” removes from our live teammate product
|
||||||
|
|
||||||
|
In-process loop does not mean “bad”. It is often cleaner. But compared to our external process teammate model, it removes or changes several product properties.
|
||||||
|
|
||||||
|
| Product property | External process teammate, our model | In-process GoClaw-style loop |
|
||||||
|
|---|---|---|
|
||||||
|
| Real process identity | Each teammate can have PID/RSS/stdout/stderr/process lifetime. | Agent run is a gateway invocation; no independent teammate PID by default. |
|
||||||
|
| CLI-realism | Claude/Codex/OpenCode behave as their real CLI runtimes, including auth, prompts, provider errors, stderr quirks. | Provider/driver behavior is normalized inside gateway; fewer raw CLI lifecycle surfaces. |
|
||||||
|
| Per-member restart semantics | Restart means kill/relaunch or reattach a concrete runtime for that member. | Restart is usually cancel/reschedule a logical run/session. |
|
||||||
|
| Bootstrap evidence | We can distinguish process alive, bootstrap submitted, bootstrap confirmed, delivery proof, task proof. | The loop itself is already the controlled runtime; less need for low-level bootstrap proof. |
|
||||||
|
| UI runtime cards | UI can show memory, process state, liveness source, failed/stalled bootstrap, exact runtime diagnostics. | UI tends to show run/session/task status rather than OS/process-level teammate state. |
|
||||||
|
| TTY/process debugging | Process/tmux mode can expose raw CLI behavior when needed. | Debugging is gateway traces/events/logs, not a live CLI pane/process per member. |
|
||||||
|
| Failure classes | Auth prompt, no stdin, CLI did not submit bootstrap, process died, stale PID, provider CLI stderr. | Mostly provider/tool/session/run errors inside the loop. |
|
||||||
|
| Isolation boundary | OS process boundary per teammate. | Mostly logical/session isolation inside one gateway process, unless it delegates to external providers/tools. |
|
||||||
|
|
||||||
|
Important distinction: in-process loop is simpler and can be more stable for gateway/chat products. It is not a drop-in replacement for a desktop product whose value includes live external teammate runtimes.
|
||||||
|
|
||||||
|
## Our Agent Teams launch/execution model
|
||||||
|
|
||||||
|
Our current direction is app-managed live external teammate runtime.
|
||||||
|
|
||||||
|
Observed local architecture:
|
||||||
|
|
||||||
|
| Layer | Role |
|
||||||
|
|---|---|
|
||||||
|
| `claude_team` Electron app | UI, provisioning, runtime projection, team messages, tasks, diagnostics, retries. |
|
||||||
|
| `agent_teams_orchestrator` | Multi-agent runtime orchestration, teammate spawning, provider/runtime bridging. |
|
||||||
|
| Process backend | Default for app-launched teammates after recent changes. Launch-owned processes are tracked as runtime entities. |
|
||||||
|
| Optional tmux mode | Debug/manual mode, not production default. Useful for real TTY inspection. |
|
||||||
|
| App-managed bootstrap | Backend injects/records startup context and requires durable readiness evidence instead of trusting “process exists”. |
|
||||||
|
| Runtime projection | Maps launch state, process liveness, bootstrap proof, delivery proof, task state and diagnostics to UI. |
|
||||||
|
|
||||||
|
Key properties:
|
||||||
|
|
||||||
|
| Dimension | Current behavior |
|
||||||
|
|---|---|
|
||||||
|
| Agent lifetime | Long-lived teammate process/session, not just one request. |
|
||||||
|
| Availability proof | Process alive is not enough. Need bootstrap/runtime evidence. |
|
||||||
|
| Provider mix | Claude, Codex, OpenCode can coexist in one team. |
|
||||||
|
| User experience | Live team room: cards, memory, tasks, messages, runtime errors, restart/retry controls. |
|
||||||
|
| Complexity cost | High. Many edge cases around launch, cleanup, stale state, delivery, work-sync, retries. |
|
||||||
|
|
||||||
|
Technical assessment:
|
||||||
|
|
||||||
|
| Criterion | Score |
|
||||||
|
|---|---:|
|
||||||
|
| Live team product fit | 9.2/10 |
|
||||||
|
| Mixed provider fidelity | 8.7/10 |
|
||||||
|
| Runtime proof strictness | 8.8/10 |
|
||||||
|
| Simplicity | 5.8/10 |
|
||||||
|
| Maintainability today | 7.2/10 |
|
||||||
|
| Overall technical score | 8.5/10 |
|
||||||
|
|
||||||
|
## Paperclip launch/execution model
|
||||||
|
|
||||||
|
Paperclip is closest to a bounded job/heartbeat runner.
|
||||||
|
|
||||||
|
Research summary from earlier pass:
|
||||||
|
|
||||||
|
| Piece | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Agent invocation | Heartbeat or scheduled run calls adapter execution. |
|
||||||
|
| Runtime | Adapter starts/calls CLI or provider, captures output/status/errors. |
|
||||||
|
| Lifecycle | Run exits, times out, or is cancelled. |
|
||||||
|
| Concurrency | Wakeups coalesce if agent is already running. |
|
||||||
|
| Persistence | Status/logs/tokens/errors are stored per run. |
|
||||||
|
|
||||||
|
This is operationally clean because there is no expectation that every teammate is a continuously alive process with card-level runtime state.
|
||||||
|
|
||||||
|
Technical assessment:
|
||||||
|
|
||||||
|
| Criterion | Score |
|
||||||
|
|---|---:|
|
||||||
|
| Bounded execution design | 9.1/10 |
|
||||||
|
| Simplicity | 8.8/10 |
|
||||||
|
| Failure boundedness | 8.7/10 |
|
||||||
|
| Live teammate room fit | 6.3/10 |
|
||||||
|
| External CLI fidelity | 7.5/10 |
|
||||||
|
| Overall technical score | 8.2/10 |
|
||||||
|
|
||||||
|
## Gastown launch/execution model
|
||||||
|
|
||||||
|
Gastown is tmux-first.
|
||||||
|
|
||||||
|
Facts from `gastownhall/gastown`:
|
||||||
|
|
||||||
|
| Piece | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Main runtime adapter | `tmux` sessions. |
|
||||||
|
| Universal integration | Any CLI that runs in terminal can be started and controlled. |
|
||||||
|
| Work unit | Beads/issues and convoys. |
|
||||||
|
| Worker identity | Polecats have persistent identity and reusable worktrees. |
|
||||||
|
| Session lifetime | Sessions are ephemeral; identity and sandbox can persist. |
|
||||||
|
| Communication | Mail, nudges, hooks, Beads state, tmux input/output. |
|
||||||
|
| Monitoring | Witness, Deacon, Dogs, Doctor, cleanup commands. |
|
||||||
|
| Provider integration | Built-in/custom presets with command, args, env, process names, hooks, readiness delay/prompt. |
|
||||||
|
|
||||||
|
Gastown explicitly documents a Tier 0 tmux shim: start CLI in tmux, send work through keystrokes, detect liveness through pane process, read output through captured pane. It also notes that this level is timing-sensitive and lacks delivery confirmation.
|
||||||
|
|
||||||
|
Core model:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gt sling <bead> <rig>
|
||||||
|
-> allocate or reuse polecat identity/worktree
|
||||||
|
-> create tmux session
|
||||||
|
-> set env: GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GT_AGENT, etc.
|
||||||
|
-> inject startup beacon / prompt / hook context
|
||||||
|
-> nudge with instructions if provider needs fallback
|
||||||
|
-> Witness/Deacon patrol health and cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Technical assessment:
|
||||||
|
|
||||||
|
| Criterion | Score |
|
||||||
|
|---|---:|
|
||||||
|
| Terminal-native ops | 9.0/10 |
|
||||||
|
| Persistent worker identity | 8.7/10 |
|
||||||
|
| Cleanup / doctor culture | 8.8/10 |
|
||||||
|
| Delivery proof strictness | 6.4/10 |
|
||||||
|
| Live product state consistency | 6.8/10 |
|
||||||
|
| Overall technical score | 8.0/10 |
|
||||||
|
|
||||||
|
## GoClaw Enterprise / Teams launch/execution model
|
||||||
|
|
||||||
|
This is `nextlevelbuilder/goclaw`, the relevant GoClaw for agent teams.
|
||||||
|
|
||||||
|
Core architecture from docs/code:
|
||||||
|
|
||||||
|
| Piece | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Agent unit | `Loop` configured with provider, model, tools, workspace and agent type. |
|
||||||
|
| Run entrypoint | `Loop.Run(ctx, RunRequest)`. |
|
||||||
|
| Loop pattern | Think -> Act -> Observe, with max iterations and tool execution. |
|
||||||
|
| Scheduler | First-class lane scheduler. |
|
||||||
|
| Lanes | `main`, `subagent`, `team`, `cron`. |
|
||||||
|
| Queueing | Per-session queues with debounce, drop policy, max concurrent. |
|
||||||
|
| Team model | Lead/member, task board, mailbox, delegation. |
|
||||||
|
| Task semantics | Atomic claim, status lifecycle, dependencies, blocker escalation, task events. |
|
||||||
|
| Events | Typed WS events for delegation, tasks, team messages and agent lifecycle. |
|
||||||
|
|
||||||
|
Core execution shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Inbound message / teammate message / cron / delegation
|
||||||
|
-> Scheduler.Schedule(lane, RunRequest)
|
||||||
|
-> SessionQueue serializes or bounds per session
|
||||||
|
-> Lane worker admits execution
|
||||||
|
-> Router.Get(agentID)
|
||||||
|
-> Loop.Run(ctx, req)
|
||||||
|
-> Provider call + tools + finalization
|
||||||
|
-> Events + stored session/task/trace state
|
||||||
|
```
|
||||||
|
|
||||||
|
GoClaw team member execution is conceptually a scheduled agent run, not an externally spawned teammate CLI process with bootstrap/check-in.
|
||||||
|
|
||||||
|
Technical assessment:
|
||||||
|
|
||||||
|
| Criterion | Score |
|
||||||
|
|---|---:|
|
||||||
|
| Scheduler architecture | 9.2/10 |
|
||||||
|
| Agent loop clarity | 8.9/10 |
|
||||||
|
| Team task model | 8.8/10 |
|
||||||
|
| Typed event model | 8.8/10 |
|
||||||
|
| Real external teammate runtime fidelity | 6.6/10 |
|
||||||
|
| Live process UI fit | 6.5/10 |
|
||||||
|
| Overall technical score | 8.7/10 |
|
||||||
|
|
||||||
|
## GoClaw OpenClaw-compatible gateway model
|
||||||
|
|
||||||
|
This is `roelfdiedericks/goclaw`. It is a different project than `nextlevelbuilder/goclaw`.
|
||||||
|
|
||||||
|
High-level facts:
|
||||||
|
|
||||||
|
| Piece | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Product class | Personal AI gateway / OpenClaw-compatible bot runtime. |
|
||||||
|
| Main strengths | Transcript search, memory graph, channels, persistent memory, delegated runs, ACP sessions. |
|
||||||
|
| Delegated work | `subagent_spawn`, `subagent_fanout`, `subagent_status`, `subagent_cancel`. |
|
||||||
|
| Runner | `DefaultRunner` starts active runs as goroutines with run IDs, timeout/cancel, optional concurrency lane semaphore. |
|
||||||
|
| UI/control | `/runners` dashboard, SSE events, Telegram/TUI summaries. |
|
||||||
|
| Cursor integration | ACP attachment to live Cursor session. |
|
||||||
|
|
||||||
|
Runner shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subagent_spawn / fanout
|
||||||
|
-> DefaultRunner.Start(ctx, RunSpec)
|
||||||
|
-> create RunRecord queued
|
||||||
|
-> goroutine waits for lane admission
|
||||||
|
-> execute function runs child work
|
||||||
|
-> registry records completed/failed/canceled/timeout
|
||||||
|
-> events emitted
|
||||||
|
```
|
||||||
|
|
||||||
|
This is closer to Paperclip-style delegated bounded runs than to our live teammate process model.
|
||||||
|
|
||||||
|
Technical assessment:
|
||||||
|
|
||||||
|
| Criterion | Score |
|
||||||
|
|---|---:|
|
||||||
|
| Personal gateway/memory architecture | 8.8/10 |
|
||||||
|
| Delegated run boundedness | 8.5/10 |
|
||||||
|
| Channel/memory richness | 9.0/10 |
|
||||||
|
| Live external teammate fidelity | 5.8/10 |
|
||||||
|
| Team room fit | 6.4/10 |
|
||||||
|
| Overall technical score | 8.1/10 |
|
||||||
|
|
||||||
|
## Direct comparison table
|
||||||
|
|
||||||
|
| System | Launch/execution primitive | Separate OS process per agent? | Long-lived teammate? | Task board | Team messages | Scheduler | Tmux | Best fit |
|
||||||
|
|---|---|---:|---:|---:|---:|---:|---:|---|
|
||||||
|
| Our Agent Teams | Launch-owned external CLI/process runtime | Yes | Yes | Yes | Yes | Partial/ad-hoc today | Optional/debug | Desktop live mixed-provider team room |
|
||||||
|
| Paperclip | Bounded adapter heartbeat run | Usually per run | No | Limited/not central | Not team-room focused | Yes, job-like | No core tmux | Reliable background/job agents |
|
||||||
|
| Gastown | Tmux session + worktree + Beads | Yes, through tmux | Session ephemeral, identity persistent | Beads/convoys | Mail/nudges | Scheduler/capacity exists | Core | Terminal-native multi-agent ops |
|
||||||
|
| GoClaw Enterprise | In-process scheduled agent loop | Not by default | Logical sessions/runs | Yes | Yes | First-class lanes | No core tmux | Multi-agent gateway/platform |
|
||||||
|
| GoClaw OpenClaw-compatible | Delegated goroutine runner + gateway sessions | Not by default | Logical runs/sessions | Not primary team board in same way | Channels | Runner lane semaphore | No core tmux | Personal gateway, memory, delegated runs |
|
||||||
|
|
||||||
|
## Honest overall scores
|
||||||
|
|
||||||
|
| System | Overall technical score | Why |
|
||||||
|
|---|---:|---|
|
||||||
|
| GoClaw Enterprise / Teams | 8.7/10 | Cleanest scheduler/team/task/event architecture among compared systems. |
|
||||||
|
| Our Agent Teams | 8.5/10 | Best fit for real live external Claude/Codex/OpenCode teammate product, but high complexity. |
|
||||||
|
| Paperclip | 8.2/10 | Very clean bounded runtime model, but not a live team-room system. |
|
||||||
|
| GoClaw OpenClaw-compatible | 8.1/10 | Strong personal gateway/memory/delegated run model, less comparable to our team runtime. |
|
||||||
|
| Gastown | 8.0/10 | Strong terminal ops and lifecycle culture, but tmux-first delivery/readiness is less proof-strict. |
|
||||||
|
|
||||||
|
## Research conclusions
|
||||||
|
|
||||||
|
The systems optimize for different truths:
|
||||||
|
|
||||||
|
| System | Optimized for |
|
||||||
|
|---|---|
|
||||||
|
| Our Agent Teams | User-visible live team of real external coding agents. |
|
||||||
|
| Paperclip | Bounded, simple, resumable background agent runs. |
|
||||||
|
| Gastown | Terminal-native agent ops at scale with durable work identity. |
|
||||||
|
| GoClaw Enterprise | Clean gateway-native multi-agent scheduling and team task orchestration. |
|
||||||
|
| GoClaw OpenClaw-compatible | Long-memory personal agent gateway with delegated subruns. |
|
||||||
|
|
||||||
|
Most useful conceptual takeaways for future reference:
|
||||||
|
|
||||||
|
| Idea | Source | Why it matters |
|
||||||
|
|---|---|---|
|
||||||
|
| First-class scheduler lanes | GoClaw Enterprise | Separates main/team/subagent/cron load and makes cancellation/backpressure more deterministic. |
|
||||||
|
| Typed team event catalog | GoClaw Enterprise | Makes UI and state transitions easier to reason about. |
|
||||||
|
| Persistent identity vs ephemeral session | Gastown | Useful framing for member identity, runtime session, task ownership and cleanup. |
|
||||||
|
| Bounded adapter runs | Paperclip | Good model for cron, background checks and non-live workers. |
|
||||||
|
| Patrol/doctor cleanup culture | Gastown | Good operational model for stale runtime/process/data cleanup. |
|
||||||
|
|
||||||
|
Non-recommendation note: this document intentionally does not propose changing our architecture. It records observed models for future design discussions.
|
||||||
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Paperclip agent launch research
|
||||||
|
|
||||||
|
Research date: 2026-05-07
|
||||||
|
|
||||||
|
This note records factual findings only. It is not an implementation plan and does not make recommendations.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Compared Paperclip agent launch/runtime behavior with the local Agent Teams orchestrator.
|
||||||
|
|
||||||
|
Local orchestrator inspected:
|
||||||
|
|
||||||
|
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
|
||||||
|
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
|
||||||
|
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/processBackend.ts`
|
||||||
|
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
|
||||||
|
|
||||||
|
Paperclip inspected from GitHub and a shallow local clone at `/tmp/paperclip-inspect`.
|
||||||
|
|
||||||
|
## Paperclip runtime model
|
||||||
|
|
||||||
|
Paperclip agents do not run continuously. They run in heartbeat windows triggered by wakeups such as timer, assignment, on-demand, or automation.
|
||||||
|
|
||||||
|
Each heartbeat starts an adapter, gives it prompt/context, lets it run until exit, timeout, or cancellation, stores run status/tokens/errors/logs, and updates UI.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/docs/agents-runtime.md
|
||||||
|
|
||||||
|
## Paperclip process execution
|
||||||
|
|
||||||
|
Paperclip uses child processes for local adapters. Normal execution is not tmux-based.
|
||||||
|
|
||||||
|
The shared process runner uses `node:child_process.spawn`, streams stdout/stderr, records pid and process group information, supports timeout, sends graceful termination, and escalates to kill after a grace period.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapter-utils/src/server-utils.ts
|
||||||
|
|
||||||
|
The generic process adapter is documented as executing arbitrary shell commands as child processes with env injection and exit-code based success/failure.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/docs/adapters/process.md
|
||||||
|
|
||||||
|
## Paperclip adapter examples
|
||||||
|
|
||||||
|
Claude local adapter:
|
||||||
|
|
||||||
|
- Uses `claude --print - --output-format stream-json --verbose`.
|
||||||
|
- Supports session resume with `--resume`.
|
||||||
|
- Supports model/effort/max-turns/append-system-prompt/add-dir style options.
|
||||||
|
- Parses stream JSON for terminal result, session id, and usage.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/claude-local/src/server/execute.ts
|
||||||
|
|
||||||
|
Codex local adapter:
|
||||||
|
|
||||||
|
- Uses `codex exec --json ... -`.
|
||||||
|
- Supports session continuation through `resume`.
|
||||||
|
- Manages `CODEX_HOME`, injected skills/config/auth context, and fresh-session fallback paths.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/codex-local/src/server/execute.ts
|
||||||
|
|
||||||
|
OpenCode local adapter:
|
||||||
|
|
||||||
|
- Uses `opencode run --format json`.
|
||||||
|
- Supports `--session`, `--model`, and `--variant`.
|
||||||
|
- Uses temp config and model/session validation paths.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/opencode-local/src/server/execute.ts
|
||||||
|
|
||||||
|
## Paperclip orchestration/lifecycle
|
||||||
|
|
||||||
|
Paperclip stores heartbeat runs and events, updates agent runtime state, publishes live events, stores run logs, supports cancellation by pid/process group, and has recovery paths for lost/orphaned running runs.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/heartbeat.ts
|
||||||
|
|
||||||
|
It has a per-agent start lock so concurrent starts for the same agent are coalesced or blocked.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/agent-start-lock.ts
|
||||||
|
|
||||||
|
It also has run liveness classification/recovery paths for cases like empty or low-signal runs.
|
||||||
|
|
||||||
|
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/run-liveness.ts
|
||||||
|
|
||||||
|
## Comparison facts
|
||||||
|
|
||||||
|
Paperclip is organized around short, resumable heartbeat runs. It waits for CLI run completion and records result/logs/state.
|
||||||
|
|
||||||
|
Agent Teams is organized around live team members, mixed providers, direct messages, tasks, work-sync, runtime evidence, and durable bootstrap/check-in proof.
|
||||||
|
|
||||||
|
Paperclip does not need the same live teammate readiness model because it does not maintain a long-running team room with continuously addressable members.
|
||||||
|
|
||||||
|
Agent Teams still supports tmux/pane backends in the orchestrator, but current app-launched teammates can use process backend with app-managed runtime evidence.
|
||||||
|
|
||||||
|
Paperclip's process lifecycle primitives are more centralized. Agent Teams has more live multi-agent protocol surface and therefore more runtime states to reconcile.
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||||
|
const COMPACT_ROW_TITLE_LIMIT = 24;
|
||||||
const COMPACT_ROW_TEXT_LIMIT = 76;
|
const COMPACT_ROW_TEXT_LIMIT = 76;
|
||||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
||||||
|
|
||||||
|
|
@ -63,7 +64,7 @@ function normalizeMemberName(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRenderedItemKey(memberName: string, itemId: string): string {
|
function buildRenderedItemKey(memberName: string, itemId: string): string {
|
||||||
return `${normalizeMemberName(memberName)}:${itemId}`;
|
return JSON.stringify([normalizeMemberName(memberName), itemId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: string): string {
|
function formatRelativeTime(timestamp: string): string {
|
||||||
|
|
@ -120,8 +121,21 @@ function resolveEmptyText(
|
||||||
return 'No recent logs';
|
return 'No recent logs';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackDisplayTitle(item: MemberLogPreviewItem): string {
|
||||||
|
if (item.kind === 'tool_result') {
|
||||||
|
return item.tone === 'error' ? 'Tool error' : 'Tool result';
|
||||||
|
}
|
||||||
|
if (item.kind === 'tool_use') {
|
||||||
|
return item.toolName?.trim() || 'Tool use';
|
||||||
|
}
|
||||||
|
if (item.kind === 'thinking') {
|
||||||
|
return 'Thinking';
|
||||||
|
}
|
||||||
|
return item.tone === 'error' ? 'Error' : 'Log event';
|
||||||
|
}
|
||||||
|
|
||||||
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
||||||
const title = item.title.trim();
|
const title = item.title.trim() || fallbackDisplayTitle(item);
|
||||||
if (title.toLowerCase() === 'tool result') {
|
if (title.toLowerCase() === 'tool result') {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
@ -131,6 +145,14 @@ function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateCompactTitle(value: string): string {
|
||||||
|
const compact = value.replace(/\s+/g, ' ').trim();
|
||||||
|
if (compact.length <= COMPACT_ROW_TITLE_LIMIT) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
return `${compact.slice(0, COMPACT_ROW_TITLE_LIMIT - 3).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
||||||
const normalizedPreview = preview.toLowerCase();
|
const normalizedPreview = preview.toLowerCase();
|
||||||
const normalizedTitle = title.toLowerCase();
|
const normalizedTitle = title.toLowerCase();
|
||||||
|
|
@ -146,12 +168,16 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
|
function compactPreviewText(
|
||||||
|
item: MemberLogPreviewItem,
|
||||||
|
displayTitle: string,
|
||||||
|
rawDisplayTitle = displayTitle
|
||||||
|
): string {
|
||||||
const preview = item.preview?.trim();
|
const preview = item.preview?.trim();
|
||||||
if (preview) {
|
if (preview) {
|
||||||
const rawTitle = item.title.trim();
|
const rawTitle = item.title.trim();
|
||||||
const compact = trimRepeatedTitlePrefix(
|
const compact = trimRepeatedTitlePrefix(
|
||||||
trimRepeatedTitlePrefix(preview, rawTitle),
|
trimRepeatedTitlePrefix(trimRepeatedTitlePrefix(preview, rawTitle), rawDisplayTitle),
|
||||||
displayTitle
|
displayTitle
|
||||||
);
|
);
|
||||||
return compact || preview;
|
return compact || preview;
|
||||||
|
|
@ -177,6 +203,13 @@ function truncateCompactRowPreview(
|
||||||
return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`;
|
return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactRowLabel(parts: readonly (string | null | undefined)[]): string {
|
||||||
|
return parts
|
||||||
|
.map((part) => part?.trim())
|
||||||
|
.filter((part): part is string => Boolean(part))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function setShellHidden(shell: HTMLDivElement): void {
|
function setShellHidden(shell: HTMLDivElement): void {
|
||||||
shell.style.opacity = '0';
|
shell.style.opacity = '0';
|
||||||
shell.style.pointerEvents = 'none';
|
shell.style.pointerEvents = 'none';
|
||||||
|
|
@ -426,12 +459,11 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(memberName: string, item: MemberLogPreviewItem) => {
|
(memberName: string, item: MemberLogPreviewItem) => {
|
||||||
const relativeTime = formatRelativeTime(item.timestamp);
|
const relativeTime = formatRelativeTime(item.timestamp);
|
||||||
const displayTitle = compactDisplayTitle(item);
|
const rawDisplayTitle = compactDisplayTitle(item);
|
||||||
const fullPreviewText = compactPreviewText(item, displayTitle);
|
const displayTitle = truncateCompactTitle(rawDisplayTitle);
|
||||||
|
const fullPreviewText = compactPreviewText(item, displayTitle, rawDisplayTitle);
|
||||||
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
|
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
|
||||||
const titleText = relativeTime
|
const titleText = compactRowLabel([rawDisplayTitle, relativeTime, fullPreviewText]);
|
||||||
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
|
||||||
: `${displayTitle} ${fullPreviewText}`;
|
|
||||||
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
|
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
|
||||||
const isError = item.tone === 'error';
|
const isError = item.tone === 'error';
|
||||||
const rowStateClassName = isHighlighted
|
const rowStateClassName = isHighlighted
|
||||||
|
|
@ -442,28 +474,29 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
||||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
||||||
const iconClassName = isError
|
const iconClassName = isError
|
||||||
? 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||||
: 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
: 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||||
const headerClassName = 'inline align-baseline';
|
const headerClassName = 'inline align-baseline';
|
||||||
const titleClassName = isError
|
const titleClassName = isError
|
||||||
? 'align-baseline text-[11px] font-medium leading-[18px] text-rose-100'
|
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100'
|
||||||
: 'align-baseline text-[11px] font-medium leading-[18px] text-slate-200';
|
: 'align-baseline text-[11px] font-medium leading-5 text-slate-200';
|
||||||
const timeClassName = isError
|
const timeClassName = isError
|
||||||
? 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-rose-300/70'
|
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70'
|
||||||
: 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-slate-500';
|
: 'ml-1 align-baseline text-[9px] font-normal leading-5 text-slate-500';
|
||||||
const previewClassName = isError
|
const previewClassName = isError
|
||||||
? 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-rose-100/85'
|
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85'
|
||||||
: 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-slate-300/85';
|
: 'ml-1 break-words align-baseline text-[10px] leading-5 text-slate-300/85';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
'block h-[68px] min-h-[68px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
|
'block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
|
||||||
rowStateClassName,
|
rowStateClassName,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
title={titleText}
|
title={titleText}
|
||||||
|
aria-label={titleText}
|
||||||
onClick={() => openLogs(memberName)}
|
onClick={() => openLogs(memberName)}
|
||||||
>
|
>
|
||||||
<span className={iconClassName} aria-hidden="true">
|
<span className={iconClassName} aria-hidden="true">
|
||||||
|
|
@ -517,7 +550,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
<div className="mb-1 flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
<div className="flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||||
<Wrench className="size-3 text-slate-500" />
|
<Wrench className="size-3 text-slate-500" />
|
||||||
Logs
|
Logs
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -527,7 +560,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-[68px] min-h-[68px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||||
onClick={() => openLogs(memberName)}
|
onClick={() => openLogs(memberName)}
|
||||||
>
|
>
|
||||||
{resolveEmptyText(preview, loading, error)}
|
{resolveEmptyText(preview, loading, error)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { CodexAccountSnapshotDto } from './dto';
|
import type { CodexAccountSnapshotDto, CodexChatgptLoginMode } from './dto';
|
||||||
|
|
||||||
|
export interface CodexStartChatgptLoginOptions {
|
||||||
|
mode?: CodexChatgptLoginMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodexAccountElectronApi {
|
export interface CodexAccountElectronApi {
|
||||||
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
|
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
|
||||||
|
|
@ -6,7 +10,9 @@ export interface CodexAccountElectronApi {
|
||||||
includeRateLimits?: boolean;
|
includeRateLimits?: boolean;
|
||||||
forceRefreshToken?: boolean;
|
forceRefreshToken?: boolean;
|
||||||
}) => Promise<CodexAccountSnapshotDto>;
|
}) => Promise<CodexAccountSnapshotDto>;
|
||||||
startCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
startCodexChatgptLogin: (
|
||||||
|
options?: CodexStartChatgptLoginOptions
|
||||||
|
) => Promise<CodexAccountSnapshotDto>;
|
||||||
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
||||||
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
|
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
|
||||||
onCodexAccountSnapshotChanged: (
|
onCodexAccountSnapshotChanged: (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export type CodexAccountAppServerState =
|
||||||
| 'runtime-missing'
|
| 'runtime-missing'
|
||||||
| 'incompatible';
|
| 'incompatible';
|
||||||
export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled';
|
export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled';
|
||||||
|
export type CodexChatgptLoginMode = 'browser' | 'device_code';
|
||||||
export type CodexLaunchReadinessState =
|
export type CodexLaunchReadinessState =
|
||||||
| 'ready_chatgpt'
|
| 'ready_chatgpt'
|
||||||
| 'ready_api_key'
|
| 'ready_api_key'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CODEX_ACCOUNT_LOGOUT,
|
CODEX_ACCOUNT_LOGOUT,
|
||||||
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
|
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
|
||||||
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
|
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
|
||||||
|
type CodexStartChatgptLoginOptions,
|
||||||
} from '@features/codex-account/contracts';
|
} from '@features/codex-account/contracts';
|
||||||
|
|
||||||
import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature';
|
import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature';
|
||||||
|
|
@ -19,7 +20,10 @@ export function registerCodexAccountIpc(
|
||||||
(_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) =>
|
(_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) =>
|
||||||
feature.refreshSnapshot(options)
|
feature.refreshSnapshot(options)
|
||||||
);
|
);
|
||||||
ipcMain.handle(CODEX_ACCOUNT_START_CHATGPT_LOGIN, () => feature.startChatgptLogin());
|
ipcMain.handle(
|
||||||
|
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
|
||||||
|
(_event, options?: CodexStartChatgptLoginOptions) => feature.startChatgptLogin(options)
|
||||||
|
);
|
||||||
ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin());
|
ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin());
|
||||||
ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout());
|
ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
type CodexAccountAuthMode,
|
type CodexAccountAuthMode,
|
||||||
type CodexAccountSnapshotDto,
|
type CodexAccountSnapshotDto,
|
||||||
type CodexApiKeyAvailabilityDto,
|
type CodexApiKeyAvailabilityDto,
|
||||||
|
type CodexChatgptLoginMode,
|
||||||
type CodexCreditsSnapshotDto,
|
type CodexCreditsSnapshotDto,
|
||||||
type CodexLoginStateDto,
|
type CodexLoginStateDto,
|
||||||
type CodexManagedAccountDto,
|
type CodexManagedAccountDto,
|
||||||
|
|
@ -234,7 +235,7 @@ export interface CodexAccountFeatureFacade {
|
||||||
includeRateLimits?: boolean;
|
includeRateLimits?: boolean;
|
||||||
forceRefreshToken?: boolean;
|
forceRefreshToken?: boolean;
|
||||||
}): Promise<CodexAccountSnapshotDto>;
|
}): Promise<CodexAccountSnapshotDto>;
|
||||||
startChatgptLogin(): Promise<CodexAccountSnapshotDto>;
|
startChatgptLogin(options?: { mode?: CodexChatgptLoginMode }): Promise<CodexAccountSnapshotDto>;
|
||||||
cancelLogin(): Promise<CodexAccountSnapshotDto>;
|
cancelLogin(): Promise<CodexAccountSnapshotDto>;
|
||||||
logout(): Promise<CodexAccountSnapshotDto>;
|
logout(): Promise<CodexAccountSnapshotDto>;
|
||||||
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
|
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
|
||||||
|
|
@ -323,7 +324,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
return this.refreshPromise;
|
return this.refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startChatgptLogin(): Promise<CodexAccountSnapshotDto> {
|
async startChatgptLogin(options?: {
|
||||||
|
mode?: CodexChatgptLoginMode;
|
||||||
|
}): Promise<CodexAccountSnapshotDto> {
|
||||||
let binaryMissing = false;
|
let binaryMissing = false;
|
||||||
await this.runSerializedMutation(async () => {
|
await this.runSerializedMutation(async () => {
|
||||||
const binaryPath = await CodexBinaryResolver.resolve();
|
const binaryPath = await CodexBinaryResolver.resolve();
|
||||||
|
|
@ -333,7 +336,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
|
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
|
||||||
await this.loginSessionManager.start({ binaryPath, env });
|
await this.loginSessionManager.start({ binaryPath, env, mode: options?.mode });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (binaryMissing) {
|
if (binaryMissing) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
type CodexAppServerSession,
|
type CodexAppServerSession,
|
||||||
} from '@main/services/infrastructure/codexAppServer';
|
} from '@main/services/infrastructure/codexAppServer';
|
||||||
|
|
||||||
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
|
import type { CodexChatgptLoginMode, CodexLoginStateDto } from '@features/codex-account/contracts';
|
||||||
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
||||||
|
|
||||||
const LOGIN_REQUEST_TIMEOUT_MS = 5_000;
|
const LOGIN_REQUEST_TIMEOUT_MS = 5_000;
|
||||||
|
|
@ -59,7 +59,11 @@ export class CodexLoginSessionManager {
|
||||||
return structuredClone(this.state);
|
return structuredClone(this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(options: { binaryPath: string; env: NodeJS.ProcessEnv }): Promise<void> {
|
async start(options: {
|
||||||
|
binaryPath: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
mode?: CodexChatgptLoginMode;
|
||||||
|
}): Promise<void> {
|
||||||
if (this.activeSession || this.pendingStartToken) {
|
if (this.activeSession || this.pendingStartToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -89,9 +93,11 @@ export class CodexLoginSessionManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestedResponseType =
|
||||||
|
options.mode === 'device_code' ? 'chatgptDeviceCode' : 'chatgpt';
|
||||||
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
||||||
'account/login/start',
|
'account/login/start',
|
||||||
{ type: 'chatgptDeviceCode' },
|
{ type: requestedResponseType },
|
||||||
LOGIN_REQUEST_TIMEOUT_MS
|
LOGIN_REQUEST_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -100,16 +106,19 @@ export class CodexLoginSessionManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.type !== 'chatgptDeviceCode') {
|
if (response.type !== requestedResponseType) {
|
||||||
throw new Error('Codex app-server returned an unexpected login response type');
|
throw new Error('Codex app-server returned an unexpected login response type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authUrl = new URL(response.verificationUrl);
|
const authUrl = new URL(
|
||||||
|
response.type === 'chatgptDeviceCode' ? response.verificationUrl : response.authUrl
|
||||||
|
);
|
||||||
if (authUrl.protocol !== 'https:') {
|
if (authUrl.protocol !== 'https:') {
|
||||||
throw new Error('Codex app-server returned a non-https auth URL');
|
throw new Error('Codex app-server returned a non-https auth URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.userCode.trim()) {
|
const userCode = response.type === 'chatgptDeviceCode' ? response.userCode.trim() : null;
|
||||||
|
if (response.type === 'chatgptDeviceCode' && !userCode) {
|
||||||
throw new Error('Codex app-server returned an empty ChatGPT login code');
|
throw new Error('Codex app-server returned an empty ChatGPT login code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +152,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: authUrl.toString(),
|
authUrl: authUrl.toString(),
|
||||||
userCode: response.userCode,
|
userCode,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wasAbandonedDuringStart =
|
const wasAbandonedDuringStart =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CodexLoginSessionManager } from '../CodexLoginSessionManager';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CodexAppServerLoginAccountResponse,
|
||||||
|
CodexAppServerSession,
|
||||||
|
CodexAppServerSessionFactory,
|
||||||
|
} from '@main/services/infrastructure/codexAppServer';
|
||||||
|
|
||||||
|
function createSessionManagerHarness(loginResponse: CodexAppServerLoginAccountResponse): {
|
||||||
|
manager: CodexLoginSessionManager;
|
||||||
|
session: CodexAppServerSession;
|
||||||
|
request: ReturnType<typeof vi.fn>;
|
||||||
|
emitNotification: (method: string, params: unknown) => void;
|
||||||
|
} {
|
||||||
|
let notificationListener: ((method: string, params: unknown) => void) | null = null;
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'account/login/start') {
|
||||||
|
return loginResponse;
|
||||||
|
}
|
||||||
|
if (method === 'account/login/cancel') {
|
||||||
|
return { status: 'canceled' };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
initializeResponse: {
|
||||||
|
userAgent: 'codex-cli 0.125.0',
|
||||||
|
codexHome: '/Users/me/.codex',
|
||||||
|
platformFamily: 'macos',
|
||||||
|
platformOs: 'darwin',
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
notify: async () => undefined,
|
||||||
|
onNotification: (listener: (method: string, params: unknown) => void) => {
|
||||||
|
notificationListener = listener;
|
||||||
|
return () => {
|
||||||
|
notificationListener = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
} as unknown as CodexAppServerSession;
|
||||||
|
|
||||||
|
const factory = {
|
||||||
|
openSession: vi.fn(async () => session),
|
||||||
|
} as unknown as CodexAppServerSessionFactory;
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager: new CodexLoginSessionManager(factory, { warn: () => undefined }),
|
||||||
|
session,
|
||||||
|
request,
|
||||||
|
emitNotification: (method, params) => {
|
||||||
|
notificationListener?.(method, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexLoginSessionManager', () => {
|
||||||
|
it('uses the documented ChatGPT browser flow by default', async () => {
|
||||||
|
const { manager, request } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
'account/login/start',
|
||||||
|
{ type: 'chatgpt' },
|
||||||
|
expect.any(Number)
|
||||||
|
);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
userCode: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the documented device-code flow only when explicitly requested', async () => {
|
||||||
|
const { manager, request } = createSessionManagerHarness({
|
||||||
|
type: 'chatgptDeviceCode',
|
||||||
|
loginId: 'device-login',
|
||||||
|
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
mode: 'device_code',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
'account/login/start',
|
||||||
|
{ type: 'chatgptDeviceCode' },
|
||||||
|
expect.any(Number)
|
||||||
|
);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
authUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-https browser auth URL', async () => {
|
||||||
|
const { manager } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl: 'http://chatgpt.com/auth',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
|
||||||
|
'non-https auth URL'
|
||||||
|
);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a device-code response for the default browser flow', async () => {
|
||||||
|
const { manager } = createSessionManagerHarness({
|
||||||
|
type: 'chatgptDeviceCode',
|
||||||
|
loginId: 'device-login',
|
||||||
|
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
|
||||||
|
'unexpected login response type'
|
||||||
|
);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a browser response for the explicit device-code flow', async () => {
|
||||||
|
const { manager } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
mode: 'device_code',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('unexpected login response type');
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty device-code user code', async () => {
|
||||||
|
const { manager } = createSessionManagerHarness({
|
||||||
|
type: 'chatgptDeviceCode',
|
||||||
|
loginId: 'device-login',
|
||||||
|
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: ' ',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
mode: 'device_code',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('empty ChatGPT login code');
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the active login pending when an unrelated completion notification arrives', async () => {
|
||||||
|
const { manager, emitNotification } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||||
|
emitNotification('account/login/completed', {
|
||||||
|
loginId: 'other-login',
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears login state when the matching browser login completes successfully', async () => {
|
||||||
|
const { manager, emitNotification, session } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||||
|
emitNotification('account/login/completed', {
|
||||||
|
loginId: 'browser-login',
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(session.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'idle',
|
||||||
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps device-code details visible when the matching login fails', async () => {
|
||||||
|
const { manager, emitNotification } = createSessionManagerHarness({
|
||||||
|
type: 'chatgptDeviceCode',
|
||||||
|
loginId: 'device-login',
|
||||||
|
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
mode: 'device_code',
|
||||||
|
});
|
||||||
|
emitNotification('account/login/completed', {
|
||||||
|
loginId: 'device-login',
|
||||||
|
success: false,
|
||||||
|
error: 'Login was not completed',
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Login was not completed',
|
||||||
|
authUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an active browser login through app-server and clears copied link state', async () => {
|
||||||
|
const { manager, request, session } = createSessionManagerHarness({
|
||||||
|
type: 'chatgpt',
|
||||||
|
loginId: 'browser-login',
|
||||||
|
authUrl:
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||||
|
await manager.cancel();
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
'account/login/cancel',
|
||||||
|
{ loginId: 'browser-login' },
|
||||||
|
expect.any(Number)
|
||||||
|
);
|
||||||
|
expect(session.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getState()).toMatchObject({
|
||||||
|
status: 'cancelled',
|
||||||
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,8 @@ export function createCodexAccountBridge({
|
||||||
getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT),
|
getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT),
|
||||||
refreshCodexAccountSnapshot: (options) =>
|
refreshCodexAccountSnapshot: (options) =>
|
||||||
ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options),
|
ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options),
|
||||||
startCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN),
|
startCodexChatgptLogin: (options) =>
|
||||||
|
ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN, options),
|
||||||
cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN),
|
cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN),
|
||||||
logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT),
|
logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT),
|
||||||
onCodexAccountSnapshotChanged: (callback) => {
|
onCodexAccountSnapshotChanged: (callback) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
|
|
||||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
import type {
|
||||||
|
CodexAccountSnapshotDto,
|
||||||
|
CodexChatgptLoginMode,
|
||||||
|
} from '@features/codex-account/contracts';
|
||||||
|
|
||||||
const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
||||||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||||
|
|
@ -47,7 +50,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
forceRefreshToken?: boolean;
|
forceRefreshToken?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
startChatgptLogin: () => Promise<boolean>;
|
startChatgptLogin: (mode?: CodexChatgptLoginMode) => Promise<boolean>;
|
||||||
cancelChatgptLogin: () => Promise<boolean>;
|
cancelChatgptLogin: () => Promise<boolean>;
|
||||||
logout: () => Promise<boolean>;
|
logout: () => Promise<boolean>;
|
||||||
} {
|
} {
|
||||||
|
|
@ -223,7 +226,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refresh,
|
refresh,
|
||||||
startChatgptLogin: () => runAction(() => api.startCodexChatgptLogin()),
|
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
|
||||||
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
||||||
logout: () => runAction(() => api.logoutCodexAccount()),
|
logout: () => runAction(() => api.logoutCodexAccount()),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberLogPreviewSource,
|
MemberLogPreviewSource,
|
||||||
MemberLogPreviewSourceInput,
|
MemberLogPreviewSourceInput,
|
||||||
|
|
@ -18,9 +16,7 @@ export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSour
|
||||||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
const isCodexMember =
|
const isCodexMember =
|
||||||
member?.providerId === 'codex' ||
|
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
|
||||||
member?.providerBackendId === 'codex-native' ||
|
|
||||||
(member ? false : isLeadMember({ name: input.memberName }));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberLogStreamSource,
|
MemberLogStreamSource,
|
||||||
MemberLogStreamSourceInput,
|
MemberLogStreamSourceInput,
|
||||||
|
|
@ -18,9 +16,7 @@ export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource
|
||||||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
const isCodexMember =
|
const isCodexMember =
|
||||||
member?.providerId === 'codex' ||
|
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
|
||||||
member?.providerBackendId === 'codex-native' ||
|
|
||||||
(member ? false : isLeadMember({ name: input.memberName }));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,9 @@ describe('CodexNativeMemberTraceStreamSource', () => {
|
||||||
members: [{ name: 'alice', providerId: 'opencode' }],
|
members: [{ name: 'alice', providerId: 'opencode' }],
|
||||||
}),
|
}),
|
||||||
} as never);
|
} as never);
|
||||||
|
const unknownLeadSource = new CodexNativeMemberTraceStreamSource({
|
||||||
|
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||||
|
} as never);
|
||||||
|
|
||||||
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
|
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
|
|
@ -420,22 +423,39 @@ describe('CodexNativeMemberTraceStreamSource', () => {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
warnings: [],
|
warnings: [],
|
||||||
});
|
});
|
||||||
|
await expect(
|
||||||
|
unknownLeadSource.load(sourceInput({ memberName: 'team-lead' }))
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: 'skipped',
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CodexNativeMemberTracePreviewSource', () => {
|
describe('CodexNativeMemberTracePreviewSource', () => {
|
||||||
it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => {
|
it('returns unsupported empty coverage for known Codex previews without breaking the batch', async () => {
|
||||||
const source = new CodexNativeMemberTracePreviewSource({
|
const codexSource = new CodexNativeMemberTracePreviewSource({
|
||||||
getConfig: vi.fn().mockResolvedValue({
|
getConfig: vi.fn().mockResolvedValue({
|
||||||
members: [{ name: 'alice', providerId: 'codex' }],
|
members: [{ name: 'alice', providerId: 'codex' }],
|
||||||
}),
|
}),
|
||||||
} as never);
|
} as never);
|
||||||
|
const unknownLeadSource = new CodexNativeMemberTracePreviewSource({
|
||||||
|
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||||
|
} as never);
|
||||||
|
|
||||||
await expect(source.loadPreview(previewInput())).resolves.toMatchObject({
|
await expect(codexSource.loadPreview(previewInput())).resolves.toMatchObject({
|
||||||
provider: 'codex_native_trace',
|
provider: 'codex_native_trace',
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
items: [],
|
items: [],
|
||||||
warnings: [{ code: 'codex_member_wide_not_supported' }],
|
warnings: [{ code: 'codex_member_wide_not_supported' }],
|
||||||
});
|
});
|
||||||
|
await expect(
|
||||||
|
unknownLeadSource.loadPreview(previewInput({ memberName: 'team-lead' }))
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
provider: 'codex_native_trace',
|
||||||
|
status: 'skipped',
|
||||||
|
items: [],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1757,6 +1757,19 @@ interface OpenCodeSecondaryRetryOutcome {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MemberLifecycleOperationKind =
|
||||||
|
| 'manual_restart'
|
||||||
|
| 'opencode_retry'
|
||||||
|
| 'opencode_member_added'
|
||||||
|
| 'opencode_member_updated'
|
||||||
|
| 'opencode_member_removed';
|
||||||
|
|
||||||
|
interface MemberLifecycleOperation {
|
||||||
|
kind: MemberLifecycleOperationKind;
|
||||||
|
token: symbol;
|
||||||
|
startedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
|
function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
|
||||||
return typeof value === 'number' && Number.isFinite(value)
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
? `${Math.max(0, Math.round(value))}ms`
|
? `${Math.max(0, Math.round(value))}ms`
|
||||||
|
|
@ -5372,6 +5385,7 @@ export class TeamProvisioningService {
|
||||||
string,
|
string,
|
||||||
Promise<RetryFailedOpenCodeSecondaryLanesResult>
|
Promise<RetryFailedOpenCodeSecondaryLanesResult>
|
||||||
>();
|
>();
|
||||||
|
private readonly memberLifecycleOperations = new Map<string, MemberLifecycleOperation>();
|
||||||
private memberRuntimeAdvisoryInvalidator:
|
private memberRuntimeAdvisoryInvalidator:
|
||||||
| ((teamName: string, memberName: string) => void)
|
| ((teamName: string, memberName: string) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
|
@ -13081,7 +13095,79 @@ export class TeamProvisioningService {
|
||||||
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
|
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMemberLifecycleOperationKey(teamName: string, memberName: string): string {
|
||||||
|
return `${teamName.trim().toLowerCase()}\u0000${memberName.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveMemberLifecycleOperation(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string
|
||||||
|
): MemberLifecycleOperation | null {
|
||||||
|
return (
|
||||||
|
this.memberLifecycleOperations.get(
|
||||||
|
this.getMemberLifecycleOperationKey(teamName, memberName)
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMemberLifecycleOperationActive(teamName: string, memberName: string): boolean {
|
||||||
|
return this.getActiveMemberLifecycleOperation(teamName, memberName) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMemberLifecycleOperationInProgressError(memberName: string): Error {
|
||||||
|
return new Error(`Lifecycle operation for teammate "${memberName}" is already in progress`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMemberLifecycleOperationInProgressError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
/^Lifecycle operation for teammate ".+" is already in progress$/.test(error.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMemberLifecycleOperation<T>(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
kind: MemberLifecycleOperationKind,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const key = this.getMemberLifecycleOperationKey(teamName, memberName);
|
||||||
|
if (this.memberLifecycleOperations.has(key)) {
|
||||||
|
throw this.createMemberLifecycleOperationInProgressError(memberName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Symbol(`${kind}:${teamName}:${memberName}`);
|
||||||
|
this.memberLifecycleOperations.set(key, {
|
||||||
|
kind,
|
||||||
|
token,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
if (this.memberLifecycleOperations.get(key)?.token === token) {
|
||||||
|
this.memberLifecycleOperations.delete(key);
|
||||||
|
}
|
||||||
|
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOpenCodeReattachLifecycleKind(
|
||||||
|
reason?: 'member_added' | 'member_updated' | 'manual_restart'
|
||||||
|
): MemberLifecycleOperationKind {
|
||||||
|
if (reason === 'member_added') return 'opencode_member_added';
|
||||||
|
if (reason === 'member_updated') return 'opencode_member_updated';
|
||||||
|
return 'manual_restart';
|
||||||
|
}
|
||||||
|
|
||||||
async restartMember(teamName: string, memberName: string): Promise<void> {
|
async restartMember(teamName: string, memberName: string): Promise<void> {
|
||||||
|
return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () =>
|
||||||
|
this.restartMemberUnlocked(teamName, memberName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restartMemberUnlocked(teamName: string, memberName: string): Promise<void> {
|
||||||
const runId = this.getAliveRunId(teamName);
|
const runId = this.getAliveRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
throw new Error(`Team "${teamName}" is not currently running`);
|
throw new Error(`Team "${teamName}" is not currently running`);
|
||||||
|
|
@ -13142,7 +13228,7 @@ export class TeamProvisioningService {
|
||||||
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
||||||
const desiredSecondaryLane = desiredProviderId === 'opencode' && leadProviderId !== 'opencode';
|
const desiredSecondaryLane = desiredProviderId === 'opencode' && leadProviderId !== 'opencode';
|
||||||
if (liveSecondaryLaneMemberName === memberName || desiredSecondaryLane) {
|
if (liveSecondaryLaneMemberName === memberName || desiredSecondaryLane) {
|
||||||
await this.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
|
await this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, {
|
||||||
reason: 'manual_restart',
|
reason: 'manual_restart',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -13447,9 +13533,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.reattachOpenCodeOwnedMemberLane(teamName, candidate.memberName, {
|
await this.runMemberLifecycleOperation(
|
||||||
reason: 'manual_restart',
|
teamName,
|
||||||
});
|
candidate.memberName,
|
||||||
|
'opencode_retry',
|
||||||
|
() =>
|
||||||
|
this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, candidate.memberName, {
|
||||||
|
reason: 'manual_restart',
|
||||||
|
})
|
||||||
|
);
|
||||||
result.attempted.push(candidate.memberName);
|
result.attempted.push(candidate.memberName);
|
||||||
|
|
||||||
const outcome = await this.readOpenCodeSecondaryRetryOutcome(
|
const outcome = await this.readOpenCodeSecondaryRetryOutcome(
|
||||||
|
|
@ -13473,10 +13565,17 @@ export class TeamProvisioningService {
|
||||||
result.pending.push(candidate.memberName);
|
result.pending.push(candidate.memberName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.failed.push({
|
if (this.isMemberLifecycleOperationInProgressError(error)) {
|
||||||
memberName: candidate.memberName,
|
result.skipped.push({
|
||||||
error: error instanceof Error ? error.message : String(error),
|
memberName: candidate.memberName,
|
||||||
});
|
reason: 'Lifecycle operation already in progress',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.failed.push({
|
||||||
|
memberName: candidate.memberName,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13904,6 +14003,19 @@ export class TeamProvisioningService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
|
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
|
||||||
|
): Promise<void> {
|
||||||
|
return this.runMemberLifecycleOperation(
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
this.getOpenCodeReattachLifecycleKind(options?.reason),
|
||||||
|
() => this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, options)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reattachOpenCodeOwnedMemberLaneUnlocked(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const run = this.getMutableAliveRunOrThrow(teamName);
|
const run = this.getMutableAliveRunOrThrow(teamName);
|
||||||
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
||||||
|
|
@ -14058,6 +14170,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
|
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
|
||||||
|
return this.runMemberLifecycleOperation(teamName, memberName, 'opencode_member_removed', () =>
|
||||||
|
this.detachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detachOpenCodeOwnedMemberLaneUnlocked(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string
|
||||||
|
): Promise<void> {
|
||||||
const run = this.getMutableAliveRunOrThrow(teamName);
|
const run = this.getMutableAliveRunOrThrow(teamName);
|
||||||
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
|
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
|
||||||
matchesTeamMemberIdentity(lane.member.name, memberName)
|
matchesTeamMemberIdentity(lane.member.name, memberName)
|
||||||
|
|
@ -16529,15 +16650,53 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushStdoutParserCarry(run: ProvisioningRun): void {
|
private flushStdoutParserCarry(run: ProvisioningRun): void {
|
||||||
const trimmed = run.stdoutParserCarry.trim();
|
const stdoutParserCarry =
|
||||||
|
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
|
||||||
|
const trimmed = stdoutParserCarry.trim();
|
||||||
if (!trimmed || !run.stdoutParserCarryIsCompleteJson) {
|
if (!trimmed || !run.stdoutParserCarryIsCompleteJson) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Flushing final stream-json stdout carry before process close handling`,
|
||||||
|
this.buildStdoutCarryDiagnostic(run)
|
||||||
|
);
|
||||||
this.handleStdoutParserLine(run, trimmed);
|
this.handleStdoutParserLine(run, trimmed);
|
||||||
this.updateStdoutParserCarry(run, '');
|
this.updateStdoutParserCarry(run, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildStdoutCarryDiagnostic(run: ProvisioningRun): Record<string, unknown> {
|
||||||
|
const stdoutParserCarry =
|
||||||
|
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
|
||||||
|
const diagnostic: Record<string, unknown> = {
|
||||||
|
runId: run.runId,
|
||||||
|
stdoutCarryLength: stdoutParserCarry.length,
|
||||||
|
stdoutCarryCompleteJson: run.stdoutParserCarryIsCompleteJson === true,
|
||||||
|
stdoutCarryLooksLikeClaudeJson: run.stdoutParserCarryLooksLikeClaudeJson === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (run.stdoutParserCarryIsCompleteJson === true) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdoutParserCarry.trim()) as Record<string, unknown>;
|
||||||
|
diagnostic.messageType = typeof parsed.type === 'string' ? parsed.type : null;
|
||||||
|
diagnostic.messageSubtype = typeof parsed.subtype === 'string' ? parsed.subtype : null;
|
||||||
|
diagnostic.bootstrapEvent = typeof parsed.event === 'string' ? parsed.event : null;
|
||||||
|
diagnostic.sequence = typeof parsed.seq === 'number' ? parsed.seq : null;
|
||||||
|
} catch {
|
||||||
|
diagnostic.messageType = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUnconfirmedBootstrapMemberNames(run: ProvisioningRun): string[] {
|
||||||
|
return run.expectedMembers.filter((expected) => {
|
||||||
|
const status = run.memberSpawnStatuses.get(expected);
|
||||||
|
return status?.bootstrapConfirmed !== true && status?.skippedForLaunch !== true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void {
|
private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void {
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -20366,6 +20525,9 @@ export class TeamProvisioningService {
|
||||||
if (matchedRuntimeNames.length > 0) {
|
if (matchedRuntimeNames.length > 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const current = run.memberSpawnStatuses.get(expected);
|
const current = run.memberSpawnStatuses.get(expected);
|
||||||
if (
|
if (
|
||||||
|
|
@ -20399,22 +20561,26 @@ export class TeamProvisioningService {
|
||||||
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const hasExistingFailure =
|
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hasExistingTerminalFailure =
|
||||||
prev.status === 'error' ||
|
prev.status === 'error' ||
|
||||||
prev.launchState === 'failed_to_start' ||
|
prev.launchState === 'failed_to_start' ||
|
||||||
prev.hardFailure === true ||
|
prev.hardFailure === true ||
|
||||||
Boolean(prev.error) ||
|
|
||||||
Boolean(prev.hardFailureReason);
|
Boolean(prev.hardFailureReason);
|
||||||
if (options?.preserveExistingFailure && hasExistingFailure) {
|
const preservedFailureReason =
|
||||||
continue;
|
options?.preserveExistingFailure && hasExistingTerminalFailure
|
||||||
}
|
? (prev.hardFailureReason ?? prev.error)?.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
||||||
const hardFailureReason = runtimeWasAlive
|
const fallbackFailureReason = runtimeWasAlive
|
||||||
? `${baseReason} Runtime process was alive after bootstrap failure${
|
? `${baseReason} Runtime process was alive after bootstrap failure${
|
||||||
options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.'
|
options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.'
|
||||||
}`
|
}`
|
||||||
: baseReason;
|
: baseReason;
|
||||||
|
const hardFailureReason = preservedFailureReason || fallbackFailureReason;
|
||||||
const next: MemberSpawnStatusEntry = {
|
const next: MemberSpawnStatusEntry = {
|
||||||
...prev,
|
...prev,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
|
@ -28356,6 +28522,15 @@ export class TeamProvisioningService {
|
||||||
: run.progress.state === 'failed' && run.progress.message.trim()
|
: run.progress.state === 'failed' && run.progress.message.trim()
|
||||||
? run.progress.message.trim()
|
? run.progress.message.trim()
|
||||||
: 'Launch ended before teammate bootstrap completed.';
|
: 'Launch ended before teammate bootstrap completed.';
|
||||||
|
logger.warn(`[${run.teamName}] Launch cleanup finalizing unconfirmed bootstrap members`, {
|
||||||
|
runId: run.runId,
|
||||||
|
progressState: run.progress.state,
|
||||||
|
progressMessage: run.progress.message,
|
||||||
|
progressError: run.progress.error ?? null,
|
||||||
|
cleanupReason,
|
||||||
|
unconfirmedMembers: this.getUnconfirmedBootstrapMemberNames(run),
|
||||||
|
...this.buildStdoutCarryDiagnostic(run),
|
||||||
|
});
|
||||||
this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, {
|
this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, {
|
||||||
cleanupRequested: true,
|
cleanupRequested: true,
|
||||||
preserveExistingFailure: true,
|
preserveExistingFailure: true,
|
||||||
|
|
@ -28640,6 +28815,10 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isProvisioningRunFailed(run: ProvisioningRun): boolean {
|
||||||
|
return run.progress.state === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise<void> {
|
private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise<void> {
|
||||||
if (run.finalizingByTimeout) {
|
if (run.finalizingByTimeout) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -28655,7 +28834,25 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry.trim() : '') &&
|
||||||
|
!run.stdoutParserCarryIsCompleteJson &&
|
||||||
|
run.stdoutParserCarryLooksLikeClaudeJson
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Process closed with incomplete stream-json stdout carry`,
|
||||||
|
this.buildStdoutCarryDiagnostic(run)
|
||||||
|
);
|
||||||
|
}
|
||||||
this.flushStdoutParserCarry(run);
|
this.flushStdoutParserCarry(run);
|
||||||
|
if (
|
||||||
|
this.isProvisioningRunFailed(run) ||
|
||||||
|
run.cancelRequested ||
|
||||||
|
run.processKilled ||
|
||||||
|
run.authRetryInProgress
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
||||||
// During respawn, the old process exit fires but run.stallCheckHandle already
|
// During respawn, the old process exit fires but run.stallCheckHandle already
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import {
|
||||||
createEmptyMemberLogStreamResponse,
|
createEmptyMemberLogStreamResponse,
|
||||||
} from '@features/member-log-stream/contracts';
|
} from '@features/member-log-stream/contracts';
|
||||||
|
|
||||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
import type {
|
||||||
|
CodexAccountSnapshotDto,
|
||||||
|
CodexStartChatgptLoginOptions,
|
||||||
|
} from '@features/codex-account/contracts';
|
||||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||||
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||||
|
|
@ -240,7 +243,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
}): Promise<CodexAccountSnapshotDto> =>
|
}): Promise<CodexAccountSnapshotDto> =>
|
||||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||||
|
|
||||||
startCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
startCodexChatgptLogin = (
|
||||||
|
_options?: CodexStartChatgptLoginOptions
|
||||||
|
): Promise<CodexAccountSnapshotDto> =>
|
||||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||||
|
|
||||||
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ interface InstalledBannerProps {
|
||||||
onProviderManage: (providerId: CliProviderId) => void;
|
onProviderManage: (providerId: CliProviderId) => void;
|
||||||
onProviderRefresh: (providerId: CliProviderId) => void;
|
onProviderRefresh: (providerId: CliProviderId) => void;
|
||||||
onCodexReconnect: () => void;
|
onCodexReconnect: () => void;
|
||||||
|
onCodexDeviceCodeLogin: () => void;
|
||||||
codexReconnectBusy: boolean;
|
codexReconnectBusy: boolean;
|
||||||
variant: BannerVariant;
|
variant: BannerVariant;
|
||||||
}
|
}
|
||||||
|
|
@ -584,6 +585,7 @@ const InstalledBanner = ({
|
||||||
onProviderManage,
|
onProviderManage,
|
||||||
onProviderRefresh,
|
onProviderRefresh,
|
||||||
onCodexReconnect,
|
onCodexReconnect,
|
||||||
|
onCodexDeviceCodeLogin,
|
||||||
codexReconnectBusy,
|
codexReconnectBusy,
|
||||||
variant,
|
variant,
|
||||||
}: InstalledBannerProps): React.JSX.Element => {
|
}: InstalledBannerProps): React.JSX.Element => {
|
||||||
|
|
@ -899,6 +901,21 @@ const InstalledBanner = ({
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||||
|
{!codexLoginAuthUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCodexDeviceCodeLogin}
|
||||||
|
disabled={codexReconnectBusy || actionDisabled}
|
||||||
|
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(245, 158, 11, 0.22)',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.05)',
|
||||||
|
color: '#fbbf24',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use code
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -1161,7 +1178,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const handleCodexDashboardLogin = useCallback(() => {
|
const handleCodexDashboardLogin = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await codexAccount.startChatgptLogin();
|
await codexAccount.startChatgptLogin('browser');
|
||||||
|
})();
|
||||||
|
}, [codexAccount]);
|
||||||
|
|
||||||
|
const handleCodexDashboardDeviceCodeLogin = useCallback(() => {
|
||||||
|
void (async () => {
|
||||||
|
await codexAccount.startChatgptLogin('device_code');
|
||||||
})();
|
})();
|
||||||
}, [codexAccount]);
|
}, [codexAccount]);
|
||||||
|
|
||||||
|
|
@ -1412,6 +1435,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onProviderManage={handleProviderManage}
|
onProviderManage={handleProviderManage}
|
||||||
onProviderRefresh={handleProviderRefresh}
|
onProviderRefresh={handleProviderRefresh}
|
||||||
onCodexReconnect={handleCodexDashboardLogin}
|
onCodexReconnect={handleCodexDashboardLogin}
|
||||||
|
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||||
codexReconnectBusy={codexAccount.loading}
|
codexReconnectBusy={codexAccount.loading}
|
||||||
variant="info"
|
variant="info"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1637,6 +1661,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onProviderManage={handleProviderManage}
|
onProviderManage={handleProviderManage}
|
||||||
onProviderRefresh={handleProviderRefresh}
|
onProviderRefresh={handleProviderRefresh}
|
||||||
onCodexReconnect={handleCodexDashboardLogin}
|
onCodexReconnect={handleCodexDashboardLogin}
|
||||||
|
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||||
codexReconnectBusy={codexAccount.loading}
|
codexReconnectBusy={codexAccount.loading}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1696,6 +1721,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onProviderManage={handleProviderManage}
|
onProviderManage={handleProviderManage}
|
||||||
onProviderRefresh={handleProviderRefresh}
|
onProviderRefresh={handleProviderRefresh}
|
||||||
onCodexReconnect={handleCodexDashboardLogin}
|
onCodexReconnect={handleCodexDashboardLogin}
|
||||||
|
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||||
codexReconnectBusy={codexAccount.loading}
|
codexReconnectBusy={codexAccount.loading}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1915,6 +1941,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onProviderManage={handleProviderManage}
|
onProviderManage={handleProviderManage}
|
||||||
onProviderRefresh={handleProviderRefresh}
|
onProviderRefresh={handleProviderRefresh}
|
||||||
onCodexReconnect={handleCodexDashboardLogin}
|
onCodexReconnect={handleCodexDashboardLogin}
|
||||||
|
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||||
codexReconnectBusy={codexAccount.loading}
|
codexReconnectBusy={codexAccount.loading}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CodexLoginLinkCopyButton, CodexLoginUserCodeBadge } from './CodexLoginLinkCopyButton';
|
||||||
|
|
||||||
|
async function renderNode(
|
||||||
|
node: React.ReactElement
|
||||||
|
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(node);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
cleanup: async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubClipboard(writeText: ReturnType<typeof vi.fn>): void {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
...navigator,
|
||||||
|
clipboard: { writeText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexLoginLinkCopyButton', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing until an auth URL is available', async () => {
|
||||||
|
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={null} />);
|
||||||
|
|
||||||
|
expect(host.textContent).toBe('');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies only the browser login URL when no user code is present', async () => {
|
||||||
|
const writeText = vi.fn(async () => undefined);
|
||||||
|
stubClipboard(writeText);
|
||||||
|
const authUrl =
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
|
||||||
|
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={authUrl} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
host.querySelector('button')?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(authUrl);
|
||||||
|
expect(host.textContent).toContain('Copied');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the device login URL and code together', async () => {
|
||||||
|
const writeText = vi.fn(async () => undefined);
|
||||||
|
stubClipboard(writeText);
|
||||||
|
const { host, cleanup } = await renderNode(
|
||||||
|
<CodexLoginLinkCopyButton
|
||||||
|
authUrl="https://auth.openai.com/codex/device"
|
||||||
|
userCode="ABCD-1234"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
host.querySelector('button')?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://auth.openai.com/codex/device\nCode: ABCD-1234');
|
||||||
|
expect(host.textContent).toContain('Copied');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows copy failure when clipboard write fails', async () => {
|
||||||
|
const writeText = vi.fn(async () => {
|
||||||
|
throw new Error('clipboard denied');
|
||||||
|
});
|
||||||
|
stubClipboard(writeText);
|
||||||
|
const { host, cleanup } = await renderNode(
|
||||||
|
<CodexLoginLinkCopyButton authUrl="https://chatgpt.com/auth" />
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
host.querySelector('button')?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Copy failed');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a user code badge only for device-code login state', async () => {
|
||||||
|
const empty = await renderNode(<CodexLoginUserCodeBadge userCode={null} />);
|
||||||
|
expect(empty.host.textContent).toBe('');
|
||||||
|
await empty.cleanup();
|
||||||
|
|
||||||
|
const filled = await renderNode(<CodexLoginUserCodeBadge userCode="ABCD-1234" />);
|
||||||
|
expect(filled.host.textContent).toContain('Code');
|
||||||
|
expect(filled.host.textContent).toContain('ABCD-1234');
|
||||||
|
await filled.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1019,9 +1019,11 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCodexStartLogin = async (): Promise<void> => {
|
const handleCodexStartLogin = async (
|
||||||
|
mode: 'browser' | 'device_code' = 'browser'
|
||||||
|
): Promise<void> => {
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
const success = await codexAccount.startChatgptLogin();
|
const success = await codexAccount.startChatgptLogin(mode);
|
||||||
if (!success && codexAccount.error) {
|
if (!success && codexAccount.error) {
|
||||||
setConnectionError(codexAccount.error);
|
setConnectionError(codexAccount.error);
|
||||||
}
|
}
|
||||||
|
|
@ -1444,7 +1446,16 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={codexActionBusy}
|
disabled={codexActionBusy}
|
||||||
onClick={() => void handleCodexStartLogin()}
|
onClick={() => void handleCodexStartLogin('device_code')}
|
||||||
|
>
|
||||||
|
<Link2 className="mr-1 size-3.5" />
|
||||||
|
Use code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={codexActionBusy}
|
||||||
|
onClick={() => void handleCodexStartLogin('browser')}
|
||||||
>
|
>
|
||||||
<Link2 className="mr-1 size-3.5" />
|
<Link2 className="mr-1 size-3.5" />
|
||||||
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
|
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||||
|
|
||||||
|
const apiMock = vi.hoisted(() => ({
|
||||||
|
openExternal: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function renderPrompt(
|
||||||
|
props: React.ComponentProps<typeof CodexReconnectPrompt>
|
||||||
|
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<CodexReconnectPrompt {...props} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
cleanup: async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(host: HTMLElement, label: string): HTMLButtonElement {
|
||||||
|
const button = [...host.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(label)
|
||||||
|
);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`Button not found: ${label}`);
|
||||||
|
}
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexReconnectPrompt', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers browser login and device-code fallback before a link exists', async () => {
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
const onDeviceCodeReconnect = vi.fn();
|
||||||
|
const { host, cleanup } = await renderPrompt({
|
||||||
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
|
reconnectBusy: false,
|
||||||
|
onReconnect,
|
||||||
|
onDeviceCodeReconnect,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getButton(host, 'Generate link').click();
|
||||||
|
getButton(host, 'Use code').click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onDeviceCodeReconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMock.openExternal).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the generated browser link without starting another login', async () => {
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
const onDeviceCodeReconnect = vi.fn();
|
||||||
|
const authUrl =
|
||||||
|
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
|
||||||
|
const { host, cleanup } = await renderPrompt({
|
||||||
|
authUrl,
|
||||||
|
userCode: null,
|
||||||
|
reconnectBusy: false,
|
||||||
|
onReconnect,
|
||||||
|
onDeviceCodeReconnect,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain('Use code');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getButton(host, 'Open login').click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMock.openExternal).toHaveBeenCalledWith(authUrl);
|
||||||
|
expect(onReconnect).not.toHaveBeenCalled();
|
||||||
|
expect(onDeviceCodeReconnect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the code badge for a pending device-code login', async () => {
|
||||||
|
const { host, cleanup } = await renderPrompt({
|
||||||
|
authUrl: 'https://auth.openai.com/codex/device',
|
||||||
|
userCode: 'ABCD-1234',
|
||||||
|
reconnectBusy: false,
|
||||||
|
onReconnect: vi.fn(),
|
||||||
|
onDeviceCodeReconnect: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Code');
|
||||||
|
expect(host.textContent).toContain('ABCD-1234');
|
||||||
|
expect(host.textContent).toContain('Copy link + code');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -68,11 +68,13 @@ export const CodexReconnectPrompt = ({
|
||||||
userCode,
|
userCode,
|
||||||
reconnectBusy,
|
reconnectBusy,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onDeviceCodeReconnect,
|
||||||
}: {
|
}: {
|
||||||
authUrl: string | null;
|
authUrl: string | null;
|
||||||
userCode: string | null;
|
userCode: string | null;
|
||||||
reconnectBusy: boolean;
|
reconnectBusy: boolean;
|
||||||
onReconnect: () => void;
|
onReconnect: () => void;
|
||||||
|
onDeviceCodeReconnect: () => void;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -94,6 +96,20 @@ export const CodexReconnectPrompt = ({
|
||||||
disabled={reconnectBusy}
|
disabled={reconnectBusy}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
|
{!authUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeviceCodeReconnect}
|
||||||
|
disabled={reconnectBusy}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(245, 158, 11, 0.24)',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use code
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -711,11 +711,14 @@ export const CreateTeamDialog = ({
|
||||||
});
|
});
|
||||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||||
|
|
||||||
const handleCodexReconnect = useCallback(() => {
|
const handleCodexReconnect = useCallback(
|
||||||
void (async () => {
|
(mode: 'browser' | 'device_code' = 'browser') => {
|
||||||
await codexAccount.startChatgptLogin();
|
void (async () => {
|
||||||
})();
|
await codexAccount.startChatgptLogin(mode);
|
||||||
}, [codexAccount]);
|
})();
|
||||||
|
},
|
||||||
|
[codexAccount]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
|
|
@ -2179,7 +2182,8 @@ export const CreateTeamDialog = ({
|
||||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||||
reconnectBusy={codexAccount.loading}
|
reconnectBusy={codexAccount.loading}
|
||||||
onReconnect={handleCodexReconnect}
|
onReconnect={() => handleCodexReconnect('browser')}
|
||||||
|
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -588,11 +588,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
});
|
});
|
||||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||||
|
|
||||||
const handleCodexReconnect = React.useCallback(() => {
|
const handleCodexReconnect = React.useCallback(
|
||||||
void (async () => {
|
(mode: 'browser' | 'device_code' = 'browser') => {
|
||||||
await codexAccount.startChatgptLogin();
|
void (async () => {
|
||||||
})();
|
await codexAccount.startChatgptLogin(mode);
|
||||||
}, [codexAccount]);
|
})();
|
||||||
|
},
|
||||||
|
[codexAccount]
|
||||||
|
);
|
||||||
|
|
||||||
// Schedule store actions
|
// Schedule store actions
|
||||||
const createSchedule = useStore((s) => s.createSchedule);
|
const createSchedule = useStore((s) => s.createSchedule);
|
||||||
|
|
@ -2912,7 +2915,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||||
reconnectBusy={codexAccount.loading}
|
reconnectBusy={codexAccount.loading}
|
||||||
onReconnect={handleCodexReconnect}
|
onReconnect={() => handleCodexReconnect('browser')}
|
||||||
|
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1237,6 +1237,7 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => {
|
it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => {
|
||||||
|
allowConsoleLogs();
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const run = createClaudeLogsRun({
|
const run = createClaudeLogsRun({
|
||||||
runId: 'run-cleanup-finalizes-launch',
|
runId: 'run-cleanup-finalizes-launch',
|
||||||
|
|
@ -1296,6 +1297,7 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves specific member launch failures when cleanup applies its fallback reason', () => {
|
it('preserves specific member launch failures when cleanup applies its fallback reason', () => {
|
||||||
|
allowConsoleLogs();
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||||
const specificReason = 'OpenCode bridge reported member launch failure';
|
const specificReason = 'OpenCode bridge reported member launch failure';
|
||||||
|
|
@ -1326,6 +1328,8 @@ describe('TeamProvisioningService', () => {
|
||||||
hardFailure: true,
|
hardFailure: true,
|
||||||
hardFailureReason: specificReason,
|
hardFailureReason: specificReason,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'process',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -1347,17 +1351,73 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
(svc as any).cleanupRun(run);
|
(svc as any).cleanupRun(run);
|
||||||
|
|
||||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
const bobStatus = run.memberSpawnStatuses.get('bob');
|
||||||
|
expect(bobStatus).toMatchObject({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
launchState: 'failed_to_start',
|
launchState: 'failed_to_start',
|
||||||
hardFailureReason: specificReason,
|
hardFailureReason: specificReason,
|
||||||
|
runtimeAlive: false,
|
||||||
|
runtimeDiagnostic:
|
||||||
|
'Bootstrap failed before teammate check-in; launch-owned runtime cleanup requested.',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
});
|
});
|
||||||
|
expect(bobStatus?.livenessSource).toBeUndefined();
|
||||||
expect(run.memberSpawnStatuses.get('carol')).toMatchObject({
|
expect(run.memberSpawnStatuses.get('carol')).toMatchObject({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
launchState: 'failed_to_start',
|
launchState: 'failed_to_start',
|
||||||
hardFailureReason: timeoutReason,
|
hardFailureReason: timeoutReason,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat non-terminal error text as an existing launch failure during cleanup', () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||||
|
const run = createClaudeLogsRun({
|
||||||
|
runId: 'run-cleanup-non-terminal-error-text',
|
||||||
|
teamName: 'cleanup-non-terminal-error-text-team',
|
||||||
|
isLaunch: true,
|
||||||
|
provisioningComplete: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
provisioningOutputParts: [],
|
||||||
|
progress: {
|
||||||
|
runId: 'run-cleanup-non-terminal-error-text',
|
||||||
|
teamName: 'cleanup-non-terminal-error-text-team',
|
||||||
|
state: 'failed',
|
||||||
|
message: 'Deterministic bootstrap failed',
|
||||||
|
startedAt: '2026-04-19T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-19T10:00:01.000Z',
|
||||||
|
error: timeoutReason,
|
||||||
|
},
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
error: 'Transient runtime diagnostic, not terminal launch failure',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||||
|
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
|
||||||
|
|
||||||
|
(svc as any).cleanupRun(run);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: timeoutReason,
|
||||||
|
error: timeoutReason,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('member spawn status launch reads', () => {
|
describe('member spawn status launch reads', () => {
|
||||||
|
|
@ -12534,6 +12594,132 @@ describe('TeamProvisioningService', () => {
|
||||||
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not verify provisioning again after flushing a final newline-less error result', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'launch-close-flushes-final-error-team';
|
||||||
|
const leadSessionId = 'lead-session-final-error-flush';
|
||||||
|
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||||
|
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
const child = createRunningChild();
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||||
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||||
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
} as any);
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
|
authSource: 'anthropic_api_key',
|
||||||
|
}));
|
||||||
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
source: 'members-meta',
|
||||||
|
warning: undefined,
|
||||||
|
}));
|
||||||
|
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||||
|
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||||
|
);
|
||||||
|
const waitForValidConfig = vi
|
||||||
|
.spyOn(svc as any, 'waitForValidConfig')
|
||||||
|
.mockResolvedValue({ ok: false });
|
||||||
|
const progressStates: string[] = [];
|
||||||
|
|
||||||
|
await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
|
||||||
|
progressStates.push(progress.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'error',
|
||||||
|
error: 'runtime failed before bootstrap completed',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(waitForValidConfig).not.toHaveBeenCalled();
|
||||||
|
expect(progressStates).toContain('failed');
|
||||||
|
expect(progressStates).not.toContain('verifying');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not verify provisioning while auth retry is scheduled from final newline-less output', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'launch-close-flushes-final-auth-team';
|
||||||
|
const leadSessionId = 'lead-session-final-auth-flush';
|
||||||
|
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||||
|
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
const child = createRunningChild();
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||||
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||||
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
} as any);
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
|
authSource: 'anthropic_api_key',
|
||||||
|
}));
|
||||||
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
source: 'members-meta',
|
||||||
|
warning: undefined,
|
||||||
|
}));
|
||||||
|
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||||
|
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||||
|
);
|
||||||
|
const waitForValidConfig = vi
|
||||||
|
.spyOn(svc as any, 'waitForValidConfig')
|
||||||
|
.mockResolvedValue({ ok: false });
|
||||||
|
const respawnAfterAuthFailure = vi
|
||||||
|
.spyOn(svc as any, 'respawnAfterAuthFailure')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const progressStates: string[] = [];
|
||||||
|
|
||||||
|
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
|
||||||
|
progressStates.push(progress.state);
|
||||||
|
});
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
|
||||||
|
child.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'invalid api key' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(run.authRetryInProgress).toBe(true);
|
||||||
|
expect(respawnAfterAuthFailure).toHaveBeenCalledWith(run);
|
||||||
|
expect(waitForValidConfig).not.toHaveBeenCalled();
|
||||||
|
expect(progressStates).not.toContain('verifying');
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
@ -17975,7 +18161,7 @@ describe('TeamProvisioningService', () => {
|
||||||
{ memberName: 'nova', laneId: 'secondary:opencode:nova' },
|
{ memberName: 'nova', laneId: 'secondary:opencode:nova' },
|
||||||
]);
|
]);
|
||||||
const reattach = vi
|
const reattach = vi
|
||||||
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLane')
|
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
|
||||||
.mockResolvedValueOnce(undefined)
|
.mockResolvedValueOnce(undefined)
|
||||||
.mockResolvedValueOnce(undefined)
|
.mockResolvedValueOnce(undefined)
|
||||||
.mockRejectedValueOnce(new Error('OpenCode bridge crashed'));
|
.mockRejectedValueOnce(new Error('OpenCode bridge crashed'));
|
||||||
|
|
@ -18015,4 +18201,247 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
expect(notify).toHaveBeenCalledWith(run, result);
|
expect(notify).toHaveBeenCalledWith(run, result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a concurrent manual restart for the same teammate', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'codex-lifecycle-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
run.child = { pid: 111 };
|
||||||
|
run.processKilled = false;
|
||||||
|
run.cancelRequested = false;
|
||||||
|
|
||||||
|
const configReady = createDeferred<{
|
||||||
|
name: string;
|
||||||
|
members: Array<{ name: string; agentType?: string }>;
|
||||||
|
}>();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(() => configReady.promise),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).sendMessageToRun = vi.fn(async () => undefined);
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
|
||||||
|
const firstRestart = svc.restartMember(run.teamName, 'bob');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
await expect(svc.restartMember(run.teamName, 'bob')).rejects.toThrow(
|
||||||
|
'Lifecycle operation for teammate "bob" is already in progress'
|
||||||
|
);
|
||||||
|
|
||||||
|
configReady.resolve({
|
||||||
|
name: 'Codex Lifecycle Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
});
|
||||||
|
await firstRestart;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let one teammate lifecycle operation block another teammate or team', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const aliceDone = createDeferred<void>();
|
||||||
|
const firstOperation = (svc as any).runMemberLifecycleOperation(
|
||||||
|
'same-team',
|
||||||
|
'alice',
|
||||||
|
'manual_restart',
|
||||||
|
() => aliceDone.promise
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () =>
|
||||||
|
'bob-ok'
|
||||||
|
)
|
||||||
|
).resolves.toBe('bob-ok');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () =>
|
||||||
|
'other-ok'
|
||||||
|
)
|
||||||
|
).resolves.toBe('other-ok');
|
||||||
|
|
||||||
|
aliceDone.resolve();
|
||||||
|
await firstOperation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips busy OpenCode retry candidates while continuing other candidates', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'mixed-retry-busy-team',
|
||||||
|
runId: 'run-mixed-retry-busy',
|
||||||
|
expectedMembers: ['alice', 'tom'],
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
run.provisioningComplete = true;
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
|
||||||
|
|
||||||
|
const busyDone = createDeferred<void>();
|
||||||
|
const busyOperation = (svc as any).runMemberLifecycleOperation(
|
||||||
|
run.teamName,
|
||||||
|
'alice',
|
||||||
|
'manual_restart',
|
||||||
|
() => busyDone.promise
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(svc as any, 'collectFailedOpenCodeSecondaryRetryCandidates').mockResolvedValue([
|
||||||
|
{ memberName: 'alice', laneId: 'secondary:opencode:alice' },
|
||||||
|
{ memberName: 'tom', laneId: 'secondary:opencode:tom' },
|
||||||
|
]);
|
||||||
|
const reattach = vi
|
||||||
|
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(svc as any, 'readOpenCodeSecondaryRetryOutcome').mockResolvedValue({
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
});
|
||||||
|
vi.spyOn(svc as any, 'notifyLeadAboutConfirmedOpenCodeRetries').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await svc.retryFailedOpenCodeSecondaryLanes(run.teamName);
|
||||||
|
|
||||||
|
expect(reattach).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reattach).toHaveBeenCalledWith(run.teamName, 'tom', { reason: 'manual_restart' });
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
attempted: ['tom'],
|
||||||
|
confirmed: ['tom'],
|
||||||
|
skipped: [
|
||||||
|
{
|
||||||
|
memberName: 'alice',
|
||||||
|
reason: 'Lifecycle operation already in progress',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
busyDone.resolve();
|
||||||
|
await busyOperation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks manual restart while an OpenCode member update reattach is active', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const reattachDone = createDeferred<void>();
|
||||||
|
const reattachOperation = (svc as any).runMemberLifecycleOperation(
|
||||||
|
'mixed-update-team',
|
||||||
|
'bob',
|
||||||
|
'opencode_member_updated',
|
||||||
|
() => reattachDone.promise
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.restartMember('mixed-update-team', 'bob')).rejects.toThrow(
|
||||||
|
'Lifecycle operation for teammate "bob" is already in progress'
|
||||||
|
);
|
||||||
|
|
||||||
|
reattachDone.resolve();
|
||||||
|
await reattachOperation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks manual restart while an OpenCode member removal detach is active', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const detachDone = createDeferred<void>();
|
||||||
|
const detachOperation = (svc as any).runMemberLifecycleOperation(
|
||||||
|
'mixed-remove-team',
|
||||||
|
'bob',
|
||||||
|
'opencode_member_removed',
|
||||||
|
() => detachDone.promise
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.restartMember('mixed-remove-team', 'bob')).rejects.toThrow(
|
||||||
|
'Lifecycle operation for teammate "bob" is already in progress'
|
||||||
|
);
|
||||||
|
|
||||||
|
detachDone.resolve();
|
||||||
|
await detachOperation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let launch cleanup overwrite a teammate with an active lifecycle operation', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'cleanup-guard-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const lifecycleDone = createDeferred<void>();
|
||||||
|
const lifecycleOperation = (svc as any).runMemberLifecycleOperation(
|
||||||
|
run.teamName,
|
||||||
|
'bob',
|
||||||
|
'manual_restart',
|
||||||
|
() => lifecycleDone.promise
|
||||||
|
);
|
||||||
|
|
||||||
|
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
|
||||||
|
cleanupRequested: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
hardFailure: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
lifecycleDone.resolve();
|
||||||
|
await lifecycleOperation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still marks unconfirmed teammates failed during cleanup when no lifecycle operation is active', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'cleanup-no-guard-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
livenessSource: 'process',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
|
||||||
|
cleanupRequested: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
hardFailure: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
livenessSource: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,8 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
expect(row).not.toBeUndefined();
|
expect(row).not.toBeUndefined();
|
||||||
expect(row?.querySelector('.float-left')).not.toBeNull();
|
expect(row?.querySelector('.float-left')).not.toBeNull();
|
||||||
expect(row?.querySelector('.line-clamp-3')).toBeNull();
|
expect(row?.querySelector('.line-clamp-3')).toBeNull();
|
||||||
expect(row?.className).toContain('h-[68px]');
|
expect(row?.className).toContain('h-[72px]');
|
||||||
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-[18px]');
|
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-5');
|
||||||
expect(row?.textContent).toContain('pnpm test');
|
expect(row?.textContent).toContain('pnpm test');
|
||||||
|
|
||||||
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
|
|
@ -564,6 +564,87 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders distinct empty states for unsupported and simply empty members', async () => {
|
||||||
|
const codexNode: GraphNode = {
|
||||||
|
id: 'member:alpha-team:codex-dev',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'codex-dev',
|
||||||
|
state: 'idle',
|
||||||
|
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' },
|
||||||
|
};
|
||||||
|
const quietNode: GraphNode = {
|
||||||
|
id: 'member:alpha-team:quiet-dev',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'quiet-dev',
|
||||||
|
state: 'idle',
|
||||||
|
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
|
||||||
|
};
|
||||||
|
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||||
|
[
|
||||||
|
'codex-dev',
|
||||||
|
{
|
||||||
|
memberName: 'codex-dev',
|
||||||
|
items: [],
|
||||||
|
coverage: [{ provider: 'codex_native_trace', status: 'skipped' }],
|
||||||
|
warnings: [
|
||||||
|
{
|
||||||
|
code: 'codex_member_wide_not_supported',
|
||||||
|
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
overflowCount: 0,
|
||||||
|
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quiet-dev',
|
||||||
|
{
|
||||||
|
memberName: 'quiet-dev',
|
||||||
|
items: [],
|
||||||
|
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
|
||||||
|
warnings: [],
|
||||||
|
truncated: false,
|
||||||
|
overflowCount: 0,
|
||||||
|
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<GraphMemberLogPreviewHud
|
||||||
|
teamName="alpha-team"
|
||||||
|
nodes={[codexNode, quietNode]}
|
||||||
|
getLogWorldRect={(ownerNodeId) => ({
|
||||||
|
left: ownerNodeId.includes('quiet') ? 360 : 40,
|
||||||
|
top: 80,
|
||||||
|
right: ownerNodeId.includes('quiet') ? 620 : 300,
|
||||||
|
bottom: 372,
|
||||||
|
width: 260,
|
||||||
|
height: 292,
|
||||||
|
})}
|
||||||
|
getCameraZoom={() => 1}
|
||||||
|
worldToScreen={(x, y) => ({ x, y })}
|
||||||
|
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||||
|
focusNodeIds={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Unsupported provider');
|
||||||
|
expect(host.textContent).toContain('No recent logs');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||||
const leadNode: GraphNode = {
|
const leadNode: GraphNode = {
|
||||||
id: 'lead:alpha-team',
|
id: 'lead:alpha-team',
|
||||||
|
|
@ -717,4 +798,155 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('truncates long event titles without losing the full tooltip context', async () => {
|
||||||
|
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
memberName: 'alice',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'long-title-preview',
|
||||||
|
kind: 'tool_use',
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
timestamp: '2026-04-03T00:01:00.000Z',
|
||||||
|
title: 'Very long custom provider tool name',
|
||||||
|
preview:
|
||||||
|
'Very long custom provider tool name: compact body should still remain visible',
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||||
|
warnings: [],
|
||||||
|
truncated: false,
|
||||||
|
overflowCount: 0,
|
||||||
|
generatedAt: '2026-04-03T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: 'member:alpha-team:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'active',
|
||||||
|
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<GraphMemberLogPreviewHud
|
||||||
|
teamName="alpha-team"
|
||||||
|
nodes={[node]}
|
||||||
|
getLogWorldRect={() => ({
|
||||||
|
left: 40,
|
||||||
|
top: 80,
|
||||||
|
right: 300,
|
||||||
|
bottom: 372,
|
||||||
|
width: 260,
|
||||||
|
height: 292,
|
||||||
|
})}
|
||||||
|
getCameraZoom={() => 1}
|
||||||
|
worldToScreen={(x, y) => ({ x, y })}
|
||||||
|
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||||
|
focusNodeIds={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('compact body')
|
||||||
|
);
|
||||||
|
expect(row?.textContent).toContain('Very long custom prov...');
|
||||||
|
expect(row?.textContent).toContain('compact body should still remain visible');
|
||||||
|
expect(row?.textContent).not.toContain('Very long custom provider tool nameVery long');
|
||||||
|
expect(row?.getAttribute('title')).toContain('Very long custom provider tool name');
|
||||||
|
expect(row?.getAttribute('aria-label')).toContain('Very long custom provider tool name');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses safe fallback titles for malformed compact events', async () => {
|
||||||
|
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
memberName: 'alice',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'empty-title-result',
|
||||||
|
kind: 'tool_result',
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
timestamp: '2026-04-03T00:01:00.000Z',
|
||||||
|
title: ' ',
|
||||||
|
preview: 'stored',
|
||||||
|
tone: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empty-title-error',
|
||||||
|
kind: 'text',
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
timestamp: 'bad timestamp',
|
||||||
|
title: '',
|
||||||
|
preview: 'provider returned malformed timestamp',
|
||||||
|
tone: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||||
|
warnings: [],
|
||||||
|
truncated: false,
|
||||||
|
overflowCount: 0,
|
||||||
|
generatedAt: '2026-04-03T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: 'member:alpha-team:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'active',
|
||||||
|
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<GraphMemberLogPreviewHud
|
||||||
|
teamName="alpha-team"
|
||||||
|
nodes={[node]}
|
||||||
|
getLogWorldRect={() => ({
|
||||||
|
left: 40,
|
||||||
|
top: 80,
|
||||||
|
right: 300,
|
||||||
|
bottom: 372,
|
||||||
|
width: 260,
|
||||||
|
height: 292,
|
||||||
|
})}
|
||||||
|
getCameraZoom={() => 1}
|
||||||
|
worldToScreen={(x, y) => ({ x, y })}
|
||||||
|
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||||
|
focusNodeIds={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Tool result');
|
||||||
|
expect(host.textContent).toContain('stored');
|
||||||
|
expect(host.textContent).toContain('Error');
|
||||||
|
expect(host.textContent).toContain('provider returned malformed timestamp');
|
||||||
|
expect(host.textContent).not.toContain('undefined');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue