Skip to content

Commit e1c1a18

Browse files
FeilixXclaude
andcommitted
fix: energy starvation — distribute ticks to agents only, remove root exemption
Agents were receiving 0 energy per tick because root's energy_share (100.0) dominated the total, causing floor() to round smaller shares to 0. - Energy distribution now filters by actor_type='agent' (zero DB migration) - Removed hardcoded root lifecycle exemption (root follows lineage checks) - Added update_energy_share lifecycle operation for runtime share adjustment - Added is_finite() validation to prevent NaN/Infinity poisoning - Windows IPC: file-path pipe (\.\pipe\punkgo-kernel) fixes Access Denied - Daemon PID lockfile with duplicate instance detection - Bumped to 0.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cef194f commit e1c1a18

15 files changed

Lines changed: 446 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to PunkGo Kernel will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.3.0] - 2026-03-13
9+
10+
### Fixed
11+
12+
- **Energy starvation bug** — agents received 0 energy per tick due to `floor()` rounding when root's `energy_share` dominated the total. Distribution now targets agents only (`actor_type = 'agent'`); humans receive one-time initial balance
13+
- **Windows IPC "Access Denied"** — default endpoint changed to file-path pipe (`\\.\pipe\punkgo-kernel`) to avoid `GenericNamespaced` permission issues
14+
- **NaN/Infinity validation**`update_energy_share` rejects non-finite values that would poison tick distribution
15+
16+
### Added
17+
18+
- **`update_energy_share` lifecycle operation** — runtime adjustment of an agent's energy share via `mutate` action
19+
- **PID lockfile** (`daemon.pid`) — daemon startup detects and prompts to kill existing instances
20+
- **`PUNKGO_IPC_ENDPOINT` env var** — override the default IPC endpoint
21+
22+
### Changed
23+
24+
- **Root is genesis-only** — removed hardcoded root exemption from lifecycle authorization; root now follows lineage-based checks like any human
25+
- **Root default `energy_share`** — 100.0 → 0.0 for new databases (existing databases unaffected due to `ON CONFLICT DO NOTHING`)
26+
- **PIP-001 §3** — clarified that share distribution applies to agents only
27+
828
## [0.2.1] - 2026-02-22
929

1030
### Fixed

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
resolver = "2"
88

99
[workspace.package]
10-
version = "0.2.9"
10+
version = "0.3.0"
1111
edition = "2024"
1212
authors = ["Felix <feijiu@punkgo.ai>"]
1313
license = "MIT"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Every action goes through a **7-step pipeline**:
5454

5555
### Energy System
5656

57-
Energy is produced continuously, anchored to the machine's hardware compute power (INT8 TOPS). Each tick, energy is distributed proportionally among active actors based on their shares. The kernel itself holds no energy (energy neutrality).
57+
Energy is produced continuously, anchored to the machine's hardware compute power (INT8 TOPS). Each tick, energy is distributed proportionally among active **agents** based on their shares. Humans (including root) receive a one-time initial balance but do not participate in tick-based distribution. The kernel itself holds no energy (energy neutrality).
5858

5959
### Actor Model
6060

crates/punkgo-core/src/actor.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,16 @@ pub struct CreateActorSpec {
107107
// ---------------------------------------------------------------------------
108108

109109
/// Phase 4a: Lifecycle operations that can be performed on actors.
110-
#[derive(Debug, Clone, PartialEq, Eq)]
110+
#[derive(Debug, Clone, PartialEq)]
111111
pub enum LifecycleOp {
112112
/// Freeze an actor — suspends all state-changing actions.
113113
Freeze { reason: Option<String> },
114114
/// Unfreeze — restores an actor to active.
115115
Unfreeze,
116116
/// Terminate — permanently removes an agent (creates orphans).
117117
Terminate { reason: Option<String> },
118+
/// Update an actor's energy_share for tick-based distribution.
119+
UpdateEnergyShare { energy_share: f64 },
118120
}
119121

120122
// ---------------------------------------------------------------------------

crates/punkgo-kernel/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ default = []
1717
testkit = []
1818

1919
[dependencies]
20-
punkgo-core = { path = "../punkgo-core", version = "0.2.9" }
20+
punkgo-core = { path = "../punkgo-core", version = "0.3.0" }
2121
anyhow.workspace = true
2222
async-trait.workspace = true
2323
serde.workspace = true

crates/punkgo-kernel/src/daemon/main.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//! 3. Listens for IPC connections and dispatches requests to [`Kernel::handle_request`]
1111
//! 4. On CTRL-C: signals the energy producer to stop, aborts the IPC server, and exits
1212
13+
use std::path::PathBuf;
1314
use std::sync::Arc;
1415

1516
use anyhow::Result;
@@ -27,6 +28,11 @@ async fn main() -> Result<()> {
2728
.init();
2829

2930
let config = KernelConfig::default();
31+
32+
// Acquire PID lockfile — prevents multiple daemons and detects stale pipes.
33+
let lockfile = config.state_dir.join("daemon.pid");
34+
check_and_acquire_lock(&lockfile)?;
35+
3036
let kernel = Arc::new(Kernel::bootstrap(&config).await?);
3137
info!(state_dir = %config.state_dir.display(), "kernel bootstrapped");
3238

@@ -45,6 +51,7 @@ async fn main() -> Result<()> {
4551
});
4652

4753
let endpoint = config.ipc_endpoint.clone();
54+
info!(endpoint = %endpoint, "IPC endpoint resolved");
4855
let kernel_for_server = Arc::clone(&kernel);
4956
let mut server =
5057
tokio::spawn(async move { ipc::run_ipc_server(kernel_for_server, &endpoint).await });
@@ -81,6 +88,79 @@ async fn main() -> Result<()> {
8188
}
8289
}
8390

91+
// Clean up lockfile on graceful shutdown.
92+
let _ = std::fs::remove_file(&lockfile);
8493
drop(kernel);
8594
Ok(())
8695
}
96+
97+
/// Check for existing daemon via PID lockfile. If a stale lock exists (process
98+
/// dead), prompt the user to confirm cleanup.
99+
fn check_and_acquire_lock(lockfile: &std::path::Path) -> Result<()> {
100+
if lockfile.exists() {
101+
let content = std::fs::read_to_string(lockfile).unwrap_or_default();
102+
let old_pid: u32 = content.trim().parse().unwrap_or(0);
103+
104+
if old_pid > 0 && is_process_alive(old_pid) {
105+
eprintln!("Another punkgo-kerneld is running (PID {old_pid}).");
106+
eprintln!();
107+
eprint!("Kill it and continue? [y/N] ");
108+
let mut input = String::new();
109+
std::io::stdin().read_line(&mut input)?;
110+
if input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes") {
111+
kill_process(old_pid);
112+
std::thread::sleep(std::time::Duration::from_secs(2));
113+
} else {
114+
eprintln!("Aborted.");
115+
std::process::exit(1);
116+
}
117+
} else {
118+
// Stale lockfile — old daemon crashed. Clean up silently.
119+
info!(old_pid, "removing stale lockfile");
120+
}
121+
}
122+
123+
// Write our PID.
124+
std::fs::write(lockfile, std::process::id().to_string())?;
125+
Ok(())
126+
}
127+
128+
fn is_process_alive(pid: u32) -> bool {
129+
#[cfg(windows)]
130+
{
131+
let output = std::process::Command::new("tasklist")
132+
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
133+
.output();
134+
match output {
135+
Ok(out) => {
136+
let text = String::from_utf8_lossy(&out.stdout);
137+
// CSV format: "process.exe","PID","...". Check for exact PID field.
138+
text.contains(&format!("\"{pid}\""))
139+
}
140+
Err(_) => false,
141+
}
142+
}
143+
#[cfg(not(windows))]
144+
{
145+
std::process::Command::new("kill")
146+
.args(["-0", &pid.to_string()])
147+
.status()
148+
.map(|s| s.success())
149+
.unwrap_or(false)
150+
}
151+
}
152+
153+
fn kill_process(pid: u32) {
154+
#[cfg(windows)]
155+
{
156+
let _ = std::process::Command::new("taskkill")
157+
.args(["/F", "/PID", &pid.to_string()])
158+
.output();
159+
}
160+
#[cfg(not(windows))]
161+
{
162+
let _ = std::process::Command::new("kill")
163+
.args(["-9", &pid.to_string()])
164+
.output();
165+
}
166+
}

crates/punkgo-kernel/src/runtime/energy_producer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ impl EnergyProducer {
8585

8686
/// Execute a single production tick. Public for testing.
8787
pub async fn produce_tick(&self, energy_per_tick: i64) -> Result<TickResult, String> {
88-
// 1. Query all active actors with energy_share > 0
88+
// 1. Query all active agents with energy_share > 0 (humans excluded)
8989
let actors = self
9090
.actor_store
9191
.list_active_with_shares()

crates/punkgo-kernel/src/runtime/kernel.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,31 @@ impl Default for KernelConfig {
3535
let state_dir = std::env::var("PUNKGO_STATE_DIR")
3636
.map(PathBuf::from)
3737
.unwrap_or_else(|_| default_state_dir());
38+
let ipc_endpoint = std::env::var("PUNKGO_IPC_ENDPOINT")
39+
.unwrap_or_else(|_| default_ipc_endpoint());
3840
Self {
3941
state_dir,
40-
ipc_endpoint: "punkgo-kernel".to_string(),
42+
ipc_endpoint,
4143
}
4244
}
4345
}
4446

4547
/// Default state directory: ~/.punkgo/state
4648
///
4749
/// Falls back to `./state` if home directory cannot be determined.
50+
/// Default IPC endpoint.
51+
///
52+
/// On Windows, uses a file-path named pipe (`\\.\pipe\punkgo-kernel`) because
53+
/// the `GenericNamespaced` namespace can hit "Access Denied" depending on
54+
/// Windows security policy. On Unix, uses a simple namespace name.
55+
fn default_ipc_endpoint() -> String {
56+
if cfg!(windows) {
57+
r"\\.\pipe\punkgo-kernel".to_string()
58+
} else {
59+
"punkgo-kernel".to_string()
60+
}
61+
}
62+
4863
fn default_state_dir() -> PathBuf {
4964
if let Some(home) = home_dir() {
5065
return home.join(".punkgo").join("state");
@@ -641,6 +656,27 @@ impl Kernel {
641656
}
642657
}
643658
}
659+
punkgo_core::actor::LifecycleOp::UpdateEnergyShare { energy_share } => {
660+
match lifecycle::execute_update_energy_share(
661+
&self.actor_store,
662+
&pool,
663+
target_actor_id,
664+
*energy_share,
665+
)
666+
.await
667+
{
668+
Ok(()) => {
669+
info!(
670+
target = %target_actor_id,
671+
energy_share,
672+
"lifecycle: energy_share updated"
673+
);
674+
}
675+
Err(err) => {
676+
warn!(error = %err, "lifecycle: update_energy_share failed (event already committed)");
677+
}
678+
}
679+
}
644680
}
645681
}
646682

crates/punkgo-kernel/src/runtime/lifecycle.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ pub fn parse_lifecycle_op(
3838
"freeze" => LifecycleOp::Freeze { reason },
3939
"unfreeze" => LifecycleOp::Unfreeze,
4040
"terminate" => LifecycleOp::Terminate { reason },
41+
"update_energy_share" => {
42+
let energy_share = payload.get("energy_share")?.as_f64()?;
43+
LifecycleOp::UpdateEnergyShare { energy_share }
44+
}
4145
_ => return None,
4246
};
4347

@@ -51,9 +55,8 @@ pub fn parse_lifecycle_op(
5155
///
5256
/// | Initiator | Target | Allowed |
5357
/// |-----------|------------|---------|
54-
/// | human | own agent | yes |
58+
/// | human | own agent | yes (lineage check) |
5559
/// | agent | any | no |
56-
/// | root | any agent | yes |
5760
pub async fn validate_lifecycle_authorization(
5861
initiator: &ActorRecord,
5962
target: &ActorRecord,
@@ -66,11 +69,6 @@ pub async fn validate_lifecycle_authorization(
6669
));
6770
}
6871

69-
// Root can do anything
70-
if initiator.actor_id == "root" {
71-
return Ok(());
72-
}
73-
7472
// PIP-001 §5: Agents cannot manage other agents.
7573
if initiator.actor_type == ActorType::Agent {
7674
return Err(KernelError::PolicyViolation(format!(
@@ -144,6 +142,21 @@ pub async fn execute_unfreeze(
144142
Ok(())
145143
}
146144

145+
/// Execute an update_energy_share operation: change the target's energy_share.
146+
pub async fn execute_update_energy_share(
147+
actor_store: &ActorStore,
148+
pool: &sqlx::SqlitePool,
149+
target_id: &str,
150+
energy_share: f64,
151+
) -> KernelResult<()> {
152+
let mut tx = pool.begin().await?;
153+
actor_store
154+
.update_energy_share_in_tx(&mut tx, target_id, energy_share)
155+
.await?;
156+
tx.commit().await?;
157+
Ok(())
158+
}
159+
147160
/// Check existence conditions for an actor (PIP-001 §7).
148161
///
149162
/// Four conditions that must hold for an actor to exist:
@@ -176,6 +189,29 @@ mod tests {
176189
use super::*;
177190
use serde_json::json;
178191

192+
#[test]
193+
fn parse_update_energy_share_op() {
194+
let (id, op) = parse_lifecycle_op(
195+
"actor/agent-1",
196+
&json!({"op": "update_energy_share", "energy_share": 42.5}),
197+
)
198+
.expect("should parse");
199+
assert_eq!(id, "agent-1");
200+
assert!(
201+
matches!(op, LifecycleOp::UpdateEnergyShare { energy_share } if (energy_share - 42.5).abs() < f64::EPSILON)
202+
);
203+
}
204+
205+
#[test]
206+
fn parse_update_energy_share_missing_value() {
207+
let result =
208+
parse_lifecycle_op("actor/agent-1", &json!({"op": "update_energy_share"}));
209+
assert!(
210+
result.is_none(),
211+
"missing energy_share value should return None"
212+
);
213+
}
214+
179215
#[test]
180216
fn parse_freeze_op() {
181217
let (id, op) =

0 commit comments

Comments
 (0)