Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions FOLLOWUPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,36 @@ initial panel plus two fix rounds; do not start a fourth panel cycle for these n
No residual acceptance or merge authority is recorded here. The merge decision still
belongs to the operator's governed path. Windows visible-window acceptance and effective
hook migration remain separate from the green portability tests.

## Fleet task coordination residuals (PR #288, 2026-09-08)

Final panel reviewed code head `9a243af21bd7f72f94e5c5af0e7bad0e4d60b1fd`.
Three cycles completed; AGENTS.md Review-cycle discipline requires residual P2s
and nits to be recorded for judgment rather than another panel loop. These are
proposed deferrals, not accepted risk or merge permission. No installation.

- **P2, Windows branch spelling:** CmdRequest retains supplied spelling rather
than the canonical branch spelling resolved by Git. On case-insensitive Windows
ref lookup, Task can resolve task but compare ownership/activity under a different
key. Fix canonical identity while retaining deleted-branch replay behavior and
add Windows coverage before portability claims. Normal hook effects remain
subject to their own branch guard; request itself grants no lease or execution.
Source: Codex review comment 3954511775. Windows live parity remains unproved.
- **P2, incomplete request evidence:** strictDispatchRows validates base dispatch
fields, not all request-specific types. Missing/invalid numeric at can default to
zero and admit old matching activity while complete remains true. Validate
request_id, worker and finite timestamps before deriving status/replaying.
Source: Codex review comment 3954511783. This is a status-integrity defect, not
evidence of acceptance or authority. Must be resolved or explicitly accepted
before treating the new board as operationally reliable.
- **P2 assessment, busy-store diagnostics:** ErrKeyBusy bubbles up as generic exit
4 in request/dispatch/reassign/undispatch rather than an actionable refusal.
The lock callback does not execute, so no conflicting write is authorized.
Normalize error classification and test contention response in a follow-on.
Source: Copilot review comment 3954505362 and its suppressed sibling comments.
- **Cosmetic:** scoped row loops retain redundant repo/branch/relationship tests.
Claude final review finds no blocking defects; leave this harmless redundancy.

Code verification: Fleet race tests, lint/vet, both harness regression suites and
full CI/fuzz/hygiene passed. Those checks do not cover or dismiss the residuals
above. No additional panel request should be sent for this PR under the current cap.
63 changes: 63 additions & 0 deletions WORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!-- reaper-work:v1 -->
# Work: Retry-safe Fleet assignments and truthful observation

Work-ID: fleet-task-coordination
Status: active
Subject: git:f1cea9a96ef77680ab3a6e05ed1614d6b8048e40
Stop-at: reviewed-change

## Outcome

A supervisor can retry an assignment without overwriting or duplicating work and
inspect hook-observed activity without claiming delivery, acceptance or termination.

## Preserve

- Existing branch leases, hooks, Gate authority and legacy dispatch behavior except
refusing uncorrelated replacement of a request-bound or unreadable assignment.
- Root cmd/triage/labels/mismatches.jsonl and earlier worktree friction logs are unrelated.
- One existing dispatch store and hook-owned facts; no second editable ledger.

## Change

- `FOLLOWUPS.md`: final-panel residuals and written deferral rationale.
- `cmd/fleet/internal/verbs/request.go`: immutable request IDs and serialized replay.
- `cmd/fleet/internal/verbs/role.go`: subscribe generated hooks to write events.
- `cmd/fleet/internal/verbs/role_hooks_test.go`: generated subscription and rebind coverage.
- `cmd/fleet/internal/verbs/status.go`: read-only plain-language request observations.
- `cmd/fleet/internal/verbs/work.go`: protect request records from legacy mutations.
- `cmd/fleet/internal/verbs/verbs.go`: command entrypoints before lazy migration.
- `cmd/fleet/internal/fleet/session.go`: merge per-branch observations under the session lock.
- `cmd/fleet/internal/codex/task_activity_test.go`: patch adapter lease and evidence proof.
- `cmd/fleet/internal/fleet/hook.go`: completed write-tool observation in session record.
- `cmd/fleet/internal/mcp/mcp.go`: equivalent request/status tool entrypoints.
- `cmd/fleet/internal/verbs/request_test.go`: replay, conflicts, races and read-only proof.
- `cmd/fleet/internal/fleet/task_activity_test.go`: post-tool activity provenance.
- `cmd/fleet/internal/mcp/task_test.go`: non-migrating observation through JSON-RPC.
- `cmd/fleet/README.md`: interface, current limits and next adapter proof.

## Prove

- Green: go test -race ./cmd/fleet/...; go vet ./cmd/fleet/...; golangci-lint run ./cmd/fleet/...
- Green: both cmd/fleet/testdata/run-suite.sh harness suites; focused real Git CLI exercise.
- Red: competing requests, changed replay, damaged evidence and late unrelated activity never yield false acceptance.

## Stop

- No live worker launches, stops, lease transfers or installed hook changes from this PR.
- Delivery, semantic acceptance, correlated answers and effect-safe replacement remain
follow-on implementation under tsk_01M1ZJVZ1ZDHJC1PR1AZGE47TC, not claimed complete.

## Evidence

- Verified: focused Fleet race tests, root Go vet/lint, and Claude regression scenarios pass.
- Verified: separate-process replay/conflict tests and Codex regression scenarios pass.
- Verified: incomplete MCP status remains parseable JSON; compiled-binary fixture smoke passes for both adapter shapes.
- Verified: initial-head full-module CI and all three configured reviewers completed.
- Verified: final code head 9a243af has green CI and completed three-member panel.
- Residual: FOLLOWUPS.md records Windows spelling, incomplete request evidence and lock diagnostics for judgment.

## Handoff

- Last: second panel consolidated; per-branch evidence, MultiEdit subscription and scoped legacy maintenance fixed.
- Next: judge recorded residuals; no more panel cycles, no merge or live installation. Broader adapter work remains open.
82 changes: 82 additions & 0 deletions cmd/fleet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Fleet task coordination: first implementation increment

This adds retry-safe local assignments and a read-only observation view. It does
not yet implement the four-interaction product: launch/delivery, semantic worker
acceptance, correlated questions, safe stop and replacement remain adapter work.
Do not activate a live trial or present this as cross-harness lifecycle parity.

The approved direction is [cc-skills PR #60](https://github.com/itsHabib/cc-skills/pull/60):
one lead, one active worker, task-owned workspace and natural interaction through
the supervisor skill. The interfaces below are for the supervisor/adapter, not a
set of commands the operator should have to learn.

## Record once, retry safely

From the task's repository checkout, with a known worker:

```sh
fleet request my-branch --id navigation-fix-1 --worker SESSION \
--for supervisor:ivy --brief 'Reproduce and fix the navigation failure; return focused checks.'
fleet status
fleet status --json
```

Use the discovered executable path if Fleet is not on PATH. The installed Mac
entrypoint during this build was `/Users/mh/.fleet/bin/fleet`; the new commands
are not available there until a reviewed release is installed.

Equivalent MCP tools are `fleet_request` (requires caller cwd) and `fleet_status`.
The supervisor chooses a stable request ID before calling. Same repo + same ID +
same branch/worker/lead/brief returns the existing assignment without renewing its
timestamp, resetting its initial head, posting a message or acquiring a lease.
Full-ID retries remain valid after branch deletion or session-record cleanup; a
short session prefix must still resolve uniquely. Supply a branch name, not a
numbered change. Changing the payload under that ID refuses. A second assignment for the same
branch refuses, as do unknown ownership, an unavailable worker and an applicable
stop flag. A recorded assignment is not an execution reservation; the ordinary
hook/lease guard still controls actual effects.

Records extend the existing `dispatch` row with `request_id` and `worker`.
One dispatch-store lock serializes decisions across processes, followed by the
existing branch lock for ownership inspection. Legacy dispatch/reassign/undispatch
cannot overwrite or delete these records, including with `--take`. They remain
retained until a correlated lifecycle operation is implemented. Do not remove
records manually to reuse IDs. Ordinary legacy records remain supported.

`request` is effectful and performs the existing lazy key migration before lease
inspection. Retained collisions refuse; failed requests can leave a migration
marker/lock but no new assignment. No GitHub write or worker launch occurs.

## Observe without claiming more than the evidence

`status` bypasses migration in both CLI and MCP and restores read-only mode after
rendering. JSON is `fleet-task-status-v1`, scoped to local request-bound assignments;
it is not the full portfolio inventory or permission to dispatch. `complete`
means the assignment sources were readable, not that every task is healthy.

- **Queued:** the assignment exists; delivery and acceptance are unconfirmed.
- **Activity observed:** the selected worker has a matching post-dispatch write-tool
event on the task branch. This is not a claim of successful edits or acceptance.
- **Status needs checking:** conflicting/unreadable ownership, stop flag or missing
worker liveness. A stopped/dead session does not establish command quiescence.

The existing hook-owned session record carries `last_writes`, keyed by branch,
with time and tool-use ID; `last_write` remains for compatibility. Writes on a
second branch do not erase the first branch observation. Read-only commands, old activity and another branch do not count.
JSON keeps IDs and evidence timestamps for debugging; terminal output does not
require the operator to interpret internal session IDs. No new agent-written
progress ledger, acceptance claim, done state or automatic takeover is introduced.

Generated role bindings subscribe Codex write events and supplement Claude
file-write post-tool events alongside its global Bash hook. Existing bindings
need regeneration and harness reload when this release is installed. This PR
does not edit installed hooks. Terminal observations include the activity age.

## Verification

Run `go test -race ./cmd/fleet/...`, `go vet ./cmd/fleet/...` and
`golangci-lint run ./cmd/fleet/...`, then both `testdata/run-suite.sh` variants
(default and `codex`). New tests cover real Git state, separate-process replay and
conflicts, immutable payloads, legacy-writer protection, damaged evidence,
post-tool provenance and non-migrating JSON-RPC observation. Harness event
fixtures are not proof of actual live Claude/Codex delivery or stop behavior.
48 changes: 48 additions & 0 deletions cmd/fleet/internal/codex/task_activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package codex

import (
"github.com/itsHabib/workbench/cmd/fleet/internal/fleet"
"os"
"path/filepath"
"testing"
)

func TestPatchAdapterEnforcesLeaseAndRecordsActivity(t *testing.T) {
oldState, oldOrg := fleet.State, fleet.OrgState
root := t.TempDir()
fleet.State, fleet.OrgState = filepath.Join(root, "state"), filepath.Join(root, "org")
t.Cleanup(func() { fleet.State, fleet.OrgState = oldState, oldOrg })
repo := filepath.Join(root, "repo")
if err := os.MkdirAll(filepath.Join(repo, ".git", "refs", "heads"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, ".git", "HEAD"), []byte("ref: refs/heads/task\n"), 0600); err != nil {
t.Fatal(err)
}
key := fleet.Scope(repo, "task")
if err := fleet.WriteJSON(fleet.Path("sessions", "holder.json"), fleet.Rec{"session": "holder", "pid": os.Getpid(), "pid_kind": "harness", "last_event_at": fleet.Now()}); err != nil {
t.Fatal(err)
}
if err := fleet.WriteLease(key, fleet.LeaseRecord(key, "holder", "", repo, nil)); err != nil {
t.Fatal(err)
}
ev := fleet.Event{"session_id": "worker", "cwd": repo, "hook_event_name": "PreToolUse", "tool_name": "apply_patch", "tool_input": fleet.Rec{"command": "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"}}
if v := Run(ev); v.Code != 2 {
t.Fatalf("patch escaped foreign lease: %v", v)
}
if fleet.S(fleet.Lease(key), "session") != "holder" {
t.Fatal("foreign lease changed")
}
ev["session_id"] = "holder"
if v := Run(ev); v.Code != 0 {
t.Fatal(v)
}
ev["hook_event_name"] = "PostToolUse"
if v := Run(ev); v.Code != 0 {
t.Fatal(v)
}
write := fleet.M(fleet.M(fleet.SessionRecord("holder"), "last_writes"), key)
if fleet.S(write, "key") != key || fleet.F(write, "at") == 0 {
t.Fatalf("patch observation absent: %v", write)
}
}
23 changes: 22 additions & 1 deletion cmd/fleet/internal/fleet/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ func recordInflight(ev Event, sid, cmd string) {
}

func onPostTool(ev Event, sid string) *Verdict {
rec := TouchSession(sid, ev, Rec{})
rec := TouchSession(sid, ev, postWriteEvidence(ev, sid))
cmd := S(M(ev, "tool_input"), "command")
if cmd != "" {
start := S(ev, "cwd")
Expand Down Expand Up @@ -452,3 +452,24 @@ func Exit(v *Verdict) {
}
os.Exit(v.Code)
}

// postWriteEvidence records observed tool activity, not acceptance or success.
// It is telemetry in the existing session record; missing evidence never permits
// a tool and does not imply that a queued assignment started.
func postWriteEvidence(ev Event, sid string) Rec {
tool := S(ev, "tool_name")
cmd := S(M(ev, "tool_input"), "command")
if !IsWrite(tool, cmd) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Subscribe write tools to post-tool activity

For Codex installations generated by fleet role, cmd/fleet/internal/verbs/role.go:140 registers PostToolUse only for ^Bash$. Consequently this new evidence path is never invoked for ordinary Edit/Write/apply_patch activity, so a Codex worker can modify the assigned branch while fleet status remains Queued. Broaden the installed post-tool matcher to include the write tools handled here.

Useful? React with 👍 / 👎.

return Rec{}
}
target := preToolTarget(ev, SessionRecord(sid), tool, cmd)
branch := BranchOf(target)
if branch == "" {
return Rec{}
}
key := Scope(target, branch)
if key == "" {
return Rec{}
}
return Rec{"last_write": Rec{"key": key, "at": Now(), "tool_use_id": ev["tool_use_id"]}}
}
10 changes: 10 additions & 0 deletions cmd/fleet/internal/fleet/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ func touchSessionLocked(sid string, ev Event, fields Rec) (Rec, error) {
pid, kind := HarnessPid(true)
rec["pid"], rec["pid_kind"] = float64(pid), kind
}
// Merge branch observations under the session lock so concurrent tools on
// different task branches cannot erase each other's evidence.
if write := M(fields, "last_write"); S(write, "key") != "" {
writes := M(rec, "last_writes")
if writes == nil {
writes = Rec{}
}
writes[S(write, "key")] = write
rec["last_writes"] = writes
}
for k, v := range fields {
rec[k] = v
}
Expand Down
39 changes: 39 additions & 0 deletions cmd/fleet/internal/fleet/task_activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package fleet

import (
"os"
"path/filepath"
"testing"
)

func TestPostWriteEvidenceOnlyObservedTarget(t *testing.T) {
oldState, oldOrg := State, OrgState
root := t.TempDir()
State, OrgState = filepath.Join(root, "fleet"), filepath.Join(root, "org")
t.Cleanup(func() { State, OrgState = oldState, oldOrg })
repo := filepath.Join(root, "repo")
if err := os.MkdirAll(filepath.Join(repo, ".git", "refs", "heads"), 0700); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(repo, ".git", "HEAD"), []byte("ref: refs/heads/task\n"), 0600)
ev := Event{"session_id": "worker", "cwd": repo, "hook_event_name": "PostToolUse", "tool_name": "Read", "tool_input": Rec{"file_path": filepath.Join(repo, "file")}}
if len(postWriteEvidence(ev, "worker")) != 0 {
t.Fatal("read counted as write activity")
}
ev["tool_name"] = "Edit"
got := postWriteEvidence(ev, "worker")
if S(M(got, "last_write"), "key") != Scope(repo, "task") || F(M(got, "last_write"), "at") == 0 {
t.Fatal(got)
}
// Actual hook persists this fact for both adapter faces; it is not an agent-written acceptance.
if v := Run(ev); v.Code != 0 {
t.Fatal(v)
}
if S(M(SessionRecord("worker"), "last_write"), "key") != Scope(repo, "task") {
t.Fatal(SessionRecord("worker"))
}
ev["tool_input"] = Rec{"file_path": filepath.Join(root, "outside")}
if len(postWriteEvidence(ev, "worker")) != 0 {
t.Fatal("outside target attributed to task")
}
}
24 changes: 23 additions & 1 deletion cmd/fleet/internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ var tools = []schema{
"for": str("accountable role; default: the dispatcher"), "due": str("duration like 45m or 2h"), "slot": str("free slot to place the work in (fleet_slots)"),
"brief": str("one line the slot's session reads at start"), "reply_to": str("your session id, handed to the seat as its address for questions"), "take": schema{"type": "boolean", "description": "rewrite a row that has live hands"}, "cwd": cwdArg},
"required": []any{"change", "as", "cwd"}}},
{"name": "fleet_request",
"description": "Record one retry-safe local assignment for a known worker; does not deliver, accept, launch or transfer a lease.",
"inputSchema": schema{"type": "object", "properties": schema{"change": str("branch"), "id": str("stable repo-scoped request ID"), "worker": str("known session ID or unique prefix"), "for": str("accountable lead"), "brief": str("bounded assignment"), "cwd": cwdArg}, "required": []any{"change", "id", "worker", "for", "brief", "cwd"}}},
{"name": "fleet_status", "description": "Read-only local request board; activity is not acceptance or completion.",
"inputSchema": schema{"type": "object", "properties": schema{}}},
{"name": "fleet_work",
"description": "Every ownership row on this machine with its observed state: dead, late, undeclared (need a decision); working, idle, dispatched, done.",
"inputSchema": schema{"type": "object", "properties": schema{"for": str("only rows this role is accountable for"), "cwd": cwdArg}}},
Expand Down Expand Up @@ -259,6 +264,10 @@ func dispatch(name string, a map[string]any) (string, bool) {
return runVerb(func() error {
return verbs.CmdDispatch(s("change"), s("as"), s("for"), s("due"), s("slot"), s("brief"), "mcp", s("reply_to"), take)
})
case "fleet_request":
return runVerb(func() error { return verbs.CmdRequest(s("change"), s("id"), s("worker"), s("for"), s("brief")) })
case "fleet_status":
return taskStatusJSON()
case "fleet_work":
return js(verbs.WorkRows(s("for"))), false
case "fleet_reassign":
Expand Down Expand Up @@ -299,7 +308,9 @@ func handle(msg map[string]any) map[string]any {
case "tools/call":
name, _ := params["name"].(string)
args, _ := params["arguments"].(map[string]any)
fleet.MigrateLegacyKeys()
if name != "fleet_status" && name != "fleet_request" {
fleet.MigrateLegacyKeys()
}
text, isErr, err := safeCall(name, args)
if errors.Is(err, errUnknownTool) {
return rpcError(id, -32602, fmt.Sprintf("unknown tool %s", fleet.PyRepr(name)))
Expand Down Expand Up @@ -364,3 +375,14 @@ func handleSafe(msg map[string]any) (resp map[string]any) {
}()
return handle(msg)
}

// Preserve a parseable packet even when its sources are incomplete. The tool's
// isError flag carries the failure; appending prose would corrupt the JSON.
func taskStatusJSON() (string, bool) {
var buf strings.Builder
previous := verbs.Out
verbs.Out = &buf
defer func() { verbs.Out = previous }()
err := verbs.CmdStatus([]string{"--json"})
return buf.String(), err != nil
}
Loading
Loading