Merge pull request #1300 from harshitsinghbhandari/sessions-redone
This commit is contained in:
commit
509fb12b5e
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-agent-codex": patch
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Improve Claude Code and Codex session cost estimates to account for cached-token spend, make Codex restore commands fall back to approval prompts for worker sessions instead of blindly reusing dangerous bypass flags, and register the Codex plugin in the web dashboard so native activity detection works there.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Make session detail agent reports collapsible and add explicit audit attribution for the session, actor, and report source command.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
---
|
||||
|
||||
Stop carrying forward `stuck` / `probe_failure` session truth when the runtime is still confirmed alive and activity is merely unavailable, and degrade that combination to `detecting` until stronger evidence arrives.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Keep closed-unmerged sessions actionable in the dashboard by removing them from the done lane unless the runtime actually ended, and hide restore controls for merged sessions that are intentionally non-restorable.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
---
|
||||
|
||||
Keep lifecycle observability and batch diagnostic logs out of user-visible terminal stderr by routing them into AO's observability audit files instead, while preserving structured traces for debugging and regression coverage.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
---
|
||||
|
||||
Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
|
||||
|
||||
**Migration:** `Session` now carries canonical lifecycle truth in `session.lifecycle`
|
||||
and explicit activity-evidence metadata in `session.activitySignal`. Third-party
|
||||
callers that construct `Session` objects directly must populate those fields or
|
||||
route through the core session helpers that synthesize them.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Model activity evidence explicitly across lifecycle inference and dashboard rendering so missing or failed probes cannot spuriously produce idle or stuck interpretations. This also stabilizes repeated polls by preserving stronger prior lifecycle states when the only new evidence is weak or unavailable.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
---
|
||||
|
||||
Add centralized lifecycle transitions and report watcher for agent monitoring.
|
||||
|
||||
- **Lifecycle transitions (#137)**: Centralize all lifecycle state mutations through `applyLifecycleDecision()` for consistent timestamp handling, atomic metadata persistence, and observability.
|
||||
- **Detecting bounds (#138)**: Add time-based (5 min) and attempt-based (3 attempts) bounds to detecting state with evidence hashing to prevent counter reset on unchanged probe results.
|
||||
- **Report watcher (#140)**: Background trigger system that audits agent reports for anomalies (no_acknowledge, stale_report, agent_needs_input) and integrates with the reaction engine.
|
||||
|
||||
New exports:
|
||||
- `applyLifecycleDecision`, `applyDecisionToLifecycle`, `buildTransitionMetadataPatch`, `createStateTransitionDecision`
|
||||
- `DETECTING_MAX_ATTEMPTS`, `DETECTING_MAX_DURATION_MS`, `hashEvidence`, `isDetectingTimedOut`
|
||||
- `auditAgentReports`, `checkAcknowledgeTimeout`, `checkStaleReport`, `checkBlockedAgent`, `shouldAuditSession`, `getReactionKeyForTrigger`, `DEFAULT_REPORT_WATCHER_CONFIG`, `REPORT_WATCHER_METADATA_KEYS`
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing, making agent-report metadata writes atomic, persisting canonical payloads for legacy sessions on read, stabilizing detecting evidence hashes, and removing the remaining inline-style cleanup debt from the session detail view. Follow-on fixes also split the Session Detail view into smaller components, harden PR URL parsing and wrapper capture for GitHub Enterprise and GitLab-style hosts, redact sensitive observability payload fields, bound on-disk audit logs, and align cleanup wording with the current merged-session lifecycle policy.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
---
|
||||
|
||||
Improve lifecycle detection to use bounded `detecting` retries when runtime, process, and activity evidence disagree, and make recovery validation escalate probe uncertainty for human review instead of treating it as cleanup-safe death.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Decouple canonical session state from PR state so workers stay idle while waiting on reviews or merged/closed PR decisions, stop cleanup from auto-killing merged PR sessions, and make the dashboard/rendered labels follow canonical PR truth instead of inferring it from legacy lifecycle aliases.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Expose split session, PR, and runtime lifecycle truth in dashboard API payloads, render that truth directly in session cards and detail views, and extend lifecycle observability with structured transition evidence, reasons, and recovery context while preserving legacy metadata compatibility.
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
# Stage 1 Plan: Canonical Lifecycle Model and Persistence Foundation
|
||||
|
||||
## Intent
|
||||
|
||||
Stage 1 is a planning and foundation stage. It does not redesign the dashboard, change reaction behavior, or implement auto-recovery. Its job is to replace the current overloaded lifecycle model with a canonical persisted model that can safely support later stages.
|
||||
|
||||
This plan is derived from the redesign brief for this project and from the current implementation in:
|
||||
|
||||
- `packages/core/src/types.ts`
|
||||
- `packages/core/src/session-manager.ts`
|
||||
- `packages/core/src/lifecycle-manager.ts`
|
||||
- `packages/core/src/metadata.ts`
|
||||
- `packages/core/src/utils/session-from-metadata.ts`
|
||||
- `packages/web/src/lib/serialize.ts`
|
||||
- `packages/web/src/lib/types.ts`
|
||||
|
||||
## Stage 1 Goals
|
||||
|
||||
1. Define one canonical lifecycle model for sessions that separates session truth from PR truth.
|
||||
2. Introduce persisted structured state so the system stops depending on a single overloaded `status` string plus ad hoc metadata keys.
|
||||
3. Make it explicit which fields are authoritative facts and which are derived projections for the UI, notifications, and reactions.
|
||||
4. Preserve backward compatibility long enough to migrate existing metadata files safely.
|
||||
5. Keep Stage 1 limited to foundation work only, so later stages can build on stable persisted semantics.
|
||||
|
||||
## Stage 1 Non-Goals
|
||||
|
||||
1. Do not redesign the dashboard UX or kanban layout.
|
||||
2. Do not implement new CI polling behavior, review automation, or notifier routing changes.
|
||||
3. Do not add restore/recovery orchestration beyond the persisted fields required to support it later.
|
||||
4. Do not replace the flat-file storage mechanism in Stage 1.
|
||||
5. Do not change the meaning of every existing UI label yet; Stage 1 should provide a compatibility layer first.
|
||||
|
||||
## Current Problems To Fix First
|
||||
|
||||
1. `Session.status` currently mixes workflow state, PR state, and terminal/runtime outcomes into one enum.
|
||||
2. `Session.activity` is partially authoritative but is not persisted as part of a structured lifecycle record.
|
||||
3. Metadata persistence is an untyped key-value bag, so new state can be added without a stable contract.
|
||||
4. `lifecycle-manager.ts` infers status by probing runtime, agent activity, and PR state, then writes a single `status` back to metadata, which loses the reason for the transition.
|
||||
5. The web layer computes attention and display semantics from a mix of `status`, `activity`, and live PR enrichment, which makes later redesign work risky unless the core truth model is stabilized first.
|
||||
|
||||
## Canonical Model To Introduce In Stage 1
|
||||
|
||||
Stage 1 should persist three first-class truth domains for every session:
|
||||
|
||||
1. `session`: what the agent session is doing as a workflow record.
|
||||
2. `pr`: the state of the associated pull request, if one exists.
|
||||
3. `runtime`: what is known about process/runtime liveness.
|
||||
|
||||
Each domain must persist both a coarse state and an explicit reason.
|
||||
|
||||
### 1. Session Domain
|
||||
|
||||
Persisted fields:
|
||||
|
||||
- `session.kind`: `orchestrator | worker`
|
||||
- `session.state`: `not_started | working | idle | needs_input | stuck | detecting | done | terminated`
|
||||
- `session.reason`: string enum, initially constrained to known reasons
|
||||
- `session.startedAt`: ISO timestamp or null
|
||||
- `session.completedAt`: ISO timestamp or null
|
||||
- `session.terminatedAt`: ISO timestamp or null
|
||||
- `session.lastTransitionAt`: ISO timestamp
|
||||
|
||||
Initial reason set for Stage 1:
|
||||
|
||||
- `spawn_requested`
|
||||
- `agent_acknowledged`
|
||||
- `task_in_progress`
|
||||
- `pr_created`
|
||||
- `fixing_ci`
|
||||
- `resolving_review_comments`
|
||||
- `awaiting_user_input`
|
||||
- `awaiting_external_review`
|
||||
- `research_complete`
|
||||
- `merged_waiting_decision`
|
||||
- `manually_killed`
|
||||
- `runtime_lost`
|
||||
- `agent_process_exited`
|
||||
- `probe_failure`
|
||||
- `error_in_process`
|
||||
|
||||
Notes:
|
||||
|
||||
- Worker sessions are workflow records, not just processes. A merged PR must not force `session.state = terminated`.
|
||||
- Orchestrator sessions use the same shape but may enforce different allowed transitions later. Stage 1 only persists the distinction.
|
||||
|
||||
### 2. PR Domain
|
||||
|
||||
Persisted fields:
|
||||
|
||||
- `pr.state`: `none | open | merged | closed`
|
||||
- `pr.reason`: `not_created | in_progress | ci_failing | review_pending | changes_requested | approved | merge_ready | merged | closed_unmerged`
|
||||
- `pr.number`: number or null
|
||||
- `pr.url`: string or null
|
||||
- `pr.lastObservedAt`: ISO timestamp or null
|
||||
|
||||
Notes:
|
||||
|
||||
- `pr.state` must represent PR truth only.
|
||||
- Existing lifecycle values such as `pr_open`, `ci_failed`, `review_pending`, `changes_requested`, `approved`, and `mergeable` move under `pr.reason`, not `session.state`.
|
||||
|
||||
### 3. Runtime Domain
|
||||
|
||||
Persisted fields:
|
||||
|
||||
- `runtime.state`: `unknown | alive | exited | missing | probe_failed`
|
||||
- `runtime.reason`: `spawn_incomplete | process_running | process_missing | tmux_missing | manual_kill_requested | probe_error`
|
||||
- `runtime.lastObservedAt`: ISO timestamp or null
|
||||
- `runtime.handle`: existing runtime handle payload
|
||||
- `runtime.tmuxName`: existing tmux name if applicable
|
||||
|
||||
Notes:
|
||||
|
||||
- Stage 1 does not finish the full recovery policy.
|
||||
- Stage 1 does persist enough runtime truth to support the redesign brief's `detecting` state later without inventing it in the UI.
|
||||
|
||||
## What Remains Derived In Stage 1
|
||||
|
||||
The following should remain derived projections, not canonical persisted truth:
|
||||
|
||||
1. Dashboard attention zones such as `merge`, `respond`, `review`, `pending`, `working`, `done`.
|
||||
2. Notification priority.
|
||||
3. Whether a session should be shown as “actionable”.
|
||||
4. Aggregate dashboard stats.
|
||||
5. Temporary PR enrichment such as CI checks, review comment lists, and mergeability details.
|
||||
|
||||
## Persistence Design
|
||||
|
||||
Stage 1 should keep flat files, but move from loose keys to a versioned structured payload inside metadata.
|
||||
|
||||
### File Format Approach
|
||||
|
||||
Keep the existing key-value metadata file as the storage envelope for compatibility, but add a new canonical JSON field:
|
||||
|
||||
- `stateVersion=2`
|
||||
- `statePayload=<json>`
|
||||
|
||||
`statePayload` should be a compact JSON object containing the three truth domains and key timestamps.
|
||||
|
||||
This is preferable to scattering dozens of new top-level keys because:
|
||||
|
||||
1. Stage 1 needs schema versioning.
|
||||
2. The lifecycle record is hierarchical by nature.
|
||||
3. Backward compatibility can be maintained by dual-writing selected legacy keys during migration.
|
||||
|
||||
### Proposed `statePayload` Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"session": {
|
||||
"kind": "worker",
|
||||
"state": "working",
|
||||
"reason": "fixing_ci",
|
||||
"startedAt": "2026-04-15T12:00:00.000Z",
|
||||
"completedAt": null,
|
||||
"terminatedAt": null,
|
||||
"lastTransitionAt": "2026-04-15T12:34:56.000Z"
|
||||
},
|
||||
"pr": {
|
||||
"state": "open",
|
||||
"reason": "ci_failing",
|
||||
"number": 123,
|
||||
"url": "https://github.com/org/repo/pull/123",
|
||||
"lastObservedAt": "2026-04-15T12:34:30.000Z"
|
||||
},
|
||||
"runtime": {
|
||||
"state": "alive",
|
||||
"reason": "process_running",
|
||||
"lastObservedAt": "2026-04-15T12:34:20.000Z",
|
||||
"handle": {},
|
||||
"tmuxName": "abc123-app-4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Legacy Keys To Keep During Migration
|
||||
|
||||
Stage 1 should continue to write these top-level metadata keys for compatibility:
|
||||
|
||||
- `status`
|
||||
- `pr`
|
||||
- `branch`
|
||||
- `issue`
|
||||
- `project`
|
||||
- `agent`
|
||||
- `createdAt`
|
||||
- `runtimeHandle`
|
||||
- `tmuxName`
|
||||
- `role`
|
||||
|
||||
`status` becomes a compatibility projection only. It should be derived from `statePayload`, never treated as the primary truth once Stage 1 lands.
|
||||
|
||||
## Core Type Changes Planned In Stage 1
|
||||
|
||||
### `packages/core/src/types.ts`
|
||||
|
||||
Add new types:
|
||||
|
||||
- `CanonicalSessionState`
|
||||
- `CanonicalSessionReason`
|
||||
- `CanonicalPRState`
|
||||
- `CanonicalPRReason`
|
||||
- `CanonicalRuntimeState`
|
||||
- `CanonicalRuntimeReason`
|
||||
- `SessionStateRecord`
|
||||
- `PRStateRecord`
|
||||
- `RuntimeStateRecord`
|
||||
- `CanonicalSessionLifecycle`
|
||||
|
||||
Adjust `Session` to include:
|
||||
|
||||
- `lifecycle: CanonicalSessionLifecycle`
|
||||
|
||||
Keep the existing `status` and `activity` fields for compatibility in Stage 1, but document them as derived/legacy-facing.
|
||||
|
||||
### `packages/core/src/utils/validation.ts`
|
||||
|
||||
Replace the current `validateStatus()`-only approach with:
|
||||
|
||||
1. validation for `stateVersion`
|
||||
2. parsing for `statePayload`
|
||||
3. fallback synthesis from legacy metadata if `statePayload` is absent
|
||||
|
||||
### `packages/core/src/utils/session-from-metadata.ts`
|
||||
|
||||
Refactor session reconstruction so:
|
||||
|
||||
1. canonical lifecycle is parsed first
|
||||
2. legacy `status` is only synthesized when needed
|
||||
3. `activity` is not confused with canonical workflow state
|
||||
|
||||
## Metadata Read/Write Plan
|
||||
|
||||
### `packages/core/src/metadata.ts`
|
||||
|
||||
Stage 1 changes:
|
||||
|
||||
1. Add helpers to read and write `statePayload`.
|
||||
2. Add a migration-safe update helper for lifecycle subtrees, so callers do not hand-edit raw JSON strings.
|
||||
3. Continue exposing raw metadata functions for compatibility, but shift new code to typed helpers.
|
||||
|
||||
New helper candidates:
|
||||
|
||||
- `readCanonicalLifecycle(dataDir, sessionId)`
|
||||
- `writeCanonicalLifecycle(dataDir, sessionId, lifecycle)`
|
||||
- `updateCanonicalLifecycle(dataDir, sessionId, updater)`
|
||||
- `deriveLegacyStatus(lifecycle)`
|
||||
|
||||
## Session Manager Work In Stage 1
|
||||
|
||||
### Spawn Paths
|
||||
|
||||
Update both worker and orchestrator spawn flows in `packages/core/src/session-manager.ts` so newly created sessions persist canonical lifecycle from the first write.
|
||||
|
||||
Worker spawn should initialize as:
|
||||
|
||||
- `session.kind = worker`
|
||||
- `session.state = not_started`
|
||||
- `session.reason = spawn_requested`
|
||||
- `pr.state = none`
|
||||
- `pr.reason = not_created`
|
||||
- `runtime.state = unknown`
|
||||
- `runtime.reason = spawn_incomplete`
|
||||
|
||||
Orchestrator spawn should initialize the same shape with `session.kind = orchestrator`.
|
||||
|
||||
### Session Reconstruction
|
||||
|
||||
`list()`, `get()`, `restore()`, `kill()`, and `claimPR()` must all read and mutate canonical lifecycle first, then project legacy keys.
|
||||
|
||||
### PR Claim / Attachment
|
||||
|
||||
When a PR is attached or detected:
|
||||
|
||||
1. update `pr.state` and `pr.reason`
|
||||
2. do not overwrite `session.state` unless the workflow truth actually changed
|
||||
3. stop using `status=pr_open` as the authoritative record
|
||||
|
||||
### Kill / Restore Semantics
|
||||
|
||||
Stage 1 should make these writes explicit:
|
||||
|
||||
1. manual kill updates `session.state = terminated`, `session.reason = manually_killed`
|
||||
2. merged PR does not automatically terminate the session
|
||||
3. restore eligibility should be evaluated from canonical runtime/session truth, not only legacy `status`
|
||||
|
||||
## Lifecycle Manager Work In Stage 1
|
||||
|
||||
### Determine Truth, Then Project
|
||||
|
||||
Refactor `determineStatus()` into a canonical evaluation flow:
|
||||
|
||||
1. observe runtime evidence
|
||||
2. observe agent activity evidence
|
||||
3. observe PR evidence
|
||||
4. compute canonical `runtime`
|
||||
5. compute canonical `pr`
|
||||
6. compute canonical `session`
|
||||
7. derive legacy `status` for compatibility
|
||||
|
||||
This keeps policy readable and prevents PR truth from overwriting workflow truth.
|
||||
|
||||
### Canonical Transition Rules For Stage 1
|
||||
|
||||
Initial rules:
|
||||
|
||||
1. runtime dead plus explicit manual kill signal => `session.terminated / manually_killed`
|
||||
2. runtime dead without clear cause => `session.detecting / runtime_lost`
|
||||
3. agent waiting for input => `session.needs_input / awaiting_user_input`
|
||||
4. agent blocked or stale beyond threshold => `session.stuck / error_in_process` or `session.stuck / probe_failure` based on evidence
|
||||
5. PR open with CI failure while agent is working => `session.working / fixing_ci`, `pr.open / ci_failing`
|
||||
6. PR open with review comments while agent is working => `session.working / resolving_review_comments`, `pr.open / changes_requested`
|
||||
7. PR merged while runtime alive => `session.idle / merged_waiting_decision`, `pr.merged / merged`
|
||||
|
||||
Stage 1 should encode these rules in code and tests, but avoid introducing later-stage automation decisions tied to them.
|
||||
|
||||
### Transition Evidence
|
||||
|
||||
Each canonical transition should persist:
|
||||
|
||||
- previous state
|
||||
- new state
|
||||
- reason
|
||||
- observedAt
|
||||
|
||||
Stage 1 does not need a full append-only event log, but it should at minimum make the latest transition timestamp and reason durable.
|
||||
|
||||
## Web/API Compatibility Plan
|
||||
|
||||
Stage 1 should avoid breaking the current dashboard contract while shifting the source of truth.
|
||||
|
||||
### `packages/web/src/lib/serialize.ts`
|
||||
|
||||
1. Serialize the new canonical lifecycle alongside existing fields.
|
||||
2. Continue populating `status` and `activity` for current UI consumers.
|
||||
3. Prefer deriving `status` from canonical lifecycle rather than directly from old metadata.
|
||||
|
||||
### `packages/web/src/lib/types.ts`
|
||||
|
||||
Add optional fields for the new lifecycle record to `DashboardSession`, but do not require the UI to consume them yet.
|
||||
|
||||
Proposed additions:
|
||||
|
||||
- `lifecycle.sessionState`
|
||||
- `lifecycle.sessionReason`
|
||||
- `lifecycle.prState`
|
||||
- `lifecycle.prReason`
|
||||
- `lifecycle.runtimeState`
|
||||
- `lifecycle.runtimeReason`
|
||||
|
||||
This lets Stage 2 update UI components without another API shape migration.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Read Path
|
||||
|
||||
1. If `statePayload` exists and validates, use it.
|
||||
2. If not, synthesize canonical lifecycle from legacy metadata plus live runtime enrichment.
|
||||
3. If synthesis succeeds, dual-write back `statePayload` lazily on the next safe update.
|
||||
|
||||
### Write Path
|
||||
|
||||
For all state mutations during Stage 1:
|
||||
|
||||
1. write canonical lifecycle
|
||||
2. derive and write legacy `status`
|
||||
3. keep existing top-level keys synchronized during the migration window
|
||||
|
||||
### Archive Compatibility
|
||||
|
||||
Archived session files must preserve `statePayload` unchanged. Restore logic must be able to read either v1-only metadata or v2 metadata with `statePayload`.
|
||||
|
||||
## Test Plan For Stage 1
|
||||
|
||||
### Core Unit Tests
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
1. canonical lifecycle parsing from fresh v2 metadata
|
||||
2. fallback synthesis from legacy metadata
|
||||
3. legacy status derivation from canonical lifecycle
|
||||
4. worker spawn initialization
|
||||
5. orchestrator spawn initialization
|
||||
6. PR attach and PR detect flows
|
||||
7. merged-PR-but-runtime-alive behavior
|
||||
8. manual kill behavior
|
||||
9. restore eligibility derived from canonical lifecycle
|
||||
10. archive read/write compatibility
|
||||
|
||||
### Lifecycle Manager Tests
|
||||
|
||||
Cover at least these cases:
|
||||
|
||||
1. runtime alive, no PR, active agent => `working / task_in_progress`
|
||||
2. waiting input => `needs_input / awaiting_user_input`
|
||||
3. idle too long without PR => `stuck`
|
||||
4. PR open + CI failing => `pr.open / ci_failing`, session remains workflow-based
|
||||
5. PR merged + runtime alive => session does not become terminated
|
||||
6. runtime probes fail => `detecting` rather than a misleading terminal label
|
||||
|
||||
### Web Serialization Tests
|
||||
|
||||
Add tests proving:
|
||||
|
||||
1. existing dashboard consumers still receive `status` and `activity`
|
||||
2. canonical lifecycle fields are serialized consistently
|
||||
3. attention calculations still work during Stage 1 compatibility mode
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Add canonical lifecycle types and metadata helpers in `core`.
|
||||
2. Add lifecycle parsing and legacy synthesis helpers.
|
||||
3. Update session spawn paths to write canonical lifecycle from creation time.
|
||||
4. Update session reconstruction paths to consume canonical lifecycle.
|
||||
5. Refactor lifecycle-manager evaluation to compute canonical session/pr/runtime truth before deriving legacy status.
|
||||
6. Update serialization and dashboard session types to expose canonical lifecycle without changing the UI behavior yet.
|
||||
7. Add migration tests and compatibility tests.
|
||||
|
||||
## Risks And Mitigations
|
||||
|
||||
1. Risk: dual-writing legacy `status` and canonical lifecycle can drift.
|
||||
Mitigation: centralize all lifecycle writes behind typed helpers and ban direct raw `status` mutations outside compatibility helpers.
|
||||
|
||||
2. Risk: merged sessions regress restore/kill behavior.
|
||||
Mitigation: explicitly test merged PR with runtime alive, merged PR with runtime dead, and manual kill after merge.
|
||||
|
||||
3. Risk: orchestrator sessions accidentally inherit worker-only PR semantics.
|
||||
Mitigation: persist `session.kind` from day one and gate PR-derived transitions on it.
|
||||
|
||||
4. Risk: old archived sessions become unreadable.
|
||||
Mitigation: keep lazy migration on read and never require `statePayload` for restore.
|
||||
|
||||
## Explicit Deferrals To Later Stages
|
||||
|
||||
These are out of scope for Stage 1:
|
||||
|
||||
1. agent-driven explicit acknowledgement commands such as `ao acknowledge`
|
||||
2. new user notifications and UX flows for merged-but-still-running sessions
|
||||
3. CI polling cadence redesign
|
||||
4. review-comment file persistence and handoff automation
|
||||
5. auto-recovery and resume orchestration
|
||||
6. dashboard redesign for disagreement/detecting visualization
|
||||
7. learning pipelines for closed PRs
|
||||
|
||||
## Definition Of Done For Stage 1
|
||||
|
||||
Stage 1 is complete when:
|
||||
|
||||
1. every new session persists canonical `session`, `pr`, and `runtime` truth from creation onward
|
||||
2. existing sessions can still be read through legacy metadata without breakage
|
||||
3. lifecycle-manager transitions write canonical reasoned state, not only a flat `status`
|
||||
4. merged PRs no longer imply session termination at the canonical model layer
|
||||
5. the web/API layer can expose canonical lifecycle data without breaking current consumers
|
||||
6. all foundation work is covered by tests and no UI redesign or later-stage automation work has started
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
# State Machine Stage 2 Plan: Evidence, Detecting, And Recovery
|
||||
|
||||
Status: Draft
|
||||
Primary issue: #95
|
||||
Target branch: `sessions-redone`
|
||||
Inputs:
|
||||
- `~/.ao/ao-ahead/human-work/redesign.md`
|
||||
- `~/.ao/ao-ahead/aa-2/state-machine-redesign-rollout-plan.md`
|
||||
- `docs/state-machine-redesign.md`
|
||||
- `CLAUDE.md`
|
||||
|
||||
## Purpose
|
||||
|
||||
Stage 2 is where lifecycle classification stops being a pile of loosely competing probes and becomes an explicit evidence assessment system.
|
||||
|
||||
The goal is not to add more statuses. The goal is to make AO answer six questions cleanly and in order:
|
||||
|
||||
1. what evidence exists right now
|
||||
2. how fresh and trustworthy that evidence is
|
||||
3. whether the evidence agrees
|
||||
4. whether the system should enter `detecting`
|
||||
5. whether retries are still allowed
|
||||
6. whether AO should recover automatically, wait for a human, or declare a terminal outcome
|
||||
|
||||
This document is planning only. It defines policy, sequencing, and acceptance criteria for Stage 2. It does not prescribe code-level implementation details yet.
|
||||
|
||||
## Scope
|
||||
|
||||
Stage 2 covers:
|
||||
|
||||
- evidence assessment for runtime, process, activity, and PR-adjacent signals
|
||||
- `detecting` state semantics and exit rules
|
||||
- signal disagreement classification
|
||||
- stale evidence policy by source type
|
||||
- bounded retry policy for uncertain or failed probes
|
||||
- recovery rules for worker and orchestrator sessions
|
||||
- decision logging needed to explain why AO chose a state
|
||||
|
||||
Stage 2 does not cover:
|
||||
|
||||
- agent-authored lifecycle reporting commands
|
||||
- prompt changes to force explicit acknowledgment or waiting signals
|
||||
- UI redesign beyond the minimum data contracts Stage 2 requires
|
||||
- full PR workflow automation redesign
|
||||
|
||||
## Design Position
|
||||
|
||||
Stage 2 should treat runtime truth, process truth, activity truth, and PR truth as separate inputs to an assessment pass. No single weak signal should be allowed to kill a session. `detecting` is the controlled buffer state used when the system cannot yet defend a confident answer.
|
||||
|
||||
The practical policy is:
|
||||
|
||||
- prefer explicit facts over heuristics
|
||||
- prefer `detecting` over a false terminal label
|
||||
- prefer bounded waiting over infinite ambiguity
|
||||
- prefer recovery when the session is plausibly resumable
|
||||
- prefer human escalation when the evidence is conflicting after retries
|
||||
|
||||
## Evidence Inventory
|
||||
|
||||
Each assessment pass should normalize evidence into a common shape with `source`, `observedAt`, `freshness`, `reliability`, and `value`.
|
||||
|
||||
### Source classes
|
||||
|
||||
- runtime evidence
|
||||
- tmux/session handle exists
|
||||
- runtime plugin reports reachable or unreachable
|
||||
- process evidence
|
||||
- agent process scan reports running or not running
|
||||
- pid metadata exists or is missing
|
||||
- activity evidence
|
||||
- structured agent activity
|
||||
- AO-generated activity markers
|
||||
- terminal output heartbeat only as weak fallback
|
||||
- recovery evidence
|
||||
- provider-specific session resumability
|
||||
- known recovery action available
|
||||
- PR evidence
|
||||
- PR open, merged, closed, CI pending, CI failing, review comments
|
||||
- operator evidence
|
||||
- explicit user kill
|
||||
- explicit user continue/resume request
|
||||
|
||||
### Reliability tiers
|
||||
|
||||
- `authoritative`
|
||||
- explicit user kill
|
||||
- successful runtime/plugin confirmation
|
||||
- successful provider resumability check
|
||||
- `strong`
|
||||
- process running or not running from a validated probe
|
||||
- fresh structured activity from AO or provider logs
|
||||
- `supporting`
|
||||
- recent terminal output without structured semantics
|
||||
- existing metadata from prior successful scans
|
||||
- `historical`
|
||||
- stale activity
|
||||
- previous assessment results
|
||||
|
||||
## Assessment Model
|
||||
|
||||
Every lifecycle pass should produce an evidence report before it produces a session state.
|
||||
|
||||
### Pass order
|
||||
|
||||
1. collect the latest evidence for runtime, process, activity, recovery, and PR
|
||||
2. grade each evidence item for freshness and reliability
|
||||
3. detect contradictions
|
||||
4. decide whether the contradiction is resolvable within retry bounds
|
||||
5. emit one of:
|
||||
- confident classification
|
||||
- `detecting`
|
||||
- terminal outcome with explicit reason
|
||||
- recovery-needed classification
|
||||
|
||||
### Required outputs
|
||||
|
||||
Each pass should produce:
|
||||
|
||||
- session assessment outcome
|
||||
- primary reason
|
||||
- evidence summary
|
||||
- disagreement summary if any
|
||||
- retry counter state
|
||||
- recovery recommendation if any
|
||||
|
||||
## `detecting` State Semantics
|
||||
|
||||
`detecting` is not a synonym for `unknown`. It is an active assessment window where AO is intentionally trying to resolve conflicting, missing, or failed evidence.
|
||||
|
||||
### Enter `detecting` when
|
||||
|
||||
- runtime and process signals disagree
|
||||
- runtime is unreachable but fresh activity still exists
|
||||
- process probe fails in a way that could be transient
|
||||
- activity is recent enough to block a dead classification but not enough to confirm health
|
||||
- recovery appears possible but the recovery preconditions are not yet confirmed
|
||||
- required probes time out or return incomplete data
|
||||
|
||||
### Do not enter `detecting` when
|
||||
|
||||
- user kill is explicit and confirmed
|
||||
- runtime and process are both confirmed dead and no recovery path exists
|
||||
- session is intentionally terminal for a reason independent of liveness
|
||||
- the same disagreement has already exhausted the retry budget
|
||||
|
||||
### Exit `detecting` when
|
||||
|
||||
- evidence converges on a confident non-terminal state
|
||||
- evidence converges on a terminal outcome
|
||||
- bounded retries are exhausted and the result must be escalated
|
||||
- recovery succeeds and a new healthy state is established
|
||||
- recovery is impossible and the terminal reason is clear
|
||||
|
||||
## Signal Disagreement Handling
|
||||
|
||||
Disagreement handling must be explicit. Stage 2 should classify disagreements instead of burying them inside ad hoc fallthrough logic.
|
||||
|
||||
### Core disagreement classes
|
||||
|
||||
- `runtime_alive_process_dead`
|
||||
- `runtime_dead_process_alive`
|
||||
- `runtime_dead_recent_activity`
|
||||
- `process_alive_no_recent_activity`
|
||||
- `probe_failed_runtime_unknown`
|
||||
- `probe_failed_process_unknown`
|
||||
- `pr_terminal_runtime_alive`
|
||||
- `recovery_possible_runtime_uncertain`
|
||||
|
||||
### Resolution policy
|
||||
|
||||
- `runtime_alive_process_dead`
|
||||
- treat as likely recoverable worker failure
|
||||
- enter `detecting`
|
||||
- retry process probe
|
||||
- evaluate resumability before declaring terminal
|
||||
- `runtime_dead_process_alive`
|
||||
- treat process evidence as suspicious
|
||||
- prefer `detecting`
|
||||
- retry runtime probe
|
||||
- downgrade process evidence if the runtime container/session cannot be confirmed
|
||||
- `runtime_dead_recent_activity`
|
||||
- treat stale-vs-live timing carefully
|
||||
- recent structured activity blocks immediate death
|
||||
- historical activity does not
|
||||
- `process_alive_no_recent_activity`
|
||||
- do not classify as dead
|
||||
- remain in current workflow state or `idle`/`stuck` depending on broader context
|
||||
- `pr_terminal_runtime_alive`
|
||||
- do not kill solely because the PR state is terminal
|
||||
- classify workflow and runtime separately
|
||||
|
||||
## Stale Evidence Policy
|
||||
|
||||
Stale evidence should explain history, not overrule current hard facts.
|
||||
|
||||
### Freshness windows
|
||||
|
||||
- structured activity
|
||||
- `0s-60s`: strong support for liveness
|
||||
- `61s-5m`: weak support for liveness, enough to block immediate death on its own
|
||||
- `>5m`: historical only
|
||||
- terminal heartbeat output
|
||||
- `0s-30s`: supporting only
|
||||
- `31s-2m`: weak historical context
|
||||
- `>2m`: ignore for liveness decisions
|
||||
- runtime/process probe results
|
||||
- `0s-30s`: current
|
||||
- `31s-90s`: usable but should be refreshed before a terminal classification
|
||||
- `>90s`: stale for decisive outcomes
|
||||
- PR state
|
||||
- remains authoritative for PR truth
|
||||
- does not become authoritative for runtime death
|
||||
|
||||
### Rules
|
||||
|
||||
- stale activity cannot by itself prove the session is alive
|
||||
- stale activity can explain why AO avoided a dead classification earlier
|
||||
- a terminal outcome requires current hard evidence or an explicit operator action
|
||||
- recovery eligibility may use stale evidence only to rank options, not to assert that recovery is safe
|
||||
|
||||
## Bounded Retry Policy
|
||||
|
||||
`detecting` must be temporary. Every disagreement path needs a fixed retry budget and an explicit escalation outcome.
|
||||
|
||||
### Retry budgets
|
||||
|
||||
- transient probe failures
|
||||
- retry up to 3 times
|
||||
- exponential spacing such as immediate, 10s, 30s
|
||||
- hard disagreement with partial evidence
|
||||
- retry up to 2 full assessment passes after the initial pass
|
||||
- recovery attempt verification
|
||||
- allow 1 recovery attempt per assessment cycle unless the operator explicitly asks again
|
||||
|
||||
### Retry stop conditions
|
||||
|
||||
- decisive evidence arrives
|
||||
- the retry budget is exhausted
|
||||
- an operator action overrides automated handling
|
||||
- the recovery subsystem returns a definitive non-recoverable result
|
||||
|
||||
### Escalation after retries
|
||||
|
||||
When retries are exhausted, AO should not loop forever. It should produce one of:
|
||||
|
||||
- `stuck` with a clear reason if human action is needed
|
||||
- terminal with explicit reason if death is defensible
|
||||
- recovery-pending-human if recovery exists but is unsafe to auto-run
|
||||
|
||||
## Recovery Rules
|
||||
|
||||
Recovery must be policy-driven, not implicit.
|
||||
|
||||
### Worker sessions
|
||||
|
||||
Auto-recovery is allowed when:
|
||||
|
||||
- the runtime is reachable or can be recreated safely
|
||||
- the provider session is resumable or a new worker can be created with preserved context
|
||||
- the failure reason is operational rather than semantic
|
||||
- there is no explicit user kill or explicit human stop condition
|
||||
|
||||
Human-gated recovery is required when:
|
||||
|
||||
- the evidence is still contradictory after retries
|
||||
- PR state suggests the worker should be preserved rather than replaced
|
||||
- the replacement action could duplicate work or create PR confusion
|
||||
- permissions or credentials are required
|
||||
|
||||
Do not auto-recover when:
|
||||
|
||||
- the user explicitly killed the session
|
||||
- the provider reports the session as non-resumable and replacement would violate workflow policy
|
||||
- the session is already intentionally terminal
|
||||
|
||||
### Orchestrator sessions
|
||||
|
||||
Orchestrators should have a higher bar for terminal classification and auto-recovery attempts because they coordinate other sessions.
|
||||
|
||||
Policy:
|
||||
|
||||
- prefer recovery over terminal classification
|
||||
- require stronger evidence before declaring them dead
|
||||
- never tie orchestrator death to PR state
|
||||
- surface unresolved orchestrator disagreements prominently for human review
|
||||
|
||||
## Recovery Outcome Matrix
|
||||
|
||||
### Recover automatically
|
||||
|
||||
- worker runtime reachable, process dead, resumable session available
|
||||
- worker runtime reachable, process dead, replacement worker policy explicitly allows restart
|
||||
|
||||
### Wait for human
|
||||
|
||||
- retries exhausted with unresolved disagreement
|
||||
- PR merged or closed but runtime is still alive and policy choice is needed
|
||||
- recovery would create a second worker or reopen a completed workflow
|
||||
|
||||
### Declare terminal
|
||||
|
||||
- explicit user kill confirmed
|
||||
- runtime and process both confirmed dead after fresh probes
|
||||
- recovery path explicitly unavailable and workflow policy allows termination
|
||||
|
||||
## Decision Logging Requirements
|
||||
|
||||
Stage 2 needs durable reasoning trails so the dashboard and operators can understand why a status changed.
|
||||
|
||||
Each assessment result should record:
|
||||
|
||||
- prior session state and reason
|
||||
- new session state and reason
|
||||
- major evidence items considered
|
||||
- freshness classification for each decisive signal
|
||||
- disagreement code if one existed
|
||||
- retry count and remaining budget
|
||||
- recovery action attempted, skipped, or blocked
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Stage 2 is ready when the plan can support these guarantees:
|
||||
|
||||
- AO no longer declares a session dead from one weak signal
|
||||
- `detecting` has defined entry and exit conditions
|
||||
- every disagreement class has a retry policy and an escalation outcome
|
||||
- stale evidence has explicit freshness windows by source type
|
||||
- worker and orchestrator recovery policies differ where operationally necessary
|
||||
- every terminal outcome has a defendable reason
|
||||
- operators can inspect why AO chose the current state
|
||||
|
||||
## Suggested Implementation Sequence
|
||||
|
||||
1. define evidence data shapes and disagreement codes
|
||||
2. define freshness grading and stale evidence rules
|
||||
3. extract lifecycle assessment into a dedicated evaluation step
|
||||
4. wire `detecting` entry, exit, and retry counters
|
||||
5. define recovery policy gates for worker versus orchestrator sessions
|
||||
6. add decision logging and test scenarios for each disagreement class
|
||||
|
||||
## Open Decisions To Confirm Before Coding
|
||||
|
||||
- whether the activity freshness windows should differ by agent provider
|
||||
- whether terminal output should be considered at all once structured activity exists
|
||||
- whether worker replacement and session resume are separate recovery classes in Stage 2 or deferred to Stage 3
|
||||
- whether retry counters live in session metadata or are recomputed from assessment history
|
||||
- whether `stuck` is emitted directly by Stage 2 or remains a later projection over assessment outcomes
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import type * as CoreModule from "@aoagents/ao-core";
|
||||
|
||||
const { mockConfigRef, mockSessionManager, mockApplyAgentReport, mockGetSessionsDir } = vi.hoisted(
|
||||
() => ({
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
mockApplyAgentReport: vi.fn(),
|
||||
mockGetSessionsDir: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof CoreModule;
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
getSessionsDir: (...args: unknown[]) => mockGetSessionsDir(...args),
|
||||
applyAgentReport: (...args: unknown[]) => mockApplyAgentReport(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
}));
|
||||
|
||||
import { registerAcknowledge, registerReport } from "../../src/commands/report.js";
|
||||
|
||||
describe("report commands", () => {
|
||||
let program: Command;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerAcknowledge(program);
|
||||
registerReport(program);
|
||||
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env["AO_SESSION_ID"];
|
||||
process.env["USER"] = "codex";
|
||||
|
||||
mockConfigRef.current = {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
path: "/tmp/app",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockSessionManager.get.mockReset();
|
||||
mockApplyAgentReport.mockReset();
|
||||
mockGetSessionsDir.mockReset();
|
||||
mockGetSessionsDir.mockReturnValue("/tmp/sessions");
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
});
|
||||
mockApplyAgentReport.mockReturnValue({
|
||||
previousState: "working",
|
||||
nextState: "started",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses AO_SESSION_ID for acknowledge when no explicit session is provided", async () => {
|
||||
process.env["AO_SESSION_ID"] = "app-1";
|
||||
|
||||
await program.parseAsync(["node", "test", "acknowledge", "--note", "picked up"]);
|
||||
|
||||
expect(mockSessionManager.get).toHaveBeenCalledWith("app-1");
|
||||
expect(mockApplyAgentReport).toHaveBeenCalledWith(
|
||||
"/tmp/sessions",
|
||||
"app-1",
|
||||
expect.objectContaining({
|
||||
state: "started",
|
||||
note: "picked up",
|
||||
source: "acknowledge",
|
||||
actor: "codex",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --session over AO_SESSION_ID", async () => {
|
||||
process.env["AO_SESSION_ID"] = "wrong-session";
|
||||
|
||||
await program.parseAsync(["node", "test", "report", "working", "--session", "app-2"]);
|
||||
|
||||
expect(mockSessionManager.get).toHaveBeenCalledWith("app-2");
|
||||
});
|
||||
|
||||
it("rejects unknown states before touching the session manager", async () => {
|
||||
await expect(program.parseAsync(["node", "test", "report", "bogus-state"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(mockSessionManager.get).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown state"));
|
||||
});
|
||||
|
||||
it("rejects invalid PR numbers", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "report", "pr-created", "--pr-number", "abc"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid PR number"));
|
||||
});
|
||||
|
||||
it("rejects PR metadata flags for non-PR workflow states", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "report", "working", "--pr-url", "https://example.com"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("PR metadata flags are only valid"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces session-not-found errors", async () => {
|
||||
mockSessionManager.get.mockResolvedValue(null);
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
|
||||
.rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Session not found"));
|
||||
});
|
||||
|
||||
it("surfaces project-not-found errors", async () => {
|
||||
mockConfigRef.current = {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
projects: {},
|
||||
};
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
|
||||
.rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Project not found for session"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces rejected reports from applyAgentReport", async () => {
|
||||
mockApplyAgentReport.mockImplementation(() => {
|
||||
throw new Error("PR number 7 does not match PR URL");
|
||||
});
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"report",
|
||||
"pr-created",
|
||||
"--session",
|
||||
"app-1",
|
||||
"--pr-number",
|
||||
"7",
|
||||
"--pr-url",
|
||||
"https://github.com/acme/app/pull/9",
|
||||
]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Report rejected: PR number 7 does not match PR URL"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* `ao acknowledge` and `ao report` — explicit agent reporting commands (Stage 3).
|
||||
*
|
||||
* These commands are invoked by the worker agent from inside its managed
|
||||
* session to declare workflow transitions (started / waiting / needs-input /
|
||||
* fixing-ci / addressing-reviews / completed).
|
||||
*
|
||||
* Both commands resolve the session from:
|
||||
* 1. Explicit `--session` / positional argument, OR
|
||||
* 2. the `AO_SESSION_ID` environment variable set by every agent plugin.
|
||||
*
|
||||
* The lifecycle manager prefers fresh reports over weak inference but runtime
|
||||
* evidence (process death, merged PR) still overrides — see
|
||||
* `packages/core/src/agent-report.ts` for the fallback matrix.
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
AGENT_REPORTED_STATES,
|
||||
applyAgentReport,
|
||||
getSessionsDir,
|
||||
loadConfig,
|
||||
normalizeAgentReportedState,
|
||||
type AgentReportedState,
|
||||
} from "@aoagents/ao-core";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
function resolveSessionId(explicit: string | undefined): string {
|
||||
const fromArg = explicit?.trim();
|
||||
if (fromArg) return fromArg;
|
||||
const fromEnv = process.env["AO_SESSION_ID"]?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
console.error(
|
||||
chalk.red(
|
||||
"No session provided. Pass a session name or set AO_SESSION_ID (set automatically inside managed sessions).",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function writeReport(
|
||||
sessionName: string,
|
||||
state: AgentReportedState,
|
||||
note: string | undefined,
|
||||
prUrl: string | undefined,
|
||||
prNumber: number | undefined,
|
||||
source: "acknowledge" | "report",
|
||||
): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
const session = await sm.get(sessionName);
|
||||
if (!session) {
|
||||
console.error(chalk.red(`Session not found: ${sessionName}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project) {
|
||||
console.error(chalk.red(`Project not found for session: ${sessionName}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const sessionsDir = getSessionsDir(config.configPath, project.path);
|
||||
try {
|
||||
const result = applyAgentReport(sessionsDir, sessionName, {
|
||||
state,
|
||||
note,
|
||||
prUrl,
|
||||
prNumber,
|
||||
source,
|
||||
actor: process.env["USER"] ?? process.env["LOGNAME"] ?? process.env["USERNAME"],
|
||||
});
|
||||
const label =
|
||||
result.previousState === result.nextState
|
||||
? chalk.dim(`(${result.nextState})`)
|
||||
: chalk.dim(`(${result.previousState} → ${result.nextState})`);
|
||||
console.log(
|
||||
`${chalk.green("✓")} ${chalk.bold(sessionName)} reported ${chalk.cyan(state)} ${label}`,
|
||||
);
|
||||
if (prUrl || prNumber !== undefined) {
|
||||
const details = [prNumber !== undefined ? `#${prNumber}` : null, prUrl ?? null].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
console.log(chalk.dim(` PR: ${details.join(" ")}`));
|
||||
}
|
||||
if (note) {
|
||||
console.log(chalk.dim(` note: ${note}`));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(chalk.red(`Report rejected: ${message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAcknowledge(program: Command): void {
|
||||
program
|
||||
.command("acknowledge")
|
||||
.description(
|
||||
"Acknowledge session pickup — agents run this once after reading the initial prompt (Stage 3).",
|
||||
)
|
||||
.argument("[session]", "Session ID (defaults to AO_SESSION_ID)")
|
||||
.option("--note <text>", "Optional brief note to include with the acknowledgment")
|
||||
.action(async (session: string | undefined, opts: { note?: string }) => {
|
||||
const sessionId = resolveSessionId(session);
|
||||
await writeReport(sessionId, "started", opts.note, undefined, undefined, "acknowledge");
|
||||
});
|
||||
}
|
||||
|
||||
export function registerReport(program: Command): void {
|
||||
const allowed = AGENT_REPORTED_STATES.join(", ");
|
||||
program
|
||||
.command("report")
|
||||
.description(
|
||||
`Declare a workflow transition (Stage 3). Allowed states: ${allowed} (hyphenated aliases accepted).`,
|
||||
)
|
||||
.argument(
|
||||
"<state>",
|
||||
`One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, pr-created, ready-for-review, ...)`,
|
||||
)
|
||||
.option("-s, --session <id>", "Session ID (defaults to AO_SESSION_ID)")
|
||||
.option("--note <text>", "Optional brief note to include with the report")
|
||||
.option(
|
||||
"--pr-url <url>",
|
||||
"Attach a PR URL to pr-created / draft-pr-created / ready-for-review reports",
|
||||
)
|
||||
.option(
|
||||
"--pr-number <number>",
|
||||
"Attach a PR number to pr-created / draft-pr-created / ready-for-review reports",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
state: string,
|
||||
opts: { session?: string; note?: string; prUrl?: string; prNumber?: string },
|
||||
) => {
|
||||
const canonical = normalizeAgentReportedState(state);
|
||||
if (!canonical) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Unknown state: ${state}. Allowed: ${allowed} (or aliases: fixing-ci, addressing-reviews, needs-input, pr-created, ready-for-review).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const prWorkflowState =
|
||||
canonical === "pr_created" ||
|
||||
canonical === "draft_pr_created" ||
|
||||
canonical === "ready_for_review";
|
||||
if (!prWorkflowState && (opts.prUrl || opts.prNumber)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
"PR metadata flags are only valid with pr-created, draft-pr-created, or ready-for-review.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const prNumber =
|
||||
opts.prNumber !== undefined ? Number.parseInt(opts.prNumber, 10) : undefined;
|
||||
if (
|
||||
opts.prNumber !== undefined &&
|
||||
(!Number.isInteger(prNumber) || prNumber === undefined || prNumber <= 0)
|
||||
) {
|
||||
console.error(chalk.red(`Invalid PR number: ${opts.prNumber}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const sessionId = resolveSessionId(opts.session);
|
||||
await writeReport(sessionId, canonical, opts.note, opts.prUrl, prNumber, "report");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ export function registerSession(program: Command): void {
|
|||
|
||||
session
|
||||
.command("cleanup")
|
||||
.description("Kill sessions where PR is merged or issue is closed")
|
||||
.description("Kill cleanup-eligible sessions with closed work or dead runtimes")
|
||||
.option("-p, --project <id>", "Filter by project ID")
|
||||
.option("--dry-run", "Show what would be cleaned up without doing it")
|
||||
.action(async (opts: { project?: string; dryRun?: boolean }) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
type Agent,
|
||||
type SCM,
|
||||
type Session,
|
||||
|
|
@ -474,17 +476,26 @@ async function showFallbackStatus(): Promise<void> {
|
|||
const details = await Promise.all(
|
||||
sortedSessions.map(async (session) => {
|
||||
const activityTsPromise = getTmuxActivity(session).catch(() => null);
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
lifecycle.runtime.handle = { id: session, runtimeName: "tmux", data: {} };
|
||||
|
||||
const sessionObj: Session = {
|
||||
id: session,
|
||||
projectId: "",
|
||||
status: "working",
|
||||
activity: null,
|
||||
activitySignal: createActivitySignal("unavailable"),
|
||||
lifecycle,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: session, runtimeName: "tmux", data: {} },
|
||||
runtimeHandle: lifecycle.runtime.handle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { registerStatus } from "./commands/status.js";
|
|||
import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
|
||||
import { registerSession } from "./commands/session.js";
|
||||
import { registerSend } from "./commands/send.js";
|
||||
import { registerAcknowledge, registerReport } from "./commands/report.js";
|
||||
import { registerReviewCheck } from "./commands/review-check.js";
|
||||
import { registerDashboard } from "./commands/dashboard.js";
|
||||
import { registerOpen } from "./commands/open.js";
|
||||
|
|
@ -32,6 +33,8 @@ export function createProgram(): Command {
|
|||
registerBatchSpawn(program);
|
||||
registerSession(program);
|
||||
registerSend(program);
|
||||
registerAcknowledge(program);
|
||||
registerReport(program);
|
||||
registerReviewCheck(program);
|
||||
registerDashboard(program);
|
||||
registerOpen(program);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTIVITY_STRONG_WINDOW_MS,
|
||||
ACTIVITY_WEAK_WINDOW_MS,
|
||||
classifyActivitySignal,
|
||||
createActivitySignal,
|
||||
hasPositiveIdleEvidence,
|
||||
isWeakActivityEvidence,
|
||||
summarizeActivityFreshness,
|
||||
supportsRecentLiveness,
|
||||
} from "../activity-signal.js";
|
||||
|
||||
describe("summarizeActivityFreshness", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("returns none without a timestamp", () => {
|
||||
expect(summarizeActivityFreshness(undefined, now)).toBe("none");
|
||||
});
|
||||
|
||||
it("classifies exact strong and weak boundaries correctly", () => {
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_STRONG_WINDOW_MS), now),
|
||||
).toBe("strong");
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS), now),
|
||||
).toBe("weak");
|
||||
});
|
||||
|
||||
it("treats future timestamps as strong instead of going negative", () => {
|
||||
expect(summarizeActivityFreshness(new Date("2025-01-01T12:01:00.000Z"), now)).toBe("strong");
|
||||
});
|
||||
|
||||
it("returns stale when the timestamp is older than the weak window", () => {
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1), now),
|
||||
).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyActivitySignal", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("keeps fresh active activity as valid", () => {
|
||||
expect(
|
||||
classifyActivitySignal(
|
||||
{
|
||||
state: "active",
|
||||
timestamp: new Date("2025-01-01T11:59:30.000Z"),
|
||||
},
|
||||
"native",
|
||||
now,
|
||||
),
|
||||
).toEqual({
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-01-01T11:59:30.000Z"),
|
||||
source: "native",
|
||||
detail: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks idle-without-timestamp as stale missing_timestamp evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "idle" }, "native", now)).toEqual({
|
||||
state: "stale",
|
||||
activity: "idle",
|
||||
timestamp: undefined,
|
||||
source: "native",
|
||||
detail: "missing_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks blocked-without-timestamp as stale missing_timestamp evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "blocked" }, "terminal", now)).toEqual({
|
||||
state: "stale",
|
||||
activity: "blocked",
|
||||
timestamp: undefined,
|
||||
source: "terminal",
|
||||
detail: "missing_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps active-without-timestamp valid because it is positive liveness evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "active" }, "native", now).state).toBe("valid");
|
||||
});
|
||||
|
||||
it("marks stale active evidence as stale_timestamp", () => {
|
||||
expect(
|
||||
classifyActivitySignal(
|
||||
{
|
||||
state: "active",
|
||||
timestamp: new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1),
|
||||
},
|
||||
"native",
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "stale",
|
||||
activity: "active",
|
||||
detail: "stale_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exited activity valid even without timestamp", () => {
|
||||
expect(classifyActivitySignal({ state: "exited" }, "runtime", now)).toEqual({
|
||||
state: "valid",
|
||||
activity: "exited",
|
||||
timestamp: undefined,
|
||||
source: "runtime",
|
||||
detail: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity signal helpers", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("detects positive idle evidence only for valid idle states with timestamps", () => {
|
||||
expect(
|
||||
hasPositiveIdleEvidence(
|
||||
createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2025-01-01T11:59:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasPositiveIdleEvidence(createActivitySignal("valid", { activity: "idle", source: "native" })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasPositiveIdleEvidence(
|
||||
createActivitySignal("stale", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2025-01-01T11:59:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("supports recent liveness only for valid active/ready timestamps within the weak window", () => {
|
||||
expect(
|
||||
supportsRecentLiveness(
|
||||
createActivitySignal("valid", {
|
||||
activity: "ready",
|
||||
timestamp: new Date("2025-01-01T11:56:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsRecentLiveness(
|
||||
createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-01-01T11:54:59.999Z"),
|
||||
source: "native",
|
||||
}),
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats all non-valid signals as weak evidence", () => {
|
||||
expect(isWeakActivityEvidence(createActivitySignal("valid", { activity: "active" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isWeakActivityEvidence(createActivitySignal("stale", { activity: "active" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWeakActivityEvidence(createActivitySignal("unavailable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||
AGENT_REPORTED_STATES,
|
||||
AGENT_REPORT_FRESHNESS_MS,
|
||||
AGENT_REPORT_METADATA_KEYS,
|
||||
applyAgentReport,
|
||||
isAgentReportFresh,
|
||||
mapAgentReportToLifecycle,
|
||||
normalizeAgentReportedState,
|
||||
readAgentReport,
|
||||
readAgentReportAuditTrail,
|
||||
validateAgentReportTransition,
|
||||
} from "../agent-report.js";
|
||||
import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import type { CanonicalSessionLifecycle } from "../types.js";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedWorkerSession(
|
||||
sessionId: string,
|
||||
init?: Partial<CanonicalSessionLifecycle["session"]>,
|
||||
): CanonicalSessionLifecycle {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
// Default the seeded lifecycle to "working" with an existing startedAt so
|
||||
// tests exercise the transition-applied path (not the first-start path).
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z";
|
||||
if (init) {
|
||||
Object.assign(lifecycle.session, init);
|
||||
}
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
writeMetadata(dataDir, sessionId, {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/x",
|
||||
status: "working",
|
||||
project: "demo",
|
||||
});
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
describe("normalizeAgentReportedState", () => {
|
||||
it("accepts canonical values", () => {
|
||||
for (const state of AGENT_REPORTED_STATES) {
|
||||
expect(normalizeAgentReportedState(state)).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts hyphen and short aliases", () => {
|
||||
expect(normalizeAgentReportedState("needs-input")).toBe("needs_input");
|
||||
expect(normalizeAgentReportedState("fixing-ci")).toBe("fixing_ci");
|
||||
expect(normalizeAgentReportedState("addressing-reviews")).toBe("addressing_reviews");
|
||||
expect(normalizeAgentReportedState("pr-created")).toBe("pr_created");
|
||||
expect(normalizeAgentReportedState("draft-pr-created")).toBe("draft_pr_created");
|
||||
expect(normalizeAgentReportedState("ready-for-review")).toBe("ready_for_review");
|
||||
expect(normalizeAgentReportedState("ci")).toBe("fixing_ci");
|
||||
expect(normalizeAgentReportedState("reviews")).toBe("addressing_reviews");
|
||||
expect(normalizeAgentReportedState("complete")).toBe("completed");
|
||||
expect(normalizeAgentReportedState("input")).toBe("needs_input");
|
||||
expect(normalizeAgentReportedState("start")).toBe("started");
|
||||
expect(normalizeAgentReportedState("work")).toBe("working");
|
||||
expect(normalizeAgentReportedState("wait")).toBe("waiting");
|
||||
});
|
||||
|
||||
it("does not alias `done` (agents cannot self-report terminal done)", () => {
|
||||
expect(normalizeAgentReportedState("done")).toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive and trims whitespace", () => {
|
||||
expect(normalizeAgentReportedState(" WAITING ")).toBe("waiting");
|
||||
expect(normalizeAgentReportedState("Needs-Input")).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("returns null for unknown values", () => {
|
||||
expect(normalizeAgentReportedState("foo")).toBeNull();
|
||||
expect(normalizeAgentReportedState("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapAgentReportToLifecycle", () => {
|
||||
it("maps every reportable state to a canonical pair", () => {
|
||||
for (const state of AGENT_REPORTED_STATES) {
|
||||
const mapped = mapAgentReportToLifecycle(state);
|
||||
expect(mapped.sessionState).toBeTypeOf("string");
|
||||
expect(mapped.sessionReason).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps needs_input to the canonical needs_input state", () => {
|
||||
expect(mapAgentReportToLifecycle("needs_input")).toEqual({
|
||||
sessionState: "needs_input",
|
||||
sessionReason: "awaiting_user_input",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps waiting and completed to idle (non-terminal)", () => {
|
||||
expect(mapAgentReportToLifecycle("waiting").sessionState).toBe("idle");
|
||||
expect(mapAgentReportToLifecycle("completed").sessionState).toBe("idle");
|
||||
});
|
||||
|
||||
it("maps fixing_ci and addressing_reviews to working with the right reason", () => {
|
||||
expect(mapAgentReportToLifecycle("fixing_ci")).toEqual({
|
||||
sessionState: "working",
|
||||
sessionReason: "fixing_ci",
|
||||
});
|
||||
expect(mapAgentReportToLifecycle("addressing_reviews")).toEqual({
|
||||
sessionState: "working",
|
||||
sessionReason: "resolving_review_comments",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps PR workflow reports to the expected session phase", () => {
|
||||
expect(mapAgentReportToLifecycle("pr_created")).toEqual({
|
||||
sessionState: "idle",
|
||||
sessionReason: "pr_created",
|
||||
});
|
||||
expect(mapAgentReportToLifecycle("draft_pr_created")).toEqual({
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
});
|
||||
expect(mapAgentReportToLifecycle("ready_for_review")).toEqual({
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAgentReportTransition", () => {
|
||||
it("rejects orchestrator sessions", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("orchestrator");
|
||||
const result = validateAgentReportTransition(lifecycle, "working");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/orchestrator/);
|
||||
});
|
||||
|
||||
it("rejects terminated sessions", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "terminated";
|
||||
const result = validateAgentReportTransition(lifecycle, "working");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects all reports when session is done (terminal state cannot reopen)", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "done";
|
||||
// `completed` maps back to `idle` and would reanimate a `done` session, so
|
||||
// it must also be rejected — not just the obvious working/needs_input ones.
|
||||
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
|
||||
expect(validateAgentReportTransition(lifecycle, "completed").ok).toBe(false);
|
||||
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reports on merged or closed PRs", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.pr.state = "merged";
|
||||
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
|
||||
|
||||
lifecycle.pr.state = "closed";
|
||||
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reports when runtime is missing or exited", () => {
|
||||
const missing = createInitialCanonicalLifecycle("worker");
|
||||
missing.runtime.state = "missing";
|
||||
expect(validateAgentReportTransition(missing, "working").ok).toBe(false);
|
||||
|
||||
const exited = createInitialCanonicalLifecycle("worker");
|
||||
exited.runtime.state = "exited";
|
||||
expect(validateAgentReportTransition(exited, "working").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid worker transitions", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.runtime.state = "alive";
|
||||
expect(validateAgentReportTransition(lifecycle, "fixing_ci").ok).toBe(true);
|
||||
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAgentReport", () => {
|
||||
const sessionId = "demo-1";
|
||||
|
||||
beforeEach(() => {
|
||||
seedWorkerSession(sessionId);
|
||||
});
|
||||
|
||||
it("writes canonical lifecycle and metadata keys", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "needs_input",
|
||||
note: " please clarify the spec ",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.report.state).toBe("needs_input");
|
||||
expect(result.report.timestamp).toBe(now.toISOString());
|
||||
expect(result.report.note).toBe("please clarify the spec");
|
||||
expect(result.nextState).toBe("needs_input");
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId);
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta![AGENT_REPORT_METADATA_KEYS.STATE]).toBe("needs_input");
|
||||
expect(meta![AGENT_REPORT_METADATA_KEYS.AT]).toBe(now.toISOString());
|
||||
expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE]).toBe("please clarify the spec");
|
||||
const audit = readAgentReportAuditTrail(dataDir, sessionId);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({
|
||||
accepted: true,
|
||||
source: "report",
|
||||
actor: "unknown",
|
||||
reportState: "needs_input",
|
||||
note: "please clarify the spec",
|
||||
before: {
|
||||
legacyStatus: "working",
|
||||
sessionState: "working",
|
||||
},
|
||||
after: {
|
||||
legacyStatus: "needs_input",
|
||||
sessionState: "needs_input",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records pr_created with PR metadata and pr_open lifecycle", () => {
|
||||
const now = new Date("2025-01-02T09:30:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/42",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("pr_open");
|
||||
expect(result.report.prNumber).toBe(42);
|
||||
expect(result.report.prUrl).toBe("https://github.com/test/repo/pull/42");
|
||||
expect(result.report.prIsDraft).toBe(false);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBe("https://github.com/test/repo/pull/42");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBe("42");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("false");
|
||||
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("idle");
|
||||
expect(payload.session.reason).toBe("pr_created");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("in_progress");
|
||||
expect(payload.pr.number).toBe(42);
|
||||
expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42");
|
||||
});
|
||||
|
||||
it("does not invent an open PR without a URL or number", () => {
|
||||
const now = new Date("2025-01-02T09:35:00.000Z");
|
||||
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("idle");
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.pr.state).toBe("none");
|
||||
expect(payload.pr.reason).toBe("not_created");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBeUndefined();
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps draft PR creation in working and marks the report as draft", () => {
|
||||
const now = new Date("2025-01-02T10:00:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "draft_pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/43",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("pr_open");
|
||||
expect(result.nextState).toBe("working");
|
||||
expect(result.report.prIsDraft).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("true");
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("working");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("promotes ready_for_review to review_pending and clears draft metadata", () => {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "draft_pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/44",
|
||||
now: new Date("2025-01-02T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "ready_for_review",
|
||||
prNumber: 44,
|
||||
now: new Date("2025-01-02T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("review_pending");
|
||||
expect(result.report.prIsDraft).toBe(false);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("false");
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("idle");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("review_pending");
|
||||
expect(payload.pr.number).toBe(44);
|
||||
});
|
||||
|
||||
it("preserves prior PR URL when ready_for_review only supplies a PR number", () => {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "draft_pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/45",
|
||||
now: new Date("2025-01-02T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "ready_for_review",
|
||||
prNumber: 45,
|
||||
now: new Date("2025-01-02T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBe("https://github.com/test/repo/pull/45");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBe("45");
|
||||
});
|
||||
|
||||
it("does not clear PR metadata on later non-PR workflow reports", () => {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/46",
|
||||
now: new Date("2025-01-02T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
now: new Date("2025-01-02T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBe("https://github.com/test/repo/pull/46");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBe("46");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("false");
|
||||
});
|
||||
|
||||
it("rejects conflicting PR URL and PR number inputs", () => {
|
||||
expect(() =>
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/47",
|
||||
prNumber: 99,
|
||||
now: new Date("2025-01-02T10:00:00.000Z"),
|
||||
}),
|
||||
).toThrow(/does not match PR URL/);
|
||||
});
|
||||
|
||||
it("sets startedAt on the first working transition", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
// Re-seed with startedAt explicitly null so we exercise the first-start
|
||||
// branch of applyAgentReport.
|
||||
seedWorkerSession(sessionId, {
|
||||
state: "not_started",
|
||||
reason: "spawn_requested",
|
||||
startedAt: null,
|
||||
});
|
||||
applyAgentReport(dataDir, sessionId, { state: "started", now });
|
||||
const meta = readMetadataRaw(dataDir, sessionId);
|
||||
expect(meta).not.toBeNull();
|
||||
// The canonical payload is stored in statePayload as JSON.
|
||||
const payload = JSON.parse(meta!["statePayload"]);
|
||||
expect(payload.session.state).toBe("working");
|
||||
expect(payload.session.reason).toBe("agent_acknowledged");
|
||||
expect(payload.session.startedAt).toBe(now.toISOString());
|
||||
});
|
||||
|
||||
it("clears a previous note when none is supplied", () => {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
note: "first note",
|
||||
now: new Date("2025-01-01T11:00:00.000Z"),
|
||||
});
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
now: new Date("2025-01-01T12:00:00.000Z"),
|
||||
});
|
||||
const meta = readMetadataRaw(dataDir, sessionId);
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE] ?? "").toBe("");
|
||||
});
|
||||
|
||||
it("throws when the transition is rejected", () => {
|
||||
// Force lifecycle into a terminated state and try to re-report.
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "terminated";
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
expect(() =>
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
now: new Date(),
|
||||
}),
|
||||
).toThrow(/terminated/);
|
||||
const audit = readAgentReportAuditTrail(dataDir, sessionId);
|
||||
expect(audit[0]).toMatchObject({
|
||||
accepted: false,
|
||||
reportState: "working",
|
||||
rejectionReason: "session is terminated",
|
||||
before: {
|
||||
sessionState: "terminated",
|
||||
},
|
||||
after: {
|
||||
sessionState: "terminated",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the session does not exist", () => {
|
||||
expect(() =>
|
||||
applyAgentReport(dataDir, "missing-session", {
|
||||
state: "working",
|
||||
now: new Date(),
|
||||
}),
|
||||
).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it("bounds the on-disk audit trail to recent entries", () => {
|
||||
for (let index = 0; index < 260; index += 1) {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
note: `entry-${index}-${"x".repeat(1200)}`,
|
||||
now: new Date(`2025-01-01T12:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readAgentReportAuditTrail(dataDir, sessionId);
|
||||
expect(audit.length).toBeLessThanOrEqual(200);
|
||||
|
||||
const auditFilePath = join(dataDir, ".agent-report-audit", `${sessionId}.ndjson`);
|
||||
const rawAudit = readFileSync(auditFilePath, "utf8");
|
||||
expect(Buffer.byteLength(rawAudit, "utf8")).toBeLessThan(300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAgentReport + isAgentReportFresh", () => {
|
||||
it("returns null when metadata lacks report keys", () => {
|
||||
expect(readAgentReport({})).toBeNull();
|
||||
expect(readAgentReport(null)).toBeNull();
|
||||
expect(readAgentReport(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown states or bad timestamps", () => {
|
||||
expect(
|
||||
readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "not-a-state",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: new Date().toISOString(),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: "not-a-timestamp",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a valid report with note", () => {
|
||||
const at = "2025-01-01T00:00:00.000Z";
|
||||
const report = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "fixing_ci",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: at,
|
||||
[AGENT_REPORT_METADATA_KEYS.NOTE]: "still debugging",
|
||||
});
|
||||
expect(report).toEqual({ state: "fixing_ci", timestamp: at, note: "still debugging" });
|
||||
});
|
||||
|
||||
it("treats an empty note as absent", () => {
|
||||
const at = "2025-01-01T00:00:00.000Z";
|
||||
const report = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: at,
|
||||
[AGENT_REPORT_METADATA_KEYS.NOTE]: "",
|
||||
});
|
||||
expect(report?.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses PR workflow payload fields when present", () => {
|
||||
const at = "2025-01-01T00:00:00.000Z";
|
||||
const report = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "pr_created",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: at,
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]: "55",
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_URL]: "https://github.com/test/repo/pull/55",
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]: "false",
|
||||
});
|
||||
expect(report).toEqual({
|
||||
state: "pr_created",
|
||||
timestamp: at,
|
||||
prNumber: 55,
|
||||
prUrl: "https://github.com/test/repo/pull/55",
|
||||
prIsDraft: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports freshness against the default window", () => {
|
||||
const now = new Date("2025-01-01T12:05:00.000Z");
|
||||
const freshAt = "2025-01-01T12:04:00.000Z"; // 1m old
|
||||
const staleAt = "2025-01-01T11:55:00.000Z"; // 10m old
|
||||
const fresh = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: freshAt,
|
||||
})!;
|
||||
const stale = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: staleAt,
|
||||
})!;
|
||||
expect(isAgentReportFresh(fresh, now)).toBe(true);
|
||||
expect(isAgentReportFresh(stale, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts small future skew inside the tolerance window", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
const slightlyFuture = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
|
||||
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS - 1,
|
||||
).toISOString(),
|
||||
})!;
|
||||
expect(isAgentReportFresh(slightlyFuture, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future timestamps outside the skew tolerance", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
const future = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
|
||||
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS + 1,
|
||||
).toISOString(),
|
||||
})!;
|
||||
expect(isAgentReportFresh(future, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the default freshness window (5 minutes)", () => {
|
||||
expect(AGENT_REPORT_FRESHNESS_MS).toBe(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
|
@ -58,10 +58,7 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
|
||||
it("creates ao bin directory", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
"/home/testuser/.ao/bin",
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/home/testuser/.ao/bin", { recursive: true });
|
||||
});
|
||||
|
||||
it("writes wrapper scripts when version marker is missing", async () => {
|
||||
|
|
@ -69,15 +66,15 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
// atomicWriteFile writes to .tmp then renames
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
// .ao/AGENTS.md is written directly
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips wrapper rewrite when version matches", async () => {
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("0.2.0") // version marker matches
|
||||
.mockResolvedValueOnce("0.3.0") // version marker matches
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
|
||||
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
|
@ -91,8 +88,8 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
it("writes .ao/AGENTS.md with session context", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createLifecycleManager } from "../lifecycle-manager.js";
|
||||
import {
|
||||
resolvePREnrichmentDecision,
|
||||
resolvePRLiveDecision,
|
||||
resolveProbeDecision,
|
||||
} from "../lifecycle-status-decisions.js";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata, readMetadataRaw } from "../metadata.js";
|
||||
import { readObservabilitySummary } from "../observability.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
|
|
@ -41,6 +47,82 @@ afterEach(() => {
|
|||
env.cleanup();
|
||||
});
|
||||
|
||||
describe("status decision helpers", () => {
|
||||
it("promotes conflicting runtime evidence into detecting instead of terminating", () => {
|
||||
const decision = resolveProbeDecision({
|
||||
currentAttempts: 1,
|
||||
runtimeProbe: { state: "dead", failed: false },
|
||||
processProbe: { state: "alive", failed: false },
|
||||
canProbeRuntimeIdentity: true,
|
||||
activitySignal: {
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
},
|
||||
activityEvidence: "activity_signal=valid via_native activity=active",
|
||||
idleWasBlocked: false,
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "detecting",
|
||||
sessionState: "detecting",
|
||||
sessionReason: "runtime_lost",
|
||||
detectingAttempts: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps merged enrichment data to merged lifecycle state", () => {
|
||||
const decision = resolvePREnrichmentDecision(
|
||||
{
|
||||
state: "merged",
|
||||
ciStatus: "none",
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
},
|
||||
{
|
||||
shouldEscalateIdleToStuck: false,
|
||||
idleWasBlocked: false,
|
||||
activityEvidence: "activity_signal=valid",
|
||||
},
|
||||
);
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "merged",
|
||||
prState: "merged",
|
||||
prReason: "merged",
|
||||
sessionState: "idle",
|
||||
sessionReason: "merged_waiting_decision",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps live PR checks to review_pending without mutating other state", () => {
|
||||
const decision = resolvePRLiveDecision({
|
||||
prState: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "pending",
|
||||
mergeable: false,
|
||||
shouldEscalateIdleToStuck: false,
|
||||
idleWasBlocked: false,
|
||||
activityEvidence: "activity_signal=valid",
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "review_pending",
|
||||
prState: "open",
|
||||
prReason: "review_pending",
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper: write standard session metadata and return a lifecycle manager */
|
||||
function setupCheck(
|
||||
sessionId: string,
|
||||
|
|
@ -114,6 +196,128 @@ describe("check (single session)", () => {
|
|||
expect(meta!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("records split lifecycle observability for transitions", async () => {
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "spawning" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
const trace = summary.projects["my-app"]?.recentTraces.find(
|
||||
(entry) => entry.operation === "lifecycle.transition" && entry.sessionId === "app-1",
|
||||
);
|
||||
|
||||
expect(trace?.reason).toBe("task_in_progress");
|
||||
expect(trace?.data).toMatchObject({
|
||||
oldStatus: "spawning",
|
||||
newStatus: "working",
|
||||
previousSessionState: "not_started",
|
||||
newSessionState: "working",
|
||||
previousPRState: "none",
|
||||
newPRState: "none",
|
||||
previousRuntimeState: "alive",
|
||||
newRuntimeState: "alive",
|
||||
primaryReason: "task_in_progress",
|
||||
evidence: "activity_signal=valid via_native activity=active",
|
||||
signalsConsulted: ["activity_signal=valid", "via_native", "activity=active"],
|
||||
recoveryAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mirror lifecycle transition observability logs to stderr during polling", async () => {
|
||||
const originalAoObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "spawning" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalAoObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalAoObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("clears stale lifecycle compatibility metadata in memory and on disk", async () => {
|
||||
const session = makeSession({
|
||||
status: "working",
|
||||
lifecycle: {
|
||||
...makeSession().lifecycle,
|
||||
pr: {
|
||||
state: "none",
|
||||
reason: "not_created",
|
||||
number: null,
|
||||
url: null,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
state: "alive",
|
||||
reason: "process_running",
|
||||
lastObservedAt: null,
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
runtimeHandle: null,
|
||||
pr: null,
|
||||
metadata: {
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
runtimeHandle: JSON.stringify({ id: "stale", runtimeName: "mock", data: {} }),
|
||||
tmuxName: "stale-tmux",
|
||||
role: "orchestrator",
|
||||
},
|
||||
});
|
||||
const persistedMetadata = {
|
||||
worktree: "/tmp",
|
||||
branch: session.branch ?? "main",
|
||||
status: session.status,
|
||||
project: "my-app",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
runtimeHandle: JSON.stringify({ id: "stale", runtimeName: "mock", data: {} }),
|
||||
tmuxName: "stale-tmux",
|
||||
role: "orchestrator",
|
||||
};
|
||||
const currentSession = {
|
||||
...session,
|
||||
metadata: {
|
||||
...session.metadata,
|
||||
...persistedMetadata,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(currentSession);
|
||||
writeMetadata(env.sessionsDir, "app-1", persistedMetadata);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const metadata = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(metadata?.["pr"]).toBeUndefined();
|
||||
expect(metadata?.["runtimeHandle"]).toBeUndefined();
|
||||
expect(metadata?.["tmuxName"]).toBeUndefined();
|
||||
expect(metadata?.["role"]).toBeUndefined();
|
||||
expect(currentSession.metadata["pr"]).toBeUndefined();
|
||||
expect(currentSession.metadata["runtimeHandle"]).toBeUndefined();
|
||||
expect(currentSession.metadata["tmuxName"]).toBeUndefined();
|
||||
expect(currentSession.metadata["role"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not kill a spawning session when its runtime handle has not been persisted yet", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
|
||||
|
|
@ -195,7 +399,7 @@ describe("check (single session)", () => {
|
|||
runtimeName: "mock",
|
||||
data: {},
|
||||
});
|
||||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
});
|
||||
|
||||
it("uses worker-specific agent fallback when metadata does not persist an agent", async () => {
|
||||
|
|
@ -244,6 +448,8 @@ describe("check (single session)", () => {
|
|||
|
||||
it("detects killed state when runtime is dead", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "idle" });
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
|
|
@ -255,6 +461,21 @@ describe("check (single session)", () => {
|
|||
|
||||
it("detects killed state when getActivityState returns exited", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "exited" });
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(true);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
});
|
||||
|
||||
it("detects killed via terminal fallback when getActivityState returns null", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.agent.detectActivity).mockReturnValue("idle");
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
|
|
@ -264,9 +485,12 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
});
|
||||
|
||||
it("detects killed via terminal fallback when getActivityState returns null", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.agent.detectActivity).mockReturnValue("idle");
|
||||
it("enters detecting when runtime is dead but recent activity is still fresh", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({
|
||||
state: "active",
|
||||
timestamp: new Date(Date.now() - 30_000),
|
||||
});
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
|
|
@ -274,7 +498,62 @@ describe("check (single session)", () => {
|
|||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["detectingAttempts"]).toBe("1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("signal_disagreement");
|
||||
});
|
||||
|
||||
it("enters detecting when runtime is dead but process state is unknown", async () => {
|
||||
const registryWithoutAgent = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
});
|
||||
vi.mocked(registryWithoutAgent.get).mockImplementation((slot: string, _name?: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
if (slot === "agent") return null;
|
||||
return null;
|
||||
});
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
registry: registryWithoutAgent,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("runtime_dead process_unknown");
|
||||
expect(meta?.["detectingAttempts"]).toBe("1");
|
||||
});
|
||||
|
||||
it("escalates detecting to stuck after bounded retries", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({
|
||||
state: "active",
|
||||
timestamp: new Date(Date.now() - 30_000),
|
||||
});
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({
|
||||
status: "detecting",
|
||||
metadata: { detectingAttempts: "3" },
|
||||
}),
|
||||
metaOverrides: {
|
||||
detectingAttempts: "3",
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("stuck");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["detectingAttempts"]).toBe("4");
|
||||
expect(meta?.["detectingEscalatedAt"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("stays working when agent is idle but process is still running (fallback path)", async () => {
|
||||
|
|
@ -290,6 +569,81 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("working");
|
||||
});
|
||||
|
||||
it("does not mark a session stuck from terminal-only idle evidence without a timestamp", async () => {
|
||||
config.reactions = {
|
||||
"agent-stuck": { auto: true, action: "notify", threshold: "1m" },
|
||||
};
|
||||
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.agent.detectActivity).mockReturnValue("idle");
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(true);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("working");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("activity=idle");
|
||||
});
|
||||
|
||||
it("does not treat stale activity as recent liveness evidence during runtime-loss detection", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({
|
||||
state: "active",
|
||||
timestamp: new Date(Date.now() - 10 * 60_000),
|
||||
});
|
||||
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale");
|
||||
});
|
||||
|
||||
it("records explicit probe-failure activity evidence", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockRejectedValue(new Error("boom"));
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=probe_failure");
|
||||
});
|
||||
|
||||
it("degrades stuck probe-failure sessions to detecting when runtime is alive but activity is unavailable", async () => {
|
||||
const registryWithoutAgent: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "stuck" }),
|
||||
registry: registryWithoutAgent,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=unavailable");
|
||||
});
|
||||
|
||||
it("detects needs_input from agent", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "waiting_input" });
|
||||
|
||||
|
|
@ -423,6 +777,69 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("stuck");
|
||||
});
|
||||
|
||||
it("preserves needs_input state when getActivityState returns null with no terminal evidence", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "needs_input" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("preserves stuck state across repeated polls with unchanged weak evidence", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "stuck" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("stuck");
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("stuck");
|
||||
});
|
||||
|
||||
it("preserves needs_input across repeated polls with unchanged weak evidence", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "needs_input" }),
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("needs_input");
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("preserves canonical needs_input when persisted status is stale working", async () => {
|
||||
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
|
||||
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
session.lifecycle.session.state = "needs_input";
|
||||
session.lifecycle.session.reason = "awaiting_user_input";
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session,
|
||||
metaOverrides: {
|
||||
status: "working",
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("needs_input");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["status"]).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("detects PR states from SCM", async () => {
|
||||
const mockSCM = createMockSCM({ getCISummary: vi.fn().mockResolvedValue("failing") });
|
||||
const registry = createMockRegistry({
|
||||
|
|
@ -440,6 +857,38 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("ci_failed");
|
||||
});
|
||||
|
||||
it("keeps canonical session state idle while waiting on external review", async () => {
|
||||
const mockSCM = createMockSCM({ getReviewDecision: vi.fn().mockResolvedValue("pending") });
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
});
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(env.sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: session.branch ?? "main",
|
||||
status: session.status,
|
||||
project: "my-app",
|
||||
pr: session.pr?.url,
|
||||
runtimeHandle: session.runtimeHandle ? JSON.stringify(session.runtimeHandle) : undefined,
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("review_pending");
|
||||
expect(session.lifecycle.session.state).toBe("idle");
|
||||
expect(session.lifecycle.session.reason).toBe("awaiting_external_review");
|
||||
});
|
||||
|
||||
it("skips PR auto-detection when metadata disables it", async () => {
|
||||
const mockSCM = createMockSCM({ detectPR: vi.fn().mockResolvedValue(makePR()) });
|
||||
const registry = createMockRegistry({
|
||||
|
|
@ -558,6 +1007,104 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("merged");
|
||||
});
|
||||
|
||||
it("preserves merged PR truth in metadata instead of regressing to no-pr lifecycle state", async () => {
|
||||
const pr = makePR();
|
||||
const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("merged") });
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
});
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "pr_open", pr }),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("merged");
|
||||
expect(meta?.["status"]).toBe("merged");
|
||||
expect(meta?.["pr"]).toBe(pr.url);
|
||||
expect(meta?.["statePayload"]).toContain('"state":"merged"');
|
||||
expect(meta?.["statePayload"]).toContain('"reason":"merged"');
|
||||
expect(meta?.["statePayload"]).not.toContain('"reason":"not_created"');
|
||||
});
|
||||
|
||||
it("keeps closed PR sessions idle and emits a PR-closed notification", async () => {
|
||||
const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("closed") });
|
||||
const notifier = createMockNotifier();
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier,
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
const lm = setupCheck("app-1", {
|
||||
session,
|
||||
registry,
|
||||
configOverride: {
|
||||
...config,
|
||||
notificationRouting: {
|
||||
...config.notificationRouting,
|
||||
info: ["desktop"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(lm.getStates().get("app-1")).toBe("idle");
|
||||
const meta = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(meta?.["status"]).toBe("idle");
|
||||
expect(meta?.["statePayload"]).toContain('"state":"closed"');
|
||||
expect(meta?.["statePayload"]).toContain('"reason":"pr_closed_waiting_decision"');
|
||||
expect(notifier.notify).toHaveBeenCalledWith(expect.objectContaining({ type: "pr.closed" }));
|
||||
});
|
||||
|
||||
it("routes closed PR transitions through the pr-closed reaction key", async () => {
|
||||
const notifier = createMockNotifier();
|
||||
const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("closed") });
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
notifier,
|
||||
});
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
const lm = setupCheck("app-1", {
|
||||
session,
|
||||
registry,
|
||||
configOverride: {
|
||||
...config,
|
||||
reactions: {
|
||||
...config.reactions,
|
||||
"pr-closed": {
|
||||
auto: true,
|
||||
action: "notify",
|
||||
priority: "action",
|
||||
},
|
||||
},
|
||||
notificationRouting: {
|
||||
...config.notificationRouting,
|
||||
action: ["desktop"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "reaction.triggered",
|
||||
data: expect.objectContaining({ reactionKey: "pr-closed" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects mergeable when approved + CI green", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
getReviewDecision: vi.fn().mockResolvedValue("approved"),
|
||||
|
|
@ -611,6 +1158,58 @@ describe("check (single session)", () => {
|
|||
});
|
||||
|
||||
describe("reactions", () => {
|
||||
it("fires report watcher reactions only once per active trigger", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const notifier = createMockNotifier();
|
||||
const registryWithNotifier = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
notifier,
|
||||
});
|
||||
const staleSession = makeSession({
|
||||
id: "app-1",
|
||||
status: "working",
|
||||
createdAt: new Date("2025-01-01T11:40:00.000Z"),
|
||||
metadata: {
|
||||
createdAt: "2025-01-01T11:40:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
config.reactions = {
|
||||
"report-no-acknowledge": { auto: true, action: "notify", priority: "urgent" },
|
||||
};
|
||||
vi.mocked(mockSessionManager.list).mockResolvedValue([staleSession]);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithNotifier,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z"));
|
||||
lm.start(60_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
const reactionNotifications = vi.mocked(notifier.notify).mock.calls.filter((call) => {
|
||||
const event = call[0] as { type?: string; data?: Record<string, unknown> } | undefined;
|
||||
return (
|
||||
event?.type === "reaction.triggered" &&
|
||||
event.data?.["reactionKey"] === "report-no-acknowledge"
|
||||
);
|
||||
});
|
||||
|
||||
expect(reactionNotifications).toHaveLength(1);
|
||||
expect(staleSession.metadata["reportWatcherTriggerCount"]).toBe("2");
|
||||
expect(staleSession.metadata["reportWatcherActiveTrigger"]).toBe("no_acknowledge");
|
||||
} finally {
|
||||
lm.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("triggers send-to-agent reaction on CI failure", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
|
|
@ -1400,6 +1999,42 @@ describe("reactions", () => {
|
|||
expect(metadata?.["lastMergeConflictDispatched"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("clears merge conflict tracking when PR is closed", async () => {
|
||||
config.reactions = {
|
||||
"merge-conflicts": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message: "Resolve merge conflicts.",
|
||||
},
|
||||
};
|
||||
|
||||
const getMergeability = vi.fn();
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("closed"),
|
||||
getMergeability,
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
});
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({
|
||||
status: "pr_open",
|
||||
pr: makePR(),
|
||||
metadata: { lastMergeConflictDispatched: "true" },
|
||||
}),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const metadata = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(metadata?.["lastMergeConflictDispatched"]).toBeFalsy();
|
||||
expect(getMergeability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies humans on significant transitions without reaction config", async () => {
|
||||
const notifier = createMockNotifier();
|
||||
const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("merged") });
|
||||
|
|
@ -1905,6 +2540,49 @@ describe("rate limiting optimizations", () => {
|
|||
await lm.check("app-1");
|
||||
expect(getPendingMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears review backlog tracking when PR is closed", async () => {
|
||||
const getPendingMock = vi.fn();
|
||||
const getAutomatedMock = vi.fn();
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("closed"),
|
||||
getPendingComments: getPendingMock,
|
||||
getAutomatedComments: getAutomatedMock,
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
});
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({
|
||||
status: "pr_open",
|
||||
pr: makePR(),
|
||||
metadata: {
|
||||
lastPendingReviewFingerprint: "fingerprint",
|
||||
lastPendingReviewDispatchHash: "dispatch",
|
||||
lastPendingReviewDispatchAt: "2025-01-01T00:00:00.000Z",
|
||||
lastAutomatedReviewFingerprint: "auto-fingerprint",
|
||||
lastAutomatedReviewDispatchHash: "auto-dispatch",
|
||||
lastAutomatedReviewDispatchAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const metadata = readMetadataRaw(env.sessionsDir, "app-1");
|
||||
expect(metadata?.["lastPendingReviewFingerprint"]).toBeFalsy();
|
||||
expect(metadata?.["lastPendingReviewDispatchHash"]).toBeFalsy();
|
||||
expect(metadata?.["lastPendingReviewDispatchAt"]).toBeFalsy();
|
||||
expect(metadata?.["lastAutomatedReviewFingerprint"]).toBeFalsy();
|
||||
expect(metadata?.["lastAutomatedReviewDispatchHash"]).toBeFalsy();
|
||||
expect(metadata?.["lastAutomatedReviewDispatchAt"]).toBeFalsy();
|
||||
expect(getPendingMock).not.toHaveBeenCalled();
|
||||
expect(getAutomatedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("summary pinning", () => {
|
||||
it("pins first quality summary when pinnedSummary not set", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cloneLifecycle,
|
||||
createInitialCanonicalLifecycle,
|
||||
deriveLegacyStatus,
|
||||
parseCanonicalLifecycle,
|
||||
} from "../lifecycle-state.js";
|
||||
|
||||
function createOpenPRLifecycle() {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "review_pending";
|
||||
lifecycle.pr.number = 42;
|
||||
lifecycle.pr.url = "https://github.com/org/repo/pull/42";
|
||||
lifecycle.pr.lastObservedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
describe("deriveLegacyStatus", () => {
|
||||
it("preserves urgent session states ahead of open PR aliases", () => {
|
||||
const needsInput = createOpenPRLifecycle();
|
||||
needsInput.session.state = "needs_input";
|
||||
needsInput.session.reason = "awaiting_user_input";
|
||||
|
||||
const stuck = createOpenPRLifecycle();
|
||||
stuck.session.state = "stuck";
|
||||
stuck.session.reason = "probe_failure";
|
||||
|
||||
const terminated = createOpenPRLifecycle();
|
||||
terminated.session.state = "terminated";
|
||||
terminated.session.reason = "manually_killed";
|
||||
|
||||
expect(deriveLegacyStatus(needsInput)).toBe("needs_input");
|
||||
expect(deriveLegacyStatus(stuck)).toBe("stuck");
|
||||
expect(deriveLegacyStatus(terminated)).toBe("terminated");
|
||||
});
|
||||
|
||||
it("preserves prior terminal legacy statuses for terminated sessions", () => {
|
||||
const terminated = createOpenPRLifecycle();
|
||||
terminated.session.state = "terminated";
|
||||
terminated.session.reason = "manually_killed";
|
||||
|
||||
expect(deriveLegacyStatus(terminated, "killed")).toBe("killed");
|
||||
expect(deriveLegacyStatus(terminated, "cleanup")).toBe("cleanup");
|
||||
expect(deriveLegacyStatus(terminated, "errored")).toBe("errored");
|
||||
});
|
||||
|
||||
it("keeps PR-oriented aliases for idle workers with open PRs", () => {
|
||||
const reviewPending = createOpenPRLifecycle();
|
||||
reviewPending.session.state = "idle";
|
||||
reviewPending.session.reason = "awaiting_external_review";
|
||||
|
||||
const mergeReady = createOpenPRLifecycle();
|
||||
mergeReady.session.state = "idle";
|
||||
mergeReady.session.reason = "awaiting_external_review";
|
||||
mergeReady.pr.reason = "merge_ready";
|
||||
|
||||
expect(deriveLegacyStatus(reviewPending)).toBe("review_pending");
|
||||
expect(deriveLegacyStatus(mergeReady)).toBe("mergeable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCanonicalLifecycle", () => {
|
||||
it("rehydrates legacy merged sessions with a merged PR state", () => {
|
||||
const parsed = parseCanonicalLifecycle({
|
||||
status: "merged",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(parsed.session.state).toBe("idle");
|
||||
expect(parsed.session.reason).toBe("merged_waiting_decision");
|
||||
expect(parsed.pr.state).toBe("merged");
|
||||
expect(parsed.pr.reason).toBe("merged");
|
||||
expect(parsed.pr.number).toBe(42);
|
||||
expect(deriveLegacyStatus(parsed, "merged")).toBe("merged");
|
||||
});
|
||||
|
||||
it("preserves explicit null payload fields instead of rehydrating stale flat metadata", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = "2025-01-01T00:00:00.000Z";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
|
||||
const parsed = parseCanonicalLifecycle({
|
||||
status: "working",
|
||||
role: "orchestrator",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
tmuxName: "tmux-1",
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify(lifecycle),
|
||||
});
|
||||
|
||||
expect(parsed.session.kind).toBe("worker");
|
||||
expect(parsed.pr.url).toBeNull();
|
||||
expect(parsed.runtime.handle).toBeNull();
|
||||
expect(parsed.runtime.tmuxName).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to synthesized lifecycle when a v2 payload is malformed", () => {
|
||||
const parsed = parseCanonicalLifecycle({
|
||||
status: "review_pending",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify({
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "working",
|
||||
reason: 123,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(parsed.session.state).toBe("working");
|
||||
expect(parsed.session.reason).toBe("task_in_progress");
|
||||
expect(parsed.pr.state).toBe("open");
|
||||
expect(parsed.pr.reason).toBe("in_progress");
|
||||
expect(parsed.pr.number).toBe(42);
|
||||
});
|
||||
|
||||
it("preserves valid partial v2 payload fields while synthesizing missing sections", () => {
|
||||
const parsed = parseCanonicalLifecycle({
|
||||
status: "review_pending",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify({
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "orchestrator",
|
||||
state: "idle",
|
||||
reason: "awaiting_external_review",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(parsed.session.kind).toBe("orchestrator");
|
||||
expect(parsed.session.state).toBe("idle");
|
||||
expect(parsed.session.reason).toBe("awaiting_external_review");
|
||||
expect(parsed.pr.state).toBe("open");
|
||||
expect(parsed.pr.reason).toBe("in_progress");
|
||||
expect(parsed.pr.number).toBe(42);
|
||||
});
|
||||
|
||||
it("normalizes runtime handles without data instead of discarding the payload", () => {
|
||||
const parsed = parseCanonicalLifecycle({
|
||||
status: "working",
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify({
|
||||
version: 2,
|
||||
runtime: {
|
||||
handle: {
|
||||
id: "rt-1",
|
||||
runtimeName: "tmux",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(parsed.runtime.handle).toEqual({
|
||||
id: "rt-1",
|
||||
runtimeName: "tmux",
|
||||
data: {},
|
||||
});
|
||||
expect(parsed.runtime.state).toBe("unknown");
|
||||
expect(parsed.runtime.reason).toBe("spawn_incomplete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneLifecycle", () => {
|
||||
it("deep-clones nested runtime handle data", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
|
||||
lifecycle.runtime.handle = {
|
||||
id: "rt-1",
|
||||
runtimeName: "tmux",
|
||||
data: {
|
||||
nested: { attempts: [1, 2, 3] },
|
||||
},
|
||||
};
|
||||
|
||||
const cloned = cloneLifecycle(lifecycle);
|
||||
const clonedNested = cloned.runtime.handle?.data["nested"] as {
|
||||
attempts: number[];
|
||||
};
|
||||
clonedNested.attempts.push(4);
|
||||
|
||||
expect(lifecycle.runtime.handle?.data).toEqual({
|
||||
nested: { attempts: [1, 2, 3] },
|
||||
});
|
||||
expect(cloned.runtime.handle?.data).toEqual({
|
||||
nested: { attempts: [1, 2, 3, 4] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createDetectingDecision,
|
||||
hashEvidence,
|
||||
isDetectingTimedOut,
|
||||
DETECTING_MAX_ATTEMPTS,
|
||||
DETECTING_MAX_DURATION_MS,
|
||||
} from "../lifecycle-status-decisions.js";
|
||||
|
||||
describe("hashEvidence", () => {
|
||||
it("returns a 12-character hex string", () => {
|
||||
const hash = hashEvidence("some evidence string");
|
||||
expect(hash).toMatch(/^[a-f0-9]{12}$/);
|
||||
});
|
||||
|
||||
it("returns the same hash for the same input", () => {
|
||||
const hash1 = hashEvidence("test evidence");
|
||||
const hash2 = hashEvidence("test evidence");
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it("returns different hashes for different inputs", () => {
|
||||
const hash1 = hashEvidence("evidence A");
|
||||
const hash2 = hashEvidence("evidence B");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("ignores activity labels and timestamps when hashing probe evidence", () => {
|
||||
const active = hashEvidence(
|
||||
"signal_disagreement runtime=alive process=unknown activity_signal=valid via_native activity=active at=2026-04-18T10:00:00.000Z",
|
||||
);
|
||||
const blocked = hashEvidence(
|
||||
"signal_disagreement runtime=alive process=unknown activity_signal=valid via_native activity=blocked at=2026-04-18T10:01:00.000Z",
|
||||
);
|
||||
|
||||
expect(active).toBe(blocked);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDetectingTimedOut", () => {
|
||||
it("returns false when detectingStartedAt is undefined", () => {
|
||||
expect(isDetectingTimedOut(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when detectingStartedAt is invalid", () => {
|
||||
expect(isDetectingTimedOut("invalid-date")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when within time budget", () => {
|
||||
const now = new Date();
|
||||
const startedAt = new Date(now.getTime() - 60_000).toISOString(); // 1 minute ago
|
||||
expect(isDetectingTimedOut(startedAt, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when time budget exceeded", () => {
|
||||
const now = new Date();
|
||||
const startedAt = new Date(now.getTime() - DETECTING_MAX_DURATION_MS - 1000).toISOString();
|
||||
expect(isDetectingTimedOut(startedAt, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false at exactly the time limit", () => {
|
||||
const now = new Date();
|
||||
const startedAt = new Date(now.getTime() - DETECTING_MAX_DURATION_MS).toISOString();
|
||||
expect(isDetectingTimedOut(startedAt, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectingDecision", () => {
|
||||
const baseInput = {
|
||||
currentAttempts: 0,
|
||||
idleWasBlocked: false,
|
||||
evidence: "test evidence",
|
||||
};
|
||||
|
||||
describe("attempt counting", () => {
|
||||
it("increments attempts when no previous evidence hash exists", () => {
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 1,
|
||||
});
|
||||
expect(result.detectingAttempts).toBe(2);
|
||||
expect(result.status).toBe("detecting");
|
||||
});
|
||||
|
||||
it("increments attempts when evidence is unchanged", () => {
|
||||
const evidenceHash = hashEvidence("same evidence");
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 2,
|
||||
evidence: "same evidence",
|
||||
previousEvidenceHash: evidenceHash,
|
||||
});
|
||||
expect(result.detectingAttempts).toBe(3);
|
||||
});
|
||||
|
||||
it("resets attempts to 1 when evidence changes", () => {
|
||||
const oldEvidenceHash = hashEvidence("old evidence");
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 5,
|
||||
evidence: "new evidence",
|
||||
previousEvidenceHash: oldEvidenceHash,
|
||||
});
|
||||
expect(result.detectingAttempts).toBe(1);
|
||||
expect(result.status).toBe("detecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attempt-based escalation", () => {
|
||||
it("escalates to stuck after exceeding max attempts", () => {
|
||||
const evidenceHash = hashEvidence("test evidence");
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: DETECTING_MAX_ATTEMPTS,
|
||||
previousEvidenceHash: evidenceHash,
|
||||
});
|
||||
expect(result.status).toBe("stuck");
|
||||
expect(result.detectingAttempts).toBe(DETECTING_MAX_ATTEMPTS + 1);
|
||||
expect(result.sessionState).toBe("stuck");
|
||||
expect(result.sessionReason).toBe("probe_failure");
|
||||
});
|
||||
|
||||
it("uses error_in_process reason when idleWasBlocked", () => {
|
||||
const evidenceHash = hashEvidence("test evidence");
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: DETECTING_MAX_ATTEMPTS,
|
||||
idleWasBlocked: true,
|
||||
previousEvidenceHash: evidenceHash,
|
||||
});
|
||||
expect(result.status).toBe("stuck");
|
||||
expect(result.sessionReason).toBe("error_in_process");
|
||||
});
|
||||
});
|
||||
|
||||
describe("time-based escalation", () => {
|
||||
it("escalates to stuck when time budget is exceeded", () => {
|
||||
const now = new Date();
|
||||
const oldStartedAt = new Date(now.getTime() - DETECTING_MAX_DURATION_MS - 1000).toISOString();
|
||||
const evidenceHash = hashEvidence("test evidence");
|
||||
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 1, // Well under attempt limit
|
||||
detectingStartedAt: oldStartedAt,
|
||||
previousEvidenceHash: evidenceHash,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("stuck");
|
||||
expect(result.sessionState).toBe("stuck");
|
||||
});
|
||||
|
||||
it("does not escalate when both time and attempts are within limits", () => {
|
||||
const now = new Date();
|
||||
const recentStartedAt = new Date(now.getTime() - 60_000).toISOString(); // 1 minute ago
|
||||
const evidenceHash = hashEvidence("test evidence");
|
||||
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 1,
|
||||
detectingStartedAt: recentStartedAt,
|
||||
previousEvidenceHash: evidenceHash,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("detecting");
|
||||
});
|
||||
|
||||
it("resets detectingStartedAt when evidence changes", () => {
|
||||
const now = new Date();
|
||||
const oldStartedAt = new Date(now.getTime() - DETECTING_MAX_DURATION_MS - 1000).toISOString();
|
||||
const oldEvidenceHash = hashEvidence("old evidence");
|
||||
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
currentAttempts: 1,
|
||||
evidence: "new evidence", // Different from old
|
||||
detectingStartedAt: oldStartedAt,
|
||||
previousEvidenceHash: oldEvidenceHash,
|
||||
now,
|
||||
});
|
||||
|
||||
// Should NOT escalate because evidence changed, resetting the timer
|
||||
expect(result.status).toBe("detecting");
|
||||
expect(result.detectingAttempts).toBe(1);
|
||||
expect(result.detectingStartedAt).not.toBe(oldStartedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata tracking", () => {
|
||||
it("includes detectingEvidenceHash in the result", () => {
|
||||
const result = createDetectingDecision(baseInput);
|
||||
expect(result.detectingEvidenceHash).toBe(hashEvidence("test evidence"));
|
||||
});
|
||||
|
||||
it("includes detectingStartedAt in the result", () => {
|
||||
const result = createDetectingDecision(baseInput);
|
||||
expect(result.detectingStartedAt).toBeDefined();
|
||||
expect(Date.parse(result.detectingStartedAt!)).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("preserves detectingStartedAt when evidence is unchanged", () => {
|
||||
const now = new Date();
|
||||
const previousStartedAt = new Date(now.getTime() - 30_000).toISOString();
|
||||
const evidenceHash = hashEvidence("test evidence");
|
||||
|
||||
const result = createDetectingDecision({
|
||||
...baseInput,
|
||||
detectingStartedAt: previousStartedAt,
|
||||
previousEvidenceHash: evidenceHash,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.detectingStartedAt).toBe(previousStartedAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
applyLifecycleDecision,
|
||||
applyDecisionToLifecycle,
|
||||
buildTransitionMetadataPatch,
|
||||
createStateTransitionDecision,
|
||||
} from "../lifecycle-transition.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { readMetadataRaw } from "../metadata.js";
|
||||
import type { LifecycleDecision } from "../lifecycle-status-decisions.js";
|
||||
import type { CanonicalSessionLifecycle } from "../types.js";
|
||||
|
||||
describe("applyDecisionToLifecycle", () => {
|
||||
let lifecycle: CanonicalSessionLifecycle;
|
||||
const nowIso = "2026-04-17T12:00:00.000Z";
|
||||
|
||||
beforeEach(() => {
|
||||
lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
});
|
||||
|
||||
it("applies session state and reason", () => {
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.state).toBe("working");
|
||||
expect(lifecycle.session.reason).toBe("task_in_progress");
|
||||
expect(lifecycle.session.lastTransitionAt).toBe(nowIso);
|
||||
});
|
||||
|
||||
it("sets startedAt when transitioning to working", () => {
|
||||
expect(lifecycle.session.startedAt).toBeNull();
|
||||
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.startedAt).toBe(nowIso);
|
||||
});
|
||||
|
||||
it("does not overwrite startedAt if already set", () => {
|
||||
lifecycle.session.startedAt = "2026-04-16T12:00:00.000Z";
|
||||
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.startedAt).toBe("2026-04-16T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("sets completedAt when transitioning to done", () => {
|
||||
const decision: LifecycleDecision = {
|
||||
status: "done",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "done",
|
||||
sessionReason: "research_complete",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.completedAt).toBe(nowIso);
|
||||
});
|
||||
|
||||
it("sets terminatedAt when transitioning to terminated", () => {
|
||||
const decision: LifecycleDecision = {
|
||||
status: "terminated",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "terminated",
|
||||
sessionReason: "manually_killed",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.terminatedAt).toBe(nowIso);
|
||||
});
|
||||
|
||||
it("does not overwrite completedAt if already set", () => {
|
||||
lifecycle.session.completedAt = "2026-04-16T12:00:00.000Z";
|
||||
|
||||
const decision: LifecycleDecision = {
|
||||
status: "done",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "done",
|
||||
sessionReason: "research_complete",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.completedAt).toBe("2026-04-16T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not overwrite terminatedAt if already set", () => {
|
||||
lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z";
|
||||
|
||||
const decision: LifecycleDecision = {
|
||||
status: "terminated",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "terminated",
|
||||
sessionReason: "manually_killed",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.terminatedAt).toBe("2026-04-16T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("applies PR state and reason", () => {
|
||||
const decision: LifecycleDecision = {
|
||||
status: "pr_open",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "in_progress",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.pr.state).toBe("open");
|
||||
expect(lifecycle.pr.reason).toBe("in_progress");
|
||||
expect(lifecycle.pr.lastObservedAt).toBe(nowIso);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTransitionMetadataPatch", () => {
|
||||
it("includes lifecycle evidence and detecting metadata", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const decision: LifecycleDecision = {
|
||||
status: "detecting",
|
||||
evidence: "probe_failed",
|
||||
detectingAttempts: 2,
|
||||
detectingStartedAt: "2026-04-17T11:55:00.000Z",
|
||||
detectingEvidenceHash: "abc123def456",
|
||||
sessionState: "detecting",
|
||||
sessionReason: "probe_failure",
|
||||
};
|
||||
|
||||
const patch = buildTransitionMetadataPatch(lifecycle, decision, "working");
|
||||
|
||||
expect(patch["lifecycleEvidence"]).toBe("probe_failed");
|
||||
expect(patch["detectingAttempts"]).toBe("2");
|
||||
expect(patch["detectingStartedAt"]).toBe("2026-04-17T11:55:00.000Z");
|
||||
expect(patch["detectingEvidenceHash"]).toBe("abc123def456");
|
||||
});
|
||||
|
||||
it("clears detecting metadata when not detecting", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "active",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
const patch = buildTransitionMetadataPatch(lifecycle, decision, "detecting");
|
||||
|
||||
expect(patch["detectingAttempts"]).toBe("");
|
||||
expect(patch["detectingStartedAt"]).toBe("");
|
||||
expect(patch["detectingEvidenceHash"]).toBe("");
|
||||
});
|
||||
|
||||
it("includes stateVersion and statePayload", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
};
|
||||
|
||||
const patch = buildTransitionMetadataPatch(lifecycle, decision, "working");
|
||||
|
||||
expect(patch["stateVersion"]).toBe("2");
|
||||
expect(patch["statePayload"]).toBeDefined();
|
||||
expect(JSON.parse(patch["statePayload"])).toHaveProperty("version", 2);
|
||||
});
|
||||
|
||||
it("clears stale PR, runtime, and role metadata when lifecycle no longer carries them", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "active",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
const patch = buildTransitionMetadataPatch(lifecycle, decision, "working");
|
||||
|
||||
expect(patch["pr"]).toBe("");
|
||||
expect(patch["runtimeHandle"]).toBe("");
|
||||
expect(patch["tmuxName"]).toBe("");
|
||||
expect(patch["role"]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLifecycleDecision (integration)", () => {
|
||||
let testDir: string;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `lifecycle-transition-test-${Date.now()}`);
|
||||
dataDir = join(testDir, "sessions");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeTestSession(sessionId: string, metadata: Record<string, string>) {
|
||||
const content = Object.entries(metadata)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
writeFileSync(join(dataDir, sessionId), content);
|
||||
}
|
||||
|
||||
it("returns failure when session not found", () => {
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "nonexistent",
|
||||
decision: {
|
||||
status: "working",
|
||||
evidence: "test",
|
||||
detectingAttempts: 0,
|
||||
},
|
||||
source: "poll",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.rejectionReason).toContain("Session not found");
|
||||
});
|
||||
|
||||
it("applies decision and persists metadata", () => {
|
||||
writeTestSession("test-1", {
|
||||
status: "spawning",
|
||||
worktree: "/tmp/test",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "test-1",
|
||||
decision: {
|
||||
status: "working",
|
||||
evidence: "agent_started",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
},
|
||||
source: "poll",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.previousStatus).toBe("spawning");
|
||||
expect(result.nextStatus).toBe("working");
|
||||
expect(result.statusChanged).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, "test-1");
|
||||
expect(meta?.["status"]).toBe("working");
|
||||
expect(meta?.["lifecycleEvidence"]).toBe("agent_started");
|
||||
});
|
||||
|
||||
it("merges non-conflicting additional metadata", () => {
|
||||
writeTestSession("test-2", {
|
||||
status: "working",
|
||||
worktree: "/tmp/test",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "test-2",
|
||||
decision: {
|
||||
status: "pr_open",
|
||||
evidence: "pr_created",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "in_progress",
|
||||
},
|
||||
source: "agent_report",
|
||||
additionalMetadata: {
|
||||
summary: "worker reported PR creation",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, "test-2");
|
||||
expect(meta?.["summary"]).toBe("worker reported PR creation");
|
||||
});
|
||||
|
||||
it("clears stale metadata fields that are absent from the next lifecycle", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = "2026-04-17T10:00:00.000Z";
|
||||
lifecycle.session.lastTransitionAt = "2026-04-17T10:00:00.000Z";
|
||||
|
||||
writeTestSession("test-3", {
|
||||
status: "working",
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify(lifecycle),
|
||||
pr: "https://github.com/test/repo/pull/456",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
tmuxName: "tmux-1",
|
||||
role: "orchestrator",
|
||||
worktree: "/tmp/test",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "test-3",
|
||||
decision: {
|
||||
status: "working",
|
||||
evidence: "active",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
},
|
||||
source: "poll",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, "test-3");
|
||||
expect(meta?.["pr"]).toBeUndefined();
|
||||
expect(meta?.["runtimeHandle"]).toBeUndefined();
|
||||
expect(meta?.["tmuxName"]).toBeUndefined();
|
||||
expect(meta?.["role"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates stored legacy status before deriving the previous status", () => {
|
||||
writeTestSession("test-4", {
|
||||
status: "not-a-real-status",
|
||||
worktree: "/tmp/test",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "test-4",
|
||||
decision: {
|
||||
status: "working",
|
||||
evidence: "agent_started",
|
||||
detectingAttempts: 0,
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
},
|
||||
source: "poll",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.previousStatus).toBe("spawning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStateTransitionDecision", () => {
|
||||
it("creates a minimal decision for direct state updates", () => {
|
||||
const decision = createStateTransitionDecision(
|
||||
"stuck",
|
||||
"stuck",
|
||||
"probe_failure",
|
||||
"runtime dead after 3 attempts",
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("stuck");
|
||||
expect(decision.sessionState).toBe("stuck");
|
||||
expect(decision.sessionReason).toBe("probe_failure");
|
||||
expect(decision.evidence).toBe("runtime dead after 3 attempts");
|
||||
expect(decision.detectingAttempts).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,9 @@ import { randomUUID } from "node:crypto";
|
|||
import {
|
||||
readMetadata,
|
||||
readMetadataRaw,
|
||||
readCanonicalLifecycle,
|
||||
readArchivedMetadataRaw,
|
||||
mutateMetadata,
|
||||
writeMetadata,
|
||||
updateMetadata,
|
||||
deleteMetadata,
|
||||
|
|
@ -51,6 +53,9 @@ describe("writeMetadata + readMetadata", () => {
|
|||
project: "my-app",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
runtimeHandle: '{"id":"tmux-1","runtimeName":"tmux"}',
|
||||
stateVersion: "2",
|
||||
statePayload:
|
||||
'{"version":2,"session":{"kind":"worker","state":"working","reason":"task_in_progress","startedAt":"2025-01-01T00:00:00.000Z","completedAt":null,"terminatedAt":null,"lastTransitionAt":"2025-01-01T00:00:00.000Z"},"pr":{"state":"none","reason":"not_created","number":null,"url":null,"lastObservedAt":null},"runtime":{"state":"alive","reason":"process_running","lastObservedAt":"2025-01-01T00:00:00.000Z","handle":{"id":"tmux-1","runtimeName":"tmux","data":{}},"tmuxName":null}}',
|
||||
});
|
||||
|
||||
const meta = readMetadata(dataDir, "app-2");
|
||||
|
|
@ -62,6 +67,8 @@ describe("writeMetadata + readMetadata", () => {
|
|||
expect(meta!.project).toBe("my-app");
|
||||
expect(meta!.createdAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
expect(meta!.runtimeHandle).toBe('{"id":"tmux-1","runtimeName":"tmux"}');
|
||||
expect(meta!.stateVersion).toBe("2");
|
||||
expect(meta!.statePayload).toContain('"version":2');
|
||||
});
|
||||
|
||||
it("returns null for nonexistent session", () => {
|
||||
|
|
@ -206,6 +213,61 @@ describe("updateMetadata", () => {
|
|||
expect(meta!.status).toBe("pr_open");
|
||||
expect(meta!.summary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the normalized record that is actually persisted", () => {
|
||||
writeMetadata(dataDir, "upd-5", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
summary: "doing stuff",
|
||||
});
|
||||
|
||||
const next = mutateMetadata(dataDir, "upd-5", (existing) => ({
|
||||
...existing,
|
||||
summary: "",
|
||||
pr: "https://github.com/org/repo/pull/5",
|
||||
}));
|
||||
|
||||
expect(next).toEqual({
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
pr: "https://github.com/org/repo/pull/5",
|
||||
});
|
||||
expect(readMetadataRaw(dataDir, "upd-5")).toEqual(next);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCanonicalLifecycle", () => {
|
||||
it("reads canonical lifecycle from statePayload", () => {
|
||||
writeMetadata(dataDir, "lifecycle-1", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
stateVersion: "2",
|
||||
statePayload:
|
||||
'{"version":2,"session":{"kind":"worker","state":"working","reason":"task_in_progress","startedAt":"2025-01-01T00:00:00.000Z","completedAt":null,"terminatedAt":null,"lastTransitionAt":"2025-01-01T00:00:00.000Z"},"pr":{"state":"open","reason":"in_progress","number":42,"url":"https://github.com/org/repo/pull/42","lastObservedAt":"2025-01-01T00:00:00.000Z"},"runtime":{"state":"alive","reason":"process_running","lastObservedAt":"2025-01-01T00:00:00.000Z","handle":{"id":"tmux-1","runtimeName":"tmux","data":{}},"tmuxName":"tmux-1"}}',
|
||||
});
|
||||
|
||||
const lifecycle = readCanonicalLifecycle(dataDir, "lifecycle-1");
|
||||
expect(lifecycle).not.toBeNull();
|
||||
expect(lifecycle!.session.state).toBe("working");
|
||||
expect(lifecycle!.pr.state).toBe("open");
|
||||
expect(lifecycle!.runtime.state).toBe("alive");
|
||||
});
|
||||
|
||||
it("validates legacy status before synthesizing canonical lifecycle", () => {
|
||||
writeMetadata(dataDir, "lifecycle-legacy-invalid", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "unknown",
|
||||
});
|
||||
|
||||
const lifecycle = readCanonicalLifecycle(dataDir, "lifecycle-legacy-invalid");
|
||||
expect(lifecycle).not.toBeNull();
|
||||
expect(lifecycle!.session.state).toBe("not_started");
|
||||
expect(lifecycle!.session.reason).toBe("spawn_requested");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMetadata", () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
createProjectObserver,
|
||||
getObservabilityBaseDir,
|
||||
readObservabilitySummary,
|
||||
type OrchestratorConfig,
|
||||
} from "../index.js";
|
||||
|
|
@ -101,4 +102,99 @@ describe("observability snapshot", () => {
|
|||
expect(project.health["lifecycle.worker"]?.status).toBe("warn");
|
||||
expect(summary.overallStatus).toBe("warn");
|
||||
});
|
||||
|
||||
it("writes observability diagnostics to audit files without mirroring to stderr by default", () => {
|
||||
const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
|
||||
observer.recordOperation({
|
||||
metric: "spawn",
|
||||
operation: "session.spawn",
|
||||
outcome: "success",
|
||||
correlationId: "corr-1",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
level: "warn",
|
||||
});
|
||||
|
||||
observer.recordDiagnostic?.({
|
||||
operation: "batch_enrichment.log",
|
||||
correlationId: "corr-2",
|
||||
projectId: "my-app",
|
||||
message: "GraphQL batch returned cached result",
|
||||
level: "warn",
|
||||
data: { plugin: "github" },
|
||||
});
|
||||
|
||||
observer.setHealth({
|
||||
surface: "lifecycle.worker",
|
||||
status: "warn",
|
||||
projectId: "my-app",
|
||||
correlationId: "corr-3",
|
||||
details: { projectId: "my-app" },
|
||||
});
|
||||
|
||||
const auditDir = join(getObservabilityBaseDir(config.configPath), "processes");
|
||||
const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson"));
|
||||
expect(auditFiles.length).toBeGreaterThan(0);
|
||||
|
||||
const auditLog = readFileSync(join(auditDir, auditFiles[0]!), "utf-8");
|
||||
expect(auditLog).toContain('"operation":"session.spawn"');
|
||||
expect(auditLog).toContain('"operation":"batch_enrichment.log"');
|
||||
expect(auditLog).toContain('"message":"GraphQL batch returned cached result"');
|
||||
expect(auditLog).toContain('"timestamp"');
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive observability payload fields before persisting them", () => {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
|
||||
observer.recordOperation({
|
||||
metric: "send",
|
||||
operation: "session.send",
|
||||
outcome: "failure",
|
||||
correlationId: "corr-redact",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
reason: "Authorization token abc123 failed validation",
|
||||
data: {
|
||||
token: "abc123",
|
||||
prompt: "ship it",
|
||||
nested: {
|
||||
password: "s3cr3t",
|
||||
detail: "safe detail",
|
||||
},
|
||||
},
|
||||
level: "error",
|
||||
});
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
const trace = summary.projects["my-app"]?.recentTraces.find(
|
||||
(entry) => entry.operation === "session.send",
|
||||
);
|
||||
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace?.reason).toContain("Authorization token abc123 failed validation");
|
||||
expect(trace?.data).toEqual({
|
||||
token: "[redacted]",
|
||||
prompt: "[redacted]",
|
||||
nested: {
|
||||
password: "[redacted]",
|
||||
detail: "safe detail",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ describe("plugin integration", () => {
|
|||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("SessionManager + SCM", () => {
|
||||
it("cleanup() calls scm-github getPRState() and kills merged PR sessions", async () => {
|
||||
it("cleanup() calls scm-github getPRState() but keeps merged PR sessions alive", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
|
|
@ -412,7 +412,7 @@ describe("plugin integration", () => {
|
|||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
expect(result.skipped).toContain("app-1");
|
||||
// Verify gh CLI was called for PR state check
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ function makeAssessment(overrides: Partial<RecoveryAssessment> = {}): RecoveryAs
|
|||
classification: "live",
|
||||
action: "recover",
|
||||
reason: "Session is running normally",
|
||||
runtimeProbeSucceeded: true,
|
||||
processProbeSucceeded: true,
|
||||
signalDisagreement: false,
|
||||
recoveryRule: "auto",
|
||||
runtimeAlive: true,
|
||||
runtimeHandle: { id: "rt-1", runtimeName: "tmux", data: {} },
|
||||
workspaceExists: true,
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ describe("recovery validator", () => {
|
|||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
worktree: join(rootDir, "missing-worktree"),
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
|
|
@ -212,5 +212,524 @@ describe("recovery validator", () => {
|
|||
|
||||
expect(mockRuntime.isAlive).toHaveBeenCalled();
|
||||
expect(assessment.runtimeAlive).toBe(false);
|
||||
expect(assessment.runtimeProbeSucceeded).toBe(false);
|
||||
expect(assessment.classification).toBe("partial");
|
||||
expect(assessment.action).toBe("escalate");
|
||||
expect(assessment.reason).toContain("Probe uncertainty");
|
||||
});
|
||||
|
||||
it("escalates when runtime and process signals disagree", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: join(rootDir, "missing-worktree"),
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.signalDisagreement).toBe(true);
|
||||
expect(assessment.recoveryRule).toBe("human");
|
||||
expect(assessment.action).toBe("escalate");
|
||||
expect(assessment.reason).toContain("Signal disagreement");
|
||||
});
|
||||
|
||||
it("uses agent activity as liveness fallback when process probing says not running", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(mockAgent.getActivityState).toHaveBeenCalled();
|
||||
expect(assessment.agentActivity).toBe("active");
|
||||
expect(assessment.agentProcessRunning).toBe(true);
|
||||
expect(assessment.classification).toBe("live");
|
||||
});
|
||||
|
||||
it("keeps terminal metadata unrecoverable even when probes are uncertain", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "merged",
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.classification).toBe("unrecoverable");
|
||||
expect(assessment.recoveryRule).toBe("skip");
|
||||
expect(assessment.action).toBe("skip");
|
||||
});
|
||||
|
||||
it("recovers healthy sessions even if stale metadata still says detecting", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "detecting",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.classification).toBe("live");
|
||||
expect(assessment.recoveryRule).toBe("auto");
|
||||
expect(assessment.action).toBe("recover");
|
||||
});
|
||||
|
||||
it("treats missing runtime handle plus missing workspace as dead metadata", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: join(rootDir, "missing-worktree"),
|
||||
status: "working",
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.classification).toBe("dead");
|
||||
expect(assessment.recoveryRule).toBe("auto");
|
||||
expect(assessment.action).toBe("cleanup");
|
||||
});
|
||||
|
||||
it("respects escalatePartial=false for non-disagreement partial sessions", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: join(rootDir, "missing-worktree"),
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry, {
|
||||
escalatePartial: false,
|
||||
});
|
||||
|
||||
expect(assessment.classification).toBe("partial");
|
||||
expect(assessment.signalDisagreement).toBe(false);
|
||||
expect(assessment.recoveryRule).toBe("auto");
|
||||
expect(assessment.action).toBe("cleanup");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditAgentReports,
|
||||
checkAcknowledgeTimeout,
|
||||
checkStaleReport,
|
||||
checkBlockedAgent,
|
||||
shouldAuditSession,
|
||||
getReactionKeyForTrigger,
|
||||
DEFAULT_REPORT_WATCHER_CONFIG,
|
||||
type ReportWatcherConfig,
|
||||
} from "../report-watcher.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import type { Session, SessionStatus } from "../types.js";
|
||||
import type { AgentReport } from "../agent-report.js";
|
||||
|
||||
function createMockSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
|
||||
return {
|
||||
id: "test-session",
|
||||
projectId: "test-project",
|
||||
status: "working" as SessionStatus,
|
||||
branch: "main",
|
||||
createdAt: new Date(),
|
||||
lifecycle,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("shouldAuditSession", () => {
|
||||
it("returns true for active working sessions", () => {
|
||||
const session = createMockSession({ status: "working" });
|
||||
expect(shouldAuditSession(session)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for terminal sessions", () => {
|
||||
const terminalStatuses: SessionStatus[] = [
|
||||
"done",
|
||||
"terminated",
|
||||
"killed",
|
||||
"cleanup",
|
||||
"merged",
|
||||
"errored",
|
||||
];
|
||||
|
||||
for (const status of terminalStatuses) {
|
||||
const session = createMockSession({ status });
|
||||
expect(shouldAuditSession(session)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for spawning sessions (allows acknowledge timeout check)", () => {
|
||||
const session = createMockSession({ status: "spawning" });
|
||||
expect(shouldAuditSession(session)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for orchestrator sessions", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("orchestrator");
|
||||
const session = createMockSession({ lifecycle });
|
||||
expect(shouldAuditSession(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAcknowledgeTimeout", () => {
|
||||
const config = DEFAULT_REPORT_WATCHER_CONFIG;
|
||||
|
||||
it("returns null when report exists", () => {
|
||||
const session = createMockSession();
|
||||
const report: AgentReport = {
|
||||
state: "started",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const result = checkAcknowledgeTimeout(session, report, now, config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when within timeout window", () => {
|
||||
const now = new Date();
|
||||
const spawnTime = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const session = createMockSession({
|
||||
createdAt: spawnTime,
|
||||
metadata: { createdAt: spawnTime.toISOString() },
|
||||
});
|
||||
|
||||
const result = checkAcknowledgeTimeout(session, null, now, config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trigger when timeout exceeded", () => {
|
||||
const now = new Date();
|
||||
const spawnTime = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago
|
||||
const session = createMockSession({
|
||||
createdAt: spawnTime,
|
||||
metadata: { createdAt: spawnTime.toISOString() },
|
||||
});
|
||||
|
||||
const result = checkAcknowledgeTimeout(session, null, now, config);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.trigger).toBe("no_acknowledge");
|
||||
expect(result?.timeSinceSpawnMs).toBeGreaterThan(config.acknowledgeTimeoutMs);
|
||||
});
|
||||
|
||||
it("respects checkAcknowledge config flag", () => {
|
||||
const now = new Date();
|
||||
const spawnTime = new Date(now.getTime() - 15 * 60 * 1000);
|
||||
const session = createMockSession({
|
||||
createdAt: spawnTime,
|
||||
metadata: { createdAt: spawnTime.toISOString() },
|
||||
});
|
||||
const disabledConfig: ReportWatcherConfig = {
|
||||
...config,
|
||||
checkAcknowledge: false,
|
||||
};
|
||||
|
||||
const result = checkAcknowledgeTimeout(session, null, now, disabledConfig);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkStaleReport", () => {
|
||||
const config = DEFAULT_REPORT_WATCHER_CONFIG;
|
||||
|
||||
it("returns null when no report exists", () => {
|
||||
const session = createMockSession();
|
||||
const result = checkStaleReport(session, null, new Date(), config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when report is fresh", () => {
|
||||
const now = new Date();
|
||||
const reportTime = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const session = createMockSession();
|
||||
const report: AgentReport = {
|
||||
state: "working",
|
||||
timestamp: reportTime.toISOString(),
|
||||
};
|
||||
|
||||
const result = checkStaleReport(session, report, now, config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trigger when report is stale", () => {
|
||||
const now = new Date();
|
||||
const reportTime = new Date(now.getTime() - 45 * 60 * 1000); // 45 minutes ago
|
||||
const session = createMockSession();
|
||||
const report: AgentReport = {
|
||||
state: "working",
|
||||
timestamp: reportTime.toISOString(),
|
||||
};
|
||||
|
||||
const result = checkStaleReport(session, report, now, config);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.trigger).toBe("stale_report");
|
||||
expect(result?.timeSinceReportMs).toBeGreaterThan(config.staleReportTimeoutMs);
|
||||
});
|
||||
|
||||
it("does not flag waiting states as stale", () => {
|
||||
const now = new Date();
|
||||
const reportTime = new Date(now.getTime() - 45 * 60 * 1000); // 45 minutes ago
|
||||
const session = createMockSession();
|
||||
|
||||
for (const waitingState of ["waiting", "needs_input"] as const) {
|
||||
const report: AgentReport = {
|
||||
state: waitingState,
|
||||
timestamp: reportTime.toISOString(),
|
||||
};
|
||||
const result = checkStaleReport(session, report, now, config);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBlockedAgent", () => {
|
||||
const config = DEFAULT_REPORT_WATCHER_CONFIG;
|
||||
|
||||
it("returns null when no report exists", () => {
|
||||
const session = createMockSession();
|
||||
const result = checkBlockedAgent(session, null, new Date(), config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trigger for needs_input state", () => {
|
||||
const now = new Date();
|
||||
const session = createMockSession();
|
||||
const report: AgentReport = {
|
||||
state: "needs_input",
|
||||
timestamp: now.toISOString(),
|
||||
note: "Need API key",
|
||||
};
|
||||
|
||||
const result = checkBlockedAgent(session, report, now, config);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.trigger).toBe("agent_needs_input");
|
||||
expect(result?.message).toContain("Need API key");
|
||||
});
|
||||
|
||||
it("keeps needs_input visible even after the freshness window", () => {
|
||||
const now = new Date();
|
||||
const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const session = createMockSession();
|
||||
const report: AgentReport = {
|
||||
state: "needs_input",
|
||||
timestamp: oldReportTime.toISOString(),
|
||||
};
|
||||
|
||||
const result = checkBlockedAgent(session, report, now, config);
|
||||
expect(result?.trigger).toBe("agent_needs_input");
|
||||
expect(result?.timeSinceReportMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditAgentReports", () => {
|
||||
it("returns null for terminal sessions", () => {
|
||||
const session = createMockSession({ status: "done" });
|
||||
expect(auditAgentReports(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no issues found", () => {
|
||||
const now = new Date();
|
||||
const session = createMockSession({
|
||||
createdAt: now,
|
||||
metadata: {
|
||||
createdAt: now.toISOString(),
|
||||
agentReportedState: "working",
|
||||
agentReportedAt: now.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditAgentReports(session, {}, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritizes blocked over acknowledge timeout", () => {
|
||||
const now = new Date();
|
||||
const spawnTime = new Date(now.getTime() - 15 * 60 * 1000); // Would trigger no_acknowledge
|
||||
const session = createMockSession({
|
||||
createdAt: spawnTime,
|
||||
metadata: {
|
||||
createdAt: spawnTime.toISOString(),
|
||||
agentReportedState: "needs_input",
|
||||
agentReportedAt: now.toISOString(), // Fresh needs_input
|
||||
},
|
||||
});
|
||||
|
||||
const result = auditAgentReports(session, {}, now);
|
||||
expect(result?.trigger).toBe("agent_needs_input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReactionKeyForTrigger", () => {
|
||||
it("maps triggers to reaction keys", () => {
|
||||
expect(getReactionKeyForTrigger("no_acknowledge")).toBe("report-no-acknowledge");
|
||||
expect(getReactionKeyForTrigger("stale_report")).toBe("report-stale");
|
||||
expect(getReactionKeyForTrigger("agent_needs_input")).toBe("report-needs-input");
|
||||
});
|
||||
});
|
||||
|
|
@ -229,11 +229,11 @@ describe("kill", () => {
|
|||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("kills sessions with merged PRs", async () => {
|
||||
it("kills sessions with closed PRs", async () => {
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
getPRState: vi.fn().mockResolvedValue("closed"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
|
|
@ -272,7 +272,7 @@ describe("cleanup", () => {
|
|||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletes mapped OpenCode session during cleanup", async () => {
|
||||
it("deletes mapped OpenCode session during cleanup for closed PRs", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
|
|
@ -280,7 +280,7 @@ describe("cleanup", () => {
|
|||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
getPRState: vi.fn().mockResolvedValue("closed"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
|
|
@ -322,14 +322,14 @@ describe("cleanup", () => {
|
|||
expect(deleteLog).toContain("session delete ses_cleanup");
|
||||
});
|
||||
|
||||
it("treats missing mapped OpenCode session as already cleaned", async () => {
|
||||
it("treats missing mapped OpenCode session as already cleaned for closed PRs", async () => {
|
||||
const mockBin = installMockOpencodeWithNotFoundDelete(tmpDir, "[]");
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
getPRState: vi.fn().mockResolvedValue("closed"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
|
|
@ -392,7 +392,7 @@ describe("cleanup", () => {
|
|||
expect(result.killed).toContain("app-6");
|
||||
const deleteLog = readFileSync(deleteLogPath, "utf-8");
|
||||
expect(deleteLog).toContain("session delete ses_archived");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("does not skip archived cleanup for matching session IDs in other projects", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived-cross-project.log");
|
||||
|
|
|
|||
|
|
@ -88,6 +88,34 @@ describe("list", () => {
|
|||
expect(repaired!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("persists canonical lifecycle payloads for legacy session metadata on read", async () => {
|
||||
writeMetadata(sessionsDir, "app-legacy", {
|
||||
worktree: "/tmp/legacy",
|
||||
branch: "feat/legacy",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const oldTime = new Date("2026-01-02T00:00:00.000Z");
|
||||
utimesSync(join(sessionsDir, "app-legacy"), oldTime, oldTime);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const sessions = await sm.list("my-app");
|
||||
const legacy = sessions.find((session) => session.id === "app-legacy");
|
||||
|
||||
expect(legacy).toBeDefined();
|
||||
expect(legacy!.lastActivityAt.getTime()).toBe(oldTime.getTime());
|
||||
|
||||
const repaired = readMetadataRaw(sessionsDir, "app-legacy");
|
||||
expect(repaired?.["stateVersion"]).toBe("2");
|
||||
expect(repaired?.["statePayload"]).toBeTruthy();
|
||||
|
||||
const payload = JSON.parse(repaired!["statePayload"]);
|
||||
expect(payload.session.startedAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
expect(payload.session.lastTransitionAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("filters by project ID", async () => {
|
||||
// In hash-based architecture, each project has its own directory
|
||||
// so filtering is implicit. This test verifies list(projectId) only
|
||||
|
|
@ -315,6 +343,7 @@ describe("list", () => {
|
|||
|
||||
// Should keep null (absent) when getActivityState fails
|
||||
expect(sessions[0].activity).toBeNull();
|
||||
expect(sessions[0].activitySignal.state).toBe("probe_failure");
|
||||
});
|
||||
|
||||
it("keeps existing activity when getActivityState returns null", async () => {
|
||||
|
|
@ -345,6 +374,36 @@ describe("list", () => {
|
|||
// null = "I don't know" — activity stays null (absent)
|
||||
expect(agentWithNull.getActivityState).toHaveBeenCalled();
|
||||
expect(sessions[0].activity).toBeNull();
|
||||
expect(sessions[0].activitySignal.state).toBe("null");
|
||||
});
|
||||
|
||||
it("marks terminal fallback-free stale activity explicitly when timing is missing", async () => {
|
||||
const agentWithIdleNoTimestamp: Agent = {
|
||||
...mockAgent,
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "idle" }),
|
||||
};
|
||||
const registryWithStaleSignal: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return agentWithIdleNoTimestamp;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithStaleSignal });
|
||||
const sessions = await sm.list();
|
||||
|
||||
expect(sessions[0].activity).toBe("idle");
|
||||
expect(sessions[0].activitySignal.state).toBe("stale");
|
||||
});
|
||||
|
||||
it("updates lastActivityAt when detection timestamp is newer", async () => {
|
||||
|
|
|
|||
|
|
@ -176,6 +176,20 @@ describe("restore", () => {
|
|||
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
|
||||
});
|
||||
|
||||
it("throws SessionNotRestorableError for legacy merged sessions with a PR URL", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "merged",
|
||||
pr: "https://github.com/org/my-app/pull/10",
|
||||
project: "my-app",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
|
||||
});
|
||||
|
||||
it("throws SessionNotRestorableError for working sessions", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ describe("spawn", () => {
|
|||
|
||||
const metadata = readMetadataRaw(sessionsDir, session.id);
|
||||
expect(metadata?.["opencodeSessionId"]).toBeUndefined();
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("throws for unknown project", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
|
@ -1865,7 +1865,7 @@ describe("spawn", () => {
|
|||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("throws for unknown project", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
|
||||
import { createInitialCanonicalLifecycle, deriveLegacyStatus } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
|
|
@ -28,11 +30,69 @@ export function makeHandle(id: string): RuntimeHandle {
|
|||
}
|
||||
|
||||
export function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
const requestedStatus = overrides.status ?? "working";
|
||||
switch (requestedStatus) {
|
||||
case "spawning":
|
||||
lifecycle.session.state = "not_started";
|
||||
lifecycle.session.reason = "spawn_requested";
|
||||
break;
|
||||
case "needs_input":
|
||||
lifecycle.session.state = "needs_input";
|
||||
lifecycle.session.reason = "awaiting_user_input";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
break;
|
||||
case "stuck":
|
||||
case "errored":
|
||||
lifecycle.session.state = "stuck";
|
||||
lifecycle.session.reason = requestedStatus === "errored" ? "error_in_process" : "probe_failure";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
break;
|
||||
case "merged":
|
||||
lifecycle.session.state = "idle";
|
||||
lifecycle.session.reason = "merged_waiting_decision";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.pr.state = "merged";
|
||||
lifecycle.pr.reason = "merged";
|
||||
break;
|
||||
case "done":
|
||||
lifecycle.session.state = "done";
|
||||
lifecycle.session.reason = "research_complete";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.session.completedAt = lifecycle.session.lastTransitionAt;
|
||||
break;
|
||||
case "killed":
|
||||
case "terminated":
|
||||
case "cleanup":
|
||||
lifecycle.session.state = "terminated";
|
||||
lifecycle.session.reason = "manually_killed";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.session.terminatedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "missing";
|
||||
lifecycle.runtime.reason = "manual_kill_requested";
|
||||
break;
|
||||
default:
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
break;
|
||||
}
|
||||
if (lifecycle.session.state !== "terminated") {
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
}
|
||||
lifecycle.runtime.handle = { id: "rt-1", runtimeName: "mock", data: {} };
|
||||
const base: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
status: deriveLegacyStatus(lifecycle),
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
@ -42,7 +102,11 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
lifecycle: overrides.lifecycle ?? lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +264,8 @@ export interface TestEnvironment {
|
|||
export function createTestEnvironment(): TestEnvironment {
|
||||
const tmpDir = join(tmpdir(), `ao-test-lifecycle-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const previousHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "projects: {}\n");
|
||||
|
|
@ -239,6 +305,11 @@ export function createTestEnvironment(): TestEnvironment {
|
|||
mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const cleanup = () => {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = previousHome;
|
||||
}
|
||||
const projectBaseDir = getProjectBaseDir(configPath, join(tmpDir, "my-app"));
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -187,13 +187,31 @@ describe("parsePrFromUrl", () => {
|
|||
|
||||
it("falls back to trailing number for non-GitHub URLs", () => {
|
||||
expect(parsePrFromUrl("https://gitlab.com/foo/bar/-/merge_requests/456")).toEqual({
|
||||
owner: "",
|
||||
repo: "",
|
||||
owner: "foo",
|
||||
repo: "bar",
|
||||
number: 456,
|
||||
url: "https://gitlab.com/foo/bar/-/merge_requests/456",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses GitHub Enterprise pull request URLs", () => {
|
||||
expect(parsePrFromUrl("https://github.example.com/foo/bar/pull/789")).toEqual({
|
||||
owner: "foo",
|
||||
repo: "bar",
|
||||
number: 789,
|
||||
url: "https://github.example.com/foo/bar/pull/789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses GitHub pull request URLs with trailing path segments", () => {
|
||||
expect(parsePrFromUrl("https://github.com/foo/bar/pull/123/files")).toEqual({
|
||||
owner: "foo",
|
||||
repo: "bar",
|
||||
number: 123,
|
||||
url: "https://github.com/foo/bar/pull/123/files",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the URL has no PR number", () => {
|
||||
expect(parsePrFromUrl("https://example.com/foo/bar/pull/not-a-number")).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import type { ActivityDetection, ActivitySignal, ActivitySignalSource, ActivityState } from "./types.js";
|
||||
|
||||
const TIMING_SENSITIVE_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set([
|
||||
"active",
|
||||
"ready",
|
||||
"idle",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
const LIVENESS_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set(["active", "ready"]);
|
||||
const IDLE_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set(["idle", "blocked"]);
|
||||
|
||||
export const ACTIVITY_STRONG_WINDOW_MS = 60_000;
|
||||
export const ACTIVITY_WEAK_WINDOW_MS = 5 * 60_000;
|
||||
|
||||
export function summarizeActivityFreshness(
|
||||
timestamp: Date | undefined,
|
||||
now: Date = new Date(),
|
||||
): "strong" | "weak" | "stale" | "none" {
|
||||
if (!timestamp) return "none";
|
||||
const ageMs = Math.max(0, now.getTime() - timestamp.getTime());
|
||||
if (ageMs <= ACTIVITY_STRONG_WINDOW_MS) return "strong";
|
||||
if (ageMs <= ACTIVITY_WEAK_WINDOW_MS) return "weak";
|
||||
return "stale";
|
||||
}
|
||||
|
||||
export function createActivitySignal(
|
||||
state: ActivitySignal["state"],
|
||||
options: {
|
||||
activity?: ActivityState | null;
|
||||
timestamp?: Date;
|
||||
source?: ActivitySignalSource;
|
||||
detail?: string;
|
||||
} = {},
|
||||
): ActivitySignal {
|
||||
return {
|
||||
state,
|
||||
activity: options.activity ?? null,
|
||||
timestamp: options.timestamp,
|
||||
source: options.source ?? "none",
|
||||
detail: options.detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyActivitySignal(
|
||||
detection: ActivityDetection,
|
||||
source: ActivitySignalSource,
|
||||
now: Date = new Date(),
|
||||
): ActivitySignal {
|
||||
if (!TIMING_SENSITIVE_ACTIVITY_STATES.has(detection.state)) {
|
||||
return createActivitySignal("valid", {
|
||||
activity: detection.state,
|
||||
timestamp: detection.timestamp,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
if (!detection.timestamp && IDLE_ACTIVITY_STATES.has(detection.state)) {
|
||||
return createActivitySignal("stale", {
|
||||
activity: detection.state,
|
||||
source,
|
||||
detail: "missing_timestamp",
|
||||
});
|
||||
}
|
||||
|
||||
if (!detection.timestamp) {
|
||||
return createActivitySignal("valid", {
|
||||
activity: detection.state,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
LIVENESS_ACTIVITY_STATES.has(detection.state) &&
|
||||
summarizeActivityFreshness(detection.timestamp, now) === "stale"
|
||||
) {
|
||||
return createActivitySignal("stale", {
|
||||
activity: detection.state,
|
||||
timestamp: detection.timestamp,
|
||||
source,
|
||||
detail: "stale_timestamp",
|
||||
});
|
||||
}
|
||||
|
||||
return createActivitySignal("valid", {
|
||||
activity: detection.state,
|
||||
timestamp: detection.timestamp,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasPositiveIdleEvidence(signal: ActivitySignal): signal is ActivitySignal & {
|
||||
activity: "idle" | "blocked";
|
||||
timestamp: Date;
|
||||
state: "valid";
|
||||
} {
|
||||
return (
|
||||
signal.state === "valid" &&
|
||||
signal.timestamp instanceof Date &&
|
||||
signal.activity !== null &&
|
||||
IDLE_ACTIVITY_STATES.has(signal.activity)
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsRecentLiveness(signal: ActivitySignal, now: Date = new Date()): boolean {
|
||||
return (
|
||||
signal.state === "valid" &&
|
||||
signal.timestamp instanceof Date &&
|
||||
signal.activity !== null &&
|
||||
LIVENESS_ACTIVITY_STATES.has(signal.activity) &&
|
||||
summarizeActivityFreshness(signal.timestamp, now) !== "stale"
|
||||
);
|
||||
}
|
||||
|
||||
export function isWeakActivityEvidence(signal: ActivitySignal): boolean {
|
||||
return signal.state !== "valid";
|
||||
}
|
||||
|
||||
export function formatActivitySignalEvidence(signal: ActivitySignal): string {
|
||||
const source = signal.source === "none" ? "" : ` via_${signal.source}`;
|
||||
const activity = signal.activity ? ` activity=${signal.activity}` : "";
|
||||
const timing = signal.timestamp ? ` at=${signal.timestamp.toISOString()}` : "";
|
||||
const detail = signal.detail ? ` detail=${signal.detail}` : "";
|
||||
return `activity_signal=${signal.state}${source}${activity}${timing}${detail}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
/**
|
||||
* Agent Report — explicit workflow transitions declared by the worker agent.
|
||||
*
|
||||
* Stage 3 of the state-machine redesign. Agents run `ao acknowledge` and
|
||||
* `ao report <state>` from inside a managed session to declare the workflow
|
||||
* phase they are entering. The lifecycle manager prefers fresh agent reports
|
||||
* over weak inference, but runtime evidence (process death, merged PR) and
|
||||
* SCM ground-truth (CI, review decisions) still take precedence.
|
||||
*
|
||||
* Fallback matrix (highest precedence first):
|
||||
* 1. Runtime dead + no recent activity → terminated/stuck
|
||||
* 2. Agent activity plugin surfaces waiting_input/exited
|
||||
* 3. SCM/PR ground truth (merged, closed, CI, reviews)
|
||||
* 4. Fresh agent report (this module)
|
||||
* 5. Idle-beyond-threshold promotion → stuck
|
||||
* 6. Default to working
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
CanonicalSessionLifecycle,
|
||||
CanonicalSessionReason,
|
||||
CanonicalSessionState,
|
||||
SessionId,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { mutateMetadata, readMetadataRaw } from "./metadata.js";
|
||||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js";
|
||||
import { parsePrFromUrl } from "./utils/pr.js";
|
||||
import { assertValidSessionIdComponent } from "./utils/session-id.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
||||
/**
|
||||
* Canonical set of states an agent can self-declare.
|
||||
*
|
||||
* - `started` — agent has begun the task after planning
|
||||
* - `working` — generic working signal, useful after a pause
|
||||
* - `waiting` — blocked on an external dependency agent cannot unblock
|
||||
* - `needs_input` — blocked on human input
|
||||
* - `fixing_ci` — responding to a failing CI run
|
||||
* - `addressing_reviews`— responding to requested review changes
|
||||
* - `pr_created` / `draft_pr_created` / `ready_for_review`
|
||||
* — non-terminal PR workflow events with optional PR metadata
|
||||
* - `completed` — finished research/non-coding work (not "merged")
|
||||
*
|
||||
* Note: agents cannot self-report `done`, `terminated`, or terminal PR states
|
||||
* like `merged` / `closed`. Those remain owned by AO so ground-truth sources
|
||||
* (SCM, runtime) stay authoritative.
|
||||
*/
|
||||
export const AGENT_REPORTED_STATES = [
|
||||
"started",
|
||||
"working",
|
||||
"waiting",
|
||||
"needs_input",
|
||||
"fixing_ci",
|
||||
"addressing_reviews",
|
||||
"pr_created",
|
||||
"draft_pr_created",
|
||||
"ready_for_review",
|
||||
"completed",
|
||||
] as const;
|
||||
|
||||
export type AgentReportedState = (typeof AGENT_REPORTED_STATES)[number];
|
||||
|
||||
export interface AgentReport {
|
||||
state: AgentReportedState;
|
||||
/** ISO 8601 timestamp — when the agent issued the report. */
|
||||
timestamp: string;
|
||||
/** Optional free-text note the agent may include (e.g. brief status line). */
|
||||
note?: string;
|
||||
/** Optional PR number attached to PR workflow reports. */
|
||||
prNumber?: number;
|
||||
/** Optional PR URL attached to PR workflow reports. */
|
||||
prUrl?: string;
|
||||
/** Optional draft hint attached to PR workflow reports. */
|
||||
prIsDraft?: boolean;
|
||||
/** Local actor identity when available (e.g. $USER). */
|
||||
actor?: string;
|
||||
/** Which CLI surface produced this report. */
|
||||
source?: "acknowledge" | "report";
|
||||
}
|
||||
|
||||
export interface AgentReportAuditSnapshot {
|
||||
legacyStatus: SessionStatus;
|
||||
sessionState: CanonicalSessionState;
|
||||
sessionReason: CanonicalSessionReason;
|
||||
lastTransitionAt: string | null;
|
||||
}
|
||||
|
||||
export interface AgentReportAuditEntry {
|
||||
timestamp: string;
|
||||
actor: string;
|
||||
source: "acknowledge" | "report";
|
||||
reportState: AgentReportedState;
|
||||
note?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
prIsDraft?: boolean;
|
||||
accepted: boolean;
|
||||
rejectionReason?: string;
|
||||
before: AgentReportAuditSnapshot;
|
||||
after: AgentReportAuditSnapshot;
|
||||
}
|
||||
|
||||
/** Metadata keys written by `applyAgentReport`. Keep in sync with CLI parsing. */
|
||||
export const AGENT_REPORT_METADATA_KEYS = {
|
||||
STATE: "agentReportedState",
|
||||
AT: "agentReportedAt",
|
||||
NOTE: "agentReportedNote",
|
||||
PR_NUMBER: "agentReportedPrNumber",
|
||||
PR_URL: "agentReportedPrUrl",
|
||||
PR_IS_DRAFT: "agentReportedPrIsDraft",
|
||||
} as const;
|
||||
|
||||
/** Freshness window — agent reports older than this are ignored. */
|
||||
export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
|
||||
export const AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* CLI surface accepts these hyphen/underscore aliases for convenience.
|
||||
*
|
||||
* Note: `done` is intentionally NOT an alias — agents cannot self-report
|
||||
* terminal `done` state (AO owns that transition via SCM ground truth). Use
|
||||
* `completed` for finished non-coding research/analysis work.
|
||||
*/
|
||||
const INPUT_ALIASES: Record<string, AgentReportedState> = {
|
||||
start: "started",
|
||||
started: "started",
|
||||
working: "working",
|
||||
work: "working",
|
||||
wait: "waiting",
|
||||
waiting: "waiting",
|
||||
"needs-input": "needs_input",
|
||||
needs_input: "needs_input",
|
||||
input: "needs_input",
|
||||
"fixing-ci": "fixing_ci",
|
||||
fixing_ci: "fixing_ci",
|
||||
ci: "fixing_ci",
|
||||
"addressing-reviews": "addressing_reviews",
|
||||
addressing_reviews: "addressing_reviews",
|
||||
reviews: "addressing_reviews",
|
||||
"pr-created": "pr_created",
|
||||
pr_created: "pr_created",
|
||||
"draft-pr-created": "draft_pr_created",
|
||||
draft_pr_created: "draft_pr_created",
|
||||
"ready-for-review": "ready_for_review",
|
||||
ready_for_review: "ready_for_review",
|
||||
completed: "completed",
|
||||
complete: "completed",
|
||||
};
|
||||
|
||||
/** Normalize a user-supplied report name into the canonical form. */
|
||||
export function normalizeAgentReportedState(input: string): AgentReportedState | null {
|
||||
if (!input) return null;
|
||||
return INPUT_ALIASES[input.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/** Canonical mapping: AgentReportedState → (canonical session state, reason). */
|
||||
export function mapAgentReportToLifecycle(state: AgentReportedState): {
|
||||
sessionState: CanonicalSessionState;
|
||||
sessionReason: CanonicalSessionReason;
|
||||
} {
|
||||
switch (state) {
|
||||
case "started":
|
||||
return { sessionState: "working", sessionReason: "agent_acknowledged" };
|
||||
case "working":
|
||||
return { sessionState: "working", sessionReason: "task_in_progress" };
|
||||
case "waiting":
|
||||
return { sessionState: "idle", sessionReason: "awaiting_external_review" };
|
||||
case "needs_input":
|
||||
return { sessionState: "needs_input", sessionReason: "awaiting_user_input" };
|
||||
case "fixing_ci":
|
||||
return { sessionState: "working", sessionReason: "fixing_ci" };
|
||||
case "addressing_reviews":
|
||||
return { sessionState: "working", sessionReason: "resolving_review_comments" };
|
||||
case "pr_created":
|
||||
return { sessionState: "idle", sessionReason: "pr_created" };
|
||||
case "draft_pr_created":
|
||||
return { sessionState: "working", sessionReason: "task_in_progress" };
|
||||
case "ready_for_review":
|
||||
return { sessionState: "idle", sessionReason: "awaiting_external_review" };
|
||||
case "completed":
|
||||
return { sessionState: "idle", sessionReason: "research_complete" };
|
||||
}
|
||||
}
|
||||
|
||||
function isPRWorkflowReport(state: AgentReportedState): boolean {
|
||||
return state === "pr_created" || state === "draft_pr_created" || state === "ready_for_review";
|
||||
}
|
||||
|
||||
export interface AgentReportTransitionResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether an agent-issued report is allowed given the current lifecycle.
|
||||
*
|
||||
* Rules:
|
||||
* - Orchestrator sessions cannot accept agent reports (orchestrator sessions
|
||||
* are read-only coordinators).
|
||||
* - Terminal states (`done`, `terminated`) cannot be re-opened by an agent.
|
||||
* - Merged PRs cannot be re-opened by an agent (`completed`/`working` etc.
|
||||
* attempts are rejected).
|
||||
* - Runtime state of `missing`/`exited` means the agent cannot possibly be
|
||||
* reporting — reject so we don't silently contradict runtime truth.
|
||||
*/
|
||||
export function validateAgentReportTransition(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
_next: AgentReportedState,
|
||||
): AgentReportTransitionResult {
|
||||
if (lifecycle.session.kind === "orchestrator") {
|
||||
return { ok: false, reason: "orchestrator sessions cannot self-report" };
|
||||
}
|
||||
if (lifecycle.session.state === "terminated") {
|
||||
return { ok: false, reason: "session is terminated" };
|
||||
}
|
||||
// Terminal states cannot be re-opened by an agent — including `completed`,
|
||||
// which maps back to `idle` and would otherwise reanimate a `done` session.
|
||||
if (lifecycle.session.state === "done") {
|
||||
return { ok: false, reason: "session is already done" };
|
||||
}
|
||||
if (lifecycle.pr.state === "merged" || lifecycle.pr.state === "closed") {
|
||||
return { ok: false, reason: `PR already ${lifecycle.pr.state}` };
|
||||
}
|
||||
if (lifecycle.runtime.state === "missing" || lifecycle.runtime.state === "exited") {
|
||||
return { ok: false, reason: "runtime is not alive" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface ApplyAgentReportInput {
|
||||
state: AgentReportedState;
|
||||
note?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
actor?: string;
|
||||
source?: "acknowledge" | "report";
|
||||
/** Override the current clock — used by tests. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface ApplyAgentReportResult {
|
||||
report: AgentReport;
|
||||
legacyStatus: SessionStatus;
|
||||
previousState: CanonicalSessionState;
|
||||
nextState: CanonicalSessionState;
|
||||
auditEntry: AgentReportAuditEntry;
|
||||
}
|
||||
|
||||
function buildAuditDir(dataDir: string): string {
|
||||
return join(dataDir, ".agent-report-audit");
|
||||
}
|
||||
|
||||
const AGENT_REPORT_AUDIT_MAX_BYTES = 256 * 1024;
|
||||
const AGENT_REPORT_AUDIT_MAX_ENTRIES = 200;
|
||||
|
||||
function validateAuditSessionId(sessionId: SessionId): void {
|
||||
assertValidSessionIdComponent(sessionId);
|
||||
}
|
||||
|
||||
function buildAuditFilePath(dataDir: string, sessionId: SessionId): string {
|
||||
validateAuditSessionId(sessionId);
|
||||
return join(buildAuditDir(dataDir), `${sessionId}.ndjson`);
|
||||
}
|
||||
|
||||
function normalizeActor(actor: string | undefined): string {
|
||||
const trimmed = actor?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function buildAuditSnapshot(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
legacyStatus: SessionStatus,
|
||||
): AgentReportAuditSnapshot {
|
||||
return {
|
||||
legacyStatus,
|
||||
sessionState: lifecycle.session.state,
|
||||
sessionReason: lifecycle.session.reason,
|
||||
lastTransitionAt: lifecycle.session.lastTransitionAt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendAgentReportAuditEntry(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
entry: AgentReportAuditEntry,
|
||||
): void {
|
||||
const auditDir = buildAuditDir(dataDir);
|
||||
mkdirSync(auditDir, { recursive: true });
|
||||
const auditFilePath = buildAuditFilePath(dataDir, sessionId);
|
||||
const serializedEntry = `${JSON.stringify(entry)}\n`;
|
||||
if (existsSync(auditFilePath)) {
|
||||
const current = readFileSync(auditFilePath, "utf8");
|
||||
let entries = current
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-(AGENT_REPORT_AUDIT_MAX_ENTRIES - 1));
|
||||
entries.push(serializedEntry.trimEnd());
|
||||
while (
|
||||
entries.length > 1 &&
|
||||
Buffer.byteLength(`${entries.join("\n")}\n`, "utf8") > AGENT_REPORT_AUDIT_MAX_BYTES
|
||||
) {
|
||||
entries = entries.slice(1);
|
||||
}
|
||||
if (entries.length > AGENT_REPORT_AUDIT_MAX_ENTRIES) {
|
||||
entries = entries.slice(-AGENT_REPORT_AUDIT_MAX_ENTRIES);
|
||||
}
|
||||
if (
|
||||
Buffer.byteLength(current, "utf8") + Buffer.byteLength(serializedEntry, "utf8") >=
|
||||
AGENT_REPORT_AUDIT_MAX_BYTES
|
||||
) {
|
||||
writeFileSync(auditFilePath, `${entries.join("\n")}\n`, "utf8");
|
||||
return;
|
||||
}
|
||||
}
|
||||
appendFileSync(auditFilePath, serializedEntry, "utf8");
|
||||
}
|
||||
|
||||
export function readAgentReportAuditTrail(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
): AgentReportAuditEntry[] {
|
||||
const auditFilePath = buildAuditFilePath(dataDir, sessionId);
|
||||
if (!existsSync(auditFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseAgentReportAuditTrail(readFileSync(auditFilePath, "utf8"));
|
||||
}
|
||||
|
||||
export async function readAgentReportAuditTrailAsync(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
): Promise<AgentReportAuditEntry[]> {
|
||||
const auditFilePath = buildAuditFilePath(dataDir, sessionId);
|
||||
if (!existsSync(auditFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseAgentReportAuditTrail(await readFile(auditFilePath, "utf8"));
|
||||
}
|
||||
|
||||
function parseAgentReportAuditTrail(content: string): AgentReportAuditEntry[] {
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Partial<AgentReportAuditEntry>;
|
||||
if (
|
||||
typeof parsed.timestamp !== "string" ||
|
||||
typeof parsed.actor !== "string" ||
|
||||
(parsed.source !== "acknowledge" && parsed.source !== "report") ||
|
||||
!AGENT_REPORTED_STATES.includes(parsed.reportState as AgentReportedState) ||
|
||||
typeof parsed.accepted !== "boolean" ||
|
||||
!parsed.before ||
|
||||
!parsed.after
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [parsed as AgentReportAuditEntry];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an agent report to a session: update the canonical lifecycle on disk
|
||||
* and persist the report metadata keys. Throws when the transition is rejected.
|
||||
*
|
||||
* The write is idempotent: applying the same report twice is safe (lifecycle
|
||||
* fields are already set, metadata timestamp refreshes).
|
||||
*/
|
||||
export function applyAgentReport(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
input: ApplyAgentReportInput,
|
||||
): ApplyAgentReportResult {
|
||||
const raw = readMetadataRaw(dataDir, sessionId);
|
||||
if (!raw) {
|
||||
throw new Error(`Session not found: ${sessionId}`);
|
||||
}
|
||||
|
||||
validateAuditSessionId(sessionId);
|
||||
const now = (input.now ?? new Date()).toISOString();
|
||||
const source = input.source ?? "report";
|
||||
const actor = normalizeActor(input.actor);
|
||||
const trimmedNote = input.note?.trim() || undefined;
|
||||
const trimmedPrUrl = input.prUrl?.trim() || undefined;
|
||||
const parsedPrNumber =
|
||||
typeof input.prNumber === "number" && Number.isInteger(input.prNumber) && input.prNumber > 0
|
||||
? input.prNumber
|
||||
: undefined;
|
||||
const parsedPrFromUrl = trimmedPrUrl ? parsePrFromUrl(trimmedPrUrl) : null;
|
||||
const urlPrNumber = parsedPrFromUrl?.number;
|
||||
if (
|
||||
trimmedPrUrl &&
|
||||
parsedPrNumber !== undefined &&
|
||||
urlPrNumber !== undefined &&
|
||||
parsedPrNumber !== urlPrNumber
|
||||
) {
|
||||
throw new Error(`PR number ${parsedPrNumber} does not match PR URL ${trimmedPrUrl}`);
|
||||
}
|
||||
const inferredPrNumber = urlPrNumber;
|
||||
const prNumber = parsedPrNumber ?? inferredPrNumber;
|
||||
const prIsDraft =
|
||||
input.state === "draft_pr_created"
|
||||
? true
|
||||
: input.state === "pr_created" || input.state === "ready_for_review"
|
||||
? false
|
||||
: undefined;
|
||||
const existingPrUrl = raw[AGENT_REPORT_METADATA_KEYS.PR_URL]?.trim() || undefined;
|
||||
const existingPrNumberRaw = raw[AGENT_REPORT_METADATA_KEYS.PR_NUMBER];
|
||||
const existingPrNumber =
|
||||
existingPrNumberRaw && /^\d+$/.test(existingPrNumberRaw)
|
||||
? Number.parseInt(existingPrNumberRaw, 10)
|
||||
: undefined;
|
||||
let before: AgentReportAuditSnapshot | null = null;
|
||||
let previousState: CanonicalSessionState | null = null;
|
||||
let nextState: CanonicalSessionState | null = null;
|
||||
let legacyStatus: SessionStatus | null = null;
|
||||
let previousLegacyStatus: SessionStatus | null = null;
|
||||
|
||||
const nextMetadata = mutateMetadata(dataDir, sessionId, (existing) => {
|
||||
const current = cloneLifecycle(
|
||||
parseCanonicalLifecycle(existing, {
|
||||
sessionId,
|
||||
status: validateStatus(existing["status"]),
|
||||
}),
|
||||
);
|
||||
previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"]));
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
if (!validation.ok) {
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: false,
|
||||
rejectionReason: validation.reason ?? "transition rejected",
|
||||
before,
|
||||
after: before,
|
||||
});
|
||||
throw new Error(validation.reason ?? "transition rejected");
|
||||
}
|
||||
const mapped = mapAgentReportToLifecycle(input.state);
|
||||
previousState = current.session.state;
|
||||
nextState = mapped.sessionState;
|
||||
current.session.state = mapped.sessionState;
|
||||
current.session.reason = mapped.sessionReason;
|
||||
current.session.lastTransitionAt = now;
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
||||
const effectivePrNumber =
|
||||
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
||||
const canAdvancePrState =
|
||||
effectivePrUrl !== undefined ||
|
||||
effectivePrNumber !== undefined ||
|
||||
current.pr.state !== "none";
|
||||
if (canAdvancePrState) {
|
||||
current.pr.state = "open";
|
||||
current.pr.reason =
|
||||
input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
}
|
||||
if (effectivePrUrl) {
|
||||
current.pr.url = effectivePrUrl;
|
||||
}
|
||||
if (effectivePrNumber !== undefined) {
|
||||
current.pr.number = effectivePrNumber;
|
||||
}
|
||||
}
|
||||
if (mapped.sessionState === "working" && current.session.startedAt === null) {
|
||||
current.session.startedAt = now;
|
||||
}
|
||||
legacyStatus = deriveLegacyStatus(current, previousLegacyStatus);
|
||||
const next = { ...existing };
|
||||
Object.assign(
|
||||
next,
|
||||
buildLifecycleMetadataPatch(current, previousLegacyStatus),
|
||||
{
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: now,
|
||||
},
|
||||
);
|
||||
if (trimmedNote) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
|
||||
} else {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
if (trimmedPrUrl) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) {
|
||||
throw new Error(`Failed to apply agent report for session ${sessionId}`);
|
||||
}
|
||||
|
||||
const nextLifecycle = parseCanonicalLifecycle(nextMetadata, {
|
||||
sessionId,
|
||||
status: validateStatus(nextMetadata["status"]),
|
||||
});
|
||||
|
||||
const after = buildAuditSnapshot(nextLifecycle, legacyStatus);
|
||||
const auditEntry: AgentReportAuditEntry = {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: true,
|
||||
before,
|
||||
after,
|
||||
};
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, auditEntry);
|
||||
|
||||
return {
|
||||
report: {
|
||||
state: input.state,
|
||||
timestamp: now,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
actor,
|
||||
source,
|
||||
},
|
||||
legacyStatus,
|
||||
previousState,
|
||||
nextState,
|
||||
auditEntry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read an agent report out of a session's raw metadata, or null if absent. */
|
||||
export function readAgentReport(
|
||||
meta: Record<string, string> | null | undefined,
|
||||
): AgentReport | null {
|
||||
if (!meta) return null;
|
||||
const state = meta[AGENT_REPORT_METADATA_KEYS.STATE];
|
||||
const at = meta[AGENT_REPORT_METADATA_KEYS.AT];
|
||||
if (!state || !at) return null;
|
||||
if (!AGENT_REPORTED_STATES.includes(state as AgentReportedState)) return null;
|
||||
const parsed = Date.parse(at);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
const note = meta[AGENT_REPORT_METADATA_KEYS.NOTE];
|
||||
const rawPrNumber = meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER];
|
||||
const prNumber =
|
||||
rawPrNumber && /^\d+$/.test(rawPrNumber) ? Number.parseInt(rawPrNumber, 10) : undefined;
|
||||
const prUrl = meta[AGENT_REPORT_METADATA_KEYS.PR_URL] || undefined;
|
||||
const rawPrIsDraft = meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT];
|
||||
const prIsDraft = rawPrIsDraft === "true" ? true : rawPrIsDraft === "false" ? false : undefined;
|
||||
return {
|
||||
state: state as AgentReportedState,
|
||||
timestamp: new Date(parsed).toISOString(),
|
||||
note: note && note.length > 0 ? note : undefined,
|
||||
prNumber,
|
||||
prUrl,
|
||||
prIsDraft,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an agent report is fresh (within the freshness window).
|
||||
*
|
||||
* Future timestamps (clock skew, malformed input) are rejected — otherwise a
|
||||
* single skewed `agentReportedAt` could appear "fresh" indefinitely and
|
||||
* override stronger inference signals.
|
||||
*/
|
||||
export function isAgentReportFresh(
|
||||
report: AgentReport,
|
||||
now: Date = new Date(),
|
||||
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
|
||||
clockSkewToleranceMs: number = AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||
): boolean {
|
||||
const reportedAt = Date.parse(report.timestamp);
|
||||
if (Number.isNaN(reportedAt)) return false;
|
||||
const currentTime = now.getTime();
|
||||
if (reportedAt > currentTime + clockSkewToleranceMs) return false;
|
||||
return currentTime - reportedAt <= windowMs;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* native hook systems (Codex, Aider, OpenCode).
|
||||
*
|
||||
* Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that intercept
|
||||
* PR creation, PR merge, and branch operations to auto-update session metadata.
|
||||
* PR creation and branch operations to auto-update session metadata.
|
||||
*
|
||||
* Claude Code uses its own PostToolUse hook system instead.
|
||||
*/
|
||||
|
|
@ -32,7 +32,7 @@ function getAoBinDir(): string {
|
|||
}
|
||||
|
||||
/** Current version of wrapper scripts — bump when scripts change */
|
||||
const WRAPPER_VERSION = "0.2.0";
|
||||
const WRAPPER_VERSION = "0.3.0";
|
||||
|
||||
// =============================================================================
|
||||
// PATH Builder
|
||||
|
|
@ -134,8 +134,9 @@ update_ao_metadata() {
|
|||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update
|
||||
* session metadata. All other commands pass through transparently.
|
||||
* gh wrapper — intercepts `gh pr create` to auto-update session metadata.
|
||||
* Merge/close state remains SCM-owned, so `gh pr merge` is not used to set
|
||||
* terminal session state directly.
|
||||
*/
|
||||
export const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
|
|
@ -167,10 +168,10 @@ fi
|
|||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create, pr/merge).
|
||||
# Only capture output for commands we need to parse (pr/create).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
case "\$1/\$2" in
|
||||
pr/create|pr/merge)
|
||||
pr/create)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
|
|
@ -179,18 +180,27 @@ case "\$1/\$2" in
|
|||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata status pr_open
|
||||
fi
|
||||
;;
|
||||
pr/merge)
|
||||
update_ao_metadata status merged
|
||||
;;
|
||||
esac
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
report_state="pr_created"
|
||||
report_draft="false"
|
||||
for arg in "\$@"; do
|
||||
if [[ "\$arg" == "--draft" || "\$arg" == "-d" ]]; then
|
||||
report_state="draft_pr_created"
|
||||
report_draft="true"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata agentReportedPrUrl "\$pr_url"
|
||||
fi
|
||||
pr_number="\$(printf '%s' "\$pr_url" | grep -Eo '[0-9]+$' | head -1)"
|
||||
if [[ -n "\$pr_number" ]]; then
|
||||
update_ao_metadata agentReportedPrNumber "\$pr_number"
|
||||
fi
|
||||
update_ao_metadata agentReportedState "\$report_state"
|
||||
update_ao_metadata agentReportedAt "\$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
update_ao_metadata agentReportedPrIsDraft "\$report_draft"
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
|
|
@ -317,11 +327,7 @@ export async function setupPathWrapperWorkspace(workspacePath: string): Promise<
|
|||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await atomicWriteFile(
|
||||
join(getAoBinDir(), "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
await atomicWriteFile(join(getAoBinDir(), "ao-metadata-helper.sh"), AO_METADATA_HELPER, 0o755);
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
|
|
|
|||
|
|
@ -553,6 +553,13 @@ function validateProjectUniqueness(config: OrchestratorConfig): void {
|
|||
/** Apply default reactions */
|
||||
function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
|
||||
const defaults: Record<string, (typeof config.reactions)[string]> = {
|
||||
"pr-closed": {
|
||||
auto: true,
|
||||
action: "notify",
|
||||
priority: "action",
|
||||
message:
|
||||
"A PR was closed without merging. Decide whether to learn from the closure, resume the work, or terminate the session.",
|
||||
},
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
|
|
|
|||
|
|
@ -33,9 +33,75 @@ export {
|
|||
readMetadataRaw,
|
||||
writeMetadata,
|
||||
updateMetadata,
|
||||
readCanonicalLifecycle,
|
||||
writeCanonicalLifecycle,
|
||||
updateCanonicalLifecycle,
|
||||
deleteMetadata,
|
||||
listMetadata,
|
||||
} from "./metadata.js";
|
||||
export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
|
||||
// Lifecycle transitions — centralized transition boundary (#137)
|
||||
export {
|
||||
applyLifecycleDecision,
|
||||
applyDecisionToLifecycle,
|
||||
buildTransitionMetadataPatch,
|
||||
createStateTransitionDecision,
|
||||
} from "./lifecycle-transition.js";
|
||||
export type {
|
||||
TransitionSource,
|
||||
TransitionResult,
|
||||
ApplyDecisionInput,
|
||||
} from "./lifecycle-transition.js";
|
||||
|
||||
// Lifecycle status decisions — pure decision helpers (#136)
|
||||
export {
|
||||
DETECTING_MAX_ATTEMPTS,
|
||||
DETECTING_MAX_DURATION_MS,
|
||||
hashEvidence,
|
||||
isDetectingTimedOut,
|
||||
} from "./lifecycle-status-decisions.js";
|
||||
|
||||
// Report watcher — background trigger system for agent reports (#140)
|
||||
export {
|
||||
auditAgentReports,
|
||||
checkAcknowledgeTimeout,
|
||||
checkStaleReport,
|
||||
checkBlockedAgent,
|
||||
shouldAuditSession,
|
||||
getReactionKeyForTrigger,
|
||||
DEFAULT_REPORT_WATCHER_CONFIG,
|
||||
REPORT_WATCHER_METADATA_KEYS,
|
||||
} from "./report-watcher.js";
|
||||
export type {
|
||||
ReportWatcherTrigger,
|
||||
ReportAuditResult,
|
||||
ReportWatcherConfig,
|
||||
} from "./report-watcher.js";
|
||||
|
||||
// Agent reports — explicit workflow transitions declared by worker agents (Stage 3)
|
||||
export {
|
||||
AGENT_REPORTED_STATES,
|
||||
AGENT_REPORT_METADATA_KEYS,
|
||||
AGENT_REPORT_FRESHNESS_MS,
|
||||
applyAgentReport,
|
||||
readAgentReport,
|
||||
readAgentReportAuditTrail,
|
||||
readAgentReportAuditTrailAsync,
|
||||
isAgentReportFresh,
|
||||
mapAgentReportToLifecycle,
|
||||
normalizeAgentReportedState,
|
||||
validateAgentReportTransition,
|
||||
} from "./agent-report.js";
|
||||
export type {
|
||||
AgentReport,
|
||||
AgentReportAuditEntry,
|
||||
AgentReportAuditSnapshot,
|
||||
AgentReportedState,
|
||||
ApplyAgentReportInput,
|
||||
ApplyAgentReportResult,
|
||||
AgentReportTransitionResult,
|
||||
} from "./agent-report.js";
|
||||
|
||||
// tmux — command wrappers
|
||||
export {
|
||||
|
|
@ -98,6 +164,17 @@ export {
|
|||
classifyTerminalActivity,
|
||||
recordTerminalActivity,
|
||||
} from "./activity-log.js";
|
||||
export {
|
||||
ACTIVITY_STRONG_WINDOW_MS,
|
||||
ACTIVITY_WEAK_WINDOW_MS,
|
||||
classifyActivitySignal,
|
||||
createActivitySignal,
|
||||
formatActivitySignalEvidence,
|
||||
hasPositiveIdleEvidence,
|
||||
isWeakActivityEvidence,
|
||||
summarizeActivityFreshness,
|
||||
supportsRecentLiveness,
|
||||
} from "./activity-signal.js";
|
||||
|
||||
// Agent workspace hooks — shared PATH-wrapper setup for non-Claude agents
|
||||
export {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,480 @@
|
|||
import type {
|
||||
CanonicalPRReason,
|
||||
CanonicalPRState,
|
||||
CanonicalRuntimeReason,
|
||||
CanonicalRuntimeState,
|
||||
CanonicalSessionLifecycle,
|
||||
CanonicalSessionReason,
|
||||
CanonicalSessionState,
|
||||
RuntimeHandle,
|
||||
SessionKind,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { z } from "zod";
|
||||
import { parsePrFromUrl } from "./utils/pr.js";
|
||||
import { safeJsonParse, validateStatus } from "./utils/validation.js";
|
||||
|
||||
interface ParseCanonicalLifecycleOptions {
|
||||
sessionId?: string;
|
||||
status?: SessionStatus;
|
||||
runtimeHandle?: RuntimeHandle | null;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
const TimestampSchema = z.string().nullable();
|
||||
|
||||
const RuntimeHandleSchema = z.object({
|
||||
id: z.string(),
|
||||
runtimeName: z.string(),
|
||||
data: z.preprocess((value) => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}, z.record(z.unknown())),
|
||||
});
|
||||
|
||||
const CanonicalSessionLifecycleSchema = z.object({
|
||||
version: z.literal(2),
|
||||
session: z
|
||||
.object({
|
||||
kind: z.enum(["worker", "orchestrator"]),
|
||||
state: z.enum([
|
||||
"not_started",
|
||||
"working",
|
||||
"idle",
|
||||
"needs_input",
|
||||
"stuck",
|
||||
"detecting",
|
||||
"done",
|
||||
"terminated",
|
||||
]),
|
||||
reason: z.enum([
|
||||
"spawn_requested",
|
||||
"agent_acknowledged",
|
||||
"task_in_progress",
|
||||
"pr_created",
|
||||
"pr_closed_waiting_decision",
|
||||
"fixing_ci",
|
||||
"resolving_review_comments",
|
||||
"awaiting_user_input",
|
||||
"awaiting_external_review",
|
||||
"research_complete",
|
||||
"merged_waiting_decision",
|
||||
"manually_killed",
|
||||
"runtime_lost",
|
||||
"agent_process_exited",
|
||||
"probe_failure",
|
||||
"error_in_process",
|
||||
]),
|
||||
startedAt: TimestampSchema,
|
||||
completedAt: TimestampSchema,
|
||||
terminatedAt: TimestampSchema,
|
||||
lastTransitionAt: z.string(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
pr: z
|
||||
.object({
|
||||
state: z.enum(["none", "open", "merged", "closed"]),
|
||||
reason: z.enum([
|
||||
"not_created",
|
||||
"in_progress",
|
||||
"ci_failing",
|
||||
"review_pending",
|
||||
"changes_requested",
|
||||
"approved",
|
||||
"merge_ready",
|
||||
"merged",
|
||||
"closed_unmerged",
|
||||
]),
|
||||
number: z.number().int().nullable(),
|
||||
url: z.string().nullable(),
|
||||
lastObservedAt: TimestampSchema,
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
runtime: z
|
||||
.object({
|
||||
state: z.enum(["unknown", "alive", "exited", "missing", "probe_failed"]),
|
||||
reason: z.enum([
|
||||
"spawn_incomplete",
|
||||
"process_running",
|
||||
"process_missing",
|
||||
"tmux_missing",
|
||||
"manual_kill_requested",
|
||||
"probe_error",
|
||||
]),
|
||||
lastObservedAt: TimestampSchema,
|
||||
handle: RuntimeHandleSchema.nullable(),
|
||||
tmuxName: z.string().nullable(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type ParsedCanonicalSessionLifecycle = z.infer<typeof CanonicalSessionLifecycleSchema>;
|
||||
|
||||
function normalizeTimestamp(value: unknown, fallback: string | null = null): string | null {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? fallback : new Date(parsed).toISOString();
|
||||
}
|
||||
|
||||
function normalizeRuntimeHandle(value: unknown): RuntimeHandle | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record["id"] !== "string" || typeof record["runtimeName"] !== "string") return null;
|
||||
const data = record["data"];
|
||||
return {
|
||||
id: record["id"],
|
||||
runtimeName: record["runtimeName"],
|
||||
data: data && typeof data === "object" ? (data as Record<string, unknown>) : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialCanonicalLifecycle(
|
||||
kind: SessionKind,
|
||||
now = new Date(),
|
||||
): CanonicalSessionLifecycle {
|
||||
const timestamp = now.toISOString();
|
||||
return {
|
||||
version: 2,
|
||||
session: {
|
||||
kind,
|
||||
state: "not_started",
|
||||
reason: "spawn_requested",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: timestamp,
|
||||
},
|
||||
pr: {
|
||||
state: "none",
|
||||
reason: "not_created",
|
||||
number: null,
|
||||
url: null,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
state: "unknown",
|
||||
reason: "spawn_incomplete",
|
||||
lastObservedAt: null,
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeSessionState(
|
||||
status: SessionStatus,
|
||||
): { state: CanonicalSessionState; reason: CanonicalSessionReason } {
|
||||
switch (status) {
|
||||
case "spawning":
|
||||
return { state: "not_started", reason: "spawn_requested" };
|
||||
case "needs_input":
|
||||
return { state: "needs_input", reason: "awaiting_user_input" };
|
||||
case "stuck":
|
||||
return { state: "stuck", reason: "probe_failure" };
|
||||
case "errored":
|
||||
return { state: "terminated", reason: "error_in_process" };
|
||||
case "killed":
|
||||
case "terminated":
|
||||
case "cleanup":
|
||||
return { state: "terminated", reason: "manually_killed" };
|
||||
case "done":
|
||||
return { state: "done", reason: "research_complete" };
|
||||
case "merged":
|
||||
return { state: "idle", reason: "merged_waiting_decision" };
|
||||
case "idle":
|
||||
return { state: "idle", reason: "awaiting_external_review" };
|
||||
default:
|
||||
return { state: "working", reason: "task_in_progress" };
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizePRState(meta: Record<string, string>, status: SessionStatus): {
|
||||
state: CanonicalPRState;
|
||||
reason: CanonicalPRReason;
|
||||
number: number | null;
|
||||
url: string | null;
|
||||
} {
|
||||
const prUrl = meta["pr"] ?? null;
|
||||
if (!prUrl) {
|
||||
return { state: "none", reason: "not_created", number: null, url: null };
|
||||
}
|
||||
const parsed = parsePrFromUrl(prUrl);
|
||||
return {
|
||||
state: status === "merged" ? "merged" : "open",
|
||||
reason: status === "merged" ? "merged" : "in_progress",
|
||||
number: parsed?.number ?? null,
|
||||
url: prUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeRuntimeState(
|
||||
meta: Record<string, string>,
|
||||
runtimeHandle: RuntimeHandle | null,
|
||||
): { state: CanonicalRuntimeState; reason: CanonicalRuntimeReason; handle: RuntimeHandle | null; tmuxName: string | null } {
|
||||
const tmuxName = meta["tmuxName"]?.trim() || null;
|
||||
const handle =
|
||||
runtimeHandle ?? (meta["runtimeHandle"] ? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"]) : null);
|
||||
if (handle || tmuxName) {
|
||||
return {
|
||||
state: "unknown",
|
||||
reason: "spawn_incomplete",
|
||||
handle: handle ?? null,
|
||||
tmuxName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "unknown",
|
||||
reason: "spawn_incomplete",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeCanonicalLifecycle(
|
||||
meta: Record<string, string>,
|
||||
options: ParseCanonicalLifecycleOptions = {},
|
||||
): CanonicalSessionLifecycle {
|
||||
const status = options.status ?? validateStatus(meta["status"]);
|
||||
const sessionKind: SessionKind =
|
||||
meta["role"] === "orchestrator" || options.sessionId?.endsWith("-orchestrator")
|
||||
? "orchestrator"
|
||||
: "worker";
|
||||
const now =
|
||||
options.createdAt?.toISOString() ??
|
||||
normalizeTimestamp(meta["createdAt"], new Date().toISOString()) ??
|
||||
new Date().toISOString();
|
||||
const sessionState = synthesizeSessionState(status);
|
||||
const pr = synthesizePRState(meta, status);
|
||||
const runtime = synthesizeRuntimeState(meta, options.runtimeHandle ?? null);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: sessionKind,
|
||||
state: sessionState.state,
|
||||
reason: sessionState.reason,
|
||||
startedAt: status === "spawning" ? null : now,
|
||||
completedAt: status === "done" ? now : null,
|
||||
terminatedAt:
|
||||
status === "killed" || status === "terminated" || status === "cleanup" ? now : null,
|
||||
lastTransitionAt: now,
|
||||
},
|
||||
pr: {
|
||||
state: pr.state,
|
||||
reason: pr.reason,
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
lastObservedAt: pr.url ? now : null,
|
||||
},
|
||||
runtime: {
|
||||
state: runtime.state,
|
||||
reason: runtime.reason,
|
||||
lastObservedAt: runtime.handle || runtime.tmuxName ? now : null,
|
||||
handle: runtime.handle,
|
||||
tmuxName: runtime.tmuxName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePayloadLifecycle(
|
||||
payload: ParsedCanonicalSessionLifecycle,
|
||||
meta: Record<string, string>,
|
||||
options: ParseCanonicalLifecycleOptions = {},
|
||||
): CanonicalSessionLifecycle {
|
||||
const synthesized = synthesizeCanonicalLifecycle(meta, options);
|
||||
const payloadSession = payload.session;
|
||||
const payloadPr = payload.pr;
|
||||
const payloadRuntime = payload.runtime;
|
||||
const hasPayloadSessionKind = Object.hasOwn(payloadSession ?? {}, "kind");
|
||||
const hasPayloadSessionState = Object.hasOwn(payloadSession ?? {}, "state");
|
||||
const hasPayloadSessionReason = Object.hasOwn(payloadSession ?? {}, "reason");
|
||||
const hasPayloadSessionStartedAt = Object.hasOwn(payloadSession ?? {}, "startedAt");
|
||||
const hasPayloadSessionCompletedAt = Object.hasOwn(payloadSession ?? {}, "completedAt");
|
||||
const hasPayloadSessionTerminatedAt = Object.hasOwn(payloadSession ?? {}, "terminatedAt");
|
||||
const hasPayloadSessionLastTransitionAt = Object.hasOwn(payloadSession ?? {}, "lastTransitionAt");
|
||||
const hasPayloadPrState = Object.hasOwn(payloadPr ?? {}, "state");
|
||||
const hasPayloadPrReason = Object.hasOwn(payloadPr ?? {}, "reason");
|
||||
const hasPayloadPrNumber = Object.hasOwn(payloadPr ?? {}, "number");
|
||||
const hasPayloadPrUrl = Object.hasOwn(payloadPr ?? {}, "url");
|
||||
const hasPayloadPrLastObservedAt = Object.hasOwn(payloadPr ?? {}, "lastObservedAt");
|
||||
const hasPayloadRuntimeState = Object.hasOwn(payloadRuntime ?? {}, "state");
|
||||
const hasPayloadRuntimeReason = Object.hasOwn(payloadRuntime ?? {}, "reason");
|
||||
const hasPayloadRuntimeLastObservedAt = Object.hasOwn(payloadRuntime ?? {}, "lastObservedAt");
|
||||
const hasPayloadRuntimeHandle = Object.hasOwn(payloadRuntime ?? {}, "handle");
|
||||
const hasPayloadRuntimeTmuxName = Object.hasOwn(payloadRuntime ?? {}, "tmuxName");
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: hasPayloadSessionKind
|
||||
? payloadSession?.kind === "orchestrator"
|
||||
? "orchestrator"
|
||||
: "worker"
|
||||
: synthesized.session.kind,
|
||||
state: hasPayloadSessionState
|
||||
? (payloadSession?.state as CanonicalSessionState | undefined) ?? synthesized.session.state
|
||||
: synthesized.session.state,
|
||||
reason:
|
||||
hasPayloadSessionReason
|
||||
? (payloadSession?.reason as CanonicalSessionReason | undefined) ?? synthesized.session.reason
|
||||
: synthesized.session.reason,
|
||||
startedAt: hasPayloadSessionStartedAt
|
||||
? normalizeTimestamp(payloadSession?.startedAt)
|
||||
: synthesized.session.startedAt,
|
||||
completedAt: hasPayloadSessionCompletedAt
|
||||
? normalizeTimestamp(payloadSession?.completedAt)
|
||||
: synthesized.session.completedAt,
|
||||
terminatedAt: hasPayloadSessionTerminatedAt
|
||||
? normalizeTimestamp(payloadSession?.terminatedAt)
|
||||
: synthesized.session.terminatedAt,
|
||||
lastTransitionAt: hasPayloadSessionLastTransitionAt
|
||||
? normalizeTimestamp(payloadSession?.lastTransitionAt, synthesized.session.lastTransitionAt) ??
|
||||
synthesized.session.lastTransitionAt
|
||||
: synthesized.session.lastTransitionAt,
|
||||
},
|
||||
pr: {
|
||||
state: hasPayloadPrState
|
||||
? (payloadPr?.state as CanonicalPRState | undefined) ?? synthesized.pr.state
|
||||
: synthesized.pr.state,
|
||||
reason: hasPayloadPrReason
|
||||
? (payloadPr?.reason as CanonicalPRReason | undefined) ?? synthesized.pr.reason
|
||||
: synthesized.pr.reason,
|
||||
number: hasPayloadPrNumber
|
||||
? typeof payloadPr?.number === "number"
|
||||
? payloadPr.number
|
||||
: null
|
||||
: synthesized.pr.number,
|
||||
url: hasPayloadPrUrl ? (typeof payloadPr?.url === "string" ? payloadPr.url : null) : synthesized.pr.url,
|
||||
lastObservedAt: hasPayloadPrLastObservedAt
|
||||
? normalizeTimestamp(payloadPr?.lastObservedAt)
|
||||
: synthesized.pr.lastObservedAt,
|
||||
},
|
||||
runtime: {
|
||||
state: hasPayloadRuntimeState
|
||||
? (payloadRuntime?.state as CanonicalRuntimeState | undefined) ?? synthesized.runtime.state
|
||||
: synthesized.runtime.state,
|
||||
reason:
|
||||
hasPayloadRuntimeReason
|
||||
? (payloadRuntime?.reason as CanonicalRuntimeReason | undefined) ??
|
||||
synthesized.runtime.reason
|
||||
: synthesized.runtime.reason,
|
||||
lastObservedAt: hasPayloadRuntimeLastObservedAt
|
||||
? normalizeTimestamp(payloadRuntime?.lastObservedAt)
|
||||
: synthesized.runtime.lastObservedAt,
|
||||
handle: hasPayloadRuntimeHandle
|
||||
? normalizeRuntimeHandle(payloadRuntime?.handle)
|
||||
: synthesized.runtime.handle,
|
||||
tmuxName: hasPayloadRuntimeTmuxName
|
||||
? typeof payloadRuntime?.tmuxName === "string"
|
||||
? payloadRuntime.tmuxName
|
||||
: null
|
||||
: synthesized.runtime.tmuxName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCanonicalLifecycle(
|
||||
meta: Record<string, string>,
|
||||
options: ParseCanonicalLifecycleOptions = {},
|
||||
): CanonicalSessionLifecycle {
|
||||
const parsed =
|
||||
meta["statePayload"] && meta["stateVersion"] === "2"
|
||||
? safeJsonParse<unknown>(meta["statePayload"])
|
||||
: null;
|
||||
const validated = CanonicalSessionLifecycleSchema.safeParse(parsed);
|
||||
if (validated.success) {
|
||||
return normalizePayloadLifecycle(validated.data, meta, options);
|
||||
}
|
||||
return synthesizeCanonicalLifecycle(meta, options);
|
||||
}
|
||||
|
||||
export function deriveLegacyStatus(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
previousStatus: SessionStatus = "working",
|
||||
): SessionStatus {
|
||||
if (
|
||||
lifecycle.session.state === "terminated" &&
|
||||
(previousStatus === "cleanup" ||
|
||||
previousStatus === "errored" ||
|
||||
previousStatus === "killed" ||
|
||||
previousStatus === "terminated")
|
||||
) {
|
||||
return previousStatus;
|
||||
}
|
||||
|
||||
switch (lifecycle.session.state) {
|
||||
case "not_started":
|
||||
return "spawning";
|
||||
case "needs_input":
|
||||
return "needs_input";
|
||||
case "stuck":
|
||||
return "stuck";
|
||||
case "done":
|
||||
return "done";
|
||||
case "terminated":
|
||||
return "terminated";
|
||||
case "detecting":
|
||||
return "detecting";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (lifecycle.pr.state === "merged") {
|
||||
return "merged";
|
||||
}
|
||||
if (lifecycle.pr.state === "open") {
|
||||
if (lifecycle.pr.reason === "ci_failing") return "ci_failed";
|
||||
if (lifecycle.pr.reason === "changes_requested") return "changes_requested";
|
||||
if (lifecycle.pr.reason === "review_pending") return "review_pending";
|
||||
if (lifecycle.pr.reason === "approved") return "approved";
|
||||
if (lifecycle.pr.reason === "merge_ready") return "mergeable";
|
||||
return "pr_open";
|
||||
}
|
||||
|
||||
switch (lifecycle.session.state) {
|
||||
case "idle":
|
||||
return "idle";
|
||||
case "working":
|
||||
return "working";
|
||||
default:
|
||||
return previousStatus;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLifecycleMetadataPatch(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
previousStatus?: SessionStatus,
|
||||
): Partial<Record<string, string>> {
|
||||
return {
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify(lifecycle),
|
||||
status: deriveLegacyStatus(lifecycle, previousStatus),
|
||||
pr: lifecycle.pr.url ?? "",
|
||||
runtimeHandle: lifecycle.runtime.handle ? JSON.stringify(lifecycle.runtime.handle) : "",
|
||||
tmuxName: lifecycle.runtime.tmuxName ?? "",
|
||||
role: lifecycle.session.kind === "orchestrator" ? "orchestrator" : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneLifecycle(lifecycle: CanonicalSessionLifecycle): CanonicalSessionLifecycle {
|
||||
return {
|
||||
version: 2,
|
||||
session: { ...lifecycle.session },
|
||||
pr: { ...lifecycle.pr },
|
||||
runtime: {
|
||||
...lifecycle.runtime,
|
||||
handle: lifecycle.runtime.handle
|
||||
? {
|
||||
id: lifecycle.runtime.handle.id,
|
||||
runtimeName: lifecycle.runtime.handle.runtimeName,
|
||||
data: structuredClone(lifecycle.runtime.handle.data),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
CI_STATUS,
|
||||
PR_STATE,
|
||||
SESSION_STATUS,
|
||||
type ActivitySignal,
|
||||
type CanonicalPRReason,
|
||||
type CanonicalPRState,
|
||||
type CanonicalSessionReason,
|
||||
type CanonicalSessionState,
|
||||
type CIStatus,
|
||||
type PREnrichmentData,
|
||||
type SessionStatus,
|
||||
} from "./types.js";
|
||||
import { supportsRecentLiveness } from "./activity-signal.js";
|
||||
|
||||
export const DETECTING_MAX_ATTEMPTS = 3;
|
||||
/** Hard time cap for detecting state — escalate after this regardless of attempts. */
|
||||
export const DETECTING_MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
type ProbeState = "alive" | "dead" | "unknown";
|
||||
type PRReviewDecision = PREnrichmentData["reviewDecision"];
|
||||
type LifecycleSessionState = CanonicalSessionState;
|
||||
type LifecycleSessionReason = CanonicalSessionReason;
|
||||
type LifecyclePRState = CanonicalPRState;
|
||||
type LifecyclePRReason = CanonicalPRReason;
|
||||
|
||||
interface LifecycleDecision {
|
||||
status: SessionStatus;
|
||||
evidence: string;
|
||||
detectingAttempts: number;
|
||||
/** ISO timestamp when detecting state was first entered (for time-based escalation). */
|
||||
detectingStartedAt?: string;
|
||||
/** Hash of evidence to detect unchanged evidence across polls. */
|
||||
detectingEvidenceHash?: string;
|
||||
sessionState?: LifecycleSessionState;
|
||||
sessionReason?: LifecycleSessionReason;
|
||||
prState?: LifecyclePRState;
|
||||
prReason?: LifecyclePRReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of evidence string for detecting unchanged evidence.
|
||||
* Used to prevent counter reset when the same weak evidence re-presents.
|
||||
*/
|
||||
function normalizeEvidenceForHash(evidence: string): string {
|
||||
return evidence
|
||||
.replace(/\sactivity=[^\s]+/g, "")
|
||||
.replace(/\sat=[^\s]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function hashEvidence(evidence: string): string {
|
||||
return createHash("sha256")
|
||||
.update(normalizeEvidenceForHash(evidence))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
interface OpenPRDecisionInput {
|
||||
reviewDecision: PRReviewDecision;
|
||||
ciFailing: boolean;
|
||||
mergeable: boolean;
|
||||
shouldEscalateIdleToStuck: boolean;
|
||||
idleWasBlocked: boolean;
|
||||
activityEvidence: string;
|
||||
}
|
||||
|
||||
interface ProbeResult {
|
||||
state: ProbeState;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
interface ProbeDecisionInput {
|
||||
currentAttempts: number;
|
||||
runtimeProbe: ProbeResult;
|
||||
processProbe: ProbeResult;
|
||||
canProbeRuntimeIdentity: boolean;
|
||||
activitySignal: ActivitySignal;
|
||||
activityEvidence: string;
|
||||
idleWasBlocked: boolean;
|
||||
/** ISO timestamp when detecting first started (from metadata). */
|
||||
detectingStartedAt?: string;
|
||||
/** Previous evidence hash (from metadata). */
|
||||
previousEvidenceHash?: string;
|
||||
}
|
||||
|
||||
function createLifecycleDecision(decision: LifecycleDecision): LifecycleDecision {
|
||||
return decision;
|
||||
}
|
||||
|
||||
export interface DetectingDecisionInput {
|
||||
currentAttempts: number;
|
||||
idleWasBlocked: boolean;
|
||||
evidence: string;
|
||||
reason?: LifecycleSessionReason;
|
||||
/** ISO timestamp when detecting first started (from metadata). */
|
||||
detectingStartedAt?: string;
|
||||
/** Previous evidence hash (from metadata). */
|
||||
previousEvidenceHash?: string;
|
||||
/** Current time for time-based escalation check. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detecting has exceeded its time budget.
|
||||
*/
|
||||
export function isDetectingTimedOut(
|
||||
detectingStartedAt: string | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!detectingStartedAt) return false;
|
||||
const startedAt = Date.parse(detectingStartedAt);
|
||||
if (Number.isNaN(startedAt)) return false;
|
||||
return now.getTime() - startedAt > DETECTING_MAX_DURATION_MS;
|
||||
}
|
||||
|
||||
export function createDetectingDecision(
|
||||
input: DetectingDecisionInput | (Pick<ProbeDecisionInput, "currentAttempts" | "idleWasBlocked"> & {
|
||||
evidence: string;
|
||||
reason?: LifecycleSessionReason;
|
||||
}),
|
||||
): LifecycleDecision {
|
||||
const now = "now" in input && input.now ? input.now : new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const evidenceHash = hashEvidence(input.evidence);
|
||||
|
||||
// Determine the starting time for detecting
|
||||
// If we have a previous startedAt AND evidence is unchanged, preserve it
|
||||
// Otherwise, start fresh
|
||||
const previousEvidenceHash = "previousEvidenceHash" in input ? input.previousEvidenceHash : undefined;
|
||||
const previousStartedAt = "detectingStartedAt" in input ? input.detectingStartedAt : undefined;
|
||||
|
||||
// Evidence is considered unchanged if:
|
||||
// 1. We have a previous hash AND it matches, OR
|
||||
// 2. We have no previous hash (first time entering detecting)
|
||||
const evidenceChanged = previousEvidenceHash !== undefined && previousEvidenceHash !== evidenceHash;
|
||||
const detectingStartedAt = evidenceChanged ? nowIso : (previousStartedAt ?? nowIso);
|
||||
|
||||
// Calculate attempts: reset if evidence changed, otherwise increment
|
||||
const attempts = evidenceChanged ? 1 : input.currentAttempts + 1;
|
||||
|
||||
// Check escalation conditions: attempt limit OR time limit
|
||||
const attemptLimitExceeded = attempts > DETECTING_MAX_ATTEMPTS;
|
||||
const timeLimitExceeded = isDetectingTimedOut(detectingStartedAt, now);
|
||||
|
||||
if (attemptLimitExceeded || timeLimitExceeded) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.STUCK,
|
||||
evidence: input.evidence,
|
||||
detectingAttempts: attempts,
|
||||
detectingStartedAt,
|
||||
detectingEvidenceHash: evidenceHash,
|
||||
sessionState: "stuck",
|
||||
sessionReason: input.idleWasBlocked ? "error_in_process" : "probe_failure",
|
||||
});
|
||||
}
|
||||
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.DETECTING,
|
||||
evidence: input.evidence,
|
||||
detectingAttempts: attempts,
|
||||
detectingStartedAt,
|
||||
detectingEvidenceHash: evidenceHash,
|
||||
sessionState: "detecting",
|
||||
sessionReason: input.reason ?? "probe_failure",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTerminalPRStateDecision(
|
||||
prState: PREnrichmentData["state"] | "open",
|
||||
): LifecycleDecision | null {
|
||||
if (prState === PR_STATE.MERGED) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.MERGED,
|
||||
evidence: "pr_merged",
|
||||
detectingAttempts: 0,
|
||||
prState: "merged",
|
||||
prReason: "merged",
|
||||
sessionState: "idle",
|
||||
sessionReason: "merged_waiting_decision",
|
||||
});
|
||||
}
|
||||
|
||||
if (prState === PR_STATE.CLOSED) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.IDLE,
|
||||
evidence: "pr_closed",
|
||||
detectingAttempts: 0,
|
||||
prState: "closed",
|
||||
prReason: "closed_unmerged",
|
||||
sessionState: "idle",
|
||||
sessionReason: "pr_closed_waiting_decision",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveOpenPRDecision(input: OpenPRDecisionInput): LifecycleDecision {
|
||||
if (input.ciFailing) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.CI_FAILED,
|
||||
evidence: "ci_failing",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "ci_failing",
|
||||
sessionState: "working",
|
||||
sessionReason: "fixing_ci",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.reviewDecision === "changes_requested") {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.CHANGES_REQUESTED,
|
||||
evidence: "review_changes_requested",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "changes_requested",
|
||||
sessionState: "working",
|
||||
sessionReason: "resolving_review_comments",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.reviewDecision === "approved" || input.reviewDecision === "none") {
|
||||
if (input.mergeable) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.MERGEABLE,
|
||||
evidence: "merge_ready",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "merge_ready",
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.reviewDecision === "approved") {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.APPROVED,
|
||||
evidence: "review_approved",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "approved",
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.reviewDecision === "pending") {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.REVIEW_PENDING,
|
||||
evidence: "review_pending",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "review_pending",
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.shouldEscalateIdleToStuck) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.STUCK,
|
||||
evidence: `idle_beyond_threshold ${input.activityEvidence}`,
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "in_progress",
|
||||
sessionState: "stuck",
|
||||
sessionReason: input.idleWasBlocked ? "error_in_process" : "probe_failure",
|
||||
});
|
||||
}
|
||||
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.PR_OPEN,
|
||||
evidence: "pr_open",
|
||||
detectingAttempts: 0,
|
||||
prState: "open",
|
||||
prReason: "in_progress",
|
||||
sessionState: "idle",
|
||||
sessionReason: "pr_created",
|
||||
});
|
||||
}
|
||||
|
||||
export function parseAttemptCount(raw: string | undefined): number {
|
||||
if (!raw) return 0;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function resolveProbeDecision(input: ProbeDecisionInput): LifecycleDecision | null {
|
||||
const recentActivitySupportsLiveness = supportsRecentLiveness(input.activitySignal);
|
||||
|
||||
if (input.runtimeProbe.failed || input.processProbe.failed) {
|
||||
return createDetectingDecision({
|
||||
currentAttempts: input.currentAttempts,
|
||||
idleWasBlocked: input.idleWasBlocked,
|
||||
evidence: `probe_failed runtime=${input.runtimeProbe.state} process=${input.processProbe.state} ${input.activityEvidence}`,
|
||||
detectingStartedAt: input.detectingStartedAt,
|
||||
previousEvidenceHash: input.previousEvidenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(input.runtimeProbe.state === "dead" && input.processProbe.state === "alive") ||
|
||||
(input.runtimeProbe.state === "alive" && input.processProbe.state === "dead") ||
|
||||
(input.runtimeProbe.state === "dead" && recentActivitySupportsLiveness)
|
||||
) {
|
||||
return createDetectingDecision({
|
||||
currentAttempts: input.currentAttempts,
|
||||
idleWasBlocked: input.idleWasBlocked,
|
||||
evidence: `signal_disagreement runtime=${input.runtimeProbe.state} process=${input.processProbe.state} ${input.activityEvidence}`,
|
||||
reason: input.runtimeProbe.state === "dead" ? "runtime_lost" : "agent_process_exited",
|
||||
detectingStartedAt: input.detectingStartedAt,
|
||||
previousEvidenceHash: input.previousEvidenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
input.runtimeProbe.state === "dead" &&
|
||||
input.processProbe.state === "unknown" &&
|
||||
input.canProbeRuntimeIdentity
|
||||
) {
|
||||
return createDetectingDecision({
|
||||
currentAttempts: input.currentAttempts,
|
||||
idleWasBlocked: input.idleWasBlocked,
|
||||
evidence: `runtime_dead process_unknown ${input.activityEvidence}`,
|
||||
reason: "runtime_lost",
|
||||
detectingStartedAt: input.detectingStartedAt,
|
||||
previousEvidenceHash: input.previousEvidenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
input.runtimeProbe.state === "dead" &&
|
||||
input.processProbe.state === "dead" &&
|
||||
!recentActivitySupportsLiveness
|
||||
) {
|
||||
return createLifecycleDecision({
|
||||
status: SESSION_STATUS.KILLED,
|
||||
evidence: `runtime_dead process_dead ${input.activityEvidence}`,
|
||||
detectingAttempts: 0,
|
||||
sessionState: "terminated",
|
||||
sessionReason: "runtime_lost",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePREnrichmentDecision(
|
||||
cachedData: PREnrichmentData,
|
||||
options: Pick<
|
||||
OpenPRDecisionInput,
|
||||
"shouldEscalateIdleToStuck" | "idleWasBlocked" | "activityEvidence"
|
||||
>,
|
||||
): LifecycleDecision {
|
||||
const terminalDecision = resolveTerminalPRStateDecision(cachedData.state);
|
||||
if (terminalDecision) {
|
||||
return terminalDecision;
|
||||
}
|
||||
|
||||
return resolveOpenPRDecision({
|
||||
reviewDecision: cachedData.reviewDecision,
|
||||
ciFailing: cachedData.ciStatus === CI_STATUS.FAILING,
|
||||
mergeable: cachedData.mergeable,
|
||||
shouldEscalateIdleToStuck: options.shouldEscalateIdleToStuck,
|
||||
idleWasBlocked: options.idleWasBlocked,
|
||||
activityEvidence: options.activityEvidence,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePRLiveDecision(input: {
|
||||
prState: "open" | "merged" | "closed";
|
||||
ciStatus: CIStatus;
|
||||
reviewDecision: PRReviewDecision;
|
||||
mergeable: boolean;
|
||||
shouldEscalateIdleToStuck: boolean;
|
||||
idleWasBlocked: boolean;
|
||||
activityEvidence: string;
|
||||
}): LifecycleDecision {
|
||||
const terminalDecision = resolveTerminalPRStateDecision(input.prState);
|
||||
if (terminalDecision) {
|
||||
return terminalDecision;
|
||||
}
|
||||
|
||||
return resolveOpenPRDecision({
|
||||
reviewDecision: input.reviewDecision,
|
||||
ciFailing: input.ciStatus === CI_STATUS.FAILING,
|
||||
mergeable: input.mergeable,
|
||||
shouldEscalateIdleToStuck: input.shouldEscalateIdleToStuck,
|
||||
idleWasBlocked: input.idleWasBlocked,
|
||||
activityEvidence: input.activityEvidence,
|
||||
});
|
||||
}
|
||||
|
||||
export type { LifecycleDecision };
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Lifecycle Transition Service
|
||||
*
|
||||
* Centralizes all lifecycle state mutations behind a single boundary.
|
||||
* This module ensures:
|
||||
* - Consistent timestamp handling
|
||||
* - Before/after state capture for observability
|
||||
* - Atomic metadata persistence
|
||||
* - Validation before mutation
|
||||
*
|
||||
* All lifecycle changes (from polling, agent reports, spawn, kill) should
|
||||
* flow through this module.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CanonicalSessionLifecycle,
|
||||
CanonicalSessionReason,
|
||||
CanonicalSessionState,
|
||||
SessionId,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import { updateMetadata, readMetadataRaw, readCanonicalLifecycle } from "./metadata.js";
|
||||
import type { LifecycleDecision } from "./lifecycle-status-decisions.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
||||
/**
|
||||
* Source of the lifecycle transition — used for audit and observability.
|
||||
*/
|
||||
export type TransitionSource =
|
||||
| "poll" // Lifecycle manager polling
|
||||
| "agent_report" // Agent self-report (ao acknowledge, ao report)
|
||||
| "spawn" // Session spawn
|
||||
| "restore" // Session restore
|
||||
| "kill" // Manual kill
|
||||
| "cleanup" // Session cleanup
|
||||
| "claim_pr"; // PR claim
|
||||
|
||||
/**
|
||||
* Result of a lifecycle transition attempt.
|
||||
*/
|
||||
export interface TransitionResult {
|
||||
success: boolean;
|
||||
/** The previous lifecycle state (before mutation). */
|
||||
previousLifecycle: CanonicalSessionLifecycle;
|
||||
/** The new lifecycle state (after mutation). */
|
||||
nextLifecycle: CanonicalSessionLifecycle;
|
||||
/** The previous legacy status. */
|
||||
previousStatus: SessionStatus;
|
||||
/** The new legacy status. */
|
||||
nextStatus: SessionStatus;
|
||||
/** ISO timestamp when the transition occurred. */
|
||||
transitionedAt: string;
|
||||
/** Whether the status actually changed. */
|
||||
statusChanged: boolean;
|
||||
/** Reason if transition was rejected. */
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for applying a lifecycle decision.
|
||||
*/
|
||||
export interface ApplyDecisionInput {
|
||||
dataDir: string;
|
||||
sessionId: SessionId;
|
||||
decision: LifecycleDecision;
|
||||
/** Additional metadata to persist alongside the lifecycle. */
|
||||
additionalMetadata?: Record<string, string>;
|
||||
/** Source of the transition for observability. */
|
||||
source: TransitionSource;
|
||||
/** Override the current time (for testing). */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a lifecycle decision to the in-memory lifecycle object.
|
||||
* This mutates the lifecycle in place.
|
||||
*/
|
||||
export function applyDecisionToLifecycle(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
decision: LifecycleDecision,
|
||||
nowIso: string,
|
||||
): void {
|
||||
// Apply PR state if present
|
||||
if (decision.prState) {
|
||||
lifecycle.pr.state = decision.prState;
|
||||
lifecycle.pr.lastObservedAt = nowIso;
|
||||
}
|
||||
if (decision.prReason) {
|
||||
lifecycle.pr.reason = decision.prReason;
|
||||
}
|
||||
|
||||
// Apply session state if present
|
||||
if (decision.sessionState && decision.sessionReason) {
|
||||
lifecycle.session.state = decision.sessionState;
|
||||
lifecycle.session.reason = decision.sessionReason;
|
||||
lifecycle.session.lastTransitionAt = nowIso;
|
||||
|
||||
// Handle special timestamp fields (only set if not already set)
|
||||
if (decision.sessionState === "working" && lifecycle.session.startedAt === null) {
|
||||
lifecycle.session.startedAt = nowIso;
|
||||
}
|
||||
if (decision.sessionState === "done" && lifecycle.session.completedAt === null) {
|
||||
lifecycle.session.completedAt = nowIso;
|
||||
}
|
||||
if (decision.sessionState === "terminated" && lifecycle.session.terminatedAt === null) {
|
||||
lifecycle.session.terminatedAt = nowIso;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata patch for persisting a lifecycle transition.
|
||||
*/
|
||||
export function buildTransitionMetadataPatch(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
decision: LifecycleDecision,
|
||||
previousStatus: SessionStatus,
|
||||
): Record<string, string> {
|
||||
const patch: Record<string, string> = {
|
||||
stateVersion: "2",
|
||||
statePayload: JSON.stringify(lifecycle),
|
||||
status: deriveLegacyStatus(lifecycle, previousStatus),
|
||||
};
|
||||
|
||||
// Include lifecycle evidence
|
||||
if (decision.evidence) {
|
||||
patch["lifecycleEvidence"] = decision.evidence;
|
||||
}
|
||||
|
||||
// Include detecting metadata
|
||||
if (decision.detectingAttempts > 0) {
|
||||
patch["detectingAttempts"] = String(decision.detectingAttempts);
|
||||
} else {
|
||||
patch["detectingAttempts"] = "";
|
||||
}
|
||||
|
||||
if (decision.detectingStartedAt) {
|
||||
patch["detectingStartedAt"] = decision.detectingStartedAt;
|
||||
} else {
|
||||
patch["detectingStartedAt"] = "";
|
||||
}
|
||||
|
||||
if (decision.detectingEvidenceHash) {
|
||||
patch["detectingEvidenceHash"] = decision.detectingEvidenceHash;
|
||||
} else {
|
||||
patch["detectingEvidenceHash"] = "";
|
||||
}
|
||||
|
||||
patch["pr"] = lifecycle.pr.url ?? "";
|
||||
|
||||
patch["runtimeHandle"] = lifecycle.runtime.handle ? JSON.stringify(lifecycle.runtime.handle) : "";
|
||||
patch["tmuxName"] = lifecycle.runtime.tmuxName ?? "";
|
||||
|
||||
patch["role"] = lifecycle.session.kind === "orchestrator" ? "orchestrator" : "";
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a lifecycle decision and persist to metadata.
|
||||
*
|
||||
* This is the primary entry point for lifecycle mutations. It:
|
||||
* 1. Reads the current lifecycle state
|
||||
* 2. Validates the transition (if applicable)
|
||||
* 3. Applies the decision to the lifecycle
|
||||
* 4. Persists the updated lifecycle and metadata
|
||||
* 5. Returns before/after state for observability
|
||||
*/
|
||||
export function applyLifecycleDecision(
|
||||
input: ApplyDecisionInput,
|
||||
): TransitionResult {
|
||||
const now = input.now ?? new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// Read current state
|
||||
const rawMeta = readMetadataRaw(input.dataDir, input.sessionId);
|
||||
if (!rawMeta) {
|
||||
const emptyLifecycle = createEmptyLifecycle();
|
||||
return {
|
||||
success: false,
|
||||
previousLifecycle: emptyLifecycle,
|
||||
nextLifecycle: emptyLifecycle,
|
||||
previousStatus: "working",
|
||||
nextStatus: "working",
|
||||
transitionedAt: nowIso,
|
||||
statusChanged: false,
|
||||
rejectionReason: `Session not found: ${input.sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentLifecycle = readCanonicalLifecycle(input.dataDir, input.sessionId);
|
||||
if (!currentLifecycle) {
|
||||
const emptyLifecycle = createEmptyLifecycle();
|
||||
return {
|
||||
success: false,
|
||||
previousLifecycle: emptyLifecycle,
|
||||
nextLifecycle: emptyLifecycle,
|
||||
previousStatus: "working",
|
||||
nextStatus: "working",
|
||||
transitionedAt: nowIso,
|
||||
statusChanged: false,
|
||||
rejectionReason: `Failed to read lifecycle for session: ${input.sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const previousLifecycle = cloneLifecycle(currentLifecycle);
|
||||
const previousStatus = deriveLegacyStatus(
|
||||
previousLifecycle,
|
||||
validateStatus(rawMeta["status"]),
|
||||
);
|
||||
|
||||
// Apply the decision to the lifecycle
|
||||
const nextLifecycle = cloneLifecycle(currentLifecycle);
|
||||
applyDecisionToLifecycle(nextLifecycle, input.decision, nowIso);
|
||||
|
||||
const nextStatus = deriveLegacyStatus(nextLifecycle, previousStatus);
|
||||
const statusChanged = nextStatus !== previousStatus;
|
||||
|
||||
// Build metadata patch, starting with additional metadata (so lifecycle keys take precedence)
|
||||
const metadataPatch: Record<string, string> = {};
|
||||
|
||||
// Apply additional metadata first
|
||||
if (input.additionalMetadata) {
|
||||
for (const [key, value] of Object.entries(input.additionalMetadata)) {
|
||||
metadataPatch[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply lifecycle patch second (overwrites any conflicting keys from additionalMetadata)
|
||||
const lifecyclePatch = buildTransitionMetadataPatch(
|
||||
nextLifecycle,
|
||||
input.decision,
|
||||
previousStatus,
|
||||
);
|
||||
for (const [key, value] of Object.entries(lifecyclePatch)) {
|
||||
metadataPatch[key] = value;
|
||||
}
|
||||
|
||||
// Persist
|
||||
updateMetadata(input.dataDir, input.sessionId, metadataPatch);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
previousLifecycle,
|
||||
nextLifecycle,
|
||||
previousStatus,
|
||||
nextStatus,
|
||||
transitionedAt: nowIso,
|
||||
statusChanged,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty lifecycle for error cases.
|
||||
*/
|
||||
function createEmptyLifecycle(): CanonicalSessionLifecycle {
|
||||
return {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "not_started",
|
||||
reason: "spawn_requested",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: new Date().toISOString(),
|
||||
},
|
||||
pr: {
|
||||
state: "none",
|
||||
reason: "not_created",
|
||||
number: null,
|
||||
url: null,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
state: "unknown",
|
||||
reason: "spawn_incomplete",
|
||||
lastObservedAt: null,
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a minimal lifecycle decision for direct state updates.
|
||||
*/
|
||||
export function createStateTransitionDecision(
|
||||
status: SessionStatus,
|
||||
state: CanonicalSessionState,
|
||||
reason: CanonicalSessionReason,
|
||||
evidence: string,
|
||||
): LifecycleDecision {
|
||||
return {
|
||||
status,
|
||||
evidence,
|
||||
detectingAttempts: 0,
|
||||
sessionState: state,
|
||||
sessionReason: reason,
|
||||
};
|
||||
}
|
||||
|
|
@ -32,9 +32,16 @@ import {
|
|||
constants,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { SessionId, SessionMetadata } from "./types.js";
|
||||
import type { CanonicalSessionLifecycle, SessionId, SessionMetadata, SessionStatus } from "./types.js";
|
||||
import { atomicWriteFileSync } from "./atomic-write.js";
|
||||
import { parseKeyValueContent } from "./key-value.js";
|
||||
import {
|
||||
buildLifecycleMetadataPatch,
|
||||
cloneLifecycle,
|
||||
parseCanonicalLifecycle,
|
||||
} from "./lifecycle-state.js";
|
||||
import { assertValidSessionIdComponent, SESSION_ID_COMPONENT_PATTERN } from "./utils/session-id.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
||||
/** Serialize a record back to key=value format. Newlines in values are replaced to prevent injection. */
|
||||
function serializeMetadata(data: Record<string, string>): string {
|
||||
|
|
@ -46,13 +53,8 @@ function serializeMetadata(data: Record<string, string>): string {
|
|||
);
|
||||
}
|
||||
|
||||
/** Validate sessionId to prevent path traversal. */
|
||||
const VALID_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function validateSessionId(sessionId: SessionId): void {
|
||||
if (!VALID_SESSION_ID.test(sessionId)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`);
|
||||
}
|
||||
assertValidSessionIdComponent(sessionId);
|
||||
}
|
||||
|
||||
/** Get the metadata file path for a session. */
|
||||
|
|
@ -85,6 +87,8 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
|
|||
agent: raw["agent"],
|
||||
createdAt: raw["createdAt"],
|
||||
runtimeHandle: raw["runtimeHandle"],
|
||||
stateVersion: raw["stateVersion"],
|
||||
statePayload: raw["statePayload"],
|
||||
restoredAt: raw["restoredAt"],
|
||||
role: raw["role"],
|
||||
dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined,
|
||||
|
|
@ -136,6 +140,8 @@ export function writeMetadata(
|
|||
if (metadata.agent) data["agent"] = metadata.agent;
|
||||
if (metadata.createdAt) data["createdAt"] = metadata.createdAt;
|
||||
if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle;
|
||||
if (metadata.stateVersion) data["stateVersion"] = metadata.stateVersion;
|
||||
if (metadata.statePayload) data["statePayload"] = metadata.statePayload;
|
||||
if (metadata.restoredAt) data["restoredAt"] = metadata.restoredAt;
|
||||
if (metadata.role) data["role"] = metadata.role;
|
||||
if (metadata.dashboardPort !== undefined) data["dashboardPort"] = String(metadata.dashboardPort);
|
||||
|
|
@ -159,26 +165,95 @@ export function updateMetadata(
|
|||
sessionId: SessionId,
|
||||
updates: Partial<Record<string, string>>,
|
||||
): void {
|
||||
mutateMetadata(dataDir, sessionId, (existing) => {
|
||||
return applyMetadataUpdates(existing, updates);
|
||||
}, { createIfMissing: true });
|
||||
}
|
||||
|
||||
function applyMetadataUpdates(
|
||||
existing: Record<string, string>,
|
||||
updates: Partial<Record<string, string>>,
|
||||
): Record<string, string> {
|
||||
let next = { ...existing };
|
||||
// Merge updates — remove keys set to empty string
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
const { [key]: _removed, ...rest } = next;
|
||||
void _removed;
|
||||
next = rest;
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeMetadataRecord(data: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([, value]) => value !== undefined && value !== ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function mutateMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
updater: (existing: Record<string, string>) => Record<string, string>,
|
||||
options: { createIfMissing?: boolean } = {},
|
||||
): Record<string, string> | null {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
let existing: Record<string, string> = {};
|
||||
|
||||
if (existsSync(path)) {
|
||||
existing = parseKeyValueContent(readFileSync(path, "utf-8"));
|
||||
} else if (!options.createIfMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Merge updates — remove keys set to empty string
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
const { [key]: _, ...rest } = existing;
|
||||
existing = rest;
|
||||
} else {
|
||||
existing[key] = value;
|
||||
}
|
||||
}
|
||||
const next = normalizeMetadataRecord(updater({ ...existing }));
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
atomicWriteFileSync(path, serializeMetadata(existing));
|
||||
atomicWriteFileSync(path, serializeMetadata(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function readCanonicalLifecycle(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
): CanonicalSessionLifecycle | null {
|
||||
const raw = readMetadataRaw(dataDir, sessionId);
|
||||
if (!raw) return null;
|
||||
return parseCanonicalLifecycle(raw, { sessionId, status: validateStatus(raw["status"]) });
|
||||
}
|
||||
|
||||
export function writeCanonicalLifecycle(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
previousStatus?: SessionStatus,
|
||||
): void {
|
||||
updateMetadata(
|
||||
dataDir,
|
||||
sessionId,
|
||||
buildLifecycleMetadataPatch(cloneLifecycle(lifecycle), previousStatus),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCanonicalLifecycle(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
updater: (current: CanonicalSessionLifecycle) => CanonicalSessionLifecycle,
|
||||
previousStatus?: SessionStatus,
|
||||
): CanonicalSessionLifecycle | null {
|
||||
const raw = readMetadataRaw(dataDir, sessionId);
|
||||
if (!raw) return null;
|
||||
const current = parseCanonicalLifecycle(raw, {
|
||||
sessionId,
|
||||
status: validateStatus(raw["status"]),
|
||||
});
|
||||
const next = updater(cloneLifecycle(current));
|
||||
writeCanonicalLifecycle(dataDir, sessionId, next, previousStatus);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -288,7 +363,7 @@ export function listMetadata(dataDir: string): SessionId[] {
|
|||
|
||||
return readdirSync(dir).filter((name) => {
|
||||
if (name === "archive" || name.startsWith(".")) return false;
|
||||
if (!VALID_SESSION_ID.test(name)) return false;
|
||||
if (!SESSION_ID_COMPONENT_PATTERN.test(name)) return false;
|
||||
try {
|
||||
return statSync(join(dir, name)).isFile();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import {
|
||||
appendFileSync,
|
||||
statSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -129,11 +132,26 @@ export interface SetHealthInput {
|
|||
export interface ProjectObserver {
|
||||
readonly component: string;
|
||||
recordOperation(input: RecordOperationInput): void;
|
||||
recordDiagnostic?(input: {
|
||||
operation: string;
|
||||
correlationId: string;
|
||||
projectId?: string;
|
||||
sessionId?: SessionId;
|
||||
message: string;
|
||||
level?: ObservabilityLevel;
|
||||
path?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): void;
|
||||
setHealth(input: SetHealthInput): void;
|
||||
}
|
||||
|
||||
const TRACE_LIMIT = 80;
|
||||
const SESSION_LIMIT = 200;
|
||||
const AUDIT_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const OBSERVABILITY_ERROR_LOG_MAX_BYTES = 512 * 1024;
|
||||
const MAX_REDACTED_DEPTH = 4;
|
||||
const MAX_REDACTED_STRING_LENGTH = 256;
|
||||
const REDACTED_VALUE = "[redacted]";
|
||||
const LEVEL_ORDER: Record<ObservabilityLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
|
|
@ -161,8 +179,13 @@ function shouldLog(level: ObservabilityLevel): boolean {
|
|||
return LEVEL_ORDER[level] >= LEVEL_ORDER[getLogLevel()];
|
||||
}
|
||||
|
||||
function shouldMirrorStructuredLogsToStderr(): boolean {
|
||||
const raw = process.env["AO_OBSERVABILITY_STDERR"]?.trim().toLowerCase();
|
||||
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
||||
}
|
||||
|
||||
function emitStructuredLog(entry: Record<string, unknown>, level: ObservabilityLevel): void {
|
||||
if (!shouldLog(level)) return;
|
||||
if (!shouldMirrorStructuredLogsToStderr() || !shouldLog(level)) return;
|
||||
process.stderr.write(`${JSON.stringify({ ...entry, level })}\n`);
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +205,93 @@ function getSnapshotPath(config: OrchestratorConfig, component: string): string
|
|||
return join(getObservabilityDir(config), `${sanitizeComponent(component)}-${process.pid}.json`);
|
||||
}
|
||||
|
||||
function getAuditLogPath(config: OrchestratorConfig, component: string): string {
|
||||
return join(getObservabilityDir(config), `${sanitizeComponent(component)}-${process.pid}.ndjson`);
|
||||
}
|
||||
|
||||
function shouldRedactKey(key: string): boolean {
|
||||
return /token|secret|password|cookie|authorization|api[-_]?key|prompt|message|note/i.test(
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeString(value: string): string {
|
||||
const collapsed = value.replace(/\s+/g, " ").trim();
|
||||
return collapsed.length > MAX_REDACTED_STRING_LENGTH
|
||||
? `${collapsed.slice(0, MAX_REDACTED_STRING_LENGTH)}…`
|
||||
: collapsed;
|
||||
}
|
||||
|
||||
function sanitizeUnknown(value: unknown, depth = 0): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === "string") return sanitizeString(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (depth >= MAX_REDACTED_DEPTH) return "[truncated]";
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 25).map((entry) => sanitizeUnknown(entry, depth + 1));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).slice(0, 25).map(([key, entry]) => [
|
||||
key,
|
||||
shouldRedactKey(key) ? REDACTED_VALUE : sanitizeUnknown(entry, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function sanitizeDataRecord(data?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!data) return undefined;
|
||||
return sanitizeUnknown(data) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sanitizeReason(reason?: string): string | undefined {
|
||||
if (!reason) return undefined;
|
||||
return sanitizeString(reason);
|
||||
}
|
||||
|
||||
function sanitizePath(path?: string): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return sanitizeString(path);
|
||||
}
|
||||
|
||||
function appendRotatingNdjson(filePath: string, payload: Record<string, unknown>, maxBytes: number): void {
|
||||
const rotatedPath = `${filePath}.1`;
|
||||
if (existsSync(filePath)) {
|
||||
const currentSize = statSync(filePath).size;
|
||||
if (currentSize >= maxBytes) {
|
||||
if (existsSync(rotatedPath)) {
|
||||
unlinkSync(rotatedPath);
|
||||
}
|
||||
renameSync(filePath, rotatedPath);
|
||||
}
|
||||
}
|
||||
appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function appendAuditLog(
|
||||
config: OrchestratorConfig,
|
||||
component: string,
|
||||
payload: Record<string, unknown>,
|
||||
level: ObservabilityLevel,
|
||||
): void {
|
||||
const filePath = getAuditLogPath(config, component);
|
||||
appendRotatingNdjson(filePath, { ...payload, level }, AUDIT_LOG_MAX_BYTES);
|
||||
}
|
||||
|
||||
function appendObservabilityFailure(
|
||||
config: OrchestratorConfig,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
try {
|
||||
const filePath = join(getObservabilityDir(config), "observability-errors.ndjson");
|
||||
appendRotatingNdjson(filePath, payload, OBSERVABILITY_ERROR_LOG_MAX_BYTES);
|
||||
} catch {
|
||||
// Best effort only — avoid recursive observability failures.
|
||||
}
|
||||
}
|
||||
|
||||
function readSnapshot(filePath: string, component: string): ProcessObservabilitySnapshot {
|
||||
if (!existsSync(filePath)) {
|
||||
return {
|
||||
|
|
@ -314,20 +424,21 @@ export function createProjectObserver(
|
|||
const snapshot = readSnapshot(filePath, normalizedComponent);
|
||||
updater(snapshot);
|
||||
writeSnapshot(config, snapshot);
|
||||
if (logEntry) {
|
||||
if (logEntry && shouldLog(logEntry.level)) {
|
||||
appendAuditLog(config, normalizedComponent, logEntry.payload, logEntry.level);
|
||||
emitStructuredLog(logEntry.payload, logEntry.level);
|
||||
}
|
||||
} catch (error) {
|
||||
emitStructuredLog(
|
||||
{
|
||||
source: "ao-observability",
|
||||
component: normalizedComponent,
|
||||
outcome: "failure",
|
||||
operation: "observability.write",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"error",
|
||||
);
|
||||
const payload = {
|
||||
source: "ao-observability",
|
||||
timestamp: nowIso(),
|
||||
component: normalizedComponent,
|
||||
outcome: "failure",
|
||||
operation: "observability.write",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
appendObservabilityFailure(config, payload);
|
||||
emitStructuredLog(payload, "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,10 +457,10 @@ export function createProjectObserver(
|
|||
correlationId: input.correlationId,
|
||||
projectId: input.projectId,
|
||||
sessionId: input.sessionId,
|
||||
path: input.path,
|
||||
reason: input.reason,
|
||||
path: sanitizePath(input.path),
|
||||
reason: sanitizeReason(input.reason),
|
||||
durationMs: input.durationMs,
|
||||
data: input.data,
|
||||
data: sanitizeDataRecord(input.data),
|
||||
};
|
||||
|
||||
updateSnapshot(
|
||||
|
|
@ -368,7 +479,7 @@ export function createProjectObserver(
|
|||
} else {
|
||||
currentCounter.failure += 1;
|
||||
currentCounter.lastFailureAt = timestamp;
|
||||
currentCounter.lastFailureReason = input.reason;
|
||||
currentCounter.lastFailureReason = sanitizeReason(input.reason);
|
||||
}
|
||||
snapshot.metrics[bucketKey] = currentCounter;
|
||||
|
||||
|
|
@ -384,7 +495,7 @@ export function createProjectObserver(
|
|||
operation,
|
||||
outcome: input.outcome,
|
||||
updatedAt: timestamp,
|
||||
reason: input.reason,
|
||||
reason: sanitizeReason(input.reason),
|
||||
};
|
||||
|
||||
const sessionEntries = Object.entries(snapshot.sessions).sort(([, a], [, b]) =>
|
||||
|
|
@ -397,6 +508,7 @@ export function createProjectObserver(
|
|||
level,
|
||||
payload: {
|
||||
source: "ao-observability",
|
||||
timestamp,
|
||||
component: normalizedComponent,
|
||||
metric: input.metric,
|
||||
operation,
|
||||
|
|
@ -404,10 +516,71 @@ export function createProjectObserver(
|
|||
correlationId: input.correlationId,
|
||||
projectId: input.projectId,
|
||||
sessionId: input.sessionId,
|
||||
reason: input.reason,
|
||||
reason: sanitizeReason(input.reason),
|
||||
durationMs: input.durationMs,
|
||||
path: input.path,
|
||||
data: input.data,
|
||||
path: sanitizePath(input.path),
|
||||
data: sanitizeDataRecord(input.data),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
recordDiagnostic(input) {
|
||||
const timestamp = nowIso();
|
||||
const level = input.level ?? "info";
|
||||
const trace: ObservabilityTraceRecord = {
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
component: normalizedComponent,
|
||||
operation: input.operation,
|
||||
outcome: "success",
|
||||
correlationId: input.correlationId,
|
||||
projectId: input.projectId,
|
||||
sessionId: input.sessionId,
|
||||
path: sanitizePath(input.path),
|
||||
data: {
|
||||
message: sanitizeString(input.message),
|
||||
...sanitizeDataRecord(input.data),
|
||||
},
|
||||
};
|
||||
|
||||
updateSnapshot(
|
||||
(snapshot) => {
|
||||
snapshot.traces = [trace, ...snapshot.traces]
|
||||
.sort((a, b) => compareIsoDesc(a.timestamp, b.timestamp))
|
||||
.slice(0, TRACE_LIMIT);
|
||||
|
||||
if (input.sessionId) {
|
||||
snapshot.sessions[input.sessionId] = {
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
correlationId: input.correlationId,
|
||||
operation: input.operation,
|
||||
outcome: "success",
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
const sessionEntries = Object.entries(snapshot.sessions).sort(([, a], [, b]) =>
|
||||
compareIsoDesc(a.updatedAt, b.updatedAt),
|
||||
);
|
||||
snapshot.sessions = Object.fromEntries(sessionEntries.slice(0, SESSION_LIMIT));
|
||||
}
|
||||
},
|
||||
{
|
||||
level,
|
||||
payload: {
|
||||
source: "ao-observability",
|
||||
timestamp,
|
||||
component: normalizedComponent,
|
||||
operation: input.operation,
|
||||
correlationId: input.correlationId,
|
||||
projectId: input.projectId,
|
||||
sessionId: input.sessionId,
|
||||
path: sanitizePath(input.path),
|
||||
data: {
|
||||
message: sanitizeString(input.message),
|
||||
...sanitizeDataRecord(input.data),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -424,21 +597,22 @@ export function createProjectObserver(
|
|||
component: normalizedComponent,
|
||||
projectId: input.projectId,
|
||||
correlationId: input.correlationId,
|
||||
reason: input.reason,
|
||||
details: input.details,
|
||||
reason: sanitizeReason(input.reason),
|
||||
details: sanitizeDataRecord(input.details),
|
||||
};
|
||||
},
|
||||
{
|
||||
level: input.status === "error" ? "error" : input.status === "warn" ? "warn" : "info",
|
||||
payload: {
|
||||
source: "ao-observability",
|
||||
timestamp: updatedAt,
|
||||
component: normalizedComponent,
|
||||
surface: input.surface,
|
||||
status: input.status,
|
||||
projectId: input.projectId,
|
||||
correlationId: input.correlationId,
|
||||
reason: input.reason,
|
||||
details: input.details,
|
||||
reason: sanitizeReason(input.reason),
|
||||
details: sanitizeDataRecord(input.details),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,23 @@ export const BASE_AGENT_PROMPT = `You are an AI coding agent managed by the Agen
|
|||
- If CI fails, the orchestrator will send you the failures — fix them and push again.
|
||||
- If reviewers request changes, the orchestrator will forward their comments — address each one, push fixes, and reply to the comments.
|
||||
|
||||
## Reporting Progress to AO
|
||||
The orchestrator infers your status from runtime signals, but explicit reports are always preferred — they are accurate and fresh. Run these commands from the session shell (AO_SESSION_ID is pre-set for you):
|
||||
|
||||
- \`ao acknowledge\` — run once after reading the initial task so AO knows you picked it up.
|
||||
- \`ao report working\` — declare you are actively making progress (useful after pauses or long thinking blocks).
|
||||
- \`ao report waiting\` — you are blocked on something AO cannot unblock on its own (e.g. waiting for a human, external service).
|
||||
- \`ao report needs-input\` — you need a decision or info from the human before proceeding.
|
||||
- \`ao report fixing-ci\` — you are working specifically on making CI green again.
|
||||
- \`ao report addressing-reviews\` — you are working on reviewer-requested changes.
|
||||
- \`ao report pr-created --pr-url <url>\` / \`draft-pr-created\` / \`ready-for-review\` — declare PR workflow milestones as soon as you create or update the PR.
|
||||
- \`ao report completed\` — you finished non-coding research or analysis work that doesn't produce a PR.
|
||||
|
||||
Rules:
|
||||
- Do NOT self-report \`done\`, \`terminated\`, or terminal PR states like \`merged\`/\`closed\` — AO owns those transitions via SCM ground truth.
|
||||
- A fresh report is trusted over weak inference but runtime death, activity-based waiting_input, and SCM events (merged/closed PR, CI failure, reviews) still take precedence.
|
||||
- Use \`--note "<text>"\` to attach a short rationale when the state change is non-obvious.
|
||||
|
||||
## Git Workflow
|
||||
- Always create a feature branch from the default branch (never commit directly to it).
|
||||
- Use conventional commit messages (feat:, fix:, chore:, etc.).
|
||||
|
|
@ -46,6 +63,14 @@ export const BASE_AGENT_PROMPT_NO_REPO = `You are an AI coding agent managed by
|
|||
- You are running inside a managed session. Focus on the assigned task.
|
||||
- No remote repository is configured — work locally. PR, CI, and review features are unavailable.
|
||||
|
||||
## Reporting Progress to AO
|
||||
Explicit reports help the orchestrator track your state accurately. Run these from the session shell (AO_SESSION_ID is pre-set):
|
||||
- \`ao acknowledge\` — run once after reading the initial task.
|
||||
- \`ao report working\` / \`waiting\` / \`needs-input\` — declare your current phase.
|
||||
- \`ao report pr-created --pr-url <url>\` or \`draft-pr-created\` / \`ready-for-review\` — declare non-terminal PR workflow events when relevant.
|
||||
- \`ao report completed\` — finish non-coding research or analysis work.
|
||||
Do NOT self-report \`done\` or \`terminated\` — AO owns those transitions.
|
||||
|
||||
## Git Workflow
|
||||
- Always create a feature branch from the default branch (never commit directly to it).
|
||||
- Use conventional commit messages (feat:, fix:, chore:, etc.).`;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
|
|||
```
|
||||
|
||||
{{REPO_NOT_CONFIGURED_SECTION_START}}
|
||||
|
||||
> **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable.
|
||||
> Add a `repo` field (owner/repo) to `agent-orchestrator.yaml` to enable them.
|
||||
{{REPO_NOT_CONFIGURED_SECTION_END}}
|
||||
|
|
@ -61,12 +62,12 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
|
|||
|
||||
- `ao status`: Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}}
|
||||
- `ao spawn [issue] [--prompt <text>]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr <pr>]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}}
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
- `ao session kill <session>`: Kill a specific session
|
||||
- `ao session cleanup [-p project]`: Kill completed/merged sessions
|
||||
- `ao session cleanup [-p project]`: Kill cleanup-eligible sessions (closed work or dead runtimes)
|
||||
- `ao send <session> <message>`: Send a message to a running session
|
||||
- `ao send --no-wait <session> <message>`: Send without waiting for session to become idle
|
||||
- `ao dashboard`: Start the web dashboard (http://localhost:{{dashboardPort}})
|
||||
|
|
@ -95,11 +96,20 @@ ao spawn --prompt "Add rate limiting to the /api/upload endpoint"
|
|||
Use `ao status` to see:
|
||||
|
||||
- Current session status (working, pr_open, review_pending, etc.)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed)
|
||||
- CI status (passing/failing/pending)
|
||||
- Review decision (approved/changes_requested/pending)
|
||||
- Unresolved comments count
|
||||
{{REPO_CONFIGURED_SECTION_END}}
|
||||
{{REPO_CONFIGURED_SECTION_END}}
|
||||
|
||||
### Explicit Agent Reports
|
||||
|
||||
Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report <state>` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, pr-created, draft-pr-created, ready-for-review, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth.
|
||||
|
||||
- Never run `ao acknowledge` or `ao report` from the orchestrator session - they are worker-only commands.
|
||||
- Fresh reports (<5 min) are useful hints when inference is weak, but runtime death, activity-based waiting_input, and SCM truth (merged/closed PR, CI failure, review decisions) still take precedence.
|
||||
- Use `--pr-url` / `--pr-number` on PR workflow reports when the agent knows them; merged/closed remain SCM-owned.
|
||||
- If an agent reports `waiting` but a PR actually merged, trust the PR state and follow up.
|
||||
|
||||
### Sending Messages
|
||||
|
||||
|
|
@ -138,7 +148,7 @@ When debugging or triaging from the orchestrator session:
|
|||
Remove completed sessions:
|
||||
|
||||
```bash
|
||||
ao session cleanup -p {{projectId}} # Kill sessions where PR is merged or issue is closed
|
||||
ao session cleanup -p {{projectId}} # Kill sessions whose work closed or runtime has exited
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
|
@ -170,7 +180,7 @@ The system automatically handles these events:
|
|||
2. Use `ao batch-spawn` to spawn sessions for each issue
|
||||
3. Monitor with `ao status` or the dashboard
|
||||
4. Agents will fetch, implement, test, PR, and respond to reviews
|
||||
5. Use `ao session cleanup` when PRs are merged
|
||||
5. Use `ao session cleanup` when work is truly finished or the runtime is gone
|
||||
|
||||
{{REPO_CONFIGURED_SECTION_END}}### Handling Stuck Agents
|
||||
|
||||
|
|
@ -209,7 +219,7 @@ When an agent needs human judgment:
|
|||
|
||||
5. **Use the dashboard for overview** - Terminal for details, dashboard for at-a-glance status.
|
||||
|
||||
6. **Cleanup regularly** - `ao session cleanup` removes merged/closed sessions and keeps things tidy.
|
||||
6. **Cleanup regularly** - `ao session cleanup` removes sessions that are truly cleanup-eligible and keeps things tidy.
|
||||
|
||||
7. **Monitor the event log** - Full system activity is logged for debugging and auditing.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@ export interface RecoveryAssessment {
|
|||
/** Human-readable reason for classification */
|
||||
reason: string;
|
||||
|
||||
/** Whether the runtime probe itself succeeded */
|
||||
runtimeProbeSucceeded: boolean;
|
||||
|
||||
/** Whether the process probe itself succeeded */
|
||||
processProbeSucceeded: boolean;
|
||||
|
||||
/** Whether the signals disagree strongly enough to block cleanup */
|
||||
signalDisagreement: boolean;
|
||||
|
||||
/** Whether recovery can proceed automatically, needs a human, or should be skipped */
|
||||
recoveryRule: "auto" | "human" | "skip";
|
||||
|
||||
// --- Runtime state ---
|
||||
|
||||
/** Whether the runtime (tmux/docker) is alive */
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type RuntimeHandle,
|
||||
type SessionStatus,
|
||||
type ActivityState,
|
||||
type Session,
|
||||
} from "../types.js";
|
||||
import { safeJsonParse, validateStatus } from "../utils/validation.js";
|
||||
import type { ScannedSession } from "./scanner.js";
|
||||
|
|
@ -20,6 +21,18 @@ import {
|
|||
type RecoveryConfig,
|
||||
} from "./types.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
|
||||
function indicatesLiveAgentActivity(activity: ActivityState | null): boolean {
|
||||
return (
|
||||
activity === "active" ||
|
||||
activity === "ready" ||
|
||||
activity === "idle" ||
|
||||
activity === "waiting_input" ||
|
||||
activity === "blocked"
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateSession(
|
||||
scanned: ScannedSession,
|
||||
|
|
@ -57,11 +70,14 @@ export async function validateSession(
|
|||
};
|
||||
|
||||
let runtimeAlive = false;
|
||||
let runtimeProbeSucceeded = false;
|
||||
if (runtime && runtimeHandle) {
|
||||
try {
|
||||
runtimeAlive = await runtime.isAlive(runtimeHandle);
|
||||
runtimeProbeSucceeded = true;
|
||||
} catch {
|
||||
runtimeAlive = false;
|
||||
runtimeProbeSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,12 +98,46 @@ export async function validateSession(
|
|||
}
|
||||
|
||||
let agentProcessRunning = false;
|
||||
const agentActivity: ActivityState | null = null;
|
||||
let processProbeSucceeded = false;
|
||||
let agentActivity: ActivityState | null = null;
|
||||
if (agent && runtimeHandle) {
|
||||
try {
|
||||
agentProcessRunning = await agent.isProcessRunning(runtimeHandle);
|
||||
processProbeSucceeded = true;
|
||||
} catch {
|
||||
agentProcessRunning = false;
|
||||
processProbeSucceeded = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const probeSession: Session = {
|
||||
id: sessionId,
|
||||
projectId,
|
||||
status: metadataStatus,
|
||||
activity: null,
|
||||
activitySignal: createActivitySignal("unavailable"),
|
||||
lifecycle,
|
||||
branch: rawMetadata["branch"] ?? null,
|
||||
issueId: rawMetadata["issue"] ?? null,
|
||||
pr: null,
|
||||
workspacePath,
|
||||
runtimeHandle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()),
|
||||
lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()),
|
||||
metadata: rawMetadata,
|
||||
};
|
||||
const detection = await agent.getActivityState(probeSession, config.readyThresholdMs);
|
||||
agentActivity = detection?.state ?? null;
|
||||
if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) {
|
||||
agentProcessRunning = true;
|
||||
}
|
||||
if (agentActivity === "exited") {
|
||||
agentProcessRunning = false;
|
||||
}
|
||||
} catch {
|
||||
agentActivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,15 +147,40 @@ export async function validateSession(
|
|||
workspaceExists,
|
||||
agentProcessRunning,
|
||||
metadataStatus,
|
||||
runtimeProbeSucceeded,
|
||||
processProbeSucceeded,
|
||||
runtimeHandle !== null,
|
||||
);
|
||||
const action = determineAction(classification, metadataStatus, recoveryConfig);
|
||||
const signalDisagreement =
|
||||
runtimeProbeSucceeded &&
|
||||
processProbeSucceeded &&
|
||||
((runtimeAlive && !agentProcessRunning) || (!runtimeAlive && agentProcessRunning));
|
||||
const recoveryRule = determineRecoveryRule(
|
||||
classification,
|
||||
signalDisagreement,
|
||||
metadataStatus,
|
||||
recoveryConfig,
|
||||
);
|
||||
const action = determineAction(classification, metadataStatus, recoveryConfig, recoveryRule);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectId,
|
||||
classification,
|
||||
action,
|
||||
reason: getReason(classification, runtimeAlive, workspaceExists, agentProcessRunning),
|
||||
reason: getReason(
|
||||
classification,
|
||||
runtimeAlive,
|
||||
workspaceExists,
|
||||
agentProcessRunning,
|
||||
runtimeProbeSucceeded,
|
||||
processProbeSucceeded,
|
||||
signalDisagreement,
|
||||
),
|
||||
runtimeProbeSucceeded,
|
||||
processProbeSucceeded,
|
||||
signalDisagreement,
|
||||
recoveryRule,
|
||||
runtimeAlive,
|
||||
runtimeHandle,
|
||||
workspaceExists,
|
||||
|
|
@ -123,18 +198,26 @@ function classifySession(
|
|||
workspaceExists: boolean,
|
||||
agentProcessRunning: boolean,
|
||||
metadataStatus: SessionStatus,
|
||||
runtimeProbeSucceeded: boolean,
|
||||
processProbeSucceeded: boolean,
|
||||
hasRuntimeHandle: boolean,
|
||||
): RecoveryClassification {
|
||||
if (TERMINAL_STATUSES_SET.has(metadataStatus)) {
|
||||
return "unrecoverable";
|
||||
}
|
||||
|
||||
if (runtimeAlive && workspaceExists && agentProcessRunning) {
|
||||
return "live";
|
||||
}
|
||||
|
||||
if (!runtimeAlive && !workspaceExists) {
|
||||
if (TERMINAL_STATUSES_SET.has(metadataStatus)) {
|
||||
return "unrecoverable";
|
||||
}
|
||||
if (!workspaceExists && (!hasRuntimeHandle || (runtimeProbeSucceeded && !runtimeAlive))) {
|
||||
return "dead";
|
||||
}
|
||||
|
||||
if (metadataStatus === "detecting" || !runtimeProbeSucceeded || !processProbeSucceeded) {
|
||||
return "partial";
|
||||
}
|
||||
|
||||
if (runtimeAlive && !workspaceExists) {
|
||||
return "partial";
|
||||
}
|
||||
|
|
@ -150,11 +233,40 @@ function classifySession(
|
|||
return "partial";
|
||||
}
|
||||
|
||||
function determineRecoveryRule(
|
||||
classification: RecoveryClassification,
|
||||
signalDisagreement: boolean,
|
||||
metadataStatus: SessionStatus,
|
||||
recoveryConfig: RecoveryConfig = DEFAULT_RECOVERY_CONFIG,
|
||||
): "auto" | "human" | "skip" {
|
||||
if (classification === "unrecoverable") return "skip";
|
||||
if (signalDisagreement) {
|
||||
return "human";
|
||||
}
|
||||
if (classification === "live" || classification === "dead") {
|
||||
return "auto";
|
||||
}
|
||||
if (metadataStatus === "detecting") {
|
||||
return "human";
|
||||
}
|
||||
if (classification === "partial") {
|
||||
return recoveryConfig.escalatePartial ? "human" : "auto";
|
||||
}
|
||||
return "human";
|
||||
}
|
||||
|
||||
function determineAction(
|
||||
classification: RecoveryClassification,
|
||||
_metadataStatus: SessionStatus,
|
||||
recoveryConfig: RecoveryConfig,
|
||||
recoveryRule: "auto" | "human" | "skip",
|
||||
): RecoveryAction {
|
||||
if (recoveryRule === "skip") {
|
||||
return "skip";
|
||||
}
|
||||
if (recoveryRule === "human") {
|
||||
return "escalate";
|
||||
}
|
||||
switch (classification) {
|
||||
case "live":
|
||||
return "recover";
|
||||
|
|
@ -174,7 +286,16 @@ function getReason(
|
|||
runtimeAlive: boolean,
|
||||
workspaceExists: boolean,
|
||||
agentProcessRunning: boolean,
|
||||
runtimeProbeSucceeded: boolean,
|
||||
processProbeSucceeded: boolean,
|
||||
signalDisagreement: boolean,
|
||||
): string {
|
||||
if (!runtimeProbeSucceeded || !processProbeSucceeded) {
|
||||
return `Probe uncertainty: runtimeProbe=${runtimeProbeSucceeded}, processProbe=${processProbeSucceeded}`;
|
||||
}
|
||||
if (signalDisagreement) {
|
||||
return `Signal disagreement: runtime=${runtimeAlive}, workspace=${workspaceExists}, agent=${agentProcessRunning}`;
|
||||
}
|
||||
switch (classification) {
|
||||
case "live":
|
||||
return "Session is running normally";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* Report Watcher — Background trigger system for agent reports (#140)
|
||||
*
|
||||
* Monitors agent reports and triggers actions when anomalies are detected:
|
||||
* - No acknowledge timeout: agent didn't acknowledge task after spawn
|
||||
* - Stale report: agent hasn't reported in a while
|
||||
* - Agent blocked: agent reported blocked/needs_input state
|
||||
*
|
||||
* This module is designed to be called from the lifecycle-manager polling loop.
|
||||
*/
|
||||
|
||||
import type { Session, SessionStatus } from "./types.js";
|
||||
import { readAgentReport, type AgentReport } from "./agent-report.js";
|
||||
|
||||
/**
|
||||
* Report watcher trigger types.
|
||||
*/
|
||||
export type ReportWatcherTrigger =
|
||||
| "no_acknowledge"
|
||||
| "stale_report"
|
||||
| "agent_needs_input";
|
||||
|
||||
/**
|
||||
* Result of a report audit check.
|
||||
*/
|
||||
export interface ReportAuditResult {
|
||||
/** Which trigger was activated, if any. */
|
||||
trigger: ReportWatcherTrigger | null;
|
||||
/** Human-readable description of the finding. */
|
||||
message: string;
|
||||
/** ISO timestamp when the check was performed. */
|
||||
checkedAt: string;
|
||||
/** The agent report that was checked (if available). */
|
||||
report: AgentReport | null;
|
||||
/** Time since spawn in milliseconds. */
|
||||
timeSinceSpawnMs?: number;
|
||||
/** Time since last report in milliseconds. */
|
||||
timeSinceReportMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for report watcher thresholds.
|
||||
*/
|
||||
export interface ReportWatcherConfig {
|
||||
/** Time after spawn before triggering no-acknowledge (default: 10 minutes). */
|
||||
acknowledgeTimeoutMs: number;
|
||||
/** Time without reports before triggering stale report (default: 30 minutes). */
|
||||
staleReportTimeoutMs: number;
|
||||
/** Whether to check for acknowledge timeout. */
|
||||
checkAcknowledge: boolean;
|
||||
/** Whether to check for stale reports. */
|
||||
checkStale: boolean;
|
||||
/** Whether to check for blocked agents. */
|
||||
checkBlocked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default report watcher configuration.
|
||||
*/
|
||||
export const DEFAULT_REPORT_WATCHER_CONFIG: ReportWatcherConfig = {
|
||||
acknowledgeTimeoutMs: 10 * 60 * 1000, // 10 minutes
|
||||
staleReportTimeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
checkAcknowledge: true,
|
||||
checkStale: true,
|
||||
checkBlocked: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal statuses that should not be audited.
|
||||
*/
|
||||
const TERMINAL_STATUSES: Set<SessionStatus> = new Set([
|
||||
"done",
|
||||
"terminated",
|
||||
"killed",
|
||||
"cleanup",
|
||||
"merged",
|
||||
"errored",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a session should be audited for report issues.
|
||||
* Note: spawning sessions are included so acknowledge timeout can fire.
|
||||
*/
|
||||
export function shouldAuditSession(session: Session): boolean {
|
||||
// Skip terminal sessions
|
||||
if (TERMINAL_STATUSES.has(session.status)) {
|
||||
return false;
|
||||
}
|
||||
// Skip orchestrator sessions
|
||||
if (session.lifecycle?.session.kind === "orchestrator") {
|
||||
return false;
|
||||
}
|
||||
// Note: spawning sessions are NOT skipped — they need acknowledge timeout checks
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for acknowledge timeout: agent didn't acknowledge task within threshold.
|
||||
*/
|
||||
export function checkAcknowledgeTimeout(
|
||||
session: Session,
|
||||
report: AgentReport | null,
|
||||
now: Date,
|
||||
config: ReportWatcherConfig,
|
||||
): ReportAuditResult | null {
|
||||
if (!config.checkAcknowledge) return null;
|
||||
|
||||
// If we have any report (acknowledge or otherwise), the agent responded
|
||||
if (report) return null;
|
||||
|
||||
// Check time since spawn
|
||||
const createdAt = session.createdAt ?? session.metadata["createdAt"];
|
||||
if (!createdAt) return null;
|
||||
|
||||
const spawnTime = typeof createdAt === "string" ? Date.parse(createdAt) : createdAt.getTime();
|
||||
if (Number.isNaN(spawnTime)) return null;
|
||||
|
||||
const timeSinceSpawn = now.getTime() - spawnTime;
|
||||
if (timeSinceSpawn < config.acknowledgeTimeoutMs) return null;
|
||||
|
||||
return {
|
||||
trigger: "no_acknowledge",
|
||||
message: `Agent has not acknowledged task after ${Math.round(timeSinceSpawn / 60000)} minutes`,
|
||||
checkedAt: now.toISOString(),
|
||||
report: null,
|
||||
timeSinceSpawnMs: timeSinceSpawn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for stale report: agent hasn't reported in a while.
|
||||
*/
|
||||
export function checkStaleReport(
|
||||
session: Session,
|
||||
report: AgentReport | null,
|
||||
now: Date,
|
||||
config: ReportWatcherConfig,
|
||||
): ReportAuditResult | null {
|
||||
if (!config.checkStale) return null;
|
||||
|
||||
// If no report, the acknowledge check handles it
|
||||
if (!report) return null;
|
||||
|
||||
// Check if the report is stale
|
||||
const reportTime = Date.parse(report.timestamp);
|
||||
if (Number.isNaN(reportTime)) return null;
|
||||
|
||||
const timeSinceReport = now.getTime() - reportTime;
|
||||
if (timeSinceReport < config.staleReportTimeoutMs) return null;
|
||||
|
||||
// Don't flag as stale if agent is in a waiting state (that's expected)
|
||||
if (report.state === "waiting" || report.state === "needs_input") return null;
|
||||
|
||||
return {
|
||||
trigger: "stale_report",
|
||||
message: `Agent report is stale (${Math.round(timeSinceReport / 60000)} minutes since last report)`,
|
||||
checkedAt: now.toISOString(),
|
||||
report,
|
||||
timeSinceReportMs: timeSinceReport,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for blocked agent: agent reported blocked or needs_input state.
|
||||
*/
|
||||
export function checkBlockedAgent(
|
||||
_session: Session,
|
||||
report: AgentReport | null,
|
||||
now: Date,
|
||||
config: ReportWatcherConfig,
|
||||
): ReportAuditResult | null {
|
||||
if (!config.checkBlocked) return null;
|
||||
|
||||
// If no report, nothing to check
|
||||
if (!report) return null;
|
||||
|
||||
if (report.state === "needs_input") {
|
||||
const reportTime = Date.parse(report.timestamp);
|
||||
const timeSinceReportMs = Number.isNaN(reportTime) ? undefined : now.getTime() - reportTime;
|
||||
return {
|
||||
trigger: "agent_needs_input",
|
||||
message: `Agent needs input: ${report.note ?? "waiting for user decision"}`,
|
||||
checkedAt: now.toISOString(),
|
||||
report,
|
||||
timeSinceReportMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Note: "blocked" is not in the current AGENT_REPORTED_STATES but we check for it
|
||||
// in case it gets added or for forward compatibility
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit a session's agent reports for issues.
|
||||
* Returns the first triggered audit result, or null if no issues found.
|
||||
*/
|
||||
export function auditAgentReports(
|
||||
session: Session,
|
||||
config: Partial<ReportWatcherConfig> = {},
|
||||
now: Date = new Date(),
|
||||
): ReportAuditResult | null {
|
||||
if (!shouldAuditSession(session)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullConfig: ReportWatcherConfig = {
|
||||
...DEFAULT_REPORT_WATCHER_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
const report = readAgentReport(session.metadata);
|
||||
|
||||
// Check in priority order: blocked > no_acknowledge > stale
|
||||
const blockedResult = checkBlockedAgent(session, report, now, fullConfig);
|
||||
if (blockedResult) return blockedResult;
|
||||
|
||||
const acknowledgeResult = checkAcknowledgeTimeout(session, report, now, fullConfig);
|
||||
if (acknowledgeResult) return acknowledgeResult;
|
||||
|
||||
const staleResult = checkStaleReport(session, report, now, fullConfig);
|
||||
if (staleResult) return staleResult;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reaction key for a report watcher trigger.
|
||||
* Used to look up the reaction configuration.
|
||||
*/
|
||||
export function getReactionKeyForTrigger(trigger: ReportWatcherTrigger): string {
|
||||
switch (trigger) {
|
||||
case "no_acknowledge":
|
||||
return "report-no-acknowledge";
|
||||
case "stale_report":
|
||||
return "report-stale";
|
||||
case "agent_needs_input":
|
||||
return "report-needs-input";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata keys for storing report watcher flags.
|
||||
*/
|
||||
export const REPORT_WATCHER_METADATA_KEYS = {
|
||||
/** ISO timestamp of last audit */
|
||||
LAST_AUDITED_AT: "reportWatcherLastAuditedAt",
|
||||
/** Current active trigger (if any) */
|
||||
ACTIVE_TRIGGER: "reportWatcherActiveTrigger",
|
||||
/** ISO timestamp when trigger was first activated */
|
||||
TRIGGER_ACTIVATED_AT: "reportWatcherTriggerActivatedAt",
|
||||
/** Number of times this trigger has fired */
|
||||
TRIGGER_COUNT: "reportWatcherTriggerCount",
|
||||
} as const;
|
||||
|
|
@ -19,7 +19,6 @@ import { promisify } from "node:util";
|
|||
import {
|
||||
isIssueNotFoundError,
|
||||
isRestorable,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
SessionNotFoundError,
|
||||
SessionNotRestorableError,
|
||||
WorkspaceMissingError,
|
||||
|
|
@ -53,7 +52,15 @@ import {
|
|||
listMetadata,
|
||||
reserveSessionId,
|
||||
} from "./metadata.js";
|
||||
import {
|
||||
buildLifecycleMetadataPatch,
|
||||
cloneLifecycle,
|
||||
createInitialCanonicalLifecycle,
|
||||
deriveLegacyStatus,
|
||||
parseCanonicalLifecycle,
|
||||
} from "./lifecycle-state.js";
|
||||
import { buildPrompt } from "./prompt-builder.js";
|
||||
import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js";
|
||||
import {
|
||||
getSessionsDir,
|
||||
getWorktreesDir,
|
||||
|
|
@ -67,7 +74,7 @@ import {
|
|||
} from "./opencode-agents-md.js";
|
||||
import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
|
||||
import { safeJsonParse } from "./utils/validation.js";
|
||||
import { safeJsonParse, validateStatus } from "./utils/validation.js";
|
||||
import { isGitBranchNameSafe } from "./utils.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
|
||||
|
|
@ -372,6 +379,28 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
return next;
|
||||
}
|
||||
|
||||
function buildUpdatedLifecycle(
|
||||
sessionId: string,
|
||||
raw: Record<string, string>,
|
||||
updater: (lifecycle: ReturnType<typeof parseCanonicalLifecycle>) => void,
|
||||
) {
|
||||
const lifecycle = cloneLifecycle(
|
||||
parseCanonicalLifecycle(raw, {
|
||||
sessionId,
|
||||
status: validateStatus(raw["status"]),
|
||||
}),
|
||||
);
|
||||
updater(lifecycle);
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
function lifecycleMetadataUpdates(
|
||||
raw: Record<string, string>,
|
||||
lifecycle: ReturnType<typeof parseCanonicalLifecycle>,
|
||||
): Partial<Record<string, string>> {
|
||||
return buildLifecycleMetadataPatch(lifecycle, validateStatus(raw["status"]));
|
||||
}
|
||||
|
||||
function updateMetadataPreservingMtime(
|
||||
sessionsDir: string,
|
||||
sessionName: string,
|
||||
|
|
@ -423,13 +452,28 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const lifecycle = buildUpdatedLifecycle(repaired.sessionName, repaired.raw, (next) => {
|
||||
next.session.kind = "orchestrator";
|
||||
next.pr.state = "none";
|
||||
next.pr.reason = "not_created";
|
||||
next.pr.number = null;
|
||||
next.pr.url = null;
|
||||
next.pr.lastObservedAt = null;
|
||||
if (updates["status"] === "working") {
|
||||
next.session.state = "working";
|
||||
next.session.reason = "task_in_progress";
|
||||
}
|
||||
});
|
||||
updateMetadataPreservingMtime(
|
||||
sessionsDir,
|
||||
repaired.sessionName,
|
||||
updates,
|
||||
{ ...updates, ...lifecycleMetadataUpdates(repaired.raw, lifecycle) },
|
||||
repaired.modifiedAt,
|
||||
);
|
||||
repaired.raw = applyMetadataUpdatesToRaw(repaired.raw, updates);
|
||||
repaired.raw = applyMetadataUpdatesToRaw(repaired.raw, {
|
||||
...updates,
|
||||
...lifecycleMetadataUpdates(repaired.raw, lifecycle),
|
||||
});
|
||||
}
|
||||
|
||||
return repaired;
|
||||
|
|
@ -450,6 +494,24 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
const duplicatePRAttachments = new Map<string, ActiveSessionRecord[]>();
|
||||
|
||||
for (const record of repaired) {
|
||||
if (record.raw["stateVersion"] !== "2" || !record.raw["statePayload"]) {
|
||||
const lifecycle = cloneLifecycle(
|
||||
parseCanonicalLifecycle(record.raw, {
|
||||
sessionId: record.sessionName,
|
||||
status: validateStatus(record.raw["status"]),
|
||||
createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined,
|
||||
}),
|
||||
);
|
||||
const canonicalUpdates = lifecycleMetadataUpdates(record.raw, lifecycle);
|
||||
updateMetadataPreservingMtime(
|
||||
sessionsDir,
|
||||
record.sessionName,
|
||||
canonicalUpdates,
|
||||
record.modifiedAt,
|
||||
);
|
||||
record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates);
|
||||
}
|
||||
|
||||
if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) {
|
||||
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw;
|
||||
continue;
|
||||
|
|
@ -486,8 +548,25 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
prAutoDetect: "off",
|
||||
...(PR_TRACKING_STATUSES.has(record.raw["status"] ?? "") ? { status: "working" } : {}),
|
||||
};
|
||||
updateMetadataPreservingMtime(sessionsDir, record.sessionName, updates, record.modifiedAt);
|
||||
record.raw = applyMetadataUpdatesToRaw(record.raw, updates);
|
||||
const lifecycle = buildUpdatedLifecycle(record.sessionName, record.raw, (next) => {
|
||||
next.pr.state = "none";
|
||||
next.pr.reason = "not_created";
|
||||
next.pr.number = null;
|
||||
next.pr.url = null;
|
||||
next.pr.lastObservedAt = null;
|
||||
if (updates["status"] === "working") {
|
||||
next.session.state = "working";
|
||||
next.session.reason = "task_in_progress";
|
||||
}
|
||||
});
|
||||
const lifecycleUpdates = lifecycleMetadataUpdates(record.raw, lifecycle);
|
||||
updateMetadataPreservingMtime(
|
||||
sessionsDir,
|
||||
record.sessionName,
|
||||
{ ...updates, ...lifecycleUpdates },
|
||||
record.modifiedAt,
|
||||
);
|
||||
record.raw = applyMetadataUpdatesToRaw(record.raw, { ...updates, ...lifecycleUpdates });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -897,16 +976,35 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
try {
|
||||
const alive = await plugins.runtime.isAlive(session.runtimeHandle);
|
||||
if (!alive) {
|
||||
session.lifecycle.runtime.state = "missing";
|
||||
session.lifecycle.runtime.reason =
|
||||
session.runtimeHandle.runtimeName === "tmux" ? "tmux_missing" : "process_missing";
|
||||
session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
|
||||
if (
|
||||
session.lifecycle.session.state !== "done" &&
|
||||
session.lifecycle.session.state !== "terminated"
|
||||
) {
|
||||
session.lifecycle.session.state = "detecting";
|
||||
session.lifecycle.session.reason = "runtime_lost";
|
||||
session.lifecycle.session.lastTransitionAt = new Date().toISOString();
|
||||
}
|
||||
// Process is confirmed dead — set activity to exited.
|
||||
// Only update status to "killed" if not already in a terminal state.
|
||||
if (!TERMINAL_SESSION_STATUSES.has(session.status)) {
|
||||
session.status = "killed";
|
||||
}
|
||||
session.activity = "exited";
|
||||
session.activitySignal = createActivitySignal("valid", {
|
||||
activity: "exited",
|
||||
source: "runtime",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Can't check liveness — continue to activity detection
|
||||
session.lifecycle.runtime.state = "probe_failed";
|
||||
session.lifecycle.runtime.reason = "probe_error";
|
||||
session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -916,17 +1014,24 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// external scripts (which don't store runtimeHandle) to always show "unknown".
|
||||
// This now runs for ALL sessions, including terminal statuses, so a merged
|
||||
// session with a live agent shows accurate activity (ready/idle/waiting_input).
|
||||
session.activitySignal = createActivitySignal("unavailable");
|
||||
if (plugins.agent) {
|
||||
try {
|
||||
const detected = await plugins.agent.getActivityState(session, config.readyThresholdMs);
|
||||
if (detected !== null) {
|
||||
session.activitySignal = classifyActivitySignal(detected, "native");
|
||||
session.activity = detected.state;
|
||||
session.lifecycle.runtime.state = "alive";
|
||||
session.lifecycle.runtime.reason = "process_running";
|
||||
session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
|
||||
if (detected.timestamp && detected.timestamp > session.lastActivityAt) {
|
||||
session.lastActivityAt = detected.timestamp;
|
||||
}
|
||||
} else {
|
||||
session.activitySignal = createActivitySignal("null", { source: "native" });
|
||||
}
|
||||
} catch {
|
||||
// Can't detect activity — keep existing value
|
||||
session.activitySignal = createActivitySignal("probe_failure", { source: "native" });
|
||||
}
|
||||
|
||||
// Enrich with live agent session info (summary, cost).
|
||||
|
|
@ -1145,19 +1250,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
// Write metadata and run post-launch setup — clean up on failure
|
||||
const createdAt = new Date();
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", createdAt);
|
||||
lifecycle.runtime.handle = handle;
|
||||
lifecycle.runtime.tmuxName = tmuxName ?? null;
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
projectId: spawnConfig.projectId,
|
||||
status: "spawning",
|
||||
status: deriveLegacyStatus(lifecycle),
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: createdAt,
|
||||
source: "runtime",
|
||||
}),
|
||||
lifecycle,
|
||||
branch,
|
||||
issueId: spawnConfig.issueId ?? null,
|
||||
pr: null,
|
||||
workspacePath,
|
||||
runtimeHandle: handle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
createdAt,
|
||||
lastActivityAt: createdAt,
|
||||
metadata: {
|
||||
...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}),
|
||||
...(spawnConfig.prompt ? { userPrompt: spawnConfig.prompt } : {}),
|
||||
|
|
@ -1168,12 +1284,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
writeMetadata(sessionsDir, sessionId, {
|
||||
worktree: workspacePath,
|
||||
branch,
|
||||
status: "spawning",
|
||||
status: deriveLegacyStatus(lifecycle),
|
||||
...buildLifecycleMetadataPatch(lifecycle),
|
||||
tmuxName, // Store tmux name for mapping
|
||||
issue: spawnConfig.issueId,
|
||||
project: spawnConfig.projectId,
|
||||
agent: selection.agentName, // Persist agent name for lifecycle manager
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: createdAt.toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
opencodeSessionId: reusedOpenCodeSessionId,
|
||||
userPrompt: spawnConfig.prompt,
|
||||
|
|
@ -1467,19 +1584,34 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
// Write metadata and run post-launch setup
|
||||
const createdAt = new Date();
|
||||
const lifecycle = createInitialCanonicalLifecycle("orchestrator", createdAt);
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = createdAt.toISOString();
|
||||
lifecycle.session.lastTransitionAt = createdAt.toISOString();
|
||||
lifecycle.runtime.handle = handle;
|
||||
lifecycle.runtime.tmuxName = tmuxName ?? null;
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
projectId: orchestratorConfig.projectId,
|
||||
status: "working",
|
||||
status: deriveLegacyStatus(lifecycle),
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: createdAt,
|
||||
source: "runtime",
|
||||
}),
|
||||
lifecycle,
|
||||
branch,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath,
|
||||
runtimeHandle: handle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
createdAt,
|
||||
lastActivityAt: createdAt,
|
||||
metadata: {
|
||||
...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}),
|
||||
},
|
||||
|
|
@ -1489,12 +1621,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
writeMetadata(sessionsDir, sessionId, {
|
||||
worktree: workspacePath,
|
||||
branch,
|
||||
status: "working",
|
||||
status: deriveLegacyStatus(lifecycle),
|
||||
...buildLifecycleMetadataPatch(lifecycle),
|
||||
role: "orchestrator",
|
||||
tmuxName,
|
||||
project: orchestratorConfig.projectId,
|
||||
agent: selection.agentName,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: createdAt.toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
opencodeSessionId: reusableOpenCodeSessionId,
|
||||
});
|
||||
|
|
@ -1701,6 +1834,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
}
|
||||
|
||||
const terminatedLifecycle = buildUpdatedLifecycle(sessionId, raw, (next) => {
|
||||
next.session.state = "terminated";
|
||||
next.session.reason = "manually_killed";
|
||||
next.session.terminatedAt = new Date().toISOString();
|
||||
next.session.lastTransitionAt = next.session.terminatedAt;
|
||||
next.runtime.state = raw["runtimeHandle"] || raw["tmuxName"] ? "missing" : "exited";
|
||||
next.runtime.reason = "manual_kill_requested";
|
||||
next.runtime.lastObservedAt = new Date().toISOString();
|
||||
});
|
||||
updateMetadata(sessionsDir, sessionId, {
|
||||
...lifecycleMetadataUpdates(raw, terminatedLifecycle),
|
||||
});
|
||||
|
||||
// Archive metadata
|
||||
deleteMetadata(sessionsDir, sessionId, true);
|
||||
if (didPurgeOpenCodeSession) {
|
||||
|
|
@ -1763,11 +1909,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
const plugins = resolvePlugins(project);
|
||||
let shouldKill = false;
|
||||
|
||||
// Check if PR is merged
|
||||
// Check if PR was closed without merging.
|
||||
if (session.pr && plugins.scm) {
|
||||
try {
|
||||
const prState = await plugins.scm.getPRState(session.pr);
|
||||
if (prState === PR_STATE.MERGED || prState === PR_STATE.CLOSED) {
|
||||
if (prState === PR_STATE.CLOSED) {
|
||||
shouldKill = true;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -2018,7 +2164,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
};
|
||||
|
||||
const restoreForDelivery = async (reason: string, session: Session): Promise<Session> => {
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
if (session.lifecycle.session.state === "done") {
|
||||
throw new Error(`Cannot send to session ${sessionId}: ${reason}`);
|
||||
}
|
||||
|
||||
|
|
@ -2128,8 +2274,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
try {
|
||||
await sendWithConfirmation(prepared);
|
||||
} catch (err) {
|
||||
const shouldRetryWithRestore =
|
||||
prepared.restoredAt === undefined && !NON_RESTORABLE_STATUSES.has(prepared.status);
|
||||
const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared);
|
||||
|
||||
if (!shouldRetryWithRestore) {
|
||||
if (err instanceof Error) {
|
||||
|
|
@ -2206,21 +2351,43 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
const branchChanged = await scm.checkoutPR(pr, workspacePath);
|
||||
|
||||
const claimLifecycle = buildUpdatedLifecycle(sessionId, raw, (next) => {
|
||||
next.pr.state = "open";
|
||||
next.pr.reason = "in_progress";
|
||||
next.pr.number = pr.number;
|
||||
next.pr.url = pr.url;
|
||||
next.pr.lastObservedAt = new Date().toISOString();
|
||||
});
|
||||
updateMetadata(sessionsDir, sessionId, {
|
||||
pr: pr.url,
|
||||
status: "pr_open",
|
||||
status: deriveLegacyStatus(claimLifecycle, validateStatus(raw["status"])),
|
||||
branch: pr.branch,
|
||||
prAutoDetect: "",
|
||||
...lifecycleMetadataUpdates(raw, claimLifecycle),
|
||||
});
|
||||
|
||||
for (const previousSessionId of takenOverFrom) {
|
||||
const previousRaw = readMetadataRaw(sessionsDir, previousSessionId);
|
||||
if (!previousRaw) continue;
|
||||
|
||||
const previousLifecycle = buildUpdatedLifecycle(previousSessionId, previousRaw, (next) => {
|
||||
next.pr.state = "none";
|
||||
next.pr.reason = "not_created";
|
||||
next.pr.number = null;
|
||||
next.pr.url = null;
|
||||
next.pr.lastObservedAt = null;
|
||||
if (PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "")) {
|
||||
next.session.state = "working";
|
||||
next.session.reason = "task_in_progress";
|
||||
}
|
||||
});
|
||||
updateMetadata(sessionsDir, previousSessionId, {
|
||||
pr: "",
|
||||
prAutoDetect: "off",
|
||||
...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "") ? { status: "working" } : {}),
|
||||
...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "")
|
||||
? { status: "working" }
|
||||
: {}),
|
||||
...lifecycleMetadataUpdates(previousRaw, previousLifecycle),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2337,8 +2504,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
// 3. Validate restorability
|
||||
if (!isRestorable(session)) {
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
throw new SessionNotRestorableError(sessionId, `status is "${session.status}"`);
|
||||
if (session.lifecycle.session.state === "done") {
|
||||
throw new SessionNotRestorableError(
|
||||
sessionId,
|
||||
`session state is "${session.lifecycle.session.state}"`,
|
||||
);
|
||||
}
|
||||
throw new SessionNotRestorableError(sessionId, "session is not in a terminal state");
|
||||
}
|
||||
|
|
@ -2347,7 +2517,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
writeMetadata(sessionsDir, sessionId, {
|
||||
worktree: raw["worktree"] ?? "",
|
||||
branch: raw["branch"] ?? "",
|
||||
status: raw["status"] ?? "killed",
|
||||
status: raw["status"] ?? "terminated",
|
||||
stateVersion: raw["stateVersion"],
|
||||
statePayload: raw["statePayload"],
|
||||
role: raw["role"],
|
||||
tmuxName: raw["tmuxName"],
|
||||
issue: raw["issue"],
|
||||
|
|
|
|||
|
|
@ -24,10 +24,97 @@ import type { ObservabilityLevel } from "./observability.js";
|
|||
/** Unique session identifier, e.g. "my-app-1", "backend-12" */
|
||||
export type SessionId = string;
|
||||
|
||||
export type SessionKind = "worker" | "orchestrator";
|
||||
|
||||
export type CanonicalSessionState =
|
||||
| "not_started"
|
||||
| "working"
|
||||
| "idle"
|
||||
| "needs_input"
|
||||
| "stuck"
|
||||
| "detecting"
|
||||
| "done"
|
||||
| "terminated";
|
||||
|
||||
export type CanonicalSessionReason =
|
||||
| "spawn_requested"
|
||||
| "agent_acknowledged"
|
||||
| "task_in_progress"
|
||||
| "pr_created"
|
||||
| "pr_closed_waiting_decision"
|
||||
| "fixing_ci"
|
||||
| "resolving_review_comments"
|
||||
| "awaiting_user_input"
|
||||
| "awaiting_external_review"
|
||||
| "research_complete"
|
||||
| "merged_waiting_decision"
|
||||
| "manually_killed"
|
||||
| "runtime_lost"
|
||||
| "agent_process_exited"
|
||||
| "probe_failure"
|
||||
| "error_in_process";
|
||||
|
||||
export type CanonicalPRState = "none" | "open" | "merged" | "closed";
|
||||
|
||||
export type CanonicalPRReason =
|
||||
| "not_created"
|
||||
| "in_progress"
|
||||
| "ci_failing"
|
||||
| "review_pending"
|
||||
| "changes_requested"
|
||||
| "approved"
|
||||
| "merge_ready"
|
||||
| "merged"
|
||||
| "closed_unmerged";
|
||||
|
||||
export type CanonicalRuntimeState = "unknown" | "alive" | "exited" | "missing" | "probe_failed";
|
||||
|
||||
export type CanonicalRuntimeReason =
|
||||
| "spawn_incomplete"
|
||||
| "process_running"
|
||||
| "process_missing"
|
||||
| "tmux_missing"
|
||||
| "manual_kill_requested"
|
||||
| "probe_error";
|
||||
|
||||
export interface SessionStateRecord {
|
||||
kind: SessionKind;
|
||||
state: CanonicalSessionState;
|
||||
reason: CanonicalSessionReason;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
terminatedAt: string | null;
|
||||
lastTransitionAt: string;
|
||||
}
|
||||
|
||||
export interface PRStateRecord {
|
||||
state: CanonicalPRState;
|
||||
reason: CanonicalPRReason;
|
||||
number: number | null;
|
||||
url: string | null;
|
||||
lastObservedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeStateRecord {
|
||||
state: CanonicalRuntimeState;
|
||||
reason: CanonicalRuntimeReason;
|
||||
lastObservedAt: string | null;
|
||||
handle: RuntimeHandle | null;
|
||||
tmuxName: string | null;
|
||||
}
|
||||
|
||||
export interface CanonicalSessionLifecycle {
|
||||
version: 2;
|
||||
session: SessionStateRecord;
|
||||
pr: PRStateRecord;
|
||||
runtime: RuntimeStateRecord;
|
||||
}
|
||||
|
||||
/** Session lifecycle states */
|
||||
export type SessionStatus =
|
||||
| "spawning"
|
||||
| "working"
|
||||
| "detecting"
|
||||
| "pr_open"
|
||||
| "ci_failed"
|
||||
| "review_pending"
|
||||
|
|
@ -63,6 +150,23 @@ export const ACTIVITY_STATE = {
|
|||
EXITED: "exited" as const,
|
||||
} satisfies Record<string, ActivityState>;
|
||||
|
||||
export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure";
|
||||
|
||||
export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none";
|
||||
|
||||
export interface ActivitySignal {
|
||||
/** Confidence bucket for the activity probe result. */
|
||||
state: ActivitySignalState;
|
||||
/** The observed activity value, if one was surfaced. */
|
||||
activity: ActivityState | null;
|
||||
/** Timestamp that makes timing-based inferences safe, when available. */
|
||||
timestamp?: Date;
|
||||
/** Where the activity signal came from. */
|
||||
source: ActivitySignalSource;
|
||||
/** Optional extra detail for stale / failed probes. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Result of activity detection, carrying both the state and an optional timestamp. */
|
||||
export interface ActivityDetection {
|
||||
state: ActivityState;
|
||||
|
|
@ -92,6 +196,7 @@ export const DEFAULT_ACTIVE_WINDOW_MS = 30_000; // 30 seconds
|
|||
export const SESSION_STATUS = {
|
||||
SPAWNING: "spawning" as const,
|
||||
WORKING: "working" as const,
|
||||
DETECTING: "detecting" as const,
|
||||
PR_OPEN: "pr_open" as const,
|
||||
CI_FAILED: "ci_failed" as const,
|
||||
REVIEW_PENDING: "review_pending" as const,
|
||||
|
|
@ -125,11 +230,32 @@ export const TERMINAL_ACTIVITIES: ReadonlySet<ActivityState> = new Set(["exited"
|
|||
/** Statuses that must never be restored (e.g. already merged). */
|
||||
export const NON_RESTORABLE_STATUSES: ReadonlySet<SessionStatus> = new Set(["merged"]);
|
||||
|
||||
/** Check whether lifecycle metadata indicates the session's PR is already merged. */
|
||||
function hasMergedLifecyclePR(lifecycle: CanonicalSessionLifecycle): boolean {
|
||||
return (
|
||||
(
|
||||
lifecycle as CanonicalSessionLifecycle & {
|
||||
pr?: { state?: string | null } | null;
|
||||
}
|
||||
).pr?.state === "merged"
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a session is in a terminal (dead) state. */
|
||||
export function isTerminalSession(session: {
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
lifecycle?: CanonicalSessionLifecycle;
|
||||
}): boolean {
|
||||
if (session.lifecycle) {
|
||||
return (
|
||||
session.lifecycle.session.state === "done" ||
|
||||
session.lifecycle.session.state === "terminated" ||
|
||||
session.lifecycle.pr.state === "merged" ||
|
||||
session.lifecycle.runtime.state === "missing" ||
|
||||
session.lifecycle.runtime.state === "exited"
|
||||
);
|
||||
}
|
||||
return (
|
||||
TERMINAL_STATUSES.has(session.status) ||
|
||||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity))
|
||||
|
|
@ -140,7 +266,15 @@ export function isTerminalSession(session: {
|
|||
export function isRestorable(session: {
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
lifecycle?: CanonicalSessionLifecycle;
|
||||
}): boolean {
|
||||
if (session.lifecycle) {
|
||||
return (
|
||||
isTerminalSession(session) &&
|
||||
!NON_RESTORABLE_STATUSES.has(session.status) &&
|
||||
!hasMergedLifecyclePR(session.lifecycle)
|
||||
);
|
||||
}
|
||||
return isTerminalSession(session) && !NON_RESTORABLE_STATUSES.has(session.status);
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +292,12 @@ export interface Session {
|
|||
/** Activity state from agent plugin (null = not yet determined) */
|
||||
activity: ActivityState | null;
|
||||
|
||||
/** Explicit confidence/availability contract for the current activity signal. */
|
||||
activitySignal: ActivitySignal;
|
||||
|
||||
/** Canonical lifecycle truth persisted in metadata. */
|
||||
lifecycle: CanonicalSessionLifecycle;
|
||||
|
||||
/** Git branch name */
|
||||
branch: string | null;
|
||||
|
||||
|
|
@ -1393,6 +1533,8 @@ export interface SessionMetadata {
|
|||
worktree: string;
|
||||
branch: string;
|
||||
status: string;
|
||||
stateVersion?: string;
|
||||
statePayload?: string;
|
||||
tmuxName?: string; // Globally unique tmux session name (includes hash)
|
||||
issue?: string;
|
||||
pr?: string;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,46 @@ import type { PRInfo } from "../types.js";
|
|||
|
||||
export type ParsedPrUrl = Pick<PRInfo, "owner" | "repo" | "number" | "url">;
|
||||
|
||||
const GITHUB_PR_URL_REGEX = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/;
|
||||
const TRAILING_NUMBER_REGEX = /\/(\d+)$/;
|
||||
|
||||
export function parsePrFromUrl(prUrl: string): ParsedPrUrl | null {
|
||||
const githubMatch = prUrl.match(GITHUB_PR_URL_REGEX);
|
||||
if (githubMatch) {
|
||||
const [, owner, repo, prNumber] = githubMatch;
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
number: parseInt(prNumber, 10),
|
||||
url: prUrl,
|
||||
};
|
||||
const parsedUrl = tryParseUrl(prUrl);
|
||||
const pathSegments = parsedUrl?.pathname.split("/").filter(Boolean) ?? [];
|
||||
|
||||
const githubStylePullIndex = pathSegments.findIndex((segment) => segment === "pull");
|
||||
if (githubStylePullIndex >= 2 && githubStylePullIndex + 1 < pathSegments.length) {
|
||||
const owner = pathSegments[githubStylePullIndex - 2];
|
||||
const repo = pathSegments[githubStylePullIndex - 1];
|
||||
const prNumber = pathSegments[githubStylePullIndex + 1];
|
||||
if (owner && repo && prNumber && /^\d+$/.test(prNumber)) {
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
number: Number.parseInt(prNumber, 10),
|
||||
url: prUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const gitlabMergeRequestIndex = pathSegments.findIndex(
|
||||
(segment, index) =>
|
||||
segment === "-" &&
|
||||
pathSegments[index + 1] === "merge_requests" &&
|
||||
index >= 2 &&
|
||||
index + 2 < pathSegments.length,
|
||||
);
|
||||
if (gitlabMergeRequestIndex >= 2) {
|
||||
const owner = pathSegments[gitlabMergeRequestIndex - 2];
|
||||
const repo = pathSegments[gitlabMergeRequestIndex - 1];
|
||||
const prNumber = pathSegments[gitlabMergeRequestIndex + 2];
|
||||
if (owner && repo && prNumber && /^\d+$/.test(prNumber)) {
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
number: Number.parseInt(prNumber, 10),
|
||||
url: prUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trailingNumberMatch = prUrl.match(TRAILING_NUMBER_REGEX);
|
||||
|
|
@ -29,3 +56,11 @@ export function parsePrFromUrl(prUrl: string): ParsedPrUrl | null {
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryParseUrl(prUrl: string): URL | null {
|
||||
try {
|
||||
return new URL(prUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js";
|
||||
import type { ActivitySignal, RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js";
|
||||
import { deriveLegacyStatus, parseCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import { AGENT_REPORT_METADATA_KEYS } from "../agent-report.js";
|
||||
import { parsePrFromUrl } from "./pr.js";
|
||||
import { safeJsonParse, validateStatus } from "./validation.js";
|
||||
|
||||
|
|
@ -6,46 +9,76 @@ interface SessionFromMetadataOptions {
|
|||
projectId?: string;
|
||||
status?: SessionStatus;
|
||||
activity?: Session["activity"];
|
||||
activitySignal?: ActivitySignal;
|
||||
runtimeHandle?: RuntimeHandle | null;
|
||||
createdAt?: Date;
|
||||
lastActivityAt?: Date;
|
||||
restoredAt?: Date;
|
||||
}
|
||||
|
||||
function deriveDefaultActivitySignal(options: SessionFromMetadataOptions): ActivitySignal {
|
||||
if (options.activitySignal) {
|
||||
return options.activitySignal;
|
||||
}
|
||||
|
||||
if (options.activity === undefined || options.activity === null) {
|
||||
return createActivitySignal("unavailable");
|
||||
}
|
||||
|
||||
return createActivitySignal("valid", {
|
||||
activity: options.activity,
|
||||
timestamp: options.lastActivityAt,
|
||||
source: options.activity === "exited" ? "runtime" : "native",
|
||||
});
|
||||
}
|
||||
|
||||
export function sessionFromMetadata(
|
||||
sessionId: SessionId,
|
||||
meta: Record<string, string>,
|
||||
options: SessionFromMetadataOptions = {},
|
||||
): Session {
|
||||
const runtimeHandle =
|
||||
options.runtimeHandle !== undefined
|
||||
? options.runtimeHandle
|
||||
: meta["runtimeHandle"]
|
||||
? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"])
|
||||
: null;
|
||||
const lifecycle = parseCanonicalLifecycle(meta, {
|
||||
sessionId,
|
||||
status: options.status ?? validateStatus(meta["status"]),
|
||||
runtimeHandle,
|
||||
createdAt: options.createdAt,
|
||||
});
|
||||
const status = options.status ?? deriveLegacyStatus(lifecycle, validateStatus(meta["status"]));
|
||||
const prUrl = lifecycle.pr.url ?? meta["pr"];
|
||||
const prIsDraft = meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] === "true";
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
projectId: meta["project"] ?? options.projectId ?? "",
|
||||
status: options.status ?? validateStatus(meta["status"]),
|
||||
status,
|
||||
activity: options.activity ?? null,
|
||||
activitySignal: deriveDefaultActivitySignal(options),
|
||||
lifecycle,
|
||||
branch: meta["branch"] || null,
|
||||
issueId: meta["issue"] || null,
|
||||
pr: meta["pr"]
|
||||
pr: prUrl
|
||||
? (() => {
|
||||
const parsed = parsePrFromUrl(meta["pr"]);
|
||||
const parsed = parsePrFromUrl(prUrl);
|
||||
return {
|
||||
number: parsed?.number ?? 0,
|
||||
url: meta["pr"],
|
||||
number: lifecycle.pr.number ?? parsed?.number ?? 0,
|
||||
url: prUrl,
|
||||
title: "",
|
||||
owner: parsed?.owner ?? "",
|
||||
repo: parsed?.repo ?? "",
|
||||
branch: meta["branch"] ?? "",
|
||||
baseBranch: "",
|
||||
isDraft: false,
|
||||
isDraft: prIsDraft,
|
||||
};
|
||||
})()
|
||||
: null,
|
||||
workspacePath: meta["worktree"] || null,
|
||||
runtimeHandle:
|
||||
options.runtimeHandle !== undefined
|
||||
? options.runtimeHandle
|
||||
: meta["runtimeHandle"]
|
||||
? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"])
|
||||
: null,
|
||||
runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle,
|
||||
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,
|
||||
createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()),
|
||||
lastActivityAt: options.lastActivityAt ?? new Date(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { SessionId } from "../types.js";
|
||||
|
||||
export const SESSION_ID_COMPONENT_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function assertValidSessionIdComponent(
|
||||
sessionId: SessionId,
|
||||
context = "session ID",
|
||||
): void {
|
||||
if (!SESSION_ID_COMPONENT_PATTERN.test(sessionId)) {
|
||||
throw new Error(`Invalid ${context}: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type { SessionStatus } from "../types.js";
|
|||
const VALID_STATUSES: ReadonlySet<string> = new Set([
|
||||
"spawning",
|
||||
"working",
|
||||
"detecting",
|
||||
"pr_open",
|
||||
"ci_failed",
|
||||
"review_pending",
|
||||
|
|
@ -20,6 +21,7 @@ const VALID_STATUSES: ReadonlySet<string> = new Set([
|
|||
"stuck",
|
||||
"errored",
|
||||
"killed",
|
||||
"idle",
|
||||
"done",
|
||||
"terminated",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type {
|
||||
OrchestratorEvent,
|
||||
Session,
|
||||
EventPriority,
|
||||
EventType,
|
||||
SessionStatus,
|
||||
ActivityState,
|
||||
import {
|
||||
createActivitySignal,
|
||||
createInitialCanonicalLifecycle,
|
||||
type OrchestratorEvent,
|
||||
type Session,
|
||||
type EventPriority,
|
||||
type EventType,
|
||||
type SessionStatus,
|
||||
type ActivityState,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
/**
|
||||
|
|
@ -30,11 +32,23 @@ export function makeEvent(overrides: Partial<OrchestratorEvent> = {}): Orchestra
|
|||
* Override any field as needed.
|
||||
*/
|
||||
export function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-06-15T12:00:00Z"));
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working" as SessionStatus,
|
||||
activity: "active" as ActivityState,
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Factory helpers to build Session and RuntimeHandle objects for tests.
|
||||
*/
|
||||
|
||||
import type { RuntimeHandle, Session } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, createInitialCanonicalLifecycle, type RuntimeHandle, type Session } from "@aoagents/ao-core";
|
||||
|
||||
/** Build a tmux RuntimeHandle for a given session name. */
|
||||
export function makeTmuxHandle(sessionName: string): RuntimeHandle {
|
||||
|
|
@ -19,11 +19,24 @@ export function makeSession(
|
|||
handle: RuntimeHandle | null,
|
||||
workspacePath: string | null,
|
||||
): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = handle ? "alive" : "unknown";
|
||||
lifecycle.runtime.reason = handle ? "process_running" : "spawn_incomplete";
|
||||
lifecycle.runtime.handle = handle;
|
||||
return {
|
||||
id,
|
||||
projectId: "inttest",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
|
||||
// Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md)
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
|
|
@ -53,6 +53,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:
|
|||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { toClaudeProjectPath, create } from "../index.js";
|
||||
import type { Session, RuntimeHandle } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core";
|
||||
|
||||
// Mock homedir() so getActivityState looks in our temp dir
|
||||
vi.mock("node:os", async (importOriginal) => {
|
||||
|
|
@ -25,6 +25,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "main",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig, WorkspaceHooksConfig } from "@aoagents/ao-core";
|
||||
import {
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
type RuntimeHandle,
|
||||
type AgentLaunchConfig,
|
||||
type WorkspaceHooksConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks — available inside vi.mock factories
|
||||
|
|
@ -50,7 +56,13 @@ vi.mock("node:os", () => ({
|
|||
homedir: mockHomedir,
|
||||
}));
|
||||
|
||||
import { create, manifest, default as defaultExport, resetPsCache, METADATA_UPDATER_SCRIPT } from "./index.js";
|
||||
import {
|
||||
create,
|
||||
manifest,
|
||||
default as defaultExport,
|
||||
resetPsCache,
|
||||
METADATA_UPDATER_SCRIPT,
|
||||
} from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
|
|
@ -61,6 +73,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
@ -577,6 +594,17 @@ describe("getSessionInfo", () => {
|
|||
expect(result?.cost?.outputTokens).toBe(50);
|
||||
});
|
||||
|
||||
it("uses model-aware pricing when cached tokens are present", async () => {
|
||||
const jsonl = [
|
||||
'{"type":"assistant","model":"claude-sonnet-4-5","usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":10000,"cache_creation_input_tokens":2000}}',
|
||||
].join("\n");
|
||||
mockJsonlFiles(jsonl);
|
||||
const result = await agent.getSessionInfo(makeSession());
|
||||
expect(result?.cost?.inputTokens).toBe(13000);
|
||||
expect(result?.cost?.outputTokens).toBe(100);
|
||||
expect(result?.cost?.estimatedCostUsd).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses costUSD field when present", async () => {
|
||||
const jsonl = [
|
||||
'{"type":"user","message":{"content":"hi"}}',
|
||||
|
|
@ -720,12 +748,8 @@ describe("METADATA_UPDATER_SCRIPT content", () => {
|
|||
it("does NOT use ^-anchored regexes directly on $command for gh/git detection", () => {
|
||||
// The old buggy patterns matched $command with ^ anchor.
|
||||
// After the fix, ^ is still used but on $clean_command (which has cd stripped).
|
||||
expect(METADATA_UPDATER_SCRIPT).not.toMatch(
|
||||
/"\$command"\s*=~\s*\^gh/,
|
||||
);
|
||||
expect(METADATA_UPDATER_SCRIPT).not.toMatch(
|
||||
/"\$command"\s*=~\s*\^git/,
|
||||
);
|
||||
expect(METADATA_UPDATER_SCRIPT).not.toMatch(/"\$command"\s*=~\s*\^gh/);
|
||||
expect(METADATA_UPDATER_SCRIPT).not.toMatch(/"\$command"\s*=~\s*\^git/);
|
||||
});
|
||||
|
||||
it("strips cd prefixes with both && and ; delimiters", () => {
|
||||
|
|
@ -737,21 +761,15 @@ describe("METADATA_UPDATER_SCRIPT content", () => {
|
|||
});
|
||||
|
||||
it("detects gh pr create on clean_command", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(
|
||||
/"\$clean_command"\s*=~\s*\^gh\[/,
|
||||
);
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^gh\[/);
|
||||
});
|
||||
|
||||
it("detects git checkout -b on clean_command", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(
|
||||
/"\$clean_command"\s*=~\s*\^git\[.*checkout/,
|
||||
);
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^git\[.*checkout/);
|
||||
});
|
||||
|
||||
it("detects gh pr merge on clean_command", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(
|
||||
/"\$clean_command"\s*=~\s*\^gh\[.*merge/,
|
||||
);
|
||||
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^gh\[.*merge/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -307,8 +307,7 @@ async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise
|
|||
// Skip potentially truncated first line only when we started mid-file.
|
||||
// If offset === 0 we read from the start so the first line is complete.
|
||||
const firstNewline = content.indexOf("\n");
|
||||
const safeContent =
|
||||
offset > 0 && firstNewline >= 0 ? content.slice(firstNewline + 1) : content;
|
||||
const safeContent = offset > 0 && firstNewline >= 0 ? content.slice(firstNewline + 1) : content;
|
||||
const lines: JsonlLine[] = [];
|
||||
for (const line of safeContent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
|
@ -326,9 +325,7 @@ async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise
|
|||
}
|
||||
|
||||
/** Extract auto-generated summary from JSONL (last "summary" type entry) */
|
||||
function extractSummary(
|
||||
lines: JsonlLine[],
|
||||
): { summary: string; isFallback: boolean } | null {
|
||||
function extractSummary(lines: JsonlLine[]): { summary: string; isFallback: boolean } | null {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line?.type === "summary" && line.summary) {
|
||||
|
|
@ -358,6 +355,8 @@ function extractSummary(
|
|||
function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -373,8 +372,8 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
|
|||
// double-counting if a line contains both.
|
||||
if (line.usage) {
|
||||
inputTokens += line.usage.input_tokens ?? 0;
|
||||
inputTokens += line.usage.cache_read_input_tokens ?? 0;
|
||||
inputTokens += line.usage.cache_creation_input_tokens ?? 0;
|
||||
cachedReadTokens += line.usage.cache_read_input_tokens ?? 0;
|
||||
cacheCreationTokens += line.usage.cache_creation_input_tokens ?? 0;
|
||||
outputTokens += line.usage.output_tokens ?? 0;
|
||||
} else {
|
||||
if (typeof line.inputTokens === "number") {
|
||||
|
|
@ -386,19 +385,29 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
if (inputTokens === 0 && outputTokens === 0 && totalCost === 0) {
|
||||
if (
|
||||
inputTokens === 0 &&
|
||||
outputTokens === 0 &&
|
||||
totalCost === 0 &&
|
||||
cachedReadTokens === 0 &&
|
||||
cacheCreationTokens === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Rough estimate when no direct cost data — uses Sonnet 4.5 pricing as a
|
||||
// baseline. Will be inaccurate for other models (Opus, Haiku) but provides
|
||||
// a useful order-of-magnitude signal. TODO: make pricing configurable or
|
||||
// infer from model field in JSONL.
|
||||
if (totalCost === 0 && (inputTokens > 0 || outputTokens > 0)) {
|
||||
totalCost = (inputTokens / 1_000_000) * 3.0 + (outputTokens / 1_000_000) * 15.0;
|
||||
if (totalCost === 0) {
|
||||
totalCost =
|
||||
(inputTokens / 1_000_000) * 3.0 +
|
||||
(outputTokens / 1_000_000) * 15.0 +
|
||||
(cachedReadTokens / 1_000_000) * 0.3 +
|
||||
(cacheCreationTokens / 1_000_000) * 3.75;
|
||||
}
|
||||
|
||||
return { inputTokens, outputTokens, estimatedCostUsd: totalCost };
|
||||
return {
|
||||
inputTokens: inputTokens + cachedReadTokens + cacheCreationTokens,
|
||||
outputTokens,
|
||||
estimatedCostUsd: totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -819,7 +828,11 @@ function createClaudeCodeAgent(): Agent {
|
|||
const parts: string[] = ["claude", "--resume", shellEscape(sessionUuid)];
|
||||
|
||||
const permissionMode = normalizeAgentPermissionMode(project.agentConfig?.permissions);
|
||||
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
||||
const isOrchestrator = session.metadata?.["role"] === "orchestrator";
|
||||
if (
|
||||
isOrchestrator &&
|
||||
(permissionMode === "permissionless" || permissionMode === "auto-edit")
|
||||
) {
|
||||
parts.push("--dangerously-skip-permissions");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig, AgentSpecificConfig } from "@aoagents/ao-core";
|
||||
import {
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
type RuntimeHandle,
|
||||
type AgentLaunchConfig,
|
||||
type AgentSpecificConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks — available inside vi.mock factories
|
||||
|
|
@ -72,7 +78,13 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
});
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
import { create, manifest, default as defaultExport, resolveCodexBinary, _resetSessionFileCache } from "./index.js";
|
||||
import {
|
||||
create,
|
||||
manifest,
|
||||
default as defaultExport,
|
||||
resolveCodexBinary,
|
||||
_resetSessionFileCache,
|
||||
} from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
|
|
@ -83,6 +95,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
@ -145,15 +162,17 @@ function makeFakeFileHandle(content: string) {
|
|||
const buf = Buffer.from(content, "utf-8");
|
||||
let cursor = 0;
|
||||
return {
|
||||
read: vi.fn().mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => {
|
||||
if (cursor >= buf.length) {
|
||||
return Promise.resolve({ bytesRead: 0, buffer });
|
||||
}
|
||||
const bytesToCopy = Math.min(length, buf.length - cursor);
|
||||
buf.copy(buffer, offset, cursor, cursor + bytesToCopy);
|
||||
cursor += bytesToCopy;
|
||||
return Promise.resolve({ bytesRead: bytesToCopy, buffer });
|
||||
}),
|
||||
read: vi
|
||||
.fn()
|
||||
.mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => {
|
||||
if (cursor >= buf.length) {
|
||||
return Promise.resolve({ bytesRead: 0, buffer });
|
||||
}
|
||||
const bytesToCopy = Math.min(length, buf.length - cursor);
|
||||
buf.copy(buffer, offset, cursor, cursor + bytesToCopy);
|
||||
cursor += bytesToCopy;
|
||||
return Promise.resolve({ bytesRead: bytesToCopy, buffer });
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
|
@ -229,7 +248,9 @@ describe("getLaunchCommand", () => {
|
|||
const agent = create();
|
||||
|
||||
it("generates base command", () => {
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes bypass flag when permissions=permissionless", () => {
|
||||
|
|
@ -277,7 +298,9 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ permissions: "permissionless", model: "o3", prompt: "Go" }),
|
||||
);
|
||||
expect(cmd).toBe("'codex' -c check_for_update_on_startup=false --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'");
|
||||
expect(cmd).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
|
|
@ -286,9 +309,7 @@ describe("getLaunchCommand", () => {
|
|||
});
|
||||
|
||||
it("escapes dangerous characters in prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }),
|
||||
);
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }));
|
||||
// Single-quoted strings prevent shell expansion
|
||||
expect(cmd).toContain("-- '$(rm -rf /); `evil`; $HOME'");
|
||||
});
|
||||
|
|
@ -413,7 +434,7 @@ describe("getEnvironment", () => {
|
|||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("sets CODEX_DISABLE_UPDATE_CHECK=1 to suppress interactive update prompts", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["CODEX_DISABLE_UPDATE_CHECK"]).toBe("1");
|
||||
|
|
@ -594,12 +615,12 @@ describe("detectActivity", () => {
|
|||
it("returns waiting_input when permission prompt follows historical activity", () => {
|
||||
// Permission prompt at the bottom should NOT be overridden by historical
|
||||
// spinner/esc output higher in the buffer.
|
||||
expect(
|
||||
agent.detectActivity("✶ Writing files\nDone.\napproval required\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(
|
||||
agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(agent.detectActivity("✶ Writing files\nDone.\napproval required\n")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
expect(agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
});
|
||||
|
||||
// -- Active states --
|
||||
|
|
@ -658,7 +679,10 @@ describe("getActivityState", () => {
|
|||
it("returns null when process is running but no session file found", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -674,7 +698,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
|
|
@ -693,7 +720,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(staleTime),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("idle");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
|
|
@ -710,7 +740,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -726,7 +759,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
|
@ -742,7 +778,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
|
@ -761,7 +800,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -778,7 +820,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -796,7 +841,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
|
@ -813,7 +861,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
|
@ -830,22 +881,24 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("detects activity from payload-wrapped Codex session_meta files", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content =
|
||||
`${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-123",
|
||||
base_instructions: "x".repeat(8_000),
|
||||
},
|
||||
})}\n`;
|
||||
const content = `${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-123",
|
||||
base_instructions: "x".repeat(8_000),
|
||||
},
|
||||
})}\n`;
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
|
|
@ -854,7 +907,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
|
@ -866,15 +922,14 @@ describe("getActivityState", () => {
|
|||
// character will straddle a read boundary. Without StringDecoder,
|
||||
// the split character decodes to U+FFFD and JSON.parse fails.
|
||||
const padding = "日".repeat(3_000); // 9000 bytes of padding
|
||||
const content =
|
||||
`${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-utf8",
|
||||
base_instructions: padding,
|
||||
},
|
||||
})}\n`;
|
||||
const content = `${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-utf8",
|
||||
base_instructions: padding,
|
||||
},
|
||||
})}\n`;
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
|
|
@ -883,7 +938,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
// If UTF-8 boundary handling is broken, JSON.parse fails, cwd never
|
||||
// matches, no session file is selected, and state falls through to null.
|
||||
|
|
@ -936,14 +994,34 @@ describe("getSessionInfo", () => {
|
|||
const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" });
|
||||
setupMockOpen(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull();
|
||||
expect(
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns session info with cost and model when matching session found", async () => {
|
||||
const sessionContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
||||
{ type: "event_msg", msg: { type: "token_count", input_tokens: 1000, output_tokens: 500, cached_tokens: 200, reasoning_tokens: 100 } },
|
||||
{ type: "event_msg", msg: { type: "token_count", input_tokens: 2000, output_tokens: 300, cached_tokens: 0, reasoning_tokens: 0 } },
|
||||
{
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "token_count",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cached_tokens: 200,
|
||||
reasoning_tokens: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "token_count",
|
||||
input_tokens: 2000,
|
||||
output_tokens: 300,
|
||||
cached_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReaddir.mockResolvedValue(["session-123.jsonl"]);
|
||||
|
|
@ -959,10 +1037,10 @@ describe("getSessionInfo", () => {
|
|||
expect(result!.summary).toBe("Codex session (o3-mini)");
|
||||
expect(result!.summaryIsFallback).toBe(true);
|
||||
expect(result!.cost).toBeDefined();
|
||||
// cached_tokens/reasoning_tokens are subsets, not additive
|
||||
// input: 1000 + 2000 = 3000
|
||||
// cached tokens count toward effective input spend
|
||||
// input: 1000 + 2000 + 200 = 3200
|
||||
// output: 500 + 300 = 800
|
||||
expect(result!.cost!.inputTokens).toBe(3000);
|
||||
expect(result!.cost!.inputTokens).toBe(3200);
|
||||
expect(result!.cost!.outputTokens).toBe(800);
|
||||
expect(result!.cost!.estimatedCostUsd).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -1037,12 +1115,8 @@ describe("getSessionInfo", () => {
|
|||
});
|
||||
|
||||
it("picks the most recently modified matching session file", async () => {
|
||||
const oldContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const newContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3" },
|
||||
);
|
||||
const oldContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
const newContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3" });
|
||||
|
||||
mockReaddir.mockResolvedValue(["old-session.jsonl", "new-session.jsonl"]);
|
||||
mockOpen.mockImplementation(async (path: string) => {
|
||||
|
|
@ -1074,7 +1148,8 @@ describe("getSessionInfo", () => {
|
|||
});
|
||||
|
||||
it("handles corrupt/malformed JSONL lines gracefully", async () => {
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' +
|
||||
const content =
|
||||
'{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' +
|
||||
"not valid json\n" +
|
||||
'{"type":"event_msg","msg":{"type":"token_count","input_tokens":500,"output_tokens":200}}\n';
|
||||
|
||||
|
|
@ -1137,15 +1212,17 @@ describe("getSessionInfo", () => {
|
|||
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
||||
mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); });
|
||||
mockCreateReadStream.mockImplementation(() => {
|
||||
throw new Error("EACCES");
|
||||
});
|
||||
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull();
|
||||
expect(
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("skips session files when stat throws", async () => {
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1186,9 +1263,7 @@ describe("getSessionInfo", () => {
|
|||
mockLstat.mockResolvedValue({ isDirectory: () => true });
|
||||
// stat is used by findCodexSessionFile to get mtimeMs of matching JSONL files
|
||||
mockStat.mockResolvedValue({ mtimeMs: 2000 });
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" });
|
||||
setupMockOpen(content);
|
||||
setupMockStream(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1201,9 +1276,7 @@ describe("getSessionInfo", () => {
|
|||
|
||||
it("ignores non-JSONL files in sessions directory", async () => {
|
||||
mockReaddir.mockResolvedValue(["notes.txt", "config.json", "sess.jsonl"]);
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
setupMockOpen(content);
|
||||
setupMockStream(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1357,10 +1430,16 @@ describe("getRestoreCommand", () => {
|
|||
mockReadFile.mockResolvedValue(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "permissionless" },
|
||||
}));
|
||||
const session = makeSession({
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { role: "orchestrator" },
|
||||
});
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "permissionless" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
expect(cmd).not.toContain("--ask-for-approval");
|
||||
|
|
@ -1377,14 +1456,43 @@ describe("getRestoreCommand", () => {
|
|||
mockReadFile.mockResolvedValue(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
|
||||
}));
|
||||
const session = makeSession({
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { role: "orchestrator" },
|
||||
});
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
|
||||
it("demotes worker restore permissionless mode to ask-for-approval never", async () => {
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
{ threadId: "thread-1" },
|
||||
);
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
setupMockStream(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test", metadata: { role: "worker" } });
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "permissionless" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--ask-for-approval never");
|
||||
expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
|
||||
it("includes --ask-for-approval never from project config", async () => {
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
|
|
@ -1397,9 +1505,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--ask-for-approval never");
|
||||
expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
|
|
@ -1417,9 +1528,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "suggest" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "suggest" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--ask-for-approval untrusted");
|
||||
});
|
||||
|
|
@ -1436,9 +1550,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit", model: "o3-mini" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit", model: "o3-mini" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).not.toBeNull();
|
||||
// threadId should come after all flags
|
||||
|
|
@ -1461,9 +1578,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { model: "o3-mini" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { model: "o3-mini" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--model 'o3-mini'");
|
||||
expect(cmd).toContain("-c model_reasoning_effort=high");
|
||||
|
|
@ -1493,7 +1613,9 @@ describe("getRestoreCommand", () => {
|
|||
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
||||
// readFile (full parse) fails
|
||||
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
||||
mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); });
|
||||
mockCreateReadStream.mockImplementation(() => {
|
||||
throw new Error("EACCES");
|
||||
});
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
|
|
@ -1614,13 +1736,17 @@ describe("postLaunchSetup", () => {
|
|||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
// Before postLaunchSetup, binary is "codex"
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
|
||||
// After postLaunchSetup resolves the binary
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
|
||||
|
||||
// Now getLaunchCommand should use the resolved binary
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'/opt/bin/codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'/opt/bin/codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1731,7 +1857,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Second call for AGENTS.md — file doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
// AGENTS.md read attempt
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
|
|
@ -1746,7 +1872,9 @@ describe("setupWorkspaceHooks", () => {
|
|||
const wrapperWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" &&
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") || call[0].includes("/gh.tmp.") || call[0].includes("/git.tmp.")),
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") ||
|
||||
call[0].includes("/gh.tmp.") ||
|
||||
call[0].includes("/git.tmp.")),
|
||||
);
|
||||
expect(wrapperWrites).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -1765,7 +1893,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
|
||||
);
|
||||
expect(versionWriteCall).toBeDefined();
|
||||
expect(versionWriteCall![1]).toBe("0.2.0");
|
||||
expect(versionWriteCall![1]).toBe("0.3.0");
|
||||
|
||||
const versionRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
|
||||
|
|
@ -1777,7 +1905,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Version marker matches (skip wrapper install)
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1805,8 +1933,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Every wrapper file should be written to a .tmp file first, then renamed
|
||||
// This ensures concurrent readers never see a partially written file
|
||||
const tmpWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
(call: [string, string, object]) => typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
);
|
||||
const renames = mockRename.mock.calls;
|
||||
|
||||
|
|
@ -1824,7 +1951,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
it("writes .ao/AGENTS.md without modifying repo-tracked AGENTS.md", async () => {
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1862,10 +1989,9 @@ describe("shell wrapper content", () => {
|
|||
|
||||
// With atomic writes, content is written to a .tmp. file
|
||||
const call = mockWriteFile.mock.calls.find(
|
||||
(c: [string, string, object]) =>
|
||||
typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
(c: [string, string, object]) => typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
);
|
||||
return call ? call[1] as string : "";
|
||||
return call ? (call[1] as string) : "";
|
||||
}
|
||||
|
||||
describe("metadata helper", () => {
|
||||
|
|
@ -1923,9 +2049,10 @@ describe("shell wrapper content", () => {
|
|||
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
|
||||
});
|
||||
|
||||
it("only captures output for pr/create and pr/merge", async () => {
|
||||
it("only captures output for pr/create", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("pr/create|pr/merge");
|
||||
expect(content).toContain('case "$1/$2" in');
|
||||
expect(content).toContain("pr/create)");
|
||||
});
|
||||
|
||||
it("uses exec for non-PR commands (transparent passthrough)", async () => {
|
||||
|
|
@ -1947,13 +2074,17 @@ describe("shell wrapper content", () => {
|
|||
|
||||
it("extracts PR URL from gh pr create output", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("https://github");
|
||||
expect(content).toContain(
|
||||
"grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+'",
|
||||
);
|
||||
expect(content).toContain("update_ao_metadata pr");
|
||||
});
|
||||
|
||||
it("updates status to merged on gh pr merge", async () => {
|
||||
it("records agent-reported PR metadata on gh pr create", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("update_ao_metadata status merged");
|
||||
expect(content).toContain("update_ao_metadata agentReportedState");
|
||||
expect(content).toContain("update_ao_metadata agentReportedPrUrl");
|
||||
expect(content).toContain("update_ao_metadata agentReportedPrIsDraft");
|
||||
});
|
||||
|
||||
it("cleans up temp file on exit", async () => {
|
||||
|
|
|
|||
|
|
@ -185,10 +185,7 @@ async function readJsonlPrefixLines(filePath: string, maxLines: number): Promise
|
|||
* session_meta entry matching the given workspace path. This avoids parsing a
|
||||
* truncated session_meta line when Codex embeds large base_instructions.
|
||||
*/
|
||||
async function sessionFileMatchesCwd(
|
||||
filePath: string,
|
||||
workspacePath: string,
|
||||
): Promise<boolean> {
|
||||
async function sessionFileMatchesCwd(filePath: string, workspacePath: string): Promise<boolean> {
|
||||
try {
|
||||
const lines = await readJsonlPrefixLines(filePath, SESSION_MATCH_SCAN_LINE_LIMIT);
|
||||
for (const line of lines) {
|
||||
|
|
@ -245,6 +242,8 @@ interface CodexSessionData {
|
|||
threadId: string | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedTokens: number;
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -254,7 +253,14 @@ interface CodexSessionData {
|
|||
*/
|
||||
async function streamCodexSessionData(filePath: string): Promise<CodexSessionData | null> {
|
||||
try {
|
||||
const data: CodexSessionData = { model: null, threadId: null, inputTokens: 0, outputTokens: 0 };
|
||||
const data: CodexSessionData = {
|
||||
model: null,
|
||||
threadId: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
};
|
||||
const rl = createInterface({
|
||||
input: createReadStream(filePath, { encoding: "utf-8" }),
|
||||
crlfDelay: Infinity,
|
||||
|
|
@ -319,6 +325,8 @@ async function streamCodexSessionData(filePath: string): Promise<CodexSessionDat
|
|||
if (entry.type === "event_msg" && entry.msg?.type === "token_count") {
|
||||
data.inputTokens += entry.msg.input_tokens ?? 0;
|
||||
data.outputTokens += entry.msg.output_tokens ?? 0;
|
||||
data.cachedTokens += entry.msg.cached_tokens ?? 0;
|
||||
data.reasoningTokens += entry.msg.reasoning_tokens ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
|
|
@ -377,10 +385,18 @@ export async function resolveCodexBinary(): Promise<string> {
|
|||
// =============================================================================
|
||||
|
||||
/** Append approval-policy flags to a command parts array */
|
||||
function appendApprovalFlags(parts: string[], permissions: string | undefined): void {
|
||||
function appendApprovalFlags(
|
||||
parts: string[],
|
||||
permissions: string | undefined,
|
||||
allowDangerousBypass = true,
|
||||
): void {
|
||||
const mode = normalizeAgentPermissionMode(permissions);
|
||||
if (mode === "permissionless") {
|
||||
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (allowDangerousBypass) {
|
||||
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
} else {
|
||||
parts.push("--ask-for-approval", "never");
|
||||
}
|
||||
} else if (mode === "auto-edit") {
|
||||
parts.push("--ask-for-approval", "never");
|
||||
} else if (mode === "suggest") {
|
||||
|
|
@ -421,7 +437,10 @@ async function findCodexSessionFileCached(workspacePath: string): Promise<string
|
|||
return cached.path;
|
||||
}
|
||||
const result = await findCodexSessionFile(workspacePath);
|
||||
sessionFileCache.set(workspacePath, { path: result, expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS });
|
||||
sessionFileCache.set(workspacePath, {
|
||||
path: result,
|
||||
expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -498,7 +517,10 @@ function createCodexAgent(): Agent {
|
|||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session, readyThresholdMs?: number): Promise<ActivityDetection | null> {
|
||||
async getActivityState(
|
||||
session: Session,
|
||||
readyThresholdMs?: number,
|
||||
): Promise<ActivityDetection | null> {
|
||||
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
||||
|
||||
// Check if process is running first
|
||||
|
|
@ -670,15 +692,19 @@ function createCodexAgent(): Agent {
|
|||
|
||||
const agentSessionId = basename(sessionFile, ".jsonl");
|
||||
|
||||
const cost: CostEstimate | undefined =
|
||||
data.inputTokens === 0 && data.outputTokens === 0
|
||||
? undefined
|
||||
: {
|
||||
inputTokens: data.inputTokens,
|
||||
outputTokens: data.outputTokens,
|
||||
estimatedCostUsd:
|
||||
(data.inputTokens / 1_000_000) * 2.5 + (data.outputTokens / 1_000_000) * 10.0,
|
||||
};
|
||||
let cost: CostEstimate | undefined;
|
||||
const totalInputTokens = data.inputTokens + data.cachedTokens;
|
||||
if (totalInputTokens > 0 || data.outputTokens > 0 || data.reasoningTokens > 0) {
|
||||
const estimatedCostUsd =
|
||||
(data.inputTokens / 1_000_000) * 2.5 +
|
||||
(data.cachedTokens / 1_000_000) * 0.625 +
|
||||
((data.outputTokens + data.reasoningTokens) / 1_000_000) * 10.0;
|
||||
cost = {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: data.outputTokens,
|
||||
estimatedCostUsd,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: data.model ? `Codex session (${data.model})` : null,
|
||||
|
|
@ -707,7 +733,8 @@ function createCodexAgent(): Agent {
|
|||
const parts: string[] = [shellEscape(binary), "resume"];
|
||||
appendNoUpdateCheckFlag(parts);
|
||||
|
||||
appendApprovalFlags(parts, project.agentConfig?.permissions);
|
||||
const isOrchestrator = session.metadata?.["role"] === "orchestrator";
|
||||
appendApprovalFlags(parts, project.agentConfig?.permissions, isOrchestrator);
|
||||
const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined;
|
||||
appendModelFlags(parts, effectiveModel ?? undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
|
||||
// Mock fs/promises for getSessionInfo tests
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
|
|
@ -73,6 +73,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
|
||||
|
||||
const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } =
|
||||
vi.hoisted(() => ({
|
||||
|
|
@ -42,6 +42,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ vi.mock("node:child_process", () => {
|
|||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { PRInfo, SCMWebhookRequest, Session, ProjectConfig } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
|
@ -46,6 +46,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/my-feature",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
@ -423,6 +428,12 @@ describe("scm-github plugin", () => {
|
|||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when project has no repo configured", async () => {
|
||||
const result = await scm.detectPR(makeSession(), { ...project, repo: undefined });
|
||||
expect(result).toBeNull();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null on gh CLI error", async () => {
|
||||
mockGhError("gh: not found");
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ vi.mock("node:child_process", () => {
|
|||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { PRInfo, Session, ProjectConfig, SCMWebhookRequest } from "@aoagents/ao-core";
|
||||
import { createActivitySignal, type PRInfo, type Session, type ProjectConfig, type SCMWebhookRequest } from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
|
@ -44,6 +44,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
projectId: "test",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
branch: "feat/my-feature",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
@ -351,6 +356,12 @@ describe("scm-gitlab plugin", () => {
|
|||
expect(glabMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when project has no repo configured", async () => {
|
||||
const result = await scm.detectPR(makeSession(), { ...project, repo: undefined });
|
||||
expect(result).toBeNull();
|
||||
expect(glabMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null and warns on glab CLI error", async () => {
|
||||
mockGlabError("glab: not found");
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
import type { Session } from "@aoagents/ao-core";
|
||||
import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
|
|
@ -17,11 +17,23 @@ const mockExecFile = execFile as unknown as Mock;
|
|||
const mockPlatform = platform as unknown as Mock;
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session } from "@aoagents/ao-core";
|
||||
import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const nextConfig = {
|
|||
transpilePackages: [
|
||||
"@aoagents/ao-core",
|
||||
"@aoagents/ao-plugin-agent-claude-code",
|
||||
"@aoagents/ao-plugin-agent-codex",
|
||||
"@aoagents/ao-plugin-agent-opencode",
|
||||
"@aoagents/ao-plugin-runtime-tmux",
|
||||
"@aoagents/ao-plugin-scm-github",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"dependencies": {
|
||||
"@aoagents/ao-core": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-claude-code": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-codex": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-cursor": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@aoagents/ao-plugin-runtime-tmux": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { NextRequest } from "next/server";
|
|||
import {
|
||||
SessionNotFoundError,
|
||||
SessionNotRestorableError,
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -16,10 +18,22 @@ import { getSCM } from "@/lib/services";
|
|||
// Provides test sessions covering the key states the dashboard needs.
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id: string }): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date());
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return {
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -245,29 +245,72 @@ describe("getAttentionLevel", () => {
|
|||
describe("done zone", () => {
|
||||
it("returns done when PR is merged", () => {
|
||||
const pr = makePR({ state: "merged" });
|
||||
const session = makeSession({ status: "merged", activity: "exited", pr });
|
||||
const session = makeSession({
|
||||
status: "merged",
|
||||
activity: "exited",
|
||||
pr,
|
||||
lifecycle: {
|
||||
...makeSession().lifecycle!,
|
||||
sessionState: "idle",
|
||||
sessionReason: "merged_waiting_decision",
|
||||
prState: "merged",
|
||||
prReason: "merged",
|
||||
},
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when PR is closed (not merged)", () => {
|
||||
const pr = makePR({ state: "closed" });
|
||||
const session = makeSession({ status: "working", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
it("returns pending when the PR is closed without merge but runtime is still alive", () => {
|
||||
const pr = makePR({
|
||||
state: "closed",
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({
|
||||
status: "idle",
|
||||
activity: "idle",
|
||||
pr,
|
||||
lifecycle: {
|
||||
...makeSession().lifecycle!,
|
||||
sessionState: "idle",
|
||||
sessionReason: "pr_closed_waiting_decision",
|
||||
prState: "closed",
|
||||
prReason: "closed_unmerged",
|
||||
runtimeState: "alive",
|
||||
},
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns done when session status is merged (even with open PR state)", () => {
|
||||
const pr = makePR({ state: "merged" });
|
||||
const session = makeSession({ status: "merged", activity: "idle", pr });
|
||||
const session = makeSession({ status: "merged", activity: "idle", pr, lifecycle: undefined });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when session is killed", () => {
|
||||
const session = makeSession({ status: "killed", activity: "exited", pr: null });
|
||||
const session = makeSession({
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
pr: null,
|
||||
lifecycle: undefined,
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when agent has exited with cleanup status", () => {
|
||||
const session = makeSession({ status: "cleanup", activity: "exited", pr: null });
|
||||
const session = makeSession({
|
||||
status: "cleanup",
|
||||
activity: "exited",
|
||||
pr: null,
|
||||
lifecycle: undefined,
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,228 @@
|
|||
import type { DashboardSession, DashboardPR } from "@/lib/types";
|
||||
import type {
|
||||
DashboardActivitySignal,
|
||||
DashboardLifecycle,
|
||||
DashboardPR,
|
||||
DashboardSession,
|
||||
} from "@/lib/types";
|
||||
|
||||
function humanize(token: string): string {
|
||||
return token.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function deriveActivitySignal(session: DashboardSession): DashboardActivitySignal {
|
||||
if (session.activitySignal) {
|
||||
return session.activitySignal;
|
||||
}
|
||||
|
||||
if (session.activity === "exited") {
|
||||
return {
|
||||
state: "valid",
|
||||
activity: "exited",
|
||||
timestamp: session.lastActivityAt,
|
||||
source: "runtime",
|
||||
};
|
||||
}
|
||||
|
||||
if (session.activity === null) {
|
||||
return {
|
||||
state: "unavailable",
|
||||
activity: null,
|
||||
timestamp: null,
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
activity: session.activity,
|
||||
timestamp: session.lastActivityAt,
|
||||
source: "native",
|
||||
};
|
||||
}
|
||||
|
||||
function deriveLifecycle(session: DashboardSession): DashboardLifecycle {
|
||||
const isRuntimeEnded =
|
||||
session.activity === "exited" ||
|
||||
session.status === "killed" ||
|
||||
session.status === "terminated" ||
|
||||
session.status === "cleanup" ||
|
||||
session.status === "done" ||
|
||||
session.status === "merged";
|
||||
|
||||
const sessionFacet = (() => {
|
||||
switch (session.status) {
|
||||
case "spawning":
|
||||
return { state: "not_started", reason: "spawn_requested" };
|
||||
case "detecting":
|
||||
return { state: "detecting", reason: "probe_failure" };
|
||||
case "needs_input":
|
||||
return { state: "needs_input", reason: "awaiting_user_input" };
|
||||
case "stuck":
|
||||
case "errored":
|
||||
return { state: "stuck", reason: "probe_failure" };
|
||||
case "done":
|
||||
return { state: "done", reason: "research_complete" };
|
||||
case "merged":
|
||||
return { state: "done", reason: "merged_waiting_decision" };
|
||||
case "cleanup":
|
||||
return { state: "terminated", reason: "agent_process_exited" };
|
||||
case "killed":
|
||||
return { state: "terminated", reason: "manually_killed" };
|
||||
case "terminated":
|
||||
return { state: "terminated", reason: "runtime_lost" };
|
||||
case "pr_open":
|
||||
return { state: "working", reason: "pr_created" };
|
||||
case "ci_failed":
|
||||
return { state: "working", reason: "fixing_ci" };
|
||||
case "changes_requested":
|
||||
return { state: "working", reason: "resolving_review_comments" };
|
||||
case "review_pending":
|
||||
case "approved":
|
||||
case "mergeable":
|
||||
case "idle":
|
||||
return { state: "idle", reason: "awaiting_external_review" };
|
||||
case "working":
|
||||
default:
|
||||
return { state: "working", reason: "task_in_progress" };
|
||||
}
|
||||
})();
|
||||
|
||||
const prFacet = (() => {
|
||||
if (session.pr?.state === "merged" || session.status === "merged") {
|
||||
return { state: "merged", reason: "merged" };
|
||||
}
|
||||
if (session.pr?.state === "closed") {
|
||||
return { state: "closed", reason: "closed_unmerged" };
|
||||
}
|
||||
if (session.pr) {
|
||||
if (session.pr.isDraft) {
|
||||
return { state: "open", reason: "in_progress" };
|
||||
}
|
||||
if (session.pr.ciStatus === "failing") {
|
||||
return { state: "open", reason: "ci_failing" };
|
||||
}
|
||||
if (session.pr.reviewDecision === "changes_requested") {
|
||||
return { state: "open", reason: "changes_requested" };
|
||||
}
|
||||
if (session.pr.unresolvedThreads > 0) {
|
||||
return { state: "open", reason: "review_pending" };
|
||||
}
|
||||
if (session.pr.reviewDecision === "approved") {
|
||||
const ready =
|
||||
session.pr.mergeability.mergeable &&
|
||||
session.pr.mergeability.ciPassing &&
|
||||
session.pr.mergeability.approved &&
|
||||
session.pr.mergeability.noConflicts;
|
||||
return { state: "open", reason: ready ? "merge_ready" : "in_progress" };
|
||||
}
|
||||
if (session.pr.reviewDecision === "pending" || session.pr.reviewDecision === "none") {
|
||||
return { state: "open", reason: "review_pending" };
|
||||
}
|
||||
return { state: "open", reason: "in_progress" };
|
||||
}
|
||||
return { state: "none", reason: "not_created" };
|
||||
})();
|
||||
|
||||
const runtimeFacet = (() => {
|
||||
if (session.status === "detecting") {
|
||||
return { state: "probe_failed", reason: "probe_error" };
|
||||
}
|
||||
if (session.status === "killed") {
|
||||
return { state: "missing", reason: "manual_kill_requested" };
|
||||
}
|
||||
if (session.status === "terminated") {
|
||||
return { state: "missing", reason: "process_missing" };
|
||||
}
|
||||
if (isRuntimeEnded) {
|
||||
return { state: "exited", reason: "process_missing" };
|
||||
}
|
||||
if (session.status === "spawning") {
|
||||
return { state: "unknown", reason: "spawn_incomplete" };
|
||||
}
|
||||
return { state: "alive", reason: "process_running" };
|
||||
})();
|
||||
|
||||
return {
|
||||
sessionState: sessionFacet.state,
|
||||
sessionReason: sessionFacet.reason,
|
||||
prState: prFacet.state,
|
||||
prReason: prFacet.reason,
|
||||
runtimeState: runtimeFacet.state,
|
||||
runtimeReason: runtimeFacet.reason,
|
||||
session: {
|
||||
state: sessionFacet.state,
|
||||
reason: sessionFacet.reason,
|
||||
label: humanize(sessionFacet.state),
|
||||
reasonLabel: humanize(sessionFacet.reason),
|
||||
startedAt: session.createdAt,
|
||||
completedAt: sessionFacet.state === "done" ? session.lastActivityAt : null,
|
||||
terminatedAt: sessionFacet.state === "terminated" ? session.lastActivityAt : null,
|
||||
lastTransitionAt: session.lastActivityAt,
|
||||
},
|
||||
pr: {
|
||||
state: prFacet.state,
|
||||
reason: prFacet.reason,
|
||||
label: humanize(prFacet.state),
|
||||
reasonLabel: humanize(prFacet.reason),
|
||||
number: session.pr?.number ?? null,
|
||||
url: session.pr?.url ?? null,
|
||||
lastObservedAt: session.lastActivityAt,
|
||||
},
|
||||
runtime: {
|
||||
state: runtimeFacet.state,
|
||||
reason: runtimeFacet.reason,
|
||||
label: humanize(runtimeFacet.state),
|
||||
reasonLabel: humanize(runtimeFacet.reason),
|
||||
lastObservedAt: session.lastActivityAt,
|
||||
},
|
||||
legacyStatus: session.status,
|
||||
evidence: null,
|
||||
detectingAttempts: session.status === "detecting" ? 1 : 0,
|
||||
detectingEscalatedAt: null,
|
||||
summary: `Session ${humanize(sessionFacet.state)}, PR ${humanize(prFacet.state)}, runtime ${humanize(runtimeFacet.state)}`,
|
||||
guidance: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a minimal mock session with overrides */
|
||||
export function makeSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
|
||||
return {
|
||||
const baseSession: DashboardSession = {
|
||||
id: "test-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: {
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "native",
|
||||
},
|
||||
lifecycle: undefined,
|
||||
branch: "feat/test",
|
||||
issueId: "https://linear.app/test/issue/INT-100",
|
||||
issueUrl: "https://linear.app/test/issue/INT-100",
|
||||
issueLabel: "INT-100",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: null,
|
||||
issueTitle: null,
|
||||
userPrompt: null,
|
||||
summary: "Test session",
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
agentReportAudit: [],
|
||||
};
|
||||
|
||||
const session = {
|
||||
...baseSession,
|
||||
...overrides,
|
||||
issueLabel: overrides.issueLabel ?? overrides.issueId ?? baseSession.issueLabel,
|
||||
} satisfies DashboardSession;
|
||||
|
||||
return {
|
||||
...session,
|
||||
activitySignal: overrides.activitySignal ?? deriveActivitySignal(session),
|
||||
lifecycle: overrides.lifecycle ?? deriveLifecycle(session),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,22 +14,36 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
Session,
|
||||
SessionManager,
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
SCM,
|
||||
import {
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type SCM,
|
||||
} from "@aoagents/ao-core";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
|
||||
// ── Shared test fixtures ──────────────────────────────────────────────
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id: string }): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = lifecycle.session.lastTransitionAt;
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
return {
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const {
|
|||
mockRegistry,
|
||||
tmuxPlugin,
|
||||
claudePlugin,
|
||||
codexPlugin,
|
||||
opencodePlugin,
|
||||
worktreePlugin,
|
||||
scmPlugin,
|
||||
|
|
@ -31,6 +32,7 @@ const {
|
|||
mockRegistry,
|
||||
tmuxPlugin: { manifest: { name: "tmux" } },
|
||||
claudePlugin: { manifest: { name: "claude-code" } },
|
||||
codexPlugin: { manifest: { name: "codex" } },
|
||||
opencodePlugin: { manifest: { name: "opencode" } },
|
||||
worktreePlugin: { manifest: { name: "worktree" } },
|
||||
scmPlugin: { manifest: { name: "github" } },
|
||||
|
|
@ -54,6 +56,7 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
|
||||
vi.mock("@aoagents/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin }));
|
||||
vi.mock("@aoagents/ao-plugin-agent-claude-code", () => ({ default: claudePlugin }));
|
||||
vi.mock("@aoagents/ao-plugin-agent-codex", () => ({ default: codexPlugin }));
|
||||
vi.mock("@aoagents/ao-plugin-agent-opencode", () => ({ default: opencodePlugin }));
|
||||
vi.mock("@aoagents/ao-plugin-workspace-worktree", () => ({ default: worktreePlugin }));
|
||||
vi.mock("@aoagents/ao-plugin-scm-github", () => ({ default: scmPlugin }));
|
||||
|
|
@ -94,6 +97,14 @@ describe("services", () => {
|
|||
expect(mockRegister).toHaveBeenCalledWith(opencodePlugin);
|
||||
});
|
||||
|
||||
it("registers the Codex agent plugin with web services", async () => {
|
||||
const { getServices } = await import("../lib/services");
|
||||
|
||||
await getServices();
|
||||
|
||||
expect(mockRegister).toHaveBeenCalledWith(codexPlugin);
|
||||
});
|
||||
|
||||
it("caches initialized services across repeated calls", async () => {
|
||||
const { getServices } = await import("../lib/services");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { type NextRequest } from "next/server";
|
||||
import { getSessionsDir, readAgentReportAuditTrailAsync } from "@aoagents/ao-core";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import {
|
||||
sessionToDashboard,
|
||||
|
|
@ -21,13 +22,20 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||
}
|
||||
|
||||
const dashboardSession = sessionToDashboard(coreSession);
|
||||
const project = resolveProject(coreSession, config.projects);
|
||||
if (project) {
|
||||
const sessionsDir = getSessionsDir(config.configPath, project.path);
|
||||
dashboardSession.agentReportAudit = await readAgentReportAuditTrailAsync(
|
||||
sessionsDir,
|
||||
coreSession.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich metadata (issue labels, agent summaries, issue titles)
|
||||
await enrichSessionsMetadata([coreSession], [dashboardSession], config, registry);
|
||||
|
||||
// Enrich PR — serve cache immediately, refresh in background if stale
|
||||
if (coreSession.pr) {
|
||||
const project = resolveProject(coreSession, config.projects);
|
||||
const scm = getSCM(registry, project);
|
||||
if (scm) {
|
||||
const cached = await enrichSessionPR(dashboardSession, scm, coreSession.pr, {
|
||||
|
|
|
|||
|
|
@ -1841,6 +1841,42 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.session-detail-tone--working {
|
||||
color: var(--color-status-working);
|
||||
background: var(--color-status-working);
|
||||
}
|
||||
|
||||
.session-detail-tone--ready {
|
||||
color: var(--color-status-ready);
|
||||
background: var(--color-status-ready);
|
||||
}
|
||||
|
||||
.session-detail-tone--idle {
|
||||
color: var(--color-status-idle);
|
||||
background: var(--color-status-idle);
|
||||
}
|
||||
|
||||
.session-detail-tone--attention {
|
||||
color: var(--color-status-attention);
|
||||
background: var(--color-status-attention);
|
||||
}
|
||||
|
||||
.session-detail-tone--error {
|
||||
color: var(--color-status-error);
|
||||
background: var(--color-status-error);
|
||||
}
|
||||
|
||||
.session-detail-tone--muted {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.session-detail-tone--accent {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.session-detail-status-pill--active {
|
||||
|
|
@ -1939,6 +1975,69 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-height--worker {
|
||||
height: clamp(380px, 48vh, 560px);
|
||||
}
|
||||
|
||||
.session-detail-height--orchestrator {
|
||||
height: clamp(400px, 52vh, 620px);
|
||||
}
|
||||
|
||||
.session-detail-height--full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill__label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--merge {
|
||||
color: var(--color-status-ready);
|
||||
background: color-mix(in srgb, var(--color-status-ready) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--respond {
|
||||
color: var(--color-status-error);
|
||||
background: color-mix(in srgb, var(--color-status-error) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--review {
|
||||
color: var(--color-accent-orange);
|
||||
background: color-mix(in srgb, var(--color-accent-orange) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--working {
|
||||
color: var(--color-accent-blue);
|
||||
background: color-mix(in srgb, var(--color-accent-blue) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--pending {
|
||||
color: var(--color-status-attention);
|
||||
background: color-mix(in srgb, var(--color-status-attention) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--done {
|
||||
color: var(--color-text-tertiary);
|
||||
background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ── Session cards — Emil design vision ──────────────────────────────── */
|
||||
|
||||
.session-card {
|
||||
|
|
@ -5012,6 +5111,31 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.session-detail__status-pill--active {
|
||||
color: var(--color-status-working);
|
||||
background: color-mix(in srgb, var(--color-status-working) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--ready {
|
||||
color: var(--color-status-ready);
|
||||
background: color-mix(in srgb, var(--color-status-ready) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--waiting {
|
||||
color: var(--color-status-attention);
|
||||
background: color-mix(in srgb, var(--color-status-attention) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--error {
|
||||
color: var(--color-status-error);
|
||||
background: color-mix(in srgb, var(--color-status-error) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--idle {
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__time {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
|
|
@ -5094,6 +5218,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--neutral {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--pass {
|
||||
background: var(--color-accent-green);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--fail {
|
||||
background: var(--color-accent-red);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--pending {
|
||||
background: var(--color-status-attention);
|
||||
}
|
||||
|
||||
/* ── Mobile PR Dense Rows enhancements ─────────────────────────────── */
|
||||
|
||||
.mobile-pr-mobile-layout {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Geist, JetBrains_Mono } from "next/font/google";
|
||||
import { getProjectName } from "@/lib/project-name";
|
||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||
|
|
@ -44,9 +45,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`} suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<ServiceWorkerRegistrar />
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function makeWorkerSession(): DashboardSession {
|
|||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
agentReportAudit: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isPRRateLimited,
|
||||
isPRMergeReady,
|
||||
isPRUnenriched,
|
||||
isDashboardSessionRestorable,
|
||||
} from "@/lib/types";
|
||||
import { AttentionZone } from "./AttentionZone";
|
||||
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
|
||||
|
|
@ -107,8 +108,12 @@ function DoneCard({
|
|||
session.issueTitle ||
|
||||
session.summary ||
|
||||
session.id;
|
||||
const isMerged = session.pr?.state === "merged";
|
||||
const isTerminated = session.status === "killed" || session.status === "terminated";
|
||||
const isMerged = session.lifecycle?.prState === "merged" || session.pr?.state === "merged";
|
||||
const isTerminated =
|
||||
session.lifecycle?.sessionState === "terminated" ||
|
||||
session.status === "killed" ||
|
||||
session.status === "terminated";
|
||||
const isRestorable = isDashboardSessionRestorable(session);
|
||||
const badgeLabel = isMerged ? "merged" : isTerminated ? "terminated" : "done";
|
||||
const badgeClass = `done-card__badge ${isTerminated ? "done-card__badge--terminated" : "done-card__badge--merged"}`;
|
||||
|
||||
|
|
@ -125,7 +130,14 @@ function DoneCard({
|
|||
className="done-card__pr"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M6 9v3a6 6 0 0 0 6 6h3" />
|
||||
|
|
@ -134,16 +146,18 @@ function DoneCard({
|
|||
</a>
|
||||
) : null}
|
||||
<span className="done-card__age">{formatRelativeTimeCompact(session.lastActivityAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="done-card__restore"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore(session.id);
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
{isRestorable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="done-card__restore"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore(session.id);
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -160,7 +174,11 @@ function getCiPillClass(pr: DashboardPR): string {
|
|||
|
||||
function getCiLabel(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "";
|
||||
return pr.ciStatus === "passing" ? "CI passing" : pr.ciStatus === "failing" ? "CI failed" : "CI pending";
|
||||
return pr.ciStatus === "passing"
|
||||
? "CI passing"
|
||||
: pr.ciStatus === "failing"
|
||||
? "CI failed"
|
||||
: "CI pending";
|
||||
}
|
||||
|
||||
function getReviewPillClass(pr: DashboardPR): string {
|
||||
|
|
@ -198,11 +216,7 @@ function MobileFeedCard({
|
|||
const pr = session.pr;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-feed-card"
|
||||
onClick={() => onTap(session)}
|
||||
>
|
||||
<button type="button" className="mobile-feed-card" onClick={() => onTap(session)}>
|
||||
<div className="mobile-feed-card__strip" data-level={level} />
|
||||
<div className="mobile-feed-card__content">
|
||||
<div className="mobile-feed-card__header">
|
||||
|
|
@ -216,13 +230,10 @@ function MobileFeedCard({
|
|||
{session.branch ? (
|
||||
<span className="mobile-feed-card__branch">{session.branch}</span>
|
||||
) : null}
|
||||
{pr ? (
|
||||
<span className="mobile-feed-card__pr">#{pr.number}</span>
|
||||
) : null}
|
||||
{pr ? <span className="mobile-feed-card__pr">#{pr.number}</span> : null}
|
||||
{pr && !isPRRateLimited(pr) && !isPRUnenriched(pr) ? (
|
||||
<span className="mobile-feed-card__diff">
|
||||
<span style={{ color: "var(--color-accent-green)" }}>+{pr.additions}</span>
|
||||
{" "}
|
||||
<span style={{ color: "var(--color-accent-green)" }}>+{pr.additions}</span>{" "}
|
||||
<span style={{ color: "var(--color-accent-red)" }}>-{pr.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@ import {
|
|||
getAttentionLevel,
|
||||
isPRRateLimited,
|
||||
isPRUnenriched,
|
||||
TERMINAL_STATUSES,
|
||||
TERMINAL_ACTIVITIES,
|
||||
CI_STATUS,
|
||||
getSessionTruthLabel,
|
||||
getPRTruthLabel,
|
||||
getRuntimeTruthLabel,
|
||||
getLifecycleGuidance,
|
||||
isDashboardSessionDone,
|
||||
isDashboardSessionTerminal,
|
||||
isDashboardSessionRestorable,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
|
|
@ -33,7 +38,7 @@ function getDoneStatusInfo(session: DashboardSession): {
|
|||
} {
|
||||
const activity = session.activity;
|
||||
const status = session.status;
|
||||
const prState = session.pr?.state;
|
||||
const prState = session.lifecycle?.prState ?? session.pr?.state;
|
||||
|
||||
if (prState === "merged" || status === "merged") {
|
||||
return {
|
||||
|
|
@ -53,9 +58,28 @@ function getDoneStatusInfo(session: DashboardSession): {
|
|||
};
|
||||
}
|
||||
|
||||
if (status === "killed" || status === "terminated") {
|
||||
if (prState === "closed") {
|
||||
return {
|
||||
label: status,
|
||||
label: "closed",
|
||||
pillClass: "done-status-pill--exited",
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3 w-3"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9 12h6" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (session.lifecycle?.sessionState === "terminated" || status === "killed" || status === "terminated") {
|
||||
return {
|
||||
label: getSessionTruthLabel(session),
|
||||
pillClass: "done-status-pill--killed",
|
||||
icon: (
|
||||
<svg
|
||||
|
|
@ -72,7 +96,7 @@ function getDoneStatusInfo(session: DashboardSession): {
|
|||
}
|
||||
|
||||
// Default: exited / done / cleanup / closed PR
|
||||
const label = activity === "exited" ? "exited" : status;
|
||||
const label = activity === "exited" ? "exited" : getSessionTruthLabel(session);
|
||||
return {
|
||||
label,
|
||||
pillClass: "done-status-pill--exited",
|
||||
|
|
@ -160,17 +184,23 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
const prUnenriched = pr ? isPRUnenriched(pr) : false;
|
||||
const alerts = getAlerts(session);
|
||||
const isReadyToMerge = !rateLimited && pr?.mergeability.mergeable && pr.state === "open";
|
||||
const isTerminal =
|
||||
TERMINAL_STATUSES.has(session.status) ||
|
||||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity));
|
||||
const isRestorable = isTerminal && session.status !== "merged";
|
||||
const isTerminal = isDashboardSessionTerminal(session);
|
||||
const isRestorable = isDashboardSessionRestorable(session);
|
||||
|
||||
const title = getSessionTitle(session);
|
||||
const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge));
|
||||
const visiblePassingChecks = !rateLimited && pr && !prUnenriched
|
||||
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
|
||||
: [];
|
||||
const isDone = level === "done";
|
||||
const isDone = isDashboardSessionDone(session) || level === "done";
|
||||
const truthLine = session.lifecycle
|
||||
? [
|
||||
`Session ${getSessionTruthLabel(session)}`,
|
||||
`PR ${getPRTruthLabel(session)}`,
|
||||
`Runtime ${getRuntimeTruthLabel(session)}`,
|
||||
].join(" · ")
|
||||
: null;
|
||||
const lifecycleGuidance = getLifecycleGuidance(session);
|
||||
const secondaryText = session.issueLabel
|
||||
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
|
||||
: (session.issueTitle ??
|
||||
|
|
@ -551,6 +581,22 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</div>
|
||||
)}
|
||||
|
||||
{truthLine && (
|
||||
<div className="px-[10px] pb-[5px]">
|
||||
<p className="text-[10px] leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{truthLine}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lifecycleGuidance && (
|
||||
<div className="px-[10px] pb-[6px]">
|
||||
<p className="inline-flex items-center gap-1 rounded-full border border-[color-mix(in_srgb,var(--color-status-attention)_35%,transparent)] bg-[color-mix(in_srgb,var(--color-status-attention)_9%,transparent)] px-2 py-1 text-[10px] leading-none text-[var(--color-status-attention)]">
|
||||
{lifecycleGuidance}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rateLimited && pr?.state === "open" && (
|
||||
<div className="px-[10px] pb-[5px]">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -803,11 +849,12 @@ function getFooterStatusLabel(
|
|||
isReadyToMerge: boolean,
|
||||
): string {
|
||||
if (isReadyToMerge || level === "merge") return "mergeable";
|
||||
if (level === "respond") return "waiting_input";
|
||||
if (session.status === "ci_failed") return "ci_failed";
|
||||
if (level === "review") return "review_pending";
|
||||
if (level === "working") return "working";
|
||||
return session.status;
|
||||
if (session.lifecycle?.sessionState === "detecting") return "detecting";
|
||||
if (level === "respond") return getSessionTruthLabel(session);
|
||||
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed") return "ci failing";
|
||||
if (level === "review") return getPRTruthLabel(session);
|
||||
if (level === "working") return getSessionTruthLabel(session);
|
||||
return getSessionTruthLabel(session);
|
||||
}
|
||||
|
||||
interface Alert {
|
||||
|
|
@ -834,11 +881,17 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
// The lifecycle manager's status is the most up-to-date source of truth.
|
||||
// PR enrichment data can be stale (5-min cache) or unavailable (rate limit/timeout).
|
||||
// Use lifecycle status as fallback when PR data hasn't caught up yet.
|
||||
const lifecyclePrReason = session.lifecycle?.prReason ?? null;
|
||||
const lifecycleStatus = meta["status"];
|
||||
|
||||
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
|
||||
const ciIsFailing =
|
||||
pr.ciStatus === CI_STATUS.FAILING ||
|
||||
lifecyclePrReason === "ci_failing" ||
|
||||
lifecycleStatus === "ci_failed";
|
||||
const hasChangesRequested =
|
||||
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
|
||||
pr.reviewDecision === "changes_requested" ||
|
||||
lifecyclePrReason === "changes_requested" ||
|
||||
lifecycleStatus === "changes_requested";
|
||||
const hasConflicts = !pr.mergeability.noConflicts;
|
||||
|
||||
if (ciIsFailing) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CI_STATUS } from "@aoagents/ao-core/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isPRMergeReady, type DashboardPR } from "@/lib/types";
|
||||
import { cleanBugbotComment } from "./session-detail-utils";
|
||||
|
||||
interface SessionDetailPRCardProps {
|
||||
pr: DashboardPR;
|
||||
metadata: Record<string, string>;
|
||||
lifecyclePrReason?: string;
|
||||
onAskAgentToFix: (
|
||||
comment: { url: string; path: string; body: string },
|
||||
onSuccess: () => void,
|
||||
onError: () => void,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
interface BlockerChip {
|
||||
icon: string;
|
||||
text: string;
|
||||
variant: "fail" | "warn" | "muted";
|
||||
notified?: boolean;
|
||||
}
|
||||
|
||||
function buildBlockerChips(
|
||||
pr: DashboardPR,
|
||||
metadata: Record<string, string>,
|
||||
lifecyclePrReason?: string,
|
||||
): BlockerChip[] {
|
||||
const chips: BlockerChip[] = [];
|
||||
|
||||
const ciNotified = Boolean(metadata["lastCIFailureDispatchHash"]);
|
||||
const conflictNotified = metadata["lastMergeConflictDispatched"] === "true";
|
||||
const reviewNotified = Boolean(metadata["lastPendingReviewDispatchHash"]);
|
||||
const lifecycleStatus = metadata["status"];
|
||||
|
||||
const ciIsFailing =
|
||||
pr.ciStatus === CI_STATUS.FAILING ||
|
||||
lifecyclePrReason === "ci_failing" ||
|
||||
lifecycleStatus === "ci_failed";
|
||||
const hasChangesRequested =
|
||||
pr.reviewDecision === "changes_requested" ||
|
||||
lifecyclePrReason === "changes_requested" ||
|
||||
lifecycleStatus === "changes_requested";
|
||||
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
|
||||
if (ciIsFailing) {
|
||||
const failCount = pr.ciChecks.filter((check) => check.status === "failed").length;
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
|
||||
notified: ciNotified,
|
||||
});
|
||||
} else if (pr.ciStatus === CI_STATUS.PENDING) {
|
||||
chips.push({ icon: "\u25CF", variant: "warn", text: "CI pending" });
|
||||
}
|
||||
|
||||
if (hasChangesRequested) {
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: "Changes requested",
|
||||
notified: reviewNotified,
|
||||
});
|
||||
} else if (!pr.mergeability.approved) {
|
||||
chips.push({ icon: "\u25CB", variant: "muted", text: "Awaiting reviewer" });
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: "Merge conflicts",
|
||||
notified: conflictNotified,
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.isDraft) {
|
||||
chips.push({ icon: "\u25CB", variant: "muted", text: "Draft" });
|
||||
}
|
||||
|
||||
return chips;
|
||||
}
|
||||
|
||||
export function SessionDetailPRCard({
|
||||
pr,
|
||||
metadata,
|
||||
lifecyclePrReason,
|
||||
onAskAgentToFix,
|
||||
}: SessionDetailPRCardProps) {
|
||||
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
|
||||
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
|
||||
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAskAgentToFix = async (comment: { url: string; path: string; body: string }) => {
|
||||
setSentComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setErrorComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setSendingComments((prev) => new Set(prev).add(comment.url));
|
||||
|
||||
await onAskAgentToFix(
|
||||
comment,
|
||||
() => {
|
||||
setSendingComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setSentComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
setSentComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(comment.url);
|
||||
}, 3000);
|
||||
timersRef.current.set(comment.url, timer);
|
||||
},
|
||||
() => {
|
||||
setSendingComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setErrorComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
setErrorComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(comment.url);
|
||||
}, 3000);
|
||||
timersRef.current.set(comment.url, timer);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const allGreen = isPRMergeReady(pr);
|
||||
const blockerIssues = buildBlockerChips(pr, metadata, lifecyclePrReason);
|
||||
const fileCount = pr.changedFiles ?? 0;
|
||||
|
||||
return (
|
||||
<div className={cn("session-detail-pr-card", allGreen && "session-detail-pr-card--green")}>
|
||||
<div className="session-detail-pr-card__row">
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-pr-card__title-link"
|
||||
>
|
||||
PR #{pr.number}: {pr.title}
|
||||
</a>
|
||||
<span className="session-detail-pr-card__diff-stats">
|
||||
<span className="session-detail-diff--add">+{pr.additions}</span>{" "}
|
||||
<span className="session-detail-diff--del">-{pr.deletions}</span>
|
||||
</span>
|
||||
{fileCount > 0 ? (
|
||||
<span className="session-detail-pr-card__diff-label">
|
||||
{fileCount} file{fileCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{pr.isDraft ? <span className="session-detail-pr-card__diff-label">Draft</span> : null}
|
||||
{pr.state === "merged" ? (
|
||||
<span className="session-detail-pr-card__diff-label">Merged</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="session-detail-pr-card__details">
|
||||
{allGreen ? (
|
||||
<div className="session-detail-merge-banner">
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
Ready to merge
|
||||
</div>
|
||||
) : (
|
||||
blockerIssues.map((issue) => (
|
||||
<span
|
||||
key={issue.text}
|
||||
className={cn(
|
||||
"session-detail-blocker-chip",
|
||||
issue.variant === "fail" && "session-detail-blocker-chip--fail",
|
||||
issue.variant === "warn" && "session-detail-blocker-chip--warn",
|
||||
issue.variant === "muted" && "session-detail-blocker-chip--muted",
|
||||
)}
|
||||
>
|
||||
{issue.icon} {issue.text}
|
||||
{issue.notified ? (
|
||||
<span className="session-detail-blocker-chip__note">· notified</span>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
{pr.ciChecks.length > 0 ? (
|
||||
<>
|
||||
<div className="session-detail-pr-sep" />
|
||||
{pr.ciChecks.map((check, index) => {
|
||||
const key = check.url ?? `${check.name}-${index}`;
|
||||
const chip = (
|
||||
<span
|
||||
className={cn(
|
||||
"session-detail-ci-chip",
|
||||
check.status === "passed" && "session-detail-ci-chip--pass",
|
||||
check.status === "failed" && "session-detail-ci-chip--fail",
|
||||
check.status === "pending" && "session-detail-ci-chip--pending",
|
||||
check.status !== "passed" &&
|
||||
check.status !== "failed" &&
|
||||
check.status !== "pending" &&
|
||||
"session-detail-ci-chip--queued",
|
||||
)}
|
||||
>
|
||||
{check.status === "passed"
|
||||
? "\u2713"
|
||||
: check.status === "failed"
|
||||
? "\u2717"
|
||||
: check.status === "pending"
|
||||
? "\u25CF"
|
||||
: "\u25CB"}{" "}
|
||||
{check.name}
|
||||
</span>
|
||||
);
|
||||
return check.url ? (
|
||||
<a
|
||||
key={key}
|
||||
href={check.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:no-underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{chip}
|
||||
</a>
|
||||
) : (
|
||||
<span key={key}>{chip}</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pr.unresolvedComments.length > 0 ? (
|
||||
<details className="session-detail-comments-strip">
|
||||
<summary>
|
||||
<div className="session-detail-comments-strip__toggle">
|
||||
<svg
|
||||
className="session-detail-comments-strip__chevron"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="session-detail-comments-strip__label">Unresolved Comments</span>
|
||||
<span className="session-detail-comments-strip__count">{pr.unresolvedThreads}</span>
|
||||
<span className="session-detail-comments-strip__hint">click to expand</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="session-detail-comments-strip__body">
|
||||
{pr.unresolvedComments.map((comment, index) => {
|
||||
const { title, description } = cleanBugbotComment(comment.body);
|
||||
return (
|
||||
<details key={comment.url} className="session-detail-comment" open={index === 0}>
|
||||
<summary>
|
||||
<div className="session-detail-comment__row">
|
||||
<svg
|
||||
className="session-detail-comment__chevron"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="session-detail-comment__title">{title}</span>
|
||||
<span className="session-detail-comment__author">· {comment.author}</span>
|
||||
<a
|
||||
href={comment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="session-detail-comment__view"
|
||||
>
|
||||
view →
|
||||
</a>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="session-detail-comment__body">
|
||||
<div className="session-detail-comment__file">{comment.path}</div>
|
||||
<p className="session-detail-comment__text">{description}</p>
|
||||
<button
|
||||
onClick={() => handleAskAgentToFix(comment)}
|
||||
disabled={sendingComments.has(comment.url)}
|
||||
className={cn(
|
||||
"session-detail-comment__fix-btn",
|
||||
sentComments.has(comment.url) && "session-detail-comment__fix-btn--sent",
|
||||
errorComments.has(comment.url) && "session-detail-comment__fix-btn--error",
|
||||
)}
|
||||
>
|
||||
{sendingComments.has(comment.url)
|
||||
? "Sending\u2026"
|
||||
: sentComments.has(comment.url)
|
||||
? "Sent \u2713"
|
||||
: errorComments.has(comment.url)
|
||||
? "Failed"
|
||||
: "Ask Agent to Fix"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DashboardPR } from "@/lib/types";
|
||||
import {
|
||||
activityStateClass,
|
||||
activityToneClass,
|
||||
buildGitHubBranchUrl,
|
||||
} from "./session-detail-utils";
|
||||
|
||||
interface SessionTopStripProps {
|
||||
headline: string;
|
||||
crumbId: string;
|
||||
activityLabel: string;
|
||||
activityColor: string;
|
||||
branch: string | null;
|
||||
pr: DashboardPR | null;
|
||||
isOrchestrator?: boolean;
|
||||
crumbHref: string;
|
||||
crumbLabel: string;
|
||||
rightSlot?: ReactNode;
|
||||
onKill?: () => void;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
export function SessionTopStrip({
|
||||
headline,
|
||||
crumbId,
|
||||
activityLabel,
|
||||
activityColor,
|
||||
branch,
|
||||
pr,
|
||||
isOrchestrator = false,
|
||||
crumbHref,
|
||||
crumbLabel,
|
||||
rightSlot,
|
||||
onKill,
|
||||
onRestore,
|
||||
}: SessionTopStripProps) {
|
||||
return (
|
||||
<div className="session-detail-top-strip">
|
||||
<div className="session-detail-crumbs">
|
||||
<a href={crumbHref} className="session-detail-crumb-back">
|
||||
<svg
|
||||
className="h-3 w-3 opacity-60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{crumbLabel}
|
||||
</a>
|
||||
<span className="session-detail-crumb-sep">/</span>
|
||||
<span className="session-detail-crumb-id">{crumbId}</span>
|
||||
{isOrchestrator ? <span className="session-detail-mode-badge">orchestrator</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="session-detail-identity">
|
||||
<div className="session-detail-identity__info">
|
||||
<h1 className="session-detail-identity__title">{headline}</h1>
|
||||
<div className="session-detail-identity__pills">
|
||||
<div className={cn("session-detail-status-pill", activityStateClass(activityLabel))}>
|
||||
<span
|
||||
className={cn(
|
||||
"session-detail-status-pill__dot",
|
||||
activityToneClass(activityColor),
|
||||
)}
|
||||
/>
|
||||
<span className="session-detail-status-pill__label">{activityLabel}</span>
|
||||
</div>
|
||||
{branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-link-pill session-detail-link-pill--branch session-detail-link-pill--branch-link hover:no-underline"
|
||||
>
|
||||
{branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="session-detail-link-pill session-detail-link-pill--branch">
|
||||
{branch}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{pr ? (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-link-pill session-detail-link-pill--pr hover:no-underline"
|
||||
>
|
||||
PR #{pr.number}
|
||||
</a>
|
||||
) : null}
|
||||
{pr && (pr.additions > 0 || pr.deletions > 0) ? (
|
||||
<span className="session-detail-link-pill session-detail-link-pill--diff">
|
||||
<span className="session-detail-diff--add">+{pr.additions}</span>{" "}
|
||||
<span className="session-detail-diff--del">-{pr.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rightSlot ? (
|
||||
<div className="session-detail-identity__actions session-detail-identity__actions--custom">
|
||||
{rightSlot}
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-detail-identity__actions">
|
||||
{onRestore ? (
|
||||
<button type="button" className="done-restore-btn" onClick={onRestore}>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3 w-3"
|
||||
>
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
Restore
|
||||
</button>
|
||||
) : onKill ? (
|
||||
<button
|
||||
type="button"
|
||||
className="session-detail-action-btn session-detail-action-btn--danger"
|
||||
onClick={onKill}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Kill
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DashboardAgentReportAuditEntry } from "@/lib/types";
|
||||
|
||||
const RECENT_AUDIT_LIMIT = 10;
|
||||
|
||||
function formatAuditTimestamp(isoDate: string): string {
|
||||
const parsed = Date.parse(isoDate);
|
||||
if (Number.isNaN(parsed)) return isoDate;
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
|
||||
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
|
||||
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
|
||||
}
|
||||
|
||||
export function SessionReportAuditPanel({
|
||||
sessionId,
|
||||
entries,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entries: DashboardAgentReportAuditEntry[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showOlderEntries, setShowOlderEntries] = useState(false);
|
||||
const panelId = `${sessionId}-report-audit-panel`;
|
||||
const hasOlderEntries = entries.length > RECENT_AUDIT_LIMIT;
|
||||
const visibleEntries =
|
||||
showOlderEntries || !hasOlderEntries ? entries : entries.slice(0, RECENT_AUDIT_LIMIT);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
<span className="block">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Agent Reports
|
||||
</span>
|
||||
<span className="mt-1 block text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id={panelId} hidden={!isExpanded} className="mt-3 space-y-3">
|
||||
{visibleEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}-${entry.source}-${String(entry.accepted)}-${index}`}
|
||||
className="rounded-[16px] border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
entry.accepted
|
||||
? "border-[var(--color-status-ready)]/25 bg-[var(--color-tint-green)] text-[var(--color-status-ready)]"
|
||||
: "border-[var(--color-status-error)]/25 bg-[var(--color-tint-red)] text-[var(--color-status-error)]",
|
||||
)}
|
||||
>
|
||||
{entry.accepted ? "Accepted" : "Rejected"}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{formatAuditTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Session
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{sessionId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Actor
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Source
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{getAuditCommandLabel(entry)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entry.before.legacyStatus} / {entry.before.sessionState}
|
||||
{" -> "}
|
||||
{entry.after.legacyStatus} / {entry.after.sessionState}
|
||||
</div>
|
||||
{entry.note ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
|
||||
Note: {entry.note}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.rejectionReason ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
|
||||
Rejection: {entry.rejectionReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{hasOlderEntries && !showOlderEntries ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] font-medium text-[var(--color-accent)] transition-colors hover:text-[var(--color-accent-hover)]"
|
||||
onClick={() => setShowOlderEntries(true)}
|
||||
>
|
||||
Show older reports ({entries.length - RECENT_AUDIT_LIMIT})
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
getActivitySignalLabel,
|
||||
getActivitySignalReasonLabel,
|
||||
getLifecycleEvidence,
|
||||
getLifecycleGuidance,
|
||||
getPRTruthLabel,
|
||||
getPRTruthReasonLabel,
|
||||
getRuntimeTruthLabel,
|
||||
getRuntimeTruthReasonLabel,
|
||||
getSessionTruthLabel,
|
||||
getSessionTruthReasonLabel,
|
||||
type DashboardSession,
|
||||
} from "@/lib/types";
|
||||
|
||||
function getActivityFactToneClass(session: DashboardSession): string {
|
||||
if (!session.activitySignal || session.activitySignal.state !== "valid") {
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
|
||||
switch (session.activitySignal.activity) {
|
||||
case "active":
|
||||
return "text-[var(--color-status-working)]";
|
||||
case "ready":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "idle":
|
||||
return "text-[var(--color-status-idle)]";
|
||||
case "waiting_input":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "blocked":
|
||||
case "exited":
|
||||
return "text-[var(--color-status-error)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.session.state) {
|
||||
case "detecting":
|
||||
case "needs_input":
|
||||
case "stuck":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "terminated":
|
||||
return "text-[var(--color-status-error)]";
|
||||
case "done":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "working":
|
||||
return "text-[var(--color-status-working)]";
|
||||
case "idle":
|
||||
return "text-[var(--color-status-pending)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getPrFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.pr.state) {
|
||||
case "merged":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "closed":
|
||||
return "text-[var(--color-status-error)]";
|
||||
case "open":
|
||||
return "text-[var(--color-accent)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.runtime.state) {
|
||||
case "alive":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "probe_failed":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "missing":
|
||||
case "exited":
|
||||
return "text-[var(--color-status-error)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionTruthPanel({ session }: { session: DashboardSession }) {
|
||||
if (!session.lifecycle) return null;
|
||||
|
||||
const facts = [
|
||||
{
|
||||
heading: "Activity",
|
||||
label: getActivitySignalLabel(session),
|
||||
reason: getActivitySignalReasonLabel(session),
|
||||
toneClassName: getActivityFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "Session",
|
||||
label: getSessionTruthLabel(session),
|
||||
reason: getSessionTruthReasonLabel(session),
|
||||
toneClassName: getSessionFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "PR",
|
||||
label: getPRTruthLabel(session),
|
||||
reason: getPRTruthReasonLabel(session),
|
||||
toneClassName: getPrFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "Runtime",
|
||||
label: getRuntimeTruthLabel(session),
|
||||
reason: getRuntimeTruthReasonLabel(session),
|
||||
toneClassName: getRuntimeFactToneClass(session),
|
||||
},
|
||||
];
|
||||
const guidance = getLifecycleGuidance(session);
|
||||
const evidence = getLifecycleEvidence(session);
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Lifecycle Truth
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
|
||||
{session.lifecycle.summary}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{facts.map((fact) => (
|
||||
<div
|
||||
key={fact.heading}
|
||||
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
{fact.heading}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[12px] font-medium ${fact.toneClassName}`}>
|
||||
{fact.label}
|
||||
</div>
|
||||
{fact.reason ? (
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{fact.reason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{guidance ? (
|
||||
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{evidence ? (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
Evidence: {evidence}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,4 +59,11 @@ describe("Dashboard done bar", () => {
|
|||
render(<Dashboard initialSessions={[DONE_SESSION]} />);
|
||||
expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a restore action for merged sessions", () => {
|
||||
render(<Dashboard initialSessions={[DONE_SESSION]} />);
|
||||
const toggle = screen.getByText("Done / Terminated").closest("button")!;
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByRole("button", { name: /restore/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ describe("Dashboard mobile layout", () => {
|
|||
issueTitle: "Restore completed agent",
|
||||
branch: null,
|
||||
lastActivityAt: "2026-04-11T09:07:00.000Z",
|
||||
pr: makePR({ number: 209, state: "merged", title: "Wrapped up work" }),
|
||||
pr: makePR({ number: 209, state: "closed", title: "Wrapped up work" }),
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
|
|
@ -331,7 +331,7 @@ describe("Dashboard mobile layout", () => {
|
|||
"href",
|
||||
"https://github.com/acme/app/pull/100",
|
||||
);
|
||||
expect(screen.getByText("merged")).toBeInTheDocument();
|
||||
expect(screen.getByText("terminated")).toBeInTheDocument();
|
||||
expect(screen.getByText("2h ago")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
import { buildAgentFixMessage } from "../session-detail-agent-actions";
|
||||
import { makePR, makeSession } from "../../__tests__/helpers";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
|
@ -60,6 +61,28 @@ describe("SessionDetail desktop layout", () => {
|
|||
projectId: "my-app",
|
||||
summary: "Desktop session detail",
|
||||
branch: "feat/desktop-detail",
|
||||
agentReportAudit: [
|
||||
{
|
||||
timestamp: "2025-01-01T10:00:00.000Z",
|
||||
actor: "codex",
|
||||
source: "report",
|
||||
reportState: "working",
|
||||
note: "Running final verification",
|
||||
accepted: true,
|
||||
before: {
|
||||
legacyStatus: "working",
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
lastTransitionAt: "2025-01-01T09:55:00.000Z",
|
||||
},
|
||||
after: {
|
||||
legacyStatus: "working",
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
lastTransitionAt: "2025-01-01T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
pr: makePR({
|
||||
number: 310,
|
||||
title: "Desktop detail coverage",
|
||||
|
|
@ -120,14 +143,65 @@ describe("SessionDetail desktop layout", () => {
|
|||
);
|
||||
expect(screen.getByText("3 files")).toBeInTheDocument();
|
||||
expect(screen.getByText("Draft")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Changes requested/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Merge conflicts/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unresolved Comments/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Tighten the copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("The empty state text needs to be shorter.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Agent Reports/i }));
|
||||
expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("ao report working")).toBeInTheDocument();
|
||||
expect(screen.getByText("codex")).toBeInTheDocument();
|
||||
expect(screen.getByText("Live Terminal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the agent reports section", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-audit-toggle",
|
||||
projectId: "my-app",
|
||||
agentReportAudit: [
|
||||
{
|
||||
timestamp: "2025-01-01T10:00:00.000Z",
|
||||
actor: "codex",
|
||||
source: "acknowledge",
|
||||
reportState: "started",
|
||||
accepted: true,
|
||||
before: {
|
||||
legacyStatus: "spawning",
|
||||
sessionState: "spawning",
|
||||
sessionReason: "agent_spawned",
|
||||
lastTransitionAt: "2025-01-01T09:55:00.000Z",
|
||||
},
|
||||
after: {
|
||||
legacyStatus: "working",
|
||||
sessionState: "working",
|
||||
sessionReason: "agent_acknowledged",
|
||||
lastTransitionAt: "2025-01-01T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Agent Reports/i });
|
||||
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("sends unresolved comments back to the agent and shows sent state", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
|
@ -170,6 +244,41 @@ describe("SessionDetail desktop layout", () => {
|
|||
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("builds branch links from the PR host for GitHub Enterprise repos", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-ghe",
|
||||
projectId: "my-app",
|
||||
branch: "feat/ghe-detail",
|
||||
pr: makePR({
|
||||
number: 312,
|
||||
url: "https://github.enterprise.local/acme/app/pull/312",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/ghe-detail",
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "feat/ghe-detail" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.enterprise.local/acme/app/tree/feat/ghe-detail",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates review-comment messages below the API payload cap", () => {
|
||||
const message = buildAgentFixMessage({
|
||||
url: "https://github.com/acme/app/pull/311#discussion_r2",
|
||||
path: `packages/web/${"deep/".repeat(200)}component.tsx`,
|
||||
body: `### ${"T".repeat(500)}\n<!-- DESCRIPTION START -->${"D".repeat(15_000)}<!-- DESCRIPTION END -->`,
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThanOrEqual(9_500);
|
||||
expect(message).toContain("Resolve the comment at https://github.com/acme/app/pull/311#discussion_r2");
|
||||
});
|
||||
|
||||
it("shows terminal-ended placeholder for exited desktop sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
|
|
@ -210,5 +319,7 @@ describe("SessionDetail desktop layout", () => {
|
|||
|
||||
expect(screen.queryByRole("link", { name: "Orchestrator" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("orchestrator")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Lifecycle Truth")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Agent Reports/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { cleanBugbotComment } from "./session-detail-utils";
|
||||
|
||||
const MAX_AGENT_MESSAGE_LENGTH = 9_500;
|
||||
const MAX_TITLE_LENGTH = 240;
|
||||
const MAX_DESCRIPTION_LENGTH = 7_500;
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function buildAgentFixMessage(comment: { url: string; path: string; body: string }): string {
|
||||
const { title, description } = cleanBugbotComment(comment.body);
|
||||
const message = [
|
||||
"Please address this review comment:",
|
||||
"",
|
||||
`File: ${truncate(comment.path, 500)}`,
|
||||
`Comment: ${truncate(title, MAX_TITLE_LENGTH)}`,
|
||||
`Description: ${truncate(description, MAX_DESCRIPTION_LENGTH)}`,
|
||||
"",
|
||||
`Resolve the comment at ${comment.url} after fixing it.`,
|
||||
].join("\n");
|
||||
|
||||
return truncate(message, MAX_AGENT_MESSAGE_LENGTH);
|
||||
}
|
||||
|
||||
export async function askAgentToFix(
|
||||
sessionId: string,
|
||||
comment: { url: string; path: string; body: string },
|
||||
onSuccess: () => void,
|
||||
onError: () => void,
|
||||
) {
|
||||
try {
|
||||
const message = buildAgentFixMessage(comment);
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to send message to agent:", error);
|
||||
onError();
|
||||
}
|
||||
}
|
||||
|
||||
export { buildAgentFixMessage };
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { isPRRateLimited, isPRUnenriched, type DashboardPR } from "@/lib/types";
|
||||
|
||||
export const sessionActivityMeta: Record<string, { label: string; color: string }> = {
|
||||
active: { label: "Active", color: "var(--color-status-working)" },
|
||||
ready: { label: "Ready", color: "var(--color-status-ready)" },
|
||||
idle: { label: "Idle", color: "var(--color-status-idle)" },
|
||||
waiting_input: { label: "Waiting for input", color: "var(--color-status-attention)" },
|
||||
blocked: { label: "Blocked", color: "var(--color-status-error)" },
|
||||
exited: { label: "Exited", color: "var(--color-status-error)" },
|
||||
};
|
||||
|
||||
export function formatTimeCompact(isoDate: string | null): string {
|
||||
if (!isoDate) return "just now";
|
||||
const ts = new Date(isoDate).getTime();
|
||||
if (!Number.isFinite(ts)) return "just now";
|
||||
const diffMs = Date.now() - ts;
|
||||
if (diffMs <= 0) return "just now";
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${Math.floor(diffHours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function getCiShortLabel(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "CI";
|
||||
if (pr.ciStatus === "passing") return "CI passing";
|
||||
if (pr.ciStatus === "failing") return "CI failed";
|
||||
return "CI pending";
|
||||
}
|
||||
|
||||
export function getReviewShortLabel(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "";
|
||||
if (pr.reviewDecision === "approved") return "approved";
|
||||
if (pr.reviewDecision === "changes_requested") return "changes";
|
||||
return "review";
|
||||
}
|
||||
|
||||
export function cleanBugbotComment(body: string): { title: string; description: string } {
|
||||
const isBugbot = body.includes("<!-- DESCRIPTION START -->") || body.includes("### ");
|
||||
if (isBugbot) {
|
||||
const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/);
|
||||
const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment";
|
||||
const descMatch = body.match(
|
||||
/<!-- DESCRIPTION START -->\s*([\s\S]*?)\s*<!-- DESCRIPTION END -->/,
|
||||
);
|
||||
const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description";
|
||||
return { title, description };
|
||||
}
|
||||
return { title: "Comment", description: body.trim() };
|
||||
}
|
||||
|
||||
export function buildGitHubBranchUrl(pr: DashboardPR): string {
|
||||
let origin = "https://github.com";
|
||||
|
||||
try {
|
||||
origin = new URL(pr.url).origin;
|
||||
} catch {
|
||||
// Fall back to the public GitHub host if the PR URL is missing or invalid.
|
||||
}
|
||||
|
||||
return `${origin}/${pr.owner}/${pr.repo}/tree/${pr.branch}`;
|
||||
}
|
||||
|
||||
export function activityStateClass(activityLabel: string): string {
|
||||
const normalized = activityLabel.toLowerCase();
|
||||
if (normalized === "active") return "session-detail-status-pill--active";
|
||||
if (normalized === "ready") return "session-detail-status-pill--ready";
|
||||
if (normalized === "idle") return "session-detail-status-pill--idle";
|
||||
if (normalized === "waiting for input") return "session-detail-status-pill--waiting";
|
||||
if (normalized === "blocked" || normalized === "exited") {
|
||||
return "session-detail-status-pill--error";
|
||||
}
|
||||
return "session-detail-status-pill--neutral";
|
||||
}
|
||||
|
||||
export function activityToneClass(activityColor: string): string {
|
||||
switch (activityColor) {
|
||||
case "var(--color-status-working)":
|
||||
return "session-detail-tone--working";
|
||||
case "var(--color-status-ready)":
|
||||
return "session-detail-tone--ready";
|
||||
case "var(--color-status-idle)":
|
||||
return "session-detail-tone--idle";
|
||||
case "var(--color-status-attention)":
|
||||
return "session-detail-tone--attention";
|
||||
case "var(--color-status-error)":
|
||||
return "session-detail-tone--error";
|
||||
default:
|
||||
return "session-detail-tone--muted";
|
||||
}
|
||||
}
|
||||
|
||||
export function mobileStatusPillClass(activityLabel: string): string {
|
||||
const normalized = activityLabel.toLowerCase();
|
||||
if (normalized === "active") return "session-detail__status-pill--active";
|
||||
if (normalized === "ready") return "session-detail__status-pill--ready";
|
||||
if (normalized === "waiting for input") return "session-detail__status-pill--waiting";
|
||||
if (normalized === "blocked" || normalized === "exited") {
|
||||
return "session-detail__status-pill--error";
|
||||
}
|
||||
return "session-detail__status-pill--idle";
|
||||
}
|
||||
|
||||
export function ciToneClass(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "session-detail-ci-tone--neutral";
|
||||
if (pr.ciStatus === "passing") return "session-detail-ci-tone--pass";
|
||||
if (pr.ciStatus === "failing") return "session-detail-ci-tone--fail";
|
||||
return "session-detail-ci-tone--pending";
|
||||
}
|
||||
|
|
@ -60,15 +60,15 @@ describe("getDashboardPageData fast path", () => {
|
|||
hoisted.enrichSessionsMetadataFastMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs fast enrichment, uses cache-only PR hydration, and infers merged/closed state for terminal cache misses even without SCM", async () => {
|
||||
it("runs fast enrichment, uses cache-only PR hydration, and preserves canonical PR state on cache misses even without SCM", async () => {
|
||||
const noPrCore = { id: "session-no-pr", status: "working", pr: null };
|
||||
const closedCore = { id: "session-closed", status: "killed", pr: { number: 2 } };
|
||||
const mergedCore = { id: "session-merged", status: "merged", pr: { number: 3 } };
|
||||
const closedCore = { id: "session-closed", status: "idle", pr: { number: 2 } };
|
||||
const mergedCore = { id: "session-merged", status: "idle", pr: { number: 3 } };
|
||||
const allSessions = [noPrCore, closedCore, mergedCore];
|
||||
|
||||
const dashboardNoPr = { id: "session-no-pr", pr: null };
|
||||
const dashboardClosed = { id: "session-closed", pr: { state: "open", enriched: false } };
|
||||
const dashboardMerged = { id: "session-merged", pr: { state: "open", enriched: false } };
|
||||
const dashboardClosed = { id: "session-closed", pr: { state: "closed", enriched: false } };
|
||||
const dashboardMerged = { id: "session-merged", pr: { state: "merged", enriched: false } };
|
||||
|
||||
hoisted.getServicesMock.mockResolvedValue({
|
||||
config: { projects: { docs: { id: "docs" } } },
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ function makeSession(overrides?: Partial<DashboardSession>): DashboardSession {
|
|||
projectId: "test",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: {
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "native",
|
||||
},
|
||||
branch: null,
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue