From b7891e5732d2264f05b95abe68ceb037cbb69f7d Mon Sep 17 00:00:00 2001 From: Janko Date: Sat, 14 Mar 2026 22:50:43 +0100 Subject: [PATCH 1/5] feat(sdk): add effect lifecycle model and treasury sentinel demo agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effect model makes crash-safe side effects a runtime primitive. IntentState machine: Recorded → InFlight → Confirmed, with the "resume rule" (InFlight → Unresolved on Unmarshal) for safe crash recovery. Treasury sentinel agent demonstrates the pattern end-to-end. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 +- Makefile | 16 +- agents/sentinel/Makefile | 7 + agents/sentinel/agent.manifest.json | 10 + agents/sentinel/go.mod | 7 + agents/sentinel/main.go | 259 +++++++++++++++++++++++ docs/runtime/EFFECT_LIFECYCLE.md | 312 ++++++++++++++++++++++++++++ scripts/demo-sentinel.sh | 142 +++++++++++++ sdk/igor/effects.go | 236 +++++++++++++++++++++ sdk/igor/effects_test.go | 305 +++++++++++++++++++++++++++ 10 files changed, 1300 insertions(+), 4 deletions(-) create mode 100644 agents/sentinel/Makefile create mode 100644 agents/sentinel/agent.manifest.json create mode 100644 agents/sentinel/go.mod create mode 100644 agents/sentinel/main.go create mode 100644 docs/runtime/EFFECT_LIFECYCLE.md create mode 100755 scripts/demo-sentinel.sh create mode 100644 sdk/igor/effects.go create mode 100644 sdk/igor/effects_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 2ab2395..504efd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ make build # Build igord → bin/igord make agent # Build example WASM agent → agents/example/agent.wasm make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm +make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm make test # Run tests: go test -v ./... make lint # golangci-lint (5m timeout) make vet # go vet @@ -27,6 +28,7 @@ make run-agent # Build + run example agent with budget 1.0 make demo # Build + run bridge reconciliation demo make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify) make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify) +make demo-sentinel # Build + run treasury sentinel demo (effect lifecycle → crash → reconcile) make clean # Remove bin/, checkpoints/, agent.wasm ``` @@ -35,7 +37,7 @@ Run a single test: `go test -v -run TestName ./internal/agent/...` Run manually (new subcommands): ```bash ./bin/igord run --budget 1.0 agents/heartbeat/agent.wasm -./bin/igord resume checkpoints/heartbeat/checkpoint.ckpt agents/heartbeat/agent.wasm +./bin/igord resume --checkpoint checkpoints/heartbeat/checkpoint.ckpt --wasm agents/heartbeat/agent.wasm ./bin/igord verify checkpoints/heartbeat/history/ ./bin/igord inspect checkpoints/heartbeat/checkpoint.ckpt ``` @@ -79,9 +81,11 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi - `pkg/manifest/` — Capability manifest parsing and validation - `pkg/protocol/` — Message types: `AgentPackage`, `AgentTransfer`, `AgentStarted` - `pkg/receipt/` — Payment receipt data structure, Ed25519 signing, binary serialization -- `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization +- `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization, EffectLog for intent tracking across checkpoint/resume +- `sdk/igor/effects.go` — Effect lifecycle primitives: EffectLog, IntentState (Recorded→InFlight→Confirmed/Unresolved→Compensated), the resume rule (InFlight→Unresolved on Unmarshal) - `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks - `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume +- `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation - `agents/example/` — Original demo agent (Survivor) from research phases - `scripts/demo-portable.sh` — End-to-end portable agent demo @@ -90,7 +94,7 @@ Source checkpoints → packages (WASM + checkpoint + budget) → transfers over ### CLI subcommands (Product Phase 1) - `igord run [flags] ` — run agent with new identity (`--budget`, `--checkpoint-dir`, `--agent-id`) -- `igord resume ` — resume agent from checkpoint file +- `igord resume --checkpoint --wasm ` — resume agent from checkpoint file - `igord verify ` — verify checkpoint lineage chain - `igord inspect ` — display checkpoint details with DID identity diff --git a/Makefile b/Makefile index 4b634f0..72d58af 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher run-agent demo demo-portable demo-pricewatcher gh-check gh-metadata gh-release +.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel run-agent demo demo-portable demo-pricewatcher demo-sentinel gh-check gh-metadata gh-release .DEFAULT_GOAL := help @@ -9,6 +9,7 @@ AGENT_DIR := agents/example HEARTBEAT_AGENT_DIR := agents/heartbeat RECONCILIATION_AGENT_DIR := agents/reconciliation PRICEWATCHER_AGENT_DIR := agents/pricewatcher +SENTINEL_AGENT_DIR := agents/sentinel # Go commands GOCMD := go @@ -56,6 +57,7 @@ clean: ## Remove build artifacts rm -f agents/heartbeat/agent.wasm rm -f agents/reconciliation/agent.wasm rm -f agents/pricewatcher/agent.wasm + rm -f agents/sentinel/agent.wasm @echo "Clean complete" test: ## Run tests (with race detector) @@ -127,6 +129,13 @@ agent-pricewatcher: ## Build price watcher demo agent WASM cd $(PRICEWATCHER_AGENT_DIR) && $(MAKE) build @echo "Agent built: $(PRICEWATCHER_AGENT_DIR)/agent.wasm" +agent-sentinel: ## Build treasury sentinel demo agent WASM + @echo "Building sentinel agent..." + @which tinygo > /dev/null || \ + (echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1) + cd $(SENTINEL_AGENT_DIR) && $(MAKE) build + @echo "Agent built: $(SENTINEL_AGENT_DIR)/agent.wasm" + demo: build agent-reconciliation ## Build and run reconciliation demo @echo "Building demo runner..." @mkdir -p $(BINARY_DIR) @@ -144,6 +153,11 @@ demo-pricewatcher: build agent-pricewatcher ## Run the price watcher demo (fetch @chmod +x scripts/demo-pricewatcher.sh @./scripts/demo-pricewatcher.sh +demo-sentinel: build agent-sentinel ## Run the treasury sentinel demo (effect lifecycle, crash recovery) + @echo "Running Treasury Sentinel Demo..." + @chmod +x scripts/demo-sentinel.sh + @./scripts/demo-sentinel.sh + check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests) @echo "All checks passed" diff --git a/agents/sentinel/Makefile b/agents/sentinel/Makefile new file mode 100644 index 0000000..7fa1ed3 --- /dev/null +++ b/agents/sentinel/Makefile @@ -0,0 +1,7 @@ +.PHONY: build clean + +build: + tinygo build -target=wasi -no-debug -o agent.wasm . + +clean: + rm -f agent.wasm diff --git a/agents/sentinel/agent.manifest.json b/agents/sentinel/agent.manifest.json new file mode 100644 index 0000000..c9431ee --- /dev/null +++ b/agents/sentinel/agent.manifest.json @@ -0,0 +1,10 @@ +{ + "capabilities": { + "clock": { "version": 1 }, + "rand": { "version": 1 }, + "log": { "version": 1 } + }, + "resource_limits": { + "max_memory_bytes": 67108864 + } +} diff --git a/agents/sentinel/go.mod b/agents/sentinel/go.mod new file mode 100644 index 0000000..7353b91 --- /dev/null +++ b/agents/sentinel/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/sentinel + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/sentinel/main.go b/agents/sentinel/main.go new file mode 100644 index 0000000..643cfb0 --- /dev/null +++ b/agents/sentinel/main.go @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +// Treasury Sentinel: a DeFi operator that monitors a treasury balance, +// triggers refills when it drops below threshold, and handles crash +// recovery using Igor's effect model. +// +// The world is simulated internally — no real chain dependency. The point +// is to demonstrate the effect lifecycle under partial failure: +// - Intent is recorded before any external action +// - Checkpoint captures the intent +// - If crashed mid-flight, resume discovers Unresolved intents +// - Agent reconciles by checking simulated bridge status +// - No blind retry, no duplicate transfer +package main + +import ( + "encoding/binary" + + "github.com/simonovic86/igor/sdk/igor" +) + +// Treasury simulation parameters. +const ( + initialBalance uint64 = 10_000_00 // $10,000.00 in cents + refillThreshold uint64 = 3_000_00 // refill when below $3,000 + refillAmount uint64 = 5_000_00 // refill $5,000 each time + spendMin uint64 = 50_00 // min spend per tick: $50 + spendRange uint64 = 200_00 // spend variation: up to $200 extra +) + +// Sentinel phases. +const ( + phaseMonitoring uint8 = 0 // watching balance + phaseReconciling uint8 = 1 // handling unresolved intents on resume +) + +// Sentinel implements a treasury monitoring agent with effect-safe refills. +type Sentinel struct { + TickCount uint64 + BirthNano int64 + LastNano int64 + + // Treasury state (simulated). + Balance uint64 // current treasury balance in cents + RefillCount uint32 // successful refills completed + SpendTotal uint64 // total spent across lifetime + + // Effect tracking. + Effects igor.EffectLog + Phase uint8 + + // RNG state for deterministic simulation (seeded from igor.RandBytes). + rngState uint64 +} + +func (s *Sentinel) Init() {} + +func (s *Sentinel) Tick() bool { + s.TickCount++ + now := igor.ClockNow() + if s.BirthNano == 0 { + s.BirthNano = now + s.Balance = initialBalance + // Seed RNG. + var seed [8]byte + _ = igor.RandBytes(seed[:]) + s.rngState = binary.LittleEndian.Uint64(seed[:]) + igor.Logf("[sentinel] Treasury initialized: $%d.%02d", s.Balance/100, s.Balance%100) + } + s.LastNano = now + ageSec := (s.LastNano - s.BirthNano) / 1_000_000_000 + + // On resume, handle unresolved intents first. + unresolved := s.Effects.Unresolved() + if len(unresolved) > 0 { + s.Phase = phaseReconciling + for _, intent := range unresolved { + s.reconcile(intent, ageSec) + } + s.Effects.Prune() + s.Phase = phaseMonitoring + return true + } + + // Check for pending intents that need execution. + pending := s.Effects.Pending() + for _, intent := range pending { + if intent.State == igor.Recorded { + return s.executeRefill(intent, ageSec) + } + } + + // Normal operation: simulate spending. + spend := spendMin + (s.rng() % spendRange) + if spend > s.Balance { + spend = s.Balance + } + s.Balance -= spend + s.SpendTotal += spend + + // Log every 5 ticks. + if s.TickCount%5 == 0 { + igor.Logf("[sentinel] tick=%d age=%ds balance=$%d.%02d spent=$%d.%02d refills=%d", + s.TickCount, ageSec, + s.Balance/100, s.Balance%100, + s.SpendTotal/100, s.SpendTotal%100, + s.RefillCount) + } + + // Check if refill needed. + if s.Balance < refillThreshold { + igor.Logf("[sentinel] ALERT: balance $%d.%02d below threshold $%d.%02d — initiating refill", + s.Balance/100, s.Balance%100, + refillThreshold/100, refillThreshold%100) + return s.recordRefillIntent(ageSec) + } + + return false +} + +// recordRefillIntent creates a new refill intent. The intent is captured +// in the next checkpoint. The actual transfer happens in a subsequent tick. +func (s *Sentinel) recordRefillIntent(ageSec int64) bool { + // Generate idempotency key. + var key [16]byte + _ = igor.RandBytes(key[:]) + + // Encode transfer parameters. + data := igor.NewEncoder(32). + Uint64(refillAmount). + Uint64(s.Balance). + Int64(ageSec). + Finish() + + if err := s.Effects.Record(key[:], data); err != nil { + igor.Logf("[sentinel] ERROR: failed to record intent: %s", err.Error()) + return false + } + + igor.Logf("[sentinel] Intent RECORDED: refill $%d.%02d (key=%x...)", + refillAmount/100, refillAmount%100, key[:4]) + igor.Logf("[sentinel] Waiting for checkpoint before execution...") + return true // request fast tick so we proceed quickly +} + +// executeRefill transitions a Recorded intent to InFlight and performs +// the simulated bridge transfer. +func (s *Sentinel) executeRefill(intent igor.Intent, ageSec int64) bool { + if err := s.Effects.Begin(intent.ID); err != nil { + igor.Logf("[sentinel] ERROR: failed to begin intent: %s", err.Error()) + return false + } + + igor.Logf("[sentinel] Refill IN-FLIGHT: executing bridge transfer (key=%x...)", intent.ID[:4]) + igor.Logf("[sentinel] Bridge: source=chain-A dest=chain-B amount=$%d.%02d", + refillAmount/100, refillAmount%100) + + // === THIS IS THE DANGER ZONE === + // If the process crashes between Begin and Confirm, the intent becomes + // Unresolved on resume. The agent must then reconcile. + + // Simulate the bridge transfer succeeding. + s.Balance += refillAmount + s.RefillCount++ + + if err := s.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[sentinel] ERROR: failed to confirm intent: %s", err.Error()) + return false + } + + igor.Logf("[sentinel] Refill CONFIRMED: balance now $%d.%02d (key=%x...)", + s.Balance/100, s.Balance%100, intent.ID[:4]) + + s.Effects.Prune() + return true +} + +// reconcile handles an unresolved intent after crash recovery. +// In a real system, this would query the bridge API to check if the +// transfer completed. Here we simulate: if the idempotency key's first +// byte is even, the bridge completed the transfer before the crash. +func (s *Sentinel) reconcile(intent igor.Intent, ageSec int64) { + igor.Logf("[sentinel] RECONCILING: found unresolved intent (key=%x...)", intent.ID[:4]) + igor.Logf("[sentinel] Checking bridge status for in-flight transfer...") + + // Simulate bridge status check. + // In production: HTTP call to bridge API with idempotency key. + bridgeCompleted := (intent.ID[0] % 2) == 0 + + if bridgeCompleted { + // The transfer happened before the crash. Credit the balance, + // but do NOT re-execute the transfer. + s.Balance += refillAmount + s.RefillCount++ + + if err := s.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[sentinel] ERROR: reconcile confirm failed: %s", err.Error()) + return + } + + igor.Logf("[sentinel] Reconciled: transfer COMPLETED before crash") + igor.Logf("[sentinel] Balance credited: $%d.%02d (no duplicate execution)", + s.Balance/100, s.Balance%100) + } else { + // The transfer did not complete. Safe to compensate and retry. + if err := s.Effects.Compensate(intent.ID); err != nil { + igor.Logf("[sentinel] ERROR: reconcile compensate failed: %s", err.Error()) + return + } + + igor.Logf("[sentinel] Reconciled: transfer DID NOT complete before crash") + igor.Logf("[sentinel] Compensated — will retry in next cycle") + } +} + +// rng returns a pseudo-random uint64 using xorshift64. +func (s *Sentinel) rng() uint64 { + s.rngState ^= s.rngState << 13 + s.rngState ^= s.rngState >> 7 + s.rngState ^= s.rngState << 17 + return s.rngState +} + +func (s *Sentinel) Marshal() []byte { + return igor.NewEncoder(256). + Uint64(s.TickCount). + Int64(s.BirthNano). + Int64(s.LastNano). + Uint64(s.Balance). + Uint32(s.RefillCount). + Uint64(s.SpendTotal). + Bytes(s.Effects.Marshal()). + Bool(s.Phase != 0). + Uint64(s.rngState). + Finish() +} + +func (s *Sentinel) Unmarshal(data []byte) { + d := igor.NewDecoder(data) + s.TickCount = d.Uint64() + s.BirthNano = d.Int64() + s.LastNano = d.Int64() + s.Balance = d.Uint64() + s.RefillCount = d.Uint32() + s.SpendTotal = d.Uint64() + s.Effects.Unmarshal(d.Bytes()) // THE RESUME RULE: InFlight → Unresolved + if d.Bool() { + s.Phase = phaseReconciling + } + s.rngState = d.Uint64() + if err := d.Err(); err != nil { + panic("unmarshal checkpoint: " + err.Error()) + } +} + +func init() { igor.Run(&Sentinel{}) } +func main() {} diff --git a/docs/runtime/EFFECT_LIFECYCLE.md b/docs/runtime/EFFECT_LIFECYCLE.md new file mode 100644 index 0000000..6d2b9cf --- /dev/null +++ b/docs/runtime/EFFECT_LIFECYCLE.md @@ -0,0 +1,312 @@ +# Effect Lifecycle Specification + +Status: **Draft v1** — executable truth, not strategy. + +Derives from: EI-6 (Safety Over Liveness), CM-1 (Total Mediation), RE-1 (Atomic Checkpoints). + +## Problem + +Agents perform irreversible external actions (transfers, API calls, state mutations). +When a crash occurs between initiating and confirming such an action, the agent +must know — on resume — that the action may have already happened. Blind retry +risks duplication. Blind skip risks omission. The ugly middle is the whole movie. + +Today, the reconciler agent solves this manually with `IntentRecorded bool`, +`FinalizeExecuted bool`, `DupChecked bool`. Every agent that touches the outside +world needs the same machinery. Until effects are a runtime primitive, Igor's +core promise is social convention. + +## Design Constraint: Effects Live in Agent State + +The agent runs inside WASM. The SDK runs inside WASM. There is no durable path +from the SDK to the runtime except through `Marshal() []byte`. Therefore: + +- Effect tracking state serializes as part of agent state. +- Effect tracking state checkpoints and resumes with the agent. +- Effect tracking state migrates with the agent automatically. +- The runtime does not need a separate effect journal for v1. + +This is not a limitation. It is the correct design for portable actors. +The agent IS its checkpoint. Effects are part of what happened to the agent. + +## Intent Lifecycle + +An **intent** is a declared plan to perform an irreversible external action. + +### States + +``` + Recorded ──→ InFlight ──→ Confirmed + │ + [crash] + │ + ▼ + Unresolved ──→ Confirmed (it happened) + ──→ Compensated (it didn't, or we fixed it) +``` + +| State | Meaning | +|---|---| +| **Recorded** | Agent has declared intent. Not yet attempted. Checkpoint captures this. | +| **InFlight** | Agent has begun the external action (HTTP call, tx submission). | +| **Confirmed** | External action verified complete. Terminal state. | +| **Unresolved** | On resume, any InFlight intent transitions here automatically. The agent does not know if the action happened. This is the swamp. | +| **Compensated** | Agent investigated an Unresolved intent and determined it either did not happen or has been compensated. Terminal state. | + +### Allowed Transitions + +``` +Recorded → InFlight (agent begins execution) +InFlight → Confirmed (agent verifies success within same run) +InFlight → Unresolved (AUTOMATIC on resume — not agent-initiated) +Unresolved → Confirmed (agent checks external state, confirms it happened) +Unresolved → Compensated (agent checks external state, it didn't happen or was rolled back) +Confirmed → (terminal) +Compensated → (terminal) +``` + +No other transitions are valid. In particular: +- `Unresolved` cannot go back to `InFlight`. You don't retry blindly. You reconcile. +- `Recorded` cannot skip to `Confirmed`. You must go through `InFlight`. +- `Confirmed` and `Compensated` are final. An intent does not reopen. + +### The Resume Rule + +On `Unmarshal`, every intent in `InFlight` state becomes `Unresolved`. + +This is the single most important rule in the effect model. It encodes the +epistemological reality: after a crash, you do not know whether an in-flight +action completed. The runtime forces you to confront that uncertainty rather +than pretending it didn't happen. + +## IntentID + +Each intent has a unique key chosen by the agent. The SDK does not generate IDs +because the agent is the authority on what constitutes a distinct action. + +- Type: `[]byte` (variable length, agent-chosen) +- Must be unique within the agent's effect log at any given time +- Typically: idempotency key, transaction hash, nonce, or descriptive tag +- Used for lookup, deduplication, and external correlation + +## SDK API + +### Types + +```go +// IntentState represents the lifecycle state of an intent. +type IntentState uint8 + +const ( + Recorded IntentState = 1 + InFlight IntentState = 2 + Confirmed IntentState = 3 + Unresolved IntentState = 4 + Compensated IntentState = 5 +) + +// Intent is an individual effect intent tracked by the EffectLog. +type Intent struct { + ID []byte + State IntentState + Data []byte // Agent-defined payload (tx params, API call details, etc.) +} + +// EffectLog tracks intents across checkpoint/resume cycles. +type EffectLog struct { + intents []Intent +} +``` + +### Operations + +```go +// Record declares a new intent. Returns error if ID already exists. +// The intent starts in Recorded state. +func (e *EffectLog) Record(id, data []byte) error + +// Begin transitions an intent from Recorded to InFlight. +// Call this immediately before performing the external action. +func (e *EffectLog) Begin(id []byte) error + +// Confirm transitions an intent from InFlight or Unresolved to Confirmed. +// Call this after verifying the external action succeeded. +func (e *EffectLog) Confirm(id []byte) error + +// Compensate transitions an intent from Unresolved to Compensated. +// Call this after determining the action did not happen or was rolled back. +func (e *EffectLog) Compensate(id []byte) error + +// Pending returns all intents not in a terminal state (Recorded, InFlight, Unresolved). +func (e *EffectLog) Pending() []Intent + +// Unresolved returns only intents in Unresolved state. +// This is what the agent should check on resume. +func (e *EffectLog) Unresolved() []Intent + +// Get returns the intent with the given ID, or nil. +func (e *EffectLog) Get(id []byte) *Intent + +// Prune removes all terminal intents (Confirmed, Compensated). +// Call periodically to prevent unbounded growth. +// Returns the number of intents removed. +func (e *EffectLog) Prune() int +``` + +### Serialization + +The EffectLog participates in the agent's `Marshal`/`Unmarshal` cycle. + +```go +// Marshal serializes the effect log. Append this to your agent state. +func (e *EffectLog) Marshal() []byte + +// Unmarshal restores the effect log from serialized bytes. +// CRITICAL: All InFlight intents become Unresolved during unmarshal. +// This is the resume rule. +func (e *EffectLog) Unmarshal(data []byte) +``` + +Wire format (little-endian): +``` +[count: uint32] + for each intent: + [state: uint8] + [id_len: uint32][id: N bytes] + [data_len: uint32][data: N bytes] +``` + +### Checkpoint Relationship + +The EffectLog is part of agent state. The checkpoint boundary defines +what the runtime considers durable truth: + +- **Before checkpoint**: intent state changes are volatile. A crash + reverts to the last checkpoint. +- **After checkpoint**: intent state is durable. Resume restores it. + +This means the correct pattern is: + +``` +Tick N: Record intent → return (checkpoint happens) +Tick N+1: Begin intent → perform action → Confirm intent → return +``` + +If the agent records an intent and performs the action in the same tick, +a crash after the action but before the next checkpoint loses the +Recorded→InFlight transition. The intent won't appear on resume at all. +The agent performed an untracked external action. This is a bug. + +**Rule: Always checkpoint between Record and Begin.** + +The runtime checkpoints every 5 seconds (configurable). At 1 Hz tick rate, +this means at least 4 ticks between actions. In practice, Record in one tick, +Begin in the next tick, and trust the checkpoint interval. + +## Agent Usage Pattern + +```go +type Sentinel struct { + Effects igor.EffectLog + // ... other state +} + +func (s *Sentinel) Tick() bool { + // On resume, handle unresolved intents first. + for _, intent := range s.Effects.Unresolved() { + s.reconcile(intent) + return true + } + + // Normal operation... + if needsRefill() { + id := generateIdempotencyKey() + s.Effects.Record(id, transferParams) + return true // checkpoint will happen before next tick + } + + // In a later tick, execute the recorded intent. + for _, intent := range s.Effects.Pending() { + if intent.State == igor.Recorded { + s.Effects.Begin(intent.ID) + result := executeTransfer(intent.Data) + if result.OK { + s.Effects.Confirm(intent.ID) + } + return true + } + } + return false +} + +func (s *Sentinel) reconcile(intent igor.Intent) { + // Check external state to determine if the action happened. + status := checkBridgeStatus(intent.Data) + if status.Completed { + s.Effects.Confirm(intent.ID) + } else if status.NotFound { + s.Effects.Compensate(intent.ID) + } + // If status is ambiguous, leave as Unresolved and check next tick. +} + +func (s *Sentinel) Marshal() []byte { + return igor.NewEncoder(256). + Bytes(s.Effects.Marshal()). + // ... other fields + Finish() +} + +func (s *Sentinel) Unmarshal(data []byte) { + d := igor.NewDecoder(data) + s.Effects.Unmarshal(d.Bytes()) + // ... other fields +} +``` + +## What the Runtime Owns vs. What the SDK Owns + +| Concern | Owner | +|---|---| +| Checkpoint timing | Runtime | +| Checkpoint atomicity (RE-1) | Runtime | +| Effect state storage | SDK (inside agent state) | +| Intent state transitions | SDK (validates transitions) | +| InFlight→Unresolved on resume | SDK (automatic in Unmarshal) | +| Reconciliation logic | Agent (application-specific) | +| IntentID generation | Agent | +| External action execution | Agent (via hostcalls) | +| Pruning terminal intents | Agent (calls Prune) | + +## Future: Runtime Awareness + +In a later phase, we may add a hostcall for the agent to report effects +to the runtime for audit and inspection: + +``` +igor.effect_report(intent_id, state, data) → void +``` + +This would let `igord inspect` show the effect journal without parsing +agent-specific state. But this is not required for v1. The agent's own +state is the source of truth. + +## Open Design Questions (Resolved) + +**Q: Should the runtime manage effects outside agent state?** +A: No. The WASM boundary means agent state is the only durable path. + External effect journals are a future audit feature, not the truth source. + +**Q: Can an agent have multiple in-flight intents?** +A: Yes. The EffectLog is a list, not a single slot. An agent monitoring + multiple positions might have several concurrent intents. + +**Q: What about nested effects (effect B depends on effect A)?** +A: Model as separate intents with application-level ordering. + The SDK provides a flat list; the agent owns the dependency graph. + +**Q: Should the SDK enforce Record-before-Begin across ticks?** +A: No. The SDK validates state transitions (can't Begin from Confirmed). + Enforcing the "checkpoint between Record and Begin" rule is a convention, + not a runtime constraint. Agents that violate it risk untracked effects. + Documentation makes this clear; the SDK does not police tick boundaries. diff --git a/scripts/demo-sentinel.sh b/scripts/demo-sentinel.sh new file mode 100755 index 0000000..3fe09af --- /dev/null +++ b/scripts/demo-sentinel.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# demo-sentinel.sh — Treasury Sentinel: crash recovery with effect tracking. +# +# Demonstrates Igor's core value proposition: +# Critical automations fail in the middle. Igor is built for the middle. +# +# Flow: +# 1. Run sentinel on "Machine A" — treasury depletes over time +# 2. Agent detects low balance, records a refill intent +# 3. KILL the process mid-flight (simulating crash) +# 4. Resume on "Machine B" from checkpoint +# 5. Agent finds unresolved intent, reconciles instead of blindly retrying +# 6. Verify cryptographic lineage across both machines +# +# The key insight: after crash, the agent doesn't know if the transfer +# completed. It checks. It doesn't guess. That's the whole movie. +set -euo pipefail + +IGORD="./bin/igord" +WASM="./agents/sentinel/agent.wasm" +DIR_A="/tmp/igor-demo-sentinel-a" +DIR_B="/tmp/igor-demo-sentinel-b" + +# Cleanup from previous runs. +rm -rf "$DIR_A" "$DIR_B" +mkdir -p "$DIR_A" "$DIR_B" + +echo "" +echo "==========================================" +echo " Igor: Treasury Sentinel Demo" +echo " Effect-safe crash recovery" +echo "==========================================" +echo "" +echo " Scenario: Treasury balance depletes. Agent refills." +echo " Crash mid-refill. Resume. Reconcile. No duplicates." +echo "" + +# --- Machine A: Run until low balance triggers refill --- +echo "[Machine A] Starting treasury sentinel..." +echo "[Machine A] Watching treasury balance, spending simulated..." +echo "" +$IGORD run --budget 100.0 --checkpoint-dir "$DIR_A" --agent-id sentinel "$WASM" & +PID_A=$! + +# Let it run until balance drops and a refill intent is recorded. +# At ~$50-250/tick at 1Hz, $10,000 treasury hits $3,000 threshold in ~28-50 ticks. +# We give it enough time to trigger at least one refill cycle. +sleep 45 + +echo "" +echo "[Machine A] KILLING process (simulating crash mid-operation)..." +kill -9 "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true + +echo "[Machine A] Process killed. Checkpoint is the last known good state." +echo "" + +# --- Copy checkpoint to Machine B --- +CKPT_FILE="$DIR_A/sentinel.checkpoint" +if [ ! -f "$CKPT_FILE" ]; then + echo "ERROR: Checkpoint file not found at $CKPT_FILE" + echo "(Agent may not have run long enough to produce a checkpoint.)" + rm -rf "$DIR_A" "$DIR_B" + exit 1 +fi + +echo "==========================================" +echo " Simulating Transfer to Machine B" +echo "==========================================" +echo "" +echo "[Transfer] Copying checkpoint + identity to Machine B..." +cp "$CKPT_FILE" "$DIR_B/" +if [ -f "$DIR_A/sentinel.identity" ]; then + cp "$DIR_A/sentinel.identity" "$DIR_B/" +fi +echo "[Transfer] Done. The checkpoint IS the agent." +echo "" + +# --- Machine B: Resume and reconcile --- +echo "==========================================" +echo " Machine B: Resume + Reconcile" +echo "==========================================" +echo "" +echo "[Machine B] Resuming sentinel from checkpoint..." +echo "[Machine B] If there's an in-flight refill, it becomes UNRESOLVED." +echo "[Machine B] The agent will check bridge status, not blindly retry." +echo "" +$IGORD resume --checkpoint "$DIR_B/sentinel.checkpoint" --wasm "$WASM" --checkpoint-dir "$DIR_B" --agent-id sentinel & +PID_B=$! + +# Let it reconcile and run a few more cycles. +sleep 20 + +echo "" +echo "[Machine B] Stopping agent gracefully..." +kill -INT "$PID_B" 2>/dev/null || true +wait "$PID_B" 2>/dev/null || true +echo "" + +# --- Verify Lineage --- +VERIFY_DIR="/tmp/igor-demo-sentinel-verify" +rm -rf "$VERIFY_DIR" +mkdir -p "$VERIFY_DIR" + +if [ -d "$DIR_A/history/sentinel" ]; then + cp "$DIR_A/history/sentinel/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi +if [ -d "$DIR_B/history/sentinel" ]; then + cp "$DIR_B/history/sentinel/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi + +CKPT_COUNT=$(ls "$VERIFY_DIR/"*.ckpt 2>/dev/null | wc -l | tr -d ' ') +echo "==========================================" +echo " Lineage Verification ($CKPT_COUNT checkpoints)" +echo "==========================================" +echo "" + +if [ "$CKPT_COUNT" -gt 0 ]; then + $IGORD verify "$VERIFY_DIR" +else + echo "No checkpoint history found for verification." +fi + +echo "" +echo "==========================================" +echo " Demo Complete" +echo "==========================================" +echo "" +echo "What you just saw:" +echo " - A treasury sentinel monitoring balance and executing refills" +echo " - Process KILLED mid-operation (SIGKILL, not graceful)" +echo " - Resumed on Machine B from last checkpoint" +echo " - In-flight intents became UNRESOLVED (the resume rule)" +echo " - Agent RECONCILED by checking bridge status" +echo " - No blind retry. No duplicate transfer. Continuity." +echo "" +echo "This is what makes Igor different from retry logic:" +echo " The agent knows it doesn't know. And it acts accordingly." +echo "" + +# Cleanup. +rm -rf "$DIR_A" "$DIR_B" "$VERIFY_DIR" diff --git a/sdk/igor/effects.go b/sdk/igor/effects.go new file mode 100644 index 0000000..ecfa8d9 --- /dev/null +++ b/sdk/igor/effects.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 + +package igor + +import ( + "bytes" + "errors" +) + +// IntentState represents the lifecycle state of an effect intent. +// See docs/runtime/EFFECT_LIFECYCLE.md for the full state machine. +type IntentState uint8 + +const ( + // Recorded means the agent has declared intent to perform an external action. + // The intent exists in state and will survive checkpoint, but no action has been attempted. + Recorded IntentState = 1 + + // InFlight means the agent has begun the external action. + // If a crash occurs, this becomes Unresolved on resume. + InFlight IntentState = 2 + + // Confirmed means the external action was verified complete. Terminal. + Confirmed IntentState = 3 + + // Unresolved means a crash occurred while the intent was InFlight. + // The agent does not know whether the action completed. + // Must be resolved via Confirm (it happened) or Compensate (it didn't). + Unresolved IntentState = 4 + + // Compensated means the agent determined an Unresolved intent either + // did not execute or has been rolled back. Terminal. + Compensated IntentState = 5 +) + +// Intent is an individual effect intent tracked by the EffectLog. +type Intent struct { + ID []byte + State IntentState + Data []byte // Agent-defined payload (tx params, API call details, etc.) +} + +var ( + errIntentExists = errors.New("igor: intent ID already exists") + errIntentNotFound = errors.New("igor: intent not found") + errInvalidTransition = errors.New("igor: invalid intent state transition") +) + +// EffectLog tracks intents across checkpoint/resume cycles. +// Embed this in your agent struct and include it in Marshal/Unmarshal. +type EffectLog struct { + intents []Intent +} + +// Record declares a new intent. The intent starts in Recorded state. +// Returns an error if an intent with the same ID already exists. +func (e *EffectLog) Record(id, data []byte) error { + if e.find(id) != nil { + return errIntentExists + } + e.intents = append(e.intents, Intent{ + ID: copyBytes(id), + State: Recorded, + Data: copyBytes(data), + }) + return nil +} + +// Begin transitions an intent from Recorded to InFlight. +// Call this immediately before performing the external action. +func (e *EffectLog) Begin(id []byte) error { + intent := e.find(id) + if intent == nil { + return errIntentNotFound + } + if intent.State != Recorded { + return errInvalidTransition + } + intent.State = InFlight + return nil +} + +// Confirm transitions an intent from InFlight or Unresolved to Confirmed. +// Call this after verifying the external action succeeded. +func (e *EffectLog) Confirm(id []byte) error { + intent := e.find(id) + if intent == nil { + return errIntentNotFound + } + if intent.State != InFlight && intent.State != Unresolved { + return errInvalidTransition + } + intent.State = Confirmed + return nil +} + +// Compensate transitions an intent from Unresolved to Compensated. +// Call this after determining the action did not happen or was rolled back. +func (e *EffectLog) Compensate(id []byte) error { + intent := e.find(id) + if intent == nil { + return errIntentNotFound + } + if intent.State != Unresolved { + return errInvalidTransition + } + intent.State = Compensated + return nil +} + +// Pending returns all intents not in a terminal state (Recorded, InFlight, Unresolved). +func (e *EffectLog) Pending() []Intent { + var out []Intent + for _, intent := range e.intents { + if intent.State != Confirmed && intent.State != Compensated { + out = append(out, intent) + } + } + return out +} + +// Unresolved returns only intents in Unresolved state. +// Check this on resume to handle the crash recovery swamp. +func (e *EffectLog) Unresolved() []Intent { + var out []Intent + for _, intent := range e.intents { + if intent.State == Unresolved { + out = append(out, intent) + } + } + return out +} + +// Get returns the intent with the given ID, or nil if not found. +func (e *EffectLog) Get(id []byte) *Intent { + return e.find(id) +} + +// Len returns the total number of intents (all states). +func (e *EffectLog) Len() int { + return len(e.intents) +} + +// Prune removes all terminal intents (Confirmed, Compensated). +// Call periodically to prevent unbounded growth. +// Returns the number of intents removed. +func (e *EffectLog) Prune() int { + n := 0 + kept := e.intents[:0] + for _, intent := range e.intents { + if intent.State == Confirmed || intent.State == Compensated { + n++ + } else { + kept = append(kept, intent) + } + } + e.intents = kept + return n +} + +// Marshal serializes the effect log for checkpointing. +// Wire format (little-endian): +// +// [count: uint32] +// for each intent: +// [state: uint8] +// [id_len: uint32][id: N bytes] +// [data_len: uint32][data: N bytes] +func (e *EffectLog) Marshal() []byte { + // Estimate size: 4 + N * (1 + 4 + avg_id + 4 + avg_data) + enc := NewEncoder(4 + len(e.intents)*32) + enc.Uint32(uint32(len(e.intents))) + for _, intent := range e.intents { + enc.buf = append(enc.buf, byte(intent.State)) + enc.Bytes(intent.ID) + enc.Bytes(intent.Data) + } + return enc.Finish() +} + +// Unmarshal restores the effect log from serialized bytes. +// CRITICAL: All InFlight intents become Unresolved. This is the resume rule. +func (e *EffectLog) Unmarshal(data []byte) { + if len(data) == 0 { + e.intents = nil + return + } + d := NewDecoder(data) + count := d.Uint32() + if d.Err() != nil { + e.intents = nil + return + } + e.intents = make([]Intent, 0, count) + for i := uint32(0); i < count; i++ { + if d.Err() != nil { + break + } + state := IntentState(d.data[d.pos]) + d.pos++ + + id := d.Bytes() + payload := d.Bytes() + + // THE RESUME RULE: InFlight → Unresolved + if state == InFlight { + state = Unresolved + } + + e.intents = append(e.intents, Intent{ + ID: id, + State: state, + Data: payload, + }) + } +} + +// find returns a pointer to the intent with the given ID, or nil. +func (e *EffectLog) find(id []byte) *Intent { + for i := range e.intents { + if bytes.Equal(e.intents[i].ID, id) { + return &e.intents[i] + } + } + return nil +} + +// copyBytes returns a copy of b, or nil if b is nil. +func copyBytes(b []byte) []byte { + if b == nil { + return nil + } + c := make([]byte, len(b)) + copy(c, b) + return c +} diff --git a/sdk/igor/effects_test.go b/sdk/igor/effects_test.go new file mode 100644 index 0000000..a1ae24e --- /dev/null +++ b/sdk/igor/effects_test.go @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: Apache-2.0 + +package igor + +import ( + "testing" +) + +func TestEffectLog_HappyPath(t *testing.T) { + var e EffectLog + + id := []byte("tx-001") + data := []byte(`{"amount":100}`) + + if err := e.Record(id, data); err != nil { + t.Fatalf("Record: %v", err) + } + if e.Len() != 1 { + t.Fatalf("expected 1 intent, got %d", e.Len()) + } + + intent := e.Get(id) + if intent == nil { + t.Fatal("Get returned nil") + } + if intent.State != Recorded { + t.Fatalf("expected Recorded, got %d", intent.State) + } + + if err := e.Begin(id); err != nil { + t.Fatalf("Begin: %v", err) + } + if intent.State != InFlight { + t.Fatalf("expected InFlight, got %d", intent.State) + } + + if err := e.Confirm(id); err != nil { + t.Fatalf("Confirm: %v", err) + } + if intent.State != Confirmed { + t.Fatalf("expected Confirmed, got %d", intent.State) + } + + // Terminal — should not appear in Pending. + if len(e.Pending()) != 0 { + t.Fatalf("expected 0 pending, got %d", len(e.Pending())) + } +} + +func TestEffectLog_DuplicateID(t *testing.T) { + var e EffectLog + id := []byte("dup") + if err := e.Record(id, nil); err != nil { + t.Fatal(err) + } + if err := e.Record(id, nil); err == nil { + t.Fatal("expected error on duplicate ID") + } +} + +func TestEffectLog_InvalidTransitions(t *testing.T) { + var e EffectLog + id := []byte("tx") + e.Record(id, nil) + + // Can't Confirm from Recorded. + if err := e.Confirm(id); err == nil { + t.Fatal("expected error: Confirm from Recorded") + } + // Can't Compensate from Recorded. + if err := e.Compensate(id); err == nil { + t.Fatal("expected error: Compensate from Recorded") + } + + e.Begin(id) + + // Can't Begin again. + if err := e.Begin(id); err == nil { + t.Fatal("expected error: Begin from InFlight") + } + // Can't Compensate from InFlight (only from Unresolved). + if err := e.Compensate(id); err == nil { + t.Fatal("expected error: Compensate from InFlight") + } + + e.Confirm(id) + + // Can't do anything from Confirmed. + if err := e.Begin(id); err == nil { + t.Fatal("expected error: Begin from Confirmed") + } + if err := e.Confirm(id); err == nil { + t.Fatal("expected error: Confirm from Confirmed") + } +} + +func TestEffectLog_ResumeRule(t *testing.T) { + var e EffectLog + + e.Record([]byte("recorded"), nil) + e.Record([]byte("inflight"), nil) + e.Begin([]byte("inflight")) + e.Record([]byte("confirmed"), nil) + e.Begin([]byte("confirmed")) + e.Confirm([]byte("confirmed")) + + // Serialize and deserialize (simulates checkpoint → crash → resume). + data := e.Marshal() + + var resumed EffectLog + resumed.Unmarshal(data) + + // "recorded" should stay Recorded. + if intent := resumed.Get([]byte("recorded")); intent.State != Recorded { + t.Fatalf("expected Recorded, got %d", intent.State) + } + + // "inflight" should become Unresolved (THE RESUME RULE). + if intent := resumed.Get([]byte("inflight")); intent.State != Unresolved { + t.Fatalf("expected Unresolved, got %d", intent.State) + } + + // "confirmed" should stay Confirmed. + if intent := resumed.Get([]byte("confirmed")); intent.State != Confirmed { + t.Fatalf("expected Confirmed, got %d", intent.State) + } + + // Unresolved list should have exactly one. + unresolved := resumed.Unresolved() + if len(unresolved) != 1 { + t.Fatalf("expected 1 unresolved, got %d", len(unresolved)) + } + if string(unresolved[0].ID) != "inflight" { + t.Fatalf("wrong unresolved ID: %s", unresolved[0].ID) + } +} + +func TestEffectLog_Compensate(t *testing.T) { + var e EffectLog + id := []byte("tx") + e.Record(id, []byte("payload")) + e.Begin(id) + + // Simulate crash: marshal while InFlight, unmarshal. + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + + intent := resumed.Get(id) + if intent.State != Unresolved { + t.Fatalf("expected Unresolved, got %d", intent.State) + } + + if err := resumed.Compensate(id); err != nil { + t.Fatalf("Compensate: %v", err) + } + if intent.State != Compensated { + t.Fatalf("expected Compensated, got %d", intent.State) + } + + // Compensated is terminal. + if err := resumed.Confirm(id); err == nil { + t.Fatal("expected error: Confirm from Compensated") + } +} + +func TestEffectLog_ConfirmFromUnresolved(t *testing.T) { + var e EffectLog + id := []byte("tx") + e.Record(id, nil) + e.Begin(id) + + // Simulate crash. + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + + // Agent checks external state, finds the action completed. + if err := resumed.Confirm(id); err != nil { + t.Fatalf("Confirm from Unresolved: %v", err) + } + if resumed.Get(id).State != Confirmed { + t.Fatal("expected Confirmed") + } +} + +func TestEffectLog_Prune(t *testing.T) { + var e EffectLog + e.Record([]byte("a"), nil) + e.Begin([]byte("a")) + e.Confirm([]byte("a")) + + e.Record([]byte("b"), nil) + + e.Record([]byte("c"), nil) + e.Begin([]byte("c")) + + // Simulate crash for c. + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + resumed.Compensate([]byte("c")) + + // a=Confirmed, b=Recorded, c=Compensated + removed := resumed.Prune() + if removed != 2 { + t.Fatalf("expected 2 pruned, got %d", removed) + } + if resumed.Len() != 1 { + t.Fatalf("expected 1 remaining, got %d", resumed.Len()) + } + if resumed.Get([]byte("b")) == nil { + t.Fatal("expected b to survive prune") + } +} + +func TestEffectLog_Pending(t *testing.T) { + var e EffectLog + e.Record([]byte("r"), nil) + e.Record([]byte("i"), nil) + e.Begin([]byte("i")) + e.Record([]byte("c"), nil) + e.Begin([]byte("c")) + e.Confirm([]byte("c")) + + pending := e.Pending() + if len(pending) != 2 { + t.Fatalf("expected 2 pending, got %d", len(pending)) + } +} + +func TestEffectLog_MultipleIntents(t *testing.T) { + var e EffectLog + + // Agent monitoring multiple positions. + for i := 0; i < 5; i++ { + id := []byte{byte(i)} + if err := e.Record(id, nil); err != nil { + t.Fatalf("Record %d: %v", i, err) + } + } + if e.Len() != 5 { + t.Fatalf("expected 5, got %d", e.Len()) + } + + // Begin all. + for i := 0; i < 5; i++ { + e.Begin([]byte{byte(i)}) + } + + // Crash and resume. + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + + // All should be Unresolved. + if len(resumed.Unresolved()) != 5 { + t.Fatalf("expected 5 unresolved, got %d", len(resumed.Unresolved())) + } +} + +func TestEffectLog_EmptyMarshal(t *testing.T) { + var e EffectLog + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + if resumed.Len() != 0 { + t.Fatalf("expected 0, got %d", resumed.Len()) + } +} + +func TestEffectLog_DataPreserved(t *testing.T) { + var e EffectLog + id := []byte("tx") + payload := []byte(`{"chain":"ethereum","amount":"1.5","to":"0xabc"}`) + e.Record(id, payload) + e.Begin(id) + + data := e.Marshal() + var resumed EffectLog + resumed.Unmarshal(data) + + intent := resumed.Get(id) + if string(intent.Data) != string(payload) { + t.Fatalf("data not preserved: got %q", intent.Data) + } +} + +func TestEffectLog_NotFoundErrors(t *testing.T) { + var e EffectLog + missing := []byte("nope") + + if err := e.Begin(missing); err == nil { + t.Fatal("expected error for Begin on missing intent") + } + if err := e.Confirm(missing); err == nil { + t.Fatal("expected error for Confirm on missing intent") + } + if err := e.Compensate(missing); err == nil { + t.Fatal("expected error for Compensate on missing intent") + } + if e.Get(missing) != nil { + t.Fatal("expected nil for Get on missing intent") + } +} From 7a85fbc14d9598bef81e8a19739a8674cadd139c Mon Sep 17 00:00:00 2001 From: Janko Date: Sat, 14 Mar 2026 22:52:15 +0100 Subject: [PATCH 2/5] docs: fix 10 documentation inconsistencies found in audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix tick timeout 100ms → 15s across 4 files (matches config.TickTimeout) - Fix igord resume CLI syntax (positional → --checkpoint/--wasm flags) - Add HTTP hostcall documentation to HOSTCALL_ABI.md - Fix IMPLEMENTATION_STATUS.md: flag attribution (igord-lab not igord), add HTTP/effects/pricewatcher/sentinel entries, fix stale claims - Update ROADMAP.md Phase 2 with completed items and current status - Fix stale phase references and lease version (v0x03 → v0x04) Co-Authored-By: Claude Opus 4.6 --- docs/IMPLEMENTATION_STATUS.md | 23 +++++----- .../RUNTIME_ENFORCEMENT_INVARIANTS.md | 6 +-- docs/governance/ROADMAP.md | 32 ++++++++------ docs/runtime/AGENT_LIFECYCLE.md | 6 +-- docs/runtime/ARCHITECTURE.md | 10 ++--- docs/runtime/HOSTCALL_ABI.md | 43 +++++++++++++++++-- sdk/igor/lifecycle.go | 2 +- 7 files changed, 83 insertions(+), 39 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 935ff13..f4bef67 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -70,10 +70,10 @@ Last updated: 2026-03-13 | Periodic self-verification | Implemented | `internal/runner/runner.go` `VerifyNextTick` | | Migration-time replay verification | Implemented | `internal/migration/service.go` `verifyMigrationReplay` | | Replay window (sliding buffer) | Implemented | `internal/agent/instance.go` `ReplayWindow` | -| `--replay-window` CLI flag | Implemented | `cmd/igord/main.go` | -| `--verify-interval` CLI flag | Implemented | `cmd/igord/main.go` | -| Formal replay modes (off/periodic/on-migrate/full) | Implemented | `internal/config/config.go` `ReplayMode`, `cmd/igord/main.go` `--replay-mode` | -| Replay cost metering | Implemented | `internal/replay/engine.go` `Result.Duration`, `cmd/igord/main.go` `--replay-cost-log` | +| `--replay-window` CLI flag | Implemented | `cmd/igord-lab/main.go` | +| `--verify-interval` CLI flag | Implemented | `cmd/igord-lab/main.go` | +| Formal replay modes (off/periodic/on-migrate/full) | Implemented | `internal/config/config.go` `ReplayMode`, `cmd/igord-lab/main.go` `--replay-mode` | +| Replay cost metering | Implemented | `internal/replay/engine.go` `Result.Duration`, `cmd/igord-lab/main.go` `--replay-cost-log` | ## Capability Membrane @@ -85,9 +85,9 @@ Last updated: 2026-03-13 | Observation recording (CM-4) | Implemented | `internal/eventlog/eventlog.go` | | Manifest in migration package | Implemented | `pkg/protocol/messages.go` `ManifestData` | | Pre-migration capability check (CE-5) | Implemented | `internal/migration/service.go` `handleIncomingMigration` | -| Side-effect authority gating (CM-5) | Not implemented | Requires authority state machine (Phase 5). Currently no side-effect hostcalls exist (kv/net are unimplemented). When added, side-effect hostcalls MUST only execute in ACTIVE_OWNER state. | +| HTTP hostcall (`http_request`) | Implemented | `internal/hostcall/http.go` — side-effect hostcall with allowed_hosts, timeout, max response size. Recorded in event log for replay. | +| Side-effect authority gating (CM-5) | Not implemented | Requires authority state machine (Phase 5). HTTP hostcall exists but is not gated on ACTIVE_OWNER. When authority gating is added, side-effect hostcalls MUST only execute in ACTIVE_OWNER state. | | KV storage hostcalls | Not implemented | Roadmap future task | -| Network hostcalls | Not implemented | Roadmap future phase | ## Identity and Authority @@ -101,7 +101,7 @@ Last updated: 2026-03-13 | OA-2 authority lifecycle states | Partial | Lease-based authority (Task 12) provides ACTIVE/EXPIRED/RECOVERY_REQUIRED states via `internal/authority/`. Full OA-2 ownership model (HANDOFF_INITIATED, HANDOFF_PENDING, RETIRED) tracked implicitly through migration handoff. | | EI-11 divergent lineage detection | Partial | RECOVERY_REQUIRED state implemented in `internal/authority/`. Signed checkpoint lineage (Task 13) enables tamper detection. Full cross-node distributed detection requires Task 15 (Permissionless Hardening). | | Signed checkpoint lineage | Implemented | Task 13: `pkg/lineage/`, checkpoint v0x04 with signed hash chain. See [SIGNED_LINEAGE.md](runtime/SIGNED_LINEAGE.md). | -| Lease-based authority epochs | Implemented | Task 12: `internal/authority/`, checkpoint v0x03 with epoch metadata. See [LEASE_EPOCH.md](runtime/LEASE_EPOCH.md). | +| Lease-based authority epochs | Implemented | Task 12: `internal/authority/`, epoch metadata in checkpoint v0x04 header. See [LEASE_EPOCH.md](runtime/LEASE_EPOCH.md). | | Cryptographic agent identity | Implemented | Task 13: `pkg/identity/`, Ed25519 agent keypairs with persistent storage. | | DID encoding (did:key multicodec) | Implemented | `pkg/identity/did.go`, base58btc with 0xed01 prefix. | @@ -138,11 +138,14 @@ Last updated: 2026-03-13 | Simulator replay verification | Implemented | `internal/simulator/simulator.go` `verifyTick` | | Checkpoint inspector | Implemented | `internal/inspector/inspector.go` | | WASM hash verification in inspector | Implemented | `internal/inspector/inspector.go` `VerifyWASM` | -| Agent template (Survivor example) | Implemented | `agents/example/` | +| Agent template (Survivor example) | Implemented | `agents/example/` (research phase artifact) | | Heartbeat demo agent | Implemented | `agents/heartbeat/` — tick count, age, milestones | +| Price watcher demo agent | Implemented | `agents/pricewatcher/` — fetches BTC/ETH prices via HTTP hostcall, tracks high/low/latest | +| Treasury sentinel demo agent | Implemented | `agents/sentinel/` — treasury monitoring with effect-safe crash recovery | +| Effect lifecycle model (SDK) | Implemented | `sdk/igor/effects.go` — EffectLog, intent state machine (Recorded→InFlight→Confirmed/Unresolved→Compensated), resume rule | | Lineage chain verifier | Implemented | `internal/inspector/chain.go` `VerifyChain`, `PrintChain` | -| `--simulate` CLI flag | Implemented | `cmd/igord/main.go` | -| `--inspect-checkpoint` CLI flag | Implemented | `cmd/igord/main.go` | +| `--simulate` CLI flag | Implemented | `cmd/igord-lab/main.go` | +| `--inspect-checkpoint` CLI flag | Implemented | `cmd/igord-lab/main.go` | ## Runtime Optimizations diff --git a/docs/enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md b/docs/enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md index af7c18b..06684db 100644 --- a/docs/enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md +++ b/docs/enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md @@ -143,7 +143,7 @@ The system is designed to **fail loudly** when enforcement invariants are violat ### RE-5: Tick Duration Limit -**Invariant:** Each tick completes within 100ms. +**Invariant:** Each tick completes within 15s. **Derives from:** EI-1 (single active instance — responsiveness supports migration handoff) @@ -275,7 +275,7 @@ init → [tick* → checkpoint*] → resume (optional) | RE-2 | Agent state survives shutdown and migration | Checkpoint | EI-2, EI-10 | | RE-3 | Budget is conserved (never created or destroyed) | Budget | EI-3 | | RE-4 | Budget never increases during execution | Budget | EI-3 | -| RE-5 | Tick completes within 100ms | Tick | EI-1 | +| RE-5 | Tick completes within 15s | Tick | EI-1 | | RE-6 | Tick is deterministic given same state | Tick | EI-3 | | RE-7 | Agents cannot access each other's checkpoints | Sandbox | EI-4, OA-1 | | RE-8 | Lifecycle functions called in strict order | Lifecycle | EI-2, OA-2 | @@ -287,7 +287,7 @@ init → [tick* → checkpoint*] → resume (optional) ### What Igor Guarantees 1. **Agents execute in WASM sandbox** -2. **Ticks timeout at 100ms** +2. **Ticks timeout at 15s** 3. **Memory limited to 64MB** 4. **Budget decreases monotonically** 5. **Checkpoints are atomic** diff --git a/docs/governance/ROADMAP.md b/docs/governance/ROADMAP.md index ea27101..01319c1 100644 --- a/docs/governance/ROADMAP.md +++ b/docs/governance/ROADMAP.md @@ -216,8 +216,13 @@ Igor's research foundation (Phases 2–5) proved that agents can checkpoint, mig **Goal:** Agents choose and pay for their own infrastructure. +**Status:** In progress. + **Scope:** -- **HTTP hostcall** — agent calls external APIs (REST, webhooks) +- ✅ **HTTP hostcall** — agent calls external APIs (REST, webhooks) — `internal/hostcall/http.go`, allowed_hosts, timeout, max response size +- ✅ **Effect lifecycle model** — intent state machine in SDK for crash-safe effect tracking — `sdk/igor/effects.go` +- ✅ **Price watcher demo** — agent fetching live crypto prices via HTTP hostcall — `agents/pricewatcher/` +- ✅ **Treasury sentinel demo** — effect-safe treasury monitoring with crash recovery — `agents/sentinel/` - **x402/USDC wallet hostcall** — agent pays for services with real money - **Compute provider hostcall** — agent deploys itself to Akash, Golem, or similar - **Self-migration** — agent decides when and where to move based on price/performance @@ -295,11 +300,11 @@ Igor v0 is **not ready for production** and may never be. ### Breaking Changes -Phase 2 → Phase 3 may break: -- Checkpoint format (add manifest) -- Protocol messages (add capabilities) -- CLI flags (restructure) -- Agent lifecycle (new host functions) +Product phase transitions may break: +- Checkpoint format (new fields) +- CLI flags (new subcommands) +- Agent lifecycle (new hostcalls) +- SDK API (new types) **No compatibility guarantees in v0.** @@ -332,7 +337,8 @@ None. v0 is experimental. Things may be: ### Product Phase 2 Goals -- Agent calls external HTTP APIs via hostcall +- ✅ Agent calls external HTTP APIs via hostcall +- ✅ Effect lifecycle model for crash-safe side effects - Agent pays for compute with real money (x402/USDC) - Agent deploys itself to Akash/Golem - Agent decides when to migrate @@ -348,7 +354,7 @@ Igor development follows "done when it's done" philosophy: - Correctness over features - Learning over shipping -Phase 3 began after Phase 2 validation. Tasks 6, 7, and 8 are complete. +Research phases (2–5) complete. Product Phase 2 in progress. --- @@ -367,7 +373,7 @@ Igor v0 is experimental research software. - Performance optimizations (premature) - Production deployments (not ready) -Focus: Validate Phase 2 before expanding scope. +Focus: Complete Product Phase 2 before expanding scope. --- @@ -423,8 +429,8 @@ Igor is **not novel**. It combines existing ideas in a specific way to explore a ## Next Immediate Steps -Product Phase 1 complete. Next: +Product Phase 2 in progress. HTTP hostcall, effect model, and demo agents delivered. Next: -1. **Product Phase 2: Agent Self-Provisioning** — HTTP hostcall, x402 wallet, Akash deployment -2. **Demo agents** — more compelling demo agents that use external APIs -3. **Developer experience** — better error messages, documentation, examples +1. **x402/USDC wallet hostcall** — agent pays for services with real money +2. **Compute provider integration** — agent deploys itself to Akash/Golem +3. **Self-migration** — agent decides when and where to move based on price/performance diff --git a/docs/runtime/AGENT_LIFECYCLE.md b/docs/runtime/AGENT_LIFECYCLE.md index 09ea69d..d61ce42 100644 --- a/docs/runtime/AGENT_LIFECYCLE.md +++ b/docs/runtime/AGENT_LIFECYCLE.md @@ -62,7 +62,7 @@ Loop: Tick → Meter → Budget Check → Checkpoint (periodic) → Tick ... **Runtime Actions:** - Call `agent_tick()` every 1 second -- Enforce 100ms timeout +- Enforce 15s timeout - Measure execution duration - Calculate cost - Deduct from budget @@ -72,7 +72,7 @@ Loop: Tick → Meter → Budget Check → Checkpoint (periodic) → Tick ... **Agent Actions:** - Execute one unit of work - Update internal state -- Must complete quickly (<100ms) +- Must complete within tick timeout (15s) **Example:** ```go @@ -254,7 +254,7 @@ Checkpoints are: ### Time Limits -- **Tick timeout**: 100ms per execution +- **Tick timeout**: 15s per execution - **Context cancellation**: Respected by runtime - **No blocking**: Ticks must be short and resumable diff --git a/docs/runtime/ARCHITECTURE.md b/docs/runtime/ARCHITECTURE.md index 774de66..4de94ef 100644 --- a/docs/runtime/ARCHITECTURE.md +++ b/docs/runtime/ARCHITECTURE.md @@ -32,7 +32,7 @@ Agents execute in isolated WASM instances using wazero: - **Memory limit:** 64MB (1024 pages × 64KB) - **Filesystem access:** Disabled (via WASI restrictions) - **Network access:** Disabled (via WASI restrictions) -- **Tick timeout:** 100ms enforced via context cancellation +- **Tick timeout:** 15s enforced via context cancellation - **Agent I/O:** Through `igor` hostcall module (see [HOSTCALL_ABI.md](./HOSTCALL_ABI.md)); raw WASI filesystem/network remain disabled Compilation and instantiation: @@ -83,7 +83,7 @@ Agents interact with the outside world exclusively through runtime-provided host | `rand` | Observation | Cryptographic randomness | Implemented | | `kv` | Mixed | Per-agent key-value storage | Not implemented | | `log` | Observation | Structured logging | Implemented | -| `net` | Side effect | Network requests | Not implemented | +| `http` | Side effect | HTTP requests to external APIs | Implemented | | `wallet` | Mixed | Budget introspection, transfers | Implemented | Agents declare required capabilities in a manifest. The runtime grants only declared capabilities (deny by default, CM-3). All observation hostcalls are recorded in an event log for deterministic replay (CM-4, CE-3). @@ -293,8 +293,8 @@ Agent budget never increases during execution. **I6: Execution Determinism** Given same state and inputs, tick produces same outputs (agent responsibility). -**I7: Tick Timeout** -Each tick completes within 100ms or is aborted. +**I7: Tick Timeout** +Each tick completes within 15s or is aborted. See [RUNTIME_ENFORCEMENT_INVARIANTS.md](../enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md) for enforcement specifications and [EXECUTION_INVARIANTS.md](../constitution/EXECUTION_INVARIANTS.md) for constitutional invariants. @@ -344,7 +344,7 @@ These are architectural hooks, not committed features. **WASM Sandbox:** Agents cannot escape sandbox, access filesystem, or make network calls. -**Resource Limits:** Memory capped at 64MB, tick timeout at 100ms. +**Resource Limits:** Memory capped at 64MB, tick timeout at 15s. **Assumptions:** Nodes are semi-trusted. Agent state is visible to nodes. Budget accounting is not cryptographically verified. diff --git a/docs/runtime/HOSTCALL_ABI.md b/docs/runtime/HOSTCALL_ABI.md index 366094b..0cf50bd 100644 --- a/docs/runtime/HOSTCALL_ABI.md +++ b/docs/runtime/HOSTCALL_ABI.md @@ -91,13 +91,47 @@ Persistent key-value storage scoped to the agent. Reads are observations; writes | `kv_put` | `(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32) -> (i32)` | Writes value for key. Side effect. Returns 0 on success, negative on error. | | `kv_delete` | `(key_ptr: i32, key_len: i32) -> (i32)` | Deletes key. Side effect. Returns 0 on success, negative on error. | -### net — Network Requests (Not Yet Implemented) +### http — HTTP Requests -HTTP-like request/response capability. Side-effect hostcall gated on ACTIVE_OWNER (CE-4). Reserved for a future phase. +HTTP request/response capability for calling external APIs. Side-effect hostcall. Recorded in event log for replay (CE-3). | Function | Signature | Description | |----------|-----------|-------------| -| `net_request` | `(req_ptr: i32, req_len: i32, resp_ptr: i32, resp_cap: i32) -> (i32)` | Sends request, writes response to buffer. Returns bytes written or negative on error. | +| `http_request` | `(method_ptr: i32, method_len: i32, url_ptr: i32, url_len: i32, headers_ptr: i32, headers_len: i32, body_ptr: i32, body_len: i32, resp_ptr: i32, resp_cap: i32) -> (i32)` | Sends HTTP request, writes response to buffer. Returns HTTP status code (>0) on success, negative error code on failure. | + +**Implementation:** `internal/hostcall/http.go` — reads method, URL, headers, and body from WASM memory, executes the request via `http.Client`, writes response as `[body_len: 4 bytes LE][body: N bytes]` to the response buffer, records status code and body in event log. + +**Replay behavior:** During replay, returns the recorded status code and response body from the event log. + +**Manifest configuration:** +```json +{ + "http": { + "version": 1, + "options": { + "allowed_hosts": ["api.coingecko.com"], + "timeout_ms": 10000, + "max_response_bytes": 1048576 + } + } +} +``` + +**Options:** +- `allowed_hosts` — list of permitted hostnames (empty = all hosts allowed) +- `timeout_ms` — per-request timeout in milliseconds (default: 10000) +- `max_response_bytes` — maximum response body size (default: 1MB) + +**Error codes:** +| Value | Meaning | +|-------|---------| +| `-1` | Network error | +| `-2` | Input too long (URL > 8KB, headers > 32KB, body > 1MB) | +| `-3` | Host not in allowed_hosts | +| `-4` | Request timeout | +| `-5` | Response body exceeds max_response_bytes | + +**Size hint:** When response buffer is too small (`-5`), the first 4 bytes of the response buffer contain the required body length as LE uint32, allowing the agent to retry with a larger allocation. --- @@ -131,7 +165,8 @@ Agents declare required capabilities in their manifest. The manifest is evaluate "clock": { "version": 1 }, "rand": { "version": 1 }, "log": { "version": 1 }, - "wallet": { "version": 1 } + "wallet": { "version": 1 }, + "http": { "version": 1, "options": { "allowed_hosts": ["api.example.com"] } } }, "resource_limits": { "max_memory_bytes": 33554432 diff --git a/sdk/igor/lifecycle.go b/sdk/igor/lifecycle.go index 067e5ac..bcdb00b 100644 --- a/sdk/igor/lifecycle.go +++ b/sdk/igor/lifecycle.go @@ -12,7 +12,7 @@ type Agent interface { Init() // Tick executes one step of the agent's logic. - // Must complete within 100ms. + // Must complete within the tick timeout (15s). // Return true if there is more work pending (tick again soon, subject to // 10ms minimum interval). Return false to sleep until the next normal // interval (~1 Hz). From 04ae5b35224679dba1c467ea9fcfc40f94a88ef3 Mon Sep 17 00:00:00 2001 From: Janko Date: Sat, 14 Mar 2026 23:15:47 +0100 Subject: [PATCH 3/5] refactor(agent): decouple product binary from research-only packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the transitive dependency chain that pulled authority and config packages into the product igord binary: - Extract DefaultTickTimeout (15s) into internal/agent, replacing config.TickTimeout usage in the product path - Define EpochData struct in internal/agent to replace authority.Epoch in checkpoint headers; Instance.Lease becomes `any` with LeaseInfo interface for checkpoint building - Extract duplicated loadOrGenerateIdentity into pkg/identity/loader.go with a minimal Store interface - Move research agents (example, reconciliation) under agents/research/ - Move cmd/demo-reconciliation into agents/research/reconciliation/cmd/demo/ - Update Makefile, CLAUDE.md, and all doc references for new paths Verified: `go list -deps ./cmd/igord/ | grep -E 'authority|config'` returns nothing — product binary is fully decoupled. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 ++- Makefile | 12 ++-- agents/example/go.mod | 7 --- agents/reconciliation/go.mod | 7 --- agents/{ => research}/example/Makefile | 0 agents/{ => research}/example/README.md | 6 +- .../example/agent.manifest.json | 0 agents/research/example/go.mod | 7 +++ agents/{ => research}/example/main.go | 0 agents/{ => research}/reconciliation/Makefile | 0 .../reconciliation/agent.manifest.json | 0 .../research/reconciliation/cmd/demo}/main.go | 4 +- agents/research/reconciliation/go.mod | 7 +++ agents/{ => research}/reconciliation/main.go | 0 cmd/igord-lab/main.go | 46 ++------------ cmd/igord/main.go | 39 +----------- docs/IMPLEMENTATION_STATUS.md | 2 +- docs/archive/GENESIS_COMMIT.md | 2 +- docs/archive/GENESIS_RELEASE_CHECKLIST.md | 4 +- docs/governance/DEVELOPMENT.md | 6 +- docs/governance/ROADMAP.md | 2 +- docs/philosophy/OVERVIEW.md | 4 +- docs/runtime/AGENT_LIFECYCLE.md | 2 +- docs/runtime/MIGRATION_PROTOCOL.md | 2 +- internal/agent/instance.go | 43 ++++++++++--- internal/agent/instance_test.go | 5 +- internal/authority/lease.go | 15 +++++ internal/inspector/inspector.go | 3 +- internal/migration/service.go | 62 ++++++++++++------- internal/replay/engine.go | 4 +- internal/runner/research/research.go | 33 +++++++--- internal/simulator/simulator.go | 4 +- pkg/identity/loader.go | 52 ++++++++++++++++ scripts/prepare-release.sh | 2 +- 34 files changed, 222 insertions(+), 167 deletions(-) delete mode 100644 agents/example/go.mod delete mode 100644 agents/reconciliation/go.mod rename agents/{ => research}/example/Makefile (100%) rename agents/{ => research}/example/README.md (92%) rename agents/{ => research}/example/agent.manifest.json (100%) create mode 100644 agents/research/example/go.mod rename agents/{ => research}/example/main.go (100%) rename agents/{ => research}/reconciliation/Makefile (100%) rename agents/{ => research}/reconciliation/agent.manifest.json (100%) rename {cmd/demo-reconciliation => agents/research/reconciliation/cmd/demo}/main.go (99%) create mode 100644 agents/research/reconciliation/go.mod rename agents/{ => research}/reconciliation/main.go (100%) create mode 100644 pkg/identity/loader.go diff --git a/CLAUDE.md b/CLAUDE.md index 504efd6..f14d7ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Igor is the runtime for portable, immortal software agents. The checkpoint file ```bash make bootstrap # Install toolchain (Go, golangci-lint, goimports, TinyGo) make build # Build igord → bin/igord -make agent # Build example WASM agent → agents/example/agent.wasm +make agent # Build example WASM agent → agents/research/example/agent.wasm make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm @@ -44,7 +44,7 @@ Run manually (new subcommands): Legacy mode (P2P/migration): ```bash -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ./bin/igord --migrate-agent local-agent --to /ip4/127.0.0.1/tcp/4002/p2p/ --wasm agent.wasm ``` @@ -86,7 +86,8 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi - `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks - `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume - `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation -- `agents/example/` — Original demo agent (Survivor) from research phases +- `agents/research/example/` — Original demo agent (Survivor) from research phases +- `agents/research/reconciliation/` — Bridge reconciliation demo agent (research phase) - `scripts/demo-portable.sh` — End-to-end portable agent demo ### Migration flow diff --git a/Makefile b/Makefile index 72d58af..3cd0557 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,9 @@ # Build configuration BINARY_NAME := igord BINARY_DIR := bin -AGENT_DIR := agents/example +AGENT_DIR := agents/research/example HEARTBEAT_AGENT_DIR := agents/heartbeat -RECONCILIATION_AGENT_DIR := agents/reconciliation +RECONCILIATION_AGENT_DIR := agents/research/reconciliation PRICEWATCHER_AGENT_DIR := agents/pricewatcher SENTINEL_AGENT_DIR := agents/sentinel @@ -52,10 +52,10 @@ clean: ## Remove build artifacts $(GOCLEAN) rm -rf $(BINARY_DIR) rm -rf checkpoints - rm -f agents/example/agent.wasm - rm -f agents/example/agent.wasm.checkpoint + rm -f agents/research/example/agent.wasm + rm -f agents/research/example/agent.wasm.checkpoint rm -f agents/heartbeat/agent.wasm - rm -f agents/reconciliation/agent.wasm + rm -f agents/research/reconciliation/agent.wasm rm -f agents/pricewatcher/agent.wasm rm -f agents/sentinel/agent.wasm @echo "Clean complete" @@ -139,7 +139,7 @@ agent-sentinel: ## Build treasury sentinel demo agent WASM demo: build agent-reconciliation ## Build and run reconciliation demo @echo "Building demo runner..." @mkdir -p $(BINARY_DIR) - $(GOBUILD) -o $(BINARY_DIR)/demo-reconciliation ./cmd/demo-reconciliation + $(GOBUILD) -o $(BINARY_DIR)/demo-reconciliation ./agents/research/reconciliation/cmd/demo @echo "Running Bridge Reconciliation Demo..." ./$(BINARY_DIR)/demo-reconciliation --wasm $(RECONCILIATION_AGENT_DIR)/agent.wasm diff --git a/agents/example/go.mod b/agents/example/go.mod deleted file mode 100644 index 40d600c..0000000 --- a/agents/example/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module github.com/simonovic86/igor/agents/example - -go 1.24 - -require github.com/simonovic86/igor/sdk/igor v0.0.0 - -replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/reconciliation/go.mod b/agents/reconciliation/go.mod deleted file mode 100644 index df01f5e..0000000 --- a/agents/reconciliation/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module github.com/simonovic86/igor/agents/reconciliation - -go 1.24 - -require github.com/simonovic86/igor/sdk/igor v0.0.0 - -replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/example/Makefile b/agents/research/example/Makefile similarity index 100% rename from agents/example/Makefile rename to agents/research/example/Makefile diff --git a/agents/example/README.md b/agents/research/example/README.md similarity index 92% rename from agents/example/README.md rename to agents/research/example/README.md index b7a194a..8119bed 100644 --- a/agents/example/README.md +++ b/agents/research/example/README.md @@ -68,17 +68,17 @@ make agent # from repo root ```bash make run-agent # default budget 1.0 -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ``` ## Demonstrating Survival ```bash # Start the agent -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 # Let it tick a few times, then Ctrl-C (checkpoints on shutdown) # Restart — it resumes from checkpoint, tick count and age continue -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ``` diff --git a/agents/example/agent.manifest.json b/agents/research/example/agent.manifest.json similarity index 100% rename from agents/example/agent.manifest.json rename to agents/research/example/agent.manifest.json diff --git a/agents/research/example/go.mod b/agents/research/example/go.mod new file mode 100644 index 0000000..18fecd6 --- /dev/null +++ b/agents/research/example/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/research/example + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../../sdk/igor diff --git a/agents/example/main.go b/agents/research/example/main.go similarity index 100% rename from agents/example/main.go rename to agents/research/example/main.go diff --git a/agents/reconciliation/Makefile b/agents/research/reconciliation/Makefile similarity index 100% rename from agents/reconciliation/Makefile rename to agents/research/reconciliation/Makefile diff --git a/agents/reconciliation/agent.manifest.json b/agents/research/reconciliation/agent.manifest.json similarity index 100% rename from agents/reconciliation/agent.manifest.json rename to agents/research/reconciliation/agent.manifest.json diff --git a/cmd/demo-reconciliation/main.go b/agents/research/reconciliation/cmd/demo/main.go similarity index 99% rename from cmd/demo-reconciliation/main.go rename to agents/research/reconciliation/cmd/demo/main.go index 678c319..ffd1e86 100644 --- a/cmd/demo-reconciliation/main.go +++ b/agents/research/reconciliation/cmd/demo/main.go @@ -158,8 +158,8 @@ func runDemo(wasmPath string) error { } leaseEpoch := "(1,0)" - if instB.Lease != nil { - leaseEpoch = instB.Lease.Epoch.String() + if lease, ok := instB.Lease.(*authority.Lease); ok && lease != nil { + leaseEpoch = lease.Epoch.String() } fmt.Printf(" Agent migrated to node-b (epoch: %s)\n", leaseEpoch) fmt.Println() diff --git a/agents/research/reconciliation/go.mod b/agents/research/reconciliation/go.mod new file mode 100644 index 0000000..f85a1e9 --- /dev/null +++ b/agents/research/reconciliation/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/research/reconciliation + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../../sdk/igor diff --git a/agents/reconciliation/main.go b/agents/research/reconciliation/main.go similarity index 100% rename from agents/reconciliation/main.go rename to agents/research/reconciliation/main.go diff --git a/cmd/igord-lab/main.go b/cmd/igord-lab/main.go index 374aa39..89ddfba 100644 --- a/cmd/igord-lab/main.go +++ b/cmd/igord-lab/main.go @@ -191,7 +191,7 @@ func runLocalAgent( signingKey, nodeID := research.ExtractSigningKey(node) // Load or generate agent cryptographic identity for signed checkpoint lineage. - agentIdent, err := loadOrGenerateIdentity(ctx, storageProvider, "local-agent", logger) + agentIdent, err := identity.LoadOrGenerate(ctx, storageProvider, "local-agent", logger) if err != nil { return fmt.Errorf("agent identity: %w", err) } @@ -325,10 +325,11 @@ func initLocalAgent( RenewalWindow: cfg.LeaseRenewalWindow, GracePeriod: cfg.LeaseGracePeriod, } - instance.Lease = authority.NewLease(leaseCfg) + lease := authority.NewLease(leaseCfg) + instance.Lease = lease logger.Info("Lease granted", - "epoch", instance.Lease.Epoch, - "expiry", instance.Lease.Expiry, + "epoch", lease.Epoch, + "expiry", lease.Expiry, "duration", cfg.LeaseDuration, ) } @@ -358,7 +359,7 @@ func handleTick( ) (tickResult, error) { // Pre-tick lease validation (EI-6: safety over liveness) if leaseErr := research.CheckAndRenewLease(instance, logger); leaseErr != nil { - if instance.Lease != nil && instance.Lease.State == authority.StateRecoveryRequired { + if lease, ok := instance.Lease.(*authority.Lease); ok && lease != nil && lease.State == authority.StateRecoveryRequired { if recoverErr := research.AttemptLeaseRecovery(ctx, instance, logger); recoverErr == nil { return tickRecovered, nil } @@ -456,38 +457,3 @@ func runSimulator(wasmPath, manifestPath string, budgetVal float64, ticks int, v os.Exit(1) } } - -// loadOrGenerateIdentity loads an existing agent identity from storage, -// or generates a new one and persists it. -func loadOrGenerateIdentity( - ctx context.Context, - storageProvider storage.Provider, - agentID string, - logger *slog.Logger, -) (*identity.AgentIdentity, error) { - data, err := storageProvider.LoadIdentity(ctx, agentID) - if err == nil { - id, parseErr := identity.UnmarshalBinary(data) - if parseErr != nil { - logger.Warn("Corrupted agent identity, generating new", "error", parseErr) - } else { - logger.Info("Agent identity loaded", - "agent_id", agentID, - "pub_key_size", len(id.PublicKey), - ) - return id, nil - } - } - - id, err := identity.Generate() - if err != nil { - return nil, fmt.Errorf("generate identity: %w", err) - } - - if err := storageProvider.SaveIdentity(ctx, agentID, id.MarshalBinary()); err != nil { - return nil, fmt.Errorf("save identity: %w", err) - } - - logger.Info("Agent identity generated and saved", "agent_id", agentID) - return id, nil -} diff --git a/cmd/igord/main.go b/cmd/igord/main.go index 75ac043..b0d9c37 100644 --- a/cmd/igord/main.go +++ b/cmd/igord/main.go @@ -206,7 +206,7 @@ func runStandalone(wasmPath, agentID string, budgetVal float64, manifestPath, ch defer engine.Close(ctx) // Load or generate agent identity. - agentIdent, err := loadOrGenerateIdentity(ctx, storageProvider, agentID, logger) + agentIdent, err := identity.LoadOrGenerate(ctx, storageProvider, agentID, logger) if err != nil { logger.Error("Agent identity error", "error", err) os.Exit(1) @@ -300,7 +300,7 @@ func resumeFromCheckpoint(checkpointPath, wasmPath, agentID string, budgetVal fl agentIdent, _ = identity.UnmarshalBinary(idData) } if agentIdent == nil { - agentIdent, err = loadOrGenerateIdentity(ctx, storageProvider, agentID, logger) + agentIdent, err = identity.LoadOrGenerate(ctx, storageProvider, agentID, logger) if err != nil { logger.Error("Agent identity error", "error", err) os.Exit(1) @@ -403,41 +403,6 @@ func runTickLoop(ctx context.Context, instance *agent.Instance, logger *slog.Log } } -// loadOrGenerateIdentity loads an existing agent identity from storage, -// or generates a new one and persists it. -func loadOrGenerateIdentity( - ctx context.Context, - storageProvider storage.Provider, - agentID string, - logger *slog.Logger, -) (*identity.AgentIdentity, error) { - data, err := storageProvider.LoadIdentity(ctx, agentID) - if err == nil { - id, parseErr := identity.UnmarshalBinary(data) - if parseErr != nil { - logger.Warn("Corrupted agent identity, generating new", "error", parseErr) - } else { - logger.Info("Agent identity loaded", - "agent_id", agentID, - "pub_key_size", len(id.PublicKey), - ) - return id, nil - } - } - - id, err := identity.Generate() - if err != nil { - return nil, fmt.Errorf("generate identity: %w", err) - } - - if err := storageProvider.SaveIdentity(ctx, agentID, id.MarshalBinary()); err != nil { - return nil, fmt.Errorf("save identity: %w", err) - } - - logger.Info("Agent identity generated and saved", "agent_id", agentID) - return id, nil -} - // agentIDFromPath derives an agent ID from a WASM file path. func agentIDFromPath(wasmPath string) string { base := filepath.Base(wasmPath) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f4bef67..f425c07 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -138,7 +138,7 @@ Last updated: 2026-03-13 | Simulator replay verification | Implemented | `internal/simulator/simulator.go` `verifyTick` | | Checkpoint inspector | Implemented | `internal/inspector/inspector.go` | | WASM hash verification in inspector | Implemented | `internal/inspector/inspector.go` `VerifyWASM` | -| Agent template (Survivor example) | Implemented | `agents/example/` (research phase artifact) | +| Agent template (Survivor example) | Implemented | `agents/research/example/` (research phase artifact) | | Heartbeat demo agent | Implemented | `agents/heartbeat/` — tick count, age, milestones | | Price watcher demo agent | Implemented | `agents/pricewatcher/` — fetches BTC/ETH prices via HTTP hostcall, tracks high/low/latest | | Treasury sentinel demo agent | Implemented | `agents/sentinel/` — treasury monitoring with effect-safe crash recovery | diff --git a/docs/archive/GENESIS_COMMIT.md b/docs/archive/GENESIS_COMMIT.md index 111ce6b..e76629d 100644 --- a/docs/archive/GENESIS_COMMIT.md +++ b/docs/archive/GENESIS_COMMIT.md @@ -99,7 +99,7 @@ internal/migration/ # P2P migration internal/storage/ # Checkpoint persistence internal/p2p/ # libp2p networking pkg/protocol/ # Migration messages -agents/example/ # Counter agent (TinyGo) +agents/research/example/ # Counter agent (TinyGo) docs/ # Technical documentation TECHNICAL STACK diff --git a/docs/archive/GENESIS_RELEASE_CHECKLIST.md b/docs/archive/GENESIS_RELEASE_CHECKLIST.md index 0fb87a4..6b68679 100644 --- a/docs/archive/GENESIS_RELEASE_CHECKLIST.md +++ b/docs/archive/GENESIS_RELEASE_CHECKLIST.md @@ -141,7 +141,7 @@ Documented in: README.md, SECURITY.md, docs/runtime/SECURITY_MODEL.md - [ ] `make clean && make build` succeeds - [ ] Binary runs: `./bin/igord` - [ ] Agent example compiles: `make agent` -- [ ] Agent runs: `./bin/igord --run-agent agents/example/agent.wasm` +- [ ] Agent runs: `./bin/igord --run-agent agents/research/example/agent.wasm` - [ ] No segfaults or panics during normal operation ### Platform Support @@ -186,7 +186,7 @@ make build make check # Functionality test -./bin/igord --run-agent agents/example/agent.wasm --budget 1.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 1.0 # Verify: agent runs, ticks, checkpoints, terminates on interrupt ``` diff --git a/docs/governance/DEVELOPMENT.md b/docs/governance/DEVELOPMENT.md index 4377c42..729275d 100644 --- a/docs/governance/DEVELOPMENT.md +++ b/docs/governance/DEVELOPMENT.md @@ -112,7 +112,7 @@ Build the example agent: make agent ``` -Output: `agents/example/agent.wasm` +Output: `agents/research/example/agent.wasm` ### Running Locally @@ -125,7 +125,7 @@ make run-agent Or manually: ```bash -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ``` ### Code Quality @@ -489,7 +489,7 @@ Use delve debugger: go install github.com/go-delve/delve/cmd/dlv@latest # Debug igord -dlv exec ./bin/igord -- --run-agent agents/example/agent.wasm +dlv exec ./bin/igord -- --run-agent agents/research/example/agent.wasm ``` ### Agent Debugging diff --git a/docs/governance/ROADMAP.md b/docs/governance/ROADMAP.md index 01319c1..f45c2a9 100644 --- a/docs/governance/ROADMAP.md +++ b/docs/governance/ROADMAP.md @@ -78,7 +78,7 @@ Igor has completed its research foundation (**Phases 2–5**) and pivoted to pro - Capability mocks (`sdk/igor/mock/`): pluggable `MockBackend` for native testing without WASM, deterministic clock/rand, log capture - Local simulator (`internal/simulator/`): single-process WASM runner with deterministic hostcalls, per-tick replay verification, checkpoint round-trip verification - Checkpoint inspector (`internal/inspector/`): parse and display checkpoint files, WASM hash verification -- Agent template (`agents/example/`): Survivor agent demonstrating SDK usage, hostcall patterns, and state serialization +- Agent template (`agents/research/example/`): Survivor agent demonstrating SDK usage, hostcall patterns, and state serialization - CLI flags: `--simulate`, `--ticks`, `--verify`, `--deterministic`, `--seed`, `--inspect-checkpoint`, `--inspect-wasm` **Outcome:** Developers can build agents without manually managing WASM exports, memory, and hostcall signatures. Agents can be tested natively with mocks or as compiled WASM in the simulator. diff --git a/docs/philosophy/OVERVIEW.md b/docs/philosophy/OVERVIEW.md index a68392d..8f3ee01 100644 --- a/docs/philosophy/OVERVIEW.md +++ b/docs/philosophy/OVERVIEW.md @@ -111,7 +111,7 @@ go build -o bin/igord ./cmd/igord ### Run an Agent ```bash -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ``` ### Migrate an Agent @@ -124,7 +124,7 @@ go build -o bin/igord ./cmd/igord ./bin/igord \ --migrate-agent local-agent \ --to /ip4/127.0.0.1/tcp/4002/p2p/ \ - --wasm agents/example/agent.wasm + --wasm agents/research/example/agent.wasm ``` ## Further Reading diff --git a/docs/runtime/AGENT_LIFECYCLE.md b/docs/runtime/AGENT_LIFECYCLE.md index d61ce42..74f39a7 100644 --- a/docs/runtime/AGENT_LIFECYCLE.md +++ b/docs/runtime/AGENT_LIFECYCLE.md @@ -372,7 +372,7 @@ If `agent_resume()` fails: ## Example Agent (Survivor) -Complete implementation in `agents/example/main.go` using the Igor SDK (`sdk/igor`): +Complete implementation in `agents/research/example/main.go` using the Igor SDK (`sdk/igor`): ```go type Survivor struct { diff --git a/docs/runtime/MIGRATION_PROTOCOL.md b/docs/runtime/MIGRATION_PROTOCOL.md index 0bd43a0..c9a01ed 100644 --- a/docs/runtime/MIGRATION_PROTOCOL.md +++ b/docs/runtime/MIGRATION_PROTOCOL.md @@ -283,7 +283,7 @@ Source Node Target Node ./bin/igord \ --migrate-agent local-agent \ --to /ip4/127.0.0.1/tcp/4002/p2p/12D3KooW... \ - --wasm agents/example/agent.wasm + --wasm agents/research/example/agent.wasm ``` ### Requirements diff --git a/internal/agent/instance.go b/internal/agent/instance.go index 31260a3..bc42a43 100644 --- a/internal/agent/instance.go +++ b/internal/agent/instance.go @@ -14,8 +14,6 @@ import ( "os" "time" - "github.com/simonovic86/igor/internal/authority" - "github.com/simonovic86/igor/internal/config" "github.com/simonovic86/igor/internal/eventlog" "github.com/simonovic86/igor/internal/hostcall" "github.com/simonovic86/igor/internal/runtime" @@ -44,8 +42,34 @@ const ( // DefaultReplayWindowSize is the number of recent tick snapshots retained // for sliding replay verification (CM-4). DefaultReplayWindowSize = 16 + + // DefaultTickTimeout is the maximum duration for a single agent tick. + // Used by agent execution, replay verification, and the simulator. + // Set to 15s to accommodate agents making HTTP requests during ticks. + DefaultTickTimeout = 15 * time.Second ) +// EpochData holds authority epoch metadata from a checkpoint header. +// Decoupled from the authority state machine (which is a research/P2P concern). +type EpochData struct { + MajorVersion uint64 + LeaseGeneration uint64 +} + +// String returns a human-readable representation of the epoch. +func (e EpochData) String() string { + return fmt.Sprintf("(%d,%d)", e.MajorVersion, e.LeaseGeneration) +} + +// LeaseInfo provides lease metadata for checkpoint building. +// Implemented by authority.Lease; nil when leases are disabled. +// Research code may type-assert to *authority.Lease for full API access. +type LeaseInfo interface { + GetMajorVersion() uint64 + GetLeaseGeneration() uint64 + ExpiryUnixNano() int64 +} + // CheckpointHeader holds all parsed checkpoint metadata. // Returned by ParseCheckpointHeader for clean access to all fields. type CheckpointHeader struct { @@ -54,7 +78,7 @@ type CheckpointHeader struct { PricePerSecond int64 TickNumber uint64 WASMHash [32]byte - Epoch authority.Epoch + Epoch EpochData LeaseExpiry int64 // Unix nanoseconds; 0 = no lease // V4 lineage fields (zero values for v0x02/v0x03) PrevHash [32]byte @@ -116,7 +140,8 @@ type Instance struct { BudgetAdapter settlement.BudgetAdapter // optional; nil = no external budget validation // Lease-based authority (Phase 5: Hardening) - Lease *authority.Lease // Lease state; nil = leases disabled + // Product code: always nil. Research code sets this to *authority.Lease. + Lease any // nil = leases disabled; set to *authority.Lease for research // Agent cryptographic identity (Task 13: Signed Checkpoint Lineage) AgentIdentity *identity.AgentIdentity // Agent's Ed25519 keypair; nil = lineage disabled @@ -397,7 +422,7 @@ func (i *Instance) Tick(ctx context.Context) (bool, error) { i.EventLog.BeginTick(i.TickNumber) // Enforce tick timeout - tickCtx, cancel := context.WithTimeout(ctx, config.TickTimeout) + tickCtx, cancel := context.WithTimeout(ctx, DefaultTickTimeout) defer cancel() fn := i.Module.ExportedFunction("agent_tick") @@ -642,10 +667,10 @@ func (i *Instance) buildCheckpoint(state []byte) ([]byte, error) { // Lease epoch metadata (zero values if leases disabled) var majorVersion, leaseGeneration uint64 var leaseExpiryNanos int64 - if i.Lease != nil { - majorVersion = i.Lease.Epoch.MajorVersion - leaseGeneration = i.Lease.Epoch.LeaseGeneration - leaseExpiryNanos = i.Lease.Expiry.UnixNano() + if lease, ok := i.Lease.(LeaseInfo); ok && lease != nil { + majorVersion = lease.GetMajorVersion() + leaseGeneration = lease.GetLeaseGeneration() + leaseExpiryNanos = lease.ExpiryUnixNano() } if i.AgentIdentity == nil { diff --git a/internal/agent/instance_test.go b/internal/agent/instance_test.go index d16011f..7c81186 100644 --- a/internal/agent/instance_test.go +++ b/internal/agent/instance_test.go @@ -14,7 +14,6 @@ import ( "testing" "time" - "github.com/simonovic86/igor/internal/config" "github.com/simonovic86/igor/internal/eventlog" "github.com/simonovic86/igor/internal/runtime" "github.com/simonovic86/igor/internal/storage" @@ -932,7 +931,7 @@ func TestTick_TimeoutEnforcement(t *testing.T) { t.Logf("Tick timed out after %v with error: %v", elapsed, err) // Should complete within a reasonable bound (allow generous margin for CI) - if elapsed > config.TickTimeout+5*time.Second { - t.Fatalf("tick took too long: %v (expected < %v)", elapsed, config.TickTimeout+5*time.Second) + if elapsed > DefaultTickTimeout+5*time.Second { + t.Fatalf("tick took too long: %v (expected < %v)", elapsed, DefaultTickTimeout+5*time.Second) } } diff --git a/internal/authority/lease.go b/internal/authority/lease.go index d9ef4e4..a53ea4c 100644 --- a/internal/authority/lease.go +++ b/internal/authority/lease.go @@ -200,3 +200,18 @@ func (l *Lease) Recover() error { func (l *Lease) Config() LeaseConfig { return l.config } + +// GetMajorVersion implements agent.LeaseInfo. +func (l *Lease) GetMajorVersion() uint64 { + return l.Epoch.MajorVersion +} + +// GetLeaseGeneration implements agent.LeaseInfo. +func (l *Lease) GetLeaseGeneration() uint64 { + return l.Epoch.LeaseGeneration +} + +// ExpiryUnixNano implements agent.LeaseInfo. +func (l *Lease) ExpiryUnixNano() int64 { + return l.Expiry.UnixNano() +} diff --git a/internal/inspector/inspector.go b/internal/inspector/inspector.go index 3dac3fc..35d9ef0 100644 --- a/internal/inspector/inspector.go +++ b/internal/inspector/inspector.go @@ -14,7 +14,6 @@ import ( "time" "github.com/simonovic86/igor/internal/agent" - "github.com/simonovic86/igor/internal/authority" "github.com/simonovic86/igor/pkg/budget" "github.com/simonovic86/igor/pkg/lineage" ) @@ -29,7 +28,7 @@ type Result struct { TickNumber uint64 WASMHash [32]byte WASMHashHex string - Epoch authority.Epoch + Epoch agent.EpochData LeaseExpiry int64 // Unix nanoseconds; 0 = no lease StateSize int State []byte diff --git a/internal/migration/service.go b/internal/migration/service.go index 1aaeec5..8e4bafb 100644 --- a/internal/migration/service.go +++ b/internal/migration/service.go @@ -36,6 +36,15 @@ import ( // MigrateProtocol is the libp2p protocol ID for agent migration. const MigrateProtocol protocol.ID = "/igor/migrate/1.0.0" +// getLease extracts the *authority.Lease from an instance's Lease field (any). +func getLease(instance *agent.Instance) *authority.Lease { + if instance.Lease == nil { + return nil + } + l, _ := instance.Lease.(*authority.Lease) + return l +} + // Service coordinates agent migration between nodes. type Service struct { host host.Host @@ -137,7 +146,7 @@ func (s *Service) buildMigrationPackage( if hdr, _, parseErr := agent.ParseCheckpointHeader(checkpoint); parseErr == nil { budgetVal = hdr.Budget pricePerSecond = hdr.PricePerSecond - epoch = hdr.Epoch + epoch = authority.Epoch{MajorVersion: hdr.Epoch.MajorVersion, LeaseGeneration: hdr.Epoch.LeaseGeneration} s.logger.Info("Budget metadata extracted", "budget", budget.Format(budgetVal), "price_per_second", budget.Format(pricePerSecond), @@ -209,10 +218,12 @@ func (s *Service) MigrateAgent( // Transition local lease to HANDOFF_INITIATED before anything else. s.mu.RLock() - if li, ok := s.activeAgents[agentID]; ok && li.Lease != nil { - if err := li.Lease.TransitionToHandoff(); err != nil { - s.mu.RUnlock() - return fmt.Errorf("lease handoff transition: %w", err) + if li, ok := s.activeAgents[agentID]; ok { + if lease := getLease(li); lease != nil { + if err := lease.TransitionToHandoff(); err != nil { + s.mu.RUnlock() + return fmt.Errorf("lease handoff transition: %w", err) + } } } s.mu.RUnlock() @@ -298,9 +309,11 @@ func (s *Service) migrateToTarget( // and cleans up local storage after a successful migration. func (s *Service) finalizeMigration(ctx context.Context, agentID string, localInstance *agent.Instance) { // Transition source lease to RETIRED after target confirms - if localInstance != nil && localInstance.Lease != nil { - if err := localInstance.Lease.TransitionToRetired(); err != nil { - s.logger.Warn("Failed to transition lease to RETIRED", "error", err) + if localInstance != nil { + if lease := getLease(localInstance); lease != nil { + if err := lease.TransitionToRetired(); err != nil { + s.logger.Warn("Failed to transition lease to RETIRED", "error", err) + } } } @@ -351,9 +364,11 @@ func (s *Service) MigrateAgentWithRetry( s.mu.RLock() li, hasAgent := s.activeAgents[agentID] s.mu.RUnlock() - if hasAgent && li.Lease != nil { - if err := li.Lease.TransitionToHandoff(); err != nil { - return "", fmt.Errorf("lease handoff transition: %w", err) + if hasAgent { + if lease := getLease(li); lease != nil { + if err := lease.TransitionToHandoff(); err != nil { + return "", fmt.Errorf("lease handoff transition: %w", err) + } } } @@ -408,8 +423,10 @@ func (s *Service) MigrateAgentWithRetry( s.logger.Error("Transfer sent but no confirmation (FS-2) — entering RECOVERY_REQUIRED", "agent_id", agentID, ) - if hasAgent && li.Lease != nil { - li.Lease.State = authority.StateRecoveryRequired + if hasAgent { + if lease := getLease(li); lease != nil { + lease.State = authority.StateRecoveryRequired + } } return "", fmt.Errorf("migration ambiguous (transfer sent, no confirmation): %w", migrateErr) } @@ -447,11 +464,13 @@ func (s *Service) revertHandoff(agentID string) { s.mu.RLock() li, ok := s.activeAgents[agentID] s.mu.RUnlock() - if ok && li.Lease != nil { - if err := li.Lease.RevertHandoff(); err != nil { - s.logger.Warn("Failed to revert handoff", "agent_id", agentID, "error", err) - } else { - s.logger.Info("Handoff reverted, agent can resume ticking", "agent_id", agentID) + if ok { + if lease := getLease(li); lease != nil { + if err := lease.RevertHandoff(); err != nil { + s.logger.Warn("Failed to revert handoff", "agent_id", agentID, "error", err) + } else { + s.logger.Info("Handoff reverted, agent can resume ticking", "agent_id", agentID) + } } } } @@ -642,11 +661,12 @@ func (s *Service) handleIncomingMigration(stream network.Stream) { // Grant lease with advanced epoch (MajorVersion+1) for the migrated agent if s.leaseConfig.Duration > 0 { - instance.Lease = authority.NewLeaseFromMigration(pkg.MajorVersion, s.leaseConfig) + lease := authority.NewLeaseFromMigration(pkg.MajorVersion, s.leaseConfig) + instance.Lease = lease s.logger.Info("Lease granted to migrated agent", "agent_id", pkg.AgentID, - "epoch", instance.Lease.Epoch, - "expiry", instance.Lease.Expiry, + "epoch", lease.Epoch, + "expiry", lease.Expiry, ) } diff --git a/internal/replay/engine.go b/internal/replay/engine.go index 3069e0c..a4e8cb5 100644 --- a/internal/replay/engine.go +++ b/internal/replay/engine.go @@ -15,7 +15,7 @@ import ( "log/slog" "time" - "github.com/simonovic86/igor/internal/config" + "github.com/simonovic86/igor/internal/agent" "github.com/simonovic86/igor/internal/eventlog" "github.com/simonovic86/igor/internal/wasmutil" "github.com/simonovic86/igor/pkg/manifest" @@ -25,7 +25,7 @@ import ( ) // replayTickTimeout aliases the shared tick timeout constant. -const replayTickTimeout = config.TickTimeout +const replayTickTimeout = agent.DefaultTickTimeout // Result describes the outcome of replaying a single tick. type Result struct { diff --git a/internal/runner/research/research.go b/internal/runner/research/research.go index 3ab5499..1b25ff3 100644 --- a/internal/runner/research/research.go +++ b/internal/runner/research/research.go @@ -13,12 +13,23 @@ import ( "log/slog" "github.com/simonovic86/igor/internal/agent" + "github.com/simonovic86/igor/internal/authority" "github.com/simonovic86/igor/internal/config" "github.com/simonovic86/igor/internal/p2p" "github.com/simonovic86/igor/internal/replay" "github.com/simonovic86/igor/internal/runner" ) +// getLease extracts the *authority.Lease from instance.Lease (which is any). +// Returns nil if leases are disabled. +func getLease(instance *agent.Instance) *authority.Lease { + if instance.Lease == nil { + return nil + } + l, _ := instance.Lease.(*authority.Lease) + return l +} + // HandleDivergenceAction acts on the escalation policy returned by VerifyNextTick. // migrateFn is optional — passing nil preserves the existing "fall through to pause" // behavior for DivergenceMigrate. @@ -58,19 +69,20 @@ func HandleDivergenceAction(ctx context.Context, instance *agent.Instance, cfg * // Returns nil if ticking is allowed, or an error if the lease has expired. // No-op if leases are disabled (instance.Lease == nil). func CheckAndRenewLease(instance *agent.Instance, logger *slog.Logger) error { - if instance.Lease == nil { + lease := getLease(instance) + if lease == nil { return nil } - if err := instance.Lease.ValidateForTick(); err != nil { + if err := lease.ValidateForTick(); err != nil { return err } - if instance.Lease.NeedsRenewal() { - if err := instance.Lease.Renew(); err != nil { + if lease.NeedsRenewal() { + if err := lease.Renew(); err != nil { logger.Error("Lease renewal failed", "error", err) } else { logger.Info("Lease renewed", - "epoch", instance.Lease.Epoch, - "expiry", instance.Lease.Expiry, + "epoch", lease.Epoch, + "expiry", lease.Expiry, ) } } @@ -90,16 +102,17 @@ func HandleLeaseExpiry(ctx context.Context, instance *agent.Instance, leaseErr e // AttemptLeaseRecovery tries to recover from RECOVERY_REQUIRED state. func AttemptLeaseRecovery(ctx context.Context, instance *agent.Instance, logger *slog.Logger) error { - if instance.Lease == nil { + lease := getLease(instance) + if lease == nil { return fmt.Errorf("lease recovery: no lease configured") } - if err := instance.Lease.Recover(); err != nil { + if err := lease.Recover(); err != nil { return fmt.Errorf("lease recovery: %w", err) } logger.Info("Lease recovered", "agent_id", instance.AgentID, - "epoch", instance.Lease.Epoch, - "expiry", instance.Lease.Expiry, + "epoch", lease.Epoch, + "expiry", lease.Expiry, ) trySaveCheckpoint(ctx, instance, logger) return nil diff --git a/internal/simulator/simulator.go b/internal/simulator/simulator.go index e2fc69d..21a7181 100644 --- a/internal/simulator/simulator.go +++ b/internal/simulator/simulator.go @@ -13,7 +13,7 @@ import ( "os" "time" - "github.com/simonovic86/igor/internal/config" + "github.com/simonovic86/igor/internal/agent" "github.com/simonovic86/igor/internal/eventlog" "github.com/simonovic86/igor/internal/hostcall" "github.com/simonovic86/igor/internal/replay" @@ -242,7 +242,7 @@ func executeTick(ctx context.Context, env *simEnv, result *Result, tickNum uint6 env.el.BeginTick(tickNum) - tickCtx, cancel := context.WithTimeout(ctx, config.TickTimeout) + tickCtx, cancel := context.WithTimeout(ctx, agent.DefaultTickTimeout) start := time.Now() _, tickErr := env.mod.ExportedFunction("agent_tick").Call(tickCtx) elapsed := time.Since(start) diff --git a/pkg/identity/loader.go b/pkg/identity/loader.go new file mode 100644 index 0000000..8dc1a53 --- /dev/null +++ b/pkg/identity/loader.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import ( + "context" + "fmt" + "log/slog" +) + +// Store is the subset of storage.Provider needed for identity +// persistence. Both internal/storage.FSProvider and any future +// implementations satisfy this interface implicitly. +type Store interface { + LoadIdentity(ctx context.Context, agentID string) ([]byte, error) + SaveIdentity(ctx context.Context, agentID string, data []byte) error +} + +// LoadOrGenerate loads an existing agent identity from storage, or generates +// a new one if none exists (or the stored data is corrupted). +func LoadOrGenerate( + ctx context.Context, + store Store, + agentID string, + logger *slog.Logger, +) (*AgentIdentity, error) { + data, err := store.LoadIdentity(ctx, agentID) + if err == nil { + id, parseErr := UnmarshalBinary(data) + if parseErr != nil { + logger.Warn("Corrupted agent identity, generating new", "error", parseErr) + } else { + logger.Info("Agent identity loaded", + "agent_id", agentID, + "pub_key_size", len(id.PublicKey), + ) + return id, nil + } + } + + id, err := Generate() + if err != nil { + return nil, fmt.Errorf("generate identity: %w", err) + } + + if err := store.SaveIdentity(ctx, agentID, id.MarshalBinary()); err != nil { + return nil, fmt.Errorf("save identity: %w", err) + } + + logger.Info("Agent identity generated and saved", "agent_id", agentID) + return id, nil +} diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index a84feee..aa0ea34 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -75,7 +75,7 @@ See [SECURITY.md](./SECURITY.md) for complete threat model. ```bash make build -./bin/igord --run-agent agents/example/agent.wasm --budget 10.0 +./bin/igord --run-agent agents/research/example/agent.wasm --budget 10.0 ``` ## Documentation From 6cbe0100d1972d5a5cf6a1fb262de3013b05ae7a Mon Sep 17 00:00:00 2001 From: Janko Date: Sat, 14 Mar 2026 23:17:44 +0100 Subject: [PATCH 4/5] chore: set LICENSE copyright to Janko Simonovic Co-Authored-By: Claude Opus 4.6 --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 3749ec6..afa2e8a 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ "Refer to the LICENSE file" or similar statement added to each source file to indicate the file is part of the licensed work. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 Janko Simonovic Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 36ce1122dca4e45e2003a718ab5f9f14a1195a94 Mon Sep 17 00:00:00 2001 From: Janko Date: Sat, 14 Mar 2026 23:21:18 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(agent):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20checkpoint-before-execute=20and=20unmarshal=20bound?= =?UTF-8?q?s=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sentinel: return false (standard 1s tick) after recording a refill intent so the runtime persists a checkpoint before executeRefill runs. Previously returned true (10ms fast tick), risking intent loss on crash. - EffectLog.Unmarshal: add bounds check before reading state byte to prevent panic on truncated effect-log payloads during resume. Co-Authored-By: Claude Opus 4.6 --- agents/sentinel/main.go | 6 +++++- sdk/igor/effects.go | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/agents/sentinel/main.go b/agents/sentinel/main.go index 643cfb0..9cbdd82 100644 --- a/agents/sentinel/main.go +++ b/agents/sentinel/main.go @@ -142,7 +142,11 @@ func (s *Sentinel) recordRefillIntent(ageSec int64) bool { igor.Logf("[sentinel] Intent RECORDED: refill $%d.%02d (key=%x...)", refillAmount/100, refillAmount%100, key[:4]) igor.Logf("[sentinel] Waiting for checkpoint before execution...") - return true // request fast tick so we proceed quickly + // Return false (standard 1s interval) so the runtime has time to + // persist a checkpoint containing this intent before we execute. + // If we returned true (10ms fast tick), executeRefill would run + // before the intent is checkpointed, and a crash would lose it. + return false } // executeRefill transitions a Recorded intent to InFlight and performs diff --git a/sdk/igor/effects.go b/sdk/igor/effects.go index ecfa8d9..c0e7d93 100644 --- a/sdk/igor/effects.go +++ b/sdk/igor/effects.go @@ -196,6 +196,9 @@ func (e *EffectLog) Unmarshal(data []byte) { if d.Err() != nil { break } + if d.pos >= len(d.data) { + break + } state := IntentState(d.data[d.pos]) d.pos++