@joshuafolkken/kit 1.1379.0 → 1.1383.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.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
- package/.claude/skills/workflow-commands/retrospective.md +1 -2
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-YSDAH73S.js → chunk-2X2ART5G.js} +2 -2
- package/dist/hooks/{chunk-RW4XMF47.js → chunk-4MGHBTOM.js} +125 -232
- package/dist/hooks/{chunk-TG2AB3W6.js → chunk-6AC4C2IO.js} +45 -55
- package/dist/hooks/{chunk-HGA4EG5S.js → chunk-6MAW7BCM.js} +12 -12
- package/dist/hooks/{chunk-QARPP36N.js → chunk-7VSZ2N4B.js} +3 -3
- package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-KC6LUZZ2.js} +1 -1
- package/dist/hooks/{chunk-4RWRP3FF.js → chunk-KCF2YE7O.js} +5 -5
- package/dist/hooks/{chunk-E7LZL5AN.js → chunk-LC56R53A.js} +3 -3
- package/dist/hooks/{chunk-546X4PX3.js → chunk-LOJTBU5V.js} +3 -3
- package/dist/hooks/{chunk-7ZM464OW.js → chunk-RYP3TSUJ.js} +3 -3
- package/dist/hooks/{chunk-TORRSNDJ.js → chunk-TT742SIO.js} +2 -2
- package/dist/hooks/{chunk-NBEAGPS4.js → chunk-Y5R47QHU.js} +1 -1
- package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-YKUITFT7.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +199 -200
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +19 -15
- package/dist/josh.js +1 -1
- package/package.json +1 -1
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/backlog/backlog-budget-cli.ts +2 -20
- package/scripts/backlog/backlog-budget.ts +72 -109
- package/scripts/backlog/backlog-drive-cli.ts +10 -3
- package/scripts/backlog/backlog-drive.ts +5 -2
- package/scripts/backlog/backlog-offer-cli.ts +1 -15
- package/scripts/epic/epic-candidate-confirm.ts +1 -1
- package/scripts/epic/epic-cross-repo.ts +4 -4
- package/scripts/epic/epic-next.ts +1 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/run/board/run-board-header.ts +107 -84
- package/scripts/run/board/run-board-labels.ts +18 -17
- package/scripts/run/board/run-board-machine.ts +21 -8
- package/scripts/run/board/run-board-render.ts +1 -5
- package/scripts/run/board/run-board-span.ts +0 -1
- package/scripts/run/board/run-board-status.ts +0 -17
- package/scripts/run/board/run-board-tick.ts +1 -0
- package/scripts/run/carry/run-carry-cli.ts +8 -62
- package/scripts/run/carry/run-carry-ended.ts +1 -1
- package/scripts/run/carry/run-carry.ts +22 -68
- package/scripts/run/cut/run-cut.ts +4 -4
- package/scripts/run/event/run-event-render.ts +0 -1
- package/scripts/run/event/run-event-scope.ts +4 -8
- package/scripts/run/event/run-event-stream-emit.ts +0 -25
- package/scripts/run/event/run-event-stream.ts +23 -31
- package/scripts/run/merge/run-merge-collect.ts +1 -1
- package/scripts/run/merge/run-merge-steps.ts +1 -1
- package/scripts/run/progress/run-progress-read.ts +1 -1
- package/scripts/run/run-headless.ts +2 -2
- package/scripts/run/run-status-cli.ts +1 -5
- package/scripts/run/run-step.ts +2 -9
- package/scripts/run/run-stop-notify.ts +1 -1
- package/scripts/run/run-stranded-detect.ts +3 -3
- package/scripts/run/run-stranded.ts +25 -17
- package/scripts/run/wake/run-wake-cli.ts +7 -9
- package/scripts/run/wake/run-wake-describe.ts +3 -5
- package/scripts/run/wake/run-wake-driver.ts +3 -3
- package/scripts/run/wake/run-wake-loop.ts +4 -21
- package/scripts/run/wake/run-wake-restart.ts +1 -1
- package/scripts/run/wake/run-wake.ts +6 -7
- package/scripts/backlog/backlog-idle.ts +0 -88
package/dist/hooks/stop-guard.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
run_event_stream,
|
|
17
17
|
run_event_stream_emit,
|
|
18
18
|
run_hold
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-6AC4C2IO.js";
|
|
20
20
|
import {
|
|
21
21
|
session_language
|
|
22
22
|
} from "./chunk-6V5OPIUU.js";
|
|
@@ -26,22 +26,22 @@ import {
|
|
|
26
26
|
issue_cite,
|
|
27
27
|
lane_registry,
|
|
28
28
|
run_headless
|
|
29
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-2X2ART5G.js";
|
|
30
30
|
import {
|
|
31
31
|
timed_fetch
|
|
32
32
|
} from "./chunk-FZ6FUSBM.js";
|
|
33
33
|
import "./chunk-CDY4NFQG.js";
|
|
34
34
|
import {
|
|
35
35
|
hook_decision
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-LC56R53A.js";
|
|
37
37
|
import {
|
|
38
38
|
time_density_hook,
|
|
39
39
|
time_hook_transcript,
|
|
40
40
|
time_reported_failure
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-TT742SIO.js";
|
|
42
42
|
import {
|
|
43
43
|
lane_child_marker
|
|
44
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-Y5R47QHU.js";
|
|
45
45
|
import {
|
|
46
46
|
backlog_budget,
|
|
47
47
|
issue_number_shape,
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
process_owner_schema,
|
|
51
51
|
run_carry,
|
|
52
52
|
run_invocation
|
|
53
|
-
} from "./chunk-
|
|
53
|
+
} from "./chunk-4MGHBTOM.js";
|
|
54
54
|
import {
|
|
55
55
|
PROJECT_ROOT,
|
|
56
56
|
stamp_file
|
|
@@ -981,7 +981,6 @@ var PENDING_DECISION = { kind: "pending" };
|
|
|
981
981
|
var FAILED_DECISION = { kind: "failed" };
|
|
982
982
|
var STOP_REASONS = {
|
|
983
983
|
none: "ended",
|
|
984
|
-
expired: "expired",
|
|
985
984
|
unreadable: "unreadable"
|
|
986
985
|
};
|
|
987
986
|
var run_wake_schema = process_owner_schema.extend({
|
|
@@ -1157,14 +1156,17 @@ function supervisor_liveness(wake, same_process = process_identity.is_same_proce
|
|
|
1157
1156
|
if (is_same === void 0) return UNKNOWN;
|
|
1158
1157
|
return is_same ? LIVE : GONE;
|
|
1159
1158
|
}
|
|
1159
|
+
function had_driver(carry) {
|
|
1160
|
+
return carry.is_handed_off === true || carry.owner_pid !== void 0;
|
|
1161
|
+
}
|
|
1160
1162
|
function is_stranded(input) {
|
|
1161
|
-
if (input.read.kind !== "carried" || input.
|
|
1163
|
+
if (input.read.kind !== "carried" || input.is_owner_live || !had_driver(input.read.carry)) {
|
|
1162
1164
|
return false;
|
|
1163
1165
|
}
|
|
1164
1166
|
return input.supervisor === GONE;
|
|
1165
1167
|
}
|
|
1166
1168
|
function describe2(carry) {
|
|
1167
|
-
return `Run stranded \u2014 ${run_carry.describe_carry(carry)}. The owner is gone, no
|
|
1169
|
+
return `Run stranded \u2014 ${run_carry.describe_carry(carry)}. The owner is gone, no successor holds the budget, and no supervisor is watching.`;
|
|
1168
1170
|
}
|
|
1169
1171
|
function verdict_of(is_run_stranded) {
|
|
1170
1172
|
return is_run_stranded ? STRANDED : OK2;
|
|
@@ -1295,18 +1297,20 @@ async function outcome_of(raw_payload) {
|
|
|
1295
1297
|
return stop_rules.NO_OUTCOME;
|
|
1296
1298
|
}
|
|
1297
1299
|
}
|
|
1298
|
-
async function
|
|
1300
|
+
async function carried_owner_live() {
|
|
1299
1301
|
try {
|
|
1300
1302
|
const directory = await run_carry.repository_directory();
|
|
1301
|
-
if (directory === void 0) return
|
|
1302
|
-
|
|
1303
|
+
if (directory === void 0) return void 0;
|
|
1304
|
+
const read = run_carry.read_carry(run_carry.carry_path(directory));
|
|
1305
|
+
return read.kind === "carried" ? run_carry.is_owner_live(read.carry) : void 0;
|
|
1303
1306
|
} catch {
|
|
1304
|
-
return
|
|
1307
|
+
return void 0;
|
|
1305
1308
|
}
|
|
1306
1309
|
}
|
|
1307
1310
|
async function run_report_checks() {
|
|
1308
|
-
|
|
1309
|
-
|
|
1311
|
+
const is_owner_live = await carried_owner_live();
|
|
1312
|
+
if (is_owner_live === void 0) return;
|
|
1313
|
+
if (is_owner_live) await backlog_stalled_detect.run_stall_check(backlog_ready.DEFAULT_PORTS);
|
|
1310
1314
|
await run_stranded_detect.run_stranded_check();
|
|
1311
1315
|
}
|
|
1312
1316
|
async function write_stop_decision(raw_payload) {
|
package/dist/josh.js
CHANGED
|
@@ -1867,7 +1867,7 @@ var AI_COMMANDS = {
|
|
|
1867
1867
|
// `.env` rather than the ambient environment: the strand notification needs the Telegram
|
|
1868
1868
|
// credentials, the same reason `run:wake` carries these flags.
|
|
1869
1869
|
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
1870
|
-
description: "Report whether the run is stranded \u2014
|
|
1870
|
+
description: "Report whether the run is stranded \u2014 owner gone, no successor holding the budget, and no supervisor watching",
|
|
1871
1871
|
category: "AI tools",
|
|
1872
1872
|
reference: ["", "automation", ["processes", "notifications"]]
|
|
1873
1873
|
},
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.1383.0",
|
|
3
3
|
"name": "@joshuafolkken/kit",
|
|
4
4
|
"description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
|
|
5
5
|
"keywords": [
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
1. **起票する** — `pnpm josh issue:file "<title>" --body-file <path> --depth <n> --route interrupt --label priority:high`。上限は効かない(`wip-cap.md` →「割り込み起票 — 上限が効かない側」の条件 2)
|
|
19
19
|
2. **止まらずに直す** — 壊れた機構を迂回しない(回避策の禁止と、検証ゲートを緩めない規則はここでも変わらない)。`backlogrun` の中では、その割り込みを次の子にし、修正が着地してから塞がれていた子へ戻る。プールから選ぶランでは `priority:high` が先頭に並べ、epic の下では `wip-cap.md` の手順 3 で挿入する。名前を指定したランのドライバーは指定された Issue しか出さないので、`pnpm josh lane:launch <N>` で自分で dispatch する。バッチの外では `wip-cap.md` の手順 4 が変わらず効く — 起票まで済ませ、打つべき `fullrun #<N>` を報告して止まる
|
|
20
20
|
3. **再開できるなら自分で再開し、動くことを確かめる** — 修正のマージ後、再開の手段がある答えには人の打ち直しを求めない。手渡し済みの記録、所有者が死んだ記録、監督プロセスの停止(`warning` Telegram)には `pnpm josh run:wake --start`、`run:carry` の `standing` には `pnpm josh run:carry --resume "<invocation>" --owner "$PPID"` を自分で打つ。そのあと `pnpm josh run:wake --list` が監督プロセスを示し、そのログに次の子の dispatch が現れることを確かめて報告する
|
|
21
|
-
4. **人に打ち直しを求めるのは再開の手段が無いときだけ** — `
|
|
21
|
+
4. **人に打ち直しを求めるのは再開の手段が無いときだけ** — `mismatch`(別の起動の記録)、別の生きた所有者の `busy`。新しい承認を宣言するのは人だけ、という規則も変わらない
|
|
22
22
|
5. **`needs-decision` を付けない** — ランが自分で直せる不具合は人の判断を要さない(`.claude/skills/workflow-commands/backlogrun-park.md` → "Only a person's judgement carries `needs-decision`")
|
|
23
23
|
|
|
24
24
|
手順:
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
type BudgetDecision,
|
|
9
9
|
type BudgetInput,
|
|
10
10
|
} from './backlog-budget'
|
|
11
|
-
import { backlog_idle } from './backlog-idle'
|
|
12
11
|
|
|
13
12
|
// The thin half of `josh backlog:budget` (joshuafolkken/kit#1632): read the loop's state off the
|
|
14
13
|
// command line, hand it to the pure decision, print the verdict on stdout and the reason on stderr
|
|
@@ -18,7 +17,6 @@ const ARGV_OFFSET = 2
|
|
|
18
17
|
const SUCCESS_EXIT_CODE = 0
|
|
19
18
|
const FAILURE_EXIT_CODE = 1
|
|
20
19
|
const JSON_KEY = 'budget'
|
|
21
|
-
const EXHAUSTED_ANSWER: BacklogAnswer = 'exhausted'
|
|
22
20
|
|
|
23
21
|
const USAGE = `Usage: josh backlog:budget --answer <${backlog_budget.ANSWERS.join('|')}> --started <ISO-8601> [--active <ISO-8601>, required unless the watch is off] [--merged <count>] [--running <count>] [--max <count>] [--idle <minutes>, 0 to turn the watch off] [--json]`
|
|
24
22
|
|
|
@@ -71,7 +69,7 @@ function to_count(raw: string | undefined): number | undefined {
|
|
|
71
69
|
|
|
72
70
|
// A flag that was given but could not be read makes the whole invocation unreadable. Falling back to
|
|
73
71
|
// the default instead would answer a question nobody asked — a mistyped `--idle 0` would read as the
|
|
74
|
-
// default
|
|
72
|
+
// default watch and hold a run open that was asked to finish at its first empty backlog.
|
|
75
73
|
function is_readable(raw: string | undefined, parsed: number | undefined): boolean {
|
|
76
74
|
return raw === undefined || parsed !== undefined
|
|
77
75
|
}
|
|
@@ -165,27 +163,11 @@ function build_input(values: ParsedValues, now_ms: number): BudgetInput | undefi
|
|
|
165
163
|
return input_of(values, { answer, now_ms, started_at_ms })
|
|
166
164
|
}
|
|
167
165
|
|
|
168
|
-
// The idle window, carried only on the watch an empty backlog opens — the one `idle_decision` reaches,
|
|
169
|
-
// not the draining watch a reached bound returns while children still run — so `backlog:offer` can
|
|
170
|
-
// record when that wait ends (joshuafolkken/kit#3430).
|
|
171
|
-
function idle_of(input: BudgetInput, verdict: string): string | undefined {
|
|
172
|
-
const is_idle_watch =
|
|
173
|
-
verdict === backlog_budget.WATCH_VERDICT &&
|
|
174
|
-
input.answer === EXHAUSTED_ANSWER &&
|
|
175
|
-
backlog_budget.is_finish(input)
|
|
176
|
-
const window = is_idle_watch ? backlog_idle.window_of(input) : undefined
|
|
177
|
-
|
|
178
|
-
return window === undefined ? undefined : backlog_idle.text_of(window)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
166
|
function print_json(input: BudgetInput, decision: BudgetDecision): void {
|
|
182
167
|
const { verdict, reason } = decision
|
|
183
|
-
const idle = idle_of(input, verdict)
|
|
184
168
|
const is_finish = backlog_budget.is_finish(input)
|
|
185
169
|
|
|
186
|
-
console.info(
|
|
187
|
-
JSON.stringify({ [JSON_KEY]: verdict, reason, is_finish, ...(idle !== undefined && { idle }) }),
|
|
188
|
-
)
|
|
170
|
+
console.info(JSON.stringify({ [JSON_KEY]: verdict, reason, is_finish }))
|
|
189
171
|
}
|
|
190
172
|
|
|
191
173
|
function refuse(): number {
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// `josh backlog:budget` — whether a `backlogrun` may start more work, keep watching, or finish.
|
|
2
2
|
//
|
|
3
3
|
// `backlog:next` says what may start; it says nothing about when the run itself should end. Without
|
|
4
|
-
// this a run would finish the moment
|
|
4
|
+
// this a run would finish the moment nothing could proceed, so an issue a person opted in minutes
|
|
5
5
|
// later would need a whole new session.
|
|
6
6
|
//
|
|
7
7
|
// **The idle watch is on by default and the maximum is not.** A `backlogrun` with neither flag given
|
|
8
|
-
// watches
|
|
9
|
-
// the backlog holds. `--idle 0` is how the watch is turned off.
|
|
8
|
+
// watches for `IDLE_WAIT_HOURS` once nothing can proceed before it finishes, and takes as many issues
|
|
9
|
+
// as the backlog holds. `--idle 0` is how the watch is turned off.
|
|
10
|
+
//
|
|
11
|
+
// **Every reason nothing can proceed is watched, not only an empty backlog.** Everything opted in
|
|
12
|
+
// waiting on a person, and a listing that could not be read, both change without this run doing
|
|
13
|
+
// anything — a person opts the prerequisite in, GitHub answers again — so ending on either would miss
|
|
14
|
+
// the issue opted in minutes later, which is the one thing the watch exists to catch.
|
|
10
15
|
//
|
|
11
16
|
// The decision is a command's rather than the loop's own arithmetic, for the reason `josh delegate`
|
|
12
17
|
// and `josh review:level` are commands: a count and an elapsed time kept in an agent's head are a
|
|
@@ -14,29 +19,23 @@
|
|
|
14
19
|
|
|
15
20
|
const MS_PER_MINUTE = 60_000
|
|
16
21
|
const MINUTES_PER_HOUR = 60
|
|
17
|
-
// The whole-run bound, decided here like the idle watch and the maximum rather than by an agent reading
|
|
18
|
-
// a clock.
|
|
19
|
-
const WHOLE_RUN_BUDGET_HOURS = 8
|
|
20
|
-
const WHOLE_RUN_BUDGET_MINUTES = WHOLE_RUN_BUDGET_HOURS * MINUTES_PER_HOUR
|
|
21
|
-
const WHOLE_RUN_BUDGET_MS = WHOLE_RUN_BUDGET_MINUTES * MS_PER_MINUTE
|
|
22
22
|
|
|
23
|
-
// **
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// - **It is about the length of one child** — 12 to 28 minutes measured — so a run that has emptied its backlog waits roughly as long as one more issue would have taken.
|
|
30
|
-
const DEFAULT_IDLE_MINUTES = 30
|
|
23
|
+
// **How long a run with nothing to do waits, and the only time bound a run has.** It is counted from
|
|
24
|
+
// when the run last had work, never from its start: a run still working, or still inside this wait,
|
|
25
|
+
// is not ended by its age. Eight hours is one unattended night — a person who opts an issue in before
|
|
26
|
+
// they sleep, or after they wake, finds the run still there to pick it up.
|
|
27
|
+
const IDLE_WAIT_HOURS = 8
|
|
28
|
+
const DEFAULT_IDLE_MINUTES = IDLE_WAIT_HOURS * MINUTES_PER_HOUR
|
|
31
29
|
const DEFAULT_IDLE_MS = DEFAULT_IDLE_MINUTES * MS_PER_MINUTE
|
|
32
30
|
|
|
33
|
-
// **
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// history — to learn nothing thirty times. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
|
|
31
|
+
// **The one interval between two asks for work, whether lanes are running or nothing is.** The driver
|
|
32
|
+
// and the wake supervisor both read it from here. The loop is a process, so an ask spends no AI turn,
|
|
33
|
+
// and a watch is polled as often as a running batch; a child collected in between is asked about at
|
|
34
|
+
// once, without waiting it out. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
|
|
38
35
|
// the row; the figure is here because `idle_watch_reason` below has to say it.
|
|
39
|
-
const
|
|
36
|
+
const OFFER_INTERVAL_SECONDS = 60
|
|
37
|
+
const MS_PER_SECOND = 1000
|
|
38
|
+
const OFFER_INTERVAL_MS = OFFER_INTERVAL_SECONDS * MS_PER_SECOND
|
|
40
39
|
|
|
41
40
|
const RUN_VERDICT = 'run'
|
|
42
41
|
const WATCH_VERDICT = 'watch'
|
|
@@ -69,7 +68,7 @@ interface BudgetInput {
|
|
|
69
68
|
// otherwise be measured against a maximum that cannot see it.
|
|
70
69
|
running: number
|
|
71
70
|
started_at_ms: number
|
|
72
|
-
// When this run last had work —
|
|
71
|
+
// When this run last had work — its most recent launch or collected child, or the run's start.
|
|
73
72
|
// Refreshing it is what restarts the idle watch when a candidate appears during one.
|
|
74
73
|
active_at_ms: number
|
|
75
74
|
now_ms: number
|
|
@@ -87,22 +86,28 @@ interface BudgetDecision {
|
|
|
87
86
|
reason: string
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
const WHOLE_RUN_REASON = `The whole-run bound of ${String(WHOLE_RUN_BUDGET_HOURS)} hours has passed. An unattended run that has not finished overnight needs a person, not more waiting.`
|
|
91
|
-
|
|
92
|
-
const PARKED_REASON =
|
|
93
|
-
'Nothing opted in can proceed without a person. Report the parked issues and finish.'
|
|
94
|
-
|
|
95
|
-
const UNREADABLE_REASON =
|
|
96
|
-
'The backlog listing could not be read, so nothing was offered. Report what the command printed and finish — it is not an empty backlog.'
|
|
97
|
-
|
|
98
89
|
const BLOCKED_REASON =
|
|
99
90
|
'Everything opted in is blocked or already running, so waiting can still change the answer. Poll again at the polling interval.'
|
|
100
91
|
|
|
101
92
|
const TRIAGE_REASON =
|
|
102
93
|
'An opted-in candidate carries neither `run:solo` nor `run:lane`, so nothing starts until it is judged. Read each issue named above, record its order and one of the two labels with a comment, then ask again.'
|
|
103
94
|
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
// What the run is waiting on, by the answer that opened the watch — the opening of every sentence a
|
|
96
|
+
// watch or its ending prints, so a person reads which of the three it was.
|
|
97
|
+
const EMPTY_BACKLOG = 'The backlog is empty'
|
|
98
|
+
const WAITING_ON: Readonly<Partial<Record<BacklogAnswer, string>>> = {
|
|
99
|
+
exhausted: EMPTY_BACKLOG,
|
|
100
|
+
parked: 'Nothing opted in can proceed without a person',
|
|
101
|
+
unreadable: 'The backlog listing could not be read',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function waiting_on(answer: BacklogAnswer): string {
|
|
105
|
+
return WAITING_ON[answer] ?? EMPTY_BACKLOG
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function no_idle_watch_reason(answer: BacklogAnswer): string {
|
|
109
|
+
return `${waiting_on(answer)} and the idle watch was turned off with \`--idle 0\`, so the run finishes here.`
|
|
110
|
+
}
|
|
106
111
|
|
|
107
112
|
function to_minutes(duration_ms: number): string {
|
|
108
113
|
return String(Math.ceil(duration_ms / MS_PER_MINUTE))
|
|
@@ -119,39 +124,29 @@ function draining_reason(running: number, reason: string): string {
|
|
|
119
124
|
return `Start nothing more and poll until the running children merge (running: ${String(running)}). Then report and finish — ${reason}`
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
function idle_expired_reason(idle_budget_ms: number): string {
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Never longer than the watch has left: "about 2 minutes left, ask again in 5 minutes" would overrun
|
|
127
|
-
// the budget a person declared, and a sentence that contradicts itself is read as neither half.
|
|
128
|
-
function next_ask_minutes(left_ms: number): string {
|
|
129
|
-
const left_minutes = Number(to_minutes(left_ms))
|
|
130
|
-
|
|
131
|
-
return String(Math.min(IDLE_POLL_MINUTES, left_minutes))
|
|
127
|
+
function idle_expired_reason(answer: BacklogAnswer, idle_budget_ms: number): string {
|
|
128
|
+
return `${waiting_on(answer)}, and stayed so for the whole ${to_minutes(idle_budget_ms)}-minute idle watch. Report what was done, including how many were picked up during the watch, and finish.`
|
|
132
129
|
}
|
|
133
130
|
|
|
134
|
-
function idle_watch_reason(left_ms: number): string {
|
|
135
|
-
return
|
|
131
|
+
function idle_watch_reason(answer: BacklogAnswer, left_ms: number): string {
|
|
132
|
+
return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left. Release the working tree, ask again in ${String(OFFER_INTERVAL_SECONDS)} seconds, and restart the watch the moment a candidate appears.`
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
// **A watch that opens while children are still in lanes is not the idle watch's ordinary shape**: the
|
|
139
|
-
// backlog can
|
|
140
|
-
//
|
|
141
|
-
//
|
|
136
|
+
// backlog can offer nothing while this run's own children are still merging. The sentence above
|
|
137
|
+
// would get one thing wrong there — the working tree is still held, because the hold is released at
|
|
138
|
+
// the *last* child's merge.
|
|
142
139
|
//
|
|
143
|
-
// **The watch runs down while they drain, and
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
function running_watch_reason(running: number, left_ms: number): string {
|
|
148
|
-
return `The backlog is empty and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval, not the idle poll — the watch is running down meanwhile.`
|
|
140
|
+
// **The watch runs down while they drain, and restarts at each one collected.** A collected child is
|
|
141
|
+
// work, so it moves `active_at`; an ask that offered nothing never does.
|
|
142
|
+
function running_watch_reason(answer: BacklogAnswer, running: number, left_ms: number): string {
|
|
143
|
+
return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval — the watch is running down meanwhile.`
|
|
149
144
|
}
|
|
150
145
|
|
|
151
146
|
function watching_reason(input: BudgetInput, left_ms: number): string {
|
|
152
|
-
if (input.running === 0) return idle_watch_reason(left_ms)
|
|
147
|
+
if (input.running === 0) return idle_watch_reason(input.answer, left_ms)
|
|
153
148
|
|
|
154
|
-
return running_watch_reason(input.running, left_ms)
|
|
149
|
+
return running_watch_reason(input.answer, input.running, left_ms)
|
|
155
150
|
}
|
|
156
151
|
|
|
157
152
|
// What the maximum has already taken: merged issues and the children still running. Counting only
|
|
@@ -169,32 +164,10 @@ function run_reason(input: BudgetInput): string {
|
|
|
169
164
|
return `Start at most ${String(max_issues - taken_by(input))} more: this run's maximum is ${String(max_issues)}, with ${String(input.merged)} merged and ${String(input.running)} running.`
|
|
170
165
|
}
|
|
171
166
|
|
|
172
|
-
// The two answers that end a run whatever the budgets say. Neither is a budget decision — the graph
|
|
173
|
-
// said so — but both reach this command, because the loop asks one question rather than branching on
|
|
174
|
-
// `backlog:next`'s word itself and then asking a second.
|
|
175
|
-
const ANSWER_STOP_REASONS: Readonly<Partial<Record<BacklogAnswer, string>>> = {
|
|
176
|
-
parked: PARKED_REASON,
|
|
177
|
-
unreadable: UNREADABLE_REASON,
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function whole_run_reason(input: BudgetInput): string | undefined {
|
|
181
|
-
if (input.now_ms - input.started_at_ms < WHOLE_RUN_BUDGET_MS) return undefined
|
|
182
|
-
|
|
183
|
-
return WHOLE_RUN_REASON
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// The answer-level stops come first, ahead of every budget. Both of them say something the person
|
|
187
|
-
// has to act on — issues are parked, or the listing could not be read — and the reason printed here
|
|
188
|
-
// is quoted verbatim into the completion report, so a budget reason in front of one would report a
|
|
189
|
-
// tidy ending for a run whose backlog actually broke.
|
|
190
|
-
function stop_reason(input: BudgetInput): string | undefined {
|
|
191
|
-
return ANSWER_STOP_REASONS[input.answer] ?? whole_run_reason(input)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
167
|
// Every ending goes through here, so no ending abandons a lane. Children already started have to
|
|
195
|
-
// merge and be reported whatever ended the run — the maximum, a
|
|
196
|
-
//
|
|
197
|
-
//
|
|
168
|
+
// merge and be reported whatever ended the run — the maximum, or a watch that ran out or was turned
|
|
169
|
+
// off — so a `stop` while any of them is still running would leave open lanes behind and merged work
|
|
170
|
+
// unmentioned.
|
|
198
171
|
function stopping(input: BudgetInput, reason: string): BudgetDecision {
|
|
199
172
|
if (input.running === 0) return { verdict: STOP_VERDICT, reason }
|
|
200
173
|
|
|
@@ -212,30 +185,31 @@ function max_decision(input: BudgetInput): BudgetDecision | undefined {
|
|
|
212
185
|
return stopping(input, max_reached_reason(input.merged, max_issues))
|
|
213
186
|
}
|
|
214
187
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// the other branch, where
|
|
188
|
+
// Nothing can proceed, so the run watches rather than ends — whichever of the three answers said so.
|
|
189
|
+
// The watch is the run's only time bound: it is counted from `active_at_ms`, so a run is never ended
|
|
190
|
+
// by how long ago it started. Only `--idle 0` takes the other branch, where the first such answer is
|
|
191
|
+
// the run's ending.
|
|
218
192
|
function idle_decision(input: BudgetInput): BudgetDecision {
|
|
219
193
|
const { idle_budget_ms } = input
|
|
220
194
|
|
|
221
|
-
if (idle_budget_ms === undefined) return stopping(input,
|
|
195
|
+
if (idle_budget_ms === undefined) return stopping(input, no_idle_watch_reason(input.answer))
|
|
222
196
|
|
|
223
197
|
const left_ms = idle_budget_ms - (input.now_ms - input.active_at_ms)
|
|
224
198
|
|
|
225
|
-
if (left_ms
|
|
199
|
+
if (left_ms > 0) return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
|
|
226
200
|
|
|
227
|
-
return
|
|
201
|
+
return stopping(input, idle_expired_reason(input.answer, idle_budget_ms))
|
|
228
202
|
}
|
|
229
203
|
|
|
230
|
-
// Whether a `stop` for this input is the run finishing — the
|
|
231
|
-
// rather than stopping on something a person must act on:
|
|
232
|
-
//
|
|
233
|
-
// `--end --stopped`), and `backlog:drive` reads which one from here.
|
|
204
|
+
// Whether a `stop` for this input is the run finishing — the watch ending on an empty backlog —
|
|
205
|
+
// rather than stopping on something a person must act on: the maximum, issues still waiting on a
|
|
206
|
+
// person, or a listing that never became readable. The two end the carry record differently (`--end`
|
|
207
|
+
// against `--end --stopped`), and `backlog:drive` reads which one from here.
|
|
234
208
|
function is_finish(input: BudgetInput): boolean {
|
|
235
|
-
return
|
|
209
|
+
return input.answer === 'exhausted' && max_decision(input) === undefined
|
|
236
210
|
}
|
|
237
211
|
|
|
238
|
-
// The answers whose decision no count changes, once the
|
|
212
|
+
// The answers whose decision no count changes, once the maximum above has been applied.
|
|
239
213
|
const FIXED_DECISIONS: Readonly<Partial<Record<BacklogAnswer, BudgetDecision>>> = {
|
|
240
214
|
blocked: { verdict: WATCH_VERDICT, reason: BLOCKED_REASON },
|
|
241
215
|
untriaged: { verdict: TRIAGE_VERDICT, reason: TRIAGE_REASON },
|
|
@@ -248,15 +222,7 @@ function answer_decision(input: BudgetInput): BudgetDecision {
|
|
|
248
222
|
}
|
|
249
223
|
|
|
250
224
|
function decide(input: BudgetInput): BudgetDecision {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (reason !== undefined) return stopping(input, reason)
|
|
254
|
-
|
|
255
|
-
const capped = max_decision(input)
|
|
256
|
-
|
|
257
|
-
if (capped !== undefined) return capped
|
|
258
|
-
|
|
259
|
-
return answer_decision(input)
|
|
225
|
+
return max_decision(input) ?? answer_decision(input)
|
|
260
226
|
}
|
|
261
227
|
|
|
262
228
|
const backlog_budget = {
|
|
@@ -264,25 +230,22 @@ const backlog_budget = {
|
|
|
264
230
|
BLOCKED_REASON,
|
|
265
231
|
DEFAULT_IDLE_MINUTES,
|
|
266
232
|
DEFAULT_IDLE_MS,
|
|
267
|
-
|
|
233
|
+
IDLE_WAIT_HOURS,
|
|
268
234
|
MS_PER_MINUTE,
|
|
269
|
-
|
|
270
|
-
PARKED_REASON,
|
|
235
|
+
OFFER_INTERVAL_MS,
|
|
271
236
|
RUN_VERDICT,
|
|
272
237
|
STOP_VERDICT,
|
|
273
238
|
TRIAGE_REASON,
|
|
274
239
|
TRIAGE_VERDICT,
|
|
275
|
-
|
|
240
|
+
WAITING_ON,
|
|
276
241
|
WATCH_VERDICT,
|
|
277
|
-
WHOLE_RUN_BUDGET_HOURS,
|
|
278
|
-
WHOLE_RUN_BUDGET_MS,
|
|
279
|
-
WHOLE_RUN_REASON,
|
|
280
242
|
decide,
|
|
281
243
|
draining_reason,
|
|
282
244
|
idle_expired_reason,
|
|
283
245
|
idle_watch_reason,
|
|
284
246
|
is_finish,
|
|
285
247
|
max_reached_reason,
|
|
248
|
+
no_idle_watch_reason,
|
|
286
249
|
run_reason,
|
|
287
250
|
running_watch_reason,
|
|
288
251
|
taken_by,
|
|
@@ -13,10 +13,12 @@ import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
|
|
|
13
13
|
import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
|
|
14
14
|
import { run_merge_cli, type MergeResult } from '#scripts/run/merge/run-merge-cli'
|
|
15
15
|
import { z } from 'zod'
|
|
16
|
+
import { backlog_budget } from './backlog-budget'
|
|
16
17
|
import {
|
|
17
18
|
backlog_drive,
|
|
18
19
|
type DriveEnd,
|
|
19
20
|
type DriveState,
|
|
21
|
+
type LoopConfig,
|
|
20
22
|
type LoopPorts,
|
|
21
23
|
type OfferRead,
|
|
22
24
|
} from './backlog-drive'
|
|
@@ -46,7 +48,6 @@ const SUCCESS_EXIT_CODE = 0
|
|
|
46
48
|
const FAILURE_EXIT_CODE = 1
|
|
47
49
|
const should_forward_stderr = true
|
|
48
50
|
const POLL_MS = 5000
|
|
49
|
-
const OFFER_MS = 60_000
|
|
50
51
|
const FIRST_LINE = 0
|
|
51
52
|
|
|
52
53
|
// The `--json` object `backlog:offer` prints, keyed under its `JSON_KEY`.
|
|
@@ -71,7 +72,7 @@ async function read_record(): Promise<RunCarry | undefined> {
|
|
|
71
72
|
|
|
72
73
|
const read = run_carry.read_carry(run_carry.carry_path(target))
|
|
73
74
|
|
|
74
|
-
return read.kind === 'carried'
|
|
75
|
+
return read.kind === 'carried' ? read.carry : undefined
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
async function read_offer(
|
|
@@ -204,6 +205,11 @@ async function seeded_lanes(context: DriveContext): Promise<ReadonlyArray<string
|
|
|
204
205
|
return backlog_drive_restore.restore(lanes, events, carry.merged_issues ?? [])
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// The interval between two asks is `backlog:budget`'s own, so the driver keeps the one its reason prints.
|
|
209
|
+
function loop_config(window_ms: number | undefined): LoopConfig {
|
|
210
|
+
return { poll_ms: POLL_MS, offer_ms: backlog_budget.OFFER_INTERVAL_MS, window_ms }
|
|
211
|
+
}
|
|
212
|
+
|
|
207
213
|
async function drive(context: DriveContext): Promise<number> {
|
|
208
214
|
const seeded = await seeded_lanes(context)
|
|
209
215
|
|
|
@@ -212,7 +218,7 @@ async function drive(context: DriveContext): Promise<number> {
|
|
|
212
218
|
const initial =
|
|
213
219
|
context.stopped === undefined ? fresh : backlog_drive.merge_stopped(context.stopped, fresh)
|
|
214
220
|
const last = { state: { ...initial, exclude: [...context.exclude] } }
|
|
215
|
-
const config =
|
|
221
|
+
const config = loop_config(context.window_ms)
|
|
216
222
|
const end = await backlog_drive.run_loop(last.state, config, ports_of(context, seeded, last))
|
|
217
223
|
|
|
218
224
|
console.info(end_line(end))
|
|
@@ -258,6 +264,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
258
264
|
const backlog_drive_cli = {
|
|
259
265
|
end_line,
|
|
260
266
|
finish: backlog_drive_finish.finish,
|
|
267
|
+
loop_config,
|
|
261
268
|
merge,
|
|
262
269
|
merge_token,
|
|
263
270
|
offer_argv: backlog_drive_offer_argv.offer_argv,
|
|
@@ -140,9 +140,12 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// Only an empty backlog drains: `backlog:offer` marks the drain on `exhausted` alone, so a watch over a
|
|
144
|
+
// parked or unreadable backlog has no retrospective for `run:step` to fire and stays the driver's.
|
|
143
145
|
function is_drain(read: OfferRead, state: DriveState): boolean {
|
|
144
146
|
return (
|
|
145
147
|
read.verdict === WATCH_VERDICT &&
|
|
148
|
+
read.answer === 'exhausted' &&
|
|
146
149
|
state.in_flight.length === 0 &&
|
|
147
150
|
read.is_retrospective_owed === true
|
|
148
151
|
)
|
|
@@ -352,8 +355,8 @@ async function run_pass(
|
|
|
352
355
|
|
|
353
356
|
interface LoopConfig {
|
|
354
357
|
poll_ms: number
|
|
355
|
-
// The floor between two `backlog:offer` asks while nothing was collected
|
|
356
|
-
//
|
|
358
|
+
// The floor between two `backlog:offer` asks while nothing was collected, running or idle alike —
|
|
359
|
+
// `backlog_budget.OFFER_INTERVAL_MS`, the interval the wake supervisor polls on too.
|
|
357
360
|
offer_ms: number
|
|
358
361
|
// How long one invocation may wait before returning `window`, or `undefined` for no bound. A headless
|
|
359
362
|
// parent's foreground call is capped by its shell's timeout, so it passes one under that cap.
|
|
@@ -125,27 +125,19 @@ const budget_read_schema = z.object({
|
|
|
125
125
|
[BUDGET_JSON_KEY]: z.string(),
|
|
126
126
|
reason: z.string(),
|
|
127
127
|
is_finish: z.boolean(),
|
|
128
|
-
idle: z.string().optional(),
|
|
129
128
|
})
|
|
130
129
|
|
|
131
130
|
interface BudgetRead {
|
|
132
131
|
verdict: string
|
|
133
132
|
reason: string
|
|
134
133
|
is_finish: boolean
|
|
135
|
-
// The idle watch's window, as `backlog_idle.text_of` wrote it, when the budget opened one.
|
|
136
|
-
idle?: string | undefined
|
|
137
134
|
}
|
|
138
135
|
|
|
139
136
|
function budget_of(out: string): BudgetRead | undefined {
|
|
140
137
|
const read = json_value.parse_with(out, budget_read_schema)
|
|
141
138
|
if (read === undefined) return undefined
|
|
142
139
|
|
|
143
|
-
return {
|
|
144
|
-
verdict: read[BUDGET_JSON_KEY],
|
|
145
|
-
reason: read.reason,
|
|
146
|
-
is_finish: read.is_finish,
|
|
147
|
-
idle: read.idle,
|
|
148
|
-
}
|
|
140
|
+
return { verdict: read[BUDGET_JSON_KEY], reason: read.reason, is_finish: read.is_finish }
|
|
149
141
|
}
|
|
150
142
|
|
|
151
143
|
function to_tokens(out: string): ReadonlyArray<string> {
|
|
@@ -225,12 +217,6 @@ async function decide(values: ParsedValues, counts: OfferCounts): Promise<number
|
|
|
225
217
|
|
|
226
218
|
await mark_drain(decision.verdict, offer.answer, counts.running)
|
|
227
219
|
|
|
228
|
-
// The idle window with the ask that read it, once per watching poll, so `run:board` counts the next
|
|
229
|
-
// check from the poll that ran rather than guessing one.
|
|
230
|
-
if (decision.idle !== undefined) {
|
|
231
|
-
await run_event_stream_emit.emit_changed(run_event_stream.EVENT_KIND.IDLE, decision.idle)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
220
|
return emit(decision, offer, values.json === true)
|
|
235
221
|
}
|
|
236
222
|
|
|
@@ -111,7 +111,7 @@ function is_still_runnable(
|
|
|
111
111
|
// A read that failed withholds the candidate rather than confirming it. "Could not tell" is not
|
|
112
112
|
// "nothing blocks it", and this is the one direction the guard may not fail in, because that answer
|
|
113
113
|
// *starts* work — the same reason `epic-busy.ts` answers `wait` for a listing it could not read. The
|
|
114
|
-
// caller then offers the next candidate or waits, and the
|
|
114
|
+
// caller then offers the next candidate or waits, and the idle wait bounds the wait.
|
|
115
115
|
async function read_or_withhold(
|
|
116
116
|
candidate: EpicChild,
|
|
117
117
|
read_blockers: BlockersReader,
|