Skip to content

Commit c8dd06a

Browse files
authored
Honor initialization execution budget (#647)
1 parent ba4047d commit c8dd06a

18 files changed

Lines changed: 302 additions & 60 deletions

‎apps/daemon/internal/dispatch/runtime_preparation.go‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,18 @@ import (
1313
"github.com/google/uuid"
1414
)
1515

16-
const runtimePreparationTimeout = 120 * time.Second
17-
1816
// Router.mu protects one connection-local transfer. Partial installation data
1917
// belongs to the bound Environment and is never removed by transfer cleanup.
2018
type runtimePreparationTransfer struct {
21-
envelope proto.Envelope
22-
request proto.RuntimePreparePayload
23-
data []byte
24-
ready chan struct{}
25-
cancel context.CancelFunc
26-
finished bool
27-
apply bool
28-
uncertain bool
19+
transferDeadline time.Time
20+
envelope proto.Envelope
21+
request proto.RuntimePreparePayload
22+
data []byte
23+
ready chan struct{}
24+
cancel context.CancelFunc
25+
finished bool
26+
apply bool
27+
uncertain bool
2928
}
3029

3130
func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) error {
@@ -65,9 +64,10 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e
6564
r.mu.Unlock()
6665
return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable"))
6766
}
68-
owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), runtimePreparationTimeout)
67+
owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Duration(request.BudgetMS)*time.Millisecond)
6968
u := &runtimePreparationTransfer{
70-
envelope: env, request: request, data: make([]byte, 0, request.SizeBytes),
69+
transferDeadline: time.Now().Add(time.Duration(proto.RuntimePrepareTransferBudgetMS) * time.Millisecond),
70+
envelope: env, request: request, data: make([]byte, 0, request.SizeBytes),
7171
ready: make(chan struct{}), cancel: cancel,
7272
}
7373
r.runtimePreparation = u
@@ -100,7 +100,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e
100100
return nil
101101
}
102102
apply := false
103-
if request.Step == "commit" && len(u.data) == u.request.SizeBytes {
103+
if request.Step == "commit" && len(u.data) == u.request.SizeBytes && time.Now().Before(u.transferDeadline) {
104104
if u.request.Action == "finalize" || u.request.Action == "initialize" {
105105
apply = true
106106
} else {
@@ -135,7 +135,10 @@ func (r *Router) finishRuntimePreparationTransferLocked(u *runtimePreparationTra
135135
func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePreparationTransfer, apply func(context.Context, proto.RuntimePreparePayload, []byte) error) {
136136
defer r.shutdownWG.Done()
137137
defer u.cancel()
138+
staging := time.NewTimer(time.Until(u.transferDeadline))
139+
defer staging.Stop()
138140
select {
141+
case <-staging.C:
139142
case <-u.ready:
140143
case <-r.shutdownCh:
141144
case <-ctx.Done():

‎apps/daemon/internal/dispatch/runtime_preparation_test.go‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path/filepath"
1111
"testing"
12+
"testing/synctest"
1213
"time"
1314

1415
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
@@ -57,7 +58,7 @@ func capabilityEnvelope(t *testing.T, id string, request proto.RuntimePreparePay
5758
func capabilityBegin(environment, session string, body []byte) proto.RuntimePreparePayload {
5859
digest := sha256.Sum256(body)
5960
return proto.RuntimePreparePayload{
60-
Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session,
61+
BudgetMS: 300000, Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session,
6162
Skill: &agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."},
6263
SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]),
6364
}
@@ -247,7 +248,7 @@ func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing.
247248
r, sender, environment, session := capabilitiesTestRouter(t)
248249
ctx, cancel := context.WithCancel(context.Background())
249250
id := uuid.NewString()
250-
request := proto.RuntimePreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}}
251+
request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}}
251252
owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true}
252253
close(owner.ready)
253254
r.runtimePreparation = owner
@@ -346,3 +347,79 @@ func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) {
346347
t.Fatal("shutdown claimed uncertain mutation settled")
347348
}
348349
}
350+
351+
func TestRuntimePreparationStagingDeadlineRejectsWithoutApplying(t *testing.T) {
352+
synctest.Test(t, func(t *testing.T) {
353+
r, sender, environment, session := capabilitiesTestRouter(t)
354+
id := uuid.NewString()
355+
request := capabilityBegin(environment, session, []byte("abc"))
356+
if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil {
357+
t.Fatal(err)
358+
}
359+
capabilitiesReceipt(t, sender, id, "ready")
360+
time.Sleep(121 * time.Second)
361+
synctest.Wait()
362+
capabilitiesReceipt(t, sender, id, "rejected")
363+
r.mu.Lock()
364+
pending := r.runtimePreparation != nil
365+
r.mu.Unlock()
366+
if pending {
367+
t.Fatal("uncommitted staging retained ownership")
368+
}
369+
shutdownCapabilitiesRouter(t, r)
370+
})
371+
}
372+
373+
func TestRuntimePreparationApplyOutlivesStagingAndHonorsBudget(t *testing.T) {
374+
synctest.Test(t, func(t *testing.T) {
375+
r, sender, environment, session := capabilitiesTestRouter(t)
376+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
377+
id := uuid.NewString()
378+
request := capabilityBegin(environment, session, []byte("abc"))
379+
owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true, transferDeadline: time.Now().Add(2 * time.Minute)}
380+
close(owner.ready)
381+
r.runtimePreparation = owner
382+
r.shutdownWG.Add(1)
383+
stopped := make(chan struct{})
384+
go r.runRuntimePreparationTransfer(ctx, owner, func(ctx context.Context, _ proto.RuntimePreparePayload, _ []byte) error {
385+
<-ctx.Done()
386+
close(stopped)
387+
return ctx.Err()
388+
})
389+
time.Sleep(121 * time.Second)
390+
synctest.Wait()
391+
select {
392+
case <-stopped:
393+
t.Fatal("staging timeout killed apply")
394+
default:
395+
}
396+
time.Sleep(180 * time.Second)
397+
synctest.Wait()
398+
<-stopped
399+
capabilitiesReceipt(t, sender, id, "unknown")
400+
// Unknown effects remain retained; this test must not claim safe replay.
401+
r.mu.Lock()
402+
unknown := r.runtimePreparation == owner && owner.uncertain
403+
r.mu.Unlock()
404+
if !unknown {
405+
t.Fatal("unknown apply lost ownership")
406+
}
407+
})
408+
}
409+
410+
func TestRuntimePreparationHonorsBeginBudgetDuringStaging(t *testing.T) {
411+
synctest.Test(t, func(t *testing.T) {
412+
r, sender, environment, session := capabilitiesTestRouter(t)
413+
id := uuid.NewString()
414+
request := capabilityBegin(environment, session, []byte("abc"))
415+
request.BudgetMS = 10
416+
if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil {
417+
t.Fatal(err)
418+
}
419+
capabilitiesReceipt(t, sender, id, "ready")
420+
time.Sleep(11 * time.Millisecond)
421+
synctest.Wait()
422+
capabilitiesReceipt(t, sender, id, "rejected")
423+
shutdownCapabilitiesRouter(t, r)
424+
})
425+
}

‎apps/daemon/internal/localworkspace/runtime_initialization_process.go‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,13 @@ func initializationEnvironment(configured map[string]string) []string {
101101
// The shared process owner settles the leader and descendants. Readers finish
102102
// before return; output is discarded with constant memory, never put in errors.
103103
func runInitializationProcess(ctx context.Context, binary string, args []string, directory string, env []string) error {
104-
operation, cancel := context.WithTimeout(ctx, 30*time.Minute)
105-
defer cancel()
106-
if operation.Err() != nil {
104+
if ctx.Err() != nil {
107105
return ErrInitializationUnconfirmed
108106
}
109107
if info, err := os.Stat(directory); err != nil || !info.IsDir() {
110108
return &InitializationFailure{}
111109
}
112-
process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args,
110+
process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args,
113111
Dir: directory, Env: env, KillTimeout: 250 * time.Millisecond})
114112
if err != nil {
115113
return ErrInitializationUnconfirmed
@@ -127,7 +125,7 @@ func runInitializationProcess(ctx context.Context, binary string, args []string,
127125
}
128126
first, second := <-finished, <-finished
129127
_ = process.Wait()
130-
if operation.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil {
128+
if ctx.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil {
131129
return ErrInitializationUnconfirmed
132130
}
133131
code := process.Cmd.ProcessState.ExitCode()

‎apps/daemon/internal/localworkspace/runtime_initialization_test.go‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) {
348348
t.Fatal(err)
349349
}
350350
digest := sha256.Sum256(nil)
351-
input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID,
351+
input := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID,
352352
Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])}
353353
if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) {
354354
t.Fatal("finalized Runtime accepted file", err)
@@ -361,3 +361,25 @@ func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) {
361361
t.Fatal("finalized Runtime accepted initialize", err)
362362
}
363363
}
364+
365+
func TestRuntimeInitializationDeadlineStopsDescendants(t *testing.T) {
366+
binary, err := os.Executable()
367+
if err != nil {
368+
t.Fatal(err)
369+
}
370+
directory := t.TempDir()
371+
ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
372+
defer cancel()
373+
env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory)
374+
err = runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env)
375+
if !errors.Is(err, ErrInitializationUnconfirmed) || !errors.Is(ctx.Err(), context.DeadlineExceeded) {
376+
t.Fatal("deadline outcome", err, ctx.Err())
377+
}
378+
if _, err := os.Stat(filepath.Join(directory, "started")); err != nil {
379+
t.Fatal("fixture did not start", err)
380+
}
381+
time.Sleep(1100 * time.Millisecond)
382+
if _, err := os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) {
383+
t.Fatal("descendant survived deadline")
384+
}
385+
}

‎apps/daemon/internal/localworkspace/snapshot_marker_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func TestSnapshotMarkerRefusesDamagedState(t *testing.T) {
186186
func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) {
187187
b, _ := markerBinding(t)
188188
identity := b.capabilityIdentity()
189-
finalize := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}}
189+
finalize := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}}
190190
if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err != nil {
191191
t.Fatal(err)
192192
}
@@ -197,7 +197,7 @@ func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T)
197197
if err := os.RemoveAll(b.capabilityRoot); err != nil {
198198
t.Fatal(err)
199199
}
200-
skill := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
200+
skill := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
201201
if err := b.ApplyRuntimePreparation(t.Context(), skill, []byte("x")); err == nil {
202202
t.Fatal("completed identity accepted import")
203203
}

‎contracts/agents-api/environments.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ An Environment's initialization is `pending`, `running`, `complete` or `failed`,
229229

230230
- The Worker's initialization scheduler scans 32 Environments at a time, wraps at the end and bounds concurrent preparations by execution concurrency, independently of Provider maintenance.
231231
- A missing socket does not consume a pending attempt. An unavailable Harness fails before installation. Each operation rechecks current authority and the original socket; completion rechecks the exact binding.
232-
- Each file transfer, configure, Skill, Plugin, package and setup step has a two-minute budget; the whole initialization has 30 minutes. Initial input keeps its five-minute admission deadline, so large installations should start from an idle Session.
232+
- Each file, Skill or Plugin transfer has a two-minute staging budget. Configure, package installation, setup commands and snapshot finalization share the whole initialization’s 30-minute budget, whose remaining duration Core carries on each operation and the Runtime enforces. Initial input keeps its five-minute admission deadline, so large installations should start from an idle Session.
233233
- A running initialization whose owner is lost, including across a Core restart, fails as unconfirmed; nothing is replayed. A completed Environment never reinstalls on reconnect or native recovery, so later user changes survive.
234234
- Failure is terminal for the Session but destroys neither compute nor files.
235235

‎contracts/agents-api/zh/environments.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: "环境与模板"
33
source: contracts/agents-api/environments.md
4-
source_hash: 48b9d8679a983adc5eb9164c9a94e1137bf75800fbb1a0f81a0ae1b3cfccdafb
4+
source_hash: 9007be1099cf50f5ba376d2de4a060d039b6ee0b1c13c78a94665c28c2fc3fc6
55
---
66

77
Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment Template 是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。
@@ -231,7 +231,7 @@ Environment 的初始化状态为 `pending`、`running`、`complete` 或 `failed
231231

232232
- Worker 的初始化调度器每次扫描 32 个 Environment,在末尾循环回绕,并依据执行并发度限制并发准备,且独立于 Provider 维护。
233233
- 缺少套接字不会消耗一次 pending 尝试。Harness 不可用时,会在安装前失败。每个操作都会重新检查当前权限和原始套接字;完成时还会重新检查精确绑定。
234-
- 每个文件传输、configure、Skill、Plugin、软件包和设置步骤都有两分钟的预算;整个初始化过程有 30 分钟。初始输入仍保留其五分钟接纳期限,因此大型安装应从空闲 Session 开始。
234+
- 每个文件、Skill 或 Plugin 传输的暂存预算为两分钟。configure、软件包安装、设置命令和快照最终确定共享整个初始化过程的 30 分钟预算,Core 在每项操作中携带剩余时长,由 Runtime 执行。初始输入仍保留其五分钟接纳期限,因此大型安装应从空闲 Session 开始。
235235
- 正在运行且所有权丧失的初始化,包括跨 Core 重启丧失所有权,会作为未确认而失败;不会重播任何内容。已完成的 Environment 在重连或原生恢复时绝不会重新安装,因此用户后续更改会保留下来。
236236
- 失败对 Session 而言是终止状态,但不会销毁计算资源或文件。
237237

‎docs/runtime-protocol.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Usage frames and the final usage snapshot each carry the cumulative measurement
110110

111111
## Preparation and execution order
112112

113-
Environment initialization uses `runtime_prepare` on every connection, managed or user-owned; the [Environment contract](../contracts/agents-api/environments.md#runtime-capability-preparation) owns what is prepared and when. For a file or archive, send `begin`, wait for `ready`, send ordered chunks and await each matching `received` offset, then send `commit` and await `completed`. Initialization and finalization have typed headers without file data. Validate the expected outcome, offset, size and finite error code with the shared validator. One transfer is allowed per connection. A chunk receipt confirms staged bytes, not installation; a completed commit confirms that operation, not that a later Turn ran.
113+
Environment initialization uses `runtime_prepare` on every connection, managed or user-owned; the [Environment contract](../contracts/agents-api/environments.md#runtime-capability-preparation) owns what is prepared and when. For a file or archive, send `begin`, wait for `ready`, send ordered chunks and await each matching `received` offset, then send `commit` and await `completed`. Initialization and finalization have typed headers without file data. Every `begin` requires `budget_ms`, an integer from 1 through 1800000 carrying the remaining Core-owned initialization budget; chunks and commits omit it. The Runtime enforces that relative budget from receipt of `begin`, and Core waits no longer than its original operation deadline. The complete `begin`–`commit` staging phase has a separate two-minute limit; applying a committed operation and waiting for `completed` use the remaining initialization budget, not the staging limit. Expiry before application rejects the transfer; expiry during application reports `unknown` after local mutations stop and never authorizes replay. Validate the expected outcome, offset, size and finite error code with the shared validator. One transfer is allowed per connection. A chunk receipt confirms staged bytes, not installation; a completed commit confirms that operation, not that a later Turn ran.
114114

115115
An execution Turn runs in five steps:
116116

0 commit comments

Comments
 (0)