carson 3.8.0 → 3.10.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/RELEASE.md +36 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/local/worktree.rb +22 -0
- data/lib/carson/runtime/session.rb +77 -10
- data/lib/carson/runtime/status.rb +53 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0a28061ca9078e4ba0cf70626b209da67a9b85b6af31414d2a98e7f7e7abd36
|
|
4
|
+
data.tar.gz: de84e7e100d34a35ca9d844e34235eaf2e5b9dcf5071d2b651f28ee4bf83d3e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42ac93cfe9c110636f1327e2cf0a1cbd1fc4ad1cb258d745e3c487984153f95e3ec5ebe70016214d8f8a63de3ee13c8a248afedd6a6710277019011f5cf995d2
|
|
7
|
+
data.tar.gz: a64696f982dbe5947a333cf0fd2c94744a739b26dde0a60593f322a368ea9a6b371e6901007c762c012a810fbcb2702fcfc23d8b9543c51ffa966d4a83b97b6f
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,42 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.10.0 — CWD Safety Guard
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **CWD-inside-worktree guard** — `carson worktree remove` now detects when the caller's working directory is inside the worktree being removed, and blocks with `EXIT_BLOCK` instead of proceeding. This eliminates the #1 agent session crash scenario: removing a worktree directory while the shell is inside it kills the shell permanently.
|
|
13
|
+
- **Recovery command** — the block message includes the exact recovery: `cd <repo_root> && carson worktree remove <name>`.
|
|
14
|
+
|
|
15
|
+
### UX
|
|
16
|
+
|
|
17
|
+
- No change when the caller's CWD is outside the worktree — removal proceeds normally.
|
|
18
|
+
- When blocked, the error is clear and the recovery is one command.
|
|
19
|
+
|
|
20
|
+
### Migration
|
|
21
|
+
|
|
22
|
+
- No breaking changes. New safety guard — previously dangerous operations now fail safely.
|
|
23
|
+
|
|
24
|
+
## 3.9.0 — Agent Coordination Signals
|
|
25
|
+
|
|
26
|
+
### What changed
|
|
27
|
+
|
|
28
|
+
- **Per-session state files** — each Runtime instance gets a unique session ID (`<pid>-<timestamp>`) and its own session file at `~/.carson/sessions/<repo_slug>/<session_id>.json`. Multiple agents on the same repo each have independent state that does not collide.
|
|
29
|
+
- **Session ownership on worktrees** — `carson status` cross-references active session files to annotate each worktree with its owning session's PID, task, and staleness. Other agents can see which worktrees are in use and by whom.
|
|
30
|
+
- **Staleness detection** — sessions whose PID is no longer running and whose last update is older than 1 hour are marked as `stale`. This signals to other agents that the worktree may be abandoned and available for cleanup.
|
|
31
|
+
- **`session_list`** — new method that scans all session files for the current repo and returns structured data with staleness annotations.
|
|
32
|
+
- **Migration from 3.8 format** — old single-file session state (`<slug>.json`) is automatically migrated to the per-session directory format on first access.
|
|
33
|
+
|
|
34
|
+
### UX
|
|
35
|
+
|
|
36
|
+
- `carson status` worktree listing now shows owner context: task description if set, PID if no task, or "stale session" if the owner is no longer running.
|
|
37
|
+
- `carson session --json` now includes `session_id` and `pid` fields for correlation by other agents.
|
|
38
|
+
- Human output unchanged when no session ownership data exists.
|
|
39
|
+
|
|
40
|
+
### Migration
|
|
41
|
+
|
|
42
|
+
- Automatic. The old `<slug>.json` file is moved into the new `<slug>/` directory as `migrated.json` on first access. No user action required.
|
|
43
|
+
|
|
8
44
|
## 3.8.0 — Session State
|
|
9
45
|
|
|
10
46
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.10.0
|
|
@@ -133,6 +133,17 @@ module Carson
|
|
|
133
133
|
)
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# Safety: refuse if the caller's shell CWD is inside the worktree.
|
|
137
|
+
# Removing a directory while a shell is inside it kills the shell permanently.
|
|
138
|
+
if cwd_inside_worktree?( worktree_path: resolved_path )
|
|
139
|
+
return worktree_finish(
|
|
140
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
141
|
+
error: "current working directory is inside this worktree",
|
|
142
|
+
recovery: "cd #{repo_root} && carson worktree remove #{File.basename( resolved_path )}" },
|
|
143
|
+
exit_code: EXIT_BLOCK, json_output: json_output
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
136
147
|
branch = worktree_branch( path: resolved_path )
|
|
137
148
|
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
138
149
|
|
|
@@ -235,6 +246,17 @@ module Carson
|
|
|
235
246
|
end
|
|
236
247
|
end
|
|
237
248
|
|
|
249
|
+
# Returns true when the process CWD is inside the given worktree path.
|
|
250
|
+
# This detects the most common session-crash scenario: removing a worktree
|
|
251
|
+
# while the caller's shell is inside it.
|
|
252
|
+
def cwd_inside_worktree?( worktree_path: )
|
|
253
|
+
cwd = Dir.pwd
|
|
254
|
+
normalised_wt = File.join( worktree_path, "" )
|
|
255
|
+
cwd == worktree_path || cwd.start_with?( normalised_wt )
|
|
256
|
+
rescue StandardError
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
238
260
|
# Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
|
|
239
261
|
def resolve_worktree_path( worktree_path: )
|
|
240
262
|
return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Session state persistence for coding agents.
|
|
2
|
-
# Maintains a lightweight JSON file per
|
|
3
|
-
# agents can discover the current working context without re-running discovery commands.
|
|
2
|
+
# Maintains a lightweight JSON file per session in ~/.carson/sessions/<repo_slug>/
|
|
3
|
+
# so agents can discover the current working context without re-running discovery commands.
|
|
4
|
+
# Multiple agents on the same repo each get their own session file.
|
|
4
5
|
# Respects the outsider boundary: state lives in Carson's own space, not the repository.
|
|
5
6
|
require "digest"
|
|
6
7
|
|
|
@@ -25,7 +26,7 @@ module Carson
|
|
|
25
26
|
)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
# Clears session state for
|
|
29
|
+
# Clears session state for the current session.
|
|
29
30
|
def session_clear!( json_output: false )
|
|
30
31
|
path = session_file_path
|
|
31
32
|
File.delete( path ) if File.exist?( path )
|
|
@@ -54,19 +55,55 @@ module Carson
|
|
|
54
55
|
|
|
55
56
|
state[ :task ] = task if task
|
|
56
57
|
state[ :repo ] = repo_root
|
|
58
|
+
state[ :session_id ] = session_id
|
|
59
|
+
state[ :pid ] = Process.pid
|
|
57
60
|
state[ :updated_at ] = Time.now.utc.iso8601
|
|
58
61
|
|
|
59
62
|
write_session( state )
|
|
60
63
|
end
|
|
61
64
|
|
|
65
|
+
# Returns all active sessions for this repository.
|
|
66
|
+
# Each entry is a parsed session state hash with staleness annotation.
|
|
67
|
+
def session_list
|
|
68
|
+
dir = session_repo_dir
|
|
69
|
+
return [] unless Dir.exist?( dir )
|
|
70
|
+
|
|
71
|
+
Dir.glob( File.join( dir, "*.json" ) ).filter_map do |path|
|
|
72
|
+
data = JSON.parse( File.read( path ), symbolize_names: true ) rescue next
|
|
73
|
+
data[ :stale ] = session_stale?( data )
|
|
74
|
+
data
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
private
|
|
63
79
|
|
|
64
|
-
# Returns
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
# Returns a stable session identifier for this Runtime instance.
|
|
81
|
+
# PID + start timestamp — unique per process, stable across calls.
|
|
82
|
+
def session_id
|
|
83
|
+
@session_id ||= "#{Process.pid}-#{Time.now.utc.strftime( '%Y%m%d%H%M%S' )}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the per-repo session directory: ~/.carson/sessions/<slug>/
|
|
87
|
+
# Migrates from the pre-3.9 single-file format if found.
|
|
88
|
+
def session_repo_dir
|
|
68
89
|
slug = session_repo_slug
|
|
69
|
-
File.join(
|
|
90
|
+
dir = File.join( carson_home, "sessions", slug )
|
|
91
|
+
|
|
92
|
+
# Migrate from pre-3.9 single-file format: <slug>.json → <slug>/<session_id>.json
|
|
93
|
+
old_file = File.join( carson_home, "sessions", "#{slug}.json" )
|
|
94
|
+
if File.file?( old_file ) && !Dir.exist?( dir )
|
|
95
|
+
FileUtils.mkdir_p( dir )
|
|
96
|
+
migrated = File.join( dir, "migrated.json" )
|
|
97
|
+
FileUtils.mv( old_file, migrated )
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
FileUtils.mkdir_p( dir )
|
|
101
|
+
dir
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns the session file path for the current session.
|
|
105
|
+
def session_file_path
|
|
106
|
+
File.join( session_repo_dir, "#{session_id}.json" )
|
|
70
107
|
end
|
|
71
108
|
|
|
72
109
|
# Generates a readable, unique slug for the repository: basename-shortsha.
|
|
@@ -87,13 +124,14 @@ module Carson
|
|
|
87
124
|
# Reads session state from disk. Returns an empty hash if no state exists.
|
|
88
125
|
def read_session
|
|
89
126
|
path = session_file_path
|
|
90
|
-
return { repo: repo_root } unless File.exist?( path )
|
|
127
|
+
return { repo: repo_root, session_id: session_id } unless File.exist?( path )
|
|
91
128
|
|
|
92
129
|
data = JSON.parse( File.read( path ), symbolize_names: true )
|
|
93
130
|
data[ :repo ] = repo_root
|
|
131
|
+
data[ :session_id ] = session_id
|
|
94
132
|
data
|
|
95
133
|
rescue JSON::ParserError, StandardError
|
|
96
|
-
{ repo: repo_root }
|
|
134
|
+
{ repo: repo_root, session_id: session_id }
|
|
97
135
|
end
|
|
98
136
|
|
|
99
137
|
# Writes session state to disk as formatted JSON.
|
|
@@ -112,6 +150,35 @@ module Carson
|
|
|
112
150
|
end
|
|
113
151
|
end
|
|
114
152
|
|
|
153
|
+
# Detects whether a session is stale — PID no longer running and updated
|
|
154
|
+
# more than 1 hour ago.
|
|
155
|
+
def session_stale?( data )
|
|
156
|
+
pid = data[ :pid ]
|
|
157
|
+
updated = data[ :updated_at ]
|
|
158
|
+
|
|
159
|
+
# If PID is still running, not stale.
|
|
160
|
+
if pid
|
|
161
|
+
begin
|
|
162
|
+
Process.kill( 0, pid.to_i )
|
|
163
|
+
return false
|
|
164
|
+
rescue Errno::ESRCH
|
|
165
|
+
# Process not running — check age.
|
|
166
|
+
rescue Errno::EPERM
|
|
167
|
+
# Process exists but we lack permission — assume active.
|
|
168
|
+
return false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# If no timestamp, assume stale.
|
|
173
|
+
return true unless updated
|
|
174
|
+
|
|
175
|
+
# Stale if last updated more than 1 hour ago.
|
|
176
|
+
age = Time.now.utc - Time.parse( updated )
|
|
177
|
+
age > 3600
|
|
178
|
+
rescue StandardError
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
115
182
|
# Unified output for session results — JSON or human-readable.
|
|
116
183
|
def session_finish( result:, exit_code:, json_output: )
|
|
117
184
|
result[ :exit_code ] = exit_code
|
|
@@ -81,17 +81,47 @@ module Carson
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Lists all worktrees with branch and
|
|
84
|
+
# Lists all worktrees with branch, lifecycle state, and session ownership.
|
|
85
85
|
def gather_worktree_info
|
|
86
86
|
entries = worktree_list
|
|
87
|
+
sessions = session_list
|
|
88
|
+
ownership = build_worktree_ownership( sessions: sessions )
|
|
89
|
+
|
|
87
90
|
# Filter out the main worktree (the repository root itself).
|
|
88
91
|
entries.reject { |wt| wt.fetch( :path ) == repo_root }.map do |wt|
|
|
89
|
-
|
|
92
|
+
name = File.basename( wt.fetch( :path ) )
|
|
93
|
+
info = {
|
|
90
94
|
path: wt.fetch( :path ),
|
|
91
|
-
name:
|
|
95
|
+
name: name,
|
|
92
96
|
branch: wt.fetch( :branch, nil )
|
|
93
97
|
}
|
|
98
|
+
owner = ownership[ name ]
|
|
99
|
+
if owner
|
|
100
|
+
info[ :owner ] = owner[ :session_id ]
|
|
101
|
+
info[ :owner_pid ] = owner[ :pid ]
|
|
102
|
+
info[ :owner_task ] = owner[ :task ]
|
|
103
|
+
info[ :stale ] = owner[ :stale ]
|
|
104
|
+
end
|
|
105
|
+
info
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Builds a name-to-session mapping for worktree ownership.
|
|
110
|
+
def build_worktree_ownership( sessions: )
|
|
111
|
+
result = {}
|
|
112
|
+
sessions.each do |session|
|
|
113
|
+
wt = session[ :worktree ]
|
|
114
|
+
next unless wt
|
|
115
|
+
name = wt[ :name ] || wt[ "name" ]
|
|
116
|
+
next unless name
|
|
117
|
+
result[ name ] = {
|
|
118
|
+
session_id: session[ :session_id ] || session[ "session_id" ],
|
|
119
|
+
pid: session[ :pid ] || session[ "pid" ],
|
|
120
|
+
task: session[ :task ] || session[ "task" ],
|
|
121
|
+
stale: session[ :stale ]
|
|
122
|
+
}
|
|
94
123
|
end
|
|
124
|
+
result
|
|
95
125
|
end
|
|
96
126
|
|
|
97
127
|
# Queries open PRs via gh.
|
|
@@ -177,7 +207,8 @@ module Carson
|
|
|
177
207
|
puts_line "Worktrees:"
|
|
178
208
|
worktrees.each do |wt|
|
|
179
209
|
branch_label = wt.fetch( :branch ) || "(detached)"
|
|
180
|
-
|
|
210
|
+
owner_label = format_worktree_owner( worktree: wt )
|
|
211
|
+
puts_line " #{wt.fetch( :name )} #{branch_label}#{owner_label}"
|
|
181
212
|
end
|
|
182
213
|
end
|
|
183
214
|
|
|
@@ -211,6 +242,24 @@ module Carson
|
|
|
211
242
|
end
|
|
212
243
|
end
|
|
213
244
|
|
|
245
|
+
# Formats owner annotation for a worktree entry.
|
|
246
|
+
def format_worktree_owner( worktree: )
|
|
247
|
+
owner = worktree[ :owner ]
|
|
248
|
+
return "" unless owner
|
|
249
|
+
|
|
250
|
+
stale = worktree[ :stale ]
|
|
251
|
+
task = worktree[ :owner_task ]
|
|
252
|
+
pid = worktree[ :owner_pid ]
|
|
253
|
+
|
|
254
|
+
if stale
|
|
255
|
+
" (stale session #{pid})"
|
|
256
|
+
elsif task
|
|
257
|
+
" (#{task})"
|
|
258
|
+
else
|
|
259
|
+
" (session #{pid})"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
214
263
|
# Formats sync status for display.
|
|
215
264
|
def format_sync( sync: )
|
|
216
265
|
case sync
|