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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +15 -1
- data/lib/turnkit/active_record_store.rb +16 -6
- data/lib/turnkit/memory_store.rb +11 -6
- data/lib/turnkit/reconciliation.rb +72 -0
- data/lib/turnkit/record.rb +1 -1
- data/lib/turnkit/store.rb +13 -2
- data/lib/turnkit/tool_runner.rb +27 -3
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +2 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3db08f0653a468f22daa74070c7b1a290f02b44beaf6d35c950f3d36d86dc224
|
|
4
|
+
data.tar.gz: 18d3b2df593865ba53e0863d0e2b061f8744bdbbf78409b40d525c3ba47b5070
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
158
|
-
turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
|
|
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
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -100,10 +100,12 @@ module TurnKit
|
|
|
100
100
|
@mutex.synchronize { duplicate(@tool_executions.fetch(id)) }
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
def
|
|
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
|
|
123
|
+
def reconcile_stale_turns(before:)
|
|
122
124
|
@mutex.synchronize do
|
|
123
|
-
@turns.values.
|
|
124
|
-
%w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
|
|
125
|
-
|
|
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
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)
|
data/lib/turnkit/version.rb
CHANGED
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
|
-
|
|
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
|
+
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-
|
|
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
|