jackherd 1.12.1 → 1.12.3
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.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/decision.ts +35 -2
- package/kit/workflow/skills/orchestrate/SKILL.md +7 -1
- package/kit/workflow/skills/orchestrate/references/events.md +4 -2
- package/package.json +1 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/task-signals/command.ts +12 -3
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +6 -0
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- package/src/plans/next-rows.ts +0 -12
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { errorText } from './error-text'
|
|
3
|
+
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting } from './plan-chips'
|
|
4
|
+
import type { Snapshot, Source } from './snapshot'
|
|
5
|
+
import { writeSprintPageInSteps } from './sprint-export'
|
|
6
|
+
import { boardTexts } from './texts'
|
|
7
|
+
|
|
8
|
+
/** The sprint page last written, a write still running, and why the last one failed. */
|
|
9
|
+
export type PageState = { page: KnownPage | null; writing: boolean; error: string | null }
|
|
10
|
+
|
|
11
|
+
export const initialPageState = (): PageState => ({ page: null, writing: false, error: null })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Writes the sprint page, so a plan chip's link points at a file that exists. Only when the plans `read` holds changed
|
|
15
|
+
* or the file is gone: building it takes seconds, with the event loop blocked for all of them. `whole` gives the whole
|
|
16
|
+
* board to build it from, `read` itself unless it shows one lane. True when it wrote or failed, so the owner draws again.
|
|
17
|
+
*/
|
|
18
|
+
export const syncSprintPage = async (
|
|
19
|
+
state: PageState,
|
|
20
|
+
source: Source,
|
|
21
|
+
read: Snapshot,
|
|
22
|
+
whole: () => Promise<Snapshot>,
|
|
23
|
+
): Promise<boolean> => {
|
|
24
|
+
const key = pageKey(read)
|
|
25
|
+
if (state.writing || !pageNeedsWriting(state.page, key, state.page !== null && existsSync(state.page.path))) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
state.writing = true
|
|
29
|
+
try {
|
|
30
|
+
const written = await writeSprintPageInSteps(source, await whole())
|
|
31
|
+
state.error = 'error' in written ? boardTexts(source.settings.board.term).pageNotWritten(written.error) : null
|
|
32
|
+
state.page = pageAfterWrite(state.page, written, key)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
state.error = boardTexts(source.settings.board.term).pageNotWritten(errorText(error))
|
|
35
|
+
} finally {
|
|
36
|
+
state.writing = false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { PHASE_WORK, type PlanPhase, phaseWorkFields } from '../plan-format'
|
|
2
2
|
|
|
3
3
|
/** What a lane has done in its own worktree: commits ahead of the base ref (null when unknown) and changed files. */
|
|
4
4
|
export type OwnWork = { ahead: number | null; dirty: number }
|
|
@@ -56,7 +56,10 @@ export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase
|
|
|
56
56
|
*/
|
|
57
57
|
const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase => {
|
|
58
58
|
if (basePhase && sameTicks(phase, basePhase)) return phase
|
|
59
|
-
return
|
|
59
|
+
return {
|
|
60
|
+
...phase,
|
|
61
|
+
...phaseWorkFields(basePhase && tickCount(basePhase) > 0 ? PHASE_WORK.merged : PHASE_WORK.touched),
|
|
62
|
+
}
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { stampOf } from '../shared/stat-cache'
|
|
4
|
+
import type { LaneMemory } from './current-phase'
|
|
5
|
+
import { errorText } from './error-text'
|
|
6
|
+
import {
|
|
7
|
+
type FetchState,
|
|
8
|
+
fetchBase,
|
|
9
|
+
fetchNotice,
|
|
10
|
+
fetchPlanHeads,
|
|
11
|
+
goneLanePullRequests,
|
|
12
|
+
initialFetchState,
|
|
13
|
+
shouldFetch,
|
|
14
|
+
watchedFiles,
|
|
15
|
+
} from './fetch'
|
|
16
|
+
import { fetchHeadPath } from './git'
|
|
17
|
+
import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
|
|
18
|
+
import { recallLanes, rememberLanes } from './herd-memory'
|
|
19
|
+
import { planPullRequests } from './plan-heads'
|
|
20
|
+
import { newReadCache, type ReadCache } from './read-cache'
|
|
21
|
+
import { quietRoundsAfter, REFRESH_PACE, refreshDelay } from './refresh-pace'
|
|
22
|
+
import { createRefreshSchedule, type RefreshSchedule } from './refresh-schedule'
|
|
23
|
+
import { BASE_REMOTE, baseRefOf, CLOCK_LOCALE } from './settings'
|
|
24
|
+
import { singleFlight } from './single-flight'
|
|
25
|
+
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
26
|
+
import { isOwnFetchWrite } from './watch-burst'
|
|
27
|
+
|
|
28
|
+
/** What one read gave: the snapshot, or why it failed. */
|
|
29
|
+
export type ReadOutcome = { read: Snapshot } | { error: string }
|
|
30
|
+
|
|
31
|
+
/** What the reader's owner does with a read; true when it changed what the owner shows, which keeps the pace up. */
|
|
32
|
+
export type OnRead = (outcome: ReadOutcome) => boolean
|
|
33
|
+
|
|
34
|
+
/** A running reader: what it knows besides the snapshot, a read asked for at once, and its end. */
|
|
35
|
+
export type Reader = {
|
|
36
|
+
/** The pull requests of the last GitHub read. */
|
|
37
|
+
pullRequests: () => PullRequestState
|
|
38
|
+
/** The footer line after a failed fetch of the base branch, null while the last one worked. */
|
|
39
|
+
fetchLine: () => string | null
|
|
40
|
+
/** Reads soon, bundled with other asks (`RefreshSchedule`). */
|
|
41
|
+
schedule: () => void
|
|
42
|
+
stop: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The state of a running reader; only the functions below `startReader` change it. */
|
|
46
|
+
type ReaderState = {
|
|
47
|
+
source: Source
|
|
48
|
+
onRead: OnRead
|
|
49
|
+
pullRequests: PullRequestState
|
|
50
|
+
fetch: FetchState
|
|
51
|
+
watchers: Map<string, FSWatcher>
|
|
52
|
+
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
53
|
+
fetchHead: string | null
|
|
54
|
+
/** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
|
|
55
|
+
memories: Map<string, LaneMemory>
|
|
56
|
+
/** Session logs, plans and git answers kept from the last read while they did not change. */
|
|
57
|
+
cache: ReadCache
|
|
58
|
+
flight: (() => Promise<void>) | null
|
|
59
|
+
/** The reads in a row that changed nothing the owner shows; the next one waits `refreshDelay` of it. */
|
|
60
|
+
quietRounds: number
|
|
61
|
+
stopped: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PULL_REQUEST_REFRESH_MS = 60_000
|
|
65
|
+
|
|
66
|
+
/** Where the hooks write what agents report (`jh task`, open questions, sent files): a change there is read at once. */
|
|
67
|
+
const RECORDS_DIRECTORY = '.jackherd'
|
|
68
|
+
|
|
69
|
+
/** When `file` was last written (epoch milliseconds); 0 when it cannot be stat-ed, which no fetch of the herd claims. */
|
|
70
|
+
const modifiedAt = (file: string): number => stampOf(file)?.mtimeMs ?? 0
|
|
71
|
+
|
|
72
|
+
const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: (file: string) => void) => {
|
|
73
|
+
for (const [file, watcher] of watchers) {
|
|
74
|
+
if (files.includes(file)) continue
|
|
75
|
+
watcher.close()
|
|
76
|
+
watchers.delete(file)
|
|
77
|
+
}
|
|
78
|
+
for (const file of files.filter((candidate) => !watchers.has(candidate))) {
|
|
79
|
+
try {
|
|
80
|
+
const watcher = watch(file, (event) => {
|
|
81
|
+
if (event === 'rename') {
|
|
82
|
+
watcher.close()
|
|
83
|
+
watchers.delete(file)
|
|
84
|
+
}
|
|
85
|
+
onChange(file)
|
|
86
|
+
})
|
|
87
|
+
watchers.set(file, watcher)
|
|
88
|
+
} catch {
|
|
89
|
+
// A file that cannot be watched right now is retried on the next refresh, which also covers it meanwhile.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Watches the hooks' records below the checkout, so a reported task or question shows without waiting for the pace. */
|
|
95
|
+
const watchRecords = (root: string, onChange: () => void): FSWatcher | null => {
|
|
96
|
+
try {
|
|
97
|
+
return watch(join(root, RECORDS_DIRECTORY), { recursive: true }, onChange)
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const readOnce = async (state: ReaderState, schedule: RefreshSchedule) => {
|
|
104
|
+
schedule.refreshStarted()
|
|
105
|
+
let outcome: ReadOutcome
|
|
106
|
+
try {
|
|
107
|
+
const read = await loadSnapshot(state.source, state.pullRequests, state.memories, state.cache)
|
|
108
|
+
rememberLanes(state.source.root, state.memories, read.documents.laneOrder)
|
|
109
|
+
if (!state.stopped) syncWatchers(state.watchers, watchedFiles(read.files, state.fetchHead), schedule.onFileChange)
|
|
110
|
+
outcome = { read }
|
|
111
|
+
} catch (error) {
|
|
112
|
+
outcome = { error: errorText(error) }
|
|
113
|
+
}
|
|
114
|
+
if (state.stopped) return
|
|
115
|
+
state.quietRounds = quietRoundsAfter(state.quietRounds, state.onRead(outcome))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A read never overlaps another: one asked for meanwhile runs once after the running one, so no older read wins. */
|
|
119
|
+
const refresh = (state: ReaderState, schedule: RefreshSchedule): Promise<void> => {
|
|
120
|
+
state.flight ??= singleFlight(() => readOnce(state, schedule))
|
|
121
|
+
return state.flight()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reads the board, its plans and the lanes' state for `source` and hands each read to `onRead`: at once, then at the
|
|
126
|
+
* pace of `refreshDelay` (faster while reads change what the owner shows), at once when a watched plan file, this
|
|
127
|
+
* clone's `FETCH_HEAD` or a hook record changes, and after a fetch of the base branch. It reads GitHub's pull requests
|
|
128
|
+
* every minute and fetches the base branch when due, both as the herd always did. The herd runs one for itself, the
|
|
129
|
+
* watcher one for every herd of the board.
|
|
130
|
+
*/
|
|
131
|
+
export const startReader = (source: Source, onRead: OnRead): Reader => {
|
|
132
|
+
/** The lanes' branches at the last read: a pull request of one of them that is gone asks for a fetch. */
|
|
133
|
+
const laneBranches = new Set<string>()
|
|
134
|
+
const state: ReaderState = {
|
|
135
|
+
source,
|
|
136
|
+
onRead: (outcome) => {
|
|
137
|
+
if ('read' in outcome) {
|
|
138
|
+
laneBranches.clear()
|
|
139
|
+
for (const { status } of outcome.read.views) if (status.branch) laneBranches.add(status.branch)
|
|
140
|
+
}
|
|
141
|
+
return onRead(outcome)
|
|
142
|
+
},
|
|
143
|
+
pullRequests: noPullRequests(),
|
|
144
|
+
fetch: initialFetchState(),
|
|
145
|
+
watchers: new Map(),
|
|
146
|
+
fetchHead: fetchHeadPath(source.root),
|
|
147
|
+
memories: recallLanes(source.root),
|
|
148
|
+
cache: newReadCache(),
|
|
149
|
+
flight: null,
|
|
150
|
+
quietRounds: 0,
|
|
151
|
+
stopped: false,
|
|
152
|
+
}
|
|
153
|
+
const timers: { poll: Timer | null } = { poll: null }
|
|
154
|
+
const schedule: RefreshSchedule = createRefreshSchedule({
|
|
155
|
+
now: Date.now,
|
|
156
|
+
setTimer: (run, delayMs) => void setTimeout(() => state.stopped || run(), delayMs),
|
|
157
|
+
refresh: () => void refresh(state, schedule),
|
|
158
|
+
isOwnFetchWrite: (file) => file === state.fetchHead && isOwnFetchWrite(modifiedAt(file), state.fetch),
|
|
159
|
+
})
|
|
160
|
+
const fetchBaseBranch = async () => {
|
|
161
|
+
state.fetch = { ...state.fetch, lastAttempt: Date.now(), running: true }
|
|
162
|
+
const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
|
|
163
|
+
const planHeads = planPullRequests([...state.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
164
|
+
if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
|
|
165
|
+
state.fetch = {
|
|
166
|
+
...state.fetch,
|
|
167
|
+
running: false,
|
|
168
|
+
failure,
|
|
169
|
+
lastSuccess: failure === null ? new Date() : state.fetch.lastSuccess,
|
|
170
|
+
}
|
|
171
|
+
schedule.schedule()
|
|
172
|
+
}
|
|
173
|
+
/** Starts a fetch when one is due; true when it did, whose end then refreshes. */
|
|
174
|
+
const fetchWhenDue = (pullRequestGone: boolean): boolean => {
|
|
175
|
+
const due = shouldFetch({
|
|
176
|
+
now: Date.now(),
|
|
177
|
+
state: state.fetch,
|
|
178
|
+
intervalMinutes: source.settings.fetchIntervalMinutes,
|
|
179
|
+
pullRequestGone,
|
|
180
|
+
})
|
|
181
|
+
if (due) void fetchBaseBranch()
|
|
182
|
+
return due
|
|
183
|
+
}
|
|
184
|
+
const refreshPullRequests = async () => {
|
|
185
|
+
const previous = state.pullRequests
|
|
186
|
+
state.pullRequests = await fetchPullRequests(source.root)
|
|
187
|
+
if (state.stopped) return
|
|
188
|
+
const readable = previous.errors.length === 0 && state.pullRequests.errors.length === 0
|
|
189
|
+
const gone = goneLanePullRequests(previous.byBranch, state.pullRequests.byBranch, laneBranches)
|
|
190
|
+
if (!fetchWhenDue(readable && gone.length > 0)) schedule.schedule()
|
|
191
|
+
}
|
|
192
|
+
const poll = () => {
|
|
193
|
+
if (state.stopped) return
|
|
194
|
+
fetchWhenDue(false)
|
|
195
|
+
void refresh(state, schedule).finally(() => {
|
|
196
|
+
if (!state.stopped) timers.poll = setTimeout(poll, refreshDelay(state.quietRounds))
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
const records = watchRecords(source.root, () => schedule.schedule())
|
|
200
|
+
timers.poll = setTimeout(poll, REFRESH_PACE.baseMs)
|
|
201
|
+
const pullRequestTimer = setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
|
|
202
|
+
void refresh(state, schedule)
|
|
203
|
+
void refreshPullRequests()
|
|
204
|
+
return {
|
|
205
|
+
pullRequests: () => state.pullRequests,
|
|
206
|
+
fetchLine: () => fetchNotice(state.fetch, baseRefOf(source.settings), CLOCK_LOCALE),
|
|
207
|
+
schedule: () => schedule.schedule(),
|
|
208
|
+
stop: () => {
|
|
209
|
+
state.stopped = true
|
|
210
|
+
if (timers.poll) clearTimeout(timers.poll)
|
|
211
|
+
clearInterval(pullRequestTimer)
|
|
212
|
+
records?.close()
|
|
213
|
+
for (const watcher of state.watchers.values()) watcher.close()
|
|
214
|
+
state.watchers.clear()
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { watchRefreshDelay } from './watch-burst'
|
|
2
|
+
|
|
3
|
+
export type RefreshScheduleDeps = {
|
|
4
|
+
/** The clock, epoch milliseconds. */
|
|
5
|
+
now: () => number
|
|
6
|
+
/** Runs `run` once after `delayMs`. */
|
|
7
|
+
setTimer: (run: () => void, delayMs: number) => void
|
|
8
|
+
/** Starts the refresh; it tells `refreshStarted` when it begins reading. */
|
|
9
|
+
refresh: () => void
|
|
10
|
+
/** Whether a write to `file` came from the herd's own fetch, whose end refreshes once on its own. */
|
|
11
|
+
isOwnFetchWrite: (file: string) => boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RefreshSchedule = {
|
|
15
|
+
/** A refresh asked for outside the pace, bundled by `watchRefreshDelay`: one already waiting covers the ask. */
|
|
16
|
+
schedule: () => void
|
|
17
|
+
/** A watched file changed; the herd's own fetch writing `FETCH_HEAD` asks nothing. */
|
|
18
|
+
onFileChange: (file: string) => void
|
|
19
|
+
/** A refresh began reading; the asks that follow wait for the end of its window. */
|
|
20
|
+
refreshStarted: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const createRefreshSchedule = ({
|
|
24
|
+
now,
|
|
25
|
+
setTimer,
|
|
26
|
+
refresh,
|
|
27
|
+
isOwnFetchWrite,
|
|
28
|
+
}: RefreshScheduleDeps): RefreshSchedule => {
|
|
29
|
+
let waiting = false
|
|
30
|
+
let lastRefreshAt: number | null = null
|
|
31
|
+
|
|
32
|
+
const schedule = () => {
|
|
33
|
+
if (waiting) return
|
|
34
|
+
waiting = true
|
|
35
|
+
setTimer(
|
|
36
|
+
() => {
|
|
37
|
+
waiting = false
|
|
38
|
+
refresh()
|
|
39
|
+
},
|
|
40
|
+
watchRefreshDelay(lastRefreshAt, now()),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
schedule,
|
|
46
|
+
onFileChange: (file) => {
|
|
47
|
+
if (!isOwnFetchWrite(file)) schedule()
|
|
48
|
+
},
|
|
49
|
+
refreshStarted: () => {
|
|
50
|
+
lastRefreshAt = now()
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -36,17 +36,7 @@ import {
|
|
|
36
36
|
} from './current-phase'
|
|
37
37
|
import { activityTextOf, taskSignalsOf } from './current-task'
|
|
38
38
|
import { errorText } from './error-text'
|
|
39
|
-
import {
|
|
40
|
-
type BaseReader,
|
|
41
|
-
type BranchStatusReader,
|
|
42
|
-
baseReaderAt,
|
|
43
|
-
branchStatusAt,
|
|
44
|
-
gitStatusOf,
|
|
45
|
-
lastChangesAt,
|
|
46
|
-
refCommits,
|
|
47
|
-
refExists,
|
|
48
|
-
resolveCommit,
|
|
49
|
-
} from './git'
|
|
39
|
+
import { type BaseReader, baseReaderAt, gitStatusOf, lastChangesAt, refCommits, refExists, resolveCommit } from './git'
|
|
50
40
|
import type { PullRequestState } from './github'
|
|
51
41
|
import { groomedDaysAgo } from './groomed'
|
|
52
42
|
import {
|
|
@@ -77,7 +67,7 @@ import { readAllSignalsSliced } from './signals-read'
|
|
|
77
67
|
import { createSliceGate, type SliceGate } from './slice-gate'
|
|
78
68
|
import { withSourcePrefix } from './source-prefix'
|
|
79
69
|
import type { HerdSource, SourceBlock } from './source-rows'
|
|
80
|
-
import { type KeptStatus, logMark, pacedStatus } from './status-pace'
|
|
70
|
+
import { type KeptStatus, logMark, pacedStatus, type WorktreeStatus } from './status-pace'
|
|
81
71
|
import type { SubagentActivity } from './subagents'
|
|
82
72
|
import { readSubagentActivitySliced, subagentSourceOf } from './subagents-read'
|
|
83
73
|
import { boardTexts } from './texts'
|
|
@@ -128,6 +118,8 @@ export type Snapshot = {
|
|
|
128
118
|
planned: PlannedPlan[]
|
|
129
119
|
files: string[]
|
|
130
120
|
titles: Map<string, string | null>
|
|
121
|
+
/** The pane of each lane whose agent has one, by lane name: which of `titles` a lane's own herd sets. */
|
|
122
|
+
panes?: Record<string, string>
|
|
131
123
|
errors: string[]
|
|
132
124
|
/**
|
|
133
125
|
* The watcher's health; absent when no lane has an open pull request and no source is marked `ticket`, so the
|
|
@@ -171,8 +163,10 @@ type LaneSources = {
|
|
|
171
163
|
parsed?: PlanSources['parsed']
|
|
172
164
|
/** The gate each lane passes before a piece of synchronous work, so the lanes take turns with keys and drawing. */
|
|
173
165
|
gate: SliceGate
|
|
174
|
-
/** Each worktree's last
|
|
166
|
+
/** Each worktree's last git answer, kept between the herd's reads; absent for a single read, which asks git. */
|
|
175
167
|
statuses?: Map<string, KeptStatus>
|
|
168
|
+
/** The base ref's commit at this read, null when it does not resolve: a kept answer from another base is asked again. */
|
|
169
|
+
baseCommit: string | null
|
|
176
170
|
/** When this read happens, epoch milliseconds. */
|
|
177
171
|
now: number
|
|
178
172
|
}
|
|
@@ -248,28 +242,27 @@ const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: Lane
|
|
|
248
242
|
}
|
|
249
243
|
|
|
250
244
|
/**
|
|
251
|
-
* `git status` of a lane's worktree asked only when something there may have moved:
|
|
252
|
-
* log or its subagents' work (`worktreeStamp`), or the kept
|
|
245
|
+
* `git status` and the commits ahead of the base of a lane's worktree, asked only when something there may have moved:
|
|
246
|
+
* its index, HEAD or commits, the base's commit, its agent's log or its subagents' work (`worktreeStamp`), or the kept
|
|
247
|
+
* answer is old; git every time without kept statuses.
|
|
253
248
|
*/
|
|
254
|
-
const
|
|
255
|
-
const { statuses, now } = sources
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
249
|
+
const pacedGitStatus = (sources: LaneSources, name: string, agent: LaneAgent | undefined): Promise<WorktreeStatus> => {
|
|
250
|
+
const { source, statuses, now, baseCommit, gate } = sources
|
|
251
|
+
const worktree = worktreeOf(source.root, source.settings, name)
|
|
252
|
+
const read = () => gitStatusOf(worktree, baseRefOf(source.settings), gate.pass)
|
|
253
|
+
if (!statuses) return read()
|
|
254
|
+
const activity = [
|
|
255
|
+
baseCommit ?? '',
|
|
256
|
+
logMark(agent ? sources.readLog(agent) : null),
|
|
257
|
+
sources.subagents.get(name)?.activity ?? '',
|
|
258
|
+
].join('|')
|
|
259
|
+
return pacedStatus(statuses, { worktree, stamp: worktreeStamp(worktree, activity), now }, read)
|
|
262
260
|
}
|
|
263
261
|
|
|
264
262
|
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
265
263
|
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
266
264
|
const { source, pullRequests } = sources
|
|
267
|
-
const gitStatus = await
|
|
268
|
-
worktreeOf(source.root, source.settings, name),
|
|
269
|
-
baseRefOf(source.settings),
|
|
270
|
-
sources.gate.pass,
|
|
271
|
-
pacedStatusReader(sources, name, agent),
|
|
272
|
-
)
|
|
265
|
+
const gitStatus = await pacedGitStatus(sources, name, agent)
|
|
273
266
|
await sources.gate.pass()
|
|
274
267
|
const subagent = agent ? undefined : sources.subagents.get(name)
|
|
275
268
|
return {
|
|
@@ -531,6 +524,7 @@ const laneSetOf = async (
|
|
|
531
524
|
source: Source,
|
|
532
525
|
context: {
|
|
533
526
|
base: BaseReader
|
|
527
|
+
baseCommit: string | null
|
|
534
528
|
board: BoardLanes
|
|
535
529
|
sprintDirectory: string
|
|
536
530
|
worktrees: string
|
|
@@ -540,7 +534,7 @@ const laneSetOf = async (
|
|
|
540
534
|
pullRequests: PullRequestState,
|
|
541
535
|
now: number,
|
|
542
536
|
): Promise<LaneSet> => {
|
|
543
|
-
const { base, board, sprintDirectory, worktrees, cache, gate } = context
|
|
537
|
+
const { base, baseCommit, board, sprintDirectory, worktrees, cache, gate } = context
|
|
544
538
|
await gate.pass()
|
|
545
539
|
const { agents, errors } = await readAgents(source)
|
|
546
540
|
const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
|
|
@@ -567,6 +561,7 @@ const laneSetOf = async (
|
|
|
567
561
|
parsed: cache && { entries: cache.plans, used: new Set<string>() },
|
|
568
562
|
gate,
|
|
569
563
|
statuses: cache?.statuses,
|
|
564
|
+
baseCommit,
|
|
570
565
|
now,
|
|
571
566
|
},
|
|
572
567
|
errors,
|
|
@@ -674,7 +669,12 @@ export const loadSnapshot = async (
|
|
|
674
669
|
const worktrees = join(root, settings.worktreeDirectory)
|
|
675
670
|
const now = Date.now()
|
|
676
671
|
const liveBoard = board.liveMarkdown
|
|
677
|
-
const laneSet = await laneSetOf(
|
|
672
|
+
const laneSet = await laneSetOf(
|
|
673
|
+
source,
|
|
674
|
+
{ base, baseCommit, board, sprintDirectory, worktrees, cache, gate },
|
|
675
|
+
pullRequests,
|
|
676
|
+
now,
|
|
677
|
+
)
|
|
678
678
|
const results = await laneResultsOf(laneSet, fallbacks, now, memories)
|
|
679
679
|
const { parsed } = laneSet.sources
|
|
680
680
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
@@ -738,6 +738,7 @@ export const loadSnapshot = async (
|
|
|
738
738
|
pane ? [[pane, signalledTitle(view.summary?.signalled ?? null, shortTitle(view.progress))]] : [],
|
|
739
739
|
),
|
|
740
740
|
),
|
|
741
|
+
panes: Object.fromEntries(results.flatMap(({ view, pane }) => (pane ? [[view.progress.lane.name, pane]] : []))),
|
|
741
742
|
errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
|
|
742
743
|
watch: reading?.health,
|
|
743
744
|
waiting,
|
package/src/plans/status-pace.ts
CHANGED
|
@@ -3,8 +3,11 @@ import type { LaneStatus } from './layout'
|
|
|
3
3
|
/** What `git status` tells the herd about a worktree. */
|
|
4
4
|
export type BranchStatus = Pick<LaneStatus, 'branch' | 'dirty'>
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
export type
|
|
6
|
+
/** What git tells the herd about a worktree: `git status`, and the commits ahead of the base (`rev-list`). */
|
|
7
|
+
export type WorktreeStatus = Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>
|
|
8
|
+
|
|
9
|
+
/** The last git answer for a worktree, with the stamp of the worktree it was asked at and when (epoch milliseconds). */
|
|
10
|
+
export type KeptStatus = { stamp: string; at: number; status: WorktreeStatus }
|
|
8
11
|
|
|
9
12
|
/** Where and when a status is asked: the worktree, its stamp (`worktreeStamp`) and the time. */
|
|
10
13
|
export type StatusAsk = { worktree: string; stamp: string; now: number }
|
|
@@ -35,8 +38,8 @@ export const logMark = (lines: readonly string[] | null): string => {
|
|
|
35
38
|
export const pacedStatus = async (
|
|
36
39
|
kept: Map<string, KeptStatus>,
|
|
37
40
|
ask: StatusAsk,
|
|
38
|
-
read: () => Promise<
|
|
39
|
-
): Promise<
|
|
41
|
+
read: () => Promise<WorktreeStatus>,
|
|
42
|
+
): Promise<WorktreeStatus> => {
|
|
40
43
|
const previous = kept.get(ask.worktree)
|
|
41
44
|
if (isFresh(previous, ask)) return previous.status
|
|
42
45
|
const status = await read()
|
|
@@ -2,6 +2,8 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { defaultProjectsDirectory, followTailLines, projectDirectoryOf } from '../context/session-log'
|
|
4
4
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
|
+
import { boundedMemo } from '../shared/bounded-memo'
|
|
6
|
+
import { stampOf } from '../shared/stat-cache'
|
|
5
7
|
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
6
8
|
import { readApprovalRequests } from '../watch/approvals'
|
|
7
9
|
import {
|
|
@@ -51,7 +53,7 @@ const directoriesOf = (path: string): string[] => {
|
|
|
51
53
|
/** How many directories keep their listing: one `subagents` directory per session, a few hundred in a busy project. */
|
|
52
54
|
const LISTED_DIRECTORIES = 2048
|
|
53
55
|
|
|
54
|
-
const listings =
|
|
56
|
+
const listings = boundedMemo<string, { modifiedAt: number; names: string[] }>(LISTED_DIRECTORIES)
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
59
|
* The entry names of `path`, listed again only when the directory changed since (a file added or removed moves its
|
|
@@ -62,10 +64,7 @@ const namesIn = (path: string): string[] => {
|
|
|
62
64
|
const modifiedAt = statSync(path).mtimeMs
|
|
63
65
|
const kept = listings.get(path)
|
|
64
66
|
if (kept?.modifiedAt === modifiedAt) return kept.names
|
|
65
|
-
|
|
66
|
-
if (listings.size >= LISTED_DIRECTORIES) listings.clear()
|
|
67
|
-
listings.set(path, { modifiedAt, names })
|
|
68
|
-
return names
|
|
67
|
+
return listings.set(path, { modifiedAt, names: readdirSync(path) }).names
|
|
69
68
|
} catch {
|
|
70
69
|
return []
|
|
71
70
|
}
|
|
@@ -90,9 +89,24 @@ const logsInSession = (directory: string, now: number): SubagentLog[] =>
|
|
|
90
89
|
return id ? logOf(directory, name, id, now) : []
|
|
91
90
|
})
|
|
92
91
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
/**
|
|
93
|
+
* How long a session's own log may be untouched before its subagents are no longer looked for: a subagent runs while
|
|
94
|
+
* its session does, and a day is far beyond any subagent's run. Without it, every read checked each log of every past
|
|
95
|
+
* session (about 2,000 in a busy project), when none of them could be running.
|
|
96
|
+
*/
|
|
97
|
+
export const QUIET_SESSION_MS = 24 * 60 * MS_PER_MINUTE
|
|
98
|
+
|
|
99
|
+
/** Whether the session's own log was written within `QUIET_SESSION_MS`; a session without a readable log counts as live. */
|
|
100
|
+
const isSessionLive = (project: string, session: string, now: number): boolean => {
|
|
101
|
+
const stamp = stampOf(join(project, `${session}.jsonl`))
|
|
102
|
+
return stamp === null || now - stamp.mtimeMs <= QUIET_SESSION_MS
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The `subagents` directory of every session in one project directory that may still run one. */
|
|
106
|
+
const sessionDirectoriesOf = (project: string, now: number): string[] =>
|
|
107
|
+
directoriesOf(project)
|
|
108
|
+
.filter((session) => isSessionLive(project, session, now))
|
|
109
|
+
.map((session) => join(project, session, 'subagents'))
|
|
96
110
|
|
|
97
111
|
/**
|
|
98
112
|
* The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
|
|
@@ -105,8 +119,10 @@ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?
|
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/** The `subagents` directory of every session of the main checkout and its worktrees. */
|
|
108
|
-
const allSessionDirectories = (projects: string, root: string, worktreeDirectory?: string): string[] =>
|
|
109
|
-
projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) =>
|
|
122
|
+
const allSessionDirectories = (projects: string, root: string, now: number, worktreeDirectory?: string): string[] =>
|
|
123
|
+
projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) =>
|
|
124
|
+
sessionDirectoriesOf(join(projects, name), now),
|
|
125
|
+
)
|
|
110
126
|
|
|
111
127
|
/**
|
|
112
128
|
* The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
|
|
@@ -119,7 +135,7 @@ export const readSubagentLogs = (
|
|
|
119
135
|
now: number,
|
|
120
136
|
worktreeDirectory?: string,
|
|
121
137
|
): SubagentLog[] =>
|
|
122
|
-
allSessionDirectories(projects, root, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
|
|
138
|
+
allSessionDirectories(projects, root, now, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
|
|
123
139
|
|
|
124
140
|
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
125
141
|
const heldNames = (root: string, now: number): Set<string> =>
|
|
@@ -160,7 +176,7 @@ export const readSubagentActivitySliced = async (
|
|
|
160
176
|
now: number = Date.now(),
|
|
161
177
|
): Promise<Map<string, SubagentActivity>> => {
|
|
162
178
|
const logs: SubagentLog[] = []
|
|
163
|
-
for (const directory of allSessionDirectories(source.projects, source.root, source.worktreeDirectory)) {
|
|
179
|
+
for (const directory of allSessionDirectories(source.projects, source.root, now, source.worktreeDirectory)) {
|
|
164
180
|
await pass()
|
|
165
181
|
logs.push(...logsInSession(directory, now))
|
|
166
182
|
}
|
package/src/plans/supervisor.ts
CHANGED
|
@@ -2,12 +2,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
|
|
|
2
2
|
import { watch } from 'node:fs'
|
|
3
3
|
import { dirname } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { TERMINAL_OFF } from '../terminal/lifetime'
|
|
5
6
|
import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
|
|
6
7
|
import type { StartedKit } from '../version/started-kit'
|
|
7
8
|
import { STOP_REQUEST, type Stopper, stopperOf } from './child-stop'
|
|
8
9
|
import { HERD_COMMAND } from './herd-command'
|
|
9
10
|
import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
|
|
10
|
-
import { TERMINAL_OFF } from './view'
|
|
11
11
|
|
|
12
12
|
const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
13
13
|
|