kairos-chain 3.50.0 → 3.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/bin/agent_step_worker.rb +183 -0
- data/templates/skillsets/agent/lib/agent/advance_gate.rb +357 -0
- data/templates/skillsets/agent/lib/agent/session.rb +12 -3
- data/templates/skillsets/agent/lib/agent/step_delegation.rb +344 -0
- data/templates/skillsets/agent/lib/agent.rb +2 -0
- data/templates/skillsets/agent/skillset.json +2 -1
- data/templates/skillsets/agent/test/test_agent_interruption_resilience.rb +729 -0
- data/templates/skillsets/agent/tools/agent_status.rb +42 -3
- data/templates/skillsets/agent/tools/agent_step.rb +299 -18
- data/templates/skillsets/agent/tools/agent_stop.rb +52 -14
- data/templates/skillsets/agent/tools/agent_wait.rb +191 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fea78afc847b26db63d7355188444750fdb3cbbf84494cc489e63402bea3b630
|
|
4
|
+
data.tar.gz: b638968a01320bf5196d7f2b383b5c6dfa118a9432dc6feadf4ef04cbd4ee6b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0be2e69efbd5eda0c8c839c627389def7dd68db5cda29a904072e762677238459ef6a2fad8373d612715f1eeeb928579a7a318bc97ffdd5fb6c38eb54991c522
|
|
7
|
+
data.tar.gz: 74fd8b7ba83ef25552130dbd7b86047ce96fda674b1d151b247813597adef09c60ec192c524507ad054d3af3c63154b497de71d912891f84cad8637e79bd1bdf
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.51.0] - 2026-07-23
|
|
8
|
+
|
|
9
|
+
### Agent SkillSet — interruption resilience A-1 + A-2
|
|
10
|
+
|
|
11
|
+
Addresses the failure mode where the orchestrator's API stalling leaves an
|
|
12
|
+
auto-mode agent loop waiting on input: the running MCP server is intact, but
|
|
13
|
+
the driver stops, and auto-permission is orthogonal to driver liveness.
|
|
14
|
+
Design v0.3.1 FROZEN (design-by-invariant, multi-LLM review 3R converged).
|
|
15
|
+
|
|
16
|
+
- **A-1 — AdvanceGate** (`lib/agent/advance_gate.rb`): every state-advancing
|
|
17
|
+
`agent_step` call passes through a per-session serialized, atomic, anchored
|
|
18
|
+
gate. INV-A2 (serialized atomic advance via `advance.lock` + tmp-rename
|
|
19
|
+
commit), INV-A3 (anchored at-most-once with a `seq:state:cycle` anchor,
|
|
20
|
+
committed outcomes replay on retry, side-effect intent bracket with
|
|
21
|
+
no-silent-drop), INV-A4 (`next_move` derivable from persisted state),
|
|
22
|
+
INV-A5 (adjudication as a gated judgment). `adjudicate` action added.
|
|
23
|
+
Impl review 4R converged, 49 probes.
|
|
24
|
+
- **A-2 — delegated step executor** (`lib/agent/step_delegation.rb`,
|
|
25
|
+
`bin/agent_step_worker.rb`): INV-A1 driver independence. `agent_step` with
|
|
26
|
+
`execution: "delegated"` returns a resumable handle while a detached
|
|
27
|
+
(setsid) worker re-enters the SAME gated `agent_step` under server-side
|
|
28
|
+
ownership — delegate → wait → collect, transplanted from the review
|
|
29
|
+
SkillSet. All correctness inherited from the A-1 gate; the delegation layer
|
|
30
|
+
is coordination/observability only. New `agent_wait` tool; `agent_status`
|
|
31
|
+
surfaces the handle. Impl review 6R converged, 81 probes total.
|
|
32
|
+
- Attended real-worker run with a live LLM-bearing step validated
|
|
33
|
+
(2026-07-23, isolated harness): the detached worker bootstrapped the full
|
|
34
|
+
ToolRegistry and ran ORIENT+DECIDE via the claude CLI; liveness, collect,
|
|
35
|
+
and collector-owned teardown all held.
|
|
36
|
+
- Native body path unchanged (selectable-off). All prior agent-suite tests
|
|
37
|
+
remain at the main baseline.
|
|
38
|
+
|
|
7
39
|
## [3.50.0] - 2026-07-23
|
|
8
40
|
|
|
9
41
|
### Chain Distillation SkillSet — slice 1 (CD-1..CD-6)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Interruption resilience Slice A-2 — detached agent step worker (INV-A1).
|
|
5
|
+
#
|
|
6
|
+
# Spawned by StepDelegation#spawn_worker after agent_step was called with
|
|
7
|
+
# execution: "delegated". This worker is deliberately thin: it bootstraps a
|
|
8
|
+
# ToolRegistry (the same construction the MCP server uses) and re-enters the
|
|
9
|
+
# SAME gated agent_step path with the recorded arguments (the delegation-start
|
|
10
|
+
# anchor is already injected into those arguments). Every correctness property
|
|
11
|
+
# — per-session serialization, anchored at-most-once, side-effect intent
|
|
12
|
+
# bracket — is enforced by the AdvanceGate inside that call (Slice A-1), not by
|
|
13
|
+
# this script. If this worker dies, the driver re-issues the recorded call
|
|
14
|
+
# safely; if it double-runs, the gate serializes and replays.
|
|
15
|
+
#
|
|
16
|
+
# argv: <session_id> <session_dir>
|
|
17
|
+
# env: KAIROS_PROJECT_ROOT (chdir target)
|
|
18
|
+
# KAIROS_SERVER_LIB (lib dir to load kairos_mcp from)
|
|
19
|
+
# KAIROS_DATA_DIR (the server's effective data dir; makes the
|
|
20
|
+
# worker resolve the SAME .kairos)
|
|
21
|
+
#
|
|
22
|
+
# Exit codes: 0 success; 1 exception; 125 setsid failed; 130 signal.
|
|
23
|
+
#
|
|
24
|
+
# NB: bootstrap failures (LoadError/ScriptError from require) are caught too,
|
|
25
|
+
# so the driver always sees a result rather than a silently hung handle.
|
|
26
|
+
|
|
27
|
+
require 'json'
|
|
28
|
+
require 'time'
|
|
29
|
+
require 'fileutils'
|
|
30
|
+
|
|
31
|
+
session_id = ARGV[0] or abort 'usage: agent_step_worker.rb <session_id> <session_dir>'
|
|
32
|
+
session_dir = ARGV[1] or abort 'usage: agent_step_worker.rb <session_id> <session_dir>'
|
|
33
|
+
|
|
34
|
+
# Read the handle identity from the raw file first, so even a bootstrap
|
|
35
|
+
# failure can tag its error result with the delegation it belongs to.
|
|
36
|
+
def read_handle_identity(session_dir)
|
|
37
|
+
raw = JSON.parse(File.read(File.join(session_dir, 'delegation.json')))
|
|
38
|
+
{ 'issue_anchor' => raw['issue_anchor'], 'action_key' => raw['action_key'],
|
|
39
|
+
'step_token' => raw['step_token'] }
|
|
40
|
+
rescue StandardError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write_raw_result(session_dir, identity, outcome)
|
|
45
|
+
payload = identity.merge('outcome' => outcome)
|
|
46
|
+
tmp = File.join(session_dir, "delegation_result.json.tmp.#{Process.pid}")
|
|
47
|
+
File.write(tmp, JSON.generate(payload))
|
|
48
|
+
File.rename(tmp, File.join(session_dir, 'delegation_result.json'))
|
|
49
|
+
rescue StandardError
|
|
50
|
+
# nothing more we can do
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
boot_identity = read_handle_identity(session_dir)
|
|
54
|
+
|
|
55
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
56
|
+
begin
|
|
57
|
+
require 'agent/step_delegation'
|
|
58
|
+
rescue ScriptError, StandardError => e
|
|
59
|
+
# The delegation lib is co-located with this script; if even it cannot load
|
|
60
|
+
# we tag a raw result with the handle identity so status can surface it as
|
|
61
|
+
# 'ready' (an error outcome) rather than the driver waiting out the grace.
|
|
62
|
+
write_raw_result(session_dir, boot_identity,
|
|
63
|
+
{ 'status' => 'error',
|
|
64
|
+
'error' => "worker bootstrap failed: #{e.class}: #{e.message}" })
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
delegation = KairosMcp::SkillSets::Agent::StepDelegation.new(session_dir)
|
|
69
|
+
my_token = boot_identity['step_token']
|
|
70
|
+
|
|
71
|
+
shutdown = { requested: false }
|
|
72
|
+
%w[TERM INT HUP].each do |sig|
|
|
73
|
+
Signal.trap(sig) { shutdown[:requested] = true }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
Process.setsid
|
|
78
|
+
rescue Errno::EPERM
|
|
79
|
+
# Already a session leader — acceptable; continue.
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
delegation.write_result({ 'status' => 'error',
|
|
82
|
+
'error' => "worker setsid failed: #{e.message}" },
|
|
83
|
+
identity: boot_identity)
|
|
84
|
+
exit 125
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
heartbeat_thread = Thread.new do
|
|
88
|
+
loop do
|
|
89
|
+
begin
|
|
90
|
+
delegation.touch_heartbeat(my_token)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
# A transient touch failure must not kill the heartbeat thread and
|
|
93
|
+
# make a live worker look crashed; retry on the next tick.
|
|
94
|
+
end
|
|
95
|
+
sleep KairosMcp::SkillSets::Agent::StepDelegation::HEARTBEAT_INTERVAL_SECONDS
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Self-timeout watchdog: a hung gated call would otherwise hold the advance
|
|
100
|
+
# lock forever. Exiting the process releases its flock; the driver then sees
|
|
101
|
+
# 'crashed' and re-issues safely. We deliberately write NO result here so the
|
|
102
|
+
# collector takes the crash path — if the gated advance had already committed,
|
|
103
|
+
# crashed_response recovers its outcome from the gate log; an error result
|
|
104
|
+
# would instead mask that committed advance.
|
|
105
|
+
timeout_s = KairosMcp::SkillSets::Agent::StepDelegation.worker_self_timeout_seconds
|
|
106
|
+
watchdog = Thread.new do
|
|
107
|
+
sleep timeout_s
|
|
108
|
+
exit!(124)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
Dir.chdir(ENV['KAIROS_PROJECT_ROOT']) if ENV['KAIROS_PROJECT_ROOT'] &&
|
|
113
|
+
Dir.exist?(ENV['KAIROS_PROJECT_ROOT'])
|
|
114
|
+
$LOAD_PATH.unshift(ENV['KAIROS_SERVER_LIB']) if ENV['KAIROS_SERVER_LIB']
|
|
115
|
+
# KAIROS_DATA_DIR was set in the worker env by spawn_worker so ToolRegistry
|
|
116
|
+
# / Session resolve the server's effective .kairos even under --data-dir.
|
|
117
|
+
require 'kairos_mcp/tool_registry'
|
|
118
|
+
|
|
119
|
+
pending = delegation.pending
|
|
120
|
+
raise "no pending delegation in #{session_dir}" unless pending
|
|
121
|
+
|
|
122
|
+
# Supersession guard: run ONLY if the live handle is still ours. If a fresh
|
|
123
|
+
# open_handle replaced it while we were starting up (e.g. we were declared
|
|
124
|
+
# 'crashed' on a stale heartbeat and the driver re-delegated), or we could
|
|
125
|
+
# not read our own identity at boot (my_token nil), do NOT run: the new
|
|
126
|
+
# worker owns this delegation, and the gate would replay/serialize our stale
|
|
127
|
+
# call anyway. Exiting leaves the current handle to its rightful worker.
|
|
128
|
+
if my_token.nil? || pending['step_token'] != my_token
|
|
129
|
+
exit 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Past the guard, boot_identity is non-empty and is our own handle identity
|
|
133
|
+
# (also driving the per-token heartbeat); write_result tags with it.
|
|
134
|
+
identity = boot_identity
|
|
135
|
+
|
|
136
|
+
args = (pending['arguments'] || {}).merge('session_id' => session_id)
|
|
137
|
+
args.delete('execution') # never recurse into another delegation
|
|
138
|
+
|
|
139
|
+
exit 130 if shutdown[:requested]
|
|
140
|
+
|
|
141
|
+
registry = KairosMcp::ToolRegistry.new
|
|
142
|
+
raw = registry.call_tool('agent_step', args)
|
|
143
|
+
|
|
144
|
+
# Normalize the MCP content shape to the response hash the inline call
|
|
145
|
+
# would have returned.
|
|
146
|
+
text = if raw.is_a?(Array) && raw.first.is_a?(Hash)
|
|
147
|
+
raw.first[:text] || raw.first['text']
|
|
148
|
+
end
|
|
149
|
+
response = begin
|
|
150
|
+
text ? JSON.parse(text) : { 'status' => 'error', 'error' => 'unrecognized tool result shape' }
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
{ 'status' => 'error', 'error' => 'unparseable tool result', 'raw' => text.to_s[0, 500] }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Write the result (tagged with our OWN startup identity) and leave the
|
|
156
|
+
# pending handle in place: teardown is the collector's job (agent_wait#collect
|
|
157
|
+
# clears result+handle atomically under delegation.lock), so the worker never
|
|
158
|
+
# races a concurrently-opened fresh delegation by clearing state it may no
|
|
159
|
+
# longer own or by mislabeling its result as a newer delegation's.
|
|
160
|
+
delegation.write_result(response, identity: identity)
|
|
161
|
+
exit 0
|
|
162
|
+
rescue SystemExit, SignalException
|
|
163
|
+
# A deliberate exit (including our own `exit 0`) or a signal is not a
|
|
164
|
+
# failure to report — let it propagate.
|
|
165
|
+
raise
|
|
166
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
167
|
+
# Catch Exception, not just StandardError: LoadError/ScriptError from the
|
|
168
|
+
# bootstrap require are exactly the failure class the driver must see as a
|
|
169
|
+
# result rather than a silently hung handle. Leave teardown to the collector.
|
|
170
|
+
# Tag with our OWN startup identity (boot_identity is always our handle;
|
|
171
|
+
# the in-block `identity` local may be unassigned if we failed early, and
|
|
172
|
+
# a re-read of pending could belong to a superseding delegation).
|
|
173
|
+
begin
|
|
174
|
+
delegation.write_result({ 'status' => 'error', 'error' => "worker: #{e.class}: #{e.message}" },
|
|
175
|
+
identity: (boot_identity.empty? ? nil : boot_identity))
|
|
176
|
+
rescue StandardError
|
|
177
|
+
# best effort
|
|
178
|
+
end
|
|
179
|
+
exit 1
|
|
180
|
+
ensure
|
|
181
|
+
watchdog&.kill
|
|
182
|
+
heartbeat_thread&.kill
|
|
183
|
+
end
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module KairosMcp
|
|
9
|
+
module SkillSets
|
|
10
|
+
module Agent
|
|
11
|
+
# Interruption resilience Slice A (design v0.3.1 FROZEN).
|
|
12
|
+
#
|
|
13
|
+
# AdvanceGate is the single entry point through which every
|
|
14
|
+
# state-advancing operation on an agent session must pass. It carries
|
|
15
|
+
# three of the frozen invariants:
|
|
16
|
+
#
|
|
17
|
+
# INV-A2 (serialized atomic advance): a non-blocking per-session file
|
|
18
|
+
# lock serializes advances; a concurrent caller is refused with
|
|
19
|
+
# 'busy', never interleaved. Every gate file is committed via
|
|
20
|
+
# tmp-write + atomic rename, so a resumed driver observes a
|
|
21
|
+
# transition in full or not at all.
|
|
22
|
+
#
|
|
23
|
+
# INV-A3 (anchored at-most-once): each advance is bound to the anchor
|
|
24
|
+
# of the state it was issued against. A re-issue whose anchor (and
|
|
25
|
+
# action) match an already-committed advance replays the recorded
|
|
26
|
+
# outcome without re-executing. A stale anchor is rejected with the
|
|
27
|
+
# current state. The side-effect intent bracket (open_intent /
|
|
28
|
+
# close_intent) makes a crash between executing an external effect
|
|
29
|
+
# and recording it detectable: the orphan intent surfaces as an
|
|
30
|
+
# unresolved point instead of being silently dropped or re-run.
|
|
31
|
+
#
|
|
32
|
+
# INV-A4 (monotone derivable recovery): current_anchor and the
|
|
33
|
+
# committed advance log are derived from persisted state alone, so
|
|
34
|
+
# a fresh driver needs no memory of the interrupted one.
|
|
35
|
+
#
|
|
36
|
+
# The anchor is a monotonic sequence number combined with the session
|
|
37
|
+
# state name and cycle ("<seq>:<state>:<cycle>"): the sequence carries
|
|
38
|
+
# uniqueness, the state/cycle carry readability. Anchors are optional on
|
|
39
|
+
# the wire for compatibility (§7): an anchorless call is treated as
|
|
40
|
+
# issued against the current state, and every response carries the new
|
|
41
|
+
# anchor so the caller can join the regime on its next call.
|
|
42
|
+
class AdvanceGate
|
|
43
|
+
LOCK_FILE = 'advance.lock'
|
|
44
|
+
STATE_FILE = 'advance.json'
|
|
45
|
+
LOG_FILE = 'advance_log.jsonl'
|
|
46
|
+
INTENT_FILE = 'act_intent.json'
|
|
47
|
+
|
|
48
|
+
# Raised never; results are communicated as Hashes so the tool layer
|
|
49
|
+
# can render them without exception plumbing.
|
|
50
|
+
|
|
51
|
+
def initialize(session_dir)
|
|
52
|
+
@dir = session_dir
|
|
53
|
+
FileUtils.mkdir_p(@dir)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ---- anchor -------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def seq
|
|
59
|
+
gate_state['seq']
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def current_anchor(session)
|
|
63
|
+
"#{gate_state['seq']}:#{session.state}:#{session.cycle_number}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ---- serialized advance (INV-A2) ---------------------------------
|
|
67
|
+
|
|
68
|
+
# Runs the block under the per-session advance lock.
|
|
69
|
+
# Returns the block's value, or { 'status' => 'busy' } if another
|
|
70
|
+
# advance holds the lock. The lock spans anchor validation, phase
|
|
71
|
+
# execution, and outcome commit, so no two advances interleave.
|
|
72
|
+
def with_lock
|
|
73
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
74
|
+
unless f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
75
|
+
return { 'status' => 'busy',
|
|
76
|
+
'error' => 'another advance is in progress on this session' }
|
|
77
|
+
end
|
|
78
|
+
begin
|
|
79
|
+
# Any memoized gate state predates the lock; drop it so every
|
|
80
|
+
# read inside the critical section reflects the persisted truth
|
|
81
|
+
# (a gate instance that outlives a lock window must not serve a
|
|
82
|
+
# stale seq).
|
|
83
|
+
@gate_state = nil
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
f.flock(File::LOCK_UN)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# True while another advance holds the lock. Read-only probes (the
|
|
92
|
+
# status surface) use this to distinguish "an advance is in flight"
|
|
93
|
+
# from "an advance died mid-effect" — an open intent plus a held lock
|
|
94
|
+
# is normal execution, not an unresolved point.
|
|
95
|
+
# Probes with a shared lock so concurrent probes never serialize each
|
|
96
|
+
# other and the window in which a probe could make a real advance's
|
|
97
|
+
# LOCK_EX attempt fail spuriously is minimal (a spurious 'busy' is
|
|
98
|
+
# safe: the caller retries).
|
|
99
|
+
def busy?
|
|
100
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
101
|
+
if f.flock(File::LOCK_SH | File::LOCK_NB)
|
|
102
|
+
f.flock(File::LOCK_UN)
|
|
103
|
+
false
|
|
104
|
+
else
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ---- anchored at-most-once (INV-A3) ------------------------------
|
|
111
|
+
|
|
112
|
+
# Checks a provided anchor against the current state and the committed
|
|
113
|
+
# log. Returns:
|
|
114
|
+
# { 'disposition' => 'proceed' } — execute normally
|
|
115
|
+
# { 'disposition' => 'replay', 'outcome' => {...} } — committed already
|
|
116
|
+
# { 'disposition' => 'rejected', ... } — stale/unknown anchor
|
|
117
|
+
# A nil anchor proceeds (compatibility path, §7: the first advance
|
|
118
|
+
# establishes the anchor from the state it reads).
|
|
119
|
+
def check(anchor, action, session)
|
|
120
|
+
return { 'disposition' => 'proceed' } if anchor.nil? || anchor.to_s.empty?
|
|
121
|
+
return { 'disposition' => 'proceed' } if anchor == current_anchor(session)
|
|
122
|
+
|
|
123
|
+
committed = find_committed(anchor)
|
|
124
|
+
if committed
|
|
125
|
+
if committed['action'] == action
|
|
126
|
+
return { 'disposition' => 'replay', 'outcome' => committed['outcome'] }
|
|
127
|
+
end
|
|
128
|
+
return { 'disposition' => 'rejected',
|
|
129
|
+
'reason' => 'anchor was consumed by a different action',
|
|
130
|
+
'consumed_by' => committed['action'] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
{ 'disposition' => 'rejected',
|
|
134
|
+
'reason' => 'anchor does not match current session state' }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Commits an advance: appends the outcome record and bumps the
|
|
138
|
+
# sequence, both atomically. Called with the lock held, after the
|
|
139
|
+
# session's own state has been persisted, so a crash before this call
|
|
140
|
+
# leaves the old anchor valid (the advance never happened) and a crash
|
|
141
|
+
# after leaves it replayable (the advance fully happened).
|
|
142
|
+
def commit(anchor_at_issue, action, outcome)
|
|
143
|
+
entry = {
|
|
144
|
+
'seq' => gate_state['seq'],
|
|
145
|
+
'anchor' => anchor_at_issue,
|
|
146
|
+
'action' => action,
|
|
147
|
+
'outcome' => outcome,
|
|
148
|
+
'timestamp' => Time.now.utc.iso8601
|
|
149
|
+
}
|
|
150
|
+
repair_log_tail
|
|
151
|
+
File.open(log_path, 'a') { |f| f.puts(JSON.generate(entry)) }
|
|
152
|
+
atomic_write(state_path, JSON.pretty_generate('seq' => gate_state['seq'] + 1))
|
|
153
|
+
@gate_state = nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ---- side-effect intent bracket (INV-A3, no silent drop) ---------
|
|
157
|
+
|
|
158
|
+
# Persisted before an external side effect begins.
|
|
159
|
+
def open_intent(anchor, decision_payload)
|
|
160
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(decision_payload || {}))[0, 16]
|
|
161
|
+
atomic_write(intent_path, JSON.pretty_generate(
|
|
162
|
+
'anchor' => anchor, 'decision_digest' => digest,
|
|
163
|
+
'opened_at' => Time.now.utc.iso8601
|
|
164
|
+
))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Removed only after the advance carrying the effect has committed.
|
|
168
|
+
def close_intent
|
|
169
|
+
File.delete(intent_path) if File.exist?(intent_path)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# An intent whose advance never committed. Its outcome is unknowable
|
|
173
|
+
# from here: it is surfaced, never resolved silently (INV-A3), and its
|
|
174
|
+
# adjudication is a gated human judgment (INV-A5).
|
|
175
|
+
#
|
|
176
|
+
# cleanup: deleting a stale intent is a write, so it happens only when
|
|
177
|
+
# the caller holds the advance lock (cleanup: true from the gated step
|
|
178
|
+
# path). Read-only probes (status/next_move) must not delete: an
|
|
179
|
+
# unlocked delayed delete could race a fresh open_intent and remove a
|
|
180
|
+
# LIVE intent.
|
|
181
|
+
def unresolved_intent(cleanup: false)
|
|
182
|
+
raw = begin
|
|
183
|
+
File.read(intent_path)
|
|
184
|
+
rescue Errno::ENOENT
|
|
185
|
+
return nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
intent = JSON.parse(raw)
|
|
189
|
+
# If the log contains a committed advance for the intent's anchor,
|
|
190
|
+
# the effect was recorded — the intent file is a stale leftover.
|
|
191
|
+
# Exception: a stop committed at the same anchor with the intent
|
|
192
|
+
# still unresolved did NOT record the effect (it recorded the
|
|
193
|
+
# ambiguity); such a commit must not make the audit trace look
|
|
194
|
+
# stale, or cleanup would silently erase it.
|
|
195
|
+
committed = find_committed(intent['anchor'])
|
|
196
|
+
if committed && !committed.dig('outcome', 'unresolved_intent_at_stop')
|
|
197
|
+
File.delete(intent_path) if cleanup && File.exist?(intent_path)
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
intent
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
# A torn intent file is itself an unresolved point; report it.
|
|
203
|
+
{ 'anchor' => nil, 'corrupt' => true }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ---- derivable next move (INV-A4) --------------------------------
|
|
207
|
+
|
|
208
|
+
# From persisted state alone: the single next move a fresh driver
|
|
209
|
+
# should issue. An unresolved side-effect point takes precedence over
|
|
210
|
+
# every other pending advance (v0.3.1 uniqueness clause).
|
|
211
|
+
def next_move(session)
|
|
212
|
+
intent = unresolved_intent
|
|
213
|
+
# A terminated session has no next move; a kept intent is an audit
|
|
214
|
+
# record of the ambiguity, not a pending adjudication (the step
|
|
215
|
+
# tool refuses post-termination adjudication, so recommending it
|
|
216
|
+
# would loop forever).
|
|
217
|
+
if session.state == 'terminated'
|
|
218
|
+
move = { 'tool' => nil,
|
|
219
|
+
'reason' => 'session is terminated; no further moves' }
|
|
220
|
+
move['audit_intent'] = intent if intent
|
|
221
|
+
return move
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if intent
|
|
225
|
+
return {
|
|
226
|
+
'tool' => 'agent_step',
|
|
227
|
+
'args' => { 'session_id' => session.session_id, 'action' => 'adjudicate',
|
|
228
|
+
'anchor' => current_anchor(session) },
|
|
229
|
+
'reason' => 'unresolved side-effect: an act was started but its outcome ' \
|
|
230
|
+
'was never recorded; adjudicate with resolution ' \
|
|
231
|
+
'"reattempt" or "already_done"',
|
|
232
|
+
'unresolved_intent' => intent
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
action, reason = case effective_state(session)
|
|
237
|
+
when 'observed', 'autonomous_cycling'
|
|
238
|
+
['approve', 'run Orient+Decide']
|
|
239
|
+
when 'proposed'
|
|
240
|
+
['approve', 'run Act+Reflect (or "revise"/"skip")']
|
|
241
|
+
when 'checkpoint'
|
|
242
|
+
['approve', 'start next cycle']
|
|
243
|
+
when 'paused_risk', 'paused_error'
|
|
244
|
+
['approve', 'resume from pause (or "skip"/"stop")']
|
|
245
|
+
else
|
|
246
|
+
[nil, "unrecognized state #{session.state}"]
|
|
247
|
+
end
|
|
248
|
+
return { 'tool' => nil, 'reason' => reason } unless action
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
'tool' => 'agent_step',
|
|
252
|
+
'args' => { 'session_id' => session.session_id, 'action' => action,
|
|
253
|
+
'anchor' => current_anchor(session) },
|
|
254
|
+
'reason' => reason
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A transient phase state persisted by an interrupted call maps to the
|
|
259
|
+
# stable state it is recoverable from. Phases before ACT are
|
|
260
|
+
# side-effect free (their re-run is safe); the ACT window is governed
|
|
261
|
+
# by the intent bracket, not by this mapping.
|
|
262
|
+
def effective_state(session)
|
|
263
|
+
case session.state
|
|
264
|
+
when 'orienting', 'deciding' then 'observed'
|
|
265
|
+
when 'acting', 'reflecting' then 'proposed'
|
|
266
|
+
else session.state
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The committed outcome recorded for an anchor, or nil. Used by the
|
|
271
|
+
# delegation wait surface (Slice A-2) to recover a committed advance's
|
|
272
|
+
# return value when a worker died after committing but before writing
|
|
273
|
+
# its result (crash-window b). When action is given, the committed
|
|
274
|
+
# entry's action must match — so a different judgment that consumed the
|
|
275
|
+
# same anchor before the crash never returns the wrong outcome.
|
|
276
|
+
def committed_outcome(anchor, action = nil)
|
|
277
|
+
entry = find_committed(anchor)
|
|
278
|
+
return nil unless entry
|
|
279
|
+
return nil if action && entry['action'] != action
|
|
280
|
+
entry['outcome']
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def find_committed(anchor)
|
|
286
|
+
return nil unless File.exist?(log_path)
|
|
287
|
+
|
|
288
|
+
found = nil
|
|
289
|
+
File.foreach(log_path) do |line|
|
|
290
|
+
entry = JSON.parse(line.strip) rescue nil
|
|
291
|
+
found = entry if entry && entry['anchor'] == anchor
|
|
292
|
+
end
|
|
293
|
+
found
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# The effective sequence fails closed: it is reconciled against the
|
|
297
|
+
# committed log's highest recorded seq, so a lost or corrupt
|
|
298
|
+
# advance.json can never regress the sequence and let an
|
|
299
|
+
# already-consumed anchor match the current one again (at-most-once
|
|
300
|
+
# would silently break on exactly the anchors whose state component
|
|
301
|
+
# did not change, e.g. revise at proposed).
|
|
302
|
+
def gate_state
|
|
303
|
+
@gate_state ||= begin
|
|
304
|
+
file_seq = begin
|
|
305
|
+
File.exist?(state_path) ? (JSON.parse(File.read(state_path))['seq'] || 0) : 0
|
|
306
|
+
rescue JSON::ParserError
|
|
307
|
+
0
|
|
308
|
+
end
|
|
309
|
+
{ 'seq' => [file_seq, log_max_seq + 1].max }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Highest committed seq in the log, or -1 when no advance committed.
|
|
314
|
+
def log_max_seq
|
|
315
|
+
return -1 unless File.exist?(log_path)
|
|
316
|
+
|
|
317
|
+
max = -1
|
|
318
|
+
File.foreach(log_path) do |line|
|
|
319
|
+
entry = JSON.parse(line.strip) rescue nil
|
|
320
|
+
s = entry && entry['seq']
|
|
321
|
+
max = s if s.is_a?(Integer) && s > max
|
|
322
|
+
end
|
|
323
|
+
max
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A crash mid-append can leave a torn tail line; without repair the
|
|
327
|
+
# next append would concatenate onto it and corrupt BOTH records.
|
|
328
|
+
# Terminating the torn tail confines the damage to the already-lost
|
|
329
|
+
# entry.
|
|
330
|
+
def repair_log_tail
|
|
331
|
+
return unless File.exist?(log_path) && File.size(log_path).positive?
|
|
332
|
+
|
|
333
|
+
last = File.open(log_path, 'rb') do |f|
|
|
334
|
+
f.seek(-1, IO::SEEK_END)
|
|
335
|
+
f.read(1)
|
|
336
|
+
end
|
|
337
|
+
File.open(log_path, 'a') { |f| f.write("\n") } unless last == "\n"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# INV-A2: no partial-write window. Content lands under a temp name and
|
|
341
|
+
# is renamed into place; rename is atomic on POSIX filesystems. The
|
|
342
|
+
# temp name carries pid + thread id so two writers in one process
|
|
343
|
+
# cannot collide on the temp file.
|
|
344
|
+
def atomic_write(path, content)
|
|
345
|
+
tmp = "#{path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
346
|
+
File.write(tmp, content)
|
|
347
|
+
File.rename(tmp, path)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def lock_path = File.join(@dir, LOCK_FILE)
|
|
351
|
+
def state_path = File.join(@dir, STATE_FILE)
|
|
352
|
+
def log_path = File.join(@dir, LOG_FILE)
|
|
353
|
+
def intent_path = File.join(@dir, INTENT_FILE)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
@@ -71,7 +71,7 @@ module KairosMcp
|
|
|
71
71
|
|
|
72
72
|
# Persist decision_payload for the proposed→ACT transition.
|
|
73
73
|
def save_decision(decision_payload)
|
|
74
|
-
|
|
74
|
+
atomic_write(decision_path, JSON.pretty_generate(decision_payload))
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# Load the last saved decision_payload.
|
|
@@ -84,7 +84,7 @@ module KairosMcp
|
|
|
84
84
|
|
|
85
85
|
# Persist observation for ORIENT continuity.
|
|
86
86
|
def save_observation(observation)
|
|
87
|
-
|
|
87
|
+
atomic_write(observation_path, JSON.pretty_generate(observation))
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# Append a progress entry after REFLECT (M5: cumulative progress file).
|
|
@@ -139,7 +139,7 @@ module KairosMcp
|
|
|
139
139
|
config: @config, autonomous: @autonomous,
|
|
140
140
|
invocation_context: @invocation_context.to_h
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
atomic_write(state_path, JSON.pretty_generate(data))
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
# Load a session from disk.
|
|
@@ -210,6 +210,15 @@ module KairosMcp
|
|
|
210
210
|
|
|
211
211
|
private
|
|
212
212
|
|
|
213
|
+
# INV-A2 (interruption resilience Slice A): no partial-write window.
|
|
214
|
+
# A resumed driver must observe a persisted record in full or not at
|
|
215
|
+
# all; tmp-write + rename makes each single-file commit atomic.
|
|
216
|
+
def atomic_write(path, content)
|
|
217
|
+
tmp = "#{path}.tmp.#{Process.pid}"
|
|
218
|
+
File.write(tmp, content)
|
|
219
|
+
File.rename(tmp, path)
|
|
220
|
+
end
|
|
221
|
+
|
|
213
222
|
def review_path
|
|
214
223
|
File.join(session_dir, 'last_review.json')
|
|
215
224
|
end
|