diff --git a/.changeset/keep-tmux-session-alive-after-agent-exit.md b/.changeset/keep-tmux-session-alive-after-agent-exit.md deleted file mode 100644 index 44b60042b..000000000 --- a/.changeset/keep-tmux-session-alive-after-agent-exit.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@aoagents/ao-plugin-runtime-tmux": patch -"@aoagents/ao-web": patch ---- - -Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. - -Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) diff --git a/.changeset/native-windows-support.md b/.changeset/native-windows-support.md deleted file mode 100644 index f40349b08..000000000 --- a/.changeset/native-windows-support.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor -"@aoagents/ao": minor -"@aoagents/ao-plugin-runtime-process": minor -"@aoagents/ao-plugin-runtime-tmux": minor -"@aoagents/ao-plugin-agent-claude-code": minor -"@aoagents/ao-plugin-agent-codex": minor -"@aoagents/ao-plugin-agent-aider": minor -"@aoagents/ao-plugin-agent-opencode": minor -"@aoagents/ao-plugin-workspace-worktree": minor -"@aoagents/ao-plugin-workspace-clone": minor -"@aoagents/ao-plugin-tracker-github": minor -"@aoagents/ao-plugin-tracker-linear": minor -"@aoagents/ao-plugin-scm-github": minor -"@aoagents/ao-plugin-notifier-desktop": minor -"@aoagents/ao-plugin-notifier-slack": minor -"@aoagents/ao-plugin-notifier-webhook": minor -"@aoagents/ao-plugin-notifier-composio": minor -"@aoagents/ao-plugin-terminal-iterm2": minor -"@aoagents/ao-plugin-terminal-web": minor -"@aoagents/ao-web": minor ---- - -feat: native Windows support - -AO now runs natively on Windows. The default runtime on Windows is `process` -(ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, -agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, -and `ao update` all work out of the box. Each session gets a small detached -pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, -registered so `ao stop` can reach it. - -A new cross-platform abstraction layer (`packages/core/src/platform.ts`) -centralises every platform branch behind helpers like `isWindows()`, -`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, -and `getEnvDefaults()`. Path comparison uses `pathsEqual` / -`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for -agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; -`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New -`ao-doctor.ps1` / `ao-update.ps1` shipped. - -`ao open` is now cross-platform: it sources sessions from `sm.list()` -instead of `tmux list-sessions` (so `runtime-process` sessions on Windows -appear), and the open action branches per OS — `open-iterm-tab` stays the -macOS path, native handling on Windows and Linux. - -Behaviour on macOS and Linux is unchanged. Every Windows path is gated -behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. - -See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, -EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). -The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, -mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. diff --git a/.changeset/orchestrator-session-id-env.md b/.changeset/orchestrator-session-id-env.md deleted file mode 100644 index 8fcf20027..000000000 --- a/.changeset/orchestrator-session-id-env.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor ---- - -Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) diff --git a/.changeset/release-train.md b/.changeset/release-train.md deleted file mode 100644 index 527832eb6..000000000 --- a/.changeset/release-train.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor -"@aoagents/ao": minor -"@aoagents/ao-web": minor ---- - -feat(release): weekly release train — channels, onboarding, dashboard banner, cron - -Ships the full release pipeline described in `release-process.html`: - -- **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via - `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. - Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via - `changesets/action`. `.changeset/config.json` adds the snapshot template - (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it - private would 404 every `npm install -g @aoagents/ao` after publish). - `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml - before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:*. - -- **Update channels.** New `updateChannel` field in the global config schema - (`stable | nightly | manual`, default `manual` so existing users see no - surprise installs). `update-check.ts` reads `dist-tags[channel]` from the - npm registry, compares prerelease versions segment-by-segment so SHA-suffixed - nightlies sort correctly, and skips notices entirely on `manual`. - -- **Soft auto-install + active-session guard.** On stable/nightly, `ao update` - skips the confirm prompt and just installs. Before installing it lists - sessions and refuses with `N session(s) active. Run \`ao stop\` first.` if - any are in `working`/`idle`/`needs_input`/`stuck`. Same guard duplicated - in `POST /api/update` so the dashboard returns a structured 409. - -- **Onboarding question.** `ao start` prompts once for the channel if unset; - dismissal persists `manual`. `ao config set updateChannel ` (and - `installMethod`) lets users change it later. - -- **Dashboard banner.** `GET /api/version` reads the same cache file as the - CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the - top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; - dismissal persists per-version in `localStorage`. - -- **Bun + Homebrew detection.** New install-method classifiers for - `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) - and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering - brew's symlinks). `installMethod` config field overrides path detection. - -Supersedes #1525 (incorporates the canary + release infrastructure with the -cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in -the design doc). diff --git a/.changeset/rename-worker-sessions.md b/.changeset/rename-worker-sessions.md deleted file mode 100644 index 528c43ff1..000000000 --- a/.changeset/rename-worker-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-web": minor ---- - -Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647) diff --git a/.changeset/restore-preserves-existing-branch.md b/.changeset/restore-preserves-existing-branch.md deleted file mode 100644 index 784d776fe..000000000 --- a/.changeset/restore-preserves-existing-branch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-plugin-workspace-worktree": patch ---- - -Restoring a session whose worktree directory was cleaned up but whose branch still existed locally would 422 with `fatal: a branch named already exists`. The recovery path in `workspace.restore()` unconditionally fell through to `git worktree add -b`, even when the local branch was present (which `destroy()` deliberately preserves). The catch now checks for the local branch and re-attaches it without `-b`/`-B`, preserving the session's commits. (#1741) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fb42f6996..234c715b6 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -1,10 +1,21 @@ name: Canary -# Nightly canary publishes the current tip of `main` to npm under @nightly. +# Two-stage release pipeline (nightly canary side). +# +# Nightly canary creates snapshot versions, tags, and a GitHub prerelease. +# npm publishing is handled by a private cron job (AO) that polls GitHub +# releases and publishes when a new prerelease tag is ahead of the current +# npm nightly version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# # Cron schedule: 23:30 IST = 18:00 UTC, on Fri,Sat,Sun,Mon,Tue # (DOW 5,6,0,1,2). Wed/Thu are the bake window — no scheduled publishes. -# `workflow_dispatch` lets the release captain re-cut a nightly during bake -# when a fix lands and the Discord cohort should test the patched candidate. +# `workflow_dispatch` lets the release captain re-cut a nightly during +# bake when a fix lands and the Discord cohort should test the patched +# candidate. + on: schedule: - cron: "0 18 * * 5,6,0,1,2" @@ -15,38 +26,86 @@ concurrency: cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write jobs: canary: name: Publish canary runs-on: ubuntu-latest - environment: release steps: - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 + # Skip-if-unchanged guard: if the most recent prerelease in this + # repo was cut from the current tip of main, there's nothing new + # to publish — short-circuit the whole job. Prevents republishing + # the same SHA on every cron tick during quiet stretches. + # + # We filter to prereleases only (`isPrerelease == true`). If the + # most recent release were a stable cut from `release.yml`, an + # explicit `workflow_dispatch` nightly right after the stable + # release would otherwise be suppressed — which we don't want. + # + # The previous-nightly tag points at the snapshot commit created + # by the "Commit snapshot version bumps" step below. The snapshot + # commit's first parent is the source main HEAD, so `${LAST_TAG}^` + # is the right anchor to compare against `GITHUB_SHA` (main HEAD + # at this run). + # + # `workflow_dispatch` always proceeds — recovery path for partial + # failures. A human triggered the run, they want it to run. + - name: Check if main has new commits since the last nightly + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ]; then + echo 'Manual trigger via workflow_dispatch — bypassing skip guard.' + echo 'skip=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + LAST_TAG=$(gh release list --json tagName,isPrerelease --jq '[.[] | select(.isPrerelease)][0].tagName // empty') + LAST_SHA="" + if [ -n "$LAST_TAG" ]; then + LAST_SHA=$(git rev-parse "${LAST_TAG}^" 2>/dev/null || echo "") + fi + if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" = "$GITHUB_SHA" ]; then + echo "Last prerelease ($LAST_TAG) was cut from $GITHUB_SHA — skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: pnpm/action-setup@v4 + if: steps.check.outputs.skip != 'true' - uses: actions/setup-node@v4 + if: steps.check.outputs.skip != 'true' with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - - run: echo "HUSKY=0" >> $GITHUB_ENV + - if: steps.check.outputs.skip != 'true' + run: echo "HUSKY=0" >> $GITHUB_ENV - - run: pnpm install --frozen-lockfile + - if: steps.check.outputs.skip != 'true' + run: pnpm install --frozen-lockfile - - run: pnpm -r build + - if: steps.check.outputs.skip != 'true' + run: pnpm -r build - # Pre-publish guard: same as release.yml — catches workspace:* deps on - # private packages before they'd silently break a published install. - - run: node scripts/check-publishable-deps.mjs + # Pre-publish guard: same as release.yml — catches workspace:* deps + # on private packages before they'd silently break a published + # install. Still runs here so the public repo blocks a malformed + # snapshot before it reaches npm. + - if: steps.check.outputs.skip != 'true' + run: node scripts/check-publishable-deps.mjs - name: Create snapshot versions + if: steps.check.outputs.skip != 'true' run: | # If no changesets exist (e.g. right after a Version Packages merge), # create a minimal one. `changeset version --snapshot` consumes and @@ -58,8 +117,52 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish canary - run: pnpm changeset publish --tag nightly --no-git-tag + # `pnpm changeset version --snapshot` modifies package.json files + # on disk but does NOT create a git commit. Without this commit, + # the umbrella tag we push below would point at the pre-snapshot + # HEAD, so when the npm publisher checks out the tag it would see + # the un-bumped versions and either publish wrong version numbers + # or fail trying to republish an existing version. + # + # The commit is NEVER pushed to main — only the tag below is + # pushed, and the snapshot commit travels with it as part of the + # tag's reachable history. `[skip ci]` is defensive in case any + # future change ever pushes this commit to a branch. + - name: Commit snapshot version bumps + if: steps.check.outputs.skip != 'true' + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add packages/*/package.json packages/plugins/*/package.json .changeset/ + git diff --cached --quiet || git commit -m 'chore: snapshot version bump [skip ci]' + + # Push the umbrella `vX.Y.Z` tag pointing at the snapshot commit. + # The npm publisher resolves this tag to the snapshot commit with + # the bumped package.json versions. + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) every night, which adds + # up fast on the nightly cadence. The npm publisher only consumes + # the umbrella tag, so the per-package tags are pure decoration. + - name: Tag snapshot versions + id: tag + if: steps.check.outputs.skip != 'true' + run: | + version=$(node -p "require('./packages/ao/package.json').version") + git tag "v$version" + git push origin "v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Public-facing GitHub prerelease. `--prerelease` flags it as such + # so consumers and tooling that filter by stability stay correct. + # No `--target`: the tag was just pushed and GitHub resolves the + # commitish from it (avoids the race where another commit lands + # on main between the tag push and this step). + - name: Create GitHub prerelease + if: steps.check.outputs.skip != 'true' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.tag.outputs.version }}" \ + --prerelease \ + --generate-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee557905..eb2c322cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,17 @@ name: Release -# Stable @latest publishes when CI passes on `main` and a Version Packages PR -# is merged. The changesets/action below opens/updates that PR automatically; -# merging it runs `changeset publish` and pushes to npm. +# Two-stage release pipeline. +# +# This public repo is responsible for version bumps, tagging, and creating +# the GitHub release. npm publishing is handled by a private cron job (AO) +# that polls GitHub releases and publishes when a new tag is ahead of the +# current npm version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# +# See CONTRIBUTING.md → "Release architecture" for the full picture. + on: workflow_run: # Depends on the workflow named "CI" in .github/workflows/ci.yml — if @@ -20,13 +29,11 @@ concurrency: permissions: contents: write pull-requests: write - id-token: write jobs: release: name: Release runs-on: ubuntu-latest - environment: release if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && @@ -41,24 +48,105 @@ jobs: with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - run: echo "HUSKY=0" >> $GITHUB_ENV - run: pnpm install --frozen-lockfile - run: pnpm -r build # Pre-publish guard: catches the case where a publishable package has a # workspace:* runtime dep on a `private: true` package — pnpm would # rewrite the dep on publish to a version that doesn't exist on npm, - # breaking `npm install -g @aoagents/ao`. + # breaking `npm install -g @aoagents/ao`. Still runs here so the + # public repo blocks a malformed version bump before it reaches npm. - run: node scripts/check-publishable-deps.mjs + + # changesets/action manages the "Version Packages" PR — when there + # are pending changesets it opens/updates the PR; when there are + # none (i.e. that PR was just merged) it would normally invoke the + # `publish:` command. We deliberately omit `publish:` so the action + # never runs `changeset publish`. npm publishing is handled by a + # private cron that detects the GitHub release. - uses: changesets/action@v1 + id: changesets with: - publish: pnpm changeset publish version: pnpm changeset version - # Creates/updates a PR titled "chore: version packages" when changesets - # are pending. Merging that PR publishes to npm under @latest. title: "chore: version packages" commit: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + + # Determine release state. Each downstream step (tag push, GH + # release creation) is gated on its own piece of state so the + # workflow is idempotent and recovers cleanly on re-run after a + # partial failure. + # + # `is_release_commit` is the crucial signal: it filters out + # regular commits to main (which also have `hasChangesets == + # 'false'` but should NOT trigger a publish). We detect a + # version-bump commit by comparing the umbrella package's + # version against its value in the parent commit — a Version + # Packages merge changes that version, a regular commit does not. + # The umbrella `@aoagents/ao` is the canonical version source for + # the `vX.Y.Z` tag scheme and is part of the linked group, so + # any release that bumps the cohort will bump this file. + - name: Determine release state + id: state + if: steps.changesets.outputs.hasChangesets == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(node -p "require('./packages/ao/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + + prev_version="" + if git rev-parse HEAD^ >/dev/null 2>&1; then + prev_version=$(git show HEAD^:packages/ao/package.json 2>/dev/null | jq -r .version 2>/dev/null || echo "") + fi + if [ -n "$prev_version" ] && [ "$prev_version" != "$version" ]; then + echo "is_release_commit=true" >> "$GITHUB_OUTPUT" + else + echo "is_release_commit=false" >> "$GITHUB_OUTPUT" + fi + + if git ls-remote --tags --exit-code origin "refs/tags/v$version" >/dev/null 2>&1; then + echo "tag_on_remote=true" >> "$GITHUB_OUTPUT" + else + echo "tag_on_remote=false" >> "$GITHUB_OUTPUT" + fi + + if gh release view "v$version" --json tagName >/dev/null 2>&1; then + echo "release_exists=true" >> "$GITHUB_OUTPUT" + else + echo "release_exists=false" >> "$GITHUB_OUTPUT" + fi + + # Push the umbrella `vX.Y.Z` tag only if this is a fresh + # version-bump commit AND the tag isn't already on the remote. + # Skipped on re-runs where the tag was pushed on a prior run + # (idempotent recovery). + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) on every release, which + # creates partial-recovery conflicts on re-run when `git push --tags` + # tries to re-push existing per-package tags. The npm publisher + # only consumes the umbrella tag, so the per-package tags add no + # value. + - name: Tag versioned packages + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.tag_on_remote == 'false' + run: | + git tag "v${{ steps.state.outputs.version }}" + git push origin "v${{ steps.state.outputs.version }}" + + # Public-facing GitHub release. Stable channel — not a prerelease. + # No `--target`: the `vX.Y.Z` tag is on the remote at the + # version-bump commit, and `gh release create` resolves the + # commitish from the existing tag. Avoids the race where another + # commit lands on main between the tag push and this step, + # pulling unrelated commits into the auto-generated release notes. + # Skipped if a release for this version already exists. + - name: Create GitHub release + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.release_exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.state.outputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1df81037..825003c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,13 +70,57 @@ ao update `ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install. -## Release Setup (maintainers only) +## Release Architecture (maintainers only) -The canary and stable release workflows require one secret configured in GitHub repo settings: +AO uses a **two-stage release pipeline**. This public repo handles version bumps, git tags, and GitHub releases. npm publishing runs on a private server (AO cron job) that polls GitHub releases and publishes when a new tag is ahead of the current npm version. Org compliance forbids npm publish credentials in public repositories, so `NPM_TOKEN` never enters this repo. -- `NPM_TOKEN` — an npm automation token with publish access to the `@aoagents` org. Add it at **Settings → Secrets and variables → Actions → New repository secret**. +### Where things happen -Without this secret, both `release.yml` and `canary.yml` will fail at the publish step. +| Stage | Where | Responsibility | +| ------------------------ | ------------------------------ | ------------------------------------------------------------------------ | +| Versioning + GitHub release | This repo (public, CI) | Changesets version bumps, git tags, `gh release create` | +| npm publish | Private server (AO cron) | Detects new GitHub releases → builds → `pnpm changeset publish` | + +The flow on every release: + +``` +This repo (public CI) Private server (AO cron) +────────────────────── ───────────────────────── +release.yml: Polls gh release list + changeset version Detects new vX.Y.Z tag + push vX.Y.Z tag Compare to npm @latest/@nightly + gh release create vX.Y.Z If behind → checkout tag → build → publish + +canary.yml: Same cron, detects prereleases + changeset version --snapshot Publishes with --tag nightly + commit snapshot bump + tag + gh release create --prerelease +``` + +Each release pushes a single umbrella `vX.Y.Z` git tag pointing at the version-bump commit. We deliberately do **not** run `pnpm changeset tag`, which would emit one tag per publishable package (~27) every release — fine for stable's monthly cadence, noisy on the nightly cadence (~7 000 tags/year). The npm publisher only consumes the umbrella tag, so the per-package tags add no value. + +### Secrets + +This repo requires **no additional secrets** beyond the automatic `GITHUB_TOKEN`. `NPM_TOKEN` lives only on the private server. + +### How releases are cut + +- **Stable**: merge the "chore: version packages" PR opened by `changesets/action`. `release.yml` tags the bumped packages and creates a `vX.Y.Z` GitHub release. The AO cron detects the new release and publishes to npm `@latest`. +- **Nightly**: `canary.yml` runs on cron (23:30 IST Fri–Tue) or via `workflow_dispatch`. It snapshots versions to `X.Y.Z-nightly-` format (e.g., `0.6.1-nightly-7c46dc92`), tags, and creates a prerelease GitHub release. The AO cron detects the new prerelease and publishes to npm `@nightly`. + +There is no path from this repo that calls `npm publish` directly. + +### Idempotency + +`release.yml` is idempotent: each step (tag push, GitHub release creation) is gated on whether that piece of state already exists, so a re-run after a partial failure picks up only the missing steps. + +The AO cron is also idempotent — `pnpm changeset publish` skips packages whose current version is already on the registry, so re-running after a partial publish is safe. + +### Recovery + +If `release.yml` fails after the GitHub release was created, **re-run the failed workflow**: the state-detection step will see that the tag and release already exist and skip those steps. + +If the AO cron fails to publish, it will retry on the next poll cycle (every 15 minutes). No manual intervention needed for transient failures. For persistent issues, check the cron logs on the private server. ## Testing your changes diff --git a/ao-pr1483 b/ao-pr1483 new file mode 160000 index 000000000..2abb1c1e3 --- /dev/null +++ b/ao-pr1483 @@ -0,0 +1 @@ +Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032 diff --git a/eslint.config.js b/eslint.config.js index 86d1cd57f..c6dcc531e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -99,6 +99,31 @@ export default tseslint.config( }, }, + // Long-running daemon entrypoints must use the managed-child API so any + // subprocess they own is registered and reaped on stop/SIGINT. + { + files: ["packages/cli/src/commands/start.ts", "packages/web/server/start-all.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "node:child_process", + importNames: ["spawn"], + message: "Use spawnManagedDaemonChild() for daemon-owned subprocesses.", + }, + { + name: "child_process", + importNames: ["spawn"], + message: "Use spawnManagedDaemonChild() for daemon-owned subprocesses.", + }, + ], + }, + ], + }, + }, + // Scripts directory - Node.js environment { files: ["scripts/**/*.js", "scripts/**/*.mjs", "packages/*/scripts/**/*.js"], diff --git a/hermes-agent b/hermes-agent new file mode 160000 index 000000000..27eeea055 --- /dev/null +++ b/hermes-agent @@ -0,0 +1 @@ +Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1 diff --git a/libkrunfw b/libkrunfw new file mode 160000 index 000000000..351d354b4 --- /dev/null +++ b/libkrunfw @@ -0,0 +1 @@ +Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41 diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 6da307509..8c7303359 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,93 @@ # @aoagents/ao +## 0.8.0 + +### Patch Changes + +- @aoagents/ao-cli@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:\_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-cli@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index 7f137042a..ab301e4e8 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.6.0", + "version": "0.8.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a110d80f4..eab75ff1e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,142 @@ # @aoagents/ao-cli +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-agent-claude-code@0.8.0 + - @aoagents/ao-plugin-agent-codex@0.8.0 + - @aoagents/ao-plugin-agent-aider@0.8.0 + - @aoagents/ao-plugin-agent-opencode@0.8.0 + - @aoagents/ao-plugin-agent-cursor@0.8.0 + - @aoagents/ao-plugin-agent-kimicode@0.8.0 + - @aoagents/ao-plugin-notifier-composio@0.8.0 + - @aoagents/ao-plugin-notifier-desktop@0.8.0 + - @aoagents/ao-plugin-notifier-discord@0.8.0 + - @aoagents/ao-plugin-notifier-openclaw@0.8.0 + - @aoagents/ao-plugin-notifier-slack@0.8.0 + - @aoagents/ao-plugin-notifier-webhook@0.8.0 + - @aoagents/ao-plugin-runtime-process@0.8.0 + - @aoagents/ao-plugin-runtime-tmux@0.8.0 + - @aoagents/ao-plugin-scm-github@0.8.0 + - @aoagents/ao-plugin-terminal-iterm2@0.8.0 + - @aoagents/ao-plugin-terminal-web@0.8.0 + - @aoagents/ao-plugin-tracker-github@0.8.0 + - @aoagents/ao-plugin-tracker-linear@0.8.0 + - @aoagents/ao-plugin-workspace-clone@0.8.0 + - @aoagents/ao-plugin-workspace-worktree@0.8.0 + - @aoagents/ao-web@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- fe33bb7: Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:\_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +### Patch Changes + +- Updated dependencies [845fffd] +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] +- Updated dependencies [71326bc] +- Updated dependencies [a33b2ba] + - @aoagents/ao-plugin-runtime-tmux@0.7.0 + - @aoagents/ao-web@0.7.0 + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-runtime-process@0.7.0 + - @aoagents/ao-plugin-agent-claude-code@0.7.0 + - @aoagents/ao-plugin-agent-codex@0.7.0 + - @aoagents/ao-plugin-agent-aider@0.7.0 + - @aoagents/ao-plugin-agent-opencode@0.7.0 + - @aoagents/ao-plugin-workspace-worktree@0.7.0 + - @aoagents/ao-plugin-workspace-clone@0.7.0 + - @aoagents/ao-plugin-tracker-github@0.7.0 + - @aoagents/ao-plugin-tracker-linear@0.7.0 + - @aoagents/ao-plugin-scm-github@0.7.0 + - @aoagents/ao-plugin-notifier-desktop@0.7.0 + - @aoagents/ao-plugin-notifier-slack@0.7.0 + - @aoagents/ao-plugin-notifier-webhook@0.7.0 + - @aoagents/ao-plugin-notifier-composio@0.7.0 + - @aoagents/ao-plugin-terminal-iterm2@0.7.0 + - @aoagents/ao-plugin-terminal-web@0.7.0 + - @aoagents/ao-plugin-agent-cursor@0.7.0 + - @aoagents/ao-plugin-agent-kimicode@0.7.0 + - @aoagents/ao-plugin-notifier-discord@0.7.0 + - @aoagents/ao-plugin-notifier-openclaw@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts index cc90bdabb..ed0dfd3e1 100644 --- a/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts +++ b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts @@ -239,6 +239,7 @@ describe("ao stop — activity events", () => { }); it("emits cli.stop_invoked at the start of the action", async () => { + const projectArg = "https://token@example.com/org/repo.git"; // Force a fast failure so the action exits quickly after emitting stop_invoked. mockGetRunning.mockResolvedValue(null); // Make loadConfig throw so we hit the outer catch @@ -263,13 +264,17 @@ describe("ao stop — activity events", () => { program.exitOverride(); reloaded.registerStop(program); - await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow(); + await expect(program.parseAsync(["node", "ao", "stop", projectArg])).rejects.toThrow(); const events = recordedEvents(); expect(events).toContainEqual( expect.objectContaining({ kind: "cli.stop_invoked", source: "cli", + summary: "ao stop invoked", + data: expect.objectContaining({ + projectArg, + }), }), ); @@ -525,6 +530,7 @@ describe("ao start — activity events (failure paths)", () => { }); it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => { + const projectArg = "https://token@example.com/org/repo.git"; const resolveProjectMod = await import("../../src/lib/resolve-project.js"); vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue( new Error("project resolution exploded"), @@ -533,7 +539,7 @@ describe("ao start — activity events (failure paths)", () => { const program = buildProgram(); try { - await program.parseAsync(["node", "ao", "start"]); + await program.parseAsync(["node", "ao", "start", projectArg]); } catch { // process.exit(1) throws in the spy } @@ -544,8 +550,9 @@ describe("ao start — activity events (failure paths)", () => { kind: "cli.start_invoked", source: "cli", level: "info", + summary: "ao start invoked", data: expect.objectContaining({ - projectArg: null, + projectArg, }), }), ); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 841830617..60a247c8e 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -34,6 +34,9 @@ const { mockSpawn, mockFindPidByPort, mockKillProcessTree, + mockSweepDaemonChildren, + mockScanAoOrphans, + mockReapAoOrphans, mockStartProjectSupervisor, } = vi.hoisted(() => ({ mockExec: vi.fn(), @@ -56,6 +59,9 @@ const { mockSpawn: vi.fn(), mockFindPidByPort: vi.fn(), mockKillProcessTree: vi.fn(), + mockSweepDaemonChildren: vi.fn(), + mockScanAoOrphans: vi.fn(), + mockReapAoOrphans: vi.fn(), mockStartProjectSupervisor: vi.fn(), })); @@ -144,6 +150,9 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { }, findPidByPort: mockFindPidByPort, killProcessTree: mockKillProcessTree, + sweepDaemonChildren: mockSweepDaemonChildren, + scanAoOrphans: mockScanAoOrphans, + reapAoOrphans: mockReapAoOrphans, }; }); @@ -282,6 +291,7 @@ import { registerStart, registerStop, autoCreateConfig } from "../../src/command let tmpDir: string; let program: Command; let cwdSpy: ReturnType; +let originalAoGlobalConfig: string | undefined; function createSpawnChild(options?: { /** Emit `error` instead of `close`. */ @@ -319,6 +329,8 @@ function createSpawnChild(options?: { beforeEach(async () => { tmpDir = mkdtempSync(join(tmpdir(), "ao-start-test-")); + originalAoGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + process.env["AO_GLOBAL_CONFIG"] = join(tmpDir, "global-agent-orchestrator.yaml"); program = new Command(); program.exitOverride(); @@ -398,6 +410,22 @@ beforeEach(async () => { mockFindPidByPort.mockResolvedValue(null); mockKillProcessTree.mockReset(); mockKillProcessTree.mockResolvedValue(undefined); + mockSweepDaemonChildren.mockReset(); + mockSweepDaemonChildren.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); + mockScanAoOrphans.mockReset(); + mockScanAoOrphans.mockResolvedValue([]); + mockReapAoOrphans.mockReset(); + mockReapAoOrphans.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); mockStartProjectSupervisor.mockReset(); mockStartProjectSupervisor.mockResolvedValue({ stop: vi.fn(), reconcileNow: vi.fn() }); mockDetectOpenClawInstallation.mockReset(); @@ -436,6 +464,8 @@ beforeEach(async () => { afterEach(() => { if (cwdSpy) cwdSpy.mockRestore(); + if (originalAoGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = originalAoGlobalConfig; rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -1986,6 +2016,7 @@ describe("start command — platform-aware runtime fallback", () => { .join("\n"); expect(output).toContain("Stopped sessions for"); expect(output).not.toContain("Dashboard stopped"); + expect(mockSweepDaemonChildren).not.toHaveBeenCalled(); }); it("targeted stop does NOT unregister running.json", async () => { @@ -2110,6 +2141,7 @@ describe("start command — platform-aware runtime fallback", () => { // not a direct process.kill — that's how it gets `taskkill /T /F` on // Windows and process-group kill on Unix. Assert on the mock. expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM"); + expect(mockSweepDaemonChildren).toHaveBeenCalledWith({ ownerPid: 99999 }); expect(mockUnregister).toHaveBeenCalled(); expect(mockRemoveProjectFromRunning).not.toHaveBeenCalled(); }); @@ -2390,7 +2422,12 @@ describe("start command — already-running detection", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2582,7 +2619,12 @@ describe("start command — already-running detection", () => { configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2636,7 +2678,12 @@ describe("start command — already-running detection", () => { const { stringify: yamlStringify } = await import("yaml"); const originalYaml = yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2689,7 +2736,12 @@ describe("start command — path-based deduplication in addProjectToConfig", () configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2742,7 +2794,12 @@ describe("start command — path-based deduplication in addProjectToConfig", () configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "old-name": { name: "Old Name", @@ -2802,7 +2859,12 @@ describe("start command — global registry mutations", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { current: { projectId: "current", @@ -2903,7 +2965,12 @@ describe("start command — global registry mutations", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { current: { projectId: "current", diff --git a/packages/cli/__tests__/commands/update-instrumentation.test.ts b/packages/cli/__tests__/commands/update-instrumentation.test.ts index 737edf637..30fb22156 100644 --- a/packages/cli/__tests__/commands/update-instrumentation.test.ts +++ b/packages/cli/__tests__/commands/update-instrumentation.test.ts @@ -178,4 +178,56 @@ describe("ao update — activity events", () => { }), ); }); + + it("emits cli.update_failed when npm registry lookup returns no version", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue({ + currentVersion: "0.2.2", + latestVersion: null, + isOutdated: false, + installMethod: "npm-global" as const, + recommendedCommand: "npm install -g @aoagents/ao@latest", + checkedAt: null, + }); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + method: "npm-global", + reason: "registry_unreachable", + }), + }), + ); + }); + + it("emits cli.update_failed when npm registry lookup throws", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockRejectedValue(new Error("registry timeout")); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + method: "npm-global", + reason: "registry_lookup_threw", + errorMessage: "registry timeout", + }), + }), + ); + }); }); diff --git a/packages/cli/__tests__/lib/daemon.test.ts b/packages/cli/__tests__/lib/daemon.test.ts index c50e1edd1..990fa5b80 100644 --- a/packages/cli/__tests__/lib/daemon.test.ts +++ b/packages/cli/__tests__/lib/daemon.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type * as AoCore from "@aoagents/ao-core"; -const { mockUnregister, mockWaitForExit, mockKillProcessTree } = vi.hoisted(() => ({ - mockUnregister: vi.fn(), - mockWaitForExit: vi.fn(), - mockKillProcessTree: vi.fn(), -})); +const { mockUnregister, mockWaitForExit, mockKillProcessTree, mockSweepDaemonChildren } = + vi.hoisted(() => ({ + mockUnregister: vi.fn(), + mockWaitForExit: vi.fn(), + mockKillProcessTree: vi.fn(), + mockSweepDaemonChildren: vi.fn(), + })); vi.mock("../../src/lib/running-state.js", () => ({ unregister: mockUnregister, @@ -17,6 +19,7 @@ vi.mock("@aoagents/ao-core", async () => { return { ...actual, killProcessTree: mockKillProcessTree, + sweepDaemonChildren: mockSweepDaemonChildren, }; }); @@ -37,6 +40,13 @@ beforeEach(() => { mockWaitForExit.mockReset(); mockKillProcessTree.mockReset(); mockKillProcessTree.mockResolvedValue(undefined); + mockSweepDaemonChildren.mockReset(); + mockSweepDaemonChildren.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); }); afterEach(() => { @@ -93,6 +103,7 @@ describe("killExistingDaemon", () => { it("uses killProcessTree(SIGTERM), awaits exit, and unregisters on the happy path", async () => { mockWaitForExit.mockResolvedValueOnce(true); await killExistingDaemon(fakeRunning); + expect(mockSweepDaemonChildren).toHaveBeenCalledWith({ ownerPid: 12345 }); expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM"); expect(mockKillProcessTree).toHaveBeenCalledTimes(1); expect(mockWaitForExit).toHaveBeenCalledWith(12345, 5000); diff --git a/packages/cli/__tests__/lib/update-channel-onboarding.test.ts b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts index f06726d28..42053d4be 100644 --- a/packages/cli/__tests__/lib/update-channel-onboarding.test.ts +++ b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts @@ -105,12 +105,23 @@ describe("update-channel-onboarding", () => { expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); }); - it("prompts and persists the chosen channel on first run", async () => { + it("does not prompt when global config does not exist", async () => { mockExistsSync.mockReturnValue(false); const prompt = vi.fn().mockResolvedValue("nightly" as const); await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).not.toHaveBeenCalled(); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); + }); + + it("prompts and persists the chosen channel when global config exists but updateChannel is unset", async () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; // no updateChannel set + const prompt = vi.fn().mockResolvedValue("nightly" as const); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).toHaveBeenCalledTimes(1); expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1); const [config] = mockSaveGlobalConfig.mock.calls[0]!; @@ -118,7 +129,8 @@ describe("update-channel-onboarding", () => { }); it("falls back to 'manual' when the user dismisses the prompt", async () => { - mockExistsSync.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; // no updateChannel set const prompt = vi.fn().mockRejectedValue(new Error("dismissed")); await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); @@ -129,7 +141,8 @@ describe("update-channel-onboarding", () => { }); it("does not re-prompt the second time it runs after persisting", async () => { - mockExistsSync.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; const prompt = vi.fn().mockResolvedValue("stable" as const); // First call: persists. @@ -137,7 +150,6 @@ describe("update-channel-onboarding", () => { expect(prompt).toHaveBeenCalledTimes(1); // Simulate the persisted state. - mockExistsSync.mockReturnValue(true); mockGlobalConfig.value = { updateChannel: "stable" }; await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); @@ -154,11 +166,10 @@ describe("update-channel-onboarding", () => { expect((config as { updateChannel: string }).updateChannel).toBe("nightly"); }); - it("creates a fresh config when none exists", () => { + it("does not write when global config does not exist", () => { mockExistsSync.mockReturnValue(false); persistUpdateChannel("stable"); - const [config] = mockSaveGlobalConfig.mock.calls[0]!; - expect((config as { updateChannel: string }).updateChannel).toBe("stable"); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index b86ceb410..99bd6290d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.6.0", + "version": "0.8.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 79c7e10ba..e9a3cb083 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -9,7 +9,7 @@ * (or equivalent flag) at launch time — no file writing required. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve, basename, dirname } from "node:path"; import { cwd } from "node:process"; @@ -27,6 +27,8 @@ import { isTerminalSession, getDefaultRuntime, isWindows, + isMac, + isLinux, findPidByPort, killProcessTree, loadLocalProjectConfigDetailed, @@ -38,9 +40,15 @@ import { type ProjectConfig, type ParsedRepoUrl, writeLocalProjectConfig, + spawnManagedDaemonChild, + sweepDaemonChildren, + scanAoOrphans, + reapAoOrphans, + type DaemonChildSweepResult, + type AoOrphanProcess, } from "@aoagents/ao-core"; import { parse as yamlParse, stringify as yamlStringify } from "yaml"; -import { exec, execSilent, forwardSignalsToChild, git } from "../lib/shell.js"; +import { exec, execSilent, git } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { listLifecycleWorkers } from "../lib/lifecycle-service.js"; import { startBunTmpJanitor } from "../lib/bun-tmp-janitor.js"; @@ -97,7 +105,7 @@ import { tryInstallWithAttempts, } from "../lib/install-helpers.js"; import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js"; -import { installShutdownHandlers } from "../lib/shutdown.js"; +import { installShutdownHandlers, isShutdownInProgress } from "../lib/shutdown.js"; import { resolveOrCreateProject } from "../lib/resolve-project.js"; import { pathsEqual } from "../lib/path-equality.js"; import { maybePromptForUpdateChannel } from "../lib/update-channel-onboarding.js"; @@ -318,10 +326,10 @@ async function promptAgentSelection(): Promise<{ } function ghInstallAttempts(): InstallAttempt[] { - if (process.platform === "darwin") { + if (isMac()) { return [{ cmd: "brew", args: ["install", "gh"], label: "brew install gh" }]; } - if (process.platform === "linux") { + if (isLinux()) { return [ { cmd: "sudo", @@ -582,6 +590,19 @@ export async function autoCreateConfig(workingDir: string): Promise { - try { - dashboardProcess?.kill("SIGTERM"); - } catch { - // already dead - } - }; - /* c8 ignore stop */ - process.on("exit", killDashboardChild); - dashboardProcess.on("exit", (code) => { - process.removeListener("exit", killDashboardChild); if (openAbort) openAbort.abort(); + if (isShutdownInProgress()) return; if (code !== 0 && code !== null) { console.error(chalk.red(`Dashboard exited with code ${code}`)); } @@ -1278,6 +1278,60 @@ async function stopDashboard(port: number): Promise { console.log(chalk.yellow("Could not stop dashboard (may not be running)")); } +function formatSweepSummary(result: DaemonChildSweepResult): string { + return `${result.terminated} graceful, ${result.forceKilled} force-killed${ + result.failed > 0 ? `, ${result.failed} failed` : "" + }`; +} + +async function sweepRegisteredDaemonChildren(ownerPid?: number): Promise { + const result = await sweepDaemonChildren({ ownerPid }); + if (result.attempted > 0) { + console.log( + chalk.dim( + ` Swept ${result.attempted} registered daemon child(ren): ${formatSweepSummary(result)}`, + ), + ); + } +} + +function describeAoOrphans(orphans: AoOrphanProcess[]): string { + return orphans + .map((orphan) => `${orphan.pid} (${orphan.role})`) + .slice(0, 8) + .join(", "); +} + +async function maybeSweepAoOrphansOnStart(reapOrphans: boolean | undefined): Promise { + const orphans = await scanAoOrphans(); + if (orphans.length === 0) return; + + if (!reapOrphans && isHumanCaller()) { + console.log( + chalk.yellow( + `\n Found ${orphans.length} orphaned AO child process(es): ${describeAoOrphans(orphans)}`, + ), + ); + reapOrphans = await promptConfirm("Kill orphaned AO child processes before starting?", true); + } + + if (!reapOrphans) { + console.log( + chalk.yellow( + ` Found ${orphans.length} orphaned AO child process(es). Run \`ao start --reap-orphans\` to clean them up.`, + ), + ); + return; + } + + const result = await reapAoOrphans(orphans); + console.log( + chalk.green( + ` Reaped ${result.attempted} orphaned AO child process(es): ${formatSweepSummary(result)}`, + ), + ); +} + /** * Spawn an orchestrator session against an already-running daemon, invalidate * the dashboard's project cache, and surface enough context for the user to @@ -1364,6 +1418,7 @@ export function registerStart(program: Command): void { .option("--rebuild", "Clean and rebuild dashboard before starting") .option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)") .option("--interactive", "Prompt to configure config settings") + .option("--reap-orphans", "Kill orphaned AO child processes before starting") .action( async ( projectArg?: string, @@ -1373,13 +1428,14 @@ export function registerStart(program: Command): void { rebuild?: boolean; dev?: boolean; interactive?: boolean; + reapOrphans?: boolean; }, ) => { recordActivityEvent({ source: "cli", kind: "cli.start_invoked", level: "info", - summary: `ao start invoked${projectArg ? ` for ${projectArg}` : ""}`, + summary: "ao start invoked", data: { projectArg: projectArg ?? null, dashboard: opts?.dashboard !== false, @@ -1400,6 +1456,7 @@ export function registerStart(program: Command): void { try { releaseStartupLock = await acquireStartupLock(); + await maybeSweepAoOrphansOnStart(opts?.reapOrphans); let config: OrchestratorConfig; let projectId: string; let project: ProjectConfig; @@ -1684,9 +1741,7 @@ export function registerStart(program: Command): void { }); // Ctrl+C and `ao stop` (which sends SIGTERM) perform a full - // graceful shutdown: kill sessions, record last-stop state for - // restore, unregister, then exit. See lib/shutdown.ts. - installShutdownHandlers({ configPath: config.configPath, projectId }); + // graceful shutdown via the handler installed inside runStartup(). } catch (err) { recordActivityEvent({ source: "cli", @@ -1773,7 +1828,7 @@ export function registerStop(program: Command): void { source: "cli", kind: "cli.stop_invoked", level: "info", - summary: `ao stop invoked${projectArg ? ` for ${projectArg}` : ""}`, + summary: "ao stop invoked", data: { projectArg: projectArg ?? null, all: opts.all === true, @@ -1793,6 +1848,7 @@ export function registerStop(program: Command): void { // protocol so node-pty disposes ConPTY gracefully (avoids WER // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); // killProcessTree handles process trees on Windows (taskkill /T /F) // and process groups on Unix; it swallows "already dead" internally. await killProcessTree(running.pid, "SIGTERM"); @@ -1975,6 +2031,7 @@ export function registerStop(program: Command): void { // protocol so node-pty disposes ConPTY gracefully (avoids WER // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); try { await killProcessTree(running.pid, "SIGTERM"); recordActivityEvent({ @@ -1999,6 +2056,8 @@ export function registerStop(program: Command): void { }); } await unregister(); + } else { + await sweepRegisteredDaemonChildren(); } await stopDashboard(running?.port ?? port); } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 60e005c81..8e3d37dd9 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -293,9 +293,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise { // would overwrite cache.channel before we can read it. const previousChannel = readCachedUpdateInfo(method)?.channel; - const info = await checkForUpdate({ force: true, channel }); + let info: Awaited>; + try { + info = await checkForUpdate({ force: true, channel }); + } catch (error) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: npm registry lookup threw`, + data: { + method, + channel, + reason: "registry_lookup_threw", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); + console.error(chalk.red("Could not reach npm registry. Check your network and try again.")); + process.exit(1); + } if (!info.latestVersion) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: npm registry lookup returned no version`, + data: { method, channel, reason: "registry_unreachable" }, + }); console.error(chalk.red("Could not reach npm registry. Check your network and try again.")); process.exit(1); } @@ -406,8 +431,8 @@ async function handleNpmUpdate(method: InstallMethod): Promise { source: "cli", kind: "cli.update_failed", level: "error", - summary: `ao update (npm/pnpm) failed: install command exited non-zero`, - data: { method: "npm-global", command, exitCode }, + summary: `ao update (${method}) failed: install command exited non-zero`, + data: { method, command, exitCode }, }); process.exit(exitCode); } diff --git a/packages/cli/src/lib/daemon.ts b/packages/cli/src/lib/daemon.ts index 9a5c60e6a..5ccccd924 100644 --- a/packages/cli/src/lib/daemon.ts +++ b/packages/cli/src/lib/daemon.ts @@ -20,7 +20,7 @@ */ import chalk from "chalk"; -import { killProcessTree } from "@aoagents/ao-core"; +import { killProcessTree, sweepDaemonChildren } from "@aoagents/ao-core"; import { unregister, waitForExit, type RunningState } from "./running-state.js"; /** @@ -90,6 +90,7 @@ export function attachToDaemon(running: RunningState): AttachedDaemon { * internally. */ export async function killExistingDaemon(running: RunningState): Promise { + await sweepDaemonChildren({ ownerPid: running.pid }); await killProcessTree(running.pid, "SIGTERM"); if (!(await waitForExit(running.pid, 5000))) { console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL...")); diff --git a/packages/cli/src/lib/shutdown.ts b/packages/cli/src/lib/shutdown.ts index a89163eed..6c215403b 100644 --- a/packages/cli/src/lib/shutdown.ts +++ b/packages/cli/src/lib/shutdown.ts @@ -12,7 +12,13 @@ * see ao-118 plan PR B). */ -import { isTerminalSession, loadConfig, recordActivityEvent } from "@aoagents/ao-core"; +import { + isTerminalSession, + loadConfig, + markDaemonShutdownHandlerInstalled, + recordActivityEvent, + sweepDaemonChildren, +} from "@aoagents/ao-core"; import { stopBunTmpJanitor } from "./bun-tmp-janitor.js"; import { getSessionManager } from "./create-session-manager.js"; import { stopAllLifecycleWorkers } from "./lifecycle-service.js"; @@ -36,6 +42,10 @@ export interface ShutdownContext { let handlersInstalled = false; let shuttingDown = false; +export function isShutdownInProgress(): boolean { + return shuttingDown; +} + /** * Install SIGINT/SIGTERM handlers. Process-wide idempotent — calling * this more than once is a no-op. Only the first signal triggers @@ -45,6 +55,7 @@ let shuttingDown = false; export function installShutdownHandlers(ctx: ShutdownContext): void { if (handlersInstalled) return; handlersInstalled = true; + markDaemonShutdownHandlerInstalled(); const shutdown = (signal: NodeJS.Signals): void => { if (shuttingDown) return; @@ -137,6 +148,7 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { } } + await sweepDaemonChildren({ ownerPid: process.pid }); await unregister(); if (lastStopWriteError) { throw lastStopWriteError; diff --git a/packages/cli/src/lib/update-channel-onboarding.ts b/packages/cli/src/lib/update-channel-onboarding.ts index 4d5d665de..8c1d10bb3 100644 --- a/packages/cli/src/lib/update-channel-onboarding.ts +++ b/packages/cli/src/lib/update-channel-onboarding.ts @@ -54,15 +54,20 @@ export function hasChosenUpdateChannel(): boolean { /** * Persist the chosen channel to the global config. - * Loads existing config (or creates a new one via `createDefaultGlobalConfig`) - * and writes the field. + * + * No-op when the global config doesn't exist — autoCreateConfig() handles + * bootstrap during first run. When the file exists but can't be parsed, + * falls back to a fresh default config. */ export function persistUpdateChannel(channel: UpdateChannel): void { const path = getGlobalConfigPath(); - const existing = existsSync(path) ? loadGlobalConfig(path) : null; - const next: GlobalConfig = existing - ? { ...existing, updateChannel: channel } - : { ...createDefaultGlobalConfig(), updateChannel: channel }; + // Don't create an empty global config here — autoCreateConfig() handles + // bootstrap (with project registration) during first run. Writing an empty + // husk poisons the dashboard, which loads the global config and finds + // zero projects. + if (!existsSync(path)) return; + const existing = loadGlobalConfig(path) ?? createDefaultGlobalConfig(); + const next: GlobalConfig = { ...existing, updateChannel: channel }; saveGlobalConfig(next, path); } @@ -81,6 +86,12 @@ export async function maybePromptForUpdateChannel(deps: PromptDeps = {}): Promis if (!isInteractive()) return; if (hasChosenUpdateChannel()) return; + // No global config yet — skip. autoCreateConfig() handles full bootstrap + // (creating the global config and registering the project) during first run. + // On the next `ao start` the global config will exist and the prompt fires. + // Skipping here avoids writing an empty husk that poisons the dashboard. + if (!existsSync(getGlobalConfigPath())) return; + console.log(chalk.bold("\nHow do you want to receive updates?")); console.log( chalk.dim( diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 663adb01c..31a717031 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,87 @@ # @aoagents/ao-core +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- fe33bb7: Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:\_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + ## 0.6.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 00b11bac7..0b83ed14b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.6.0", + "version": "0.8.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", diff --git a/packages/core/src/__tests__/daemon-children.test.ts b/packages/core/src/__tests__/daemon-children.test.ts new file mode 100644 index 000000000..2f4ec8f4b --- /dev/null +++ b/packages/core/src/__tests__/daemon-children.test.ts @@ -0,0 +1,171 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + __getDaemonChildrenRegistryFile, + clearDaemonChildrenRegistry, + detectAoOrphansFromPsOutput, + getDaemonChildren, + registerDaemonChild, + spawnManagedDaemonChild, + sweepDaemonChildren, + unregisterDaemonChild, +} from "../daemon-children.js"; + +function isProcessAliveForTest(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +async function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); +} + +describe("daemon child registry", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "ao-daemon-children-")); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpHome; + clearDaemonChildrenRegistry(); + }); + + afterEach(() => { + clearDaemonChildrenRegistry(); + if (originalHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = originalHome; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writes, reads, and unregisters daemon child pids", () => { + registerDaemonChild({ + pid: process.pid, + parentPid: process.pid, + role: "dashboard", + command: "node dist-server/start-all.js", + }); + + expect(getDaemonChildren()).toEqual([ + expect.objectContaining({ + pid: process.pid, + parentPid: process.pid, + role: "dashboard", + command: "node dist-server/start-all.js", + }), + ]); + + const raw = JSON.parse(readFileSync(__getDaemonChildrenRegistryFile(), "utf-8")) as unknown[]; + expect(raw).toHaveLength(1); + + unregisterDaemonChild(process.pid); + expect(getDaemonChildren()).toEqual([]); + }); + + it("sweeps only children owned by the requested daemon pid", async () => { + const targetChild = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], { + stdio: "ignore", + }); + const otherChild = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], { + stdio: "ignore", + }); + + try { + expect(targetChild.pid).toBeTypeOf("number"); + expect(otherChild.pid).toBeTypeOf("number"); + const targetPid = targetChild.pid as number; + const otherPid = otherChild.pid as number; + + registerDaemonChild({ + pid: targetPid, + parentPid: 111, + role: "owned-by-target", + command: "node target.js", + }); + registerDaemonChild({ + pid: otherPid, + parentPid: 222, + role: "owned-by-other-daemon", + command: "node other.js", + }); + + const result = await sweepDaemonChildren({ ownerPid: 111, graceMs: 1_000 }); + + expect(result.attempted).toBe(1); + expect(isProcessAliveForTest(otherPid)).toBe(true); + expect(getDaemonChildren()).toContainEqual( + expect.objectContaining({ + pid: otherPid, + parentPid: 222, + role: "owned-by-other-daemon", + }), + ); + } finally { + targetChild.kill("SIGKILL"); + otherChild.kill("SIGKILL"); + await waitForChildExit(targetChild); + await waitForChildExit(otherChild); + } + }); + + it("spawnManagedDaemonChild makes registry tracking the default for daemon spawns", async () => { + const child = spawnManagedDaemonChild( + "test-child", + process.execPath, + ["-e", "setTimeout(() => {}, 30_000)"], + { stdio: "ignore" }, + ); + + expect(child.pid).toBeTypeOf("number"); + expect(getDaemonChildren()).toContainEqual( + expect.objectContaining({ + pid: child.pid, + role: "test-child", + parentPid: process.pid, + command: `${process.execPath} -e setTimeout(() => {}, 30_000)`, + }), + ); + + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", () => resolve())); + + expect(getDaemonChildren()).not.toContainEqual(expect.objectContaining({ pid: child.pid })); + }); +}); + +describe("AO orphan detection", () => { + it("detects PPID=1 AO dashboard, websocket, and lifecycle processes", () => { + const output = [ + " 90350 1 node next-server (v15.5.15)", + " 90351 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web/dist-server/start-all.js", + " 47457 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/start-all.js", + " 47458 1 node @aoagents/ao-web@0.2.4 dist-server/start-all.js", + " 47575 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/terminal-websocket.js", + " 47580 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/direct-terminal-ws.js", + " 9914 1 node /opt/homebrew/bin/ao lifecycle-worker codex-startup-factory", + " 22222 3333 node /opt/homebrew/bin/ao lifecycle-worker not-an-orphan", + " 44444 1 node unrelated-server.js", + ].join("\n"); + + expect(detectAoOrphansFromPsOutput(output)).toEqual([ + expect.objectContaining({ pid: 90350, role: "next-server" }), + expect.objectContaining({ pid: 90351, role: "ao-web" }), + expect.objectContaining({ pid: 47457, role: "ao-web" }), + expect.objectContaining({ pid: 47458, role: "ao-web" }), + expect.objectContaining({ pid: 47575, role: "ao-web" }), + expect.objectContaining({ pid: 47580, role: "ao-web" }), + expect.objectContaining({ pid: 9914, role: "lifecycle-worker" }), + ]); + }); +}); diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index eaf2e0c35..13b8ef32a 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -697,6 +697,36 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("working"); }); + it("leaves lifecycle metadata untouched when process probe is indeterminate", async () => { + vi.mocked(plugins.runtime.isAlive).mockResolvedValue(true); + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue("indeterminate"); + + const session = makeSession({ + status: "working", + workspacePath: null, + metadata: { + lifecycleEvidence: "previous_evidence", + detectingAttempts: "2", + }, + }); + const lifecycle = JSON.stringify(session.lifecycle); + const lm = setupCheck("app-1", { + session, + metaOverrides: { + lifecycle, + lifecycleEvidence: "previous_evidence", + detectingAttempts: "2", + }, + }); + const before = readMetadataRaw(env.sessionsDir, "app-1"); + + await lm.check("app-1"); + + expect(readMetadataRaw(env.sessionsDir, "app-1")).toEqual(before); + 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" }, @@ -2335,7 +2365,66 @@ describe("reactions", () => { expect(sentMessage).toContain("Potential issue detected"); }); - it("dispatches CI failure details with check names and URLs on subsequent polls", async () => { + it("dispatches CI failure summary with failed step and log tail", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + retries: 3, + escalateAfter: 3, + }, + }; + + const ciChecks = [ + { + name: "build", + status: "failed", + url: "https://github.com/org/repo/actions/runs/123/job/456", + conclusion: "FAILURE", + }, + ]; + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIFailureSummary: vi.fn().mockResolvedValue({ + failedJobs: [ + { + name: "build", + failedStep: "Run pnpm test", + runUrl: "https://github.com/org/repo/actions/runs/123/job/456", + logTail: + "AssertionError: expected true to be false\n```\nProcess completed with exit code 1", + }, + ], + }), + enrichSessionsPRBatch: mockBatchEnrichment({ ciStatus: "failing", ciChecks }), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI is failing on your PR."); + expect(sentMessage).toContain("Failed: build → Run pnpm test"); + expect(sentMessage).toContain("Failure URL: https://github.com/org/repo/actions/runs/123/job/456"); + expect(sentMessage).toContain("Log tail (last 3 lines):"); + expect(sentMessage).toContain("AssertionError: expected true to be false"); + expect(sentMessage).toContain("\u200B```"); + expect(sentMessage).toContain("Fix the issues and push again."); + expect(mockSCM.getCIFailureSummary).toHaveBeenCalledWith(makePR(), ciChecks); + }); + + it("falls back to check names and URLs when SCM lacks getCIFailureSummary", async () => { config.reactions = { "ci-failed": { auto: true, @@ -2389,6 +2478,7 @@ describe("reactions", () => { expect(lm.getStates().get("app-1")).toBe("ci_failed"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI checks are failing on your PR."); expect(sentMessage).toContain("lint"); expect(sentMessage).toContain("typecheck"); expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/123"); diff --git a/packages/core/src/__tests__/lifecycle-transition.test.ts b/packages/core/src/__tests__/lifecycle-transition.test.ts index 80ac87fbc..53fdcf358 100644 --- a/packages/core/src/__tests__/lifecycle-transition.test.ts +++ b/packages/core/src/__tests__/lifecycle-transition.test.ts @@ -129,6 +129,29 @@ describe("applyDecisionToLifecycle", () => { expect(lifecycle.session.terminatedAt).toBe("2026-04-16T12:00:00.000Z"); }); + it("clears terminal markers when transitioning into a non-terminal state", () => { + lifecycle.session.state = "terminated"; + lifecycle.session.reason = "runtime_lost"; + lifecycle.session.completedAt = "2026-04-15T12:00:00.000Z"; + lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z"; + lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z"; + + const decision: LifecycleDecision = { + status: "working", + evidence: "restore", + detecting: { attempts: 0 }, + sessionState: "working", + sessionReason: "task_in_progress", + }; + + applyDecisionToLifecycle(lifecycle, decision, nowIso); + + expect(lifecycle.session.state).toBe("working"); + expect(lifecycle.session.completedAt).toBeNull(); + expect(lifecycle.session.terminatedAt).toBeNull(); + expect(lifecycle.session.lastTransitionAt).toBe(nowIso); + }); + it("applies PR state and reason", () => { const decision: LifecycleDecision = { status: "pr_open", @@ -372,6 +395,45 @@ describe("applyLifecycleDecision (integration)", () => { expect(result.success).toBe(true); expect(result.previousStatus).toBe("spawning"); }); + + it("persists cleared terminal markers when a terminal snapshot transitions to working", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "terminated"; + lifecycle.session.reason = "runtime_lost"; + lifecycle.session.completedAt = "2026-04-16T11:00:00.000Z"; + lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z"; + lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z"; + + writeTestSession("test-5", { + status: "killed", + lifecycle: JSON.stringify(lifecycle), + worktree: "/tmp/test", + branch: "main", + }); + + const result = applyLifecycleDecision({ + dataDir, + sessionId: "test-5", + decision: { + status: "working", + evidence: "restore", + detecting: { attempts: 0 }, + sessionState: "working", + sessionReason: "task_in_progress", + }, + source: "restore", + now: new Date("2026-04-17T12:00:00.000Z"), + }); + + expect(result.success).toBe(true); + + const meta = readMetadataRaw(dataDir, "test-5"); + const persisted = JSON.parse(meta!["lifecycle"]); + expect(persisted.session.state).toBe("working"); + expect(persisted.session.completedAt).toBeNull(); + expect(persisted.session.terminatedAt).toBeNull(); + expect(persisted.session.lastTransitionAt).toBe("2026-04-17T12:00:00.000Z"); + }); }); describe("createStateTransitionDecision", () => { diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index c881ead05..e8acb988d 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -9,6 +9,7 @@ import { writeMetadata, readMetadataRaw, } from "../../metadata.js"; +import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import type { OrchestratorConfig, PluginRegistry, @@ -401,6 +402,45 @@ describe("list", () => { expect(sessions[0].activitySignal.state).toBe("null"); }); + it("does not persist runtime_lost from list() when agent activity probe is indeterminate", async () => { + const agentWithIndeterminateProbe: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockResolvedValue(null), + isProcessRunning: vi.fn().mockResolvedValue("indeterminate"), + }; + const registryWithIndeterminateProbe: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithIndeterminateProbe; + return null; + }), + }; + + const runtimeHandle = makeHandle("rt-1"); + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.handle = runtimeHandle; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle, + lifecycle, + }); + const before = readMetadataRaw(sessionsDir, "app-1"); + + const sm = createSessionManager({ config, registry: registryWithIndeterminateProbe }); + const sessions = await sm.list(); + + expect(sessions[0].status).toBe("working"); + expect(readMetadataRaw(sessionsDir, "app-1")).toEqual(before); + }); + it("marks terminal fallback-free stale activity explicitly when timing is missing", async () => { const agentWithIdleNoTimestamp: Agent = { ...mockAgent, diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index aa287bcee..9445d16f3 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -7,6 +7,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; +import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import { getWorkspaceAgentsMdPath } from "../../opencode-agents-md.js"; import { getProjectDir } from "../../paths.js"; import { @@ -283,8 +284,22 @@ describe("restore", () => { createdAt: "2025-01-01T00:00:00.000Z", runtimeHandle: makeHandle("rt-old"), }); + const staleLifecycle = createInitialCanonicalLifecycle( + "worker", + new Date("2025-01-01T00:00:00.000Z"), + ); + staleLifecycle.session.state = "terminated"; + staleLifecycle.session.reason = "runtime_lost"; + staleLifecycle.session.completedAt = "2025-01-01T00:01:00.000Z"; + staleLifecycle.session.terminatedAt = "2025-01-01T00:02:00.000Z"; + staleLifecycle.session.lastTransitionAt = "2025-01-01T00:02:00.000Z"; + staleLifecycle.pr.state = "open"; + staleLifecycle.pr.reason = "in_progress"; + staleLifecycle.pr.number = 10; + staleLifecycle.pr.url = "https://github.com/org/my-app/pull/10"; + staleLifecycle.pr.lastObservedAt = "2025-01-01T00:00:00.000Z"; updateMetadata(sessionsDir, "app-1", { - lifecycle: JSON.stringify({ session: { state: "terminated", terminatedAt: new Date().toISOString() } }), + lifecycle: JSON.stringify(staleLifecycle), }); const sm = createSessionManager({ config, registry: mockRegistry }); @@ -300,6 +315,14 @@ describe("restore", () => { expect(meta).not.toBeNull(); expect(meta!["issue"]).toBe("TEST-1"); expect(meta!["pr"]).toBe("https://github.com/org/my-app/pull/10"); + + const lifecycle = JSON.parse(meta!["lifecycle"]); + expect(lifecycle.session.state).toBe("working"); + expect(lifecycle.session.completedAt).toBeNull(); + expect(lifecycle.session.terminatedAt).toBeNull(); + expect(new Date(lifecycle.session.lastTransitionAt).getTime()).toBeGreaterThan( + new Date("2025-01-01T00:02:00.000Z").getTime(), + ); }); it("preserves displayName when restoring terminated session", async () => { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 17b77505b..37c59d3ce 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -669,8 +669,6 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig { "ci-failed": { auto: true, action: "send-to-agent", - message: - "CI is failing on your PR. Investigate the failures, fix the issues, and push again.", retries: 2, escalateAfter: 2, }, diff --git a/packages/core/src/daemon-children.ts b/packages/core/src/daemon-children.ts new file mode 100644 index 000000000..1e3d55008 --- /dev/null +++ b/packages/core/src/daemon-children.ts @@ -0,0 +1,528 @@ +import { + type ChildProcess, + type SpawnOptions, + execFile as execFileCb, + spawn, +} from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + rmdirSync, + statSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { promisify } from "node:util"; +import { atomicWriteFileSync } from "./atomic-write.js"; +import { isWindows, killProcessTree } from "./platform.js"; + +const execFileAsync = promisify(execFileCb); +const DEFAULT_GRACE_MS = 5_000; +const LOCK_STALE_MS = 10_000; + +export interface DaemonChildEntry { + pid: number; + role: string; + parentPid: number; + startedAt: string; + command?: string; +} + +export interface DaemonChildSweepResult { + attempted: number; + terminated: number; + forceKilled: number; + failed: number; +} + +export interface AoOrphanProcess { + pid: number; + ppid: number; + command: string; + role: string; +} + +export interface DaemonChildSweepOptions { + ownerPid?: number; + graceMs?: number; +} + +function getRegistryFile(): string { + return join(homedir(), ".agent-orchestrator", "daemon-children.json"); +} + +function getLockDir(): string { + return join(homedir(), ".agent-orchestrator", "daemon-children.lock"); +} + +function ensureStateDir(): void { + mkdirSync(join(homedir(), ".agent-orchestrator"), { recursive: true }); +} + +function sleepSync(ms: number): void { + const buffer = new SharedArrayBuffer(4); + const view = new Int32Array(buffer); + Atomics.wait(view, 0, 0, ms); +} + +function acquireRegistryLock(): () => void { + ensureStateDir(); + const lockDir = getLockDir(); + const deadline = Date.now() + 5_000; + while (true) { + try { + mkdirSync(lockDir); + return () => { + try { + rmdirSync(lockDir); + } catch { + // Best effort. + } + }; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw err; + try { + const ageMs = Date.now() - statSync(lockDir).mtimeMs; + if (ageMs > LOCK_STALE_MS) { + rmSync(lockDir, { recursive: true, force: true }); + continue; + } + } catch { + // Retry lock acquisition if the lock disappeared between calls. + } + if (Date.now() > deadline) { + throw new Error(`Could not acquire daemon child registry lock (${lockDir})`, { + cause: err, + }); + } + sleepSync(25); + } + } +} + +function isDaemonChildEntry(value: unknown): value is DaemonChildEntry { + if (typeof value !== "object" || value === null) return false; + const entry = value as Partial; + return ( + typeof entry.pid === "number" && + entry.pid > 0 && + typeof entry.role === "string" && + typeof entry.parentPid === "number" && + typeof entry.startedAt === "string" && + (entry.command === undefined || typeof entry.command === "string") + ); +} + +function readRawDaemonChildren(): DaemonChildEntry[] { + const file = getRegistryFile(); + if (!existsSync(file)) return []; + try { + const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isDaemonChildEntry); + } catch { + return []; + } +} + +function writeRawDaemonChildren(entries: DaemonChildEntry[]): void { + const file = getRegistryFile(); + if (entries.length === 0) { + try { + unlinkSync(file); + } catch { + // File may not exist. + } + return; + } + ensureStateDir(); + atomicWriteFileSync(file, JSON.stringify(entries, null, 2)); +} + +function isProcessAlive(pid: number): boolean { + if (pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +async function waitForProcessesExit(pids: number[], timeoutMs: number): Promise> { + const alive = new Set(pids.filter(isProcessAlive)); + const deadline = Date.now() + timeoutMs; + + while (alive.size > 0 && Date.now() < deadline) { + for (const pid of alive) { + if (!isProcessAlive(pid)) alive.delete(pid); + } + if (alive.size > 0) { + await sleep(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + for (const pid of alive) { + if (!isProcessAlive(pid)) alive.delete(pid); + } + + return alive; +} + +export function registerDaemonChild(entry: Omit): void { + if (entry.pid <= 0) return; + const release = acquireRegistryLock(); + try { + const next = readRawDaemonChildren().filter((existing) => existing.pid !== entry.pid); + next.push({ ...entry, startedAt: new Date().toISOString() }); + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +export function unregisterDaemonChild(pid: number): void { + const release = acquireRegistryLock(); + try { + const before = readRawDaemonChildren(); + const next = before.filter((entry) => entry.pid !== pid); + if (next.length === before.length) return; + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +export function getDaemonChildren(): DaemonChildEntry[] { + const release = acquireRegistryLock(); + try { + const all = readRawDaemonChildren(); + const live = all.filter((entry) => isProcessAlive(entry.pid)); + if (live.length !== all.length) writeRawDaemonChildren(live); + return live; + } finally { + release(); + } +} + +export function clearDaemonChildrenRegistry(): void { + const release = acquireRegistryLock(); + try { + writeRawDaemonChildren([]); + } finally { + release(); + } +} + +function pruneSweptDaemonChildren(sweptPids: Set): void { + const release = acquireRegistryLock(); + try { + const next = readRawDaemonChildren().filter( + (entry) => !sweptPids.has(entry.pid) || isProcessAlive(entry.pid), + ); + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +const reapedChildren = new WeakSet(); +const managedChildren = new Map(); +let managedSignalHandlersInstalled = false; +let daemonShutdownHandlerInstalled = false; +let fallbackShutdownStarted = false; + +/** + * Tell the managed child reaper that this process owns an application-level + * SIGINT/SIGTERM shutdown path. The reaper will still forward signals to + * children, but it will not install its default 50ms fallback exit; the owning + * shutdown handler is responsible for exiting after its async cleanup finishes. + */ +export function markDaemonShutdownHandlerInstalled(): void { + daemonShutdownHandlerInstalled = true; +} + +function getManagedChildPids(): number[] { + return [...managedChildren.keys()]; +} + +function getSignalExitCode(signal: NodeJS.Signals): number { + if (signal === "SIGINT") return 130; + if (signal === "SIGTERM") return 143; + return 1; +} + +function terminateManagedChildren(signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): void { + for (const [pid, child] of managedChildren) { + void killProcessTree(pid, signal); + try { + child.kill(signal); + } catch { + // Already gone. + } + } +} + +async function exitAfterManagedChildren(signal: NodeJS.Signals): Promise { + const exitCode = getSignalExitCode(signal); + const pids = getManagedChildPids(); + + terminateManagedChildren("SIGTERM"); + + const stillAlive = await waitForProcessesExit(pids, DEFAULT_GRACE_MS); + if (stillAlive.size > 0) { + terminateManagedChildren("SIGKILL"); + await waitForProcessesExit([...stillAlive], 1_000); + } + + process.exit(exitCode); +} + +function installManagedSignalHandlers(): void { + if (managedSignalHandlersInstalled || isWindows()) return; + managedSignalHandlersInstalled = true; + + const forward = (signal: NodeJS.Signals): void => { + terminateManagedChildren(); + + // Installing a signal listener disables Node's default "exit on signal" + // behaviour. If no application-level shutdown handler is present, preserve + // that default after giving managed children the same graceful + // SIGTERM→wait→SIGKILL lifecycle used by `ao stop`. + if (!daemonShutdownHandlerInstalled && !fallbackShutdownStarted) { + fallbackShutdownStarted = true; + void exitAfterManagedChildren(signal); + } + }; + + process.on("SIGINT", forward); + process.on("SIGTERM", forward); + process.on("exit", () => terminateManagedChildren()); +} + +/** + * Track a long-running daemon child in the pid registry and forward parent + * shutdown to it. If the owning process has its own shutdown handler, that + * handler remains responsible for exiting; otherwise the managed signal + * handler preserves Node's default signal-exit behaviour after forwarding. + */ +export function registerChildReaper(child: ChildProcess, role: string, command?: string): void { + if (reapedChildren.has(child)) return; + reapedChildren.add(child); + + const pid = child.pid; + if (!pid) return; + + registerDaemonChild({ pid, role, parentPid: process.pid, command }); + managedChildren.set(pid, child); + installManagedSignalHandlers(); + + const cleanup = (): void => { + managedChildren.delete(pid); + unregisterDaemonChild(pid); + }; + + child.once("exit", cleanup); + child.once("error", cleanup); +} + +/** + * The required interface for long-running subprocesses owned by the AO daemon. + * Callers get normal child_process.spawn behaviour, plus pid registry, + * signal forwarding, process-group cleanup, and registry unregister on exit. + */ +export function spawnManagedDaemonChild( + role: string, + command: string, + args: readonly string[], + options: SpawnOptions = {}, +): ChildProcess { + const child = spawn(command, [...args], options); + registerChildReaper(child, role, [command, ...args].join(" ")); + return child; +} + +export async function sweepDaemonChildren( + options: DaemonChildSweepOptions = {}, +): Promise { + const { ownerPid, graceMs = DEFAULT_GRACE_MS } = options; + const entries = getDaemonChildren().filter( + (entry) => ownerPid === undefined || entry.parentPid === ownerPid, + ); + const result: DaemonChildSweepResult = { + attempted: entries.length, + terminated: 0, + forceKilled: 0, + failed: 0, + }; + + for (const entry of entries) { + await killProcessTree(entry.pid, "SIGTERM"); + } + + const pids = entries.map((entry) => entry.pid); + const stillAliveAfterTerm = await waitForProcessesExit(pids, graceMs); + result.terminated = pids.length - stillAliveAfterTerm.size; + + for (const entry of entries.filter((entry) => stillAliveAfterTerm.has(entry.pid))) { + await killProcessTree(entry.pid, "SIGKILL"); + } + + const stillAliveAfterKill = await waitForProcessesExit([...stillAliveAfterTerm], 1_000); + result.forceKilled = stillAliveAfterTerm.size - stillAliveAfterKill.size; + result.failed = stillAliveAfterKill.size; + + pruneSweptDaemonChildren(new Set(entries.map((entry) => entry.pid))); + return result; +} + +function isAsciiWhitespace(char: string): boolean { + return char === " " || char === "\t" || char === "\r" || char === "\n"; +} + +function normalizeCommand(command: string): string { + return command.replaceAll("\\", "/").toLowerCase(); +} + +function firstCommandWord(command: string): string { + let start = 0; + while (start < command.length && isAsciiWhitespace(command[start] ?? "")) start++; + + let end = start; + while (end < command.length && !isAsciiWhitespace(command[end] ?? "")) end++; + + return command.slice(start, end); +} + +function commandLooksLikeNode(command: string): boolean { + const executable = firstCommandWord(normalizeCommand(command)); + const basename = executable.slice(executable.lastIndexOf("/") + 1); + return basename === "node" || basename === "node.exe"; +} + +export function classifyAoOrphanCommand(command: string): string | null { + if (!commandLooksLikeNode(command)) return null; + + const normalized = normalizeCommand(command); + + if ( + normalized.includes("@aoagents/ao-web") && + (normalized.includes("/dist-server/") || normalized.includes(" dist-server/")) + ) { + return "ao-web"; + } + if ( + normalized.includes("/ao lifecycle-worker ") || + normalized.includes(" ao lifecycle-worker ") + ) { + return "lifecycle-worker"; + } + if (normalized.includes("next-server")) { + return "next-server"; + } + return null; +} + +function parseLeadingUnsignedInt( + value: string, + start: number, +): { value: number; next: number } | null { + let index = start; + while (index < value.length && isAsciiWhitespace(value[index] ?? "")) index++; + + const firstDigit = index; + while (index < value.length) { + const code = value.charCodeAt(index); + if (code < 48 || code > 57) break; + index++; + } + + if (index === firstDigit) return null; + return { value: Number(value.slice(firstDigit, index)), next: index }; +} + +function parsePsLine(line: string): AoOrphanProcess | null { + const pid = parseLeadingUnsignedInt(line, 0); + if (!pid) return null; + + const ppid = parseLeadingUnsignedInt(line, pid.next); + if (!ppid) return null; + + let commandStart = ppid.next; + while (commandStart < line.length && isAsciiWhitespace(line[commandStart] ?? "")) { + commandStart++; + } + + const command = line.slice(commandStart); + if (!Number.isFinite(pid.value) || !Number.isFinite(ppid.value) || ppid.value !== 1) { + return null; + } + + const role = classifyAoOrphanCommand(command); + if (!role) return null; + return { pid: pid.value, ppid: ppid.value, command, role }; +} + +export function detectAoOrphansFromPsOutput(psOutput: string): AoOrphanProcess[] { + const orphans: AoOrphanProcess[] = []; + for (const rawLine of psOutput.replaceAll("\r\n", "\n").split("\n")) { + const line = rawLine.trimStart(); + if (!line) continue; + + const orphan = parsePsLine(line); + if (orphan) orphans.push(orphan); + } + return orphans; +} + +export async function scanAoOrphans(): Promise { + if (isWindows()) return []; + try { + const { stdout } = await execFileAsync("ps", ["-axo", "pid,ppid,command"], { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + }); + return detectAoOrphansFromPsOutput(stdout); + } catch { + return []; + } +} + +export async function reapAoOrphans( + orphans: AoOrphanProcess[], + graceMs: number = DEFAULT_GRACE_MS, +): Promise { + const result: DaemonChildSweepResult = { + attempted: orphans.length, + terminated: 0, + forceKilled: 0, + failed: 0, + }; + + for (const orphan of orphans) { + await killProcessTree(orphan.pid, "SIGTERM"); + } + + const pids = orphans.map((orphan) => orphan.pid); + const stillAliveAfterTerm = await waitForProcessesExit(pids, graceMs); + result.terminated = pids.length - stillAliveAfterTerm.size; + + for (const orphan of orphans.filter((orphan) => stillAliveAfterTerm.has(orphan.pid))) { + await killProcessTree(orphan.pid, "SIGKILL"); + } + + const stillAliveAfterKill = await waitForProcessesExit([...stillAliveAfterTerm], 1_000); + result.forceKilled = stillAliveAfterTerm.size - stillAliveAfterKill.size; + result.failed = stillAliveAfterKill.size; + + return result; +} + +export function __getDaemonChildrenRegistryFile(): string { + return getRegistryFile(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6adcdaae4..6cd385068 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,10 +164,7 @@ export { resetOpenCodeSessionListCache, } from "./opencode-shared.js"; export type { OpenCodeSessionListEntry } from "./opencode-shared.js"; -export { - getWorkspaceAgentsMdPath, - writeWorkspaceOpenCodeAgentsMd, -} from "./opencode-agents-md.js"; +export { getWorkspaceAgentsMdPath, writeWorkspaceOpenCodeAgentsMd } from "./opencode-agents-md.js"; export { writeOpenCodeConfig } from "./opencode-config.js"; export { getOrchestratorSessionId, @@ -276,6 +273,7 @@ export { export { isWindows, isMac, + isLinux, getDefaultRuntime, getShell, killProcessTree, @@ -412,6 +410,25 @@ export { type WindowsPtyHostEntry, } from "./windows-pty-registry.js"; +export { + registerDaemonChild, + unregisterDaemonChild, + getDaemonChildren, + clearDaemonChildrenRegistry, + markDaemonShutdownHandlerInstalled, + registerChildReaper, + spawnManagedDaemonChild, + sweepDaemonChildren, + classifyAoOrphanCommand, + detectAoOrphansFromPsOutput, + scanAoOrphans, + reapAoOrphans, + type DaemonChildEntry, + type DaemonChildSweepOptions, + type DaemonChildSweepResult, + type AoOrphanProcess, +} from "./daemon-children.js"; + // Activity event logging — structured diagnostic event trail export { recordActivityEvent, droppedEventCount } from "./activity-events.js"; export { isActivityEventsFtsEnabled, closeDb } from "./events-db.js"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 3ae00cf27..e4193ecbf 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -39,8 +39,12 @@ import { type ProjectConfig as _ProjectConfig, type PREnrichmentData, type CICheck, + type CIFailureSummary, + type PRInfo, type ReviewComment, type ReviewSummary, + type ProcessProbeResult, + isProcessProbeIndeterminate, } from "./types.js"; import { buildLifecycleMetadataPatch, @@ -107,6 +111,12 @@ const PERSISTENT_REACTION_KEYS = new Set(["ci-failed"]); * next real CI failure incident. */ const CI_PASSING_STABLE_THRESHOLD = 2; +type TransitionReaction = { + key: string; + result: ReactionResult | null; + messageEnriched?: boolean; +}; + type WorkspaceBranchProbe = | { kind: "branch"; branch: string } | { kind: "detached" } @@ -387,6 +397,8 @@ interface DeterminedStatus { status: SessionStatus; evidence: string; detectingAttempts: number; + /** True when probes produced no reliable verdict and lifecycle metadata must remain untouched. */ + skipMetadataWrite?: boolean; /** ISO timestamp when detecting first started. */ detectingStartedAt?: string; /** Hash of evidence for unchanged-evidence detection. */ @@ -396,6 +408,14 @@ interface DeterminedStatus { interface ProbeResult { state: "alive" | "dead" | "unknown"; failed: boolean; + indeterminate?: boolean; +} + +function processProbeResultToProbeResult(result: ProcessProbeResult): ProbeResult { + if (isProcessProbeIndeterminate(result)) { + return { state: "unknown", failed: false, indeterminate: true }; + } + return { state: result ? "alive" : "dead", failed: false }; } function splitEvidenceSignals(evidence: string): string[] { @@ -1062,8 +1082,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan try { const processAlive = await agent.isProcessRunning(session.runtimeHandle); - processProbe = { state: processAlive ? "alive" : "dead", failed: false }; - if (!processAlive) { + processProbe = processProbeResultToProbeResult(processAlive); + if (processAlive === false) { lifecycle.runtime.state = "exited"; lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; @@ -1129,14 +1149,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if ( processProbe.state === "unknown" && + !processProbe.indeterminate && session.runtimeHandle && canProbeRuntimeIdentity && agent ) { try { const processAlive = await agent.isProcessRunning(session.runtimeHandle); - processProbe = { state: processAlive ? "alive" : "dead", failed: false }; - if (!processAlive) { + processProbe = processProbeResultToProbeResult(processAlive); + if (processAlive === false) { lifecycle.runtime.state = "exited"; lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; @@ -1159,6 +1180,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + if (processProbe.indeterminate) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "agent", + kind: "agent.process_probe_failed", + level: "warn", + summary: `agent.isProcessRunning indeterminate for ${session.id}`, + data: { + agentName, + reason: "probe_indeterminate", + }, + }); + return { + status: session.status, + evidence: session.metadata["lifecycleEvidence"] ?? "process_probe_indeterminate", + detectingAttempts: currentDetectingAttempts, + detectingStartedAt: currentDetectingStartedAt, + detectingEvidenceHash: currentDetectingEvidenceHash, + skipMetadataWrite: true, + }; + } + const probeDecision = resolveProbeDecision({ currentAttempts: currentDetectingAttempts, runtimeProbe, @@ -1608,7 +1652,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1883,11 +1927,40 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } - /** - * Format CI check failures into a human-readable message for the agent. - * Includes check names, statuses, and links for debugging. - */ - function formatCIFailureMessage(failedChecks: CICheck[]): string { + function isFailedCICheck(check: CICheck): boolean { + return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE"; + } + + function formatCIFailureSummaryMessage(summary: CIFailureSummary): string { + const lines = ["CI is failing on your PR.", ""]; + + for (const job of summary.failedJobs) { + const failed = job.failedStep ? `${job.name} → ${job.failedStep}` : job.name; + lines.push(`Failed: ${failed}`); + lines.push(`Failure URL: ${job.runUrl}`); + + if (job.logTail) { + const lineCount = job.logTail.split(/\r?\n/).length; + const lineLabel = lineCount === 1 ? "line" : "lines"; + const escapedTail = escapeMarkdownCodeFenceClosers(job.logTail); + lines.push("", `Log tail (last ${lineCount} ${lineLabel}):`, "```", escapedTail, "```"); + } + + lines.push(""); + } + + lines.push("Fix the issues and push again."); + return lines.join("\n"); + } + + function escapeMarkdownCodeFenceClosers(logTail: string): string { + return logTail + .split(/\r?\n/) + .map((line) => (line.startsWith("```") ? `\u200B${line}` : line)) + .join("\n"); + } + + function formatCIFailureChecksFallback(failedChecks: CICheck[]): string { const lines = ["CI checks are failing on your PR. Here are the failed checks:", ""]; for (const check of failedChecks) { const status = check.conclusion ?? check.status; @@ -1898,11 +1971,60 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } + /** + * Format CI failures into a human-readable message for the agent. + * Uses SCM-provided failed job/step/log details when available and falls + * back to check names/statuses/links for SCM plugins that do not implement it. + */ + async function formatCIFailureMessage( + scm: SCM, + pr: PRInfo, + failedChecks: CICheck[], + ): Promise { + if (scm.getCIFailureSummary) { + try { + const summary = await scm.getCIFailureSummary(pr, failedChecks); + if (summary?.failedJobs.length) { + return formatCIFailureSummaryMessage(summary); + } + } catch { + // Fall back to check names when summary enrichment fails. + } + } + + return formatCIFailureChecksFallback(failedChecks); + } + + async function getFailedCIChecks( + scm: SCM, + pr: PRInfo, + options: { allowFetch: boolean }, + ): Promise { + const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; + const cachedEnrichment = prEnrichmentCache.get(prKey); + + let checks: CICheck[] | undefined = cachedEnrichment?.ciChecks; + if (checks === undefined && options.allowFetch) { + try { + checks = await scm.getCIChecks(pr); + } catch { + return null; + } + } + + const failedChecks = checks?.filter(isFailedCICheck) ?? []; + return failedChecks.length > 0 ? failedChecks : null; + } + + function makeCIFailureFingerprint(failedChecks: CICheck[]): string { + return makeFingerprint(failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`)); + } + async function maybeDispatchCIFailureDetails( session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1938,32 +2060,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return; } - // Fetch individual CI checks for failure details. - // Use batch enrichment data when available to avoid an extra REST call; - // fall back to getCIChecks() when the batch didn't run this cycle. - const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; - const cachedEnrichment = prEnrichmentCache.get(prKey); + const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: true }); + if (!failedChecks) return; - let checks: CICheck[]; - if (cachedEnrichment?.ciChecks !== undefined) { - checks = cachedEnrichment.ciChecks; - } else { - try { - checks = await scm.getCIChecks(session.pr); - } catch { - // Failed to fetch checks — skip this cycle - return; - } - } - - const failedChecks = checks.filter( - (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", - ); - if (failedChecks.length === 0) return; - - const ciFingerprint = makeFingerprint( - failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`), - ); + const ciFingerprint = makeCIFailureFingerprint(failedChecks); const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? ""; @@ -1977,12 +2077,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); } - // If the transition reaction already sent a ci-failed reaction (now enriched - // with detailed check info from the batch cache), record the dispatch hash so - // subsequent polls don't re-send the same failure details. + // If the transition reaction already delivered an enriched agent message, + // or handled a non-agent action, record the dispatch hash so subsequent + // polls don't re-send the same failure details. if ( transitionReaction?.key === ciReactionKey && - transitionReaction.result?.success + transitionReaction.result?.success && + (transitionReaction.messageEnriched === true || + transitionReaction.result.action !== "send-to-agent") ) { updateSessionMetadata(session, { lastCIFailureDispatchHash: ciFingerprint, @@ -2003,7 +2105,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reactionConfig.action && (reactionConfig.auto !== false || reactionConfig.action === "notify") ) { - const detailedMessage = formatCIFailureMessage(failedChecks); + const detailedMessage = await formatCIFailureMessage(scm, session.pr, failedChecks); try { if (reactionConfig.action === "send-to-agent") { @@ -2293,9 +2395,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const previousLifecycle = cloneLifecycle(session.lifecycle); const previousPRState = session.lifecycle.pr.state; const assessment = await determineStatus(session); + if (assessment.skipMetadataWrite) { + states.set(session.id, oldStatus); + return; + } const newStatus = assessment.status; const lifecycleChanged = session.metadata["lifecycle"] !== JSON.stringify(session.lifecycle); - let transitionReaction: { key: string; result: ReactionResult | null } | undefined; + let transitionReaction: TransitionReaction | undefined; const nextLifecycleEvidence = assessment.evidence; const nextDetectingAttempts = @@ -2433,18 +2539,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reactionKey) { let reactionConfig = getReactionConfigForSession(session, reactionKey); + let messageEnriched = false; - // Enrich CI failure message with actual check details from batch cache - if (reactionKey === "ci-failed" && session.pr && reactionConfig) { - const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; - const cachedData = prEnrichmentCache.get(prKey); - if (cachedData?.ciChecks) { - const failedChecks = cachedData.ciChecks.filter((c) => c.status === "failed"); - if (failedChecks.length > 0) { + // Enrich CI failure message with failed job/step/log details when + // batch check data is already available. If it is not, the + // post-transition CI dispatcher below fetches checks and sends the + // composed message without altering lifecycle state transitions. + if ( + reactionKey === "ci-failed" && + session.pr && + reactionConfig?.action === "send-to-agent" + ) { + const project = config.projects[session.projectId]; + const scm = project?.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (scm) { + const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: false }); + if (failedChecks) { reactionConfig = { ...reactionConfig, - message: formatCIFailureMessage(failedChecks), + message: await formatCIFailureMessage(scm, session.pr, failedChecks), }; + messageEnriched = true; } } } @@ -2453,7 +2568,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // auto: false skips automated agent actions but still allows notifications if (reactionConfig.auto !== false || reactionConfig.action === "notify") { const reactionResult = await executeReaction(session, reactionKey, reactionConfig); - transitionReaction = { key: reactionKey, result: reactionResult }; + transitionReaction = { key: reactionKey, result: reactionResult, messageEnriched }; observer.recordOperation({ metric: "lifecycle_poll", operation: "lifecycle.transition.reaction", diff --git a/packages/core/src/lifecycle-state.ts b/packages/core/src/lifecycle-state.ts index 0daf6f609..6192c23a9 100644 --- a/packages/core/src/lifecycle-state.ts +++ b/packages/core/src/lifecycle-state.ts @@ -128,6 +128,19 @@ const CanonicalSessionLifecycleSchema = z.object({ type ParsedCanonicalSessionLifecycle = z.infer; +const TERMINAL_CANONICAL_SESSION_STATES: ReadonlySet = new Set([ + "done", + "terminated", +]); + +export function clearTerminalMarkersForNonTerminalState( + lifecycle: CanonicalSessionLifecycle, +): void { + if (TERMINAL_CANONICAL_SESSION_STATES.has(lifecycle.session.state)) return; + lifecycle.session.completedAt = null; + lifecycle.session.terminatedAt = null; +} + function normalizeTimestamp(value: unknown, fallback: string | null = null): string | null { if (typeof value !== "string") return fallback; const parsed = Date.parse(value); diff --git a/packages/core/src/lifecycle-transition.ts b/packages/core/src/lifecycle-transition.ts index f54aebb34..b35b46d40 100644 --- a/packages/core/src/lifecycle-transition.ts +++ b/packages/core/src/lifecycle-transition.ts @@ -19,7 +19,11 @@ import type { SessionId, SessionStatus, } from "./types.js"; -import { cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; +import { + clearTerminalMarkersForNonTerminalState, + cloneLifecycle, + deriveLegacyStatus, +} from "./lifecycle-state.js"; import { updateMetadata, readMetadataRaw, readCanonicalLifecycle } from "./metadata.js"; import type { LifecycleDecision } from "./lifecycle-status-decisions.js"; @@ -35,6 +39,7 @@ export type TransitionSource = | "cleanup" // Session cleanup | "claim_pr"; // PR claim + /** * Result of a lifecycle transition attempt. */ @@ -106,6 +111,8 @@ export function applyDecisionToLifecycle( lifecycle.session.terminatedAt = nowIso; } } + + clearTerminalMarkersForNonTerminalState(lifecycle); } /** diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 763a953f0..5cde1af87 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -20,6 +20,10 @@ export function isMac(): boolean { return process.platform === "darwin"; } +export function isLinux(): boolean { + return process.platform === "linux"; +} + export function getDefaultRuntime(): "tmux" | "process" { return isWindows() ? "process" : "tmux"; } diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index c06725997..dff4fc7b7 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { + isProcessProbeIndeterminate, TERMINAL_STATUSES as TERMINAL_STATUSES_SET, type OrchestratorConfig, type PluginRegistry, @@ -102,8 +103,9 @@ export async function validateSession( let agentActivity: ActivityState | null = null; if (agent && runtimeHandle) { try { - agentProcessRunning = await agent.isProcessRunning(runtimeHandle); - processProbeSucceeded = true; + const processProbe = await agent.isProcessRunning(runtimeHandle); + agentProcessRunning = processProbe === true; + processProbeSucceeded = !isProcessProbeIndeterminate(processProbe); } catch { agentProcessRunning = false; processProbeSucceeded = false; @@ -130,7 +132,11 @@ export async function validateSession( }; const detection = await agent.getActivityState(probeSession, config.readyThresholdMs); agentActivity = detection?.state ?? null; - if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) { + if ( + processProbeSucceeded && + !agentProcessRunning && + indicatesLiveAgentActivity(agentActivity) + ) { agentProcessRunning = true; } if (agentActivity === "exited") { diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 8f8976d68..be8238e10 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -62,6 +62,7 @@ import { } from "./metadata.js"; import { buildLifecycleMetadataPatch, + clearTerminalMarkersForNonTerminalState, cloneLifecycle, createInitialCanonicalLifecycle, deriveLegacyStatus, @@ -108,6 +109,7 @@ const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; const EXEC_SHELL_OPTION = process.platform === "win32" ? ({ shell: true, windowsHide: true } as const) : ({} as const); + function errorIncludesSessionNotFound(err: unknown): boolean { if (!(err instanceof Error)) return false; const e = err as Error & { stderr?: string; stdout?: string }; @@ -291,6 +293,20 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function isAgentProcessNotDefinitelyMissing( + agent: Agent, + handle: RuntimeHandle, +): Promise { + try { + return (await agent.isProcessRunning(handle)) !== false; + } catch { + // Send/restore readiness should only block on a definitive "process missing" + // verdict. Probe failures are no verdict, so keep waiting or fall back to + // terminal output instead of forcing a restore. + return true; + } +} + function isFixedOrchestratorReservationError(err: unknown, sessionId: string): boolean { return err instanceof Error && err.message.includes(`Orchestrator session "${sessionId}" already exists`); } @@ -479,6 +495,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }), ); updater(lifecycle); + clearTerminalMarkersForNonTerminalState(lifecycle); return lifecycle; } @@ -2394,7 +2411,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM while (true) { const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), captureOutput(handle), handle.runtimeName === "tmux" ? getTmuxForegroundCommand(handle.id) @@ -2441,7 +2458,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM while (true) { const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), captureOutput(handle), handle.runtimeName === "tmux" ? getTmuxForegroundCommand(handle.id) @@ -2509,14 +2526,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM let [runtimeAlive, processRunning] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), ]); if (normalized.status === "spawning" && runtimeAlive) { await waitForInteractiveReadiness(normalized, SEND_BOOTSTRAP_READY_TIMEOUT_MS); [runtimeAlive, processRunning] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), ]); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d10d10c4..d759c7769 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -458,6 +458,17 @@ export interface AttachInfo { * Agent adapter for a specific AI coding tool. * Knows how to launch, detect activity, and extract session info. */ + +export const PROCESS_PROBE_INDETERMINATE = "indeterminate" as const; + +export type ProcessProbeResult = boolean | typeof PROCESS_PROBE_INDETERMINATE; + +export function isProcessProbeIndeterminate( + result: ProcessProbeResult, +): result is typeof PROCESS_PROBE_INDETERMINATE { + return result === PROCESS_PROBE_INDETERMINATE; +} + export interface Agent { readonly name: string; @@ -483,8 +494,14 @@ export interface Agent { */ getActivityState(session: Session, readyThresholdMs?: number): Promise; - /** Check if agent process is running (given runtime handle) */ - isProcessRunning(handle: RuntimeHandle): Promise; + /** + * Check if agent process is running (given runtime handle). + * + * Returns "indeterminate" when the probe could not reliably determine + * liveness (for example, `ps`/`tmux` timed out or failed). Callers must + * treat that as no verdict, not as a missing process. + */ + isProcessRunning(handle: RuntimeHandle): Promise; /** Extract information from agent's internal data (summary, cost, session ID) */ getSessionInfo(session: Session): Promise; @@ -811,6 +828,9 @@ export interface SCM { /** Get individual CI check statuses */ getCIChecks(pr: PRInfo): Promise; + /** Get failed CI jobs/steps with a bounded failed-log tail, if supported. */ + getCIFailureSummary?(pr: PRInfo, failedChecks?: CICheck[]): Promise; + /** Get overall CI summary */ getCISummary(pr: PRInfo): Promise; @@ -993,6 +1013,15 @@ export interface CICheck { completedAt?: Date; } +export interface CIFailureSummary { + failedJobs: Array<{ + name: string; + failedStep?: string; + runUrl: string; + logTail?: string; + }>; +} + export type CIStatus = "pending" | "passing" | "failing" | "none"; /** CI status constants */ diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index 07ad17ac9..636fdea14 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -116,7 +116,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -128,10 +128,14 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { } // Wait for agent to exit — aider with --message should exit after responding - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -164,7 +168,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → null (not implemented for aider)", () => { diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index 9c58ee469..77f2a72a3 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -210,7 +210,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; try { const activityState = await agent.getActivityState(session); @@ -228,10 +228,14 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { } // Wait for agent to exit (simple task should complete within 90s) - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); // Capture post-exit observations exitedActivityState = await agent.getActivityState(session); @@ -274,7 +278,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => { diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index 4b761fb8c..ba22054f5 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -83,7 +83,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -95,10 +95,14 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { } // Wait for agent to exit - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -128,7 +132,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → null (not implemented for codex)", () => { diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index ead459ef6..1e28ab3d2 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -127,7 +127,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -138,10 +138,14 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { await sleep(500); } - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -169,7 +173,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { }); it("getActivityState -> returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo -> null (not implemented for opencode)", () => { diff --git a/packages/integration-tests/src/daemon-children.integration.test.ts b/packages/integration-tests/src/daemon-children.integration.test.ts new file mode 100644 index 000000000..fd234ea0c --- /dev/null +++ b/packages/integration-tests/src/daemon-children.integration.test.ts @@ -0,0 +1,149 @@ +import { spawn, execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, realpath } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isWindows, killProcessTree } from "@aoagents/ao-core"; +import { sleep } from "./helpers/polling.js"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/index.ts"); +const tsxBin = join(repoRoot, "packages/cli/node_modules/.bin/tsx"); +const dashboardEntry = join(repoRoot, "packages/web/dist-server/start-all.js"); + +const canRun = !isWindows() && existsSync(tsxBin) && existsSync(dashboardEntry); + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Could not allocate a free port")); + }); + }); + }); +} + +async function readChildPids(pid: number): Promise { + try { + const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)]); + return stdout + .split(/\s+/) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0); + } catch { + return []; + } +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +describe.skipIf(!canRun)("daemon child reaping (integration)", () => { + let tmpHome: string; + let repoPath: string; + let configPath: string; + let startPid: number | undefined; + let port: number; + + beforeEach(async () => { + tmpHome = await realpath(await mkdtemp(join(tmpdir(), "ao-daemon-int-home-"))); + port = await getFreePort(); + repoPath = join(tmpHome, "repo"); + mkdirSync(repoPath, { recursive: true }); + await execFileAsync("git", ["init"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.name", "Test User"], { cwd: repoPath }); + writeFileSync(join(repoPath, "README.md"), "# daemon child reaping\n"); + await execFileAsync("git", ["add", "."], { cwd: repoPath }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoPath }); + + configPath = join(repoPath, "agent-orchestrator.yaml"); + writeFileSync( + configPath, + ["runtime: process", "agent: claude-code", "workspace: worktree"].join("\n"), + ); + + const globalConfigPath = join(tmpHome, "global-agent-orchestrator.yaml"); + writeFileSync( + globalConfigPath, + [ + `port: ${port}`, + "defaults:", + " runtime: process", + " agent: claude-code", + " workspace: worktree", + " notifiers: []", + "projects:", + " daemon-int:", + " displayName: Daemon Integration", + ` path: ${JSON.stringify(repoPath)}`, + " defaultBranch: main", + " sessionPrefix: daemon-int", + ].join("\n"), + ); + configPath = globalConfigPath; + }, 30_000); + + afterEach(async () => { + if (startPid && isAlive(startPid)) { + await killProcessTree(startPid, "SIGKILL"); + } + await rm(tmpHome, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("ao stop terminates children spawned by ao start", async () => { + const env = { + ...process.env, + HOME: tmpHome, + AO_CALLER_TYPE: "agent", + AO_CONFIG_PATH: configPath, + AO_GLOBAL_CONFIG: configPath, + PORT: String(port), + }; + const start = spawn(tsxBin, [cliEntry, "start", "--no-orchestrator", "--reap-orphans"], { + cwd: repoPath, + env, + stdio: "ignore", + }); + startPid = start.pid; + expect(startPid).toBeTypeOf("number"); + + const runningPath = join(tmpHome, ".agent-orchestrator/running.json"); + let runningPid: number | undefined; + for (let i = 0; i < 100; i++) { + if (existsSync(runningPath)) { + const running = JSON.parse(readFileSync(runningPath, "utf-8")) as { pid?: number }; + runningPid = running.pid; + break; + } + await sleep(100); + } + expect(runningPid).toBeTypeOf("number"); + + const childPids = await readChildPids(runningPid!); + expect(childPids.length).toBeGreaterThan(0); + + await execFileAsync(tsxBin, [cliEntry, "stop", "--all"], { cwd: repoPath, env, timeout: 20_000 }); + await sleep(5_000); + + const stillAlive = childPids.filter(isAlive); + expect(stillAlive).toEqual([]); + expect(isAlive(runningPid!)).toBe(false); + }, 60_000); +}); diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 51df2e23f..2d281b453 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-plugin-agent-aider +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 990c4807f..0d099bab4 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.6.0", + "version": "0.8.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index 8a2330a7f..95fb70093 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActivitySignal, type Session, type RuntimeHandle, type 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) => { @@ -213,7 +218,9 @@ describe("getLaunchCommand", () => { it("inlines systemPromptFile content on Windows instead of $(cat ...)", () => { mockIsWindows.mockReturnValueOnce(true); mockReadFileSync.mockReturnValueOnce("You are a senior engineer."); - const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "C:\\prompts\\sys.md" })); + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPromptFile: "C:\\prompts\\sys.md" }), + ); expect(cmd).toContain("--system-prompt"); expect(cmd).toContain("You are a senior engineer."); expect(cmd).not.toContain("$(cat"); @@ -279,9 +286,18 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(handle)).toBe(false); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux gone")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys005\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -367,7 +383,9 @@ describe("detectActivity", () => { }); it("returns waiting_input for Y/N confirmation", () => { - expect(agent.detectActivity("Allow creation of new file foo.ts\n(Y)es/(N)o")).toBe("waiting_input"); + expect(agent.detectActivity("Allow creation of new file foo.ts\n(Y)es/(N)o")).toBe( + "waiting_input", + ); }); it("returns waiting_input for add-to-chat prompt", () => { @@ -429,10 +447,13 @@ describe("getRestoreCommand", () => { const agent = create(); it("returns null (aider does not support session resume)", async () => { - const result = await agent.getRestoreCommand!( - makeSession(), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const result = await agent.getRestoreCommand!(makeSession(), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(result).toBeNull(); }); }); @@ -521,9 +542,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("waiting_input"); }); @@ -534,9 +553,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("blocked"); }); @@ -551,9 +568,7 @@ describe("getActivityState with activity JSONL", () => { // falls through to git/chat fallbacks. With no git commits or chat history, // falls through to JSONL mtime fallback (step 4) which returns "active" // since modifiedAt is recent. - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("active"); }); }); diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 1834cc4e3..3fd871fee 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -9,12 +9,14 @@ import { DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, isWindows, + PROCESS_PROBE_INDETERMINATE, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityDetection, type ActivityState, type PluginModule, + type ProcessProbeResult, type RuntimeHandle, type Session, type WorkspaceHooksConfig, @@ -170,6 +172,7 @@ function createAiderAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // Process is running - check for activity signals @@ -210,7 +213,7 @@ function createAiderAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // ps -eo is Unix-only; guard against stale tmux handles on Windows @@ -230,6 +233,7 @@ function createAiderAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)aider(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -259,7 +263,7 @@ function createAiderAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index f246bb9e8..00a33ac2e 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index 3a4734da3..ec4b77b30 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.6.0", + "version": "0.8.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index d764808c2..e664509a0 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -402,9 +402,18 @@ describe("isProcessRunning", () => { expect(mockExecFileAsync).not.toHaveBeenCalled(); }); - it("returns false when tmux command fails", async () => { + it("returns indeterminate when tmux command fails", async () => { mockExecFileAsync.mockRejectedValue(new Error("fail")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when cached ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys002\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -996,7 +1005,11 @@ describe("hook setup — relative path (symlink-safe)", () => { ); expect(scriptWrite).toBeDefined(); expect(scriptWrite![0]).toBe( - pathJoin("/Users/equinox/.worktrees/integrator/integrator-5", ".claude", "metadata-updater.sh"), + pathJoin( + "/Users/equinox/.worktrees/integrator/integrator-5", + ".claude", + "metadata-updater.sh", + ), ); }); @@ -1039,10 +1052,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("writes a Node.js hook script instead of bash on Windows", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); // The .cjs file must have been written (.cjs forces CJS mode in ESM workspaces) const cjsContent = getWrittenScriptContent("metadata-updater.cjs"); @@ -1063,10 +1073,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("uses node command in settings.json hook command on Windows", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); const hookCommand = getWrittenHookCommand(); expect(hookCommand).toBe("node .claude/metadata-updater.cjs"); @@ -1074,10 +1081,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("skips chmod on win32", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); expect(mockChmod).not.toHaveBeenCalled(); }); @@ -1119,10 +1123,7 @@ describe("setupWorkspaceHooks on win32", () => { it("does not add duplicate hook entry when called twice on Windows", async () => { // First call creates the hook - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); // Simulate second call: settings.json now contains the .cjs hook const firstSettings = mockWriteFile.mock.calls.find( @@ -1134,10 +1135,7 @@ describe("setupWorkspaceHooks on win32", () => { mockIsWindows.mockReturnValue(true); // Second call — should UPDATE the existing hook, not add a duplicate - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); const secondSettings = mockWriteFile.mock.calls.find( ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), @@ -1146,9 +1144,9 @@ describe("setupWorkspaceHooks on win32", () => { const parsed = JSON.parse(secondSettings![1] as string); const hookEntries = parsed.hooks.PostToolUse as Array<{ hooks: Array<{ command: string }> }>; // Count all hook commands matching our metadata updater - const metadataHooks = hookEntries.flatMap((e) => e.hooks).filter( - (h) => h.command.includes("metadata-updater"), - ); + const metadataHooks = hookEntries + .flatMap((e) => e.hooks) + .filter((h) => h.command.includes("metadata-updater")); // Must be exactly 1 — no duplicates expect(metadataHooks).toHaveLength(1); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index b0f3b5116..1e5a933ac 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -3,6 +3,7 @@ import { readLastJsonlEntry, normalizeAgentPermissionMode, isWindows, + PROCESS_PROBE_INDETERMINATE, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, type Agent, @@ -13,6 +14,7 @@ import { type CostEstimate, type PluginModule, type ProjectConfig, + type ProcessProbeResult, type RuntimeHandle, type Session, type WorkspaceHooksConfig, @@ -616,7 +618,12 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined { * with many processes. The cache ensures `ps` is called at most once per TTL * window regardless of how many sessions are being enriched. */ -let psCache: { output: string; timestamp: number; promise?: Promise } | null = null; +type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; +let psCache: { + output: ProcessListResult; + timestamp: number; + promise?: Promise; +} | null = null; const PS_CACHE_TTL_MS = 5_000; /** Reset the ps cache. Exported for testing only. */ @@ -624,7 +631,7 @@ export function resetPsCache(): void { psCache = null; } -async function getCachedProcessList(): Promise { +async function getCachedProcessList(): Promise { // ps -eo is a Unix-only command; on Windows the tmux branch is never taken // in normal operation, but guard here to avoid a spurious spawn error if // a stale tmux handle is encountered. @@ -640,41 +647,42 @@ async function getCachedProcessList(): Promise { // Guard both callbacks so they only update psCache if it still belongs to // this request — a newer request may have replaced it while we were waiting. const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { - timeout: 5_000, - }).then(({ stdout }) => { - if (psCache?.promise === promise) { - psCache = { output: stdout, timestamp: Date.now() }; - } - return stdout; - }); + timeout: 30_000, + }) + .then(({ stdout }) => { + if (psCache?.promise === promise) { + psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return stdout || PROCESS_PROBE_INDETERMINATE; + }) + .catch(() => { + if (psCache?.promise === promise) { + psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return PROCESS_PROBE_INDETERMINATE; + }); // Store the in-flight promise so concurrent callers share it psCache = { output: "", timestamp: now, promise }; - try { - return await promise; - } catch { - // On failure, clear cache so the next caller retries — but only if - // psCache still points to this request (avoid clobbering a newer entry) - if (psCache?.promise === promise) { - psCache = null; - } - return ""; - } + return promise; } /** * Check if a process named "claude" is running in the given runtime handle's context. * Uses ps to find processes by TTY (for tmux) or by PID. */ -async function findClaudeProcess(handle: RuntimeHandle): Promise { +async function findClaudeProcess( + handle: RuntimeHandle, +): Promise { try { // For tmux runtime, get the pane TTY and find claude on it if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return null; const { stdout: ttyOut } = await execFileAsync( "tmux", ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], - { timeout: 5_000 }, + { timeout: 30_000 }, ); // Iterate all pane TTYs (multi-pane sessions) — succeed on any match const ttys = ttyOut @@ -685,7 +693,7 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise if (ttys.length === 0) return null; const psOut = await getCachedProcessList(); - if (!psOut) return null; + if (psOut === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); // Match "claude" as a word boundary — prevents false positives on @@ -721,7 +729,7 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise // No reliable way to identify the correct process for this session return null; } catch { - return null; + return PROCESS_PROBE_INDETERMINATE; } } @@ -939,8 +947,9 @@ function createClaudeCodeAgent(): Agent { return classifyTerminalOutput(terminalOutput); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { const pid = await findClaudeProcess(handle); + if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; return pid !== null; }, @@ -954,6 +963,7 @@ function createClaudeCodeAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // Process is running - check JSONL session file for activity diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index b6749c398..0149854f8 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-plugin-agent-codex +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 56853d557..66baf3267 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.6.0", + "version": "0.8.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 857854ce2..880b20a03 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -479,9 +479,18 @@ describe("isProcessRunning", () => { expect(mockExecFileAsync).not.toHaveBeenCalled(); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -639,13 +648,19 @@ describe("getActivityState", () => { }); it("returns exited when process is not running", async () => { - mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); + mockTmuxWithProcess("codex", false); const session = makeSession({ runtimeHandle: makeTmuxHandle() }); const result = await agent.getActivityState(session); expect(result?.state).toBe("exited"); expect(result?.timestamp).toBeInstanceOf(Date); }); + it("returns null when process probe is indeterminate", async () => { + mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); + const session = makeSession({ runtimeHandle: makeTmuxHandle() }); + await expect(agent.getActivityState(session)).resolves.toBeNull(); + }); + it("returns null when process is running but no workspacePath", async () => { mockTmuxWithProcess("codex"); const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index ad84e1f58..93179d6d3 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -9,6 +9,7 @@ import { getActivityFallbackState, recordTerminalActivity, isWindows, + PROCESS_PROBE_INDETERMINATE, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -16,6 +17,7 @@ import { type ActivityDetection, type CostEstimate, type PluginModule, + type ProcessProbeResult, type ProjectConfig, type RuntimeHandle, type Session, @@ -599,6 +601,7 @@ function createCodexAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; if (!session.workspacePath) return null; @@ -700,7 +703,7 @@ function createCodexAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // ps -eo is Unix-only; guard against stale tmux handles on Windows @@ -720,6 +723,7 @@ function createCodexAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)codex(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -749,7 +753,7 @@ function createCodexAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, diff --git a/packages/plugins/agent-codex/src/package-version.test.ts b/packages/plugins/agent-codex/src/package-version.test.ts deleted file mode 100644 index 5b0f95f09..000000000 --- a/packages/plugins/agent-codex/src/package-version.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; - -describe("package manifest version", () => { - it("is bumped to 0.6.0", () => { - const packageJsonUrl = new URL("../package.json", import.meta.url); - const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { version?: string }; - - expect(packageJson.version).toBe("0.6.0"); - }); -}); diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 577e8c55e..2d60850e0 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,21 @@ # @aoagents/ao-plugin-agent-cursor +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.1.4 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index dd383f3e0..906630c79 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.1.4", + "version": "0.8.0", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index 946ba3001..c2eeaab2c 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,21 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.1.3 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 6d9fd38c0..30c09fa89 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.1.3", + "version": "0.8.0", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index aa05d8c3a..db22d59f2 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-plugin-agent-opencode +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 67e794c55..16e3ba5a6 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.6.0", + "version": "0.8.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 1b9d42976..75ea49bca 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; +import { + createActivitySignal, + type Session, + type RuntimeHandle, + type AgentLaunchConfig, +} from "@aoagents/ao-core"; const { mockAppendActivityEntry, @@ -49,7 +54,12 @@ vi.mock("node:child_process", () => ({ }, })); -import { create, manifest, default as defaultExport, resetOpenCodeSessionListCache } from "./index.js"; +import { + create, + manifest, + default as defaultExport, + resetOpenCodeSessionListCache, +} from "./index.js"; function makeSession(overrides: Partial = {}): Session { return { @@ -192,9 +202,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( "exec opencode --session \"$SES_ID\" --prompt 'fix bug' --agent 'sisyphus'", ); @@ -339,9 +347,7 @@ describe("getLaunchCommand", () => { prompt: "fix the bug", }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( `exec opencode --session "$SES_ID" --prompt 'fix the bug' --agent 'sisyphus'`, ); @@ -368,9 +374,7 @@ describe("getLaunchCommand", () => { prompt: "fix the bug", }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( `exec opencode --session "$SES_ID" --prompt 'fix the bug' --agent 'sisyphus'`, ); @@ -501,9 +505,18 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(handle)).toBe(false); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -778,10 +791,13 @@ describe("getRestoreCommand", () => { it("returns null when no session ID found", async () => { mockExecFileAsync.mockRejectedValue(new Error("opencode not found")); - const cmd = await agent.getRestoreCommand!( - makeSession({ metadata: {} }), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const cmd = await agent.getRestoreCommand!(makeSession({ metadata: {} }), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(cmd).toBeNull(); }); @@ -796,10 +812,13 @@ describe("getRestoreCommand", () => { return Promise.reject(new Error("unexpected")); }); - const cmd = await agent.getRestoreCommand!( - makeSession({ metadata: {} }), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const cmd = await agent.getRestoreCommand!(makeSession({ metadata: {} }), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(cmd).toBe("opencode --session 'ses_found'"); }); }); @@ -991,9 +1010,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("waiting_input"); }); @@ -1004,9 +1021,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("blocked"); }); @@ -1024,7 +1039,11 @@ describe("getActivityState with activity JSONL", () => { if (cmd === "opencode") { return Promise.resolve({ stdout: JSON.stringify([ - { id: "ses_abc123", title: "AO:test-1", updated: new Date(Date.now() - 5_000).toISOString() }, + { + id: "ses_abc123", + title: "AO:test-1", + updated: new Date(Date.now() - 5_000).toISOString(), + }, ]), stderr: "", }); @@ -1033,7 +1052,10 @@ describe("getActivityState with activity JSONL", () => { }); const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle(), metadata: { opencodeSessionId: "ses_abc123" } }), + makeSession({ + runtimeHandle: makeTmuxHandle(), + metadata: { opencodeSessionId: "ses_abc123" }, + }), 60_000, ); expect(result?.state).toBe("active"); @@ -1067,7 +1089,11 @@ describe("getActivityState with activity JSONL", () => { it("falls back to JSONL entry with age decay — old entry becomes idle", async () => { mockTmuxWithProcess("opencode"); mockReadLastActivityEntry.mockResolvedValueOnce({ - entry: { ts: new Date(Date.now() - 120_000).toISOString(), state: "active", source: "terminal" }, + entry: { + ts: new Date(Date.now() - 120_000).toISOString(), + state: "active", + source: "terminal", + }, modifiedAt: new Date(Date.now() - 120_000), }); mockExecFileAsync.mockImplementation((cmd: string) => { diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 69f8fdfe3..6c9a9ae25 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -8,6 +8,7 @@ import { recordTerminalActivity, asValidOpenCodeSessionId, isWindows, + PROCESS_PROBE_INDETERMINATE, getCachedOpenCodeSessionList, getOpenCodeChildEnv, ensureOpenCodeTmpDir, @@ -18,6 +19,7 @@ import { type ActivityDetection, type ActivityState, type PluginModule, + type ProcessProbeResult, type ProjectConfig, type RuntimeHandle, type Session, @@ -299,6 +301,7 @@ function createOpenCodeAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // 1. Check AO activity JSONL first (written by recordActivity from terminal output). @@ -341,7 +344,7 @@ function createOpenCodeAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // tmux and ps are Unix-only; guard before any tmux calls on Windows. @@ -361,6 +364,7 @@ function createOpenCodeAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)opencode(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -390,7 +394,7 @@ function createOpenCodeAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, @@ -459,4 +463,4 @@ export function detect(): boolean { } } -export default { manifest, create, detect } satisfies PluginModule; \ No newline at end of file +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index 2bf202333..ec8d20f5c 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-notifier-composio +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index c907a66fb..ef78c917f 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.6.0", + "version": "0.8.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index 222fec642..79b929fde 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index cd69871c0..003280b7c 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.6.0", + "version": "0.8.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index 80b29fe1b..e067ee0e1 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,21 @@ # @aoagents/ao-plugin-notifier-discord +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 3a4ad75ea..21c0ef29e 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.2.9", + "version": "0.8.0", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index 636384032..f392d06a9 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,21 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index 6a50caf20..2b03b6369 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.2.9", + "version": "0.8.0", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index c6c180fec..b0ddc06b1 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-notifier-slack +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index a81db3852..cbf23bd85 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.6.0", + "version": "0.8.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 719b15724..6288a0aa3 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index 9da736c7a..c6a0b545f 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.6.0", + "version": "0.8.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index b0ac8d8ec..bda98972b 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-runtime-process +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 9609c0f8d..692ffb065 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.6.0", + "version": "0.8.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 8a6620867..87ad7b266 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- 845fffd: Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. + + Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index 4392457d9..d32591794 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.6.0", + "version": "0.8.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 915bafc42..f989e12f2 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-scm-github +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index e2c9f1de7..75f262e7f 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.6.0", + "version": "0.8.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 5c2ed2f90..8d065db01 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -23,6 +23,7 @@ import { type PRState, type MergeMethod, type CICheck, + type CIFailureSummary, type CIStatus, type Review, type ReviewDecision, @@ -60,6 +61,8 @@ const BOT_AUTHORS = new Set([ "lgtm-com[bot]", ]); +const CI_FAILURE_LOG_TAIL_LINES = 120; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -167,6 +170,80 @@ function mapRawCheckStateToStatus(rawState: string | undefined): CICheck["status return "skipped"; } +function isFailedCheck(check: CICheck): boolean { + return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE"; +} + +function isDecimalId(value: string): boolean { + return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9"); +} + +function extractActionRunReference( + check: CICheck, +): { runId: string; jobId?: string; runUrl: string } | null { + if (!check.url) return null; + let pathParts: string[]; + try { + pathParts = new URL(check.url).pathname.split("/").filter(Boolean); + } catch { + return null; + } + + const actionsIndex = pathParts.findIndex( + (part, index) => part === "actions" && pathParts[index + 1] === "runs", + ); + const runId = actionsIndex >= 0 ? pathParts[actionsIndex + 2] : undefined; + if (!runId || !isDecimalId(runId)) return null; + + const jobIndex = pathParts.findIndex((part, index) => index > actionsIndex && part === "job"); + const jobId = jobIndex >= 0 ? pathParts[jobIndex + 1] : undefined; + + return { + runId, + ...(jobId && isDecimalId(jobId) ? { jobId } : {}), + runUrl: check.url, + }; +} + +function tailLines(text: string, maxLines: number): string | undefined { + const lines = text.split(/\r?\n/); + const tail = lines.slice(-maxLines).join("\n").trimEnd(); + return tail.length > 0 ? tail : undefined; +} + +function extractFailedStep(log: string): string | undefined { + let lastStep: string | undefined; + for (const line of log.split(/\r?\n/)) { + const parts = line.split("\t"); + const step = parts.length >= 3 ? parts[1]?.trim() : undefined; + if (step) lastStep = step; + } + return lastStep; +} + +async function getFailedJobLog( + pr: PRInfo, + runReference: { runId: string; jobId?: string }, +): Promise { + try { + return await gh([ + "run", + "view", + runReference.runId, + "--repo", + repoFlag(pr), + "--log-failed", + ...(runReference.jobId ? ["--job", runReference.jobId] : []), + ]); + } catch (err) { + if (!runReference.jobId) throw err; + return gh([ + "api", + `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`, + ]); + } +} + async function getCIChecksFromStatusRollup(pr: PRInfo): Promise { const raw = await gh([ "pr", @@ -811,6 +888,46 @@ function createGitHubSCM(): SCM { }); }, + async getCIFailureSummary( + pr: PRInfo, + providedFailedChecks?: CICheck[], + ): Promise { + try { + const failedChecks = (providedFailedChecks ?? (await this.getCIChecks(pr))).filter( + isFailedCheck, + ); + if (failedChecks.length === 0) return null; + + const failedJobs: CIFailureSummary["failedJobs"] = []; + const seenRuns = new Set(); + + for (const check of failedChecks) { + const runReference = extractActionRunReference(check); + if (!runReference) continue; + + const seenKey = `${runReference.runId}:${runReference.jobId ?? ""}`; + if (seenRuns.has(seenKey)) continue; + seenRuns.add(seenKey); + + const log = await getFailedJobLog(pr, runReference); + + const failedJob: CIFailureSummary["failedJobs"][number] = { + name: check.name, + runUrl: runReference.runUrl, + }; + const failedStep = extractFailedStep(log); + if (failedStep) failedJob.failedStep = failedStep; + const logTail = tailLines(log, CI_FAILURE_LOG_TAIL_LINES); + if (logTail) failedJob.logTail = logTail; + failedJobs.push(failedJob); + } + + return failedJobs.length > 0 ? { failedJobs } : null; + } catch { + return null; + } + }, + async getCISummary(pr: PRInfo): Promise { let checks: CICheck[]; try { diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index f985fcea2..501ff4e09 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -647,6 +647,96 @@ describe("scm-github plugin", () => { }); }); + // ---- getCIFailureSummary ----------------------------------------------- + + describe("getCIFailureSummary", () => { + it("returns failed job step and caps the failed log tail", async () => { + const checks = [ + { + name: "build", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + { + name: "lint", + status: "passed" as const, + conclusion: "SUCCESS", + url: "https://github.com/acme/repo/actions/runs/124/job/457", + }, + ]; + const logLines = Array.from( + { length: 125 }, + (_, index) => { + const step = index < 100 ? "Install dependencies" : "Run pnpm test"; + return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; + }, + ); + mockGhRaw(logLines.join("\n")); + + const summary = await scm.getCIFailureSummary?.(pr, checks); + + expect(summary?.failedJobs).toHaveLength(1); + expect(summary?.failedJobs[0]).toEqual({ + name: "build", + failedStep: "Run pnpm test", + runUrl: "https://github.com/acme/repo/actions/runs/123/job/456", + logTail: logLines.slice(-120).join("\n"), + }); + expect(summary?.failedJobs[0]?.logTail?.split("\n")).toHaveLength(120); + expect(summary?.failedJobs[0]?.logTail?.split("\n")[0]).toContain("line 6"); + expect(summary?.failedJobs[0]?.logTail).toContain("line 125"); + expect(ghMock).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + ["run", "view", "123", "--repo", "acme/repo", "--log-failed", "--job", "456"], + expect.any(Object), + ); + expect(ghMock).toHaveBeenCalledTimes(1); + }); + + it("returns null when failed-log fetch fails", async () => { + const checks = [ + { + name: "build", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + ]; + mockGhError("run view failed"); + + await expect(scm.getCIFailureSummary?.(pr, checks)).resolves.toBeNull(); + }); + + it("falls back to job logs API when gh run view cannot read logs yet", async () => { + const checks = [ + { + name: "lint", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + ]; + mockGhError("run 123 is still in progress; logs will be available when it is complete"); + mockGhRaw("2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable"); + + const summary = await scm.getCIFailureSummary?.(pr, checks); + + expect(summary?.failedJobs).toEqual([ + { + name: "lint", + runUrl: "https://github.com/acme/repo/actions/runs/123/job/456", + logTail: "2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable", + }, + ]); + expect(ghMock).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + ["api", "repos/acme/repo/actions/jobs/456/logs"], + expect.any(Object), + ); + }); + }); + // ---- getCISummary ------------------------------------------------------ describe("getCISummary", () => { diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 709102027..4e55944ea 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,21 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index 754577095..9367e5177 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.2.9", + "version": "0.8.0", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index 3488b1239..f11718dc8 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index d5ab04077..b8472a40b 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.6.0", + "version": "0.8.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index 2582dfc67..d99d4c95e 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-terminal-web +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index b6733f82e..b0743fa88 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.6.0", + "version": "0.8.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index 28df0d28b..012d22bc3 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-tracker-github +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 7a6d8b466..501e2d014 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.6.0", + "version": "0.8.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index f91c361fc..67aa2b0be 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,23 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-scm-gitlab@0.8.0 + +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-scm-gitlab@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index 5268be3d2..0275eb208 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.2.9", + "version": "0.8.0", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index bf035d188..57c285650 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-tracker-linear +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index 8c8e247d8..baee198ec 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.6.0", + "version": "0.8.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index ed2d03761..bd7137c03 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -51,10 +51,21 @@ class LinearHttpError extends Error { } } +class LinearNetworkError extends Error { + constructor(message: string) { + super(`Linear API network error: ${message}`); + } +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isRetryableDirectTransportError(err: unknown): boolean { + if (err instanceof LinearHttpError) return err.transient; + return err instanceof LinearNetworkError; +} + function getApiKey(): string { const key = process.env["LINEAR_API_KEY"]; if (!key) { @@ -99,7 +110,9 @@ function createDirectTransport(): GraphQLTransport { }, (res) => { const chunks: Buffer[] = []; - res.on("error", (err: Error) => settle(() => reject(err))); + res.on("error", (err: Error) => + settle(() => reject(new LinearNetworkError(err.message))), + ); res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => { settle(() => { @@ -131,11 +144,11 @@ function createDirectTransport(): GraphQLTransport { req.setTimeout(30_000, () => { settle(() => { req.destroy(); - reject(new Error("Linear API request timed out after 30s")); + reject(new LinearHttpError(408, "client-side timeout after 30s")); }); }); - req.on("error", (err) => settle(() => reject(err))); + req.on("error", (err) => settle(() => reject(new LinearNetworkError(err.message)))); req.write(body); req.end(); }); @@ -145,9 +158,7 @@ function createDirectTransport(): GraphQLTransport { return await execute(); } catch (err) { const shouldRetry = - err instanceof LinearHttpError && - err.transient && - attempt < DIRECT_TRANSPORT_MAX_ATTEMPTS; + isRetryableDirectTransportError(err) && attempt < DIRECT_TRANSPORT_MAX_ATTEMPTS; if (!shouldRetry) throw err; await sleep(DIRECT_TRANSPORT_RETRY_DELAY_MS * attempt); } diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index fa58579bf..d31f562ac 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -136,6 +136,40 @@ function mockHTTPError(statusCode: number, body: string) { ); } +/** Queue a request-level network error before Linear returns a response. */ +function mockRequestError(message: string) { + requestMock.mockImplementationOnce(() => { + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => req.emit("error", new Error(message))); + }), + destroy: vi.fn(), + setTimeout: vi.fn(), + }); + return req; + }); +} + +/** Queue a client-side timeout before Linear returns a response. */ +function mockRequestTimeout() { + requestMock.mockImplementationOnce(() => { + let timeoutHandler: (() => void) | undefined; + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => timeoutHandler?.()); + }), + destroy: vi.fn(), + setTimeout: vi.fn((_ms: number, handler: () => void) => { + timeoutHandler = handler; + return req; + }), + }); + return req; + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -286,6 +320,26 @@ describe("tracker-linear plugin", () => { expect(issue.id).toBe("INT-123"); expect(requestMock).toHaveBeenCalledTimes(2); }); + + it("retries request-level network errors", async () => { + mockRequestError("ECONNRESET"); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + it("retries client-side request timeouts", async () => { + mockRequestTimeout(); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); }); // ---- isCompleted ------------------------------------------------------- diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index f5c8afc36..fc4342852 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,54 @@ # @aoagents/ao-plugin-workspace-clone +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index f5f04461e..bb8668e84 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.6.0", + "version": "0.8.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index b8572df3a..776b1a984 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,55 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- a33b2ba: Restoring a session whose worktree directory was cleaned up but whose branch still existed locally would 422 with `fatal: a branch named already exists`. The recovery path in `workspace.restore()` unconditionally fell through to `git worktree add -b`, even when the local branch was present (which `destroy()` deliberately preserves). The catch now checks for the local branch and re-attaches it without `-b`/`-B`, preserving the session's commits. (#1741) +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 4167b5ebe..61b2459e0 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.6.0", + "version": "0.8.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 9a91af54f..cd79e9e9b 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,124 @@ # @aoagents/ao-web +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-agent-claude-code@0.8.0 + - @aoagents/ao-plugin-agent-codex@0.8.0 + - @aoagents/ao-plugin-agent-opencode@0.8.0 + - @aoagents/ao-plugin-agent-cursor@0.8.0 + - @aoagents/ao-plugin-agent-kimicode@0.8.0 + - @aoagents/ao-plugin-runtime-process@0.8.0 + - @aoagents/ao-plugin-runtime-tmux@0.8.0 + - @aoagents/ao-plugin-scm-github@0.8.0 + - @aoagents/ao-plugin-tracker-github@0.8.0 + - @aoagents/ao-plugin-tracker-linear@0.8.0 + - @aoagents/ao-plugin-workspace-worktree@0.8.0 + +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:\_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +- 71326bc: Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647) + +### Patch Changes + +- 845fffd: Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. + + Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) + +- Updated dependencies [845fffd] +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] +- Updated dependencies [a33b2ba] + - @aoagents/ao-plugin-runtime-tmux@0.7.0 + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-runtime-process@0.7.0 + - @aoagents/ao-plugin-agent-claude-code@0.7.0 + - @aoagents/ao-plugin-agent-codex@0.7.0 + - @aoagents/ao-plugin-agent-opencode@0.7.0 + - @aoagents/ao-plugin-workspace-worktree@0.7.0 + - @aoagents/ao-plugin-tracker-github@0.7.0 + - @aoagents/ao-plugin-tracker-linear@0.7.0 + - @aoagents/ao-plugin-scm-github@0.7.0 + - @aoagents/ao-plugin-agent-cursor@0.7.0 + - @aoagents/ao-plugin-agent-kimicode@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/web/package.json b/packages/web/package.json index bbf38cf7c..83a59ace5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.6.0", + "version": "0.8.0", "description": "Web dashboard for agent-orchestrator", "license": "MIT", "type": "module", diff --git a/packages/web/server/__tests__/server-compatibility.test.ts b/packages/web/server/__tests__/server-compatibility.test.ts index 25ffcf20d..efcfc01db 100644 --- a/packages/web/server/__tests__/server-compatibility.test.ts +++ b/packages/web/server/__tests__/server-compatibility.test.ts @@ -54,11 +54,11 @@ describe("start-all.ts", () => { it("kills child process trees during shutdown", () => { expect(source).toMatch(/killProcessTree/); - expect(source).toMatch(/detached:\s*process\.platform\s*!==\s*["']win32["']/); + expect(source).toMatch(/spawnManagedDaemonChild/); + expect(source).toMatch(/detached:\s*!\s*isWindows\(\)/); }); }); - describe("OrchestratorConfig compatibility", () => { it("OrchestratorConfig does not have dataDir property", () => { const typesSource = readFileSync( diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index dd3c3dbaf..d54b11c33 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -4,12 +4,17 @@ * Replaces the dev-only `concurrently` setup. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { resolve, dirname } from "node:path"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { killProcessTree } from "@aoagents/ao-core"; +import { + isWindows, + killProcessTree, + markDaemonShutdownHandlerInstalled, + spawnManagedDaemonChild, +} from "@aoagents/ao-core"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,6 +23,7 @@ const __dirname = dirname(__filename); const pkgRoot = resolve(__dirname, ".."); const children: ChildProcess[] = []; +markDaemonShutdownHandlerInstalled(); function log(label: string, msg: string): void { process.stdout.write(`[${label}] ${msg}\n`); @@ -34,11 +40,11 @@ function spawnProcess( let slotIndex = -1; function launch(): ChildProcess { - const child = spawn(command, args, { + const child = spawnManagedDaemonChild(`dashboard:${label}`, command, args, { cwd: pkgRoot, stdio: ["ignore", "pipe", "pipe"], env: process.env, - detached: process.platform !== "win32", + detached: !isWindows(), }); child.stdout?.on("data", (data: Buffer) => { @@ -83,7 +89,7 @@ function spawnProcess( function resolveNextBin(): string { // On Windows, .bin/next is a POSIX shell shim that spawn() cannot execute. // Skip it and go straight to the JS entry point. - if (process.platform !== "win32") { + if (!isWindows()) { const localBin = resolve(pkgRoot, "node_modules", ".bin", "next"); if (existsSync(localBin)) return localBin; } @@ -103,7 +109,7 @@ function resolveNextBin(): string { const port = process.env["PORT"] || "3000"; const nextBin = resolveNextBin(); -if (process.platform === "win32" && nextBin !== "next") { +if (isWindows() && nextBin !== "next") { // On Windows, run the JS entry point via the current node binary. // spawn() can't execute .js files directly on Windows. spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); @@ -112,7 +118,9 @@ if (process.platform === "win32" && nextBin !== "next") { } // Start direct terminal WebSocket server (auto-restart on crash) -spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { restart: true }); +spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { + restart: true, +}); // Graceful shutdown — send SIGTERM to children and wait for them to exit let shuttingDown = false; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 60ff21c15..84f769e97 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -315,6 +315,37 @@ describe("API Routes", () => { vi.useRealTimers(); }); + it("activeOnly keeps working sessions with stale terminatedAt annotations", async () => { + const staleLifecycle = createInitialCanonicalLifecycle( + "worker", + new Date("2026-05-13T19:13:20.146Z"), + ); + staleLifecycle.session.state = "working"; + staleLifecycle.session.reason = "task_in_progress"; + staleLifecycle.session.terminatedAt = "2026-05-13T19:13:20.146Z"; + staleLifecycle.runtime.state = "alive"; + staleLifecycle.runtime.reason = "process_running"; + + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + makeSession({ + id: "worker-restored-stale-terminal-marker", + status: "terminated", + activity: "exited", + lifecycle: staleLifecycle, + }), + ]); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions?active=true")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([ + "worker-restored-stale-terminal-marker", + ]); + expect(data.sessions[0].lifecycle.sessionState).toBe("working"); + expect(data.sessions[0].lifecycle.session.terminatedAt).toBe("2026-05-13T19:13:20.146Z"); + }); + it("returns per-project orchestrators and excludes them from worker sessions", async () => { (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce( multiProjectSessions, diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 77a3f1ec2..539740f16 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE, isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; +import { isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { sessionToDashboard, @@ -10,11 +10,14 @@ import { import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; import { filterProjectSessions } from "@/lib/project-utils"; import { settlesWithin } from "@/lib/async-utils"; -import type { DashboardOrchestratorLink } from "@/lib/types"; +import { isDashboardSessionTerminal, type DashboardOrchestratorLink } from "@/lib/types"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; -function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { +function compareOrchestratorRecency( + a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, + b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, +): number { return ( (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || @@ -54,7 +57,7 @@ function selectPreferredOrchestratorId( function listPreferredProjectOrchestrators( sessions: Parameters[0], projects: Parameters[1], -) : DashboardOrchestratorLink[] { +): DashboardOrchestratorLink[] { const preferredOrchestrators = listProjectOrchestratorSessions(sessions, projects); return preferredOrchestrators @@ -90,7 +93,9 @@ export async function GET(request: Request) { : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects) - : (orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null); + : orchestrators.length === 1 + ? (orchestrators[0]?.id ?? null) + : null; if (orchestratorOnly) { recordApiObservation({ @@ -132,7 +137,7 @@ export async function GET(request: Request) { if (activeOnly) { const activeIndices = dashboardSessions - .map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1)) + .map((session, index) => (!isDashboardSessionTerminal(session) ? index : -1)) .filter((index) => index !== -1); workerSessions = activeIndices.map((index) => workerSessions[index]); dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index b6f405c02..1f3158e8d 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -149,20 +149,20 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/projects", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions/worker-1", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect( @@ -258,7 +258,7 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); }); @@ -336,7 +336,7 @@ describe("SessionPage project polling", () => { expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument(); }); - it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => { + it("keeps retrying after a stuck session fetch times out before showing an error state", async () => { global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === "/api/projects") { @@ -377,12 +377,30 @@ describe("SessionPage project polling", () => { }); await flushAsyncWork(); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + await flushAsyncWork(); + + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12_000); + }); + await flushAsyncWork(); + expect(screen.getByText("Failed to load session")).toBeInTheDocument(); expect(screen.getByText(/taking too long/i)).toBeInTheDocument(); expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); }); - it("shows a recoverable unavailable state when the first session request aborts", async () => { + it("does not show the route error after the first aborted session request", async () => { + let sessionFetches = 0; + global.fetch = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/projects") { @@ -396,6 +414,7 @@ describe("SessionPage project polling", () => { } if (url === "/api/sessions/worker-1") { + sessionFetches += 1; return Promise.reject(new DOMException("Aborted", "AbortError")); } @@ -415,9 +434,24 @@ describe("SessionPage project polling", () => { render(); await flushAsyncWork(); - expect(screen.getByText("Session unavailable")).toBeInTheDocument(); - expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument(); - expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); + expect(sessionFetches).toBe(1); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000); + }); + await flushAsyncWork(); + + expect(sessionFetches).toBeGreaterThanOrEqual(3); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + await flushAsyncWork(); + + expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0); }); it("marks sidebar data as loading until the sessions list resolves", async () => { diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index cbd7efb63..f16c5c365 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -84,6 +84,9 @@ let cachedProjects: ProjectInfo[] | null = null; let cachedSidebarSessions: DashboardSession[] | null = null; const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; const SESSION_FETCH_TIMEOUT_MS = 8000; +const SESSION_LOAD_MAX_CONSECUTIVE_FAILURES = 4; +const SESSION_LOAD_MAX_RETRY_ELAPSED_MS = 30_000; +const SESSION_LOAD_RETRY_BACKOFF_MS = [1_000, 2_000, 4_000] as const; const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000; const PROJECTS_FETCH_TIMEOUT_MS = 5000; const validSessionStatuses = new Set(Object.values(SESSION_STATUS)); @@ -136,6 +139,19 @@ function isAbortLikeError(error: unknown): boolean { return false; } +function isTransientSessionLoadError(error: unknown): boolean { + if (isAbortLikeError(error)) return true; + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("timed out") || + message.includes("network") || + message.includes("failed to fetch") + ); + } + return false; +} + function getSessionLoadErrorMessage(error: Error): string { const normalized = error.message.toLowerCase(); if (normalized.includes("timed out")) { @@ -418,6 +434,22 @@ export default function SessionPage() { const projectSessionsFetchControllerRef = useRef(null); const sidebarFetchControllerRef = useRef(null); const pageUnloadingRef = useRef(false); + const sessionLoadFailureCountRef = useRef(0); + const sessionLoadFirstFailureAtRef = useRef(null); + const sessionLoadRetryTimerRef = useRef | null>(null); + + const clearSessionLoadRetry = useCallback(() => { + if (sessionLoadRetryTimerRef.current) { + clearTimeout(sessionLoadRetryTimerRef.current); + sessionLoadRetryTimerRef.current = null; + } + }, []); + + const resetSessionLoadFailures = useCallback(() => { + sessionLoadFailureCountRef.current = 0; + sessionLoadFirstFailureAtRef.current = null; + clearSessionLoadRetry(); + }, [clearSessionLoadRetry]); // Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map useEffect(() => { @@ -497,6 +529,7 @@ export default function SessionPage() { fetchingSessionRef.current = true; const controller = new AbortController(); sessionFetchControllerRef.current = controller; + let keepLoadingForRetry = false; try { const data = await fetchJsonWithTimeout( `/api/sessions/${encodeURIComponent(id)}`, @@ -510,8 +543,9 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); hasLoadedSessionRef.current = true; + resetSessionLoadFailures(); } catch (err) { - if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) { + if (pageUnloadingRef.current || controller.signal.aborted) { return; } const message = err instanceof Error ? err.message : "Failed to load session"; @@ -524,20 +558,55 @@ export default function SessionPage() { setSessionMissing(true); } setLoading(false); + resetSessionLoadFailures(); return; } + + if (!hasLoadedSessionRef.current && isTransientSessionLoadError(err)) { + const failureCount = sessionLoadFailureCountRef.current + 1; + sessionLoadFailureCountRef.current = failureCount; + sessionLoadFirstFailureAtRef.current ??= Date.now(); + const elapsedMs = Date.now() - sessionLoadFirstFailureAtRef.current; + const shouldKeepRetrying = + failureCount < SESSION_LOAD_MAX_CONSECUTIVE_FAILURES && + elapsedMs < SESSION_LOAD_MAX_RETRY_ELAPSED_MS; + + if (shouldKeepRetrying) { + const delay = + SESSION_LOAD_RETRY_BACKOFF_MS[ + Math.min(failureCount - 1, SESSION_LOAD_RETRY_BACKOFF_MS.length - 1) + ]; + keepLoadingForRetry = true; + setLoading(true); + console.warn("Session fetch failed transiently; retrying", { + sessionId: id, + failureCount, + retryInMs: delay, + error: err, + }); + clearSessionLoadRetry(); + sessionLoadRetryTimerRef.current = setTimeout(() => { + sessionLoadRetryTimerRef.current = null; + void fetchSession(); + }, delay); + return; + } + } + console.error("Failed to fetch session:", err); if (!hasLoadedSessionRef.current) { setRouteError(err instanceof Error ? err : new Error("Failed to load session")); } } finally { - setLoading(false); + if (!keepLoadingForRetry) { + setLoading(false); + } fetchingSessionRef.current = false; if (sessionFetchControllerRef.current === controller) { sessionFetchControllerRef.current = null; } } - }, [id]); + }, [clearSessionLoadRetry, id, resetSessionLoadFailures]); const fetchProjectSessions = useCallback(async () => { if (fetchingProjectSessionsRef.current) return; @@ -727,11 +796,12 @@ export default function SessionPage() { useEffect(() => { return () => { + clearSessionLoadRetry(); sessionFetchControllerRef.current?.abort(); projectSessionsFetchControllerRef.current?.abort(); sidebarFetchControllerRef.current?.abort(); }; - }, []); + }, [clearSessionLoadRetry]); if (loading) { return ( @@ -816,6 +886,7 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); setLoading(true); + resetSessionLoadFailures(); void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]); }, }} diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx index 5860efe49..5f09d33d0 100644 --- a/packages/web/src/components/BottomSheet.tsx +++ b/packages/web/src/components/BottomSheet.tsx @@ -1,7 +1,14 @@ "use client"; import { useEffect, useRef } from "react"; -import { getAttentionLevel, isPRRateLimited, isPRUnenriched, type DashboardSession } from "@/lib/types"; +import { + getAttentionLevel, + isDashboardSessionTerminated, + isDashboardSessionTerminal, + isPRRateLimited, + isPRUnenriched, + type DashboardSession, +} from "@/lib/types"; import { getSessionTitle } from "@/lib/format"; import { projectSessionPath } from "@/lib/routes"; @@ -24,12 +31,10 @@ function formatTagLabel(value: string): string { } function isTag( - value: - | { - label: string; - tone: "accent" | "neutral" | "mono"; - } - | null, + value: { + label: string; + tone: "accent" | "neutral" | "mono"; + } | null, ): value is { label: string; tone: "accent" | "neutral" | "mono" } { return value !== null; } @@ -76,7 +81,7 @@ export function BottomSheet({ if (!sheetRef.current) return; const focusable = sheetRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; @@ -122,13 +127,12 @@ export function BottomSheet({ const title = getSessionTitle(session); const attention = getAttentionLevel(session); - const summary = - session.summary && !session.summaryIsFallback ? session.summary : null; + const summary = session.summary && !session.summaryIsFallback ? session.summary : null; const hasLiveTerminateAction = - attention !== "done" && attention !== "merge" && session.status !== "terminated"; + attention !== "done" && attention !== "merge" && !isDashboardSessionTerminated(session); const pr = session.pr; const showLivePrData = Boolean(pr && !isPRRateLimited(pr) && !isPRUnenriched(pr)); - const showTerminalStatePills = attention === "done" || session.status === "terminated" || session.activity === "exited"; + const showTerminalStatePills = attention === "done" || isDashboardSessionTerminal(session); const tags = [ { label: formatTagLabel(attention), tone: "accent" as const }, { label: formatTagLabel(session.status), tone: "neutral" as const }, @@ -141,11 +145,7 @@ export function BottomSheet({ return ( <> {/* Backdrop */} -