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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand All @@ -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
```
Expand Down Expand Up @@ -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

Expand All @@ -90,7 +94,7 @@ Source checkpoints → packages (WASM + checkpoint + budget) → transfers over

### CLI subcommands (Product Phase 1)
- `igord run [flags] <agent.wasm>` — run agent with new identity (`--budget`, `--checkpoint-dir`, `--agent-id`)
- `igord resume <checkpoint.ckpt> <agent.wasm>` — resume agent from checkpoint file
- `igord resume --checkpoint <path> --wasm <path>` — resume agent from checkpoint file
- `igord verify <history-dir>` — verify checkpoint lineage chain
- `igord inspect <checkpoint.ckpt>` — display checkpoint details with DID identity

Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions agents/sentinel/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.PHONY: build clean

build:
tinygo build -target=wasi -no-debug -o agent.wasm .

clean:
rm -f agent.wasm
10 changes: 10 additions & 0 deletions agents/sentinel/agent.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"capabilities": {
"clock": { "version": 1 },
"rand": { "version": 1 },
"log": { "version": 1 }
},
"resource_limits": {
"max_memory_bytes": 67108864
}
}
7 changes: 7 additions & 0 deletions agents/sentinel/go.mod
Original file line number Diff line number Diff line change
@@ -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
259 changes: 259 additions & 0 deletions agents/sentinel/main.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for a durable checkpoint before executing refill

recordRefillIntent asks for a fast follow-up tick (return true), but in cmd/igord/main.go the fast interval is 10ms while checkpoints are only persisted every 5s; this means executeRefill will usually run before any checkpoint captures the newly Recorded intent. If the process crashes during that execution window, resume can roll back to a checkpoint that never contained the intent, so the transfer cannot become Unresolved and may be retried/omitted incorrectly.

Useful? React with 👍 / 👎.

}

// 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() {}
Loading
Loading