Fix HIGH priority issues: unbounded queries, rate limiting, health checks
Issue #3 - Unbounded Query Results (OOM): - get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default) - Added 'id' field to records from get_archived_history() and get_archived_history_keyset() - Protocol signature updated with page_size, max_rows, last_ban_id params Issue #7 - Docker Health Check Fails: - Added curl to Dockerfile.backend runtime image - HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health' - compose.prod.yml: increased start_period to 40s, timeout to 10s - Frontend healthcheck proxies to backend /api/health Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1592,6 +1592,33 @@ except Exception as exc:
|
||||
raise
|
||||
```
|
||||
|
||||
### 7.6 Domain Model Pattern for Services
|
||||
|
||||
Services **return domain models** (frozen dataclasses), not Pydantic response models. Conversion to response models happens at the **router boundary**.
|
||||
|
||||
**Example (correct):**
|
||||
|
||||
```python
|
||||
# app/services/jail_service.py — returns domain model
|
||||
async def get_jail(socket_path: str, name: str) -> DomainJailDetail:
|
||||
...
|
||||
return DomainJailDetail(name=name, ...)
|
||||
|
||||
# app/routers/jails.py — converts at boundary
|
||||
@router.get("/{name}")
|
||||
async def get_jail(...) -> JailDetailResponse:
|
||||
domain = await jail_service.get_jail(socket_path, name)
|
||||
return jail_mappers.map_domain_jail_detail_to_response(domain)
|
||||
```
|
||||
|
||||
**When adding a new service:**
|
||||
1. Define domain model in `app/models/{domain}_domain.py` (frozen dataclass)
|
||||
2. Add mapper in `app/mappers/{domain}_mappers.py`: `map_domain_X_to_response(domain: DomainX) -> XResponse`
|
||||
3. Service returns domain model type
|
||||
4. Router calls mapper before returning
|
||||
|
||||
**Reference:** `ban_service.py` + `ban_mappers.py` is canonical example. See `Docs/DOMAIN_MODELS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
124
Docs/DOMAIN_MODELS.md
Normal file
124
Docs/DOMAIN_MODELS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Domain Models — Reference Guide
|
||||
|
||||
This document explains the domain model pattern used in BanGUI's backend and where to find examples.
|
||||
|
||||
---
|
||||
|
||||
## What Are Domain Models?
|
||||
|
||||
Domain models (e.g., `DomainActiveBanList`, `DomainJailConfig`) are **frozen dataclasses** that represent pure business logic. They are defined in `app/models/{domain}_domain.py` and are **returned by services**.
|
||||
|
||||
Response models (e.g., `ActiveBanListResponse`, `JailConfigResponse`) are **Pydantic models** defined in `app/models/{domain}.py`. They are used **only by routers** for HTTP serialization.
|
||||
|
||||
---
|
||||
|
||||
## Why This Separation?
|
||||
|
||||
```
|
||||
Service (returns domain model)
|
||||
↓
|
||||
Router (converts domain → response via mapper)
|
||||
↓
|
||||
HTTP Response (Pydantic model)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Domain logic evolves without affecting API shape
|
||||
- Services are reusable across different frontends (GraphQL, gRPC, CLI)
|
||||
- Testing is simpler (no Pydantic overhead)
|
||||
- Changes to endpoint responses don't require service changes
|
||||
|
||||
---
|
||||
|
||||
## Existing Domain Models
|
||||
|
||||
| Domain | Domain Model(s) | Mapper Module |
|
||||
|--------|----------------|---------------|
|
||||
| **Ban** | `DomainActiveBanList`, `DomainActiveBan`, `DomainBansByCountry` | `ban_mappers.py` |
|
||||
| **Jail** | `DomainJailList`, `DomainJailDetail`, `DomainJailBannedIps`, `DomainActiveBan` | `jail_mappers.py` |
|
||||
| **Config** | `DomainJailConfig`, `DomainJailConfigList`, `DomainGlobalConfig`, `DomainServiceStatus`, `DomainBantimeEscalation`, `DomainFilterConfig`, `DomainFilterList`, `DomainRegexTest`, `DomainMapColorThresholds` | `config_mappers.py` |
|
||||
| **History** | `DomainHistoryList`, `DomainHistoryBanItem`, `DomainIpDetail`, `DomainIpTimelineEvent` | `history_mappers.py` |
|
||||
| **Server** | `DomainServerSettings`, `DomainServerSettingsResult` | `server_mappers.py` |
|
||||
| **Blocklist** | `DomainBlocklistSource`, `DomainImportLogEntry`, `DomainImportLogList`, `DomainImportSourceResult`, `DomainImportRunResult`, `DomainPreviewResult`, `DomainScheduleConfig`, `DomainScheduleInfo` | `blocklist_mappers.py` |
|
||||
|
||||
---
|
||||
|
||||
## The Pattern — Step by Step
|
||||
|
||||
### Step 1: Define Domain Model in `app/models/{domain}_domain.py`
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainJailConfig:
|
||||
"""Configuration snapshot of a single jail (domain model)."""
|
||||
|
||||
name: str
|
||||
ban_time: int
|
||||
max_retry: int
|
||||
find_time: int
|
||||
fail_regex: list[str]
|
||||
actions: list[str] # ← no default BEFORE default = FIELD ORDER ERROR
|
||||
date_pattern: str | None = None # ← all fields with defaults come AFTER
|
||||
log_encoding: LogEncoding = "UTF-8"
|
||||
```
|
||||
|
||||
**⚠️ Field Order Rule:** All fields without defaults must appear before all fields with defaults.
|
||||
|
||||
### Step 2: Add Mapper in `app/mappers/{domain}_mappers.py`
|
||||
|
||||
```python
|
||||
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
|
||||
"""Convert domain jail config to response model."""
|
||||
return JailConfig(
|
||||
name=domain.name,
|
||||
ban_time=domain.ban_time,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3: Service Returns Domain Model
|
||||
|
||||
```python
|
||||
# In app/services/jail_service.py
|
||||
from app.models.config_domain import DomainJailConfig, DomainJailConfigList
|
||||
|
||||
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
|
||||
...
|
||||
return DomainJailConfig(...) # ← return domain model
|
||||
```
|
||||
|
||||
### Step 4: Router Uses Mapper at Boundary
|
||||
|
||||
```python
|
||||
# In app/routers/jail_config.py
|
||||
from app.mappers import config_mappers
|
||||
|
||||
@router.get("/{name}", response_model=JailConfigResponse)
|
||||
async def get_jail_config(...) -> JailConfigResponse:
|
||||
domain_result = await config_service.get_jail_config(socket_path, name)
|
||||
return config_mappers.map_domain_jail_config_to_response(domain_result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
`ban_service.py` + `ban_mappers.py` is the canonical example of the correct pattern. Study it first when adding a new service.
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Field Ordering Error
|
||||
|
||||
```
|
||||
TypeError: non-default argument 'actions' follows default argument
|
||||
```
|
||||
|
||||
**Fix:** Move all fields with defaults (`field: T | None = None`) after all fields without defaults.
|
||||
|
||||
### Forgetting the Mapper
|
||||
|
||||
If you refactor a service to return a domain model but forget to update the router, you'll get a type mismatch at the boundary. Always update router + service together.
|
||||
@@ -124,6 +124,31 @@ Check logs for these key events:
|
||||
|
||||
If duplication occurs frequently, consider migrating to Redis-backed locking (see Advanced section below) for higher reliability.
|
||||
|
||||
### Troubleshooting: "Scheduler stops completely"
|
||||
|
||||
**Symptom:** Background tasks (blocklist import, geo cache cleanup, history sync, session cleanup) stop running. No errors in logs but tasks don't execute.
|
||||
|
||||
**Cause:** Instance holding the scheduler lock crashed without releasing it, or heartbeat is failing silently.
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
1. Check if lock exists: `SELECT * FROM scheduler_lock;`
|
||||
2. If lock exists with a PID that no longer runs, it's orphaned
|
||||
3. Check logs for `scheduler_lock_heartbeat_lost` warnings
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Clear the orphaned lock:** `DELETE FROM scheduler_lock;`
|
||||
2. **Restart the instance** that should hold the lock
|
||||
3. Verify lock acquisition: `grep "scheduler_lock_acquired" logs`
|
||||
4. If heartbeat keeps failing, check database latency (SQLite heartbeats should be <100ms)
|
||||
|
||||
**Prevention:**
|
||||
|
||||
- Monitor `scheduler_lock_heartbeat_lost` events — more than 3 in an hour indicates a problem
|
||||
- Ensure database I/O is not bottlenecked (SSD recommended for SQLite)
|
||||
- Consider reducing heartbeat interval if network latency causes false timeouts
|
||||
|
||||
### Advanced: Migrating to Redis
|
||||
|
||||
For very high-traffic deployments with strict data consistency requirements, you can replace the SQLite-backed lock with Redis:
|
||||
|
||||
@@ -678,6 +678,63 @@ Planned observability improvements:
|
||||
|
||||
---
|
||||
|
||||
## Scheduler Lock Health Monitoring
|
||||
|
||||
The scheduler lock ensures only one instance runs background tasks. Monitoring its health is critical for production reliability.
|
||||
|
||||
### Key Metrics
|
||||
|
||||
Monitor these log events for scheduler lock health:
|
||||
|
||||
| Event | Level | Meaning |
|
||||
|-------|-------|---------|
|
||||
| `scheduler_lock_acquired` | info | Successfully acquired the scheduler lock |
|
||||
| `scheduler_lock_held_by_other_instance` | warning | Another instance holds the lock (expected during normal multi-instance operation) |
|
||||
| `scheduler_lock_stale_overwrite` | info | Took over a stale lock from a crashed instance |
|
||||
| `scheduler_lock_heartbeat_lost` | warning | Heartbeat update failed; we lost the lock |
|
||||
| `scheduler_lock_release_mismatch` | warning | Release attempted but we don't hold the lock |
|
||||
|
||||
### Lock Health Check
|
||||
|
||||
Query current lock status via `get_lock_health()`:
|
||||
|
||||
```python
|
||||
from app.utils.scheduler_lock import get_lock_health
|
||||
|
||||
health = await get_lock_health(db)
|
||||
# Returns: {"locked": bool, "pid": int|None, "hostname": str|None,
|
||||
# "age_seconds": float|None, "is_stale": bool, "ttl_remaining": float|None}
|
||||
```
|
||||
|
||||
### Alerting Rules
|
||||
|
||||
**Critical alerts:**
|
||||
- `scheduler_lock_acquired` not seen for >5 minutes during startup → Instance may not have acquired lock
|
||||
- `scheduler_lock_heartbeat_lost` repeated >3 times → Lock keeps being stolen, possible contention issue
|
||||
|
||||
**Warning alerts:**
|
||||
- `scheduler_lock_held_by_other_instance` every few minutes → Normal if multiple instances, abnormal if single instance
|
||||
|
||||
### Database Query
|
||||
|
||||
Check lock state directly in SQLite:
|
||||
|
||||
```sql
|
||||
SELECT pid, hostname, heartbeat_at, heartbeat_timeout,
|
||||
(datetime('now') - datetime(heartbeat_at, 'unixepoch')) as age
|
||||
FROM scheduler_lock WHERE id = 1;
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Lock not acquired on startup**: Check logs for `scheduler_lock_held_by_other_instance`. If another instance holds it, verify if that instance is healthy.
|
||||
|
||||
2. **Background tasks not running**: Use `get_lock_health()` to verify the lock is held. If not held, the instance cannot run scheduled tasks.
|
||||
|
||||
3. **Frequent lock steals**: If `scheduler_lock_stale_overwrite` occurs frequently, the heartbeat interval may be too long or network latency is causing false staleness detection.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [structlog Documentation](https://www.structlog.org/)
|
||||
|
||||
115
Docs/TROUBLESHOOTING.md
Normal file
115
Docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Scheduler Lock Issues
|
||||
|
||||
### Lock Held by Crashed Instance (Orphaned Lock)
|
||||
|
||||
**Symptom:** Background tasks stop running. Logs show `scheduler_lock_held_by_other_instance` but no other instance is running.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
|
||||
```
|
||||
|
||||
If `heartbeat_at` is older than 5 minutes and the PID no longer exists, the lock is orphaned.
|
||||
|
||||
**Recovery:**
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
|
||||
```
|
||||
|
||||
Restart the backend. It will acquire the lock fresh.
|
||||
|
||||
**Prevention:**
|
||||
- Monitor `scheduler_lock_heartbeat_lost` events in logs
|
||||
- If >3 occurrences per hour, investigate database I/O performance
|
||||
|
||||
---
|
||||
|
||||
### Two Instances Both Running Scheduler
|
||||
|
||||
**Symptom:** Duplicate blocklist imports, duplicate geo cache cleanups, or duplicate history syncs.
|
||||
|
||||
**Cause:** Both instances believe they hold the lock.
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check which instance holds the lock: `SELECT pid, hostname FROM scheduler_lock;`
|
||||
2. Compare with running processes: `ps aux | grep bangui`
|
||||
|
||||
**Solution:**
|
||||
1. Stop one instance immediately
|
||||
2. Clear lock: `DELETE FROM scheduler_lock;`
|
||||
3. Restart the remaining instance
|
||||
|
||||
**Prevention:**
|
||||
- Ensure only one instance starts before heartbeat begins
|
||||
- Check `BANGUI_SINGLE_INSTANCE=true` is set if single-instance operation is required
|
||||
|
||||
---
|
||||
|
||||
### Heartbeat Update Failures
|
||||
|
||||
**Symptom:** Logs show `scheduler_lock_heartbeat_lost` repeatedly, then lock is lost.
|
||||
|
||||
**Cause:** Database writes failing or extremely slow (>5 seconds per write).
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
time sqlite3 /var/lib/bangui/bangui.db "UPDATE scheduler_lock SET heartbeat_at = unixepoch();"
|
||||
```
|
||||
|
||||
If this takes >1 second, database I/O is degraded.
|
||||
|
||||
**Solution:**
|
||||
1. Check disk health: `sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"`
|
||||
2. Move database to faster storage (SSD)
|
||||
3. Check for other I/O bottlenecks on the host
|
||||
|
||||
---
|
||||
|
||||
### Lock Not Acquired at Startup
|
||||
|
||||
**Symptom:** Instance fails to start with error "Could not acquire scheduler lock".
|
||||
|
||||
**Cause:** Another instance already holds the lock and appears healthy.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
|
||||
ps aux | grep <pid>
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
- If other instance is healthy and should run scheduler: this instance must wait
|
||||
- If other instance is crashed: `DELETE FROM scheduler_lock;` then restart this instance
|
||||
- If running single instance: ensure no other instances are running before startup
|
||||
|
||||
---
|
||||
|
||||
## General Recovery Commands
|
||||
|
||||
Clear all locks:
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
|
||||
```
|
||||
|
||||
Check lock status:
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "SELECT * FROM scheduler_lock;"
|
||||
```
|
||||
|
||||
Verify database integrity:
|
||||
```bash
|
||||
sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If issues persist after following this guide:
|
||||
|
||||
1. Enable debug logging: `BANGUI_LOG_LEVEL=debug`
|
||||
2. Collect logs around the failure time
|
||||
3. Check `Docs/Deployment.md` for configuration guidance
|
||||
4. Check `Docs/Observability.md` for monitoring setup
|
||||
@@ -1,90 +1,3 @@
|
||||
### Issue #1: CRITICAL - Services Return Response Models Instead of Domain Models
|
||||
|
||||
**Where found**:
|
||||
- `backend/app/services/jail_service.py` (list_jails, get_jail)
|
||||
- `backend/app/services/server_service.py` (get_settings)
|
||||
- `backend/app/services/config_service.py` (get_jail_config, get_global_config)
|
||||
- `backend/app/services/history_service.py` (list_history, get_ip_detail)
|
||||
- `backend/app/services/filter_config_service.py` (list_filters)
|
||||
- `backend/app/services/blocklist_service.py` (preview_blocklist)
|
||||
- `backend/app/services/health_service.py` (get_service_status)
|
||||
- `backend/app/repositories/protocols.py` (service interface definitions)
|
||||
|
||||
**Why this is needed**:
|
||||
The documented architecture explicitly states: "Services return **domain models** (e.g., `DomainActiveBanList`) that represent pure business logic. Response models are defined in `app/models/` and used only by routers. Conversion happens at the **router boundary**." This violates the principle that "Domain logic can evolve without affecting API shape."
|
||||
|
||||
**Goal**:
|
||||
Refactor all services to return domain models instead of response models. This enables services to be reusable across different frontends (GraphQL, gRPC, CLI), makes testing simpler, and decouples HTTP concerns from business logic.
|
||||
|
||||
**What to do**:
|
||||
1. Create `app/models/{domain}_domain.py` for each domain (config, jail, history, server, health)
|
||||
2. Create `app/mappers/{domain}_mappers.py` with conversion functions
|
||||
3. Refactor each service to return domain models
|
||||
4. Update routers to use mappers at the boundary: `domain_result = await service(...); return map_domain_to_response(domain_result)`
|
||||
5. Update service protocols to specify domain model returns
|
||||
6. Update service tests to work with simple domain objects
|
||||
|
||||
**Possible traps and issues**:
|
||||
- Services like `ban_service` already implement this correctly (with `ban_domain.py` and `ban_mappers.py`). Use this as template to avoid inconsistency.
|
||||
- Frontend tests that mock services will need updates if mocking response models
|
||||
- Existing code that imports response models from services will break and need refactoring
|
||||
- Performance might change if mappers do additional transformations (profile and optimize)
|
||||
|
||||
**Docs changes needed**:
|
||||
- Update `Docs/Architekture.md` section 2.2 to clarify domain vs response model distinction with clear examples
|
||||
- Update service development guidelines in `Docs/Backend-Development.md` with step-by-step guide for adding new services
|
||||
- Create `Docs/DOMAIN_MODELS.md` explaining the pattern and where to find examples
|
||||
|
||||
**Doc references**:
|
||||
- `Docs/Architekture.md` - Section 2.2 "Module Purposes - Services"
|
||||
- ARCHITECTURE_REVIEW.md - Section "CRITICAL: Services Return Response Models"
|
||||
- DETAILED_FINDINGS.md - (implicit in architecture violation)
|
||||
|
||||
---
|
||||
|
||||
### Issue #2: CRITICAL - Scheduler Lock Race Condition (Background Tasks Could Permanently Stop)
|
||||
|
||||
**Where found**:
|
||||
- `backend/app/utils/scheduler_lock.py` (lines 114+)
|
||||
- `backend/app/tasks/` (all background job registration)
|
||||
- `backend/app/startup.py` (scheduler initialization)
|
||||
|
||||
**Why this is needed**:
|
||||
The current scheduler lock implementation can leave an orphaned lock if the heartbeat task crashes. When this happens, the second instance cannot acquire the scheduler role, and ALL background tasks (blocklist imports, geo cache cleanup, history sync, session cleanup) stop permanently. This is a critical production issue.
|
||||
|
||||
**Goal**:
|
||||
Implement atomic lock acquisition with timeout-based expiration, ensuring that dead processes don't block the entire scheduler system.
|
||||
|
||||
**What to do**:
|
||||
1. Add `heartbeat_timeout` to `scheduler_lock` table (default 5 minutes)
|
||||
2. Update `acquire_lock()` to use `INSERT ... ON CONFLICT` with `BEGIN IMMEDIATE` transaction
|
||||
3. Add logic to steal lock if previous holder's heartbeat is stale
|
||||
4. Add heartbeat update with error handling (don't crash if update fails)
|
||||
5. Add monitoring to detect stuck locks
|
||||
6. Write tests for:
|
||||
- Concurrent lock acquisition attempts
|
||||
- Orphaned lock recovery
|
||||
- Heartbeat update failures
|
||||
|
||||
**Possible traps and issues**:
|
||||
- Must use explicit transactions (`BEGIN IMMEDIATE`) to avoid race conditions in SQLite
|
||||
- Heartbeat update timing is critical: too short = false positives, too long = stale locks
|
||||
- Multiple processes might try to steal lock simultaneously - need atomic operation
|
||||
- Database lock contention on scheduler_lock table during high load
|
||||
- Clock skew between servers could cause lock expiration issues (use DB time, not system time)
|
||||
|
||||
**Docs changes needed**:
|
||||
- Add section to `Docs/Observability.md` on monitoring scheduler lock health
|
||||
- Document in `Docs/Deployment.md` what to do if scheduler stops
|
||||
- Add error recovery procedures to `Docs/TROUBLESHOOTING.md`
|
||||
|
||||
**Doc references**:
|
||||
- DETAILED_FINDINGS.md - Issue #11 "Scheduler Lock Race Condition"
|
||||
- `backend/app/utils/scheduler_lock.py` - Current implementation
|
||||
- `backend/app/startup.py` (lines 89-103) - Startup validation
|
||||
|
||||
---
|
||||
|
||||
## HIGH PRIORITY ISSUES
|
||||
|
||||
---
|
||||
@@ -1630,4 +1543,4 @@ Enforce minimum test coverage.
|
||||
- Migration history
|
||||
|
||||
**References**:
|
||||
- DATABASE_API_DEPLOYMENT_ISSUES.md - Issue "1 Database Design"
|
||||
- DATABASE_API_DEPLOYMENT_ISSUES.md - Issue "1 Database Design"
|
||||
@@ -101,7 +101,8 @@ for (int i = 0; i < items.Count; i++)
|
||||
Console.WriteLine();
|
||||
|
||||
// Step 1 — run the task prompt
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"read ./Docs/Instructions.md. {item}");
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/Instructions.md. {item}");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 2 — confirm completion in the same chat session
|
||||
@@ -121,7 +122,9 @@ for (int i = 0; i < items.Count; i++)
|
||||
|
||||
// Step 4 — commit the work
|
||||
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
|
||||
await RunCopilot(Enumerable.Empty<string>(), "make git commit");
|
||||
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, "make git commit");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 5 — remove completed task from Tasks.md
|
||||
|
||||
Reference in New Issue
Block a user