@erclx/aitk 3.51.1 → 3.52.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.51.1",
4
+ "version": "3.52.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -5,7 +5,12 @@
5
5
  # holds. This answers "what just changed" on a loop of its own, and it exists
6
6
  # because the two readings a dispatch needs are not one reading: a worker that
7
7
  # finishes goes idle and a worker that crashes vanishes, so a watch matching
8
- # only the pull request list stays silent through the second.
8
+ # only the pull request list stays silent through the second. A third case is
9
+ # neither: a worker that stops on a question or a prompt neither finishes nor
10
+ # crashes, and sits reading "waiting" beside the two statuses that resolve on
11
+ # their own until something reports it, which is the WORKER-STOPPED line
12
+ # below, or WORKER-UNMEASURABLE for a "waiting" row too old to carry a stamp
13
+ # this reads.
9
14
  #
10
15
  # `set -e` is deliberately not set. This runs for hours, and one transient `gh`
11
16
  # failure aborting the loop is a silent stop rather than a reported one. Every
@@ -17,6 +22,28 @@ set -uo pipefail
17
22
  # in a project whose workers run long.
18
23
  INTERVAL=60
19
24
 
25
+ # Seconds a worker can sit in "waiting" before this reports it as stopped
26
+ # rather than folding it into the ordinary status-change lines below. "busy"
27
+ # and "idle" resolve on their own; "waiting" does not, so a dwell that keeps
28
+ # growing there is a session blocked on something outside itself.
29
+ #
30
+ # Measured against the live session registry: 341 usable records carried a
31
+ # status at all, and exactly one carried "waiting", too sparse a sample to fit
32
+ # a distribution. The number is picked from the two bounds the measurement can
33
+ # still name rather than from a round guess: well above INTERVAL, so a handful
34
+ # of passes confirm the row before it reports rather than one slow tool call
35
+ # tripping it, and well inside the ten-to-thirty-minute span a dispatched build
36
+ # ordinarily runs, so a real stall is caught with most of that window still
37
+ # open to act on it.
38
+ #
39
+ # That one record carried `waitingFor: "approve Bash"`, a permission prompt
40
+ # rather than a question, and the two clear on different schedules: a prompt
41
+ # resolves the moment a person approves it, where a question can sit
42
+ # legitimately while a controller finishes a turn. This number is defensible
43
+ # for the second and generous for the first, and the gap stays open rather
44
+ # than splitting the constant on a sample of one.
45
+ STALL_THRESHOLD_S=300
46
+
20
47
  # Resolving the main worktree root rather than this file's folder keeps a watch
21
48
  # started from a linked worktree reading the same repository as one started from
22
49
  # main. The repository field on a roster row is that root's `.git`.
@@ -40,13 +67,19 @@ BASE_BRANCH="${BASE_REF#origin/}"
40
67
  # worker, whoever launched it. The prototype matched the `orchestrator-` prefix
41
68
  # instead, which reads a dispatched worker and misses every hand-launched one,
42
69
  # and those are the ordinary shape whenever the operator is launching.
70
+ #
71
+ # The row goes out tab-separated and comes back read with IFS set to a tab. A
72
+ # name this client actually writes carries spaces, `Update session markdown
73
+ # and check runnable commands (3)` among them, and splitting on whitespace
74
+ # took that one apart into a wrong name, branch, status, and dwell.
43
75
  read_workers() {
44
76
  aitk sessions list --json 2>/dev/null | tail -1 | jq -r \
45
77
  --arg repository "$REPOSITORY" --arg base "$BASE_BRANCH" '
46
78
  .sessions[]?
47
79
  | select(.repository == $repository)
48
80
  | select(.branch != null and .branch != $base)
49
- | "\(.name) \(.branch) \(.status)"
81
+ | [.name, .branch, .status, (.statusDwellMs // -1 | tostring)]
82
+ | @tsv
50
83
  ' 2>/dev/null | sort
51
84
  }
52
85
 
@@ -70,6 +103,15 @@ prev_names=""
70
103
  pulls_seen=0
71
104
  workers_seen=0
72
105
 
106
+ # One row per worker name currently reported, waiting past STALL_THRESHOLD_S
107
+ # or waiting with no stamp to measure, so a reader watching the log is told
108
+ # once rather than on every remaining pass. The prototype for that shape is
109
+ # the WORKER-GONE/WORKER pair above, tracked in the same state a status change
110
+ # is: a name drops out the pass it stops reading "waiting", which lets the
111
+ # same worker report a second stall later in its life rather than being
112
+ # marked forever by its first one.
113
+ declare -A stalled
114
+
73
115
  while true; do
74
116
  pulls="$(read_pulls)"
75
117
  pulls_read=$?
@@ -83,7 +125,7 @@ while true; do
83
125
  echo "watch: the session roster failed to load, so no worker is classified this pass"
84
126
  fi
85
127
 
86
- names="$(printf '%s\n' "$workers" | awk 'NF {print $1}' | sort -u)"
128
+ names="$(printf '%s\n' "$workers" | awk -F'\t' 'NF {print $1}' | sort -u)"
87
129
 
88
130
  if [ "$pulls_seen" -eq 1 ] && [ "$pulls_read" -eq 0 ]; then
89
131
  comm -13 <(printf '%s\n' "$prev_pulls") <(printf '%s\n' "$pulls") | grep . || true
@@ -96,6 +138,35 @@ while true; do
96
138
  grep . | sed 's/^/WORKER-GONE /' || true
97
139
  fi
98
140
 
141
+ if [ "$workers_read" -eq 0 ]; then
142
+ while IFS=$'\t' read -r w_name w_branch w_status w_dwell_ms; do
143
+ [ -z "$w_name" ] && continue
144
+
145
+ if [ "$w_status" != "waiting" ]; then
146
+ unset "stalled[$w_name]"
147
+ continue
148
+ fi
149
+
150
+ if [ "$w_dwell_ms" = "-1" ]; then
151
+ if [ -z "${stalled[$w_name]:-}" ]; then
152
+ echo "WORKER-UNMEASURABLE $w_name $w_branch"
153
+ stalled[$w_name]=1
154
+ fi
155
+ continue
156
+ fi
157
+
158
+ w_dwell_s=$((w_dwell_ms / 1000))
159
+ if [ "$w_dwell_s" -ge "$STALL_THRESHOLD_S" ]; then
160
+ if [ -z "${stalled[$w_name]:-}" ]; then
161
+ echo "WORKER-STOPPED $w_name $w_branch ${w_dwell_s}s"
162
+ stalled[$w_name]=1
163
+ fi
164
+ else
165
+ unset "stalled[$w_name]"
166
+ fi
167
+ done <<<"$workers"
168
+ fi
169
+
99
170
  if [ "$pulls_read" -eq 0 ]; then
100
171
  prev_pulls="$pulls"
101
172
  pulls_seen=1
@@ -105,6 +105,14 @@ Every report states how liveness was decided, on a pass as well as a failure.
105
105
 
106
106
  The registry holds one record per session and is never pruned, so it accumulates thousands of entries. On the `unverified` path a stale record whose pid has been reused reads as live, which is why the field is reported rather than assumed.
107
107
 
108
+ ## The status dwell
109
+
110
+ Every row carries `statusUpdatedAt`, the stamp a client writes beside `status` at the moment it last changed, and `statusDwellMs`, the elapsed milliseconds since that stamp. Measured over the live registry, 23 of 341 usable records carry `statusUpdatedAt`, so `null` is the ordinary answer rather than an edge case, and the absence tracks a client version rather than a record's age alone: the one record ever measured carrying `status: "waiting"` is among the 318 without it.
111
+
112
+ `statusDwellMs` falls back to the coarser `updatedAt` stamp when `statusUpdatedAt` is absent, so it is `null` only where a record carries neither. `statusUpdatedAt` itself is never backfilled from the fallback and stays `null` in that case, since it names the exact stamp rather than an estimate. A stamp ahead of the reading clock clamps the dwell to zero rather than reporting a negative one.
113
+
114
+ The dwell is what separates a status that resolves on its own from one that does not. `busy` and `idle` transition without help, so a long dwell there is ordinary. `waiting` does not: a session in that state is blocked on something outside itself, and a dwell that keeps growing past the ordinary span of a prompt is a session stalled rather than paused. `aitk sessions list` renders the dwell beside the status at the coarsest unit that keeps it a whole number, and the JSON record carries both fields on every row.
115
+
108
116
  ## What the read depends on
109
117
 
110
118
  The records live under the Claude Code configuration directory, which the verb resolves from `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Their location, their filenames, and the fields inside them are a client implementation detail rather than a published interface, so a client change can move them. The verb reports an absent registry as a refusal rather than as a machine running no sessions, which is what surfaces the move instead of burying it in an empty roster.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.51.1",
4
+ "version": "3.52.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -124,6 +124,14 @@ export function register(program: Command): void {
124
124
  'cannot rule out a pid handed to an unrelated process, so a roster',
125
125
  'reported that way is a candidate list rather than an identity.',
126
126
  '',
127
+ 'The JSON record also carries "statusUpdatedAt" (the stamp the client',
128
+ 'wrote beside "status", or null where its record carries none) and',
129
+ '"statusDwellMs" (the elapsed milliseconds since that stamp, falling',
130
+ 'back to the coarser "updatedAt" where the narrower one is absent,',
131
+ 'computed at read time and clamped at zero against clock skew). The',
132
+ 'framed listing renders the same dwell beside the status, at the',
133
+ 'coarsest unit that keeps it a whole number.',
134
+ '',
127
135
  'Examples:',
128
136
  ' aitk sessions list',
129
137
  ' aitk sessions list --json',
@@ -349,12 +357,30 @@ function reportSessions(
349
357
  const held =
350
358
  session.branch ??
351
359
  `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
352
- return `${session.name} ${session.status} ${held}\n ${session.cwd}`
360
+ const dwell = formatDwell(session.statusDwellMs)
361
+ const status = dwell ? `${session.status} ${dwell}` : session.status
362
+ return `${session.name} ${status} ${held}\n ${session.cwd}`
353
363
  })
354
364
  .join('\n'),
355
365
  )
356
366
  }
357
367
 
368
+ /**
369
+ * Renders the dwell at the coarsest unit that keeps it a whole number, since a
370
+ * reader scanning a roster wants an age at a glance rather than a millisecond
371
+ * count. An absent dwell renders as nothing, folding a status carrying no
372
+ * stamp back to the bare status line the reader already knew.
373
+ */
374
+ function formatDwell(ms: number | null): string {
375
+ if (ms === null) return ''
376
+ const seconds = Math.round(ms / 1000)
377
+ if (seconds < 60) return `${seconds}s`
378
+ const minutes = Math.round(seconds / 60)
379
+ if (minutes < 60) return `${minutes}m`
380
+ const hours = Math.round(minutes / 60)
381
+ return `${hours}h`
382
+ }
383
+
358
384
  function reportClaim(claim: ClaimReport): void {
359
385
  logStep('Claim')
360
386
 
@@ -21,6 +21,23 @@ export interface SessionRecord {
21
21
  readonly sessionId: string | undefined
22
22
  readonly kind: string | undefined
23
23
  readonly status: string | undefined
24
+ /**
25
+ * The epoch millisecond the client last changed `status`. Measured against
26
+ * the live registry, 23 of 341 usable records carry it, and the one record
27
+ * that has ever carried `status: "waiting"` is not among them, so its
28
+ * absence tracks a client version rather than a record's age alone.
29
+ * Declared here rather than read opportunistically off the parsed object,
30
+ * since this file is what states what the domain reads and an undeclared
31
+ * field read anyway is the drift this domain exists downstream of.
32
+ */
33
+ readonly statusUpdatedAt: number | undefined
34
+ /**
35
+ * The epoch millisecond the client last wrote the record at all, a coarser
36
+ * stamp than `statusUpdatedAt` that a client writes whether or not it also
37
+ * stamps the status change itself. Declared as the fallback dwell source
38
+ * for a record predating the narrower field, per the same reasoning above.
39
+ */
40
+ readonly updatedAt: number | undefined
24
41
  readonly startedAt: number | undefined
25
42
  /**
26
43
  * The process start time the client stamped at launch, compared against the
@@ -23,6 +23,25 @@ export interface ResolvedSession {
23
23
  readonly kind: string
24
24
  readonly status: string
25
25
  readonly startedAt: string | null
26
+ /**
27
+ * The stamp the record carries beside `status`, kept alongside the dwell it
28
+ * computes below for a caller comparing two sessions rather than reading the
29
+ * age of one.
30
+ */
31
+ readonly statusUpdatedAt: string | null
32
+ /**
33
+ * Elapsed milliseconds since `statusUpdatedAt`, falling back to the coarser
34
+ * `updatedAt` when the record predates the narrower field, computed once at
35
+ * read time so three callers do not each convert the raw stamp and each get
36
+ * the clock-skew case wrong. The one record ever measured carrying
37
+ * `status: "waiting"` has exactly this shape: no `statusUpdatedAt`, an
38
+ * `updatedAt` beside it, so a caller reading this field alone would report
39
+ * the single real instance as unmeasured rather than as stalled.
40
+ * Clamped at zero rather than reported negative: a record stamped
41
+ * by a clock running ahead of this one is a skew to absorb, not a session
42
+ * that has not started waiting yet.
43
+ */
44
+ readonly statusDwellMs: number | null
26
45
  /**
27
46
  * The shared git directory every worktree of one repository resolves to,
28
47
  * which is what identifies the repository a row belongs to. A branch name is
@@ -52,6 +71,8 @@ export interface ResolveOptions {
52
71
  readonly dir?: string
53
72
  readonly probes?: LivenessProbes
54
73
  readonly locate?: (cwd: string) => Promise<Located>
74
+ /** The instant the dwell is computed against. Defaults to `Date.now`. */
75
+ readonly now?: () => number
55
76
  }
56
77
 
57
78
  export interface Located {
@@ -123,6 +144,17 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
123
144
  return resolved.length > 0 ? resolved : null
124
145
  }
125
146
 
147
+ /**
148
+ * Falls back to `updatedAt` when `statusUpdatedAt` is absent, since the
149
+ * narrower field is the newer of the two and a record predating it still
150
+ * carries the coarser one. The one record measured with `status: "waiting"`
151
+ * takes exactly this path: no `statusUpdatedAt`, an `updatedAt` beside it.
152
+ */
153
+ function dwellMs(record: SessionRecord, now: number): number | null {
154
+ const stamp = record.statusUpdatedAt ?? record.updatedAt
155
+ return stamp === undefined ? null : Math.max(0, now - stamp)
156
+ }
157
+
126
158
  /**
127
159
  * An absent field is rendered as an absence rather than as a value.
128
160
  *
@@ -131,7 +163,11 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
131
163
  * for data. Null says the record did not carry it, which is the same
132
164
  * distinction the registry draws between an absent folder and an empty one.
133
165
  */
134
- function present(record: SessionRecord, located: Located): ResolvedSession {
166
+ function present(
167
+ record: SessionRecord,
168
+ located: Located,
169
+ now: number,
170
+ ): ResolvedSession {
135
171
  return {
136
172
  name: record.name,
137
173
  pid: record.pid,
@@ -143,6 +179,11 @@ function present(record: SessionRecord, located: Located): ResolvedSession {
143
179
  record.startedAt === undefined
144
180
  ? null
145
181
  : new Date(record.startedAt).toISOString(),
182
+ statusUpdatedAt:
183
+ record.statusUpdatedAt === undefined
184
+ ? null
185
+ : new Date(record.statusUpdatedAt).toISOString(),
186
+ statusDwellMs: dwellMs(record, now),
146
187
  repository: located.repository,
147
188
  worktree: located.worktree,
148
189
  branch: located.branch,
@@ -167,6 +208,7 @@ export async function resolveSessions(
167
208
  ): Promise<SessionReport> {
168
209
  const probes = opts.probes ?? SYSTEM_PROBES
169
210
  const find = opts.locate ?? locate
211
+ const now = opts.now?.() ?? Date.now()
170
212
  const registry = readRegistry(opts.dir)
171
213
 
172
214
  if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
@@ -182,7 +224,7 @@ export async function resolveSessions(
182
224
  }
183
225
 
184
226
  const sessions = await Promise.all(
185
- live.map(async (record) => present(record, await find(record.cwd))),
227
+ live.map(async (record) => present(record, await find(record.cwd), now)),
186
228
  )
187
229
 
188
230
  return { kind: 'resolved', dir: registry.dir, confidence, sessions }