carson 3.8.0 → 3.9.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: ec88c12e0edb3541ab43fbefd93c75ef87dfe6032217c1132f6e3cc9857428cf
4
- data.tar.gz: cba06c9b0c85e0884157e6e2794a58140cbfd903da6eb61d9eb1e96f2f182860
3
+ metadata.gz: ffbada076dd17e9f8ae9a85e9abce9abbc84e23b71324c8cde1ffec1db537071
4
+ data.tar.gz: 4654c5d248f856818ffa83600b4db21838c0f665bb6a16f2d45d566643312ec6
5
5
  SHA512:
6
- metadata.gz: 33367440b238d8ba9872aac28da62a10bd74aa6179398bde682860ea798851af3943d9f932a5a157b0913449fa82c9961800f0d832a065fe73b6f9b902a148d6
7
- data.tar.gz: 4fc299aa1cc6b15f0a7498659912395691213fdfd2d778594a4c6ba52ca9ad77f87db810dc91bdb4a47d1f4aa34a2f7c375d62a5b0d9a481847189798cc36e5d
6
+ metadata.gz: b4e517ccc14a75a2389bf5743fdd095b2796605c5c7fbeb4d12af4b0f237f370a275e2ff244436da4439b10e44b38a7aceae44c26b858d2224a8b0a25b1d193f
7
+ data.tar.gz: a1de0764868ee6fda99d4f74f5ec10dfc2071a84e65102ab77749e83a8307aee37aa661d98b3670e1d62aeda28f1be1a05093283ac697de3a39ad542256e6da9
data/RELEASE.md CHANGED
@@ -5,6 +5,26 @@ 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.9.0 — Agent Coordination Signals
9
+
10
+ ### What changed
11
+
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **`session_list`** — new method that scans all session files for the current repo and returns structured data with staleness annotations.
16
+ - **Migration from 3.8 format** — old single-file session state (`<slug>.json`) is automatically migrated to the per-session directory format on first access.
17
+
18
+ ### UX
19
+
20
+ - `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.
21
+ - `carson session --json` now includes `session_id` and `pid` fields for correlation by other agents.
22
+ - Human output unchanged when no session ownership data exists.
23
+
24
+ ### Migration
25
+
26
+ - Automatic. The old `<slug>.json` file is moved into the new `<slug>/` directory as `migrated.json` on first access. No user action required.
27
+
8
28
  ## 3.8.0 — Session State
9
29
 
10
30
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.8.0
1
+ 3.9.0
@@ -1,6 +1,7 @@
1
1
  # Session state persistence for coding agents.
2
- # Maintains a lightweight JSON file per repository in ~/.carson/sessions/ so
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 this repository.
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 the session file path for this repository.
65
- def session_file_path
66
- sessions_dir = File.join( carson_home, "sessions" )
67
- FileUtils.mkdir_p( sessions_dir )
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( sessions_dir, "#{slug}.json" )
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 lifecycle state.
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: File.basename( wt.fetch( :path ) ),
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
- puts_line " #{wt.fetch( :name )} #{branch_label}"
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.0
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang