-
Notifications
You must be signed in to change notification settings - Fork 0
docs + refactor: fix audit inconsistencies, decouple product from research packages #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b7891e5
feat(sdk): add effect lifecycle model and treasury sentinel demo agent
simonovic86 7a85fbc
docs: fix 10 documentation inconsistencies found in audit
simonovic86 04ae5b3
refactor(agent): decouple product binary from research-only packages
simonovic86 6cbe010
chore: set LICENSE copyright to Janko Simonovic
simonovic86 36ce112
fix(agent): address PR review — checkpoint-before-execute and unmarsh…
simonovic86 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| // 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() {} | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
recordRefillIntentasks for a fast follow-up tick (return true), but incmd/igord/main.gothe fast interval is 10ms while checkpoints are only persisted every 5s; this meansexecuteRefillwill usually run before any checkpoint captures the newlyRecordedintent. If the process crashes during that execution window, resume can roll back to a checkpoint that never contained the intent, so the transfer cannot becomeUnresolvedand may be retried/omitted incorrectly.Useful? React with 👍 / 👎.