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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/port/known-defects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Python defects not carried over

- **Python defect not carried over:** `daemon._gate` fingerprint omits the receipt turn id (`daemon.py:~993`), so an accepted receipt that gains a turn id later is never reconciled; Go includes it (`TestReconcilePass_receipt_gains_turn_id`).

- **Python defect not carried over:** `registry.bind_anchor` (`registry.py:790-822`) reads the pending anchor before beginning its transaction. Concurrent binders can overwrite a generation's dispatch turn after one has already succeeded, violating the never-rebind invariant. Go reads and decides inside the write transaction; `TestBindAnchor_concurrent_turns_never_replace_the_winner` proves only one distinct turn binds and the loser is refused.
- **Python defect not carried over:** `ack.bind_dispatched_revision` (`ack.py:1026-1035`) rejects acknowledged revisions even though `ack.bind_pending_anchors` (`ack.py:1037-1060`) selects them for recovery. This violates the invariant that every dispatched revision's pending anchor can recover after acknowledgement. Go accepts both states; `TestBindPendingAnchors_recovers_acknowledged_revision` proves the recovery binds and is idempotent.
90 changes: 89 additions & 1 deletion docs/port/test-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,94 @@ Go tests in `internal/bridge`, each reviewed against its Python property:

The remaining MCP case is deferred to todo 16's "Expose the bridge as an MCP stdio server and wire `crw bridge`" and "QA scenarios: happy = stdio round trip create_thread against fakehost via an MCP client in Go test". Property parity is recorded row by row in the todo-15 ledger below. The todo-15 bridge fakehost work is separate from the 36 execution and 23 settings package properties counted under todo 14 in the file rows below.

## Todo 21 part A delivery property progress (2026-09-26)

Ported by property (Jun, 2026-09-26). Go tests in `internal/relay/delivery`, named
`Test<ID>_...` after `.omo/ulw-execute/todo21-properties.md`; each runs the same scenario through
the real Python package over the same fixture tree and compares records, refusals and every table
row with Python's.

| Python file | properties | Go tests | python-internal (not ported) |
| --- | ---: | ---: | --- |
| `test_delivery.py` | 35 | 35 | - |
| `test_delivery_relation.py` | 5 | 4 | DRL-0 (drift guard between two Python spellings; Go has one constant) |
| `test_supersession.py` | 8 | 8 | - |
| `test_multi_parent_isolation.py` | 5 | 5 | - |
| `test_on_request_delivery.py` | 9 | 9 | - |
| `test_anchor_binding.py` | 8 | 8 | - |
| `test_criteria_registration.py` | 7 | 7 | - |
| `test_revision_roundtrip.py` | 5 | 5 | - |
| `test_verification_currency.py` | 13 | 12 | VCU-5 (`inspect.signature`; Go `RecordVerdict` has no bypass parameter) |
| **Total** | **95** | **93** | **2** |

The twelve delivery commands (`emit`, `deliver`, `reconcile`, `recover`, `claim`, `ack-proof`,
`ack`, `verdict`, `criteria-register`, `criteria-show`, `revision-head`, `verify-acks`) answer
byte for byte like Python (`TestCLI_every_delivery_command_answers_byte_for_byte_like_python`);
`deliver`, `reconcile`, `recover` and `verify-acks` with `--socket` need the host adapter
(todo 28).

## Todo 21 part B1 intent and marker property progress (2026-09-26)

Go tests in `internal/relay/delivery`, named `Test<ID>_...` after
`.omo/ulw-execute/todo21-properties.md`; each runs one JSON op list through the real `marker.py` /
`intent.py` (`testdata/markerops.py`) and the Go port over the same tree and compares the whole
answers (records, refusal reason and detail, derived states, fact reads).

| Python file | properties | Go tests | python-internal (not ported) |
| --- | ---: | ---: | --- |
| `test_intent.py` | 23 | 23 | - |
| `test_marker.py` | 8 | 8 | - |
| **Total** | **31** | **31** | **0** |

The eight marker commands (`intent-declare`, `intent-attempt`, `intent-bind`, `intent-register`,
`intent-claim`, `intent-disposition`, `intent-resolve`, `intent-show`), with the store records
`intent-claim` and `intent-disposition` mirror (declarations.py), answer byte for byte like Python
(`TestCLI_every_intent_command_answers_byte_for_byte_like_python`); `guard-evaluate` is todo 33.

## Todo 21 part B2 host-loss and unknown-send property progress (2026-09-26)

Go tests in `internal/relay/delivery`, named `Test21_<ID>_...` after
`.omo/ulw-execute/todo21-properties.md`. `testdata/capture.py` runs every Python test of the file,
each in its own tree, and records every value it asserts; the Go twin of each test runs the same
steps in the same tree and must produce the same values, delivery tables, `delivery_stalled`
fault rows and host sends.

| Python file | properties | Go tests | python-internal (not ported) |
| --- | ---: | ---: | --- |
| `test_host_lost_turn.py` | 29 | 29 | - |
| `test_unknown_send_lost.py` | 23 | 22 | USL-18 (`assertIs` on `cli.Services` wiring; covered by USL-15/17) |
| **Total** | **52** | **51** | **1** |

The fault sweep/ledger (todo 22) and the merge-turn grant rule (todo 26) these properties read are
ported as marked subsets in `internal/relay/faults` and `internal/relay/mergeturn`; the recipient
reads of the bridge adapter (todo 28) as `delivery.BridgeReads`.

## Todo 21 part C acknowledgement, re-review and recovery property progress (2026-09-26)

Same method as part B2 (`testdata/capture.py` + the Go twin of each Python test, every asserted
value and the delivery tables compared; the re-review tests also compare the review tables).

| Python file | properties | Go tests | python-internal (not ported) |
| --- | ---: | ---: | --- |
| `test_ack_reconcile.py` | 23 | 23 | - |
| `test_ack_disposition_race.py` | 3 | 3 | - |
| `test_rereview_deadlock.py` | 9 | 9 | - |
| `test_recovery_negatives.py` | 3 | 3 | - |
| **Total** | **38** | **38** | **0** |

Carried from todo 25A into `internal/relay/delivery`: `test_cli.py` CLI-5, CLI-7, CLI-9, CLI-21,
CLI-38 (`Test25_CLI*`, whole stdout against the Python command) and
`test_registration_contention.py` RCT-1, RCT-4 for their `intent.bind` marker half (`Test25_RCT*`;
the guard-evaluate half is todo 33).

Carried from todo 18 into todo 21: `test_attempt_message_atomicity.py` AMA-1..AMA-6
(`Test21_AMA1..6` in `internal/relay/delivery/attempt_message_atomicity_test.go`,
with `cmd_show` AMA-4..AMA-6 in `internal/relay/cli/attempt_message_atomicity_test.go`).
The Go mirrors compare the Python tests' captured assertions via `testdata/capture.py`;
AMA-6 pins oldest-first order independently in both Service.AttemptMessages and CLI show.
`Test21_RegistrationHold_refuses_resolved_live_state` pins the registration hold's
write admission against a symlink into a temporary live-state-shaped directory.

## Files

| path | tests | class | family | fixtures | owner | destination | coupling |
Expand All @@ -73,7 +161,7 @@ The remaining MCP case is deferred to todo 16's "Expose the bridge as an MCP std
| `packages/codex-session-relay/tests/test_ack_reconcile.py` | 40 | B | ACK, verdicts, reconciliation and restart recovery | - | todo 21 / CRW-153 | go-test: `internal/relay/delivery` (todo 21) | - |
| `packages/codex-session-relay/tests/test_anchor_binding.py` | 14 | B | an anchor binds on every route to dispatched | - | todo 21 / CRW-153 | go-test: `internal/relay/delivery` (todo 21) | - |
| `packages/codex-session-relay/tests/test_assignment.py` | 58 | B | assignment ledger: one issue, one responsible child | - | todo 25 / CRW-154 | go-test: `internal/relay/registry` (todo 25: Test25_ASG1..19, whole answers vs testdata/python_assignment.json) + go-test: `internal/relay/store` (todo 19: typed queries, guard-index refusals and Python-store parity for the tables this file writes) | - |
| `packages/codex-session-relay/tests/test_attempt_message_atomicity.py` | 13 | B | a send carries the message its attempt froze | - | todo 18 / CRW-152 | go-test: `internal/relay/store` (todo 18); DeliveryService pre-claim seam, reconciliation and cmd_show preview halves of 6 cases -> todo 21 `internal/relay/delivery` | - |
| `packages/codex-session-relay/tests/test_attempt_message_atomicity.py` | 13 | B | a send carries the message its attempt froze | - | todo 18 / CRW-152 | go-test: `internal/relay/store` (todo 18); DeliveryService pre-claim seam, reconciliation and cmd_show preview halves of 6 cases -> todo 21 `internal/relay/delivery` and `internal/relay/cli` (`Test21_AMA1..6`, plus CLI AMA-4..6) | - |
| `packages/codex-session-relay/tests/test_bridge_adapter.py` | 75 | B | bridge adapter logic over an injected RPC surface | `codex_session_relay.fakehost` | todo 28 / CRW-154 | go-test: bridge adapter package (todo 28) | - |
| `packages/codex-session-relay/tests/test_bridge_load_roots.py` | 12 | B | a parent loaded by another task's bridge is still reached (CRW-235) | - | todo 28 / CRW-154 | go-test: bridge adapter package (todo 28) | - |
| `packages/codex-session-relay/tests/test_capacity.py` | 30 | B | capacity counting and refusal to infer | - | todo 27 / CRW-154 | go-test: capacity package (todo 27) + go-test: `internal/relay/store` (todo 19: typed queries, guard-index refusals and Python-store parity for the tables this file writes) | - |
Expand Down
2 changes: 2 additions & 0 deletions internal/contracttest/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func runCLI(t *testing.T, scenario Scenario) (map[string]any, error) {
}
// A scenario that drives a relay command the Go build has not registered yet is not ported:
// it is counted as a skip (a failure under CRW_CONTRACT_STRICT=1), never run against usage.
// relaycli.Registered is the union of both gates: the delivery commands (todo 21) and the
// diagnostics commands of internal/relay/cli (todo 20: doctor, status, show, store-*).
for _, step := range steps {
if argv, _ := step["argv"].([]any); len(argv) > 0 {
if name, ok := argv[0].(string); ok && !relaycli.Registered(name) {
Expand Down
157 changes: 157 additions & 0 deletions internal/relay/cli/attempt_message_atomicity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cli_test

import (
"database/sql"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"

_ "modernc.org/sqlite"
)

type amaCapture struct {
Captures []any `json:"captures"`
Problems []string `json:"problems"`
}

func amaTree(t *testing.T, method string) (string, amaCapture) {
t.Helper()
amaRoot := t.TempDir()
home := t.TempDir()
cmd := exec.Command("uv", "run", "--no-sync", "python", filepath.Join(repositoryRoot(t), "internal/relay/delivery/testdata/capture.py"), amaRoot, "test_attempt_message_atomicity")
cmd.Dir = filepath.Join(repositoryRoot(t), "packages/codex-session-relay")
cmd.Env = append(os.Environ(), "HOME="+home, "XDG_STATE_HOME="+filepath.Join(home, "state"), "XDG_DATA_HOME="+filepath.Join(home, "data"), "XDG_CONFIG_HOME="+filepath.Join(home, "config"), "CODEX_HOME="+filepath.Join(home, "codex"), "TMPDIR="+home, "PYTHONPATH="+filepath.Join(repositoryRoot(t), "packages/codex-session-relay/src")+":"+filepath.Join(repositoryRoot(t), "packages/codex-session-relay"))
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatal(&captureFailure{err, string(output)})
}
tree := filepath.Join(amaRoot, "AttemptMessageAtomicity."+method)
raw, err := os.ReadFile(filepath.Join(tree, "capture.json"))
if err != nil {
t.Fatal(err)
}
var captured amaCapture
if err := json.Unmarshal(raw, &captured); err != nil {
t.Fatal(err)
}
if len(captured.Problems) > 0 {
t.Fatal(captured.Problems)
}
return tree, captured
}

type captureFailure struct {
err error
output string
}

func (e *captureFailure) Error() string { return e.err.Error() + "\n" + e.output }
func amaShow(t *testing.T, tree string, event string) map[string]any {
t.Helper()
home := pythonHome(t)
state := filepath.Join(tree, "state")
result := golang(t, home, "--state", state, "show", "--event", event, "--message")
if result.code != 0 {
t.Fatalf("show: %+v", result)
}
return decode(t, result.stdout)
}
func amaEvent(t *testing.T, tree string) string {
t.Helper()
// The Python case has exactly one event. Read its ID from its captured store.
state := filepath.Join(tree, "state", "relay.sqlite3")
db, err := sql.Open("sqlite", state)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var event string
if err := db.QueryRow("SELECT event_id FROM events").Scan(&event); err != nil {
t.Fatal(err)
}
return event
}
func amaTokenCLI(t *testing.T, value any) string {
t.Helper()
for _, line := range strings.Split(value.(string), "\n") {
if strings.HasPrefix(line, "requestId: ") {
return strings.TrimPrefix(line, "requestId: ")
}
}
t.Fatal("message carries no requestId")
return ""
}
func amaCompare(t *testing.T, got, want []any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("assertions Go=%v Python=%v", got, want)
}
}
func Test21_AMA4_show_message_returns_the_sent_attempt_after_a_send(t *testing.T) {
tree, py := amaTree(t, "test_show_message_returns_the_sent_attempt_after_a_send")
payload := amaShow(t, tree, amaEvent(t, tree))
entries := payload["attemptMessages"].([]any)
e := entries[0].(map[string]any)
_, preview := payload["previewMessage"]
amaCompare(t, []any{float64(len(entries)), e["requestId"], e["status"], amaTokenCLI(t, e["message"]), preview}, py.Captures)
}
func Test21_AMA5_show_message_offers_a_preview_only_before_anything_is_prepared(t *testing.T) {
tree, py := amaTree(t, "test_show_message_offers_a_preview_only_before_anything_is_prepared")
event := amaEvent(t, tree)
// Rewind a copy of the Python fixture to the queued, unprepared state. The
// original remains intact for the after-send command.
before := t.TempDir()
raw, err := os.ReadFile(filepath.Join(tree, "state", "relay.sqlite3"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(before, "relay.sqlite3"), raw, 0600); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", filepath.Join(before, "relay.sqlite3"))
if err != nil {
t.Fatal(err)
}
for _, statement := range []string{"DELETE FROM attempt_messages", "DELETE FROM attempts", "UPDATE deliveries SET attempt_count = 0, state = 'queued', next_eligible_at = NULL"} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
home := pythonHome(t)
prior := golang(t, home, "--state", before, "show", "--event", event, "--message")
if prior.code != 0 {
t.Fatalf("before show: %+v", prior)
}
pre := decode(t, prior.stdout)
preview, present := pre["previewMessage"]
got := []any{pre["attemptMessages"], present, strings.HasSuffix(amaTokenCLI(t, preview), "-a1")}
payload := amaShow(t, tree, event)
entries := payload["attemptMessages"].([]any)
_, afterPreview := payload["previewMessage"]
got = append(got, afterPreview, eRequest(entries))
amaCompare(t, got, py.Captures)
}
func eRequest(entries []any) any { return entries[0].(map[string]any)["requestId"] }
func Test21_AMA6_show_message_after_a_retry_lists_both_attempts_distinctly(t *testing.T) {
tree, py := amaTree(t, "test_show_message_after_a_retry_lists_both_attempts_distinctly")
payload := amaShow(t, tree, amaEvent(t, tree))
entries := payload["attemptMessages"].([]any)
ids, statuses := []any{}, []any{}
for _, item := range entries {
e := item.(map[string]any)
ids = append(ids, e["requestId"])
statuses = append(statuses, e["status"])
}
got := []any{ids, statuses}
for _, item := range entries {
e := item.(map[string]any)
got = append(got, amaTokenCLI(t, e["message"]))
}
amaCompare(t, got, py.Captures)
}
11 changes: 11 additions & 0 deletions internal/relay/cli/python_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ func TestKindModule_matches_python_for_an_unimportable_module(t *testing.T) {
}
}

func TestDelivery_kind_module_refusal_matches_python_before_ack_proof(t *testing.T) {
home := pythonHome(t)
args := []string{"--kind-module", "does_not_exist", "ack-proof", "--event", "0123456789abcdef0123456789abcdef", "--turn", "turn-1"}
for _, argv := range [][]string{args, args[2:]} {
py, got := python(t, home, argv...), golang(t, home, argv...)
if py.code != got.code || py.stdout != got.stdout || py.stderr != got.stderr {
t.Fatalf("%v: python %+v; go %+v", argv, py, got)
}
}
}

func TestRegistry_kind_module_refusal_matches_python_before_register(t *testing.T) {
home := pythonHome(t)
state := filepath.Join(home, "state")
Expand Down
24 changes: 21 additions & 3 deletions internal/relay/cli/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/thisisjun786/codex-relay-workflow/internal/contract"
"github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery"
"github.com/thisisjun786/codex-relay-workflow/internal/relay/registry"
"github.com/thisisjun786/codex-relay-workflow/internal/relay/store"
)
Expand Down Expand Up @@ -63,7 +64,7 @@ type PayloadExit struct {

func (e *PayloadExit) Error() string { return fmt.Sprint(get(e.Payload, "detail")) }

// ExitPayload lets another relay package print this answer whole (registry.PayloadError).
// ExitPayload lets another relay package print this answer whole (registry.PayloadError, delivery.PayloadError).
func (e *PayloadExit) ExitPayload() (contract.OrderedObject, int) { return e.Payload, e.Code }

// HostError is an unexpected failure whose Python class name is known, so the host envelope
Expand Down Expand Up @@ -131,6 +132,22 @@ func ExecuteAs(ctx context.Context, argv0 string, argv []string, stdout, stderr
return kindModuleRefusal(*kindModules)
})
}
if slices.Contains(delivery.CommandNames(), remaining[0]) {
code, _ := delivery.ExecuteAs(ctx, prog, argv, stdout, stderr, func(selection store.StateSelection, socket string) error {
if remaining[0] != "ack-proof" {
services := Services{Selection: selection, SocketPath: socket, AdapterRequested: socket != "", Program: program(argv0)}
refusal, err := selectionRefusal(services)
if err != nil {
return err
}
if refusal != nil {
return &PayloadExit{Payload: refusal, Code: contract.ExitRefused}
}
}
return kindModuleRefusal(*kindModules)
})
return code
Comment thread
thisisjun786 marked this conversation as resolved.
}
var command *Command
for i := range Commands {
if Commands[i].Name == remaining[0] {
Expand Down Expand Up @@ -280,13 +297,14 @@ func argparseMessage(err error) string {
return text
}

// allNames is every relay command this build registers: this package's and registry's.
// allNames is every relay command this build registers: this package's, registry's and delivery's.
func allNames() []string {
names := make([]string, 0, len(Commands))
for _, command := range Commands {
names = append(names, command.Name)
}
return append(names, registry.Names()...)
names = append(names, registry.Names()...)
return append(names, delivery.CommandNames()...)
}

func commandNames() string { return strings.Join(allNames(), ",") }
Expand Down
Loading
Loading