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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 826aabda2632b42bb5e4c688f443616faf29b9b97daba55aa02fa6834bf1629d
4
- data.tar.gz: 2a743cfa76eb0d56db2220ed40c11ea0cc52f88c9b16ed8dfb84855d54b15981
3
+ metadata.gz: fea78afc847b26db63d7355188444750fdb3cbbf84494cc489e63402bea3b630
4
+ data.tar.gz: b638968a01320bf5196d7f2b383b5c6dfa118a9432dc6feadf4ef04cbd4ee6b8
5
5
  SHA512:
6
- metadata.gz: 0e0ab6e95d0f527e738053fe45e9899e761820809c0f26ab37dd55eacadf13b387b22737475a8685b894d095aa9ba4529df142ab3809adf917c2ff275b46f403
7
- data.tar.gz: 1272d3d2ffd8cc80615dc1990346758982cb64cb78d947965d754d3ae5d0e148524044c8197efbb836e0ecf067cfcae69f51aa90a934a71da691525c0b190da2
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)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.50.0"
2
+ VERSION = "3.51.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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
- File.write(decision_path, JSON.pretty_generate(decision_payload))
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
- File.write(observation_path, JSON.pretty_generate(observation))
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
- File.write(state_path, JSON.pretty_generate(data))
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