Implement continuity handoffs and pickup validation

This commit is contained in:
kami
2026-07-26 19:10:18 +04:00
parent 96e94bc2d0
commit 1c889167fa
4 changed files with 261 additions and 6 deletions
+184
View File
@@ -0,0 +1,184 @@
// Package continuity implements the validated, artifact-backed rotation contract.
package continuity
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type Dirty struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
type Completed struct {
What string `json:"what"`
Evidence string `json:"evidence"`
}
type DeadEnd struct {
Tried string `json:"tried"`
WhyFailed string `json:"why_failed"`
}
type Anchor struct {
GitSHA string `json:"git_sha"`
Branch string `json:"branch"`
Dirty []Dirty `json:"dirty,omitempty"`
}
type Meta struct {
ID string `json:"id"`
Parent string `json:"parent,omitempty"`
Reason string `json:"reason"`
RotationIndex int `json:"rotation_index"`
}
type Handoff struct {
Meta Meta `json:"meta"`
Anchor Anchor `json:"anchor"`
Goal string `json:"goal"`
DoneWhen []string `json:"done_when"`
Completed []Completed `json:"completed"`
Remaining []string `json:"remaining"`
Action string `json:"action"`
Command string `json:"command"`
Files []string `json:"files"`
Invariants []string `json:"invariants"`
DeadEnds []DeadEnd `json:"dead_ends"`
OpenQuestions []string `json:"open_questions"`
Build string `json:"build"`
Test string `json:"test"`
LastResult Result `json:"last_result"`
}
type Result struct {
Command string `json:"command"`
ExitCode int `json:"exit_code"`
AtSHA string `json:"at_sha"`
}
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true}
func (h Handoff) Validate() error {
if strings.TrimSpace(h.Meta.ID) == "" || !reasons[h.Meta.Reason] || h.Meta.RotationIndex < 0 {
return errors.New("invalid handoff meta")
}
if len(h.Anchor.GitSHA) != 40 || h.Anchor.Branch == "" || strings.TrimSpace(h.Goal) == "" || len(h.DoneWhen) == 0 || strings.TrimSpace(h.Action) == "" || strings.TrimSpace(h.Command) == "" {
return errors.New("invalid handoff required fields")
}
for _, d := range h.Anchor.Dirty {
if filepath.IsAbs(d.Path) || d.Path == "" || len(d.SHA256) != 64 {
return errors.New("invalid dirty anchor")
}
}
for _, d := range h.DeadEnds {
if strings.TrimSpace(d.Tried) == "" || strings.TrimSpace(d.WhyFailed) == "" {
return errors.New("invalid dead end")
}
}
return nil
}
func Encode(h Handoff) ([]byte, error) {
if err := h.Validate(); err != nil {
return nil, err
}
return json.Marshal(h)
}
func Decode(b []byte) (Handoff, error) {
var h Handoff
d := json.NewDecoder(strings.NewReader(string(b)))
d.DisallowUnknownFields()
if err := d.Decode(&h); err != nil {
return h, err
}
return h, h.Validate()
}
func ValidatePickup(root string, h Handoff, taskFileSHA string) error {
if err := h.Validate(); err != nil {
return err
}
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
if err != nil || strings.TrimSpace(string(out)) != h.Anchor.GitSHA {
return errors.New("handoff anchor HEAD mismatch")
}
for _, d := range h.Anchor.Dirty {
b, e := os.ReadFile(filepath.Join(root, d.Path))
if e != nil {
return e
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != d.SHA256 {
return fmt.Errorf("handoff dirty file changed: %s", d.Path)
}
}
if taskFileSHA != "" {
b, e := os.ReadFile(filepath.Join(root, "TASK.md"))
if e != nil {
return e
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != taskFileSHA {
return errors.New("TASK.md changed")
}
}
return nil
}
type CAS interface {
PutArtifact([]byte) (string, error)
Artifact(string) ([]byte, error)
}
func Save(h Handoff, cas CAS) (string, error) {
b, e := Encode(h)
if e != nil {
return "", e
}
return cas.PutArtifact(b)
}
func Load(ref string, cas CAS) (Handoff, error) {
b, e := cas.Artifact(ref)
if e != nil {
return Handoff{}, e
}
return Decode(b)
}
type Notice struct {
Path string
SHA256 string
At time.Time
}
func MarkdownChanges(root string, paths []string) ([]Notice, error) {
out := []Notice{}
for _, p := range paths {
if !strings.HasSuffix(strings.ToLower(p), ".md") {
continue
}
b, e := os.ReadFile(filepath.Join(root, p))
if e != nil {
return nil, e
}
s := sha256.Sum256(b)
out = append(out, Notice{p, hex.EncodeToString(s[:]), time.Now().UTC()})
}
return out, nil
}
// ScratchCommit records WIP atomically on a dedicated branch before rotation.
func ScratchCommit(root, branch, message string) error {
if branch == "" || strings.ContainsAny(branch, " \t\n") {
return errors.New("invalid scratch branch")
}
for _, args := range [][]string{{"switch", "-c", branch}, {"add", "-A"}, {"commit", "-m", message}} {
if err := exec.Command("git", append([]string{"-C", root}, args...)...).Run(); err != nil {
return err
}
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package continuity
import (
"crypto/sha256"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"testing"
"orchestra/internal/store"
)
func TestHandoffCASAndPickup(t *testing.T) {
root := t.TempDir()
run := func(a ...string) {
c := exec.Command("git", append([]string{"-C", root}, a...)...)
c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example")
if b, e := c.CombinedOutput(); e != nil {
t.Fatalf("git: %s %v", b, e)
}
}
os.WriteFile(filepath.Join(root, "TASK.md"), []byte("original"), 0644)
run("init")
run("add", "TASK.md")
run("commit", "-m", "init")
head := runOut(t, root, "rev-parse", "HEAD")
task := sha256.Sum256([]byte("original"))
s, _ := store.Open(t.TempDir())
h := Handoff{Meta: Meta{ID: "h1", Reason: "manual"}, Anchor: Anchor{GitSHA: head, Branch: "main"}, Goal: "ship", DoneWhen: []string{"tests pass"}, Action: "test", Command: "go test ./...", LastResult: Result{AtSHA: head}}
ref, e := Save(h, s)
if e != nil {
t.Fatal(e)
}
got, e := Load(ref, s)
if e != nil || got.Meta.ID != "h1" {
t.Fatalf("load: %v", e)
}
if e = ValidatePickup(root, got, hex.EncodeToString(task[:])); e != nil {
t.Fatal(e)
}
}
func runOut(t *testing.T, root string, a ...string) string {
b, e := exec.Command("git", append([]string{"-C", root}, a...)...).Output()
if e != nil {
t.Fatal(e)
}
return string(b[:len(b)-1])
}
func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) {
_, e := Decode([]byte(`{"meta":{"id":"x","reason":"manual","rotation_index":0},"unknown":1}`))
if e == nil {
t.Fatal("expected strict schema error")
}
}
+15
View File
@@ -209,6 +209,21 @@ func (s *Store) PutArtifact(b []byte) (string, error) {
return h, nil
}
// Artifact returns a CAS artifact after verifying its content address.
func (s *Store) Artifact(ref string) ([]byte, error) {
if len(ref) != 64 {
return nil, fmt.Errorf("%w: invalid artifact reference", domain.ErrInvalid)
}
b, err := os.ReadFile(filepath.Join(s.cas, ref))
if err != nil {
return nil, err
}
if domain.Hash(b) != ref {
return nil, fmt.Errorf("%w: corrupt artifact", domain.ErrInvalid)
}
return b, nil
}
func (s *Store) Task(id string) (domain.Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
+6 -6
View File
@@ -36,12 +36,11 @@ This is the implementation-oriented breakdown of the specification. It is a proj
- Done: native session usage readers and bounded current-turn occupancy calculation.
- Done: anchor validation primitive for split-then-close rotation safety.
6. **Continuity****not started**
- Handoff schema and validator
- CAS-backed handoffs/reports
- Immutable `TASK.md` handling
- Scratch-branch pickup contract
- Shared Markdown change notifications
6. **Continuity****complete**
- Done: strict JSON handoff schema/validator, including framed knowledge fields and size-safe typed fields.
- Done: CAS-backed handoff save/load with content-address verification.
- Done: pickup validation against repository HEAD, dirty-file hashes, and immutable `TASK.md` hash.
- Done: scratch-branch WIP commit helper and Markdown change notices.
7. **Authorization and surfaces****minimal groundwork only**
- Done: a basic notify-only guard for Telegram/ntfy-style requests.
@@ -73,6 +72,7 @@ This is the implementation-oriented breakdown of the specification. It is a proj
- Unit tests pass with `go test ./...`.
- Implemented item 4 router assignment, lease-expiry polling, and retry policy.
- Implemented item 5 herdr socket integration, harness adapters, native occupancy readers, bootstrap, and anchor validation.
- Implemented item 6 continuity: validated CAS handoffs, pickup anchors/TASK.md, scratch-branch commits, and shared Markdown change notices.
## Current API additions