mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-11 03:23:50 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d27d44676 | ||
|
|
6a66c9741f | ||
|
|
a09c103cfb | ||
|
|
1dec763e26 | ||
|
|
f57ea2df59 | ||
|
|
d215c33549 | ||
|
|
b3f8fcfea6 | ||
|
|
806bb04a35 |
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@@ -97,6 +97,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
- name: Prepare release files
|
- name: Prepare release files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
@@ -104,32 +109,20 @@ 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
|
- name: Generate release notes with git-cliff
|
||||||
id: extract_notes
|
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF#refs/tags/v}
|
# Install git-cliff via npx
|
||||||
|
npx git-cliff@latest --current --strip all -o release_notes.md
|
||||||
|
|
||||||
# Extract version section from CHANGELOG.md
|
# Fallback if generation failed
|
||||||
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
|
if [ ! -s release_notes.md ]; then
|
||||||
echo "⚠️ No release notes found in CHANGELOG.md for version $VERSION" > release_notes.md
|
echo "⚠️ Failed to generate release notes with git-cliff" > release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "## What's Changed" >> release_notes.md
|
echo "## What's Changed" >> release_notes.md
|
||||||
echo "See commits in this release for details." >> release_notes.md
|
echo "See commits in this release for details." >> release_notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "--- Generated Release Notes ---"
|
||||||
cat release_notes.md
|
cat release_notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|||||||
268
CHANGELOG.md
268
CHANGELOG.md
@@ -1,129 +1,209 @@
|
|||||||
# 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
|
### 🐛 Bug Fixes
|
||||||
- **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)
|
- *(executor)* Isolate log files per task in parallel mode
|
||||||
- **Renamed**: `codex-wrapper` → `codeagent-wrapper` with pluggable backend architecture
|
- *(codeagent)* 防止 Claude backend 无限递归调用
|
||||||
- **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
|
### ⚙️ Miscellaneous Tasks
|
||||||
- **`/dev`**: 6-step minimal dev workflow with mandatory 90% test coverage
|
|
||||||
|
|
||||||
#### Hooks System
|
- Bump version to 5.2.4
|
||||||
- **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
|
## [5.2.3] - 2025-12-15
|
||||||
- **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`
|
- *(parser)* 修复 bufio.Scanner token too long 错误 (#64)
|
||||||
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output
|
|
||||||
- **Streaming Output**: `op_run_command` streams bash script execution
|
### 🧪 Testing
|
||||||
- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json`
|
|
||||||
|
- 同步测试中的版本号至 5.2.3
|
||||||
|
|
||||||
|
## [5.2.2] - 2025-12-13
|
||||||
|
|
||||||
|
### 🧪 Testing
|
||||||
|
|
||||||
|
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
|
||||||
|
|
||||||
|
### ⚙️ Miscellaneous Tasks
|
||||||
|
|
||||||
|
- *(v5.2.2)* Bump version and clean up documentation
|
||||||
|
|
||||||
|
## [5.2.0] - 2025-12-13
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- *(dev-workflow)* 替换 Codex 为 codeagent 并添加 UI 自动检测
|
||||||
|
- *(codeagent-wrapper)* 完整多后端支持与安全优化
|
||||||
|
- *(install)* 添加终端日志输出和 verbose 模式
|
||||||
|
- *(v5.2.0)* Improve release notes and installation scripts
|
||||||
|
- *(v5.2.0)* Complete skills system integration and config cleanup
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(merge)* 修复master合并后的编译和测试问题
|
||||||
|
- *(parallel)* 修复并行执行启动横幅重复打印问题
|
||||||
|
- *(ci)* 移除 .claude 配置文件验证步骤
|
||||||
|
- *(codeagent-wrapper)* 重构信号处理逻辑避免重复 nil 检查
|
||||||
|
- *(codeagent-wrapper)* 修复权限标志逻辑和版本号测试
|
||||||
|
- *(install)* Op_run_command 实时流式输出
|
||||||
|
- *(codeagent-wrapper)* 异常退出时显示最近错误信息
|
||||||
|
- *(codeagent-wrapper)* Remove binary artifacts and improve error messages
|
||||||
|
- *(codeagent-wrapper)* Use -r flag for claude backend resume
|
||||||
|
- *(install)* Clarify module list shows default state not enabled
|
||||||
|
- *(codeagent-wrapper)* Use -r flag for gemini backend resume
|
||||||
|
- *(codeagent-wrapper)* Add worker limit cap and remove legacy alias
|
||||||
|
- *(codeagent-wrapper)* Fix race condition in stdout parsing
|
||||||
|
|
||||||
|
### 🚜 Refactor
|
||||||
|
|
||||||
|
- *(pr-53)* 调整文件命名和技能定义
|
||||||
|
|
||||||
### 📚 Documentation
|
### 📚 Documentation
|
||||||
|
|
||||||
- `docs/architecture.md` (21KB): Architecture overview with ASCII diagrams
|
- *(changelog)* Remove GitHub workflow related content
|
||||||
- `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
|
### 🧪 Testing
|
||||||
|
|
||||||
#### codeagent-wrapper
|
- *(codeagent-wrapper)* 添加 ExtractRecentErrors 单元测试
|
||||||
- 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
|
### ⚙️ Miscellaneous Tasks
|
||||||
- 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
|
- *(v5.2.0)* Update CHANGELOG and remove deprecated test files
|
||||||
- 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
|
## [5.1.4] - 2025-12-09
|
||||||
- Reverted `skills/codex/SKILL.md` to `codex-wrapper` for backward compatibility
|
|
||||||
|
|
||||||
#### dev-workflow
|
### 🐛 Bug Fixes
|
||||||
- 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
|
- *(parallel)* 任务启动时立即返回日志文件路径以支持实时调试
|
||||||
|
|
||||||
#### New Environment Variables
|
## [5.1.3] - 2025-12-08
|
||||||
- `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
|
### 🐛 Bug Fixes
|
||||||
- `config.schema.json`: Added `op_merge_json` schema validation
|
|
||||||
|
|
||||||
### ⚠️ Breaking Changes
|
- *(test)* Resolve CI timing race in TestFakeCmdInfra
|
||||||
|
|
||||||
**codex-wrapper → codeagent-wrapper rename**
|
## [5.1.2] - 2025-12-08
|
||||||
|
|
||||||
**Migration**:
|
### 🐛 Bug Fixes
|
||||||
```bash
|
|
||||||
python3 install.py --module dev --force
|
|
||||||
```
|
|
||||||
|
|
||||||
**Backward Compatibility**: `codex-wrapper/main.go` provides compatibility entry point
|
- 修复channel同步竞态条件和死锁问题
|
||||||
|
|
||||||
### 📦 Installation
|
## [5.1.1] - 2025-12-08
|
||||||
|
|
||||||
```bash
|
### 🐛 Bug Fixes
|
||||||
# Install dev module
|
|
||||||
python3 install.py --module dev
|
|
||||||
|
|
||||||
# List all modules
|
- *(test)* Resolve data race on forceKillDelay with atomic operations
|
||||||
python3 install.py --list-modules
|
- 增强日志清理的安全性和可靠性
|
||||||
|
|
||||||
# Verbose logging mode
|
### 💼 Other
|
||||||
python3 install.py --module dev --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🧪 Test Results
|
- Resolve signal handling conflict preserving testability and Windows support
|
||||||
|
|
||||||
✅ **All tests passing**
|
### 🧪 Testing
|
||||||
- Overall coverage: 93.4%
|
|
||||||
- Security scan: 0 issues (gosec)
|
|
||||||
- Linting: Pass
|
|
||||||
|
|
||||||
### 📄 Related PRs & Issues
|
- 补充测试覆盖提升至 89.3%
|
||||||
|
|
||||||
- PR #53: Enterprise Workflow with Multi-Backend Support
|
## [5.1.0] - 2025-12-07
|
||||||
- Issue #55: Installation script execution not visible
|
|
||||||
- Issue #56: Unfriendly error logging on abnormal exit
|
|
||||||
|
|
||||||
### 👥 Contributors
|
### 🚀 Features
|
||||||
|
|
||||||
- Claude Sonnet 4.5
|
- Implement enterprise workflow with multi-backend support
|
||||||
- Claude Opus 4.5
|
- *(cleanup)* 添加启动时清理日志的功能和--cleanup标志支持
|
||||||
- SWE-Agent-Bot
|
|
||||||
|
## [5.0.0] - 2025-12-05
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Implement modular installation system
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(codex-wrapper)* Defer startup log until args parsed
|
||||||
|
|
||||||
|
### 🚜 Refactor
|
||||||
|
|
||||||
|
- Remove deprecated plugin modules
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Rewrite documentation for v5.0 modular architecture
|
||||||
|
|
||||||
|
### ⚙️ Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Clarify unit-test coverage levels in requirement questions
|
||||||
|
|
||||||
|
## [4.8.2] - 2025-12-02
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(codex-wrapper)* Capture and include stderr in error messages
|
||||||
|
- Correct Go version in go.mod from 1.25.3 to 1.21
|
||||||
|
- Make forceKillDelay testable to prevent signal test timeout
|
||||||
|
- Skip signal test in CI environment
|
||||||
|
|
||||||
|
## [4.8.1] - 2025-12-01
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(codex-wrapper)* Improve --parallel parameter validation and docs
|
||||||
|
|
||||||
|
### 🎨 Styling
|
||||||
|
|
||||||
|
- *(codex-skill)* Replace emoji with text labels
|
||||||
|
|
||||||
|
## [4.7.3] - 2025-11-29
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Add async logging to temp file with lifecycle management
|
||||||
|
- Add parallel execution support to codex-wrapper
|
||||||
|
- Add session resume support and improve output format
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(logger)* 保留日志文件以便程序退出后调试并完善日志输出功能
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Improve codex skill parameter best practices
|
||||||
|
|
||||||
|
## [4.7.2] - 2025-11-28
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- *(main)* Improve buffer size and streamline message extraction
|
||||||
|
|
||||||
|
### 🧪 Testing
|
||||||
|
|
||||||
|
- *(ParseJSONStream)* 增加对超大单行文本和非字符串文本的处理测试
|
||||||
|
|
||||||
|
## [4.7] - 2025-11-27
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Update repository URLs to cexll/myclaude
|
||||||
|
|
||||||
|
## [4.4] - 2025-11-22
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- 支持通过环境变量配置 skills 模型
|
||||||
|
|
||||||
|
## [4.1] - 2025-11-04
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- 新增 /enhance-prompt 命令并更新所有 README 文档
|
||||||
|
|
||||||
|
## [3.1] - 2025-09-17
|
||||||
|
|
||||||
|
### 💼 Other
|
||||||
|
|
||||||
|
- Sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
|
||||||
|
|
||||||
|
<!-- generated by git-cliff -->
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -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"
|
||||||
|
|
||||||
|
|||||||
72
cliff.toml
Normal file
72
cliff.toml
Normal 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"
|
||||||
@@ -36,6 +36,10 @@ func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
|||||||
// args = append(args, "--dangerously-skip-permissions")
|
// args = append(args, "--dangerously-skip-permissions")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// Prevent infinite recursion: disable all setting sources (user, project, local)
|
||||||
|
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
|
||||||
|
args = append(args, "--setting-sources", "")
|
||||||
|
|
||||||
if cfg.Mode == "resume" {
|
if cfg.Mode == "resume" {
|
||||||
if cfg.SessionID != "" {
|
if cfg.SessionID != "" {
|
||||||
// Claude CLI uses -r <session_id> for resume.
|
// Claude CLI uses -r <session_id> for resume.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
|||||||
t.Run("new mode uses workdir without skip by default", func(t *testing.T) {
|
t.Run("new mode uses workdir without skip 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", "--dangerously-skip-permissions", "--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)
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
|||||||
t.Run("new mode opt-in skip permissions with default workdir", func(t *testing.T) {
|
t.Run("new mode opt-in skip permissions with default workdir", 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)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
|||||||
t.Run("resume mode uses session id and omits workdir", func(t *testing.T) {
|
t.Run("resume mode uses session id and omits workdir", 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", "--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)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ 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", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,25 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +169,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 +255,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) {
|
||||||
logger := activeLogger()
|
if logPath == "" {
|
||||||
if logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := logger.Path()
|
|
||||||
if path == "" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startPrintMu.Lock()
|
startPrintMu.Lock()
|
||||||
@@ -249,7 +264,7 @@ 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)
|
fmt.Fprintf(os.Stderr, "Task %s: Log: %s\n", taskID, logPath)
|
||||||
startPrintMu.Unlock()
|
startPrintMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,9 +334,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 taskLogger *Logger
|
||||||
|
var taskLogPath string
|
||||||
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}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -338,9 +355,20 @@ func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec
|
|||||||
logConcurrencyState("done", ts.ID, int(after), workerLimit)
|
logConcurrencyState("done", ts.ID, int(after), workerLimit)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ts.Context = ctx
|
if l, err := NewLoggerWithSuffix(ts.ID); err == nil {
|
||||||
printTaskStart(ts.ID)
|
taskLogger = l
|
||||||
resultsCh <- runCodexTaskFn(ts, timeout)
|
taskLogPath = l.Path()
|
||||||
|
defer func() { _ = taskLogger.Close() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.Context = withTaskLogger(ctx, taskLogger)
|
||||||
|
printTaskStart(ts.ID, taskLogPath)
|
||||||
|
|
||||||
|
res := runCodexTaskFn(ts, timeout)
|
||||||
|
if res.LogPath == "" && taskLogPath != "" {
|
||||||
|
res.LogPath = taskLogPath
|
||||||
|
}
|
||||||
|
resultsCh <- res
|
||||||
}(task)
|
}(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,14 +486,8 @@ 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 {
|
||||||
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,
|
||||||
@@ -521,17 +543,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 +569,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,8 +581,16 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +690,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()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,8 +787,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
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -15,6 +18,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 +85,7 @@ type execFakeRunner struct {
|
|||||||
stdout io.ReadCloser
|
stdout io.ReadCloser
|
||||||
process processHandle
|
process processHandle
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
|
dir string
|
||||||
waitErr error
|
waitErr error
|
||||||
waitDelay time.Duration
|
waitDelay time.Duration
|
||||||
startErr error
|
startErr error
|
||||||
@@ -117,7 +127,7 @@ 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) Process() processHandle {
|
func (f *execFakeRunner) Process() processHandle {
|
||||||
if f.process != nil {
|
if f.process != nil {
|
||||||
return f.process
|
return f.process
|
||||||
@@ -149,6 +159,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()
|
||||||
@@ -421,6 +435,63 @@ 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("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 +506,476 @@ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorSignalAndTermination(t *testing.T) {
|
func TestExecutorSignalAndTermination(t *testing.T) {
|
||||||
forceKillDelay.Store(0)
|
forceKillDelay.Store(0)
|
||||||
defer forceKillDelay.Store(5)
|
defer forceKillDelay.Store(5)
|
||||||
|
|||||||
158
codeagent-wrapper/logger_additional_coverage_test.go
Normal file
158
codeagent-wrapper/logger_additional_coverage_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
codeagent-wrapper/logger_suffix_test.go
Normal file
80
codeagent-wrapper/logger_suffix_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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 TestLoggerWithSuffixReturnsErrorWhenTempDirMissing(t *testing.T) {
|
||||||
|
missingTempDir := filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
setTempDirEnv(t, missingTempDir)
|
||||||
|
|
||||||
|
logger, err := NewLoggerWithSuffix("task-err")
|
||||||
|
if err == nil {
|
||||||
|
_ = logger.Close()
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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{}),
|
||||||
@@ -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,7 +891,7 @@ 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
|
content string
|
||||||
@@ -846,21 +968,21 @@ 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)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
version = "5.2.3"
|
version = "5.2.4"
|
||||||
defaultWorkdir = "."
|
defaultWorkdir = "."
|
||||||
defaultTimeout = 7200 // seconds
|
defaultTimeout = 7200 // seconds
|
||||||
codexLogLineLimit = 1000
|
codexLogLineLimit = 1000
|
||||||
|
|||||||
@@ -426,10 +426,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,7 +1378,7 @@ 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", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("length mismatch")
|
t.Fatalf("length mismatch")
|
||||||
}
|
}
|
||||||
@@ -1398,7 +1399,7 @@ 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"}
|
expectedPrefix := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose"}
|
||||||
|
|
||||||
if len(args) != len(expectedPrefix)+1 {
|
if len(args) != len(expectedPrefix)+1 {
|
||||||
t.Fatalf("args length=%d, want %d", len(args), len(expectedPrefix)+1)
|
t.Fatalf("args length=%d, want %d", len(args), len(expectedPrefix)+1)
|
||||||
@@ -2690,7 +2691,7 @@ 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.3\n"
|
want := "codeagent-wrapper version 5.2.4\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 +2705,7 @@ 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.3\n"
|
want := "codeagent-wrapper version 5.2.4\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 +2719,7 @@ 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.3\n"
|
want := "codex-wrapper version 5.2.4\n"
|
||||||
if output != want {
|
if output != want {
|
||||||
t.Fatalf("output = %q, want %q", output, want)
|
t.Fatalf("output = %q, want %q", output, want)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user