From 7f0ebfd7b54e5442a34dd99b2de42911417c0b40 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 26 Aug 2026 09:44:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=20CI=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 + README.md | 4 +- ROADMAP.en.md | 8 +- ROADMAP.md | 7 +- controlplane/operation_file.go | 31 ++++- controlplane/operation_file_test.go | 172 ++++++++++++++++++++++++++++ docs/game-adapters.md | 7 +- docs/game-adapters.zh-CN.md | 4 +- 8 files changed, 219 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d10d0..5e44c17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,11 @@ jobs: set -euo pipefail .cache/mcp-conformance/bin/rin-control >.cache/mcp-conformance/control.log 2>&1 & control_pid=$! + for attempt in $(seq 1 40); do + if curl --fail --silent --header "Authorization: Bearer ${RIN_CONTROL_TOKEN}" "${RIN_CONTROL_URL}/control/v2/info" >/dev/null; then break; fi + sleep 0.25 + done + curl --fail --silent --header "Authorization: Bearer ${RIN_CONTROL_TOKEN}" "${RIN_CONTROL_URL}/control/v2/info" >/dev/null .cache/mcp-conformance/bin/rin-mcp -conformance-addr 127.0.0.1:17380 >.cache/mcp-conformance/mcp.log 2>&1 & mcp_pid=$! trap 'kill "$mcp_pid" "$control_pid" 2>/dev/null || true' EXIT diff --git a/README.md b/README.md index 248aa0f..ad99152 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Rin 是一个引擎无关的游戏 Agent Harness。它把模型或外部 Agent 的低频决策,转换为 游戏 Host 可验证、可授权、可追踪的结构化行动;游戏仍然拥有世界状态和最终执行权。 -Minecraft、RPG、视觉小说或自研游戏只需要实现 Adapter,不需要把游戏对象、线程模型 -或私有 API 放进 Rin 核心。 +Minecraft、RPG、视觉小说或自研游戏的集成都集中在 Adapter;游戏侧仍负责权威状态、 +实时控制和持久化语义,游戏对象、线程模型或私有 API 不进入 Rin 核心。 当前源码版本为 `0.7.0` Preview。V2 是不兼容重构,不读取旧 Session/Proposal 协议状态。 公开契约分别为 `rin.host/v2`、`rin.control/v2` 和 Agent Task API `v1`。 diff --git a/ROADMAP.en.md b/ROADMAP.en.md index 5a46837..067fc90 100644 --- a/ROADMAP.en.md +++ b/ROADMAP.en.md @@ -25,12 +25,10 @@ changes; stability takes priority over compatibility with retired interfaces. ## Current gates -1. Converge public documentation and remove obsolete implementations, examples, - and compatibility claims. -2. Run complete build, race, contract, MCP, SDK, installer, and credential scans +1. Run complete build, race, contract, MCP, SDK, installer, and credential scans for Rin and the first real game adapter. -3. Fix reproducible findings without adding abstractions that have no consumer. -4. Enter human acceptance for long play, explicitly enabled multiplayer, GUI, +2. Fix reproducible findings without adding abstractions that have no consumer. +3. Enter human acceptance for long play, explicitly enabled multiplayer, GUI, emergency stop, behavioral naturalness, and model cost. ## Next release phase diff --git a/ROADMAP.md b/ROADMAP.md index 701d911..76e4957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,11 +22,10 @@ Rin 的方向是成为通用游戏 Agent Harness:模型在明确的负向约 ## 当前门禁 -1. 收敛公开文档和删除失效的旧实现、示例与兼容说明。 -2. 对 Rin 和首个真实游戏 Adapter 执行完整构建、Race、契约、MCP、SDK、安装器 +1. 对 Rin 和首个真实游戏 Adapter 执行完整构建、Race、契约、MCP、SDK、安装器 与凭据扫描。 -3. 修复复扫发现的真实问题,不增加没有消费者的抽象。 -4. 进入真人验收:长时间游玩、多人显式开放、GUI、急停、行为自然度与模型成本。 +2. 修复复扫发现的真实问题,不增加没有消费者的抽象。 +3. 进入真人验收:长时间游玩、多人显式开放、GUI、急停、行为自然度与模型成本。 ## 下一发布阶段 diff --git a/controlplane/operation_file.go b/controlplane/operation_file.go index 06a3775..3828422 100644 --- a/controlplane/operation_file.go +++ b/controlplane/operation_file.go @@ -412,9 +412,13 @@ func (service *Service) restoreOperations(state persistedOperations) error { service.emergencyStops[key] = stop } if state.PolicyState != nil { - if err := service.policyEngine.RestoreState(*state.PolicyState); err != nil { + migrated, err := service.restorePolicyState(*state.PolicyState) + if err != nil { return fmt.Errorf("%w: restore policy state: %v", ErrPersistence, err) } + if migrated { + service.operationCheckpointDirty = true + } } if len(state.Operations) != 0 { for _, operation := range service.operations { @@ -451,6 +455,25 @@ func operationPolicyReservationPending(status OperationStatus) bool { status == OperationAccepted || status == OperationRunning } +// restorePolicyState treats the active Policy config as the commit record for +// a forward config update. Policy config and operation checkpoints are separate +// atomic files, so a crash may leave the checkpoint one or more revisions +// behind. Engine.Update deliberately preserves usage and reservations across a +// revision change; rebasing only the checkpoint metadata reproduces that same +// transition without weakening rollback or same-revision digest checks. +func (service *Service) restorePolicyState(state policy.State) (bool, error) { + active := service.policyEngine.SnapshotStateFor(nil) + if state.PolicyRevision < active.PolicyRevision { + state.PolicyRevision = active.PolicyRevision + state.ConfigDigest = active.ConfigDigest + if err := service.policyEngine.RestoreState(state); err != nil { + return false, err + } + return true, nil + } + return false, service.policyEngine.RestoreState(state) +} + func validateOperationParentGraph(operations map[string]*operationState) error { complete := make(map[string]struct{}, len(operations)) for operationID := range operations { @@ -936,7 +959,11 @@ func (service *Service) flushOperationsLocked() error { if service.closed { return ErrClosed } - if !service.operationDirty && !service.operationCheckpointDirty { + // The Policy Engine may advance independently through the Management API. + // Persistent services therefore always publish a final checkpoint on clean + // shutdown even when ordinary operation state did not change. + if service.operationFile == nil && + !service.operationDirty && !service.operationCheckpointDirty { return nil } return service.writeOperationsLocked(maxOperationFileBytes) diff --git a/controlplane/operation_file_test.go b/controlplane/operation_file_test.go index b6051f3..9956787 100644 --- a/controlplane/operation_file_test.go +++ b/controlplane/operation_file_test.go @@ -3,9 +3,11 @@ package controlplane import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -135,6 +137,176 @@ func TestOperationFilePersistsControllerAndEmergencyStop(t *testing.T) { } } +func TestOperationFilePolicyRevisionCommitRecovery(t *testing.T) { + root := t.TempDir() + crashRoot := t.TempDir() + if err := os.Chmod(crashRoot, 0o700); err != nil { + t.Fatal(err) + } + now := time.UnixMilli(1_000_000) + service, lease, principal, actionHost := openActionFileHarness( + t, + root, + &now, + "instance.file.policy-update", + ) + configureActionGatewayBudget(t, service.policyEngine, 1) + accepted, err := service.SubmitAction( + context.Background(), + principal, + actionHost.input("request.file.policy-update", "action.file.policy-update"), + ) + if err != nil { + t.Fatalf("SubmitAction: %v", err) + } + pollHost(t, service, lease, 1) + if err := service.AcknowledgeHost( + "test.host", + lease.LeaseID, + HostAcknowledgement{OperationID: accepted.OperationID, Accepted: true}, + ); err != nil { + t.Fatalf("AcknowledgeHost: %v", err) + } + + configV2 := service.policyEngine.Config() + stateV2 := service.policyEngine.SnapshotState() + if len(stateV2.Usage) != 1 || len(stateV2.Reservations) != 1 { + t.Fatalf("policy state before update = %#v", stateV2) + } + checkpointV2 := readPersistedOperationsForTest(t, root) + if checkpointV2.PolicyState == nil || + checkpointV2.PolicyState.PolicyRevision != configV2.Revision { + t.Fatalf("checkpoint before update = %#v", checkpointV2.PolicyState) + } + crashPayload, err := os.ReadFile(filepath.Join(root, operationFileName)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(crashRoot, operationFileName), + crashPayload, + 0o600, + ); err != nil { + t.Fatal(err) + } + + configV3 := configV2 + configV3.Revision++ + configV3.Budgets[0].MaxActions++ + if err := service.policyEngine.Update(configV3); err != nil { + t.Fatalf("Policy Update: %v", err) + } + stateV3 := service.policyEngine.SnapshotState() + assertPolicyUsageAndReservations(t, stateV2, stateV3) + if err := service.Close(); err != nil { + t.Fatalf("Close after policy update: %v", err) + } + + cleanCheckpoint := readPersistedOperationsForTest(t, root) + if cleanCheckpoint.PolicyState == nil || + cleanCheckpoint.PolicyState.PolicyRevision != configV3.Revision { + t.Fatalf("clean-close checkpoint = %#v", cleanCheckpoint.PolicyState) + } + cleanEngine, err := policy.New(configV3) + if err != nil { + t.Fatal(err) + } + cleanOptions := fileTestOptions(&now, 128) + cleanOptions.ActionHost = actionHost + cleanOptions.PolicyEngine = cleanEngine + cleanRestored, err := OpenFile(root, cleanOptions) + if err != nil { + t.Fatalf("OpenFile after clean close: %v", err) + } + assertRestoredAcceptedOperation(t, cleanRestored, principal, accepted.OperationID) + assertPolicyUsageAndReservations(t, stateV2, cleanEngine.SnapshotState()) + if err := cleanRestored.Close(); err != nil { + t.Fatalf("Close clean restore: %v", err) + } + + crashEngine, err := policy.New(configV3) + if err != nil { + t.Fatal(err) + } + crashOptions := fileTestOptions(&now, 256) + crashOptions.ActionHost = actionHost + crashOptions.PolicyEngine = crashEngine + crashRestored, err := OpenFile(crashRoot, crashOptions) + if err != nil { + t.Fatalf("OpenFile across policy/checkpoint crash window: %v", err) + } + if !crashRestored.operationCheckpointDirty { + t.Fatal("forward-migrated checkpoint was not marked dirty") + } + assertRestoredAcceptedOperation(t, crashRestored, principal, accepted.OperationID) + assertPolicyUsageAndReservations(t, stateV2, crashEngine.SnapshotState()) + if err := crashRestored.Close(); err != nil { + t.Fatalf("Close crash-window restore: %v", err) + } + migratedCheckpoint := readPersistedOperationsForTest(t, crashRoot) + if migratedCheckpoint.PolicyState == nil || + migratedCheckpoint.PolicyState.PolicyRevision != configV3.Revision { + t.Fatalf("migrated checkpoint = %#v", migratedCheckpoint.PolicyState) + } + + rollbackEngine, err := policy.New(configV2) + if err != nil { + t.Fatal(err) + } + rollbackOptions := fileTestOptions(&now, 384) + rollbackOptions.ActionHost = actionHost + rollbackOptions.PolicyEngine = rollbackEngine + if _, err := OpenFile(crashRoot, rollbackOptions); !errors.Is(err, ErrPersistence) { + t.Fatalf("future checkpoint rollback error = %v", err) + } + changedV3 := configV3 + changedV3.Budgets[0].MaxActions++ + changedEngine, err := policy.New(changedV3) + if err != nil { + t.Fatal(err) + } + changedOptions := fileTestOptions(&now, 512) + changedOptions.ActionHost = actionHost + changedOptions.PolicyEngine = changedEngine + if _, err := OpenFile(crashRoot, changedOptions); !errors.Is(err, ErrPersistence) { + t.Fatalf("same-revision digest mismatch error = %v", err) + } +} + +func readPersistedOperationsForTest(t *testing.T, root string) persistedOperations { + t.Helper() + payload, err := os.ReadFile(filepath.Join(root, operationFileName)) + if err != nil { + t.Fatal(err) + } + var state persistedOperations + if err := json.Unmarshal(payload, &state); err != nil { + t.Fatal(err) + } + return state +} + +func assertPolicyUsageAndReservations(t *testing.T, want, got policy.State) { + t.Helper() + if !reflect.DeepEqual(got.Usage, want.Usage) || + !reflect.DeepEqual(got.Reservations, want.Reservations) { + t.Fatalf("policy runtime state changed: got=%#v want=%#v", got, want) + } +} + +func assertRestoredAcceptedOperation( + t *testing.T, + service *Service, + principal host.Principal, + operationID string, +) { + t.Helper() + view, err := service.GetOperation(principal, operationID) + if err != nil || view.Status != OperationAccepted { + t.Fatalf("restored operation = %#v, %v", view, err) + } +} + func TestOperationFileRejectsConcurrentWriterAndReleasesLock(t *testing.T) { root := t.TempDir() first, err := OpenFile(root, Options{}) diff --git a/docs/game-adapters.md b/docs/game-adapters.md index 2f0448e..51062b0 100644 --- a/docs/game-adapters.md +++ b/docs/game-adapters.md @@ -2,10 +2,9 @@ [English](game-adapters.md) | [简体中文](game-adapters.zh-CN.md) -An adapter connects a concrete game model to `rin.host/v2`. Rin does not require -ECS, behavior trees, a particular scripting language, or a network topology. An -adapter supplies trusted observations, capability binding, and authoritative -execution. +Game-specific integration lives in the adapter. Rin does not require ECS, +behavior trees, a particular scripting language, or a network topology. The +complete adapter responsibilities are defined by the required interface below. ## Required boundary diff --git a/docs/game-adapters.zh-CN.md b/docs/game-adapters.zh-CN.md index c93fb8d..45cf6d0 100644 --- a/docs/game-adapters.zh-CN.md +++ b/docs/game-adapters.zh-CN.md @@ -2,8 +2,8 @@ [English](game-adapters.md) | [简体中文](game-adapters.zh-CN.md) -Adapter 把具体游戏的世界模型接到 `rin.host/v2`。Rin 不要求游戏使用 ECS、行为树、 -特定脚本语言或特定网络架构;Adapter 只需提供可信观察、能力绑定和权威执行。 +具体游戏的集成边界集中在 Adapter。Rin 不要求游戏使用 ECS、行为树、特定脚本语言 +或特定网络架构;Adapter 的完整职责以下方“必须实现”接口为准。 ## 必须实现