From 6640d377659595a3fe47aeed1ad5595b3a2318e9 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 11:19:12 +0200 Subject: [PATCH] Expose worktree-owned SCM changes under active agent rows The VS Code Active Agents tree now nests CHANGES entries under the owning session when the repo path lives inside that session worktree, and leaves only unmatched paths in a Repo root bucket. Full verification was blocked by runtime-template parity drift in the Guardex helper shims, so the runtime copies were resynced to their template behavior and the stale merge-workflow zero-copy test was aligned with the current CLI-owned install surface. Constraint: Preserve FolderItem/ChangeItem rendering while reusing repo-root-relative session changedPaths Rejected: Infer ownership only from repo status prefixes | cannot localize session rows cleanly when worktree paths need worktree-relative display Rejected: Leave runtime parity and stale test drift unresolved | full repo verification would stay red and hide the actual feature result Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep session.changedPaths repo-root-relative in session-schema and localize back to worktree-relative paths only inside the CHANGES tree builder Tested: node --test test/vscode-active-agents-session-state.test.js; node --test test/metadata.test.js; node --test test/merge-workflow.test.js; npm test; openspec validate --specs Not-tested: Manual VS Code SCM smoke test against a live multi-session repo --- README.md | 2 +- .../.openspec.yaml | 2 + .../notes.md | 10 + .../vscode-working-agents-groups/spec.md | 29 +- .../tasks.md | 8 +- scripts/agent-branch-finish.sh | 51 +- scripts/codex-agent.sh | 143 +++-- .../vscode/guardex-active-agents/README.md | 6 +- .../vscode/guardex-active-agents/extension.js | 427 +++++++++++--- .../guardex-active-agents/session-schema.js | 207 ++++++- test/merge-workflow.test.js | 5 +- ...vscode-active-agents-session-state.test.js | 545 ++++++++++++++---- vscode/guardex-active-agents/README.md | 6 +- vscode/guardex-active-agents/extension.js | 427 +++++++++++--- .../guardex-active-agents/session-schema.js | 207 ++++++- 15 files changed, 1667 insertions(+), 408 deletions(-) create mode 100644 openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml create mode 100644 openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md diff --git a/README.md b/README.md index 7276114..77c8fbe 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ To install the real companion into local VS Code from a GitGuardex-wired repo: node scripts/install-vscode-active-agents-extension.js ``` -It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `WORKING NOW` and `THINKING` when both states are present, reads `.omx/state/active-sessions/*.json`, derives `thinking` versus `working` from each live sandbox worktree, and surfaces a working-count summary in the repo/header affordances. Reload the VS Code window after install. +It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. --- diff --git a/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md new file mode 100644 index 0000000..cc10fcd --- /dev/null +++ b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md @@ -0,0 +1,10 @@ +# T1 Notes + +- Handoff: change=`agent-codex-nest-active-agent-changes-2026-04-22-10-53`; scope=`vscode/guardex-active-agents/{extension.js,session-schema.js}`; action=`nest repo changes under the owning active session row when the path belongs to that session worktree, and keep only unmatched paths in a Repo root residual group`. +- Add `changedPaths` to normalized active-session records so the extension reuses the per-worktree change set already derived from each session's worktree status. +- Rebuild the CHANGES tree grouping so active-session worktree ownership is resolved once and rendered as session subtrees with the existing folder/change item components. +- Preserve the existing file/folder rendering for all leaf nodes and leave only paths that do not belong to any active worktree inside a residual `Repo root` group. +- Verification widened scope: runtime helper parity was already broken on this branch baseline, so `scripts/{agent-branch-start.sh,agent-branch-finish.sh,codex-agent.sh}` are also being resynced to their template behavior to clear repo-wide verification. +- Verification follow-up: `test/merge-workflow.test.js` still expected a repo-local merge shim, but the install surface now keeps workflow shims CLI-owned by default; align that stale assertion with the current zero-copy setup policy so full-suite verification matches the rest of the install tests. +- Replace the Active Agents polling loop with file watchers for `**/.omx/state/active-sessions/*.json`, `**/.omx/state/agent-file-locks.json`, and each live session worktree index so the SCM companion refreshes only when session or dirty-state inputs change. +- Debounce watcher-driven refresh to a trailing 250ms timer and keep a session-keyed watcher map so per-session index watchers are created and disposed as active sessions appear and vanish. diff --git a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md index ee321d9..e4e1700 100644 --- a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md +++ b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md @@ -1,20 +1,23 @@ ## ADDED Requirements -### Requirement: Active Agents highlights currently working lanes -The VS Code Active Agents companion SHALL separate actively editing Guardex lanes from idle-thinking lanes inside the `ACTIVE AGENTS` section. +### Requirement: Active Agents highlights sandbox session state clearly +The VS Code Active Agents companion SHALL separate Guardex sandbox sessions into explicit state groups inside the `ACTIVE AGENTS` section. -#### Scenario: Working and thinking sessions render in separate groups -- **WHEN** a repo has both live `working` and `thinking` Guardex sessions +#### Scenario: Session states render in distinct groups +- **WHEN** a repo has live Guardex sessions inferred as `blocked`, `working`, `idle`, `stalled`, or `dead` - **THEN** the repo node contains an `ACTIVE AGENTS` section -- **AND** that section contains `WORKING NOW` and `THINKING` child groups -- **AND** the working group appears before the thinking group. +- **AND** that section renders child groups for each present state +- **AND** the groups are ordered `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, `DEAD` +- **AND** the `BLOCKED` group appears above `WORKING NOW`. -#### Scenario: Repo summary exposes working counts -- **WHEN** a repo has one or more live working sessions -- **THEN** the repo row description includes the working count in addition to the active session count -- **AND** the Source Control badge tooltip mentions how many active sessions are currently working. +#### Scenario: Repo summary exposes working and dead counts +- **WHEN** a repo has one or more live `working` or `dead` sessions +- **THEN** the repo row description includes the working count in addition to the active count +- **AND** the repo row description includes the dead count when present +- **AND** the Source Control badge tooltip mentions working-now and dead counts when present. -#### Scenario: Working sessions use a distinct visual affordance -- **WHEN** a live Guardex session is inferred as `working` -- **THEN** its row uses a distinct codicon from `thinking` rows +#### Scenario: Each session state uses a distinct visual affordance +- **WHEN** a live Guardex session is inferred as `blocked`, `working`, `idle`, `stalled`, or `dead` +- **THEN** its row uses a distinct codicon for that state +- **AND** its tooltip summarizes the derived state reason - **AND** the row still keeps the existing activity/count/elapsed description text. diff --git a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md index bd57762..5fdf727 100644 --- a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md +++ b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md @@ -15,9 +15,9 @@ Handoff: 2026-04-22 09:05Z codex owns `templates/vscode/guardex-active-agents/*` ## 2. Implementation -- [x] 2.1 Split the `ACTIVE AGENTS` section into visible `WORKING NOW` and `THINKING` groups, preserving live session rows. -- [x] 2.2 Surface working counts in the repo row / view badge summary and add a distinct icon for working lanes. -- [x] 2.3 Update README guidance and focused regression tests for the new grouping behavior. +- [x] 2.1 Expand session activity derivation to `blocked`, `working`, `idle`, `stalled`, and `dead`, using git markers, dirty worktree status, PID liveness, and worktree mtimes. +- [x] 2.2 Group `ACTIVE AGENTS` into visible `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` sections, with distinct ThemeIcons/tooltips and updated repo/badge summaries. +- [x] 2.3 Update README guidance and focused regression tests for the expanded session-state behavior. ## 3. Verification @@ -30,3 +30,5 @@ Handoff: 2026-04-22 09:05Z codex owns `templates/vscode/guardex-active-agents/*` - [ ] 4.1 Run the cleanup pipeline: `bash scripts/agent-branch-finish.sh --branch agent/codex/vscode-working-agents-groups-2026-04-22-09-05 --base main --via-pr --wait-for-merge --cleanup`. - [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. - [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). + +BLOCKED: This sandbox picked up unrelated concurrent edits in `scripts/agent-branch-finish.sh`, `scripts/agent-branch-start.sh`, `scripts/codex-agent.sh`, plus a new untracked `openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/` workspace after the session-state patch was verified. Do not run finish/cleanup until the branch owner decides whether to keep or split those additional changes. diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index fb528e1..fa67866 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -9,11 +9,30 @@ DELETE_REMOTE_BRANCH=0 DELETE_REMOTE_BRANCH_EXPLICIT=0 MERGE_MODE="auto" GH_BIN="${GUARDEX_GH_BIN:-gh}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}" WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}" WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}" WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-finish] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -431,7 +450,7 @@ run_pr_flow() { if [[ -z "$pr_title" ]]; then pr_title="Merge ${SOURCE_BRANCH} into ${BASE_BRANCH}" fi - pr_body="Automated by scripts/agent-branch-finish.sh (PR flow)." + pr_body="Automated by gx branch finish (PR flow)." "$GH_BIN" pr create \ --base "$BASE_BRANCH" \ @@ -517,9 +536,7 @@ if [[ "$PUSH_ENABLED" -eq 1 ]]; then fi fi -if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then - python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true -fi +run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then @@ -555,29 +572,25 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi fi - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) - if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then - prune_args+=(--delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 - echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 - fi + prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) + if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + prune_args+=(--delete-remote-branches) + fi + if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + echo "[agent-branch-finish] Leave this directory, then run: gx cleanup --base ${BASE_BRANCH}" >&2 fi else - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then - echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 - fi + if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree." - echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" + echo "[agent-branch-finish] Cleanup later with: gx cleanup --base ${BASE_BRANCH}" fi diff --git a/scripts/codex-agent.sh b/scripts/codex-agent.sh index 75b349c..6a05817 100755 --- a/scripts/codex-agent.sh +++ b/scripts/codex-agent.sh @@ -6,6 +6,8 @@ AGENT_NAME="${GUARDEX_AGENT_NAME:-agent}" BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${GUARDEX_CODEX_BIN:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" AUTO_FINISH_RAW="${GUARDEX_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${GUARDEX_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" AUTO_CLEANUP_RAW="${GUARDEX_CODEX_AUTO_CLEANUP:-true}" @@ -16,6 +18,23 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[codex-agent] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -143,6 +162,7 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi repo_root="$(git rev-parse --show-toplevel)" +active_session_state_script="${repo_root}/scripts/agent-session-state.js" guardex_env_helper="${repo_root}/scripts/guardex-env.sh" if [[ -f "$guardex_env_helper" ]]; then @@ -373,11 +393,6 @@ start_sandbox_fallback() { printf '[agent-branch-start] Worktree: %s\n' "$worktree_path" } -if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then - echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 - exit 1 -fi - start_args=("$TASK_NAME" "$AGENT_NAME") if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") @@ -390,7 +405,7 @@ set +e start_output="$( GUARDEX_OPENSPEC_AUTO_INIT="$OPENSPEC_AUTO_INIT" \ GUARDEX_OPENSPEC_MASTERPLAN_LABEL="$OPENSPEC_MASTERPLAN_LABEL_RAW" \ - bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1 + run_guardex_cli branch start "${start_args[@]}" 2>&1 )" start_status=$? set -e @@ -446,6 +461,40 @@ has_origin_remote() { git -C "$repo_root" remote get-url origin >/dev/null 2>&1 } +run_active_session_state() { + local action="$1" + shift + + if [[ ! -f "$active_session_state_script" ]]; then + return 0 + fi + if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + return 0 + fi + + "$NODE_BIN" "$active_session_state_script" "$action" "$@" >/dev/null 2>&1 || true +} + +record_active_session_state() { + local wt="$1" + local branch="$2" + + run_active_session_state \ + start \ + --repo "$repo_root" \ + --branch "$branch" \ + --task "$TASK_NAME" \ + --agent "$AGENT_NAME" \ + --worktree "$wt" \ + --pid "$$" \ + --cli "$CODEX_BIN" +} + +clear_active_session_state() { + local branch="$1" + run_active_session_state stop --repo "$repo_root" --branch "$branch" +} + origin_remote_supports_pr_finish() { local origin_url origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" @@ -493,7 +542,7 @@ print_takeover_prompt() { change_artifact="openspec/changes/${change_slug}/" fi - finish_cmd="bash scripts/agent-branch-finish.sh --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" + finish_cmd="gx branch finish --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" echo "[codex-agent] Takeover sandbox: ${wt}" echo "[codex-agent] Takeover prompt: Continue \`${change_slug}\` on branch \`${branch}\`. Work inside \`${wt}\`, review \`${change_artifact}\`, continue from the current state instead of creating a new sandbox, and when the work is done run \`${finish_cmd}\`." @@ -549,24 +598,12 @@ ensure_openspec_plan_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local plan_slug plan_slug="$(resolve_openspec_plan_slug "$branch")" local init_output="" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1 + run_guardex_cli internal run-shell planInit "$plan_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2 @@ -586,24 +623,12 @@ ensure_openspec_change_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-change-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-change-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec change init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local change_slug capability_slug init_output="" change_slug="$(resolve_openspec_change_slug "$branch")" capability_slug="$(resolve_openspec_capability_slug)" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-change-workspace.sh" "$change_slug" "$capability_slug" 2>&1 + run_guardex_cli internal run-shell changeInit "$change_slug" "$capability_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for change '${change_slug}'." >&2 @@ -632,11 +657,6 @@ worktree_has_changes() { claim_changed_files() { local wt="$1" local branch="$2" - local lock_script="${repo_root}/scripts/agent-file-locks.py" - - if [[ ! -x "$lock_script" ]]; then - return 0 - fi local changed_raw deleted_raw changed_raw="$({ @@ -647,7 +667,7 @@ claim_changed_files() { if [[ -n "$changed_raw" ]]; then mapfile -t changed_files < <(printf '%s\n' "$changed_raw") - python3 "$lock_script" claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true fi deleted_raw="$({ @@ -657,7 +677,7 @@ claim_changed_files() { if [[ -n "$deleted_raw" ]]; then mapfile -t deleted_files < <(printf '%s\n' "$deleted_raw") - python3 "$lock_script" allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true fi } @@ -806,7 +826,7 @@ run_finish_flow() { return 2 fi - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -829,7 +849,7 @@ run_finish_flow() { fi ) - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -858,6 +878,19 @@ if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then exit 1 fi +active_session_recorded=0 +cleanup_active_session_state_on_exit() { + set +e + if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then + clear_active_session_state "$worktree_branch" + active_session_recorded=0 + fi +} + +record_active_session_state "$worktree_path" "$worktree_branch" +active_session_recorded=1 +trap cleanup_active_session_state_on_exit EXIT INT TERM + echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" set +e @@ -866,6 +899,8 @@ codex_exit="$?" set -e cd "$repo_root" +cleanup_active_session_state_on_exit +trap - EXIT INT TERM final_exit="$codex_exit" auto_finish_completed=0 @@ -903,18 +938,16 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE fi fi -if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." - prune_args=() - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then - prune_args+=(--base "$BASE_BRANCH") - fi - if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then - prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 - fi +echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." +prune_args=() +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + prune_args+=(--base "$BASE_BRANCH") +fi +if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then + prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) +fi +if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 fi if [[ ! -d "$worktree_path" ]]; then @@ -927,7 +960,7 @@ else echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" else print_takeover_prompt "$worktree_path" "$worktree_branch" - echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" + echo "[codex-agent] If finished, merge with: gx branch finish --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" fi fi diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 23228d9..0cd0b42 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -19,9 +19,9 @@ What it does: - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. -- Splits live sessions inside `ACTIVE AGENTS` into `WORKING NOW` and `THINKING` groups so active edit lanes stand out immediately. +- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. - Shows one row per live Guardex sandbox session inside those activity groups. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. -- Derives `thinking` versus `working` from the live sandbox worktree, surfaces working counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses VS Code's native animated `loading~spin` icon for the running-state affordance. +- Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. +- Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. - Reads repo-local presence files from `.omx/state/active-sessions/`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 623f107..4f99bf1 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -13,13 +13,57 @@ const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; const IDLE_WARNING_MS = 10 * 60 * 1000; const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; +const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const SESSION_SCAN_LIMIT = 200; +const REFRESH_DEBOUNCE_MS = 250; +const SESSION_ACTIVITY_GROUPS = [ + { kind: 'blocked', label: 'BLOCKED' }, + { kind: 'working', label: 'WORKING NOW' }, + { kind: 'idle', label: 'IDLE' }, + { kind: 'stalled', label: 'STALLED' }, + { kind: 'dead', label: 'DEAD' }, +]; +const SESSION_ACTIVITY_ICON_IDS = { + blocked: 'warning', + working: 'edit', + idle: 'loading~spin', + stalled: 'clock', + dead: 'error', +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } function sessionIdleDecoration(session, now = Date.now()) { - if (!session || session.activityKind === 'working') { + if (!session) { + return undefined; + } + + if (session.activityKind === 'blocked') { + return { + badge: '!', + tooltip: 'blocked', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + if (session.activityKind === 'dead') { + return { + badge: 'x', + tooltip: 'dead', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'stalled') { + return { + badge: '!', + tooltip: 'stalled', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'working') { return undefined; } @@ -74,14 +118,30 @@ class SessionDecorationProvider { } } +class InfoItem extends vscode.TreeItem { + constructor(label, description = '') { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.iconPath = new vscode.ThemeIcon('info'); + } +} + class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = [`${sessions.length} active`]; + const descriptionParts = []; + const activeCount = countActiveSessions(sessions); + const deadCount = countSessionsByActivityKind(sessions, 'dead'); const workingCount = countWorkingSessions(sessions); + if (activeCount > 0) { + descriptionParts.push(`${activeCount} active`); + } + if (deadCount > 0) { + descriptionParts.push(`${deadCount} dead`); + } if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } @@ -109,10 +169,14 @@ class SectionItem extends vscode.TreeItem { } class SessionItem extends vscode.TreeItem { - constructor(session) { + constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; - super(`${session.label} 馃敀 ${lockCount}`, vscode.TreeItemCollapsibleState.None); + super( + `${session.label} 馃敀 ${lockCount}`, + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); this.session = session; + this.items = items; this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { @@ -128,13 +192,13 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); - this.iconPath = session.activityKind === 'working' - ? new vscode.ThemeIcon('edit') - : new vscode.ThemeIcon('loading~spin'); + this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -306,7 +370,7 @@ function repoRootFromLockFile(filePath) { } function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/'); + return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function emptyLockRegistry() { @@ -389,6 +453,104 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function isPathWithin(parentPath, targetPath) { + const relativePath = path.relative(parentPath, targetPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function localizeChangeForSession(session, change) { + if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { + return null; + } + + let originalPath = change.originalPath; + if (originalPath) { + const originalAbsolutePath = path.join(session.repoRoot, originalPath); + if (isPathWithin(session.worktreePath, originalAbsolutePath)) { + originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); + } + } + + return { + ...change, + relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), + originalPath, + }; +} + +async function findRepoSessionEntries() { + const sessionFiles = await vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ); + + const repoRoots = new Set(); + for (const uri of sessionFiles) { + repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + } + + if (repoRoots.size === 0) { + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + repoRoots.add(workspaceFolder.uri.fsPath); + } + } + + const repoEntries = []; + for (const repoRoot of repoRoots) { + const sessions = readActiveSessions(repoRoot, { includeStale: true }); + if (sessions.length > 0) { + repoEntries.push({ repoRoot, sessions }); + } + } + + repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); + return repoEntries; +} + +function resolveSessionWatcherKey(session) { + return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; +} + +function resolveSessionGitIndexPath(worktreePath) { + const gitPath = path.join(worktreePath, '.git'); + const defaultIndexPath = path.join(gitPath, 'index'); + + try { + if (fs.statSync(gitPath).isDirectory()) { + return defaultIndexPath; + } + } catch (_error) { + return defaultIndexPath; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim(), 'index'); + } + } catch (_error) { + return defaultIndexPath; + } + + return defaultIndexPath; +} + +function bindRefreshWatcher(watcher, refresh) { + return [ + watcher.onDidCreate(refresh), + watcher.onDidChange(refresh), + watcher.onDidDelete(refresh), + ]; +} + +function disposeAll(disposables) { + for (const disposable of disposables) { + disposable?.dispose?.(); + } +} + function buildChangeTreeNodes(changes) { const root = []; @@ -454,9 +616,67 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } -<<<<<<< HEAD -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; +function buildGroupedChangeTreeNodes(sessions, changes) { + const changesBySession = new Map(); + const sessionByChangedPath = new Map(); + const repoRootChanges = []; + + for (const session of sessions) { + changesBySession.set(session.branch, []); + for (const changedPath of session.changedPaths || []) { + if (!sessionByChangedPath.has(changedPath)) { + sessionByChangedPath.set(changedPath, session); + } + } + } + + for (const change of changes) { + const normalizedRelativePath = normalizeRelativePath(change.relativePath); + const session = sessionByChangedPath.get(normalizedRelativePath) + || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); + if (!session) { + repoRootChanges.push(change); + continue; + } + + const localizedChange = localizeChangeForSession(session, change); + if (!localizedChange) { + repoRootChanges.push(change); + continue; + } + + changesBySession.get(session.branch).push(localizedChange); + } + + const items = sessions + .map((session) => { + const sessionChanges = changesBySession.get(session.branch) || []; + if (sessionChanges.length === 0) { + return null; + } + return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); + }) + .filter(Boolean); + + if (repoRootChanges.length > 0) { + items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { + description: String(repoRootChanges.length), + })); + } + + return items; +} + +function countActiveSessions(sessions) { + return sessions.filter((session) => session.activityKind !== 'dead').length; +} + +function countSessionsByActivityKind(sessions, activityKind) { + return sessions.filter((session) => session.activityKind === activityKind).length; +} + +function resolveSessionActivityIconId(activityKind) { + return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; } async function pickRepoRoot() { @@ -530,7 +750,8 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); -======= +} + function sessionSelectionKey(session) { if (!session?.repoRoot || !session?.branch) { return ''; @@ -561,23 +782,17 @@ function stageWorktreeForCommit(worktreePath) { function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { - const workingSessions = sessions - .filter((session) => session.activityKind === 'working') - .map((session) => new SessionItem(session)); - const thinkingSessions = sessions - .filter((session) => session.activityKind !== 'working') - .map((session) => new SessionItem(session)); const groups = []; - - if (workingSessions.length > 0) { - groups.push(new SectionItem('WORKING NOW', workingSessions)); - } - if (thinkingSessions.length > 0) { - groups.push(new SectionItem('THINKING', thinkingSessions)); + for (const group of SESSION_ACTIVITY_GROUPS) { + const groupSessions = sessions + .filter((session) => session.activityKind === group.kind) + .map((session) => new SessionItem(session)); + if (groupSessions.length > 0) { + groups.push(new SectionItem(group.label, groupSessions)); + } } return groups; @@ -601,7 +816,7 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0); + this.updateViewState(0, 0, 0); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -633,19 +848,32 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount) { + updateViewState(sessionCount, workingCount, deadCount) { if (!this.treeView) { return; } + const activeCount = Math.max(0, sessionCount - deadCount); + const badgeTooltipParts = []; + if (activeCount > 0) { + badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); + } + if (deadCount > 0) { + badgeTooltipParts.push(`${deadCount} dead`); + } + if (workingCount > 0) { + badgeTooltipParts.push(`${workingCount} working now`); + } + this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}` - + (workingCount > 0 ? ` 路 ${workingCount} working now` : ''), + tooltip: badgeTooltipParts.join(' 路 '), } : undefined; - this.treeView.message = undefined; + this.treeView.message = sessionCount > 0 + ? undefined + : 'Start a sandbox session to populate this view.'; } async syncRepoEntries() { @@ -655,8 +883,12 @@ class ActiveAgentsProvider { (total, entry) => total + countWorkingSessions(entry.sessions), 0, ); + const deadCount = repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ); - this.updateViewState(sessionCount, workingCount); + this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); return repoEntries; } @@ -689,12 +921,14 @@ class ActiveAgentsProvider { }), ]; if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildChangeTreeNodes(element.changes))); + sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + description: String(element.changes.length), + })); } return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { return element.items; } @@ -702,54 +936,100 @@ class ActiveAgentsProvider { this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { - return []; + return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); } async loadRepoEntries() { - const sessionFiles = await vscode.workspace.findFiles( - '**/.omx/state/active-sessions/*.json', - '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**', - 200, - ); + const repoEntries = await findRepoSessionEntries(); + return repoEntries.map((entry) => { + const repoRoot = entry.repoRoot; + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const currentBranch = readCurrentBranch(repoRoot); + return { + repoRoot, + sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), + }; + }); + } +} + +class ActiveAgentsRefreshController { + constructor(provider) { + this.provider = provider; + this.refreshTimer = null; + this.sessionWatchers = new Map(); + } - const repoRoots = new Set(); - for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + scheduleRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshNow(); + }, REFRESH_DEBOUNCE_MS); + } - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + async refreshNow() { + await this.syncSessionWatchers(); + await this.provider.refresh(); + } + + async syncSessionWatchers() { + const repoEntries = await findRepoSessionEntries(); + const liveSessionKeys = new Set(); + + for (const entry of repoEntries) { + for (const session of entry.sessions) { + const sessionKey = resolveSessionWatcherKey(session); + liveSessionKeys.add(sessionKey); + if (this.sessionWatchers.has(sessionKey)) { + continue; + } + + const watcher = vscode.workspace.createFileSystemWatcher( + resolveSessionGitIndexPath(session.worktreePath), + ); + const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); + this.sessionWatchers.set(sessionKey, { watcher, disposables }); } } - const repoEntries = []; - for (const repoRoot of repoRoots) { - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); - if (sessions.length > 0) { - const currentBranch = readCurrentBranch(repoRoot); - repoEntries.push({ - repoRoot, - sessions, - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - }); + for (const [sessionKey, entry] of this.sessionWatchers) { + if (liveSessionKeys.has(sessionKey)) { + continue; } + + disposeAll(entry.disposables); + entry.watcher.dispose(); + this.sessionWatchers.delete(sessionKey); + } + } + + dispose() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; } - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; + for (const entry of this.sessionWatchers.values()) { + disposeAll(entry.disposables); + entry.watcher.dispose(); + } + this.sessionWatchers.clear(); } } function activate(context) { const decorationProvider = new SessionDecorationProvider(); const provider = new ActiveAgentsProvider(decorationProvider); + const refreshController = new ActiveAgentsRefreshController(provider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, @@ -759,11 +1039,10 @@ function activate(context) { 'Active Agents Commit', ); provider.attachTreeView(treeView); - const refresh = () => { - void provider.refresh(); - }; - const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); - const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const scheduleRefresh = () => refreshController.scheduleRefresh(); + const refresh = () => void refreshController.refreshNow(); + const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -815,7 +1094,7 @@ function activate(context) { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); } - refresh(); + scheduleRefresh(); }; provider.onDidChangeSelectedSession(updateCommitInput); @@ -823,6 +1102,7 @@ function activate(context) { context.subscriptions.push( treeView, sourceControl, + refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), @@ -854,18 +1134,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(refresh), - sessionWatcher, + vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + activeSessionsWatcher, lockWatcher, { dispose: () => clearInterval(interval) }, ); - sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); - sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); - sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); - lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); + context.subscriptions.push( + ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), + ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ); + void refreshController.refreshNow(); } function deactivate() {} diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 98390bf..e9282a0 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -8,6 +8,22 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); +const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; +const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; +const BLOCKING_GIT_STATES = [ + { + label: 'Rebase in progress.', + markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], + }, + { + label: 'Merge in progress.', + markers: ['MERGE_HEAD'], + }, + { + label: 'Cherry-pick in progress.', + markers: ['CHERRY_PICK_HEAD'], + }, +]; function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -46,6 +62,10 @@ function splitOutputLines(output) { .filter((line) => line.trim().length > 0); } +function normalizeRelativePath(value) { + return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -184,37 +204,187 @@ function collectWorktreeChangedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveSessionActivity(session) { - const changedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!changedPaths) { +function resolveWorktreeGitDir(worktreePath) { + const gitPath = path.join(path.resolve(worktreePath), '.git'); + try { + if (fs.statSync(gitPath).isDirectory()) { + return gitPath; + } + } catch (_error) { + return null; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim()); + } + } catch (_error) { + return null; + } + + return null; +} + +function deriveBlockingGitLabel(worktreePath) { + const gitDir = resolveWorktreeGitDir(worktreePath); + if (!gitDir) { + return ''; + } + + for (const blockingState of BLOCKING_GIT_STATES) { + if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { + return blockingState.label; + } + } + + return ''; +} + +function collectWorktreeTrackedPaths(worktreePath) { + const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); + if (!trackedPaths) { + return null; + } + + return [...new Set(trackedPaths)] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} + +function deriveLatestWorktreeFileActivity(worktreePath) { + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); + if (!trackedPaths) { + return null; + } + + let latestMtimeMs = null; + for (const relativePath of trackedPaths) { + const absolutePath = path.join(worktreePath, relativePath); + try { + const stats = fs.statSync(absolutePath); + if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { + continue; + } + latestMtimeMs = latestMtimeMs === null + ? stats.mtimeMs + : Math.max(latestMtimeMs, stats.mtimeMs); + } catch (_error) { + continue; + } + } + + return latestMtimeMs; +} + +function deriveSessionActivity(session, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const blockingLabel = deriveBlockingGitLabel(session.worktreePath); + if (blockingLabel) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'blocked', + activityLabel: 'blocked', + activityCountLabel: '', + activitySummary: blockingLabel, + changeCount: 0, + changedPaths: [], + pidAlive: isPidAlive(session.pid), + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const pidAlive = isPidAlive(session.pid); + if (!pidAlive) { + return { + activityKind: 'dead', + activityLabel: 'dead', + activityCountLabel: '', + activitySummary: 'Recorded PID is not alive.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); + if (!worktreeChangedPaths) { + return { + activityKind: 'idle', + activityLabel: 'idle', activityCountLabel: '', activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', }; } - if (changedPaths.length === 0) { + if (worktreeChangedPaths.length > 0) { + const changedPaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath( + path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), + )) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); + + return { + activityKind: 'working', + activityLabel: 'working', + activityCountLabel: formatFileCount(worktreeChangedPaths.length), + activitySummary: previewChangedPaths(worktreeChangedPaths), + changeCount: worktreeChangedPaths.length, + changedPaths, + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const lastFileActivityAt = Number.isFinite(latestFileActivityMs) + ? new Date(latestFileActivityMs).toISOString() + : ''; + const lastFileActivityLabel = lastFileActivityAt + ? formatElapsedFrom(lastFileActivityAt, now) + : ''; + const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) + ? Math.max(0, now - latestFileActivityMs) + : null; + + if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'stalled', + activityLabel: 'stalled', activityCountLabel: '', - activitySummary: 'Worktree clean.', + activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } return { - activityKind: 'working', - activityLabel: 'working', - activityCountLabel: formatFileCount(changedPaths.length), - activitySummary: previewChangedPaths(changedPaths), - changeCount: changedPaths.length, - changedPaths, + activityKind: 'idle', + activityLabel: 'idle', + activityCountLabel: '', + activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS + ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` + : lastFileActivityLabel + ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` + : 'Worktree clean.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } @@ -289,6 +459,7 @@ function normalizeSessionRecord(input, options = {}) { startedAt: startedAt.toISOString(), filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), + changedPaths: [], }; } @@ -360,7 +531,7 @@ function readActiveSessions(repoRoot, options = {}) { } normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized)); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); sessions.push(normalized); } @@ -393,6 +564,9 @@ module.exports = { activeSessionsDirForRepo, buildSessionRecord, collectWorktreeChangedPaths, + collectWorktreeTrackedPaths, + deriveBlockingGitLabel, + deriveLatestWorktreeFileActivity, deriveSessionLabel, deriveSessionActivity, formatElapsedFrom, @@ -404,6 +578,7 @@ module.exports = { readActiveSessions, readRepoChanges, deriveRepoChangeStatus, + resolveWorktreeGitDir, sanitizeBranchForFile, sessionFileNameForBranch, sessionFilePathForBranch, diff --git a/test/merge-workflow.test.js b/test/merge-workflow.test.js index 3f3986d..1dfb327 100644 --- a/test/merge-workflow.test.js +++ b/test/merge-workflow.test.js @@ -133,7 +133,7 @@ function extractMergeTargetWorktree(output) { return match[1].trim(); } -test('setup installs the managed merge workflow shim without package script churn', () => { +test('setup keeps the merge workflow shim CLI-owned without package script churn', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -141,8 +141,7 @@ test('setup installs the managed merge workflow shim without package script chur assert.equal(result.status, 0, result.stderr || result.stdout); const mergeScriptPath = path.join(repoDir, 'scripts', 'agent-branch-merge.sh'); - assert.equal(fs.existsSync(mergeScriptPath), true, 'merge script should be installed'); - fs.accessSync(mergeScriptPath, fs.constants.X_OK); + assert.equal(fs.existsSync(mergeScriptPath), false, 'merge script should stay CLI-owned'); const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); assert.equal(packageJson.scripts['agent:branch:merge'], undefined); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index a244554..6cfcbaa 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -40,7 +40,19 @@ function initGitRepo(repoPath) { runGit(repoPath, ['config', 'user.name', 'Guardex Tests']); } -function loadExtensionWithMockVscode(mockVscode) { +function setPathMtime(filePath, whenMs) { + const when = new Date(whenMs); + fs.utimesSync(filePath, when, when); +} + +function writeSessionRecord(repoRoot, record) { + const sessionPath = sessionSchema.sessionFilePathForBranch(repoRoot, record.branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); + return sessionPath; +} + +function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { const Module = require('node:module'); const originalLoad = Module._load; delete require.cache[require.resolve(extensionEntry)]; @@ -49,6 +61,9 @@ function loadExtensionWithMockVscode(mockVscode) { if (request === 'vscode') { return mockVscode; } + if (mockSessionSchema && request === './session-schema.js' && parent?.filename === extensionEntry) { + return mockSessionSchema; + } return originalLoad.call(this, request, parent, isMain); }; @@ -71,16 +86,15 @@ function createMockVscode(tempRoot) { openedDocuments: [], shownDocuments: [], infoMessages: [], -<<<<<<< HEAD inputResponses: [], quickPickCalls: [], quickPickResponse: undefined, -======= informationMessages: [], errorMessages: [], ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) warningMessages: [], + fileWatchers: [], watchers: [], + workspaceFolderListeners: [], }; class TreeItem { @@ -104,6 +118,7 @@ function createMockVscode(tempRoot) { class EventEmitter { constructor() { + this.fireCount = 0; this.listeners = []; this.event = (listener, thisArg, disposables) => { const boundListener = thisArg ? listener.bind(thisArg) : listener; @@ -121,13 +136,14 @@ function createMockVscode(tempRoot) { } fire(event) { + this.fireCount += 1; for (const listener of [...this.listeners]) { listener(event); } } } - const disposable = () => ({ dispose() {} }); + const disposable = (onDispose) => ({ dispose: onDispose || (() => {}) }); function createFileWatcher(pattern) { const listeners = { @@ -136,16 +152,20 @@ function createMockVscode(tempRoot) { delete: [], }; - return { + const watcher = { + disposed: false, pattern, onDidCreate(callback, thisArg) { listeners.create.push({ callback, thisArg }); + return disposable(); }, onDidChange(callback, thisArg) { listeners.change.push({ callback, thisArg }); + return disposable(); }, onDidDelete(callback, thisArg) { listeners.delete.push({ callback, thisArg }); + return disposable(); }, fireCreate(uri) { for (const listener of listeners.create) { @@ -162,8 +182,13 @@ function createMockVscode(tempRoot) { listener.callback.call(listener.thisArg, uri); } }, - dispose() {}, + dispose() { + watcher.disposed = true; + }, }; + registrations.watchers.push(watcher); + registrations.fileWatchers.push(watcher); + return watcher; } return { @@ -190,11 +215,7 @@ function createMockVscode(tempRoot) { }, registerCommand: (command, handler) => { registrations.commands.set(command, handler); - return { - dispose() { - registrations.commands.delete(command); - }, - }; + return disposable(() => registrations.commands.delete(command)); }, }, scm: { @@ -317,13 +338,17 @@ function createMockVscode(tempRoot) { registrations.openedDocuments.push(document); return document; }, - createFileSystemWatcher: (pattern) => { - const watcher = createFileWatcher(pattern); - registrations.watchers.push(watcher); - return watcher; - }, + createFileSystemWatcher: (pattern) => createFileWatcher(pattern), findFiles: async () => [], - onDidChangeWorkspaceFolders: () => disposable(), + onDidChangeWorkspaceFolders: (listener) => { + registrations.workspaceFolderListeners.push(listener); + return disposable(() => { + const index = registrations.workspaceFolderListeners.indexOf(listener); + if (index >= 0) { + registrations.workspaceFolderListeners.splice(index, 1); + } + }); + }, workspaceFolders: [{ uri: { fsPath: tempRoot } }], }, ThemeColor, @@ -331,6 +356,11 @@ function createMockVscode(tempRoot) { }; } +async function flushAsyncWork() { + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); +} + test('agent-session-state writes and removes active session records', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-')); const branch = 'agent/codex/demo-task'; @@ -420,6 +450,13 @@ test('session-schema ignores stale or invalid session records', () => { const sessions = sessionSchema.readActiveSessions(tempRoot); assert.equal(sessions.length, 1); assert.equal(sessions[0].branch, liveRecord.branch); + + const sessionsIncludingStale = sessionSchema.readActiveSessions(tempRoot, { includeStale: true }); + assert.equal(sessionsIncludingStale.length, 2); + assert.equal( + sessionsIncludingStale.find((session) => session.branch === staleRecord.branch)?.activityKind, + 'dead', + ); }); test('session-schema derives working activity from dirty sandbox worktrees', () => { @@ -450,10 +487,89 @@ test('session-schema derives working activity from dirty sandbox worktrees', () assert.equal(session.activityKind, 'working'); assert.equal(session.changeCount, 2); assert.equal(session.activityCountLabel, '2 files'); - assert.deepEqual(session.changedPaths, ['new-file.txt', 'tracked.txt']); + assert.deepEqual(session.changedPaths, ['sandbox/new-file.txt', 'sandbox/tracked.txt']); assert.equal(session.activitySummary, 'new-file.txt, tracked.txt'); }); +test('session-schema derives blocked activity from git markers in the worktree git dir', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-blocked-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); + + const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/blocked-task', + taskName: 'blocked-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + + assert.equal(session.activityKind, 'blocked'); + assert.equal(session.activitySummary, 'Merge in progress.'); +}); + +test('session-schema derives idle and stalled activity from clean worktree mtimes', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-idle-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + const trackedPath = path.join(worktreePath, 'tracked.txt'); + initGitRepo(worktreePath); + fs.writeFileSync(trackedPath, 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const record = sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/idle-task', + taskName: 'idle-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }); + const now = Date.parse('2026-04-22T10:00:00.000Z'); + + setPathMtime(trackedPath, now - 45_000); + const idleSession = sessionSchema.deriveSessionActivity(record, { now }); + assert.equal(idleSession.activityKind, 'idle'); + assert.match(idleSession.activitySummary, /Recent file activity 45s ago\./); + assert.equal(idleSession.lastFileActivityAt, new Date(now - 45_000).toISOString()); + + setPathMtime(trackedPath, now - (20 * 60 * 1000)); + const stalledSession = sessionSchema.deriveSessionActivity(record, { now }); + assert.equal(stalledSession.activityKind, 'stalled'); + assert.match(stalledSession.activitySummary, /No file activity for 20m 0s\./); + assert.equal(stalledSession.lastFileActivityAt, new Date(now - (20 * 60 * 1000)).toISOString()); +}); + +test('session-schema derives dead activity when the recorded pid is not alive', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-dead-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/dead-task', + taskName: 'dead-task', + agentName: 'codex', + worktreePath, + pid: 999999, + cliName: 'codex', + })); + + assert.equal(session.activityKind, 'dead'); + assert.equal(session.activitySummary, 'Recorded PID is not alive.'); + assert.equal(session.pidAlive, false); +}); + test('session-schema derives repo change rows from root git status', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-root-status-')); initGitRepo(tempRoot); @@ -500,6 +616,7 @@ test('active-agents extension registers tree and decoration providers', async () const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); assert.equal(registrations.treeViews.length, 1); assert.equal(registrations.sourceControls.length, 1); @@ -512,15 +629,25 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.decorationProviders.length, 1); + assert.equal(registrations.fileWatchers.length, 2); + assert.deepEqual( + registrations.fileWatchers.map((watcher) => watcher.pattern), + [ + '**/.omx/state/active-sessions/*.json', + '**/.omx/state/agent-file-locks.json', + ], + ); + assert.equal(registrations.workspaceFolderListeners.length, 1); const provider = registrations.providers[0].provider; assert.equal(typeof provider.getTreeItem, 'function'); assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.startAgent'), 'function'); const rootItems = await provider.getChildren(); - assert.deepEqual(rootItems, []); + assert.equal(rootItems.length, 1); + assert.equal(rootItems[0].label, 'No active Guardex agents'); assert.equal(registrations.treeViews[0].badge, undefined); - assert.equal(registrations.treeViews[0].message, undefined); + assert.equal(registrations.treeViews[0].message, 'Start a sandbox session to populate this view.'); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -559,21 +686,15 @@ test('active-agents extension startAgent command prompts and runs gx branch star test('active-agents extension groups live sessions under a repo node', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-')); - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-task', + agentName: 'codex', + worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), + pid: process.pid, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; @@ -581,6 +702,7 @@ test('active-agents extension groups live sessions under a repo node', async () const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); @@ -591,12 +713,12 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(agentsSection.label, 'ACTIVE AGENTS'); assert.equal(agentsSection.description, '1'); - const [thinkingSection] = await provider.getChildren(agentsSection); - assert.equal(thinkingSection.label, 'THINKING'); + const [idleSection] = await provider.getChildren(agentsSection); + assert.equal(idleSection.label, 'IDLE'); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, 'live-task 馃敀 0'); - assert.match(sessionItem.description, /^thinking 路 \d+[smhd]/); + assert.match(sessionItem.description, /^idle 路 \d+[smhd]/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); assert.equal( @@ -681,6 +803,7 @@ test('active-agents extension decorates idle clean sessions without overriding w const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; await provider.getChildren(); @@ -744,6 +867,7 @@ test('active-agents refresh also invalidates session decorations', async () => { const provider = registrations.providers[0].provider; await provider.getChildren(); + await flushAsyncWork(); let decorationRefreshCount = 0; registrations.decorationProviders[0].onDidChangeFileDecorations(() => { @@ -751,7 +875,7 @@ test('active-agents refresh also invalidates session decorations', async () => { }); await provider.refresh(); - assert.equal(decorationRefreshCount, 1); + assert.ok(decorationRefreshCount >= 1); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -766,40 +890,62 @@ test('active-agents extension shows grouped repo changes beside active agents', runGit(tempRoot, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-session-')); + const worktreePath = path.join(tempRoot, 'sandbox'); initGitRepo(worktreePath); + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\n', 'utf8'); runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['add', 'src/nested.js']); runGit(worktreePath, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'new\n', 'utf8'); + fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\nchanged\n', 'utf8'); - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); + const mockSessionSchema = { + ...sessionSchema, + readActiveSessions: () => sessionSchema.readActiveSessions(tempRoot, { includeStale: true }), + readRepoChanges: () => [ + { + relativePath: 'sandbox/src/nested.js', + absolutePath: path.join(worktreePath, 'src', 'nested.js'), + statusLabel: 'M', + statusText: 'Modified', + }, + { + relativePath: 'sandbox/tracked.txt', + absolutePath: path.join(worktreePath, 'tracked.txt'), + statusLabel: 'M', + statusText: 'Modified', + }, + { + relativePath: 'root-file.txt', + absolutePath: path.join(tempRoot, 'root-file.txt'), + statusLabel: 'M', + statusText: 'Modified', + }, + ], + }; + const extension = loadExtensionWithMockVscode(vscode, mockSessionSchema); const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 active 路 1 working 路 1 changed'); + assert.equal(repoItem.description, '1 active 路 1 working 路 3 changed'); const [agentsSection, changesSection] = await provider.getChildren(repoItem); assert.equal(agentsSection.label, 'ACTIVE AGENTS'); assert.equal(changesSection.label, 'CHANGES'); @@ -810,17 +956,31 @@ test('active-agents extension shows grouped repo changes beside active agents', const [sessionItem] = await provider.getChildren(workingSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); assert.match(sessionItem.description, /^working 路 2 files 路 /); - assert.match(sessionItem.tooltip, /Changed 2 files: new-file\.txt, tracked\.txt/); + assert.match(sessionItem.tooltip, /Changed 2 files: src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'edit'); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, tooltip: '1 active agent 路 1 working now', }); - const [changeItem] = await provider.getChildren(changesSection); - assert.equal(changeItem.label, 'root-file.txt'); - assert.equal(changeItem.description, 'M'); - assert.match(changeItem.tooltip, /Status Modified/); + const [sessionGroup, repoRootGroup] = await provider.getChildren(changesSection); + assert.equal(sessionGroup.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.match(sessionGroup.description, /^working 路 2 files 路 /); + assert.equal(repoRootGroup.label, 'Repo root'); + + const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); + assert.equal(folderItem.label, 'src'); + assert.equal(trackedItem.label, 'tracked.txt'); + assert.match(trackedItem.tooltip, /^tracked\.txt\nStatus Modified\n/); + + const [nestedItem] = await provider.getChildren(folderItem); + assert.equal(nestedItem.label, 'nested.js'); + assert.match(nestedItem.tooltip, /^src\/nested\.js\nStatus Modified\n/); + + const [rootItem] = await provider.getChildren(repoRootGroup); + assert.equal(rootItem.label, 'root-file.txt'); + assert.equal(rootItem.description, 'M'); + assert.match(rootItem.tooltip, /^root-file\.txt\nStatus Modified\n/); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -881,16 +1041,18 @@ test('active-agents extension decorates sessions and repo changes from the lock const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); const [agentsSection, changesSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); assert.match(sessionItem.tooltip, /Locks 1/); - const [changeItem] = await provider.getChildren(changesSection); + const [repoRootGroup] = await provider.getChildren(changesSection); + const [changeItem] = await provider.getChildren(repoRootGroup); assert.equal(changeItem.label, 'root-file.txt'); assert.equal(changeItem.iconPath.id, 'warning'); assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); @@ -953,6 +1115,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e try { extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); @@ -960,8 +1123,8 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); assert.equal(lockReadCount, 1); @@ -987,8 +1150,8 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedRepoItem] = await provider.getChildren(); const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); - const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); - const [updatedSessionItem] = await provider.getChildren(updatedThinkingSection); + const [updatedIdleSection] = await provider.getChildren(updatedAgentsSection); + const [updatedSessionItem] = await provider.getChildren(updatedIdleSection); assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)} 馃敀 2`); await provider.getChildren(); @@ -1001,79 +1164,134 @@ test('active-agents extension re-reads lock state on watcher events instead of e } }); -test('active-agents extension splits working and thinking sessions into separate groups', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-view-')); +test('active-agents extension groups blocked, working, idle, stalled, and dead sessions in order', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-state-groups-')); + const now = Date.now(); + + const blockedPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-blocked-')); + initGitRepo(blockedPath); + fs.writeFileSync(path.join(blockedPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(blockedPath, ['add', 'tracked.txt']); + runGit(blockedPath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(blockedPath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); - const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-working-')); + const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-')); initGitRepo(workingPath); fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\n', 'utf8'); runGit(workingPath, ['add', 'tracked.txt']); runGit(workingPath, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - const thinkingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-thinking-')); - initGitRepo(thinkingPath); - fs.writeFileSync(path.join(thinkingPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(thinkingPath, ['add', 'tracked.txt']); - runGit(thinkingPath, ['commit', '-m', 'baseline']); - - const workingSessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/working-task'); - fs.mkdirSync(path.dirname(workingSessionPath), { recursive: true }); - fs.writeFileSync( - workingSessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/working-task', - taskName: 'working-task', - agentName: 'codex', - worktreePath: workingPath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const thinkingSessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/thinking-task'); - fs.writeFileSync( - thinkingSessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/thinking-task', - taskName: 'thinking-task', - agentName: 'codex', - worktreePath: thinkingPath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const idlePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-')); + initGitRepo(idlePath); + fs.writeFileSync(path.join(idlePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(idlePath, ['add', 'tracked.txt']); + runGit(idlePath, ['commit', '-m', 'baseline']); + setPathMtime(path.join(idlePath, 'tracked.txt'), now - 30_000); + + const stalledPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stalled-')); + initGitRepo(stalledPath); + fs.writeFileSync(path.join(stalledPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(stalledPath, ['add', 'tracked.txt']); + runGit(stalledPath, ['commit', '-m', 'baseline']); + setPathMtime(path.join(stalledPath, 'tracked.txt'), now - (20 * 60 * 1000)); + + const deadPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dead-')); + initGitRepo(deadPath); + fs.writeFileSync(path.join(deadPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(deadPath, ['add', 'tracked.txt']); + runGit(deadPath, ['commit', '-m', 'baseline']); + + const blockedSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/blocked-task', + taskName: 'blocked-task', + agentName: 'codex', + worktreePath: blockedPath, + pid: process.pid, + cliName: 'codex', + })); + const workingSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/working-task', + taskName: 'working-task', + agentName: 'codex', + worktreePath: workingPath, + pid: process.pid, + cliName: 'codex', + })); + const idleSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/idle-task', + taskName: 'idle-task', + agentName: 'codex', + worktreePath: idlePath, + pid: process.pid, + cliName: 'codex', + })); + const stalledSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/stalled-task', + taskName: 'stalled-task', + agentName: 'codex', + worktreePath: stalledPath, + pid: process.pid, + cliName: 'codex', + })); + const deadSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/dead-task', + taskName: 'dead-task', + agentName: 'codex', + worktreePath: deadPath, + pid: 999999, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [ + { fsPath: blockedSessionPath }, { fsPath: workingSessionPath }, - { fsPath: thinkingSessionPath }, + { fsPath: idleSessionPath }, + { fsPath: stalledSessionPath }, + { fsPath: deadSessionPath }, ]; const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '2 active 路 1 working'); + assert.equal(repoItem.description, '4 active 路 1 dead 路 1 working'); const [agentsSection] = await provider.getChildren(repoItem); - const [workingSection, thinkingSection] = await provider.getChildren(agentsSection); + const [blockedSection, workingSection, idleSection, stalledSection, deadSection] = await provider.getChildren(agentsSection); + assert.equal(blockedSection.label, 'BLOCKED'); assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(thinkingSection.label, 'THINKING'); + assert.equal(idleSection.label, 'IDLE'); + assert.equal(stalledSection.label, 'STALLED'); + assert.equal(deadSection.label, 'DEAD'); + const [blockedItem] = await provider.getChildren(blockedSection); const [workingItem] = await provider.getChildren(workingSection); - const [thinkingItem] = await provider.getChildren(thinkingSection); + const [idleItem] = await provider.getChildren(idleSection); + const [stalledItem] = await provider.getChildren(stalledSection); + const [deadItem] = await provider.getChildren(deadSection); + assert.match(blockedItem.description, /^blocked 路 \d+[smhd]/); + assert.equal(blockedItem.iconPath.id, 'warning'); assert.match(workingItem.description, /^working 路 1 file 路 /); - assert.match(thinkingItem.description, /^thinking 路 \d+[smhd]/); + assert.equal(workingItem.iconPath.id, 'edit'); + assert.match(idleItem.description, /^idle 路 \d+[smhd]/); + assert.equal(idleItem.iconPath.id, 'loading~spin'); + assert.match(stalledItem.description, /^stalled 路 \d+[smhd]/); + assert.equal(stalledItem.iconPath.id, 'clock'); + assert.match(deadItem.description, /^dead 路 \d+[smhd]/); + assert.equal(deadItem.iconPath.id, 'error'); assert.deepEqual(registrations.treeViews[0].badge, { - value: 2, - tooltip: '2 active agents 路 1 working now', + value: 5, + tooltip: '4 active agents 路 1 dead 路 1 working now', }); for (const subscription of context.subscriptions) { @@ -1081,6 +1299,78 @@ test('active-agents extension splits working and thinking sessions into separate } }); +test('active-agents extension watches active sessions, lock files, and session git indexes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-watchers-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/watch-task', + taskName: 'watch-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + let currentSessionFiles = [{ fsPath: sessionPath }]; + vscode.workspace.findFiles = async () => currentSessionFiles; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + assert.deepEqual( + registrations.fileWatchers.map((watcher) => watcher.pattern), + [ + '**/.omx/state/active-sessions/*.json', + '**/.omx/state/agent-file-locks.json', + path.join(worktreePath, '.git', 'index'), + ], + ); + + currentSessionFiles = []; + fs.unlinkSync(sessionPath); + registrations.fileWatchers[0].fireDelete({ fsPath: sessionPath }); + await new Promise((resolve) => setTimeout(resolve, 350)); + await flushAsyncWork(); + + assert.equal(registrations.fileWatchers[2].disposed, true); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension debounces refresh events with a trailing 250ms timer', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-debounce-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + provider.onDidChangeTreeDataEmitter.fireCount = 0; + + registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') }); + registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') }); + assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 300)); + await flushAsyncWork(); + + assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 1); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension commits the selected session worktree from the SCM input', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-view-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-session-')); @@ -1118,6 +1408,7 @@ test('active-agents extension commits the selected session worktree from the SCM const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); @@ -1199,12 +1490,13 @@ test('active-agents extension launches finish and sync commands in session termi const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); await registrations.commands.get('gitguardex.activeAgents.syncSession')(sessionItem.session); @@ -1236,7 +1528,6 @@ test('active-agents extension confirms stop and sends SIGTERM to the session pid const { registrations, vscode } = createMockVscode(tempRoot); const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; - let refreshCount = 0; let killed = null; const originalKill = process.kill; @@ -1251,22 +1542,20 @@ test('active-agents extension confirms stop and sends SIGTERM to the session pid try { extension.activate(context); const provider = registrations.providers[0].provider; - const originalRefresh = provider.refresh.bind(provider); - provider.refresh = () => { - refreshCount += 1; - return originalRefresh(); - }; + await flushAsyncWork(); + provider.onDidChangeTreeDataEmitter.fireCount = 0; await registrations.commands.get('gitguardex.activeAgents.stopSession')({ label: 'live-task', pid: 4242, }); + await flushAsyncWork(); } finally { process.kill = originalKill; } assert.deepEqual(killed, { pid: 4242, signal: 'SIGTERM' }); - assert.equal(refreshCount, 1); + assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); assert.equal(registrations.warningMessages.length, 1); assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); diff --git a/vscode/guardex-active-agents/README.md b/vscode/guardex-active-agents/README.md index 23228d9..0cd0b42 100644 --- a/vscode/guardex-active-agents/README.md +++ b/vscode/guardex-active-agents/README.md @@ -19,9 +19,9 @@ What it does: - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. -- Splits live sessions inside `ACTIVE AGENTS` into `WORKING NOW` and `THINKING` groups so active edit lanes stand out immediately. +- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. - Shows one row per live Guardex sandbox session inside those activity groups. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. -- Derives `thinking` versus `working` from the live sandbox worktree, surfaces working counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses VS Code's native animated `loading~spin` icon for the running-state affordance. +- Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. +- Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. - Reads repo-local presence files from `.omx/state/active-sessions/`. diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 623f107..4f99bf1 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -13,13 +13,57 @@ const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; const IDLE_WARNING_MS = 10 * 60 * 1000; const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; +const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const SESSION_SCAN_LIMIT = 200; +const REFRESH_DEBOUNCE_MS = 250; +const SESSION_ACTIVITY_GROUPS = [ + { kind: 'blocked', label: 'BLOCKED' }, + { kind: 'working', label: 'WORKING NOW' }, + { kind: 'idle', label: 'IDLE' }, + { kind: 'stalled', label: 'STALLED' }, + { kind: 'dead', label: 'DEAD' }, +]; +const SESSION_ACTIVITY_ICON_IDS = { + blocked: 'warning', + working: 'edit', + idle: 'loading~spin', + stalled: 'clock', + dead: 'error', +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } function sessionIdleDecoration(session, now = Date.now()) { - if (!session || session.activityKind === 'working') { + if (!session) { + return undefined; + } + + if (session.activityKind === 'blocked') { + return { + badge: '!', + tooltip: 'blocked', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + if (session.activityKind === 'dead') { + return { + badge: 'x', + tooltip: 'dead', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'stalled') { + return { + badge: '!', + tooltip: 'stalled', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'working') { return undefined; } @@ -74,14 +118,30 @@ class SessionDecorationProvider { } } +class InfoItem extends vscode.TreeItem { + constructor(label, description = '') { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.iconPath = new vscode.ThemeIcon('info'); + } +} + class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = [`${sessions.length} active`]; + const descriptionParts = []; + const activeCount = countActiveSessions(sessions); + const deadCount = countSessionsByActivityKind(sessions, 'dead'); const workingCount = countWorkingSessions(sessions); + if (activeCount > 0) { + descriptionParts.push(`${activeCount} active`); + } + if (deadCount > 0) { + descriptionParts.push(`${deadCount} dead`); + } if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } @@ -109,10 +169,14 @@ class SectionItem extends vscode.TreeItem { } class SessionItem extends vscode.TreeItem { - constructor(session) { + constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; - super(`${session.label} 馃敀 ${lockCount}`, vscode.TreeItemCollapsibleState.None); + super( + `${session.label} 馃敀 ${lockCount}`, + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); this.session = session; + this.items = items; this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { @@ -128,13 +192,13 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); - this.iconPath = session.activityKind === 'working' - ? new vscode.ThemeIcon('edit') - : new vscode.ThemeIcon('loading~spin'); + this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -306,7 +370,7 @@ function repoRootFromLockFile(filePath) { } function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/'); + return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function emptyLockRegistry() { @@ -389,6 +453,104 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function isPathWithin(parentPath, targetPath) { + const relativePath = path.relative(parentPath, targetPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function localizeChangeForSession(session, change) { + if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { + return null; + } + + let originalPath = change.originalPath; + if (originalPath) { + const originalAbsolutePath = path.join(session.repoRoot, originalPath); + if (isPathWithin(session.worktreePath, originalAbsolutePath)) { + originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); + } + } + + return { + ...change, + relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), + originalPath, + }; +} + +async function findRepoSessionEntries() { + const sessionFiles = await vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ); + + const repoRoots = new Set(); + for (const uri of sessionFiles) { + repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + } + + if (repoRoots.size === 0) { + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + repoRoots.add(workspaceFolder.uri.fsPath); + } + } + + const repoEntries = []; + for (const repoRoot of repoRoots) { + const sessions = readActiveSessions(repoRoot, { includeStale: true }); + if (sessions.length > 0) { + repoEntries.push({ repoRoot, sessions }); + } + } + + repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); + return repoEntries; +} + +function resolveSessionWatcherKey(session) { + return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; +} + +function resolveSessionGitIndexPath(worktreePath) { + const gitPath = path.join(worktreePath, '.git'); + const defaultIndexPath = path.join(gitPath, 'index'); + + try { + if (fs.statSync(gitPath).isDirectory()) { + return defaultIndexPath; + } + } catch (_error) { + return defaultIndexPath; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim(), 'index'); + } + } catch (_error) { + return defaultIndexPath; + } + + return defaultIndexPath; +} + +function bindRefreshWatcher(watcher, refresh) { + return [ + watcher.onDidCreate(refresh), + watcher.onDidChange(refresh), + watcher.onDidDelete(refresh), + ]; +} + +function disposeAll(disposables) { + for (const disposable of disposables) { + disposable?.dispose?.(); + } +} + function buildChangeTreeNodes(changes) { const root = []; @@ -454,9 +616,67 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } -<<<<<<< HEAD -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; +function buildGroupedChangeTreeNodes(sessions, changes) { + const changesBySession = new Map(); + const sessionByChangedPath = new Map(); + const repoRootChanges = []; + + for (const session of sessions) { + changesBySession.set(session.branch, []); + for (const changedPath of session.changedPaths || []) { + if (!sessionByChangedPath.has(changedPath)) { + sessionByChangedPath.set(changedPath, session); + } + } + } + + for (const change of changes) { + const normalizedRelativePath = normalizeRelativePath(change.relativePath); + const session = sessionByChangedPath.get(normalizedRelativePath) + || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); + if (!session) { + repoRootChanges.push(change); + continue; + } + + const localizedChange = localizeChangeForSession(session, change); + if (!localizedChange) { + repoRootChanges.push(change); + continue; + } + + changesBySession.get(session.branch).push(localizedChange); + } + + const items = sessions + .map((session) => { + const sessionChanges = changesBySession.get(session.branch) || []; + if (sessionChanges.length === 0) { + return null; + } + return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); + }) + .filter(Boolean); + + if (repoRootChanges.length > 0) { + items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { + description: String(repoRootChanges.length), + })); + } + + return items; +} + +function countActiveSessions(sessions) { + return sessions.filter((session) => session.activityKind !== 'dead').length; +} + +function countSessionsByActivityKind(sessions, activityKind) { + return sessions.filter((session) => session.activityKind === activityKind).length; +} + +function resolveSessionActivityIconId(activityKind) { + return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; } async function pickRepoRoot() { @@ -530,7 +750,8 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); -======= +} + function sessionSelectionKey(session) { if (!session?.repoRoot || !session?.branch) { return ''; @@ -561,23 +782,17 @@ function stageWorktreeForCommit(worktreePath) { function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { - const workingSessions = sessions - .filter((session) => session.activityKind === 'working') - .map((session) => new SessionItem(session)); - const thinkingSessions = sessions - .filter((session) => session.activityKind !== 'working') - .map((session) => new SessionItem(session)); const groups = []; - - if (workingSessions.length > 0) { - groups.push(new SectionItem('WORKING NOW', workingSessions)); - } - if (thinkingSessions.length > 0) { - groups.push(new SectionItem('THINKING', thinkingSessions)); + for (const group of SESSION_ACTIVITY_GROUPS) { + const groupSessions = sessions + .filter((session) => session.activityKind === group.kind) + .map((session) => new SessionItem(session)); + if (groupSessions.length > 0) { + groups.push(new SectionItem(group.label, groupSessions)); + } } return groups; @@ -601,7 +816,7 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0); + this.updateViewState(0, 0, 0); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -633,19 +848,32 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount) { + updateViewState(sessionCount, workingCount, deadCount) { if (!this.treeView) { return; } + const activeCount = Math.max(0, sessionCount - deadCount); + const badgeTooltipParts = []; + if (activeCount > 0) { + badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); + } + if (deadCount > 0) { + badgeTooltipParts.push(`${deadCount} dead`); + } + if (workingCount > 0) { + badgeTooltipParts.push(`${workingCount} working now`); + } + this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}` - + (workingCount > 0 ? ` 路 ${workingCount} working now` : ''), + tooltip: badgeTooltipParts.join(' 路 '), } : undefined; - this.treeView.message = undefined; + this.treeView.message = sessionCount > 0 + ? undefined + : 'Start a sandbox session to populate this view.'; } async syncRepoEntries() { @@ -655,8 +883,12 @@ class ActiveAgentsProvider { (total, entry) => total + countWorkingSessions(entry.sessions), 0, ); + const deadCount = repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ); - this.updateViewState(sessionCount, workingCount); + this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); return repoEntries; } @@ -689,12 +921,14 @@ class ActiveAgentsProvider { }), ]; if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildChangeTreeNodes(element.changes))); + sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + description: String(element.changes.length), + })); } return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { return element.items; } @@ -702,54 +936,100 @@ class ActiveAgentsProvider { this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { - return []; + return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); } async loadRepoEntries() { - const sessionFiles = await vscode.workspace.findFiles( - '**/.omx/state/active-sessions/*.json', - '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**', - 200, - ); + const repoEntries = await findRepoSessionEntries(); + return repoEntries.map((entry) => { + const repoRoot = entry.repoRoot; + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const currentBranch = readCurrentBranch(repoRoot); + return { + repoRoot, + sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), + }; + }); + } +} + +class ActiveAgentsRefreshController { + constructor(provider) { + this.provider = provider; + this.refreshTimer = null; + this.sessionWatchers = new Map(); + } - const repoRoots = new Set(); - for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + scheduleRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshNow(); + }, REFRESH_DEBOUNCE_MS); + } - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + async refreshNow() { + await this.syncSessionWatchers(); + await this.provider.refresh(); + } + + async syncSessionWatchers() { + const repoEntries = await findRepoSessionEntries(); + const liveSessionKeys = new Set(); + + for (const entry of repoEntries) { + for (const session of entry.sessions) { + const sessionKey = resolveSessionWatcherKey(session); + liveSessionKeys.add(sessionKey); + if (this.sessionWatchers.has(sessionKey)) { + continue; + } + + const watcher = vscode.workspace.createFileSystemWatcher( + resolveSessionGitIndexPath(session.worktreePath), + ); + const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); + this.sessionWatchers.set(sessionKey, { watcher, disposables }); } } - const repoEntries = []; - for (const repoRoot of repoRoots) { - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); - if (sessions.length > 0) { - const currentBranch = readCurrentBranch(repoRoot); - repoEntries.push({ - repoRoot, - sessions, - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - }); + for (const [sessionKey, entry] of this.sessionWatchers) { + if (liveSessionKeys.has(sessionKey)) { + continue; } + + disposeAll(entry.disposables); + entry.watcher.dispose(); + this.sessionWatchers.delete(sessionKey); + } + } + + dispose() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; } - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; + for (const entry of this.sessionWatchers.values()) { + disposeAll(entry.disposables); + entry.watcher.dispose(); + } + this.sessionWatchers.clear(); } } function activate(context) { const decorationProvider = new SessionDecorationProvider(); const provider = new ActiveAgentsProvider(decorationProvider); + const refreshController = new ActiveAgentsRefreshController(provider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, @@ -759,11 +1039,10 @@ function activate(context) { 'Active Agents Commit', ); provider.attachTreeView(treeView); - const refresh = () => { - void provider.refresh(); - }; - const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); - const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const scheduleRefresh = () => refreshController.scheduleRefresh(); + const refresh = () => void refreshController.refreshNow(); + const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -815,7 +1094,7 @@ function activate(context) { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); } - refresh(); + scheduleRefresh(); }; provider.onDidChangeSelectedSession(updateCommitInput); @@ -823,6 +1102,7 @@ function activate(context) { context.subscriptions.push( treeView, sourceControl, + refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), @@ -854,18 +1134,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(refresh), - sessionWatcher, + vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + activeSessionsWatcher, lockWatcher, { dispose: () => clearInterval(interval) }, ); - sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); - sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); - sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); - lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); + context.subscriptions.push( + ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), + ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ); + void refreshController.refreshNow(); } function deactivate() {} diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 98390bf..e9282a0 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -8,6 +8,22 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); +const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; +const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; +const BLOCKING_GIT_STATES = [ + { + label: 'Rebase in progress.', + markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], + }, + { + label: 'Merge in progress.', + markers: ['MERGE_HEAD'], + }, + { + label: 'Cherry-pick in progress.', + markers: ['CHERRY_PICK_HEAD'], + }, +]; function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -46,6 +62,10 @@ function splitOutputLines(output) { .filter((line) => line.trim().length > 0); } +function normalizeRelativePath(value) { + return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -184,37 +204,187 @@ function collectWorktreeChangedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveSessionActivity(session) { - const changedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!changedPaths) { +function resolveWorktreeGitDir(worktreePath) { + const gitPath = path.join(path.resolve(worktreePath), '.git'); + try { + if (fs.statSync(gitPath).isDirectory()) { + return gitPath; + } + } catch (_error) { + return null; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim()); + } + } catch (_error) { + return null; + } + + return null; +} + +function deriveBlockingGitLabel(worktreePath) { + const gitDir = resolveWorktreeGitDir(worktreePath); + if (!gitDir) { + return ''; + } + + for (const blockingState of BLOCKING_GIT_STATES) { + if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { + return blockingState.label; + } + } + + return ''; +} + +function collectWorktreeTrackedPaths(worktreePath) { + const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); + if (!trackedPaths) { + return null; + } + + return [...new Set(trackedPaths)] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} + +function deriveLatestWorktreeFileActivity(worktreePath) { + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); + if (!trackedPaths) { + return null; + } + + let latestMtimeMs = null; + for (const relativePath of trackedPaths) { + const absolutePath = path.join(worktreePath, relativePath); + try { + const stats = fs.statSync(absolutePath); + if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { + continue; + } + latestMtimeMs = latestMtimeMs === null + ? stats.mtimeMs + : Math.max(latestMtimeMs, stats.mtimeMs); + } catch (_error) { + continue; + } + } + + return latestMtimeMs; +} + +function deriveSessionActivity(session, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const blockingLabel = deriveBlockingGitLabel(session.worktreePath); + if (blockingLabel) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'blocked', + activityLabel: 'blocked', + activityCountLabel: '', + activitySummary: blockingLabel, + changeCount: 0, + changedPaths: [], + pidAlive: isPidAlive(session.pid), + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const pidAlive = isPidAlive(session.pid); + if (!pidAlive) { + return { + activityKind: 'dead', + activityLabel: 'dead', + activityCountLabel: '', + activitySummary: 'Recorded PID is not alive.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); + if (!worktreeChangedPaths) { + return { + activityKind: 'idle', + activityLabel: 'idle', activityCountLabel: '', activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', }; } - if (changedPaths.length === 0) { + if (worktreeChangedPaths.length > 0) { + const changedPaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath( + path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), + )) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); + + return { + activityKind: 'working', + activityLabel: 'working', + activityCountLabel: formatFileCount(worktreeChangedPaths.length), + activitySummary: previewChangedPaths(worktreeChangedPaths), + changeCount: worktreeChangedPaths.length, + changedPaths, + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const lastFileActivityAt = Number.isFinite(latestFileActivityMs) + ? new Date(latestFileActivityMs).toISOString() + : ''; + const lastFileActivityLabel = lastFileActivityAt + ? formatElapsedFrom(lastFileActivityAt, now) + : ''; + const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) + ? Math.max(0, now - latestFileActivityMs) + : null; + + if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'stalled', + activityLabel: 'stalled', activityCountLabel: '', - activitySummary: 'Worktree clean.', + activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } return { - activityKind: 'working', - activityLabel: 'working', - activityCountLabel: formatFileCount(changedPaths.length), - activitySummary: previewChangedPaths(changedPaths), - changeCount: changedPaths.length, - changedPaths, + activityKind: 'idle', + activityLabel: 'idle', + activityCountLabel: '', + activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS + ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` + : lastFileActivityLabel + ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` + : 'Worktree clean.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } @@ -289,6 +459,7 @@ function normalizeSessionRecord(input, options = {}) { startedAt: startedAt.toISOString(), filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), + changedPaths: [], }; } @@ -360,7 +531,7 @@ function readActiveSessions(repoRoot, options = {}) { } normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized)); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); sessions.push(normalized); } @@ -393,6 +564,9 @@ module.exports = { activeSessionsDirForRepo, buildSessionRecord, collectWorktreeChangedPaths, + collectWorktreeTrackedPaths, + deriveBlockingGitLabel, + deriveLatestWorktreeFileActivity, deriveSessionLabel, deriveSessionActivity, formatElapsedFrom, @@ -404,6 +578,7 @@ module.exports = { readActiveSessions, readRepoChanges, deriveRepoChangeStatus, + resolveWorktreeGitDir, sanitizeBranchForFile, sessionFileNameForBranch, sessionFilePathForBranch,