From 7bb8e3d7b236c38b9cb7f9e4d9ed62e16fe9e8f6 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 01:41:50 +0800 Subject: [PATCH 01/35] feat(harness): add remote workspace sync seam Introduce the exchange.RemoteWorkspace ABI and route the existing HTTP mnemon-hub sync client through it for both manual sync commands and the sync worker. Remote configs now carry an explicit backend field while preserving empty-backend compatibility with HTTP. Validation: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemoteEntry'; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/sync.go | 41 +++++++++------ harness/cmd/mnemon-harness/sync_test.go | 7 +-- harness/internal/app/sync_worker.go | 34 +++++++++---- .../mnemonhub/exchange/remote_workspace.go | 15 ++++++ .../internal/mnemonhub/exchange/remotes.go | 32 ++++++++++-- .../mnemonhub/exchange/remotes_test.go | 50 +++++++++++++++++++ 6 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 harness/internal/mnemonhub/exchange/remote_workspace.go create mode 100644 harness/internal/mnemonhub/exchange/remotes_test.go diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index ebb9f6e4..71559db0 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -204,11 +204,11 @@ func syncPushOnce() (syncPushResult, error) { if err != nil { return syncPushResult{}, err } - client, err := syncClientFor(remote) + workspace, err := syncRemoteWorkspaceFor(remote) if err != nil { return syncPushResult{}, err } - resp, err := client.SyncPush(contract.SyncPushRequest{ + resp, err := workspace.SyncPush(contract.SyncPushRequest{ ReplicaID: batch.ReplicaID, BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), Events: batch.Events, @@ -232,11 +232,11 @@ func syncPullOnce() (syncPullResult, error) { if err != nil { return syncPullResult{}, err } - client, err := syncClientFor(remote) + workspace, err := syncRemoteWorkspaceFor(remote) if err != nil { return syncPullResult{}, err } - resp, err := client.SyncPull(contract.SyncPullRequest{ + resp, err := workspace.SyncPull(contract.SyncPullRequest{ ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor, }) @@ -252,19 +252,30 @@ func syncPullOnce() (syncPullResult, error) { type syncRemoteConfig struct { ID string + Backend string Endpoint string Token string CAFile string } -// syncClientFor builds the bounded sync client for one resolved remote: bearer token, optional -// pinned TLS root, and the T2 downgrade gate (--allow-insecure-remote is the only override). -func syncClientFor(remote syncRemoteConfig) (*access.Client, error) { - return access.NewSyncClient(remote.Endpoint, access.SyncClientConfig{ - Token: remote.Token, - CAFile: remote.CAFile, - AllowInsecure: syncAllowInsecure, - }) +// syncRemoteWorkspaceFor builds the selected Remote Workspace backend for one resolved remote. The +// current CLI supports the first-party HTTP mnemon-hub backend; future backends must preserve this +// SyncPush/SyncPull/SyncStatus ABI rather than bypassing local import. +func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, error) { + backend := strings.TrimSpace(remote.Backend) + if backend == "" { + backend = exchange.RemoteBackendHTTP + } + switch backend { + case exchange.RemoteBackendHTTP: + return access.NewSyncClient(remote.Endpoint, access.SyncClientConfig{ + Token: remote.Token, + CAFile: remote.CAFile, + AllowInsecure: syncAllowInsecure, + }) + default: + return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", remote.ID, backend) + } } func resolveSyncRemote() (syncRemoteConfig, error) { @@ -277,7 +288,7 @@ func resolveSyncRemote() (syncRemoteConfig, error) { if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: syncRemoteID, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")}, nil + return syncRemoteConfig{ID: syncRemoteID, Backend: exchange.RemoteBackendHTTP, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")}, nil } entry, err := exchange.LoadRemoteEntry(resolvedSyncRemotesPath(), syncRemoteID) if err != nil { @@ -294,7 +305,7 @@ func resolveSyncRemote() (syncRemoteConfig, error) { if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: entry.ID, Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil + return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil } // resolvedSyncCAFile picks the pinned-root file: the --ca-file flag overrides the remotes.json @@ -326,7 +337,7 @@ func upsertSyncRemote(path, root, id, endpoint, token, tokenFile, caFile string) if err != nil { return err } - entry := exchange.RemoteEntry{ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} + entry := exchange.RemoteEntry{Backend: exchange.RemoteBackendHTTP, ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} replaced := false for i := range doc.Remotes { if doc.Remotes[i].ID == id { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index 74d7fc16..d8e442a0 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -15,6 +15,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -290,7 +291,7 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { } } config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) - for _, want := range []string{`"current": "team"`, `"id": "team"`, `"credential_ref": ".mnemon/harness/sync/credentials/team.token"`} { + for _, want := range []string{`"current": "team"`, `"backend": "http"`, `"id": "team"`, `"credential_ref": ".mnemon/harness/sync/credentials/team.token"`} { if !strings.Contains(config, want) { t.Fatalf("sync connect config missing %q:\n%s", want, config) } @@ -305,7 +306,7 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { if err != nil { t.Fatalf("resolve current remote: %v", err) } - if remote.ID != "team" || remote.Endpoint != "https://remote.example.test" || remote.Token != "secret-workspace-token" { + if remote.ID != "team" || remote.Backend != exchange.RemoteBackendHTTP || remote.Endpoint != "https://remote.example.test" || remote.Token != "secret-workspace-token" { t.Fatalf("current remote not resolved: %+v", remote) } } @@ -341,7 +342,7 @@ func TestSyncRemoteConfigLoadsCredentialRef(t *testing.T) { if err != nil { t.Fatalf("resolve remote config: %v", err) } - if remote.ID != "workspace" || remote.Endpoint != "http://127.0.0.1:8787" || remote.Token != "tok-workspace" { + if remote.ID != "workspace" || remote.Backend != exchange.RemoteBackendHTTP || remote.Endpoint != "http://127.0.0.1:8787" || remote.Token != "tok-workspace" { t.Fatalf("remote config not loaded: %+v", remote) } } diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 201a1b4c..73a1cda4 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -73,20 +73,32 @@ func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { if err != nil { return err } - client, err := syncWorkerClient(entry, opts) + remote, err := syncWorkerRemote(entry, opts) if err != nil { return err } - if err := syncWorkerPush(rt, client, entry.ID); err != nil { + if err := syncWorkerPush(rt, remote, entry.ID); err != nil { return err } - return syncWorkerPull(rt, client, entry.ID, opts.Catalog) + return syncWorkerPull(rt, remote, entry.ID, opts.Catalog) } -// syncWorkerClient builds the bounded sync client from the remote entry: credential_ref + ca_file -// resolve relative to the project root (the same resolution `sync connect` wrote them under), and -// the endpoint passes the T2 downgrade gate unless explicitly overridden. -func syncWorkerClient(entry exchange.RemoteEntry, opts SyncWorkerOptions) (*access.Client, error) { +// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. Today only +// the first-party HTTP mnemon-hub backend is implemented; the sync loop above depends only on the +// exchange.RemoteWorkspace ABI so a future GitHub publication mesh does not touch runtime import. +func syncWorkerRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { + switch entry.NormalizedBackend() { + case exchange.RemoteBackendHTTP: + return syncWorkerHTTPRemote(entry, opts) + default: + return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", entry.ID, entry.NormalizedBackend()) + } +} + +// syncWorkerHTTPRemote builds the bounded HTTP mnemon-hub sync client from the remote entry: +// credential_ref + ca_file resolve relative to the project root (the same resolution `sync connect` +// wrote them under), and the endpoint passes the T2 downgrade gate unless explicitly overridden. +func syncWorkerHTTPRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { if strings.TrimSpace(entry.CredentialRef) == "" { return nil, fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) } @@ -116,7 +128,7 @@ func syncWorkerClient(entry exchange.RemoteEntry, opts SyncWorkerOptions) (*acce // syncWorkerPush pushes the pending batch (if any) and mirrors the hub's per-event verdicts into // the local ledger — both through the live handle. -func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) error { +func syncWorkerPush(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string) error { batch, err := exchange.ReadPushBatch(rt) if err != nil { return err @@ -124,7 +136,7 @@ func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) if len(batch.Events) == 0 { return nil } - resp, err := client.SyncPush(contract.SyncPushRequest{ + resp, err := remote.SyncPush(contract.SyncPushRequest{ ReplicaID: batch.ReplicaID, BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), Events: batch.Events, @@ -138,12 +150,12 @@ func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) // syncWorkerPull pulls after the durable cursor, re-enters each event through the live runtime's // trusted intake (importPulledEvents — the same loop the offline path uses), then advances the // cursor. -func syncWorkerPull(rt *runtime.Runtime, client *access.Client, remoteID string, catalog policy.Registry) error { +func syncWorkerPull(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string, catalog policy.Registry) error { state, err := exchange.ReadPullState(rt, remoteID) if err != nil { return err } - resp, err := client.SyncPull(contract.SyncPullRequest{ + resp, err := remote.SyncPull(contract.SyncPullRequest{ ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor, }) diff --git a/harness/internal/mnemonhub/exchange/remote_workspace.go b/harness/internal/mnemonhub/exchange/remote_workspace.go new file mode 100644 index 00000000..c4a2e9b5 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/remote_workspace.go @@ -0,0 +1,15 @@ +package exchange + +import "github.com/mnemon-dev/mnemon/harness/internal/contract" + +// RemoteWorkspace is the local mnemond side of the Remote Workspace sync ABI. +// +// The method names intentionally match the existing wire verbs so the current +// HTTP mnemon-hub client satisfies this interface without a wrapper. Future +// backends, such as a GitHub publication mesh, must present the same accepted +// synced-envelope behavior to the local sync loop. +type RemoteWorkspace interface { + SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) + SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) + SyncStatus() (contract.SyncStatusResponse, error) +} diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index 4d06cf0e..f2d92a10 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -17,17 +17,39 @@ type RemotesDoc struct { Remotes []RemoteEntry `json:"remotes"` } +const RemoteBackendHTTP = "http" + type RemoteEntry struct { + // Backend selects the Remote Workspace implementation. Empty is the v1 + // compatibility default: the first-party HTTP mnemon-hub wire. + Backend string `json:"backend,omitempty"` ID string `json:"id"` - Endpoint string `json:"endpoint"` + Endpoint string `json:"endpoint,omitempty"` CredentialRef string `json:"credential_ref"` // CAFile optionally pins the remote's TLS root (PEM bundle) — the client trusts exactly it // (sync-abi-v1 §8). Empty = the system roots. CAFile string `json:"ca_file,omitempty"` } +func (r RemoteEntry) NormalizedBackend() string { + backend := strings.TrimSpace(r.Backend) + if backend == "" { + return RemoteBackendHTTP + } + return backend +} + +func validateRemoteBackend(backend string) error { + switch backend { + case RemoteBackendHTTP: + return nil + default: + return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s)", backend, RemoteBackendHTTP) + } +} + // LoadRemoteEntry resolves one remote from the registry at path: id "default" follows the doc's -// `current` pointer. It validates schema version and a non-empty endpoint; credential presence is +// `current` pointer. It validates schema version and the selected backend; credential presence is // the caller's concern (the CLI may inject a --token override). func LoadRemoteEntry(path, id string) (RemoteEntry, error) { raw, err := os.ReadFile(path) @@ -46,7 +68,11 @@ func LoadRemoteEntry(path, id string) (RemoteEntry, error) { } for _, remote := range doc.Remotes { if remote.ID == id { - if strings.TrimSpace(remote.Endpoint) == "" { + remote.Backend = remote.NormalizedBackend() + if err := validateRemoteBackend(remote.Backend); err != nil { + return RemoteEntry{}, fmt.Errorf("Remote Workspace %q: %w", id, err) + } + if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { return RemoteEntry{}, fmt.Errorf("Remote Workspace %q has no endpoint", id) } return remote, nil diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go new file mode 100644 index 00000000..b4f4724d --- /dev/null +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -0,0 +1,50 @@ +package exchange + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadRemoteEntryDefaultsBackendToHTTP(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "hub", + "remotes": [{ + "id": "hub", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + remote, err := LoadRemoteEntry(path, "default") + if err != nil { + t.Fatalf("load legacy remote: %v", err) + } + if remote.Backend != RemoteBackendHTTP || remote.NormalizedBackend() != RemoteBackendHTTP { + t.Fatalf("legacy remote backend = %q, want %q", remote.Backend, RemoteBackendHTTP) + } +} + +func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "mesh", + "backend": "bogus", + "credential_ref": ".mnemon/harness/sync/credentials/mesh.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemoteEntry(path, "mesh") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("unsupported backend must fail closed, got %v", err) + } +} From 64e77474becebe0bc0ca0fd4f55f006c81bc9d4a Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 01:44:27 +0800 Subject: [PATCH 02/35] test(harness): guard GitHub remote workspace boundaries Add coreguard coverage for the GitHub Remote Workspace direction: core packages must not import the future GitHub backend, GitHub backend code must live under mnemonhub/exchange/backend, and backend vocabulary must avoid GitHub Issue/PR/Actions and P2P networking concepts. Validation: go test ./harness/internal/coreguard. --- .../github_remote_workspace_guard_test.go | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 harness/internal/coreguard/github_remote_workspace_guard_test.go diff --git a/harness/internal/coreguard/github_remote_workspace_guard_test.go b/harness/internal/coreguard/github_remote_workspace_guard_test.go new file mode 100644 index 00000000..ccc45a2d --- /dev/null +++ b/harness/internal/coreguard/github_remote_workspace_guard_test.go @@ -0,0 +1,200 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var githubRemoteWorkspaceCorePackages = []string{ + "runtime", + "mnemond/state", + "mnemond/admission", + "mnemond/presentation", + "hostagent", +} + +var forbiddenGitHubBackendTerms = []string{ + "github issue", + "github issues", + "github pr", + "github pull request", + "github action", + "github actions", + "issue-based", + "pr-based", + "action-based", + "p2p discovery", + "peer discovery", + "node discovery", + "gossip", + "dht", + "routing table", + "nat traversal", + "overlay network", +} + +func TestGitHubRemoteWorkspaceGuardLogicIsNotVacuous(t *testing.T) { + if !isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github") { + t.Fatal("GitHub backend import matcher must flag the planned exchange/backend/github package") + } + if isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange") { + t.Fatal("plain exchange package must not be treated as GitHub backend") + } + if !isAllowedGitHubBackendDir(filepath.FromSlash("mnemonhub/exchange/backend/github")) { + t.Fatal("planned GitHub backend directory should be allowed under mnemonhub/exchange/backend") + } + if isAllowedGitHubBackendDir(filepath.FromSlash("runtime/github")) { + t.Fatal("GitHub backend outside mnemonhub/exchange/backend must not be allowed") + } + if !hasForbiddenGitHubBackendTerm("use GitHub Issues as assignments") { + t.Fatal("forbidden GitHub teamwork semantic matcher should flag Issue/PR/Actions concepts") + } + if hasForbiddenGitHubBackendTerm("publication branch enumeration") { + t.Fatal("publication terminology should remain allowed") + } +} + +func TestGitHubRemoteWorkspaceBackendDoesNotLeakIntoCore(t *testing.T) { + for _, pkg := range githubRemoteWorkspaceCorePackages { + _, files := packageFiles(t, pkg) + for _, file := range files { + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if isGitHubRemoteBackendImportPath(path) { + t.Errorf("core package %q imports GitHub Remote Workspace backend %q; GitHub must stay below mnemonhub/exchange/backend", pkg, path) + } + } + } + } +} + +func TestGitHubRemoteWorkspaceBackendLocationAndNaming(t *testing.T) { + root := filepath.Clean("..") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + relFile, err := filepath.Rel(root, path) + if err != nil { + return err + } + relDir := filepath.Dir(relFile) + if pathMentionsGitHubBackend(relDir) && !isAllowedGitHubBackendDir(relDir) { + t.Errorf("GitHub backend source %s is outside mnemonhub/exchange/backend", relFile) + } + if isAllowedGitHubBackendDir(relDir) { + assertGitHubBackendFileUsesPublicationVocabulary(t, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk harness/internal: %v", err) + } +} + +func isGitHubRemoteBackendImportPath(path string) bool { + rel := strings.TrimPrefix(path, "github.com/mnemon-dev/mnemon/") + if !strings.HasPrefix(rel, "harness/internal/mnemonhub/exchange/") { + return false + } + tail := strings.TrimPrefix(rel, "harness/internal/mnemonhub/exchange/") + for _, segment := range strings.Split(tail, "/") { + if strings.Contains(strings.ToLower(segment), "github") { + return true + } + } + return false +} + +func isAllowedGitHubBackendDir(relDir string) bool { + clean := filepath.ToSlash(filepath.Clean(relDir)) + return clean == "mnemonhub/exchange/backend" || + strings.HasPrefix(clean, "mnemonhub/exchange/backend/") +} + +func pathMentionsGitHubBackend(relDir string) bool { + for _, segment := range strings.Split(filepath.ToSlash(relDir), "/") { + if strings.Contains(strings.ToLower(segment), "github") { + return true + } + } + return false +} + +func assertGitHubBackendFileUsesPublicationVocabulary(t *testing.T, path string) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.Ident: + if hasForbiddenGitHubBackendTerm(splitIdentifierWords(n.Name)) { + t.Errorf("%s uses forbidden GitHub/P2P backend term %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), n.Name) + } + case *ast.BasicLit: + if n.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(n.Value) + if err != nil { + value = strings.Trim(n.Value, "`\"") + } + if hasForbiddenGitHubBackendTerm(value) { + t.Errorf("%s uses forbidden GitHub/P2P backend literal %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), value) + } + } + return true + }) +} + +func splitIdentifierWords(name string) string { + var b strings.Builder + var prevLower bool + for _, r := range name { + if r == '_' || r == '-' { + b.WriteByte(' ') + prevLower = false + continue + } + if r >= 'A' && r <= 'Z' { + if prevLower { + b.WriteByte(' ') + } + b.WriteRune(r + ('a' - 'A')) + prevLower = false + continue + } + b.WriteRune(r) + prevLower = r >= 'a' && r <= 'z' + } + return b.String() +} + +func hasForbiddenGitHubBackendTerm(text string) bool { + normalized := strings.ToLower(strings.Join(strings.Fields(strings.ReplaceAll(text, "_", " ")), " ")) + for _, term := range forbiddenGitHubBackendTerms { + if strings.Contains(normalized, term) { + return true + } + } + return false +} From 3e2c973afa6d92d0c5442ef4d2529002bedd5648 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 01:53:55 +0800 Subject: [PATCH 03/35] feat(harness): add directional remote plans Add Remote Workspace direction semantics and a shared RemotePlan resolver so manual sync and the in-process worker can route publish and subscribe flows without changing the runtime sync ABI. Legacy HTTP configs without explicit directions keep using the current remote bidirectionally. Directional configs fail closed for unknown directions and for multiple active publish targets. Validation: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemote|TestRemotePlan'; go test ./harness/internal/coreguard; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/sync.go | 135 ++++++++++---- harness/cmd/mnemon-harness/sync_test.go | 49 +++++ harness/internal/app/sync_worker.go | 27 ++- harness/internal/app/sync_worker_test.go | 85 ++++++++- .../internal/mnemonhub/exchange/remotes.go | 170 ++++++++++++++++-- .../mnemonhub/exchange/remotes_test.go | 122 +++++++++++++ 6 files changed, 523 insertions(+), 65 deletions(-) diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 71559db0..fbb7414a 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -200,54 +200,70 @@ func syncPushOnce() (syncPushResult, error) { if len(batch.Events) == 0 { return syncPushResult{}, nil } - remote, err := resolveSyncRemote() + plan, err := resolveSyncRemotePlan() if err != nil { return syncPushResult{}, err } - workspace, err := syncRemoteWorkspaceFor(remote) - if err != nil { - return syncPushResult{}, err - } - resp, err := workspace.SyncPush(contract.SyncPushRequest{ - ReplicaID: batch.ReplicaID, - BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), - Events: batch.Events, - }) - if err != nil { - return syncPushResult{}, fmt.Errorf("sync push failed: %w", err) + if len(plan.PushTargets) == 0 { + return syncPushResult{}, fmt.Errorf("Remote Workspace plan has no push targets") } - if err := exchange.ApplyLocalSyncPushResponse(storePath, remote.ID, resp); err != nil { - return syncPushResult{}, err + result := syncPushResult{} + for _, remote := range plan.PushTargets { + workspace, err := syncRemoteWorkspaceFor(remote) + if err != nil { + return syncPushResult{}, err + } + resp, err := workspace.SyncPush(contract.SyncPushRequest{ + ReplicaID: batch.ReplicaID, + BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), + Events: batch.Events, + }) + if err != nil { + return syncPushResult{}, fmt.Errorf("sync push failed: %w", err) + } + if err := exchange.ApplyLocalSyncPushResponse(storePath, remote.ID, resp); err != nil { + return syncPushResult{}, err + } + result.accepted += len(resp.Accepted) + result.rejected += len(resp.Rejected) + result.conflicts += len(resp.Conflicts) } - return syncPushResult{accepted: len(resp.Accepted), rejected: len(resp.Rejected), conflicts: len(resp.Conflicts)}, nil + return result, nil } func syncPullOnce() (syncPullResult, error) { - remote, err := resolveSyncRemote() - if err != nil { - return syncPullResult{}, err - } - storePath := resolvedSyncStorePath() - state, err := exchange.ReadLocalSyncPullState(storePath, remote.ID) - if err != nil { - return syncPullResult{}, err - } - workspace, err := syncRemoteWorkspaceFor(remote) + plan, err := resolveSyncRemotePlan() if err != nil { return syncPullResult{}, err } - resp, err := workspace.SyncPull(contract.SyncPullRequest{ - ReplicaID: state.ReplicaID, - RemoteCursor: state.RemoteCursor, - }) - if err != nil { - return syncPullResult{}, fmt.Errorf("sync pull failed: %w", err) + if len(plan.PullSources) == 0 { + return syncPullResult{}, fmt.Errorf("Remote Workspace plan has no pull sources") } + storePath := resolvedSyncStorePath() catalog := app.SyncImportCatalog(syncProjectRoot(), os.Stderr) - if err := app.ImportLocalSyncPull(storePath, remote.ID, resp.NextCursor, resp.Events, catalog); err != nil { - return syncPullResult{}, err + result := syncPullResult{} + for _, remote := range plan.PullSources { + state, err := exchange.ReadLocalSyncPullState(storePath, remote.ID) + if err != nil { + return syncPullResult{}, err + } + workspace, err := syncRemoteWorkspaceFor(remote) + if err != nil { + return syncPullResult{}, err + } + resp, err := workspace.SyncPull(contract.SyncPullRequest{ + ReplicaID: state.ReplicaID, + RemoteCursor: state.RemoteCursor, + }) + if err != nil { + return syncPullResult{}, fmt.Errorf("sync pull failed: %w", err) + } + if err := app.ImportLocalSyncPull(storePath, remote.ID, resp.NextCursor, resp.Events, catalog); err != nil { + return syncPullResult{}, err + } + result.events += len(resp.Events) } - return syncPullResult{events: len(resp.Events)}, nil + return result, nil } type syncRemoteConfig struct { @@ -258,6 +274,11 @@ type syncRemoteConfig struct { CAFile string } +type syncRemotePlan struct { + PushTargets []syncRemoteConfig + PullSources []syncRemoteConfig +} + // syncRemoteWorkspaceFor builds the selected Remote Workspace backend for one resolved remote. The // current CLI supports the first-party HTTP mnemon-hub backend; future backends must preserve this // SyncPush/SyncPull/SyncStatus ABI rather than bypassing local import. @@ -279,6 +300,20 @@ func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, } func resolveSyncRemote() (syncRemoteConfig, error) { + plan, err := resolveSyncRemotePlan() + if err != nil { + return syncRemoteConfig{}, err + } + if len(plan.PushTargets) > 0 { + return plan.PushTargets[0], nil + } + if len(plan.PullSources) > 0 { + return plan.PullSources[0], nil + } + return syncRemoteConfig{}, fmt.Errorf("Remote Workspace plan has no remotes") +} + +func resolveSyncRemotePlan() (syncRemotePlan, error) { if strings.TrimSpace(syncRemoteURL) != "" { tokenFile := syncRemoteTokenFile if tokenFile != "" { @@ -286,19 +321,41 @@ func resolveSyncRemote() (syncRemoteConfig, error) { } token, err := resolveSyncToken(syncRemoteToken, tokenFile) if err != nil { - return syncRemoteConfig{}, err + return syncRemotePlan{}, err } - return syncRemoteConfig{ID: syncRemoteID, Backend: exchange.RemoteBackendHTTP, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")}, nil + remote := syncRemoteConfig{ID: syncRemoteID, Backend: exchange.RemoteBackendHTTP, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")} + return syncRemotePlan{PushTargets: []syncRemoteConfig{remote}, PullSources: []syncRemoteConfig{remote}}, nil } - entry, err := exchange.LoadRemoteEntry(resolvedSyncRemotesPath(), syncRemoteID) + plan, err := exchange.LoadRemotePlan(resolvedSyncRemotesPath(), syncRemoteID) if err != nil { - return syncRemoteConfig{}, err + return syncRemotePlan{}, err + } + out := syncRemotePlan{} + for _, entry := range plan.PushTargets { + remote, err := resolveSyncRemoteEntry(entry) + if err != nil { + return syncRemotePlan{}, err + } + out.PushTargets = append(out.PushTargets, remote) } + for _, entry := range plan.PullSources { + remote, err := resolveSyncRemoteEntry(entry) + if err != nil { + return syncRemotePlan{}, err + } + out.PullSources = append(out.PullSources, remote) + } + return out, nil +} + +func resolveSyncRemoteEntry(entry exchange.RemoteEntry) (syncRemoteConfig, error) { if strings.TrimSpace(entry.CredentialRef) == "" && strings.TrimSpace(syncRemoteToken) == "" && strings.TrimSpace(syncRemoteTokenFile) == "" { return syncRemoteConfig{}, fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) } tokenFile := "" - if strings.TrimSpace(entry.CredentialRef) != "" { + if strings.TrimSpace(syncRemoteTokenFile) != "" { + tokenFile = resolveSyncPath(syncRemoteTokenFile) + } else if strings.TrimSpace(entry.CredentialRef) != "" { tokenFile = resolveSyncPath(entry.CredentialRef) } token, err := resolveSyncToken(syncRemoteToken, tokenFile) diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index d8e442a0..f3c66724 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "net/http/httptest" "os" "path/filepath" @@ -347,6 +348,54 @@ func TestSyncRemoteConfigLoadsCredentialRef(t *testing.T) { } } +func TestSyncRemotePlanLoadsDirectionalCredentials(t *testing.T) { + restoreSyncFlags(t) + root := t.TempDir() + writeCredential := func(id, token string) string { + t.Helper() + credRel := filepath.Join(".mnemon", "harness", "sync", "credentials", id+".token") + credPath := filepath.Join(root, credRel) + if err := os.MkdirAll(filepath.Dir(credPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(credPath, []byte(token+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return filepath.ToSlash(credRel) + } + pubCred := writeCredential("pub", "tok-pub") + subCred := writeCredential("sub", "tok-sub") + remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") + if err := os.WriteFile(remotesPath, []byte(fmt.Sprintf(`{ + "schema_version": 1, + "remotes": [{ + "id": "pub", + "direction": "publish", + "endpoint": "http://127.0.0.1:8787", + "credential_ref": %q + }, { + "id": "sub", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:8788", + "credential_ref": %q + }] + }`+"\n", pubCred, subCred)), 0o644); err != nil { + t.Fatal(err) + } + syncRoot = root + + plan, err := resolveSyncRemotePlan() + if err != nil { + t.Fatalf("resolve directional remote plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "pub" || plan.PushTargets[0].Token != "tok-pub" { + t.Fatalf("push target not resolved with its credential: %+v", plan.PushTargets) + } + if len(plan.PullSources) != 1 || plan.PullSources[0].ID != "sub" || plan.PullSources[0].Token != "tok-sub" { + t.Fatalf("pull source not resolved with its credential: %+v", plan.PullSources) + } +} + func restoreSyncFlags(t *testing.T) { t.Helper() oldRoot := syncRoot diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 73a1cda4..077495bf 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -58,7 +58,7 @@ func RunSyncWorker(ctx context.Context, rt *runtime.Runtime, opts SyncWorkerOpti } } -// syncWorkerPass runs ONE push+pull pass against the configured current remote. Gate: when +// syncWorkerPass runs ONE sync pass against the configured Remote Workspace plan. Gate: when // remotes.json does not exist, the pass is a no-op — zero sync activity without a connected remote // (I13), checked per pass so `sync connect` takes effect without a restart. func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { @@ -69,18 +69,29 @@ func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { } return fmt.Errorf("stat Remote Workspace config: %w", err) } - entry, err := exchange.LoadRemoteEntry(remotesPath, "default") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") if err != nil { return err } - remote, err := syncWorkerRemote(entry, opts) - if err != nil { - return err + for _, entry := range plan.PushTargets { + remote, err := syncWorkerRemote(entry, opts) + if err != nil { + return err + } + if err := syncWorkerPush(rt, remote, entry.ID); err != nil { + return err + } } - if err := syncWorkerPush(rt, remote, entry.ID); err != nil { - return err + for _, entry := range plan.PullSources { + remote, err := syncWorkerRemote(entry, opts) + if err != nil { + return err + } + if err := syncWorkerPull(rt, remote, entry.ID, opts.Catalog); err != nil { + return err + } } - return syncWorkerPull(rt, remote, entry.ID, opts.Catalog) + return nil } // syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. Today only diff --git a/harness/internal/app/sync_worker_test.go b/harness/internal/app/sync_worker_test.go index 8956a35b..6fec9ebc 100644 --- a/harness/internal/app/sync_worker_test.go +++ b/harness/internal/app/sync_worker_test.go @@ -62,6 +62,11 @@ func startHub(t *testing.T, principals map[string]contract.ActorID, scopes []con } func connectRemote(t *testing.T, root, endpoint, token string) { + t.Helper() + connectRemoteWithDirection(t, root, endpoint, token, "") +} + +func connectRemoteWithDirection(t *testing.T, root, endpoint, token, direction string) { t.Helper() credRel := filepath.Join(".mnemon", "harness", "sync", "credentials", "hub.token") credPath := filepath.Join(root, credRel) @@ -72,7 +77,11 @@ func connectRemote(t *testing.T, root, endpoint, token string) { t.Fatal(err) } remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") - doc := fmt.Sprintf(`{"schema_version":1,"current":"hub","remotes":[{"id":"hub","endpoint":%q,"credential_ref":%q}]}`, endpoint, filepath.ToSlash(credRel)) + directionField := "" + if strings.TrimSpace(direction) != "" { + directionField = fmt.Sprintf(`,"direction":%q`, direction) + } + doc := fmt.Sprintf(`{"schema_version":1,"current":"hub","remotes":[{"id":"hub"%s,"endpoint":%q,"credential_ref":%q}]}`, directionField, endpoint, filepath.ToSlash(credRel)) if err := os.WriteFile(remotesPath, []byte(doc+"\n"), 0o600); err != nil { t.Fatal(err) } @@ -239,6 +248,80 @@ func TestSyncWorkerPushPullRoundTrip(t *testing.T) { } } +func TestSyncWorkerPublishOnlyDoesNotPull(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + endpoint, hub, _ := startHub(t, map[string]contract.ActorID{ + "tok-local": "replica-local@team", + "tok-other": "replica-other@team", + }, []contract.ResourceRef{progressRef}) + connectRemoteWithDirection(t, root, endpoint, "tok-local", "publish") + + observeProgress(t, rt, "m-publish-only", "publish-only local progress reaches the hub") + foreign := foreignProgressMaterial("dec-publish-only-foreign", "remote-publish-only", "publish-only must not import this") + if resp, err := hub.Push("replica-other@team", contract.SyncPushRequest{ + ReplicaID: "other-replica", BatchID: "seed-publish-only", Events: testSyncedEvents(t, foreign), + }); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed foreign material: %+v err=%v", resp, err) + } + + if err := syncWorkerPass(rt, SyncWorkerOptions{ProjectRoot: root}); err != nil { + t.Fatalf("publish-only worker pass: %v", err) + } + if pending, _ := rt.PendingSyncedEvents(); len(pending) != 0 { + t.Fatalf("publish-only pass must push local synced events, got %+v", pending) + } + if st, _ := hub.Status("replica-local@team"); st.HubEventsReceived != 2 { + t.Fatalf("publish-only pass must append local event to hub without duplicate work: %+v", st) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + content, _ := fields["content"].(string) + if strings.Contains(content, "publish-only must not import this") { + t.Fatalf("publish-only pass must not pull remote content:\n%s", content) + } +} + +func TestSyncWorkerSubscribeOnlyDoesNotPush(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + endpoint, hub, _ := startHub(t, map[string]contract.ActorID{ + "tok-local": "replica-local@team", + "tok-other": "replica-other@team", + }, []contract.ResourceRef{progressRef}) + connectRemoteWithDirection(t, root, endpoint, "tok-local", "subscribe") + + observeProgress(t, rt, "m-subscribe-only", "subscribe-only local progress stays pending") + foreign := foreignProgressMaterial("dec-subscribe-only-foreign", "remote-subscribe-only", "subscribe-only imports this") + if resp, err := hub.Push("replica-other@team", contract.SyncPushRequest{ + ReplicaID: "other-replica", BatchID: "seed-subscribe-only", Events: testSyncedEvents(t, foreign), + }); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed foreign material: %+v err=%v", resp, err) + } + + if err := syncWorkerPass(rt, SyncWorkerOptions{ProjectRoot: root}); err != nil { + t.Fatalf("subscribe-only worker pass: %v", err) + } + if pending, _ := rt.PendingSyncedEvents(); len(pending) != 1 { + t.Fatalf("subscribe-only pass must not push local synced events, got %+v", pending) + } + if st, _ := hub.Status("replica-local@team"); st.HubEventsReceived != 1 { + t.Fatalf("subscribe-only pass must not append local event to hub: %+v", st) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + content, _ := fields["content"].(string) + if !strings.Contains(content, "subscribe-only imports this") { + t.Fatalf("subscribe-only pass must pull remote content:\n%s", content) + } +} + // Co-existence proof for the merged policy (v1.1 #2): the serving runtime carries host rules AND // sync-import rules; host-agent flow is unaffected (admission + secret-deny behave exactly as // before), foreign events pass through the principal gates, and the import path works in-process. diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index f2d92a10..d511205b 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -19,10 +19,19 @@ type RemotesDoc struct { const RemoteBackendHTTP = "http" +const ( + RemoteDirectionBidirectional = "bidirectional" + RemoteDirectionPublish = "publish" + RemoteDirectionSubscribe = "subscribe" +) + type RemoteEntry struct { // Backend selects the Remote Workspace implementation. Empty is the v1 // compatibility default: the first-party HTTP mnemon-hub wire. - Backend string `json:"backend,omitempty"` + Backend string `json:"backend,omitempty"` + // Direction scopes how this local replica uses the remote. Empty is the v1 + // compatibility default: bidirectional push+pull. + Direction string `json:"direction,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` CredentialRef string `json:"credential_ref"` @@ -31,6 +40,11 @@ type RemoteEntry struct { CAFile string `json:"ca_file,omitempty"` } +type RemotePlan struct { + PushTargets []RemoteEntry + PullSources []RemoteEntry +} + func (r RemoteEntry) NormalizedBackend() string { backend := strings.TrimSpace(r.Backend) if backend == "" { @@ -39,6 +53,14 @@ func (r RemoteEntry) NormalizedBackend() string { return backend } +func (r RemoteEntry) NormalizedDirection() string { + direction := strings.TrimSpace(r.Direction) + if direction == "" { + return RemoteDirectionBidirectional + } + return direction +} + func validateRemoteBackend(backend string) error { switch backend { case RemoteBackendHTTP: @@ -48,35 +70,149 @@ func validateRemoteBackend(backend string) error { } } +func validateRemoteDirection(direction string) error { + switch direction { + case RemoteDirectionBidirectional, RemoteDirectionPublish, RemoteDirectionSubscribe: + return nil + default: + return fmt.Errorf("unsupported Remote Workspace direction %q (supported: %s, %s, %s)", direction, RemoteDirectionBidirectional, RemoteDirectionPublish, RemoteDirectionSubscribe) + } +} + // LoadRemoteEntry resolves one remote from the registry at path: id "default" follows the doc's -// `current` pointer. It validates schema version and the selected backend; credential presence is -// the caller's concern (the CLI may inject a --token override). +// `current` pointer. It validates schema version, backend, and direction; credential presence is the +// caller's concern (the CLI may inject a --token override). func LoadRemoteEntry(path, id string) (RemoteEntry, error) { + doc, err := loadRemotesDoc(path) + if err != nil { + return RemoteEntry{}, err + } + if id == "default" && strings.TrimSpace(doc.Current) != "" { + id = strings.TrimSpace(doc.Current) + } + remote, ok := findRemoteEntry(doc, id) + if !ok { + return RemoteEntry{}, fmt.Errorf("Remote Workspace %q not found in %s", id, path) + } + remote, err = normalizeRemoteEntry(remote) + if err != nil { + return RemoteEntry{}, fmt.Errorf("Remote Workspace %q: %w", id, err) + } + return remote, nil +} + +// LoadRemotePlan resolves the active Remote Workspace plan. Legacy configs with `current` and no +// explicit directions keep selecting exactly the current remote. Directional configs select all +// remotes by default so a local replica can publish to one workspace and subscribe to many sources. +func LoadRemotePlan(path, id string) (RemotePlan, error) { + doc, err := loadRemotesDoc(path) + if err != nil { + return RemotePlan{}, err + } + entries, err := selectRemotePlanEntries(doc, id) + if err != nil { + return RemotePlan{}, fmt.Errorf("%w in %s", err, path) + } + plan := RemotePlan{} + for _, entry := range entries { + entry, err = normalizeRemoteEntry(entry) + if err != nil { + return RemotePlan{}, fmt.Errorf("Remote Workspace %q: %w", entry.ID, err) + } + switch entry.Direction { + case RemoteDirectionBidirectional: + plan.PushTargets = append(plan.PushTargets, entry) + plan.PullSources = append(plan.PullSources, entry) + case RemoteDirectionPublish: + plan.PushTargets = append(plan.PushTargets, entry) + case RemoteDirectionSubscribe: + plan.PullSources = append(plan.PullSources, entry) + } + } + if len(plan.PushTargets) > 1 { + return RemotePlan{}, fmt.Errorf("multiple Remote Workspace publish targets unsupported: %s", remoteIDs(plan.PushTargets)) + } + return plan, nil +} + +func loadRemotesDoc(path string) (RemotesDoc, error) { raw, err := os.ReadFile(path) if err != nil { - return RemoteEntry{}, fmt.Errorf("read Remote Workspace config: %w", err) + return RemotesDoc{}, fmt.Errorf("read Remote Workspace config: %w", err) } var doc RemotesDoc if err := json.Unmarshal(raw, &doc); err != nil { - return RemoteEntry{}, fmt.Errorf("parse Remote Workspace config: %w", err) + return RemotesDoc{}, fmt.Errorf("parse Remote Workspace config: %w", err) } if doc.SchemaVersion != 1 { - return RemoteEntry{}, fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) + return RemotesDoc{}, fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) } - if id == "default" && strings.TrimSpace(doc.Current) != "" { - id = strings.TrimSpace(doc.Current) + return doc, nil +} + +func normalizeRemoteEntry(remote RemoteEntry) (RemoteEntry, error) { + remote.Backend = remote.NormalizedBackend() + if err := validateRemoteBackend(remote.Backend); err != nil { + return RemoteEntry{}, err + } + remote.Direction = remote.NormalizedDirection() + if err := validateRemoteDirection(remote.Direction); err != nil { + return RemoteEntry{}, err } + if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { + return RemoteEntry{}, fmt.Errorf("has no endpoint") + } + return remote, nil +} + +func findRemoteEntry(doc RemotesDoc, id string) (RemoteEntry, bool) { for _, remote := range doc.Remotes { if remote.ID == id { - remote.Backend = remote.NormalizedBackend() - if err := validateRemoteBackend(remote.Backend); err != nil { - return RemoteEntry{}, fmt.Errorf("Remote Workspace %q: %w", id, err) - } - if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { - return RemoteEntry{}, fmt.Errorf("Remote Workspace %q has no endpoint", id) - } - return remote, nil + return remote, true + } + } + return RemoteEntry{}, false +} + +func selectRemotePlanEntries(doc RemotesDoc, id string) ([]RemoteEntry, error) { + id = strings.TrimSpace(id) + if id == "" { + id = "default" + } + if id != "default" { + remote, ok := findRemoteEntry(doc, id) + if !ok { + return nil, fmt.Errorf("Remote Workspace %q not found", id) + } + return []RemoteEntry{remote}, nil + } + current := strings.TrimSpace(doc.Current) + if current != "" && !hasExplicitRemoteDirection(doc) { + remote, ok := findRemoteEntry(doc, current) + if !ok { + return nil, fmt.Errorf("Remote Workspace %q not found", current) + } + return []RemoteEntry{remote}, nil + } + if len(doc.Remotes) == 0 { + return nil, fmt.Errorf("Remote Workspace config has no remotes") + } + return append([]RemoteEntry(nil), doc.Remotes...), nil +} + +func hasExplicitRemoteDirection(doc RemotesDoc) bool { + for _, remote := range doc.Remotes { + if strings.TrimSpace(remote.Direction) != "" { + return true } } - return RemoteEntry{}, fmt.Errorf("Remote Workspace %q not found in %s", id, path) + return false +} + +func remoteIDs(remotes []RemoteEntry) string { + ids := make([]string, 0, len(remotes)) + for _, remote := range remotes { + ids = append(ids, remote.ID) + } + return strings.Join(ids, ", ") } diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go index b4f4724d..70e07df2 100644 --- a/harness/internal/mnemonhub/exchange/remotes_test.go +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -28,6 +28,9 @@ func TestLoadRemoteEntryDefaultsBackendToHTTP(t *testing.T) { if remote.Backend != RemoteBackendHTTP || remote.NormalizedBackend() != RemoteBackendHTTP { t.Fatalf("legacy remote backend = %q, want %q", remote.Backend, RemoteBackendHTTP) } + if remote.Direction != RemoteDirectionBidirectional || remote.NormalizedDirection() != RemoteDirectionBidirectional { + t.Fatalf("legacy remote direction = %q, want %q", remote.Direction, RemoteDirectionBidirectional) + } } func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { @@ -48,3 +51,122 @@ func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { t.Fatalf("unsupported backend must fail closed, got %v", err) } } + +func TestLoadRemotePlanLegacyCurrentIsBidirectional(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "hub", + "remotes": [{ + "id": "old", + "endpoint": "http://127.0.0.1:9786", + "credential_ref": ".mnemon/harness/sync/credentials/old.token" + }, { + "id": "hub", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + plan, err := LoadRemotePlan(path, "default") + if err != nil { + t.Fatalf("load legacy plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "hub" { + t.Fatalf("legacy push targets = %+v, want hub only", plan.PushTargets) + } + if len(plan.PullSources) != 1 || plan.PullSources[0].ID != "hub" { + t.Fatalf("legacy pull sources = %+v, want hub only", plan.PullSources) + } +} + +func TestLoadRemotePlanHonorsDirections(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "pub", + "direction": "publish", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/pub.token" + }, { + "id": "sub-a", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:9788", + "credential_ref": ".mnemon/harness/sync/credentials/sub-a.token" + }, { + "id": "sub-b", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:9789", + "credential_ref": ".mnemon/harness/sync/credentials/sub-b.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + plan, err := LoadRemotePlan(path, "default") + if err != nil { + t.Fatalf("load directional plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "pub" { + t.Fatalf("directional push targets = %+v, want pub only", plan.PushTargets) + } + if got := remotePlanIDs(plan.PullSources); strings.Join(got, ",") != "sub-a,sub-b" { + t.Fatalf("directional pull sources = %v, want sub-a,sub-b", got) + } +} + +func TestLoadRemotePlanRejectsUnknownDirection(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "mesh", + "direction": "gossip", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/mesh.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemotePlan(path, "default") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace direction") { + t.Fatalf("unsupported direction must fail closed, got %v", err) + } +} + +func TestLoadRemotePlanRejectsMultiplePublishTargets(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "hub-a", + "direction": "publish", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub-a.token" + }, { + "id": "hub-b", + "direction": "bidirectional", + "endpoint": "http://127.0.0.1:9788", + "credential_ref": ".mnemon/harness/sync/credentials/hub-b.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemotePlan(path, "default") + if err == nil || !strings.Contains(err.Error(), "multiple Remote Workspace publish targets unsupported") { + t.Fatalf("multiple publish targets must fail closed, got %v", err) + } +} + +func remotePlanIDs(remotes []RemoteEntry) []string { + ids := make([]string, 0, len(remotes)) + for _, remote := range remotes { + ids = append(ids, remote.ID) + } + return ids +} From e9a92b1d3a564ed0f65145a948bae48c4afa03a5 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 01:54:10 +0800 Subject: [PATCH 04/35] fix(harness): stabilize acceptance run-root guard Resolve run-root and .testdata through physical paths before comparing containment. This keeps the guard behavior intact while avoiding false rejections on systems where temp paths cross symlinked prefixes such as /var and /private/var. Validation: go test ./harness/cmd/mnemon-harness -run 'TestPrepareR1AcceptanceRunRoot|TestSync|TestRemote|TestLoadRemote|TestRemotePlan'; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/acceptance.go | 30 +++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index b24357ff..3f009c34 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -376,7 +376,11 @@ func installAcceptanceHarnessBinary(runRoot string) (string, error) { } func prepareR1AcceptanceRunRoot(runRoot string) error { - testdataRoot, err := filepath.Abs(".testdata") + testdataRoot, err := physicalAcceptancePath(".testdata") + if err != nil { + return err + } + runRoot, err = physicalAcceptancePath(runRoot) if err != nil { return err } @@ -401,6 +405,30 @@ func prepareR1AcceptanceRunRoot(runRoot string) error { return nil } +func physicalAcceptancePath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved, nil + } + var missing []string + for current := abs; ; current = filepath.Dir(current) { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return resolved, nil + } + parent := filepath.Dir(current) + if parent == current { + return abs, nil + } + missing = append(missing, filepath.Base(current)) + } +} + func setupR1CodexAgents(runRoot, binDir, controlURL string, count int, sourceCodexHome string) ([]r1CodexAgent, access.LoadedBindings, error) { var agents []r1CodexAgent var loaded access.LoadedBindings From fc71c413c53a15495c16152b259195bed98d5194 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 01:59:38 +0800 Subject: [PATCH 05/35] feat(harness): ingest remote pull diagnostics Consume SyncPullResponse diagnostics through the same trusted sync import path as remote events. Pull-side Remote Workspace diagnostics now become sync.remote_diagnostic.observed observations and durable sync.diagnostic entries, with deterministic external ids for repeat-pull dedupe. Validation: go test ./harness/internal/app ./harness/internal/mnemond/policy ./harness/cmd/mnemon-harness -run 'TestSync|TestDiagnostic|TestWorkerPullRemoteDiagnostic|TestPrepareR1AcceptanceRunRoot'; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/sync.go | 2 +- harness/internal/app/local_runtime.go | 4 +- harness/internal/app/local_sync.go | 71 +++++++++++++- .../app/sync_remote_diagnostic_test.go | 95 +++++++++++++++++++ harness/internal/app/sync_worker.go | 3 + .../internal/mnemond/policy/sync_import.go | 28 ++++++ .../mnemond/policy/sync_import_test.go | 32 +++++++ 7 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 harness/internal/app/sync_remote_diagnostic_test.go diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index fbb7414a..3862bdea 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -258,7 +258,7 @@ func syncPullOnce() (syncPullResult, error) { if err != nil { return syncPullResult{}, fmt.Errorf("sync pull failed: %w", err) } - if err := app.ImportLocalSyncPull(storePath, remote.ID, resp.NextCursor, resp.Events, catalog); err != nil { + if err := app.ImportLocalSyncPullWithDiagnostics(storePath, remote.ID, resp.NextCursor, resp.Events, resp.Diagnostics, catalog); err != nil { return syncPullResult{}, err } result.events += len(resp.Events) diff --git a/harness/internal/app/local_runtime.go b/harness/internal/app/local_runtime.go index 3f354864..7cb143b0 100644 --- a/harness/internal/app/local_runtime.go +++ b/harness/internal/app/local_runtime.go @@ -56,6 +56,7 @@ func withSyncImport(rc runtime.RuntimeConfig, bindings []access.ChannelBinding, rules := append([]admission.Rule(nil), rc.Rules.Rules()...) rules = append(rules, policy.RemoteImportRules(catalog, contract.SyncImportActor)...) rules = append(rules, policy.SyncImportSkippedRule(contract.SyncImportActor)) + rules = append(rules, policy.SyncRemoteDiagnosticRule(contract.SyncImportActor)) rc.Rules = admission.NewRuleSet(rules...) if rc.Subs == nil { rc.Subs = map[contract.ActorID]contract.Subscription{} @@ -380,7 +381,8 @@ func SyncImportRuntimeConfig(refs []contract.ResourceRef, catalog policy.Registr } } rules := append(policy.RemoteImportRules(catalog, contract.SyncImportActor), - policy.SyncImportSkippedRule(contract.SyncImportActor)) + policy.SyncImportSkippedRule(contract.SyncImportActor), + policy.SyncRemoteDiagnosticRule(contract.SyncImportActor)) return runtime.RuntimeConfig{ Subs: map[contract.ActorID]contract.Subscription{ contract.SyncImportActor: {Actor: contract.SyncImportActor, Refs: refs}, diff --git a/harness/internal/app/local_sync.go b/harness/internal/app/local_sync.go index 2422577c..71ac6f3e 100644 --- a/harness/internal/app/local_sync.go +++ b/harness/internal/app/local_sync.go @@ -1,6 +1,8 @@ package app import ( + "crypto/sha256" + "encoding/hex" "fmt" "strings" "time" @@ -18,10 +20,21 @@ import ( // boots its own import runtime by path, so it must never run inside a serving process (the in-process // worker drives importPulledEvents over the LIVE runtime instead — flock, v1.1 #2). func ImportLocalSyncPull(storePath, remoteID, nextCursor string, events []eventmodel.EventEnvelope, catalog policy.Registry) error { - if len(events) > 0 { - refs, err := refsFromSyncedEvents(events) - if err != nil { - return err + return ImportLocalSyncPullWithDiagnostics(storePath, remoteID, nextCursor, events, nil, catalog) +} + +// ImportLocalSyncPullWithDiagnostics is ImportLocalSyncPull plus pull-side Remote Workspace +// diagnostics. Diagnostics enter as trusted sync.remote_diagnostic.observed observations and are +// converted by policy into durable sync.diagnostic events, exactly like skipped-kind diagnostics. +func ImportLocalSyncPullWithDiagnostics(storePath, remoteID, nextCursor string, events []eventmodel.EventEnvelope, diagnostics []contract.EventExchangeResult, catalog policy.Registry) error { + if len(events) > 0 || len(diagnostics) > 0 { + var refs []contract.ResourceRef + if len(events) > 0 { + var err error + refs, err = refsFromSyncedEvents(events) + if err != nil { + return err + } } rt, err := OpenSyncImportRuntime(storePath, refs, catalog) if err != nil { @@ -31,6 +44,10 @@ func ImportLocalSyncPull(storePath, remoteID, nextCursor string, events []eventm _ = rt.Close() return err } + if err := importRemoteDiagnostics(rt, remoteID, diagnostics); err != nil { + _ = rt.Close() + return err + } if err := rt.Close(); err != nil { return err } @@ -93,6 +110,40 @@ func importPulledEvents(rt *runtime.Runtime, remoteID string, events []eventmode return nil } +func importRemoteDiagnostics(rt *runtime.Runtime, remoteID string, diagnostics []contract.EventExchangeResult) error { + if len(diagnostics) == 0 { + return nil + } + pulledAt := time.Now().UTC().Format(time.RFC3339) + for _, item := range diagnostics { + env := contract.ObservationEnvelope{ + ExternalID: syncRemoteDiagnosticExternalID(remoteID, item), + Event: contract.Event{ + Type: policy.SyncRemoteDiagnosticObserved, + Payload: map[string]any{ + "remote_id": remoteID, + "origin_mnemond": item.OriginMnemond, + "event_id": item.EventID, + "subject": string(item.Subject), + "status": item.Status, + "diagnostic": item.Diagnostic, + "pulled_at": pulledAt, + }, + }, + } + _, dup, err := rt.IngestTrusted(contract.SyncImportActor, env) + if err != nil { + return fmt.Errorf("ingest remote workspace diagnostic: %w", err) + } + if !dup { + if _, err := rt.Tick(); err != nil { + return fmt.Errorf("apply remote workspace diagnostic: %w", err) + } + } + } + return nil +} + func refsFromSyncedEvents(events []eventmodel.EventEnvelope) ([]contract.ResourceRef, error) { seen := map[contract.ResourceRef]bool{} var refs []contract.ResourceRef @@ -119,3 +170,15 @@ func syncPullExternalID(remoteID string, material contract.SyncedEventMaterial) string(material.ResourceRef.ID), }, ":") } + +func syncRemoteDiagnosticExternalID(remoteID string, item contract.EventExchangeResult) string { + sum := sha256.Sum256([]byte(strings.Join([]string{ + remoteID, + item.OriginMnemond, + item.EventID, + string(item.Subject), + item.Status, + item.Diagnostic, + }, "\x00"))) + return "pull:" + remoteID + ":diagnostic:" + hex.EncodeToString(sum[:12]) +} diff --git a/harness/internal/app/sync_remote_diagnostic_test.go b/harness/internal/app/sync_remote_diagnostic_test.go new file mode 100644 index 00000000..9b9ca794 --- /dev/null +++ b/harness/internal/app/sync_remote_diagnostic_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +type staticPullRemoteWorkspace struct { + resp contract.SyncPullResponse +} + +func (s staticPullRemoteWorkspace) SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) { + return contract.SyncPushResponse{}, nil +} + +func (s staticPullRemoteWorkspace) SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) { + return s.resp, nil +} + +func (s staticPullRemoteWorkspace) SyncStatus() (contract.SyncStatusResponse, error) { + return contract.SyncStatusResponse{}, nil +} + +func countRemoteDiagnostics(t *testing.T, rt *runtime.Runtime, remoteID string, want string) int { + t.Helper() + events, err := rt.PendingEvents(0) + if err != nil { + t.Fatalf("events: %v", err) + } + n := 0 + for _, ev := range events { + if ev.Type == "sync.remote_diagnostic.observed" && + ev.Payload["remote_id"] == remoteID && + strings.Contains(stringPayload(ev.Payload, "diagnostic"), want) { + n++ + } + if ev.Type == "sync.diagnostic" && + strings.Contains(stringPayload(ev.Payload, "reason"), want) { + n++ + } + } + return n +} + +func stringPayload(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return value +} + +func TestWorkerPullRemoteDiagnosticLandsDurablyOnce(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + progress := foreignProgressMaterial("dec-remote-diagnostic-progress", "remote-diagnostic-progress", "valid progress imports beside diagnostic") + progressEnv, err := contract.SyncedEventEnvelopeFromMaterial(progress) + if err != nil { + t.Fatalf("materialize progress: %v", err) + } + remote := staticPullRemoteWorkspace{resp: contract.SyncPullResponse{ + Events: []eventmodel.EventEnvelope{progressEnv}, + Diagnostics: []contract.EventExchangeResult{{ + OriginMnemond: "agent-b", + EventID: "bad-publication-entry", + Subject: eventmodel.Subject("progress_digest", "project"), + Status: "invalid", + Diagnostic: "publication digest mismatch", + }}, + NextCursor: "1", + }} + + if err := syncWorkerPull(rt, remote, "github-sub", nil); err != nil { + t.Fatalf("worker pull with diagnostic: %v", err) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + if content, _ := fields["content"].(string); !strings.Contains(content, "valid progress imports beside diagnostic") { + t.Fatalf("valid pull event must still import:\n%s", content) + } + if got := countRemoteDiagnostics(t, rt, "github-sub", "publication digest mismatch"); got != 2 { + t.Fatalf("remote diagnostic must land as observation + durable diagnostic, got %d matching events", got) + } + + if err := syncWorkerPull(rt, remote, "github-sub", nil); err != nil { + t.Fatalf("repeat worker pull with diagnostic: %v", err) + } + if got := countRemoteDiagnostics(t, rt, "github-sub", "publication digest mismatch"); got != 2 { + t.Fatalf("repeat pull must dedupe remote diagnostic, got %d matching events", got) + } +} diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 077495bf..84ab9007 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -176,5 +176,8 @@ func syncWorkerPull(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remote if err := importPulledEvents(rt, remoteID, resp.Events, catalog); err != nil { return err } + if err := importRemoteDiagnostics(rt, remoteID, resp.Diagnostics); err != nil { + return err + } return exchange.SetPullCursor(rt, remoteID, resp.NextCursor) } diff --git a/harness/internal/mnemond/policy/sync_import.go b/harness/internal/mnemond/policy/sync_import.go index a09ffb9c..1640f3a2 100644 --- a/harness/internal/mnemond/policy/sync_import.go +++ b/harness/internal/mnemond/policy/sync_import.go @@ -94,6 +94,11 @@ func sortedImportable(catalog Registry) []EventPackage { // origin_replica_id, local_decision_id, remote_id}. const SyncImportSkippedObserved = "sync.import_skipped.observed" +// SyncRemoteDiagnosticObserved is the observation a sync puller ingests when a Remote Workspace +// returns a pull-side diagnostic for an invalid/rejected/conflicting publication entry. Payload: +// {remote_id, origin_mnemond, event_id, subject, status, diagnostic}. +const SyncRemoteDiagnosticObserved = "sync.remote_diagnostic.observed" + // SyncImportSkippedRule is the legal diagnostic mechanism for skipped kinds: it Handles ONLY the // skipped observation, gates on the sync import principal (foreign events pass through), and always // denies with a reason naming the kind — the deny is what produces the durable *.diagnostic (S7); @@ -114,3 +119,26 @@ func SyncImportSkippedRule(principal contract.ActorID) admission.Rule { }, nil }) } + +// SyncRemoteDiagnosticRule is the legal diagnostic mechanism for pull-side Remote Workspace +// diagnostics. Like skipped-kind import, it denies a sync.* observation so the kernel emits one +// durable sync.diagnostic with lineage to the original remote diagnostic observation. +func SyncRemoteDiagnosticRule(principal contract.ActorID) admission.Rule { + return admission.NewNativeRule("sync-remote-diagnostic:"+string(principal), principal, "", []string{SyncRemoteDiagnosticObserved}, + func(in admission.RuleInput) (contract.RuleDecision, error) { + if in.Event.Actor != principal { + return contract.RuleDecision{Verdict: contract.VerdictAllow}, nil + } + remoteID, _ := in.Event.Payload["remote_id"].(string) + status, _ := in.Event.Payload["status"].(string) + origin, _ := in.Event.Payload["origin_mnemond"].(string) + eventID, _ := in.Event.Payload["event_id"].(string) + subject, _ := in.Event.Payload["subject"].(string) + diagnostic, _ := in.Event.Payload["diagnostic"].(string) + return contract.RuleDecision{ + Verdict: contract.VerdictDeny, + Reasons: []string{fmt.Sprintf("remote workspace diagnostic: remote_id=%q status=%q origin_mnemond=%q event_id=%q subject=%q: %s", + remoteID, status, origin, eventID, subject, diagnostic)}, + }, nil + }) +} diff --git a/harness/internal/mnemond/policy/sync_import_test.go b/harness/internal/mnemond/policy/sync_import_test.go index e8948e33..42eee2ab 100644 --- a/harness/internal/mnemond/policy/sync_import_test.go +++ b/harness/internal/mnemond/policy/sync_import_test.go @@ -32,6 +32,38 @@ func TestSyncImportSkippedRuleDeniesNamingKind(t *testing.T) { } } +func TestSyncRemoteDiagnosticRuleDeniesNamingRemoteDiagnostic(t *testing.T) { + r := SyncRemoteDiagnosticRule(contract.SyncImportActor) + if r.Handles("fixture_record.write_candidate.observed") || !r.Handles(SyncRemoteDiagnosticObserved) { + t.Fatal("rule must handle exactly the remote diagnostic observation type") + } + dec, err := r.Evaluate(admission.RuleInput{Event: contract.Event{ + Type: SyncRemoteDiagnosticObserved, + Actor: contract.SyncImportActor, + Payload: map[string]any{ + "remote_id": "github-sub", + "origin_mnemond": "agent-b", + "event_id": "evt-bad", + "subject": "progress_digest/project", + "status": "invalid", + "diagnostic": "digest mismatch", + }, + }}) + if err != nil { + t.Fatal(err) + } + if dec.Verdict != contract.VerdictDeny || len(dec.Reasons) != 1 || + !strings.Contains(dec.Reasons[0], `"github-sub"`) || + !strings.Contains(dec.Reasons[0], `"invalid"`) || + !strings.Contains(dec.Reasons[0], "digest mismatch") { + t.Fatalf("remote diagnostic must deny naming remote/status/reason, got %+v", dec) + } + foreign, err := r.Evaluate(admission.RuleInput{Event: contract.Event{Type: SyncRemoteDiagnosticObserved, Actor: "someone@else"}}) + if err != nil || foreign.Verdict != contract.VerdictAllow { + t.Fatalf("a foreign principal's event must pass through, got %+v err=%v", foreign, err) + } +} + // The embedded importable set is descriptor-derived (PD6, replacing the former hardcoded // contract.SyncableResourceKinds): the embedded catalog opts each syncable kind into Remote // Workspace import under its declared closed-set merge strategy. This is the pin the deleted From 74d9c13c2ab871e69e91005b26d00168e8b87648 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:03:20 +0800 Subject: [PATCH 06/35] feat(harness): add publication store seam Introduce a repository-shaped PublicationStore interface plus deterministic in-memory implementation for Remote Workspace publication backends. The fake store validates publication branches and paths, supports idempotent event puts, conflict detection, event listing cursors, and team metadata file reads/writes without any GitHub API dependency. Validation: go test ./harness/internal/mnemonhub/exchange -run 'TestPublicationStore'; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- .../mnemonhub/exchange/publication_store.go | 331 ++++++++++++++++++ .../exchange/publication_store_test.go | 185 ++++++++++ 2 files changed, 516 insertions(+) create mode 100644 harness/internal/mnemonhub/exchange/publication_store.go create mode 100644 harness/internal/mnemonhub/exchange/publication_store_test.go diff --git a/harness/internal/mnemonhub/exchange/publication_store.go b/harness/internal/mnemonhub/exchange/publication_store.go new file mode 100644 index 00000000..64520839 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/publication_store.go @@ -0,0 +1,331 @@ +package exchange + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + pathpkg "path" + "sort" + "strconv" + "strings" + "sync" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +const PublicationEventRoot = ".mnemonhub/v1/events" + +// PublicationStore is the storage seam under a Remote Workspace publication backend. It is +// intentionally repository-shaped but not tied to any GitHub client, so exchange semantics can be +// tested against a deterministic fake before a real API adapter exists. +type PublicationStore interface { + PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) + ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) + ReadFile(ctx context.Context, branch string, path string) ([]byte, error) + WriteFile(ctx context.Context, branch string, path string, body []byte) error +} + +type PublicationPutResult struct { + Created bool + ExistsSame bool + Conflict bool +} + +type PublicationStoredEvent struct { + Path string + Body []byte + Cursor string +} + +type PublicationListResult struct { + Events []PublicationStoredEvent + NextCursor string +} + +func PublicationEventPath(env eventmodel.EventEnvelope) (string, error) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return "", err + } + origin, err := normalizePublicationPathSegment(material.OriginReplicaID) + if err != nil { + return "", fmt.Errorf("publication origin: %w", err) + } + key, err := PublicationEventKey(env) + if err != nil { + return "", err + } + return PublicationEventRoot + "/" + origin + "/" + key + ".json", nil +} + +func PublicationEventKey(env eventmodel.EventEnvelope) (string, error) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(strings.Join([]string{ + material.OriginReplicaID, + material.LocalDecisionID, + strconv.FormatInt(material.LocalIngestSeq, 10), + string(material.ResourceRef.Kind), + string(material.ResourceRef.ID), + material.FieldsDigest, + }, "\x00"))) + return hex.EncodeToString(sum[:16]), nil +} + +func NormalizePublicationBranch(branch string) (string, error) { + branch = strings.TrimSpace(branch) + if branch == "" { + return "", fmt.Errorf("publication branch is required") + } + if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + for _, part := range strings.Split(branch, "/") { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + } + if pathpkg.Clean(branch) != branch { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + if branch != "mnemon/team" && !strings.HasPrefix(branch, "mnemon/") { + return "", fmt.Errorf("publication branch %q is outside the mnemon namespace", branch) + } + return branch, nil +} + +func NormalizePublicationPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("publication path is required") + } + if strings.Contains(path, "\\") || strings.HasPrefix(path, "/") { + return "", fmt.Errorf("publication path %q is invalid", path) + } + for _, part := range strings.Split(path, "/") { + if part == ".." { + return "", fmt.Errorf("publication path %q is invalid", path) + } + } + clean := pathpkg.Clean(path) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("publication path %q is invalid", path) + } + return clean, nil +} + +type MemoryPublicationStore struct { + mu sync.Mutex + branches map[string]*memoryPublicationBranch +} + +type memoryPublicationBranch struct { + seq int64 + files map[string]memoryPublicationFile +} + +type memoryPublicationFile struct { + body []byte + seq int64 + event bool +} + +func NewMemoryPublicationStore(branches ...string) (*MemoryPublicationStore, error) { + store := &MemoryPublicationStore{branches: map[string]*memoryPublicationBranch{}} + for _, branch := range branches { + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return nil, err + } + if _, ok := store.branches[branch]; !ok { + store.branches[branch] = &memoryPublicationBranch{files: map[string]memoryPublicationFile{}} + } + } + return store, nil +} + +func (s *MemoryPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) { + if err := ctx.Err(); err != nil { + return PublicationPutResult{}, err + } + branch, path, err := s.normalizeEventRef(branch, path) + if err != nil { + return PublicationPutResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return PublicationPutResult{}, err + } + if existing, ok := b.files[path]; ok { + if bytes.Equal(existing.body, body) { + return PublicationPutResult{ExistsSame: true}, nil + } + return PublicationPutResult{Conflict: true}, nil + } + b.seq++ + b.files[path] = memoryPublicationFile{body: append([]byte(nil), body...), seq: b.seq, event: true} + return PublicationPutResult{Created: true}, nil +} + +func (s *MemoryPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) { + if err := ctx.Err(); err != nil { + return PublicationListResult{}, err + } + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return PublicationListResult{}, err + } + prefix, err = normalizePublicationEventPrefix(prefix) + if err != nil { + return PublicationListResult{}, err + } + after, err := publicationCursor(cursor) + if err != nil { + return PublicationListResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return PublicationListResult{}, err + } + events := make([]PublicationStoredEvent, 0) + for path, file := range b.files { + if !file.event || file.seq <= after || !strings.HasPrefix(path, prefix) { + continue + } + events = append(events, PublicationStoredEvent{ + Path: path, + Body: append([]byte(nil), file.body...), + Cursor: strconv.FormatInt(file.seq, 10), + }) + } + sort.Slice(events, func(i, j int) bool { + left, _ := strconv.ParseInt(events[i].Cursor, 10, 64) + right, _ := strconv.ParseInt(events[j].Cursor, 10, 64) + if left == right { + return events[i].Path < events[j].Path + } + return left < right + }) + return PublicationListResult{Events: events, NextCursor: strconv.FormatInt(b.seq, 10)}, nil +} + +func (s *MemoryPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return nil, err + } + file, ok := b.files[path] + if !ok { + return nil, fmt.Errorf("publication file %s:%s not found", branch, path) + } + return append([]byte(nil), file.body...), nil +} + +func (s *MemoryPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { + if err := ctx.Err(); err != nil { + return err + } + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return err + } + if isPublicationEventPath(path) { + return fmt.Errorf("publication event path %q must be written with PutEvent", path) + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return err + } + b.seq++ + b.files[path] = memoryPublicationFile{body: append([]byte(nil), body...), seq: b.seq} + return nil +} + +func (s *MemoryPublicationStore) normalizeEventRef(branch, path string) (string, string, error) { + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return "", "", err + } + if !isPublicationEventPath(path) { + return "", "", fmt.Errorf("publication event path %q must be under %s", path, PublicationEventRoot) + } + return branch, path, nil +} + +func normalizePublicationFileRef(branch, path string) (string, string, error) { + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return "", "", err + } + path, err = NormalizePublicationPath(path) + if err != nil { + return "", "", err + } + return branch, path, nil +} + +func (s *MemoryPublicationStore) branch(branch string) (*memoryPublicationBranch, error) { + b, ok := s.branches[branch] + if !ok { + return nil, fmt.Errorf("publication branch %q is not configured", branch) + } + return b, nil +} + +func normalizePublicationEventPrefix(prefix string) (string, error) { + prefix, err := NormalizePublicationPath(prefix) + if err != nil { + return "", err + } + if prefix == PublicationEventRoot { + return prefix + "/", nil + } + if !isPublicationEventPath(prefix) { + return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, PublicationEventRoot) + } + return prefix, nil +} + +func isPublicationEventPath(path string) bool { + return strings.HasPrefix(path, PublicationEventRoot+"/") +} + +func publicationCursor(cursor string) (int64, error) { + cursor = strings.TrimSpace(cursor) + if cursor == "" { + return 0, nil + } + seq, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || seq < 0 { + return 0, fmt.Errorf("publication cursor %q is invalid", cursor) + } + return seq, nil +} + +func normalizePublicationPathSegment(segment string) (string, error) { + segment = strings.TrimSpace(segment) + if segment == "" || strings.Contains(segment, "/") || strings.Contains(segment, "\\") || segment == "." || segment == ".." { + return "", fmt.Errorf("path segment %q is invalid", segment) + } + return segment, nil +} diff --git a/harness/internal/mnemonhub/exchange/publication_store_test.go b/harness/internal/mnemonhub/exchange/publication_store_test.go new file mode 100644 index 00000000..561f496d --- /dev/null +++ b/harness/internal/mnemonhub/exchange/publication_store_test.go @@ -0,0 +1,185 @@ +package exchange + +import ( + "context" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +func TestPublicationStoreEventPathDeterministic(t *testing.T) { + env := publicationTestEnvelope(t, "dec-a", "remote-entry-a", "same event") + path1, err := PublicationEventPath(env) + if err != nil { + t.Fatalf("event path: %v", err) + } + path2, err := PublicationEventPath(env) + if err != nil { + t.Fatalf("event path again: %v", err) + } + if path1 != path2 { + t.Fatalf("event path must be deterministic: %q != %q", path1, path2) + } + if !strings.HasPrefix(path1, PublicationEventRoot+"/replica-a/") || !strings.HasSuffix(path1, ".json") { + t.Fatalf("event path must stay under publication event root, got %q", path1) + } + + changed := publicationTestEnvelope(t, "dec-b", "remote-entry-b", "different event") + changedPath, err := PublicationEventPath(changed) + if err != nil { + t.Fatalf("changed event path: %v", err) + } + if changedPath == path1 { + t.Fatalf("different event material must produce a different event path: %q", changedPath) + } +} + +func TestPublicationStorePutEventIsIdempotentAndConflictAware(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + path := PublicationEventRoot + "/replica-a/event-a.json" + + first, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("first put: %v", err) + } + if !first.Created || first.ExistsSame || first.Conflict { + t.Fatalf("first put result = %+v, want created", first) + } + same, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("same put: %v", err) + } + if !same.ExistsSame || same.Created || same.Conflict { + t.Fatalf("same put result = %+v, want exists_same", same) + } + conflict, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"different"}`)) + if err != nil { + t.Fatalf("conflict put: %v", err) + } + if !conflict.Conflict || conflict.Created || conflict.ExistsSame { + t.Fatalf("conflict put result = %+v, want conflict", conflict) + } + body, err := store.ReadFile(ctx, "mnemon/agent-a", path) + if err != nil { + t.Fatalf("read event file: %v", err) + } + if string(body) != `{"id":"a"}` { + t.Fatalf("conflict put must not overwrite existing body: %s", body) + } +} + +func TestPublicationStoreListEventsAfterCursor(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-b.json", []byte("b")); err != nil { + t.Fatal(err) + } + + all, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, "") + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all.Events) != 2 || all.Events[0].Path > all.Events[1].Path || all.NextCursor != "2" { + t.Fatalf("list all = %+v, want two ordered events with cursor 2", all) + } + afterFirst, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, all.Events[0].Cursor) + if err != nil { + t.Fatalf("list after first: %v", err) + } + if len(afterFirst.Events) != 1 || afterFirst.Events[0].Path != PublicationEventRoot+"/replica-a/event-b.json" || afterFirst.NextCursor != "2" { + t.Fatalf("list after first = %+v, want event-b only", afterFirst) + } + empty, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, afterFirst.NextCursor) + if err != nil { + t.Fatalf("list after next cursor: %v", err) + } + if len(empty.Events) != 0 || empty.NextCursor != "2" { + t.Fatalf("list after next cursor = %+v, want no events cursor 2", empty) + } +} + +func TestPublicationStoreRejectsUnsupportedBranchAndPath(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-b", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("unsupported branch must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "refs/heads/main", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "outside the mnemon namespace") { + t.Fatalf("non-publication branch must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", ".mnemonhub/v1/../events/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "invalid") { + t.Fatalf("escaping event path must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", ".mnemon/reports/run.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "must be under") { + t.Fatalf("non-event PutEvent path must fail closed, got %v", err) + } + if err := store.WriteFile(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "PutEvent") { + t.Fatalf("WriteFile must not write event paths, got %v", err) + } +} + +func TestPublicationStoreWriteReadFile(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/team") + if err != nil { + t.Fatal(err) + } + if err := store.WriteFile(ctx, "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":1}`)); err != nil { + t.Fatalf("write team manifest: %v", err) + } + body, err := store.ReadFile(ctx, "mnemon/team", ".mnemon/team.json") + if err != nil { + t.Fatalf("read team manifest: %v", err) + } + if string(body) != `{"schema_version":1}` { + t.Fatalf("read body = %s", body) + } +} + +func publicationTestEnvelope(t *testing.T, decisionID, entryID, summary string) eventmodel.EventEnvelope { + t.Helper() + fields := map[string]any{ + "content": "# Progress\n- " + summary, + "items": []any{map[string]any{ + "id": entryID, + "summary": summary, + "actor": "codex@other", + "ingest_seq": float64(7), + }}, + } + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: "replica-a", + LocalDecisionID: decisionID, + LocalIngestSeq: 7, + Actor: "codex@other", + ResourceRef: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + ResourceVersion: 1, + FieldsDigest: syncTestDigestForPublication(fields), + Fields: fields, + DecidedAt: "2026-06-12T00:00:00Z", + Status: "pending", + }) + if err != nil { + t.Fatalf("synced event envelope: %v", err) + } + return env +} + +func syncTestDigestForPublication(fields map[string]any) string { + return "digest-" + fields["content"].(string) +} From 8a408b1d1692979759d962833ad634cd4fb2b884 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:06:43 +0800 Subject: [PATCH 07/35] feat(harness): add fake-tested github publication backend Implement a GitHub-backed RemoteWorkspace skeleton over PublicationStore without any real GitHub API dependency. The backend publishes synced envelopes to publication paths, treats repeated identical puts as idempotent, reports same-key body conflicts, filters own-origin pulls, and returns pull diagnostics for invalid, out-of-scope, or digest-mismatched publication entries. Validation: go test ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/mnemonhub/exchange ./harness/internal/app -run 'TestGitHubBackendFake|TestPublicationStore|TestSync'; go test ./harness/internal/coreguard; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- .../exchange/backend/github/backend.go | 212 ++++++++++++++++++ .../exchange/backend/github/backend_test.go | 188 ++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 harness/internal/mnemonhub/exchange/backend/github/backend.go create mode 100644 harness/internal/mnemonhub/exchange/backend/github/backend_test.go diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go new file mode 100644 index 00000000..4c2e6299 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/backend.go @@ -0,0 +1,212 @@ +package githubbackend + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +type Config struct { + Store exchange.PublicationStore + Repo string + Branch string + Scopes []contract.ResourceRef +} + +type Backend struct { + store exchange.PublicationStore + repo string + branch string + scopes []contract.ResourceRef +} + +func New(cfg Config) (*Backend, error) { + if cfg.Store == nil { + return nil, fmt.Errorf("publication store is required") + } + repo := strings.TrimSpace(cfg.Repo) + if repo == "" { + return nil, fmt.Errorf("publication repo is required") + } + branch, err := exchange.NormalizePublicationBranch(cfg.Branch) + if err != nil { + return nil, err + } + return &Backend{ + store: cfg.Store, + repo: repo, + branch: branch, + scopes: append([]contract.ResourceRef(nil), cfg.Scopes...), + }, nil +} + +func (b *Backend) SyncPush(req contract.SyncPushRequest) (contract.SyncPushResponse, error) { + replicaID := strings.TrimSpace(req.ReplicaID) + if replicaID == "" { + return contract.SyncPushResponse{}, fmt.Errorf("sync push requires replica_id") + } + var resp contract.SyncPushResponse + for _, env := range req.Events { + material, diagnostic := b.syncedEventMaterial(env) + if diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + if material.OriginReplicaID != replicaID { + return contract.SyncPushResponse{}, fmt.Errorf("sync push replica_id %q does not match event origin %q", replicaID, material.OriginReplicaID) + } + if diagnostic := validateSyncedMaterial(material); diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + if diagnostic := b.scopeDiagnostic(material.ResourceRef); diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + path, err := exchange.PublicationEventPath(env) + if err != nil { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", err.Error())) + continue + } + body, err := json.Marshal(env) + if err != nil { + return contract.SyncPushResponse{}, err + } + put, err := b.store.PutEvent(context.Background(), b.branch, path, body) + if err != nil { + return contract.SyncPushResponse{}, err + } + switch { + case put.Created, put.ExistsSame: + resp.Accepted = append(resp.Accepted, eventExchangeResult(env, "accepted", "")) + case put.Conflict: + resp.Conflicts = append(resp.Conflicts, eventExchangeResult(env, "conflict", "publication event path already exists with different content")) + default: + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", "publication store returned no put verdict")) + } + } + return resp, nil +} + +func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullResponse, error) { + replicaID := strings.TrimSpace(req.ReplicaID) + if replicaID == "" { + return contract.SyncPullResponse{}, fmt.Errorf("sync pull requires replica_id") + } + scopes, err := contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) + if err != nil { + return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) + } + list, err := b.store.ListEvents(context.Background(), b.branch, exchange.PublicationEventRoot, req.RemoteCursor) + if err != nil { + return contract.SyncPullResponse{}, err + } + resp := contract.SyncPullResponse{NextCursor: list.NextCursor} + for _, stored := range list.Events { + env, result, ok := decodeStoredEvent(stored) + if !ok { + resp.Diagnostics = append(resp.Diagnostics, result) + continue + } + material, diagnostic := b.syncedEventMaterial(env) + if diagnostic != "" { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) + continue + } + if material.OriginReplicaID == replicaID { + continue + } + if diagnostic := validateSyncedMaterial(material); diagnostic != "" { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) + continue + } + if !refAllowed(scopes, material.ResourceRef) { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "rejected", fmt.Sprintf("ref %s/%s is outside configured publication scope", material.ResourceRef.Kind, material.ResourceRef.ID))) + continue + } + resp.Events = append(resp.Events, env) + } + return resp, nil +} + +func (b *Backend) SyncStatus() (contract.SyncStatusResponse, error) { + return contract.SyncStatusResponse{RemoteWorkspace: b.repo + ":" + b.branch}, nil +} + +func (b *Backend) syncedEventMaterial(env eventmodel.EventEnvelope) (contract.SyncedEventMaterial, string) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return contract.SyncedEventMaterial{}, err.Error() + } + return material, "" +} + +func (b *Backend) scopeDiagnostic(ref contract.ResourceRef) string { + if len(b.scopes) == 0 || refAllowed(b.scopes, ref) { + return "" + } + return fmt.Sprintf("ref %s/%s is outside configured publication scope", ref.Kind, ref.ID) +} + +func validateSyncedMaterial(material contract.SyncedEventMaterial) string { + switch { + case strings.TrimSpace(material.OriginReplicaID) == "": + return "origin_replica_id is required" + case strings.TrimSpace(material.LocalDecisionID) == "": + return "local_decision_id is required" + case strings.TrimSpace(string(material.Actor)) == "": + return "actor is required" + case strings.TrimSpace(string(material.ResourceRef.Kind)) == "" || strings.TrimSpace(string(material.ResourceRef.ID)) == "": + return "resource_ref is required" + case material.Fields == nil: + return "fields are required" + case strings.TrimSpace(material.FieldsDigest) == "": + return "fields_digest is required" + case material.FieldsDigest != syncedFieldsDigest(material.Fields): + return "fields_digest does not match fields" + default: + return "" + } +} + +func decodeStoredEvent(stored exchange.PublicationStoredEvent) (eventmodel.EventEnvelope, contract.EventExchangeResult, bool) { + var env eventmodel.EventEnvelope + if err := json.Unmarshal(stored.Body, &env); err != nil { + return eventmodel.EventEnvelope{}, contract.EventExchangeResult{ + EventID: stored.Path, + Status: "invalid", + Diagnostic: "publication event json is invalid: " + err.Error(), + }, false + } + return env, contract.EventExchangeResult{}, true +} + +func eventExchangeResult(env eventmodel.EventEnvelope, status, diagnostic string) contract.EventExchangeResult { + result := contract.EventExchangeResultFromEnvelope(env, status, diagnostic) + if strings.TrimSpace(result.EventID) == "" { + result.EventID = env.Event.ID + } + return result +} + +func refAllowed(scopes []contract.ResourceRef, ref contract.ResourceRef) bool { + for _, scope := range scopes { + if scope == ref { + return true + } + } + return false +} + +func syncedFieldsDigest(fields map[string]any) string { + b, _ := json.Marshal(fields) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go new file mode 100644 index 00000000..281b2d83 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go @@ -0,0 +1,188 @@ +package githubbackend + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +var progressRef = contract.ResourceRef{Kind: "progress_digest", ID: "project"} + +func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "published progress"}) + + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("sync push: %v", err) + } + if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { + t.Fatalf("push resp = %+v, want one accepted", resp) + } + list, err := store.ListEvents(context.Background(), "mnemon/agent-a", exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("list publication events: %v", err) + } + if len(list.Events) != 1 { + t.Fatalf("publication store events = %+v, want one", list.Events) + } + + replayed, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("repeat sync push: %v", err) + } + if len(replayed.Accepted) != 1 || len(replayed.Conflicts) != 0 { + t.Fatalf("repeat push must be idempotent accepted, got %+v", replayed) + } +} + +func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { + _, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "bad phase"}) + env.Phase = eventmodel.PhaseAccepted + + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("sync push invalid phase: %v", err) + } + if len(resp.Rejected) != 1 || !strings.Contains(resp.Rejected[0].Diagnostic, "phase") { + t.Fatalf("invalid phase must be rejected with diagnostic, got %+v", resp) + } +} + +func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { + _, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "same key"}) + if resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed push: resp=%+v err=%v", resp, err) + } + changedBody := env + changedBody.Event.CorrelationID = "changed-body" + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-b", Events: []eventmodel.EventEnvelope{changedBody}}) + if err != nil { + t.Fatalf("conflict push: %v", err) + } + if len(resp.Conflicts) != 1 || !strings.Contains(resp.Conflicts[0].Diagnostic, "different content") { + t.Fatalf("same key different body must conflict, got %+v", resp) + } +} + +func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/agent-b", progressRef) + foreign := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) + own := githubBackendTestEnvelope(t, "replica-a", "dec-own", progressRef, map[string]any{"content": "own progress"}) + putStoredEvent(t, store, "mnemon/agent-b", foreign) + putStoredEvent(t, store, "mnemon/agent-b", own) + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull: %v", err) + } + if len(resp.Events) != 1 || resp.Events[0].Event.ID != foreign.Event.ID || len(resp.Diagnostics) != 0 || resp.NextCursor != "2" { + t.Fatalf("pull resp = %+v, want only foreign event and cursor 2", resp) + } + again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("sync pull after cursor: %v", err) + } + if len(again.Events) != 0 || len(again.Diagnostics) != 0 || again.NextCursor != "2" { + t.Fatalf("cursor pull must be empty at cursor 2, got %+v", again) + } +} + +func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/agent-b", progressRef) + invalidPhase := githubBackendTestEnvelope(t, "replica-b", "dec-invalid-phase", progressRef, map[string]any{"content": "invalid phase"}) + invalidPhase.Phase = eventmodel.PhaseAccepted + badDigest := githubBackendTestEnvelope(t, "replica-b", "dec-bad-digest", progressRef, map[string]any{"content": "bad digest"}) + badDigest.Meta["digest"] = "wrong" + outOfScope := githubBackendTestEnvelope(t, "replica-b", "dec-out-scope", contract.ResourceRef{Kind: "assignment", ID: "project"}, map[string]any{"content": "assignment"}) + putStoredEvent(t, store, "mnemon/agent-b", invalidPhase) + putStoredEvent(t, store, "mnemon/agent-b", badDigest) + putStoredEvent(t, store, "mnemon/agent-b", outOfScope) + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull diagnostics: %v", err) + } + if len(resp.Events) != 0 || len(resp.Diagnostics) != 3 { + t.Fatalf("pull diagnostics resp = %+v, want three diagnostics", resp) + } + joined := diagnosticsText(resp.Diagnostics) + for _, want := range []string{"phase", "fields_digest", "outside configured publication scope"} { + if !strings.Contains(joined, want) { + t.Fatalf("diagnostics missing %q in %s", want, joined) + } + } +} + +func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) (*exchange.MemoryPublicationStore, *Backend) { + t.Helper() + store, err := exchange.NewMemoryPublicationStore(branch) + if err != nil { + t.Fatal(err) + } + backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: branch, Scopes: scopes}) + if err != nil { + t.Fatal(err) + } + return store, backend +} + +func putStoredEvent(t *testing.T, store *exchange.MemoryPublicationStore, branch string, env eventmodel.EventEnvelope) { + t.Helper() + path, err := exchange.PublicationEventPath(githubBackendPathEnvelope(env)) + if err != nil { + t.Fatalf("publication path: %v", err) + } + body, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(context.Background(), branch, path, body); err != nil { + t.Fatalf("put stored event: %v", err) + } +} + +func githubBackendPathEnvelope(env eventmodel.EventEnvelope) eventmodel.EventEnvelope { + if env.Phase == eventmodel.PhaseSynced { + return env + } + out := env + out.Phase = eventmodel.PhaseSynced + return out +} + +func githubBackendTestEnvelope(t *testing.T, origin, decisionID string, ref contract.ResourceRef, fields map[string]any) eventmodel.EventEnvelope { + t.Helper() + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: origin, + LocalDecisionID: decisionID, + LocalIngestSeq: 7, + Actor: "codex@project", + ResourceRef: ref, + ResourceVersion: 1, + FieldsDigest: syncedFieldsDigest(fields), + Fields: fields, + DecidedAt: "2026-06-12T00:00:00Z", + Status: "pending", + }) + if err != nil { + t.Fatalf("synced event envelope: %v", err) + } + return env +} + +func diagnosticsText(items []contract.EventExchangeResult) string { + var b strings.Builder + for _, item := range items { + b.WriteString(item.Diagnostic) + b.WriteByte('\n') + } + return b.String() +} From 8ba3346f61c413f5d914a447981129058a44419b Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:11:05 +0800 Subject: [PATCH 08/35] feat(harness): add github remote config shape Teach Remote Workspace config and sync connect about the GitHub publication backend shape: backend, direction, repo, and publication branch. HTTP connect keeps its legacy bidirectional config shape unless a direction is explicitly needed, while GitHub connect validates owner/repo and mnemon/* branch names before writing remotes.json. Validation: go test ./harness/internal/mnemonhub/exchange ./harness/cmd/mnemon-harness -run 'TestSyncConnect|TestSyncRemote|TestLoadRemote|TestRemotePlan|TestPrepareR1AcceptanceRunRoot'; go test ./harness/...; go test ./harness/internal/coreguard; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/acceptance.go | 3 +- harness/cmd/mnemon-harness/sync.go | 57 +++++++++-- harness/cmd/mnemon-harness/sync_test.go | 62 ++++++++++++ .../internal/mnemonhub/exchange/remotes.go | 96 +++++++++++++++---- .../mnemonhub/exchange/remotes_test.go | 74 ++++++++++++++ 5 files changed, 265 insertions(+), 27 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 3f009c34..610e493a 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -23,6 +23,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/state" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -1109,7 +1110,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S if i-1 >= len(hub.Tokens) { return nil, fmt.Errorf("hub token missing for agent %d", i) } - if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", hub.URL, hub.Tokens[i-1], "", ""); err != nil { + if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, "", "", hub.Tokens[i-1], "", ""); err != nil { return nil, err } loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 3862bdea..555efa5c 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -21,10 +21,14 @@ var ( syncStorePath string syncRemotesPath string syncRemoteID string + syncRemoteBackend string + syncRemoteDirection string syncRemoteURL string syncRemoteToken string syncRemoteTokenFile string syncCAFile string + syncGitHubRepo string + syncGitHubBranch string syncAllowInsecure bool syncOnce bool syncBackground bool @@ -66,10 +70,14 @@ func init() { syncCmd.PersistentFlags().StringVar(&syncStorePath, "store", "", "Local Mnemon store path") syncCmd.PersistentFlags().StringVar(&syncRemotesPath, "remotes", "", "Remote Workspace config path") syncCmd.PersistentFlags().StringVar(&syncRemoteID, "remote", "default", "Remote Workspace id") + syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http or github)") + syncCmd.PersistentFlags().StringVar(&syncRemoteDirection, "direction", "", "Remote Workspace direction (bidirectional, publish, or subscribe)") syncCmd.PersistentFlags().StringVar(&syncRemoteURL, "remote-url", "", "Remote Workspace sync endpoint") syncCmd.PersistentFlags().StringVar(&syncRemoteToken, "token", "", "Remote Workspace sync token") syncCmd.PersistentFlags().StringVar(&syncRemoteTokenFile, "token-file", "", "Remote Workspace sync token file") syncCmd.PersistentFlags().StringVar(&syncCAFile, "ca-file", "", "PEM bundle pinning the Remote Workspace TLS root (e.g. the mnemon-hub --dev-selfsigned cert)") + syncCmd.PersistentFlags().StringVar(&syncGitHubRepo, "github-repo", "", "GitHub Remote Workspace repository (owner/name)") + syncCmd.PersistentFlags().StringVar(&syncGitHubBranch, "github-branch", "", "GitHub Remote Workspace publication branch") syncCmd.PersistentFlags().BoolVar(&syncAllowInsecure, "allow-insecure-remote", false, "explicitly allow a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") _ = syncCmd.PersistentFlags().MarkHidden("store") _ = syncCmd.PersistentFlags().MarkHidden("remotes") @@ -92,19 +100,46 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { return fmt.Errorf("Remote Workspace name must use letters, numbers, dot, dash, or underscore") } endpoint := strings.TrimSpace(syncRemoteURL) - if endpoint == "" { - return fmt.Errorf("--remote-url is required") + backend, err := exchange.NormalizeRemoteBackend(syncRemoteBackend) + if err != nil { + return err } - // T2 downgrade gate at WRITE time (v1.1 #3): a plaintext non-loopback endpoint never enters - // remotes.json unless explicitly overridden — the worker and the manual verbs then re-validate - // at client construction. - if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { + direction, err := exchange.NormalizeRemoteDirection(syncRemoteDirection) + if err != nil { return err } + repo, branch := "", "" + switch backend { + case exchange.RemoteBackendHTTP: + if endpoint == "" { + return fmt.Errorf("--remote-url is required") + } + // T2 downgrade gate at WRITE time (v1.1 #3): a plaintext non-loopback endpoint never enters + // remotes.json unless explicitly overridden — the worker and the manual verbs then re-validate + // at client construction. + if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { + return err + } + case exchange.RemoteBackendGitHub: + repo, err = exchange.NormalizeGitHubRepo(syncGitHubRepo) + if err != nil { + return err + } + branch, err = exchange.NormalizePublicationBranch(syncGitHubBranch) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported Remote Workspace backend %q", backend) + } if strings.TrimSpace(syncRemoteToken) == "" && strings.TrimSpace(syncRemoteTokenFile) == "" { return fmt.Errorf("--token or --token-file is required") } - if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, endpoint, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { + directionForWrite := direction + if backend == exchange.RemoteBackendHTTP && direction == exchange.RemoteDirectionBidirectional { + directionForWrite = "" + } + if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, repo, branch, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Remote Workspace: connected %s\n", workspace) @@ -270,6 +305,8 @@ type syncRemoteConfig struct { ID string Backend string Endpoint string + Repo string + Branch string Token string CAFile string } @@ -362,7 +399,7 @@ func resolveSyncRemoteEntry(entry exchange.RemoteEntry) (syncRemoteConfig, error if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil + return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Repo: entry.Repo, Branch: entry.Branch, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil } // resolvedSyncCAFile picks the pinned-root file: the --ca-file flag overrides the remotes.json @@ -378,7 +415,7 @@ func resolvedSyncCAFile(entryCAFile string) string { return resolveSyncPath(caFile) } -func upsertSyncRemote(path, root, id, endpoint, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -394,7 +431,7 @@ func upsertSyncRemote(path, root, id, endpoint, token, tokenFile, caFile string) if err != nil { return err } - entry := exchange.RemoteEntry{Backend: exchange.RemoteBackendHTTP, ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} + entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, Repo: repo, Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} replaced := false for i := range doc.Remotes { if doc.Remotes[i].ID == id { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index f3c66724..1327b7c0 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -312,6 +312,56 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { } } +func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { + restoreSyncFlags(t) + root := t.TempDir() + syncRoot = root + syncRemoteBackend = exchange.RemoteBackendGitHub + syncRemoteDirection = exchange.RemoteDirectionPublish + syncGitHubRepo = "mnemon-dev/mnemon-teamwork-example" + syncGitHubBranch = "mnemon/agent-a" + syncRemoteToken = "secret-github-token" + var out bytes.Buffer + cmd := mustTestCommand(t) + cmd.SetOut(&out) + if err := runSyncConnect(cmd, []string{"self"}); err != nil { + t.Fatalf("sync connect github: %v", err) + } + if strings.Contains(out.String(), "secret-github-token") { + t.Fatalf("sync connect output must not expose token:\n%s", out.String()) + } + config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) + for _, want := range []string{ + `"backend": "github"`, + `"direction": "publish"`, + `"id": "self"`, + `"repo": "mnemon-dev/mnemon-teamwork-example"`, + `"branch": "mnemon/agent-a"`, + `"credential_ref": ".mnemon/harness/sync/credentials/self.token"`, + } { + if !strings.Contains(config, want) { + t.Fatalf("sync connect github config missing %q:\n%s", want, config) + } + } + if strings.Contains(config, "secret-github-token") || strings.Contains(config, "endpoint") { + t.Fatalf("github remote config must not leak token or write an endpoint:\n%s", config) + } + syncRemoteBackend = "" + syncRemoteDirection = "" + syncGitHubRepo = "" + syncGitHubBranch = "" + syncRemoteToken = "" + remote, err := resolveSyncRemote() + if err != nil { + t.Fatalf("resolve github remote: %v", err) + } + if remote.ID != "self" || remote.Backend != exchange.RemoteBackendGitHub || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" || + remote.Token != "secret-github-token" { + t.Fatalf("github remote not resolved: %+v", remote) + } +} + func TestSyncRemoteConfigLoadsCredentialRef(t *testing.T) { restoreSyncFlags(t) root := t.TempDir() @@ -402,30 +452,42 @@ func restoreSyncFlags(t *testing.T) { oldStorePath := syncStorePath oldRemotesPath := syncRemotesPath oldRemoteID := syncRemoteID + oldRemoteBackend := syncRemoteBackend + oldRemoteDirection := syncRemoteDirection oldRemoteURL := syncRemoteURL oldRemoteToken := syncRemoteToken oldRemoteTokenFile := syncRemoteTokenFile oldCAFile := syncCAFile + oldGitHubRepo := syncGitHubRepo + oldGitHubBranch := syncGitHubBranch oldAllowInsecure := syncAllowInsecure t.Cleanup(func() { syncRoot = oldRoot syncStorePath = oldStorePath syncRemotesPath = oldRemotesPath syncRemoteID = oldRemoteID + syncRemoteBackend = oldRemoteBackend + syncRemoteDirection = oldRemoteDirection syncRemoteURL = oldRemoteURL syncRemoteToken = oldRemoteToken syncRemoteTokenFile = oldRemoteTokenFile syncCAFile = oldCAFile + syncGitHubRepo = oldGitHubRepo + syncGitHubBranch = oldGitHubBranch syncAllowInsecure = oldAllowInsecure }) syncRoot = "." syncStorePath = "" syncRemotesPath = "" syncRemoteID = "default" + syncRemoteBackend = "" + syncRemoteDirection = "" syncRemoteURL = "" syncRemoteToken = "" syncRemoteTokenFile = "" syncCAFile = "" + syncGitHubRepo = "" + syncGitHubBranch = "" syncAllowInsecure = false } diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index d511205b..1c8d8b12 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -17,7 +17,10 @@ type RemotesDoc struct { Remotes []RemoteEntry `json:"remotes"` } -const RemoteBackendHTTP = "http" +const ( + RemoteBackendHTTP = "http" + RemoteBackendGitHub = "github" +) const ( RemoteDirectionBidirectional = "bidirectional" @@ -34,6 +37,8 @@ type RemoteEntry struct { Direction string `json:"direction,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` CredentialRef string `json:"credential_ref"` // CAFile optionally pins the remote's TLS root (PEM bundle) — the client trusts exactly it // (sync-abi-v1 §8). Empty = the system roots. @@ -46,27 +51,43 @@ type RemotePlan struct { } func (r RemoteEntry) NormalizedBackend() string { - backend := strings.TrimSpace(r.Backend) - if backend == "" { - return RemoteBackendHTTP - } + backend, _ := NormalizeRemoteBackend(r.Backend) return backend } func (r RemoteEntry) NormalizedDirection() string { - direction := strings.TrimSpace(r.Direction) + direction, _ := NormalizeRemoteDirection(r.Direction) + return direction +} + +func NormalizeRemoteBackend(backend string) (string, error) { + backend = strings.TrimSpace(backend) + if backend == "" { + backend = RemoteBackendHTTP + } + if err := validateRemoteBackend(backend); err != nil { + return "", err + } + return backend, nil +} + +func NormalizeRemoteDirection(direction string) (string, error) { + direction = strings.TrimSpace(direction) if direction == "" { - return RemoteDirectionBidirectional + direction = RemoteDirectionBidirectional } - return direction + if err := validateRemoteDirection(direction); err != nil { + return "", err + } + return direction, nil } func validateRemoteBackend(backend string) error { switch backend { - case RemoteBackendHTTP: + case RemoteBackendHTTP, RemoteBackendGitHub: return nil default: - return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s)", backend, RemoteBackendHTTP) + return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s, %s)", backend, RemoteBackendHTTP, RemoteBackendGitHub) } } @@ -151,20 +172,63 @@ func loadRemotesDoc(path string) (RemotesDoc, error) { } func normalizeRemoteEntry(remote RemoteEntry) (RemoteEntry, error) { - remote.Backend = remote.NormalizedBackend() - if err := validateRemoteBackend(remote.Backend); err != nil { + var err error + remote.Backend, err = NormalizeRemoteBackend(remote.Backend) + if err != nil { return RemoteEntry{}, err } - remote.Direction = remote.NormalizedDirection() - if err := validateRemoteDirection(remote.Direction); err != nil { + remote.Direction, err = NormalizeRemoteDirection(remote.Direction) + if err != nil { return RemoteEntry{}, err } - if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { - return RemoteEntry{}, fmt.Errorf("has no endpoint") + switch remote.Backend { + case RemoteBackendHTTP: + if strings.TrimSpace(remote.Endpoint) == "" { + return RemoteEntry{}, fmt.Errorf("has no endpoint") + } + case RemoteBackendGitHub: + remote.Repo, err = NormalizeGitHubRepo(remote.Repo) + if err != nil { + return RemoteEntry{}, err + } + remote.Branch, err = NormalizePublicationBranch(remote.Branch) + if err != nil { + return RemoteEntry{}, err + } } return remote, nil } +func NormalizeGitHubRepo(repo string) (string, error) { + repo = strings.TrimSpace(repo) + if repo == "" { + return "", fmt.Errorf("github repo is required") + } + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return "", fmt.Errorf("github repo %q must be owner/name", repo) + } + for _, part := range parts { + if !validGitHubRepoSegment(part) { + return "", fmt.Errorf("github repo %q is invalid", repo) + } + } + return repo, nil +} + +func validGitHubRepoSegment(segment string) bool { + if segment == "" || segment == "." || segment == ".." { + return false + } + for _, r := range segment { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + continue + } + return false + } + return true +} + func findRemoteEntry(doc RemotesDoc, id string) (RemoteEntry, bool) { for _, remote := range doc.Remotes { if remote.ID == id { diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go index 70e07df2..d6c069d1 100644 --- a/harness/internal/mnemonhub/exchange/remotes_test.go +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -52,6 +52,80 @@ func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { } } +func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "self", + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "repo": "mnemon-dev/mnemon-teamwork-example", + "branch": "mnemon/agent-a", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + remote, err := LoadRemoteEntry(path, "default") + if err != nil { + t.Fatalf("load github remote: %v", err) + } + if remote.Backend != RemoteBackendGitHub || remote.Direction != RemoteDirectionPublish || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" { + t.Fatalf("github remote not normalized: %+v", remote) + } +} + +func TestLoadRemoteEntryRejectsInvalidGitHubPublicationConfig(t *testing.T) { + cases := []struct { + name string + body string + want string + }{{ + name: "missing repo", + body: `{ + "schema_version": 1, + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "branch": "mnemon/agent-a", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`, + want: "github repo is required", + }, { + name: "bad branch", + body: `{ + "schema_version": 1, + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "repo": "mnemon-dev/mnemon-teamwork-example", + "branch": "main", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`, + want: "outside the mnemon namespace", + }} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(tc.body+"\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadRemoteEntry(path, "self") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid github remote must fail with %q, got %v", tc.want, err) + } + }) + } +} + func TestLoadRemotePlanLegacyCurrentIsBidirectional(t *testing.T) { path := filepath.Join(t.TempDir(), "remotes.json") if err := os.WriteFile(path, []byte(`{ From 2b3c49aa8e142038691793adf96980445c0913c0 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:16:07 +0800 Subject: [PATCH 09/35] feat(harness): add github publication store adapter Implement the real GitHub PublicationStore adapter behind the existing RemoteWorkspace backend using Contents API file reads/writes and References API branch-head cursors. The adapter preserves append-only event put semantics, supports metadata file updates with SHA, recursively lists publication event entries, and wires backend=github into CLI and worker factories. Validation: go test ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestGitHub|TestPublicationStore|TestSync'; go test ./harness/internal/coreguard; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/sync.go | 14 + harness/internal/app/sync_worker.go | 47 ++ .../exchange/backend/github/backend.go | 12 +- .../backend/github/publication_store.go | 440 ++++++++++++++++++ .../backend/github/publication_store_test.go | 274 +++++++++++ 5 files changed, 783 insertions(+), 4 deletions(-) create mode 100644 harness/internal/mnemonhub/exchange/backend/github/publication_store.go create mode 100644 harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 555efa5c..58be586c 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -12,6 +12,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -331,6 +332,19 @@ func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, CAFile: remote.CAFile, AllowInsecure: syncAllowInsecure, }) + case exchange.RemoteBackendGitHub: + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: remote.Repo, + Token: remote.Token, + }) + if err != nil { + return nil, err + } + return githubbackend.New(githubbackend.Config{ + Store: store, + Repo: remote.Repo, + Branch: remote.Branch, + }) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", remote.ID, backend) } diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 84ab9007..3fc93496 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "os" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/policy" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -101,6 +103,8 @@ func syncWorkerRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (excha switch entry.NormalizedBackend() { case exchange.RemoteBackendHTTP: return syncWorkerHTTPRemote(entry, opts) + case exchange.RemoteBackendGitHub: + return syncWorkerGitHubRemote(entry, opts) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", entry.ID, entry.NormalizedBackend()) } @@ -137,6 +141,49 @@ func syncWorkerHTTPRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (e }) } +func syncWorkerGitHubRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { + token, err := syncWorkerRemoteToken(entry, opts) + if err != nil { + return nil, err + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = access.DefaultSyncTimeout + } + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: entry.Repo, + Token: token, + HTTPClient: &http.Client{Timeout: timeout}, + }) + if err != nil { + return nil, err + } + return githubbackend.New(githubbackend.Config{ + Store: store, + Repo: entry.Repo, + Branch: entry.Branch, + }) +} + +func syncWorkerRemoteToken(entry exchange.RemoteEntry, opts SyncWorkerOptions) (string, error) { + if strings.TrimSpace(entry.CredentialRef) == "" { + return "", fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) + } + tokPath := entry.CredentialRef + if !filepath.IsAbs(tokPath) { + tokPath = filepath.Join(opts.ProjectRoot, tokPath) + } + raw, err := os.ReadFile(tokPath) + if err != nil { + return "", fmt.Errorf("read Remote Workspace token file: %w", err) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", fmt.Errorf("Remote Workspace token file %s is empty", entry.CredentialRef) + } + return token, nil +} + // syncWorkerPush pushes the pending batch (if any) and mirrors the hub's per-event verdicts into // the local ledger — both through the live handle. func syncWorkerPush(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string) error { diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go index 4c2e6299..58b6f4e0 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/backend.go +++ b/harness/internal/mnemonhub/exchange/backend/github/backend.go @@ -100,9 +100,13 @@ func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullRespo if replicaID == "" { return contract.SyncPullResponse{}, fmt.Errorf("sync pull requires replica_id") } - scopes, err := contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) - if err != nil { - return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) + scopes := append([]contract.ResourceRef(nil), req.Scopes...) + if len(b.scopes) > 0 { + var err error + scopes, err = contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) + if err != nil { + return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) + } } list, err := b.store.ListEvents(context.Background(), b.branch, exchange.PublicationEventRoot, req.RemoteCursor) if err != nil { @@ -127,7 +131,7 @@ func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullRespo resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) continue } - if !refAllowed(scopes, material.ResourceRef) { + if len(scopes) > 0 && !refAllowed(scopes, material.ResourceRef) { resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "rejected", fmt.Sprintf("ref %s/%s is outside configured publication scope", material.ResourceRef.Kind, material.ResourceRef.ID))) continue } diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go new file mode 100644 index 00000000..beb536a4 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -0,0 +1,440 @@ +package githubbackend + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + pathpkg "path" + "sort" + "strings" + "sync" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +const githubAPIVersion = "2022-11-28" + +type PublicationStoreConfig struct { + Repo string + Token string + BaseURL string + UserAgent string + HTTPClient *http.Client + MutativeDelay time.Duration +} + +type GitHubPublicationStore struct { + owner string + repo string + token string + baseURL string + userAgent string + client *http.Client + mutativeDelay time.Duration + writeMu sync.Mutex + lastWrite time.Time +} + +func NewPublicationStore(cfg PublicationStoreConfig) (*GitHubPublicationStore, error) { + repo, err := exchange.NormalizeGitHubRepo(cfg.Repo) + if err != nil { + return nil, err + } + owner, name, _ := strings.Cut(repo, "/") + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + baseURL = "https://api.github.com" + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + userAgent := strings.TrimSpace(cfg.UserAgent) + if userAgent == "" { + userAgent = "mnemon-harness" + } + return &GitHubPublicationStore{ + owner: owner, + repo: name, + token: strings.TrimSpace(cfg.Token), + baseURL: baseURL, + userAgent: userAgent, + client: client, + mutativeDelay: cfg.MutativeDelay, + }, nil +} + +func (s *GitHubPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (exchange.PublicationPutResult, error) { + branch, path, err := normalizeGitHubPublicationEventRef(branch, path) + if err != nil { + return exchange.PublicationPutResult{}, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + existing, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return exchange.PublicationPutResult{}, err + } + if found { + if bytes.Equal(existing.body, body) { + return exchange.PublicationPutResult{ExistsSame: true}, nil + } + return exchange.PublicationPutResult{Conflict: true}, nil + } + if err := s.pauseBeforeMutation(ctx); err != nil { + return exchange.PublicationPutResult{}, err + } + if err := s.putFile(ctx, branch, path, body, ""); err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusConflict { + return exchange.PublicationPutResult{Conflict: true}, nil + } + return exchange.PublicationPutResult{}, err + } + s.lastWrite = time.Now() + return exchange.PublicationPutResult{Created: true}, nil +} + +func (s *GitHubPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (exchange.PublicationListResult, error) { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return exchange.PublicationListResult{}, err + } + prefix, err = normalizeGitHubPublicationEventPrefix(prefix) + if err != nil { + return exchange.PublicationListResult{}, err + } + head, err := s.branchHead(ctx, branch) + if err != nil { + return exchange.PublicationListResult{}, err + } + if strings.TrimSpace(cursor) == head { + return exchange.PublicationListResult{NextCursor: head}, nil + } + paths, err := s.listEventPaths(ctx, branch, prefix) + if err != nil { + return exchange.PublicationListResult{}, err + } + events := make([]exchange.PublicationStoredEvent, 0, len(paths)) + for _, path := range paths { + file, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return exchange.PublicationListResult{}, err + } + if !found { + continue + } + events = append(events, exchange.PublicationStoredEvent{Path: path, Body: file.body, Cursor: head}) + } + return exchange.PublicationListResult{Events: events, NextCursor: head}, nil +} + +func (s *GitHubPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return nil, err + } + file, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("publication file %s:%s not found", branch, path) + } + return append([]byte(nil), file.body...), nil +} + +func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return err + } + if strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { + return fmt.Errorf("publication event path %q must be written with PutEvent", path) + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + existing, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return err + } + sha := "" + if found { + sha = existing.sha + } + if err := s.pauseBeforeMutation(ctx); err != nil { + return err + } + if err := s.putFile(ctx, branch, path, body, sha); err != nil { + return err + } + s.lastWrite = time.Now() + return nil +} + +type githubFile struct { + body []byte + sha string +} + +type githubContentResponse struct { + Type string `json:"type"` + Path string `json:"path"` + SHA string `json:"sha"` + Encoding string `json:"encoding"` + Content string `json:"content"` +} + +type githubContentEntry struct { + Type string `json:"type"` + Path string `json:"path"` +} + +type githubPutFileRequest struct { + Message string `json:"message"` + Content string `json:"content"` + SHA string `json:"sha,omitempty"` + Branch string `json:"branch"` +} + +type githubRefResponse struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` +} + +type githubAPIError struct { + Status int + Message string +} + +func (e *githubAPIError) Error() string { + if strings.TrimSpace(e.Message) == "" { + return fmt.Sprintf("github api status %d", e.Status) + } + return fmt.Sprintf("github api status %d: %s", e.Status, e.Message) +} + +func (s *GitHubPublicationStore) readFileWithSHA(ctx context.Context, branch, path string) (githubFile, bool, error) { + var out githubContentResponse + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &out) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + return githubFile{}, false, nil + } + return githubFile{}, false, err + } + if status != http.StatusOK { + return githubFile{}, false, fmt.Errorf("github content read returned status %d", status) + } + body, err := decodeGitHubContent(out) + if err != nil { + return githubFile{}, false, err + } + return githubFile{body: body, sha: out.SHA}, true, nil +} + +func (s *GitHubPublicationStore) putFile(ctx context.Context, branch, path string, body []byte, sha string) error { + req := githubPutFileRequest{ + Message: "mnemon publication update " + path, + Content: base64.StdEncoding.EncodeToString(body), + SHA: sha, + Branch: branch, + } + status, err := s.do(ctx, http.MethodPut, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), nil, req, nil) + if err != nil { + return err + } + if status != http.StatusOK && status != http.StatusCreated { + return fmt.Errorf("github content write returned status %d", status) + } + return nil +} + +func (s *GitHubPublicationStore) branchHead(ctx context.Context, branch string) (string, error) { + var out githubRefResponse + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/git/ref/heads/"+escapeGitHubPath(branch), nil, nil, &out) + if err != nil { + return "", err + } + if status != http.StatusOK || strings.TrimSpace(out.Object.SHA) == "" { + return "", fmt.Errorf("github branch ref %q returned status %d without sha", branch, status) + } + return strings.TrimSpace(out.Object.SHA), nil +} + +func (s *GitHubPublicationStore) listEventPaths(ctx context.Context, branch, prefix string) ([]string, error) { + entries, err := s.listDir(ctx, branch, prefix) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + return nil, nil + } + return nil, err + } + var paths []string + for _, entry := range entries { + switch entry.Type { + case "dir": + nested, err := s.listEventPaths(ctx, branch, entry.Path) + if err != nil { + return nil, err + } + paths = append(paths, nested...) + case "file": + if strings.HasPrefix(entry.Path, prefix) { + paths = append(paths, entry.Path) + } + } + } + sort.Strings(paths) + return paths, nil +} + +func (s *GitHubPublicationStore) listDir(ctx context.Context, branch, path string) ([]githubContentEntry, error) { + var entries []githubContentEntry + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &entries) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("github directory list returned status %d", status) + } + return entries, nil +} + +func (s *GitHubPublicationStore) do(ctx context.Context, method, apiPath string, query url.Values, in any, out any) (int, error) { + var body io.Reader + if in != nil { + data, err := json.Marshal(in) + if err != nil { + return 0, err + } + body = bytes.NewReader(data) + } + u := s.baseURL + apiPath + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + req.Header.Set("User-Agent", s.userAgent) + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + resp, err := s.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, err + } + if resp.StatusCode >= 300 { + var apiErr struct { + Message string `json:"message"` + } + _ = json.Unmarshal(data, &apiErr) + return resp.StatusCode, &githubAPIError{Status: resp.StatusCode, Message: apiErr.Message} + } + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, err + } + } + return resp.StatusCode, nil +} + +func (s *GitHubPublicationStore) pauseBeforeMutation(ctx context.Context) error { + if s.mutativeDelay <= 0 || s.lastWrite.IsZero() { + return nil + } + wait := s.mutativeDelay - time.Since(s.lastWrite) + if wait <= 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func decodeGitHubContent(resp githubContentResponse) ([]byte, error) { + if resp.Type != "" && resp.Type != "file" { + return nil, fmt.Errorf("github content %q is not a file", resp.Path) + } + if resp.Encoding != "base64" { + return nil, fmt.Errorf("github content %q has unsupported encoding %q", resp.Path, resp.Encoding) + } + content := strings.ReplaceAll(resp.Content, "\n", "") + body, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return nil, err + } + return body, nil +} + +func normalizeGitHubPublicationEventRef(branch, path string) (string, string, error) { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return "", "", err + } + if !strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { + return "", "", fmt.Errorf("publication event path %q must be under %s", path, exchange.PublicationEventRoot) + } + return branch, path, nil +} + +func normalizeGitHubPublicationFileRef(branch, path string) (string, string, error) { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return "", "", err + } + path, err = exchange.NormalizePublicationPath(path) + if err != nil { + return "", "", err + } + return branch, path, nil +} + +func normalizeGitHubPublicationEventPrefix(prefix string) (string, error) { + prefix, err := exchange.NormalizePublicationPath(prefix) + if err != nil { + return "", err + } + if prefix == exchange.PublicationEventRoot { + return prefix, nil + } + if !strings.HasPrefix(prefix, exchange.PublicationEventRoot+"/") { + return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, exchange.PublicationEventRoot) + } + return prefix, nil +} + +func escapeGitHubPath(path string) string { + clean := pathpkg.Clean(strings.Trim(path, "/")) + if clean == "." { + return "" + } + parts := strings.Split(clean, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go new file mode 100644 index 00000000..dc032bdf --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -0,0 +1,274 @@ +package githubbackend + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + Token: "secret-token", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + path := exchange.PublicationEventRoot + "/replica-a/event-a.json" + + first, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("put event create: %v", err) + } + if !first.Created || fake.puts != 1 { + t.Fatalf("first put = %+v, puts=%d; want created with one PUT", first, fake.puts) + } + same, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("put event same: %v", err) + } + if !same.ExistsSame || same.Conflict || fake.puts != 1 { + t.Fatalf("same put = %+v puts=%d; want idempotent without PUT", same, fake.puts) + } + conflict, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"b"}`)) + if err != nil { + t.Fatalf("put event conflict: %v", err) + } + if !conflict.Conflict || fake.puts != 1 { + t.Fatalf("conflict put = %+v puts=%d; want conflict without overwrite", conflict, fake.puts) + } +} + +func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.files["mnemon/team:.mnemon/team.json"] = fakeGitHubFile{body: []byte(`{"schema_version":1}`), sha: "sha-existing"} + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + if err := store.WriteFile(context.Background(), "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":2}`)); err != nil { + t.Fatalf("write team manifest: %v", err) + } + if fake.lastSHA != "sha-existing" { + t.Fatalf("write file must include existing sha, got %q", fake.lastSHA) + } + body, err := store.ReadFile(context.Background(), "mnemon/team", ".mnemon/team.json") + if err != nil { + t.Fatalf("read team manifest: %v", err) + } + if string(body) != `{"schema_version":2}` { + t.Fatalf("read body = %s", body) + } +} + +func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.head = "head-2" + fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-b/event-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} + fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-c/event-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + list, err := store.ListEvents(context.Background(), "mnemon/agent-b", exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("list events: %v", err) + } + if len(list.Events) != 2 || list.NextCursor != "head-2" { + t.Fatalf("list = %+v, want two events at head-2", list) + } + again, err := store.ListEvents(context.Background(), "mnemon/agent-b", exchange.PublicationEventRoot, list.NextCursor) + if err != nil { + t.Fatalf("list after head cursor: %v", err) + } + if len(again.Events) != 0 || again.NextCursor != "head-2" { + t.Fatalf("list after head cursor = %+v, want empty", again) + } +} + +func TestGitHubPublicationStoreLiveGated(t *testing.T) { + if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { + t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") + } + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if token == "" { + t.Skip("GITHUB_TOKEN is required for live GitHub publication store smoke test") + } + repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) + if repo == "" { + repo = "mnemon-dev/mnemon-teamwork-example" + } + branch := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH")) + if branch == "" { + branch = "mnemon/agent-a" + } + store, err := NewPublicationStore(PublicationStoreConfig{Repo: repo, Token: token}) + if err != nil { + t.Fatal(err) + } + path := exchange.PublicationEventRoot + "/live-smoke/live-smoke.json" + body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) + res, err := store.PutEvent(context.Background(), branch, path, body) + if err != nil { + t.Fatalf("live put event: %v", err) + } + if !res.Created && !res.ExistsSame { + t.Fatalf("live put result = %+v, want created or exists_same", res) + } + list, err := store.ListEvents(context.Background(), branch, exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("live list events: %v", err) + } + if list.NextCursor == "" { + t.Fatalf("live list must return a branch-head cursor: %+v", list) + } +} + +type fakeGitHubPublicationAPI struct { + server *httptest.Server + files map[string]fakeGitHubFile + head string + puts int + lastSHA string +} + +type fakeGitHubFile struct { + body []byte + sha string +} + +func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { + t.Helper() + fake := &fakeGitHubPublicationAPI{files: map[string]fakeGitHubFile{}, head: "head-1"} + fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) + t.Cleanup(fake.server.Close) + return fake +} + +func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request) { + if token := r.Header.Get("Authorization"); strings.Contains(token, "secret-token") && token != "Bearer secret-token" { + http.Error(w, `{"message":"bad token"}`, http.StatusUnauthorized) + return + } + prefix := "/repos/mnemon-dev/mnemon-teamwork-example/" + if !strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + tail := strings.TrimPrefix(r.URL.Path, prefix) + switch { + case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): + writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": f.head}}) + case strings.HasPrefix(tail, "contents/"): + path := strings.TrimPrefix(tail, "contents/") + branch := r.URL.Query().Get("ref") + f.handleContents(w, r, branch, path) + default: + http.NotFound(w, r) + } +} + +func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http.Request, branch, path string) { + switch r.Method { + case http.MethodGet: + key := branch + ":" + path + if file, ok := f.files[key]; ok { + writeJSON(w, http.StatusOK, map[string]any{ + "type": "file", + "path": path, + "sha": file.sha, + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString(file.body), + }) + return + } + entries := f.dirEntries(branch, path) + if len(entries) > 0 { + writeJSON(w, http.StatusOK, entries) + return + } + writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"}) + case http.MethodPut: + var req struct { + Content string `json:"content"` + SHA string `json:"sha"` + Branch string `json:"branch"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + body, err := base64.StdEncoding.DecodeString(req.Content) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + if branch == "" { + branch = req.Branch + } + key := branch + ":" + path + if existing, ok := f.files[key]; ok && req.SHA != existing.sha { + writeJSON(w, http.StatusConflict, map[string]any{"message": "sha mismatch"}) + return + } + f.puts++ + f.lastSHA = req.SHA + f.files[key] = fakeGitHubFile{body: body, sha: "sha-new"} + status := http.StatusCreated + if req.SHA != "" { + status = http.StatusOK + } + writeJSON(w, status, map[string]any{"content": map[string]any{"sha": "sha-new"}}) + default: + http.NotFound(w, r) + } +} + +func (f *fakeGitHubPublicationAPI) dirEntries(branch, dir string) []map[string]any { + prefix := branch + ":" + strings.TrimSuffix(dir, "/") + "/" + seen := map[string]map[string]any{} + for key := range f.files { + if !strings.HasPrefix(key, prefix) { + continue + } + rel := strings.TrimPrefix(key, prefix) + head, _, hasRest := strings.Cut(rel, "/") + path := strings.TrimSuffix(dir, "/") + "/" + head + typ := "file" + if hasRest { + typ = "dir" + } + seen[path] = map[string]any{"type": typ, "path": path} + } + out := make([]map[string]any, 0, len(seen)) + for _, entry := range seen { + out = append(out, entry) + } + return out +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} From 6ff50c57fc9c754e628c0c1918585d09c50ed1cc Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:20:18 +0800 Subject: [PATCH 10/35] test(harness): cover five-node github publication mesh Add a deterministic five-mnemond acceptance-style test over the fake GitHub publication backend. Each isolated runtime uses its own principal and branch, publishes only to its own stream, subscribes to the other streams, and verifies assignment material converges through the normal sync worker import path. Validation: go test ./harness/internal/app ./harness/internal/mnemonhub/exchange/backend/github -run 'TestSyncGitHubFakeFiveMnemondPublicationMesh|TestGitHub|TestPublicationStore'; go test ./harness/...; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/internal/app/sync_github_mesh_test.go | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 harness/internal/app/sync_github_mesh_test.go diff --git a/harness/internal/app/sync_github_mesh_test.go b/harness/internal/app/sync_github_mesh_test.go new file mode 100644 index 00000000..8172eb1d --- /dev/null +++ b/harness/internal/app/sync_github_mesh_test.go @@ -0,0 +1,143 @@ +package app + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestSyncGitHubFakeFiveMnemondPublicationMesh(t *testing.T) { + ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e"} + branches := make([]string, 0, len(ids)) + for _, id := range ids { + branches = append(branches, "mnemon/"+id) + } + store, err := exchange.NewMemoryPublicationStore(branches...) + if err != nil { + t.Fatal(err) + } + type node struct { + id string + branch string + principal string + rt *runtime.Runtime + } + nodes := make([]node, 0, len(ids)) + for i, id := range ids { + root := t.TempDir() + principal := "codex-" + id + "@project" + rt := openMeshServingRuntime(t, root, principal) + observeMeshAssignment(t, rt, principal, id) + nodes = append(nodes, node{id: id, branch: branches[i], principal: principal, rt: rt}) + } + + for _, n := range nodes { + remote := githubFakeRemote(t, store, n.branch) + if err := syncWorkerPush(n.rt, remote, "publish-"+n.id); err != nil { + t.Fatalf("%s publish: %v", n.id, err) + } + if pending, err := n.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", n.id, pending, err) + } + } + + for _, n := range nodes { + for _, source := range nodes { + if source.id == n.id { + continue + } + remote := githubFakeRemote(t, store, source.branch) + state, err := exchange.ReadPullState(n.rt, "subscribe-"+source.id) + if err != nil { + t.Fatalf("%s read pull state %s: %v", n.id, source.id, err) + } + probe, err := remote.SyncPull(contract.SyncPullRequest{ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor}) + if err != nil { + t.Fatalf("%s probe %s: %v", n.id, source.id, err) + } + if len(probe.Diagnostics) > 0 || len(probe.Events) == 0 { + t.Fatalf("%s probe %s returned events=%d diagnostics=%+v", n.id, source.id, len(probe.Events), probe.Diagnostics) + } + if err := syncWorkerPull(n.rt, remote, "subscribe-"+source.id, nil); err != nil { + t.Fatalf("%s subscribe %s: %v", n.id, source.id, err) + } + } + } + + assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} + for _, n := range nodes { + _, fields, err := n.rt.Resource(assignmentRef) + if err != nil { + t.Fatalf("%s read assignments: %v", n.id, err) + } + items, _ := fields["items"].([]any) + scopes := map[string]bool{} + for _, item := range items { + m, _ := item.(map[string]any) + scope, _ := m["scope"].(string) + scopes[scope] = true + } + for _, source := range nodes { + want := meshAssignmentScope(source.id) + if !scopes[want] { + t.Fatalf("%s assignments missing %q in %+v", n.id, want, items) + } + } + } +} + +func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runtime { + t.Helper() + refs := []contract.ResourceRef{{Kind: "progress_digest", ID: "project"}, {Kind: "assignment", ID: "project"}} + b := access.HostAgentBinding(contract.ActorID(principal), "http://127.0.0.1:8787", refs) + rt, err := OpenLocalRuntime(filepath.Join(root, runtime.DefaultStorePath), access.LoadedBindings{Bindings: []access.ChannelBinding{b}}, nil, nil) + if err != nil { + t.Fatalf("open serving runtime: %v", err) + } + t.Cleanup(func() { _ = rt.Close() }) + return rt +} + +func observeMeshAssignment(t *testing.T, rt *runtime.Runtime, principal, id string) { + t.Helper() + if _, _, err := rt.API().Ingest(contract.ActorID(principal), contract.ObservationEnvelope{ + ExternalID: "github-mesh-assignment-" + id, + Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: map[string]any{ + "assignment_id": "mesh-" + id, + "scope": meshAssignmentScope(id), + "ttl": "2h", + "assignee": "codex@" + id, + "expected_work": "complete deterministic publication mesh validation for " + id, + "expected_feedback": "progress_digest", + "evidence": "deterministic fake GitHub publication mesh test", + }}, + }); err != nil { + t.Fatalf("observe assignment: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick assignment: %v", err) + } +} + +func meshAssignmentScope(id string) string { + return fmt.Sprintf("%s publication mesh assignment", id) +} + +func githubFakeRemote(t *testing.T, store exchange.PublicationStore, branch string) exchange.RemoteWorkspace { + t.Helper() + remote, err := githubbackend.New(githubbackend.Config{ + Store: store, + Repo: "mnemon-dev/mnemon-teamwork-example", + Branch: branch, + }) + if err != nil { + t.Fatal(err) + } + return remote +} From b9734a4fd75ad906cb8c5f44d6e5d7c6b40b1308 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:25:10 +0800 Subject: [PATCH 11/35] feat(harness): scaffold github mesh acceptance Add a GitHub-backed Remote Workspace acceptance entrypoint that configures five isolated Codex appserver workspaces with one dedicated mnemond store each. The setup writes self publish and peer subscribe remotes for repo-mediated publication branches, records repo/branch/store/workspace evidence in the report, and asserts no central mnemon-hub endpoint is present. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestWriteR1GitHubMesh|TestBuildR1GitHubMesh|TestPrepareR1AcceptanceRunRoot'; go test ./harness/...; go test ./harness/internal/coreguard; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/acceptance.go | 6 + .../mnemon-harness/acceptance_github_mesh.go | 394 ++++++++++++++++++ .../acceptance_github_mesh_test.go | 99 +++++ 3 files changed, 499 insertions(+) create mode 100644 harness/cmd/mnemon-harness/acceptance_github_mesh.go create mode 100644 harness/cmd/mnemon-harness/acceptance_github_mesh_test.go diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 610e493a..78e176bd 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -148,7 +148,13 @@ type r1CodexAgentReport struct { type r1CodexSyncReport struct { Status string `json:"status"` + Backend string `json:"backend,omitempty"` + Repo string `json:"repo,omitempty"` HubURL string `json:"hub_url"` + PublicationBranches []string `json:"publication_branches,omitempty"` + BranchByAgent map[string]string `json:"branch_by_agent,omitempty"` + RuntimeWorkspaces []string `json:"runtime_workspace_paths,omitempty"` + LocalStorePaths []string `json:"local_mnemond_store_paths,omitempty"` AllowedEventSubjects []string `json:"allowed_event_subjects"` Source string `json:"source"` Target string `json:"target"` diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go new file mode 100644 index 00000000..3e1ea40d --- /dev/null +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -0,0 +1,394 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/app" + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" + "github.com/spf13/cobra" +) + +var ( + acceptanceGitHubRepo string + acceptanceGitHubTokenFile string + acceptanceGitHubBranchPrefix string + acceptanceGitHubScenarios []string +) + +var acceptanceR1GitHubMeshCmd = &cobra.Command{ + Use: "r1-github-mesh-task-suite", + Short: "Run GitHub-backed Remote Workspace task-suite acceptance", + RunE: func(cmd *cobra.Command, args []string) error { + report, err := runR1GitHubMeshAcceptance(cmd.Context(), r1GitHubMeshAcceptanceOptions{ + r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ + RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, + Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }, + Repo: acceptanceGitHubRepo, + TokenFile: acceptanceGitHubTokenFile, + BranchPrefix: acceptanceGitHubBranchPrefix, + Scenarios: acceptanceGitHubScenarios, + }) + if report.ReportPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) + } + if err != nil { + return err + } + if report.Status != "ok" { + return fmt.Errorf("GitHub mesh task-suite acceptance status: %s", report.Status) + } + return nil + }, +} + +func init() { + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") + acceptanceR1GitHubMeshCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") + acceptanceR1GitHubMeshCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed GitHub mesh events") + acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "mnemon/agent-", "GitHub publication branch prefix") + acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") + acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) +} + +type r1GitHubMeshAcceptanceOptions struct { + r1CodexAcceptanceOptions + Repo string + TokenFile string + BranchPrefix string + Scenarios []string +} + +func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceOptions) (r1CodexAcceptanceReport, error) { + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.Command == "" { + opts.Command = "codex" + } + if opts.Agents < 5 { + opts.Agents = 5 + } + if opts.TurnTimeout <= 0 { + opts.TurnTimeout = 5 * time.Minute + } + if opts.Repo == "" { + opts.Repo = "mnemon-dev/mnemon-teamwork-example" + } + if opts.BranchPrefix == "" { + opts.BranchPrefix = "mnemon/agent-" + } + started := time.Now().UTC().Truncate(time.Second) + runRoot := opts.RunRoot + if runRoot == "" { + runRoot = filepath.Join(".testdata", "r1-github-mesh-task-suite", started.Format("20060102T150405Z")) + } + runRoot, err := filepath.Abs(runRoot) + if err != nil { + return r1CodexAcceptanceReport{}, err + } + report := r1CodexAcceptanceReport{ + SchemaVersion: 1, + Status: "running", + StartedAt: started.Format(time.RFC3339), + RunRoot: runRoot, + Scenario: "github-mesh-task-suite", + AgentTurns: opts.AgentTurns, + LedgerCounts: map[string]int{}, + DerivedEventAudit: map[string]int{}, + Artifacts: map[string]string{}, + Raw: map[string]json.RawMessage{}, + } + reportPath := filepath.Join(runRoot, "report.json") + report.ReportPath = reportPath + defer func() { + report.FinishedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) + _ = os.MkdirAll(filepath.Dir(reportPath), 0o755) + data, _ := json.MarshalIndent(report, "", " ") + _ = os.WriteFile(reportPath, append(data, '\n'), 0o644) + }() + if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + if opts.TokenFile == "" { + err := fmt.Errorf("--github-token-file is required") + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + tokenFile, err := filepath.Abs(opts.TokenFile) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + if _, err := os.Stat(tokenFile); err != nil { + err = fmt.Errorf("github token file: %w", err) + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + binDir, err := installAcceptanceHarnessBinary(runRoot) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["github_repo"] = opts.Repo + report.Artifacts["github_token_file"] = tokenFile + + agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, opts.BranchPrefix, opts.Agents, sourceCodexHome) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + + syncReport := buildR1GitHubMeshSyncReport(opts.Repo, agents) + report.Sync = syncReport + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + addR1Assertion(&report, "github-mesh no central mnemon-hub endpoint", syncReport.HubURL == "" && syncReport.HubStatus.HubEventsReceived == 0, "backend=github repo-mediated publication") + addR1Assertion(&report, "github-mesh publication branches configured", len(syncReport.PublicationBranches) == opts.Agents && len(syncReport.BranchByAgent) == opts.Agents, fmt.Sprintf("branches=%v", syncReport.PublicationBranches)) + addR1Assertion(&report, "github-mesh local stores isolated", distinctStrings(syncReport.LocalStorePaths) && len(syncReport.LocalStorePaths) == opts.Agents, fmt.Sprintf("stores=%v", syncReport.LocalStorePaths)) + addR1Assertion(&report, "github-mesh runtime workspaces isolated", distinctStrings(syncReport.RuntimeWorkspaces) && len(syncReport.RuntimeWorkspaces) == opts.Agents, fmt.Sprintf("workspaces=%v", syncReport.RuntimeWorkspaces)) + + if !opts.AgentTurns { + addR1Assertion(&report, "github-mesh real agent turns requested", false, "rerun with --agent-turns") + report.Status = "failed" + return report, fmt.Errorf("GitHub mesh task-suite acceptance requires --agent-turns") + } + for i := range agents { + if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + agentReport, raw, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + report.Agents = append(report.Agents, agentReport) + syncReport.Agents = append(syncReport.Agents, agentReport) + if raw != nil { + report.Raw[agents[i].principal+":hooks"] = raw + } + } + addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + addR1Assertion(&report, "github-mesh natural suite implementation pending", false, "P8 topology is wired; natural Teamwork-ReAct scenarios still need execution wiring") + report.Status = "failed" + return report, fmt.Errorf("GitHub mesh natural task suite is not fully wired yet") +} + +func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string) ([]r1CodexSyncAgent, error) { + var agents []r1CodexSyncAgent + branches := r1GitHubMeshBranches(branchPrefix, count) + for i := 1; i <= count; i++ { + principal := fmt.Sprintf("codex-%02d@project", i) + workspace := filepath.Join(runRoot, "workspaces", fmt.Sprintf("codex-%02d", i)) + codexHome := filepath.Join(runRoot, "codex-home", fmt.Sprintf("codex-%02d", i)) + if err := os.MkdirAll(workspace, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("# R1 GitHub mesh acceptance workspace\n"), 0o644); err != nil { + return nil, err + } + if err := prepareAcceptanceCodexHome(codexHome, workspace, sourceCodexHome); err != nil { + return nil, err + } + localAddr, err := freeLoopbackAddr() + if err != nil { + return nil, err + } + localURL := "http://" + localAddr + if _, err := app.New(workspace).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ + Host: "codex", + ControlURL: localURL, + Principal: principal, + ProjectRoot: workspace, + UseToken: true, + }); err != nil { + return nil, err + } + if err := writeR1GitHubMeshRemotes(workspace, repo, tokenFile, branches, i-1); err != nil { + return nil, err + } + loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) + if err != nil { + return nil, err + } + token, err := acceptanceTokenForPrincipal(loaded.Tokens, contract.ActorID(principal)) + if err != nil { + return nil, err + } + localCtx, cancel := context.WithCancel(ctx) + localErr := make(chan error, 1) + go func(workspace, addr string, loaded access.LoadedBindings) { + localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ + ProjectRoot: workspace, + SyncInterval: 100 * time.Millisecond, + }, io.Discard) + }(workspace, localAddr, loaded) + agent := r1CodexSyncAgent{ + r1CodexAgent: r1CodexAgent{ + principal: principal, + workspace: workspace, + codexHome: codexHome, + token: token, + env: acceptanceEnv(binDir, codexHome, runRoot), + }, + localURL: localURL, + replicaPrincipal: principal, + replicaToken: tokenFile, + renderAuditPath: filepath.Join(workspace, ".mnemon", "harness", "local", "render-audit.jsonl"), + localCancel: cancel, + localErr: localErr, + } + if err := waitR1LocalReady(ctx, agent.r1CodexAgent, localURL, 10*time.Second); err != nil { + cancel() + return nil, err + } + agents = append(agents, agent) + } + return agents, nil +} + +func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { + if self < 0 || self >= len(branches) { + return fmt.Errorf("self index %d outside branches", self) + } + repo, err := exchange.NormalizeGitHubRepo(repo) + if err != nil { + return err + } + if strings.TrimSpace(tokenFile) == "" { + return fmt.Errorf("github token file is required") + } + normalized := make([]string, 0, len(branches)) + for _, branch := range branches { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return err + } + normalized = append(normalized, branch) + } + branches = normalized + remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") + if err := upsertSyncRemote(remotesPath, workspace, "self", exchange.RemoteBackendGitHub, exchange.RemoteDirectionPublish, "", repo, branches[self], "", tokenFile, ""); err != nil { + return err + } + for i, branch := range branches { + if i == self { + continue + } + id := fmt.Sprintf("stream-%02d", i+1) + if err := upsertSyncRemote(remotesPath, workspace, id, exchange.RemoteBackendGitHub, exchange.RemoteDirectionSubscribe, "", repo, branch, "", tokenFile, ""); err != nil { + return err + } + } + return nil +} + +func r1GitHubMeshBranches(prefix string, count int) []string { + if prefix == "" { + prefix = "mnemon/agent-" + } + out := make([]string, 0, count) + for i := 0; i < count; i++ { + suffix := fmt.Sprintf("%02d", i+1) + if strings.HasSuffix(prefix, "agent-") && i < 26 { + suffix = string(rune('a' + i)) + } + out = append(out, prefix+suffix) + } + return out +} + +func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1CodexSyncReport { + report := &r1CodexSyncReport{ + Status: "running", + Backend: exchange.RemoteBackendGitHub, + Repo: repo, + AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), + Artifacts: map[string]string{}, + BranchByAgent: map[string]string{}, + } + for i, agent := range agents { + branch := "" + if remote, err := exchange.LoadRemoteEntry(filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json"), "self"); err == nil { + branch = remote.Branch + } else if agent.replicaPrincipal != "" { + branch = agent.replicaPrincipal + } + if branch != "" { + report.PublicationBranches = append(report.PublicationBranches, branch) + report.BranchByAgent[agent.principal] = branch + } + report.RuntimeWorkspaces = append(report.RuntimeWorkspaces, agent.workspace) + report.LocalStorePaths = append(report.LocalStorePaths, filepath.Join(agent.workspace, runtime.DefaultStorePath)) + report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + if i == 0 { + report.Source = agent.principal + } + } + sort.Strings(report.PublicationBranches) + return report +} + +func r1GitHubMeshScopes() []contract.ResourceRef { + return []contract.ResourceRef{ + {Kind: "agent_profile", ID: "project"}, + {Kind: "project_intent", ID: "project"}, + {Kind: "teamwork_signal", ID: "project"}, + {Kind: "assignment", ID: "project"}, + {Kind: "progress_digest", ID: "project"}, + } +} + +func distinctStrings(values []string) bool { + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return false + } + seen[value] = true + } + return true +} diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go new file mode 100644 index 00000000..277cf58f --- /dev/null +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { + got := r1GitHubMeshBranches("mnemon/agent-", 5) + want := []string{"mnemon/agent-a", "mnemon/agent-b", "mnemon/agent-c", "mnemon/agent-d", "mnemon/agent-e"} + if len(got) != len(want) { + t.Fatalf("branches = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("branches = %v, want %v", got, want) + } + } +} + +func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(root, "workspaces", "codex-03") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + branches := r1GitHubMeshBranches("mnemon/agent-", 5) + if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, 2); err != nil { + t.Fatalf("write github mesh remotes: %v", err) + } + + remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") + if err != nil { + t.Fatalf("load remote plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/agent-c" { + t.Fatalf("push targets = %+v, want self on mnemon/agent-c", plan.PushTargets) + } + if len(plan.PullSources) != 4 { + t.Fatalf("pull sources = %+v, want four peer streams", plan.PullSources) + } + for _, remote := range append(plan.PushTargets, plan.PullSources...) { + if remote.Backend != exchange.RemoteBackendGitHub || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || + remote.CredentialRef != tokenFile || + remote.Endpoint != "" { + t.Fatalf("remote not a github publication stream: %+v", remote) + } + } +} + +func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + branches := r1GitHubMeshBranches("mnemon/agent-", 3) + agents := make([]r1CodexSyncAgent, 0, 3) + for i := range branches { + workspace := filepath.Join(root, "workspaces", branches[i][len("mnemon/"):]) + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, i); err != nil { + t.Fatal(err) + } + agents = append(agents, r1CodexSyncAgent{r1CodexAgent: r1CodexAgent{ + principal: "codex-0" + string(rune('1'+i)) + "@project", + workspace: workspace, + }}) + } + + report := buildR1GitHubMeshSyncReport("mnemon-dev/mnemon-teamwork-example", agents) + if report.Backend != exchange.RemoteBackendGitHub || report.Repo != "mnemon-dev/mnemon-teamwork-example" || report.HubURL != "" { + t.Fatalf("report backend/repo/hub wrong: %+v", report) + } + if len(report.PublicationBranches) != 3 || len(report.BranchByAgent) != 3 { + t.Fatalf("report branches wrong: %+v", report) + } + if !distinctStrings(report.RuntimeWorkspaces) || !distinctStrings(report.LocalStorePaths) { + t.Fatalf("report must prove isolated workspaces/stores: workspaces=%v stores=%v", report.RuntimeWorkspaces, report.LocalStorePaths) + } + for i, storePath := range report.LocalStorePaths { + want := filepath.Join(agents[i].workspace, runtime.DefaultStorePath) + if storePath != want { + t.Fatalf("store path[%d] = %q, want %q", i, storePath, want) + } + } +} From 8e2ea60b41b5ea41d03f5111df5279ebea6d7173 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:27:46 +0800 Subject: [PATCH 12/35] feat(harness): wire github mesh natural scenarios Extend the GitHub mesh acceptance command from topology setup into a natural task-suite runner. PoC agents receive the natural user messages from the architecture plan, non-entry agents only get generic Mnemon wake prompts, profiles are bootstrapped and required to converge through publication branches, and the report records prompt rounds, entry PoC agents, participants, and event-backed scenario evidence. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestWriteR1GitHubMesh|TestBuildR1GitHubMesh|TestPrepareR1AcceptanceRunRoot'; go test ./harness/...; go test ./harness/internal/coreguard; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- .../mnemon-harness/acceptance_github_mesh.go | 259 +++++++++++++++++- .../acceptance_github_mesh_test.go | 58 ++++ 2 files changed, 315 insertions(+), 2 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 3e1ea40d..8e735a32 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -123,6 +123,11 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO DerivedEventAudit: map[string]int{}, Artifacts: map[string]string{}, Raw: map[string]json.RawMessage{}, + RunnerContract: &r1RunnerContractReport{ + EntrypointProgressBeforeIntegration: -1, + EntrypointProgressAfterIntegration: -1, + WorkerWakePrompt: r1ClusterWorkerWakePrompt, + }, } reportPath := filepath.Join(runRoot, "report.json") report.ReportPath = reportPath @@ -211,9 +216,259 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } } addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) - addR1Assertion(&report, "github-mesh natural suite implementation pending", false, "P8 topology is wired; natural Teamwork-ReAct scenarios still need execution wiring") + + run := r1GitHubMeshRun{ + ctx: ctx, + opts: opts, + report: &report, + agents: agents, + runID: started.Format("150405"), + } + if err := run.bootstrapProfiles(); err != nil { + addR1Error(&report, err) + } + for _, name := range r1GitHubMeshScenarioNames(opts.Scenarios) { + if err := run.runScenario(name); err != nil { + addR1Error(&report, err) + } + } + obs, obsErr := observeAcceptanceRun(runRoot, 1000) + if obsErr == nil { + report.Observability = &obs + report.Participants = r1ClusterParticipants(r1ClusterActorEventCounts(obs), report.Entrypoint) + } else { + addR1Error(&report, obsErr) + } + report.DerivedEventAudit = prodSimDerivedAudit(agents) + if len(agents) > 0 { + report.LedgerCounts = countR1Ledger(agents[0].localURL, agents[0].r1CodexAgent) + } + addR1Assertion(&report, "github-mesh no shared governed.db", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) + if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { + syncReport.Status = "ok" + report.Status = "ok" + return report, nil + } + syncReport.Status = "failed" report.Status = "failed" - return report, fmt.Errorf("GitHub mesh natural task suite is not fully wired yet") + return report, fmt.Errorf("GitHub mesh natural task suite failed") +} + +type r1GitHubMeshRun struct { + ctx context.Context + opts r1GitHubMeshAcceptanceOptions + report *r1CodexAcceptanceReport + agents []r1CodexSyncAgent + runID string +} + +func r1GitHubMeshScenarioNames(selected []string) []string { + if len(selected) == 0 { + return []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} + } + out := make([]string, 0, len(selected)) + for _, name := range selected { + name = strings.TrimSpace(name) + if name != "" { + out = append(out, name) + } + } + return out +} + +func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected []string) bool { + want := map[string]bool{} + for _, name := range r1GitHubMeshScenarioNames(selected) { + want[name] = false + } + for _, scenario := range scenarios { + if _, ok := want[scenario.Name]; ok && scenario.Status == "ok" { + want[scenario.Name] = true + } + } + if len(want) == 0 { + return false + } + for _, ok := range want { + if !ok { + return false + } + } + return true +} + +func (s r1GitHubMeshRun) bootstrapProfiles() error { + for i := range s.agents { + agent := &s.agents[i] + payload := taskSimJSON(map[string]any{ + "actor": agent.principal, + "focus": fmt.Sprintf("GitHub mesh Remote Workspace acceptance node %s", agent.principal), + "context_advantages": []string{"isolated local mnemond", "github publication branch sync", "real Codex appserver turn"}, + "availability": "available", + "ttl": "30m", + "summary": fmt.Sprintf("%s is available for GitHub mesh teamwork validation.", agent.principal), + }) + prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. +Use external id github-mesh-profile-%s-%s and payload: +%s +After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agent.principal), payload) + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "profile_bootstrap", prompt) + s.report.RunnerContract.ProfileBootstrapPrompts++ + answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh profile emitted "+agent.principal, false, err.Error()) + return err + } + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) + } + allVisible := true + for i := range s.agents { + agent := s.agents[i] + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), 120*time.Second) + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + if counts["agent_profile"] < len(s.agents) { + allVisible = false + } + } + addR1Assertion(s.report, "github-mesh profiles converge through publication branches", allVisible, fmt.Sprintf("agents=%d", len(s.agents))) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: "bootstrap_profiles", Status: statusFromBool(allVisible)}) + if !allVisible { + return fmt.Errorf("profiles did not converge through GitHub publication branches") + } + return nil +} + +func (s r1GitHubMeshRun) runScenario(name string) error { + entries, err := s.scenarioEntries(name) + if err != nil { + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: name, Status: "blocked", Evidence: map[string]any{"error": err.Error()}}) + return err + } + var actors []string + for _, entry := range entries { + agent := &s.agents[entry.index] + actors = append(actors, agent.principal) + s.report.RunnerContract.BusinessTaskPrompts++ + if s.report.Entrypoint == "" { + s.report.Entrypoint = agent.principal + s.report.Starter = agent.principal + s.report.Sync.Source = agent.principal + } + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "natural_user_message:"+name, entry.prompt) + answer, err := runR1Turn(&agent.r1CodexAgent, entry.prompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, false, err.Error()) + return err + } + addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, true, truncateR1Cluster(answer, 300)) + } + if err := s.wakeWorkers(name, entries); err != nil { + return err + } + lead := &s.agents[entries[0].index] + integrationPrompt := r1GitHubMeshIntegrationPrompt(name) + s.report.RunnerContract.IntegrationPrompts++ + recordR1ClusterPrompt(s.report.RunnerContract, lead.principal, "integration:"+name, integrationPrompt) + answer, err := runR1Turn(&lead.r1CodexAgent, integrationPrompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, lead.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh "+name+" integration", false, err.Error()) + return err + } + waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 15*time.Second, 2*time.Second) + obs, err := observeAcceptanceRun(s.report.RunRoot, 1000) + if err != nil { + return err + } + counts := r1ClusterActorEventCounts(obs) + participants := r1ClusterNonProfileParticipantCount(counts) + replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) + passed := participants >= 2 && replans >= 2 + addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d actors=%v", participants, replans, counts)) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: name, + Status: statusFromBool(passed), + Actors: actors, + Evidence: map[string]any{ + "participants": participants, + "replanning_rounds": replans, + "entry_poc_agents": actors, + }, + }) + if !passed { + return fmt.Errorf("github mesh scenario %s did not produce team-shaped multi-round evidence", name) + } + return nil +} + +type r1GitHubMeshScenarioEntry struct { + index int + prompt string +} + +func (s r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { + if len(s.agents) < 5 { + return nil, fmt.Errorf("github mesh natural scenarios require five agents") + } + switch name { + case "onboarding-synthesis": + return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。你可以让其他成员帮忙核对架构、测试和风险。如果第一轮信息不够,请根据大家的反馈再拆一轮补齐。`}}, nil + case "sync-risk-review": + return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。你帮我检查一下 GitHub Remote Workspace 相关的配置、诊断和测试设计;如果发现顺手能补的文档或测试缺口,一起处理。第一轮先找风险,再根据结果安排第二轮验证。`}}, nil + case "live-readiness-operator-safety": + return []r1GitHubMeshScenarioEntry{ + {index: 0, prompt: `请你推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让大家找出缺口,再根据第一轮结果安排第二轮补齐。`}, + {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。你帮我从 token、repo、branch、报错可读性这几个角度检查一下。`}, + }, nil + default: + return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) + } +} + +func (s r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { + entry := map[int]bool{} + for _, item := range entries { + entry[item.index] = true + } + for cycle := 1; cycle <= 3; cycle++ { + for i := range s.agents { + if entry[i] { + continue + } + agent := &s.agents[i] + s.report.RunnerContract.WorkerWakePrompts++ + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1ClusterWorkerWakePrompt) + answer, err := runR1Turn(&agent.r1CodexAgent, r1ClusterWorkerWakePrompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + s.report.RunnerContract.WorkerWakeErrors = append(s.report.RunnerContract.WorkerWakeErrors, fmt.Sprintf("%s cycle %d %s: %v", name, cycle, agent.principal, err)) + } + } + waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 8*time.Second, 1*time.Second) + } + return nil +} + +func r1GitHubMeshIntegrationPrompt(name string) string { + return fmt.Sprintf(`Read your own Local Mnemon context and integrate the GitHub mesh teamwork scenario %q. +Use only governed Mnemon events as teammate evidence. If first-round output reveals gaps, emit a follow-up assignment before finalizing; otherwise emit a final progress_digest with participants, evidence, gaps, and next action. +Answer with the event-backed result.`, name) +} + +func r1GitHubMeshPromptRounds(contract *r1RunnerContractReport, scenario string) int { + if contract == nil { + return 0 + } + rounds := 0 + for _, prompt := range contract.PromptAudit { + if strings.Contains(prompt.Kind, scenario) { + rounds++ + } + } + return rounds } func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string) ([]r1CodexSyncAgent, error) { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 277cf58f..00dfccf3 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" @@ -97,3 +98,60 @@ func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { } } } + +func TestR1GitHubMeshScenarioContract(t *testing.T) { + names := r1GitHubMeshScenarioNames(nil) + want := []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} + if len(names) != len(want) { + t.Fatalf("default scenarios = %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Fatalf("default scenarios = %v, want %v", names, want) + } + } + run := r1GitHubMeshRun{agents: make([]r1CodexSyncAgent, 5)} + entries, err := run.scenarioEntries("live-readiness-operator-safety") + if err != nil { + t.Fatalf("multi-poc scenario entries: %v", err) + } + if len(entries) != 2 || entries[0].index == entries[1].index { + t.Fatalf("live-readiness scenario must use two distinct PoC agents, got %+v", entries) + } + for _, entry := range entries { + for _, forbidden := range []string{"Agent A must", "Agent B must", "must create assignments for"} { + if strings.Contains(entry.prompt, forbidden) { + t.Fatalf("natural prompt contains forced choreography %q: %s", forbidden, entry.prompt) + } + } + } +} + +func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { + scenarios := []r1TaskSimScenarioReport{ + {Name: "onboarding-synthesis", Status: "ok"}, + {Name: "sync-risk-review", Status: "failed"}, + {Name: "live-readiness-operator-safety", Status: "ok"}, + } + if allR1GitHubMeshScenariosOK(scenarios, nil) { + t.Fatal("default scenario set must require every selected scenario to be ok") + } + scenarios[1].Status = "ok" + if !allR1GitHubMeshScenariosOK(scenarios, nil) { + t.Fatal("default scenario set should pass when every default scenario is ok") + } + if !allR1GitHubMeshScenariosOK(scenarios[:1], []string{"onboarding-synthesis"}) { + t.Fatal("explicit scenario selection should require only the selected scenario") + } +} + +func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { + contract := &r1RunnerContractReport{} + recordR1ClusterPrompt(contract, "codex-01@project", "natural_user_message:onboarding-synthesis", "prompt") + recordR1ClusterPrompt(contract, "codex-02@project", "worker_wake:onboarding-synthesis", "wake") + recordR1ClusterPrompt(contract, "codex-01@project", "integration:onboarding-synthesis", "integrate") + recordR1ClusterPrompt(contract, "codex-01@project", "integration:other", "other") + if got := r1GitHubMeshPromptRounds(contract, "onboarding-synthesis"); got != 3 { + t.Fatalf("prompt rounds = %d, want 3", got) + } +} From faea4eef1735a316506b53e84f5250bc2688e2af Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:31:22 +0800 Subject: [PATCH 13/35] fix(harness): normalize github publication cursors Keep opaque GitHub publication cursors behind the Remote Workspace backend so local mnemond pull state continues to persist numeric cursors. This lets GitHub listing rely on Event Intake idempotency without leaking branch head SHAs into the local exchange state. Validation: go test ./harness/internal/mnemonhub/exchange/backend/github; go test ./harness/internal/mnemonhub/exchange ./harness/internal/app --- .../exchange/backend/github/backend.go | 18 +++++- .../exchange/backend/github/backend_test.go | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go index 58b6f4e0..e706aecc 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/backend.go +++ b/harness/internal/mnemonhub/exchange/backend/github/backend.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strconv" "strings" "github.com/mnemon-dev/mnemon/harness/internal/contract" @@ -112,7 +113,7 @@ func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullRespo if err != nil { return contract.SyncPullResponse{}, err } - resp := contract.SyncPullResponse{NextCursor: list.NextCursor} + resp := contract.SyncPullResponse{NextCursor: localStateCursor(req.RemoteCursor, list.NextCursor)} for _, stored := range list.Events { env, result, ok := decodeStoredEvent(stored) if !ok { @@ -140,6 +141,21 @@ func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullRespo return resp, nil } +func localStateCursor(previous, storeCursor string) string { + storeCursor = strings.TrimSpace(storeCursor) + if storeCursor == "" { + return "" + } + if _, err := strconv.ParseInt(storeCursor, 10, 64); err == nil { + return storeCursor + } + previous = strings.TrimSpace(previous) + if _, err := strconv.ParseInt(previous, 10, 64); err == nil && previous != "" { + return previous + } + return "1" +} + func (b *Backend) SyncStatus() (contract.SyncStatusResponse, error) { return contract.SyncStatusResponse{RemoteWorkspace: b.repo + ":" + b.branch}, nil } diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go index 281b2d83..3dc5f95b 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go @@ -121,6 +121,44 @@ func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { } } +func TestGitHubBackendPullNormalizesOpaquePublicationCursor(t *testing.T) { + env := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) + body, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + store := opaqueCursorStore{ + events: []exchange.PublicationStoredEvent{{ + Path: "events/replica-b/foreign.json", + Body: body, + Cursor: "head-abc", + }}, + } + backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/agent-b", Scopes: []contract.ResourceRef{progressRef}}) + if err != nil { + t.Fatal(err) + } + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull: %v", err) + } + if resp.NextCursor != "1" { + t.Fatalf("opaque publication cursor must normalize to local numeric cursor, got %q", resp.NextCursor) + } + if len(resp.Events) != 1 || resp.Events[0].Event.ID != env.Event.ID { + t.Fatalf("sync pull events = %+v, want foreign event", resp.Events) + } + + again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("sync pull with local cursor: %v", err) + } + if again.NextCursor != resp.NextCursor || len(again.Events) != 1 { + t.Fatalf("opaque cursor pull should keep local cursor and rely on import idempotency, got %+v", again) + } +} + func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) (*exchange.MemoryPublicationStore, *Backend) { t.Helper() store, err := exchange.NewMemoryPublicationStore(branch) @@ -134,6 +172,26 @@ func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) return store, backend } +type opaqueCursorStore struct { + events []exchange.PublicationStoredEvent +} + +func (s opaqueCursorStore) PutEvent(context.Context, string, string, []byte) (exchange.PublicationPutResult, error) { + return exchange.PublicationPutResult{}, nil +} + +func (s opaqueCursorStore) ListEvents(context.Context, string, string, string) (exchange.PublicationListResult, error) { + return exchange.PublicationListResult{Events: append([]exchange.PublicationStoredEvent(nil), s.events...), NextCursor: "head-abc"}, nil +} + +func (s opaqueCursorStore) ReadFile(context.Context, string, string) ([]byte, error) { + return nil, nil +} + +func (s opaqueCursorStore) WriteFile(context.Context, string, string, []byte) error { + return nil +} + func putStoredEvent(t *testing.T, store *exchange.MemoryPublicationStore, branch string, env eventmodel.EventEnvelope) { t.Helper() path, err := exchange.PublicationEventPath(githubBackendPathEnvelope(env)) From 2f07a1eafa8b02d314369e072d900542b1adb3da Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:34:02 +0800 Subject: [PATCH 14/35] test(harness): add gated github live round trip Add an opt-in live test for the GitHub publication backend that drives two isolated mnemond runtimes through Remote Workspace publish, pull, and Event Intake import in both directions. The default path skips unless MNEMON_GITHUB_LIVE=1 and a token are provided. Validation: go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v; go test ./harness/internal/app; go test ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/mnemonhub/exchange --- harness/internal/app/sync_github_live_test.go | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 harness/internal/app/sync_github_live_test.go diff --git a/harness/internal/app/sync_github_live_test.go b/harness/internal/app/sync_github_live_test.go new file mode 100644 index 00000000..70f32e89 --- /dev/null +++ b/harness/internal/app/sync_github_live_test.go @@ -0,0 +1,190 @@ +package app + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestGitHubLivePublishPullImport(t *testing.T) { + cfg := liveGitHubTestConfig(t) + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: cfg.repo, + Token: cfg.token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }) + if err != nil { + t.Fatalf("github publication store: %v", err) + } + remoteA := liveGitHubRemote(t, store, cfg.repo, cfg.branchA) + remoteB := liveGitHubRemote(t, store, cfg.repo, cfg.branchB) + + sourcePrincipal := contract.ActorID("codex-github-live-a@project") + targetPrincipal := contract.ActorID("codex-github-live-b@project") + source := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "source"), string(sourcePrincipal)) + target := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "target"), string(targetPrincipal)) + + runID := "github-live-" + time.Now().UTC().Format("20060102T150405.000000000Z") + assignmentID := runID + "-assignment" + assignmentScope := "github-live/publish-pull-import/" + runID + observeLiveAssignment(t, source, sourcePrincipal, assignmentID, assignmentScope, targetPrincipal) + + if err := syncWorkerPush(source, remoteA, "github-live-publish-a"); err != nil { + t.Fatalf("source publish branch %s: %v", cfg.branchA, err) + } + if pending, err := source.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("source publish must drain pending events, pending=%+v err=%v", pending, err) + } + + if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { + t.Fatalf("target pull branch %s: %v", cfg.branchA, err) + } + assertAssignmentScopeCount(t, target, assignmentScope, 1) + if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { + t.Fatalf("target repeat pull branch %s: %v", cfg.branchA, err) + } + assertAssignmentScopeCount(t, target, assignmentScope, 1) + + progressSummary := runID + " completed assignment " + assignmentID + observeLiveProgress(t, target, targetPrincipal, runID+"-progress", progressSummary) + if err := syncWorkerPush(target, remoteB, "github-live-publish-b"); err != nil { + t.Fatalf("target publish branch %s: %v", cfg.branchB, err) + } + if pending, err := target.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("target publish must drain pending events, pending=%+v err=%v", pending, err) + } + + if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { + t.Fatalf("source pull branch %s: %v", cfg.branchB, err) + } + assertProgressSummaryCount(t, source, progressSummary, 1) + if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { + t.Fatalf("source repeat pull branch %s: %v", cfg.branchB, err) + } + assertProgressSummaryCount(t, source, progressSummary, 1) +} + +type liveGitHubConfig struct { + repo string + branchA string + branchB string + token string +} + +func liveGitHubTestConfig(t *testing.T) liveGitHubConfig { + t.Helper() + if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { + t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publish/pull/import test") + } + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if tokenFile := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_TOKEN_FILE")); tokenFile != "" { + raw, err := os.ReadFile(tokenFile) + if err != nil { + t.Fatalf("read MNEMON_GITHUB_TOKEN_FILE: %v", err) + } + token = strings.TrimSpace(string(raw)) + } + if token == "" { + t.Skip("GITHUB_TOKEN or MNEMON_GITHUB_TOKEN_FILE is required for live GitHub publish/pull/import test") + } + repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) + if repo == "" { + repo = "mnemon-dev/mnemon-teamwork-example" + } + branchA := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_A")) + if branchA == "" { + branchA = "mnemon/agent-a" + } + branchB := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_B")) + if branchB == "" { + branchB = "mnemon/agent-b" + } + return liveGitHubConfig{repo: repo, branchA: branchA, branchB: branchB, token: token} +} + +func liveGitHubRemote(t *testing.T, store exchange.PublicationStore, repo, branch string) exchange.RemoteWorkspace { + t.Helper() + remote, err := githubbackend.New(githubbackend.Config{ + Store: store, + Repo: repo, + Branch: branch, + Scopes: []contract.ResourceRef{ + {Kind: "assignment", ID: "project"}, + {Kind: "progress_digest", ID: "project"}, + }, + }) + if err != nil { + t.Fatalf("github remote %s: %v", branch, err) + } + return remote +} + +func observeLiveAssignment(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, assignmentID, scope string, assignee contract.ActorID) { + t.Helper() + if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ + ExternalID: assignmentID, + Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: map[string]any{ + "assignment_id": assignmentID, + "scope": scope, + "ttl": "30m", + "assignee": string(assignee), + "expected_work": "complete live GitHub Remote Workspace publish/pull/import validation", + "expected_feedback": "progress_digest with result evidence", + "evidence": "gated live GitHub publication backend test", + }}, + }); err != nil { + t.Fatalf("observe live assignment: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick live assignment: %v", err) + } +} + +func observeLiveProgress(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, externalID, summary string) { + t.Helper() + if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ + ExternalID: externalID, + Event: contract.Event{Type: "progress_digest.write_candidate.observed", Payload: map[string]any{ + "summary": summary, + }}, + }); err != nil { + t.Fatalf("observe live progress: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick live progress: %v", err) + } +} + +func assertAssignmentScopeCount(t *testing.T, rt *runtime.Runtime, scope string, want int) { + t.Helper() + _, fields, err := rt.Resource(contract.ResourceRef{Kind: "assignment", ID: "project"}) + if err != nil { + t.Fatalf("read assignment: %v", err) + } + content, _ := fields["content"].(string) + if got := strings.Count(content, scope); got != want { + t.Fatalf("assignment scope %q count = %d, want %d\n%s", scope, got, want, content) + } +} + +func assertProgressSummaryCount(t *testing.T, rt *runtime.Runtime, summary string, want int) { + t.Helper() + _, fields, err := rt.Resource(contract.ResourceRef{Kind: "progress_digest", ID: "project"}) + if err != nil { + t.Fatalf("read progress_digest: %v", err) + } + content, _ := fields["content"].(string) + if got := strings.Count(content, summary); got != want { + t.Fatalf("progress summary %q count = %d, want %d\n%s", summary, got, want, content) + } +} From a91cd815915b6e159cfe8b3f55177fadbf4f59c8 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:36:59 +0800 Subject: [PATCH 15/35] test(harness): strengthen github mesh acceptance evidence Extend the GitHub mesh acceptance report with repo-mediated transport semantics, configured branch roster evidence, no-p2p discovery, per-workspace remote plans, profile convergence details, and natural multi-round scenario prompt counts. The runner now uses a GitHub-specific worker wake prompt that allows agents to refresh their profile through Local Mnemon without adding a new networking concept. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestBuildR1GitHubMesh|TestWriteR1GitHubMesh|TestGitHubMesh' -count=1; go test ./harness/cmd/mnemon-harness; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness --- harness/cmd/mnemon-harness/acceptance.go | 4 + .../mnemon-harness/acceptance_github_mesh.go | 77 ++++++++++++++++--- .../acceptance_github_mesh_test.go | 12 +++ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 78e176bd..972147de 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -150,9 +150,13 @@ type r1CodexSyncReport struct { Status string `json:"status"` Backend string `json:"backend,omitempty"` Repo string `json:"repo,omitempty"` + TransportModel string `json:"transport_model,omitempty"` + RosterSource string `json:"roster_source,omitempty"` + NetworkDiscovery string `json:"network_discovery,omitempty"` HubURL string `json:"hub_url"` PublicationBranches []string `json:"publication_branches,omitempty"` BranchByAgent map[string]string `json:"branch_by_agent,omitempty"` + RemotePlanPaths []string `json:"remote_plan_paths,omitempty"` RuntimeWorkspaces []string `json:"runtime_workspace_paths,omitempty"` LocalStorePaths []string `json:"local_mnemond_store_paths,omitempty"` AllowedEventSubjects []string `json:"allowed_event_subjects"` diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 8e735a32..afdb1b86 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -59,6 +59,11 @@ var acceptanceR1GitHubMeshCmd = &cobra.Command{ }, } +const r1GitHubMeshWorkerWakePrompt = `Check your Mnemon context. If there is governed work for you, act on it through +your own Local Mnemon and record durable progress. If your focus, availability, +or working state changed, update your agent_profile through your own Local Mnemon. +If there is no work for you, answer "no governed work".` + func init() { acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") @@ -126,7 +131,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO RunnerContract: &r1RunnerContractReport{ EntrypointProgressBeforeIntegration: -1, EntrypointProgressAfterIntegration: -1, - WorkerWakePrompt: r1ClusterWorkerWakePrompt, + WorkerWakePrompt: r1GitHubMeshWorkerWakePrompt, }, } reportPath := filepath.Join(runRoot, "report.json") @@ -188,7 +193,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath } addR1Assertion(&report, "github-mesh no central mnemon-hub endpoint", syncReport.HubURL == "" && syncReport.HubStatus.HubEventsReceived == 0, "backend=github repo-mediated publication") + addR1Assertion(&report, "github-mesh no p2p node discovery", syncReport.NetworkDiscovery == "none" && syncReport.RosterSource == "configured-remotes-json", fmt.Sprintf("roster_source=%s network_discovery=%s", syncReport.RosterSource, syncReport.NetworkDiscovery)) addR1Assertion(&report, "github-mesh publication branches configured", len(syncReport.PublicationBranches) == opts.Agents && len(syncReport.BranchByAgent) == opts.Agents, fmt.Sprintf("branches=%v", syncReport.PublicationBranches)) + addR1Assertion(&report, "github-mesh remote plans are per-workspace", distinctStrings(syncReport.RemotePlanPaths) && len(syncReport.RemotePlanPaths) == opts.Agents, fmt.Sprintf("remote_plans=%v", syncReport.RemotePlanPaths)) addR1Assertion(&report, "github-mesh local stores isolated", distinctStrings(syncReport.LocalStorePaths) && len(syncReport.LocalStorePaths) == opts.Agents, fmt.Sprintf("stores=%v", syncReport.LocalStorePaths)) addR1Assertion(&report, "github-mesh runtime workspaces isolated", distinctStrings(syncReport.RuntimeWorkspaces) && len(syncReport.RuntimeWorkspaces) == opts.Agents, fmt.Sprintf("workspaces=%v", syncReport.RuntimeWorkspaces)) @@ -299,6 +306,7 @@ func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected [] } func (s r1GitHubMeshRun) bootstrapProfiles() error { + profileCounts := map[string]int{} for i := range s.agents { agent := &s.agents[i] payload := taskSimJSON(map[string]any{ @@ -322,18 +330,27 @@ After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agen return err } waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) + profileCounts[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)["agent_profile"] } allVisible := true for i := range s.agents { agent := s.agents[i] waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), 120*time.Second) counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] if counts["agent_profile"] < len(s.agents) { allVisible = false } } addR1Assertion(s.report, "github-mesh profiles converge through publication branches", allVisible, fmt.Sprintf("agents=%d", len(s.agents))) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: "bootstrap_profiles", Status: statusFromBool(allVisible)}) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: "bootstrap_profiles", + Status: statusFromBool(allVisible), + Evidence: map[string]any{ + "profile_counts_by_agent": profileCounts, + "publication_branches": s.report.Sync.PublicationBranches, + }, + }) if !allVisible { return fmt.Errorf("profiles did not converge through GitHub publication branches") } @@ -386,16 +403,27 @@ func (s r1GitHubMeshRun) runScenario(name string) error { counts := r1ClusterActorEventCounts(obs) participants := r1ClusterNonProfileParticipantCount(counts) replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) - passed := participants >= 2 && replans >= 2 - addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d actors=%v", participants, replans, counts)) + naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) + workerWakes := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "worker_wake:"+name) + integrationPrompts := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "integration:"+name) + passed := participants >= 2 && replans >= 2 && naturalMessages == len(entries) && integrationPrompts >= 1 && s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 + addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, counts)) s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ Name: name, Status: statusFromBool(passed), Actors: actors, Evidence: map[string]any{ - "participants": participants, - "replanning_rounds": replans, - "entry_poc_agents": actors, + "participants": participants, + "replanning_rounds": replans, + "natural_user_messages": naturalMessages, + "worker_wake_prompts": workerWakes, + "integration_prompts": integrationPrompts, + "entry_poc_agents": actors, + "multi_poc": len(actors) > 1, + "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), + "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, + "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), + "cross_scenario_mnemon_ctx": true, }, }) if !passed { @@ -440,8 +468,8 @@ func (s r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenario } agent := &s.agents[i] s.report.RunnerContract.WorkerWakePrompts++ - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1ClusterWorkerWakePrompt) - answer, err := runR1Turn(&agent.r1CodexAgent, r1ClusterWorkerWakePrompt, s.opts.TurnTimeout) + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1GitHubMeshWorkerWakePrompt) + answer, err := runR1Turn(&agent.r1CodexAgent, r1GitHubMeshWorkerWakePrompt, s.opts.TurnTimeout) appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) if err != nil { s.report.RunnerContract.WorkerWakeErrors = append(s.report.RunnerContract.WorkerWakeErrors, fmt.Sprintf("%s cycle %d %s: %v", name, cycle, agent.principal, err)) @@ -471,6 +499,29 @@ func r1GitHubMeshPromptRounds(contract *r1RunnerContractReport, scenario string) return rounds } +func r1GitHubMeshPromptKindCount(contract *r1RunnerContractReport, kind string) int { + if contract == nil { + return 0 + } + count := 0 + for _, prompt := range contract.PromptAudit { + if prompt.Kind == kind { + count++ + } + } + return count +} + +func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { + out := make(map[string]string, len(agents)) + for _, agent := range agents { + if strings.TrimSpace(agent.threadID) != "" { + out[agent.principal] = agent.threadID + } + } + return out +} + func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string) ([]r1CodexSyncAgent, error) { var agents []r1CodexSyncAgent branches := r1GitHubMeshBranches(branchPrefix, count) @@ -600,6 +651,9 @@ func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1Code Status: "running", Backend: exchange.RemoteBackendGitHub, Repo: repo, + TransportModel: "repo-mediated-publication", + RosterSource: "configured-remotes-json", + NetworkDiscovery: "none", AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), Artifacts: map[string]string{}, BranchByAgent: map[string]string{}, @@ -617,12 +671,15 @@ func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1Code } report.RuntimeWorkspaces = append(report.RuntimeWorkspaces, agent.workspace) report.LocalStorePaths = append(report.LocalStorePaths, filepath.Join(agent.workspace, runtime.DefaultStorePath)) - report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + report.RemotePlanPaths = append(report.RemotePlanPaths, remotesPath) + report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = remotesPath if i == 0 { report.Source = agent.principal } } sort.Strings(report.PublicationBranches) + sort.Strings(report.RemotePlanPaths) return report } diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 00dfccf3..4bd51120 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -85,9 +85,15 @@ func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { if report.Backend != exchange.RemoteBackendGitHub || report.Repo != "mnemon-dev/mnemon-teamwork-example" || report.HubURL != "" { t.Fatalf("report backend/repo/hub wrong: %+v", report) } + if report.TransportModel != "repo-mediated-publication" || report.RosterSource != "configured-remotes-json" || report.NetworkDiscovery != "none" { + t.Fatalf("report must pin github bootstrap semantics without p2p discovery: %+v", report) + } if len(report.PublicationBranches) != 3 || len(report.BranchByAgent) != 3 { t.Fatalf("report branches wrong: %+v", report) } + if len(report.RemotePlanPaths) != 3 || !distinctStrings(report.RemotePlanPaths) { + t.Fatalf("report must expose one remotes.json per workspace, got %+v", report.RemotePlanPaths) + } if !distinctStrings(report.RuntimeWorkspaces) || !distinctStrings(report.LocalStorePaths) { t.Fatalf("report must prove isolated workspaces/stores: workspaces=%v stores=%v", report.RuntimeWorkspaces, report.LocalStorePaths) } @@ -125,6 +131,9 @@ func TestR1GitHubMeshScenarioContract(t *testing.T) { } } } + if !strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile") { + t.Fatal("github mesh worker wake prompt should let agents naturally refresh their profile") + } } func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { @@ -154,4 +163,7 @@ func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { if got := r1GitHubMeshPromptRounds(contract, "onboarding-synthesis"); got != 3 { t.Fatalf("prompt rounds = %d, want 3", got) } + if got := r1GitHubMeshPromptKindCount(contract, "worker_wake:onboarding-synthesis"); got != 1 { + t.Fatalf("worker wake prompt count = %d, want 1", got) + } } From c69d9a4c0ad85b0475c34dfd8b6217114db39bcb Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:38:32 +0800 Subject: [PATCH 16/35] test(harness): cover github mesh join leave reassignment Extend the deterministic GitHub publication mesh test to model five initial mnemond runtimes, two later joining publication branches, one paused node, an active-node reassignment event, and eventual catch-up by the paused node. The test keeps discovery explicit through branch subscriptions and does not introduce p2p node discovery. Validation: go test ./harness/internal/app -run 'TestSyncGitHubFake' -count=1 -v; go test ./harness/internal/app --- harness/internal/app/sync_github_mesh_test.go | 145 +++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/harness/internal/app/sync_github_mesh_test.go b/harness/internal/app/sync_github_mesh_test.go index 8172eb1d..d66bca99 100644 --- a/harness/internal/app/sync_github_mesh_test.go +++ b/harness/internal/app/sync_github_mesh_test.go @@ -92,6 +92,142 @@ func TestSyncGitHubFakeFiveMnemondPublicationMesh(t *testing.T) { } } +func TestSyncGitHubFakePublicationMeshJoinLeaveReassignment(t *testing.T) { + ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f", "agent-g"} + branches := make([]string, 0, len(ids)) + for _, id := range ids { + branches = append(branches, "mnemon/"+id) + } + store, err := exchange.NewMemoryPublicationStore(branches...) + if err != nil { + t.Fatal(err) + } + + nodes := make([]meshTestNode, 0, len(ids)) + for _, id := range ids[:5] { + node := newMeshTestNode(t, id) + observeMeshAssignment(t, node.rt, node.principal, id) + publishMeshNode(t, store, node) + nodes = append(nodes, node) + } + pullMeshAllSources(t, store, nodes[:5], nodes[:5]) + + for _, id := range ids[5:] { + node := newMeshTestNode(t, id) + observeMeshAssignment(t, node.rt, node.principal, id) + publishMeshNode(t, store, node) + nodes = append(nodes, node) + } + offline := nodes[3] + active := append([]meshTestNode{}, nodes[:3]...) + active = append(active, nodes[4:]...) + reassignScope := "agent-d down; reassign delayed work to agent-f" + observeMeshAssignmentWithScope(t, nodes[0].rt, nodes[0].principal, "reassign-agent-d", reassignScope, "codex@agent-f") + publishMeshNode(t, store, nodes[0]) + + pullMeshAllSources(t, store, active, nodes) + assertMeshScopes(t, active, []string{ + meshAssignmentScope("agent-a"), + meshAssignmentScope("agent-b"), + meshAssignmentScope("agent-c"), + meshAssignmentScope("agent-d"), + meshAssignmentScope("agent-e"), + meshAssignmentScope("agent-f"), + meshAssignmentScope("agent-g"), + reassignScope, + }) + assertMeshScopesAbsent(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) + + pullMeshAllSources(t, store, []meshTestNode{offline}, nodes) + assertMeshScopes(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) +} + +type meshTestNode struct { + id string + branch string + principal string + rt *runtime.Runtime +} + +func newMeshTestNode(t *testing.T, id string) meshTestNode { + t.Helper() + root := t.TempDir() + principal := "codex-" + id + "@project" + return meshTestNode{ + id: id, + branch: "mnemon/" + id, + principal: principal, + rt: openMeshServingRuntime(t, root, principal), + } +} + +func publishMeshNode(t *testing.T, store exchange.PublicationStore, node meshTestNode) { + t.Helper() + remote := githubFakeRemote(t, store, node.branch) + if err := syncWorkerPush(node.rt, remote, "publish-"+node.id); err != nil { + t.Fatalf("%s publish: %v", node.id, err) + } + if pending, err := node.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", node.id, pending, err) + } +} + +func pullMeshAllSources(t *testing.T, store exchange.PublicationStore, targets, sources []meshTestNode) { + t.Helper() + for _, target := range targets { + for _, source := range sources { + if source.id == target.id { + continue + } + remote := githubFakeRemote(t, store, source.branch) + if err := syncWorkerPull(target.rt, remote, "subscribe-"+source.id, nil); err != nil { + t.Fatalf("%s subscribe %s: %v", target.id, source.id, err) + } + } + } +} + +func assertMeshScopes(t *testing.T, nodes []meshTestNode, scopes []string) { + t.Helper() + for _, node := range nodes { + got := meshScopes(t, node) + for _, want := range scopes { + if !got[want] { + t.Fatalf("%s assignments missing %q in %+v", node.id, want, got) + } + } + } +} + +func assertMeshScopesAbsent(t *testing.T, nodes []meshTestNode, scopes []string) { + t.Helper() + for _, node := range nodes { + got := meshScopes(t, node) + for _, want := range scopes { + if got[want] { + t.Fatalf("%s assignments unexpectedly contain %q in %+v", node.id, want, got) + } + } + } +} + +func meshScopes(t *testing.T, node meshTestNode) map[string]bool { + t.Helper() + assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} + _, fields, err := node.rt.Resource(assignmentRef) + if err != nil { + t.Fatalf("%s read assignments: %v", node.id, err) + } + items, _ := fields["items"].([]any) + scopes := map[string]bool{} + for _, item := range items { + m, _ := item.(map[string]any) + scope, _ := m["scope"].(string) + scopes[scope] = true + } + return scopes +} + func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runtime { t.Helper() refs := []contract.ResourceRef{{Kind: "progress_digest", ID: "project"}, {Kind: "assignment", ID: "project"}} @@ -105,14 +241,19 @@ func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runti } func observeMeshAssignment(t *testing.T, rt *runtime.Runtime, principal, id string) { + t.Helper() + observeMeshAssignmentWithScope(t, rt, principal, id, meshAssignmentScope(id), "codex@"+id) +} + +func observeMeshAssignmentWithScope(t *testing.T, rt *runtime.Runtime, principal, id, scope, assignee string) { t.Helper() if _, _, err := rt.API().Ingest(contract.ActorID(principal), contract.ObservationEnvelope{ ExternalID: "github-mesh-assignment-" + id, Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: map[string]any{ "assignment_id": "mesh-" + id, - "scope": meshAssignmentScope(id), + "scope": scope, "ttl": "2h", - "assignee": "codex@" + id, + "assignee": assignee, "expected_work": "complete deterministic publication mesh validation for " + id, "expected_feedback": "progress_digest", "evidence": "deterministic fake GitHub publication mesh test", From ef95b52d30b26e2c32cdb88200c499d79ef83bac Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:39:57 +0800 Subject: [PATCH 17/35] docs(harness): record github mesh implementation checkpoint Update the GitHub decentralized Remote Workspace architecture notes with the implementation checkpoint, current validation coverage, no-p2p evidence fields, live GitHub test command, and remaining external evidence required before goal closure. Validation: documentation-only change; confirmed .mnemon-dev is ignored and force-added only the two GitHub architecture files requested for this work. --- ...-decentralized-mesh-implementation-plan.md | 1087 +++++++++++++ .../github-remote-workspace-backend.md | 1380 +++++++++++++++++ 2 files changed, 2467 insertions(+) create mode 100644 .mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md create mode 100644 .mnemon-dev/architecture/github-remote-workspace-backend.md diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md new file mode 100644 index 00000000..fbc4d4cf --- /dev/null +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -0,0 +1,1087 @@ +# GitHub Decentralized Mesh Implementation Plan + +> 日期:2026-06-26 +> 类型:实现计划 / 可转换为 `/goal` 的执行设计 +> 架构依据:[github-remote-workspace-backend.md](github-remote-workspace-backend.md) +> 默认 live repo:`mnemon-dev/mnemon-teamwork-example` +> 状态:D1-D7 已讨论并同意,本文件按已同意边界重新收束 + +## 0. 已锁定决策 + +本计划不再把以下内容作为开放分歧处理: + +```text +D1 GitHub direct first; no GitHub App in first implementation. +D2 Default topology is one shared repo + one branch per mnemond. +D3 Data model can express multiple publish targets, but MVP permits only one active publish target. +D4 Validation repo is evidence surface only, never mnemond governance input. +D5 Real GitHub adapter must be validated promptly; milestone cannot finish with fake store only. +D6 Branch identity uses mnemond_id, not hostagent display name. +D7 GitHub backend is repo-mediated publication, not P2P networking. +``` + +Implication: + +```text +GitHub mesh means repo-mediated publication streams. +It does not mean mnemond-to-mnemond networking. +``` + +## 1. 目标与完成定义 + +实现一个可验证的 **GitHub-backed decentralized Remote Workspace foundation**: + +```text +5 hostagents +5 mnemond +1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example +5 per-mnemond publication branches +0 shared governed.db +0 central active mnemon-hub +``` + +核心证明: + +```text +accepted synced event envelopes can propagate through repo-mediated publication streams; +each receiving mnemond validates and imports through Event Intake -> Tick -> Materializer; +teamwork can continue through assignment, nested decomposition, join, leave, reassignment, aggregation, and next act. +``` + +Foundation done means all of the following are true: + +- Existing HTTP `mnemon-hub` sync behavior remains compatible. +- `RemoteEntry` supports backend and direction without breaking old remotes. +- GitHub concepts do not enter `runtime`, `state`, `materializer`, `presentation`, or `hostagent`. +- Every connected appserver has its own `mnemond`, own local store, and isolated runtime workspace. +- Pull-side diagnostics from invalid remote publication entries become durable local diagnostics. +- GitHub backend is implemented behind the `exchange.RemoteWorkspace` ABI. +- Fake-store unit tests cover deterministic publication behavior. +- A gated live GitHub case passes against `mnemon-dev/mnemon-teamwork-example`. +- Deterministic 5-mnemond local acceptance passes without a central active `mnemon-hub`. +- Real Codex appserver acceptance has a runnable script, evidence format, and natural task suite. +- Acceptance includes the 5-node teamwork case plus at least 2-3 natural user-task scenarios. +- Each real task starts from one or more connected PoC agents receiving ordinary user messages, not from a global orchestration prompt. +- At least one real task demonstrates multiple Teamwork-ReAct rounds: output review -> replanning -> reassignment -> execution -> aggregation -> next act. + +### 1.1 Implementation checkpoint - 2026-06-26 + +Current branch progress: + +- `exchange.RemoteWorkspace` seam exists and both HTTP and GitHub backends use it. +- `RemoteEntry` now carries `backend`, `direction`, `repo`, and `branch`; legacy HTTP defaults remain compatible. +- Directional `publish` / `subscribe` remote plans are implemented. +- Pull-side remote diagnostics are imported as durable governed diagnostics. +- `PublicationStore` seam and deterministic memory store exist. +- GitHub publication backend is fake-store tested. +- Real GitHub `PublicationStore` adapter exists. +- GitHub Remote Workspace normalizes opaque GitHub branch-head cursors before writing local mnemond pull state. +- Gated live publish/pull/import test exists and is skipped unless live credentials are provided. +- Deterministic local GitHub mesh tests cover: + - five isolated mnemond runtimes; + - one branch per mnemond; + - no central active `mnemon-hub`; + - two later joining nodes; + - one paused node; + - active-node reassignment; + - paused-node catch-up through publication branches. +- Real Codex appserver acceptance runner exists as: + +```bash +mnemon-harness acceptance r1-github-mesh-task-suite \ + --agents 5 \ + --agent-turns \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-token-file +``` + +Known open evidence: + +- The live GitHub case has not been executed in this workspace because `MNEMON_GITHUB_LIVE`, `GITHUB_TOKEN`, and `MNEMON_GITHUB_TOKEN_FILE` are absent. +- The real Codex appserver GitHub mesh suite is runnable, but still requires a GitHub token file and a usable Codex appserver environment. +- Until the live GitHub and real appserver runs pass, this goal is not closed. + +## 2. Non-goals and invariants + +Non-goals: + +- Do not implement GitHub Issue/PR/Actions collaboration. +- Do not let GitHub become canonical governed state. +- Do not implement strong cross-trust-domain security in this milestone. +- Do not implement direct mnemond-to-mnemond transport. +- Do not implement P2P networking, gossip, DHT, peer routing, NAT traversal, or overlay network. +- Do not call branch enumeration a node discovery protocol. +- Do not claim multi-publish reliability before per-target sync ledger exists. + +Hard invariants: + +```text +Remote Workspace transports accepted synced envelopes. +Local mnemond alone imports through Event Intake -> Tick -> Materializer. +GitHub backend talks only to configured GitHub Remote Workspace. +Branch presence is publication inventory, not liveness or authority. +Validation reports are evidence, not runtime input. +``` + +Package boundary invariant: + +```text +runtime/state/materializer/presentation/hostagent + must not import GitHub backend packages. + +GitHub code must stay below exchange/backend boundary. +``` + +## 3. Current baseline + +Already implemented first cut: + +```text +exchange.RemoteWorkspace interface + SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) + SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) + SyncStatus() (contract.SyncStatusResponse, error) +``` + +Current HTTP `access.Client` can satisfy the interface. `remotes.json` has a `backend` field start: + +```text +empty backend -> http +unknown backend -> fail closed +``` + +Known unrelated validation caveat: + +```text +go test ./harness/cmd/mnemon-harness +``` + +may still hit the macOS `/var` vs `/private/var` acceptance run-root issue until fixed separately. + +## 4. Target topology + +Runtime topology: + +```text + configured GitHub Remote Workspace + repo: mnemon-dev/mnemon-teamwork-example + +------------------------------------------------+ + | branch mnemon/ accepted log | + | branch mnemon/ accepted log | + | branch mnemon/ accepted log | + | branch mnemon/ accepted log | + | branch mnemon/ accepted log | + +------------------------------------------------+ + ^ ^ ^ + | | | + push own stream pull subscribed pull subscribed + | | | + +----+----+ +----+----+ +----+----+ + | mnemond | | mnemond | | mnemond | + | local | | local | | local | + | store | | store | | store | + +----+----+ +----+----+ +----+----+ + ^ ^ ^ + | | | + hostagent hostagent hostagent +``` + +There is no: + +```text +mnemond -> mnemond socket +gossip channel +routing table +overlay network +GitHub Issue/PR assignment queue +``` + +Data flow: + +```text +hostagent emits event + -> local mnemond accepts event + -> local sync material marks accepted synced envelope + -> GitHub backend publishes to own branch + -> subscribed mnemond pull publication streams + -> pull validates digest/scope/phase/idempotency + -> valid envelopes enter local Event Intake + -> invalid envelopes create durable diagnostics + -> Tick -> Materializer derives local views/cues +``` + +## 5. Remote model contract + +`RemoteEntry` target model: + +```text +RemoteEntry + id + backend: http | github + direction: bidirectional | publish | subscribe + endpoint # http only + repo # github only, e.g. mnemon-dev/mnemon-teamwork-example + branch # github only, e.g. mnemon/ + credential_ref + ca_file # http only + scopes +``` + +Direction semantics: + +```text +bidirectional -> push target + pull source +publish -> push target only +subscribe -> pull source only +``` + +Compatibility: + +```text +empty backend -> http +empty direction -> bidirectional +legacy HTTP remotes preserve current behavior +``` + +MVP restriction: + +```text +At most one active publish target. +Multiple subscribe sources are allowed if cursor/import idempotency is proven. +``` + +Reason:the current sync ledger mostly tracks synced/pending/conflict globally. Multiple push targets need a per-target ledger before reliability can be claimed. + +## 6. GitHub repo contract + +Default repository: + +```text +mnemon-dev/mnemon-teamwork-example +``` + +Branch namespace: + +```text +mnemon/team +mnemon/ +``` + +Recommended layout: + +```text +mnemon/team + .mnemon/team.json + .mnemon/scenarios/.json + .mnemon/reports//summary.json + +mnemon/ + .mnemonhub/v1/manifest.json + .mnemonhub/v1/events//.json +``` + +Contract: + +- `mnemon/team` stores bootstrap metadata and reports only. +- `mnemon/` stores accepted-event publication entries only. +- A `mnemond` writes only its own branch. +- A `mnemond` reads configured/subscribed publication branches. +- Branch enumeration is publication stream enumeration inside a configured Remote Workspace. +- Branch presence is not liveness, membership authority, permission authority, or scheduling input. +- Reports are never imported as governed events. +- The validation repo may be deleted/recreated without changing local governance semantics. + +Team manifest shape: + +```json +{ + "schema_version": 1, + "team_id": "mnemon-teamwork-example", + "members": [ + { + "mnemond_id": "agent-a", + "branch": "mnemon/agent-a", + "principal": "codex-a@project" + } + ] +} +``` + +Interpretation: + +```text +members = configured publication stream inventory +members != canonical team state +members != permission grant +members != online status +``` + +## 7. PublicationStore contract + +The GitHub backend must be testable without real GitHub. Introduce a storage seam below `exchange.RemoteWorkspace`: + +```go +type PublicationStore interface { + PutEvent(ctx context.Context, branch string, path string, body []byte) (PutResult, error) + ListEvents(ctx context.Context, branch string, prefix string, cursor string) (ListResult, error) + ReadFile(ctx context.Context, branch string, path string) ([]byte, error) + WriteFile(ctx context.Context, branch string, path string, body []byte) error +} + +type PutResult struct { + Created bool + ExistsSame bool + Conflict bool +} + +type StoredEvent struct { + Path string + Body []byte + Cursor string +} +``` + +Event path: + +```text +.mnemonhub/v1/events//.json +``` + +Put semantics: + +```text +same path + same body -> idempotent success +same path + different body -> conflict diagnostic +new path -> created +unsupported branch/path -> fail closed +``` + +List/cursor semantics for MVP: + +```text +Fake store: + deterministic ordered cursor. + +GitHub store: + cursor may encode last fully scanned branch head. + if branch head is unchanged, return no new entries. + if branch head changed, list current event tree and let local import idempotency skip duplicates. +``` + +This is intentionally conservative. It avoids claiming perfect append-only incremental listing before a per-branch/per-target ledger exists. + +## 8. Phase dependency graph + +```text +P0 guardrails + -> P1 RemotePlan + -> P2 diagnostics + -> P3 PublicationStore + -> P4 fake-tested GitHub backend + -> P5 repo contract + operator config + -> P6 real GitHub adapter + live case + -> P7 deterministic 5-mnemond acceptance + -> P8 real Codex appserver acceptance + -> P9 docs + goal closure +``` + +P5 repo contract is listed before P6 because the live case must know exactly which repo, branches, paths, and reports are valid. + +## 9. P0: Concept guardrails + +Purpose:make concept boundaries executable. + +Scope: + +- Add/extend coreguard tests. +- Forbid GitHub backend imports from `runtime`, `state`, `materializer`, `presentation`, `hostagent`. +- Forbid GitHub Issue/PR/Action as teamwork semantic names. +- Forbid P2P discovery/gossip/routing/overlay terms in backend implementation names. +- Enforce `publication stream enumeration` terminology for branch listing. + +Outputs: + +- Coreguard test file or extension. +- Package allowlist for GitHub backend. +- Naming denylist for GitHub-native teamwork concepts. + +Validation: + +```text +go test ./harness/internal/coreguard +``` + +Done when: + +- Core dependency graph contains no GitHub backend import. +- Failing examples catch at least one forbidden import/name case. + +## 10. P1: Directional RemotePlan + +Purpose:separate push targets from pull sources. + +Scope: + +- Add `direction` to `RemoteEntry`. +- Add `RemotePlan{PushTargets, PullSources}`. +- Preserve legacy HTTP behavior. +- Worker/manual sync use RemotePlan instead of single remote. +- Enforce MVP one active publish target. + +Tests: + +- legacy remote -> one push target and one pull source. +- `direction=publish` -> push only. +- `direction=subscribe` -> pull only. +- unknown direction fail-closed. +- multiple publish targets fail-closed or return explicit unsupported error. +- worker idle/no remote behavior unchanged. + +Validation: + +```text +go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemote' +go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness +``` + +Done when: + +- Existing HTTP connect/push/pull tests pass. +- Config output includes backend/direction without breaking old remotes. + +## 11. P2: Pull diagnostics ingestion + +Purpose:make pull-side rejection visible and durable. + +Why now:GitHub direct has no server-side push clamp. Invalid entries must not silently disappear. + +Scope: + +- Consume `SyncPullResponse.Diagnostics`. +- Add `sync.remote_diagnostic.observed` or equivalent durable event. +- Ensure diagnostic import is idempotent. +- Keep HTTP no-diagnostics path unchanged. + +Payload shape: + +```json +{ + "remote_id": "...", + "origin_mnemond": "...", + "event_id": "...", + "subject": "...", + "status": "rejected|conflict|invalid", + "diagnostic": "..." +} +``` + +Tests: + +- fake remote returns diagnostics. +- local event log contains durable diagnostic. +- repeated pull does not duplicate diagnostic. +- valid event import remains unchanged. + +Validation: + +```text +go test ./harness/internal/app -run 'TestSync|TestDiagnostic' +``` + +Done when: + +- Invalid remote publication entries produce visible local diagnostics. +- Diagnostics are not treated as accepted remote events. + +## 12. P3: PublicationStore fake seam + +Purpose:isolate GitHub storage mechanics from exchange semantics. + +Scope: + +- Add `PublicationStore` interface. +- Add deterministic in-memory fake store. +- Add path normalization and branch validation helpers. +- Add cursor behavior tests. + +Tests: + +- event key deterministic. +- idempotent put. +- conflict put. +- list after cursor. +- unsupported branch/path fail closed. +- same event body across repeated publish does not create conflicts. + +Validation: + +```text +go test ./harness/internal/mnemonhub/exchange -run 'TestPublicationStore|TestGitHubBackendFake' +``` + +Done when: + +- Fake store can run the full publish/pull/import logic without network. +- No GitHub API package is needed for unit tests. + +## 13. P4: GitHub backend over fake store + +Purpose:implement `exchange.RemoteWorkspace` semantics before wiring real GitHub. + +Remote config: + +```text +backend: github +direction: publish|subscribe +repo: mnemon-dev/mnemon-teamwork-example +branch: mnemon/ +credential_ref: ... +scope: ... +``` + +Push flow: + +```text +SyncPush(req) + for each synced envelope: + require phase=synced + materialize SyncedEventMaterial + derive event_key + write .mnemonhub/v1/events//.json + created/exists-same -> accepted + exists-different -> conflict diagnostic +``` + +Pull flow: + +```text +SyncPull(req) + list subscribed publication branch entries + skip own origin + validate envelope digest/phase/scope + return valid Events + return invalid/out-of-scope/conflict as Diagnostics +``` + +Tests: + +- publish writes only synced envelopes. +- subscribe returns valid subscribed events. +- own origin excluded. +- invalid phase -> diagnostic. +- out-of-scope -> diagnostic. +- digest mismatch -> diagnostic. +- same key different body -> diagnostic. +- cursor unchanged -> no new events in fake store. + +Validation: + +```text +go test ./harness/internal/mnemonhub/exchange +go test ./harness/internal/app -run 'TestSync' +``` + +Done when: + +- GitHub backend behavior is proven over fake store. +- Worker can use backend through `exchange.RemoteWorkspace`, not a GitHub-specific type. + +## 14. P5: Repo contract and operator config + +Purpose:freeze the real repo shape before the live GitHub adapter. + +Scope: + +- Document `mnemon-dev/mnemon-teamwork-example` as the default live repo. +- Define branch names for the live case. +- Define `team.json` and report output. +- Add CLI/config examples. +- Add validation that repo/branch/path shapes are accepted or rejected deterministically. + +Live branch set: + +```text +mnemon/team +mnemon/agent-a +mnemon/agent-b +mnemon/agent-c +mnemon/agent-d +mnemon/agent-e +``` + +Operator UX: + +```bash +mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ... + +mnemon-harness sync connect agent-b-stream \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ... +``` + +Done when: + +- A new operator can identify the repo, branches, token file, and expected report path. +- Validation repo is clearly marked evidence surface only. + +## 15. P6: Real GitHub adapter and live case + +Purpose:prove the backend works on real GitHub, not just fake storage. + +Official docs anchors to verify at implementation time: + +- Repository Contents API: https://docs.github.com/en/rest/repos/contents +- Git Trees API: https://docs.github.com/en/rest/git/trees +- Git References API: https://docs.github.com/en/rest/git/refs +- Fine-grained token permissions: https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens +- REST API rate limits: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +- REST API best practices: https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api + +Plan: + +```text +P6A docs verification and API spike +P6B GitHub PublicationStore implementation +P6C gated live publish/pull/import case +``` + +P6A must verify: + +- create/update file behavior and conflict status. +- branch reference behavior and fast-forward constraints if Git Database API is used. +- required fine-grained token permissions for contents read/write. +- rate-limit headers and secondary rate-limit behavior. +- serial mutative request policy. + +Default API strategy: + +```text +Use Contents API first for MVP PutEvent if one-file-per-event commits are acceptable. +Use Git Trees/Commits/Refs if batching or branch-head control becomes necessary. +Use Git Trees or equivalent tree listing for event discovery when directory listing limits matter. +``` + +The implementation must not hard-code an API choice that prevents later switching behind `PublicationStore`. + +Security: + +- Token comes from `credential_ref` or token file. +- Token never appears in logs, diagnostics, reports, or test failure output. +- Use authenticated requests. +- Use explicit timeout. +- Serialize mutative requests per branch. +- Treat `403`, `404`, `409`, `422`, and rate-limit responses as structured errors. + +Gated live case: + +```text +repo: mnemon-dev/mnemon-teamwork-example +publish branch: mnemon/agent-a +subscribe branch: mnemon/agent-b + +agent-a: + local accepted synced envelope + -> publish .mnemonhub/v1/events/agent-a/.json + +agent-b: + pull mnemon/agent-a + -> validate envelope + -> import through Event Intake path + -> persist cursor/status + +repeat pull: + -> no duplicate import +``` + +Suggested gated command shape: + +```bash +MNEMON_GITHUB_LIVE=1 \ +MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ +MNEMON_GITHUB_TOKEN_FILE=/path/to/token \ +MNEMON_GITHUB_BRANCH_A=mnemon/agent-a \ +MNEMON_GITHUB_BRANCH_B=mnemon/agent-b \ +go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v +``` + +Default unit tests must skip this when `MNEMON_GITHUB_LIVE` is not set. + +Done when: + +- Fake unit tests pass. +- Gated live case has passed at least once against `mnemon-dev/mnemon-teamwork-example`. +- Live case evidence records repo, branches, commit refs or publication cursors, and import result. + +## 16. P7: Deterministic local 5-mnemond acceptance + +Purpose:prove mesh semantics without real Codex appservers. + +Topology: + +```text +5 local stores +5 mnemond runtime instances +1 fake or real publication store +5 publication branches +0 central active mnemon-hub +0 shared governed.db +``` + +Scenario: + +1. Agent A publishes `teamwork_signal` and assignments. +2. B/C/D/E subscribe/import. +3. B emits nested assignment. +4. Two new `mnemond` instances join. +5. One `mnemond` stops publishing progress/profile. +6. TTL-derived cue leads another actor to emit reassignment. +7. Progress digests aggregate. +8. Another act is emitted after aggregation. +9. Final completion evidence is emitted. + +Assertions: + +- no shared governed.db. +- no central active mnemon-hub endpoint. +- every cross-mnemond event came from a publication branch. +- imported resources exist only after Event Intake -> Tick -> Materializer. +- injected bad entries produce diagnostics. +- repeated pulls are idempotent. + +Done when: + +- Deterministic acceptance script/test passes locally. +- Report contains event chain, branch/cursor evidence, diagnostics, and no-hub/no-shared-db proof. + +Current implemented deterministic coverage: + +```bash +go test ./harness/internal/app -run 'TestSyncGitHubFake' -count=1 -v +``` + +It covers five isolated mnemond runtimes, one publication branch per mnemond, fake GitHub publication store, later join by two nodes, paused node, active-node reassignment event, and paused-node catch-up. It does not by itself prove real Codex appserver natural planning; that remains P8. + +## 17. P8: Real Codex appserver acceptance + +Purpose:prove the workflow with real hostagent behavior. + +Topology: + +```text +5 codex appservers +5 mnemond +5 isolated runtime workspaces +5 isolated local mnemond stores +1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example +5 publication branches +0 central active mnemon-hub +0 shared governed.db +``` + +Isolation requirements: + +- Each appserver connects to exactly one dedicated `mnemond`. +- Each `mnemond` has its own local store path. +- Each appserver has its own runtime workspace path. +- No appserver reads or writes another appserver's local Mnemon workspace. +- Cross-agent visibility happens only after publication branch pull/import. +- The report must include local store paths and prove they are distinct. + +Baseline 5-node Teamwork-ReAct scenario: + +1. Start 5 appservers/mnemond. +2. Install generic hook/GUIDE/skill. +3. Publish fresh `agent_profile`. +4. Send an ordinary user message to one connected PoC agent to start the teamwork task. +5. Verify assignment propagation. +6. Verify nested decomposition. +7. Verify first round outputs are published as progress digests. +8. Verify a PoC-like agent reviews outputs and emits a second-round plan. +9. Verify second-round reassignment or refinement is published and executed. +10. Add 2 `mnemond` instances mid-run. +11. Stop 1 `mnemond` mid-run. +12. Verify reassignment through TTL/stalled cue. +13. Verify aggregation. +14. Verify another act can be emitted after aggregation if the result is incomplete. +15. Verify final completion evidence. + +Natural task suite: + +- Run the baseline 5-node case. +- Run at least 2 of the natural task scenarios in section 18. +- Prefer all 3 scenarios before declaring the milestone robust. +- At least one scenario must use multiple PoC agents. +- At least one scenario must create overlapping tasks where progress on task B can complete or materially advance task A. +- At least one scenario must verify profile update and profile freshness during the run. +- At least one scenario must show multiple output-driven replanning/reassignment rounds. +- Each scenario must be team-shaped:success should require useful work from more than one agent, not just one agent doing everything while others observe. + +Report evidence: + +```text +run_id +participants +entry_poc_agents +natural_user_messages +runtime_workspace_paths +local_mnemond_store_paths +publication branches +events published per branch +events imported per mnemond +diagnostics per mnemond +assignment/progress chain +replanning_rounds +reassignment_rounds +mnemond join/leave timeline +profile refresh/update timeline +cross_task_reuse_or_completion evidence +proof no central mnemon-hub endpoint was used +proof no shared governed.db was used +proof each appserver used its own mnemond/local store/runtime workspace +``` + +Done when: + +- Script is runnable. +- A successful run produces the report above. +- Each task was initiated from connected PoC agent conversation, not global harness prompt. +- At least one successful scenario contains two or more output-driven replanning rounds. +- At least one successful scenario demonstrates isolated per-agent `mnemond` and local store paths. +- Failure mode leaves enough diagnostics to distinguish API/auth/config errors from Mnemon import/admission errors. + +## 18. Natural acceptance task suite + +Purpose:test whether teamwork behaves like normal agent usage, not like a scripted benchmark. + +Harness rules: + +- The harness may start/stop appservers, start/stop `mnemond`, configure remotes, and collect evidence. +- The harness may send ordinary user messages to one or more chosen PoC agents. +- The harness must not send a global prompt describing the expected internal delegation graph. +- The harness must not directly tell agents which member should receive which assignment, except where a normal user would reasonably mention a person/role. +- The harness must not inject hidden "expected answer" scaffolding into agent contexts. +- The harness observes events, profiles, diagnostics, assignments, and progress digests after the fact. + +Natural prompt shape: + +```text +User -> connected PoC agent: + "Can you help me get X done? Pull in help if useful." +``` + +Avoid prompt shape: + +```text +Global harness -> all agents: + "Agent A must create assignments for B/C/D, then B must split work, then C must..." +``` + +Required observation dimensions: + +- PoC initiation:which connected agent received the user message and emitted the first teamwork signal. +- Isolation:each connected agent uses its own `mnemond`, local store, and runtime workspace. +- Profile freshness:agents publish fresh `agent_profile` before and during work. +- Profile adaptation:agents update profile/posture when availability, recent success, specialization, or load changes. +- Multi-PoC behavior:two PoC agents can independently start work without corrupting each other's streams. +- Multi-task behavior:an agent can hold multiple tasks and make useful progress without losing context. +- Cross-task reuse:work done for task B can complete, unblock, or materially advance task A. +- Teamwork-ReAct loop:agents use round outputs to replan, reassign, execute again, and aggregate again. +- Team-shaped work:the final result contains meaningful contributions from multiple agents. +- Natural escalation:agents ask the user only when ambiguity/risk warrants it. +- No forced choreography:assignments and decomposition emerge from agent reasoning and governed events. + +### Scenario A: Repository onboarding synthesis + +Entry: + +```text +PoC agent-a receives: +"帮我快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。你可以让其他成员帮忙核对架构、测试和风险。如果第一轮信息不够,请根据大家的反馈再拆一轮补齐。" +``` + +Expected natural behavior: + +- `agent-a` acts as PoC and emits a teamwork signal. +- Other agents may inspect architecture docs, current harness sync code, tests, and risk areas. +- At least one agent publishes a profile/posture update showing documentation/review availability or recent context. +- First-round output reveals gaps; PoC emits a second-round refinement or fact-check assignment. +- Aggregation produces a concise onboarding artifact or report. + +What this tests: + +- Single-PoC kickoff. +- Natural decomposition without explicit worker list. +- Profile freshness and role fit. +- Aggregation from multiple publication branches. +- Output-driven replanning after first-round findings. + +### Scenario B: Sync issue plus opportunistic docs/test completion + +Entry: + +```text +PoC agent-b receives: +"同步这块我担心还有隐藏问题。你帮我检查一下 GitHub Remote Workspace 相关的配置、诊断和测试设计;如果发现顺手能补的文档或测试缺口,一起处理。第一轮先找风险,再根据结果安排第二轮验证。" +``` + +Optional earlier/open task: + +```text +PoC agent-a previously received: +"你先帮我整理一个这次 GitHub mesh 改造的文档和测试 readiness checklist,后面我们实现完要靠它验收。" +``` + +Expected natural behavior: + +- `agent-b` starts from implementation/test concern, not from a scripted assignment plan. +- First-round risk findings drive a second-round verification or docs/test patch assignment. +- While checking sync diagnostics or RemotePlan tests, an agent may produce evidence that also satisfies the earlier docs/test readiness task. +- The system records that progress on task B completed or materially advanced task A. +- Agents update profile/posture if they become loaded, blocked, or demonstrate sync/testing expertise. + +What this tests: + +- Multiple active tasks in continuous context. +- Cross-task reuse and opportunistic completion. +- Multi-round review -> replan -> verify loop. +- Diagnostics visibility. +- Avoiding duplicate work when two tasks overlap. + +### Scenario C: Multi-PoC live-readiness and operator safety + +Entry: + +```text +PoC agent-a receives: +"请你推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让大家找出缺口,再根据第一轮结果安排第二轮补齐。" + +PoC agent-c, in a separate normal conversation, receives: +"我主要担心这个 GitHub 方案的操作者安全和失败诊断。你帮我从 token、repo、branch、报错可读性这几个角度检查一下。" +``` + +Expected natural behavior: + +- Two PoC agents independently emit teamwork signals. +- The team converges on compatible work instead of creating conflicting branch/repo assumptions. +- Security/operator findings may update the live-case runbook. +- Live-case preparation may satisfy part of the operator safety task. +- First-round live-readiness/security output leads to second-round fixes or validation assignments. +- Profiles reflect current availability and specialization, for example API/live-case, docs, security, or test evidence. + +What this tests: + +- Multi-PoC initiation. +- Concurrent teamwork streams over the same repo-mediated workspace. +- Conflict avoidance and aggregation. +- Natural task overlap and reuse. +- Multi-PoC multi-round replanning. + +## 19. P9: Documentation, UX, and goal closure + +Scope: + +- Update harness README for GitHub-backed decentralized Remote Workspace. +- Document repo-mediated publication mesh, not P2P networking. +- Document branch enumeration as publication stream enumeration. +- Document honest-client/single-trust-domain boundary. +- Document live GitHub test prerequisites and skip behavior. +- Document cleanup/reset behavior for `mnemon-dev/mnemon-teamwork-example`. +- Document the natural acceptance task suite and PoC initiation rule. +- Summarize validation commands and known caveats. + +Done when: + +- Operator path is reproducible from docs. +- Candidate goal can be closed with unit, live-case, deterministic, and natural appserver acceptance evidence. + +## 20. Validation matrix + +Minimum per phase: + +```text +P0: go test ./harness/internal/coreguard +P1: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemote' +P2: go test ./harness/internal/app -run 'TestSync|TestDiagnostic' +P3: go test ./harness/internal/mnemonhub/exchange -run 'TestPublicationStore' +P4: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app -run 'TestGitHub|TestSync' +P5: config/repo contract tests and CLI help tests +P6: fake unit tests by default + gated live GitHub publish/pull/import case +P7: deterministic local 5-mnemond acceptance +P8: real Codex appserver acceptance + natural task suite +P9: docs/UX review against actual commands +``` + +Always run after code changes: + +```text +gofmt +go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness +``` + +When changing harness module assets: + +```text +make harness-validate +``` + +Full E2E remains: + +```text +bash scripts/e2e_test.sh +make test +``` + +## 21. Risk ledger + +| Risk | Mitigation | +| --- | --- | +| GitHub concepts leak into Mnemon governance | P0 guardrails and naming denylist | +| Branch enumeration becomes node discovery | D7 invariant and publication stream terminology | +| Fake-store proof hides GitHub API issues | P6 gated live case is required for completion | +| Contents API one-commit-per-event becomes too slow | Keep `PublicationStore` API-independent; switch to Git Trees/Commits/Refs behind seam | +| Directory listing/cursor is incomplete at scale | Conservative branch-head cursor + idempotent import; later per-branch ledger | +| Multiple publish targets overclaim reliability | MVP one active publish target | +| Token leakage in diagnostics | explicit redaction tests and no-token reports | +| GitHub rate/secondary limits | authenticated requests, serialized mutative writes, backoff/error handling | +| Validation repo becomes control plane | D4 invariant and report-only contract | +| Acceptance is over-scripted and proves only the harness | PoC-only natural user entry rule; harness observes instead of choreographing | +| Appservers accidentally share local governance state | per-agent mnemond/store/runtime workspace isolation proof required | +| Agents fail to refresh useful profiles | profile freshness/update assertions in P8 reports | +| Multiple tasks duplicate work or miss cross-task completion | cross-task reuse/completion evidence required in natural task suite | +| Teamwork stops after one round and does not prove protocol value | multi-round Teamwork-ReAct evidence required: output review -> replan -> reassign -> execute -> aggregate | + +## 22. Candidate `/goal` + +```text +Implement the GitHub-backed decentralized Remote Workspace foundation for mnemon harness. + +Scope: +- Preserve the existing HTTP mnemon-hub behavior. +- Add directional RemotePlan with publish/subscribe/bidirectional semantics. +- Add pull diagnostics ingestion so invalid remote publication entries become durable local diagnostics. +- Add a fake-tested GitHub publication backend implementing exchange.RemoteWorkspace over a PublicationStore seam. +- Add repo/branch contract for mnemon-dev/mnemon-teamwork-example. +- Add a real GitHub adapter and gated live GitHub publish/pull/import validation case. +- Add deterministic local 5-mnemond mesh acceptance. +- Add real Codex appserver acceptance harness/report shape and natural task suite. +- Ensure each connected agent has its own mnemond, local store, and isolated runtime workspace in acceptance. +- Do not let GitHub concepts enter runtime/state/materializer/hostagent/presentation. +- Do not use GitHub Issues/PR/Actions as teamwork semantics. +- Do not implement P2P networking; GitHub mesh means repo-mediated publication streams only. + +Validation: +- Relevant go tests for exchange/app/cmd sync paths. +- go build ./harness/cmd/mnemon-harness. +- Gated live GitHub publish/pull/import case against mnemon-dev/mnemon-teamwork-example when credentials are provided. +- Deterministic local decentralized mesh acceptance. +- Real Codex appserver acceptance script/report is runnable. +- Natural PoC-initiated task suite covers baseline 5-node case, per-agent mnemond/store/workspace isolation, multi-PoC kickoff, profile updates, cross-task reuse/completion, and multi-round Teamwork-ReAct replanning/reassignment. +``` diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md new file mode 100644 index 00000000..912ca7b9 --- /dev/null +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -0,0 +1,1380 @@ +# GitHub Remote Workspace Backend:去中心化 publication mesh 架构 + +> 日期:2026-06-26 +> 类型:详细架构文档 / Remote Workspace backend 方向 +> 第一性参照:[positioning-thesis.md](positioning-thesis.md)、[event-substrate-concept-normalization.md](event-substrate-concept-normalization.md)、[multi-machine-and-hub-redesign.md](multi-machine-and-hub-redesign.md)、[r1-production-like-per-hostagent-mnemond-experiment.md](r1-production-like-per-hostagent-mnemond-experiment.md) + +## 0. 结论 + +GitHub 用在这里是合理的,但它合理的原因不是"GitHub 能存文件",而是它可以作为 **GitHub-backed decentralized Remote Workspace** 的 bootstrap backend。 + +准确表述: + +```text +Remote Workspace is a protocol role. +mnemon-hub is the first-party HTTP implementation. +GitHub is a bootstrap backend for decentralized publication mesh. +``` + +GitHub backend 不替代 `mnemonhub` 这个协议角色,也不替代本地 `mnemond` 的治理语义。它只替代/补充 `mnemon-hub` 这个后端实现形态,用于让用户无需部署中心 hub 也能启动多 `mnemond` teamwork。 + +核心形态: + +```text +shared GitHub team repo + branch mnemon/ <- only mnemond-a writes + branch mnemon/ <- only mnemond-b writes + branch mnemon/ <- only mnemond-c writes + +each mnemond publishes its own accepted-event log; +other mnemond subscribe to those branches and validate locally. +``` + +这不是 P2P 网络通信,因为所有 `mnemond` 仍只连接已配置的 GitHub Remote Workspace;但它是 **repo-mediated decentralized publication topology**:没有一个全局 active `mnemon-hub` 服务拥有整个 teamwork exchange。 + +### 0.1 当前实现状态 - 2026-06-26 + +当前代码已经落地的边界: + +- GitHub backend 只存在于 `harness/internal/mnemonhub/exchange/backend/github`。 +- `runtime` / `state` / `materializer` / `presentation` / `hostagent` 不依赖 GitHub backend。 +- 每个 `mnemond` 通过 `remotes.json` 配置一个 `publish` branch 和多个 `subscribe` branches。 +- GitHub Contents/Refs API 的 branch head cursor 不直接写入本地 mnemond cursor;backend 会把 opaque cursor 收束为本地可持久化数字 cursor,重复列表依赖 Event Intake 幂等性去重。 +- acceptance report 会显式输出: + - `transport_model=repo-mediated-publication` + - `roster_source=configured-remotes-json` + - `network_discovery=none` + - per-workspace `remotes.json` + - per-agent publication branch + - per-agent local mnemond store path + +当前仍需外部证据的边界: + +- gated live GitHub publish/pull/import 需要 token 后实际运行。 +- real Codex appserver GitHub mesh suite 需要 token + Codex appserver 环境后实际运行。 +- 在这些验收通过前,不能把 GitHub mesh 目标标记为 complete。 + +## 1. 非目标与红线 + +GitHub backend 必须服务 Mnemon 的事件治理模型,不能反过来把 Mnemon 拉回 GitHub 协作模型。 + +非目标: + +- 不用 GitHub Issues / PR / Projects 表达 `assignment`、`progress_digest` 或 teamwork 状态。 +- 不用 GitHub Actions 作为 scheduler、runtime loop 或治理执行器。 +- 不让 GitHub backend 直接写 governed resources。 +- 不让 GitHub backend 影响 render/cue/presentation。 +- 不引入 agent-to-agent message inbox。 +- 不把 branch/commit ordering 当成治理 ordering。 +- 不实现 P2P 网络通信、gossip、DHT、peer routing、NAT traversal 或 overlay network。 +- 不把 branch 枚举实现成独立的 P2P 节点发现协议。 + +硬红线: + +```text +remote backend may transport accepted synced envelopes; +only local mnemond may import them through Event Intake -> Tick -> Materializer. +github backend may talk to configured GitHub Remote Workspace only; +it must not open mnemond-to-mnemond network links. +``` + +## 1.5 概念收束与防污染规则 + +这条路线最大的风险不是 GitHub API 复杂,而是概念污染:把 GitHub repo/branch/Issue/PR 误提升为 Mnemon 的协作主语,从而偏离 `hostagent / mnemond / mnemonhub / event` 主轴。 + +因此后续文档、代码、CLI、测试命名必须遵守以下收束规则。 + +### 1.5.1 系统主语 + +允许作为系统主语的概念: + +```text +hostagent +mnemond +mnemonhub +event / event envelope +Remote Workspace +publication backend +RemotePlan +``` + +其中: + +- `mnemond` 是去中心运行单元。 +- `event envelope` 是跨节点交换材料。 +- `Remote Workspace` 是协议角色。 +- `publication backend` 是 Remote Workspace 的传输/存储实现。 +- `GitHub` 只是一个 publication backend。 + +不允许把以下对象提升为治理主语: + +```text +GitHub repo +GitHub branch +GitHub issue +GitHub PR +GitHub Action +validation repo +team repo +``` + +这些对象只能作为 substrate / transport namespace / evidence surface。 + +### 1.5.2 命名约束 + +推荐命名: + +```text +GitHub-backed publication mesh +repo-mediated publication mesh +publication branch +publication stream enumeration +team rendezvous repo +validation repo +RemotePlan publish/subscribe +``` + +避免命名: + +```text +GitHub team state +GitHub assignment +GitHub scheduler +GitHub agent inbox +GitHub source of truth +branch state +PR-based teamwork +issue-based assignment +P2P discovery protocol +mesh networking layer +peer routing +``` + +原因:这些命名会暗示 GitHub 承担 governed state 或 teamwork 语义,与 Mnemon 架构相冲突。 + +### 1.5.3 语义边界 + +GitHub repo/branch 的语义是: + +```text +append-only publication substrate +transport cursor/provenance +team rendezvous and bootstrap +publication stream enumeration +acceptance evidence storage +``` + +GitHub repo/branch 的语义不是: + +```text +canonical governed state +assignment database +scheduler queue +agent inbox +truth source for availability +arbiter of completion +network peer discovery authority +node routing table +``` + +Canonical state 只在本地 `mnemond` store 中产生;跨节点可见性来自 accepted synced envelopes 被订阅方 `mnemond` 本地导入后的结果。 + +### 1.5.4 验证仓库边界 + +如果使用一个专门仓库做 teamwork 验证,该仓库的角色应定义为: + +```text +validation repo = GitHub-backed publication substrate + run evidence surface +``` + +它可以包含: + +```text +team manifest +per-mnemond publication branches +scenario fixtures +run reports +acceptance evidence +``` + +它不能包含或承担: + +```text +canonical governed.db +central scheduler +authoritative assignment state +agent-to-agent messages +GitHub Issue/PR workflow as teamwork semantics +``` + +验证通过的证据应证明: + +```text +5 appservers +5 mnemond stores +0 shared governed.db +0 central active mnemon-hub +accepted events propagate through publication branches +every imported event enters through local Event Intake -> Tick -> Materializer +``` + +### 1.5.5 网络通信边界 + +`decentralized`、`mesh`、`P2P-like` 这些词只允许描述 publication ownership / event propagation topology,不能描述 GitHub backend 的网络实现。 + +GitHub backend 的通信模型是: + +```text +mnemond -> configured GitHub Remote Workspace +``` + +不允许引入: + +```text +mnemond -> mnemond direct socket +gossip +DHT +peer routing +NAT traversal +overlay network +dynamic P2P topology maintenance +``` + +因此 branch 枚举的准确含义是: + +```text +publication branch enumeration += list publication streams inside an already configured Remote Workspace +!= P2P node discovery +!= team membership authority +!= liveness authority +!= permission authority +``` + +branch 枚举可以提供 roster bootstrap 的输入材料,但是否接受某个 stream、是否把它视为可用 agent、是否允许其 event 进入本地治理,都必须由本地 `mnemond` 的 admission/import policy 决定。 + +## 2. 术语裁决:decentralized / federated / P2P + +推荐术语: + +```text +Decentralized Remote Workspace +GitHub-backed publication mesh +Repo-mediated publication mesh +Mnemond-published accepted-event logs +Federated mnemond sync +Publication stream enumeration +``` + +术语边界: + +- **decentralized**:适合。没有单个中心 `mnemon-hub` 进程承载所有交换;每个 `mnemond` 拥有自己的 publication stream。 +- **federated**:适合。每个 `mnemond`/信任域保留本地治理,通过订阅授权接收其他 publication streams 中的 accepted events。 +- **P2P**:只可作为拓扑类比,不可作为网络实现承诺。当前 GitHub backend 不是 peer-to-peer socket transport,而是 **repo-mediated publication topology**。除非另起独立设计实现 direct mnemond-to-mnemond transport,否则不应把 GitHub backend 命名为 P2P networking。 + +## 3. 当前中心 hub 流程 + +当前 production-like 目标是: + +```text +5 hostagents + 5 mnemond + 1 mnemonhub + 0 shared governed.db +``` + +中心 hub 流程: + +```text ++-------------+ +-------------+ +-------------+ +| hostagent A | -----> | mnemond A | -----> | mnemon-hub | ++-------------+ event +-------------+ sync +-------------+ + | + | pull + v + +-------------+ + | mnemond B | + +-------------+ + | + v + +-------------+ + | hostagent B | + +-------------+ +``` + +数据流: + +```text +agent-a emits teamwork_signal / assignment + -> mnemond-a accepts event + -> mnemond-a records pending synced envelope + -> sync worker pushes to mnemon-hub + -> mnemon-hub validates grant/scope/digest/idempotency + -> mnemond-b/c/d/e pull from mnemon-hub + -> each imports through Event Intake -> Tick -> Materializer + -> render/skill surfaces cues + -> assigned agents act and emit progress/assignment/signal +``` + +中心 hub 特性: + +- 一个公共 exchange point。 +- push 前 server-side validation。 +- hub-side cursor/status。 +- 清晰但需要部署/运行 hub。 + +## 3.5 诚实定位:GitHub 是 bootstrap backend,不是最终去中心 substrate + +如果目标是"真正去中心运行",GitHub 不是最优雅的最终核心。GitHub mesh 的结构是: + +```text +mnemond is decentralized as publisher; +GitHub remains centralized rendezvous/storage substrate. +``` + +真正去中心的核心抽象应是: + +```text +mnemond publishes an append-only accepted-event feed; +other mnemond subscribe to that feed; +all imports still go through local governance. +``` + +因此最稳的架构表达是: + +```text +Mnemon's decentralized unit is mnemond, not GitHub. +GitHub is a convenient publication substrate. +``` + +当前 GitHub backend 的通信边界: + +```text ++-------------+ +-------------+ +| mnemond A | | mnemond B | +| local state | | local state | ++------+------+ +------+------+ + | | + | push/pull own and subscribed streams | push/pull own and subscribed streams + v v + +------------- configured GitHub Remote Workspace -------------+ + | shared repo | + | branch mnemon/a | + | branch mnemon/b | + +---------------------------------------------------------------+ +``` + +Publication backend 应保持可插拔: + +```text +publication backend: + github branch + static HTTPS directory + object storage + git remote + future mnemond-native publication endpoint +``` + +direct P2P/libp2p-like transport 属于另一个未来研究方向,不是 GitHub backend 的实现内容,也不是当前 goal 的网络层假设。 + +GitHub 的价值是现实工程上的 bootstrap: + +- repo 权限和 token 心智现成; +- storage/history/audit trail 现成; +- branch namespace 可表达 per-mnemond publication stream; +- 不需要用户部署中心 `mnemon-hub`; +- 离线节点的历史 feed 可由 GitHub 保留; +- 很适合证明 `5 appserver + 5 mnemond + 0 central active hub` 可跑通。 + +但 GitHub 不应变成架构中心: + +- 不应让 GitHub API 渗入 `runtime` / `state` / `presentation` / `hostagent`; +- 不应把 GitHub branch/commit ordering 当作治理 ordering; +- 不应把 GitHub Issue/PR/Actions 当作 teamwork 语义层; +- 不应把 "GitHub-backed mesh" 宣传成纯 P2P。 + +实现策略: + +```text +architecture core: + decentralized publication mesh + +first backend: + GitHub-backed publication branch + +future backend: + mnemond-native publication endpoint +``` + +这保证第一版可以借 GitHub 快速启动,但最终形态仍然是 `mnemond-native publication mesh`。 + +## 4. GitHub Mesh 目标流程 + +GitHub mesh 改为 shared repo + per-agent branch: + +```text +repo: mnemon-dev/mnemon-teamwork-example + +branches: + mnemon/agent-a + mnemon/agent-b + mnemon/agent-c + mnemon/agent-d + mnemon/agent-e +``` + +拓扑: + +```text + shared GitHub repo + +----------------------------------+ + | branch mnemon/agent-a | + | branch mnemon/agent-b | + | branch mnemon/agent-c | + | branch mnemon/agent-d | + | branch mnemon/agent-e | + +----------------------------------+ + ^ ^ ^ ^ + | | | | + publish read read read + | + mnemond-a +``` + +每个 `mnemond`: + +- 只写自己的 branch。 +- 读取自己订阅的 publication branches。 +- pull 后本地验证 scope/digest/idempotency。 +- 只通过本地 Event Intake 导入。 + +GitHub mesh 数据流: + +```text +agent-a emits teamwork_signal / assignment + -> mnemond-a accepts event locally + -> mnemond-a publishes synced envelope to branch mnemon/agent-a + -> mnemond-b/c/d/e read branch mnemon/agent-a + -> each local mnemond validates and imports + -> assigned agents act + -> each publishes its own accepted events on its own branch +``` + +这不是 agent-to-agent messaging。它是: + +```text +mnemond-to-mnemond accepted-event publication mesh +``` + +## 5. 为什么是 shared repo + per-agent branch + +### 5.1 优于 per-agent repo + +```text +per-agent repo: + + ownership clean + - team bootstrap inventory = N repos + - onboarding heavy + - permissions/config spread across repos + +shared repo + per-agent branch: + + one team rendezvous + + one permission namespace + + publication streams enumerable by branch/manifest + + still preserves one-writer-per-publication-stream +``` + +### 5.2 优于 one shared branch + +```text +shared branch: + - all mnemond write same head + - commit races likely + - ownership boundary fuzzy + - one bad writer can corrupt shared stream + +per-agent branch: + + one writer per branch + + no cross-agent branch-head contention + + readers merge by local governance import + + branch is transport namespace, not governed truth +``` + +### 5.3 推荐默认 + +默认 GitHub direct backend 应使用: + +```text +one shared team repo +one publication branch per mnemond +``` + +不是每个 agent 一个 repo,也不是所有 agent 写一个 branch。 + +## 6. 组件架构 + +目标组件图: + +```text ++----------------------------- HostAgent -----------------------------+ +| Codex / Claude Code | +| thin hook + managed GUIDE + mnemon-observe skill | +| observe / pull / render | ++-------------------------------+--------------------------------------+ + | + v ++----------------------------- mnemond -------------------------------+ +| Local governance domain | +| | +| Event Intake -> Admission Rules -> Bridge -> Materializer | +| | | | | | +| v v v v | +| observed log diagnostics proposed events resources | +| | +| Store: events / resources / decisions / sync_events / cursors | ++-------------------------------+--------------------------------------+ + | + v ++------------------------ Sync Orchestrator --------------------------+ +| Reads RemotePlan | +| | +| Push lane: | +| local accepted synced events -> push targets | +| | +| Pull lane: | +| pull sources -> validate/diagnose -> importPulledEvents | ++-------------------------------+--------------------------------------+ + | + v ++----------------------- Remote Workspace ABI ------------------------+ +| interface: | +| SyncPush(req) -> SyncPushResponse | +| SyncPull(req) -> SyncPullResponse | +| SyncStatus() -> SyncStatusResponse | ++-------------------+-------------------------------+------------------+ + | | + v v + +----------------------+ +------------------------------+ + | HTTP backend | | GitHub backend | + | mnemon-hub | | publication mesh | + | central sync service | | shared repo/per-agent branch | + +----------------------+ +------------------------------+ +``` + +Boundary: + +- `runtime` / `state` / `presentation` / `hostagent` must not know GitHub. +- `app` owns orchestration and import. +- `mnemonhub/exchange` owns remote exchange seams, cursors, local sync ledger helpers, and backend adapters. +- `mnemonhub` remains the central HTTP reference backend. + +## 7. RemotePlan:从双向 remote 到 directional plan + +当前 remote 是隐含双向: + +```text +remote hub: + push local events + pull remote events +``` + +GitHub mesh 需要方向分离: + +```text +publish target: + where this mnemond writes its accepted-event stream + +subscribe source: + where this mnemond reads subscribed accepted-event streams +``` + +目标 model: + +```text +RemoteEntry + id + backend: http | github + direction: bidirectional | publish | subscribe + credential_ref + scope + backend-specific config + +RemotePlan + PushTargets []RemoteEntry + PullSources []RemoteEntry +``` + +兼容规则: + +```text +empty backend -> http +empty direction -> bidirectional +old remotes.json -> behavior unchanged +``` + +Mapping: + +```text +http + bidirectional: + PushTargets += entry + PullSources += entry + +github + publish: + PushTargets += entry + +github + subscribe: + PullSources += entry +``` + +ASCII: + +```text ++------------------------- RemotePlan Loader --------------------------+ +| remotes.json | +| | +| backend: http direction: bidirectional -> push + pull | +| backend: github direction: publish -> push only | +| backend: github direction: subscribe -> pull only | ++-------------------------------+--------------------------------------+ + | + v ++------------------------- Sync Orchestrator --------------------------+ +| for each PushTarget: | +| ReadPushBatch -> RemoteWorkspace.SyncPush -> ApplyPushResponse | +| | +| for each PullSource: | +| ReadPullState -> RemoteWorkspace.SyncPull -> import events | +| \-> ingest diagnostics | ++----------------------------------------------------------------------+ +``` + +## 8. GitHub repo layout + +Team repo: + +```text +mnemon-dev/mnemon-teamwork-example +``` + +Team coordination branch: + +```text +branch: mnemon/team + +.mnemon/team.json +``` + +Per-mnemond publication branches: + +```text +branch: mnemon/ + +.mnemonhub/v1/ + manifest.json + events/ + / + .json + receipts/ + / + .json +``` + +`team.json` sketch: + +```json +{ + "schema_version": 1, + "team_id": "mnemon-teamwork-example", + "members": [ + { + "mnemond_id": "agent-a", + "branch": "mnemon/agent-a", + "principal": "codex-a@project" + } + ] +} +``` + +`manifest.json` sketch: + +```json +{ + "schema_version": 1, + "backend": "github", + "mode": "publication", + "origin_mnemond": "agent-a", + "published_at": "2026-06-26T00:00:00Z", + "published_scopes": [ + { "kind": "assignment", "id": "project" }, + { "kind": "progress_digest", "id": "project" } + ] +} +``` + +Event file: + +```text +.mnemonhub/v1/events//.json +``` + +`event_key`: + +```text +sha256(origin_mnemond + "\0" + event_id + "\0" + subject + "\0" + digest) +``` + +Rules: + +- File body is a single `event.EventEnvelope` with `phase=synced`. +- Same key + same body = idempotent. +- Same key + different body = conflict diagnostic. +- Git commit SHA is transport cursor/provenance only. +- Governance ordering remains local ingest seq after import. + +## 9. Data flows + +### 9.1 Publish + +```text +local Materializer accepts decision + -> state records accepted envelope + -> state records pending sync event + -> sync orchestrator reads PushTargets + -> GitHub backend derives event_key + -> writes event file to own publication branch + -> records per-target ack locally +``` + +ASCII: + +```text ++----------+ +---------+ +-------------+ +------------------+ +| decision | --> | synced | --> | sync worker | --> | GitHub branch | +| accepted | | event | | push lane | | mnemon/agent-a | ++----------+ +---------+ +-------------+ +------------------+ +``` + +### 9.2 Subscribe / import + +```text +sync orchestrator reads PullSources + -> GitHub backend lists subscribed branch after cursor + -> validates event envelope + -> returns SyncPullResponse.Events + -> app.importPulledEvents + -> IngestTrusted(sync@local) + -> Tick + -> RemoteImportRule + -> Materializer.Apply +``` + +ASCII: + +```text ++------------------+ +-------------+ +--------------+ +----------+ +| GitHub branch | --> | pull lane | --> | Event Intake | --> | local | +| mnemon/agent-b | | validate | | + Tick | | resource | ++------------------+ +-------------+ +--------------+ +----------+ +``` + +Forbidden: + +```text +GitHub event -> direct Store.Resource write +``` + +Required: + +```text +GitHub event -> Event Intake -> Tick -> Materializer +``` + +### 9.3 Diagnostics + +GitHub direct lacks server-side push clamp, so pull must surface problems: + +```text +invalid phase +invalid schema +digest mismatch +out-of-subscription scope +same event_key different body +unknown importable kind +``` + +All must become local visible diagnostics, never silent drops. + +Target flow: + +```text +GitHub backend detects invalid entry + -> SyncPullResponse.Diagnostics + -> sync orchestrator ingests diagnostic observation + -> durable sync.diagnostic appears in local event log +``` + +This extends the existing skipped-kind path: + +```text +sync.import_skipped.observed -> SyncImportSkippedRule -> sync.diagnostic +``` + +## 10. Teamwork scenario flow + +Target acceptance scenario: + +```text +5 codex appservers +5 mnemond +1 shared GitHub team repo +5 per-agent publication branches +0 shared governed.db +0 central active mnemon-hub +``` + +### 10.1 Bootstrap + +```text +agent-a appserver starts + -> mnemond-a starts + -> publish branch mnemon/agent-a exists + -> agent-a emits agent_profile + -> profile is published +``` + +Team publication enumeration / roster bootstrap: + +```text +team.json / branch list says a publication stream exists +agent_profile says whether agent-a is currently useful/available +assignment TTL/progress says whether work is healthy +``` + +Branch presence is not enough to assign work and is not a network-level online signal. + +### 10.2 First act + +```text +agent-a receives user teamwork task + -> reads governed context + -> emits teamwork_signal + -> emits assignment(s) + -> mnemond-a accepts + -> mnemond-a publishes to mnemon/agent-a +``` + +Subscribed mnemond: + +```text +mnemond-b/c/d/e subscribe to mnemon/agent-a + -> import signal/assignment + -> render work cues + -> assigned agents act +``` + +### 10.3 Nested decomposition + +```text +agent-b receives assignment + -> decides it should split + -> emits new teamwork_signal / assignment + -> mnemond-b accepts + -> mnemond-b publishes to mnemon/agent-b + -> subscribed mnemond pull/import +``` + +This is Teamwork-ReAct-like: + +```text +Sense -> Select -> Work -> Feedback + ^ | + +------ next Act -----+ +``` + +But the "act" is event-governed, not direct message dispatch. + +### 10.4 Mnemond join + +Two new `mnemond` instances join during work: + +```text +agent-f/g appservers start + -> create branches mnemon/agent-f/g + -> publish fresh agent_profile + -> subscribe to existing branches + -> pull backlog according to cursors +``` + +Existing mnemond can import them only after the next configured repo pull and: + +```text +team manifest/branch enumeration includes the publication stream ++ fresh agent_profile is imported +``` + +### 10.5 Mnemond stale and reassignment + +One `mnemond` stops publishing fresh evidence: + +```text +agent-c stops publishing progress/profile refresh +``` + +No scheduler should directly reassign. Instead: + +```text +assignment TTL expires without progress + -> presentation derives expired/stalled cue + -> another agent emits teamwork_signal or assignment + -> mnemond accepts reassignment event + -> event propagates through mesh +``` + +This preserves R1 rule: + +```text +assignment_expired is derived, not durable state. +assignment_status is deferred. +``` + +### 10.6 Aggregation and next act + +Agents publish `progress_digest`. + +Aggregator-like agent sees: + +```text +progress_digest from branches b/d/e/f +missing/expired assignment from c +``` + +It may: + +- emit final `progress_digest`; +- emit new `teamwork_signal`; +- emit new `assignment` for another act; +- ask user only if ambiguity/risk requires escalation. + +Loop continues until the task is truly complete. + +## 11. Publication sensing model + +Do not conflate publication stream inventory with teamwork availability. + +Three layers: + +```text +1. Configured workspace inventory + shared repo / team.json / branch enumeration + tells mnemond which publication streams can be subscribed + +2. Governed agent presence + agent_profile with ttl/freshness/availability + tells agents who appears available and suited + +3. Assignment health + assignment TTL + progress_digest + tells agents whether a commitment is stale, blocked, or complete enough +``` + +Publication candidate active: + +```text +publication branch exists ++ fresh agent_profile ++ suitable scope/context advantages +=> candidate for assignment +``` + +This does not mean a direct network counterpart is online. It only means the configured workspace currently contains an acceptable publication stream with fresh governed evidence. + +Publication candidate stale: + +```text +profile stale +or assignment TTL expired without progress +=> derived cue; another agent may reassign +``` + +## 12. 权限与安全 + +Two-layer permission model: + +```text +GitHub permission: + who can read/write branches or repo + +Mnemon permission: + which origin/resource_ref/event material this mnemond accepts +``` + +Shared repo + per-agent branch 模式必须诚实承认一个边界:GitHub 本身不能可靠地替 Mnemon 表达每个 branch / 每个 resource_ref / 每个 event 的治理权限。实际安全模型应理解为: + +```text +GitHub repo permission = transport access +mnemond import policy = governance permission +``` + +也就是说: + +- GitHub 控制谁能接触这个 repo。 +- Branch 命名和 branch ownership 是约定/配置,不是完整的 Mnemon 权限系统。 +- 一个订阅方能读到某个 branch,不等于本地 `mnemond` 会接受里面的 event。 +- 一个 writer 能把内容写进 GitHub,不等于其他 `mnemond` 会导入这些内容。 +- 真正的接受/拒绝发生在订阅方本地 pull/import 时。 + +Pull-side acceptance chain: + +```text +GitHub event + -> backend validation + -> subscription/origin policy + -> resource_ref scope check + -> digest/schema/idempotency check + -> Event Intake + -> Tick + -> Materializer +``` + +如果任何一步失败,结果必须是 diagnostic,不是静默丢弃,也不是直接写资源。 + +GitHub direct: + +- Good for quick start and single trust domain. +- Branch protection/rulesets can reduce accidental branch damage. +- Fine-grained tokens/deploy keys can limit repo access. +- Still cannot express Mnemon resource-level scope by itself. +- Cannot prevent an already-authorized repo writer from publishing malformed or out-of-scope material. +- Relies on each subscribing `mnemond` to fail-closed on import. + +Therefore GitHub direct is appropriate for: + +```text +single trust domain +honest clients +quick-start decentralized mesh +``` + +It is not enough for: + +```text +strong cross-trust-domain boundary +server-side push clamp +strict pre-append authorization +``` + +GitHub App: + +```text +Local mnemond -> GitHub App backend -> GitHub repo +``` + +App backend can validate before writing: + +- identity +- scope clamp +- digest +- idempotency +- branch ownership + +This is closer to `mnemon-hub` server-side semantics, but requires running an App backend. Use it when "bad material should not be appendable to the remote at all" is a requirement. + +mnemon-hub: + +- Strongest first-party reference backend. +- Server-side validation. +- Good for stronger cross-domain boundary. +- Requires service deployment. + +Positioning: + +```text +GitHub direct = bootstrap decentralized mesh +mnemon-hub = strong reference hub +GitHub App = GitHub-hosted strong validation variant +``` + +## 13. State model pressure point:per-remote sync status + +Current local sync ledger mostly treats a synced event as pending/synced/conflict globally. GitHub mesh may need per-target status: + +```text +event E published to: + github-self: synced + http-hub: pending + archive: conflict +``` + +If one local accepted event must publish to multiple push targets, marking it globally synced after the first successful target would starve other targets. + +Likely requirement: + +```text +sync_event_targets + event_key + remote_id + status + diagnostic + updated_at +``` + +MVP escape hatch: + +- Start with one publish target for GitHub direct. +- Do not claim multi-publish reliability until per-target ledger exists. + +## 14. Implementation stages + +### Stage 0: Backend seam + +Status: initial version implemented in code. + +```text +exchange.RemoteWorkspace + SyncPush + SyncPull + SyncStatus +``` + +HTTP `access.Client` satisfies this ABI. + +### Stage 1: Directional RemotePlan + +Add: + +```text +backend +direction +RemotePlan{PushTargets, PullSources} +``` + +Tests: + +- legacy config remains bidirectional HTTP. +- unknown backend fail-closed. +- unknown direction fail-closed. +- publish-only does not pull. +- subscribe-only does not push. + +### Stage 2: Pull diagnostics consumption + +Consume `SyncPullResponse.Diagnostics`. + +Tests: + +- fake remote returns diagnostic. +- local event log gets durable `sync.diagnostic`. +- repeated pull is idempotent. + +### Stage 3: Publication store interface + +Before real GitHub API, define fake-testable storage: + +```go +type PublicationStore interface { + PutEvent(path string, body []byte) (created bool, conflict bool, err error) + ListEvents(prefix string, cursor string) (events []StoredEvent, nextCursor string, err error) +} +``` + +Tests use memory/fake store. + +### Stage 4: GitHub backend skeleton + +Implement `RemoteWorkspace` over `PublicationStore`. + +Push: + +- read local batch +- derive event key +- write to own branch store +- return accepted/conflict + +Pull: + +- list subscribed branch store +- validate envelope +- return valid events + diagnostics + +### Stage 5: Repo contract and operator config + +Freeze the validation repo and operator-facing config before the live adapter: + +```text +repo: mnemon-dev/mnemon-teamwork-example +team metadata branch: mnemon/team +publication branch: mnemon/ +``` + +This stage defines: + +- branch namespace; +- event/report paths; +- `team.json` shape; +- CLI examples; +- validation rules for repo/branch/path shape. + +CLI options: + +```bash +mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ... + +mnemon-harness sync connect agent-b-stream \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ... +``` + +Later, if clearer: + +```bash +mnemon-harness sync publish connect ... +mnemon-harness sync subscribe add ... +``` + +### Stage 6: Real GitHub adapter and live case + +Add GitHub API implementation behind `PublicationStore`. Detailed execution is governed by [github-decentralized-mesh-implementation-plan.md](github-decentralized-mesh-implementation-plan.md). + +MVP choices: + +- Contents API: simpler for one-file-per-event writes. +- Git Trees/Commits/Refs: better for batching and branch-head control. + +Use fake store for default unit behavior, but do not stop at fake-store proof. Stage 6 is complete only after a gated real GitHub case proves the flow on `mnemon-dev/mnemon-teamwork-example`. + +Required gated live case: + +```text +configured GitHub repo: mnemon-dev/mnemon-teamwork-example +branch mnemon/agent-a +branch mnemon/agent-b + +agent-a push: + accepted synced envelope -> GitHub publication branch + +agent-b pull: + enumerate subscribed publication branch + read publication entry + validate envelope + import through Event Intake + persist cursor/status + +repeat pull: + no duplicate import +``` + +Do not add real network tests as default unit tests. The real GitHub case should run only when credentials/repo are explicitly provided, but the milestone cannot be called done until that case has passed at least once. + +### Stage 6.5: Validation report contract + +Before the full 5-appserver acceptance, define the dedicated evidence report contract so the test harness does not drift into GitHub-native teamwork. + +Validation repo layout: + +```text +mnemon/team + .mnemon/team.json + .mnemon/scenarios/.json + .mnemon/reports//summary.json + +mnemon/ + .mnemonhub/v1/manifest.json + .mnemonhub/v1/events//.json + +mnemon/ +mnemon/ +... +``` + +Contract: + +- `mnemon/team` holds bootstrap metadata and reports only. +- `mnemon/` branches hold accepted-event publication logs only. +- No Issue/PR/Action is used as teamwork state. +- Reports summarize evidence; they do not become input to `mnemond` governance. +- The validation repo may be deleted and recreated without changing local governance semantics. + +Required report evidence: + +```text +run_id +participants +publication branches +events published per branch +events imported per mnemond +diagnostics per mnemond +assignment/progress chain +mnemond join/leave timeline +proof no central mnemon-hub endpoint was used +proof no shared governed.db was used +``` + +### Stage 7: Acceptance scenarios + +All real appserver scenarios must start from one or more connected PoC agents receiving ordinary user messages. The harness may start/stop nodes and collect evidence, but it must not use a global choreography prompt to tell every agent what to do. + +Deterministic acceptance: + +```text +5 local mnemond/runtime instances +1 fake or real publication store +5 publication branches +0 central mnemon-hub +0 shared governed.db +``` + +Real appserver acceptance: + +```text +5 real codex appservers +5 mnemond +5 isolated runtime workspaces +5 isolated local mnemond stores +1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example +5 publication branches +0 central mnemon-hub +0 shared governed.db +``` + +Isolation requirements: + +- each appserver is attached to exactly one dedicated `mnemond`; +- each `mnemond` has its own local store; +- each appserver has its own runtime workspace; +- cross-agent visibility only happens through publication branch pull/import. + +Scenario: + +1. Start 5 appservers/mnemond. +2. Publish fresh profiles. +3. Send an ordinary user message to one connected PoC agent to start teamwork. +4. Verify assignment propagation. +5. Verify nested decomposition. +6. Verify first-round outputs cause a second-round plan. +7. Verify second-round reassignment/refinement is executed. +8. Add 2 `mnemond` instances mid-run. +9. Stop 1 `mnemond` mid-run. +10. Verify stale/TTL cue causes governed reassignment. +11. Verify progress aggregation. +12. Verify another act can be emitted after aggregation. +13. Verify completion proof uses accepted events, not GitHub issue/PR state. + +Additional natural task scenarios should cover: + +- single-PoC repository onboarding synthesis; +- implementation/test investigation where work on task B can complete or advance task A; +- multi-PoC live-readiness/operator-safety work over the same repo; +- profile freshness and profile/posture updates during the run. +- multiple output-driven Teamwork-ReAct rounds: review outputs, replan, reassign, execute, aggregate again. + +## 15. Remaining open questions + +- Should public read repos be supported with signed envelopes, or require private repos for MVP? +- Should branch protection be recommended or automated? +- Do we need external signatures before claiming cross-trust-domain safety? +- How aggressive should compaction/checkpoint be for long-running teams? From febd01af70be51f6208f7c577012955b406598d8 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:41:52 +0800 Subject: [PATCH 18/35] docs(harness): describe github remote workspace backend Document the experimental GitHub Remote Workspace backend as repo-mediated publication branches, with explicit publish/subscribe setup examples and the gated live validation command. Keep mnemon-hub named as the first-party Remote Workspace backend and call out that GitHub does not add P2P discovery or GitHub-native teamwork semantics. Validation: documentation-only change. --- docs/harness/README.md | 8 +++++++- docs/harness/USAGE.md | 25 +++++++++++++++++++++++-- harness/README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/harness/README.md b/docs/harness/README.md index 771b380e..87c993f1 100644 --- a/docs/harness/README.md +++ b/docs/harness/README.md @@ -14,7 +14,9 @@ The user-facing command surface is intentionally small: - `setup`: install Agent Integration shim assets. - `local`: run or inspect Local Mnemon. - `status`: show Agent Integration, Local Mnemon, and Remote Workspace state. -- `sync`: connect Local Mnemon to a Remote Workspace. +- `sync`: connect Local Mnemon to a Remote Workspace. `mnemon-hub` is the + first-party backend; GitHub publication branches are available as an + experimental repo-mediated backend. Other implementation commands are internal and are not part of the beta product contract. @@ -29,6 +31,10 @@ The current beta does not promise production readiness, automatic apply, multi-agent governance, broad organization scope, or a general evaluation runtime. +The GitHub Remote Workspace backend is experimental. It uses explicitly +configured publication branches and does not implement P2P discovery, GitHub +Issues, GitHub PRs, or GitHub Actions as teamwork semantics. + ## 3. Separation From Stable Mnemon `mnemon-harness` is built from `./harness/cmd/mnemon-harness`. diff --git a/docs/harness/USAGE.md b/docs/harness/USAGE.md index ffed63c1..2b6dac44 100644 --- a/docs/harness/USAGE.md +++ b/docs/harness/USAGE.md @@ -38,12 +38,33 @@ Inspect local state: ## 3. Remote Workspace Sync -Connect a Remote Workspace: +Connect an HTTP `mnemon-hub` Remote Workspace: ```sh -./mnemon-harness sync connect my-workspace +./mnemon-harness sync connect my-workspace --remote-url https://mnemon-hub.example/sync --token-file ./hub.token ``` +Experimental GitHub publication backend: + +```sh +./mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ~/.config/mnemon/github.token + +./mnemon-harness sync connect agent-b \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ~/.config/mnemon/github.token +``` + +The GitHub backend is repo-mediated publication, not P2P discovery. Configure the +branches you publish and subscribe to explicitly. + Run one push or pull: ```sh diff --git a/harness/README.md b/harness/README.md index b5bf2d5c..8c96f716 100644 --- a/harness/README.md +++ b/harness/README.md @@ -8,8 +8,9 @@ The current product surface is intentionally small: - `setup` installs Agent Integration shim assets into Codex or Claude Code. - `local run` starts the project-local Mnemon service. - `status` reports Agent Integration, Local Mnemon, and sync status. -- `sync` connects Local Mnemon to a Remote Workspace (`mnemon-hub`) and pushes/pulls - governed commits with attribution preserved. +- `sync` connects Local Mnemon to a Remote Workspace and pushes/pulls governed + commits with attribution preserved. The first-party backend is `mnemon-hub`; + the experimental GitHub backend uses repo-mediated publication branches. - `loop validate` remains hidden and is used by `make harness-validate`. Host directories such as `.codex` and `.claude` are projection surfaces. Runtime @@ -48,3 +49,40 @@ Remove projected assets for a principal: ``` More command examples are in `docs/harness/USAGE.md`. + +## Experimental GitHub Remote Workspace + +GitHub can be used as a bootstrap Remote Workspace backend for a decentralized +publication mesh. Each Local Mnemon publishes accepted synced events to its own +configured branch and subscribes to explicitly configured peer branches. + +This is not P2P networking or GitHub Issues/PR-based teamwork. GitHub is only the +publication substrate; every imported event still enters through the receiving +Local Mnemon's Event Intake. + +Example shape: + +```sh +./mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ~/.config/mnemon/github.token + +./mnemon-harness sync connect agent-b \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ~/.config/mnemon/github.token +``` + +Live validation is opt-in: + +```sh +MNEMON_GITHUB_LIVE=1 \ +MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ +MNEMON_GITHUB_TOKEN_FILE=~/.config/mnemon/github.token \ +go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v +``` From cbeb5322e8c69ad5794cb8696e37f315bff6723d Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:44:28 +0800 Subject: [PATCH 19/35] test(harness): require governed github mesh task evidence Strengthen the GitHub mesh natural acceptance runner so each scenario must produce actual governed assignment and progress_digest evidence, not just multiple runner prompts. The report now records per-actor event counts and adds a post-scenario profile refresh assertion for work-time profile updates. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestBuildR1GitHubMesh|TestWriteR1GitHubMesh|TestGitHubMesh' -count=1; go test ./harness/cmd/mnemon-harness; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness --- .../mnemon-harness/acceptance_github_mesh.go | 55 ++++++++++++++++++- .../acceptance_github_mesh_test.go | 16 ++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index afdb1b86..57760ed6 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -239,6 +239,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO addR1Error(&report, err) } } + run.addPostScenarioAssertions() obs, obsErr := observeAcceptanceRun(runRoot, 1000) if obsErr == nil { report.Observability = &obs @@ -406,8 +407,18 @@ func (s r1GitHubMeshRun) runScenario(name string) error { naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) workerWakes := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "worker_wake:"+name) integrationPrompts := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "integration:"+name) - passed := participants >= 2 && replans >= 2 && naturalMessages == len(entries) && integrationPrompts >= 1 && s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 - addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, counts)) + assignments := r1GitHubMeshKindTotal(counts, "assignment") + progress := r1GitHubMeshKindTotal(counts, "progress_digest") + signals := r1GitHubMeshKindTotal(counts, "teamwork_signal") + intents := r1GitHubMeshKindTotal(counts, "project_intent") + passed := participants >= 2 && + replans >= 2 && + naturalMessages == len(entries) && + integrationPrompts >= 1 && + s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 && + assignments >= 1 && + progress >= 1 + addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d assignments=%d progress_digest=%d teamwork_signal=%d project_intent=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, assignments, progress, signals, intents, counts)) s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ Name: name, Status: statusFromBool(passed), @@ -424,6 +435,11 @@ func (s r1GitHubMeshRun) runScenario(name string) error { "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), "cross_scenario_mnemon_ctx": true, + "actor_event_counts": counts, + "assignment_events": assignments, + "progress_digest_events": progress, + "teamwork_signal_events": signals, + "project_intent_events": intents, }, }) if !passed { @@ -432,6 +448,25 @@ func (s r1GitHubMeshRun) runScenario(name string) error { return nil } +func (s r1GitHubMeshRun) addPostScenarioAssertions() { + profileCounts := r1GitHubMeshLedgerCountsByAgent(s.agents, "agent_profile") + refreshed := false + for _, count := range profileCounts { + if count > len(s.agents) { + refreshed = true + break + } + } + addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_agents=%d", profileCounts, len(s.agents))) + if s.report.Sync != nil { + if s.report.Raw == nil { + s.report.Raw = map[string]json.RawMessage{} + } + raw, _ := json.Marshal(profileCounts) + s.report.Raw["github_mesh:profile_counts_after_scenarios"] = raw + } +} + type r1GitHubMeshScenarioEntry struct { index int prompt string @@ -512,6 +547,22 @@ func r1GitHubMeshPromptKindCount(contract *r1RunnerContractReport, kind string) return count } +func r1GitHubMeshKindTotal(counts map[string]map[string]int, kind string) int { + total := 0 + for _, byKind := range counts { + total += byKind[kind] + } + return total +} + +func r1GitHubMeshLedgerCountsByAgent(agents []r1CodexSyncAgent, kind string) map[string]int { + out := make(map[string]int, len(agents)) + for _, agent := range agents { + out[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)[kind] + } + return out +} + func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { out := make(map[string]string, len(agents)) for _, agent := range agents { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 4bd51120..4059e400 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -167,3 +167,19 @@ func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { t.Fatalf("worker wake prompt count = %d, want 1", got) } } + +func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { + counts := map[string]map[string]int{ + "codex-01@project": {"assignment": 2, "progress_digest": 1}, + "codex-02@project": {"assignment": 1, "teamwork_signal": 1}, + } + if got := r1GitHubMeshKindTotal(counts, "assignment"); got != 3 { + t.Fatalf("assignment total = %d, want 3", got) + } + if got := r1GitHubMeshKindTotal(counts, "progress_digest"); got != 1 { + t.Fatalf("progress total = %d, want 1", got) + } + if got := r1GitHubMeshKindTotal(counts, "project_intent"); got != 0 { + t.Fatalf("missing kind total = %d, want 0", got) + } +} From 42a7fdfbaefde3d847256cd9bab03ecfddaba3cf Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:47:54 +0800 Subject: [PATCH 20/35] test(harness): exercise github mesh mnemond lifecycle Add lifecycle evidence to the GitHub mesh acceptance report and pause/restart one isolated Local Mnemon after appserver initialization. The probe records the configured publication branch and proves the appserver can keep its own mnemond/store/workspace boundary through restart without adding node discovery. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestBuildR1GitHubMesh|TestWriteR1GitHubMesh|TestGitHubMesh' -count=1; go test ./harness/cmd/mnemon-harness; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness --- harness/cmd/mnemon-harness/acceptance.go | 10 +++ .../mnemon-harness/acceptance_github_mesh.go | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 972147de..85f6b9ee 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -160,6 +160,7 @@ type r1CodexSyncReport struct { RuntimeWorkspaces []string `json:"runtime_workspace_paths,omitempty"` LocalStorePaths []string `json:"local_mnemond_store_paths,omitempty"` AllowedEventSubjects []string `json:"allowed_event_subjects"` + Lifecycle []r1SyncLifecycleReport `json:"lifecycle,omitempty"` Source string `json:"source"` Target string `json:"target"` Agents []r1CodexAgentReport `json:"agents"` @@ -169,6 +170,15 @@ type r1CodexSyncReport struct { Artifacts map[string]string `json:"artifacts,omitempty"` } +type r1SyncLifecycleReport struct { + At string `json:"at"` + Principal string `json:"principal"` + Action string `json:"action"` + Result string `json:"result"` + Branch string `json:"branch,omitempty"` + Detail string `json:"detail,omitempty"` +} + type r1AcceptanceAssertion struct { Name string `json:"name"` Passed bool `json:"passed"` diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 57760ed6..954c8eac 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -223,6 +223,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } } addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + if err := exerciseR1GitHubMeshLifecycle(ctx, &report, agents); err != nil { + addR1Error(&report, err) + } run := r1GitHubMeshRun{ ctx: ctx, @@ -646,6 +649,66 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to return agents, nil } +func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent) error { + if report == nil || report.Sync == nil || len(agents) < 5 { + return nil + } + target := &agents[3] + branch := report.Sync.BranchByAgent[target.principal] + report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: target.principal, + Action: "pause_local_mnemond", + Result: "requested", + Branch: branch, + Detail: "cancel one isolated Local Mnemon before teamwork turns; appserver remains initialized", + }) + if target.localCancel == nil { + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, "target has no localCancel") + return fmt.Errorf("%s has no local mnemond cancel function", target.principal) + } + target.localCancel() + if target.localErr != nil { + select { + case <-target.localErr: + case <-time.After(5 * time.Second): + addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop") + return fmt.Errorf("%s local mnemond did not stop within timeout", target.principal) + } + } + loaded, err := access.LoadBindingFile(target.workspace, filepath.Join(target.workspace, access.DefaultBindingFile)) + if err != nil { + addR1Assertion(report, "github-mesh local mnemond restart loads bindings", false, err.Error()) + return err + } + addr := strings.TrimPrefix(target.localURL, "http://") + localCtx, cancel := context.WithCancel(ctx) + localErr := make(chan error, 1) + go func(workspace, addr string, loaded access.LoadedBindings) { + localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ + ProjectRoot: workspace, + SyncInterval: 100 * time.Millisecond, + }, io.Discard) + }(target.workspace, addr, loaded) + target.localCancel = cancel + target.localErr = localErr + if err := waitR1LocalReady(ctx, target.r1CodexAgent, target.localURL, 10*time.Second); err != nil { + cancel() + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) + return err + } + report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: target.principal, + Action: "restart_local_mnemond", + Result: "ready", + Branch: branch, + Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", + }) + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) + return nil +} + func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { if self < 0 || self >= len(branches) { return fmt.Errorf("self index %d outside branches", self) From b584a15a7c2c6ff38a2a27f3a3e35edf49a5fc52 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:48:38 +0800 Subject: [PATCH 21/35] docs(harness): record live github validation pass Update the GitHub Remote Workspace architecture notes to reflect the real mnemon-dev/mnemon-teamwork-example validation. The live publish/pull/import case initially exposed missing publication branches, then passed after initializing mnemon/agent-a through mnemon/agent-e. Validation: MNEMON_GITHUB_LIVE=1 MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example MNEMON_GITHUB_TOKEN_FILE= MNEMON_GITHUB_BRANCH_A=mnemon/agent-a MNEMON_GITHUB_BRANCH_B=mnemon/agent-b go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v --- .../github-decentralized-mesh-implementation-plan.md | 4 ++-- .mnemon-dev/architecture/github-remote-workspace-backend.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index fbc4d4cf..511cd3a7 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -77,6 +77,7 @@ Current branch progress: - Real GitHub `PublicationStore` adapter exists. - GitHub Remote Workspace normalizes opaque GitHub branch-head cursors before writing local mnemond pull state. - Gated live publish/pull/import test exists and is skipped unless live credentials are provided. +- Gated live publish/pull/import passed against `mnemon-dev/mnemon-teamwork-example` on 2026-06-26 after initializing default publication branches `mnemon/agent-a` through `mnemon/agent-e`. - Deterministic local GitHub mesh tests cover: - five isolated mnemond runtimes; - one branch per mnemond; @@ -97,9 +98,8 @@ mnemon-harness acceptance r1-github-mesh-task-suite \ Known open evidence: -- The live GitHub case has not been executed in this workspace because `MNEMON_GITHUB_LIVE`, `GITHUB_TOKEN`, and `MNEMON_GITHUB_TOKEN_FILE` are absent. - The real Codex appserver GitHub mesh suite is runnable, but still requires a GitHub token file and a usable Codex appserver environment. -- Until the live GitHub and real appserver runs pass, this goal is not closed. +- Until the real appserver run passes, this goal is not closed. ## 2. Non-goals and invariants diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index 912ca7b9..c6f0fc39 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -50,9 +50,9 @@ other mnemond subscribe to those branches and validate locally. 当前仍需外部证据的边界: -- gated live GitHub publish/pull/import 需要 token 后实际运行。 +- gated live GitHub publish/pull/import 已在 2026-06-26 通过真实 GitHub 访问验证;验证仓库缺失默认 publication branches 时先失败,随后初始化 `mnemon/agent-a` 到 `mnemon/agent-e` 后通过。 - real Codex appserver GitHub mesh suite 需要 token + Codex appserver 环境后实际运行。 -- 在这些验收通过前,不能把 GitHub mesh 目标标记为 complete。 +- 在 real Codex appserver GitHub mesh suite 通过前,不能把 GitHub mesh 目标标记为 complete。 ## 1. 非目标与红线 From 09652c944f6d063e1895d60f4c07084ef0f167d8 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 02:56:08 +0800 Subject: [PATCH 22/35] feat(harness): use explicit github publication paths Move GitHub-backed publication entries out of .mnemonhub-style hash paths and into a visible mnemon-publications/v1/events////-.json layout. Keep idempotent same-path semantics while making repo contents easier to review and maintain. Validation: go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go test ./harness/cmd/mnemon-harness; live GitHub gated publication store smoke and app publish/pull/import against mnemon-dev/mnemon-teamwork-example; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 20 +++++++---- .../github-remote-workspace-backend.md | 32 ++++++++--------- .../backend/github/publication_store_test.go | 8 ++--- .../mnemonhub/exchange/publication_store.go | 35 +++++++++++-------- .../exchange/publication_store_test.go | 21 +++++------ 5 files changed, 64 insertions(+), 52 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 511cd3a7..9ba01848 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -276,8 +276,8 @@ mnemon/team .mnemon/reports//summary.json mnemon/ - .mnemonhub/v1/manifest.json - .mnemonhub/v1/events//.json + mnemon-publications/v1/manifest.json + mnemon-publications/v1/events////-.json ``` Contract: @@ -344,7 +344,13 @@ type StoredEvent struct { Event path: ```text -.mnemonhub/v1/events//.json +mnemon-publications/v1/events////-.json +``` + +`local_ingest_seq` is zero-padded to 12 digits. The path is intentionally visible and maintainable in GitHub, for example: + +```text +mnemon-publications/v1/events/agent-a/progress_digest/project/000000000007-dec-a.json ``` Put semantics: @@ -506,7 +512,7 @@ Scope: Tests: -- event key deterministic. +- publication path deterministic and human-reviewable. - idempotent put. - conflict put. - list after cursor. @@ -546,8 +552,8 @@ SyncPush(req) for each synced envelope: require phase=synced materialize SyncedEventMaterial - derive event_key - write .mnemonhub/v1/events//.json + derive visible publication path + write mnemon-publications/v1/events////-.json created/exists-same -> accepted exists-different -> conflict diagnostic ``` @@ -689,7 +695,7 @@ subscribe branch: mnemon/agent-b agent-a: local accepted synced envelope - -> publish .mnemonhub/v1/events/agent-a/.json + -> publish mnemon-publications/v1/events/agent-a/progress_digest/project/000000000001-dec-a.json agent-b: pull mnemon/agent-a diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index c6f0fc39..e39534c7 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -666,14 +666,13 @@ Per-mnemond publication branches: ```text branch: mnemon/ -.mnemonhub/v1/ +mnemon-publications/v1/ manifest.json events/ / - .json - receipts/ - / - .json + / + / + -.json ``` `team.json` sketch: @@ -711,20 +710,21 @@ branch: mnemon/ Event file: ```text -.mnemonhub/v1/events//.json +mnemon-publications/v1/events////-.json ``` -`event_key`: +`local_ingest_seq` is zero-padded to 12 digits. This keeps GitHub paths human-reviewable: ```text -sha256(origin_mnemond + "\0" + event_id + "\0" + subject + "\0" + digest) +mnemon-publications/v1/events/agent-a/progress_digest/project/000000000007-dec-a.json ``` Rules: - File body is a single `event.EventEnvelope` with `phase=synced`. -- Same key + same body = idempotent. -- Same key + different body = conflict diagnostic. +- Same path + same body = idempotent. +- Same path + different body = conflict diagnostic. +- The path is a visible publication address, not a hash key. - Git commit SHA is transport cursor/provenance only. - Governance ordering remains local ingest seq after import. @@ -737,7 +737,7 @@ local Materializer accepts decision -> state records accepted envelope -> state records pending sync event -> sync orchestrator reads PushTargets - -> GitHub backend derives event_key + -> GitHub backend derives visible publication path -> writes event file to own publication branch -> records per-target ack locally ``` @@ -795,7 +795,7 @@ invalid phase invalid schema digest mismatch out-of-subscription scope -same event_key different body +same publication path different body unknown importable kind ``` @@ -1108,7 +1108,7 @@ Likely requirement: ```text sync_event_targets - event_key + publication_path remote_id status diagnostic @@ -1183,7 +1183,7 @@ Implement `RemoteWorkspace` over `PublicationStore`. Push: - read local batch -- derive event key +- derive visible publication path - write to own branch store - return accepted/conflict @@ -1283,8 +1283,8 @@ mnemon/team .mnemon/reports//summary.json mnemon/ - .mnemonhub/v1/manifest.json - .mnemonhub/v1/events//.json + mnemon-publications/v1/manifest.json + mnemon-publications/v1/events////-.json mnemon/ mnemon/ diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go index dc032bdf..ca707571 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -24,7 +24,7 @@ func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { if err != nil { t.Fatal(err) } - path := exchange.PublicationEventRoot + "/replica-a/event-a.json" + path := exchange.PublicationEventRoot + "/replica-a/progress_digest/project/000000000001-dec-a.json" first, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"a"}`)) if err != nil { @@ -79,8 +79,8 @@ func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { fake := newFakeGitHubPublicationAPI(t) fake.head = "head-2" - fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-b/event-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} - fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-c/event-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} + fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} + fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} store, err := NewPublicationStore(PublicationStoreConfig{ Repo: "mnemon-dev/mnemon-teamwork-example", BaseURL: fake.server.URL, @@ -126,7 +126,7 @@ func TestGitHubPublicationStoreLiveGated(t *testing.T) { if err != nil { t.Fatal(err) } - path := exchange.PublicationEventRoot + "/live-smoke/live-smoke.json" + path := exchange.PublicationEventRoot + "/live-smoke/progress_digest/project/000000000001-live-smoke.json" body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) res, err := store.PutEvent(context.Background(), branch, path, body) if err != nil { diff --git a/harness/internal/mnemonhub/exchange/publication_store.go b/harness/internal/mnemonhub/exchange/publication_store.go index 64520839..d5959b82 100644 --- a/harness/internal/mnemonhub/exchange/publication_store.go +++ b/harness/internal/mnemonhub/exchange/publication_store.go @@ -3,8 +3,6 @@ package exchange import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "fmt" pathpkg "path" "sort" @@ -16,7 +14,7 @@ import ( eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" ) -const PublicationEventRoot = ".mnemonhub/v1/events" +const PublicationEventRoot = "mnemon-publications/v1/events" // PublicationStore is the storage seam under a Remote Workspace publication backend. It is // intentionally repository-shaped but not tied to any GitHub client, so exchange semantics can be @@ -54,27 +52,34 @@ func PublicationEventPath(env eventmodel.EventEnvelope) (string, error) { if err != nil { return "", fmt.Errorf("publication origin: %w", err) } - key, err := PublicationEventKey(env) + kind, err := normalizePublicationPathSegment(string(material.ResourceRef.Kind)) + if err != nil { + return "", fmt.Errorf("publication resource kind: %w", err) + } + resourceID, err := normalizePublicationPathSegment(string(material.ResourceRef.ID)) + if err != nil { + return "", fmt.Errorf("publication resource id: %w", err) + } + stem, err := PublicationEventFileStem(env) if err != nil { return "", err } - return PublicationEventRoot + "/" + origin + "/" + key + ".json", nil + return PublicationEventRoot + "/" + origin + "/" + kind + "/" + resourceID + "/" + stem + ".json", nil } -func PublicationEventKey(env eventmodel.EventEnvelope) (string, error) { +func PublicationEventFileStem(env eventmodel.EventEnvelope) (string, error) { material, err := contract.SyncedEventMaterialFromEnvelope(env) if err != nil { return "", err } - sum := sha256.Sum256([]byte(strings.Join([]string{ - material.OriginReplicaID, - material.LocalDecisionID, - strconv.FormatInt(material.LocalIngestSeq, 10), - string(material.ResourceRef.Kind), - string(material.ResourceRef.ID), - material.FieldsDigest, - }, "\x00"))) - return hex.EncodeToString(sum[:16]), nil + if material.LocalIngestSeq < 0 { + return "", fmt.Errorf("local_ingest_seq must be non-negative") + } + decisionID, err := normalizePublicationPathSegment(material.LocalDecisionID) + if err != nil { + return "", fmt.Errorf("publication decision id: %w", err) + } + return fmt.Sprintf("%012d-%s", material.LocalIngestSeq, decisionID), nil } func NormalizePublicationBranch(branch string) (string, error) { diff --git a/harness/internal/mnemonhub/exchange/publication_store_test.go b/harness/internal/mnemonhub/exchange/publication_store_test.go index 561f496d..012d3acf 100644 --- a/harness/internal/mnemonhub/exchange/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/publication_store_test.go @@ -22,8 +22,9 @@ func TestPublicationStoreEventPathDeterministic(t *testing.T) { if path1 != path2 { t.Fatalf("event path must be deterministic: %q != %q", path1, path2) } - if !strings.HasPrefix(path1, PublicationEventRoot+"/replica-a/") || !strings.HasSuffix(path1, ".json") { - t.Fatalf("event path must stay under publication event root, got %q", path1) + wantPath := PublicationEventRoot + "/replica-a/progress_digest/project/000000000007-dec-a.json" + if path1 != wantPath { + t.Fatalf("event path = %q, want explicit maintainable path %q", path1, wantPath) } changed := publicationTestEnvelope(t, "dec-b", "remote-entry-b", "different event") @@ -42,7 +43,7 @@ func TestPublicationStorePutEventIsIdempotentAndConflictAware(t *testing.T) { if err != nil { t.Fatal(err) } - path := PublicationEventRoot + "/replica-a/event-a.json" + path := PublicationEventRoot + "/replica-a/progress_digest/project/000000000007-dec-a.json" first, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"a"}`)) if err != nil { @@ -80,10 +81,10 @@ func TestPublicationStoreListEventsAfterCursor(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err != nil { + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err != nil { t.Fatal(err) } - if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-b.json", []byte("b")); err != nil { + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000002-dec-b.json", []byte("b")); err != nil { t.Fatal(err) } @@ -98,7 +99,7 @@ func TestPublicationStoreListEventsAfterCursor(t *testing.T) { if err != nil { t.Fatalf("list after first: %v", err) } - if len(afterFirst.Events) != 1 || afterFirst.Events[0].Path != PublicationEventRoot+"/replica-a/event-b.json" || afterFirst.NextCursor != "2" { + if len(afterFirst.Events) != 1 || afterFirst.Events[0].Path != PublicationEventRoot+"/replica-a/progress_digest/project/000000000002-dec-b.json" || afterFirst.NextCursor != "2" { t.Fatalf("list after first = %+v, want event-b only", afterFirst) } empty, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, afterFirst.NextCursor) @@ -116,19 +117,19 @@ func TestPublicationStoreRejectsUnsupportedBranchAndPath(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := store.PutEvent(ctx, "mnemon/agent-b", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "not configured") { + if _, err := store.PutEvent(ctx, "mnemon/agent-b", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "not configured") { t.Fatalf("unsupported branch must fail closed, got %v", err) } - if _, err := store.PutEvent(ctx, "refs/heads/main", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "outside the mnemon namespace") { + if _, err := store.PutEvent(ctx, "refs/heads/main", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "outside the mnemon namespace") { t.Fatalf("non-publication branch must fail closed, got %v", err) } - if _, err := store.PutEvent(ctx, "mnemon/agent-a", ".mnemonhub/v1/../events/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "invalid") { + if _, err := store.PutEvent(ctx, "mnemon/agent-a", "mnemon-publications/v1/../events/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "invalid") { t.Fatalf("escaping event path must fail closed, got %v", err) } if _, err := store.PutEvent(ctx, "mnemon/agent-a", ".mnemon/reports/run.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "must be under") { t.Fatalf("non-event PutEvent path must fail closed, got %v", err) } - if err := store.WriteFile(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "PutEvent") { + if err := store.WriteFile(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "PutEvent") { t.Fatalf("WriteFile must not write event paths, got %v", err) } } From 33ca5932dad6d2e5510fb38aa91cd941f22215ac Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:08:24 +0800 Subject: [PATCH 23/35] feat(harness): isolate github mesh acceptance branches Teach the GitHub publication store to initialize missing publication branches from main and make the real GitHub mesh acceptance runner default to run-scoped branches. This prevents fresh appserver acceptance runs from importing historical events from long-lived mnemon/agent-* branches while still allowing operators to pass an explicit branch prefix for manual smoke tests. Validation: go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app ./harness/cmd/mnemon-harness; real GitHub acceptance preflight created mnemon/acceptance/20260625T190648Z/agent-a..e and passed topology/remote-plan assertions before the expected --agent-turns gate; live GitHub publish/pull/import passed on the run-scoped agent-a/b branches; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 14 +++- .../github-remote-workspace-backend.md | 4 +- .../mnemon-harness/acceptance_github_mesh.go | 56 +++++++++++-- .../acceptance_github_mesh_test.go | 22 ++++++ .../backend/github/publication_store.go | 68 ++++++++++++++++ .../backend/github/publication_store_test.go | 79 +++++++++++++++++-- 6 files changed, 229 insertions(+), 14 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 9ba01848..962678dd 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -96,6 +96,16 @@ mnemon-harness acceptance r1-github-mesh-task-suite \ --github-token-file ``` +When `--github-branch-prefix` is omitted, the runner must create run-scoped publication branches: + +```text +mnemon/acceptance//agent-a +mnemon/acceptance//agent-b +... +``` + +Fixed branches such as `mnemon/agent-a` through `mnemon/agent-e` are valid for manual operator smoke tests, but not the default real appserver acceptance path because historical publication entries can pollute a fresh run. + Known open evidence: - The real Codex appserver GitHub mesh suite is runnable, but still requires a GitHub token file and a usable Codex appserver environment. @@ -787,7 +797,7 @@ Topology: 5 isolated runtime workspaces 5 isolated local mnemond stores 1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example -5 publication branches +5 run-scoped publication branches 0 central active mnemon-hub 0 shared governed.db ``` @@ -800,6 +810,8 @@ Isolation requirements: - No appserver reads or writes another appserver's local Mnemon workspace. - Cross-agent visibility happens only after publication branch pull/import. - The report must include local store paths and prove they are distinct. +- The default branch prefix is run-scoped; the runner initializes missing branches from `main` before local sync starts. +- Reusing long-lived branches is allowed only when explicitly requested with `--github-branch-prefix`. Baseline 5-node Teamwork-ReAct scenario: diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index e39534c7..cd0effe2 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -1336,7 +1336,7 @@ Real appserver acceptance: 5 isolated runtime workspaces 5 isolated local mnemond stores 1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example -5 publication branches +5 run-scoped publication branches 0 central mnemon-hub 0 shared governed.db ``` @@ -1347,6 +1347,8 @@ Isolation requirements: - each `mnemond` has its own local store; - each appserver has its own runtime workspace; - cross-agent visibility only happens through publication branch pull/import. +- default real acceptance branches are `mnemon/acceptance//agent-*` and are initialized from `main` before local sync starts. +- long-lived branches such as `mnemon/agent-a` are explicit operator smoke-test inputs, not the default acceptance isolation model. Scenario: diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 954c8eac..ba92c2e2 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -15,6 +15,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -73,7 +74,7 @@ func init() { acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "mnemon/agent-", "GitHub publication branch prefix") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped acceptance prefix") acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) } @@ -105,10 +106,8 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO if opts.Repo == "" { opts.Repo = "mnemon-dev/mnemon-teamwork-example" } - if opts.BranchPrefix == "" { - opts.BranchPrefix = "mnemon/agent-" - } started := time.Now().UTC().Truncate(time.Second) + branchPrefix := r1GitHubMeshBranchPrefix(opts.BranchPrefix, started) runRoot := opts.RunRoot if runRoot == "" { runRoot = filepath.Join(".testdata", "r1-github-mesh-task-suite", started.Format("20060102T150405Z")) @@ -165,6 +164,12 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Status = "blocked" return report, err } + branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) + if err := ensureR1GitHubMeshBranches(ctx, opts.Repo, tokenFile, branches); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } binDir, err := installAcceptanceHarnessBinary(runRoot) if err != nil { addR1Error(&report, err) @@ -175,8 +180,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Artifacts["codex_home_source"] = sourceCodexHome report.Artifacts["github_repo"] = opts.Repo report.Artifacts["github_token_file"] = tokenFile + report.Artifacts["github_branch_prefix"] = branchPrefix - agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, opts.BranchPrefix, opts.Agents, sourceCodexHome) + agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome) if err != nil { addR1Error(&report, err) report.Status = "blocked" @@ -760,6 +766,46 @@ func r1GitHubMeshBranches(prefix string, count int) []string { return out } +func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { + prefix = strings.TrimSpace(prefix) + if prefix != "" { + return prefix + } + return "mnemon/acceptance/" + started.UTC().Format("20060102T150405Z") + "/agent-" +} + +func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { + token, err := readR1GitHubMeshToken(tokenFile) + if err != nil { + return err + } + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: repo, + Token: token, + }) + if err != nil { + return err + } + for _, branch := range branches { + if err := store.EnsureBranch(ctx, branch, "main"); err != nil { + return fmt.Errorf("ensure GitHub branch %q: %w", branch, err) + } + } + return nil +} + +func readR1GitHubMeshToken(tokenFile string) (string, error) { + body, err := os.ReadFile(tokenFile) + if err != nil { + return "", fmt.Errorf("read github token file: %w", err) + } + token := strings.TrimSpace(string(body)) + if token == "" { + return "", fmt.Errorf("github token file is empty") + } + return token, nil +} + func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1CodexSyncReport { report := &r1CodexSyncReport{ Status: "running", diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 4059e400..c066947d 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" @@ -23,6 +24,27 @@ func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { } } +func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { + started := time.Date(2026, 6, 25, 18, 57, 20, 0, time.UTC) + prefix := r1GitHubMeshBranchPrefix("", started) + if prefix != "mnemon/acceptance/20260625T185720Z/agent-" { + t.Fatalf("prefix = %q, want run-scoped acceptance prefix", prefix) + } + got := r1GitHubMeshBranches(prefix, 2) + want := []string{ + "mnemon/acceptance/20260625T185720Z/agent-a", + "mnemon/acceptance/20260625T185720Z/agent-b", + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("branches = %v, want %v", got, want) + } + } + if explicit := r1GitHubMeshBranchPrefix("mnemon/agent-", started); explicit != "mnemon/agent-" { + t.Fatalf("explicit prefix = %q, want unchanged", explicit) + } +} + func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { root := t.TempDir() tokenFile := filepath.Join(root, "github.token") diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go index beb536a4..a53d75f8 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -177,6 +177,50 @@ func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, p return nil } +func (s *GitHubPublicationStore) EnsureBranch(ctx context.Context, branch string, baseBranch string) error { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return err + } + baseBranch, err = normalizeGitHubBranchName(baseBranch) + if err != nil { + return fmt.Errorf("base branch: %w", err) + } + if baseBranch == "" { + baseBranch = "main" + } + if _, err := s.branchHead(ctx, branch); err == nil { + return nil + } else if apiErr, ok := err.(*githubAPIError); !ok || apiErr.Status != http.StatusNotFound { + return err + } + baseSHA, err := s.branchHead(ctx, baseBranch) + if err != nil { + return fmt.Errorf("read base branch %q: %w", baseBranch, err) + } + if err := s.pauseBeforeMutation(ctx); err != nil { + return err + } + req := githubCreateRefRequest{ + Ref: "refs/heads/" + branch, + SHA: baseSHA, + } + status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { + if _, headErr := s.branchHead(ctx, branch); headErr == nil { + return nil + } + } + return err + } + if status != http.StatusCreated { + return fmt.Errorf("github branch create returned status %d", status) + } + s.lastWrite = time.Now() + return nil +} + type githubFile struct { body []byte sha string @@ -202,6 +246,11 @@ type githubPutFileRequest struct { Branch string `json:"branch"` } +type githubCreateRefRequest struct { + Ref string `json:"ref"` + SHA string `json:"sha"` +} + type githubRefResponse struct { Object struct { SHA string `json:"sha"` @@ -413,6 +462,25 @@ func normalizeGitHubPublicationFileRef(branch, path string) (string, string, err return branch, path, nil } +func normalizeGitHubBranchName(branch string) (string, error) { + branch = strings.TrimSpace(branch) + if branch == "" { + return "", nil + } + if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { + return "", fmt.Errorf("branch %q is invalid", branch) + } + for _, part := range strings.Split(branch, "/") { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("branch %q is invalid", branch) + } + } + if pathpkg.Clean(branch) != branch { + return "", fmt.Errorf("branch %q is invalid", branch) + } + return branch, nil +} + func normalizeGitHubPublicationEventPrefix(prefix string) (string, error) { prefix, err := exchange.NormalizePublicationPath(prefix) if err != nil { diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go index ca707571..c5af3a51 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -106,6 +106,36 @@ func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { } } +func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.refs["main"] = "main-sha" + fake.missingRefs["mnemon/acceptance/run-1/agent-a"] = true + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + if err := store.EnsureBranch(context.Background(), "mnemon/acceptance/run-1/agent-a", "main"); err != nil { + t.Fatalf("ensure branch: %v", err) + } + if fake.creates != 1 { + t.Fatalf("creates = %d, want one branch create", fake.creates) + } + if got := fake.refs["mnemon/acceptance/run-1/agent-a"]; got != "main-sha" { + t.Fatalf("created branch sha = %q, want main-sha", got) + } + if err := store.EnsureBranch(context.Background(), "mnemon/acceptance/run-1/agent-a", "main"); err != nil { + t.Fatalf("ensure branch again: %v", err) + } + if fake.creates != 1 { + t.Fatalf("idempotent ensure must not recreate branch, creates=%d", fake.creates) + } +} + func TestGitHubPublicationStoreLiveGated(t *testing.T) { if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") @@ -145,11 +175,14 @@ func TestGitHubPublicationStoreLiveGated(t *testing.T) { } type fakeGitHubPublicationAPI struct { - server *httptest.Server - files map[string]fakeGitHubFile - head string - puts int - lastSHA string + server *httptest.Server + files map[string]fakeGitHubFile + refs map[string]string + missingRefs map[string]bool + head string + puts int + creates int + lastSHA string } type fakeGitHubFile struct { @@ -159,7 +192,12 @@ type fakeGitHubFile struct { func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { t.Helper() - fake := &fakeGitHubPublicationAPI{files: map[string]fakeGitHubFile{}, head: "head-1"} + fake := &fakeGitHubPublicationAPI{ + files: map[string]fakeGitHubFile{}, + refs: map[string]string{}, + missingRefs: map[string]bool{}, + head: "head-1", + } fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) t.Cleanup(fake.server.Close) return fake @@ -178,7 +216,34 @@ func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request tail := strings.TrimPrefix(r.URL.Path, prefix) switch { case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): - writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": f.head}}) + branch := strings.TrimPrefix(tail, "git/ref/heads/") + if f.missingRefs[branch] { + writeJSON(w, http.StatusNotFound, map[string]any{"message": "ref not found"}) + return + } + sha := f.refs[branch] + if sha == "" { + sha = f.head + } + writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": sha}}) + case r.Method == http.MethodPost && tail == "git/refs": + var req struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + branch := strings.TrimPrefix(req.Ref, "refs/heads/") + if branch == req.Ref || branch == "" || req.SHA == "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid ref"}) + return + } + delete(f.missingRefs, branch) + f.refs[branch] = req.SHA + f.creates++ + writeJSON(w, http.StatusCreated, map[string]any{"ref": req.Ref, "object": map[string]any{"sha": req.SHA}}) case strings.HasPrefix(tail, "contents/"): path := strings.TrimPrefix(tail, "contents/") branch := r.URL.Query().Get("ref") From 13eadca87ae68b52aa496c825860448298b5add9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:14:26 +0800 Subject: [PATCH 24/35] fix(harness): throttle github mesh acceptance sync Add a GitHub mesh acceptance sync interval flag and default real GitHub polling to 30 seconds per local mnemond. The previous 100ms interval was suitable for fake/local tests but can overwhelm the real GitHub API during five-node appserver validation. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh' -count=1; go test ./harness/cmd/mnemon-harness; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. A real five-node smoke after the previous commit was stopped after GitHub returned secondary rate-limit 403s, which this commit addresses. --- ...-decentralized-mesh-implementation-plan.md | 4 ++- .../github-remote-workspace-backend.md | 1 + .../mnemon-harness/acceptance_github_mesh.go | 26 ++++++++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 962678dd..e0b40a5a 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -93,7 +93,8 @@ mnemon-harness acceptance r1-github-mesh-task-suite \ --agents 5 \ --agent-turns \ --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-token-file + --github-token-file \ + --sync-interval 30s ``` When `--github-branch-prefix` is omitted, the runner must create run-scoped publication branches: @@ -812,6 +813,7 @@ Isolation requirements: - The report must include local store paths and prove they are distinct. - The default branch prefix is run-scoped; the runner initializes missing branches from `main` before local sync starts. - Reusing long-lived branches is allowed only when explicitly requested with `--github-branch-prefix`. +- The default real GitHub sync interval is 30 seconds per local `mnemond`; fake/local tests may use shorter intervals, but real GitHub acceptance must not use 100ms polling. Baseline 5-node Teamwork-ReAct scenario: diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index cd0effe2..22f21264 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -1349,6 +1349,7 @@ Isolation requirements: - cross-agent visibility only happens through publication branch pull/import. - default real acceptance branches are `mnemon/acceptance//agent-*` and are initialized from `main` before local sync starts. - long-lived branches such as `mnemon/agent-a` are explicit operator smoke-test inputs, not the default acceptance isolation model. +- real GitHub acceptance uses a 30 second default sync interval per local `mnemond`; 100ms polling is reserved for fake/local tests. Scenario: diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index ba92c2e2..f03cb8be 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -25,6 +25,7 @@ var ( acceptanceGitHubTokenFile string acceptanceGitHubBranchPrefix string acceptanceGitHubScenarios []string + acceptanceGitHubSyncInterval time.Duration ) var acceptanceR1GitHubMeshCmd = &cobra.Command{ @@ -46,6 +47,7 @@ var acceptanceR1GitHubMeshCmd = &cobra.Command{ TokenFile: acceptanceGitHubTokenFile, BranchPrefix: acceptanceGitHubBranchPrefix, Scenarios: acceptanceGitHubScenarios, + SyncInterval: acceptanceGitHubSyncInterval, }) if report.ReportPath != "" { fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) @@ -76,6 +78,7 @@ func init() { acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped acceptance prefix") acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") + acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) } @@ -85,6 +88,7 @@ type r1GitHubMeshAcceptanceOptions struct { TokenFile string BranchPrefix string Scenarios []string + SyncInterval time.Duration } func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceOptions) (r1CodexAcceptanceReport, error) { @@ -106,6 +110,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO if opts.Repo == "" { opts.Repo = "mnemon-dev/mnemon-teamwork-example" } + if opts.SyncInterval <= 0 { + opts.SyncInterval = 30 * time.Second + } started := time.Now().UTC().Truncate(time.Second) branchPrefix := r1GitHubMeshBranchPrefix(opts.BranchPrefix, started) runRoot := opts.RunRoot @@ -181,8 +188,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Artifacts["github_repo"] = opts.Repo report.Artifacts["github_token_file"] = tokenFile report.Artifacts["github_branch_prefix"] = branchPrefix + report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() - agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome) + agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval) if err != nil { addR1Error(&report, err) report.Status = "blocked" @@ -229,7 +237,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } } addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) - if err := exerciseR1GitHubMeshLifecycle(ctx, &report, agents); err != nil { + if err := exerciseR1GitHubMeshLifecycle(ctx, &report, agents, opts.SyncInterval); err != nil { addR1Error(&report, err) } @@ -582,7 +590,10 @@ func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { return out } -func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string) ([]r1CodexSyncAgent, error) { +func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration) ([]r1CodexSyncAgent, error) { + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } var agents []r1CodexSyncAgent branches := r1GitHubMeshBranches(branchPrefix, count) for i := 1; i <= count; i++ { @@ -628,7 +639,7 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to go func(workspace, addr string, loaded access.LoadedBindings) { localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ ProjectRoot: workspace, - SyncInterval: 100 * time.Millisecond, + SyncInterval: syncInterval, }, io.Discard) }(workspace, localAddr, loaded) agent := r1CodexSyncAgent{ @@ -655,10 +666,13 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to return agents, nil } -func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent) error { +func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent, syncInterval time.Duration) error { if report == nil || report.Sync == nil || len(agents) < 5 { return nil } + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } target := &agents[3] branch := report.Sync.BranchByAgent[target.principal] report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ @@ -693,7 +707,7 @@ func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanc go func(workspace, addr string, loaded access.LoadedBindings) { localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ ProjectRoot: workspace, - SyncInterval: 100 * time.Millisecond, + SyncInterval: syncInterval, }, io.Discard) }(target.workspace, addr, loaded) target.localCancel = cancel From 718f6a5e80b3439d16a4a3de641d93c4d85e7f8c Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:20:17 +0800 Subject: [PATCH 25/35] test(harness): summarize github mesh sync evidence Add machine-readable publication, import, diagnostic, and profile summaries to the GitHub mesh acceptance report. The summaries are derived from local governed stores via acceptance observe, so they strengthen P8 evidence without adding GitHub API traffic. Validation: go test ./harness/cmd/mnemon-harness -run 'TestObserveAcceptanceRunReadsMnemondAndHubEvents|TestR1GitHubMesh|TestPopulateR1GitHubMeshSyncEvidence' -count=1; go test ./harness/cmd/mnemon-harness; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 9 ++++ harness/cmd/mnemon-harness/acceptance.go | 4 ++ .../mnemon-harness/acceptance_github_mesh.go | 54 +++++++++++++++++++ .../acceptance_github_mesh_test.go | 46 ++++++++++++++++ .../cmd/mnemon-harness/acceptance_observe.go | 28 ++++++++++ 5 files changed, 141 insertions(+) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index e0b40a5a..aaed02c9 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -868,6 +868,15 @@ proof no shared governed.db was used proof each appserver used its own mnemond/local store/runtime workspace ``` +The acceptance report exposes the publication/import summary as machine-readable fields under `sync`: + +```text +sync.published_events_by_branch +sync.imported_events_by_mnemond +sync.diagnostics_by_mnemond +sync.profile_events_by_mnemond +``` + Done when: - Script is runnable. diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 85f6b9ee..39c94f0a 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -159,6 +159,10 @@ type r1CodexSyncReport struct { RemotePlanPaths []string `json:"remote_plan_paths,omitempty"` RuntimeWorkspaces []string `json:"runtime_workspace_paths,omitempty"` LocalStorePaths []string `json:"local_mnemond_store_paths,omitempty"` + PublishedByBranch map[string]int `json:"published_events_by_branch,omitempty"` + ImportedByMnemond map[string]int `json:"imported_events_by_mnemond,omitempty"` + DiagnosticsByMnemond map[string]int `json:"diagnostics_by_mnemond,omitempty"` + ProfileByMnemond map[string]int `json:"profile_events_by_mnemond,omitempty"` AllowedEventSubjects []string `json:"allowed_event_subjects"` Lifecycle []r1SyncLifecycleReport `json:"lifecycle,omitempty"` Source string `json:"source"` diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index f03cb8be..694a148a 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -260,6 +260,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO obs, obsErr := observeAcceptanceRun(runRoot, 1000) if obsErr == nil { report.Observability = &obs + populateR1GitHubMeshSyncEvidence(&report, obs) report.Participants = r1ClusterParticipants(r1ClusterActorEventCounts(obs), report.Entrypoint) } else { addR1Error(&report, obsErr) @@ -270,6 +271,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } addR1Assertion(&report, "github-mesh no shared governed.db", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) + addR1Assertion(&report, "github-mesh report includes publication/import evidence", len(syncReport.PublishedByBranch) == opts.Agents && len(syncReport.ImportedByMnemond) == opts.Agents, fmt.Sprintf("published=%v imported=%v diagnostics=%v", syncReport.PublishedByBranch, syncReport.ImportedByMnemond, syncReport.DiagnosticsByMnemond)) if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { syncReport.Status = "ok" report.Status = "ok" @@ -831,6 +833,10 @@ func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1Code AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), Artifacts: map[string]string{}, BranchByAgent: map[string]string{}, + PublishedByBranch: map[string]int{}, + ImportedByMnemond: map[string]int{}, + DiagnosticsByMnemond: map[string]int{}, + ProfileByMnemond: map[string]int{}, } for i, agent := range agents { branch := "" @@ -857,6 +863,54 @@ func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1Code return report } +func populateR1GitHubMeshSyncEvidence(report *r1CodexAcceptanceReport, obs acceptanceObserveReport) { + if report == nil || report.Sync == nil { + return + } + syncReport := report.Sync + if syncReport.PublishedByBranch == nil { + syncReport.PublishedByBranch = map[string]int{} + } + if syncReport.ImportedByMnemond == nil { + syncReport.ImportedByMnemond = map[string]int{} + } + if syncReport.DiagnosticsByMnemond == nil { + syncReport.DiagnosticsByMnemond = map[string]int{} + } + if syncReport.ProfileByMnemond == nil { + syncReport.ProfileByMnemond = map[string]int{} + } + stores := map[string]acceptanceStoreInspect{} + for _, store := range obs.Stores { + if store.Role == "mnemond" { + stores[store.Name] = store + } + } + for _, agent := range syncReport.Agents { + principal := strings.TrimSpace(agent.Principal) + if principal == "" { + continue + } + storeName := r1GitHubMeshStoreName(principal) + store, ok := stores[storeName] + if !ok { + continue + } + branch := syncReport.BranchByAgent[principal] + if branch != "" { + syncReport.PublishedByBranch[branch] = store.SyncEventsByStatus["synced"] + } + syncReport.ImportedByMnemond[principal] = store.Counts["imported_accepted"] + syncReport.DiagnosticsByMnemond[principal] = store.ObservedByType["sync.diagnostic"] + store.ObservedByType["sync.remote_diagnostic.observed"] + syncReport.ProfileByMnemond[principal] = store.EnvelopeByType["agent_profile.accepted"] + } +} + +func r1GitHubMeshStoreName(principal string) string { + name, _, _ := strings.Cut(strings.TrimSpace(principal), "@") + return name +} + func r1GitHubMeshScopes() []contract.ResourceRef { return []contract.ResourceRef{ {Kind: "agent_profile", ID: "project"}, diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index c066947d..388b9a17 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -127,6 +127,52 @@ func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { } } +func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { + report := &r1CodexAcceptanceReport{Sync: &r1CodexSyncReport{ + Agents: []r1CodexAgentReport{ + {Principal: "codex-01@project"}, + {Principal: "codex-02@project"}, + }, + BranchByAgent: map[string]string{ + "codex-01@project": "mnemon/acceptance/run/agent-a", + "codex-02@project": "mnemon/acceptance/run/agent-b", + }, + }} + obs := acceptanceObserveReport{Stores: []acceptanceStoreInspect{ + { + Name: "codex-01", + Role: "mnemond", + Counts: map[string]int{"imported_accepted": 4}, + SyncEventsByStatus: map[string]int{"synced": 3}, + ObservedByType: map[string]int{"sync.diagnostic": 1}, + EnvelopeByType: map[string]int{"agent_profile.accepted": 5}, + }, + { + Name: "codex-02", + Role: "mnemond", + Counts: map[string]int{"imported_accepted": 2}, + SyncEventsByStatus: map[string]int{"synced": 1}, + ObservedByType: map[string]int{"sync.remote_diagnostic.observed": 2}, + EnvelopeByType: map[string]int{"agent_profile.accepted": 3}, + }, + }} + + populateR1GitHubMeshSyncEvidence(report, obs) + + if got := report.Sync.PublishedByBranch["mnemon/acceptance/run/agent-a"]; got != 3 { + t.Fatalf("published branch a = %d, want 3", got) + } + if got := report.Sync.ImportedByMnemond["codex-02@project"]; got != 2 { + t.Fatalf("imported codex-02 = %d, want 2", got) + } + if got := report.Sync.DiagnosticsByMnemond["codex-02@project"]; got != 2 { + t.Fatalf("diagnostics codex-02 = %d, want 2", got) + } + if got := report.Sync.ProfileByMnemond["codex-01@project"]; got != 5 { + t.Fatalf("profiles codex-01 = %d, want 5", got) + } +} + func TestR1GitHubMeshScenarioContract(t *testing.T) { names := r1GitHubMeshScenarioNames(nil) want := []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} diff --git a/harness/cmd/mnemon-harness/acceptance_observe.go b/harness/cmd/mnemon-harness/acceptance_observe.go index 995acec1..845e8a49 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe.go +++ b/harness/cmd/mnemon-harness/acceptance_observe.go @@ -106,6 +106,7 @@ type acceptanceStoreInspect struct { Counts map[string]int `json:"counts"` EnvelopeByPhase map[string]int `json:"envelope_by_phase,omitempty"` EnvelopeByType map[string]int `json:"envelope_by_type,omitempty"` + ObservedByType map[string]int `json:"observed_by_type,omitempty"` SyncEventsByStatus map[string]int `json:"sync_events_by_status,omitempty"` RemoteEventsByStatus map[string]int `json:"remote_events_by_status,omitempty"` GovernedRowsByKind map[string]int `json:"governed_rows_by_kind,omitempty"` @@ -349,6 +350,10 @@ func inspectAcceptanceStore(root, path string, latest int) (acceptanceStoreInspe report.Counts["imported_accepted"] = sumCountMap(report.ImportedAcceptedByRef) } if report.Counts["events"] > 0 { + report.ObservedByType, err = sqliteObservedByType(ctx, db) + if err != nil { + return report, err + } report.LatestObserved, err = sqliteLatestObservedEvents(ctx, db, latest) if err != nil { return report, err @@ -472,6 +477,29 @@ func sqliteLatestObservedEvents(ctx context.Context, db *sql.DB, limit int) ([]a return out, rows.Err() } +func sqliteObservedByType(ctx context.Context, db *sql.DB) (map[string]int, error) { + rows, err := db.QueryContext(ctx, `SELECT payload FROM events`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var payload string + if err := rows.Scan(&payload); err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal([]byte(payload), &raw); err != nil { + continue + } + if eventType, _ := raw["type"].(string); eventType != "" { + out[eventType]++ + } + } + return out, rows.Err() +} + func sqliteImportedRemoteDecisions(ctx context.Context, db *sql.DB) (map[string]int, error) { rows, err := db.QueryContext(ctx, `SELECT payload FROM events WHERE payload LIKE '%remote_synced_event.observed%'`) if err != nil { From 4842fb7c2e17bc5085374489577f1e14a65d06b9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:22:22 +0800 Subject: [PATCH 26/35] fix(harness): preserve github rate limit hints Carry GitHub retry and rate-limit headers through backend API errors so live acceptance failures can distinguish temporary cooldown from auth or repo misconfiguration. Document the diagnostic requirement in the GitHub mesh plan. Validation: go test ./harness/internal/mnemonhub/exchange/backend/github -run 'TestGitHubPublicationStore(RateLimit|EnsureBranch|PutEvent|ListEvents)' -count=1; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app ./harness/cmd/mnemon-harness; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 1 + .../backend/github/publication_store.go | 35 ++++++++++++++++--- .../backend/github/publication_store_test.go | 29 +++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index aaed02c9..cbcaad0a 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -885,6 +885,7 @@ Done when: - At least one successful scenario contains two or more output-driven replanning rounds. - At least one successful scenario demonstrates isolated per-agent `mnemond` and local store paths. - Failure mode leaves enough diagnostics to distinguish API/auth/config errors from Mnemon import/admission errors. +- GitHub API rate-limit failures must preserve retry/rate-limit headers when GitHub returns them, so an operator can distinguish temporary cooldown from auth or repo misconfiguration. ## 18. Natural acceptance task suite diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go index a53d75f8..f4709cc7 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -258,15 +258,34 @@ type githubRefResponse struct { } type githubAPIError struct { - Status int - Message string + Status int + Message string + RetryAfter string + RateLimitRemaining string + RateLimitReset string } func (e *githubAPIError) Error() string { + base := "" if strings.TrimSpace(e.Message) == "" { - return fmt.Sprintf("github api status %d", e.Status) + base = fmt.Sprintf("github api status %d", e.Status) + } else { + base = fmt.Sprintf("github api status %d: %s", e.Status, e.Message) } - return fmt.Sprintf("github api status %d: %s", e.Status, e.Message) + var hints []string + if strings.TrimSpace(e.RetryAfter) != "" { + hints = append(hints, "retry_after="+strings.TrimSpace(e.RetryAfter)) + } + if strings.TrimSpace(e.RateLimitRemaining) != "" { + hints = append(hints, "rate_limit_remaining="+strings.TrimSpace(e.RateLimitRemaining)) + } + if strings.TrimSpace(e.RateLimitReset) != "" { + hints = append(hints, "rate_limit_reset="+strings.TrimSpace(e.RateLimitReset)) + } + if len(hints) == 0 { + return base + } + return base + " (" + strings.Join(hints, ", ") + ")" } func (s *GitHubPublicationStore) readFileWithSHA(ctx context.Context, branch, path string) (githubFile, bool, error) { @@ -396,7 +415,13 @@ func (s *GitHubPublicationStore) do(ctx context.Context, method, apiPath string, Message string `json:"message"` } _ = json.Unmarshal(data, &apiErr) - return resp.StatusCode, &githubAPIError{Status: resp.StatusCode, Message: apiErr.Message} + return resp.StatusCode, &githubAPIError{ + Status: resp.StatusCode, + Message: apiErr.Message, + RetryAfter: resp.Header.Get("Retry-After"), + RateLimitRemaining: resp.Header.Get("X-RateLimit-Remaining"), + RateLimitReset: resp.Header.Get("X-RateLimit-Reset"), + } } if out != nil && len(data) > 0 { if err := json.Unmarshal(data, out); err != nil { diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go index c5af3a51..38381ffa 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -136,6 +136,35 @@ func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testi } } +func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "120") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "1782416790") + writeJSON(w, http.StatusForbidden, map[string]any{"message": "API rate limit exceeded"}) + })) + t.Cleanup(server.Close) + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + err = store.EnsureBranch(context.Background(), "mnemon/acceptance/run/agent-a", "main") + if err == nil { + t.Fatal("ensure branch should return rate-limit error") + } + msg := err.Error() + for _, want := range []string{"API rate limit exceeded", "retry_after=120", "rate_limit_remaining=0", "rate_limit_reset=1782416790"} { + if !strings.Contains(msg, want) { + t.Fatalf("error %q does not include %q", msg, want) + } + } +} + func TestGitHubPublicationStoreLiveGated(t *testing.T) { if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") From 40a1488dcd13b3ddb137504725a0f71a5d9d9f05 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:32:49 +0800 Subject: [PATCH 27/35] test(harness): exercise github mesh delayed join Extend the GitHub mesh appserver acceptance runner so P8 can keep five publication streams configured while starting a smaller initial online set, activate two delayed mnemond/appserver pairs during the natural task flow, and record join plus pause/restart lifecycle evidence in the report. Validation: go test ./harness/cmd/mnemon-harness; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 47 ++- .../github-remote-workspace-backend.md | 33 +- harness/cmd/mnemon-harness/acceptance.go | 13 +- .../mnemon-harness/acceptance_github_mesh.go | 322 ++++++++++++++---- .../acceptance_github_mesh_test.go | 50 +++ 5 files changed, 370 insertions(+), 95 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index cbcaad0a..17d747ee 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -107,6 +107,19 @@ mnemon/acceptance//agent-b Fixed branches such as `mnemon/agent-a` through `mnemon/agent-e` are valid for manual operator smoke tests, but not the default real appserver acceptance path because historical publication entries can pollute a fresh run. +Real runner lifecycle now treats join as delayed activation of preconfigured publication streams: + +```text +configure 5 workspaces/remotes/branches up front +start 3 mnemond/appservers for the first natural task round +start the remaining 2 mnemond/appservers during the task +publish fresh joined agent_profile events +verify profiles converge through configured publication branches +pause/restart one already joined local mnemond during the task +``` + +This is intentionally not branch discovery, P2P node discovery, or dynamic networking. The branch list and `remotes.json` remain configured bootstrap inventory; the lifecycle evidence proves delayed availability and backlog import against those configured streams. + Known open evidence: - The real Codex appserver GitHub mesh suite is runnable, but still requires a GitHub token file and a usable Codex appserver environment. @@ -817,21 +830,23 @@ Isolation requirements: Baseline 5-node Teamwork-ReAct scenario: -1. Start 5 appservers/mnemond. -2. Install generic hook/GUIDE/skill. -3. Publish fresh `agent_profile`. -4. Send an ordinary user message to one connected PoC agent to start the teamwork task. -5. Verify assignment propagation. -6. Verify nested decomposition. -7. Verify first round outputs are published as progress digests. -8. Verify a PoC-like agent reviews outputs and emits a second-round plan. -9. Verify second-round reassignment or refinement is published and executed. -10. Add 2 `mnemond` instances mid-run. -11. Stop 1 `mnemond` mid-run. -12. Verify reassignment through TTL/stalled cue. -13. Verify aggregation. -14. Verify another act can be emitted after aggregation if the result is incomplete. -15. Verify final completion evidence. +1. Configure 5 isolated appserver/mnemond workspaces, remotes, and run-scoped publication branches. +2. Start the initial online subset of appservers/mnemond; the current runner uses 3 online and 2 delayed nodes for the natural task round. +3. Install generic hook/GUIDE/skill for started appservers. +4. Publish fresh `agent_profile` from the initial online nodes. +5. Send an ordinary user message to one connected PoC agent to start the teamwork task. +6. Verify assignment propagation. +7. Verify nested decomposition. +8. Verify first round outputs are published as progress digests. +9. Verify a PoC-like agent reviews outputs and emits a second-round plan. +10. Verify second-round reassignment or refinement is published and executed. +11. Start 2 delayed `mnemond`/appserver pairs mid-run from the already configured `remotes.json`. +12. Verify delayed nodes import backlog and publish fresh `agent_profile` events. +13. Stop/restart 1 `mnemond` mid-run. +14. Verify reassignment or renewed progress through governed events rather than direct scheduling. +15. Verify aggregation. +16. Verify another act can be emitted after aggregation if the result is incomplete. +17. Verify final completion evidence. Natural task suite: @@ -875,6 +890,8 @@ sync.published_events_by_branch sync.imported_events_by_mnemond sync.diagnostics_by_mnemond sync.profile_events_by_mnemond +sync.lifecycle[].action = delayed_join_start | delayed_join_ready | pause_local_mnemond | restart_local_mnemond +sync.lifecycle[].ledger = local ledger counts captured at lifecycle boundaries ``` Done when: diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index 22f21264..18f59f32 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -47,6 +47,8 @@ other mnemond subscribe to those branches and validate locally. - per-workspace `remotes.json` - per-agent publication branch - per-agent local mnemond store path + - delayed join / pause / restart lifecycle entries under `sync.lifecycle` + - publication/import/profile summaries under `sync.published_events_by_branch`, `sync.imported_events_by_mnemond`, and `sync.profile_events_by_mnemond` 当前仍需外部证据的边界: @@ -1310,6 +1312,7 @@ events imported per mnemond diagnostics per mnemond assignment/progress chain mnemond join/leave timeline +profile refresh/update timeline proof no central mnemon-hub endpoint was used proof no shared governed.db was used ``` @@ -1353,19 +1356,23 @@ Isolation requirements: Scenario: -1. Start 5 appservers/mnemond. -2. Publish fresh profiles. -3. Send an ordinary user message to one connected PoC agent to start teamwork. -4. Verify assignment propagation. -5. Verify nested decomposition. -6. Verify first-round outputs cause a second-round plan. -7. Verify second-round reassignment/refinement is executed. -8. Add 2 `mnemond` instances mid-run. -9. Stop 1 `mnemond` mid-run. -10. Verify stale/TTL cue causes governed reassignment. -11. Verify progress aggregation. -12. Verify another act can be emitted after aggregation. -13. Verify completion proof uses accepted events, not GitHub issue/PR state. +1. Configure 5 appserver/mnemond workspaces, remotes, and run-scoped publication branches. +2. Start the initial online subset of appservers/mnemond; the current runner uses 3 online and 2 delayed nodes for the natural task round. +3. Publish fresh profiles from the initial online nodes. +4. Send an ordinary user message to one connected PoC agent to start teamwork. +5. Verify assignment propagation. +6. Verify nested decomposition. +7. Verify first-round outputs cause a second-round plan. +8. Verify second-round reassignment/refinement is executed. +9. Start 2 delayed `mnemond`/appserver pairs mid-run from the already configured `remotes.json`. +10. Verify delayed nodes import backlog and publish fresh `agent_profile` events. +11. Stop/restart 1 `mnemond` mid-run. +12. Verify stale/TTL cue or renewed progress is expressed through governed events. +13. Verify progress aggregation. +14. Verify another act can be emitted after aggregation. +15. Verify completion proof uses accepted events, not GitHub issue/PR state. + +The delayed join step is not discovery. It proves that preconfigured publication streams can become available later, import backlog through the normal pull/import path, and then participate by publishing fresh accepted events. Additional natural task scenarios should cover: diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index 39c94f0a..3fe62871 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -175,12 +175,13 @@ type r1CodexSyncReport struct { } type r1SyncLifecycleReport struct { - At string `json:"at"` - Principal string `json:"principal"` - Action string `json:"action"` - Result string `json:"result"` - Branch string `json:"branch,omitempty"` - Detail string `json:"detail,omitempty"` + At string `json:"at"` + Principal string `json:"principal"` + Action string `json:"action"` + Result string `json:"result"` + Branch string `json:"branch,omitempty"` + Detail string `json:"detail,omitempty"` + Ledger map[string]int `json:"ledger,omitempty"` } type r1AcceptanceAssertion struct { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 694a148a..7ddd881e 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -189,8 +189,13 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Artifacts["github_token_file"] = tokenFile report.Artifacts["github_branch_prefix"] = branchPrefix report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() + initialOnline := opts.Agents + if opts.AgentTurns { + initialOnline = r1GitHubMeshInitialOnline(opts.Agents) + } + report.Artifacts["github_initial_online_agents"] = fmt.Sprintf("%d", initialOnline) - agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval) + agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval, initialOnline) if err != nil { addR1Error(&report, err) report.Status = "blocked" @@ -218,7 +223,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Status = "failed" return report, fmt.Errorf("GitHub mesh task-suite acceptance requires --agent-turns") } - for i := range agents { + for _, i := range r1GitHubMeshLocalOnlineIndexes(agents) { if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { addR1Error(&report, err) report.Status = "blocked" @@ -236,17 +241,15 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Raw[agents[i].principal+":hooks"] = raw } } - addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) - if err := exerciseR1GitHubMeshLifecycle(ctx, &report, agents, opts.SyncInterval); err != nil { - addR1Error(&report, err) - } + addR1Assertion(&report, "github-mesh initial appservers start/init", len(report.Agents) == initialOnline, fmt.Sprintf("started=%d initial_online=%d requested=%d", len(report.Agents), initialOnline, opts.Agents)) run := r1GitHubMeshRun{ - ctx: ctx, - opts: opts, - report: &report, - agents: agents, - runID: started.Format("150405"), + ctx: ctx, + opts: opts, + report: &report, + agents: agents, + runID: started.Format("150405"), + initialOnline: initialOnline, } if err := run.bootstrapProfiles(); err != nil { addR1Error(&report, err) @@ -256,6 +259,9 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO addR1Error(&report, err) } } + addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + addR1Assertion(&report, "github-mesh delayed mnemond join exercised", run.joined || initialOnline == opts.Agents, fmt.Sprintf("initial_online=%d total=%d lifecycle=%v", initialOnline, opts.Agents, syncReport.Lifecycle)) + addR1Assertion(&report, "github-mesh local mnemond leave/restart exercised", run.lifecycleExercised, fmt.Sprintf("lifecycle=%v", syncReport.Lifecycle)) run.addPostScenarioAssertions() obs, obsErr := observeAcceptanceRun(runRoot, 1000) if obsErr == nil { @@ -283,11 +289,14 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } type r1GitHubMeshRun struct { - ctx context.Context - opts r1GitHubMeshAcceptanceOptions - report *r1CodexAcceptanceReport - agents []r1CodexSyncAgent - runID string + ctx context.Context + opts r1GitHubMeshAcceptanceOptions + report *r1CodexAcceptanceReport + agents []r1CodexSyncAgent + runID string + initialOnline int + joined bool + lifecycleExercised bool } func r1GitHubMeshScenarioNames(selected []string) []string { @@ -325,9 +334,13 @@ func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected [] return true } -func (s r1GitHubMeshRun) bootstrapProfiles() error { +func (s *r1GitHubMeshRun) bootstrapProfiles() error { profileCounts := map[string]int{} - for i := range s.agents { + active := r1GitHubMeshReadyAgentIndexes(s.agents) + if len(active) == 0 { + return fmt.Errorf("github mesh profile bootstrap requires at least one online agent") + } + for _, i := range active { agent := &s.agents[i] payload := taskSimJSON(map[string]any{ "actor": agent.principal, @@ -353,21 +366,22 @@ After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agen profileCounts[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)["agent_profile"] } allVisible := true - for i := range s.agents { + for _, i := range active { agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), 120*time.Second) + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(active), 120*time.Second) counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) profileCounts[agent.principal] = counts["agent_profile"] - if counts["agent_profile"] < len(s.agents) { + if counts["agent_profile"] < len(active) { allVisible = false } } - addR1Assertion(s.report, "github-mesh profiles converge through publication branches", allVisible, fmt.Sprintf("agents=%d", len(s.agents))) + addR1Assertion(s.report, "github-mesh initial profiles converge through publication branches", allVisible, fmt.Sprintf("initial_online_agents=%d", len(active))) s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ Name: "bootstrap_profiles", Status: statusFromBool(allVisible), Evidence: map[string]any{ "profile_counts_by_agent": profileCounts, + "initial_online_agents": len(active), "publication_branches": s.report.Sync.PublicationBranches, }, }) @@ -377,7 +391,7 @@ After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agen return nil } -func (s r1GitHubMeshRun) runScenario(name string) error { +func (s *r1GitHubMeshRun) runScenario(name string) error { entries, err := s.scenarioEntries(name) if err != nil { s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: name, Status: "blocked", Evidence: map[string]any{"error": err.Error()}}) @@ -467,16 +481,20 @@ func (s r1GitHubMeshRun) runScenario(name string) error { return nil } -func (s r1GitHubMeshRun) addPostScenarioAssertions() { +func (s *r1GitHubMeshRun) addPostScenarioAssertions() { profileCounts := r1GitHubMeshLedgerCountsByAgent(s.agents, "agent_profile") + baseline := len(s.agents) + if s.initialOnline > 0 && s.initialOnline < len(s.agents) { + baseline = s.initialOnline + } refreshed := false for _, count := range profileCounts { - if count > len(s.agents) { + if count > baseline { refreshed = true break } } - addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_agents=%d", profileCounts, len(s.agents))) + addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_online_agents=%d total_agents=%d", profileCounts, baseline, len(s.agents))) if s.report.Sync != nil { if s.report.Raw == nil { s.report.Raw = map[string]json.RawMessage{} @@ -491,7 +509,7 @@ type r1GitHubMeshScenarioEntry struct { prompt string } -func (s r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { +func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { if len(s.agents) < 5 { return nil, fmt.Errorf("github mesh natural scenarios require five agents") } @@ -510,14 +528,14 @@ func (s r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEnt } } -func (s r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { +func (s *r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { entry := map[int]bool{} for _, item := range entries { entry[item.index] = true } for cycle := 1; cycle <= 3; cycle++ { for i := range s.agents { - if entry[i] { + if entry[i] || !r1GitHubMeshAgentReady(s.agents[i]) { continue } agent := &s.agents[i] @@ -530,7 +548,137 @@ func (s r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenario } } waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 8*time.Second, 1*time.Second) + if cycle == 1 { + if err := s.joinDelayedAgents(name); err != nil { + return err + } + } + if cycle == 2 && !s.lifecycleExercised { + if err := exerciseR1GitHubMeshLifecycle(s.ctx, s.report, s.agents, s.opts.SyncInterval); err != nil { + return err + } + s.lifecycleExercised = true + } + } + return nil +} + +func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { + if s.joined { + return nil + } + var joined []string + profileCounts := map[string]int{} + for i := range s.agents { + if r1GitHubMeshAgentReady(s.agents[i]) { + continue + } + agent := &s.agents[i] + branch := "" + if s.report.Sync != nil { + branch = s.report.Sync.BranchByAgent[agent.principal] + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "delayed_join_start", + Result: "requested", + Branch: branch, + Detail: "start a preconfigured isolated Local Mnemon during the natural GitHub mesh task", + }) + } + if err := startR1GitHubMeshLocalMnemond(s.ctx, agent, s.opts.SyncInterval); err != nil { + addR1Assertion(s.report, "github-mesh delayed mnemond join "+agent.principal, false, err.Error()) + return err + } + if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { + addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) + return err + } + agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) + if err != nil { + addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) + return err + } + s.report.Agents = append(s.report.Agents, agentReport) + if s.report.Sync != nil { + s.report.Sync.Agents = append(s.report.Sync.Agents, agentReport) + } + if raw != nil { + s.report.Raw[agent.principal+":hooks"] = raw + } + if err := s.emitJoinedProfile(agent, scenario); err != nil { + return err + } + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] + joined = append(joined, agent.principal) + if s.report.Sync != nil { + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "delayed_join_ready", + Result: "ready", + Branch: branch, + Detail: "joined configured publication mesh, initialized appserver, and published fresh profile", + Ledger: counts, + }) + } + } + s.joined = true + if len(joined) == 0 { + return nil + } + allVisible := true + for _, i := range r1GitHubMeshReadyAgentIndexes(s.agents) { + agent := s.agents[i] + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), 120*time.Second) + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] + if counts["agent_profile"] < len(s.agents) { + allVisible = false + } + } + passed := len(joined) >= 2 && allVisible + addR1Assertion(s.report, "github-mesh two delayed mnemond join and import backlog", passed, fmt.Sprintf("joined=%v profile_counts=%v", joined, profileCounts)) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: "delayed_join_profiles", + Status: statusFromBool(passed), + Actors: joined, + Evidence: map[string]any{ + "scenario": scenario, + "joined_agents": joined, + "profile_counts_by_agent": profileCounts, + "publication_branches": s.report.Sync.PublicationBranches, + }, + }) + if !passed { + return fmt.Errorf("delayed GitHub mesh join did not converge profiles through publication branches") + } + return nil +} + +func (s *r1GitHubMeshRun) emitJoinedProfile(agent *r1CodexSyncAgent, scenario string) error { + payload := taskSimJSON(map[string]any{ + "actor": agent.principal, + "focus": fmt.Sprintf("Joined GitHub mesh task %s with fresh local context", scenario), + "context_advantages": []string{"late join backlog import", "github publication branch sync", "isolated local mnemond"}, + "availability": "available", + "ttl": "30m", + "summary": fmt.Sprintf("%s joined during %s and can pick up governed work from imported context.", agent.principal, scenario), + }) + prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. +Use external id github-mesh-join-profile-%s-%s and payload: +%s +After the command succeeds, answer "joined profile written".`, s.runID, prodSafeID(agent.principal), payload) + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "delayed_join_profile:"+scenario, prompt) + s.report.RunnerContract.ProfileBootstrapPrompts++ + answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh delayed profile emitted "+agent.principal, false, err.Error()) + return err } + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) return nil } @@ -592,10 +740,13 @@ func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { return out } -func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration) ([]r1CodexSyncAgent, error) { +func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration, initialOnline int) ([]r1CodexSyncAgent, error) { if syncInterval <= 0 { syncInterval = 30 * time.Second } + if initialOnline < 0 || initialOnline > count { + initialOnline = count + } var agents []r1CodexSyncAgent branches := r1GitHubMeshBranches(branchPrefix, count) for i := 1; i <= count; i++ { @@ -636,14 +787,6 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to if err != nil { return nil, err } - localCtx, cancel := context.WithCancel(ctx) - localErr := make(chan error, 1) - go func(workspace, addr string, loaded access.LoadedBindings) { - localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ - ProjectRoot: workspace, - SyncInterval: syncInterval, - }, io.Discard) - }(workspace, localAddr, loaded) agent := r1CodexSyncAgent{ r1CodexAgent: r1CodexAgent{ principal: principal, @@ -656,12 +799,11 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to replicaPrincipal: principal, replicaToken: tokenFile, renderAuditPath: filepath.Join(workspace, ".mnemon", "harness", "local", "render-audit.jsonl"), - localCancel: cancel, - localErr: localErr, } - if err := waitR1LocalReady(ctx, agent.r1CodexAgent, localURL, 10*time.Second); err != nil { - cancel() - return nil, err + if i <= initialOnline { + if err := startR1GitHubMeshLocalMnemond(ctx, &agent, syncInterval); err != nil { + return nil, err + } } agents = append(agents, agent) } @@ -675,7 +817,7 @@ func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanc if syncInterval <= 0 { syncInterval = 30 * time.Second } - target := &agents[3] + target := &agents[2] branch := report.Sync.BranchByAgent[target.principal] report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ At: time.Now().UTC().Format(time.RFC3339), @@ -698,12 +840,44 @@ func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanc return fmt.Errorf("%s local mnemond did not stop within timeout", target.principal) } } - loaded, err := access.LoadBindingFile(target.workspace, filepath.Join(target.workspace, access.DefaultBindingFile)) + target.localCancel = nil + target.localErr = nil + if err := startR1GitHubMeshLocalMnemond(ctx, target, syncInterval); err != nil { + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) + return err + } + counts := countR1Ledger(target.localURL, target.r1CodexAgent) + report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: target.principal, + Action: "restart_local_mnemond", + Result: "ready", + Branch: branch, + Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", + Ledger: counts, + }) + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) + return nil +} + +func startR1GitHubMeshLocalMnemond(ctx context.Context, agent *r1CodexSyncAgent, syncInterval time.Duration) error { + if agent == nil { + return fmt.Errorf("nil GitHub mesh agent") + } + if agent.localCancel != nil { + return nil + } + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } + loaded, err := access.LoadBindingFile(agent.workspace, filepath.Join(agent.workspace, access.DefaultBindingFile)) if err != nil { - addR1Assertion(report, "github-mesh local mnemond restart loads bindings", false, err.Error()) return err } - addr := strings.TrimPrefix(target.localURL, "http://") + addr := strings.TrimPrefix(agent.localURL, "http://") + if strings.TrimSpace(addr) == "" { + return fmt.Errorf("%s has no local URL", agent.principal) + } localCtx, cancel := context.WithCancel(ctx) localErr := make(chan error, 1) go func(workspace, addr string, loaded access.LoadedBindings) { @@ -711,26 +885,52 @@ func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanc ProjectRoot: workspace, SyncInterval: syncInterval, }, io.Discard) - }(target.workspace, addr, loaded) - target.localCancel = cancel - target.localErr = localErr - if err := waitR1LocalReady(ctx, target.r1CodexAgent, target.localURL, 10*time.Second); err != nil { + }(agent.workspace, addr, loaded) + agent.localCancel = cancel + agent.localErr = localErr + if err := waitR1LocalReady(ctx, agent.r1CodexAgent, agent.localURL, 10*time.Second); err != nil { cancel() - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) + agent.localCancel = nil + agent.localErr = nil return err } - report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: target.principal, - Action: "restart_local_mnemond", - Result: "ready", - Branch: branch, - Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", - }) - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) return nil } +func r1GitHubMeshInitialOnline(count int) int { + if count <= 2 { + return count + } + if count <= 5 { + return count - 2 + } + return count - 2 +} + +func r1GitHubMeshAgentReady(agent r1CodexSyncAgent) bool { + return agent.localCancel != nil && agent.server != nil && strings.TrimSpace(agent.threadID) != "" +} + +func r1GitHubMeshLocalOnlineIndexes(agents []r1CodexSyncAgent) []int { + out := []int{} + for i, agent := range agents { + if agent.localCancel != nil { + out = append(out, i) + } + } + return out +} + +func r1GitHubMeshReadyAgentIndexes(agents []r1CodexSyncAgent) []int { + out := []int{} + for i, agent := range agents { + if r1GitHubMeshAgentReady(agent) { + out = append(out, i) + } + } + return out +} + func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { if self < 0 || self >= len(branches) { return fmt.Errorf("self index %d outside branches", self) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 388b9a17..57b0fc34 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "path/filepath" "strings" @@ -81,6 +82,55 @@ func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { } } +func TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/agent-", 5, "", 30*time.Second, 0) + if err != nil { + t.Fatalf("setup delayed github mesh agents: %v", err) + } + if len(agents) != 5 { + t.Fatalf("agents = %d, want 5", len(agents)) + } + if got := r1GitHubMeshLocalOnlineIndexes(agents); len(got) != 0 { + t.Fatalf("local online indexes = %v, want none before delayed start", got) + } + for i, agent := range agents { + if agent.localCancel != nil || agent.localErr != nil { + t.Fatalf("agent %d local mnemond should not be started", i) + } + remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") + if err != nil { + t.Fatalf("load delayed remote plan %d: %v", i, err) + } + if len(plan.PushTargets) != 1 || len(plan.PullSources) != 4 { + t.Fatalf("remote plan %d = %+v, want one publish and four subscribe streams", i, plan) + } + } +} + +func TestR1GitHubMeshInitialOnlineLeavesTwoDelayedAgents(t *testing.T) { + if got := r1GitHubMeshInitialOnline(5); got != 3 { + t.Fatalf("initial online for 5 = %d, want 3", got) + } + if got := r1GitHubMeshInitialOnline(7); got != 5 { + t.Fatalf("initial online for 7 = %d, want 5", got) + } + agents := []r1CodexSyncAgent{ + {localCancel: func() {}}, + {}, + {localCancel: func() {}}, + } + got := r1GitHubMeshLocalOnlineIndexes(agents) + if len(got) != 2 || got[0] != 0 || got[1] != 2 { + t.Fatalf("local online indexes = %v, want [0 2]", got) + } +} + func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { root := t.TempDir() tokenFile := filepath.Join(root, "github.token") From d5555aad41d4e6ce9179812e800f9d1a01464169 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:35:21 +0800 Subject: [PATCH 28/35] test(harness): assert github mesh natural evidence Record machine-readable natural-suite evidence for multi-PoC runs, prior completed scenarios, cross-task reuse candidates, and output-driven replanning in the GitHub mesh appserver report. Keep these assertions tied to selected scenarios so focused smoke runs remain usable. Validation: go test ./harness/cmd/mnemon-harness; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 4 + .../github-remote-workspace-backend.md | 4 + .../mnemon-harness/acceptance_github_mesh.go | 101 +++++++++++++++--- .../acceptance_github_mesh_test.go | 32 ++++++ 4 files changed, 125 insertions(+), 16 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 17d747ee..0264789b 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -892,6 +892,10 @@ sync.diagnostics_by_mnemond sync.profile_events_by_mnemond sync.lifecycle[].action = delayed_join_start | delayed_join_ready | pause_local_mnemond | restart_local_mnemond sync.lifecycle[].ledger = local ledger counts captured at lifecycle boundaries +scenarios[].evidence.multi_poc +scenarios[].evidence.prior_ok_scenarios +scenarios[].evidence.cross_task_reuse_or_completion +scenarios[].evidence.replanning_rounds ``` Done when: diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index 18f59f32..aeaa0769 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -49,6 +49,7 @@ other mnemond subscribe to those branches and validate locally. - per-agent local mnemond store path - delayed join / pause / restart lifecycle entries under `sync.lifecycle` - publication/import/profile summaries under `sync.published_events_by_branch`, `sync.imported_events_by_mnemond`, and `sync.profile_events_by_mnemond` + - natural suite evidence under `scenarios[].evidence` for multi-PoC, prior completed scenarios, cross-task reuse/completion, and replanning rounds 当前仍需外部证据的边界: @@ -1313,6 +1314,9 @@ diagnostics per mnemond assignment/progress chain mnemond join/leave timeline profile refresh/update timeline +multi-PoC evidence +cross-task reuse/completion evidence +output-driven replanning evidence proof no central mnemon-hub endpoint was used proof no shared governed.db was used ``` diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 7ddd881e..96019232 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -334,6 +334,64 @@ func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected [] return true } +func r1GitHubMeshScenarioSelected(selected []string, name string) bool { + for _, selectedName := range selected { + if selectedName == name { + return true + } + } + return false +} + +func r1GitHubMeshOKScenarioNames(scenarios []r1TaskSimScenarioReport) []string { + out := []string{} + for _, scenario := range scenarios { + if scenario.Status == "ok" && strings.TrimSpace(scenario.Name) != "" { + out = append(out, scenario.Name) + } + } + sort.Strings(out) + return out +} + +func r1GitHubMeshCrossTaskReuseCandidate(name string, priorOK []string) bool { + if name != "sync-risk-review" { + return false + } + return r1GitHubMeshScenarioSelected(priorOK, "onboarding-synthesis") +} + +func r1GitHubMeshHasOKScenarioEvidenceBool(scenarios []r1TaskSimScenarioReport, name, key string) bool { + for _, scenario := range scenarios { + if scenario.Name != name || scenario.Status != "ok" { + continue + } + if value, ok := scenario.Evidence[key].(bool); ok && value { + return true + } + } + return false +} + +func r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios []r1TaskSimScenarioReport, key string, min int) bool { + for _, scenario := range scenarios { + if scenario.Status != "ok" { + continue + } + switch value := scenario.Evidence[key].(type) { + case int: + if value >= min { + return true + } + case float64: + if int(value) >= min { + return true + } + } + } + return false +} + func (s *r1GitHubMeshRun) bootstrapProfiles() error { profileCounts := map[string]int{} active := r1GitHubMeshReadyAgentIndexes(s.agents) @@ -419,6 +477,7 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { if err := s.wakeWorkers(name, entries); err != nil { return err } + priorScenarios := r1GitHubMeshOKScenarioNames(s.report.Scenarios) lead := &s.agents[entries[0].index] integrationPrompt := r1GitHubMeshIntegrationPrompt(name) s.report.RunnerContract.IntegrationPrompts++ @@ -457,22 +516,24 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { Status: statusFromBool(passed), Actors: actors, Evidence: map[string]any{ - "participants": participants, - "replanning_rounds": replans, - "natural_user_messages": naturalMessages, - "worker_wake_prompts": workerWakes, - "integration_prompts": integrationPrompts, - "entry_poc_agents": actors, - "multi_poc": len(actors) > 1, - "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), - "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, - "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), - "cross_scenario_mnemon_ctx": true, - "actor_event_counts": counts, - "assignment_events": assignments, - "progress_digest_events": progress, - "teamwork_signal_events": signals, - "project_intent_events": intents, + "participants": participants, + "replanning_rounds": replans, + "natural_user_messages": naturalMessages, + "worker_wake_prompts": workerWakes, + "integration_prompts": integrationPrompts, + "entry_poc_agents": actors, + "multi_poc": len(actors) > 1, + "prior_ok_scenarios": priorScenarios, + "cross_task_reuse_or_completion": r1GitHubMeshCrossTaskReuseCandidate(name, priorScenarios), + "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), + "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, + "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), + "cross_scenario_mnemon_ctx": true, + "actor_event_counts": counts, + "assignment_events": assignments, + "progress_digest_events": progress, + "teamwork_signal_events": signals, + "project_intent_events": intents, }, }) if !passed { @@ -502,6 +563,14 @@ func (s *r1GitHubMeshRun) addPostScenarioAssertions() { raw, _ := json.Marshal(profileCounts) s.report.Raw["github_mesh:profile_counts_after_scenarios"] = raw } + selected := r1GitHubMeshScenarioNames(s.opts.Scenarios) + if r1GitHubMeshScenarioSelected(selected, "live-readiness-operator-safety") { + addR1Assertion(s.report, "github-mesh multi-poc scenario exercised", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "live-readiness-operator-safety", "multi_poc"), "scenario=live-readiness-operator-safety") + } + if r1GitHubMeshScenarioSelected(selected, "onboarding-synthesis") && r1GitHubMeshScenarioSelected(selected, "sync-risk-review") { + addR1Assertion(s.report, "github-mesh cross-task reuse/completion evidence recorded", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "sync-risk-review", "cross_task_reuse_or_completion"), "scenario=sync-risk-review prior=onboarding-synthesis") + } + addR1Assertion(s.report, "github-mesh output-driven replanning evidence recorded", r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(s.report.Scenarios, "replanning_rounds", 2), "requires at least one successful natural scenario with two or more prompt rounds") } type r1GitHubMeshScenarioEntry struct { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 57b0fc34..241d59cb 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -272,6 +272,38 @@ func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { } } +func TestR1GitHubMeshNaturalSuiteEvidenceHelpers(t *testing.T) { + scenarios := []r1TaskSimScenarioReport{ + {Name: "onboarding-synthesis", Status: "ok", Evidence: map[string]any{"replanning_rounds": 3}}, + {Name: "sync-risk-review", Status: "ok", Evidence: map[string]any{"cross_task_reuse_or_completion": true}}, + {Name: "live-readiness-operator-safety", Status: "ok", Evidence: map[string]any{"multi_poc": true}}, + } + prior := r1GitHubMeshOKScenarioNames(scenarios[:1]) + if !r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", prior) { + t.Fatal("sync-risk-review should be a cross-task reuse candidate after onboarding-synthesis") + } + if r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", nil) { + t.Fatal("sync-risk-review without prior onboarding should not claim cross-task reuse") + } + if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { + t.Fatal("multi-poc evidence should be detected on the successful live-readiness scenario") + } + if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "sync-risk-review", "cross_task_reuse_or_completion") { + t.Fatal("cross-task evidence should be detected on the successful sync-risk scenario") + } + if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { + t.Fatal("replanning evidence should accept int counts") + } + scenarios[0].Evidence["replanning_rounds"] = float64(2) + if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { + t.Fatal("replanning evidence should accept json-decoded float64 counts") + } + scenarios[2].Status = "failed" + if r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { + t.Fatal("failed scenarios must not satisfy evidence assertions") + } +} + func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { contract := &r1RunnerContractReport{} recordR1ClusterPrompt(contract, "codex-01@project", "natural_user_message:onboarding-synthesis", "prompt") From 6285c042d15c02efb6040c014f9762cf13e0a714 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:43:33 +0800 Subject: [PATCH 29/35] fix(harness): name github mesh branches by mnemond id Remove the acceptance-specific branch namespace from the GitHub mesh runner and docs. Default real runs now create publication branches as run-scoped mnemond ids such as mnemon/mnemond--a, keeping branches as mnemond publication streams rather than acceptance concepts or hostagent names. Also add batched GitHub branch ensure so acceptance branch initialization reads the base ref once, de-duplicates branches, and reports branch-specific create failures. Validation: go test ./harness/cmd/mnemon-harness; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- ...-decentralized-mesh-implementation-plan.md | 42 +++++----- .../github-remote-workspace-backend.md | 60 +++++++------- .../mnemon-harness/acceptance_github_mesh.go | 14 ++-- .../acceptance_github_mesh_test.go | 30 +++---- harness/cmd/mnemon-harness/sync_test.go | 6 +- .../exchange/backend/github/backend_test.go | 24 +++--- .../backend/github/publication_store.go | 79 +++++++++++++------ .../backend/github/publication_store_test.go | 64 ++++++++++++--- 8 files changed, 192 insertions(+), 127 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 0264789b..91244a14 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -77,7 +77,7 @@ Current branch progress: - Real GitHub `PublicationStore` adapter exists. - GitHub Remote Workspace normalizes opaque GitHub branch-head cursors before writing local mnemond pull state. - Gated live publish/pull/import test exists and is skipped unless live credentials are provided. -- Gated live publish/pull/import passed against `mnemon-dev/mnemon-teamwork-example` on 2026-06-26 after initializing default publication branches `mnemon/agent-a` through `mnemon/agent-e`. +- Gated live publish/pull/import passed against `mnemon-dev/mnemon-teamwork-example` on 2026-06-26 with operator-created publication branches; the current branch contract names branches after `mnemond_id`, for example `mnemon/mnemond-a`. - Deterministic local GitHub mesh tests cover: - five isolated mnemond runtimes; - one branch per mnemond; @@ -97,15 +97,15 @@ mnemon-harness acceptance r1-github-mesh-task-suite \ --sync-interval 30s ``` -When `--github-branch-prefix` is omitted, the runner must create run-scoped publication branches: +When `--github-branch-prefix` is omitted, the runner must create run-scoped publication branches by embedding the run id into the temporary `mnemond_id`: ```text -mnemon/acceptance//agent-a -mnemon/acceptance//agent-b +mnemon/mnemond--a +mnemon/mnemond--b ... ``` -Fixed branches such as `mnemon/agent-a` through `mnemon/agent-e` are valid for manual operator smoke tests, but not the default real appserver acceptance path because historical publication entries can pollute a fresh run. +Fixed mnemond branches such as `mnemon/mnemond-a` through `mnemon/mnemond-e` are valid for manual operator smoke tests, but not the default real appserver acceptance path because historical publication entries can pollute a fresh run. Real runner lifecycle now treats join as delayed activation of preconfigured publication streams: @@ -323,8 +323,8 @@ Team manifest shape: "team_id": "mnemon-teamwork-example", "members": [ { - "mnemond_id": "agent-a", - "branch": "mnemon/agent-a", + "mnemond_id": "mnemond-a", + "branch": "mnemon/mnemond-a", "principal": "codex-a@project" } ] @@ -374,7 +374,7 @@ mnemon-publications/v1/events//// publish mnemon-publications/v1/events/agent-a/progress_digest/project/000000000001-dec-a.json + -> publish mnemon-publications/v1/events/mnemond-a/progress_digest/project/000000000001-dec-a.json agent-b: - pull mnemon/agent-a + pull mnemon/mnemond-a -> validate envelope -> import through Event Intake path -> persist cursor/status @@ -737,8 +737,8 @@ Suggested gated command shape: MNEMON_GITHUB_LIVE=1 \ MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ MNEMON_GITHUB_TOKEN_FILE=/path/to/token \ -MNEMON_GITHUB_BRANCH_A=mnemon/agent-a \ -MNEMON_GITHUB_BRANCH_B=mnemon/agent-b \ +MNEMON_GITHUB_BRANCH_A=mnemon/mnemond-a \ +MNEMON_GITHUB_BRANCH_B=mnemon/mnemond-b \ go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v ``` diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md index aeaa0769..68e4479e 100644 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ b/.mnemon-dev/architecture/github-remote-workspace-backend.md @@ -53,7 +53,7 @@ other mnemond subscribe to those branches and validate locally. 当前仍需外部证据的边界: -- gated live GitHub publish/pull/import 已在 2026-06-26 通过真实 GitHub 访问验证;验证仓库缺失默认 publication branches 时先失败,随后初始化 `mnemon/agent-a` 到 `mnemon/agent-e` 后通过。 +- gated live GitHub publish/pull/import 已在 2026-06-26 通过真实 GitHub 访问验证;验证仓库缺失 operator publication branches 时先失败,随后初始化当时的 smoke branches 后通过。当前 branch contract 使用 `mnemond_id` 命名,例如 `mnemon/mnemond-a`。 - real Codex appserver GitHub mesh suite 需要 token + Codex appserver 环境后实际运行。 - 在 real Codex appserver GitHub mesh suite 通过前,不能把 GitHub mesh 目标标记为 complete。 @@ -416,11 +416,11 @@ GitHub mesh 改为 shared repo + per-agent branch: repo: mnemon-dev/mnemon-teamwork-example branches: - mnemon/agent-a - mnemon/agent-b - mnemon/agent-c - mnemon/agent-d - mnemon/agent-e + mnemon/mnemond-a + mnemon/mnemond-b + mnemon/mnemond-c + mnemon/mnemond-d + mnemon/mnemond-e ``` 拓扑: @@ -428,11 +428,11 @@ branches: ```text shared GitHub repo +----------------------------------+ - | branch mnemon/agent-a | - | branch mnemon/agent-b | - | branch mnemon/agent-c | - | branch mnemon/agent-d | - | branch mnemon/agent-e | + | branch mnemon/mnemond-a | + | branch mnemon/mnemond-b | + | branch mnemon/mnemond-c | + | branch mnemon/mnemond-d | + | branch mnemon/mnemond-e | +----------------------------------+ ^ ^ ^ ^ | | | | @@ -453,8 +453,8 @@ GitHub mesh 数据流: ```text agent-a emits teamwork_signal / assignment -> mnemond-a accepts event locally - -> mnemond-a publishes synced envelope to branch mnemon/agent-a - -> mnemond-b/c/d/e read branch mnemon/agent-a + -> mnemond-a publishes synced envelope to branch mnemon/mnemond-a + -> mnemond-b/c/d/e read branch mnemon/mnemond-a -> each local mnemond validates and imports -> assigned agents act -> each publishes its own accepted events on its own branch @@ -686,8 +686,8 @@ mnemon-publications/v1/ "team_id": "mnemon-teamwork-example", "members": [ { - "mnemond_id": "agent-a", - "branch": "mnemon/agent-a", + "mnemond_id": "mnemond-a", + "branch": "mnemon/mnemond-a", "principal": "codex-a@project" } ] @@ -701,7 +701,7 @@ mnemon-publications/v1/ "schema_version": 1, "backend": "github", "mode": "publication", - "origin_mnemond": "agent-a", + "origin_mnemond": "mnemond-a", "published_at": "2026-06-26T00:00:00Z", "published_scopes": [ { "kind": "assignment", "id": "project" }, @@ -719,7 +719,7 @@ mnemon-publications/v1/events//// | synced | --> | sync worker | --> | GitHub branch | -| accepted | | event | | push lane | | mnemon/agent-a | +| accepted | | event | | push lane | | mnemon/mnemond-a | +----------+ +---------+ +-------------+ +------------------+ ``` @@ -773,7 +773,7 @@ ASCII: ```text +------------------+ +-------------+ +--------------+ +----------+ | GitHub branch | --> | pull lane | --> | Event Intake | --> | local | -| mnemon/agent-b | | validate | | + Tick | | resource | +| mnemon/mnemond-b | | validate | | + Tick | | resource | +------------------+ +-------------+ +--------------+ +----------+ ``` @@ -837,7 +837,7 @@ Target acceptance scenario: ```text agent-a appserver starts -> mnemond-a starts - -> publish branch mnemon/agent-a exists + -> publish branch mnemon/mnemond-a exists -> agent-a emits agent_profile -> profile is published ``` @@ -860,13 +860,13 @@ agent-a receives user teamwork task -> emits teamwork_signal -> emits assignment(s) -> mnemond-a accepts - -> mnemond-a publishes to mnemon/agent-a + -> mnemond-a publishes to mnemon/mnemond-a ``` Subscribed mnemond: ```text -mnemond-b/c/d/e subscribe to mnemon/agent-a +mnemond-b/c/d/e subscribe to mnemon/mnemond-a -> import signal/assignment -> render work cues -> assigned agents act @@ -879,7 +879,7 @@ agent-b receives assignment -> decides it should split -> emits new teamwork_signal / assignment -> mnemond-b accepts - -> mnemond-b publishes to mnemon/agent-b + -> mnemond-b publishes to mnemon/mnemond-b -> subscribed mnemond pull/import ``` @@ -899,7 +899,7 @@ Two new `mnemond` instances join during work: ```text agent-f/g appservers start - -> create branches mnemon/agent-f/g + -> create branches mnemon/mnemond-f/g -> publish fresh agent_profile -> subscribe to existing branches -> pull backlog according to cursors @@ -1221,14 +1221,14 @@ mnemon-harness sync connect self \ --backend github \ --direction publish \ --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-a \ + --github-branch mnemon/mnemond-a \ --token-file ... mnemon-harness sync connect agent-b-stream \ --backend github \ --direction subscribe \ --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-b \ + --github-branch mnemon/mnemond-b \ --token-file ... ``` @@ -1254,8 +1254,8 @@ Required gated live case: ```text configured GitHub repo: mnemon-dev/mnemon-teamwork-example -branch mnemon/agent-a -branch mnemon/agent-b +branch mnemon/mnemond-a +branch mnemon/mnemond-b agent-a push: accepted synced envelope -> GitHub publication branch @@ -1354,8 +1354,8 @@ Isolation requirements: - each `mnemond` has its own local store; - each appserver has its own runtime workspace; - cross-agent visibility only happens through publication branch pull/import. -- default real acceptance branches are `mnemon/acceptance//agent-*` and are initialized from `main` before local sync starts. -- long-lived branches such as `mnemon/agent-a` are explicit operator smoke-test inputs, not the default acceptance isolation model. +- default real acceptance branches are run-scoped mnemond publication streams such as `mnemon/mnemond--a`, initialized from `main` before local sync starts. +- long-lived mnemond branches such as `mnemon/mnemond-a` are explicit operator smoke-test inputs, not the default acceptance isolation model. - real GitHub acceptance uses a 30 second default sync interval per local `mnemond`; 100ms polling is reserved for fake/local tests. Scenario: diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 96019232..e0cf13c2 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -76,7 +76,7 @@ func init() { acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped acceptance prefix") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) @@ -1038,12 +1038,12 @@ func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []stri func r1GitHubMeshBranches(prefix string, count int) []string { if prefix == "" { - prefix = "mnemon/agent-" + prefix = "mnemon/mnemond-" } out := make([]string, 0, count) for i := 0; i < count; i++ { suffix := fmt.Sprintf("%02d", i+1) - if strings.HasSuffix(prefix, "agent-") && i < 26 { + if strings.HasSuffix(prefix, "-") && i < 26 { suffix = string(rune('a' + i)) } out = append(out, prefix+suffix) @@ -1056,7 +1056,7 @@ func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { if prefix != "" { return prefix } - return "mnemon/acceptance/" + started.UTC().Format("20060102T150405Z") + "/agent-" + return "mnemon/mnemond-" + started.UTC().Format("20060102T150405Z") + "-" } func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { @@ -1071,10 +1071,8 @@ func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, bra if err != nil { return err } - for _, branch := range branches { - if err := store.EnsureBranch(ctx, branch, "main"); err != nil { - return fmt.Errorf("ensure GitHub branch %q: %w", branch, err) - } + if err := store.EnsureBranches(ctx, branches, "main"); err != nil { + return fmt.Errorf("ensure GitHub branches: %w", err) } return nil } diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 241d59cb..3ae10be6 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -13,8 +13,8 @@ import ( ) func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { - got := r1GitHubMeshBranches("mnemon/agent-", 5) - want := []string{"mnemon/agent-a", "mnemon/agent-b", "mnemon/agent-c", "mnemon/agent-d", "mnemon/agent-e"} + got := r1GitHubMeshBranches("mnemon/mnemond-", 5) + want := []string{"mnemon/mnemond-a", "mnemon/mnemond-b", "mnemon/mnemond-c", "mnemon/mnemond-d", "mnemon/mnemond-e"} if len(got) != len(want) { t.Fatalf("branches = %v, want %v", got, want) } @@ -28,20 +28,20 @@ func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { started := time.Date(2026, 6, 25, 18, 57, 20, 0, time.UTC) prefix := r1GitHubMeshBranchPrefix("", started) - if prefix != "mnemon/acceptance/20260625T185720Z/agent-" { - t.Fatalf("prefix = %q, want run-scoped acceptance prefix", prefix) + if prefix != "mnemon/mnemond-20260625T185720Z-" { + t.Fatalf("prefix = %q, want run-scoped mnemond prefix", prefix) } got := r1GitHubMeshBranches(prefix, 2) want := []string{ - "mnemon/acceptance/20260625T185720Z/agent-a", - "mnemon/acceptance/20260625T185720Z/agent-b", + "mnemon/mnemond-20260625T185720Z-a", + "mnemon/mnemond-20260625T185720Z-b", } for i := range want { if got[i] != want[i] { t.Fatalf("branches = %v, want %v", got, want) } } - if explicit := r1GitHubMeshBranchPrefix("mnemon/agent-", started); explicit != "mnemon/agent-" { + if explicit := r1GitHubMeshBranchPrefix("mnemon/mnemond-team-", started); explicit != "mnemon/mnemond-team-" { t.Fatalf("explicit prefix = %q, want unchanged", explicit) } } @@ -56,7 +56,7 @@ func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } - branches := r1GitHubMeshBranches("mnemon/agent-", 5) + branches := r1GitHubMeshBranches("mnemon/mnemond-", 5) if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, 2); err != nil { t.Fatalf("write github mesh remotes: %v", err) } @@ -66,8 +66,8 @@ func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { if err != nil { t.Fatalf("load remote plan: %v", err) } - if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/agent-c" { - t.Fatalf("push targets = %+v, want self on mnemon/agent-c", plan.PushTargets) + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/mnemond-c" { + t.Fatalf("push targets = %+v, want self on mnemon/mnemond-c", plan.PushTargets) } if len(plan.PullSources) != 4 { t.Fatalf("pull sources = %+v, want four peer streams", plan.PullSources) @@ -88,7 +88,7 @@ func TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart(t *testing.T) { if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { t.Fatal(err) } - agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/agent-", 5, "", 30*time.Second, 0) + agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/mnemond-", 5, "", 30*time.Second, 0) if err != nil { t.Fatalf("setup delayed github mesh agents: %v", err) } @@ -137,7 +137,7 @@ func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { t.Fatal(err) } - branches := r1GitHubMeshBranches("mnemon/agent-", 3) + branches := r1GitHubMeshBranches("mnemon/mnemond-", 3) agents := make([]r1CodexSyncAgent, 0, 3) for i := range branches { workspace := filepath.Join(root, "workspaces", branches[i][len("mnemon/"):]) @@ -184,8 +184,8 @@ func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { {Principal: "codex-02@project"}, }, BranchByAgent: map[string]string{ - "codex-01@project": "mnemon/acceptance/run/agent-a", - "codex-02@project": "mnemon/acceptance/run/agent-b", + "codex-01@project": "mnemon/mnemond-run-a", + "codex-02@project": "mnemon/mnemond-run-b", }, }} obs := acceptanceObserveReport{Stores: []acceptanceStoreInspect{ @@ -209,7 +209,7 @@ func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { populateR1GitHubMeshSyncEvidence(report, obs) - if got := report.Sync.PublishedByBranch["mnemon/acceptance/run/agent-a"]; got != 3 { + if got := report.Sync.PublishedByBranch["mnemon/mnemond-run-a"]; got != 3 { t.Fatalf("published branch a = %d, want 3", got) } if got := report.Sync.ImportedByMnemond["codex-02@project"]; got != 2 { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index 1327b7c0..3162663a 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -319,7 +319,7 @@ func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { syncRemoteBackend = exchange.RemoteBackendGitHub syncRemoteDirection = exchange.RemoteDirectionPublish syncGitHubRepo = "mnemon-dev/mnemon-teamwork-example" - syncGitHubBranch = "mnemon/agent-a" + syncGitHubBranch = "mnemon/mnemond-a" syncRemoteToken = "secret-github-token" var out bytes.Buffer cmd := mustTestCommand(t) @@ -336,7 +336,7 @@ func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { `"direction": "publish"`, `"id": "self"`, `"repo": "mnemon-dev/mnemon-teamwork-example"`, - `"branch": "mnemon/agent-a"`, + `"branch": "mnemon/mnemond-a"`, `"credential_ref": ".mnemon/harness/sync/credentials/self.token"`, } { if !strings.Contains(config, want) { @@ -356,7 +356,7 @@ func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { t.Fatalf("resolve github remote: %v", err) } if remote.ID != "self" || remote.Backend != exchange.RemoteBackendGitHub || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/mnemond-a" || remote.Token != "secret-github-token" { t.Fatalf("github remote not resolved: %+v", remote) } diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go index 3dc5f95b..2dc2e7ca 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go @@ -14,7 +14,7 @@ import ( var progressRef = contract.ResourceRef{Kind: "progress_digest", ID: "project"} func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + store, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "published progress"}) resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) @@ -24,7 +24,7 @@ func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { t.Fatalf("push resp = %+v, want one accepted", resp) } - list, err := store.ListEvents(context.Background(), "mnemon/agent-a", exchange.PublicationEventRoot, "") + list, err := store.ListEvents(context.Background(), "mnemon/mnemond-a", exchange.PublicationEventRoot, "") if err != nil { t.Fatalf("list publication events: %v", err) } @@ -42,7 +42,7 @@ func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { } func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "bad phase"}) env.Phase = eventmodel.PhaseAccepted @@ -56,7 +56,7 @@ func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { } func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/agent-a", progressRef) + _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "same key"}) if resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}); err != nil || len(resp.Accepted) != 1 { t.Fatalf("seed push: resp=%+v err=%v", resp, err) @@ -73,11 +73,11 @@ func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { } func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/agent-b", progressRef) + store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) foreign := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) own := githubBackendTestEnvelope(t, "replica-a", "dec-own", progressRef, map[string]any{"content": "own progress"}) - putStoredEvent(t, store, "mnemon/agent-b", foreign) - putStoredEvent(t, store, "mnemon/agent-b", own) + putStoredEvent(t, store, "mnemon/mnemond-b", foreign) + putStoredEvent(t, store, "mnemon/mnemond-b", own) resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) if err != nil { @@ -96,15 +96,15 @@ func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) } func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/agent-b", progressRef) + store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) invalidPhase := githubBackendTestEnvelope(t, "replica-b", "dec-invalid-phase", progressRef, map[string]any{"content": "invalid phase"}) invalidPhase.Phase = eventmodel.PhaseAccepted badDigest := githubBackendTestEnvelope(t, "replica-b", "dec-bad-digest", progressRef, map[string]any{"content": "bad digest"}) badDigest.Meta["digest"] = "wrong" outOfScope := githubBackendTestEnvelope(t, "replica-b", "dec-out-scope", contract.ResourceRef{Kind: "assignment", ID: "project"}, map[string]any{"content": "assignment"}) - putStoredEvent(t, store, "mnemon/agent-b", invalidPhase) - putStoredEvent(t, store, "mnemon/agent-b", badDigest) - putStoredEvent(t, store, "mnemon/agent-b", outOfScope) + putStoredEvent(t, store, "mnemon/mnemond-b", invalidPhase) + putStoredEvent(t, store, "mnemon/mnemond-b", badDigest) + putStoredEvent(t, store, "mnemon/mnemond-b", outOfScope) resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) if err != nil { @@ -134,7 +134,7 @@ func TestGitHubBackendPullNormalizesOpaquePublicationCursor(t *testing.T) { Cursor: "head-abc", }}, } - backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/agent-b", Scopes: []contract.ResourceRef{progressRef}}) + backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/mnemond-b", Scopes: []contract.ResourceRef{progressRef}}) if err != nil { t.Fatal(err) } diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go index f4709cc7..5faf23e0 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -178,46 +178,75 @@ func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, p } func (s *GitHubPublicationStore) EnsureBranch(ctx context.Context, branch string, baseBranch string) error { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return err - } - baseBranch, err = normalizeGitHubBranchName(baseBranch) + return s.EnsureBranches(ctx, []string{branch}, baseBranch) +} + +func (s *GitHubPublicationStore) EnsureBranches(ctx context.Context, branches []string, baseBranch string) error { + baseBranch, err := normalizeGitHubBranchName(baseBranch) if err != nil { return fmt.Errorf("base branch: %w", err) } if baseBranch == "" { baseBranch = "main" } - if _, err := s.branchHead(ctx, branch); err == nil { + normalized := make([]string, 0, len(branches)) + seen := map[string]bool{} + for _, branch := range branches { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return err + } + if seen[branch] { + continue + } + seen[branch] = true + normalized = append(normalized, branch) + } + if len(normalized) == 0 { + return nil + } + var missing []string + for _, branch := range normalized { + if _, err := s.branchHead(ctx, branch); err == nil { + continue + } else if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + missing = append(missing, branch) + continue + } else { + return err + } + } + if len(missing) == 0 { return nil - } else if apiErr, ok := err.(*githubAPIError); !ok || apiErr.Status != http.StatusNotFound { - return err } baseSHA, err := s.branchHead(ctx, baseBranch) if err != nil { return fmt.Errorf("read base branch %q: %w", baseBranch, err) } - if err := s.pauseBeforeMutation(ctx); err != nil { - return err - } - req := githubCreateRefRequest{ - Ref: "refs/heads/" + branch, - SHA: baseSHA, - } - status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { - if _, headErr := s.branchHead(ctx, branch); headErr == nil { - return nil + s.writeMu.Lock() + defer s.writeMu.Unlock() + for _, branch := range missing { + if err := s.pauseBeforeMutation(ctx); err != nil { + return err + } + req := githubCreateRefRequest{ + Ref: "refs/heads/" + branch, + SHA: baseSHA, + } + status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { + if _, headErr := s.branchHead(ctx, branch); headErr == nil { + continue + } } + return fmt.Errorf("create branch %q: %w", branch, err) } - return err - } - if status != http.StatusCreated { - return fmt.Errorf("github branch create returned status %d", status) + if status != http.StatusCreated { + return fmt.Errorf("create branch %q returned status %d", branch, status) + } + s.lastWrite = time.Now() } - s.lastWrite = time.Now() return nil } diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go index 38381ffa..98b86b90 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -26,21 +26,21 @@ func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { } path := exchange.PublicationEventRoot + "/replica-a/progress_digest/project/000000000001-dec-a.json" - first, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + first, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) if err != nil { t.Fatalf("put event create: %v", err) } if !first.Created || fake.puts != 1 { t.Fatalf("first put = %+v, puts=%d; want created with one PUT", first, fake.puts) } - same, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + same, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) if err != nil { t.Fatalf("put event same: %v", err) } if !same.ExistsSame || same.Conflict || fake.puts != 1 { t.Fatalf("same put = %+v puts=%d; want idempotent without PUT", same, fake.puts) } - conflict, err := store.PutEvent(context.Background(), "mnemon/agent-a", path, []byte(`{"id":"b"}`)) + conflict, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"b"}`)) if err != nil { t.Fatalf("put event conflict: %v", err) } @@ -79,8 +79,8 @@ func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { fake := newFakeGitHubPublicationAPI(t) fake.head = "head-2" - fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} - fake.files["mnemon/agent-b:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} + fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} + fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} store, err := NewPublicationStore(PublicationStoreConfig{ Repo: "mnemon-dev/mnemon-teamwork-example", BaseURL: fake.server.URL, @@ -90,14 +90,14 @@ func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { t.Fatal(err) } - list, err := store.ListEvents(context.Background(), "mnemon/agent-b", exchange.PublicationEventRoot, "") + list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "") if err != nil { t.Fatalf("list events: %v", err) } if len(list.Events) != 2 || list.NextCursor != "head-2" { t.Fatalf("list = %+v, want two events at head-2", list) } - again, err := store.ListEvents(context.Background(), "mnemon/agent-b", exchange.PublicationEventRoot, list.NextCursor) + again, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, list.NextCursor) if err != nil { t.Fatalf("list after head cursor: %v", err) } @@ -109,7 +109,7 @@ func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { fake := newFakeGitHubPublicationAPI(t) fake.refs["main"] = "main-sha" - fake.missingRefs["mnemon/acceptance/run-1/agent-a"] = true + fake.missingRefs["mnemon/mnemond-run-1-a"] = true store, err := NewPublicationStore(PublicationStoreConfig{ Repo: "mnemon-dev/mnemon-teamwork-example", BaseURL: fake.server.URL, @@ -119,16 +119,16 @@ func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testi t.Fatal(err) } - if err := store.EnsureBranch(context.Background(), "mnemon/acceptance/run-1/agent-a", "main"); err != nil { + if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { t.Fatalf("ensure branch: %v", err) } if fake.creates != 1 { t.Fatalf("creates = %d, want one branch create", fake.creates) } - if got := fake.refs["mnemon/acceptance/run-1/agent-a"]; got != "main-sha" { + if got := fake.refs["mnemon/mnemond-run-1-a"]; got != "main-sha" { t.Fatalf("created branch sha = %q, want main-sha", got) } - if err := store.EnsureBranch(context.Background(), "mnemon/acceptance/run-1/agent-a", "main"); err != nil { + if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { t.Fatalf("ensure branch again: %v", err) } if fake.creates != 1 { @@ -136,6 +136,41 @@ func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testi } } +func TestGitHubPublicationStoreEnsureBranchesReadsBaseOnce(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.refs["main"] = "main-sha" + fake.missingRefs["mnemon/mnemond-run-a"] = true + fake.missingRefs["mnemon/mnemond-run-b"] = true + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + err = store.EnsureBranches(context.Background(), []string{ + "mnemon/mnemond-run-a", + "mnemon/mnemond-run-b", + "mnemon/mnemond-run-b", + }, "main") + if err != nil { + t.Fatalf("ensure branches: %v", err) + } + if fake.creates != 2 { + t.Fatalf("creates = %d, want two branch creates", fake.creates) + } + if got := fake.refReads["main"]; got != 1 { + t.Fatalf("main ref reads = %d, want one", got) + } + for _, branch := range []string{"mnemon/mnemond-run-a", "mnemon/mnemond-run-b"} { + if got := fake.refs[branch]; got != "main-sha" { + t.Fatalf("created branch %s sha = %q, want main-sha", branch, got) + } + } +} + func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "120") @@ -153,7 +188,7 @@ func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { t.Fatal(err) } - err = store.EnsureBranch(context.Background(), "mnemon/acceptance/run/agent-a", "main") + err = store.EnsureBranch(context.Background(), "mnemon/mnemond-run-a", "main") if err == nil { t.Fatal("ensure branch should return rate-limit error") } @@ -179,7 +214,7 @@ func TestGitHubPublicationStoreLiveGated(t *testing.T) { } branch := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH")) if branch == "" { - branch = "mnemon/agent-a" + branch = "mnemon/mnemond-a" } store, err := NewPublicationStore(PublicationStoreConfig{Repo: repo, Token: token}) if err != nil { @@ -208,6 +243,7 @@ type fakeGitHubPublicationAPI struct { files map[string]fakeGitHubFile refs map[string]string missingRefs map[string]bool + refReads map[string]int head string puts int creates int @@ -225,6 +261,7 @@ func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { files: map[string]fakeGitHubFile{}, refs: map[string]string{}, missingRefs: map[string]bool{}, + refReads: map[string]int{}, head: "head-1", } fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) @@ -246,6 +283,7 @@ func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request switch { case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): branch := strings.TrimPrefix(tail, "git/ref/heads/") + f.refReads[branch]++ if f.missingRefs[branch] { writeJSON(w, http.StatusNotFound, map[string]any{"message": "ref not found"}) return From abb74696684984b541ed97d011f3b84a47c6a485 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 03:47:33 +0800 Subject: [PATCH 30/35] fix(harness): pace github mesh branch creation Set a mutative delay when the GitHub mesh acceptance runner creates run-scoped mnemond publication branches, reducing the chance that branch initialization trips GitHub secondary rate limits. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart' -count=1; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- harness/cmd/mnemon-harness/acceptance_github_mesh.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index e0cf13c2..06047e3f 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -1065,8 +1065,9 @@ func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, bra return err } store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: repo, - Token: token, + Repo: repo, + Token: token, + MutativeDelay: time.Second, }) if err != nil { return err From 89f9e61dd2658f89ea4722a898e3afb092f44cb9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 04:02:05 +0800 Subject: [PATCH 31/35] fix(harness): tolerate github mesh mnemond shutdown Use a GitHub-mesh-specific topology assertion that requires zero central mnemon-hub instances, and give lifecycle pause enough time to drain an in-flight GitHub sync request before failing the real appserver suite. Validation: go test ./harness/cmd/mnemon-harness; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- .../mnemon-harness/acceptance_github_mesh.go | 26 +++++++++++++++---- .../acceptance_github_mesh_test.go | 24 +++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 06047e3f..be4dec83 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -203,7 +203,8 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } defer stopR1CodexSyncAgents(agents) report.Topology = buildR1ProdSimTopology(agents) - addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + report.Topology.MnemonhubInstances = 0 + addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) syncReport := buildR1GitHubMeshSyncReport(opts.Repo, agents) report.Sync = syncReport @@ -275,7 +276,7 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO if len(agents) > 0 { report.LedgerCounts = countR1Ledger(agents[0].localURL, agents[0].r1CodexAgent) } - addR1Assertion(&report, "github-mesh no shared governed.db", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + addR1Assertion(&report, "github-mesh no shared governed.db", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) addR1Assertion(&report, "github-mesh report includes publication/import evidence", len(syncReport.PublishedByBranch) == opts.Agents && len(syncReport.ImportedByMnemond) == opts.Agents, fmt.Sprintf("published=%v imported=%v diagnostics=%v", syncReport.PublishedByBranch, syncReport.ImportedByMnemond, syncReport.DiagnosticsByMnemond)) if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { @@ -902,11 +903,12 @@ func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanc } target.localCancel() if target.localErr != nil { + stopTimeout := 45 * time.Second select { case <-target.localErr: - case <-time.After(5 * time.Second): - addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop") - return fmt.Errorf("%s local mnemond did not stop within timeout", target.principal) + case <-time.After(stopTimeout): + addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop after "+stopTimeout.String()) + return fmt.Errorf("%s local mnemond did not stop within %s", target.principal, stopTimeout) } } target.localCancel = nil @@ -1179,6 +1181,20 @@ func r1GitHubMeshStoreName(principal string) string { return name } +func r1GitHubMeshStrictTopology(top *r1AcceptanceTopologyReport) bool { + if top == nil || top.Mode != "per-hostagent-mnemond" || top.SharedMnemond || top.MnemonhubInstances != 0 || top.Agents < 5 || top.MnemondInstances != top.Agents { + return false + } + seen := map[string]bool{} + for _, path := range top.AgentMnemondMap { + if strings.TrimSpace(path) == "" || seen[path] { + return false + } + seen[path] = true + } + return len(seen) == top.Agents +} + func r1GitHubMeshScopes() []contract.ResourceRef { return []contract.ResourceRef{ {Kind: "agent_profile", ID: "project"}, diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 3ae10be6..799404e5 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -177,6 +177,30 @@ func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { } } +func TestR1GitHubMeshStrictTopologyRequiresNoHub(t *testing.T) { + top := &r1AcceptanceTopologyReport{ + Mode: "per-hostagent-mnemond", + Agents: 5, + MnemondInstances: 5, + MnemonhubInstances: 0, + SharedMnemond: false, + AgentMnemondMap: map[string]string{ + "a": "/tmp/a.db", + "b": "/tmp/b.db", + "c": "/tmp/c.db", + "d": "/tmp/d.db", + "e": "/tmp/e.db", + }, + } + if !r1GitHubMeshStrictTopology(top) { + t.Fatalf("github mesh topology should pass without a central hub: %+v", top) + } + top.MnemonhubInstances = 1 + if r1GitHubMeshStrictTopology(top) { + t.Fatal("github mesh topology must reject central mnemon-hub instances") + } +} + func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { report := &r1CodexAcceptanceReport{Sync: &r1CodexSyncReport{ Agents: []r1CodexAgentReport{ From 498c87f4e6a0b52382287c2637cba0178b7df9f9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 04:03:30 +0800 Subject: [PATCH 32/35] test(harness): make github mesh scenarios team-shaped Strengthen the natural GitHub mesh scenario entry prompts so the user asks for teamwork through Mnemon without prescribing exact agents or a fixed assignment graph. This keeps the acceptance natural while making the expected teamwork surface explicit enough for real appserver runs. Validation: go test ./harness/cmd/mnemon-harness -run 'TestR1GitHubMesh|TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart' -count=1; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness. --- .../github-decentralized-mesh-implementation-plan.md | 8 ++++---- harness/cmd/mnemon-harness/acceptance_github_mesh.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index 91244a14..d111715f 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -955,7 +955,7 @@ Entry: ```text PoC agent-a receives: -"帮我快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。你可以让其他成员帮忙核对架构、测试和风险。如果第一轮信息不够,请根据大家的反馈再拆一轮补齐。" +"帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再根据第一轮反馈做一次补齐或复核后汇总。" ``` Expected natural behavior: @@ -980,7 +980,7 @@ Entry: ```text PoC agent-b receives: -"同步这块我担心还有隐藏问题。你帮我检查一下 GitHub Remote Workspace 相关的配置、诊断和测试设计;如果发现顺手能补的文档或测试缺口,一起处理。第一轮先找风险,再根据结果安排第二轮验证。" +"同步这块我担心还有隐藏问题。请用团队协作检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。" ``` Optional earlier/open task: @@ -1012,10 +1012,10 @@ Entry: ```text PoC agent-a receives: -"请你推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让大家找出缺口,再根据第一轮结果安排第二轮补齐。" +"请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。" PoC agent-c, in a separate normal conversation, receives: -"我主要担心这个 GitHub 方案的操作者安全和失败诊断。你帮我从 token、repo、branch、报错可读性这几个角度检查一下。" +"我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。" ``` Expected natural behavior: diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index be4dec83..69c18fdc 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -585,13 +585,13 @@ func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEn } switch name { case "onboarding-synthesis": - return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。你可以让其他成员帮忙核对架构、测试和风险。如果第一轮信息不够,请根据大家的反馈再拆一轮补齐。`}}, nil + return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再根据第一轮反馈做一次补齐或复核后汇总。`}}, nil case "sync-risk-review": - return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。你帮我检查一下 GitHub Remote Workspace 相关的配置、诊断和测试设计;如果发现顺手能补的文档或测试缺口,一起处理。第一轮先找风险,再根据结果安排第二轮验证。`}}, nil + return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请用团队协作检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil case "live-readiness-operator-safety": return []r1GitHubMeshScenarioEntry{ - {index: 0, prompt: `请你推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让大家找出缺口,再根据第一轮结果安排第二轮补齐。`}, - {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。你帮我从 token、repo、branch、报错可读性这几个角度检查一下。`}, + {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, + {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, }, nil default: return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) From ba79426c968cee5763c9d175e03ba0b1f0d96330 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 05:44:05 +0800 Subject: [PATCH 33/35] test(harness): advance github mesh natural handoff Let the GitHub mesh natural suite continue once the entry PoC publishes governed assignments, then wake workers, exercise delayed join and lifecycle, and count authored evidence from each local mnemond instead of hub-only cross events. Validation: go test ./harness/cmd/mnemon-harness -count=1; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app -count=1; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness; live onboarding smoke passed at /tmp/mnemon-github-mesh-onboarding-20260625T211127Z/report.json. --- ...-decentralized-mesh-implementation-plan.md | 8 +- .../mnemon-harness/acceptance_github_mesh.go | 315 +++++++++++++++++- .../acceptance_github_mesh_test.go | 99 ++++++ 3 files changed, 408 insertions(+), 14 deletions(-) diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md index d111715f..234bd01d 100644 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md @@ -955,7 +955,7 @@ Entry: ```text PoC agent-a receives: -"帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再根据第一轮反馈做一次补齐或复核后汇总。" +"帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。" ``` Expected natural behavior: @@ -980,7 +980,7 @@ Entry: ```text PoC agent-b receives: -"同步这块我担心还有隐藏问题。请用团队协作检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。" +"同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。" ``` Optional earlier/open task: @@ -1012,10 +1012,10 @@ Entry: ```text PoC agent-a receives: -"请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。" +"请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。" PoC agent-c, in a separate normal conversation, receives: -"我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。" +"我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。" ``` Expected natural behavior: diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index 69c18fdc..c7ea0406 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "encoding/json" "fmt" "io" @@ -12,6 +13,7 @@ import ( "time" "github.com/mnemon-dev/mnemon/harness/internal/app" + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" @@ -268,7 +270,14 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO if obsErr == nil { report.Observability = &obs populateR1GitHubMeshSyncEvidence(&report, obs) - report.Participants = r1ClusterParticipants(r1ClusterActorEventCounts(obs), report.Entrypoint) + counts, warnings := r1GitHubMeshAuthoredEventCounts(agents) + if len(counts) == 0 { + counts = r1ClusterActorEventCounts(obs) + } + if len(warnings) > 0 { + report.Observability.Warnings = append(report.Observability.Warnings, warnings...) + } + report.Participants = r1ClusterParticipants(counts, report.Entrypoint) } else { addR1Error(&report, obsErr) } @@ -457,6 +466,7 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { return err } var actors []string + var entryTurns []*r1GitHubMeshEntryTurn for _, entry := range entries { agent := &s.agents[entry.index] actors = append(actors, agent.principal) @@ -467,19 +477,56 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { s.report.Sync.Source = agent.principal } recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "natural_user_message:"+name, entry.prompt) - answer, err := runR1Turn(&agent.r1CodexAgent, entry.prompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + turn, err := startR1GitHubMeshEntryTurn(agent, entry.index, entry.prompt, s.opts.TurnTimeout) if err != nil { addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, false, err.Error()) return err } - addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, true, truncateR1Cluster(answer, 300)) + entryTurns = append(entryTurns, turn) + } + seedTimeout := r1GitHubMeshEntrySeedTimeout(s.opts.TurnTimeout) + for _, turn := range entryTurns { + agent := &s.agents[turn.index] + turn.counts, turn.seeded = waitR1GitHubMeshEntrySeed(agent, seedTimeout) + if res, ok := turn.poll(); ok { + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil && !turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + return res.Err + } + } + if !turn.seeded { + err := fmt.Errorf("%s did not publish governed seed events within %s", turn.principal, seedTimeout) + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", err, turn.counts)) + return err + } + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, true, fmt.Sprintf("seeded governed teamwork events counts=%v", turn.counts)) } if err := s.wakeWorkers(name, entries); err != nil { return err } priorScenarios := r1GitHubMeshOKScenarioNames(s.report.Scenarios) - lead := &s.agents[entries[0].index] + busyEntries := map[int]bool{} + for _, turn := range entryTurns { + if res, ok := turn.poll(); ok { + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" yielded governed seed before timeout", turn.seeded, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + if turn.seeded { + if err := s.restartAgentAppserver(turn.index, name, "entry turn timed out after publishing governed seed events"); err != nil { + return err + } + } + } + continue + } + busyEntries[turn.index] = true + } + leadIndex := r1GitHubMeshIntegrationAgentIndex(s.agents, entries, busyEntries) + if leadIndex < 0 { + return fmt.Errorf("github mesh scenario %s has no idle integration agent", name) + } + lead := &s.agents[leadIndex] integrationPrompt := r1GitHubMeshIntegrationPrompt(name) s.report.RunnerContract.IntegrationPrompts++ recordR1ClusterPrompt(s.report.RunnerContract, lead.principal, "integration:"+name, integrationPrompt) @@ -489,12 +536,31 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { addR1Assertion(s.report, "github-mesh "+name+" integration", false, err.Error()) return err } + for _, turn := range entryTurns { + if turn.pollReady() { + res := turn.wait() + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil && turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" completed team handoff despite timeout", true, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + } + continue + } + if turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" handed off while still running", true, fmt.Sprintf("counts=%v", turn.counts)) + if err := s.restartAgentAppserver(turn.index, name, "entry turn still running after team integration"); err != nil { + return err + } + } + } waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 15*time.Second, 2*time.Second) obs, err := observeAcceptanceRun(s.report.RunRoot, 1000) if err != nil { return err } - counts := r1ClusterActorEventCounts(obs) + counts, countWarnings := r1GitHubMeshAuthoredEventCounts(s.agents) + if len(counts) == 0 { + counts = r1ClusterActorEventCounts(obs) + } participants := r1ClusterNonProfileParticipantCount(counts) replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) @@ -523,6 +589,7 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { "worker_wake_prompts": workerWakes, "integration_prompts": integrationPrompts, "entry_poc_agents": actors, + "integration_agent": lead.principal, "multi_poc": len(actors) > 1, "prior_ok_scenarios": priorScenarios, "cross_task_reuse_or_completion": r1GitHubMeshCrossTaskReuseCandidate(name, priorScenarios), @@ -537,6 +604,9 @@ func (s *r1GitHubMeshRun) runScenario(name string) error { "project_intent_events": intents, }, }) + if len(countWarnings) > 0 { + s.report.Scenarios[len(s.report.Scenarios)-1].Evidence["actor_event_count_warnings"] = countWarnings + } if !passed { return fmt.Errorf("github mesh scenario %s did not produce team-shaped multi-round evidence", name) } @@ -579,19 +649,187 @@ type r1GitHubMeshScenarioEntry struct { prompt string } +type r1GitHubMeshTurnResult struct { + Answer string + Err error +} + +type r1GitHubMeshEntryTurn struct { + index int + principal string + before int + done chan r1GitHubMeshTurnResult + result *r1GitHubMeshTurnResult + reported bool + seeded bool + counts map[string]int +} + +func startR1GitHubMeshEntryTurn(agent *r1CodexSyncAgent, index int, prompt string, timeout time.Duration) (*r1GitHubMeshEntryTurn, error) { + server := agent.server + before := server.NotificationCount() + if _, err := server.Request("turn/start", map[string]any{ + "threadId": agent.threadID, + "input": []map[string]any{{"type": "text", "text": prompt}}, + "cwd": agent.workspace, + "approvalPolicy": "never", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + }, 30*time.Second); err != nil { + return nil, fmt.Errorf("%s: turn/start: %w", agent.principal, err) + } + turn := &r1GitHubMeshEntryTurn{ + index: index, + principal: agent.principal, + before: before, + done: make(chan r1GitHubMeshTurnResult, 1), + } + go func() { + if _, err := server.WaitNotification("turn/completed", timeout, before); err != nil { + text := codexapp.CombinedText(server.NotificationsSince(before)) + turn.done <- r1GitHubMeshTurnResult{ + Answer: truncateR1Cluster(text, 2000), + Err: fmt.Errorf("%s: wait turn/completed: %w", agent.principal, err), + } + return + } + notifications := server.NotificationsSince(before) + answer := codexapp.FinalAnswer(notifications) + if answer == "" { + answer = codexapp.CombinedText(notifications) + } + turn.done <- r1GitHubMeshTurnResult{Answer: answer} + }() + return turn, nil +} + +func (t *r1GitHubMeshEntryTurn) poll() (r1GitHubMeshTurnResult, bool) { + if t == nil { + return r1GitHubMeshTurnResult{}, false + } + if t.result != nil { + return *t.result, true + } + select { + case res := <-t.done: + t.result = &res + return res, true + default: + return r1GitHubMeshTurnResult{}, false + } +} + +func (t *r1GitHubMeshEntryTurn) pollReady() bool { + _, ok := t.poll() + return ok +} + +func (t *r1GitHubMeshEntryTurn) wait() r1GitHubMeshTurnResult { + if res, ok := t.poll(); ok { + return res + } + res := <-t.done + t.result = &res + return res +} + +func appendR1GitHubMeshEntryTurnAnswer(report *r1CodexSyncReport, turn *r1GitHubMeshEntryTurn, res r1GitHubMeshTurnResult) { + if report == nil || turn == nil || turn.reported { + return + } + appendSyncAgentAnswer(report, turn.principal, res.Answer) + turn.reported = true +} + +func r1GitHubMeshEntrySeedTimeout(turnTimeout time.Duration) time.Duration { + if turnTimeout <= 0 { + return 5 * time.Minute + } + return turnTimeout +} + +func waitR1GitHubMeshEntrySeed(agent *r1CodexSyncAgent, timeout time.Duration) (map[string]int, bool) { + deadline := time.Now().Add(timeout) + var counts map[string]int + for time.Now().Before(deadline) { + counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) + if r1GitHubMeshEntrySeedReady(counts) { + return counts, true + } + time.Sleep(500 * time.Millisecond) + } + counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) + return counts, r1GitHubMeshEntrySeedReady(counts) +} + +func r1GitHubMeshEntrySeedReady(counts map[string]int) bool { + return counts["assignment"] >= 1 +} + +func r1GitHubMeshIntegrationAgentIndex(agents []r1CodexSyncAgent, entries []r1GitHubMeshScenarioEntry, busy map[int]bool) int { + if len(entries) > 0 { + idx := entries[0].index + if idx >= 0 && idx < len(agents) && !busy[idx] && r1GitHubMeshAgentReady(agents[idx]) { + return idx + } + } + for i := range agents { + if busy[i] || !r1GitHubMeshAgentReady(agents[i]) { + continue + } + return i + } + return -1 +} + +func (s *r1GitHubMeshRun) restartAgentAppserver(index int, scenario, reason string) error { + if index < 0 || index >= len(s.agents) { + return fmt.Errorf("github mesh restart index out of range: %d", index) + } + agent := &s.agents[index] + if agent.server != nil { + agent.server.Close() + agent.server = nil + } + if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) + return err + } + agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) + if err != nil { + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) + return err + } + if raw != nil && s.report.Raw != nil { + s.report.Raw[agent.principal+":hooks:restart:"+scenario] = raw + } + if s.report.Sync != nil { + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "appserver_restart_after_entry_handoff", + Result: "ready", + Branch: s.report.Sync.BranchByAgent[agent.principal], + Detail: reason, + }) + appendSyncAgentAnswer(s.report.Sync, agent.principal, "restarted thread "+agentReport.ThreadID+" after "+reason) + } + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, true, reason) + return nil +} + func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { if len(s.agents) < 5 { return nil, fmt.Errorf("github mesh natural scenarios require five agents") } switch name { case "onboarding-synthesis": - return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再根据第一轮反馈做一次补齐或复核后汇总。`}}, nil + return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。`}}, nil case "sync-risk-review": - return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请用团队协作检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil + return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil case "live-readiness-operator-safety": return []r1GitHubMeshScenarioEntry{ - {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。先让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, - {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, + {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, + {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, }, nil default: return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) @@ -629,6 +867,12 @@ func (s *r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenari } s.lifecycleExercised = true } + if cycle >= 2 { + counts, _ := r1GitHubMeshAuthoredEventCounts(s.agents) + if r1GitHubMeshTeamEvidenceCountsReady(counts) { + return nil + } + } } return nil } @@ -792,6 +1036,57 @@ func r1GitHubMeshKindTotal(counts map[string]map[string]int, kind string) int { return total } +func r1GitHubMeshTeamEvidenceCountsReady(counts map[string]map[string]int) bool { + return r1ClusterNonProfileParticipantCount(counts) >= 2 && + r1GitHubMeshKindTotal(counts, "assignment") >= 1 && + r1GitHubMeshKindTotal(counts, "progress_digest") >= 1 +} + +func r1GitHubMeshAuthoredEventCounts(agents []r1CodexSyncAgent) (map[string]map[string]int, []string) { + out := map[string]map[string]int{} + var warnings []string + for _, agent := range agents { + path := prodSimMnemondPath(agent) + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)") + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s open authored event counts: %v", agent.principal, err)) + continue + } + func() { + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, ` +SELECT actor, resource_kind, COUNT(*) +FROM sync_events +WHERE actor <> '' +GROUP BY actor, resource_kind +ORDER BY actor, resource_kind`) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s query authored event counts: %v", agent.principal, err)) + return + } + defer rows.Close() + for rows.Next() { + var actor, kind string + var count int + if err := rows.Scan(&actor, &kind, &count); err != nil { + warnings = append(warnings, fmt.Sprintf("%s scan authored event counts: %v", agent.principal, err)) + return + } + if out[actor] == nil { + out[actor] = map[string]int{} + } + out[actor][kind] += count + } + if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("%s read authored event counts: %v", agent.principal, err)) + } + }() + } + return out, warnings +} + func r1GitHubMeshLedgerCountsByAgent(agents []r1CodexSyncAgent, kind string) map[string]int { out := make(map[string]int, len(agents)) for _, agent := range agents { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 799404e5..66888bf8 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -2,12 +2,14 @@ package main import ( "context" + "database/sql" "os" "path/filepath" "strings" "testing" "time" + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -342,6 +344,45 @@ func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { } } +func TestR1GitHubMeshEntrySeedTimeoutIsBounded(t *testing.T) { + if got := r1GitHubMeshEntrySeedTimeout(8 * time.Minute); got != 8*time.Minute { + t.Fatalf("seed timeout = %s, want full turn timeout", got) + } + if got := r1GitHubMeshEntrySeedTimeout(2 * time.Minute); got != 2*time.Minute { + t.Fatalf("short seed timeout = %s, want full short turn timeout", got) + } + if got := r1GitHubMeshEntrySeedTimeout(0); got != 5*time.Minute { + t.Fatalf("default seed timeout = %s, want 5m", got) + } +} + +func TestR1GitHubMeshEntrySeedReadyUsesAssignment(t *testing.T) { + if !r1GitHubMeshEntrySeedReady(map[string]int{"assignment": 1}) { + t.Fatal("assignment should be enough to seed worker handoff") + } + if r1GitHubMeshEntrySeedReady(map[string]int{"teamwork_signal": 1}) { + t.Fatal("signal without assignment should not seed worker handoff") + } +} + +func TestR1GitHubMeshIntegrationAgentSkipsBusyEntrypoint(t *testing.T) { + agents := []r1CodexSyncAgent{ + {r1CodexAgent: r1CodexAgent{principal: "codex-01@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-1"}, localCancel: func() {}}, + {r1CodexAgent: r1CodexAgent{principal: "codex-02@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-2"}, localCancel: func() {}}, + {r1CodexAgent: r1CodexAgent{principal: "codex-03@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-3"}, localCancel: func() {}}, + } + entries := []r1GitHubMeshScenarioEntry{{index: 0}} + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, nil); got != 0 { + t.Fatalf("integration agent = %d, want entrypoint when idle", got) + } + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true}); got != 1 { + t.Fatalf("integration agent = %d, want first idle teammate", got) + } + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true, 1: true, 2: true}); got != -1 { + t.Fatalf("integration agent = %d, want none when all ready agents are busy", got) + } +} + func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { counts := map[string]map[string]int{ "codex-01@project": {"assignment": 2, "progress_digest": 1}, @@ -357,3 +398,61 @@ func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { t.Fatalf("missing kind total = %d, want 0", got) } } + +func TestR1GitHubMeshTeamEvidenceCountsReady(t *testing.T) { + counts := map[string]map[string]int{ + "codex-01@project": {"assignment": 2}, + "codex-02@project": {"progress_digest": 1}, + "codex-03@project": {"progress_digest": 1}, + } + if !r1GitHubMeshTeamEvidenceCountsReady(counts) { + t.Fatalf("team evidence should be ready for two non-profile participants: %+v", counts) + } + counts = map[string]map[string]int{ + "codex-01@project": {"assignment": 2}, + "codex-02@project": {"agent_profile": 1}, + } + if r1GitHubMeshTeamEvidenceCountsReady(counts) { + t.Fatalf("team evidence should require progress beyond assignment publication: %+v", counts) + } +} + +func TestR1GitHubMeshAuthoredEventCountsUseLocalSyncEvents(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspaces", "codex-01") + storePath := filepath.Join(workspace, runtime.DefaultStorePath) + if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", storePath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE sync_events(actor TEXT, resource_kind TEXT, status TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO sync_events(actor, resource_kind, status) VALUES + ('codex-01@project', 'assignment', 'synced'), + ('codex-01@project', 'assignment', 'pending'), + ('codex-02@project', 'progress_digest', 'synced'), + ('', 'progress_digest', 'synced')`); err != nil { + t.Fatal(err) + } + + counts, warnings := r1GitHubMeshAuthoredEventCounts([]r1CodexSyncAgent{{ + r1CodexAgent: r1CodexAgent{principal: "codex-01@project", workspace: workspace}, + }}) + if len(warnings) != 0 { + t.Fatalf("warnings = %v", warnings) + } + if got := counts["codex-01@project"]["assignment"]; got != 2 { + t.Fatalf("codex-01 assignment count = %d, want 2", got) + } + if got := counts["codex-02@project"]["progress_digest"]; got != 1 { + t.Fatalf("codex-02 progress count = %d, want 1", got) + } + if _, ok := counts[""]; ok { + t.Fatal("blank actors must not be counted") + } +} From 2572c3b232cbc8ed2c0eb2552f86e0fd72e63940 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 06:31:21 +0800 Subject: [PATCH 34/35] test(harness): scale github mesh profile convergence Make delayed join profile convergence wait according to the GitHub sync interval so late joining mnemond instances are not failed before the next publication pull can arrive. Validation: go test ./harness/cmd/mnemon-harness -count=1; go test ./harness/internal/mnemonhub/exchange ./harness/internal/mnemonhub/exchange/backend/github ./harness/internal/app -count=1; go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness; live sync-risk-review smoke passed at /tmp/mnemon-github-mesh-sync-risk-20260625T215846Z/report.json. --- .../mnemon-harness/acceptance_github_mesh.go | 17 ++++++++++++++++- .../acceptance_github_mesh_test.go | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go index c7ea0406..5f313645 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -943,9 +943,10 @@ func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { return nil } allVisible := true + convergeTimeout := r1GitHubMeshProfileConvergenceTimeout(s.opts.SyncInterval) for _, i := range r1GitHubMeshReadyAgentIndexes(s.agents) { agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), 120*time.Second) + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), convergeTimeout) counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) profileCounts[agent.principal] = counts["agent_profile"] if counts["agent_profile"] < len(s.agents) { @@ -971,6 +972,20 @@ func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { return nil } +func r1GitHubMeshProfileConvergenceTimeout(syncInterval time.Duration) time.Duration { + if syncInterval <= 0 { + return 120 * time.Second + } + timeout := syncInterval*4 + 30*time.Second + if timeout < 120*time.Second { + return 120 * time.Second + } + if timeout > 6*time.Minute { + return 6 * time.Minute + } + return timeout +} + func (s *r1GitHubMeshRun) emitJoinedProfile(agent *r1CodexSyncAgent, scenario string) error { payload := taskSimJSON(map[string]any{ "actor": agent.principal, diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go index 66888bf8..da79fb1a 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -365,6 +365,18 @@ func TestR1GitHubMeshEntrySeedReadyUsesAssignment(t *testing.T) { } } +func TestR1GitHubMeshProfileConvergenceTimeoutScalesWithSyncInterval(t *testing.T) { + if got := r1GitHubMeshProfileConvergenceTimeout(30 * time.Second); got != 150*time.Second { + t.Fatalf("30s sync convergence timeout = %s, want 150s", got) + } + if got := r1GitHubMeshProfileConvergenceTimeout(90 * time.Second); got != 6*time.Minute { + t.Fatalf("90s sync convergence timeout = %s, want capped 6m", got) + } + if got := r1GitHubMeshProfileConvergenceTimeout(0); got != 120*time.Second { + t.Fatalf("default convergence timeout = %s, want 120s", got) + } +} + func TestR1GitHubMeshIntegrationAgentSkipsBusyEntrypoint(t *testing.T) { agents := []r1CodexSyncAgent{ {r1CodexAgent: r1CodexAgent{principal: "codex-01@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-1"}, localCancel: func() {}}, From dab3695b4609041f998d52b6a3abeb41b143f303 Mon Sep 17 00:00:00 2001 From: Grivn Date: Fri, 26 Jun 2026 09:00:50 +0800 Subject: [PATCH 35/35] chore(harness): keep mnemon dev notes local Remove the previously forced .mnemon-dev architecture notes from the branch while leaving the ignored local files in place. The implementation and tests remain under harness and docs/harness. --- ...-decentralized-mesh-implementation-plan.md | 1138 -------------- .../github-remote-workspace-backend.md | 1394 ----------------- 2 files changed, 2532 deletions(-) delete mode 100644 .mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md delete mode 100644 .mnemon-dev/architecture/github-remote-workspace-backend.md diff --git a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md b/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md deleted file mode 100644 index 234bd01d..00000000 --- a/.mnemon-dev/architecture/github-decentralized-mesh-implementation-plan.md +++ /dev/null @@ -1,1138 +0,0 @@ -# GitHub Decentralized Mesh Implementation Plan - -> 日期:2026-06-26 -> 类型:实现计划 / 可转换为 `/goal` 的执行设计 -> 架构依据:[github-remote-workspace-backend.md](github-remote-workspace-backend.md) -> 默认 live repo:`mnemon-dev/mnemon-teamwork-example` -> 状态:D1-D7 已讨论并同意,本文件按已同意边界重新收束 - -## 0. 已锁定决策 - -本计划不再把以下内容作为开放分歧处理: - -```text -D1 GitHub direct first; no GitHub App in first implementation. -D2 Default topology is one shared repo + one branch per mnemond. -D3 Data model can express multiple publish targets, but MVP permits only one active publish target. -D4 Validation repo is evidence surface only, never mnemond governance input. -D5 Real GitHub adapter must be validated promptly; milestone cannot finish with fake store only. -D6 Branch identity uses mnemond_id, not hostagent display name. -D7 GitHub backend is repo-mediated publication, not P2P networking. -``` - -Implication: - -```text -GitHub mesh means repo-mediated publication streams. -It does not mean mnemond-to-mnemond networking. -``` - -## 1. 目标与完成定义 - -实现一个可验证的 **GitHub-backed decentralized Remote Workspace foundation**: - -```text -5 hostagents -5 mnemond -1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example -5 per-mnemond publication branches -0 shared governed.db -0 central active mnemon-hub -``` - -核心证明: - -```text -accepted synced event envelopes can propagate through repo-mediated publication streams; -each receiving mnemond validates and imports through Event Intake -> Tick -> Materializer; -teamwork can continue through assignment, nested decomposition, join, leave, reassignment, aggregation, and next act. -``` - -Foundation done means all of the following are true: - -- Existing HTTP `mnemon-hub` sync behavior remains compatible. -- `RemoteEntry` supports backend and direction without breaking old remotes. -- GitHub concepts do not enter `runtime`, `state`, `materializer`, `presentation`, or `hostagent`. -- Every connected appserver has its own `mnemond`, own local store, and isolated runtime workspace. -- Pull-side diagnostics from invalid remote publication entries become durable local diagnostics. -- GitHub backend is implemented behind the `exchange.RemoteWorkspace` ABI. -- Fake-store unit tests cover deterministic publication behavior. -- A gated live GitHub case passes against `mnemon-dev/mnemon-teamwork-example`. -- Deterministic 5-mnemond local acceptance passes without a central active `mnemon-hub`. -- Real Codex appserver acceptance has a runnable script, evidence format, and natural task suite. -- Acceptance includes the 5-node teamwork case plus at least 2-3 natural user-task scenarios. -- Each real task starts from one or more connected PoC agents receiving ordinary user messages, not from a global orchestration prompt. -- At least one real task demonstrates multiple Teamwork-ReAct rounds: output review -> replanning -> reassignment -> execution -> aggregation -> next act. - -### 1.1 Implementation checkpoint - 2026-06-26 - -Current branch progress: - -- `exchange.RemoteWorkspace` seam exists and both HTTP and GitHub backends use it. -- `RemoteEntry` now carries `backend`, `direction`, `repo`, and `branch`; legacy HTTP defaults remain compatible. -- Directional `publish` / `subscribe` remote plans are implemented. -- Pull-side remote diagnostics are imported as durable governed diagnostics. -- `PublicationStore` seam and deterministic memory store exist. -- GitHub publication backend is fake-store tested. -- Real GitHub `PublicationStore` adapter exists. -- GitHub Remote Workspace normalizes opaque GitHub branch-head cursors before writing local mnemond pull state. -- Gated live publish/pull/import test exists and is skipped unless live credentials are provided. -- Gated live publish/pull/import passed against `mnemon-dev/mnemon-teamwork-example` on 2026-06-26 with operator-created publication branches; the current branch contract names branches after `mnemond_id`, for example `mnemon/mnemond-a`. -- Deterministic local GitHub mesh tests cover: - - five isolated mnemond runtimes; - - one branch per mnemond; - - no central active `mnemon-hub`; - - two later joining nodes; - - one paused node; - - active-node reassignment; - - paused-node catch-up through publication branches. -- Real Codex appserver acceptance runner exists as: - -```bash -mnemon-harness acceptance r1-github-mesh-task-suite \ - --agents 5 \ - --agent-turns \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-token-file \ - --sync-interval 30s -``` - -When `--github-branch-prefix` is omitted, the runner must create run-scoped publication branches by embedding the run id into the temporary `mnemond_id`: - -```text -mnemon/mnemond--a -mnemon/mnemond--b -... -``` - -Fixed mnemond branches such as `mnemon/mnemond-a` through `mnemon/mnemond-e` are valid for manual operator smoke tests, but not the default real appserver acceptance path because historical publication entries can pollute a fresh run. - -Real runner lifecycle now treats join as delayed activation of preconfigured publication streams: - -```text -configure 5 workspaces/remotes/branches up front -start 3 mnemond/appservers for the first natural task round -start the remaining 2 mnemond/appservers during the task -publish fresh joined agent_profile events -verify profiles converge through configured publication branches -pause/restart one already joined local mnemond during the task -``` - -This is intentionally not branch discovery, P2P node discovery, or dynamic networking. The branch list and `remotes.json` remain configured bootstrap inventory; the lifecycle evidence proves delayed availability and backlog import against those configured streams. - -Known open evidence: - -- The real Codex appserver GitHub mesh suite is runnable, but still requires a GitHub token file and a usable Codex appserver environment. -- Until the real appserver run passes, this goal is not closed. - -## 2. Non-goals and invariants - -Non-goals: - -- Do not implement GitHub Issue/PR/Actions collaboration. -- Do not let GitHub become canonical governed state. -- Do not implement strong cross-trust-domain security in this milestone. -- Do not implement direct mnemond-to-mnemond transport. -- Do not implement P2P networking, gossip, DHT, peer routing, NAT traversal, or overlay network. -- Do not call branch enumeration a node discovery protocol. -- Do not claim multi-publish reliability before per-target sync ledger exists. - -Hard invariants: - -```text -Remote Workspace transports accepted synced envelopes. -Local mnemond alone imports through Event Intake -> Tick -> Materializer. -GitHub backend talks only to configured GitHub Remote Workspace. -Branch presence is publication inventory, not liveness or authority. -Validation reports are evidence, not runtime input. -``` - -Package boundary invariant: - -```text -runtime/state/materializer/presentation/hostagent - must not import GitHub backend packages. - -GitHub code must stay below exchange/backend boundary. -``` - -## 3. Current baseline - -Already implemented first cut: - -```text -exchange.RemoteWorkspace interface - SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) - SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) - SyncStatus() (contract.SyncStatusResponse, error) -``` - -Current HTTP `access.Client` can satisfy the interface. `remotes.json` has a `backend` field start: - -```text -empty backend -> http -unknown backend -> fail closed -``` - -Known unrelated validation caveat: - -```text -go test ./harness/cmd/mnemon-harness -``` - -may still hit the macOS `/var` vs `/private/var` acceptance run-root issue until fixed separately. - -## 4. Target topology - -Runtime topology: - -```text - configured GitHub Remote Workspace - repo: mnemon-dev/mnemon-teamwork-example - +------------------------------------------------+ - | branch mnemon/ accepted log | - | branch mnemon/ accepted log | - | branch mnemon/ accepted log | - | branch mnemon/ accepted log | - | branch mnemon/ accepted log | - +------------------------------------------------+ - ^ ^ ^ - | | | - push own stream pull subscribed pull subscribed - | | | - +----+----+ +----+----+ +----+----+ - | mnemond | | mnemond | | mnemond | - | local | | local | | local | - | store | | store | | store | - +----+----+ +----+----+ +----+----+ - ^ ^ ^ - | | | - hostagent hostagent hostagent -``` - -There is no: - -```text -mnemond -> mnemond socket -gossip channel -routing table -overlay network -GitHub Issue/PR assignment queue -``` - -Data flow: - -```text -hostagent emits event - -> local mnemond accepts event - -> local sync material marks accepted synced envelope - -> GitHub backend publishes to own branch - -> subscribed mnemond pull publication streams - -> pull validates digest/scope/phase/idempotency - -> valid envelopes enter local Event Intake - -> invalid envelopes create durable diagnostics - -> Tick -> Materializer derives local views/cues -``` - -## 5. Remote model contract - -`RemoteEntry` target model: - -```text -RemoteEntry - id - backend: http | github - direction: bidirectional | publish | subscribe - endpoint # http only - repo # github only, e.g. mnemon-dev/mnemon-teamwork-example - branch # github only, e.g. mnemon/ - credential_ref - ca_file # http only - scopes -``` - -Direction semantics: - -```text -bidirectional -> push target + pull source -publish -> push target only -subscribe -> pull source only -``` - -Compatibility: - -```text -empty backend -> http -empty direction -> bidirectional -legacy HTTP remotes preserve current behavior -``` - -MVP restriction: - -```text -At most one active publish target. -Multiple subscribe sources are allowed if cursor/import idempotency is proven. -``` - -Reason:the current sync ledger mostly tracks synced/pending/conflict globally. Multiple push targets need a per-target ledger before reliability can be claimed. - -## 6. GitHub repo contract - -Default repository: - -```text -mnemon-dev/mnemon-teamwork-example -``` - -Branch namespace: - -```text -mnemon/team -mnemon/ -``` - -Recommended layout: - -```text -mnemon/team - .mnemon/team.json - .mnemon/scenarios/.json - .mnemon/reports//summary.json - -mnemon/ - mnemon-publications/v1/manifest.json - mnemon-publications/v1/events////-.json -``` - -Contract: - -- `mnemon/team` stores bootstrap metadata and reports only. -- `mnemon/` stores accepted-event publication entries only. -- A `mnemond` writes only its own branch. -- A `mnemond` reads configured/subscribed publication branches. -- Branch enumeration is publication stream enumeration inside a configured Remote Workspace. -- Branch presence is not liveness, membership authority, permission authority, or scheduling input. -- Reports are never imported as governed events. -- The validation repo may be deleted/recreated without changing local governance semantics. - -Team manifest shape: - -```json -{ - "schema_version": 1, - "team_id": "mnemon-teamwork-example", - "members": [ - { - "mnemond_id": "mnemond-a", - "branch": "mnemon/mnemond-a", - "principal": "codex-a@project" - } - ] -} -``` - -Interpretation: - -```text -members = configured publication stream inventory -members != canonical team state -members != permission grant -members != online status -``` - -## 7. PublicationStore contract - -The GitHub backend must be testable without real GitHub. Introduce a storage seam below `exchange.RemoteWorkspace`: - -```go -type PublicationStore interface { - PutEvent(ctx context.Context, branch string, path string, body []byte) (PutResult, error) - ListEvents(ctx context.Context, branch string, prefix string, cursor string) (ListResult, error) - ReadFile(ctx context.Context, branch string, path string) ([]byte, error) - WriteFile(ctx context.Context, branch string, path string, body []byte) error -} - -type PutResult struct { - Created bool - ExistsSame bool - Conflict bool -} - -type StoredEvent struct { - Path string - Body []byte - Cursor string -} -``` - -Event path: - -```text -mnemon-publications/v1/events////-.json -``` - -`local_ingest_seq` is zero-padded to 12 digits. The path is intentionally visible and maintainable in GitHub, for example: - -```text -mnemon-publications/v1/events/mnemond-a/progress_digest/project/000000000007-dec-a.json -``` - -Put semantics: - -```text -same path + same body -> idempotent success -same path + different body -> conflict diagnostic -new path -> created -unsupported branch/path -> fail closed -``` - -List/cursor semantics for MVP: - -```text -Fake store: - deterministic ordered cursor. - -GitHub store: - cursor may encode last fully scanned branch head. - if branch head is unchanged, return no new entries. - if branch head changed, list current event tree and let local import idempotency skip duplicates. -``` - -This is intentionally conservative. It avoids claiming perfect append-only incremental listing before a per-branch/per-target ledger exists. - -## 8. Phase dependency graph - -```text -P0 guardrails - -> P1 RemotePlan - -> P2 diagnostics - -> P3 PublicationStore - -> P4 fake-tested GitHub backend - -> P5 repo contract + operator config - -> P6 real GitHub adapter + live case - -> P7 deterministic 5-mnemond acceptance - -> P8 real Codex appserver acceptance - -> P9 docs + goal closure -``` - -P5 repo contract is listed before P6 because the live case must know exactly which repo, branches, paths, and reports are valid. - -## 9. P0: Concept guardrails - -Purpose:make concept boundaries executable. - -Scope: - -- Add/extend coreguard tests. -- Forbid GitHub backend imports from `runtime`, `state`, `materializer`, `presentation`, `hostagent`. -- Forbid GitHub Issue/PR/Action as teamwork semantic names. -- Forbid P2P discovery/gossip/routing/overlay terms in backend implementation names. -- Enforce `publication stream enumeration` terminology for branch listing. - -Outputs: - -- Coreguard test file or extension. -- Package allowlist for GitHub backend. -- Naming denylist for GitHub-native teamwork concepts. - -Validation: - -```text -go test ./harness/internal/coreguard -``` - -Done when: - -- Core dependency graph contains no GitHub backend import. -- Failing examples catch at least one forbidden import/name case. - -## 10. P1: Directional RemotePlan - -Purpose:separate push targets from pull sources. - -Scope: - -- Add `direction` to `RemoteEntry`. -- Add `RemotePlan{PushTargets, PullSources}`. -- Preserve legacy HTTP behavior. -- Worker/manual sync use RemotePlan instead of single remote. -- Enforce MVP one active publish target. - -Tests: - -- legacy remote -> one push target and one pull source. -- `direction=publish` -> push only. -- `direction=subscribe` -> pull only. -- unknown direction fail-closed. -- multiple publish targets fail-closed or return explicit unsupported error. -- worker idle/no remote behavior unchanged. - -Validation: - -```text -go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemote' -go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness -``` - -Done when: - -- Existing HTTP connect/push/pull tests pass. -- Config output includes backend/direction without breaking old remotes. - -## 11. P2: Pull diagnostics ingestion - -Purpose:make pull-side rejection visible and durable. - -Why now:GitHub direct has no server-side push clamp. Invalid entries must not silently disappear. - -Scope: - -- Consume `SyncPullResponse.Diagnostics`. -- Add `sync.remote_diagnostic.observed` or equivalent durable event. -- Ensure diagnostic import is idempotent. -- Keep HTTP no-diagnostics path unchanged. - -Payload shape: - -```json -{ - "remote_id": "...", - "origin_mnemond": "...", - "event_id": "...", - "subject": "...", - "status": "rejected|conflict|invalid", - "diagnostic": "..." -} -``` - -Tests: - -- fake remote returns diagnostics. -- local event log contains durable diagnostic. -- repeated pull does not duplicate diagnostic. -- valid event import remains unchanged. - -Validation: - -```text -go test ./harness/internal/app -run 'TestSync|TestDiagnostic' -``` - -Done when: - -- Invalid remote publication entries produce visible local diagnostics. -- Diagnostics are not treated as accepted remote events. - -## 12. P3: PublicationStore fake seam - -Purpose:isolate GitHub storage mechanics from exchange semantics. - -Scope: - -- Add `PublicationStore` interface. -- Add deterministic in-memory fake store. -- Add path normalization and branch validation helpers. -- Add cursor behavior tests. - -Tests: - -- publication path deterministic and human-reviewable. -- idempotent put. -- conflict put. -- list after cursor. -- unsupported branch/path fail closed. -- same event body across repeated publish does not create conflicts. - -Validation: - -```text -go test ./harness/internal/mnemonhub/exchange -run 'TestPublicationStore|TestGitHubBackendFake' -``` - -Done when: - -- Fake store can run the full publish/pull/import logic without network. -- No GitHub API package is needed for unit tests. - -## 13. P4: GitHub backend over fake store - -Purpose:implement `exchange.RemoteWorkspace` semantics before wiring real GitHub. - -Remote config: - -```text -backend: github -direction: publish|subscribe -repo: mnemon-dev/mnemon-teamwork-example -branch: mnemon/ -credential_ref: ... -scope: ... -``` - -Push flow: - -```text -SyncPush(req) - for each synced envelope: - require phase=synced - materialize SyncedEventMaterial - derive visible publication path - write mnemon-publications/v1/events////-.json - created/exists-same -> accepted - exists-different -> conflict diagnostic -``` - -Pull flow: - -```text -SyncPull(req) - list subscribed publication branch entries - skip own origin - validate envelope digest/phase/scope - return valid Events - return invalid/out-of-scope/conflict as Diagnostics -``` - -Tests: - -- publish writes only synced envelopes. -- subscribe returns valid subscribed events. -- own origin excluded. -- invalid phase -> diagnostic. -- out-of-scope -> diagnostic. -- digest mismatch -> diagnostic. -- same key different body -> diagnostic. -- cursor unchanged -> no new events in fake store. - -Validation: - -```text -go test ./harness/internal/mnemonhub/exchange -go test ./harness/internal/app -run 'TestSync' -``` - -Done when: - -- GitHub backend behavior is proven over fake store. -- Worker can use backend through `exchange.RemoteWorkspace`, not a GitHub-specific type. - -## 14. P5: Repo contract and operator config - -Purpose:freeze the real repo shape before the live GitHub adapter. - -Scope: - -- Document `mnemon-dev/mnemon-teamwork-example` as the default live repo. -- Define branch names for the live case. -- Define `team.json` and report output. -- Add CLI/config examples. -- Add validation that repo/branch/path shapes are accepted or rejected deterministically. - -Live branch set: - -```text -mnemon/team -mnemon/mnemond-a -mnemon/mnemond-b -mnemon/mnemond-c -mnemon/mnemond-d -mnemon/mnemond-e -``` - -Operator UX: - -```bash -mnemon-harness sync connect self \ - --backend github \ - --direction publish \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/mnemond-a \ - --token-file ... - -mnemon-harness sync connect agent-b-stream \ - --backend github \ - --direction subscribe \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/mnemond-b \ - --token-file ... -``` - -Done when: - -- A new operator can identify the repo, branches, token file, and expected report path. -- Validation repo is clearly marked evidence surface only. - -## 15. P6: Real GitHub adapter and live case - -Purpose:prove the backend works on real GitHub, not just fake storage. - -Official docs anchors to verify at implementation time: - -- Repository Contents API: https://docs.github.com/en/rest/repos/contents -- Git Trees API: https://docs.github.com/en/rest/git/trees -- Git References API: https://docs.github.com/en/rest/git/refs -- Fine-grained token permissions: https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens -- REST API rate limits: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api -- REST API best practices: https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api - -Plan: - -```text -P6A docs verification and API spike -P6B GitHub PublicationStore implementation -P6C gated live publish/pull/import case -``` - -P6A must verify: - -- create/update file behavior and conflict status. -- branch reference behavior and fast-forward constraints if Git Database API is used. -- required fine-grained token permissions for contents read/write. -- rate-limit headers and secondary rate-limit behavior. -- serial mutative request policy. - -Default API strategy: - -```text -Use Contents API first for MVP PutEvent if one-file-per-event commits are acceptable. -Use Git Trees/Commits/Refs if batching or branch-head control becomes necessary. -Use Git Trees or equivalent tree listing for event discovery when directory listing limits matter. -``` - -The implementation must not hard-code an API choice that prevents later switching behind `PublicationStore`. - -Security: - -- Token comes from `credential_ref` or token file. -- Token never appears in logs, diagnostics, reports, or test failure output. -- Use authenticated requests. -- Use explicit timeout. -- Serialize mutative requests per branch. -- Treat `403`, `404`, `409`, `422`, and rate-limit responses as structured errors. - -Gated live case: - -```text -repo: mnemon-dev/mnemon-teamwork-example -publish branch: mnemon/mnemond-a -subscribe branch: mnemon/mnemond-b - -agent-a: - local accepted synced envelope - -> publish mnemon-publications/v1/events/mnemond-a/progress_digest/project/000000000001-dec-a.json - -agent-b: - pull mnemon/mnemond-a - -> validate envelope - -> import through Event Intake path - -> persist cursor/status - -repeat pull: - -> no duplicate import -``` - -Suggested gated command shape: - -```bash -MNEMON_GITHUB_LIVE=1 \ -MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ -MNEMON_GITHUB_TOKEN_FILE=/path/to/token \ -MNEMON_GITHUB_BRANCH_A=mnemon/mnemond-a \ -MNEMON_GITHUB_BRANCH_B=mnemon/mnemond-b \ -go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v -``` - -Default unit tests must skip this when `MNEMON_GITHUB_LIVE` is not set. - -Done when: - -- Fake unit tests pass. -- Gated live case has passed at least once against `mnemon-dev/mnemon-teamwork-example`. -- Live case evidence records repo, branches, commit refs or publication cursors, and import result. - -## 16. P7: Deterministic local 5-mnemond acceptance - -Purpose:prove mesh semantics without real Codex appservers. - -Topology: - -```text -5 local stores -5 mnemond runtime instances -1 fake or real publication store -5 publication branches -0 central active mnemon-hub -0 shared governed.db -``` - -Scenario: - -1. Agent A publishes `teamwork_signal` and assignments. -2. B/C/D/E subscribe/import. -3. B emits nested assignment. -4. Two new `mnemond` instances join. -5. One `mnemond` stops publishing progress/profile. -6. TTL-derived cue leads another actor to emit reassignment. -7. Progress digests aggregate. -8. Another act is emitted after aggregation. -9. Final completion evidence is emitted. - -Assertions: - -- no shared governed.db. -- no central active mnemon-hub endpoint. -- every cross-mnemond event came from a publication branch. -- imported resources exist only after Event Intake -> Tick -> Materializer. -- injected bad entries produce diagnostics. -- repeated pulls are idempotent. - -Done when: - -- Deterministic acceptance script/test passes locally. -- Report contains event chain, branch/cursor evidence, diagnostics, and no-hub/no-shared-db proof. - -Current implemented deterministic coverage: - -```bash -go test ./harness/internal/app -run 'TestSyncGitHubFake' -count=1 -v -``` - -It covers five isolated mnemond runtimes, one publication branch per mnemond, fake GitHub publication store, later join by two nodes, paused node, active-node reassignment event, and paused-node catch-up. It does not by itself prove real Codex appserver natural planning; that remains P8. - -## 17. P8: Real Codex appserver acceptance - -Purpose:prove the workflow with real hostagent behavior. - -Topology: - -```text -5 codex appservers -5 mnemond -5 isolated runtime workspaces -5 isolated local mnemond stores -1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example -5 run-scoped publication branches -0 central active mnemon-hub -0 shared governed.db -``` - -Isolation requirements: - -- Each appserver connects to exactly one dedicated `mnemond`. -- Each `mnemond` has its own local store path. -- Each appserver has its own runtime workspace path. -- No appserver reads or writes another appserver's local Mnemon workspace. -- Cross-agent visibility happens only after publication branch pull/import. -- The report must include local store paths and prove they are distinct. -- The default branch prefix is run-scoped; the runner initializes missing branches from `main` before local sync starts. -- Reusing long-lived branches is allowed only when explicitly requested with `--github-branch-prefix`. -- The default real GitHub sync interval is 30 seconds per local `mnemond`; fake/local tests may use shorter intervals, but real GitHub acceptance must not use 100ms polling. - -Baseline 5-node Teamwork-ReAct scenario: - -1. Configure 5 isolated appserver/mnemond workspaces, remotes, and run-scoped publication branches. -2. Start the initial online subset of appservers/mnemond; the current runner uses 3 online and 2 delayed nodes for the natural task round. -3. Install generic hook/GUIDE/skill for started appservers. -4. Publish fresh `agent_profile` from the initial online nodes. -5. Send an ordinary user message to one connected PoC agent to start the teamwork task. -6. Verify assignment propagation. -7. Verify nested decomposition. -8. Verify first round outputs are published as progress digests. -9. Verify a PoC-like agent reviews outputs and emits a second-round plan. -10. Verify second-round reassignment or refinement is published and executed. -11. Start 2 delayed `mnemond`/appserver pairs mid-run from the already configured `remotes.json`. -12. Verify delayed nodes import backlog and publish fresh `agent_profile` events. -13. Stop/restart 1 `mnemond` mid-run. -14. Verify reassignment or renewed progress through governed events rather than direct scheduling. -15. Verify aggregation. -16. Verify another act can be emitted after aggregation if the result is incomplete. -17. Verify final completion evidence. - -Natural task suite: - -- Run the baseline 5-node case. -- Run at least 2 of the natural task scenarios in section 18. -- Prefer all 3 scenarios before declaring the milestone robust. -- At least one scenario must use multiple PoC agents. -- At least one scenario must create overlapping tasks where progress on task B can complete or materially advance task A. -- At least one scenario must verify profile update and profile freshness during the run. -- At least one scenario must show multiple output-driven replanning/reassignment rounds. -- Each scenario must be team-shaped:success should require useful work from more than one agent, not just one agent doing everything while others observe. - -Report evidence: - -```text -run_id -participants -entry_poc_agents -natural_user_messages -runtime_workspace_paths -local_mnemond_store_paths -publication branches -events published per branch -events imported per mnemond -diagnostics per mnemond -assignment/progress chain -replanning_rounds -reassignment_rounds -mnemond join/leave timeline -profile refresh/update timeline -cross_task_reuse_or_completion evidence -proof no central mnemon-hub endpoint was used -proof no shared governed.db was used -proof each appserver used its own mnemond/local store/runtime workspace -``` - -The acceptance report exposes the publication/import summary as machine-readable fields under `sync`: - -```text -sync.published_events_by_branch -sync.imported_events_by_mnemond -sync.diagnostics_by_mnemond -sync.profile_events_by_mnemond -sync.lifecycle[].action = delayed_join_start | delayed_join_ready | pause_local_mnemond | restart_local_mnemond -sync.lifecycle[].ledger = local ledger counts captured at lifecycle boundaries -scenarios[].evidence.multi_poc -scenarios[].evidence.prior_ok_scenarios -scenarios[].evidence.cross_task_reuse_or_completion -scenarios[].evidence.replanning_rounds -``` - -Done when: - -- Script is runnable. -- A successful run produces the report above. -- Each task was initiated from connected PoC agent conversation, not global harness prompt. -- At least one successful scenario contains two or more output-driven replanning rounds. -- At least one successful scenario demonstrates isolated per-agent `mnemond` and local store paths. -- Failure mode leaves enough diagnostics to distinguish API/auth/config errors from Mnemon import/admission errors. -- GitHub API rate-limit failures must preserve retry/rate-limit headers when GitHub returns them, so an operator can distinguish temporary cooldown from auth or repo misconfiguration. - -## 18. Natural acceptance task suite - -Purpose:test whether teamwork behaves like normal agent usage, not like a scripted benchmark. - -Harness rules: - -- The harness may start/stop appservers, start/stop `mnemond`, configure remotes, and collect evidence. -- The harness may send ordinary user messages to one or more chosen PoC agents. -- The harness must not send a global prompt describing the expected internal delegation graph. -- The harness must not directly tell agents which member should receive which assignment, except where a normal user would reasonably mention a person/role. -- The harness must not inject hidden "expected answer" scaffolding into agent contexts. -- The harness observes events, profiles, diagnostics, assignments, and progress digests after the fact. - -Natural prompt shape: - -```text -User -> connected PoC agent: - "Can you help me get X done? Pull in help if useful." -``` - -Avoid prompt shape: - -```text -Global harness -> all agents: - "Agent A must create assignments for B/C/D, then B must split work, then C must..." -``` - -Required observation dimensions: - -- PoC initiation:which connected agent received the user message and emitted the first teamwork signal. -- Isolation:each connected agent uses its own `mnemond`, local store, and runtime workspace. -- Profile freshness:agents publish fresh `agent_profile` before and during work. -- Profile adaptation:agents update profile/posture when availability, recent success, specialization, or load changes. -- Multi-PoC behavior:two PoC agents can independently start work without corrupting each other's streams. -- Multi-task behavior:an agent can hold multiple tasks and make useful progress without losing context. -- Cross-task reuse:work done for task B can complete, unblock, or materially advance task A. -- Teamwork-ReAct loop:agents use round outputs to replan, reassign, execute again, and aggregate again. -- Team-shaped work:the final result contains meaningful contributions from multiple agents. -- Natural escalation:agents ask the user only when ambiguity/risk warrants it. -- No forced choreography:assignments and decomposition emerge from agent reasoning and governed events. - -### Scenario A: Repository onboarding synthesis - -Entry: - -```text -PoC agent-a receives: -"帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。" -``` - -Expected natural behavior: - -- `agent-a` acts as PoC and emits a teamwork signal. -- Other agents may inspect architecture docs, current harness sync code, tests, and risk areas. -- At least one agent publishes a profile/posture update showing documentation/review availability or recent context. -- First-round output reveals gaps; PoC emits a second-round refinement or fact-check assignment. -- Aggregation produces a concise onboarding artifact or report. - -What this tests: - -- Single-PoC kickoff. -- Natural decomposition without explicit worker list. -- Profile freshness and role fit. -- Aggregation from multiple publication branches. -- Output-driven replanning after first-round findings. - -### Scenario B: Sync issue plus opportunistic docs/test completion - -Entry: - -```text -PoC agent-b receives: -"同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。" -``` - -Optional earlier/open task: - -```text -PoC agent-a previously received: -"你先帮我整理一个这次 GitHub mesh 改造的文档和测试 readiness checklist,后面我们实现完要靠它验收。" -``` - -Expected natural behavior: - -- `agent-b` starts from implementation/test concern, not from a scripted assignment plan. -- First-round risk findings drive a second-round verification or docs/test patch assignment. -- While checking sync diagnostics or RemotePlan tests, an agent may produce evidence that also satisfies the earlier docs/test readiness task. -- The system records that progress on task B completed or materially advanced task A. -- Agents update profile/posture if they become loaded, blocked, or demonstrate sync/testing expertise. - -What this tests: - -- Multiple active tasks in continuous context. -- Cross-task reuse and opportunistic completion. -- Multi-round review -> replan -> verify loop. -- Diagnostics visibility. -- Avoiding duplicate work when two tasks overlap. - -### Scenario C: Multi-PoC live-readiness and operator safety - -Entry: - -```text -PoC agent-a receives: -"请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。" - -PoC agent-c, in a separate normal conversation, receives: -"我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。" -``` - -Expected natural behavior: - -- Two PoC agents independently emit teamwork signals. -- The team converges on compatible work instead of creating conflicting branch/repo assumptions. -- Security/operator findings may update the live-case runbook. -- Live-case preparation may satisfy part of the operator safety task. -- First-round live-readiness/security output leads to second-round fixes or validation assignments. -- Profiles reflect current availability and specialization, for example API/live-case, docs, security, or test evidence. - -What this tests: - -- Multi-PoC initiation. -- Concurrent teamwork streams over the same repo-mediated workspace. -- Conflict avoidance and aggregation. -- Natural task overlap and reuse. -- Multi-PoC multi-round replanning. - -## 19. P9: Documentation, UX, and goal closure - -Scope: - -- Update harness README for GitHub-backed decentralized Remote Workspace. -- Document repo-mediated publication mesh, not P2P networking. -- Document branch enumeration as publication stream enumeration. -- Document honest-client/single-trust-domain boundary. -- Document live GitHub test prerequisites and skip behavior. -- Document cleanup/reset behavior for `mnemon-dev/mnemon-teamwork-example`. -- Document the natural acceptance task suite and PoC initiation rule. -- Summarize validation commands and known caveats. - -Done when: - -- Operator path is reproducible from docs. -- Candidate goal can be closed with unit, live-case, deterministic, and natural appserver acceptance evidence. - -## 20. Validation matrix - -Minimum per phase: - -```text -P0: go test ./harness/internal/coreguard -P1: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app ./harness/cmd/mnemon-harness -run 'TestSync|TestRemote|TestLoadRemote' -P2: go test ./harness/internal/app -run 'TestSync|TestDiagnostic' -P3: go test ./harness/internal/mnemonhub/exchange -run 'TestPublicationStore' -P4: go test ./harness/internal/mnemonhub/exchange ./harness/internal/app -run 'TestGitHub|TestSync' -P5: config/repo contract tests and CLI help tests -P6: fake unit tests by default + gated live GitHub publish/pull/import case -P7: deterministic local 5-mnemond acceptance -P8: real Codex appserver acceptance + natural task suite -P9: docs/UX review against actual commands -``` - -Always run after code changes: - -```text -gofmt -go build -o /tmp/mnemon-harness-check ./harness/cmd/mnemon-harness -``` - -When changing harness module assets: - -```text -make harness-validate -``` - -Full E2E remains: - -```text -bash scripts/e2e_test.sh -make test -``` - -## 21. Risk ledger - -| Risk | Mitigation | -| --- | --- | -| GitHub concepts leak into Mnemon governance | P0 guardrails and naming denylist | -| Branch enumeration becomes node discovery | D7 invariant and publication stream terminology | -| Fake-store proof hides GitHub API issues | P6 gated live case is required for completion | -| Contents API one-commit-per-event becomes too slow | Keep `PublicationStore` API-independent; switch to Git Trees/Commits/Refs behind seam | -| Directory listing/cursor is incomplete at scale | Conservative branch-head cursor + idempotent import; later per-branch ledger | -| Multiple publish targets overclaim reliability | MVP one active publish target | -| Token leakage in diagnostics | explicit redaction tests and no-token reports | -| GitHub rate/secondary limits | authenticated requests, serialized mutative writes, backoff/error handling | -| Validation repo becomes control plane | D4 invariant and report-only contract | -| Acceptance is over-scripted and proves only the harness | PoC-only natural user entry rule; harness observes instead of choreographing | -| Appservers accidentally share local governance state | per-agent mnemond/store/runtime workspace isolation proof required | -| Agents fail to refresh useful profiles | profile freshness/update assertions in P8 reports | -| Multiple tasks duplicate work or miss cross-task completion | cross-task reuse/completion evidence required in natural task suite | -| Teamwork stops after one round and does not prove protocol value | multi-round Teamwork-ReAct evidence required: output review -> replan -> reassign -> execute -> aggregate | - -## 22. Candidate `/goal` - -```text -Implement the GitHub-backed decentralized Remote Workspace foundation for mnemon harness. - -Scope: -- Preserve the existing HTTP mnemon-hub behavior. -- Add directional RemotePlan with publish/subscribe/bidirectional semantics. -- Add pull diagnostics ingestion so invalid remote publication entries become durable local diagnostics. -- Add a fake-tested GitHub publication backend implementing exchange.RemoteWorkspace over a PublicationStore seam. -- Add repo/branch contract for mnemon-dev/mnemon-teamwork-example. -- Add a real GitHub adapter and gated live GitHub publish/pull/import validation case. -- Add deterministic local 5-mnemond mesh acceptance. -- Add real Codex appserver acceptance harness/report shape and natural task suite. -- Ensure each connected agent has its own mnemond, local store, and isolated runtime workspace in acceptance. -- Do not let GitHub concepts enter runtime/state/materializer/hostagent/presentation. -- Do not use GitHub Issues/PR/Actions as teamwork semantics. -- Do not implement P2P networking; GitHub mesh means repo-mediated publication streams only. - -Validation: -- Relevant go tests for exchange/app/cmd sync paths. -- go build ./harness/cmd/mnemon-harness. -- Gated live GitHub publish/pull/import case against mnemon-dev/mnemon-teamwork-example when credentials are provided. -- Deterministic local decentralized mesh acceptance. -- Real Codex appserver acceptance script/report is runnable. -- Natural PoC-initiated task suite covers baseline 5-node case, per-agent mnemond/store/workspace isolation, multi-PoC kickoff, profile updates, cross-task reuse/completion, and multi-round Teamwork-ReAct replanning/reassignment. -``` diff --git a/.mnemon-dev/architecture/github-remote-workspace-backend.md b/.mnemon-dev/architecture/github-remote-workspace-backend.md deleted file mode 100644 index 68e4479e..00000000 --- a/.mnemon-dev/architecture/github-remote-workspace-backend.md +++ /dev/null @@ -1,1394 +0,0 @@ -# GitHub Remote Workspace Backend:去中心化 publication mesh 架构 - -> 日期:2026-06-26 -> 类型:详细架构文档 / Remote Workspace backend 方向 -> 第一性参照:[positioning-thesis.md](positioning-thesis.md)、[event-substrate-concept-normalization.md](event-substrate-concept-normalization.md)、[multi-machine-and-hub-redesign.md](multi-machine-and-hub-redesign.md)、[r1-production-like-per-hostagent-mnemond-experiment.md](r1-production-like-per-hostagent-mnemond-experiment.md) - -## 0. 结论 - -GitHub 用在这里是合理的,但它合理的原因不是"GitHub 能存文件",而是它可以作为 **GitHub-backed decentralized Remote Workspace** 的 bootstrap backend。 - -准确表述: - -```text -Remote Workspace is a protocol role. -mnemon-hub is the first-party HTTP implementation. -GitHub is a bootstrap backend for decentralized publication mesh. -``` - -GitHub backend 不替代 `mnemonhub` 这个协议角色,也不替代本地 `mnemond` 的治理语义。它只替代/补充 `mnemon-hub` 这个后端实现形态,用于让用户无需部署中心 hub 也能启动多 `mnemond` teamwork。 - -核心形态: - -```text -shared GitHub team repo - branch mnemon/ <- only mnemond-a writes - branch mnemon/ <- only mnemond-b writes - branch mnemon/ <- only mnemond-c writes - -each mnemond publishes its own accepted-event log; -other mnemond subscribe to those branches and validate locally. -``` - -这不是 P2P 网络通信,因为所有 `mnemond` 仍只连接已配置的 GitHub Remote Workspace;但它是 **repo-mediated decentralized publication topology**:没有一个全局 active `mnemon-hub` 服务拥有整个 teamwork exchange。 - -### 0.1 当前实现状态 - 2026-06-26 - -当前代码已经落地的边界: - -- GitHub backend 只存在于 `harness/internal/mnemonhub/exchange/backend/github`。 -- `runtime` / `state` / `materializer` / `presentation` / `hostagent` 不依赖 GitHub backend。 -- 每个 `mnemond` 通过 `remotes.json` 配置一个 `publish` branch 和多个 `subscribe` branches。 -- GitHub Contents/Refs API 的 branch head cursor 不直接写入本地 mnemond cursor;backend 会把 opaque cursor 收束为本地可持久化数字 cursor,重复列表依赖 Event Intake 幂等性去重。 -- acceptance report 会显式输出: - - `transport_model=repo-mediated-publication` - - `roster_source=configured-remotes-json` - - `network_discovery=none` - - per-workspace `remotes.json` - - per-agent publication branch - - per-agent local mnemond store path - - delayed join / pause / restart lifecycle entries under `sync.lifecycle` - - publication/import/profile summaries under `sync.published_events_by_branch`, `sync.imported_events_by_mnemond`, and `sync.profile_events_by_mnemond` - - natural suite evidence under `scenarios[].evidence` for multi-PoC, prior completed scenarios, cross-task reuse/completion, and replanning rounds - -当前仍需外部证据的边界: - -- gated live GitHub publish/pull/import 已在 2026-06-26 通过真实 GitHub 访问验证;验证仓库缺失 operator publication branches 时先失败,随后初始化当时的 smoke branches 后通过。当前 branch contract 使用 `mnemond_id` 命名,例如 `mnemon/mnemond-a`。 -- real Codex appserver GitHub mesh suite 需要 token + Codex appserver 环境后实际运行。 -- 在 real Codex appserver GitHub mesh suite 通过前,不能把 GitHub mesh 目标标记为 complete。 - -## 1. 非目标与红线 - -GitHub backend 必须服务 Mnemon 的事件治理模型,不能反过来把 Mnemon 拉回 GitHub 协作模型。 - -非目标: - -- 不用 GitHub Issues / PR / Projects 表达 `assignment`、`progress_digest` 或 teamwork 状态。 -- 不用 GitHub Actions 作为 scheduler、runtime loop 或治理执行器。 -- 不让 GitHub backend 直接写 governed resources。 -- 不让 GitHub backend 影响 render/cue/presentation。 -- 不引入 agent-to-agent message inbox。 -- 不把 branch/commit ordering 当成治理 ordering。 -- 不实现 P2P 网络通信、gossip、DHT、peer routing、NAT traversal 或 overlay network。 -- 不把 branch 枚举实现成独立的 P2P 节点发现协议。 - -硬红线: - -```text -remote backend may transport accepted synced envelopes; -only local mnemond may import them through Event Intake -> Tick -> Materializer. -github backend may talk to configured GitHub Remote Workspace only; -it must not open mnemond-to-mnemond network links. -``` - -## 1.5 概念收束与防污染规则 - -这条路线最大的风险不是 GitHub API 复杂,而是概念污染:把 GitHub repo/branch/Issue/PR 误提升为 Mnemon 的协作主语,从而偏离 `hostagent / mnemond / mnemonhub / event` 主轴。 - -因此后续文档、代码、CLI、测试命名必须遵守以下收束规则。 - -### 1.5.1 系统主语 - -允许作为系统主语的概念: - -```text -hostagent -mnemond -mnemonhub -event / event envelope -Remote Workspace -publication backend -RemotePlan -``` - -其中: - -- `mnemond` 是去中心运行单元。 -- `event envelope` 是跨节点交换材料。 -- `Remote Workspace` 是协议角色。 -- `publication backend` 是 Remote Workspace 的传输/存储实现。 -- `GitHub` 只是一个 publication backend。 - -不允许把以下对象提升为治理主语: - -```text -GitHub repo -GitHub branch -GitHub issue -GitHub PR -GitHub Action -validation repo -team repo -``` - -这些对象只能作为 substrate / transport namespace / evidence surface。 - -### 1.5.2 命名约束 - -推荐命名: - -```text -GitHub-backed publication mesh -repo-mediated publication mesh -publication branch -publication stream enumeration -team rendezvous repo -validation repo -RemotePlan publish/subscribe -``` - -避免命名: - -```text -GitHub team state -GitHub assignment -GitHub scheduler -GitHub agent inbox -GitHub source of truth -branch state -PR-based teamwork -issue-based assignment -P2P discovery protocol -mesh networking layer -peer routing -``` - -原因:这些命名会暗示 GitHub 承担 governed state 或 teamwork 语义,与 Mnemon 架构相冲突。 - -### 1.5.3 语义边界 - -GitHub repo/branch 的语义是: - -```text -append-only publication substrate -transport cursor/provenance -team rendezvous and bootstrap -publication stream enumeration -acceptance evidence storage -``` - -GitHub repo/branch 的语义不是: - -```text -canonical governed state -assignment database -scheduler queue -agent inbox -truth source for availability -arbiter of completion -network peer discovery authority -node routing table -``` - -Canonical state 只在本地 `mnemond` store 中产生;跨节点可见性来自 accepted synced envelopes 被订阅方 `mnemond` 本地导入后的结果。 - -### 1.5.4 验证仓库边界 - -如果使用一个专门仓库做 teamwork 验证,该仓库的角色应定义为: - -```text -validation repo = GitHub-backed publication substrate + run evidence surface -``` - -它可以包含: - -```text -team manifest -per-mnemond publication branches -scenario fixtures -run reports -acceptance evidence -``` - -它不能包含或承担: - -```text -canonical governed.db -central scheduler -authoritative assignment state -agent-to-agent messages -GitHub Issue/PR workflow as teamwork semantics -``` - -验证通过的证据应证明: - -```text -5 appservers -5 mnemond stores -0 shared governed.db -0 central active mnemon-hub -accepted events propagate through publication branches -every imported event enters through local Event Intake -> Tick -> Materializer -``` - -### 1.5.5 网络通信边界 - -`decentralized`、`mesh`、`P2P-like` 这些词只允许描述 publication ownership / event propagation topology,不能描述 GitHub backend 的网络实现。 - -GitHub backend 的通信模型是: - -```text -mnemond -> configured GitHub Remote Workspace -``` - -不允许引入: - -```text -mnemond -> mnemond direct socket -gossip -DHT -peer routing -NAT traversal -overlay network -dynamic P2P topology maintenance -``` - -因此 branch 枚举的准确含义是: - -```text -publication branch enumeration -= list publication streams inside an already configured Remote Workspace -!= P2P node discovery -!= team membership authority -!= liveness authority -!= permission authority -``` - -branch 枚举可以提供 roster bootstrap 的输入材料,但是否接受某个 stream、是否把它视为可用 agent、是否允许其 event 进入本地治理,都必须由本地 `mnemond` 的 admission/import policy 决定。 - -## 2. 术语裁决:decentralized / federated / P2P - -推荐术语: - -```text -Decentralized Remote Workspace -GitHub-backed publication mesh -Repo-mediated publication mesh -Mnemond-published accepted-event logs -Federated mnemond sync -Publication stream enumeration -``` - -术语边界: - -- **decentralized**:适合。没有单个中心 `mnemon-hub` 进程承载所有交换;每个 `mnemond` 拥有自己的 publication stream。 -- **federated**:适合。每个 `mnemond`/信任域保留本地治理,通过订阅授权接收其他 publication streams 中的 accepted events。 -- **P2P**:只可作为拓扑类比,不可作为网络实现承诺。当前 GitHub backend 不是 peer-to-peer socket transport,而是 **repo-mediated publication topology**。除非另起独立设计实现 direct mnemond-to-mnemond transport,否则不应把 GitHub backend 命名为 P2P networking。 - -## 3. 当前中心 hub 流程 - -当前 production-like 目标是: - -```text -5 hostagents + 5 mnemond + 1 mnemonhub + 0 shared governed.db -``` - -中心 hub 流程: - -```text -+-------------+ +-------------+ +-------------+ -| hostagent A | -----> | mnemond A | -----> | mnemon-hub | -+-------------+ event +-------------+ sync +-------------+ - | - | pull - v - +-------------+ - | mnemond B | - +-------------+ - | - v - +-------------+ - | hostagent B | - +-------------+ -``` - -数据流: - -```text -agent-a emits teamwork_signal / assignment - -> mnemond-a accepts event - -> mnemond-a records pending synced envelope - -> sync worker pushes to mnemon-hub - -> mnemon-hub validates grant/scope/digest/idempotency - -> mnemond-b/c/d/e pull from mnemon-hub - -> each imports through Event Intake -> Tick -> Materializer - -> render/skill surfaces cues - -> assigned agents act and emit progress/assignment/signal -``` - -中心 hub 特性: - -- 一个公共 exchange point。 -- push 前 server-side validation。 -- hub-side cursor/status。 -- 清晰但需要部署/运行 hub。 - -## 3.5 诚实定位:GitHub 是 bootstrap backend,不是最终去中心 substrate - -如果目标是"真正去中心运行",GitHub 不是最优雅的最终核心。GitHub mesh 的结构是: - -```text -mnemond is decentralized as publisher; -GitHub remains centralized rendezvous/storage substrate. -``` - -真正去中心的核心抽象应是: - -```text -mnemond publishes an append-only accepted-event feed; -other mnemond subscribe to that feed; -all imports still go through local governance. -``` - -因此最稳的架构表达是: - -```text -Mnemon's decentralized unit is mnemond, not GitHub. -GitHub is a convenient publication substrate. -``` - -当前 GitHub backend 的通信边界: - -```text -+-------------+ +-------------+ -| mnemond A | | mnemond B | -| local state | | local state | -+------+------+ +------+------+ - | | - | push/pull own and subscribed streams | push/pull own and subscribed streams - v v - +------------- configured GitHub Remote Workspace -------------+ - | shared repo | - | branch mnemon/a | - | branch mnemon/b | - +---------------------------------------------------------------+ -``` - -Publication backend 应保持可插拔: - -```text -publication backend: - github branch - static HTTPS directory - object storage - git remote - future mnemond-native publication endpoint -``` - -direct P2P/libp2p-like transport 属于另一个未来研究方向,不是 GitHub backend 的实现内容,也不是当前 goal 的网络层假设。 - -GitHub 的价值是现实工程上的 bootstrap: - -- repo 权限和 token 心智现成; -- storage/history/audit trail 现成; -- branch namespace 可表达 per-mnemond publication stream; -- 不需要用户部署中心 `mnemon-hub`; -- 离线节点的历史 feed 可由 GitHub 保留; -- 很适合证明 `5 appserver + 5 mnemond + 0 central active hub` 可跑通。 - -但 GitHub 不应变成架构中心: - -- 不应让 GitHub API 渗入 `runtime` / `state` / `presentation` / `hostagent`; -- 不应把 GitHub branch/commit ordering 当作治理 ordering; -- 不应把 GitHub Issue/PR/Actions 当作 teamwork 语义层; -- 不应把 "GitHub-backed mesh" 宣传成纯 P2P。 - -实现策略: - -```text -architecture core: - decentralized publication mesh - -first backend: - GitHub-backed publication branch - -future backend: - mnemond-native publication endpoint -``` - -这保证第一版可以借 GitHub 快速启动,但最终形态仍然是 `mnemond-native publication mesh`。 - -## 4. GitHub Mesh 目标流程 - -GitHub mesh 改为 shared repo + per-agent branch: - -```text -repo: mnemon-dev/mnemon-teamwork-example - -branches: - mnemon/mnemond-a - mnemon/mnemond-b - mnemon/mnemond-c - mnemon/mnemond-d - mnemon/mnemond-e -``` - -拓扑: - -```text - shared GitHub repo - +----------------------------------+ - | branch mnemon/mnemond-a | - | branch mnemon/mnemond-b | - | branch mnemon/mnemond-c | - | branch mnemon/mnemond-d | - | branch mnemon/mnemond-e | - +----------------------------------+ - ^ ^ ^ ^ - | | | | - publish read read read - | - mnemond-a -``` - -每个 `mnemond`: - -- 只写自己的 branch。 -- 读取自己订阅的 publication branches。 -- pull 后本地验证 scope/digest/idempotency。 -- 只通过本地 Event Intake 导入。 - -GitHub mesh 数据流: - -```text -agent-a emits teamwork_signal / assignment - -> mnemond-a accepts event locally - -> mnemond-a publishes synced envelope to branch mnemon/mnemond-a - -> mnemond-b/c/d/e read branch mnemon/mnemond-a - -> each local mnemond validates and imports - -> assigned agents act - -> each publishes its own accepted events on its own branch -``` - -这不是 agent-to-agent messaging。它是: - -```text -mnemond-to-mnemond accepted-event publication mesh -``` - -## 5. 为什么是 shared repo + per-agent branch - -### 5.1 优于 per-agent repo - -```text -per-agent repo: - + ownership clean - - team bootstrap inventory = N repos - - onboarding heavy - - permissions/config spread across repos - -shared repo + per-agent branch: - + one team rendezvous - + one permission namespace - + publication streams enumerable by branch/manifest - + still preserves one-writer-per-publication-stream -``` - -### 5.2 优于 one shared branch - -```text -shared branch: - - all mnemond write same head - - commit races likely - - ownership boundary fuzzy - - one bad writer can corrupt shared stream - -per-agent branch: - + one writer per branch - + no cross-agent branch-head contention - + readers merge by local governance import - + branch is transport namespace, not governed truth -``` - -### 5.3 推荐默认 - -默认 GitHub direct backend 应使用: - -```text -one shared team repo -one publication branch per mnemond -``` - -不是每个 agent 一个 repo,也不是所有 agent 写一个 branch。 - -## 6. 组件架构 - -目标组件图: - -```text -+----------------------------- HostAgent -----------------------------+ -| Codex / Claude Code | -| thin hook + managed GUIDE + mnemon-observe skill | -| observe / pull / render | -+-------------------------------+--------------------------------------+ - | - v -+----------------------------- mnemond -------------------------------+ -| Local governance domain | -| | -| Event Intake -> Admission Rules -> Bridge -> Materializer | -| | | | | | -| v v v v | -| observed log diagnostics proposed events resources | -| | -| Store: events / resources / decisions / sync_events / cursors | -+-------------------------------+--------------------------------------+ - | - v -+------------------------ Sync Orchestrator --------------------------+ -| Reads RemotePlan | -| | -| Push lane: | -| local accepted synced events -> push targets | -| | -| Pull lane: | -| pull sources -> validate/diagnose -> importPulledEvents | -+-------------------------------+--------------------------------------+ - | - v -+----------------------- Remote Workspace ABI ------------------------+ -| interface: | -| SyncPush(req) -> SyncPushResponse | -| SyncPull(req) -> SyncPullResponse | -| SyncStatus() -> SyncStatusResponse | -+-------------------+-------------------------------+------------------+ - | | - v v - +----------------------+ +------------------------------+ - | HTTP backend | | GitHub backend | - | mnemon-hub | | publication mesh | - | central sync service | | shared repo/per-agent branch | - +----------------------+ +------------------------------+ -``` - -Boundary: - -- `runtime` / `state` / `presentation` / `hostagent` must not know GitHub. -- `app` owns orchestration and import. -- `mnemonhub/exchange` owns remote exchange seams, cursors, local sync ledger helpers, and backend adapters. -- `mnemonhub` remains the central HTTP reference backend. - -## 7. RemotePlan:从双向 remote 到 directional plan - -当前 remote 是隐含双向: - -```text -remote hub: - push local events - pull remote events -``` - -GitHub mesh 需要方向分离: - -```text -publish target: - where this mnemond writes its accepted-event stream - -subscribe source: - where this mnemond reads subscribed accepted-event streams -``` - -目标 model: - -```text -RemoteEntry - id - backend: http | github - direction: bidirectional | publish | subscribe - credential_ref - scope - backend-specific config - -RemotePlan - PushTargets []RemoteEntry - PullSources []RemoteEntry -``` - -兼容规则: - -```text -empty backend -> http -empty direction -> bidirectional -old remotes.json -> behavior unchanged -``` - -Mapping: - -```text -http + bidirectional: - PushTargets += entry - PullSources += entry - -github + publish: - PushTargets += entry - -github + subscribe: - PullSources += entry -``` - -ASCII: - -```text -+------------------------- RemotePlan Loader --------------------------+ -| remotes.json | -| | -| backend: http direction: bidirectional -> push + pull | -| backend: github direction: publish -> push only | -| backend: github direction: subscribe -> pull only | -+-------------------------------+--------------------------------------+ - | - v -+------------------------- Sync Orchestrator --------------------------+ -| for each PushTarget: | -| ReadPushBatch -> RemoteWorkspace.SyncPush -> ApplyPushResponse | -| | -| for each PullSource: | -| ReadPullState -> RemoteWorkspace.SyncPull -> import events | -| \-> ingest diagnostics | -+----------------------------------------------------------------------+ -``` - -## 8. GitHub repo layout - -Team repo: - -```text -mnemon-dev/mnemon-teamwork-example -``` - -Team coordination branch: - -```text -branch: mnemon/team - -.mnemon/team.json -``` - -Per-mnemond publication branches: - -```text -branch: mnemon/ - -mnemon-publications/v1/ - manifest.json - events/ - / - / - / - -.json -``` - -`team.json` sketch: - -```json -{ - "schema_version": 1, - "team_id": "mnemon-teamwork-example", - "members": [ - { - "mnemond_id": "mnemond-a", - "branch": "mnemon/mnemond-a", - "principal": "codex-a@project" - } - ] -} -``` - -`manifest.json` sketch: - -```json -{ - "schema_version": 1, - "backend": "github", - "mode": "publication", - "origin_mnemond": "mnemond-a", - "published_at": "2026-06-26T00:00:00Z", - "published_scopes": [ - { "kind": "assignment", "id": "project" }, - { "kind": "progress_digest", "id": "project" } - ] -} -``` - -Event file: - -```text -mnemon-publications/v1/events////-.json -``` - -`local_ingest_seq` is zero-padded to 12 digits. This keeps GitHub paths human-reviewable: - -```text -mnemon-publications/v1/events/mnemond-a/progress_digest/project/000000000007-dec-a.json -``` - -Rules: - -- File body is a single `event.EventEnvelope` with `phase=synced`. -- Same path + same body = idempotent. -- Same path + different body = conflict diagnostic. -- The path is a visible publication address, not a hash key. -- Git commit SHA is transport cursor/provenance only. -- Governance ordering remains local ingest seq after import. - -## 9. Data flows - -### 9.1 Publish - -```text -local Materializer accepts decision - -> state records accepted envelope - -> state records pending sync event - -> sync orchestrator reads PushTargets - -> GitHub backend derives visible publication path - -> writes event file to own publication branch - -> records per-target ack locally -``` - -ASCII: - -```text -+----------+ +---------+ +-------------+ +------------------+ -| decision | --> | synced | --> | sync worker | --> | GitHub branch | -| accepted | | event | | push lane | | mnemon/mnemond-a | -+----------+ +---------+ +-------------+ +------------------+ -``` - -### 9.2 Subscribe / import - -```text -sync orchestrator reads PullSources - -> GitHub backend lists subscribed branch after cursor - -> validates event envelope - -> returns SyncPullResponse.Events - -> app.importPulledEvents - -> IngestTrusted(sync@local) - -> Tick - -> RemoteImportRule - -> Materializer.Apply -``` - -ASCII: - -```text -+------------------+ +-------------+ +--------------+ +----------+ -| GitHub branch | --> | pull lane | --> | Event Intake | --> | local | -| mnemon/mnemond-b | | validate | | + Tick | | resource | -+------------------+ +-------------+ +--------------+ +----------+ -``` - -Forbidden: - -```text -GitHub event -> direct Store.Resource write -``` - -Required: - -```text -GitHub event -> Event Intake -> Tick -> Materializer -``` - -### 9.3 Diagnostics - -GitHub direct lacks server-side push clamp, so pull must surface problems: - -```text -invalid phase -invalid schema -digest mismatch -out-of-subscription scope -same publication path different body -unknown importable kind -``` - -All must become local visible diagnostics, never silent drops. - -Target flow: - -```text -GitHub backend detects invalid entry - -> SyncPullResponse.Diagnostics - -> sync orchestrator ingests diagnostic observation - -> durable sync.diagnostic appears in local event log -``` - -This extends the existing skipped-kind path: - -```text -sync.import_skipped.observed -> SyncImportSkippedRule -> sync.diagnostic -``` - -## 10. Teamwork scenario flow - -Target acceptance scenario: - -```text -5 codex appservers -5 mnemond -1 shared GitHub team repo -5 per-agent publication branches -0 shared governed.db -0 central active mnemon-hub -``` - -### 10.1 Bootstrap - -```text -agent-a appserver starts - -> mnemond-a starts - -> publish branch mnemon/mnemond-a exists - -> agent-a emits agent_profile - -> profile is published -``` - -Team publication enumeration / roster bootstrap: - -```text -team.json / branch list says a publication stream exists -agent_profile says whether agent-a is currently useful/available -assignment TTL/progress says whether work is healthy -``` - -Branch presence is not enough to assign work and is not a network-level online signal. - -### 10.2 First act - -```text -agent-a receives user teamwork task - -> reads governed context - -> emits teamwork_signal - -> emits assignment(s) - -> mnemond-a accepts - -> mnemond-a publishes to mnemon/mnemond-a -``` - -Subscribed mnemond: - -```text -mnemond-b/c/d/e subscribe to mnemon/mnemond-a - -> import signal/assignment - -> render work cues - -> assigned agents act -``` - -### 10.3 Nested decomposition - -```text -agent-b receives assignment - -> decides it should split - -> emits new teamwork_signal / assignment - -> mnemond-b accepts - -> mnemond-b publishes to mnemon/mnemond-b - -> subscribed mnemond pull/import -``` - -This is Teamwork-ReAct-like: - -```text -Sense -> Select -> Work -> Feedback - ^ | - +------ next Act -----+ -``` - -But the "act" is event-governed, not direct message dispatch. - -### 10.4 Mnemond join - -Two new `mnemond` instances join during work: - -```text -agent-f/g appservers start - -> create branches mnemon/mnemond-f/g - -> publish fresh agent_profile - -> subscribe to existing branches - -> pull backlog according to cursors -``` - -Existing mnemond can import them only after the next configured repo pull and: - -```text -team manifest/branch enumeration includes the publication stream -+ fresh agent_profile is imported -``` - -### 10.5 Mnemond stale and reassignment - -One `mnemond` stops publishing fresh evidence: - -```text -agent-c stops publishing progress/profile refresh -``` - -No scheduler should directly reassign. Instead: - -```text -assignment TTL expires without progress - -> presentation derives expired/stalled cue - -> another agent emits teamwork_signal or assignment - -> mnemond accepts reassignment event - -> event propagates through mesh -``` - -This preserves R1 rule: - -```text -assignment_expired is derived, not durable state. -assignment_status is deferred. -``` - -### 10.6 Aggregation and next act - -Agents publish `progress_digest`. - -Aggregator-like agent sees: - -```text -progress_digest from branches b/d/e/f -missing/expired assignment from c -``` - -It may: - -- emit final `progress_digest`; -- emit new `teamwork_signal`; -- emit new `assignment` for another act; -- ask user only if ambiguity/risk requires escalation. - -Loop continues until the task is truly complete. - -## 11. Publication sensing model - -Do not conflate publication stream inventory with teamwork availability. - -Three layers: - -```text -1. Configured workspace inventory - shared repo / team.json / branch enumeration - tells mnemond which publication streams can be subscribed - -2. Governed agent presence - agent_profile with ttl/freshness/availability - tells agents who appears available and suited - -3. Assignment health - assignment TTL + progress_digest - tells agents whether a commitment is stale, blocked, or complete enough -``` - -Publication candidate active: - -```text -publication branch exists -+ fresh agent_profile -+ suitable scope/context advantages -=> candidate for assignment -``` - -This does not mean a direct network counterpart is online. It only means the configured workspace currently contains an acceptable publication stream with fresh governed evidence. - -Publication candidate stale: - -```text -profile stale -or assignment TTL expired without progress -=> derived cue; another agent may reassign -``` - -## 12. 权限与安全 - -Two-layer permission model: - -```text -GitHub permission: - who can read/write branches or repo - -Mnemon permission: - which origin/resource_ref/event material this mnemond accepts -``` - -Shared repo + per-agent branch 模式必须诚实承认一个边界:GitHub 本身不能可靠地替 Mnemon 表达每个 branch / 每个 resource_ref / 每个 event 的治理权限。实际安全模型应理解为: - -```text -GitHub repo permission = transport access -mnemond import policy = governance permission -``` - -也就是说: - -- GitHub 控制谁能接触这个 repo。 -- Branch 命名和 branch ownership 是约定/配置,不是完整的 Mnemon 权限系统。 -- 一个订阅方能读到某个 branch,不等于本地 `mnemond` 会接受里面的 event。 -- 一个 writer 能把内容写进 GitHub,不等于其他 `mnemond` 会导入这些内容。 -- 真正的接受/拒绝发生在订阅方本地 pull/import 时。 - -Pull-side acceptance chain: - -```text -GitHub event - -> backend validation - -> subscription/origin policy - -> resource_ref scope check - -> digest/schema/idempotency check - -> Event Intake - -> Tick - -> Materializer -``` - -如果任何一步失败,结果必须是 diagnostic,不是静默丢弃,也不是直接写资源。 - -GitHub direct: - -- Good for quick start and single trust domain. -- Branch protection/rulesets can reduce accidental branch damage. -- Fine-grained tokens/deploy keys can limit repo access. -- Still cannot express Mnemon resource-level scope by itself. -- Cannot prevent an already-authorized repo writer from publishing malformed or out-of-scope material. -- Relies on each subscribing `mnemond` to fail-closed on import. - -Therefore GitHub direct is appropriate for: - -```text -single trust domain -honest clients -quick-start decentralized mesh -``` - -It is not enough for: - -```text -strong cross-trust-domain boundary -server-side push clamp -strict pre-append authorization -``` - -GitHub App: - -```text -Local mnemond -> GitHub App backend -> GitHub repo -``` - -App backend can validate before writing: - -- identity -- scope clamp -- digest -- idempotency -- branch ownership - -This is closer to `mnemon-hub` server-side semantics, but requires running an App backend. Use it when "bad material should not be appendable to the remote at all" is a requirement. - -mnemon-hub: - -- Strongest first-party reference backend. -- Server-side validation. -- Good for stronger cross-domain boundary. -- Requires service deployment. - -Positioning: - -```text -GitHub direct = bootstrap decentralized mesh -mnemon-hub = strong reference hub -GitHub App = GitHub-hosted strong validation variant -``` - -## 13. State model pressure point:per-remote sync status - -Current local sync ledger mostly treats a synced event as pending/synced/conflict globally. GitHub mesh may need per-target status: - -```text -event E published to: - github-self: synced - http-hub: pending - archive: conflict -``` - -If one local accepted event must publish to multiple push targets, marking it globally synced after the first successful target would starve other targets. - -Likely requirement: - -```text -sync_event_targets - publication_path - remote_id - status - diagnostic - updated_at -``` - -MVP escape hatch: - -- Start with one publish target for GitHub direct. -- Do not claim multi-publish reliability until per-target ledger exists. - -## 14. Implementation stages - -### Stage 0: Backend seam - -Status: initial version implemented in code. - -```text -exchange.RemoteWorkspace - SyncPush - SyncPull - SyncStatus -``` - -HTTP `access.Client` satisfies this ABI. - -### Stage 1: Directional RemotePlan - -Add: - -```text -backend -direction -RemotePlan{PushTargets, PullSources} -``` - -Tests: - -- legacy config remains bidirectional HTTP. -- unknown backend fail-closed. -- unknown direction fail-closed. -- publish-only does not pull. -- subscribe-only does not push. - -### Stage 2: Pull diagnostics consumption - -Consume `SyncPullResponse.Diagnostics`. - -Tests: - -- fake remote returns diagnostic. -- local event log gets durable `sync.diagnostic`. -- repeated pull is idempotent. - -### Stage 3: Publication store interface - -Before real GitHub API, define fake-testable storage: - -```go -type PublicationStore interface { - PutEvent(path string, body []byte) (created bool, conflict bool, err error) - ListEvents(prefix string, cursor string) (events []StoredEvent, nextCursor string, err error) -} -``` - -Tests use memory/fake store. - -### Stage 4: GitHub backend skeleton - -Implement `RemoteWorkspace` over `PublicationStore`. - -Push: - -- read local batch -- derive visible publication path -- write to own branch store -- return accepted/conflict - -Pull: - -- list subscribed branch store -- validate envelope -- return valid events + diagnostics - -### Stage 5: Repo contract and operator config - -Freeze the validation repo and operator-facing config before the live adapter: - -```text -repo: mnemon-dev/mnemon-teamwork-example -team metadata branch: mnemon/team -publication branch: mnemon/ -``` - -This stage defines: - -- branch namespace; -- event/report paths; -- `team.json` shape; -- CLI examples; -- validation rules for repo/branch/path shape. - -CLI options: - -```bash -mnemon-harness sync connect self \ - --backend github \ - --direction publish \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/mnemond-a \ - --token-file ... - -mnemon-harness sync connect agent-b-stream \ - --backend github \ - --direction subscribe \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/mnemond-b \ - --token-file ... -``` - -Later, if clearer: - -```bash -mnemon-harness sync publish connect ... -mnemon-harness sync subscribe add ... -``` - -### Stage 6: Real GitHub adapter and live case - -Add GitHub API implementation behind `PublicationStore`. Detailed execution is governed by [github-decentralized-mesh-implementation-plan.md](github-decentralized-mesh-implementation-plan.md). - -MVP choices: - -- Contents API: simpler for one-file-per-event writes. -- Git Trees/Commits/Refs: better for batching and branch-head control. - -Use fake store for default unit behavior, but do not stop at fake-store proof. Stage 6 is complete only after a gated real GitHub case proves the flow on `mnemon-dev/mnemon-teamwork-example`. - -Required gated live case: - -```text -configured GitHub repo: mnemon-dev/mnemon-teamwork-example -branch mnemon/mnemond-a -branch mnemon/mnemond-b - -agent-a push: - accepted synced envelope -> GitHub publication branch - -agent-b pull: - enumerate subscribed publication branch - read publication entry - validate envelope - import through Event Intake - persist cursor/status - -repeat pull: - no duplicate import -``` - -Do not add real network tests as default unit tests. The real GitHub case should run only when credentials/repo are explicitly provided, but the milestone cannot be called done until that case has passed at least once. - -### Stage 6.5: Validation report contract - -Before the full 5-appserver acceptance, define the dedicated evidence report contract so the test harness does not drift into GitHub-native teamwork. - -Validation repo layout: - -```text -mnemon/team - .mnemon/team.json - .mnemon/scenarios/.json - .mnemon/reports//summary.json - -mnemon/ - mnemon-publications/v1/manifest.json - mnemon-publications/v1/events////-.json - -mnemon/ -mnemon/ -... -``` - -Contract: - -- `mnemon/team` holds bootstrap metadata and reports only. -- `mnemon/` branches hold accepted-event publication logs only. -- No Issue/PR/Action is used as teamwork state. -- Reports summarize evidence; they do not become input to `mnemond` governance. -- The validation repo may be deleted and recreated without changing local governance semantics. - -Required report evidence: - -```text -run_id -participants -publication branches -events published per branch -events imported per mnemond -diagnostics per mnemond -assignment/progress chain -mnemond join/leave timeline -profile refresh/update timeline -multi-PoC evidence -cross-task reuse/completion evidence -output-driven replanning evidence -proof no central mnemon-hub endpoint was used -proof no shared governed.db was used -``` - -### Stage 7: Acceptance scenarios - -All real appserver scenarios must start from one or more connected PoC agents receiving ordinary user messages. The harness may start/stop nodes and collect evidence, but it must not use a global choreography prompt to tell every agent what to do. - -Deterministic acceptance: - -```text -5 local mnemond/runtime instances -1 fake or real publication store -5 publication branches -0 central mnemon-hub -0 shared governed.db -``` - -Real appserver acceptance: - -```text -5 real codex appservers -5 mnemond -5 isolated runtime workspaces -5 isolated local mnemond stores -1 shared GitHub repo: mnemon-dev/mnemon-teamwork-example -5 run-scoped publication branches -0 central mnemon-hub -0 shared governed.db -``` - -Isolation requirements: - -- each appserver is attached to exactly one dedicated `mnemond`; -- each `mnemond` has its own local store; -- each appserver has its own runtime workspace; -- cross-agent visibility only happens through publication branch pull/import. -- default real acceptance branches are run-scoped mnemond publication streams such as `mnemon/mnemond--a`, initialized from `main` before local sync starts. -- long-lived mnemond branches such as `mnemon/mnemond-a` are explicit operator smoke-test inputs, not the default acceptance isolation model. -- real GitHub acceptance uses a 30 second default sync interval per local `mnemond`; 100ms polling is reserved for fake/local tests. - -Scenario: - -1. Configure 5 appserver/mnemond workspaces, remotes, and run-scoped publication branches. -2. Start the initial online subset of appservers/mnemond; the current runner uses 3 online and 2 delayed nodes for the natural task round. -3. Publish fresh profiles from the initial online nodes. -4. Send an ordinary user message to one connected PoC agent to start teamwork. -5. Verify assignment propagation. -6. Verify nested decomposition. -7. Verify first-round outputs cause a second-round plan. -8. Verify second-round reassignment/refinement is executed. -9. Start 2 delayed `mnemond`/appserver pairs mid-run from the already configured `remotes.json`. -10. Verify delayed nodes import backlog and publish fresh `agent_profile` events. -11. Stop/restart 1 `mnemond` mid-run. -12. Verify stale/TTL cue or renewed progress is expressed through governed events. -13. Verify progress aggregation. -14. Verify another act can be emitted after aggregation. -15. Verify completion proof uses accepted events, not GitHub issue/PR state. - -The delayed join step is not discovery. It proves that preconfigured publication streams can become available later, import backlog through the normal pull/import path, and then participate by publishing fresh accepted events. - -Additional natural task scenarios should cover: - -- single-PoC repository onboarding synthesis; -- implementation/test investigation where work on task B can complete or advance task A; -- multi-PoC live-readiness/operator-safety work over the same repo; -- profile freshness and profile/posture updates during the run. -- multiple output-driven Teamwork-ReAct rounds: review outputs, replan, reassign, execute, aggregate again. - -## 15. Remaining open questions - -- Should public read repos be supported with signed envelopes, or require private repos for MVP? -- Should branch protection be recommended or automated? -- Do we need external signatures before claiming cross-trust-domain safety? -- How aggressive should compaction/checkpoint be for long-running teams?