feat(dispatcher): enforce depends_on with .blocked marker (Phase 2/G2)

Before claiming a session for a task, the dispatcher now:
1. Parses the task's frontmatter
2. If `depends_on: [project:task_id]` is non-empty, checks each entry
   against `<projectsDir>/<project>/.agent-queue/done/<task_id>.md`
3. If any dep is unresolved -> skip the task and write
   `<task>.md.blocked` next to it. The watchdog (G1) will resolve
   this marker on its next tick.

The `.blocked` marker is idempotent: re-running the dispatcher does not
refresh its mtime, so the watchdog can compute the blocked-since
timestamp from the FIRST detection (timeout precision).

Path-traversal hardening: project / task_id segments must match
`[A-Za-z0-9._-]+` and cannot be `.` or `..`. A malicious frontmatter
like `depends_on: ../../tmp:foo` is rejected before any filesystem
lookup.

assignNextTask (the doneChan path) applies the same gate so that a
session freed mid-cycle cannot bypass enforcement.

Tests (-race clean):
- DependsOnUnresolved -> .blocked marker, no dispatch
- DependsOnResolved -> normal dispatch, no marker
- PartialResolution -> stay blocked
- RejectPathTraversal -> blocked, not dispatched
- BlockedMarker idempotent (mtime stable across passes)
- NoDependsOn regression guard
This commit is contained in:
Ubuntu 2026-04-16 20:30:17 +00:00
parent 47ab86eef9
commit 5cfb58c202
3 changed files with 439 additions and 9 deletions

View file

@ -1,4 +1,31 @@
# Version actuelle : 0.3.8
# Version actuelle : 0.4.0
## [0.4.0] - 2026-04-16
**Type:** Minor — Phase 2 / Chantier G2 : dispatcher enforce depends_on
### Ajouté
- `internal/dispatcher/dispatcher.go` : champ `DependsOn []string` ajouté à
`TaskFrontmatter` et nouvelles méthodes `taskBlocked`, `dependencyResolved`,
`touchBlockedMarker`, `isSafeSegment`.
- Avant de claim une session pour une tâche, le dispatcher parse le
frontmatter. Si `depends_on: [project:task_id]` est non vide ET non résolu
(sibling task pas encore dans `<projectsDir>/<project>/.agent-queue/done/`),
la tâche est sautée et un fichier `<task>.md.blocked` est posé à côté.
- Le marker `.blocked` est idempotent : sa mtime n'est PAS rafraîchie aux
passes suivantes pour que le watchdog (G1) puisse compter le timeout
depuis la première détection.
- Sécurité : segments `project` / `task_id` validés (`[A-Za-z0-9._-]+`,
refus de `..`, `/`, NUL) avant d'être concaténés au filesystem path.
- `assignNextTask` (chemin du session done-channel) applique la même règle.
### Tests
- `TestDispatcher_DependsOnUnresolved_DropsBlockedMarker`
- `TestDispatcher_DependsOnResolved_Dispatches`
- `TestDispatcher_DependsOn_PartialResolution`
- `TestDispatcher_DependsOn_RejectPathTraversal`
- `TestDispatcher_BlockedMarker_Idempotent`
- `TestDispatcher_NoDependsOn_DispatchesNormally` (regression guard)
- `-race` clean.
## [0.3.8] - 2026-04-16
**Type:** Patch — Bug #1 (A3 flip+ensure inconsistency) + Bug #10 (requiredShared contract test)