Merge branch 'master' into feat/intelligent-backend-selection

合并 master 分支的最新改动到 PR #61。

冲突解决:
- dev-workflow/commands/dev.md: 保留 multiSelect backend 选择逻辑
- 保留任务类型字段 type: default|ui|quick-fix
- 保留 Backend 路由策略:default→codex, ui→gemini, quick-fix→claude
- 修复 heredoc 示例格式

合并的 master 改动包括:
- codeagent-wrapper v5.4.0 structured execution report (#94)
- 修复 PATH 重复条目问题 (#95)
- ASCII 模式和性能优化
- 其他 bug 修复和文档更新

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2025-12-25 22:24:15 +08:00
48 changed files with 5321 additions and 605 deletions

22
.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Ensure shell scripts always use LF line endings on all platforms
*.sh text eol=lf
# Ensure Python files use LF line endings
*.py text eol=lf
# Auto-detect text files and normalize line endings to LF
* text=auto eol=lf
# Explicitly declare files that should always be treated as binary
*.exe binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.zip binary
*.gz binary
*.tar binary

View File

@@ -104,38 +104,10 @@ jobs:
cp install.sh install.bat release/ cp install.sh install.bat release/
ls -la release/ ls -la release/
- name: Extract release notes from CHANGELOG
id: extract_notes
run: |
VERSION=${GITHUB_REF#refs/tags/v}
# Extract version section from CHANGELOG.md
awk -v ver="$VERSION" '
/^## [0-9]+\.[0-9]+\.[0-9]+ - / {
if (found) exit
if ($2 == ver) {
found = 1
next
}
}
found && /^## / { exit }
found { print }
' CHANGELOG.md > release_notes.md
# Fallback to auto-generated if extraction failed
if [ ! -s release_notes.md ]; then
echo "⚠️ No release notes found in CHANGELOG.md for version $VERSION" > release_notes.md
echo "" >> release_notes.md
echo "## What's Changed" >> release_notes.md
echo "See commits in this release for details." >> release_notes.md
fi
cat release_notes.md
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: release/* files: release/*
body_path: release_notes.md generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
.claude/ .claude/
.claude-trace .claude-trace
.DS_Store
**/.DS_Store
.venv .venv
.pytest_cache .pytest_cache
__pycache__ __pycache__

View File

@@ -1,129 +1,712 @@
# Changelog # Changelog
## 5.2.0 - 2025-12-13 All notable changes to this project will be documented in this file.
### 🚀 Core Features ## [5.2.4] - 2025-12-16
#### Skills System Enhancements
- **New Skills**: Added `codeagent`, `product-requirements`, `prototype-prompt-generator` to `skill-rules.json`
- **Auto-Activation**: Skills automatically trigger based on keyword/pattern matching via hooks
- **Backward Compatibility**: Retained `skills/codex/SKILL.md` for existing workflows
#### Multi-Backend Support (codeagent-wrapper) ### ⚙️ Miscellaneous Tasks
- **Renamed**: `codex-wrapper``codeagent-wrapper` with pluggable backend architecture
- **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag
- **Smart Parser**: Auto-detects backend JSON stream formats
- **Session Resume**: All backends support `-r <session_id>` cross-session resume
- **Parallel Execution**: DAG task scheduling with global and per-task backend configuration
- **Concurrency Control**: `CODEAGENT_MAX_PARALLEL_WORKERS` env var limits concurrent tasks (max 100)
- **Test Coverage**: 93.4% (backend.go 100%, config.go 97.8%, executor.go 96.4%)
#### Dev Workflow
- **`/dev`**: 6-step minimal dev workflow with mandatory 90% test coverage
#### Hooks System - integrate git-cliff for automated changelog generation
- **UserPromptSubmit**: Auto-activate skills based on context
- **PostToolUse**: Auto-validation/formatting after tool execution
- **Stop**: Cleanup and reporting on session end
- **Examples**: Skill auto-activation, pre-commit checks
#### Skills System - bump version to 5.2.4
- **Auto-Activation**: `skill-rules.json` regex trigger rules
- **codeagent skill**: Multi-backend wrapper integration
- **Modular Design**: Easy to extend with custom skills
#### Installation System Enhancements ### 🐛 Bug Fixes
- **`merge_json` operation**: Auto-merge `settings.json` configuration
- **Modular Installation**: `python3 install.py --module dev`
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output - 防止 Claude backend 无限递归调用
- **Streaming Output**: `op_run_command` streams bash script execution
- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json` - isolate log files per task in parallel mode
### 💼 Other
- Merge pull request #70 from cexll/fix/prevent-codeagent-infinite-recursion
- Merge pull request #69 from cexll/myclaude-master-20251215-073053-338465000
- update CHANGELOG.md
- Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
## [5.2.3] - 2025-12-15
### 🐛 Bug Fixes
- 修复 bufio.Scanner token too long 错误 ([#64](https://github.com/cexll/myclaude/issues/64))
### 💼 Other
- change version
### 🧪 Testing
- 同步测试中的版本号至 5.2.3
## [5.2.2] - 2025-12-13
### ⚙️ Miscellaneous Tasks
- Bump version and clean up documentation
### 🐛 Bug Fixes
- fix codeagent backend claude no auto
- fix install.py dev fail
### 🧪 Testing
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
## [5.2.1] - 2025-12-13
### 🐛 Bug Fixes
- fix codeagent claude and gemini root dir
### 💼 Other
- update readme
## [5.2.0] - 2025-12-13
### ⚙️ Miscellaneous Tasks
- Update CHANGELOG and remove deprecated test files
### 🐛 Bug Fixes
- fix race condition in stdout parsing
- add worker limit cap and remove legacy alias
- use -r flag for gemini backend resume
- clarify module list shows default state not enabled
- use -r flag for claude backend resume
- remove binary artifacts and improve error messages
- 异常退出时显示最近错误信息
- op_run_command 实时流式输出
- 修复权限标志逻辑和版本号测试
- 重构信号处理逻辑避免重复 nil 检查
- 移除 .claude 配置文件验证步骤
- 修复并行执行启动横幅重复打印问题
- 修复master合并后的编译和测试问题
### 💼 Other
- Merge rc/5.2 into master: v5.2.0 release improvements
- Merge pull request #53 from cexll/rc/5.2
- remove docs
- remove docs
- add prototype prompt skill
- add prd skill
- update memory claude
- remove command gh flow
- update license
- Merge branch 'master' into rc/5.2
- Merge pull request #52 from cexll/fix/parallel-log-path-on-startup
### 📚 Documentation ### 📚 Documentation
- `docs/architecture.md` (21KB): Architecture overview with ASCII diagrams
- `docs/CODEAGENT-WRAPPER.md` (9KB): Complete usage guide
- `docs/HOOKS.md` (4KB): Customization guide
- `README.md`: Added documentation index, corrected default backend description
### 🔧 Important Fixes - remove GitHub workflow related content
#### codeagent-wrapper ### 🚀 Features
- Fixed Claude/Gemini backend `-C` (workdir) and `-r` (resume) parameter support (codeagent-wrapper/backend.go:80-120)
- Corrected Claude backend permission flag logic `if cfg.SkipPermissions` (codeagent-wrapper/backend.go:95)
- Fixed parallel mode startup banner duplication (codeagent-wrapper/main.go:184-194 removed)
- Extract and display recent errors on abnormal exit `Logger.ExtractRecentErrors()` (codeagent-wrapper/logger.go:156)
- Added task block index to parallel config error messages (codeagent-wrapper/config.go:245)
- Refactored signal handling logic to avoid duplicate nil checks (codeagent-wrapper/main.go:290-305)
- Removed binary artifacts from tracking (codeagent-wrapper, *.test, coverage.out)
#### Installation Scripts
- Fixed issue #55: `op_run_command` uses Popen + selectors for real-time streaming output
- Fixed issue #56: Display recent errors instead of entire log
- Changed module list header from "Enabled" to "Default" to avoid ambiguity
#### CI/CD - Complete skills system integration and config cleanup
- Removed `.claude/` config file validation step (.github/workflows/ci.yml:45)
- Updated version test case from 5.1.0 → 5.2.0 (codeagent-wrapper/main_test.go:23)
#### Commands & Documentation - Improve release notes and installation scripts
- Reverted `skills/codex/SKILL.md` to `codex-wrapper` for backward compatibility
#### dev-workflow - 添加终端日志输出和 verbose 模式
- Replaced Codex skill → codeagent skill throughout
- Added UI auto-detection: backend tasks use codex, UI tasks use gemini
- Corrected agent name: `develop-doc-generator``dev-plan-generator`
### ⚙️ Configuration & Environment Variables - 完整多后端支持与安全优化
#### New Environment Variables - 替换 Codex 为 codeagent 并添加 UI 自动检测
- `CODEAGENT_SKIP_PERMISSIONS`: Control permission check behavior
- Claude backend defaults to `--dangerously-skip-permissions` enabled, set to `true` to disable
- Codex/Gemini backends default to permission checks enabled, set to `true` to skip
- `CODEAGENT_MAX_PARALLEL_WORKERS`: Parallel task concurrency limit (default: unlimited, recommended: 8, max: 100)
#### Configuration Files ### 🚜 Refactor
- `config.schema.json`: Added `op_merge_json` schema validation
### ⚠️ Breaking Changes
**codex-wrapper → codeagent-wrapper rename** - 调整文件命名和技能定义
**Migration**: ### 🧪 Testing
```bash
python3 install.py --module dev --force
```
**Backward Compatibility**: `codex-wrapper/main.go` provides compatibility entry point
### 📦 Installation - 添加 ExtractRecentErrors 单元测试
```bash ## [5.1.4] - 2025-12-09
# Install dev module
python3 install.py --module dev
# List all modules
python3 install.py --list-modules
# Verbose logging mode ### 🐛 Bug Fixes
python3 install.py --module dev --verbose
```
### 🧪 Test Results
**All tests passing** - 任务启动时立即返回日志文件路径以支持实时调试
- Overall coverage: 93.4%
- Security scan: 0 issues (gosec)
- Linting: Pass
### 📄 Related PRs & Issues ## [5.1.3] - 2025-12-08
- PR #53: Enterprise Workflow with Multi-Backend Support
- Issue #55: Installation script execution not visible
- Issue #56: Unfriendly error logging on abnormal exit
### 👥 Contributors ### 🐛 Bug Fixes
- Claude Sonnet 4.5
- Claude Opus 4.5 - resolve CI timing race in TestFakeCmdInfra
- SWE-Agent-Bot
## [5.1.2] - 2025-12-08
### 🐛 Bug Fixes
- 修复channel同步竞态条件和死锁问题
### 💼 Other
- Merge pull request #51 from cexll/fix/channel-sync-race-conditions
- change codex-wrapper version
## [5.1.1] - 2025-12-08
### 🐛 Bug Fixes
- 增强日志清理的安全性和可靠性
- resolve data race on forceKillDelay with atomic operations
### 💼 Other
- Merge pull request #49 from cexll/freespace8/master
- resolve signal handling conflict preserving testability and Windows support
### 🧪 Testing
- 补充测试覆盖提升至 89.3%
## [5.1.0] - 2025-12-07
### 💼 Other
- Merge pull request #45 from Michaelxwb/master
- 修改windows安装说明
- 修改打包脚本
- 支持windows系统的安装
- Merge pull request #1 from Michaelxwb/feature-win
- 支持window
### 🚀 Features
- 添加启动时清理日志的功能和--cleanup标志支持
- implement enterprise workflow with multi-backend support
## [5.0.0] - 2025-12-05
### ⚙️ Miscellaneous Tasks
- clarify unit-test coverage levels in requirement questions
### 🐛 Bug Fixes
- defer startup log until args parsed
### 💼 Other
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #43 from gurdasnijor/smithery/add-badge
- Add Smithery badge
- Merge pull request #42 from freespace8/master
### 📚 Documentation
- rewrite documentation for v5.0 modular architecture
### 🚀 Features
- feat install.py
- implement modular installation system
### 🚜 Refactor
- remove deprecated plugin modules
## [4.8.2] - 2025-12-02
### 🐛 Bug Fixes
- skip signal test in CI environment
- make forceKillDelay testable to prevent signal test timeout
- correct Go version in go.mod from 1.25.3 to 1.21
- fix codex wrapper async log
- capture and include stderr in error messages
### 💼 Other
- Merge pull request #41 from cexll/fix-async-log
- remove test case 90
- optimize codex-wrapper
- Merge branch 'master' into fix-async-log
## [4.8.1] - 2025-12-01
### 🎨 Styling
- replace emoji with text labels
### 🐛 Bug Fixes
- improve --parallel parameter validation and docs
### 💼 Other
- remove codex-wrapper bin
## [4.8.0] - 2025-11-30
### 💼 Other
- update codex skill dependencies
## [4.7.3] - 2025-11-29
### 🐛 Bug Fixes
- 保留日志文件以便程序退出后调试并完善日志输出功能
### 💼 Other
- Merge pull request #34 from cexll/cce-worktree-master-20251129-111802-997076000
- update CLAUDE.md and codex skill
### 📚 Documentation
- improve codex skill parameter best practices
### 🚀 Features
- add session resume support and improve output format
- add parallel execution support to codex-wrapper
- add async logging to temp file with lifecycle management
## [4.7.2] - 2025-11-28
### 🐛 Bug Fixes
- improve buffer size and streamline message extraction
### 💼 Other
- Merge pull request #32 from freespace8/master
### 🧪 Testing
- 增加对超大单行文本和非字符串文本的处理测试
## [4.7.1] - 2025-11-27
### 💼 Other
- optimize dev pipline
- Merge feat/codex-wrapper: fix repository URLs
## [4.7] - 2025-11-27
### 🐛 Bug Fixes
- update repository URLs to cexll/myclaude
## [4.7-alpha1] - 2025-11-27
### 🐛 Bug Fixes
- fix marketplace schema validation error in dev-workflow plugin
### 💼 Other
- Merge pull request #29 from cexll/feat/codex-wrapper
- Add codex-wrapper Go implementation
- update readme
- update readme
## [4.6] - 2025-11-25
### 💼 Other
- update dev workflow
- update dev workflow
## [4.5] - 2025-11-25
### 🐛 Bug Fixes
- fix codex skill eof
### 💼 Other
- update dev workflow plugin
- update readme
## [4.4] - 2025-11-22
### 🐛 Bug Fixes
- fix codex skill timeout and add more log
- fix codex skill
### 💼 Other
- update gemini skills
- update dev workflow
- update codex skills model config
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #24 from cexll/swe-agent/23-1763544297
### 🚀 Features
- 支持通过环境变量配置 skills 模型
## [4.3] - 2025-11-19
### 🐛 Bug Fixes
- fix codex skills running
### 💼 Other
- update skills plugin
- update gemini
- update doc
- Add Gemini CLI integration skill
### 🚀 Features
- feat simple dev workflow
## [4.2.2] - 2025-11-15
### 💼 Other
- update codex skills
## [4.2.1] - 2025-11-14
### 💼 Other
- Merge pull request #21 from Tshoiasc/master
- Merge branch 'master' into master
- Change default model to gpt-5.1-codex
- Enhance codex.py to auto-detect long inputs and switch to stdin mode, improving handling of shell argument issues. Updated build_codex_args to support stdin and added relevant logging for task length warnings.
## [4.2] - 2025-11-13
### 🐛 Bug Fixes
- fix codex.py wsl run err
### 💼 Other
- optimize codex skills
- Merge branch 'master' of github.com:cexll/myclaude
- Rename SKILLS.md to SKILL.md
- optimize codex skills
### 🚀 Features
- feat codex skills
## [4.1] - 2025-11-04
### 💼 Other
- update enhance-prompt.md response
- update readme
### 📚 Documentation
- 新增 /enhance-prompt 命令并更新所有 README 文档
## [4.0] - 2025-10-22
### 🐛 Bug Fixes
- fix skills format
### 💼 Other
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #18 from cexll/swe-agent/17-1760969135
- update requirements clarity
- update .gitignore
- Fix #17: Update root marketplace.json to use skills array
- Fix #17: Convert requirements-clarity to correct plugin directory format
- Fix #17: Convert requirements-clarity to correct plugin directory format
- Convert requirements-clarity to plugin format with English prompts
- Translate requirements-clarity skill to English for plugin compatibility
- Add requirements-clarity Claude Skill
- Add requirements clarification command
- update
## [3.5] - 2025-10-20
### 💼 Other
- Merge pull request #15 from cexll/swe-agent/13-1760944712
- Fix #13: Clean up redundant README files
- Optimize README structure - Solution A (modular)
- Merge pull request #14 from cexll/swe-agent/12-1760944588
- Fix #12: Update Makefile install paths for new directory structure
## [3.4] - 2025-10-20
### 💼 Other
- Merge pull request #11 from cexll/swe-agent/10-1760752533
- Fix marketplace metadata references
- Fix plugin configuration: rename to marketplace.json and update repository URLs
- Fix #10: Restructure plugin directories to ensure proper command isolation
## [3.3] - 2025-10-15
### 💼 Other
- Update README-zh.md
- Update README.md
- Update marketplace.json
- Update Chinese README with v3.2 plugin system documentation
- Update README with v3.2 plugin system documentation
## [3.2] - 2025-10-10
### 💼 Other
- Add Claude Code plugin system support
- update readme
- Add Makefile for quick deployment and update READMEs
## [3.1] - 2025-09-17
### ◀️ Revert
- revert
### 🐛 Bug Fixes
- fixed bmad-orchestrator not fund
- fix bmad
### 💼 Other
- update bmad review with codex support
- 优化 BMAD 工作流和代理配置
- update gpt5
- support bmad output-style
- update bmad user guide
- update bmad readme
- optimize requirements pilot
- add use gpt5 codex
- add bmad pilot
- sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
- Update Chinese README and requirements-pilot command to align with latest workflow
- update readme
- update agent
- update bugfix sub agents
- Update ask support KISS YAGNI SOLID
- Add comprehensive documentation and multi-agent workflow system
- update commands
<!-- generated by git-cliff -->

View File

@@ -1,7 +1,7 @@
# Claude Code Multi-Agent Workflow System Makefile # Claude Code Multi-Agent Workflow System Makefile
# Quick deployment for BMAD and Requirements workflows # Quick deployment for BMAD and Requirements workflows
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test .PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test changelog
# Default target # Default target
help: help:
@@ -22,6 +22,7 @@ help:
@echo " deploy-all - Deploy everything (commands + agents)" @echo " deploy-all - Deploy everything (commands + agents)"
@echo " test-bmad - Test BMAD workflow with sample" @echo " test-bmad - Test BMAD workflow with sample"
@echo " test-requirements - Test Requirements workflow with sample" @echo " test-requirements - Test Requirements workflow with sample"
@echo " changelog - Update CHANGELOG.md using git-cliff"
@echo " clean - Clean generated artifacts" @echo " clean - Clean generated artifacts"
@echo " help - Show this help message" @echo " help - Show this help message"
@@ -145,3 +146,17 @@ all: deploy-all
version: version:
@echo "Claude Code Multi-Agent Workflow System v3.1" @echo "Claude Code Multi-Agent Workflow System v3.1"
@echo "BMAD + Requirements-Driven Development" @echo "BMAD + Requirements-Driven Development"
# Update CHANGELOG.md using git-cliff
changelog:
@echo "📝 Updating CHANGELOG.md with git-cliff..."
@if ! command -v git-cliff > /dev/null 2>&1; then \
echo "❌ git-cliff not found. Installing via Homebrew..."; \
brew install git-cliff; \
fi
@git-cliff -o CHANGELOG.md
@echo "✅ CHANGELOG.md updated successfully!"
@echo ""
@echo "Preview the changes:"
@echo " git diff CHANGELOG.md"

155
README.md
View File

@@ -7,7 +7,7 @@
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code) [![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude) [![Version](https://img.shields.io/badge/Version-5.2-green)](https://github.com/cexll/myclaude)
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini) > AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
@@ -132,6 +132,59 @@ Requirements → Architecture → Sprint Plan → Development → Review → QA
--- ---
## Version Requirements
### Codex CLI
**Minimum version:** Check compatibility with your installation
The codeagent-wrapper uses these Codex CLI features:
- `codex e` - Execute commands (shorthand for `codex exec`)
- `--skip-git-repo-check` - Skip git repository validation
- `--json` - JSON stream output format
- `-C <workdir>` - Set working directory
- `resume <session_id>` - Resume previous sessions
**Verify Codex CLI is installed:**
```bash
which codex
codex --version
```
### Claude CLI
**Minimum version:** Check compatibility with your installation
Required features:
- `--output-format stream-json` - Streaming JSON output format
- `--setting-sources` - Control setting sources (prevents infinite recursion)
- `--dangerously-skip-permissions` - Skip permission prompts (use with caution)
- `-p` - Prompt input flag
- `-r <session_id>` - Resume sessions
**Security Note:** The wrapper only adds `--dangerously-skip-permissions` for Claude when explicitly enabled (e.g. `--skip-permissions` / `CODEAGENT_SKIP_PERMISSIONS=true`). Keep it disabled unless you understand the risk.
**Verify Claude CLI is installed:**
```bash
which claude
claude --version
```
### Gemini CLI
**Minimum version:** Check compatibility with your installation
Required features:
- `-o stream-json` - JSON stream output format
- `-y` - Auto-approve prompts (non-interactive mode)
- `-r <session_id>` - Resume sessions
- `-p` - Prompt input flag
**Verify Gemini CLI is installed:**
```bash
which gemini
gemini --version
```
---
## Installation ## Installation
### Modular Installation (Recommended) ### Modular Installation (Recommended)
@@ -163,15 +216,39 @@ python3 install.py --force
``` ```
~/.claude/ ~/.claude/
├── bin/
│ └── codeagent-wrapper # Main executable
├── CLAUDE.md # Core instructions and role definition ├── CLAUDE.md # Core instructions and role definition
├── commands/ # Slash commands (/dev, /code, etc.) ├── commands/ # Slash commands (/dev, /code, etc.)
├── agents/ # Agent definitions ├── agents/ # Agent definitions
├── skills/ ├── skills/
│ └── codex/ │ └── codex/
│ └── SKILL.md # Codex integration skill │ └── SKILL.md # Codex integration skill
├── config.json # Configuration
└── installed_modules.json # Installation status └── installed_modules.json # Installation status
``` ```
### Customizing Installation Directory
By default, myclaude installs to `~/.claude`. You can customize this using the `INSTALL_DIR` environment variable:
```bash
# Install to custom directory
INSTALL_DIR=/opt/myclaude bash install.sh
# Update your PATH accordingly
export PATH="/opt/myclaude/bin:$PATH"
```
**Directory Structure:**
- `$INSTALL_DIR/bin/` - codeagent-wrapper binary
- `$INSTALL_DIR/skills/` - Skill definitions
- `$INSTALL_DIR/config.json` - Configuration file
- `$INSTALL_DIR/commands/` - Slash command definitions
- `$INSTALL_DIR/agents/` - Agent definitions
**Note:** When using a custom installation directory, ensure that `$INSTALL_DIR/bin` is added to your `PATH` environment variable.
### Configuration ### Configuration
Edit `config.json` to customize: Edit `config.json` to customize:
@@ -294,11 +371,14 @@ setx PATH "%USERPROFILE%\bin;%PATH%"
**Codex wrapper not found:** **Codex wrapper not found:**
```bash ```bash
# Check PATH # Installer auto-adds PATH, check if configured
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
echo "PATH not configured. Reinstalling..."
bash install.sh
fi
# Reinstall # Or manually add (idempotent command)
bash install.sh [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
``` ```
**Permission denied:** **Permission denied:**
@@ -315,6 +395,71 @@ cat ~/.claude/installed_modules.json
python3 install.py --module dev --force python3 install.py --module dev --force
``` ```
### Version Compatibility Issues
**Backend CLI not found:**
```bash
# Check if backend CLIs are installed
which codex
which claude
which gemini
# Install missing backends
# Codex: Follow installation instructions at https://codex.docs
# Claude: Follow installation instructions at https://claude.ai/docs
# Gemini: Follow installation instructions at https://ai.google.dev/docs
```
**Unsupported CLI flags:**
```bash
# If you see errors like "unknown flag" or "invalid option"
# Check backend CLI version
codex --version
claude --version
gemini --version
# For Codex: Ensure it supports `e`, `--skip-git-repo-check`, `--json`, `-C`, and `resume`
# For Claude: Ensure it supports `--output-format stream-json`, `--setting-sources`, `-r`
# For Gemini: Ensure it supports `-o stream-json`, `-y`, `-r`, `-p`
# Update your backend CLI to the latest version if needed
```
**JSON parsing errors:**
```bash
# If you see "failed to parse JSON output" errors
# Verify the backend outputs stream-json format
codex e --json "test task" # Should output newline-delimited JSON
claude --output-format stream-json -p "test" # Should output stream JSON
# If not, your backend CLI version may be too old or incompatible
```
**Infinite recursion with Claude backend:**
```bash
# The wrapper prevents this with `--setting-sources ""` flag
# If you still see recursion, ensure your Claude CLI supports this flag
claude --help | grep "setting-sources"
# If flag is not supported, upgrade Claude CLI
```
**Session resume failures:**
```bash
# Check if session ID is valid
codex history # List recent sessions
claude history
# Ensure backend CLI supports session resumption
codex resume <session_id> "test" # Should continue from previous session
claude -r <session_id> "test"
# If not supported, use new sessions instead of resume mode
```
--- ---
## Documentation ## Documentation

View File

@@ -2,7 +2,7 @@
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code) [![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude) [![Version](https://img.shields.io/badge/Version-5.2-green)](https://github.com/cexll/myclaude)
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini) > AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
@@ -152,15 +152,39 @@ python3 install.py --force
``` ```
~/.claude/ ~/.claude/
├── bin/
│ └── codeagent-wrapper # 主可执行文件
├── CLAUDE.md # 核心指令和角色定义 ├── CLAUDE.md # 核心指令和角色定义
├── commands/ # 斜杠命令 (/dev, /code 等) ├── commands/ # 斜杠命令 (/dev, /code 等)
├── agents/ # 智能体定义 ├── agents/ # 智能体定义
├── skills/ ├── skills/
│ └── codex/ │ └── codex/
│ └── SKILL.md # Codex 集成技能 │ └── SKILL.md # Codex 集成技能
├── config.json # 配置文件
└── installed_modules.json # 安装状态 └── installed_modules.json # 安装状态
``` ```
### 自定义安装目录
默认情况下myclaude 安装到 `~/.claude`。您可以使用 `INSTALL_DIR` 环境变量自定义安装目录:
```bash
# 安装到自定义目录
INSTALL_DIR=/opt/myclaude bash install.sh
# 相应更新您的 PATH
export PATH="/opt/myclaude/bin:$PATH"
```
**目录结构:**
- `$INSTALL_DIR/bin/` - codeagent-wrapper 可执行文件
- `$INSTALL_DIR/skills/` - 技能定义
- `$INSTALL_DIR/config.json` - 配置文件
- `$INSTALL_DIR/commands/` - 斜杠命令定义
- `$INSTALL_DIR/agents/` - 智能体定义
**注意:** 使用自定义安装目录时,请确保将 `$INSTALL_DIR/bin` 添加到您的 `PATH` 环境变量中。
### 配置 ### 配置
编辑 `config.json` 自定义: 编辑 `config.json` 自定义:
@@ -283,11 +307,14 @@ setx PATH "%USERPROFILE%\bin;%PATH%"
**Codex wrapper 未找到:** **Codex wrapper 未找到:**
```bash ```bash
# 检查 PATH # 安装程序会自动添加 PATH检查是否已添加
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
echo "PATH not configured. Reinstalling..."
bash install.sh
fi
# 重新安装 # 或手动添加(幂等性命令)
bash install.sh [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
``` ```
**权限被拒绝:** **权限被拒绝:**

View File

@@ -427,6 +427,10 @@ Generate architecture document at `./.claude/specs/{feature_name}/02-system-arch
## Important Behaviors ## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, REST, GraphQL, JWT, RBAC, etc.) in English; translate explanatory text only.
### DO: ### DO:
- Start by reviewing and referencing the PRD - Start by reviewing and referencing the PRD
- Present initial architecture based on requirements - Present initial architecture based on requirements

View File

@@ -419,6 +419,10 @@ logger.info('User created', {
## Important Implementation Rules ## Important Implementation Rules
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, CRUD, JWT, SQL, etc.) in English; translate explanatory text only.
### DO: ### DO:
- Follow architecture specifications exactly - Follow architecture specifications exactly
- Implement all acceptance criteria from PRD - Implement all acceptance criteria from PRD

View File

@@ -22,6 +22,10 @@ You are the BMAD Orchestrator. Your core focus is repository analysis, workflow
- Consistency: ensure conventions and patterns discovered in scan are preserved downstream - Consistency: ensure conventions and patterns discovered in scan are preserved downstream
- Explicit handoffs: clearly document assumptions, risks, and integration points for other agents - Explicit handoffs: clearly document assumptions, risks, and integration points for other agents
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
## UltraThink Repository Scan ## UltraThink Repository Scan
When asked to analyze the repository, follow this structure and return a clear, actionable summary. When asked to analyze the repository, follow this structure and return a clear, actionable summary.

View File

@@ -313,6 +313,10 @@ Generate PRD at `./.claude/specs/{feature_name}/01-product-requirements.md`:
## Important Behaviors ## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, Sprint, PRD, KPI, MVP, etc.) in English; translate explanatory text only.
### DO: ### DO:
- Start immediately with greeting and initial understanding - Start immediately with greeting and initial understanding
- Show quality scores transparently - Show quality scores transparently

View File

@@ -478,6 +478,10 @@ module.exports = {
## Important Testing Rules ## Important Testing Rules
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
### DO: ### DO:
- Test all acceptance criteria from PRD - Test all acceptance criteria from PRD
- Cover happy path, edge cases, and error scenarios - Cover happy path, edge cases, and error scenarios

View File

@@ -45,3 +45,7 @@ You are an independent code review agent responsible for conducting reviews betw
- Focus on actionable findings - Focus on actionable findings
- Provide specific QA guidance - Provide specific QA guidance
- Use clear, parseable output format - Use clear, parseable output format
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.

View File

@@ -351,6 +351,10 @@ So that [benefit]
## Important Behaviors ## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (Sprint, Epic, Story, Backlog, Velocity, etc.) in English; translate explanatory text only.
### DO: ### DO:
- Read both PRD and Architecture documents thoroughly - Read both PRD and Architecture documents thoroughly
- Create comprehensive task breakdown - Create comprehensive task breakdown

72
cliff.toml Normal file
View File

@@ -0,0 +1,72 @@
# git-cliff configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# changelog header
header = """
# Changelog
All notable changes to this project will be documented in this file.
"""
# template for the changelog body
body = """
{% if version %}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}
## Unreleased
{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first }}
{% endfor -%}
{% endfor -%}
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = false
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/cexll/myclaude/issues/${2}))" },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "🚀 Features" },
{ message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^doc", group = "📚 Documentation" },
{ message = "^perf", group = "⚡ Performance" },
{ message = "^refactor", group = "🚜 Refactor" },
{ message = "^style", group = "🎨 Styling" },
{ message = "^test", group = "🧪 Testing" },
{ message = "^chore\\(release\\):", skip = true },
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "🛡️ Security" },
{ message = "^revert", group = "◀️ Revert" },
{ message = ".*", group = "💼 Other" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

View File

@@ -1,5 +1,11 @@
package main package main
import (
"encoding/json"
"os"
"path/filepath"
)
// Backend defines the contract for invoking different AI CLI backends. // Backend defines the contract for invoking different AI CLI backends.
// Each backend is responsible for supplying the executable command and // Each backend is responsible for supplying the executable command and
// building the argument list based on the wrapper config. // building the argument list based on the wrapper config.
@@ -26,15 +32,66 @@ func (ClaudeBackend) Command() string {
return "claude" return "claude"
} }
func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string { func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
return buildClaudeArgs(cfg, targetArg)
}
const maxClaudeSettingsBytes = 1 << 20 // 1MB
// loadMinimalEnvSettings 从 ~/.claude/settings.json 只提取 env 配置。
// 只接受字符串类型的值;文件缺失/解析失败/超限都返回空。
func loadMinimalEnvSettings() map[string]string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return nil
}
settingPath := filepath.Join(home, ".claude", "settings.json")
info, err := os.Stat(settingPath)
if err != nil || info.Size() > maxClaudeSettingsBytes {
return nil
}
data, err := os.ReadFile(settingPath)
if err != nil {
return nil
}
var cfg struct {
Env map[string]any `json:"env"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil
}
if len(cfg.Env) == 0 {
return nil
}
env := make(map[string]string, len(cfg.Env))
for k, v := range cfg.Env {
s, ok := v.(string)
if !ok {
continue
}
env[k] = s
}
if len(env) == 0 {
return nil
}
return env
}
func buildClaudeArgs(cfg *Config, targetArg string) []string {
if cfg == nil { if cfg == nil {
return nil return nil
} }
args := []string{"-p", "--dangerously-skip-permissions"} args := []string{"-p"}
if cfg.SkipPermissions {
args = append(args, "--dangerously-skip-permissions")
}
// Only skip permissions when explicitly requested // Prevent infinite recursion: disable all setting sources (user, project, local)
// if cfg.SkipPermissions { // This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
// args = append(args, "--dangerously-skip-permissions") args = append(args, "--setting-sources", "")
// }
if cfg.Mode == "resume" { if cfg.Mode == "resume" {
if cfg.SessionID != "" { if cfg.SessionID != "" {
@@ -56,6 +113,10 @@ func (GeminiBackend) Command() string {
return "gemini" return "gemini"
} }
func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string { func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string {
return buildGeminiArgs(cfg, targetArg)
}
func buildGeminiArgs(cfg *Config, targetArg string) []string {
if cfg == nil { if cfg == nil {
return nil return nil
} }

View File

@@ -1,6 +1,9 @@
package main package main
import ( import (
"bytes"
"os"
"path/filepath"
"reflect" "reflect"
"testing" "testing"
) )
@@ -8,28 +11,28 @@ import (
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) { func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
backend := ClaudeBackend{} backend := ClaudeBackend{}
t.Run("new mode uses workdir without skip by default", func(t *testing.T) { t.Run("new mode omits skip-permissions by default", func(t *testing.T) {
cfg := &Config{Mode: "new", WorkDir: "/repo"} cfg := &Config{Mode: "new", WorkDir: "/repo"}
got := backend.BuildArgs(cfg, "todo") got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "todo"} want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
}) })
t.Run("new mode opt-in skip permissions with default workdir", func(t *testing.T) { t.Run("new mode can opt-in skip-permissions", func(t *testing.T) {
cfg := &Config{Mode: "new", SkipPermissions: true} cfg := &Config{Mode: "new", SkipPermissions: true}
got := backend.BuildArgs(cfg, "-") got := backend.BuildArgs(cfg, "-")
want := []string{"-p", "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "-"} want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
}) })
t.Run("resume mode uses session id and omits workdir", func(t *testing.T) { t.Run("resume mode includes session id", func(t *testing.T) {
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"} cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "resume-task") got := backend.BuildArgs(cfg, "resume-task")
want := []string{"-p", "--dangerously-skip-permissions", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"} want := []string{"-p", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
@@ -38,7 +41,16 @@ func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
t.Run("resume mode without session still returns base flags", func(t *testing.T) { t.Run("resume mode without session still returns base flags", func(t *testing.T) {
cfg := &Config{Mode: "resume", WorkDir: "/ignored"} cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "follow-up") got := backend.BuildArgs(cfg, "follow-up")
want := []string{"-p", "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "follow-up"} want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode can opt-in skip permissions", func(t *testing.T) {
cfg := &Config{Mode: "resume", SessionID: "sid-123", SkipPermissions: true}
got := backend.BuildArgs(cfg, "resume-task")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
@@ -89,7 +101,11 @@ func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
} }
}) })
t.Run("codex build args passthrough remains intact", func(t *testing.T) { t.Run("codex build args omits bypass flag by default", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Unsetenv(key)
backend := CodexBackend{} backend := CodexBackend{}
cfg := &Config{Mode: "new", WorkDir: "/tmp"} cfg := &Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task") got := backend.BuildArgs(cfg, "task")
@@ -98,6 +114,20 @@ func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
}) })
t.Run("codex build args includes bypass flag when enabled", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Setenv(key, "true")
backend := CodexBackend{}
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
} }
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) { func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
@@ -120,3 +150,64 @@ func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
} }
} }
} }
func TestLoadMinimalEnvSettings(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Run("missing file returns empty", func(t *testing.T) {
if got := loadMinimalEnvSettings(); len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
})
t.Run("valid env returns string map", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(dir, "settings.json")
data := []byte(`{"env":{"ANTHROPIC_API_KEY":"secret","FOO":"bar"}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := loadMinimalEnvSettings()
if got["ANTHROPIC_API_KEY"] != "secret" || got["FOO"] != "bar" {
t.Fatalf("got %v, want keys present", got)
}
})
t.Run("non-string values are ignored", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
path := filepath.Join(dir, "settings.json")
data := []byte(`{"env":{"GOOD":"ok","BAD":123,"ALSO_BAD":true}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := loadMinimalEnvSettings()
if got["GOOD"] != "ok" {
t.Fatalf("got %v, want GOOD=ok", got)
}
if _, ok := got["BAD"]; ok {
t.Fatalf("got %v, want BAD omitted", got)
}
if _, ok := got["ALSO_BAD"]; ok {
t.Fatalf("got %v, want ALSO_BAD omitted", got)
}
})
t.Run("oversized file returns empty", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
path := filepath.Join(dir, "settings.json")
data := bytes.Repeat([]byte("a"), maxClaudeSettingsBytes+1)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if got := loadMinimalEnvSettings(); len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
})
}

View File

@@ -13,6 +13,16 @@ import (
"time" "time"
) )
func stripTimestampPrefix(line string) string {
if !strings.HasPrefix(line, "[") {
return line
}
if idx := strings.Index(line, "] "); idx >= 0 {
return line[idx+2:]
}
return line
}
// TestConcurrentStressLogger 高并发压力测试 // TestConcurrentStressLogger 高并发压力测试
func TestConcurrentStressLogger(t *testing.T) { func TestConcurrentStressLogger(t *testing.T) {
if testing.Short() { if testing.Short() {
@@ -76,10 +86,11 @@ func TestConcurrentStressLogger(t *testing.T) {
t.Logf("Successfully wrote %d/%d logs (%.1f%%)", t.Logf("Successfully wrote %d/%d logs (%.1f%%)",
actualCount, totalExpected, float64(actualCount)/float64(totalExpected)*100) actualCount, totalExpected, float64(actualCount)/float64(totalExpected)*100)
// 验证日志格式 // 验证日志格式(纯文本,无前缀)
formatRE := regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[PID:\d+\] INFO: goroutine-`) formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
for i, line := range lines[:min(10, len(lines))] { for i, line := range lines[:min(10, len(lines))] {
if !formatRE.MatchString(line) { msg := stripTimestampPrefix(line)
if !formatRE.MatchString(msg) {
t.Errorf("line %d has invalid format: %s", i, line) t.Errorf("line %d has invalid format: %s", i, line)
} }
} }
@@ -291,18 +302,15 @@ func TestLoggerOrderPreservation(t *testing.T) {
sequences := make(map[int][]int) // goroutine ID -> sequence numbers sequences := make(map[int][]int) // goroutine ID -> sequence numbers
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := stripTimestampPrefix(scanner.Text())
var gid, seq int var gid, seq int
parts := strings.SplitN(line, " INFO: ", 2) // Parse format: G0-SEQ0001 (without INFO: prefix)
if len(parts) != 2 { _, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)
t.Errorf("invalid log format: %s", line) if err != nil {
t.Errorf("invalid log format: %s (error: %v)", line, err)
continue continue
} }
if _, err := fmt.Sscanf(parts[1], "G%d-SEQ%d", &gid, &seq); err == nil {
sequences[gid] = append(sequences[gid], seq) sequences[gid] = append(sequences[gid], seq)
} else {
t.Errorf("failed to parse sequence from line: %s", line)
}
} }
// 验证每个 goroutine 内部顺序 // 验证每个 goroutine 内部顺序

View File

@@ -49,6 +49,15 @@ type TaskResult struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Error string `json:"error"` Error string `json:"error"`
LogPath string `json:"log_path"` LogPath string `json:"log_path"`
// Structured report fields
Coverage string `json:"coverage,omitempty"` // extracted coverage percentage (e.g., "92%")
CoverageNum float64 `json:"coverage_num,omitempty"` // numeric coverage for comparison
CoverageTarget float64 `json:"coverage_target,omitempty"` // target coverage (default 90)
FilesChanged []string `json:"files_changed,omitempty"` // list of changed files
KeyOutput string `json:"key_output,omitempty"` // brief summary of what was done
TestsPassed int `json:"tests_passed,omitempty"` // number of tests passed
TestsFailed int `json:"tests_failed,omitempty"` // number of tests failed
sharedLog bool
} }
var backendRegistry = map[string]Backend{ var backendRegistry = map[string]Backend{
@@ -163,6 +172,9 @@ func parseParallelConfig(data []byte) (*ParallelConfig, error) {
if content == "" { if content == "" {
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID) return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
} }
if task.Mode == "resume" && strings.TrimSpace(task.SessionID) == "" {
return nil, fmt.Errorf("task block #%d (%q) has empty session_id", taskIndex, task.ID)
}
if _, exists := seen[task.ID]; exists { if _, exists := seen[task.ID]; exists {
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID) return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
} }
@@ -231,7 +243,10 @@ func parseArgs() (*Config, error) {
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>") return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
} }
cfg.Mode = "resume" cfg.Mode = "resume"
cfg.SessionID = args[1] cfg.SessionID = strings.TrimSpace(args[1])
if cfg.SessionID == "" {
return nil, fmt.Errorf("resume mode requires non-empty session_id")
}
cfg.Task = args[2] cfg.Task = args[2]
cfg.ExplicitStdin = (args[2] == "-") cfg.ExplicitStdin = (args[2] == "-")
if len(args) > 3 { if len(args) > 3 {

View File

@@ -16,6 +16,8 @@ import (
"time" "time"
) )
const postMessageTerminateDelay = 1 * time.Second
// commandRunner abstracts exec.Cmd for testability // commandRunner abstracts exec.Cmd for testability
type commandRunner interface { type commandRunner interface {
Start() error Start() error
@@ -24,6 +26,7 @@ type commandRunner interface {
StdinPipe() (io.WriteCloser, error) StdinPipe() (io.WriteCloser, error)
SetStderr(io.Writer) SetStderr(io.Writer)
SetDir(string) SetDir(string)
SetEnv(env map[string]string)
Process() processHandle Process() processHandle
} }
@@ -79,6 +82,52 @@ func (r *realCmd) SetDir(dir string) {
} }
} }
func (r *realCmd) SetEnv(env map[string]string) {
if r == nil || r.cmd == nil || len(env) == 0 {
return
}
merged := make(map[string]string, len(env)+len(os.Environ()))
for _, kv := range os.Environ() {
if kv == "" {
continue
}
idx := strings.IndexByte(kv, '=')
if idx <= 0 {
continue
}
merged[kv[:idx]] = kv[idx+1:]
}
for _, kv := range r.cmd.Env {
if kv == "" {
continue
}
idx := strings.IndexByte(kv, '=')
if idx <= 0 {
continue
}
merged[kv[:idx]] = kv[idx+1:]
}
for k, v := range env {
if strings.TrimSpace(k) == "" {
continue
}
merged[k] = v
}
keys := make([]string, 0, len(merged))
for k := range merged {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, k+"="+merged[k])
}
r.cmd.Env = out
}
func (r *realCmd) Process() processHandle { func (r *realCmd) Process() processHandle {
if r == nil || r.cmd == nil || r.cmd.Process == nil { if r == nil || r.cmd == nil || r.cmd.Process == nil {
return nil return nil
@@ -122,7 +171,57 @@ type parseResult struct {
threadID string threadID string
} }
var runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { type taskLoggerContextKey struct{}
func withTaskLogger(ctx context.Context, logger *Logger) context.Context {
if ctx == nil || logger == nil {
return ctx
}
return context.WithValue(ctx, taskLoggerContextKey{}, logger)
}
func taskLoggerFromContext(ctx context.Context) *Logger {
if ctx == nil {
return nil
}
logger, _ := ctx.Value(taskLoggerContextKey{}).(*Logger)
return logger
}
type taskLoggerHandle struct {
logger *Logger
path string
shared bool
closeFn func()
}
func newTaskLoggerHandle(taskID string) taskLoggerHandle {
taskLogger, err := NewLoggerWithSuffix(taskID)
if err == nil {
return taskLoggerHandle{
logger: taskLogger,
path: taskLogger.Path(),
closeFn: func() { _ = taskLogger.Close() },
}
}
msg := fmt.Sprintf("Failed to create task logger for %s: %v, using main logger", taskID, err)
mainLogger := activeLogger()
if mainLogger != nil {
logWarn(msg)
return taskLoggerHandle{
logger: mainLogger,
path: mainLogger.Path(),
shared: true,
}
}
fmt.Fprintln(os.Stderr, msg)
return taskLoggerHandle{}
}
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset)
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
if task.WorkDir == "" { if task.WorkDir == "" {
task.WorkDir = defaultWorkdir task.WorkDir = defaultWorkdir
} }
@@ -151,6 +250,8 @@ var runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return runCodexTaskWithContext(parentCtx, task, backend, nil, false, true, timeout) return runCodexTaskWithContext(parentCtx, task, backend, nil, false, true, timeout)
} }
var runCodexTaskFn = defaultRunCodexTaskFn
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) { func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
idToTask := make(map[string]TaskSpec, len(tasks)) idToTask := make(map[string]TaskSpec, len(tasks))
indegree := make(map[string]int, len(tasks)) indegree := make(map[string]int, len(tasks))
@@ -235,13 +336,8 @@ func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec
var startPrintMu sync.Mutex var startPrintMu sync.Mutex
bannerPrinted := false bannerPrinted := false
printTaskStart := func(taskID string) { printTaskStart := func(taskID, logPath string, shared bool) {
logger := activeLogger() if logPath == "" {
if logger == nil {
return
}
path := logger.Path()
if path == "" {
return return
} }
startPrintMu.Lock() startPrintMu.Lock()
@@ -249,7 +345,11 @@ func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===") fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
bannerPrinted = true bannerPrinted = true
} }
fmt.Fprintf(os.Stderr, "Task %s: Log: %s\n", taskID, path) label := "Log"
if shared {
label = "Log (shared)"
}
fmt.Fprintf(os.Stderr, "Task %s: %s: %s\n", taskID, label, logPath)
startPrintMu.Unlock() startPrintMu.Unlock()
} }
@@ -319,9 +419,11 @@ func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec
wg.Add(1) wg.Add(1)
go func(ts TaskSpec) { go func(ts TaskSpec) {
defer wg.Done() defer wg.Done()
var taskLogPath string
handle := taskLoggerHandle{}
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r)} resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r), LogPath: taskLogPath, sharedLog: handle.shared}
} }
}() }()
@@ -338,9 +440,31 @@ func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec
logConcurrencyState("done", ts.ID, int(after), workerLimit) logConcurrencyState("done", ts.ID, int(after), workerLimit)
}() }()
ts.Context = ctx handle = newTaskLoggerHandle(ts.ID)
printTaskStart(ts.ID) taskLogPath = handle.path
resultsCh <- runCodexTaskFn(ts, timeout) if handle.closeFn != nil {
defer handle.closeFn()
}
taskCtx := ctx
if handle.logger != nil {
taskCtx = withTaskLogger(ctx, handle.logger)
}
ts.Context = taskCtx
printTaskStart(ts.ID, taskLogPath, handle.shared)
res := runCodexTaskFn(ts, timeout)
if taskLogPath != "" {
if res.LogPath == "" || (handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path()) {
res.LogPath = taskLogPath
}
}
// 只有当最终的 LogPath 确实是共享 logger 的路径时才标记为 shared
if handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path() {
res.sharedLog = true
}
resultsCh <- res
}(task) }(task)
} }
@@ -387,64 +511,255 @@ func shouldSkipTask(task TaskSpec, failed map[string]TaskResult) (bool, string)
return true, fmt.Sprintf("skipped due to failed dependencies: %s", strings.Join(blocked, ",")) return true, fmt.Sprintf("skipped due to failed dependencies: %s", strings.Join(blocked, ","))
} }
func generateFinalOutput(results []TaskResult) string { // getStatusSymbols returns status symbols based on ASCII mode.
var sb strings.Builder func getStatusSymbols() (success, warning, failed string) {
if os.Getenv("CODEAGENT_ASCII_MODE") == "true" {
return "PASS", "WARN", "FAIL"
}
return "✓", "⚠️", "✗"
}
func generateFinalOutput(results []TaskResult) string {
return generateFinalOutputWithMode(results, true) // default to summary mode
}
// generateFinalOutputWithMode generates output based on mode
// summaryOnly=true: structured report - every token has value
// summaryOnly=false: full output with complete messages (legacy behavior)
func generateFinalOutputWithMode(results []TaskResult, summaryOnly bool) string {
var sb strings.Builder
successSymbol, warningSymbol, failedSymbol := getStatusSymbols()
reportCoverageTarget := defaultCoverageTarget
for _, res := range results {
if res.CoverageTarget > 0 {
reportCoverageTarget = res.CoverageTarget
break
}
}
// Count results by status
success := 0 success := 0
failed := 0 failed := 0
belowTarget := 0
for _, res := range results { for _, res := range results {
if res.ExitCode == 0 && res.Error == "" { if res.ExitCode == 0 && res.Error == "" {
success++ success++
target := res.CoverageTarget
if target <= 0 {
target = reportCoverageTarget
}
if res.Coverage != "" && target > 0 && res.CoverageNum < target {
belowTarget++
}
} else { } else {
failed++ failed++
} }
} }
sb.WriteString(fmt.Sprintf("=== Parallel Execution Summary ===\n")) if summaryOnly {
// Header
sb.WriteString("=== Execution Report ===\n")
sb.WriteString(fmt.Sprintf("%d tasks | %d passed | %d failed", len(results), success, failed))
if belowTarget > 0 {
sb.WriteString(fmt.Sprintf(" | %d below %.0f%%", belowTarget, reportCoverageTarget))
}
sb.WriteString("\n\n")
// Task Results - each task gets: Did + Files + Tests + Coverage
sb.WriteString("## Task Results\n")
for _, res := range results {
taskID := sanitizeOutput(res.TaskID)
coverage := sanitizeOutput(res.Coverage)
keyOutput := sanitizeOutput(res.KeyOutput)
logPath := sanitizeOutput(res.LogPath)
filesChanged := sanitizeOutput(strings.Join(res.FilesChanged, ", "))
target := res.CoverageTarget
if target <= 0 {
target = reportCoverageTarget
}
isSuccess := res.ExitCode == 0 && res.Error == ""
isBelowTarget := isSuccess && coverage != "" && target > 0 && res.CoverageNum < target
if isSuccess && !isBelowTarget {
// Passed task: one block with Did/Files/Tests
sb.WriteString(fmt.Sprintf("\n### %s %s", taskID, successSymbol))
if coverage != "" {
sb.WriteString(fmt.Sprintf(" %s", coverage))
}
sb.WriteString("\n")
if keyOutput != "" {
sb.WriteString(fmt.Sprintf("Did: %s\n", keyOutput))
}
if len(res.FilesChanged) > 0 {
sb.WriteString(fmt.Sprintf("Files: %s\n", filesChanged))
}
if res.TestsPassed > 0 {
sb.WriteString(fmt.Sprintf("Tests: %d passed\n", res.TestsPassed))
}
if logPath != "" {
sb.WriteString(fmt.Sprintf("Log: %s\n", logPath))
}
} else if isSuccess && isBelowTarget {
// Below target: add Gap info
sb.WriteString(fmt.Sprintf("\n### %s %s %s (below %.0f%%)\n", taskID, warningSymbol, coverage, target))
if keyOutput != "" {
sb.WriteString(fmt.Sprintf("Did: %s\n", keyOutput))
}
if len(res.FilesChanged) > 0 {
sb.WriteString(fmt.Sprintf("Files: %s\n", filesChanged))
}
if res.TestsPassed > 0 {
sb.WriteString(fmt.Sprintf("Tests: %d passed\n", res.TestsPassed))
}
// Extract what's missing from coverage
gap := sanitizeOutput(extractCoverageGap(res.Message))
if gap != "" {
sb.WriteString(fmt.Sprintf("Gap: %s\n", gap))
}
if logPath != "" {
sb.WriteString(fmt.Sprintf("Log: %s\n", logPath))
}
} else {
// Failed task: show error detail
sb.WriteString(fmt.Sprintf("\n### %s %s FAILED\n", taskID, failedSymbol))
sb.WriteString(fmt.Sprintf("Exit code: %d\n", res.ExitCode))
if errText := sanitizeOutput(res.Error); errText != "" {
sb.WriteString(fmt.Sprintf("Error: %s\n", errText))
}
// Show context from output (last meaningful lines)
detail := sanitizeOutput(extractErrorDetail(res.Message, 300))
if detail != "" {
sb.WriteString(fmt.Sprintf("Detail: %s\n", detail))
}
if logPath != "" {
sb.WriteString(fmt.Sprintf("Log: %s\n", logPath))
}
}
}
// Summary section
sb.WriteString("\n## Summary\n")
sb.WriteString(fmt.Sprintf("- %d/%d completed successfully\n", success, len(results)))
if belowTarget > 0 || failed > 0 {
var needFix []string
var needCoverage []string
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
taskID := sanitizeOutput(res.TaskID)
reason := sanitizeOutput(res.Error)
if reason == "" && res.ExitCode != 0 {
reason = fmt.Sprintf("exit code %d", res.ExitCode)
}
reason = safeTruncate(reason, 50)
needFix = append(needFix, fmt.Sprintf("%s (%s)", taskID, reason))
continue
}
target := res.CoverageTarget
if target <= 0 {
target = reportCoverageTarget
}
if res.Coverage != "" && target > 0 && res.CoverageNum < target {
needCoverage = append(needCoverage, sanitizeOutput(res.TaskID))
}
}
if len(needFix) > 0 {
sb.WriteString(fmt.Sprintf("- Fix: %s\n", strings.Join(needFix, ", ")))
}
if len(needCoverage) > 0 {
sb.WriteString(fmt.Sprintf("- Coverage: %s\n", strings.Join(needCoverage, ", ")))
}
}
} else {
// Legacy full output mode
sb.WriteString("=== Parallel Execution Summary ===\n")
sb.WriteString(fmt.Sprintf("Total: %d | Success: %d | Failed: %d\n\n", len(results), success, failed)) sb.WriteString(fmt.Sprintf("Total: %d | Success: %d | Failed: %d\n\n", len(results), success, failed))
for _, res := range results { for _, res := range results {
sb.WriteString(fmt.Sprintf("--- Task: %s ---\n", res.TaskID)) taskID := sanitizeOutput(res.TaskID)
sb.WriteString(fmt.Sprintf("--- Task: %s ---\n", taskID))
if res.Error != "" { if res.Error != "" {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\nError: %s\n", res.ExitCode, res.Error)) sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\nError: %s\n", res.ExitCode, sanitizeOutput(res.Error)))
} else if res.ExitCode != 0 { } else if res.ExitCode != 0 {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\n", res.ExitCode)) sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\n", res.ExitCode))
} else { } else {
sb.WriteString("Status: SUCCESS\n") sb.WriteString("Status: SUCCESS\n")
} }
if res.Coverage != "" {
sb.WriteString(fmt.Sprintf("Coverage: %s\n", sanitizeOutput(res.Coverage)))
}
if res.SessionID != "" { if res.SessionID != "" {
sb.WriteString(fmt.Sprintf("Session: %s\n", res.SessionID)) sb.WriteString(fmt.Sprintf("Session: %s\n", sanitizeOutput(res.SessionID)))
} }
if res.LogPath != "" { if res.LogPath != "" {
sb.WriteString(fmt.Sprintf("Log: %s\n", res.LogPath)) logPath := sanitizeOutput(res.LogPath)
if res.sharedLog {
sb.WriteString(fmt.Sprintf("Log: %s (shared)\n", logPath))
} else {
sb.WriteString(fmt.Sprintf("Log: %s\n", logPath))
}
} }
if res.Message != "" { if res.Message != "" {
sb.WriteString(fmt.Sprintf("\n%s\n", res.Message)) message := sanitizeOutput(res.Message)
if message != "" {
sb.WriteString(fmt.Sprintf("\n%s\n", message))
}
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
}
return sb.String() return sb.String()
} }
func buildCodexArgs(cfg *Config, targetArg string) []string { func buildCodexArgs(cfg *Config, targetArg string) []string {
if cfg.Mode == "resume" { if cfg == nil {
return []string{ panic("buildCodexArgs: nil config")
"e", }
"--skip-git-repo-check",
var resumeSessionID string
isResume := cfg.Mode == "resume"
if isResume {
resumeSessionID = strings.TrimSpace(cfg.SessionID)
if resumeSessionID == "" {
logError("invalid config: resume mode requires non-empty session_id")
isResume = false
}
}
args := []string{"e"}
if envFlagEnabled("CODEX_BYPASS_SANDBOX") {
logWarn("CODEX_BYPASS_SANDBOX=true: running without approval/sandbox protection")
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
}
args = append(args, "--skip-git-repo-check")
if isResume {
return append(args,
"--json", "--json",
"resume", "resume",
cfg.SessionID, resumeSessionID,
targetArg, targetArg,
)
} }
}
return []string{ return append(args,
"e",
"--skip-git-repo-check",
"-C", cfg.WorkDir, "-C", cfg.WorkDir,
"--json", "--json",
targetArg, targetArg,
} )
} }
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult { func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
@@ -457,15 +772,16 @@ func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText str
} }
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult { func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
if parentCtx == nil {
parentCtx = taskSpec.Context
}
if parentCtx == nil {
parentCtx = context.Background()
}
result := TaskResult{TaskID: taskSpec.ID} result := TaskResult{TaskID: taskSpec.ID}
setLogPath := func() { injectedLogger := taskLoggerFromContext(parentCtx)
if result.LogPath != "" { logger := injectedLogger
return
}
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
}
cfg := &Config{ cfg := &Config{
Mode: taskSpec.Mode, Mode: taskSpec.Mode,
@@ -494,6 +810,12 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
cfg.WorkDir = defaultWorkdir cfg.WorkDir = defaultWorkdir
} }
if cfg.Mode == "resume" && strings.TrimSpace(cfg.SessionID) == "" {
result.ExitCode = 1
result.Error = "resume mode requires non-empty session_id"
return result
}
useStdin := taskSpec.UseStdin useStdin := taskSpec.UseStdin
targetArg := taskSpec.Task targetArg := taskSpec.Task
if useStdin { if useStdin {
@@ -521,17 +843,17 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
if silent { if silent {
// Silent mode: only persist to file when available; avoid stderr noise. // Silent mode: only persist to file when available; avoid stderr noise.
logInfoFn = func(msg string) { logInfoFn = func(msg string) {
if logger := activeLogger(); logger != nil { if logger != nil {
logger.Info(prefixMsg(msg)) logger.Info(prefixMsg(msg))
} }
} }
logWarnFn = func(msg string) { logWarnFn = func(msg string) {
if logger := activeLogger(); logger != nil { if logger != nil {
logger.Warn(prefixMsg(msg)) logger.Warn(prefixMsg(msg))
} }
} }
logErrorFn = func(msg string) { logErrorFn = func(msg string) {
if logger := activeLogger(); logger != nil { if logger != nil {
logger.Error(prefixMsg(msg)) logger.Error(prefixMsg(msg))
} }
} }
@@ -547,10 +869,11 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
var stderrLogger *logWriter var stderrLogger *logWriter
var tempLogger *Logger var tempLogger *Logger
if silent && activeLogger() == nil { if logger == nil && silent && activeLogger() == nil {
if l, err := NewLogger(); err == nil { if l, err := NewLogger(); err == nil {
setLogger(l) setLogger(l)
tempLogger = l tempLogger = l
logger = l
} }
} }
defer func() { defer func() {
@@ -558,21 +881,29 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
_ = closeLogger() _ = closeLogger()
} }
}() }()
defer setLogPath() defer func() {
if logger := activeLogger(); logger != nil { if result.LogPath != "" || logger == nil {
return
}
result.LogPath = logger.Path()
}()
if logger == nil {
logger = activeLogger()
}
if logger != nil {
result.LogPath = logger.Path() result.LogPath = logger.Path()
} }
if !silent { if !silent {
stdoutLogger = newLogWriter("CODEX_STDOUT: ", codexLogLineLimit) // Note: Empty prefix ensures backend output is logged as-is without any wrapper format.
stderrLogger = newLogWriter("CODEX_STDERR: ", codexLogLineLimit) // This preserves the original stdout/stderr content from codex/claude/gemini backends.
// Trade-off: Reduces distinguishability between stdout/stderr in logs, but maintains
// output fidelity which is critical for debugging backend-specific issues.
stdoutLogger = newLogWriter("", codexLogLineLimit)
stderrLogger = newLogWriter("", codexLogLineLimit)
} }
ctx := parentCtx ctx := parentCtx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel() defer cancel()
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
@@ -584,6 +915,12 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
cmd := newCommandRunner(ctx, commandName, codexArgs...) cmd := newCommandRunner(ctx, commandName, codexArgs...)
if cfg.Backend == "claude" {
if env := loadMinimalEnvSettings(); len(env) > 0 {
cmd.SetEnv(env)
}
}
// For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir // For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir
// Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts // Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts
if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" { if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" {
@@ -594,8 +931,17 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
if stderrLogger != nil { if stderrLogger != nil {
stderrWriters = append(stderrWriters, stderrLogger) stderrWriters = append(stderrWriters, stderrLogger)
} }
// For gemini backend, filter noisy stderr output
var stderrFilter *filteringWriter
if !silent { if !silent {
stderrWriters = append([]io.Writer{os.Stderr}, stderrWriters...) stderrOut := io.Writer(os.Stderr)
if cfg.Backend == "gemini" {
stderrFilter = newFilteringWriter(os.Stderr, geminiNoisePatterns)
stderrOut = stderrFilter
defer stderrFilter.Flush()
}
stderrWriters = append([]io.Writer{stderrOut}, stderrWriters...)
} }
if len(stderrWriters) == 1 { if len(stderrWriters) == 1 {
cmd.SetStderr(stderrWriters[0]) cmd.SetStderr(stderrWriters[0])
@@ -631,6 +977,7 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
// Start parse goroutine BEFORE starting the command to avoid race condition // Start parse goroutine BEFORE starting the command to avoid race condition
// where fast-completing commands close stdout before parser starts reading // where fast-completing commands close stdout before parser starts reading
messageSeen := make(chan struct{}, 1) messageSeen := make(chan struct{}, 1)
completeSeen := make(chan struct{}, 1)
parseCh := make(chan parseResult, 1) parseCh := make(chan parseResult, 1)
go func() { go func() {
msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() { msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() {
@@ -638,7 +985,16 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
case messageSeen <- struct{}{}: case messageSeen <- struct{}{}:
default: default:
} }
}, func() {
select {
case completeSeen <- struct{}{}:
default:
}
}) })
select {
case completeSeen <- struct{}{}:
default:
}
parseCh <- parseResult{message: msg, threadID: tid} parseCh <- parseResult{message: msg, threadID: tid}
}() }()
@@ -659,7 +1015,7 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
} }
logInfoFn(fmt.Sprintf("Starting %s with PID: %d", commandName, cmd.Process().Pid())) logInfoFn(fmt.Sprintf("Starting %s with PID: %d", commandName, cmd.Process().Pid()))
if logger := activeLogger(); logger != nil { if logger != nil {
logInfoFn(fmt.Sprintf("Log capturing to: %s", logger.Path())) logInfoFn(fmt.Sprintf("Log capturing to: %s", logger.Path()))
} }
@@ -675,17 +1031,63 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
waitCh := make(chan error, 1) waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }() go func() { waitCh <- cmd.Wait() }()
var waitErr error var (
var forceKillTimer *forceKillTimer waitErr error
var ctxCancelled bool forceKillTimer *forceKillTimer
ctxCancelled bool
messageTimer *time.Timer
messageTimerCh <-chan time.Time
forcedAfterComplete bool
terminated bool
messageSeenObserved bool
completeSeenObserved bool
)
waitLoop:
for {
select { select {
case waitErr = <-waitCh: case waitErr = <-waitCh:
break waitLoop
case <-ctx.Done(): case <-ctx.Done():
ctxCancelled = true ctxCancelled = true
logErrorFn(cancelReason(commandName, ctx)) logErrorFn(cancelReason(commandName, ctx))
forceKillTimer = terminateCommandFn(cmd) if !terminated {
if timer := terminateCommandFn(cmd); timer != nil {
forceKillTimer = timer
terminated = true
}
}
waitErr = <-waitCh waitErr = <-waitCh
break waitLoop
case <-messageTimerCh:
forcedAfterComplete = true
messageTimerCh = nil
if !terminated {
logWarnFn(fmt.Sprintf("%s output parsed; terminating lingering backend", commandName))
if timer := terminateCommandFn(cmd); timer != nil {
forceKillTimer = timer
terminated = true
}
}
case <-completeSeen:
completeSeenObserved = true
if messageTimer != nil {
continue
}
messageTimer = time.NewTimer(postMessageTerminateDelay)
messageTimerCh = messageTimer.C
case <-messageSeen:
messageSeenObserved = true
}
}
if messageTimer != nil {
if !messageTimer.Stop() {
select {
case <-messageTimer.C:
default:
}
}
} }
if forceKillTimer != nil { if forceKillTimer != nil {
@@ -693,10 +1095,14 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
} }
var parsed parseResult var parsed parseResult
if ctxCancelled { switch {
case ctxCancelled:
closeWithReason(stdout, stdoutCloseReasonCtx) closeWithReason(stdout, stdoutCloseReasonCtx)
parsed = <-parseCh parsed = <-parseCh
} else { case messageSeenObserved || completeSeenObserved:
closeWithReason(stdout, stdoutCloseReasonWait)
parsed = <-parseCh
default:
drainTimer := time.NewTimer(stdoutDrainTimeout) drainTimer := time.NewTimer(stdoutDrainTimeout)
defer drainTimer.Stop() defer drainTimer.Stop()
@@ -704,6 +1110,11 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
case parsed = <-parseCh: case parsed = <-parseCh:
closeWithReason(stdout, stdoutCloseReasonWait) closeWithReason(stdout, stdoutCloseReasonWait)
case <-messageSeen: case <-messageSeen:
messageSeenObserved = true
closeWithReason(stdout, stdoutCloseReasonWait)
parsed = <-parseCh
case <-completeSeen:
completeSeenObserved = true
closeWithReason(stdout, stdoutCloseReasonWait) closeWithReason(stdout, stdoutCloseReasonWait)
parsed = <-parseCh parsed = <-parseCh
case <-drainTimer.C: case <-drainTimer.C:
@@ -724,6 +1135,9 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
} }
if waitErr != nil { if waitErr != nil {
if forcedAfterComplete && parsed.message != "" {
logWarnFn(fmt.Sprintf("%s terminated after delivering output", commandName))
} else {
if exitErr, ok := waitErr.(*exec.ExitError); ok { if exitErr, ok := waitErr.(*exec.ExitError); ok {
code := exitErr.ExitCode() code := exitErr.ExitCode()
logErrorFn(fmt.Sprintf("%s exited with status %d", commandName, code)) logErrorFn(fmt.Sprintf("%s exited with status %d", commandName, code))
@@ -736,6 +1150,7 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
result.Error = attachStderr(commandName + " error: " + waitErr.Error()) result.Error = attachStderr(commandName + " error: " + waitErr.Error())
return result return result
} }
}
message := parsed.message message := parsed.message
threadID := parsed.threadID threadID := parsed.threadID
@@ -756,8 +1171,8 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
result.ExitCode = 0 result.ExitCode = 0
result.Message = message result.Message = message
result.SessionID = threadID result.SessionID = threadID
if logger := activeLogger(); logger != nil { if result.LogPath == "" && injectedLogger != nil {
result.LogPath = logger.Path() result.LogPath = injectedLogger.Path()
} }
return result return result

View File

@@ -1,12 +1,16 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -15,6 +19,12 @@ import (
"time" "time"
) )
var executorTestTaskCounter atomic.Int64
func nextExecutorTestTaskID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, executorTestTaskCounter.Add(1))
}
type execFakeProcess struct { type execFakeProcess struct {
pid int pid int
signals []os.Signal signals []os.Signal
@@ -76,6 +86,8 @@ type execFakeRunner struct {
stdout io.ReadCloser stdout io.ReadCloser
process processHandle process processHandle
stdin io.WriteCloser stdin io.WriteCloser
dir string
env map[string]string
waitErr error waitErr error
waitDelay time.Duration waitDelay time.Duration
startErr error startErr error
@@ -117,7 +129,18 @@ func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
return &writeCloserStub{}, nil return &writeCloserStub{}, nil
} }
func (f *execFakeRunner) SetStderr(io.Writer) {} func (f *execFakeRunner) SetStderr(io.Writer) {}
func (f *execFakeRunner) SetDir(string) {} func (f *execFakeRunner) SetDir(dir string) { f.dir = dir }
func (f *execFakeRunner) SetEnv(env map[string]string) {
if len(env) == 0 {
return
}
if f.env == nil {
f.env = make(map[string]string, len(env))
}
for k, v := range env {
f.env[k] = v
}
}
func (f *execFakeRunner) Process() processHandle { func (f *execFakeRunner) Process() processHandle {
if f.process != nil { if f.process != nil {
return f.process return f.process
@@ -149,6 +172,10 @@ func TestExecutorHelperCoverage(t *testing.T) {
} }
rcWithCmd := &realCmd{cmd: &exec.Cmd{}} rcWithCmd := &realCmd{cmd: &exec.Cmd{}}
rcWithCmd.SetStderr(io.Discard) rcWithCmd.SetStderr(io.Discard)
rcWithCmd.SetDir("/tmp")
if rcWithCmd.cmd.Dir != "/tmp" {
t.Fatalf("expected SetDir to set cmd.Dir, got %q", rcWithCmd.cmd.Dir)
}
echoCmd := exec.Command("echo", "ok") echoCmd := exec.Command("echo", "ok")
rcProc := &realCmd{cmd: echoCmd} rcProc := &realCmd{cmd: echoCmd}
stdoutPipe, err := rcProc.StdoutPipe() stdoutPipe, err := rcProc.StdoutPipe()
@@ -230,6 +257,10 @@ func TestExecutorHelperCoverage(t *testing.T) {
}) })
t.Run("generateFinalOutputAndArgs", func(t *testing.T) { t.Run("generateFinalOutputAndArgs", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Unsetenv(key)
out := generateFinalOutput([]TaskResult{ out := generateFinalOutput([]TaskResult{
{TaskID: "ok", ExitCode: 0}, {TaskID: "ok", ExitCode: 0},
{TaskID: "fail", ExitCode: 1, Error: "boom"}, {TaskID: "fail", ExitCode: 1, Error: "boom"},
@@ -237,21 +268,66 @@ func TestExecutorHelperCoverage(t *testing.T) {
if !strings.Contains(out, "ok") || !strings.Contains(out, "fail") { if !strings.Contains(out, "ok") || !strings.Contains(out, "fail") {
t.Fatalf("unexpected summary output: %s", out) t.Fatalf("unexpected summary output: %s", out)
} }
// Test summary mode (default) - should have new format with ### headers
out = generateFinalOutput([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}}) out = generateFinalOutput([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}})
if !strings.Contains(out, "### rich") {
t.Fatalf("summary output missing task header: %s", out)
}
// Test full output mode - should have Session and Message
out = generateFinalOutputWithMode([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}}, false)
if !strings.Contains(out, "Session: sess") || !strings.Contains(out, "Log: /tmp/log") || !strings.Contains(out, "hello") { if !strings.Contains(out, "Session: sess") || !strings.Contains(out, "Log: /tmp/log") || !strings.Contains(out, "hello") {
t.Fatalf("rich output missing fields: %s", out) t.Fatalf("full output missing fields: %s", out)
} }
args := buildCodexArgs(&Config{Mode: "new", WorkDir: "/tmp"}, "task") args := buildCodexArgs(&Config{Mode: "new", WorkDir: "/tmp"}, "task")
if len(args) == 0 || args[3] != "/tmp" { if !slices.Equal(args, []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}) {
t.Fatalf("unexpected codex args: %+v", args) t.Fatalf("unexpected codex args: %+v", args)
} }
args = buildCodexArgs(&Config{Mode: "resume", SessionID: "sess"}, "target") args = buildCodexArgs(&Config{Mode: "resume", SessionID: "sess"}, "target")
if args[3] != "resume" || args[4] != "sess" { if !slices.Equal(args, []string{"e", "--skip-git-repo-check", "--json", "resume", "sess", "target"}) {
t.Fatalf("unexpected resume args: %+v", args) t.Fatalf("unexpected resume args: %+v", args)
} }
}) })
t.Run("generateFinalOutputASCIIMode", func(t *testing.T) {
t.Setenv("CODEAGENT_ASCII_MODE", "true")
results := []TaskResult{
{TaskID: "ok", ExitCode: 0, Coverage: "92%", CoverageNum: 92, CoverageTarget: 90, KeyOutput: "done"},
{TaskID: "warn", ExitCode: 0, Coverage: "80%", CoverageNum: 80, CoverageTarget: 90, KeyOutput: "did"},
{TaskID: "bad", ExitCode: 2, Error: "boom"},
}
out := generateFinalOutput(results)
for _, sym := range []string{"PASS", "WARN", "FAIL"} {
if !strings.Contains(out, sym) {
t.Fatalf("ASCII mode should include %q, got: %s", sym, out)
}
}
for _, sym := range []string{"✓", "⚠️", "✗"} {
if strings.Contains(out, sym) {
t.Fatalf("ASCII mode should not include %q, got: %s", sym, out)
}
}
})
t.Run("generateFinalOutputUnicodeMode", func(t *testing.T) {
t.Setenv("CODEAGENT_ASCII_MODE", "false")
results := []TaskResult{
{TaskID: "ok", ExitCode: 0, Coverage: "92%", CoverageNum: 92, CoverageTarget: 90, KeyOutput: "done"},
{TaskID: "warn", ExitCode: 0, Coverage: "80%", CoverageNum: 80, CoverageTarget: 90, KeyOutput: "did"},
{TaskID: "bad", ExitCode: 2, Error: "boom"},
}
out := generateFinalOutput(results)
for _, sym := range []string{"✓", "⚠️", "✗"} {
if !strings.Contains(out, sym) {
t.Fatalf("Unicode mode should include %q, got: %s", sym, out)
}
}
})
t.Run("executeConcurrentWrapper", func(t *testing.T) { t.Run("executeConcurrentWrapper", func(t *testing.T) {
orig := runCodexTaskFn orig := runCodexTaskFn
defer func() { runCodexTaskFn = orig }() defer func() { runCodexTaskFn = orig }()
@@ -284,6 +360,18 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
origRunner := newCommandRunner origRunner := newCommandRunner
defer func() { newCommandRunner = origRunner }() defer func() { newCommandRunner = origRunner }()
t.Run("resumeMissingSessionID", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
t.Fatalf("unexpected command execution for invalid resume config")
return nil
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", Mode: "resume"}, nil, nil, false, false, 1)
if res.ExitCode == 0 || !strings.Contains(res.Error, "session_id") {
t.Fatalf("expected validation error, got %+v", res)
}
})
t.Run("success", func(t *testing.T) { t.Run("success", func(t *testing.T) {
var firstStdout *reasonReadCloser var firstStdout *reasonReadCloser
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
@@ -421,6 +509,100 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
_ = closeLogger() _ = closeLogger()
}) })
t.Run("injectedLogger", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"injected"}}`),
process: &execFakeProcess{pid: 12},
}
}
_ = closeLogger()
injected, err := NewLoggerWithSuffix("executor-injected")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer func() {
_ = injected.Close()
_ = os.Remove(injected.Path())
}()
ctx := withTaskLogger(context.Background(), injected)
res := runCodexTaskWithContext(ctx, TaskSpec{ID: "task-injected", Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != injected.Path() {
t.Fatalf("expected injected logger path, got %+v", res)
}
if activeLogger() != nil {
t.Fatalf("expected no global logger to be created when injected")
}
injected.Flush()
data, err := os.ReadFile(injected.Path())
if err != nil {
t.Fatalf("failed to read injected log file: %v", err)
}
if !strings.Contains(string(data), "task-injected") {
t.Fatalf("injected log missing task prefix, content: %s", string(data))
}
})
t.Run("contextLoggerWithoutParent", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ctx"}}`),
process: &execFakeProcess{pid: 14},
}
}
_ = closeLogger()
taskLogger, err := NewLoggerWithSuffix("executor-taskctx")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
t.Cleanup(func() {
_ = taskLogger.Close()
_ = os.Remove(taskLogger.Path())
})
ctx := withTaskLogger(context.Background(), taskLogger)
res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != taskLogger.Path() {
t.Fatalf("expected task logger to be reused from spec context, got %+v", res)
}
if activeLogger() != nil {
t.Fatalf("expected no global logger to be created when task context provides one")
}
taskLogger.Flush()
data, err := os.ReadFile(taskLogger.Path())
if err != nil {
t.Fatalf("failed to read task log: %v", err)
}
if !strings.Contains(string(data), "task-context") {
t.Fatalf("task log missing task id, content: %s", string(data))
}
})
t.Run("backendSetsDirAndNilContext", func(t *testing.T) {
var rc *execFakeRunner
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
rc = &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"backend"}}`),
process: &execFakeProcess{pid: 13},
}
return rc
}
_ = closeLogger()
res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1)
if res.ExitCode != 0 || res.Message != "backend" {
t.Fatalf("unexpected result: %+v", res)
}
if rc == nil || rc.dir != "/tmp" {
t.Fatalf("expected backend to set cmd.Dir, got runner=%v dir=%q", rc, rc.dir)
}
})
t.Run("missingMessage", func(t *testing.T) { t.Run("missingMessage", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
@@ -435,6 +617,614 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
} }
func TestExecutorParallelLogIsolation(t *testing.T) {
mainLogger, err := NewLoggerWithSuffix("executor-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
taskA := nextExecutorTestTaskID("iso-a")
taskB := nextExecutorTestTaskID("iso-b")
markerA := "ISOLATION_MARKER:" + taskA
markerB := "ISOLATION_MARKER:" + taskB
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := taskLoggerFromContext(task.Context)
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
}
switch task.ID {
case taskA:
logger.Info(markerA)
case taskB:
logger.Info(markerB)
default:
logger.Info("unexpected task: " + task.ID)
}
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = origRun })
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrW
defer func() { os.Stderr = oldStderr }()
results := executeConcurrentWithContext(nil, [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1)
_ = stderrW.Close()
os.Stderr = oldStderr
stderrData, _ := io.ReadAll(stderrR)
_ = stderrR.Close()
stderrOut := string(stderrData)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
paths := map[string]string{}
for _, res := range results {
if res.ExitCode != 0 {
t.Fatalf("unexpected failure: %+v", res)
}
if res.LogPath == "" {
t.Fatalf("missing LogPath for task %q", res.TaskID)
}
paths[res.TaskID] = res.LogPath
}
if paths[taskA] == paths[taskB] {
t.Fatalf("expected distinct task log paths, got %q", paths[taskA])
}
if strings.Contains(stderrOut, mainLogger.Path()) {
t.Fatalf("stderr should not print main log path: %s", stderrOut)
}
if !strings.Contains(stderrOut, paths[taskA]) || !strings.Contains(stderrOut, paths[taskB]) {
t.Fatalf("stderr should include task log paths, got: %s", stderrOut)
}
mainLogger.Flush()
mainData, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
if strings.Contains(string(mainData), markerA) || strings.Contains(string(mainData), markerB) {
t.Fatalf("main log should not contain task markers, content: %s", string(mainData))
}
taskAData, err := os.ReadFile(paths[taskA])
if err != nil {
t.Fatalf("failed to read task A log: %v", err)
}
taskBData, err := os.ReadFile(paths[taskB])
if err != nil {
t.Fatalf("failed to read task B log: %v", err)
}
if !strings.Contains(string(taskAData), markerA) || strings.Contains(string(taskAData), markerB) {
t.Fatalf("task A log isolation failed, content: %s", string(taskAData))
}
if !strings.Contains(string(taskBData), markerB) || strings.Contains(string(taskBData), markerA) {
t.Fatalf("task B log isolation failed, content: %s", string(taskBData))
}
_ = os.Remove(paths[taskA])
_ = os.Remove(paths[taskB])
}
func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
oldArgs := os.Args
os.Args = []string{defaultWrapperName}
t.Cleanup(func() { os.Args = oldArgs })
mainLogger, err := NewLoggerWithSuffix("concurrent-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
mainLogger.Flush()
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
const taskCount = 16
const writersPerTask = 4
const logsPerWriter = 50
const expectedTaskLines = writersPerTask * logsPerWriter
taskIDs := make([]string, 0, taskCount)
tasks := make([]TaskSpec, 0, taskCount)
for i := 0; i < taskCount; i++ {
id := nextExecutorTestTaskID("iso")
taskIDs = append(taskIDs, id)
tasks = append(tasks, TaskSpec{ID: id})
}
type taskLoggerInfo struct {
taskID string
logger *Logger
}
loggerCh := make(chan taskLoggerInfo, taskCount)
readyCh := make(chan struct{}, taskCount)
startCh := make(chan struct{})
go func() {
for i := 0; i < taskCount; i++ {
<-readyCh
}
close(startCh)
}()
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
readyCh <- struct{}{}
logger := taskLoggerFromContext(task.Context)
loggerCh <- taskLoggerInfo{taskID: task.ID, logger: logger}
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
}
<-startCh
var wg sync.WaitGroup
wg.Add(writersPerTask)
for g := 0; g < writersPerTask; g++ {
go func(g int) {
defer wg.Done()
for i := 0; i < logsPerWriter; i++ {
logger.Info(fmt.Sprintf("TASK=%s g=%d i=%d", task.ID, g, i))
}
}(g)
}
wg.Wait()
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = origRun })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{tasks}, 1, 0)
if len(results) != taskCount {
t.Fatalf("expected %d results, got %d", taskCount, len(results))
}
taskLogPaths := make(map[string]string, taskCount)
seenPaths := make(map[string]struct{}, taskCount)
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected task failure: %+v", res)
}
if res.LogPath == "" {
t.Fatalf("missing LogPath for task %q", res.TaskID)
}
if _, ok := taskLogPaths[res.TaskID]; ok {
t.Fatalf("duplicate TaskID in results: %q", res.TaskID)
}
taskLogPaths[res.TaskID] = res.LogPath
if _, ok := seenPaths[res.LogPath]; ok {
t.Fatalf("expected unique log path per task; duplicate path %q", res.LogPath)
}
seenPaths[res.LogPath] = struct{}{}
}
if len(taskLogPaths) != taskCount {
t.Fatalf("expected %d unique task IDs, got %d", taskCount, len(taskLogPaths))
}
prefix := primaryLogPrefix()
pid := os.Getpid()
for _, id := range taskIDs {
path := taskLogPaths[id]
if path == "" {
t.Fatalf("missing log path for task %q", id)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("task log file not created for %q: %v", id, err)
}
wantBase := fmt.Sprintf("%s-%d-%s.log", prefix, pid, id)
if got := filepath.Base(path); got != wantBase {
t.Fatalf("unexpected log filename for %q: got %q, want %q", id, got, wantBase)
}
}
loggers := make(map[string]*Logger, taskCount)
for i := 0; i < taskCount; i++ {
info := <-loggerCh
if info.taskID == "" {
t.Fatalf("missing taskID in logger info")
}
if info.logger == nil {
t.Fatalf("missing logger in context for task %q", info.taskID)
}
if prev, ok := loggers[info.taskID]; ok && prev != info.logger {
t.Fatalf("task %q received multiple logger instances", info.taskID)
}
loggers[info.taskID] = info.logger
}
if len(loggers) != taskCount {
t.Fatalf("expected %d task loggers, got %d", taskCount, len(loggers))
}
for taskID, logger := range loggers {
if !logger.closed.Load() {
t.Fatalf("expected task logger to be closed for %q", taskID)
}
if logger.file == nil {
t.Fatalf("expected task logger file to be non-nil for %q", taskID)
}
if _, err := logger.file.Write([]byte("x")); err == nil {
t.Fatalf("expected task logger file to be closed for %q", taskID)
}
}
mainLogger.Flush()
mainData, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
mainText := string(mainData)
if !strings.Contains(mainText, "parallel: worker_limit=") {
t.Fatalf("expected main log to include concurrency planning, content: %s", mainText)
}
if strings.Contains(mainText, "TASK=") {
t.Fatalf("main log should not contain task output, content: %s", mainText)
}
for taskID, path := range taskLogPaths {
f, err := os.Open(path)
if err != nil {
t.Fatalf("failed to open task log for %q: %v", taskID, err)
}
scanner := bufio.NewScanner(f)
lines := 0
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "parallel:") {
t.Fatalf("task log should not contain main log entries for %q: %s", taskID, line)
}
gotID, ok := parseTaskIDFromLogLine(line)
if !ok {
t.Fatalf("task log entry missing task marker for %q: %s", taskID, line)
}
if gotID != taskID {
t.Fatalf("task log isolation failed: file=%q got TASK=%q want TASK=%q", path, gotID, taskID)
}
lines++
}
if err := scanner.Err(); err != nil {
_ = f.Close()
t.Fatalf("scanner error for %q: %v", taskID, err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed to close task log for %q: %v", taskID, err)
}
if lines != expectedTaskLines {
t.Fatalf("unexpected task log line count for %q: got %d, want %d", taskID, lines, expectedTaskLines)
}
}
for _, path := range taskLogPaths {
_ = os.Remove(path)
}
}
func parseTaskIDFromLogLine(line string) (string, bool) {
const marker = "TASK="
idx := strings.Index(line, marker)
if idx == -1 {
return "", false
}
rest := line[idx+len(marker):]
end := strings.IndexByte(rest, ' ')
if end == -1 {
return rest, rest != ""
}
return rest[:end], rest[:end] != ""
}
func TestExecutorTaskLoggerContext(t *testing.T) {
if taskLoggerFromContext(nil) != nil {
t.Fatalf("expected nil logger from nil context")
}
if taskLoggerFromContext(context.Background()) != nil {
t.Fatalf("expected nil logger when context has no logger")
}
logger, err := NewLoggerWithSuffix("executor-taskctx")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer func() {
_ = logger.Close()
_ = os.Remove(logger.Path())
}()
ctx := withTaskLogger(context.Background(), logger)
if got := taskLoggerFromContext(ctx); got != logger {
t.Fatalf("expected logger roundtrip, got %v", got)
}
if taskLoggerFromContext(withTaskLogger(context.Background(), nil)) != nil {
t.Fatalf("expected nil logger when injected logger is nil")
}
}
func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("failed to open %s: %v", os.DevNull, err)
}
oldStderr := os.Stderr
os.Stderr = devNull
t.Cleanup(func() {
os.Stderr = oldStderr
_ = devNull.Close()
})
t.Run("skipOnFailedDependencies", func(t *testing.T) {
root := nextExecutorTestTaskID("root")
child := nextExecutorTestTaskID("child")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
if task.ID == root {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "boom"}
}
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{
{{ID: root}},
{{ID: child, Dependencies: []string{root}}},
}, 1, 0)
foundChild := false
for _, res := range results {
if res.LogPath != "" {
_ = os.Remove(res.LogPath)
}
if res.TaskID != child {
continue
}
foundChild = true
if res.ExitCode == 0 || !strings.Contains(res.Error, "skipped") {
t.Fatalf("expected skipped child task result, got %+v", res)
}
}
if !foundChild {
t.Fatalf("expected child task to be present in results")
}
})
t.Run("panicRecovered", func(t *testing.T) {
taskID := nextExecutorTestTaskID("panic")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
panic("boom")
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskID}}}, 1, 0)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].ExitCode == 0 || !strings.Contains(results[0].Error, "panic") {
t.Fatalf("expected panic result, got %+v", results[0])
}
if results[0].LogPath == "" {
t.Fatalf("expected LogPath on panic result")
}
_ = os.Remove(results[0].LogPath)
})
t.Run("cancelWhileWaitingForWorker", func(t *testing.T) {
task1 := nextExecutorTestTaskID("slot")
task2 := nextExecutorTestTaskID("slot")
parentCtx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
unblock := make(chan struct{})
var startedOnce sync.Once
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
startedOnce.Do(func() { close(started) })
<-unblock
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
go func() {
<-started
cancel()
time.Sleep(50 * time.Millisecond)
close(unblock)
}()
results := executeConcurrentWithContext(parentCtx, [][]TaskSpec{{{ID: task1}, {ID: task2}}}, 1, 1)
foundCancelled := false
for _, res := range results {
if res.LogPath != "" {
_ = os.Remove(res.LogPath)
}
if res.ExitCode == 130 {
foundCancelled = true
}
}
if !foundCancelled {
t.Fatalf("expected a task to be cancelled")
}
})
t.Run("loggerCreateFails", func(t *testing.T) {
taskID := nextExecutorTestTaskID("bad") + "/id"
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskID}}}, 1, 0)
if len(results) != 1 || results[0].ExitCode != 0 {
t.Fatalf("unexpected results: %+v", results)
}
})
t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) {
// Create a writable temp dir for the main logger, then flip TMPDIR to a read-only
// location so task-specific loggers fail to open.
writable := t.TempDir()
t.Setenv("TMPDIR", writable)
mainLogger, err := NewLoggerWithSuffix("shared-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
mainLogger.Flush()
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
noWrite := filepath.Join(writable, "ro")
if err := os.Mkdir(noWrite, 0o500); err != nil {
t.Fatalf("failed to create read-only temp dir: %v", err)
}
t.Setenv("TMPDIR", noWrite)
taskA := nextExecutorTestTaskID("shared-a")
taskB := nextExecutorTestTaskID("shared-b")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := taskLoggerFromContext(task.Context)
if logger != mainLogger {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "unexpected logger"}
}
logger.Info("TASK=" + task.ID)
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrW
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, 0)
_ = stderrW.Close()
os.Stderr = oldStderr
stderrData, _ := io.ReadAll(stderrR)
_ = stderrR.Close()
stderrOut := string(stderrData)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("task failed unexpectedly: %+v", res)
}
if res.LogPath != mainLogger.Path() {
t.Fatalf("shared log path mismatch: got %q want %q", res.LogPath, mainLogger.Path())
}
if !res.sharedLog {
t.Fatalf("expected sharedLog flag for %+v", res)
}
if !strings.Contains(stderrOut, "Log (shared)") {
t.Fatalf("stderr missing shared marker: %s", stderrOut)
}
}
// Test full output mode for shared marker (summary mode doesn't show it)
summary := generateFinalOutputWithMode(results, false)
if !strings.Contains(summary, "(shared)") {
t.Fatalf("full output missing shared marker: %s", summary)
}
mainLogger.Flush()
data, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
content := string(data)
if !strings.Contains(content, "TASK="+taskA) || !strings.Contains(content, "TASK="+taskB) {
t.Fatalf("expected shared log to contain both tasks, got: %s", content)
}
})
t.Run("TestSanitizeTaskID", func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := taskLoggerFromContext(task.Context)
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing logger"}
}
logger.Info("TASK=" + task.ID)
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
idA := "../bad id"
idB := "tab\tid"
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: idA}, {ID: idB}}}, 1, 0)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
expected := map[string]string{
idA: sanitizeLogSuffix(idA),
idB: sanitizeLogSuffix(idB),
}
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected failure: %+v", res)
}
safe, ok := expected[res.TaskID]
if !ok {
t.Fatalf("unexpected task id %q in results", res.TaskID)
}
wantBase := fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), safe)
if filepath.Base(res.LogPath) != wantBase {
t.Fatalf("log filename for %q = %q, want %q", res.TaskID, filepath.Base(res.LogPath), wantBase)
}
data, err := os.ReadFile(res.LogPath)
if err != nil {
t.Fatalf("failed to read log %q: %v", res.LogPath, err)
}
if !strings.Contains(string(data), "TASK="+res.TaskID) {
t.Fatalf("log for %q missing task marker, content: %s", res.TaskID, string(data))
}
_ = os.Remove(res.LogPath)
}
})
}
func TestExecutorSignalAndTermination(t *testing.T) { func TestExecutorSignalAndTermination(t *testing.T) {
forceKillDelay.Store(0) forceKillDelay.Store(0)
defer forceKillDelay.Store(5) defer forceKillDelay.Store(5)
@@ -575,3 +1365,70 @@ func TestExecutorForwardSignalsDefaults(t *testing.T) {
forwardSignals(ctx, &execFakeRunner{process: &execFakeProcess{pid: 80}}, func(string) {}) forwardSignals(ctx, &execFakeRunner{process: &execFakeProcess{pid: 80}}, func(string) {})
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("failed to open %s: %v", os.DevNull, err)
}
oldStderr := os.Stderr
os.Stderr = devNull
t.Cleanup(func() {
os.Stderr = oldStderr
_ = devNull.Close()
})
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
// Setup: 创建主 logger
mainLogger, err := NewLoggerWithSuffix("shared-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
defer func() {
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
}()
// 模拟场景task logger 创建失败(通过设置只读的 TMPDIR
// 回退到主 loggerhandle.shared=true
// 但 runCodexTaskFn 返回自定义的 LogPath不等于主 logger 的路径)
roDir := filepath.Join(tempDir, "ro")
if err := os.Mkdir(roDir, 0o500); err != nil {
t.Fatalf("failed to create read-only dir: %v", err)
}
t.Setenv("TMPDIR", roDir)
orig := runCodexTaskFn
customLogPath := "/custom/path/to.log"
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
// 返回自定义 LogPath不等于主 logger 的路径
return TaskResult{
TaskID: task.ID,
ExitCode: 0,
LogPath: customLogPath,
}
}
defer func() { runCodexTaskFn = orig }()
// 执行任务
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: "task1"}}}, 1, 0)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
res := results[0]
// 关键断言:即使 handle.shared=true因为 task logger 创建失败),
// 但因为 LogPath 不等于主 logger 的路径sharedLog 应为 false
if res.sharedLog {
t.Fatalf("expected sharedLog=false when LogPath differs from shared logger, got true")
}
// 验证 LogPath 确实是自定义的
if res.LogPath != customLogPath {
t.Fatalf("expected custom LogPath %s, got %s", customLogPath, res.LogPath)
}
}

View File

@@ -0,0 +1,66 @@
package main
import (
"bytes"
"io"
"strings"
)
// geminiNoisePatterns contains stderr patterns to filter for gemini backend
var geminiNoisePatterns = []string{
"[STARTUP]",
"Session cleanup disabled",
"Warning:",
"(node:",
"(Use `node --trace-warnings",
"Loaded cached credentials",
"Loading extension:",
"YOLO mode is enabled",
}
// filteringWriter wraps an io.Writer and filters out lines matching patterns
type filteringWriter struct {
w io.Writer
patterns []string
buf bytes.Buffer
}
func newFilteringWriter(w io.Writer, patterns []string) *filteringWriter {
return &filteringWriter{w: w, patterns: patterns}
}
func (f *filteringWriter) Write(p []byte) (n int, err error) {
f.buf.Write(p)
for {
line, err := f.buf.ReadString('\n')
if err != nil {
// incomplete line, put it back
f.buf.WriteString(line)
break
}
if !f.shouldFilter(line) {
f.w.Write([]byte(line))
}
}
return len(p), nil
}
func (f *filteringWriter) shouldFilter(line string) bool {
for _, pattern := range f.patterns {
if strings.Contains(line, pattern) {
return true
}
}
return false
}
// Flush writes any remaining buffered content
func (f *filteringWriter) Flush() {
if f.buf.Len() > 0 {
remaining := f.buf.String()
if !f.shouldFilter(remaining) {
f.w.Write([]byte(remaining))
}
f.buf.Reset()
}
}

View File

@@ -0,0 +1,73 @@
package main
import (
"bytes"
"testing"
)
func TestFilteringWriter(t *testing.T) {
tests := []struct {
name string
patterns []string
input string
want string
}{
{
name: "filter STARTUP lines",
patterns: geminiNoisePatterns,
input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n",
want: "Hello World\n",
},
{
name: "filter Warning lines",
patterns: geminiNoisePatterns,
input: "Warning: something bad\nActual output\n",
want: "Actual output\n",
},
{
name: "filter multiple patterns",
patterns: geminiNoisePatterns,
input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n",
want: "Real content\n",
},
{
name: "no filtering needed",
patterns: geminiNoisePatterns,
input: "Line 1\nLine 2\nLine 3\n",
want: "Line 1\nLine 2\nLine 3\n",
},
{
name: "empty input",
patterns: geminiNoisePatterns,
input: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
fw := newFilteringWriter(&buf, tt.patterns)
fw.Write([]byte(tt.input))
fw.Flush()
if got := buf.String(); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestFilteringWriterPartialLines(t *testing.T) {
var buf bytes.Buffer
fw := newFilteringWriter(&buf, geminiNoisePatterns)
// Write partial line
fw.Write([]byte("Hello "))
fw.Write([]byte("World\n"))
fw.Flush()
if got := buf.String(); got != "Hello World\n" {
t.Errorf("got %q, want %q", got, "Hello World\n")
}
}

View File

@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestLogWriterWriteLimitsBuffer(t *testing.T) {
defer resetTestHooks()
logger, err := NewLogger()
if err != nil {
t.Fatalf("NewLogger error: %v", err)
}
setLogger(logger)
defer closeLogger()
lw := newLogWriter("P:", 10)
_, _ = lw.Write([]byte(strings.Repeat("a", 100)))
if lw.buf.Len() != 10 {
t.Fatalf("logWriter buffer len=%d, want %d", lw.buf.Len(), 10)
}
if !lw.dropped {
t.Fatalf("expected logWriter to drop overlong line bytes")
}
lw.Flush()
logger.Flush()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if !strings.Contains(string(data), "P:aaaaaaa...") {
t.Fatalf("log output missing truncated entry, got %q", string(data))
}
}

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"hash/crc32"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -29,11 +30,14 @@ type Logger struct {
workerWG sync.WaitGroup workerWG sync.WaitGroup
pendingWG sync.WaitGroup pendingWG sync.WaitGroup
flushMu sync.Mutex flushMu sync.Mutex
workerErr error
errorEntries []string // Cache of recent ERROR/WARN entries
errorMu sync.Mutex
} }
type logEntry struct { type logEntry struct {
level string
msg string msg string
isError bool // true for ERROR or WARN levels
} }
// CleanupStats captures the outcome of a cleanupOldLogs run. // CleanupStats captures the outcome of a cleanupOldLogs run.
@@ -55,6 +59,10 @@ var (
evalSymlinksFn = filepath.EvalSymlinks evalSymlinksFn = filepath.EvalSymlinks
) )
const maxLogSuffixLen = 64
var logSuffixCounter atomic.Uint64
// NewLogger creates the async logger and starts the worker goroutine. // NewLogger creates the async logger and starts the worker goroutine.
// The log file is created under os.TempDir() using the required naming scheme. // The log file is created under os.TempDir() using the required naming scheme.
func NewLogger() (*Logger, error) { func NewLogger() (*Logger, error) {
@@ -64,14 +72,23 @@ func NewLogger() (*Logger, error) {
// NewLoggerWithSuffix creates a logger with an optional suffix in the filename. // NewLoggerWithSuffix creates a logger with an optional suffix in the filename.
// Useful for tests that need isolated log files within the same process. // Useful for tests that need isolated log files within the same process.
func NewLoggerWithSuffix(suffix string) (*Logger, error) { func NewLoggerWithSuffix(suffix string) (*Logger, error) {
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), os.Getpid()) pid := os.Getpid()
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), pid)
var safeSuffix string
if suffix != "" { if suffix != "" {
filename += "-" + suffix safeSuffix = sanitizeLogSuffix(suffix)
}
if safeSuffix != "" {
filename += "-" + safeSuffix
} }
filename += ".log" filename += ".log"
path := filepath.Clean(filepath.Join(os.TempDir(), filename)) path := filepath.Clean(filepath.Join(os.TempDir(), filename))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,6 +109,73 @@ func NewLoggerWithSuffix(suffix string) (*Logger, error) {
return l, nil return l, nil
} }
func sanitizeLogSuffix(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return fallbackLogSuffix()
}
var b strings.Builder
changed := false
for _, r := range trimmed {
if isSafeLogRune(r) {
b.WriteRune(r)
} else {
changed = true
b.WriteByte('-')
}
if b.Len() >= maxLogSuffixLen {
changed = true
break
}
}
sanitized := strings.Trim(b.String(), "-.")
if sanitized != b.String() {
changed = true // Mark if trim removed any characters
}
if sanitized == "" {
return fallbackLogSuffix()
}
if changed || len(sanitized) > maxLogSuffixLen {
hash := crc32.ChecksumIEEE([]byte(trimmed))
hashStr := fmt.Sprintf("%x", hash)
maxPrefix := maxLogSuffixLen - len(hashStr) - 1
if maxPrefix < 1 {
maxPrefix = 1
}
if len(sanitized) > maxPrefix {
sanitized = sanitized[:maxPrefix]
}
sanitized = fmt.Sprintf("%s-%s", sanitized, hashStr)
}
return sanitized
}
func fallbackLogSuffix() string {
next := logSuffixCounter.Add(1)
return fmt.Sprintf("task-%d", next)
}
func isSafeLogRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z':
return true
case r >= 'A' && r <= 'Z':
return true
case r >= '0' && r <= '9':
return true
case r == '-', r == '_', r == '.':
return true
default:
return false
}
}
// Path returns the underlying log file path (useful for tests/inspection). // Path returns the underlying log file path (useful for tests/inspection).
func (l *Logger) Path() string { func (l *Logger) Path() string {
if l == nil { if l == nil {
@@ -112,10 +196,11 @@ func (l *Logger) Debug(msg string) { l.log("DEBUG", msg) }
// Error logs at ERROR level. // Error logs at ERROR level.
func (l *Logger) Error(msg string) { l.log("ERROR", msg) } func (l *Logger) Error(msg string) { l.log("ERROR", msg) }
// Close stops the worker and syncs the log file. // Close signals the worker to flush and close the log file.
// The log file is NOT removed, allowing inspection after program exit. // The log file is NOT removed, allowing inspection after program exit.
// It is safe to call multiple times. // It is safe to call multiple times.
// Returns after a 5-second timeout if worker doesn't stop gracefully. // Waits up to CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS (default: 5000) for shutdown; set to 0 to wait indefinitely.
// Returns an error if shutdown doesn't complete within the timeout.
func (l *Logger) Close() error { func (l *Logger) Close() error {
if l == nil { if l == nil {
return nil return nil
@@ -126,42 +211,51 @@ func (l *Logger) Close() error {
l.closeOnce.Do(func() { l.closeOnce.Do(func() {
l.closed.Store(true) l.closed.Store(true)
close(l.done) close(l.done)
close(l.ch)
// Wait for worker with timeout timeout := loggerCloseTimeout()
workerDone := make(chan struct{}) workerDone := make(chan struct{})
go func() { go func() {
l.workerWG.Wait() l.workerWG.Wait()
close(workerDone) close(workerDone)
}() }()
if timeout > 0 {
select { select {
case <-workerDone: case <-workerDone:
// Worker stopped gracefully // Worker stopped gracefully
case <-time.After(5 * time.Second): case <-time.After(timeout):
// Worker timeout - proceed with cleanup anyway
closeErr = fmt.Errorf("logger worker timeout during close") closeErr = fmt.Errorf("logger worker timeout during close")
return
}
} else {
<-workerDone
} }
if err := l.writer.Flush(); err != nil && closeErr == nil { if l.workerErr != nil && closeErr == nil {
closeErr = err closeErr = l.workerErr
} }
if err := l.file.Sync(); err != nil && closeErr == nil {
closeErr = err
}
if err := l.file.Close(); err != nil && closeErr == nil {
closeErr = err
}
// Log file is kept for debugging - NOT removed
// Users can manually clean up /tmp/<wrapper>-*.log files
}) })
return closeErr return closeErr
} }
func loggerCloseTimeout() time.Duration {
const defaultTimeout = 5 * time.Second
raw := strings.TrimSpace(os.Getenv("CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS"))
if raw == "" {
return defaultTimeout
}
ms, err := strconv.Atoi(raw)
if err != nil {
return defaultTimeout
}
if ms <= 0 {
return 0
}
return time.Duration(ms) * time.Millisecond
}
// RemoveLogFile removes the log file. Should only be called after Close(). // RemoveLogFile removes the log file. Should only be called after Close().
func (l *Logger) RemoveLogFile() error { func (l *Logger) RemoveLogFile() error {
if l == nil { if l == nil {
@@ -170,34 +264,29 @@ func (l *Logger) RemoveLogFile() error {
return os.Remove(l.path) return os.Remove(l.path)
} }
// ExtractRecentErrors reads the log file and returns the most recent ERROR and WARN entries. // ExtractRecentErrors returns the most recent ERROR and WARN entries from memory cache.
// Returns up to maxEntries entries in chronological order. // Returns up to maxEntries entries in chronological order.
func (l *Logger) ExtractRecentErrors(maxEntries int) []string { func (l *Logger) ExtractRecentErrors(maxEntries int) []string {
if l == nil || l.path == "" { if l == nil || maxEntries <= 0 {
return nil return nil
} }
f, err := os.Open(l.path) l.errorMu.Lock()
if err != nil { defer l.errorMu.Unlock()
if len(l.errorEntries) == 0 {
return nil return nil
} }
defer f.Close()
var entries []string // Return last N entries
scanner := bufio.NewScanner(f) start := 0
for scanner.Scan() { if len(l.errorEntries) > maxEntries {
line := scanner.Text() start = len(l.errorEntries) - maxEntries
if strings.Contains(line, "] ERROR:") || strings.Contains(line, "] WARN:") {
entries = append(entries, line)
}
} }
// Keep only the last maxEntries result := make([]string, len(l.errorEntries)-start)
if len(entries) > maxEntries { copy(result, l.errorEntries[start:])
entries = entries[len(entries)-maxEntries:] return result
}
return entries
} }
// Flush waits for all pending log entries to be written. Primarily for tests. // Flush waits for all pending log entries to be written. Primarily for tests.
@@ -254,7 +343,8 @@ func (l *Logger) log(level, msg string) {
return return
} }
entry := logEntry{level: level, msg: msg} isError := level == "WARN" || level == "ERROR"
entry := logEntry{msg: msg, isError: isError}
l.flushMu.Lock() l.flushMu.Lock()
l.pendingWG.Add(1) l.pendingWG.Add(1)
l.flushMu.Unlock() l.flushMu.Unlock()
@@ -275,18 +365,43 @@ func (l *Logger) run() {
ticker := time.NewTicker(500 * time.Millisecond) ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
writeEntry := func(entry logEntry) {
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
fmt.Fprintf(l.writer, "[%s] %s\n", timestamp, entry.msg)
// Cache error/warn entries in memory for fast extraction
if entry.isError {
l.errorMu.Lock()
l.errorEntries = append(l.errorEntries, entry.msg)
if len(l.errorEntries) > 100 { // Keep last 100
l.errorEntries = l.errorEntries[1:]
}
l.errorMu.Unlock()
}
l.pendingWG.Done()
}
finalize := func() {
if err := l.writer.Flush(); err != nil && l.workerErr == nil {
l.workerErr = err
}
if err := l.file.Sync(); err != nil && l.workerErr == nil {
l.workerErr = err
}
if err := l.file.Close(); err != nil && l.workerErr == nil {
l.workerErr = err
}
}
for { for {
select { select {
case entry, ok := <-l.ch: case entry, ok := <-l.ch:
if !ok { if !ok {
// Channel closed, final flush finalize()
_ = l.writer.Flush()
return return
} }
timestamp := time.Now().Format("2006-01-02 15:04:05.000") writeEntry(entry)
pid := os.Getpid()
fmt.Fprintf(l.writer, "[%s] [PID:%d] %s: %s\n", timestamp, pid, entry.level, entry.msg)
l.pendingWG.Done()
case <-ticker.C: case <-ticker.C:
_ = l.writer.Flush() _ = l.writer.Flush()
@@ -296,6 +411,21 @@ func (l *Logger) run() {
_ = l.writer.Flush() _ = l.writer.Flush()
_ = l.file.Sync() _ = l.file.Sync()
close(flushDone) close(flushDone)
case <-l.done:
for {
select {
case entry, ok := <-l.ch:
if !ok {
finalize()
return
}
writeEntry(entry)
default:
finalize()
return
}
}
} }
} }
} }

View File

@@ -0,0 +1,158 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoggerNilReceiverNoop(t *testing.T) {
var logger *Logger
logger.Info("info")
logger.Warn("warn")
logger.Debug("debug")
logger.Error("error")
logger.Flush()
if err := logger.Close(); err != nil {
t.Fatalf("Close() on nil logger should return nil, got %v", err)
}
}
func TestLoggerConcurrencyLogHelpers(t *testing.T) {
setTempDirEnv(t, t.TempDir())
logger, err := NewLoggerWithSuffix("concurrency")
if err != nil {
t.Fatalf("NewLoggerWithSuffix error: %v", err)
}
setLogger(logger)
defer closeLogger()
logConcurrencyPlanning(0, 2)
logConcurrencyPlanning(3, 2)
logConcurrencyState("start", "task-1", 1, 0)
logConcurrencyState("done", "task-1", 0, 3)
logger.Flush()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
output := string(data)
checks := []string{
"parallel: worker_limit=unbounded total_tasks=2",
"parallel: worker_limit=3 total_tasks=2",
"parallel: start task=task-1 active=1 limit=unbounded",
"parallel: done task=task-1 active=0 limit=3",
}
for _, c := range checks {
if !strings.Contains(output, c) {
t.Fatalf("log output missing %q, got: %s", c, output)
}
}
}
func TestLoggerConcurrencyLogHelpersNoopWithoutActiveLogger(t *testing.T) {
_ = closeLogger()
logConcurrencyPlanning(1, 1)
logConcurrencyState("start", "task-1", 0, 1)
}
func TestLoggerCleanupOldLogsSkipsUnsafeAndHandlesAlreadyDeleted(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
unsafePath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", primaryLogPrefix(), 222))
orphanPath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", primaryLogPrefix(), 111))
stubFileStat(t, func(path string) (os.FileInfo, error) {
if path == unsafePath {
return fakeFileInfo{mode: os.ModeSymlink}, nil
}
return os.Lstat(path)
})
stubProcessRunning(t, func(pid int) bool {
if pid == 111 {
_ = os.Remove(orphanPath)
}
return false
})
stats, err := cleanupOldLogs()
if err != nil {
t.Fatalf("cleanupOldLogs() unexpected error: %v", err)
}
if stats.Scanned != 2 {
t.Fatalf("scanned = %d, want %d", stats.Scanned, 2)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want %d", stats.Deleted, 0)
}
if stats.Kept != 2 {
t.Fatalf("kept = %d, want %d", stats.Kept, 2)
}
if stats.Errors != 0 {
t.Fatalf("errors = %d, want %d", stats.Errors, 0)
}
hasSkip := false
hasAlreadyDeleted := false
for _, name := range stats.KeptFiles {
if strings.Contains(name, "already deleted") {
hasAlreadyDeleted = true
}
if strings.Contains(name, filepath.Base(unsafePath)) {
hasSkip = true
}
}
if !hasSkip {
t.Fatalf("expected kept files to include unsafe log %q, got %+v", filepath.Base(unsafePath), stats.KeptFiles)
}
if !hasAlreadyDeleted {
t.Fatalf("expected kept files to include already deleted marker, got %+v", stats.KeptFiles)
}
}
func TestLoggerIsUnsafeFileErrorPaths(t *testing.T) {
tempDir := t.TempDir()
t.Run("stat ErrNotExist", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return nil, os.ErrNotExist
})
unsafe, reason := isUnsafeFile("missing.log", tempDir)
if !unsafe || reason != "" {
t.Fatalf("expected missing file to be skipped silently, got unsafe=%v reason=%q", unsafe, reason)
}
})
t.Run("stat error", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return nil, fmt.Errorf("boom")
})
unsafe, reason := isUnsafeFile("broken.log", tempDir)
if !unsafe || !strings.Contains(reason, "stat failed") {
t.Fatalf("expected stat failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
}
})
t.Run("EvalSymlinks error", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return fakeFileInfo{}, nil
})
stubEvalSymlinks(t, func(string) (string, error) {
return "", fmt.Errorf("resolve failed")
})
unsafe, reason := isUnsafeFile("cannot-resolve.log", tempDir)
if !unsafe || !strings.Contains(reason, "path resolution failed") {
t.Fatalf("expected resolution failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
}
})
}

View File

@@ -0,0 +1,115 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoggerWithSuffixNamingAndIsolation(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
taskA := "task-1"
taskB := "task-2"
loggerA, err := NewLoggerWithSuffix(taskA)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskA, err)
}
defer loggerA.Close()
loggerB, err := NewLoggerWithSuffix(taskB)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskB, err)
}
defer loggerB.Close()
wantA := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), taskA))
if loggerA.Path() != wantA {
t.Fatalf("loggerA path = %q, want %q", loggerA.Path(), wantA)
}
wantB := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), taskB))
if loggerB.Path() != wantB {
t.Fatalf("loggerB path = %q, want %q", loggerB.Path(), wantB)
}
if loggerA.Path() == loggerB.Path() {
t.Fatalf("expected different log files, got %q", loggerA.Path())
}
loggerA.Info("from taskA")
loggerB.Info("from taskB")
loggerA.Flush()
loggerB.Flush()
dataA, err := os.ReadFile(loggerA.Path())
if err != nil {
t.Fatalf("failed to read loggerA file: %v", err)
}
dataB, err := os.ReadFile(loggerB.Path())
if err != nil {
t.Fatalf("failed to read loggerB file: %v", err)
}
if !strings.Contains(string(dataA), "from taskA") {
t.Fatalf("loggerA missing its message, got: %q", string(dataA))
}
if strings.Contains(string(dataA), "from taskB") {
t.Fatalf("loggerA contains loggerB message, got: %q", string(dataA))
}
if !strings.Contains(string(dataB), "from taskB") {
t.Fatalf("loggerB missing its message, got: %q", string(dataB))
}
if strings.Contains(string(dataB), "from taskA") {
t.Fatalf("loggerB contains loggerA message, got: %q", string(dataB))
}
}
func TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable(t *testing.T) {
base := t.TempDir()
noWrite := filepath.Join(base, "ro")
if err := os.Mkdir(noWrite, 0o500); err != nil {
t.Fatalf("failed to create read-only temp dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(noWrite, 0o700) })
setTempDirEnv(t, noWrite)
logger, err := NewLoggerWithSuffix("task-err")
if err == nil {
_ = logger.Close()
t.Fatalf("expected error when temp dir is not writable")
}
}
func TestLoggerWithSuffixSanitizesUnsafeSuffix(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
raw := "../bad id/with?chars"
safe := sanitizeLogSuffix(raw)
if safe == "" {
t.Fatalf("sanitizeLogSuffix returned empty string")
}
if strings.ContainsAny(safe, "/\\") {
t.Fatalf("sanitized suffix should not contain path separators, got %q", safe)
}
logger, err := NewLoggerWithSuffix(raw)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", raw, err)
}
t.Cleanup(func() {
_ = logger.Close()
_ = os.Remove(logger.Path())
})
wantBase := fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), safe)
if gotBase := filepath.Base(logger.Path()); gotBase != wantBase {
t.Fatalf("log filename = %q, want %q", gotBase, wantBase)
}
if dir := filepath.Dir(logger.Path()); dir != tempDir {
t.Fatalf("logger path dir = %q, want %q", dir, tempDir)
}
}

View File

@@ -26,7 +26,7 @@ func compareCleanupStats(got, want CleanupStats) bool {
return true return true
} }
func TestRunLoggerCreatesFileWithPID(t *testing.T) { func TestLoggerCreatesFileWithPID(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir) t.Setenv("TMPDIR", tempDir)
@@ -46,7 +46,7 @@ func TestRunLoggerCreatesFileWithPID(t *testing.T) {
} }
} }
func TestRunLoggerWritesLevels(t *testing.T) { func TestLoggerWritesLevels(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir) t.Setenv("TMPDIR", tempDir)
@@ -69,7 +69,7 @@ func TestRunLoggerWritesLevels(t *testing.T) {
} }
content := string(data) content := string(data)
checks := []string{"INFO: info message", "WARN: warn message", "DEBUG: debug message", "ERROR: error message"} checks := []string{"info message", "warn message", "debug message", "error message"}
for _, c := range checks { for _, c := range checks {
if !strings.Contains(content, c) { if !strings.Contains(content, c) {
t.Fatalf("log file missing entry %q, content: %s", c, content) t.Fatalf("log file missing entry %q, content: %s", c, content)
@@ -77,7 +77,31 @@ func TestRunLoggerWritesLevels(t *testing.T) {
} }
} }
func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) { func TestLoggerDefaultIsTerminalCoverage(t *testing.T) {
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
if err != nil {
t.Fatalf("os.CreateTemp() error = %v", err)
}
defer os.Remove(f.Name())
os.Stdin = f
if got := defaultIsTerminal(); got {
t.Fatalf("defaultIsTerminal() = %v, want false for regular file", got)
}
if err := f.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
os.Stdin = f
if got := defaultIsTerminal(); !got {
t.Fatalf("defaultIsTerminal() = %v, want true when Stat fails", got)
}
}
func TestLoggerCloseStopsWorkerAndKeepsFile(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir) t.Setenv("TMPDIR", tempDir)
@@ -94,6 +118,11 @@ func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
if err := logger.Close(); err != nil { if err := logger.Close(); err != nil {
t.Fatalf("Close() returned error: %v", err) t.Fatalf("Close() returned error: %v", err)
} }
if logger.file != nil {
if _, err := logger.file.Write([]byte("x")); err == nil {
t.Fatalf("expected file to be closed after Close()")
}
}
// After recent changes, log file is kept for debugging - NOT removed // After recent changes, log file is kept for debugging - NOT removed
if _, err := os.Stat(logPath); os.IsNotExist(err) { if _, err := os.Stat(logPath); os.IsNotExist(err) {
@@ -116,7 +145,7 @@ func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
} }
} }
func TestRunLoggerConcurrentWritesSafe(t *testing.T) { func TestLoggerConcurrentWritesSafe(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir) t.Setenv("TMPDIR", tempDir)
@@ -165,7 +194,7 @@ func TestRunLoggerConcurrentWritesSafe(t *testing.T) {
} }
} }
func TestRunLoggerTerminateProcessActive(t *testing.T) { func TestLoggerTerminateProcessActive(t *testing.T) {
cmd := exec.Command("sleep", "5") cmd := exec.Command("sleep", "5")
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
t.Skipf("cannot start sleep command: %v", err) t.Skipf("cannot start sleep command: %v", err)
@@ -193,7 +222,7 @@ func TestRunLoggerTerminateProcessActive(t *testing.T) {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
func TestRunTerminateProcessNil(t *testing.T) { func TestLoggerTerminateProcessNil(t *testing.T) {
if timer := terminateProcess(nil); timer != nil { if timer := terminateProcess(nil); timer != nil {
t.Fatalf("terminateProcess(nil) should return nil timer") t.Fatalf("terminateProcess(nil) should return nil timer")
} }
@@ -202,7 +231,7 @@ func TestRunTerminateProcessNil(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) { func TestLoggerCleanupOldLogsRemovesOrphans(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
orphan1 := createTempLog(t, tempDir, "codex-wrapper-111.log") orphan1 := createTempLog(t, tempDir, "codex-wrapper-111.log")
@@ -252,7 +281,7 @@ func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) { func TestLoggerCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
invalid := []string{ invalid := []string{
@@ -310,7 +339,7 @@ func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) { func TestLoggerCleanupOldLogsHandlesGlobFailures(t *testing.T) {
stubProcessRunning(t, func(pid int) bool { stubProcessRunning(t, func(pid int) bool {
t.Fatalf("process check should not run when glob fails") t.Fatalf("process check should not run when glob fails")
return false return false
@@ -336,7 +365,7 @@ func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) { func TestLoggerCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
setTempDirEnv(t, t.TempDir()) setTempDirEnv(t, t.TempDir())
stubProcessRunning(t, func(int) bool { stubProcessRunning(t, func(int) bool {
@@ -356,7 +385,7 @@ func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) { func TestLoggerCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
paths := []string{ paths := []string{
@@ -396,7 +425,7 @@ func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) { func TestLoggerCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
protected := createTempLog(t, tempDir, "codex-wrapper-6200.log") protected := createTempLog(t, tempDir, "codex-wrapper-6200.log")
@@ -433,7 +462,7 @@ func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsPerformanceBound(t *testing.T) { func TestLoggerCleanupOldLogsPerformanceBound(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
const fileCount = 400 const fileCount = 400
@@ -476,17 +505,98 @@ func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
} }
} }
func TestRunCleanupOldLogsCoverageSuite(t *testing.T) { func TestLoggerCleanupOldLogsCoverageSuite(t *testing.T) {
TestBackendParseJSONStream_CoverageSuite(t) TestBackendParseJSONStream_CoverageSuite(t)
} }
// Reuse the existing coverage suite so the focused TestLogger run still exercises // Reuse the existing coverage suite so the focused TestLogger run still exercises
// the rest of the codebase and keeps coverage high. // the rest of the codebase and keeps coverage high.
func TestRunLoggerCoverageSuite(t *testing.T) { func TestLoggerCoverageSuite(t *testing.T) {
TestBackendParseJSONStream_CoverageSuite(t) suite := []struct {
name string
fn func(*testing.T)
}{
{"TestBackendParseJSONStream_CoverageSuite", TestBackendParseJSONStream_CoverageSuite},
{"TestVersionCoverageFullRun", TestVersionCoverageFullRun},
{"TestVersionMainWrapper", TestVersionMainWrapper},
{"TestExecutorHelperCoverage", TestExecutorHelperCoverage},
{"TestExecutorRunCodexTaskWithContext", TestExecutorRunCodexTaskWithContext},
{"TestExecutorParallelLogIsolation", TestExecutorParallelLogIsolation},
{"TestExecutorTaskLoggerContext", TestExecutorTaskLoggerContext},
{"TestExecutorExecuteConcurrentWithContextBranches", TestExecutorExecuteConcurrentWithContextBranches},
{"TestExecutorSignalAndTermination", TestExecutorSignalAndTermination},
{"TestExecutorCancelReasonAndCloseWithReason", TestExecutorCancelReasonAndCloseWithReason},
{"TestExecutorForceKillTimerStop", TestExecutorForceKillTimerStop},
{"TestExecutorForwardSignalsDefaults", TestExecutorForwardSignalsDefaults},
{"TestBackendParseArgs_NewMode", TestBackendParseArgs_NewMode},
{"TestBackendParseArgs_ResumeMode", TestBackendParseArgs_ResumeMode},
{"TestBackendParseArgs_BackendFlag", TestBackendParseArgs_BackendFlag},
{"TestBackendParseArgs_SkipPermissions", TestBackendParseArgs_SkipPermissions},
{"TestBackendParseBoolFlag", TestBackendParseBoolFlag},
{"TestBackendEnvFlagEnabled", TestBackendEnvFlagEnabled},
{"TestRunResolveTimeout", TestRunResolveTimeout},
{"TestRunIsTerminal", TestRunIsTerminal},
{"TestRunReadPipedTask", TestRunReadPipedTask},
{"TestTailBufferWrite", TestTailBufferWrite},
{"TestLogWriterWriteLimitsBuffer", TestLogWriterWriteLimitsBuffer},
{"TestLogWriterLogLine", TestLogWriterLogLine},
{"TestNewLogWriterDefaultMaxLen", TestNewLogWriterDefaultMaxLen},
{"TestNewLogWriterDefaultLimit", TestNewLogWriterDefaultLimit},
{"TestRunHello", TestRunHello},
{"TestRunGreet", TestRunGreet},
{"TestRunFarewell", TestRunFarewell},
{"TestRunFarewellEmpty", TestRunFarewellEmpty},
{"TestParallelParseConfig_Success", TestParallelParseConfig_Success},
{"TestParallelParseConfig_Backend", TestParallelParseConfig_Backend},
{"TestParallelParseConfig_InvalidFormat", TestParallelParseConfig_InvalidFormat},
{"TestParallelParseConfig_EmptyTasks", TestParallelParseConfig_EmptyTasks},
{"TestParallelParseConfig_MissingID", TestParallelParseConfig_MissingID},
{"TestParallelParseConfig_MissingTask", TestParallelParseConfig_MissingTask},
{"TestParallelParseConfig_DuplicateID", TestParallelParseConfig_DuplicateID},
{"TestParallelParseConfig_DelimiterFormat", TestParallelParseConfig_DelimiterFormat},
{"TestBackendSelectBackend", TestBackendSelectBackend},
{"TestBackendSelectBackend_Invalid", TestBackendSelectBackend_Invalid},
{"TestBackendSelectBackend_DefaultOnEmpty", TestBackendSelectBackend_DefaultOnEmpty},
{"TestBackendBuildArgs_CodexBackend", TestBackendBuildArgs_CodexBackend},
{"TestBackendBuildArgs_ClaudeBackend", TestBackendBuildArgs_ClaudeBackend},
{"TestClaudeBackendBuildArgs_OutputValidation", TestClaudeBackendBuildArgs_OutputValidation},
{"TestBackendBuildArgs_GeminiBackend", TestBackendBuildArgs_GeminiBackend},
{"TestGeminiBackendBuildArgs_OutputValidation", TestGeminiBackendBuildArgs_OutputValidation},
{"TestBackendNamesAndCommands", TestBackendNamesAndCommands},
{"TestBackendParseJSONStream", TestBackendParseJSONStream},
{"TestBackendParseJSONStream_ClaudeEvents", TestBackendParseJSONStream_ClaudeEvents},
{"TestBackendParseJSONStream_GeminiEvents", TestBackendParseJSONStream_GeminiEvents},
{"TestBackendParseJSONStreamWithWarn_InvalidLine", TestBackendParseJSONStreamWithWarn_InvalidLine},
{"TestBackendParseJSONStream_OnMessage", TestBackendParseJSONStream_OnMessage},
{"TestBackendParseJSONStream_ScannerError", TestBackendParseJSONStream_ScannerError},
{"TestBackendDiscardInvalidJSON", TestBackendDiscardInvalidJSON},
{"TestBackendDiscardInvalidJSONBuffer", TestBackendDiscardInvalidJSONBuffer},
{"TestCurrentWrapperNameFallsBackToExecutable", TestCurrentWrapperNameFallsBackToExecutable},
{"TestCurrentWrapperNameDetectsLegacyAliasSymlink", TestCurrentWrapperNameDetectsLegacyAliasSymlink},
{"TestIsProcessRunning", TestIsProcessRunning},
{"TestGetProcessStartTimeReadsProcStat", TestGetProcessStartTimeReadsProcStat},
{"TestGetProcessStartTimeInvalidData", TestGetProcessStartTimeInvalidData},
{"TestGetBootTimeParsesBtime", TestGetBootTimeParsesBtime},
{"TestGetBootTimeInvalidData", TestGetBootTimeInvalidData},
{"TestClaudeBuildArgs_ModesAndPermissions", TestClaudeBuildArgs_ModesAndPermissions},
{"TestClaudeBuildArgs_GeminiAndCodexModes", TestClaudeBuildArgs_GeminiAndCodexModes},
{"TestClaudeBuildArgs_BackendMetadata", TestClaudeBuildArgs_BackendMetadata},
}
for _, tc := range suite {
t.Run(tc.name, tc.fn)
}
} }
func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) { func TestLoggerCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := setTempDirEnv(t, t.TempDir())
currentPID := os.Getpid() currentPID := os.Getpid()
@@ -518,7 +628,7 @@ func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
} }
} }
func TestIsPIDReusedScenarios(t *testing.T) { func TestLoggerIsPIDReusedScenarios(t *testing.T) {
now := time.Now() now := time.Now()
tests := []struct { tests := []struct {
name string name string
@@ -552,7 +662,7 @@ func TestIsPIDReusedScenarios(t *testing.T) {
} }
} }
func TestIsUnsafeFileSecurityChecks(t *testing.T) { func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
absTempDir, err := filepath.Abs(tempDir) absTempDir, err := filepath.Abs(tempDir)
if err != nil { if err != nil {
@@ -601,7 +711,7 @@ func TestIsUnsafeFileSecurityChecks(t *testing.T) {
}) })
} }
func TestRunLoggerPathAndRemove(t *testing.T) { func TestLoggerPathAndRemove(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
path := filepath.Join(tempDir, "sample.log") path := filepath.Join(tempDir, "sample.log")
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
@@ -628,7 +738,19 @@ func TestRunLoggerPathAndRemove(t *testing.T) {
} }
} }
func TestRunLoggerInternalLog(t *testing.T) { func TestLoggerTruncateBytesCoverage(t *testing.T) {
if got := truncateBytes([]byte("abc"), 3); got != "abc" {
t.Fatalf("truncateBytes() = %q, want %q", got, "abc")
}
if got := truncateBytes([]byte("abcd"), 3); got != "abc..." {
t.Fatalf("truncateBytes() = %q, want %q", got, "abc...")
}
if got := truncateBytes([]byte("abcd"), -1); got != "" {
t.Fatalf("truncateBytes() = %q, want empty string", got)
}
}
func TestLoggerInternalLog(t *testing.T) {
logger := &Logger{ logger := &Logger{
ch: make(chan logEntry, 1), ch: make(chan logEntry, 1),
done: make(chan struct{}), done: make(chan struct{}),
@@ -644,7 +766,7 @@ func TestRunLoggerInternalLog(t *testing.T) {
logger.log("INFO", "hello") logger.log("INFO", "hello")
entry := <-done entry := <-done
if entry.level != "INFO" || entry.msg != "hello" { if entry.msg != "hello" {
t.Fatalf("unexpected entry %+v", entry) t.Fatalf("unexpected entry %+v", entry)
} }
@@ -653,7 +775,7 @@ func TestRunLoggerInternalLog(t *testing.T) {
close(logger.done) close(logger.done)
} }
func TestRunParsePIDFromLog(t *testing.T) { func TestLoggerParsePIDFromLog(t *testing.T) {
hugePID := strconv.FormatInt(math.MaxInt64, 10) + "0" hugePID := strconv.FormatInt(math.MaxInt64, 10) + "0"
tests := []struct { tests := []struct {
name string name string
@@ -769,69 +891,93 @@ func (f fakeFileInfo) ModTime() time.Time { return f.modTime }
func (f fakeFileInfo) IsDir() bool { return false } func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() interface{} { return nil } func (f fakeFileInfo) Sys() interface{} { return nil }
func TestExtractRecentErrors(t *testing.T) { func TestLoggerExtractRecentErrors(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string logs []struct{ level, msg string }
maxEntries int maxEntries int
want []string want []string
}{ }{
{ {
name: "empty log", name: "empty log",
content: "", logs: nil,
maxEntries: 10, maxEntries: 10,
want: nil, want: nil,
}, },
{ {
name: "no errors", name: "no errors",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started logs: []struct{ level, msg string }{
[2025-01-01 12:00:01.000] [PID:123] DEBUG: processing`, {"INFO", "started"},
{"DEBUG", "processing"},
},
maxEntries: 10, maxEntries: 10,
want: nil, want: nil,
}, },
{ {
name: "single error", name: "single error",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started logs: []struct{ level, msg string }{
[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed`, {"INFO", "started"},
{"ERROR", "something failed"},
},
maxEntries: 10, maxEntries: 10,
want: []string{"[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed"}, want: []string{"something failed"},
}, },
{ {
name: "error and warn", name: "error and warn",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started logs: []struct{ level, msg string }{
[2025-01-01 12:00:01.000] [PID:123] WARN: warning message {"INFO", "started"},
[2025-01-01 12:00:02.000] [PID:123] ERROR: error message`, {"WARN", "warning message"},
{"ERROR", "error message"},
},
maxEntries: 10, maxEntries: 10,
want: []string{ want: []string{
"[2025-01-01 12:00:01.000] [PID:123] WARN: warning message", "warning message",
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error message", "error message",
}, },
}, },
{ {
name: "truncate to max", name: "truncate to max",
content: `[2025-01-01 12:00:00.000] [PID:123] ERROR: error 1 logs: []struct{ level, msg string }{
[2025-01-01 12:00:01.000] [PID:123] ERROR: error 2 {"ERROR", "error 1"},
[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3 {"ERROR", "error 2"},
[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4 {"ERROR", "error 3"},
[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5`, {"ERROR", "error 4"},
{"ERROR", "error 5"},
},
maxEntries: 3, maxEntries: 3,
want: []string{ want: []string{
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3", "error 3",
"[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4", "error 4",
"[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5", "error 5",
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir() logger, err := NewLoggerWithSuffix("extract-test")
logPath := filepath.Join(tempDir, "test.log") if err != nil {
if err := os.WriteFile(logPath, []byte(tt.content), 0o644); err != nil { t.Fatalf("NewLoggerWithSuffix() error = %v", err)
t.Fatalf("failed to write test log: %v", err) }
defer logger.Close()
defer logger.RemoveLogFile()
// Write logs using logger methods
for _, entry := range tt.logs {
switch entry.level {
case "INFO":
logger.Info(entry.msg)
case "WARN":
logger.Warn(entry.msg)
case "ERROR":
logger.Error(entry.msg)
case "DEBUG":
logger.Debug(entry.msg)
}
} }
logger := &Logger{path: logPath} logger.Flush()
got := logger.ExtractRecentErrors(tt.maxEntries) got := logger.ExtractRecentErrors(tt.maxEntries)
if len(got) != len(tt.want) { if len(got) != len(tt.want) {
@@ -846,23 +992,137 @@ func TestExtractRecentErrors(t *testing.T) {
} }
} }
func TestExtractRecentErrorsNilLogger(t *testing.T) { func TestLoggerExtractRecentErrorsNilLogger(t *testing.T) {
var logger *Logger var logger *Logger
if got := logger.ExtractRecentErrors(10); got != nil { if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nil logger ExtractRecentErrors() should return nil, got %v", got) t.Fatalf("nil logger ExtractRecentErrors() should return nil, got %v", got)
} }
} }
func TestExtractRecentErrorsEmptyPath(t *testing.T) { func TestLoggerExtractRecentErrorsEmptyPath(t *testing.T) {
logger := &Logger{path: ""} logger := &Logger{path: ""}
if got := logger.ExtractRecentErrors(10); got != nil { if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("empty path ExtractRecentErrors() should return nil, got %v", got) t.Fatalf("empty path ExtractRecentErrors() should return nil, got %v", got)
} }
} }
func TestExtractRecentErrorsFileNotExist(t *testing.T) { func TestLoggerExtractRecentErrorsFileNotExist(t *testing.T) {
logger := &Logger{path: "/nonexistent/path/to/log.log"} logger := &Logger{path: "/nonexistent/path/to/log.log"}
if got := logger.ExtractRecentErrors(10); got != nil { if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nonexistent file ExtractRecentErrors() should return nil, got %v", got) t.Fatalf("nonexistent file ExtractRecentErrors() should return nil, got %v", got)
} }
} }
func TestSanitizeLogSuffixNoDuplicates(t *testing.T) {
testCases := []string{
"task",
"task.",
".task",
"-task",
"task-",
"--task--",
"..task..",
}
seen := make(map[string]string)
for _, input := range testCases {
result := sanitizeLogSuffix(input)
if result == "" {
t.Fatalf("sanitizeLogSuffix(%q) returned empty string", input)
}
if prev, exists := seen[result]; exists {
t.Fatalf("collision detected: %q and %q both produce %q", input, prev, result)
}
seen[result] = input
// Verify result is safe for file names
if strings.ContainsAny(result, "/\\:*?\"<>|") {
t.Fatalf("sanitizeLogSuffix(%q) = %q contains unsafe characters", input, result)
}
}
}
func TestExtractRecentErrorsBoundaryCheck(t *testing.T) {
logger, err := NewLoggerWithSuffix("boundary-test")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer logger.Close()
defer logger.RemoveLogFile()
// Write some errors
logger.Error("error 1")
logger.Warn("warn 1")
logger.Error("error 2")
logger.Flush()
// Test zero
result := logger.ExtractRecentErrors(0)
if result != nil {
t.Fatalf("ExtractRecentErrors(0) should return nil, got %v", result)
}
// Test negative
result = logger.ExtractRecentErrors(-5)
if result != nil {
t.Fatalf("ExtractRecentErrors(-5) should return nil, got %v", result)
}
// Test positive still works
result = logger.ExtractRecentErrors(10)
if len(result) != 3 {
t.Fatalf("ExtractRecentErrors(10) expected 3 entries, got %d", len(result))
}
}
func TestErrorEntriesMaxLimit(t *testing.T) {
logger, err := NewLoggerWithSuffix("max-limit-test")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer logger.Close()
defer logger.RemoveLogFile()
// Write 150 error/warn entries
for i := 1; i <= 150; i++ {
if i%2 == 0 {
logger.Error(fmt.Sprintf("error-%03d", i))
} else {
logger.Warn(fmt.Sprintf("warn-%03d", i))
}
}
logger.Flush()
// Extract all cached errors
result := logger.ExtractRecentErrors(200) // Request more than cache size
// Should only have last 100 entries (entries 51-150 in sequence)
if len(result) != 100 {
t.Fatalf("expected 100 cached entries, got %d", len(result))
}
// Verify entries are the last 100 (entries 51-150)
if !strings.Contains(result[0], "051") {
t.Fatalf("first cached entry should be entry 51, got: %s", result[0])
}
if !strings.Contains(result[99], "150") {
t.Fatalf("last cached entry should be entry 150, got: %s", result[99])
}
// Verify order is preserved - simplified logic
for i := 0; i < len(result)-1; i++ {
expectedNum := 51 + i
nextNum := 51 + i + 1
expectedEntry := fmt.Sprintf("%03d", expectedNum)
nextEntry := fmt.Sprintf("%03d", nextNum)
if !strings.Contains(result[i], expectedEntry) {
t.Fatalf("entry at index %d should contain %s, got: %s", i, expectedEntry, result[i])
}
if !strings.Contains(result[i+1], nextEntry) {
t.Fatalf("entry at index %d should contain %s, got: %s", i+1, nextEntry, result[i+1])
}
}
}

View File

@@ -14,9 +14,10 @@ import (
) )
const ( const (
version = "5.2.2" version = "5.4.0"
defaultWorkdir = "." defaultWorkdir = "."
defaultTimeout = 7200 // seconds defaultTimeout = 7200 // seconds (2 hours)
defaultCoverageTarget = 90.0
codexLogLineLimit = 1000 codexLogLineLimit = 1000
stdinSpecialChars = "\n\\\"'`$" stdinSpecialChars = "\n\\\"'`$"
stderrCaptureLimit = 4 * 1024 stderrCaptureLimit = 4 * 1024
@@ -30,6 +31,8 @@ const (
stdoutDrainTimeout = 100 * time.Millisecond stdoutDrainTimeout = 100 * time.Millisecond
) )
var useASCIIMode = os.Getenv("CODEAGENT_ASCII_MODE") == "true"
// Test hooks for dependency injection // Test hooks for dependency injection
var ( var (
stdinReader io.Reader = os.Stdin stdinReader io.Reader = os.Stdin
@@ -175,6 +178,7 @@ func run() (exitCode int) {
if parallelIndex != -1 { if parallelIndex != -1 {
backendName := defaultBackendName backendName := defaultBackendName
fullOutput := false
var extras []string var extras []string
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
@@ -182,6 +186,8 @@ func run() (exitCode int) {
switch { switch {
case arg == "--parallel": case arg == "--parallel":
continue continue
case arg == "--full-output":
fullOutput = true
case arg == "--backend": case arg == "--backend":
if i+1 >= len(args) { if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value") fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
@@ -202,11 +208,12 @@ func run() (exitCode int) {
} }
if len(extras) > 0 { if len(extras) > 0 {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend is allowed.") fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend and --full-output are allowed.")
fmt.Fprintln(os.Stderr, "Usage examples:") fmt.Fprintln(os.Stderr, "Usage examples:")
fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name) fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name)
fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name) fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name)
fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name) fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name)
fmt.Fprintf(os.Stderr, " %s --parallel --full-output <<'EOF' # include full task output\n", name)
return 1 return 1
} }
@@ -244,7 +251,33 @@ func run() (exitCode int) {
} }
results := executeConcurrent(layers, timeoutSec) results := executeConcurrent(layers, timeoutSec)
fmt.Println(generateFinalOutput(results))
// Extract structured report fields from each result
for i := range results {
results[i].CoverageTarget = defaultCoverageTarget
if results[i].Message == "" {
continue
}
lines := strings.Split(results[i].Message, "\n")
// Coverage extraction
results[i].Coverage = extractCoverageFromLines(lines)
results[i].CoverageNum = extractCoverageNum(results[i].Coverage)
// Files changed
results[i].FilesChanged = extractFilesChangedFromLines(lines)
// Test results
results[i].TestsPassed, results[i].TestsFailed = extractTestResultsFromLines(lines)
// Key output summary
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
}
// Default: summary mode (context-efficient)
// --full-output: legacy full output mode
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
exitCode = 0 exitCode = 0
for _, res := range results { for _, res := range results {
@@ -447,16 +480,19 @@ Usage:
%[1]s resume <session_id> "task" [workdir] %[1]s resume <session_id> "task" [workdir]
%[1]s resume <session_id> - [workdir] %[1]s resume <session_id> - [workdir]
%[1]s --parallel Run tasks in parallel (config from stdin) %[1]s --parallel Run tasks in parallel (config from stdin)
%[1]s --parallel --full-output Run tasks in parallel with full output (legacy)
%[1]s --version %[1]s --version
%[1]s --help %[1]s --help
Parallel mode examples: Parallel mode examples:
%[1]s --parallel < tasks.txt %[1]s --parallel < tasks.txt
echo '...' | %[1]s --parallel echo '...' | %[1]s --parallel
%[1]s --parallel --full-output < tasks.txt
%[1]s --parallel <<'EOF' %[1]s --parallel <<'EOF'
Environment Variables: Environment Variables:
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000) CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
CODEAGENT_ASCII_MODE Use ASCII symbols instead of Unicode (PASS/WARN/FAIL)
Exit Codes: Exit Codes:
0 Success 0 Success

View File

@@ -46,10 +46,26 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
var currentTask *TaskResult var currentTask *TaskResult
inTaskResults := false
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Total:") {
// Parse new format header: "X tasks | Y passed | Z failed"
if strings.Contains(line, "tasks |") && strings.Contains(line, "passed |") {
parts := strings.Split(line, "|")
for _, p := range parts {
p = strings.TrimSpace(p)
if strings.HasSuffix(p, "tasks") {
fmt.Sscanf(p, "%d tasks", &payload.Summary.Total)
} else if strings.HasSuffix(p, "passed") {
fmt.Sscanf(p, "%d passed", &payload.Summary.Success)
} else if strings.HasSuffix(p, "failed") {
fmt.Sscanf(p, "%d failed", &payload.Summary.Failed)
}
}
} else if strings.HasPrefix(line, "Total:") {
// Legacy format: "Total: X | Success: Y | Failed: Z"
parts := strings.Split(line, "|") parts := strings.Split(line, "|")
for _, p := range parts { for _, p := range parts {
p = strings.TrimSpace(p) p = strings.TrimSpace(p)
@@ -61,13 +77,72 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed) fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed)
} }
} }
} else if line == "## Task Results" {
inTaskResults = true
} else if line == "## Summary" {
// End of task results section
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
currentTask = nil
}
inTaskResults = false
} else if inTaskResults && strings.HasPrefix(line, "### ") {
// New task: ### task-id ✓ 92% or ### task-id PASS 92% (ASCII mode)
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
}
currentTask = &TaskResult{}
taskLine := strings.TrimPrefix(line, "### ")
success, warning, failed := getStatusSymbols()
// Parse different formats
if strings.Contains(taskLine, " "+success) {
parts := strings.Split(taskLine, " "+success)
currentTask.TaskID = strings.TrimSpace(parts[0])
currentTask.ExitCode = 0
// Extract coverage if present
if len(parts) > 1 {
coveragePart := strings.TrimSpace(parts[1])
if strings.HasSuffix(coveragePart, "%") {
currentTask.Coverage = coveragePart
}
}
} else if strings.Contains(taskLine, " "+warning) {
parts := strings.Split(taskLine, " "+warning)
currentTask.TaskID = strings.TrimSpace(parts[0])
currentTask.ExitCode = 0
} else if strings.Contains(taskLine, " "+failed) {
parts := strings.Split(taskLine, " "+failed)
currentTask.TaskID = strings.TrimSpace(parts[0])
currentTask.ExitCode = 1
} else {
currentTask.TaskID = taskLine
}
} else if currentTask != nil && inTaskResults {
// Parse task details
if strings.HasPrefix(line, "Exit code:") {
fmt.Sscanf(line, "Exit code: %d", &currentTask.ExitCode)
} else if strings.HasPrefix(line, "Error:") {
currentTask.Error = strings.TrimPrefix(line, "Error: ")
} else if strings.HasPrefix(line, "Log:") {
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
} else if strings.HasPrefix(line, "Did:") {
currentTask.KeyOutput = strings.TrimSpace(strings.TrimPrefix(line, "Did:"))
} else if strings.HasPrefix(line, "Detail:") {
// Error detail for failed tasks
if currentTask.Message == "" {
currentTask.Message = strings.TrimSpace(strings.TrimPrefix(line, "Detail:"))
}
}
} else if strings.HasPrefix(line, "--- Task:") { } else if strings.HasPrefix(line, "--- Task:") {
// Legacy full output format
if currentTask != nil { if currentTask != nil {
payload.Results = append(payload.Results, *currentTask) payload.Results = append(payload.Results, *currentTask)
} }
currentTask = &TaskResult{} currentTask = &TaskResult{}
currentTask.TaskID = strings.TrimSuffix(strings.TrimPrefix(line, "--- Task: "), " ---") currentTask.TaskID = strings.TrimSuffix(strings.TrimPrefix(line, "--- Task: "), " ---")
} else if currentTask != nil { } else if currentTask != nil && !inTaskResults {
// Legacy format parsing
if strings.HasPrefix(line, "Status: SUCCESS") { if strings.HasPrefix(line, "Status: SUCCESS") {
currentTask.ExitCode = 0 currentTask.ExitCode = 0
} else if strings.HasPrefix(line, "Status: FAILED") { } else if strings.HasPrefix(line, "Status: FAILED") {
@@ -82,15 +157,11 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
currentTask.SessionID = strings.TrimPrefix(line, "Session: ") currentTask.SessionID = strings.TrimPrefix(line, "Session: ")
} else if strings.HasPrefix(line, "Log:") { } else if strings.HasPrefix(line, "Log:") {
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:")) currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
} else if line != "" && !strings.HasPrefix(line, "===") && !strings.HasPrefix(line, "---") {
if currentTask.Message != "" {
currentTask.Message += "\n"
}
currentTask.Message += line
} }
} }
} }
// Handle last task
if currentTask != nil { if currentTask != nil {
payload.Results = append(payload.Results, *currentTask) payload.Results = append(payload.Results, *currentTask)
} }
@@ -343,9 +414,10 @@ task-beta`
} }
for _, id := range []string{"alpha", "beta"} { for _, id := range []string{"alpha", "beta"} {
want := fmt.Sprintf("Log: %s", logPathFor(id)) // Summary mode shows log paths in table format, not "Log: xxx"
if !strings.Contains(output, want) { logPath := logPathFor(id)
t.Fatalf("parallel output missing %q for %s:\n%s", want, id, output) if !strings.Contains(output, logPath) {
t.Fatalf("parallel output missing log path %q for %s:\n%s", logPath, id, output)
} }
} }
} }
@@ -426,10 +498,11 @@ ok-d`
t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut) t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut)
} }
// After parallel log isolation fix, each task has its own log file
expectedLines := map[string]struct{}{ expectedLines := map[string]struct{}{
fmt.Sprintf("Task a: Log: %s", expectedLog): {}, fmt.Sprintf("Task a: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-a.log", os.Getpid()))): {},
fmt.Sprintf("Task b: Log: %s", expectedLog): {}, fmt.Sprintf("Task b: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-b.log", os.Getpid()))): {},
fmt.Sprintf("Task d: Log: %s", expectedLog): {}, fmt.Sprintf("Task d: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-d.log", os.Getpid()))): {},
} }
if len(taskLines) != len(expectedLines) { if len(taskLines) != len(expectedLines) {
@@ -549,16 +622,16 @@ ok-e`
if resD.LogPath != logPathFor("D") || resE.LogPath != logPathFor("E") { if resD.LogPath != logPathFor("D") || resE.LogPath != logPathFor("E") {
t.Fatalf("expected log paths for D/E, got D=%q E=%q", resD.LogPath, resE.LogPath) t.Fatalf("expected log paths for D/E, got D=%q E=%q", resD.LogPath, resE.LogPath)
} }
// Summary mode shows log paths in table, verify they appear in output
for _, id := range []string{"A", "D", "E"} { for _, id := range []string{"A", "D", "E"} {
block := extractTaskBlock(t, output, id) logPath := logPathFor(id)
want := fmt.Sprintf("Log: %s", logPathFor(id)) if !strings.Contains(output, logPath) {
if !strings.Contains(block, want) { t.Fatalf("task %s log path %q not found in output:\n%s", id, logPath, output)
t.Fatalf("task %s block missing %q:\n%s", id, want, block)
} }
} }
blockB := extractTaskBlock(t, output, "B") // Task B was skipped, should have "-" or empty log path in table
if strings.Contains(blockB, "Log:") { if resB.LogPath != "" {
t.Fatalf("skipped task B should not emit a log line:\n%s", blockB) t.Fatalf("skipped task B should have empty log path, got %q", resB.LogPath)
} }
} }

View File

@@ -41,6 +41,7 @@ func resetTestHooks() {
closeLogger() closeLogger()
executablePathFn = os.Executable executablePathFn = os.Executable
runTaskFn = runCodexTask runTaskFn = runCodexTask
runCodexTaskFn = defaultRunCodexTaskFn
exitFn = os.Exit exitFn = os.Exit
} }
@@ -254,6 +255,10 @@ func (d *drainBlockingCmd) SetDir(dir string) {
d.inner.SetDir(dir) d.inner.SetDir(dir)
} }
func (d *drainBlockingCmd) SetEnv(env map[string]string) {
d.inner.SetEnv(env)
}
func (d *drainBlockingCmd) Process() processHandle { func (d *drainBlockingCmd) Process() processHandle {
return d.inner.Process() return d.inner.Process()
} }
@@ -386,6 +391,8 @@ type fakeCmd struct {
stderr io.Writer stderr io.Writer
env map[string]string
waitDelay time.Duration waitDelay time.Duration
waitErr error waitErr error
startErr error startErr error
@@ -510,6 +517,20 @@ func (f *fakeCmd) SetStderr(w io.Writer) {
func (f *fakeCmd) SetDir(string) {} func (f *fakeCmd) SetDir(string) {}
func (f *fakeCmd) SetEnv(env map[string]string) {
if len(env) == 0 {
return
}
f.mu.Lock()
defer f.mu.Unlock()
if f.env == nil {
f.env = make(map[string]string, len(env))
}
for k, v := range env {
f.env[k] = v
}
}
func (f *fakeCmd) Process() processHandle { func (f *fakeCmd) Process() processHandle {
if f == nil { if f == nil {
return nil return nil
@@ -878,6 +899,79 @@ func TestRunCodexTask_ContextTimeout(t *testing.T) {
} }
} }
func TestRunCodexTask_ForcesStopAfterCompletion(t *testing.T) {
defer resetTestHooks()
forceKillDelay.Store(0)
fake := newFakeCmd(fakeCmdConfig{
StdoutPlan: []fakeStdoutEvent{
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"done"}}` + "\n"},
{Data: `{"type":"thread.completed","thread_id":"tid"}` + "\n"},
},
KeepStdoutOpen: true,
BlockWait: true,
ReleaseWaitOnSignal: true,
ReleaseWaitOnKill: true,
})
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return fake
}
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
codexCommand = "fake-cmd"
start := time.Now()
result := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "done", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
duration := time.Since(start)
if result.ExitCode != 0 || result.Message != "done" {
t.Fatalf("unexpected result: %+v", result)
}
if duration > 2*time.Second {
t.Fatalf("runCodexTaskWithContext took too long: %v", duration)
}
if fake.process.SignalCount() == 0 {
t.Fatalf("expected SIGTERM to be sent, got %d", fake.process.SignalCount())
}
}
func TestRunCodexTask_DoesNotTerminateBeforeThreadCompleted(t *testing.T) {
defer resetTestHooks()
forceKillDelay.Store(0)
fake := newFakeCmd(fakeCmdConfig{
StdoutPlan: []fakeStdoutEvent{
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"intermediate"}}` + "\n"},
{Delay: 1100 * time.Millisecond, Data: `{"type":"item.completed","item":{"type":"agent_message","text":"final"}}` + "\n"},
{Data: `{"type":"thread.completed","thread_id":"tid"}` + "\n"},
},
KeepStdoutOpen: true,
BlockWait: true,
ReleaseWaitOnSignal: true,
ReleaseWaitOnKill: true,
})
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return fake
}
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
codexCommand = "fake-cmd"
start := time.Now()
result := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "done", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
duration := time.Since(start)
if result.ExitCode != 0 || result.Message != "final" {
t.Fatalf("unexpected result: %+v", result)
}
if duration > 5*time.Second {
t.Fatalf("runCodexTaskWithContext took too long: %v", duration)
}
if fake.process.SignalCount() == 0 {
t.Fatalf("expected SIGTERM to be sent, got %d", fake.process.SignalCount())
}
}
func TestBackendParseArgs_NewMode(t *testing.T) { func TestBackendParseArgs_NewMode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -964,6 +1058,8 @@ func TestBackendParseArgs_ResumeMode(t *testing.T) {
}, },
{name: "resume missing session_id", args: []string{"codeagent-wrapper", "resume"}, wantErr: true}, {name: "resume missing session_id", args: []string{"codeagent-wrapper", "resume"}, wantErr: true},
{name: "resume missing task", args: []string{"codeagent-wrapper", "resume", "session-123"}, wantErr: true}, {name: "resume missing task", args: []string{"codeagent-wrapper", "resume", "session-123"}, wantErr: true},
{name: "resume empty session_id", args: []string{"codeagent-wrapper", "resume", "", "task"}, wantErr: true},
{name: "resume whitespace session_id", args: []string{"codeagent-wrapper", "resume", " ", "task"}, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1180,6 +1276,18 @@ do something`
} }
} }
func TestParallelParseConfig_EmptySessionID(t *testing.T) {
input := `---TASK---
id: task-1
session_id:
---CONTENT---
do something`
if _, err := parseParallelConfig([]byte(input)); err == nil {
t.Fatalf("expected error for empty session_id, got nil")
}
}
func TestParallelParseConfig_InvalidFormat(t *testing.T) { func TestParallelParseConfig_InvalidFormat(t *testing.T) {
if _, err := parseParallelConfig([]byte("invalid format")); err == nil { if _, err := parseParallelConfig([]byte("invalid format")); err == nil {
t.Fatalf("expected error for invalid format, got nil") t.Fatalf("expected error for invalid format, got nil")
@@ -1280,9 +1388,19 @@ func TestRunShouldUseStdin(t *testing.T) {
} }
func TestRunBuildCodexArgs_NewMode(t *testing.T) { func TestRunBuildCodexArgs_NewMode(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Unsetenv(key)
cfg := &Config{Mode: "new", WorkDir: "/test/dir"} cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
args := buildCodexArgs(cfg, "my task") args := buildCodexArgs(cfg, "my task")
expected := []string{"e", "--skip-git-repo-check", "-C", "/test/dir", "--json", "my task"} expected := []string{
"e",
"--skip-git-repo-check",
"-C", "/test/dir",
"--json",
"my task",
}
if len(args) != len(expected) { if len(args) != len(expected) {
t.Fatalf("len mismatch") t.Fatalf("len mismatch")
} }
@@ -1294,9 +1412,20 @@ func TestRunBuildCodexArgs_NewMode(t *testing.T) {
} }
func TestRunBuildCodexArgs_ResumeMode(t *testing.T) { func TestRunBuildCodexArgs_ResumeMode(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Unsetenv(key)
cfg := &Config{Mode: "resume", SessionID: "session-abc"} cfg := &Config{Mode: "resume", SessionID: "session-abc"}
args := buildCodexArgs(cfg, "-") args := buildCodexArgs(cfg, "-")
expected := []string{"e", "--skip-git-repo-check", "--json", "resume", "session-abc", "-"} expected := []string{
"e",
"--skip-git-repo-check",
"--json",
"resume",
"session-abc",
"-",
}
if len(args) != len(expected) { if len(args) != len(expected) {
t.Fatalf("len mismatch") t.Fatalf("len mismatch")
} }
@@ -1307,6 +1436,61 @@ func TestRunBuildCodexArgs_ResumeMode(t *testing.T) {
} }
} }
func TestRunBuildCodexArgs_ResumeMode_EmptySessionHandledGracefully(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Cleanup(func() { os.Unsetenv(key) })
os.Unsetenv(key)
cfg := &Config{Mode: "resume", SessionID: " ", WorkDir: "/test/dir"}
args := buildCodexArgs(cfg, "task")
expected := []string{"e", "--skip-git-repo-check", "-C", "/test/dir", "--json", "task"}
if len(args) != len(expected) {
t.Fatalf("len mismatch")
}
for i := range args {
if args[i] != expected[i] {
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
}
}
}
func TestRunBuildCodexArgs_BypassSandboxEnvTrue(t *testing.T) {
defer resetTestHooks()
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
logger, err := NewLogger()
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
setLogger(logger)
defer closeLogger()
t.Setenv("CODEX_BYPASS_SANDBOX", "true")
cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
args := buildCodexArgs(cfg, "my task")
found := false
for _, arg := range args {
if arg == "--dangerously-bypass-approvals-and-sandbox" {
found = true
break
}
}
if !found {
t.Fatalf("expected bypass flag in args, got %v", args)
}
logger.Flush()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "CODEX_BYPASS_SANDBOX=true") {
t.Fatalf("expected bypass warning log, got: %s", string(data))
}
}
func TestBackendSelectBackend(t *testing.T) { func TestBackendSelectBackend(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1362,7 +1546,13 @@ func TestBackendBuildArgs_CodexBackend(t *testing.T) {
backend := CodexBackend{} backend := CodexBackend{}
cfg := &Config{Mode: "new", WorkDir: "/test/dir"} cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
got := backend.BuildArgs(cfg, "task") got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--skip-git-repo-check", "-C", "/test/dir", "--json", "task"} want := []string{
"e",
"--skip-git-repo-check",
"-C", "/test/dir",
"--json",
"task",
}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("length mismatch") t.Fatalf("length mismatch")
} }
@@ -1377,13 +1567,13 @@ func TestBackendBuildArgs_ClaudeBackend(t *testing.T) {
backend := ClaudeBackend{} backend := ClaudeBackend{}
cfg := &Config{Mode: "new", WorkDir: defaultWorkdir} cfg := &Config{Mode: "new", WorkDir: defaultWorkdir}
got := backend.BuildArgs(cfg, "todo") got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "todo"} want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("length mismatch") t.Fatalf("args length=%d, want %d: %v", len(got), len(want), got)
} }
for i := range want { for i := range want {
if got[i] != want[i] { if got[i] != want[i] {
t.Fatalf("index %d got %s want %s", i, got[i], want[i]) t.Fatalf("index %d got %q want %q (args=%v)", i, got[i], want[i], got)
} }
} }
@@ -1398,19 +1588,15 @@ func TestClaudeBackendBuildArgs_OutputValidation(t *testing.T) {
target := "ensure-flags" target := "ensure-flags"
args := backend.BuildArgs(cfg, target) args := backend.BuildArgs(cfg, target)
expectedPrefix := []string{"-p", "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose"} want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", target}
if len(args) != len(want) {
if len(args) != len(expectedPrefix)+1 { t.Fatalf("args length=%d, want %d: %v", len(args), len(want), args)
t.Fatalf("args length=%d, want %d", len(args), len(expectedPrefix)+1)
} }
for i, val := range expectedPrefix { for i := range want {
if args[i] != val { if args[i] != want[i] {
t.Fatalf("args[%d]=%q, want %q", i, args[i], val) t.Fatalf("index %d got %q want %q (args=%v)", i, args[i], want[i], args)
} }
} }
if args[len(args)-1] != target {
t.Fatalf("last arg=%q, want target %q", args[len(args)-1], target)
}
} }
func TestBackendBuildArgs_GeminiBackend(t *testing.T) { func TestBackendBuildArgs_GeminiBackend(t *testing.T) {
@@ -1581,6 +1767,34 @@ func TestBackendParseJSONStream_ClaudeEvents(t *testing.T) {
} }
} }
func TestBackendParseJSONStream_ClaudeEvents_ItemDoesNotForceCodex(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "null item",
input: `{"type":"result","result":"OK","session_id":"abc123","item":null}`,
},
{
name: "empty object item",
input: `{"type":"result","subtype":"x","result":"OK","session_id":"abc123","item":{}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message, threadID := parseJSONStream(strings.NewReader(tt.input))
if message != "OK" {
t.Fatalf("message=%q, want %q", message, "OK")
}
if threadID != "abc123" {
t.Fatalf("threadID=%q, want %q", threadID, "abc123")
}
})
}
}
func TestBackendParseJSONStream_GeminiEvents(t *testing.T) { func TestBackendParseJSONStream_GeminiEvents(t *testing.T) {
input := `{"type":"init","session_id":"xyz789"} input := `{"type":"init","session_id":"xyz789"}
{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"} {"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"}
@@ -1597,6 +1811,43 @@ func TestBackendParseJSONStream_GeminiEvents(t *testing.T) {
} }
} }
func TestBackendParseJSONStream_GeminiEvents_DeltaFalseStillDetected(t *testing.T) {
input := `{"type":"init","session_id":"xyz789"}
{"type":"message","content":"Hi","delta":false,"session_id":"xyz789"}
{"type":"result","status":"success","session_id":"xyz789"}`
message, threadID := parseJSONStream(strings.NewReader(input))
if message != "Hi" {
t.Fatalf("message=%q, want %q", message, "Hi")
}
if threadID != "xyz789" {
t.Fatalf("threadID=%q, want %q", threadID, "xyz789")
}
}
func TestBackendParseJSONStream_GeminiEvents_OnMessageTriggeredOnStatus(t *testing.T) {
input := `{"type":"init","session_id":"xyz789"}
{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"}
{"type":"message","content":" there","delta":true}
{"type":"result","status":"success","session_id":"xyz789"}`
var called int
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
called++
}, nil)
if message != "Hi there" {
t.Fatalf("message=%q, want %q", message, "Hi there")
}
if threadID != "xyz789" {
t.Fatalf("threadID=%q, want %q", threadID, "xyz789")
}
if called != 1 {
t.Fatalf("onMessage called=%d, want %d", called, 1)
}
}
func TestBackendParseJSONStreamWithWarn_InvalidLine(t *testing.T) { func TestBackendParseJSONStreamWithWarn_InvalidLine(t *testing.T) {
var warnings []string var warnings []string
warnFn := func(msg string) { warnings = append(warnings, msg) } warnFn := func(msg string) { warnings = append(warnings, msg) }
@@ -1613,7 +1864,7 @@ func TestBackendParseJSONStream_OnMessage(t *testing.T) {
var called int var called int
message, threadID := parseJSONStreamInternal(strings.NewReader(`{"type":"item.completed","item":{"type":"agent_message","text":"hook"}}`), nil, nil, func() { message, threadID := parseJSONStreamInternal(strings.NewReader(`{"type":"item.completed","item":{"type":"agent_message","text":"hook"}}`), nil, nil, func() {
called++ called++
}) }, nil)
if message != "hook" { if message != "hook" {
t.Fatalf("message = %q, want hook", message) t.Fatalf("message = %q, want hook", message)
} }
@@ -1625,10 +1876,86 @@ func TestBackendParseJSONStream_OnMessage(t *testing.T) {
} }
} }
func TestBackendParseJSONStream_OnComplete_CodexThreadCompleted(t *testing.T) {
input := `{"type":"item.completed","item":{"type":"agent_message","text":"first"}}` + "\n" +
`{"type":"item.completed","item":{"type":"agent_message","text":"second"}}` + "\n" +
`{"type":"thread.completed","thread_id":"t-1"}`
var onMessageCalls int
var onCompleteCalls int
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
onMessageCalls++
}, func() {
onCompleteCalls++
})
if message != "second" {
t.Fatalf("message = %q, want second", message)
}
if threadID != "t-1" {
t.Fatalf("threadID = %q, want t-1", threadID)
}
if onMessageCalls != 2 {
t.Fatalf("onMessage calls = %d, want 2", onMessageCalls)
}
if onCompleteCalls != 1 {
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
}
}
func TestBackendParseJSONStream_OnComplete_ClaudeResult(t *testing.T) {
input := `{"type":"message","subtype":"stream","session_id":"s-1"}` + "\n" +
`{"type":"result","result":"OK","session_id":"s-1"}`
var onMessageCalls int
var onCompleteCalls int
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
onMessageCalls++
}, func() {
onCompleteCalls++
})
if message != "OK" {
t.Fatalf("message = %q, want OK", message)
}
if threadID != "s-1" {
t.Fatalf("threadID = %q, want s-1", threadID)
}
if onMessageCalls != 1 {
t.Fatalf("onMessage calls = %d, want 1", onMessageCalls)
}
if onCompleteCalls != 1 {
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
}
}
func TestBackendParseJSONStream_OnComplete_GeminiTerminalResultStatus(t *testing.T) {
input := `{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"g-1"}` + "\n" +
`{"type":"result","status":"success","session_id":"g-1"}`
var onMessageCalls int
var onCompleteCalls int
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
onMessageCalls++
}, func() {
onCompleteCalls++
})
if message != "Hi" {
t.Fatalf("message = %q, want Hi", message)
}
if threadID != "g-1" {
t.Fatalf("threadID = %q, want g-1", threadID)
}
if onMessageCalls != 1 {
t.Fatalf("onMessage calls = %d, want 1", onMessageCalls)
}
if onCompleteCalls != 1 {
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
}
}
func TestBackendParseJSONStream_ScannerError(t *testing.T) { func TestBackendParseJSONStream_ScannerError(t *testing.T) {
var warnings []string var warnings []string
warnFn := func(msg string) { warnings = append(warnings, msg) } warnFn := func(msg string) { warnings = append(warnings, msg) }
message, threadID := parseJSONStreamInternal(errReader{err: errors.New("scan-fail")}, warnFn, nil, nil) message, threadID := parseJSONStreamInternal(errReader{err: errors.New("scan-fail")}, warnFn, nil, nil, nil)
if message != "" || threadID != "" { if message != "" || threadID != "" {
t.Fatalf("expected empty output on scanner error, got message=%q threadID=%q", message, threadID) t.Fatalf("expected empty output on scanner error, got message=%q threadID=%q", message, threadID)
} }
@@ -1783,13 +2110,13 @@ func TestRunLogFunctions(t *testing.T) {
} }
output := string(data) output := string(data)
if !strings.Contains(output, "INFO: info message") { if !strings.Contains(output, "info message") {
t.Errorf("logInfo output missing, got: %s", output) t.Errorf("logInfo output missing, got: %s", output)
} }
if !strings.Contains(output, "WARN: warn message") { if !strings.Contains(output, "warn message") {
t.Errorf("logWarn output missing, got: %s", output) t.Errorf("logWarn output missing, got: %s", output)
} }
if !strings.Contains(output, "ERROR: error message") { if !strings.Contains(output, "error message") {
t.Errorf("logError output missing, got: %s", output) t.Errorf("logError output missing, got: %s", output)
} }
} }
@@ -2306,14 +2633,17 @@ func TestRunGenerateFinalOutput(t *testing.T) {
if out == "" { if out == "" {
t.Fatalf("generateFinalOutput() returned empty string") t.Fatalf("generateFinalOutput() returned empty string")
} }
if !strings.Contains(out, "Total: 3") || !strings.Contains(out, "Success: 2") || !strings.Contains(out, "Failed: 1") { // New format: "X tasks | Y passed | Z failed"
if !strings.Contains(out, "3 tasks") || !strings.Contains(out, "2 passed") || !strings.Contains(out, "1 failed") {
t.Fatalf("summary missing, got %q", out) t.Fatalf("summary missing, got %q", out)
} }
if !strings.Contains(out, "Task: a") || !strings.Contains(out, "Task: b") { // New format uses ### task-id for each task
t.Fatalf("task entries missing") if !strings.Contains(out, "### a") || !strings.Contains(out, "### b") {
t.Fatalf("task entries missing in structured format")
} }
if strings.Contains(out, "Log:") { // Should have Summary section
t.Fatalf("unexpected log line when LogPath empty, got %q", out) if !strings.Contains(out, "## Summary") {
t.Fatalf("Summary section missing, got %q", out)
} }
} }
@@ -2333,12 +2663,18 @@ func TestRunGenerateFinalOutput_LogPath(t *testing.T) {
LogPath: "/tmp/log-b", LogPath: "/tmp/log-b",
}, },
} }
// Test summary mode (default) - should contain log paths
out := generateFinalOutput(results) out := generateFinalOutput(results)
if !strings.Contains(out, "Session: sid\nLog: /tmp/log-a") { if !strings.Contains(out, "/tmp/log-b") {
t.Fatalf("output missing log line after session: %q", out) t.Fatalf("summary output missing log path for failed task: %q", out)
}
// Test full output mode - shows Session: and Log: lines
out = generateFinalOutputWithMode(results, false)
if !strings.Contains(out, "Session: sid") || !strings.Contains(out, "Log: /tmp/log-a") {
t.Fatalf("full output missing log line after session: %q", out)
} }
if !strings.Contains(out, "Log: /tmp/log-b") { if !strings.Contains(out, "Log: /tmp/log-b") {
t.Fatalf("output missing log line for failed task: %q", out) t.Fatalf("full output missing log line for failed task: %q", out)
} }
} }
@@ -2636,6 +2972,46 @@ test`
} }
} }
func TestRunParallelWithFullOutput(t *testing.T) {
defer resetTestHooks()
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"codeagent-wrapper", "--parallel", "--full-output"}
stdinReader = strings.NewReader(`---TASK---
id: T1
---CONTENT---
noop`)
t.Cleanup(func() { stdinReader = os.Stdin })
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "full output marker"}
}
t.Cleanup(func() { runCodexTaskFn = orig })
out := captureOutput(t, func() {
if code := run(); code != 0 {
t.Fatalf("run exit = %d, want 0", code)
}
})
if !strings.Contains(out, "=== Parallel Execution Summary ===") {
t.Fatalf("output missing full-output header, got %q", out)
}
if !strings.Contains(out, "--- Task: T1 ---") {
t.Fatalf("output missing task block, got %q", out)
}
if !strings.Contains(out, "full output marker") {
t.Fatalf("output missing task message, got %q", out)
}
if strings.Contains(out, "=== Execution Report ===") {
t.Fatalf("output should not include summary-only header, got %q", out)
}
}
func TestParallelInvalidBackend(t *testing.T) { func TestParallelInvalidBackend(t *testing.T) {
defer resetTestHooks() defer resetTestHooks()
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil } cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
@@ -2690,7 +3066,9 @@ func TestVersionFlag(t *testing.T) {
t.Errorf("exit = %d, want 0", code) t.Errorf("exit = %d, want 0", code)
} }
}) })
want := "codeagent-wrapper version 5.2.2\n"
want := "codeagent-wrapper version 5.4.0\n"
if output != want { if output != want {
t.Fatalf("output = %q, want %q", output, want) t.Fatalf("output = %q, want %q", output, want)
} }
@@ -2704,7 +3082,9 @@ func TestVersionShortFlag(t *testing.T) {
t.Errorf("exit = %d, want 0", code) t.Errorf("exit = %d, want 0", code)
} }
}) })
want := "codeagent-wrapper version 5.2.2\n"
want := "codeagent-wrapper version 5.4.0\n"
if output != want { if output != want {
t.Fatalf("output = %q, want %q", output, want) t.Fatalf("output = %q, want %q", output, want)
} }
@@ -2718,7 +3098,9 @@ func TestVersionLegacyAlias(t *testing.T) {
t.Errorf("exit = %d, want 0", code) t.Errorf("exit = %d, want 0", code)
} }
}) })
want := "codex-wrapper version 5.2.2\n"
want := "codex-wrapper version 5.4.0\n"
if output != want { if output != want {
t.Fatalf("output = %q, want %q", output, want) t.Fatalf("output = %q, want %q", output, want)
} }
@@ -3299,7 +3681,7 @@ func TestRun_PipedTaskReadError(t *testing.T) {
if exitCode != 1 { if exitCode != 1 {
t.Fatalf("exit=%d, want 1", exitCode) t.Fatalf("exit=%d, want 1", exitCode)
} }
if !strings.Contains(logOutput, "ERROR: Failed to read piped stdin: read stdin: pipe failure") { if !strings.Contains(logOutput, "Failed to read piped stdin: read stdin: pipe failure") {
t.Fatalf("log missing piped read error, got %q", logOutput) t.Fatalf("log missing piped read error, got %q", logOutput)
} }
// Log file is always removed after completion (new behavior) // Log file is always removed after completion (new behavior)

View File

@@ -50,12 +50,53 @@ func parseJSONStreamWithWarn(r io.Reader, warnFn func(string)) (message, threadI
} }
func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) { func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) {
return parseJSONStreamInternal(r, warnFn, infoFn, nil) return parseJSONStreamInternal(r, warnFn, infoFn, nil, nil)
} }
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func()) (message, threadID string) { const (
scanner := bufio.NewScanner(r) jsonLineReaderSize = 64 * 1024
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024) jsonLineMaxBytes = 10 * 1024 * 1024
jsonLinePreviewBytes = 256
)
type codexHeader struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Item *struct {
Type string `json:"type"`
} `json:"item,omitempty"`
}
// UnifiedEvent combines all backend event formats into a single structure
// to avoid multiple JSON unmarshal operations per event
type UnifiedEvent struct {
// Common fields
Type string `json:"type"`
// Codex-specific fields
ThreadID string `json:"thread_id,omitempty"`
Item json.RawMessage `json:"item,omitempty"` // Lazy parse
// Claude-specific fields
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Result string `json:"result,omitempty"`
// Gemini-specific fields
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta *bool `json:"delta,omitempty"`
Status string `json:"status,omitempty"`
}
// ItemContent represents the parsed item.text field for Codex events
type ItemContent struct {
Type string `json:"type"`
Text interface{} `json:"text"`
}
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
if warnFn == nil { if warnFn == nil {
warnFn = func(string) {} warnFn = func(string) {}
@@ -70,6 +111,12 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
} }
} }
notifyComplete := func() {
if onComplete != nil {
onComplete()
}
}
totalEvents := 0 totalEvents := 0
var ( var (
@@ -78,51 +125,57 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
geminiBuffer strings.Builder geminiBuffer strings.Builder
) )
for scanner.Scan() { for {
line := strings.TrimSpace(scanner.Text()) line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes)
if line == "" { if err != nil {
if errors.Is(err, io.EOF) {
break
}
warnFn("Read stdout error: " + err.Error())
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue continue
} }
totalEvents++ totalEvents++
var raw map[string]json.RawMessage if tooLong {
if err := json.Unmarshal([]byte(line), &raw); err != nil { warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, truncateBytes(line, 100)))
warnFn(fmt.Sprintf("Failed to parse line: %s", truncate(line, 100)))
continue continue
} }
hasItemType := false // Single unmarshal for all backend types
if rawItem, ok := raw["item"]; ok { var event UnifiedEvent
var itemMap map[string]json.RawMessage if err := json.Unmarshal(line, &event); err != nil {
if err := json.Unmarshal(rawItem, &itemMap); err == nil { warnFn(fmt.Sprintf("Failed to parse event: %s", truncateBytes(line, 100)))
if _, ok := itemMap["type"]; ok { continue
hasItemType = true
}
}
} }
isCodex := hasItemType // Detect backend type by field presence
if !isCodex { isCodex := event.ThreadID != ""
if _, ok := raw["thread_id"]; ok { if !isCodex && len(event.Item) > 0 {
var itemHeader struct {
Type string `json:"type"`
}
if json.Unmarshal(event.Item, &itemHeader) == nil && itemHeader.Type != "" {
isCodex = true isCodex = true
} }
} }
isClaude := event.Subtype != "" || event.Result != ""
switch { if !isClaude && event.Type == "result" && event.SessionID != "" && event.Status == "" {
case isCodex: isClaude = true
var event JSONEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Codex event: %s", truncate(line, 100)))
continue
} }
isGemini := event.Role != "" || event.Delta != nil || event.Status != ""
// Handle Codex events
if isCodex {
var details []string var details []string
if event.ThreadID != "" { if event.ThreadID != "" {
details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID)) details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID))
} }
if event.Item != nil && event.Item.Type != "" {
details = append(details, fmt.Sprintf("item_type=%s", event.Item.Type))
}
if len(details) > 0 { if len(details) > 0 {
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", "))) infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", ")))
} else { } else {
@@ -133,27 +186,47 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
case "thread.started": case "thread.started":
threadID = event.ThreadID threadID = event.ThreadID
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID)) infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
case "thread.completed":
if event.ThreadID != "" && threadID == "" {
threadID = event.ThreadID
}
infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID))
notifyComplete()
case "item.completed": case "item.completed":
var itemType string var itemType string
var normalized string if len(event.Item) > 0 {
if event.Item != nil { var itemHeader struct {
itemType = event.Item.Type Type string `json:"type"`
normalized = normalizeText(event.Item.Text)
} }
if err := json.Unmarshal(event.Item, &itemHeader); err == nil {
itemType = itemHeader.Type
}
}
if itemType == "agent_message" && len(event.Item) > 0 {
// Lazy parse: only parse item content when needed
var item ItemContent
if err := json.Unmarshal(event.Item, &item); err == nil {
normalized := normalizeText(item.Text)
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized))) infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
if event.Item != nil && event.Item.Type == "agent_message" && normalized != "" { if normalized != "" {
codexMessage = normalized codexMessage = normalized
notifyMessage() notifyMessage()
} }
} else {
warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error()))
}
} else {
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
}
} }
case hasKey(raw, "subtype") || hasKey(raw, "result"):
var event ClaudeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Claude event: %s", truncate(line, 100)))
continue continue
} }
// Handle Claude events
if isClaude {
if event.SessionID != "" && threadID == "" { if event.SessionID != "" && threadID == "" {
threadID = event.SessionID threadID = event.SessionID
} }
@@ -165,31 +238,41 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
notifyMessage() notifyMessage()
} }
case hasKey(raw, "role") || hasKey(raw, "delta"): if event.Type == "result" {
var event GeminiEvent notifyComplete()
if err := json.Unmarshal([]byte(line), &event); err != nil { }
warnFn(fmt.Sprintf("Failed to parse Gemini event: %s", truncate(line, 100)))
continue continue
} }
// Handle Gemini events
if isGemini {
if event.SessionID != "" && threadID == "" { if event.SessionID != "" && threadID == "" {
threadID = event.SessionID threadID = event.SessionID
} }
if event.Content != "" { if event.Content != "" {
geminiBuffer.WriteString(event.Content) geminiBuffer.WriteString(event.Content)
}
if event.Status != "" {
notifyMessage() notifyMessage()
}
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, event.Delta, event.Status, len(event.Content))) if event.Type == "result" && (event.Status == "success" || event.Status == "error" || event.Status == "complete" || event.Status == "failed") {
notifyComplete()
default:
warnFn(fmt.Sprintf("Unknown event format: %s", truncate(line, 100)))
} }
} }
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { delta := false
warnFn("Read stdout error: " + err.Error()) if event.Delta != nil {
delta = *event.Delta
}
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, delta, event.Status, len(event.Content)))
continue
}
// Unknown event format
warnFn(fmt.Sprintf("Unknown event format: %s", truncateBytes(line, 100)))
} }
switch { switch {
@@ -236,6 +319,79 @@ func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Rea
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
} }
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int) (line []byte, tooLong bool, err error) {
if r == nil {
return nil, false, errors.New("reader is nil")
}
if maxBytes <= 0 {
return nil, false, errors.New("maxBytes must be > 0")
}
if previewBytes < 0 {
previewBytes = 0
}
part, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
if !isPrefix {
if len(part) > maxBytes {
return part[:min(len(part), previewBytes)], true, nil
}
return part, false, nil
}
preview := make([]byte, 0, min(previewBytes, len(part)))
if previewBytes > 0 {
preview = append(preview, part[:min(previewBytes, len(part))]...)
}
buf := make([]byte, 0, min(maxBytes, len(part)*2))
total := 0
if len(part) > maxBytes {
tooLong = true
} else {
buf = append(buf, part...)
total = len(part)
}
for isPrefix {
part, isPrefix, err = r.ReadLine()
if err != nil {
return nil, tooLong, err
}
if previewBytes > 0 && len(preview) < previewBytes {
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
}
if !tooLong {
if total+len(part) > maxBytes {
tooLong = true
continue
}
buf = append(buf, part...)
total += len(part)
}
}
if tooLong {
return preview, true, nil
}
return buf, false, nil
}
func truncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
if maxLen < 0 {
return ""
}
return string(b[:maxLen]) + "..."
}
func normalizeText(text interface{}) string { func normalizeText(text interface{}) string {
switch v := text.(type) { switch v := text.(type) {
case string: case string:

View File

@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestParseJSONStream_SkipsOverlongLineAndContinues(t *testing.T) {
// Exceed the 10MB bufio.Scanner limit in parseJSONStreamInternal.
tooLong := strings.Repeat("a", 11*1024*1024)
input := strings.Join([]string{
`{"type":"item.completed","item":{"type":"other_type","text":"` + tooLong + `"}}`,
`{"type":"thread.started","thread_id":"t-1"}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
}, "\n")
var warns []string
warnFn := func(msg string) { warns = append(warns, msg) }
gotMessage, gotThreadID := parseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil, nil)
if gotMessage != "ok" {
t.Fatalf("message=%q, want %q (warns=%v)", gotMessage, "ok", warns)
}
if gotThreadID != "t-1" {
t.Fatalf("threadID=%q, want %q (warns=%v)", gotThreadID, "t-1", warns)
}
if len(warns) == 0 || !strings.Contains(warns[0], "Skipped overlong JSON line") {
t.Fatalf("expected warning about overlong JSON line, got %v", warns)
}
}

View File

@@ -78,6 +78,7 @@ type logWriter struct {
prefix string prefix string
maxLen int maxLen int
buf bytes.Buffer buf bytes.Buffer
dropped bool
} }
func newLogWriter(prefix string, maxLen int) *logWriter { func newLogWriter(prefix string, maxLen int) *logWriter {
@@ -94,12 +95,12 @@ func (lw *logWriter) Write(p []byte) (int, error) {
total := len(p) total := len(p)
for len(p) > 0 { for len(p) > 0 {
if idx := bytes.IndexByte(p, '\n'); idx >= 0 { if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
lw.buf.Write(p[:idx]) lw.writeLimited(p[:idx])
lw.logLine(true) lw.logLine(true)
p = p[idx+1:] p = p[idx+1:]
continue continue
} }
lw.buf.Write(p) lw.writeLimited(p)
break break
} }
return total, nil return total, nil
@@ -117,11 +118,20 @@ func (lw *logWriter) logLine(force bool) {
return return
} }
line := lw.buf.String() line := lw.buf.String()
dropped := lw.dropped
lw.dropped = false
lw.buf.Reset() lw.buf.Reset()
if line == "" && !force { if line == "" && !force {
return return
} }
if lw.maxLen > 0 && len(line) > lw.maxLen { if lw.maxLen > 0 {
if dropped {
if lw.maxLen > 3 {
line = line[:min(len(line), lw.maxLen-3)] + "..."
} else {
line = line[:min(len(line), lw.maxLen)]
}
} else if len(line) > lw.maxLen {
cutoff := lw.maxLen cutoff := lw.maxLen
if cutoff > 3 { if cutoff > 3 {
line = line[:cutoff-3] + "..." line = line[:cutoff-3] + "..."
@@ -129,9 +139,32 @@ func (lw *logWriter) logLine(force bool) {
line = line[:cutoff] line = line[:cutoff]
} }
} }
}
logInfo(lw.prefix + line) logInfo(lw.prefix + line)
} }
func (lw *logWriter) writeLimited(p []byte) {
if lw == nil || len(p) == 0 {
return
}
if lw.maxLen <= 0 {
lw.buf.Write(p)
return
}
remaining := lw.maxLen - lw.buf.Len()
if remaining <= 0 {
lw.dropped = true
return
}
if len(p) <= remaining {
lw.buf.Write(p)
return
}
lw.buf.Write(p[:remaining])
lw.dropped = true
}
type tailBuffer struct { type tailBuffer struct {
limit int limit int
data []byte data []byte
@@ -172,6 +205,55 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "..." return s[:maxLen] + "..."
} }
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
func safeTruncate(s string, maxLen int) string {
if maxLen <= 0 || s == "" {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen < 4 {
return string(runes[:1])
}
cutoff := maxLen - 3
if cutoff <= 0 {
return string(runes[:1])
}
if len(runes) <= cutoff {
return s
}
return string(runes[:cutoff]) + "..."
}
// sanitizeOutput removes ANSI escape sequences and control characters.
func sanitizeOutput(s string) string {
var result strings.Builder
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' {
inEscape = true
i++ // skip '['
continue
}
if inEscape {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
inEscape = false
}
continue
}
// Keep printable chars and common whitespace.
if s[i] >= 32 || s[i] == '\n' || s[i] == '\t' {
result.WriteByte(s[i])
}
}
return result.String()
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
@@ -190,3 +272,444 @@ func greet(name string) string {
func farewell(name string) string { func farewell(name string) string {
return "goodbye " + name return "goodbye " + name
} }
// extractMessageSummary extracts a brief summary from task output
// Returns first meaningful line or truncated content up to maxLen chars
func extractMessageSummary(message string, maxLen int) string {
if message == "" || maxLen <= 0 {
return ""
}
// Try to find a meaningful summary line
lines := strings.Split(message, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and common noise
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") {
continue
}
// Found a meaningful line
return safeTruncate(line, maxLen)
}
// Fallback: truncate entire message
clean := strings.TrimSpace(message)
return safeTruncate(clean, maxLen)
}
// extractCoverageFromLines extracts coverage from pre-split lines.
func extractCoverageFromLines(lines []string) string {
if len(lines) == 0 {
return ""
}
end := len(lines)
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
end--
}
if end == 1 {
trimmed := strings.TrimSpace(lines[0])
if strings.HasSuffix(trimmed, "%") {
if num, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, "%"), 64); err == nil && num >= 0 && num <= 100 {
return trimmed
}
}
}
coverageKeywords := []string{"file", "stmt", "branch", "line", "coverage", "total"}
for _, line := range lines[:end] {
lower := strings.ToLower(line)
hasKeyword := false
tokens := strings.FieldsFunc(lower, func(r rune) bool { return r < 'a' || r > 'z' })
for _, token := range tokens {
for _, kw := range coverageKeywords {
if strings.HasPrefix(token, kw) {
hasKeyword = true
break
}
}
if hasKeyword {
break
}
}
if !hasKeyword {
continue
}
if !strings.Contains(line, "%") {
continue
}
// Extract percentage pattern: number followed by %
for i := 0; i < len(line); i++ {
if line[i] == '%' && i > 0 {
// Walk back to find the number
j := i - 1
for j >= 0 && (line[j] == '.' || (line[j] >= '0' && line[j] <= '9')) {
j--
}
if j < i-1 {
numStr := line[j+1 : i]
// Validate it's a reasonable percentage
if num, err := strconv.ParseFloat(numStr, 64); err == nil && num >= 0 && num <= 100 {
return numStr + "%"
}
}
}
}
}
return ""
}
// extractCoverage extracts coverage percentage from task output
// Supports common formats: "Coverage: 92%", "92% coverage", "coverage 92%", "TOTAL 92%"
func extractCoverage(message string) string {
if message == "" {
return ""
}
return extractCoverageFromLines(strings.Split(message, "\n"))
}
// extractCoverageNum extracts coverage as a numeric value for comparison
func extractCoverageNum(coverage string) float64 {
if coverage == "" {
return 0
}
// Remove % sign and parse
numStr := strings.TrimSuffix(coverage, "%")
if num, err := strconv.ParseFloat(numStr, 64); err == nil {
return num
}
return 0
}
// extractFilesChangedFromLines extracts files from pre-split lines.
func extractFilesChangedFromLines(lines []string) []string {
if len(lines) == 0 {
return nil
}
var files []string
seen := make(map[string]bool)
exts := []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".java", ".vue", ".css", ".scss", ".md", ".json", ".yaml", ".yml", ".toml"}
for _, line := range lines {
line = strings.TrimSpace(line)
// Pattern 1: "Modified: path/to/file.ts" or "Created: path/to/file.ts"
matchedPrefix := false
for _, prefix := range []string{"Modified:", "Created:", "Updated:", "Edited:", "Wrote:", "Changed:"} {
if strings.HasPrefix(line, prefix) {
file := strings.TrimSpace(strings.TrimPrefix(line, prefix))
file = strings.Trim(file, "`,\"'()[],:")
file = strings.TrimPrefix(file, "@")
if file != "" && !seen[file] {
files = append(files, file)
seen[file] = true
}
matchedPrefix = true
break
}
}
if matchedPrefix {
continue
}
// Pattern 2: Tokens that look like file paths (allow root files, strip @ prefix).
parts := strings.Fields(line)
for _, part := range parts {
part = strings.Trim(part, "`,\"'()[],:")
part = strings.TrimPrefix(part, "@")
for _, ext := range exts {
if strings.HasSuffix(part, ext) && !seen[part] {
files = append(files, part)
seen[part] = true
break
}
}
}
}
// Limit to first 10 files to avoid bloat
if len(files) > 10 {
files = files[:10]
}
return files
}
// extractFilesChanged extracts list of changed files from task output
// Looks for common patterns like "Modified: file.ts", "Created: file.ts", file paths in output
func extractFilesChanged(message string) []string {
if message == "" {
return nil
}
return extractFilesChangedFromLines(strings.Split(message, "\n"))
}
// extractTestResultsFromLines extracts test results from pre-split lines.
func extractTestResultsFromLines(lines []string) (passed, failed int) {
if len(lines) == 0 {
return 0, 0
}
// Common patterns:
// pytest: "12 passed, 2 failed"
// jest: "Tests: 2 failed, 12 passed"
// go: "ok ... 12 tests"
for _, line := range lines {
line = strings.ToLower(line)
// Look for test result lines
if !strings.Contains(line, "pass") && !strings.Contains(line, "fail") && !strings.Contains(line, "test") {
continue
}
// Extract numbers near "passed" or "pass"
if idx := strings.Index(line, "pass"); idx != -1 {
// Look for number before "pass"
num := extractNumberBefore(line, idx)
if num > 0 {
passed = num
}
}
// Extract numbers near "failed" or "fail"
if idx := strings.Index(line, "fail"); idx != -1 {
num := extractNumberBefore(line, idx)
if num > 0 {
failed = num
}
}
// go test style: "ok ... 12 tests"
if passed == 0 {
if idx := strings.Index(line, "test"); idx != -1 {
num := extractNumberBefore(line, idx)
if num > 0 {
passed = num
}
}
}
// If we found both, stop
if passed > 0 && failed > 0 {
break
}
}
return passed, failed
}
// extractTestResults extracts test pass/fail counts from task output
func extractTestResults(message string) (passed, failed int) {
if message == "" {
return 0, 0
}
return extractTestResultsFromLines(strings.Split(message, "\n"))
}
// extractNumberBefore extracts a number that appears before the given index
func extractNumberBefore(s string, idx int) int {
if idx <= 0 {
return 0
}
// Walk backwards to find digits
end := idx - 1
for end >= 0 && (s[end] == ' ' || s[end] == ':' || s[end] == ',') {
end--
}
if end < 0 {
return 0
}
start := end
for start >= 0 && s[start] >= '0' && s[start] <= '9' {
start--
}
start++
if start > end {
return 0
}
numStr := s[start : end+1]
if num, err := strconv.Atoi(numStr); err == nil {
return num
}
return 0
}
// extractKeyOutputFromLines extracts key output from pre-split lines.
func extractKeyOutputFromLines(lines []string, maxLen int) string {
if len(lines) == 0 || maxLen <= 0 {
return ""
}
// Priority 1: Look for explicit summary lines
for _, line := range lines {
line = strings.TrimSpace(line)
lower := strings.ToLower(line)
if strings.HasPrefix(lower, "summary:") || strings.HasPrefix(lower, "completed:") ||
strings.HasPrefix(lower, "implemented:") || strings.HasPrefix(lower, "added:") ||
strings.HasPrefix(lower, "created:") || strings.HasPrefix(lower, "fixed:") {
content := line
for _, prefix := range []string{"Summary:", "Completed:", "Implemented:", "Added:", "Created:", "Fixed:",
"summary:", "completed:", "implemented:", "added:", "created:", "fixed:"} {
content = strings.TrimPrefix(content, prefix)
}
content = strings.TrimSpace(content)
if len(content) > 0 {
return safeTruncate(content, maxLen)
}
}
}
// Priority 2: First meaningful line (skip noise)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") ||
strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
// Skip very short lines (likely headers or markers)
if len(line) < 20 {
continue
}
return safeTruncate(line, maxLen)
}
// Fallback: truncate entire message
clean := strings.TrimSpace(strings.Join(lines, "\n"))
return safeTruncate(clean, maxLen)
}
// extractKeyOutput extracts a brief summary of what the task accomplished
// Looks for summary lines, first meaningful sentence, or truncates message
func extractKeyOutput(message string, maxLen int) string {
if message == "" || maxLen <= 0 {
return ""
}
return extractKeyOutputFromLines(strings.Split(message, "\n"), maxLen)
}
// extractCoverageGap extracts what's missing from coverage reports
// Looks for uncovered lines, branches, or functions
func extractCoverageGap(message string) string {
if message == "" {
return ""
}
lower := strings.ToLower(message)
lines := strings.Split(message, "\n")
// Look for uncovered/missing patterns
for _, line := range lines {
lineLower := strings.ToLower(line)
line = strings.TrimSpace(line)
// Common patterns for uncovered code
if strings.Contains(lineLower, "uncovered") ||
strings.Contains(lineLower, "not covered") ||
strings.Contains(lineLower, "missing coverage") ||
strings.Contains(lineLower, "lines not covered") {
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
// Look for specific file:line patterns in coverage reports
if strings.Contains(lineLower, "branch") && strings.Contains(lineLower, "not taken") {
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
}
// Look for function names that aren't covered
if strings.Contains(lower, "function") && strings.Contains(lower, "0%") {
for _, line := range lines {
if strings.Contains(strings.ToLower(line), "0%") && strings.Contains(line, "function") {
line = strings.TrimSpace(line)
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
}
}
return ""
}
// extractErrorDetail extracts meaningful error context from task output
// Returns the most relevant error information up to maxLen characters
func extractErrorDetail(message string, maxLen int) string {
if message == "" || maxLen <= 0 {
return ""
}
lines := strings.Split(message, "\n")
var errorLines []string
// Look for error-related lines
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
lower := strings.ToLower(line)
// Skip noise lines
if strings.HasPrefix(line, "at ") && strings.Contains(line, "(") {
// Stack trace line - only keep first one
if len(errorLines) > 0 && strings.HasPrefix(strings.ToLower(errorLines[len(errorLines)-1]), "at ") {
continue
}
}
// Prioritize error/fail lines
if strings.Contains(lower, "error") ||
strings.Contains(lower, "fail") ||
strings.Contains(lower, "exception") ||
strings.Contains(lower, "assert") ||
strings.Contains(lower, "expected") ||
strings.Contains(lower, "timeout") ||
strings.Contains(lower, "not found") ||
strings.Contains(lower, "cannot") ||
strings.Contains(lower, "undefined") ||
strings.HasPrefix(line, "FAIL") ||
strings.HasPrefix(line, "●") {
errorLines = append(errorLines, line)
}
}
if len(errorLines) == 0 {
// No specific error lines found, take last few lines
start := len(lines) - 5
if start < 0 {
start = 0
}
for _, line := range lines[start:] {
line = strings.TrimSpace(line)
if line != "" {
errorLines = append(errorLines, line)
}
}
}
// Join and truncate
result := strings.Join(errorLines, " | ")
return safeTruncate(result, maxLen)
}

View File

@@ -0,0 +1,143 @@
package main
import (
"fmt"
"reflect"
"strings"
"testing"
)
func TestExtractCoverage(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"bare int", "92%", "92%"},
{"bare float", "92.5%", "92.5%"},
{"coverage prefix", "coverage: 92%", "92%"},
{"total prefix", "TOTAL 92%", "92%"},
{"all files", "All files 92%", "92%"},
{"empty", "", ""},
{"no number", "coverage: N/A", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractCoverage(tt.in); got != tt.want {
t.Fatalf("extractCoverage(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestExtractTestResults(t *testing.T) {
tests := []struct {
name string
in string
wantPassed int
wantFailed int
}{
{"pytest one line", "12 passed, 2 failed", 12, 2},
{"pytest split lines", "12 passed\n2 failed", 12, 2},
{"jest format", "Tests: 2 failed, 12 passed, 14 total", 12, 2},
{"go test style count", "ok\texample.com/foo\t0.12s\t12 tests", 12, 0},
{"zero counts", "0 passed, 0 failed", 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passed, failed := extractTestResults(tt.in)
if passed != tt.wantPassed || failed != tt.wantFailed {
t.Fatalf("extractTestResults(%q) = (%d, %d), want (%d, %d)", tt.in, passed, failed, tt.wantPassed, tt.wantFailed)
}
})
}
}
func TestExtractFilesChanged(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{"root file", "Modified: main.go\n", []string{"main.go"}},
{"path file", "Created: codeagent-wrapper/utils.go\n", []string{"codeagent-wrapper/utils.go"}},
{"at prefix", "Updated: @codeagent-wrapper/main.go\n", []string{"codeagent-wrapper/main.go"}},
{"token scan", "Files: @main.go, @codeagent-wrapper/utils.go\n", []string{"main.go", "codeagent-wrapper/utils.go"}},
{"space path", "Modified: dir/with space/file.go\n", []string{"dir/with space/file.go"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractFilesChanged(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("extractFilesChanged(%q) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
t.Run("limits to first 10", func(t *testing.T) {
var b strings.Builder
for i := 0; i < 12; i++ {
fmt.Fprintf(&b, "Modified: file%d.go\n", i)
}
got := extractFilesChanged(b.String())
if len(got) != 10 {
t.Fatalf("len(files)=%d, want 10: %#v", len(got), got)
}
for i := 0; i < 10; i++ {
want := fmt.Sprintf("file%d.go", i)
if got[i] != want {
t.Fatalf("files[%d]=%q, want %q", i, got[i], want)
}
}
})
}
func TestSafeTruncate(t *testing.T) {
tests := []struct {
name string
in string
maxLen int
want string
}{
{"empty", "", 4, ""},
{"zero maxLen", "hello", 0, ""},
{"one rune", "你好", 1, "你"},
{"two runes no truncate", "你好", 2, "你好"},
{"three runes no truncate", "你好", 3, "你好"},
{"two runes truncates long", "你好世界", 2, "你"},
{"three runes truncates long", "你好世界", 3, "你"},
{"four with ellipsis", "你好世界啊", 4, "你..."},
{"emoji", "🙂🙂🙂🙂🙂", 4, "🙂..."},
{"no truncate", "你好世界", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := safeTruncate(tt.in, tt.maxLen); got != tt.want {
t.Fatalf("safeTruncate(%q, %d) = %q, want %q", tt.in, tt.maxLen, got, tt.want)
}
})
}
}
func TestSanitizeOutput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"ansi", "\x1b[31mred\x1b[0m", "red"},
{"control chars", "a\x07b\r\nc\t", "ab\nc\t"},
{"normal", "hello\nworld\t!", "hello\nworld\t!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeOutput(tt.in); got != tt.want {
t.Fatalf("sanitizeOutput(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -1,6 +1,6 @@
--- ---
name: dev-plan-generator name: dev-plan-generator
description: Use this agent when you need to generate a structured development plan document (`dev-plan.md`) that breaks down a feature into concrete implementation tasks with testing requirements and acceptance criteria. This agent should be called after requirements analysis and before actual implementation begins.\n\n<example>\nContext: User is orchestrating a feature development workflow and needs to create a development plan after Codex analysis is complete.\nuser: "Create a development plan for the user authentication feature based on the requirements and analysis"\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to create the structured development plan document."\n<commentary>\nThe user needs a dev-plan.md document generated from requirements and analysis. Use the dev-plan-generator agent to create the structured task breakdown.\n</commentary>\n</example>\n\n<example>\nContext: Orchestrator has completed requirements gathering and Codex analysis for a new feature and needs to generate the development plan before moving to implementation.\nuser: "We've completed the analysis for the payment integration feature. Generate the development plan."\nassistant: "I'm going to use the Task tool to launch the dev-plan-generator agent to create the dev-plan.md document with task breakdown and testing requirements."\n<commentary>\nThis is the step in the workflow where the development plan document needs to be generated. Use the dev-plan-generator agent to create the structured plan.\n</commentary>\n</example>\n\n<example>\nContext: User is working through a requirements-driven workflow and has just approved the technical specifications.\nuser: "The specs look good. Let's move forward with creating the implementation plan."\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to generate the dev-plan.md document with the task breakdown."\n<commentary>\nAfter spec approval, the next step is generating the development plan. Use the dev-plan-generator agent to create the structured document.\n</commentary>\n</example> description: Use this agent when you need to generate a structured development plan document (`dev-plan.md`) that breaks down a feature into concrete implementation tasks with testing requirements and acceptance criteria. This agent should be called after requirements analysis and before actual implementation begins.\n\n<example>\nContext: User is orchestrating a feature development workflow and needs to create a development plan after codeagent analysis is complete.\nuser: "Create a development plan for the user authentication feature based on the requirements and analysis"\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to create the structured development plan document."\n<commentary>\nThe user needs a dev-plan.md document generated from requirements and analysis. Use the dev-plan-generator agent to create the structured task breakdown.\n</commentary>\n</example>\n\n<example>\nContext: Orchestrator has completed requirements gathering and codeagent analysis for a new feature and needs to generate the development plan before moving to implementation.\nuser: "We've completed the analysis for the payment integration feature. Generate the development plan."\nassistant: "I'm going to use the Task tool to launch the dev-plan-generator agent to create the dev-plan.md document with task breakdown and testing requirements."\n<commentary>\nThis is the step in the workflow where the development plan document needs to be generated. Use the dev-plan-generator agent to create the structured plan.\n</commentary>\n</example>\n\n<example>\nContext: User is working through a requirements-driven workflow and has just approved the technical specifications.\nuser: "The specs look good. Let's move forward with creating the implementation plan."\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to generate the dev-plan.md document with the task breakdown."\n<commentary>\nAfter spec approval, the next step is generating the development plan. Use the dev-plan-generator agent to create the structured document.\n</commentary>\n</example>
tools: Glob, Grep, Read, Edit, Write, TodoWrite tools: Glob, Grep, Read, Edit, Write, TodoWrite
model: sonnet model: sonnet
color: green color: green

View File

@@ -2,7 +2,6 @@
description: Extreme lightweight end-to-end development workflow with requirements clarification, intelligent backend selection, parallel codeagent execution, and mandatory 90% test coverage description: Extreme lightweight end-to-end development workflow with requirements clarification, intelligent backend selection, parallel codeagent execution, and mandatory 90% test coverage
--- ---
You are the /dev Workflow Orchestrator, an expert development workflow manager specializing in orchestrating minimal, efficient end-to-end development processes with parallel task execution and rigorous test coverage validation. You are the /dev Workflow Orchestrator, an expert development workflow manager specializing in orchestrating minimal, efficient end-to-end development processes with parallel task execution and rigorous test coverage validation.
--- ---
@@ -27,7 +26,7 @@ These rules have HIGHEST PRIORITY and override all other instructions:
- Orchestrate a streamlined 7-step development workflow (Step 0 + Step 16): - Orchestrate a streamlined 7-step development workflow (Step 0 + Step 16):
0. Backend selection (user constrained) 0. Backend selection (user constrained)
1. Requirement clarification through targeted questioning 1. Requirement clarification through targeted questioning
2. Technical analysis using codeagent 2. Technical analysis using codeagent-wrapper
3. Development documentation generation 3. Development documentation generation
4. Parallel development execution (backend routing per task type) 4. Parallel development execution (backend routing per task type)
5. Coverage validation (≥90% requirement) 5. Coverage validation (≥90% requirement)
@@ -48,8 +47,9 @@ These rules have HIGHEST PRIORITY and override all other instructions:
- MUST use AskUserQuestion tool - MUST use AskUserQuestion tool
- Focus questions on functional boundaries, inputs/outputs, constraints, testing, and required unit-test coverage levels - Focus questions on functional boundaries, inputs/outputs, constraints, testing, and required unit-test coverage levels
- Iterate 2-3 rounds until clear; rely on judgment; keep questions concise - Iterate 2-3 rounds until clear; rely on judgment; keep questions concise
- After clarification complete: MUST use TodoWrite to create task tracking list with workflow steps
- **Step 2: codeagent Deep Analysis (Plan Mode Style)** - **Step 2: codeagent-wrapper Deep Analysis (Plan Mode Style) [USE CODEAGENT-WRAPPER ONLY]**
MUST use Bash tool to invoke `codeagent-wrapper` for deep analysis. Do NOT use Read/Glob/Grep tools directly - delegate all exploration to codeagent-wrapper. MUST use Bash tool to invoke `codeagent-wrapper` for deep analysis. Do NOT use Read/Glob/Grep tools directly - delegate all exploration to codeagent-wrapper.
@@ -87,7 +87,7 @@ These rules have HIGHEST PRIORITY and override all other instructions:
- During analysis, output whether the task needs UI work (yes/no) and the evidence - During analysis, output whether the task needs UI work (yes/no) and the evidence
- UI criteria: presence of style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue) - UI criteria: presence of style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue)
**What codeagent Does in Analysis Mode**: **What the AI backend does in Analysis Mode** (when invoked via codeagent-wrapper):
1. **Explore Codebase**: Use Glob, Grep, Read to understand structure, patterns, architecture 1. **Explore Codebase**: Use Glob, Grep, Read to understand structure, patterns, architecture
2. **Identify Existing Patterns**: Find how similar features are implemented, reuse conventions 2. **Identify Existing Patterns**: Find how similar features are implemented, reuse conventions
3. **Evaluate Options**: When multiple approaches exist, list trade-offs (complexity, performance, security, maintainability) 3. **Evaluate Options**: When multiple approaches exist, list trade-offs (complexity, performance, security, maintainability)
@@ -162,7 +162,6 @@ These rules have HIGHEST PRIORITY and override all other instructions:
Scope: [task file scope] Scope: [task file scope]
Test: [test command] Test: [test command]
Deliverables: code + unit tests + coverage ≥90% + coverage summary Deliverables: code + unit tests + coverage ≥90% + coverage summary
EOF
---TASK--- ---TASK---
id: [task-id-2] id: [task-id-2]
@@ -177,7 +176,7 @@ These rules have HIGHEST PRIORITY and override all other instructions:
Deliverables: code + unit tests + coverage ≥90% + coverage summary Deliverables: code + unit tests + coverage ≥90% + coverage summary
EOF EOF
``` ```
- **Note**: Use `workdir: .` (current directory) for all tasks unless specific subdirectory is required
- Execute independent tasks concurrently; serialize conflicting ones; track coverage reports - Execute independent tasks concurrently; serialize conflicting ones; track coverage reports
- Backend is routed deterministically based on task `type`, no manual intervention needed - Backend is routed deterministically based on task `type`, no manual intervention needed

View File

@@ -105,6 +105,7 @@ EOF
Execute multiple tasks concurrently with dependency management: Execute multiple tasks concurrently with dependency management:
```bash ```bash
# Default: summary output (context-efficient, recommended)
codeagent-wrapper --parallel <<'EOF' codeagent-wrapper --parallel <<'EOF'
---TASK--- ---TASK---
id: backend_1701234567 id: backend_1701234567
@@ -125,6 +126,47 @@ dependencies: backend_1701234567, frontend_1701234568
---CONTENT--- ---CONTENT---
add integration tests for user management flow add integration tests for user management flow
EOF EOF
# Full output mode (for debugging, includes complete task messages)
codeagent-wrapper --parallel --full-output <<'EOF'
...
EOF
```
**Output Modes:**
- **Summary (default)**: Structured report with extracted `Did/Files/Tests/Coverage`, plus a short action summary.
- **Full (`--full-output`)**: Complete task messages included. Use only for debugging.
**Summary Output Example:**
```
=== Execution Report ===
3 tasks | 2 passed | 1 failed | 1 below 90%
## Task Results
### backend_api ✓ 92%
Did: Implemented /api/users CRUD endpoints
Files: backend/users.go, backend/router.go
Tests: 12 passed
Log: /tmp/codeagent-xxx.log
### frontend_form ⚠️ 88% (below 90%)
Did: Created login form with validation
Files: frontend/LoginForm.tsx
Tests: 8 passed
Gap: lines not covered: frontend/LoginForm.tsx:42-47
Log: /tmp/codeagent-yyy.log
### integration_tests ✗ FAILED
Exit code: 1
Error: Assertion failed at line 45
Detail: Expected status 200 but got 401
Log: /tmp/codeagent-zzz.log
## Summary
- 2/3 completed successfully
- Fix: integration_tests (Assertion failed at line 45)
- Coverage: frontend_form
``` ```
**Parallel Task Format:** **Parallel Task Format:**

5
go.work Normal file
View File

@@ -0,0 +1,5 @@
go 1.21
use (
./codeagent-wrapper
)

View File

@@ -5,7 +5,7 @@
set -e set -e
# Get staged files # Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) STAGED_FILES="$(git diff --cached --name-only --diff-filter=ACM)"
if [ -z "$STAGED_FILES" ]; then if [ -z "$STAGED_FILES" ]; then
echo "No files to validate" echo "No files to validate"
@@ -15,17 +15,32 @@ fi
echo "Running pre-commit checks..." echo "Running pre-commit checks..."
# Check Go files # Check Go files
GO_FILES=$(echo "$STAGED_FILES" | grep '\.go$' || true) GO_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.go$' || true)"
if [ -n "$GO_FILES" ]; then if [ -n "$GO_FILES" ]; then
echo "Checking Go files..." echo "Checking Go files..."
# Format check if ! command -v gofmt &> /dev/null; then
gofmt -l $GO_FILES | while read -r file; do echo "❌ gofmt not found. Please install Go (gofmt is included with the Go toolchain)."
if [ -n "$file" ]; then
echo "$file needs formatting (run: gofmt -w $file)"
exit 1 exit 1
fi fi
done
# Format check
GO_FILE_ARGS=()
while IFS= read -r file; do
if [ -n "$file" ]; then
GO_FILE_ARGS+=("$file")
fi
done <<< "$GO_FILES"
if [ "${#GO_FILE_ARGS[@]}" -gt 0 ]; then
UNFORMATTED="$(gofmt -l "${GO_FILE_ARGS[@]}")"
if [ -n "$UNFORMATTED" ]; then
echo "❌ The following files need formatting:"
echo "$UNFORMATTED"
echo "Run: gofmt -w <file>"
exit 1
fi
fi
# Run tests # Run tests
if command -v go &> /dev/null; then if command -v go &> /dev/null; then
@@ -38,19 +53,26 @@ if [ -n "$GO_FILES" ]; then
fi fi
# Check JSON files # Check JSON files
JSON_FILES=$(echo "$STAGED_FILES" | grep '\.json$' || true) JSON_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.json$' || true)"
if [ -n "$JSON_FILES" ]; then if [ -n "$JSON_FILES" ]; then
echo "Validating JSON files..." echo "Validating JSON files..."
for file in $JSON_FILES; do if ! command -v jq &> /dev/null; then
echo "❌ jq not found. Please install jq to validate JSON files."
exit 1
fi
while IFS= read -r file; do
if [ -z "$file" ]; then
continue
fi
if ! jq empty "$file" 2>/dev/null; then if ! jq empty "$file" 2>/dev/null; then
echo "❌ Invalid JSON: $file" echo "❌ Invalid JSON: $file"
exit 1 exit 1
fi fi
done done <<< "$JSON_FILES"
fi fi
# Check Markdown files # Check Markdown files
MD_FILES=$(echo "$STAGED_FILES" | grep '\.md$' || true) MD_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.md$' || true)"
if [ -n "$MD_FILES" ]; then if [ -n "$MD_FILES" ]; then
echo "Checking markdown files..." echo "Checking markdown files..."
# Add markdown linting if needed # Add markdown linting if needed

View File

@@ -46,17 +46,23 @@ echo.
echo codeagent-wrapper installed successfully at: echo codeagent-wrapper installed successfully at:
echo %DEST% echo %DEST%
rem Automatically ensure %USERPROFILE%\bin is in the USER (HKCU) PATH rem Ensure %USERPROFILE%\bin is in PATH without duplicating entries
rem 1) Read current user PATH from registry (REG_SZ or REG_EXPAND_SZ) rem 1) Read current user PATH from registry (REG_SZ or REG_EXPAND_SZ)
set "USER_PATH_RAW=" set "USER_PATH_RAW="
set "USER_PATH_TYPE="
for /f "tokens=1,2,*" %%A in ('reg query "HKCU\Environment" /v Path 2^>nul ^| findstr /I /R "^ *Path *REG_"') do ( for /f "tokens=1,2,*" %%A in ('reg query "HKCU\Environment" /v Path 2^>nul ^| findstr /I /R "^ *Path *REG_"') do (
set "USER_PATH_TYPE=%%B"
set "USER_PATH_RAW=%%C" set "USER_PATH_RAW=%%C"
) )
rem Trim leading spaces from USER_PATH_RAW rem Trim leading spaces from USER_PATH_RAW
for /f "tokens=* delims= " %%D in ("!USER_PATH_RAW!") do set "USER_PATH_RAW=%%D" for /f "tokens=* delims= " %%D in ("!USER_PATH_RAW!") do set "USER_PATH_RAW=%%D"
rem 2) Read current system PATH from registry (REG_SZ or REG_EXPAND_SZ)
set "SYS_PATH_RAW="
for /f "tokens=1,2,*" %%A in ('reg query "HKLM\System\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul ^| findstr /I /R "^ *Path *REG_"') do (
set "SYS_PATH_RAW=%%C"
)
rem Trim leading spaces from SYS_PATH_RAW
for /f "tokens=* delims= " %%D in ("!SYS_PATH_RAW!") do set "SYS_PATH_RAW=%%D"
rem Normalize DEST_DIR by removing a trailing backslash if present rem Normalize DEST_DIR by removing a trailing backslash if present
if "!DEST_DIR:~-1!"=="\" set "DEST_DIR=!DEST_DIR:~0,-1!" if "!DEST_DIR:~-1!"=="\" set "DEST_DIR=!DEST_DIR:~0,-1!"
@@ -67,23 +73,42 @@ set "SEARCH_EXP2=;!DEST_DIR!\;"
set "SEARCH_LIT=;!PCT!USERPROFILE!PCT!\bin;" set "SEARCH_LIT=;!PCT!USERPROFILE!PCT!\bin;"
set "SEARCH_LIT2=;!PCT!USERPROFILE!PCT!\bin\;" set "SEARCH_LIT2=;!PCT!USERPROFILE!PCT!\bin\;"
rem Prepare user PATH variants for containment tests rem Prepare PATH variants for containment tests (strip quotes to avoid false negatives)
set "CHECK_RAW=;!USER_PATH_RAW!;" set "USER_PATH_RAW_CLEAN=!USER_PATH_RAW:"=!"
set "USER_PATH_EXP=!USER_PATH_RAW!" set "SYS_PATH_RAW_CLEAN=!SYS_PATH_RAW:"=!"
if defined USER_PATH_EXP call set "USER_PATH_EXP=%%USER_PATH_EXP%%"
set "CHECK_EXP=;!USER_PATH_EXP!;"
rem Check if already present in user PATH (literal or expanded, with/without trailing backslash) set "CHECK_USER_RAW=;!USER_PATH_RAW_CLEAN!;"
set "USER_PATH_EXP=!USER_PATH_RAW_CLEAN!"
if defined USER_PATH_EXP call set "USER_PATH_EXP=%%USER_PATH_EXP%%"
set "USER_PATH_EXP_CLEAN=!USER_PATH_EXP:"=!"
set "CHECK_USER_EXP=;!USER_PATH_EXP_CLEAN!;"
set "CHECK_SYS_RAW=;!SYS_PATH_RAW_CLEAN!;"
set "SYS_PATH_EXP=!SYS_PATH_RAW_CLEAN!"
if defined SYS_PATH_EXP call set "SYS_PATH_EXP=%%SYS_PATH_EXP%%"
set "SYS_PATH_EXP_CLEAN=!SYS_PATH_EXP:"=!"
set "CHECK_SYS_EXP=;!SYS_PATH_EXP_CLEAN!;"
rem Check if already present (literal or expanded, with/without trailing backslash)
set "ALREADY_IN_USERPATH=0" set "ALREADY_IN_USERPATH=0"
echo !CHECK_RAW! | findstr /I /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul && set "ALREADY_IN_USERPATH=1" echo(!CHECK_USER_RAW! | findstr /I /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul && set "ALREADY_IN_USERPATH=1"
if "!ALREADY_IN_USERPATH!"=="0" ( if "!ALREADY_IN_USERPATH!"=="0" (
echo !CHECK_EXP! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" >nul && set "ALREADY_IN_USERPATH=1" echo(!CHECK_USER_EXP! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" >nul && set "ALREADY_IN_USERPATH=1"
)
set "ALREADY_IN_SYSPATH=0"
echo(!CHECK_SYS_RAW! | findstr /I /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul && set "ALREADY_IN_SYSPATH=1"
if "!ALREADY_IN_SYSPATH!"=="0" (
echo(!CHECK_SYS_EXP! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" >nul && set "ALREADY_IN_SYSPATH=1"
) )
if "!ALREADY_IN_USERPATH!"=="1" ( if "!ALREADY_IN_USERPATH!"=="1" (
echo User PATH already includes %%USERPROFILE%%\bin. echo User PATH already includes %%USERPROFILE%%\bin.
) else ( ) else (
rem Not present: append to user PATH using setx without duplicating system PATH if "!ALREADY_IN_SYSPATH!"=="1" (
echo System PATH already includes %%USERPROFILE%%\bin; skipping user PATH update.
) else (
rem Not present: append to user PATH
if defined USER_PATH_RAW ( if defined USER_PATH_RAW (
set "USER_PATH_NEW=!USER_PATH_RAW!" set "USER_PATH_NEW=!USER_PATH_RAW!"
if not "!USER_PATH_NEW:~-1!"==";" set "USER_PATH_NEW=!USER_PATH_NEW!;" if not "!USER_PATH_NEW:~-1!"==";" set "USER_PATH_NEW=!USER_PATH_NEW!;"
@@ -92,17 +117,19 @@ if "!ALREADY_IN_USERPATH!"=="1" (
set "USER_PATH_NEW=!PCT!USERPROFILE!PCT!\bin" set "USER_PATH_NEW=!PCT!USERPROFILE!PCT!\bin"
) )
rem Persist update to HKCU\Environment\Path (user scope) rem Persist update to HKCU\Environment\Path (user scope)
setx PATH "!USER_PATH_NEW!" >nul setx Path "!USER_PATH_NEW!" >nul
if errorlevel 1 ( if errorlevel 1 (
echo WARNING: Failed to append %%USERPROFILE%%\bin to your user PATH. echo WARNING: Failed to append %%USERPROFILE%%\bin to your user PATH.
) else ( ) else (
echo Added %%USERPROFILE%%\bin to your user PATH. echo Added %%USERPROFILE%%\bin to your user PATH.
) )
)
) )
rem Update current session PATH so codex-wrapper is immediately available rem Update current session PATH so codeagent-wrapper is immediately available
set "CURPATH=;%PATH%;" set "CURPATH=;%PATH%;"
echo !CURPATH! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul set "CURPATH_CLEAN=!CURPATH:"=!"
echo(!CURPATH_CLEAN! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul
if errorlevel 1 set "PATH=!DEST_DIR!;!PATH!" if errorlevel 1 set "PATH=!DEST_DIR!;!PATH!"
goto :cleanup goto :cleanup

View File

@@ -17,7 +17,10 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
import jsonschema try:
import jsonschema
except ImportError: # pragma: no cover
jsonschema = None
DEFAULT_INSTALL_DIR = "~/.claude" DEFAULT_INSTALL_DIR = "~/.claude"
@@ -87,6 +90,32 @@ def load_config(path: str) -> Dict[str, Any]:
config_path = Path(path).expanduser().resolve() config_path = Path(path).expanduser().resolve()
config = _load_json(config_path) config = _load_json(config_path)
if jsonschema is None:
print(
"WARNING: python package 'jsonschema' is not installed; "
"skipping config validation. To enable validation run:\n"
" python3 -m pip install jsonschema\n",
file=sys.stderr,
)
if not isinstance(config, dict):
raise ValueError(
f"Config must be a dict, got {type(config).__name__}. "
"Check your config.json syntax."
)
required_keys = ["version", "install_dir", "log_file", "modules"]
missing = [key for key in required_keys if key not in config]
if missing:
missing_str = ", ".join(missing)
raise ValueError(
f"Config missing required keys: {missing_str}. "
"Install jsonschema for better validation: "
"python3 -m pip install jsonschema"
)
return config
schema_candidates = [ schema_candidates = [
config_path.parent / "config.schema.json", config_path.parent / "config.schema.json",
Path(__file__).resolve().with_name("config.schema.json"), Path(__file__).resolve().with_name("config.schema.json"),
@@ -357,6 +386,27 @@ def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
stderr_lines: List[str] = [] stderr_lines: List[str] = []
# Read stdout and stderr in real-time # Read stdout and stderr in real-time
if sys.platform == "win32":
# On Windows, use threads instead of selectors (pipes aren't selectable)
import threading
def read_output(pipe, lines, file=None):
for line in iter(pipe.readline, ''):
lines.append(line)
print(line, end="", flush=True, file=file)
pipe.close()
stdout_thread = threading.Thread(target=read_output, args=(process.stdout, stdout_lines))
stderr_thread = threading.Thread(target=read_output, args=(process.stderr, stderr_lines, sys.stderr))
stdout_thread.start()
stderr_thread.start()
stdout_thread.join()
stderr_thread.join()
process.wait()
else:
# On Unix, use selectors for more efficient I/O
import selectors import selectors
sel = selectors.DefaultSelector() sel = selectors.DefaultSelector()
sel.register(process.stdout, selectors.EVENT_READ) # type: ignore[arg-type] sel.register(process.stdout, selectors.EVENT_READ) # type: ignore[arg-type]

View File

@@ -1,12 +1,15 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "⚠️ WARNING: install.sh is LEGACY and will be removed in future versions." if [ -z "${SKIP_WARNING:-}" ]; then
echo "Please use the new installation method:" echo "⚠️ WARNING: install.sh is LEGACY and will be removed in future versions."
echo " python3 install.py --install-dir ~/.claude" echo "Please use the new installation method:"
echo "" echo " python3 install.py --install-dir ~/.claude"
echo "Continuing with legacy installation in 5 seconds..." echo ""
sleep 5 echo "Set SKIP_WARNING=1 to bypass this message"
echo "Continuing with legacy installation in 5 seconds..."
sleep 5
fi
# Detect platform # Detect platform
OS=$(uname -s | tr '[:upper:]' '[:lower:]') OS=$(uname -s | tr '[:upper:]' '[:lower:]')
@@ -31,23 +34,42 @@ if ! curl -fsSL "$URL" -o /tmp/codeagent-wrapper; then
exit 1 exit 1
fi fi
mkdir -p "$HOME/bin" INSTALL_DIR="${INSTALL_DIR:-$HOME/.claude}"
BIN_DIR="${INSTALL_DIR}/bin"
mkdir -p "$BIN_DIR"
mv /tmp/codeagent-wrapper "$HOME/bin/codeagent-wrapper" mv /tmp/codeagent-wrapper "${BIN_DIR}/codeagent-wrapper"
chmod +x "$HOME/bin/codeagent-wrapper" chmod +x "${BIN_DIR}/codeagent-wrapper"
if "$HOME/bin/codeagent-wrapper" --version >/dev/null 2>&1; then if "${BIN_DIR}/codeagent-wrapper" --version >/dev/null 2>&1; then
echo "codeagent-wrapper installed successfully to ~/bin/codeagent-wrapper" echo "codeagent-wrapper installed successfully to ${BIN_DIR}/codeagent-wrapper"
else else
echo "ERROR: installation verification failed" >&2 echo "ERROR: installation verification failed" >&2
exit 1 exit 1
fi fi
if [[ ":$PATH:" != *":$HOME/bin:"* ]]; then # Auto-add to shell config files with idempotency
if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then
echo "" echo ""
echo "WARNING: ~/bin is not in your PATH" echo "WARNING: ${BIN_DIR} is not in your PATH"
echo "Add this line to your ~/.bashrc or ~/.zshrc:"
echo "" # Detect shell config file
echo " export PATH=\"\$HOME/bin:\$PATH\"" if [ -n "$ZSH_VERSION" ]; then
RC_FILE="$HOME/.zshrc"
else
RC_FILE="$HOME/.bashrc"
fi
# Idempotent add: check if complete export statement already exists
EXPORT_LINE="export PATH=\"${BIN_DIR}:\$PATH\""
if [ -f "$RC_FILE" ] && grep -qF "${EXPORT_LINE}" "$RC_FILE" 2>/dev/null; then
echo " ${BIN_DIR} already in ${RC_FILE}, skipping."
else
echo " Adding to ${RC_FILE}..."
echo "" >> "$RC_FILE"
echo "# Added by myclaude installer" >> "$RC_FILE"
echo "export PATH=\"${BIN_DIR}:\$PATH\"" >> "$RC_FILE"
echo " Done. Run 'source ${RC_FILE}' or restart shell."
fi
echo "" echo ""
fi fi

View File

@@ -104,6 +104,10 @@ You adhere to core software engineering principles like KISS (Keep It Simple, St
## Implementation Constraints ## Implementation Constraints
### Language Rules
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, SQL, CRUD, etc.) in English; translate explanatory text only.
### MUST Requirements ### MUST Requirements
- **Working Solution**: Code must fully implement the specified functionality - **Working Solution**: Code must fully implement the specified functionality
- **Integration Compatibility**: Must work seamlessly with existing codebase - **Integration Compatibility**: Must work seamlessly with existing codebase

View File

@@ -88,6 +88,10 @@ Each phase should be independently deployable and testable.
## Key Constraints ## Key Constraints
### Language Rules
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, SQL, CRUD, etc.) in English; translate explanatory text only.
### MUST Requirements ### MUST Requirements
- **Direct Implementability**: Every item must be directly translatable to code - **Direct Implementability**: Every item must be directly translatable to code
- **Specific Technical Details**: Include exact file paths, function names, table schemas - **Specific Technical Details**: Include exact file paths, function names, table schemas

View File

@@ -176,6 +176,10 @@ You adhere to core software engineering principles like KISS (Keep It Simple, St
## Key Constraints ## Key Constraints
### Language Rules
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, etc.) in English; translate explanatory text only.
### MUST Requirements ### MUST Requirements
- **Functional Verification**: Verify all specified functionality works - **Functional Verification**: Verify all specified functionality works
- **Integration Testing**: Ensure seamless integration with existing code - **Integration Testing**: Ensure seamless integration with existing code

View File

@@ -199,6 +199,10 @@ func TestAPIEndpoint(t *testing.T) {
## Key Constraints ## Key Constraints
### Language Rules
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
### MUST Requirements ### MUST Requirements
- **Specification Coverage**: Must test all requirements from `./.claude/specs/{feature_name}/requirements-spec.md` - **Specification Coverage**: Must test all requirements from `./.claude/specs/{feature_name}/requirements-spec.md`
- **Critical Path Testing**: Must test all critical business functionality - **Critical Path Testing**: Must test all critical business functionality

View File

@@ -39,18 +39,42 @@ codeagent-wrapper --backend gemini "simple task"
## Backends ## Backends
| Backend | Command | Description | | Backend | Command | Description | Best For |
|---------|---------|-------------| |---------|---------|-------------|----------|
| codex | `--backend codex` | OpenAI Codex (default) | | codex | `--backend codex` | OpenAI Codex (default) | Code analysis, complex development |
| claude | `--backend claude` | Anthropic Claude | | claude | `--backend claude` | Anthropic Claude | Simple tasks, documentation, prompts |
| gemini | `--backend gemini` | Google Gemini | | gemini | `--backend gemini` | Google Gemini | UI/UX prototyping |
### Backend Selection Guide
**Codex** (default):
- Deep code understanding and complex logic implementation
- Large-scale refactoring with precise dependency tracking
- Algorithm optimization and performance tuning
- Example: "Analyze the call graph of @src/core and refactor the module dependency structure"
**Claude**:
- Quick feature implementation with clear requirements
- Technical documentation, API specs, README generation
- Professional prompt engineering (e.g., product requirements, design specs)
- Example: "Generate a comprehensive README for @package.json with installation, usage, and API docs"
**Gemini**:
- UI component scaffolding and layout prototyping
- Design system implementation with style consistency
- Interactive element generation with accessibility support
- Example: "Create a responsive dashboard layout with sidebar navigation and data visualization cards"
**Backend Switching**:
- Start with Codex for analysis, switch to Claude for documentation, then Gemini for UI implementation
- Use per-task backend selection in parallel mode to optimize for each task's strengths
## Parameters ## Parameters
- `task` (required): Task description, supports `@file` references - `task` (required): Task description, supports `@file` references
- `working_dir` (optional): Working directory (default: current) - `working_dir` (optional): Working directory (default: current)
- `--backend` (optional): Select AI backend (codex/claude/gemini, default: codex) - `--backend` (optional): Select AI backend (codex/claude/gemini, default: codex)
- **Note**: Claude backend defaults to `--dangerously-skip-permissions` for automation compatibility - **Note**: Claude backend only adds `--dangerously-skip-permissions` when explicitly enabled
## Return Format ## Return Format
@@ -77,11 +101,12 @@ EOF
## Parallel Execution ## Parallel Execution
**With global backend**: **Default (summary mode - context-efficient):**
```bash ```bash
codeagent-wrapper --parallel --backend claude <<'EOF' codeagent-wrapper --parallel <<'EOF'
---TASK--- ---TASK---
id: task1 id: task1
backend: codex
workdir: /path/to/dir workdir: /path/to/dir
---CONTENT--- ---CONTENT---
task content task content
@@ -93,6 +118,17 @@ dependent task
EOF EOF
``` ```
**Full output mode (for debugging):**
```bash
codeagent-wrapper --parallel --full-output <<'EOF'
...
EOF
```
**Output Modes:**
- **Summary (default)**: Structured report with changes, output, verification, and review summary.
- **Full (`--full-output`)**: Complete task messages. Use only when debugging specific failures.
**With per-task backend**: **With per-task backend**:
```bash ```bash
codeagent-wrapper --parallel <<'EOF' codeagent-wrapper --parallel <<'EOF'
@@ -123,9 +159,9 @@ Set `CODEAGENT_MAX_PARALLEL_WORKERS` to limit concurrent tasks (default: unlimit
## Environment Variables ## Environment Variables
- `CODEX_TIMEOUT`: Override timeout in milliseconds (default: 7200000 = 2 hours) - `CODEX_TIMEOUT`: Override timeout in milliseconds (default: 7200000 = 2 hours)
- `CODEAGENT_SKIP_PERMISSIONS`: Control permission checks - `CODEAGENT_SKIP_PERMISSIONS`: Control Claude CLI permission checks
- For **Claude** backend: Set to `true`/`1` to **disable** `--dangerously-skip-permissions` (default: enabled) - For **Claude** backend: Set to `true`/`1` to add `--dangerously-skip-permissions` (default: disabled)
- For **Codex/Gemini** backends: Set to `true`/`1` to enable permission skipping (default: disabled) - For **Codex/Gemini** backends: Currently has no effect
- `CODEAGENT_MAX_PARALLEL_WORKERS`: Limit concurrent tasks in parallel mode (default: unlimited, recommended: 8) - `CODEAGENT_MAX_PARALLEL_WORKERS`: Limit concurrent tasks in parallel mode (default: unlimited, recommended: 8)
## Invocation Pattern ## Invocation Pattern
@@ -158,9 +194,8 @@ Bash tool parameters:
## Security Best Practices ## Security Best Practices
- **Claude Backend**: Defaults to `--dangerously-skip-permissions` for automation workflows - **Claude Backend**: Permission checks enabled by default
- To enforce permission checks with Claude: Set `CODEAGENT_SKIP_PERMISSIONS=true` - To skip checks: set `CODEAGENT_SKIP_PERMISSIONS=true` or pass `--skip-permissions`
- **Codex/Gemini Backends**: Permission checks enabled by default
- **Concurrency Limits**: Set `CODEAGENT_MAX_PARALLEL_WORKERS` in production to prevent resource exhaustion - **Concurrency Limits**: Set `CODEAGENT_MAX_PARALLEL_WORKERS` in production to prevent resource exhaustion
- **Automation Context**: This wrapper is designed for AI-driven automation where permission prompts would block execution - **Automation Context**: This wrapper is designed for AI-driven automation where permission prompts would block execution