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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
8 changes: 3 additions & 5 deletions ROADMAP.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、急停、行为自然度与模型成本。

## 下一发布阶段

Expand Down
31 changes: 29 additions & 2 deletions controlplane/operation_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions controlplane/operation_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package controlplane
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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{})
Expand Down
7 changes: 3 additions & 4 deletions docs/game-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/game-adapters.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

[English](game-adapters.md) | [简体中文](game-adapters.zh-CN.md)

Adapter 把具体游戏的世界模型接到 `rin.host/v2`。Rin 不要求游戏使用 ECS、行为树、
特定脚本语言或特定网络架构;Adapter 只需提供可信观察、能力绑定和权威执行
具体游戏的集成边界集中在 Adapter。Rin 不要求游戏使用 ECS、行为树、特定脚本语言
或特定网络架构;Adapter 的完整职责以下方“必须实现”接口为准

## 必须实现

Expand Down