Skip to content

Commit bd3820f

Browse files
committed
feat(agentjournal): a handoff is a goal another agent can pick up
agent-runtime was a tested library with zero estate consumers, linked to GDS by gitlink and docs alone. Now the engine consumes it where its contract fits exactly: gds handoff checkpoints unfinished work for a next actor, and that is a Goal journal -- durable, revisioned, validated by agent-runtime itself, never a private dialect. Apply creates the goal (genesis create, then the store's own CAS update) and completes the checkpoint item with commit and file evidence; verify completes the verification item idempotently; both append handoff lifecycle events to a JSONL stream through the library's emitter. A journal failure is a finding beside a checkpoint that already exists, never a rollback of it. Claude-Session: https://claude.ai/code/session_01LsGid6U5RrQdFvJmvYdGCF
1 parent c19d274 commit bd3820f

5 files changed

Lines changed: 323 additions & 11 deletions

File tree

core/agentjournal/agentjournal.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Package agentjournal records a handoff as an agent-runtime goal.
2+
//
3+
// gds handoff checkpoints unfinished work for a next actor, and that is
4+
// exactly the contract agent-runtime's Goal journal and handoff lifecycle
5+
// events were written for: a durable, revisioned, vendor-neutral record of a
6+
// goal another agent is expected to pick up. The journal lives beside the
7+
// operation state, the lifecycle events append to a JSONL stream, and both
8+
// use agent-runtime's own validation -- GDS adds identity and evidence, never
9+
// a private dialect.
10+
package agentjournal
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"os"
16+
"path/filepath"
17+
"regexp"
18+
"strings"
19+
"time"
20+
21+
"github.com/NDDev-OpenNetwork/agent-runtime/goal"
22+
"github.com/NDDev-OpenNetwork/agent-runtime/observability"
23+
)
24+
25+
const (
26+
// CheckpointItem is the acceptance item the apply completes: the
27+
// checkpoint commit exists and is published.
28+
CheckpointItem = "checkpoint-published"
29+
// VerifiedItem is the acceptance item the verify completes: the handoff
30+
// operation re-proved the checkpoint.
31+
VerifiedItem = "handoff-verified"
32+
)
33+
34+
var invalidIDRunes = regexp.MustCompile(`[^a-z0-9._-]+`)
35+
36+
// Recorder writes goal journals and lifecycle events under one directory.
37+
type Recorder struct {
38+
Directory string
39+
Now func() time.Time
40+
}
41+
42+
func (r Recorder) now() time.Time {
43+
if r.Now != nil {
44+
return r.Now()
45+
}
46+
return time.Now().UTC()
47+
}
48+
49+
// GoalID derives a valid agent-runtime goal id from an operation id.
50+
func GoalID(operationID string) string {
51+
lowered := strings.ToLower(strings.TrimSpace(operationID))
52+
lowered = strings.ReplaceAll(lowered, "_", "-")
53+
lowered = invalidIDRunes.ReplaceAllString(lowered, "-")
54+
lowered = strings.Trim(lowered, "._-")
55+
if lowered == "" {
56+
lowered = "operation"
57+
}
58+
return "handoff." + lowered
59+
}
60+
61+
// JournalPath is where an operation's goal journal lives.
62+
func (r Recorder) JournalPath(operationID string) string {
63+
return filepath.Join(r.Directory, GoalID(operationID)+".json")
64+
}
65+
66+
func (r Recorder) eventsPath() string {
67+
return filepath.Join(r.Directory, "handoff-events.jsonl")
68+
}
69+
70+
// RecordCheckpoint creates the goal journal for an applied handoff and marks
71+
// the checkpoint item complete with its commit evidence, then appends the
72+
// dispatched handoff event.
73+
func (r Recorder) RecordCheckpoint(
74+
ctx context.Context,
75+
operationID, repositoryID, intent string,
76+
files []string,
77+
commitReference string,
78+
sessionID string,
79+
) error {
80+
if err := os.MkdirAll(r.Directory, 0o700); err != nil {
81+
return fmt.Errorf("create agent journal directory: %w", err)
82+
}
83+
now := r.now()
84+
journal, err := goal.New(GoalID(operationID), intent, []goal.ChecklistItem{
85+
{ID: CheckpointItem, Acceptance: "the checkpoint commit exists on the published branch"},
86+
{ID: VerifiedItem, Acceptance: "gds handoff --verify re-proved the checkpoint"},
87+
}, []string{"integration", "cleanup"}, now)
88+
if err != nil {
89+
return fmt.Errorf("draft handoff goal: %w", err)
90+
}
91+
evidence := []goal.Evidence{{Type: goal.EvidenceCommit, Reference: commitReference, Result: "checkpoint published for " + repositoryID}}
92+
for _, file := range files {
93+
evidence = append(evidence, goal.Evidence{Type: goal.EvidenceFile, Reference: file, Result: "carried by the checkpoint"})
94+
}
95+
store := goal.Store{Path: r.JournalPath(operationID)}
96+
// The store insists on a genesis create followed by a CAS update -- the
97+
// same discipline every other consumer gets, so GDS takes it too.
98+
if err := store.Create(journal); err != nil {
99+
return fmt.Errorf("store handoff goal: %w", err)
100+
}
101+
if _, err := store.Update(journal.Revision, func(stored *goal.Journal) error {
102+
return stored.CompleteItem(CheckpointItem, evidence, now)
103+
}); err != nil {
104+
return fmt.Errorf("complete checkpoint item: %w", err)
105+
}
106+
return r.emit(ctx, operationID, sessionID, observability.HandoffStageDispatched)
107+
}
108+
109+
// RecordVerified marks the verification item complete on the stored journal
110+
// and appends the completed handoff event.
111+
func (r Recorder) RecordVerified(
112+
ctx context.Context,
113+
operationID string,
114+
verificationReference string,
115+
sessionID string,
116+
) error {
117+
store := goal.Store{Path: r.JournalPath(operationID)}
118+
current, err := store.Load()
119+
if err != nil {
120+
return fmt.Errorf("load handoff goal: %w", err)
121+
}
122+
// A re-verify is idempotent: the journal already says it, and the event
123+
// stream already carries it, so neither is repeated.
124+
for _, item := range current.Goal.Acceptance {
125+
if item.ID == VerifiedItem && item.Status == goal.ItemComplete {
126+
return nil
127+
}
128+
}
129+
now := r.now()
130+
if _, err := store.Update(current.Revision, func(journal *goal.Journal) error {
131+
return journal.CompleteItem(VerifiedItem, []goal.Evidence{{
132+
Type: goal.EvidenceCommand, Reference: verificationReference,
133+
Result: "handoff verify succeeded",
134+
}}, now)
135+
}); err != nil {
136+
return fmt.Errorf("complete verification item: %w", err)
137+
}
138+
return r.emit(ctx, operationID, sessionID, observability.HandoffStageCompleted)
139+
}
140+
141+
func (r Recorder) emit(ctx context.Context, operationID, sessionID string, stage observability.HandoffStage) error {
142+
sink, err := observability.OpenJSONLSink(r.eventsPath(), observability.JSONLOptions{Name: "gds-handoff"})
143+
if err != nil {
144+
return fmt.Errorf("open handoff event sink: %w", err)
145+
}
146+
defer sink.Close(ctx)
147+
emitter, err := observability.NewEmitter(
148+
observability.Runtime{ID: "gds", Version: "handoff-v1"},
149+
[]observability.Sink{sink},
150+
observability.Options{Clock: r.now},
151+
)
152+
if err != nil {
153+
return fmt.Errorf("build handoff event emitter: %w", err)
154+
}
155+
draft, err := observability.HandoffDraft(
156+
GoalID(operationID),
157+
observability.ActorWorker, observability.ActorWorker, stage, nil, nil,
158+
observability.Context{
159+
CorrelationID: GoalID(operationID),
160+
Actor: observability.Actor{Kind: observability.ActorWorker, ID: sessionID},
161+
Attempt: observability.AttemptInitial,
162+
},
163+
)
164+
if err != nil {
165+
return fmt.Errorf("draft handoff event: %w", err)
166+
}
167+
if _, _, err := emitter.Emit(ctx, draft); err != nil {
168+
return fmt.Errorf("emit handoff event: %w", err)
169+
}
170+
return nil
171+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package agentjournal
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/NDDev-OpenNetwork/agent-runtime/goal"
13+
)
14+
15+
func fixedNow() time.Time { return time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) }
16+
17+
func TestGoalIDDerivesAValidRuntimeIdentity(t *testing.T) {
18+
id := GoalID("op_01M0G5WWVAHQGK33XJ9M183MJ5")
19+
if id != "handoff.op-01m0g5wwvahqgk33xj9m183mj5" {
20+
t.Fatalf("id=%q", id)
21+
}
22+
if _, err := goal.New(id, "x", []goal.ChecklistItem{{ID: "a", Acceptance: "b"}}, nil, fixedNow()); err != nil {
23+
t.Fatalf("derived id refused by agent-runtime: %v", err)
24+
}
25+
}
26+
27+
func TestRecordCheckpointThenVerifiedCompletesTheGoalStory(t *testing.T) {
28+
recorder := Recorder{Directory: filepath.Join(t.TempDir(), "agent-journals"), Now: fixedNow}
29+
ctx := context.Background()
30+
err := recorder.RecordCheckpoint(ctx, "op_TEST123", "device:example/repo", "checkpoint the refactor", []string{"a.go", "b.go"}, "operation:op_TEST123", "session-1")
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
store := goal.Store{Path: recorder.JournalPath("op_TEST123")}
35+
journal, err := store.Load()
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
if err := journal.Validate(); err != nil {
40+
t.Fatalf("stored journal does not validate under agent-runtime: %v", err)
41+
}
42+
byID := map[string]goal.ChecklistItem{}
43+
for _, item := range journal.Goal.Acceptance {
44+
byID[item.ID] = item
45+
}
46+
if byID[CheckpointItem].Status != goal.ItemComplete {
47+
t.Fatal("checkpoint item is not complete after apply")
48+
}
49+
if byID[VerifiedItem].Status != goal.ItemPending {
50+
t.Fatal("verification item must stay pending until verify")
51+
}
52+
// Files ride as evidence on the completed item.
53+
if len(byID[CheckpointItem].Evidence) != 3 {
54+
t.Fatalf("evidence=%+v", byID[CheckpointItem].Evidence)
55+
}
56+
57+
if err := recorder.RecordVerified(ctx, "op_TEST123", "gds handoff --verify op_TEST123", "session-2"); err != nil {
58+
t.Fatal(err)
59+
}
60+
journal, err = store.Load()
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
for _, item := range journal.Goal.Acceptance {
65+
if item.ID == VerifiedItem && item.Status != goal.ItemComplete {
66+
t.Fatal("verification item is not complete after verify")
67+
}
68+
}
69+
// A second verify is idempotent, not a corruption.
70+
if err := recorder.RecordVerified(ctx, "op_TEST123", "gds handoff --verify op_TEST123", "session-2"); err != nil {
71+
t.Fatalf("re-verify must not fail: %v", err)
72+
}
73+
74+
// The lifecycle stream carries the dispatched and completed handoff events.
75+
raw, err := os.ReadFile(filepath.Join(recorder.Directory, "handoff-events.jsonl"))
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
80+
if len(lines) < 2 {
81+
t.Fatalf("events=%d, want at least dispatched and completed", len(lines))
82+
}
83+
var first map[string]any
84+
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
85+
t.Fatalf("event stream is not JSONL: %v", err)
86+
}
87+
}
88+
89+
func TestRecordVerifiedWithoutACheckpointRefuses(t *testing.T) {
90+
recorder := Recorder{Directory: t.TempDir(), Now: fixedNow}
91+
if err := recorder.RecordVerified(context.Background(), "op_NOPE", "ref", "s"); err == nil {
92+
t.Fatal("verify without a stored goal was accepted")
93+
}
94+
}

core/app/handoff_workflow.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
"github.com/NDDev-OpenNetwork/github-device-sync/core/agentjournal"
1415
"github.com/NDDev-OpenNetwork/github-device-sync/core/canonicaljson"
1516
"github.com/NDDev-OpenNetwork/github-device-sync/core/compiler"
1617
"github.com/NDDev-OpenNetwork/github-device-sync/core/domain"
@@ -205,7 +206,7 @@ func (services *Services) ApplyHandoff(
205206
if finding := validateOperationActor(options.DeviceID, options.SessionID); finding != nil {
206207
return domain.NewEnvelope("gds handoff apply", domain.ExitInput, nil, *finding)
207208
}
208-
_, store, stateFinding := openOperationState(ctx, options.StatePath)
209+
statePath, store, stateFinding := openOperationState(ctx, options.StatePath)
209210
if stateFinding != nil {
210211
return domain.NewEnvelope("gds handoff apply", domain.ExitInput, nil, *stateFinding)
211212
}
@@ -251,9 +252,40 @@ func (services *Services) ApplyHandoff(
251252
envelope.Mutation.Attempted = result.MutationAttempted
252253
envelope.Mutation.Completed = result.MutationCompleted
253254
envelope.Scope["repository_id"] = plan.Scope.Repositories[0]
255+
// The checkpoint is durable; now the handoff itself becomes a durable
256+
// goal another agent can pick up. The journal is evidence beside the
257+
// operation, so a failure to write it is a finding, never a rollback of
258+
// a checkpoint that already exists.
259+
recorder := agentjournal.Recorder{
260+
Directory: filepath.Join(filepath.Dir(statePath), "agent-journals"),
261+
Now: services.Now,
262+
}
263+
if err := recorder.RecordCheckpoint(
264+
ctx, result.OperationID, plan.Scope.Repositories[0],
265+
handoffGoalIntent(plan), files, "operation:"+result.OperationID,
266+
options.SessionID,
267+
); err != nil {
268+
envelope.Findings = append(envelope.Findings, domain.Finding{
269+
Code: "GDS_HANDOFF_AGENT_JOURNAL_NOT_RECORDED", Severity: domain.SeverityMedium,
270+
Message: "The checkpoint exists, but its agent-runtime goal journal was not recorded: " + err.Error(),
271+
})
272+
} else {
273+
envelope.Scope["agent_journal"] = recorder.JournalPath(result.OperationID)
274+
}
254275
return envelope
255276
}
256277

278+
// handoffGoalIntent names the goal after the plan's own checkpoint message,
279+
// falling back to a stable phrase when the plan carries none.
280+
func handoffGoalIntent(plan operations.Plan) string {
281+
for _, step := range plan.Steps {
282+
if message, ok := step.Parameters["message"].(string); ok && strings.TrimSpace(message) != "" {
283+
return message
284+
}
285+
}
286+
return "carry the checkpointed work to completion"
287+
}
288+
257289
func (services *Services) VerifyHandoff(
258290
ctx context.Context,
259291
operationID string,
@@ -265,7 +297,7 @@ func (services *Services) VerifyHandoff(
265297
if finding := validateOperationActor(options.DeviceID, options.SessionID); finding != nil {
266298
return domain.NewEnvelope("gds handoff verify", domain.ExitInput, nil, *finding)
267299
}
268-
_, store, stateFinding := openOperationState(ctx, options.StatePath)
300+
statePath, store, stateFinding := openOperationState(ctx, options.StatePath)
269301
if stateFinding != nil {
270302
return domain.NewEnvelope("gds handoff verify", domain.ExitInput, nil, *stateFinding)
271303
}
@@ -301,6 +333,18 @@ func (services *Services) VerifyHandoff(
301333
envelope := domain.Success("gds handoff verify", result)
302334
envelope.OperationID = operationID
303335
envelope.Scope["repository_id"] = plan.Scope.Repositories[0]
336+
recorder := agentjournal.Recorder{
337+
Directory: filepath.Join(filepath.Dir(statePath), "agent-journals"),
338+
Now: services.Now,
339+
}
340+
if err := recorder.RecordVerified(ctx, operationID, "gds handoff --verify "+operationID, options.SessionID); err != nil {
341+
envelope.Findings = append(envelope.Findings, domain.Finding{
342+
Code: "GDS_HANDOFF_AGENT_JOURNAL_NOT_RECORDED", Severity: domain.SeverityMedium,
343+
Message: "The verification succeeded, but the agent-runtime goal journal was not advanced: " + err.Error(),
344+
})
345+
} else {
346+
envelope.Scope["agent_journal"] = recorder.JournalPath(operationID)
347+
}
304348
return envelope
305349
}
306350

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/NDDev-OpenNetwork/github-device-sync
33
go 1.26.7
44

55
require (
6+
github.com/NDDev-OpenNetwork/agent-runtime v0.1.2-0.20260828080341-a0738060888d
67
github.com/dlclark/regexp2 v1.12.0
78
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
89
github.com/spf13/cobra v1.10.2
@@ -19,7 +20,7 @@ require (
1920
github.com/ncruces/go-strftime v1.0.0 // indirect
2021
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
2122
github.com/spf13/pflag v1.0.9 // indirect
22-
golang.org/x/text v0.39.0 // indirect
23+
golang.org/x/text v0.41.0 // indirect
2324
modernc.org/libc v1.74.4 // indirect
2425
modernc.org/mathutil v1.7.1 // indirect
2526
modernc.org/memory v1.11.0 // indirect

0 commit comments

Comments
 (0)