From 194ef30c75f3b9fbb2b0ebdebc447af3b276d32e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:30:04 +0700 Subject: [PATCH] tri loop plan: the overnight tick's anomaly, idempotence and requests-out decisions as .t27 (Closes #11059) A tick of the overnight loop was prose in a skill, so a new cron cycle could repeat or undo the last one. specs/tri/loop/tick.t27 now holds those rules as pure functions over a line-oriented snapshot (now, last, row, want, pr, merged, agent, cron, req), with 25 tests: - a lane the ledger lists (any state) is never started again; a `want` with no row is START, once; - a row that says running for 45 whole minutes or more is RECHECK (the number is lanes.t27's STALE_MINUTES, imported; a test holds the two equal); - a red pull request is FIX, a conflicting (DIRTY) or behind one is UPDATE_BRANCH; no action is a force-push; - a subagent done with no pr or merged record for its branch is FIND_PR; - a later cron with the same prompt is DELETE_DUPLICATE; - a request is NONE before due, CHASE once when open and due, TAKE_DEFAULT when chased and due, never CHASE when chased; - an unreadable record, a time that does not parse, a second now, last or request id is UNREADABLE, never silence; - the tick ends with HASH n over the records and their plan (not now, not last); a snapshot whose `last` equals it is NOOP, and a lane crossing 45 minutes changes the plan, so time passing is a change. `tri loop plan [FILE]` reads the snapshot from FILE or stdin and prints one `ACTION key reason` line per action. It runs no command and contacts no network or machine. The glue sits in cli/tri/src/loopclaim.rs (23 added hand lines, through t27c gen-rust in gen/rust/tri/loop/tick.rs); the tool card of `tri loop` lists the new action. The 23 lines are paid for by deleting 40: the unused `delete_railway_service` in bootstrap/src/railway.rs (git grep -w finds no other mention). Searched first: tri night, tri lanes (its time readers and STALE_MINUTES are reused), tri worktrees --reap-plan, specs/claw/refresh.t27, specs/queen/review_valve.t27, specs/queen/waits.t27, specs/tri/reseal; none decides a tick's actions from a ledger. Closes #11059 Co-Authored-By: Claude Sonnet 5.5 --- .trinity/seals/loop_TriLoopTick.json | 37 + .trinity/seals/tri_tool_tri_loop.json | 19 +- bootstrap/src/railway.rs | 40 -- cli/tri/src/loopclaim.rs | 23 + gen/rust/tri/loop/tick.rs | 933 ++++++++++++++++++++++++++ specs/tools/tri/loop.t27 | 6 +- specs/tri/loop/tick.t27 | 930 +++++++++++++++++++++++++ 7 files changed, 1936 insertions(+), 52 deletions(-) create mode 100644 .trinity/seals/loop_TriLoopTick.json create mode 100644 gen/rust/tri/loop/tick.rs create mode 100644 specs/tri/loop/tick.t27 diff --git a/.trinity/seals/loop_TriLoopTick.json b/.trinity/seals/loop_TriLoopTick.json new file mode 100644 index 0000000000..eb81b8d9ac --- /dev/null +++ b/.trinity/seals/loop_TriLoopTick.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+c948f4df7", + "closure": [ + { + "spec": "specs/tri/lab/receipt.t27", + "spec_hash": "sha256:00d9eec8d7be84dde596c9990b3a5574de24aff9f7a94579fc620eb585712441" + }, + { + "spec": "specs/tri/lanes/lanes.t27", + "spec_hash": "sha256:aac192b3e3250196262a9b8467d4a9cef8147438c11c0744c66c3f6b175c9342" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:181c8af083e0e1b9f0f0d4888449725e38a2733c7865f967bd2f3d474bd26dfe", + "gen_hash_rust": "sha256:5fe0e37e1c4abb409f23e139450588690da57363e0e6cb3738110316105ef117", + "gen_hash_verilog": "sha256:731be2d9897541017a7d89f020ad6193a3948cd48b538754c8773eebcc1dfb69", + "gen_hash_zig": "sha256:ea606271e8b65372944ffc63e5f7ae0a5d217ef10a899c551400f311f60621f8", + "module": "TriLoopTick", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T14:13:27Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:5be75aa6ae782c007897b41a42f3ab8dc04b11f68d592c64d61dc7830b7d6ca3", + "spec_path": "specs/tri/loop/tick.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 25, + "total": 25, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/tri_tool_tri_loop.json b/.trinity/seals/tri_tool_tri_loop.json index 6f3754aed4..0aa0e61637 100644 --- a/.trinity/seals/tri_tool_tri_loop.json +++ b/.trinity/seals/tri_tool_tri_loop.json @@ -1,27 +1,28 @@ { - "built_by": "t27c-bootstrap@0.5.2+5d5d2cdef", + "built_by": "t27c-bootstrap@0.5.2+c948f4df7", "closure": [], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:18a368682fe796af4fdeb5c2ca09dbc0eee32ea2bf84fbb7c95a64b8be6b9934", - "gen_hash_rust": "sha256:2f97f928872e00c7ebc08277c6a41aa0851808f092f4db5db4b0552f21c795c8", - "gen_hash_verilog": "sha256:1441f44284c6bb7d214d6e22ac66fb526510da21ad8c3ce78ce50038ed328d05", - "gen_hash_zig": "sha256:802b3ab0893119b2c26b2f26dede4dc1d857522d60fa3b085fae3bd4c5d0025e", + "gen_hash_c": "sha256:487811e236e84acbdb37858860face73b03506cf3b2198b4386f45a16570c53a", + "gen_hash_rust": "sha256:39030192cba3ee8241459eb72ab41553c64fa92bb9f65811b1ef93218c8fb2f6", + "gen_hash_verilog": "sha256:91d63ebbe5d258f392043934e273d0c429ca21e2866c658eeb2f085584b8b6a5", + "gen_hash_zig": "sha256:1b51125055a4193828037fd08ba0f3b8074ab5f2e08608793966f601bec8e91f", "module": "tool_tri_loop", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-09T14:55:23Z", + "sealed_at": "2026-10-19T14:13:51Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:bc923ea3abad6f05f07a5ab87610391ef027b1d58dfc74bfbce8d4675f287267", + "spec_hash": "sha256:76e4e4caae4f9c9c0777aa8031bb8c250e510a53b5ed2670eea8657f67fb6857", "spec_path": "specs/tools/tri/loop.t27", "tests": { "failed": 0, "failing": [], "forced": false, "passed": 4, - "total": 4 + "total": 4, + "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/bootstrap/src/railway.rs b/bootstrap/src/railway.rs index 96fa2fe163..f1d846e8d6 100644 --- a/bootstrap/src/railway.rs +++ b/bootstrap/src/railway.rs @@ -165,46 +165,6 @@ pub async fn set_service_variables( Ok(()) } -/// Delete a Railway service -/// -/// # Arguments -/// * `service_id` - ID of the Railway service to delete -/// * `railway_token` - Railway API token for authentication -pub async fn delete_railway_service( - service_id: &str, - railway_token: &str, -) -> Result<()> { - let client = Client::new(); - - let mutation = format!( - r#"mutation {{ - serviceDelete(input: {{ id: "{}" }}) {{ - id - }} - }}"#, - service_id - ); - - let resp = client.post(RAILWAY_GRAPHQL) - .header("Authorization", format!("Bearer {}", railway_token)) - .header("Content-Type", "application/json") - .json(&serde_json::json!({"query": mutation})) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_else(|_| "Unable to read body".to_string()); - return Err(anyhow::anyhow!( - "Failed to delete Railway service: {} - {}", - status, - body - )); - } - - Ok(()) -} - /// Get the health status of a Railway service /// /// # Arguments diff --git a/cli/tri/src/loopclaim.rs b/cli/tri/src/loopclaim.rs index 34d98634de..5756648511 100644 --- a/cli/tri/src/loopclaim.rs +++ b/cli/tri/src/loopclaim.rs @@ -19,6 +19,8 @@ use anyhow::{Context, Result}; use clap::Subcommand; use std::process::Command; +#[path = "../../../gen/rust/tri/loop/tick.rs"] #[allow(dead_code, unused_parens)] +mod tick; // t27c gen-rust of specs/tri/loop/tick.t27: every rule of `tri loop plan` #[derive(Subcommand)] pub enum LoopCmd { @@ -37,6 +39,11 @@ pub enum LoopCmd { #[arg(long)] who: bool, }, + /// The next actions of the overnight loop, one `ACTION key reason` line each, from a snapshot of its state (ledger rows, wanted lanes, pull requests, agents, crons, requests out) read from FILE or stdin. Rules: specs/tri/loop/tick.t27. Runs no command, contacts no network or machine. The same snapshot prints the same lines; the last line, `HASH n`, goes into the next snapshot as `last n`, and a tick whose state and plan it matches prints NOOP. + Plan { + /// The snapshot file; stdin when absent or `-`. + file: Option, + }, } fn git(args: &[&str]) -> Result<(i32, String)> { @@ -83,7 +90,23 @@ fn holder(name: &str) -> Option { pub fn run(cmd: &LoopCmd) -> Result<()> { match cmd { LoopCmd::Claim { name, release, who } => claim(name, *release, *who), + LoopCmd::Plan { file } => plan(file.as_deref()), + } +} + +/// `tri loop plan`: the generated rules judge the snapshot, this prints what they return. +fn plan(file: Option<&std::path::Path>) -> Result<()> { + let text = match file.filter(|f| f.to_str() != Some("-")) { Some(f) => std::fs::read_to_string(f).with_context(|| f.display().to_string())?, None => std::io::read_to_string(std::io::stdin())? }; + let s: &'static str = text.leak(); + let (mut at, noop) = (tick::next_record(s, 0), tick::is_noop(s)); + if noop { println!("{} {}", tick::action_name(tick::A_NOOP), tick::why(tick::A_NOOP)); } + while !noop && at < s.len() { + let a = tick::action(s, at); + if a != tick::A_NONE { println!("{} {} {}", tick::action_name(a), &s[tick::key_from(s, at)..tick::key_to(s, at)], tick::why(a)); } + at = tick::next_record(s, tick::line_end(s, at) + 1); } + println!("HASH {}", tick::plan_hash(s)); + Ok(()) } fn claim(name: &str, release: bool, who: bool) -> Result<()> { diff --git a/gen/rust/tri/loop/tick.rs b/gen/rust/tri/loop/tick.rs new file mode 100644 index 0000000000..10b7c708ce --- /dev/null +++ b/gen/rust/tri/loop/tick.rs @@ -0,0 +1,933 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c +// NOT LOWERED BY THIS BACKEND: 25 test(s), 1 invariant(s), 0 function(s) with no body. +// This backend emits declarations only. The spec's checks live in +// the Zig and Verilog outputs; do not read this file as verified. + +pub const RUNNING_MINUTES: u64 = 45; + +pub const HASH_P1: u64 = 999999937; + +pub const HASH_P2: u64 = 999999929; + +pub const K_BAD: u8 = 0; + +pub const K_NOW: u8 = 1; + +pub const K_LAST: u8 = 2; + +pub const K_ROW: u8 = 3; + +pub const K_WANT: u8 = 4; + +pub const K_PR: u8 = 5; + +pub const K_MERGED: u8 = 6; + +pub const K_AGENT: u8 = 7; + +pub const K_CRON: u8 = 8; + +pub const K_REQ: u8 = 9; + +pub const A_NONE: u8 = 0; + +pub const A_START: u8 = 1; + +pub const A_RECHECK: u8 = 2; + +pub const A_UPDATE_BRANCH: u8 = 3; + +pub const A_FIX: u8 = 4; + +pub const A_FIND_PR: u8 = 5; + +pub const A_DELETE_DUPLICATE: u8 = 6; + +pub const A_CHASE: u8 = 7; + +pub const A_TAKE_DEFAULT: u8 = 8; + +pub const A_BAD: u8 = 9; + +pub const A_NOOP: u8 = 10; + +pub const ACTIONS: u8 = 11; + +pub const C_GREEN: u8 = 0; + +pub const C_PENDING: u8 = 1; + +pub const C_RED: u8 = 2; + +pub const C_BAD: u8 = 3; + +pub const M_CLEAN: u8 = 0; + +pub const M_BEHIND: u8 = 1; + +pub const M_BLOCKED: u8 = 2; + +pub const M_DIRTY: u8 = 3; + +pub const M_DRAFT: u8 = 4; + +pub const M_HAS_HOOKS: u8 = 5; + +pub const M_UNKNOWN: u8 = 6; + +pub const M_UNSTABLE: u8 = 7; + +pub const M_BAD: u8 = 8; + +pub const R_OPEN: u8 = 0; + +pub const R_CHASED: u8 = 1; + +pub const R_ANSWERED: u8 = 2; + +pub const R_DEFAULTED: u8 = 3; + +pub const R_BAD: u8 = 4; + +pub fn tok_from(s: &'static str, at: usize, stop: usize, n: usize) -> usize { + let mut k: usize = at; + let mut i: usize = 0; + while (i < n) { + while ((k < stop) && (s.as_bytes()[(k) as usize] != 32)) { + k = (k + 1); + } + if (k >= stop) { + return stop; + } + k = (k + 1); + i = (i + 1); + } + return k; +} + +pub fn tok_to(s: &'static str, at: usize, stop: usize, n: usize) -> usize { + let mut k: usize = tok_from(s, at, stop, n); + while ((k < stop) && (s.as_bytes()[(k) as usize] != 32)) { + k = (k + 1); + } + return k; +} + +pub fn ntok(s: &'static str, at: usize, stop: usize) -> usize { + let mut n: usize = 1; + let mut k: usize = at; + while (k < stop) { + if (s.as_bytes()[(k) as usize] == 32) { + n = (n + 1); + } + k = (k + 1); + } + return n; +} + +pub fn head_ok(s: &'static str, at: usize, stop: usize, n: usize) -> bool { + if (ntok(s, at, stop) < n) { + return false; + } + let mut i: usize = 0; + while (i < n) { + if (tok_to(s, at, stop, i) == tok_from(s, at, stop, i)) { + return false; + } + i = (i + 1); + } + return true; +} + +pub fn fields_ok(s: &'static str, at: usize, stop: usize, n: usize) -> bool { + return ((ntok(s, at, stop) == n) && head_ok(s, at, stop, n)); +} + +pub fn tok_is(s: &'static str, at: usize, stop: usize, n: usize, lit: &'static str) -> bool { + return span_is(s, tok_from(s, at, stop, n), tok_to(s, at, stop, n), lit); +} + +pub fn same_span(s: &'static str, a1: usize, a2: usize, b1: usize, b2: usize) -> bool { + if (((a2 < a1) || (b2 < b1)) || ((a2 - a1) != (b2 - b1))) { + return false; + } + let mut i: usize = 0; + while (i < (a2 - a1)) { + if (s.as_bytes()[((a1 + i)) as usize] != s.as_bytes()[((b1 + i)) as usize]) { + return false; + } + i = (i + 1); + } + return true; +} + +pub fn next_record(s: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < s.len()) { + if ((s.as_bytes()[(k) as usize] != 10) && (s.as_bytes()[(k) as usize] != 59)) { + return k; + } + k = (line_end(s, k) + 1); + } + return s.len(); +} + +pub fn kind_of(s: &'static str, at: usize, stop: usize) -> u8 { + if tok_is(s, at, stop, 0, "now") { + return K_NOW; + } + if tok_is(s, at, stop, 0, "last") { + return K_LAST; + } + if tok_is(s, at, stop, 0, "row") { + return K_ROW; + } + if tok_is(s, at, stop, 0, "want") { + return K_WANT; + } + if tok_is(s, at, stop, 0, "pr") { + return K_PR; + } + if tok_is(s, at, stop, 0, "merged") { + return K_MERGED; + } + if tok_is(s, at, stop, 0, "agent") { + return K_AGENT; + } + if tok_is(s, at, stop, 0, "cron") { + return K_CRON; + } + if tok_is(s, at, stop, 0, "req") { + return K_REQ; + } + return K_BAD; +} + +pub fn listed(s: &'static str, k: u8, n: usize, a: usize, b: usize, before: usize) -> bool { + let mut at: usize = next_record(s, 0); + while (at < before) { + let stop: usize = line_end(s, at); + if ((kind_of(s, at, stop) == k) && same_span(s, a, b, tok_from(s, at, stop, n), tok_to(s, at, stop, n))) { + return true; + } + at = next_record(s, (stop + 1)); + } + return false; +} + +pub fn time_of(s: &'static str, at: usize, stop: usize, n: usize) -> u64 { + return iso_seconds(s, tok_from(s, at, stop, n), tok_to(s, at, stop, n)); +} + +pub fn now_seconds(s: &'static str) -> u64 { + let mut at: usize = next_record(s, 0); + while (at < s.len()) { + let stop: usize = line_end(s, at); + if (kind_of(s, at, stop) == K_NOW) { + if fields_ok(s, at, stop, 2) { + return time_of(s, at, stop, 1); + } + return 0; + } + at = next_record(s, (stop + 1)); + } + return 0; +} + +pub fn last_hash(s: &'static str) -> u64 { + let mut at: usize = next_record(s, 0); + while (at < s.len()) { + let stop: usize = line_end(s, at); + if (kind_of(s, at, stop) == K_LAST) { + if fields_ok(s, at, stop, 2) { + return digits(s, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1)); + } + return 0; + } + at = next_record(s, (stop + 1)); + } + return 0; +} + +pub fn ci_code(s: &'static str, from: usize, to: usize) -> u8 { + if span_is(s, from, to, "green") { + return C_GREEN; + } + if span_is(s, from, to, "pending") { + return C_PENDING; + } + if span_is(s, from, to, "red") { + return C_RED; + } + return C_BAD; +} + +pub fn merge_code(s: &'static str, from: usize, to: usize) -> u8 { + if span_is(s, from, to, "CLEAN") { + return M_CLEAN; + } + if span_is(s, from, to, "BEHIND") { + return M_BEHIND; + } + if span_is(s, from, to, "BLOCKED") { + return M_BLOCKED; + } + if span_is(s, from, to, "DIRTY") { + return M_DIRTY; + } + if span_is(s, from, to, "DRAFT") { + return M_DRAFT; + } + if span_is(s, from, to, "HAS_HOOKS") { + return M_HAS_HOOKS; + } + if span_is(s, from, to, "UNKNOWN") { + return M_UNKNOWN; + } + if span_is(s, from, to, "UNSTABLE") { + return M_UNSTABLE; + } + return M_BAD; +} + +pub fn req_code(s: &'static str, from: usize, to: usize) -> u8 { + if span_is(s, from, to, "open") { + return R_OPEN; + } + if span_is(s, from, to, "chased") { + return R_CHASED; + } + if span_is(s, from, to, "answered") { + return R_ANSWERED; + } + if span_is(s, from, to, "defaulted") { + return R_DEFAULTED; + } + return R_BAD; +} + +pub fn row_decision(running: bool, now: u64, since: u64) -> u8 { + if (running == false) { + return A_NONE; + } + let age: u64 = age_minutes(now, since); + if (age_known(age) && (age >= RUNNING_MINUTES)) { + return A_RECHECK; + } + return A_NONE; +} + +pub fn pr_decision(ci: u8, merge: u8) -> u8 { + if (ci == C_RED) { + return A_FIX; + } + if ((merge == M_DIRTY) || (merge == M_BEHIND)) { + return A_UPDATE_BRANCH; + } + return A_NONE; +} + +pub fn req_decision(state: u8, now: u64, due: u64) -> u8 { + if ((state != R_OPEN) && (state != R_CHASED)) { + return A_NONE; + } + if (now < due) { + return A_NONE; + } + if (state == R_OPEN) { + return A_CHASE; + } + return A_TAKE_DEFAULT; +} + +pub fn bar_at(s: &'static str, from: usize, stop: usize) -> usize { + let mut k: usize = from; + while ((k + 3) <= stop) { + if span_is(s, k, (k + 3), " | ") { + return k; + } + k = (k + 1); + } + return stop; +} + +pub fn now_action(s: &'static str, at: usize, stop: usize) -> u8 { + if ((fields_ok(s, at, stop, 2) == false) || (time_of(s, at, stop, 1) == 0)) { + return A_BAD; + } + if listed(s, K_NOW, 0, tok_from(s, at, stop, 0), tok_to(s, at, stop, 0), at) { + return A_BAD; + } + return A_NONE; +} + +pub fn last_action(s: &'static str, at: usize, stop: usize) -> u8 { + if ((fields_ok(s, at, stop, 2) == false) || (digits(s, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1)) == 0)) { + return A_BAD; + } + if listed(s, K_LAST, 0, tok_from(s, at, stop, 0), tok_to(s, at, stop, 0), at) { + return A_BAD; + } + return A_NONE; +} + +pub fn row_action(s: &'static str, at: usize, stop: usize) -> u8 { + if ((fields_ok(s, at, stop, 4) == false) || (time_of(s, at, stop, 3) == 0)) { + return A_BAD; + } + let running: bool = tok_is(s, at, stop, 2, "running"); + if ((running == false) && (tok_is(s, at, stop, 2, "done") == false)) { + return A_BAD; + } + if (running && (now_seconds(s) == 0)) { + return A_BAD; + } + return row_decision(running, now_seconds(s), time_of(s, at, stop, 3)); +} + +pub fn want_action(s: &'static str, at: usize, stop: usize) -> u8 { + if (fields_ok(s, at, stop, 2) == false) { + return A_BAD; + } + if listed(s, K_ROW, 1, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1), s.len()) { + return A_NONE; + } + if listed(s, K_WANT, 1, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1), at) { + return A_NONE; + } + return A_START; +} + +pub fn pr_action(s: &'static str, at: usize, stop: usize) -> u8 { + if ((fields_ok(s, at, stop, 5) == false) || (digits(s, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1)) == 0)) { + return A_BAD; + } + let ci: u8 = ci_code(s, tok_from(s, at, stop, 3), tok_to(s, at, stop, 3)); + let merge: u8 = merge_code(s, tok_from(s, at, stop, 4), tok_to(s, at, stop, 4)); + if ((ci == C_BAD) || (merge == M_BAD)) { + return A_BAD; + } + return pr_decision(ci, merge); +} + +pub fn agent_action(s: &'static str, at: usize, stop: usize) -> u8 { + if (fields_ok(s, at, stop, 4) == false) { + return A_BAD; + } + let done: bool = tok_is(s, at, stop, 2, "done"); + if ((done == false) && (tok_is(s, at, stop, 2, "running") == false)) { + return A_BAD; + } + if (done == false) { + return A_NONE; + } + let a: usize = tok_from(s, at, stop, 3); + let b: usize = tok_to(s, at, stop, 3); + if (listed(s, K_PR, 2, a, b, s.len()) || listed(s, K_MERGED, 1, a, b, s.len())) { + return A_NONE; + } + return A_FIND_PR; +} + +pub fn cron_action(s: &'static str, at: usize, stop: usize) -> u8 { + if (head_ok(s, at, stop, 3) == false) { + return A_BAD; + } + let mut o: usize = next_record(s, 0); + while (o < at) { + let oe: usize = line_end(s, o); + if (((kind_of(s, o, oe) == K_CRON) && head_ok(s, o, oe, 3)) && same_span(s, tok_from(s, o, oe, 2), oe, tok_from(s, at, stop, 2), stop)) { + return A_DELETE_DUPLICATE; + } + o = next_record(s, (oe + 1)); + } + return A_NONE; +} + +pub fn req_action(s: &'static str, at: usize, stop: usize) -> u8 { + if (head_ok(s, at, stop, 7) == false) { + return A_BAD; + } + if listed(s, K_REQ, 1, tok_from(s, at, stop, 1), tok_to(s, at, stop, 1), at) { + return A_BAD; + } + let bar: usize = bar_at(s, tok_from(s, at, stop, 6), stop); + if ((bar + 3) >= stop) { + return A_BAD; + } + let due: u64 = time_of(s, at, stop, 4); + let state: u8 = req_code(s, tok_from(s, at, stop, 5), tok_to(s, at, stop, 5)); + if (((time_of(s, at, stop, 3) == 0) || (due == 0)) || (state == R_BAD)) { + return A_BAD; + } + if (((state == R_OPEN) || (state == R_CHASED)) && (now_seconds(s) == 0)) { + return A_BAD; + } + return req_decision(state, now_seconds(s), due); +} + +pub fn action(s: &'static str, at: usize) -> u8 { + if (at >= s.len()) { + return A_NONE; + } + let stop: usize = line_end(s, at); + let k: u8 = kind_of(s, at, stop); + if (k == K_NOW) { + return now_action(s, at, stop); + } + if (k == K_LAST) { + return last_action(s, at, stop); + } + if (k == K_ROW) { + return row_action(s, at, stop); + } + if (k == K_WANT) { + return want_action(s, at, stop); + } + if (k == K_PR) { + return pr_action(s, at, stop); + } + if (k == K_MERGED) { + if fields_ok(s, at, stop, 2) { + return A_NONE; + } + return A_BAD; + } + if (k == K_AGENT) { + return agent_action(s, at, stop); + } + if (k == K_CRON) { + return cron_action(s, at, stop); + } + if (k == K_REQ) { + return req_action(s, at, stop); + } + return A_BAD; +} + +pub fn count_action(s: &'static str, a: u8) -> u64 { + let mut n: u64 = 0; + let mut at: usize = next_record(s, 0); + while (at < s.len()) { + if (action(s, at) == a) { + n = (n + 1); + } + at = next_record(s, (line_end(s, at) + 1)); + } + return n; +} + +pub fn record_at(s: &'static str, k: usize) -> usize { + let mut at: usize = next_record(s, 0); + let mut i: usize = 0; + while ((i < k) && (at < s.len())) { + at = next_record(s, (line_end(s, at) + 1)); + i = (i + 1); + } + return at; +} + +pub fn action_of(s: &'static str, k: usize) -> u8 { + return action(s, record_at(s, k)); +} + +pub fn key_from(s: &'static str, at: usize) -> usize { + if (action(s, at) == A_BAD) { + return at; + } + return tok_from(s, at, line_end(s, at), 1); +} + +pub fn key_to(s: &'static str, at: usize) -> usize { + if (action(s, at) == A_BAD) { + return line_end(s, at); + } + return tok_to(s, at, line_end(s, at), 1); +} + +pub fn plan_hash(s: &'static str) -> u64 { + let mut h1: u64 = 7; + let mut h2: u64 = 11; + let mut at: usize = next_record(s, 0); + while (at < s.len()) { + let stop: usize = line_end(s, at); + let k: u8 = kind_of(s, at, stop); + if ((k != K_NOW) && (k != K_LAST)) { + let mut i: usize = at; + while (i < stop) { + h1 = (((h1 * 131) + (s.as_bytes()[(i) as usize] as u64)) % HASH_P1); + h2 = ((((h2 * 257) + (s.as_bytes()[(i) as usize] as u64)) + 1) % HASH_P2); + i = (i + 1); + } + h1 = ((((h1 * 131) + 10) + (action(s, at) as u64)) % HASH_P1); + h2 = ((((h2 * 257) + 11) + (action(s, at) as u64)) % HASH_P2); + } + at = next_record(s, (stop + 1)); + } + return ((h1 * HASH_P2) + h2); +} + +pub fn is_noop(s: &'static str) -> bool { + return (((now_seconds(s) != 0) && (last_hash(s) != 0)) && (last_hash(s) == plan_hash(s))); +} + +pub fn action_name(a: u8) -> &'static str { + if (a == A_START) { + return "START"; + } + if (a == A_RECHECK) { + return "RECHECK"; + } + if (a == A_UPDATE_BRANCH) { + return "UPDATE_BRANCH"; + } + if (a == A_FIX) { + return "FIX"; + } + if (a == A_FIND_PR) { + return "FIND_PR"; + } + if (a == A_DELETE_DUPLICATE) { + return "DELETE_DUPLICATE"; + } + if (a == A_CHASE) { + return "CHASE"; + } + if (a == A_TAKE_DEFAULT) { + return "TAKE_DEFAULT"; + } + if (a == A_BAD) { + return "UNREADABLE"; + } + if (a == A_NOOP) { + return "NOOP"; + } + return "NONE"; +} + +pub fn why(a: u8) -> &'static str { + if (a == A_START) { + return "wanted, and the ledger has no row for the lane: start it"; + } + if (a == A_RECHECK) { + return "the ledger row has said running for the stale limit or longer: look at the lane, then update its row"; + } + if (a == A_UPDATE_BRANCH) { + return "the pull request conflicts with its base or is behind it: merge the base into the branch, history is not rewritten"; + } + if (a == A_FIX) { + return "a check of the pull request failed: read the failure and fix it on its branch"; + } + if (a == A_FIND_PR) { + return "a subagent reported done and no pull request or merge names its branch: look for the branch on the remote, open its pull request or run the lane again"; + } + if (a == A_DELETE_DUPLICATE) { + return "an earlier cron has the same prompt: delete this one"; + } + if (a == A_CHASE) { + return "the request is past due and was never chased: chase it once and mark it chased"; + } + if (a == A_TAKE_DEFAULT) { + return "the request is past due and was chased: take the default and write it in the ledger"; + } + if (a == A_BAD) { + return "the record cannot be read: an unread fact is reported, never skipped"; + } + if (a == A_NOOP) { + return "nothing changed since the last tick: the same state and the same plan were handed out"; + } + return "nothing to do"; +} + +pub const T_NOW: &'static str = "now 2026-10-19T22:30:00Z\n"; + +pub const S_RUNNING: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 running 2026-10-19T22:20:00Z\nwant L1\n"; + +pub const S_FRESH: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 done 2026-10-19T21:00:00Z\nrow L2 running 2026-10-19T22:20:00Z\nwant L1\nwant L2\nwant L3\nwant L3\nwant L4\n"; + +pub const S_OLD_44: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 running 2026-10-19T21:46:00Z\n"; + +pub const S_OLD_45: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 running 2026-10-19T21:45:00Z\n"; + +pub const S_OLD_46: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 running 2026-10-19T21:44:00Z\n"; + +pub const S_OLD_DONE: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 done 2026-10-19T09:00:00Z\n"; + +pub const S_FUTURE: &'static str = "now 2026-10-19T22:30:00Z\nrow L1 running 2026-10-19T22:45:00Z\n"; + +pub const S_PRS: &'static str = "now 2026-10-19T22:30:00Z\npr 11050 lane/a green CLEAN\npr 11051 lane/b red CLEAN\npr 11052 lane/c green DIRTY\npr 11053 lane/d pending BEHIND\npr 11054 lane/e red DIRTY\npr 11055 lane/f pending BLOCKED\npr 11056 lane/g green DRAFT\npr 11057 lane/h green UNSTABLE\n"; + +pub const S_AGENTS: &'static str = "now 2026-10-19T22:30:00Z\npr 11060 lane/open green CLEAN\nmerged lane/merged\nagent a1 done lane/open\nagent a2 done lane/merged\nagent a3 done lane/lost\nagent a4 running lane/lost\nagent a5 done lane/open2\n"; + +pub const S_CRONS: &'static str = "now 2026-10-19T22:30:00Z\ncron c1 run the loop tick on the epic\ncron c2 run the loop tick on the epic\ncron c3 run the loop tick on the epic\ncron c4 run the loop tick on the epic.\ncron c5 other prompt\n"; + +pub const S_CHASED: &'static str = "now 2026-10-19T22:30:00Z\nreq R1 carrier 2026-10-19T19:00:00Z 2026-10-19T21:30:00Z chased which carrier | keep the cheaper carrier\n"; + +pub const S_OPEN_DUE: &'static str = "now 2026-10-19T22:30:00Z\nreq R1 carrier 2026-10-19T19:00:00Z 2026-10-19T21:30:00Z open which carrier | keep the cheaper carrier\n"; + +pub const S_OPEN_EARLY: &'static str = "now 2026-10-19T22:30:00Z\nreq R1 carrier 2026-10-19T19:00:00Z 2026-10-19T23:30:00Z open which carrier | keep the cheaper carrier\n"; + +pub const S_REQS: &'static str = "now 2026-10-19T22:30:00Z\nreq R1 a 2026-10-19T19:00:00Z 2026-10-19T22:30:00Z open x | y\nreq R2 b 2026-10-19T19:00:00Z 2026-10-19T22:29:59Z chased x | y\nreq R3 c 2026-10-19T19:00:00Z 2026-10-19T22:30:01Z open x | y\nreq R4 d 2026-10-19T19:00:00Z 2026-10-19T01:00:00Z answered x | y\nreq R5 e 2026-10-19T19:00:00Z 2026-10-19T01:00:00Z defaulted x | y\n"; + +pub const NOOP_A: &'static str = "now 2026-10-19T22:30:00Z\nlast 907227767944327903\nrow L1 running 2026-10-19T22:20:00Z\nrow L2 running 2026-10-19T21:00:00Z\npr 11051 lane/b red CLEAN\n"; + +pub const NOOP_A_LATER: &'static str = "now 2026-10-19T22:40:00Z\nlast 907227767944327903\nrow L1 running 2026-10-19T22:20:00Z\nrow L2 running 2026-10-19T21:00:00Z\npr 11051 lane/b red CLEAN\n"; + +pub const NOOP_A_STALE: &'static str = "now 2026-10-19T23:30:00Z\nlast 907227767944327903\nrow L1 running 2026-10-19T22:20:00Z\nrow L2 running 2026-10-19T21:00:00Z\npr 11051 lane/b red CLEAN\n"; + +pub const NOOP_A_CHANGED: &'static str = "now 2026-10-19T22:30:00Z\nlast 907227767944327903\nrow L1 running 2026-10-19T22:20:00Z\nrow L2 running 2026-10-19T21:00:00Z\npr 11051 lane/b green CLEAN\n"; + +pub const NOOP_NO_NOW: &'static str = "last 316340705975935075\nmerged lane/x\n"; + +pub const NOOP_WITH_NOW: &'static str = "now 2026-10-19T22:30:00Z\nlast 316340705975935075\nmerged lane/x\n"; + +pub const S_NIGHT: &'static str = "; one night\nnow 2026-10-19T22:30:00Z\n\nrow L1 running 2026-10-19T22:20:00Z\nrow L2 running 2026-10-19T21:00:00Z\nwant L1\nwant L5\npr 11051 lane/b red CLEAN\npr 11052 lane/c green DIRTY\nagent a3 done lane/lost\ncron c1 tick\ncron c2 tick\nreq R1 carrier 2026-10-19T19:00:00Z 2026-10-19T21:30:00Z open which | keep it\nreq R2 carrier 2026-10-19T19:00:00Z 2026-10-19T21:30:00Z chased which | keep it\n"; + +pub fn key_value(text: &'static str, at: usize, end: usize, ind: usize, key: &'static str) -> usize { + let v: usize = (((at + ind) + key.len()) + 4); + if (v > end) { + return (end + 1); + } + let mut k: usize = 0; + while (k < ind) { + if (text.as_bytes()[((at + k)) as usize] != 32) { + return (end + 1); + } + k = (k + 1); + } + if (text.as_bytes()[((at + ind)) as usize] != 34) { + return (end + 1); + } + if (span_is(text, ((at + ind) + 1), (((at + ind) + 1) + key.len()), key) == false) { + return (end + 1); + } + if (((text.as_bytes()[((v - 3)) as usize] != 34) || (text.as_bytes()[((v - 2)) as usize] != 58)) || (text.as_bytes()[((v - 1)) as usize] != 32)) { + return (end + 1); + } + return v; +} + +pub fn line_end(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while ((k < text.len()) && (text.as_bytes()[(k) as usize] != 10)) { + k = (k + 1); + } + return k; +} + +pub fn span_is(text: &'static str, from: usize, to: usize, lit: &'static str) -> bool { + if ((to < from) || ((to - from) != lit.len())) { + return false; + } + let mut k: usize = 0; + while (k < lit.len()) { + if (text.as_bytes()[((from + k)) as usize] != lit.as_bytes()[(k) as usize]) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn value_from(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + if (text.as_bytes()[(v) as usize] == 34) { + return (v + 1); + } + return v; +} + +pub fn value_to(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + let mut e: usize = line_end(text, v); + if ((e > v) && (text.as_bytes()[((e - 1)) as usize] == 44)) { + e = (e - 1); + } + if (((text.as_bytes()[(v) as usize] == 34) && (e > (v + 1))) && (text.as_bytes()[((e - 1)) as usize] == 34)) { + e = (e - 1); + } + if (e < value_from(text, v)) { + return value_from(text, v); + } + return e; +} + +pub const AGE_UNKNOWN: u64 = 4294967295; + +pub const CI_GREEN: u8 = 0; + +pub const CI_PENDING: u8 = 1; + +pub const CI_RED: u8 = 2; + +pub const STALE_MINUTES: u64 = 45; + +pub const UNKNOWN: &'static str = "?"; + +pub fn age_known(age: u64) -> bool { + return (age != AGE_UNKNOWN); +} + +pub fn age_minutes(now: u64, then: u64) -> u64 { + if ((then == 0) || (then > now)) { + return AGE_UNKNOWN; + } + return ((now - then) / 60); +} + +pub fn branch_from(w: &'static str, at: usize) -> usize { + let stop: usize = record_end(w, at); + let mut k: usize = at; + while (k < stop) { + if starts(w, k, "branch refs/heads/") { + return (k + 18); + } + k = (line_end(w, k) + 1); + } + return w.len(); +} + +pub fn branch_to(w: &'static str, at: usize) -> usize { + let b: usize = branch_from(w, at); + if (b >= w.len()) { + return w.len(); + } + return line_end(w, b); +} + +pub fn ci(text: &'static str, o: usize, e: usize) -> u8 { + let mut pending: bool = false; + let mut at: usize = o; + while ((at < e) && (at < text.len())) { + let end: usize = line_end(text, at); + let c: usize = key_value(text, at, end, 8, "conclusion"); + let s: usize = key_value(text, at, end, 8, "state"); + let st: usize = key_value(text, at, end, 8, "status"); + if ((c <= end) && failed_word(text, value_from(text, c), value_to(text, c))) { + return CI_RED; + } + if (s <= end) { + if failed_word(text, value_from(text, s), value_to(text, s)) { + return CI_RED; + } + if (span_is(text, value_from(text, s), value_to(text, s), "PENDING") || span_is(text, value_from(text, s), value_to(text, s), "EXPECTED")) { + pending = true; + } + } + if ((st <= end) && (span_is(text, value_from(text, st), value_to(text, st), "COMPLETED") == false)) { + pending = true; + } + at = (end + 1); + } + if pending { + return CI_PENDING; + } + return CI_GREEN; +} + +pub fn days_from_civil(y: u64, m: u64, d: u64) -> u64 { + let mut yy: u64 = y; + if (m <= 2) { + yy = (yy - 1); + } + let era: u64 = (yy / 400); + let yoe: u64 = (yy - (era * 400)); + let mut mp: u64 = (m + 9); + if (m > 2) { + mp = (m - 3); + } + let doy: u64 = (((((153 * mp) + 2) / 5) + d) - 1); + let doe: u64 = ((((yoe * 365) + (yoe / 4)) - (yoe / 100)) + doy); + return (((era * 146097) + doe) - 719468); +} + +pub fn digits(text: &'static str, from: usize, to: usize) -> u64 { + if ((to <= from) || ((to - from) > 18)) { + return 0; + } + let mut n: u64 = 0; + let mut i: usize = from; + while (i < to) { + if ((text.as_bytes()[(i) as usize] < 48) || (text.as_bytes()[(i) as usize] > 57)) { + return 0; + } + n = ((n * 10) + ((text.as_bytes()[(i) as usize] as u64) - 48)); + i = (i + 1); + } + return n; +} + +pub fn failed_word(text: &'static str, from: usize, to: usize) -> bool { + return ((((((span_is(text, from, to, "FAILURE") || span_is(text, from, to, "ERROR")) || span_is(text, from, to, "TIMED_OUT")) || span_is(text, from, to, "CANCELLED")) || span_is(text, from, to, "ACTION_REQUIRED")) || span_is(text, from, to, "STARTUP_FAILURE")) || span_is(text, from, to, "STALE")); +} + +pub fn iso_seconds(text: &'static str, from: usize, to: usize) -> u64 { + if ((((((((to - from) != 20) || (text.as_bytes()[((from + 4)) as usize] != 45)) || (text.as_bytes()[((from + 7)) as usize] != 45)) || (text.as_bytes()[((from + 10)) as usize] != 84)) || (text.as_bytes()[((from + 13)) as usize] != 58)) || (text.as_bytes()[((from + 16)) as usize] != 58)) || (text.as_bytes()[((from + 19)) as usize] != 90)) { + return 0; + } + let y: u64 = digits(text, from, (from + 4)); + let mo: u64 = digits(text, (from + 5), (from + 7)); + let d: u64 = digits(text, (from + 8), (from + 10)); + if (((((y < 1970) || (mo < 1)) || (mo > 12)) || (d < 1)) || (d > 31)) { + return 0; + } + return ((((days_from_civil(y, mo, d) * 86400) + (digits(text, (from + 11), (from + 13)) * 3600)) + (digits(text, (from + 14), (from + 16)) * 60)) + digits(text, (from + 17), (from + 19))); +} + +pub fn later(a: u64, b: u64) -> u64 { + if (a > b) { + return a; + } + return b; +} + +pub fn merged(m: &'static str, w: &'static str, at: usize) -> bool { + let b: usize = branch_from(w, at); + let be: usize = branch_to(w, at); + if (b >= w.len()) { + return false; + } + let mut k: usize = 0; + while ((((k + 15) + (be - b)) + 1) <= m.len()) { + if (span_is(m, k, (k + 15), "\"headRefName\":\"") && (m.as_bytes()[(((k + 15) + (be - b))) as usize] == 34)) { + let mut same: bool = true; + let mut i: usize = 0; + while (i < (be - b)) { + if (m.as_bytes()[(((k + 15) + i)) as usize] != w.as_bytes()[((b + i)) as usize]) { + same = false; + } + i = (i + 1); + } + if same { + return true; + } + } + k = (k + 1); + } + return false; +} + +pub fn record_end(w: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < w.len()) { + let end: usize = line_end(w, k); + if (end == k) { + return k; + } + k = (end + 1); + } + return w.len(); +} + +pub fn starts(w: &'static str, at: usize, lit: &'static str) -> bool { + if ((at + lit.len()) > w.len()) { + return false; + } + return span_is(w, at, (at + lit.len()), lit); +} + diff --git a/specs/tools/tri/loop.t27 b/specs/tools/tri/loop.t27 index dd20986530..952bf29b36 100644 --- a/specs/tools/tri/loop.t27 +++ b/specs/tools/tri/loop.t27 @@ -2,7 +2,7 @@ ; specs/tools/tri/loop.t27 -- tool tri/loop, the `tri loop` command of the tri CLI ; Source of truth for the tool card on t27.ai (#/tools?tool=tri/loop). ; Extracted from the clap #[derive(Subcommand)] enums in cli/tri/src (witness source-parse: -; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492). +; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492; the plan action was added from its /// doc in cli/tri/src/loopclaim.rs with gHashTag/t27#11059). ; Layer 5 of the ladder Specs -> Skills -> Crons -> Agents -> Tools. ASCII only (L3). ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -23,8 +23,8 @@ pub const ENTRY : str = "cli/tri/src/main.rs"; pub const ABOUT : str = "What is failing on the default branch right now, and since when. Take a named claim so two sessions of the loop cannot pick the same task."; pub const ABOUT_SOURCE : str = "clap /// doc on Commands::Loop (cli/tri/src/main.rs; module cli/tri/src/loopclaim.rs)"; ; Nested subcommands (kebab-case as clap derives them) and their first /// paragraph ("" when none). -pub const ACTIONS : [1]str = ["claim"]; -pub const ACTIONS_ABOUT : [1]str = ["Take the named claim, or say who already holds it."]; +pub const ACTIONS : [2]str = ["claim", "plan"]; +pub const ACTIONS_ABOUT : [2]str = ["Take the named claim, or say who already holds it.", "The next actions of the overnight loop, one `ACTION key reason` line each, from a snapshot of its state (ledger rows, wanted lanes, pull requests, agents, crons, requests out) read from FILE or stdin."]; ; Top-level arguments/flags of the command itself, ": doc". pub const ARGS : [0]str = []; ; Agent letters that own this command, only where a source line names it (AGENTS_NOTE cites it). diff --git a/specs/tri/loop/tick.t27 b/specs/tri/loop/tick.t27 new file mode 100644 index 0000000000..c4623e5522 --- /dev/null +++ b/specs/tri/loop/tick.t27 @@ -0,0 +1,930 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/tri/loop/tick.t27 -- what `tri loop plan` prints for one tick of the overnight loop (gHashTag/t27#11059) +; A tick of the loop (.claude/skills/analog-emulation/SKILL.md, "The overnight loop tick") used to be prose: +; read the ledger, look for anomalies, do not start a lane the ledger lists as running, chase a request +; once. Prose lets a new cron cycle repeat or undo the last one's work. This spec is that prose as +; pure functions over a snapshot of the loop's state, with tests; the command (cli/tri/src/loopclaim.rs, +; through `t27c gen-rust` in gen/rust/tri/loop/tick.rs) reads the snapshot from a file or stdin, calls +; the functions below and prints one action per line. It contacts no network and no machine. The +; clock is a line of the snapshot (`now`), so the same snapshot always prints the same lines. +; The snapshot is text, one record per line, tokens split by one space; a blank line and a line that +; starts with `;` are skipped: +; now