turnkit 0.4.2 → 0.5.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: 78b1d7eae5414daf8116c999392a6d726e50360c643578d35ff7154559c525f3
4
- data.tar.gz: f6e0f434323bf93ac97af5839cf08ede8d89b9aa50fe96eb0c0ed93992fd4d42
3
+ metadata.gz: 3db08f0653a468f22daa74070c7b1a290f02b44beaf6d35c950f3d36d86dc224
4
+ data.tar.gz: 18d3b2df593865ba53e0863d0e2b061f8744bdbbf78409b40d525c3ba47b5070
5
5
  SHA512:
6
- metadata.gz: 8fdf2b4569248ebc409da2915663864e5891d54049c3696d827e0c2c2242b283c52e2f0cf2bbaee9adc08094d74c839b28dab2f5efaf898a03812393de28cf18
7
- data.tar.gz: 3283229cf6c2e94d109437b715ee21944fbebaefe1a430670c8dc4410cbb0fbc6e670039a3d60730f4a5fd27ee2669b65eab4431cb362e342bed32dac89d4fec
6
+ metadata.gz: dd2216c9e2275dd737d6e1cf7dadd97053ca5afb6992939791669f29e047f3e44eb45f492a09b4494222117428baf07ffd9aa60352f9363b0a8b3eedb9c6ab54
7
+ data.tar.gz: 3b8f1a45bc98506dc09a9a6703aacf1f614fb7649c3c64942c98e902aff1db9232f986aad33c077be51fec1623d82abf4506d32698b34e3079ff775b9a1eb38b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-07-22
4
+
5
+ ### Breaking
6
+
7
+ - Custom stores: `find_stale_turns` is replaced by `reconcile_stale_turns(before:)`, which must atomically transition eligible turns to `stale` and return the reconciled records. `update_tool_execution` is replaced by `claim_tool_execution(id, from:, to:, **attributes)`, an atomic compare-and-set mirroring `claim_turn`.
8
+
9
+ ### Fixed
10
+
11
+ - Make stale-turn reconciliation atomic, so `TurnKit.reconcile_stale!` can no longer overwrite a turn that was concurrently claimed, heartbeated, or completed (#1).
12
+ - Heartbeat while a tool executes, so tools slower than `TurnKit.timeout` are not falsely reconciled.
13
+
14
+ ### Added
15
+
16
+ - Add an `interrupted` tool-execution status. Reconciliation marks a stale turn's unfinished tool executions `interrupted`, appends a synthetic error tool result for unresolved tool calls (keeping the transcript continuable), and emits `turn.stale` and `tool_call.interrupted` events. Late tool results arriving after interruption are dropped, never overwriting the reconciled state.
17
+ - `TurnKit.reconcile_stale!` returns the reconciled turn records. `stale` is provisional: a worker whose heartbeats were merely late finishes its turn normally, replacing `stale` with the actual outcome.
18
+
3
19
  ## 0.4.2 - 2026-07-02
4
20
 
5
21
  ### Breaking
data/README.md CHANGED
@@ -784,12 +784,26 @@ TurnKit.store = TurnKit::ActiveRecordStore.new(
784
784
  )
785
785
  ```
786
786
 
787
- Reconcile stale turns:
787
+ Reconcile turns abandoned by a dead worker (for example after a hard-killed
788
+ process). Run this periodically:
788
789
 
789
790
  ```ruby
790
791
  TurnKit.reconcile_stale!
791
792
  ```
792
793
 
794
+ Reconciliation atomically marks pending and running turns whose last heartbeat
795
+ is older than `TurnKit.timeout` as `stale`, so it never overwrites a turn that
796
+ was concurrently claimed, heartbeated, or completed. Each stale turn's
797
+ unfinished tool executions become `interrupted`, and a synthetic error tool
798
+ result is appended for any unresolved tool call so the conversation can be
799
+ continued. TurnKit never reruns an interrupted tool — whether its side effect
800
+ happened is unknown, so the continued model is told not to assume either way.
801
+
802
+ Reconciliation does not cancel or reassign the underlying process. If the
803
+ original worker is still alive (its heartbeats were merely late), it continues
804
+ over the synthetic interrupted result and later replaces `stale` with its
805
+ actual `completed` or `failed` outcome — treat `turn.stale` as provisional.
806
+
793
807
  ## Options
794
808
 
795
809
  | Option | Description |
@@ -144,18 +144,28 @@ module TurnKit
144
144
  tool_execution_hash(tool_execution_class.find_by!(uid: id))
145
145
  end
146
146
 
147
- def update_tool_execution(id, attributes)
148
- record = tool_execution_class.find_by!(uid: id)
149
- record.update!(Record.tool_execution_update(attributes))
150
- tool_execution_hash(record)
147
+ def claim_tool_execution(id, from: "running", to: "completed", **attributes)
148
+ attrs = Record.tool_execution_update(attributes.merge(status: to))
149
+ affected = tool_execution_class.where(uid: id, status: from).update_all(attrs.merge(updated_at: Clock.now))
150
+ return nil if affected.zero?
151
+
152
+ load_tool_execution(id)
151
153
  end
152
154
 
153
155
  def list_tool_executions(turn_id:)
154
156
  tool_execution_class.where(turn_uid: turn_id).order(:created_at, :uid).map { |record| tool_execution_hash(record) }
155
157
  end
156
158
 
157
- def find_stale_turns(before:)
158
- turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before).map { |record| turn_hash(record) }
159
+ def reconcile_stale_turns(before:)
160
+ scope = turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
161
+ scope.filter_map do |record|
162
+ now = Clock.now
163
+ affected = turn_class
164
+ .where(id: record.id, status: %w[pending running])
165
+ .where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
166
+ .update_all(status: "stale", completed_at: now, updated_at: now)
167
+ turn_hash(record.reload) if affected == 1
168
+ end
159
169
  end
160
170
 
161
171
  private
@@ -100,10 +100,12 @@ module TurnKit
100
100
  @mutex.synchronize { duplicate(@tool_executions.fetch(id)) }
101
101
  end
102
102
 
103
- def update_tool_execution(id, attributes)
104
- attrs = Record.tool_execution_update(attributes)
103
+ def claim_tool_execution(id, from: "running", to: "completed", **attributes)
104
+ attrs = Record.tool_execution_update(attributes.merge(status: to))
105
105
  @mutex.synchronize do
106
106
  record = @tool_executions.fetch(id)
107
+ return nil unless record["status"] == from
108
+
107
109
  record.merge!(attrs.merge("updated_at" => Clock.now))
108
110
  duplicate(record)
109
111
  end
@@ -118,11 +120,14 @@ module TurnKit
118
120
  end
119
121
  end
120
122
 
121
- def find_stale_turns(before:)
123
+ def reconcile_stale_turns(before:)
122
124
  @mutex.synchronize do
123
- @turns.values.select do |turn|
124
- %w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
125
- end.map { |turn| duplicate(turn) }
125
+ @turns.values.filter_map do |turn|
126
+ next unless %w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
127
+
128
+ turn.merge!("status" => "stale", "completed_at" => Clock.now, "updated_at" => Clock.now)
129
+ duplicate(turn)
130
+ end
126
131
  end
127
132
  end
128
133
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ # Reconciles turns abandoned by a dead worker: atomically marks them stale,
5
+ # marks their unfinished tool executions interrupted, and appends synthetic
6
+ # error tool results so the persisted transcript stays structurally complete
7
+ # for continuation. TurnKit never reruns an interrupted tool; the synthetic
8
+ # result tells the continued model the outcome is unknown.
9
+ module Reconciliation
10
+ INTERRUPTED_MESSAGE = "Tool execution was interrupted before a result was recorded. " \
11
+ "It is unknown whether the operation ran; do not assume it did or did not."
12
+
13
+ module_function
14
+
15
+ def reconcile!(before:)
16
+ TurnKit.store.reconcile_stale_turns(before: before).each do |turn|
17
+ emit("turn.stale", turn)
18
+ executions = interrupt_tool_executions(turn)
19
+ repair_transcript(turn, executions)
20
+ end
21
+ end
22
+
23
+ def interrupt_tool_executions(turn)
24
+ store = TurnKit.store
25
+ store.list_tool_executions(turn_id: turn.fetch("id")).map do |execution|
26
+ next execution unless %w[pending running].include?(execution.fetch("status"))
27
+
28
+ interrupted = store.claim_tool_execution(
29
+ execution.fetch("id"),
30
+ from: execution.fetch("status"),
31
+ to: "interrupted",
32
+ error: { "message" => "interrupted: worker terminated while the tool was executing" },
33
+ completed_at: Clock.now
34
+ )
35
+ next execution unless interrupted
36
+
37
+ emit("tool_call.interrupted", turn, id: interrupted.fetch("tool_call_id"), name: interrupted.fetch("tool_name"), tool_execution_id: interrupted.fetch("id"))
38
+ interrupted
39
+ end
40
+ end
41
+
42
+ def repair_transcript(turn, executions)
43
+ store = TurnKit.store
44
+ messages = store.list_messages(turn.fetch("conversation_id"))
45
+ resolved = messages
46
+ .select { |message| message["kind"] == "tool_result" }
47
+ .flat_map { |message| message["content"].map { |part| part["tool_call_id"] } }
48
+
49
+ messages
50
+ .select { |message| message["turn_id"] == turn.fetch("id") && message["kind"] == "tool_call" }
51
+ .flat_map { |message| message["content"].select { |part| part["type"] == "tool_call" } }
52
+ .reject { |part| resolved.include?(part["id"]) }
53
+ .each do |part|
54
+ execution = executions.find { |candidate| candidate["tool_call_id"] == part["id"] }
55
+ message = store.append_message(
56
+ "conversation_id" => turn.fetch("conversation_id"),
57
+ "turn_id" => turn.fetch("id"),
58
+ "role" => "tool",
59
+ "kind" => "tool_result",
60
+ "content" => [ { "type" => "tool_result", "tool_call_id" => part["id"], "text" => { "error" => true, "message" => INTERRUPTED_MESSAGE }.to_json, "error" => true } ],
61
+ "tool_execution_id" => execution&.fetch("id"),
62
+ "metadata" => { "tool_name" => part["name"], "interrupted" => true }
63
+ )
64
+ emit("message.created", turn, message_id: message.fetch("id"), role: "tool", kind: "tool_result")
65
+ end
66
+ end
67
+
68
+ def emit(type, turn, payload = {})
69
+ TurnKit.on_event&.call(Event.new(type: type, turn_id: turn.fetch("id"), conversation_id: turn.fetch("conversation_id"), payload: payload))
70
+ end
71
+ end
72
+ end
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  module Record
5
5
  TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
6
- TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
6
+ TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled interrupted].freeze
7
7
 
8
8
  TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
9
9
  TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
data/lib/turnkit/store.rb CHANGED
@@ -20,9 +20,20 @@ module TurnKit
20
20
 
21
21
  def create_tool_execution(_attributes) = raise(NotImplementedError)
22
22
  def load_tool_execution(_id) = raise(NotImplementedError)
23
- def update_tool_execution(_id, _attributes) = raise(NotImplementedError)
23
+ # claim_tool_execution mirrors claim_turn: an atomic compare-and-set on
24
+ # status, so a tool result recorded by a live worker and a reconciler
25
+ # marking the execution interrupted cannot overwrite each other.
26
+ def claim_tool_execution(_id, from: "running", to: "completed", **_attributes) = raise(NotImplementedError)
24
27
  def list_tool_executions(turn_id:) = raise(NotImplementedError)
25
28
 
26
- def find_stale_turns(before:) = []
29
+ # reconcile_stale_turns is the other concurrency-safety point: it must
30
+ # atomically transition each pending/running turn whose stale anchor
31
+ # (heartbeat_at, else started_at, else created_at) is older than `before`
32
+ # to `stale`, rechecking both predicates at write time so a concurrently
33
+ # claimed, heartbeated, or completed turn is never overwritten. Returns
34
+ # the reconciled turn records. Descendant turns of a dead process stop
35
+ # heartbeating too and are reconciled by the same predicate, so no
36
+ # explicit subtree cascade is needed.
37
+ def reconcile_stale_turns(before:) = []
27
38
  end
28
39
  end
@@ -71,7 +71,9 @@ module TurnKit
71
71
 
72
72
  def finish_success(execution, tool_call, payload)
73
73
  json = payload.to_json
74
- attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
74
+ attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "completed", result: payload, completed_at: Clock.now)
75
+ return superseded_execution(execution) unless attrs
76
+
75
77
  append_result(execution, tool_call, payload, json: json, error: false)
76
78
  heartbeat!
77
79
  turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
@@ -81,13 +83,21 @@ module TurnKit
81
83
  def finish_error(execution, tool_call, message, details: nil)
82
84
  error = { "message" => message.to_s, "details" => details }.compact
83
85
  json = error.to_json
84
- attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
86
+ attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "failed", error: error, completed_at: Clock.now)
87
+ return superseded_execution(execution) unless attrs
88
+
85
89
  append_result(execution, tool_call, error, json: json, error: true)
86
90
  heartbeat!
87
91
  turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
88
92
  ToolExecution.new(attrs)
89
93
  end
90
94
 
95
+ # The execution was reconciled (interrupted) while the tool ran; a
96
+ # synthetic result message already exists, so the late result is dropped.
97
+ def superseded_execution(execution)
98
+ ToolExecution.new(turn.store.load_tool_execution(execution.id))
99
+ end
100
+
91
101
  def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
92
102
  message = turn.conversation.append_message(
93
103
  role: "tool",
@@ -104,7 +114,9 @@ module TurnKit
104
114
  calls.each do |call|
105
115
  payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
106
116
  execution = ToolExecution.new(create_execution(call))
107
- attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
117
+ attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "cancelled", result: payload, completed_at: Clock.now)
118
+ next unless attrs
119
+
108
120
  append_result(ToolExecution.new(attrs), call, payload)
109
121
  turn.emit("tool_call.skipped", id: call.id, name: call.name)
110
122
  end
@@ -118,12 +130,24 @@ module TurnKit
118
130
  turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
119
131
  end
120
132
 
133
+ # Heartbeats while the tool runs so a tool slower than TurnKit.timeout
134
+ # keeps its turn's stale anchor fresh and is not falsely reconciled.
121
135
  def call_tool(tool, arguments, context:)
136
+ interval = (TurnKit.timeout || 300) / 3.0
137
+ heartbeat = Thread.new do
138
+ loop do
139
+ sleep interval
140
+ heartbeat!
141
+ end
142
+ end
143
+
122
144
  if tool.is_a?(Class)
123
145
  tool.call(arguments, context: context)
124
146
  else
125
147
  tool.class.invoke(tool, arguments, context: context)
126
148
  end
149
+ ensure
150
+ heartbeat.kill
127
151
  end
128
152
 
129
153
  def normalize_payload(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -33,6 +33,7 @@ require_relative "turnkit/prompt_context"
33
33
  require_relative "turnkit/system_prompt"
34
34
  require_relative "turnkit/store"
35
35
  require_relative "turnkit/memory_store"
36
+ require_relative "turnkit/reconciliation"
36
37
  require_relative "turnkit/compaction"
37
38
  require_relative "turnkit/tool"
38
39
  require_relative "turnkit/image_tool"
@@ -89,9 +90,7 @@ module TurnKit
89
90
  end
90
91
 
91
92
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
92
- store.find_stale_turns(before: before).each do |turn|
93
- store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
94
- end
93
+ Reconciliation.reconcile!(before: before)
95
94
  end
96
95
 
97
96
  def self.check_output_policy(output, constraints: [], context: {})
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-02 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
14
14
  runs, orchestrator agents, tool calling, skills, sub-agents, context compaction,
@@ -58,6 +58,7 @@ files:
58
58
  - lib/turnkit/output_policy.rb
59
59
  - lib/turnkit/prompt_context.rb
60
60
  - lib/turnkit/prompt_data.rb
61
+ - lib/turnkit/reconciliation.rb
61
62
  - lib/turnkit/record.rb
62
63
  - lib/turnkit/result.rb
63
64
  - lib/turnkit/run.rb