@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.
|
@@ -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
|
-
|
|
|
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
|
package/docs/agents/sessions.md
CHANGED
|
@@ -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
package/src/commands/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -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(
|
|
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 }
|