@yaawn/workflow 0.3.1 → 0.5.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/README.md +22 -43
- package/agents/personas/yaawn-product-owner.md +4 -4
- package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
- package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
- package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +8 -1
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
- package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
- package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
- package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
- package/agents/skills/yaawn-split-story/SKILL.md +1 -1
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
- package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
- package/agents/skills/yaawn-write-other/SKILL.md +6 -5
- package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
- package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
- package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
- package/agents/yaawn-glossary.md +6 -5
- package/config/install-tasks.json +0 -1
- package/config/wizard.json +5 -10
- package/dist/cli/args.js +66 -0
- package/dist/cli/errors.js +103 -0
- package/dist/cli/exit-codes.js +3 -0
- package/dist/cli/index.js +11 -0
- package/dist/cli/install-report.js +92 -0
- package/dist/cli/install.js +124 -0
- package/dist/cli/main.js +17 -0
- package/dist/cli/package-root.js +17 -0
- package/dist/cli/project-root.js +11 -0
- package/dist/cli/run.js +141 -0
- package/dist/cli/session.js +1 -0
- package/dist/cli/terminal.js +5 -0
- package/dist/cli/uninstall.js +89 -0
- package/dist/cli/version.js +7 -0
- package/dist/config/agents.js +108 -0
- package/dist/config/config.js +118 -0
- package/dist/config/errors.js +45 -0
- package/dist/config/index.js +11 -0
- package/dist/config/manifest.js +28 -0
- package/dist/config/messages.js +86 -0
- package/dist/config/names.js +40 -0
- package/dist/config/paths.js +73 -0
- package/dist/config/settings.js +101 -0
- package/dist/config/strict-json.js +119 -0
- package/dist/config/tasks.js +77 -0
- package/dist/config/wizard.js +139 -0
- package/dist/display/index.js +3 -0
- package/dist/display/palette.js +61 -0
- package/dist/display/renderer.js +169 -0
- package/dist/display/report.js +15 -0
- package/dist/display/sanitize.js +10 -0
- package/dist/link/errors.js +34 -0
- package/dist/link/fs-utils.js +79 -0
- package/dist/link/index.js +4 -0
- package/dist/link/install.js +20 -0
- package/dist/link/linker.js +137 -0
- package/dist/link/memory.js +221 -0
- package/dist/link/names.js +30 -0
- package/dist/link/types.js +30 -0
- package/dist/link/uninstall.js +90 -0
- package/dist/sync/apply.js +74 -0
- package/dist/sync/changes.js +31 -0
- package/dist/sync/fs-utils.js +63 -0
- package/dist/sync/index.js +7 -0
- package/dist/sync/install.js +57 -0
- package/dist/sync/languages.js +80 -0
- package/dist/sync/plan.js +146 -0
- package/dist/sync/projects.js +294 -0
- package/dist/sync/uninstall.js +234 -0
- package/dist/wizard/build.js +89 -0
- package/dist/wizard/index.js +5 -0
- package/dist/wizard/items.js +83 -0
- package/dist/wizard/prompts.js +167 -0
- package/dist/wizard/section.js +143 -0
- package/dist/wizard/wizard.js +73 -0
- package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
- package/package.json +11 -10
- package/yaawn/yaawn-user-roles.md +1 -1
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
- package/scripts/install.js +0 -73
|
@@ -13,10 +13,12 @@ import path from "node:path";
|
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
14
|
import { createInterface as createPrompt } from "node:readline/promises";
|
|
15
15
|
import type { Readable } from "node:stream";
|
|
16
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
16
17
|
import { promisify } from "node:util";
|
|
17
18
|
import {
|
|
18
19
|
type AgentChoice,
|
|
19
20
|
type AgentName,
|
|
21
|
+
agentNames,
|
|
20
22
|
readAgent,
|
|
21
23
|
} from "./agent.js";
|
|
22
24
|
import { keepMacAwakeWhileRunning, printWalkAwayBanner } from "./caffeinate.js";
|
|
@@ -38,7 +40,13 @@ import {
|
|
|
38
40
|
laneOwners,
|
|
39
41
|
lanePairs,
|
|
40
42
|
} from "./groups.js";
|
|
41
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
type LeftoverSession,
|
|
45
|
+
drainOutput,
|
|
46
|
+
findLeftoverAgentSessions,
|
|
47
|
+
isProcessAlive,
|
|
48
|
+
killProcessGroupByPid,
|
|
49
|
+
} from "./processes.js";
|
|
42
50
|
import {
|
|
43
51
|
type LaneOutcomeState,
|
|
44
52
|
type StageLabel,
|
|
@@ -70,12 +78,12 @@ import {
|
|
|
70
78
|
import {
|
|
71
79
|
type MainCopyCheck,
|
|
72
80
|
blockedUpdateReason,
|
|
73
|
-
branchName,
|
|
74
81
|
describeMainCopyState,
|
|
75
82
|
fetchMainCopy,
|
|
76
83
|
git,
|
|
77
84
|
isBareLayout,
|
|
78
|
-
|
|
85
|
+
isPrototype,
|
|
86
|
+
laneWorktreeAndBranch,
|
|
79
87
|
registeredWorktrees,
|
|
80
88
|
syncMainCopy,
|
|
81
89
|
} from "./worktrees.js";
|
|
@@ -92,9 +100,11 @@ export interface LaunchRequest {
|
|
|
92
100
|
type LaneState = LaneOutcomeState;
|
|
93
101
|
|
|
94
102
|
// The lane issue's title names a new worktree directory; an existing checkout
|
|
95
|
-
// of the lane branch is reused instead.
|
|
103
|
+
// of the lane branch is reused instead. Its labels mark a prototype, whose
|
|
104
|
+
// worktree and branch live under `prototype/`.
|
|
96
105
|
interface TitledLane extends Lane {
|
|
97
106
|
title: string;
|
|
107
|
+
labels: string[];
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
// What a pre-flight lookup found, or why it could not look.
|
|
@@ -119,6 +129,7 @@ interface LaneRun {
|
|
|
119
129
|
lane: TitledLane;
|
|
120
130
|
agent: AgentName;
|
|
121
131
|
worktree: string;
|
|
132
|
+
branch: string;
|
|
122
133
|
state: LaneState;
|
|
123
134
|
stage: StageLabel;
|
|
124
135
|
tail: string[];
|
|
@@ -219,9 +230,21 @@ function resolvedLane(issue: number): Lane {
|
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
function titledLane(lane: Lane): TitledLane {
|
|
222
|
-
|
|
233
|
+
const [issue] = lane.targets;
|
|
234
|
+
return { ...lane, title: issue.title, labels: issue.labels };
|
|
223
235
|
}
|
|
224
236
|
|
|
237
|
+
// Both the worktree path and its actual branch come from one porcelain
|
|
238
|
+
// lookup, so a relaunch after a rename reports the branch that is really
|
|
239
|
+
// checked out — never a branch recomputed from the new title.
|
|
240
|
+
const laneWorktreeAndBranchFor = (
|
|
241
|
+
lane: TitledLane,
|
|
242
|
+
): { path: string; branch: string } =>
|
|
243
|
+
laneWorktreeAndBranch(lane.issue, lane.title, lane.labels);
|
|
244
|
+
|
|
245
|
+
const prototypeNote = (issue: number): string =>
|
|
246
|
+
`prototype: the branch is never merged and no pull request is opened — run /yaawn-extract-learnings ${issue}`;
|
|
247
|
+
|
|
225
248
|
async function lookedUpLane(issue: number): Promise<Lookup<TitledLane[]>> {
|
|
226
249
|
return lookup(
|
|
227
250
|
resolveTargetsAsync(issue).then((targets) => [
|
|
@@ -266,9 +289,8 @@ function branchExists(branch: string): boolean {
|
|
|
266
289
|
}
|
|
267
290
|
|
|
268
291
|
// Reused on relaunch: an existing worktree keeps the work of an earlier run.
|
|
269
|
-
function ensureWorktree(worktree: string,
|
|
292
|
+
function ensureWorktree(worktree: string, branch: string): void {
|
|
270
293
|
if (registeredWorktrees().has(worktree) && existsSync(worktree)) return;
|
|
271
|
-
const branch = branchName(issue);
|
|
272
294
|
const args = branchExists(branch)
|
|
273
295
|
? ["worktree", "add", worktree, branch]
|
|
274
296
|
: ["worktree", "add", "-b", branch, worktree, "HEAD"];
|
|
@@ -475,7 +497,7 @@ function printMainCopyState(check: MainCopyCheck): void {
|
|
|
475
497
|
}
|
|
476
498
|
|
|
477
499
|
function worktreeFailures(lane: TitledLane): string[] {
|
|
478
|
-
const worktree =
|
|
500
|
+
const { path: worktree } = laneWorktreeAndBranchFor(lane);
|
|
479
501
|
return existsSync(worktree) && !registeredWorktrees().has(worktree)
|
|
480
502
|
? [
|
|
481
503
|
`lane #${lane.issue}: ${worktree} exists but is not a git worktree of this repository`,
|
|
@@ -702,7 +724,9 @@ function printImplementationGroups(groups: ImplementationGroup[]): void {
|
|
|
702
724
|
}
|
|
703
725
|
|
|
704
726
|
function describeLane(lane: TitledLane): string[] {
|
|
705
|
-
const
|
|
727
|
+
const prototype = isPrototype(lane.labels) ? ` (${prototypeNote(lane.issue)})` : "";
|
|
728
|
+
const { path: worktree, branch } = laneWorktreeAndBranchFor(lane);
|
|
729
|
+
const header = ` #${lane.issue} — worktree ${worktree}, branch ${branch}${prototype}`;
|
|
706
730
|
const targets = lane.targets.map((target, index) => {
|
|
707
731
|
const after =
|
|
708
732
|
target.dependsOn.length > 0
|
|
@@ -774,6 +798,8 @@ async function checkedLanes(
|
|
|
774
798
|
...mainCopyFailures(mainCopy, changes.value),
|
|
775
799
|
...conflicts,
|
|
776
800
|
];
|
|
801
|
+
const leftoversCleared =
|
|
802
|
+
failures.length === 0 && (await leftoverSessionsCleared(lanes));
|
|
777
803
|
printLanes(lanes, request.maxParallel);
|
|
778
804
|
printFailures(failures);
|
|
779
805
|
printMainCopyState(mainCopy);
|
|
@@ -784,9 +810,123 @@ async function checkedLanes(
|
|
|
784
810
|
if (!groups) printCompatibleSuggestion(lanes, conflicts);
|
|
785
811
|
return null;
|
|
786
812
|
}
|
|
813
|
+
if (!leftoversCleared) return null;
|
|
787
814
|
return { lanes, changes: changes.value };
|
|
788
815
|
}
|
|
789
816
|
|
|
817
|
+
// --- leftover agent sessions
|
|
818
|
+
|
|
819
|
+
const leftoverStopTimeoutMs = 5000;
|
|
820
|
+
const leftoverPollIntervalMs = 250;
|
|
821
|
+
|
|
822
|
+
// Paths come from the process table: control characters are replaced so
|
|
823
|
+
// a crafted directory name cannot rewrite the terminal.
|
|
824
|
+
const printable = (text: string): string =>
|
|
825
|
+
// eslint-disable-next-line no-control-regex -- matches the C0/C1 control characters a crafted path could use to rewrite the terminal
|
|
826
|
+
text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
827
|
+
|
|
828
|
+
function describeLeftover(session: LeftoverSession): string {
|
|
829
|
+
const cwd = session.cwd === null ? "unreadable" : printable(session.cwd);
|
|
830
|
+
const worktree = session.worktree ?? "unknown";
|
|
831
|
+
return ` pid ${session.pid} — ${session.agent}, cwd ${cwd}, worktree ${worktree}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function printLeftovers(sessions: LeftoverSession[]): void {
|
|
835
|
+
console.log(
|
|
836
|
+
"--- agent sessions from a previous run are still running in the lane worktrees (an unreadable working directory counts, to fail closed)",
|
|
837
|
+
);
|
|
838
|
+
sessions.map(describeLeftover).forEach((line) => console.log(line));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function scannedLeftovers(
|
|
842
|
+
lanes: TitledLane[],
|
|
843
|
+
): Promise<LeftoverSession[] | null> {
|
|
844
|
+
const worktrees = lanes.map((lane) => laneWorktreeAndBranchFor(lane).path);
|
|
845
|
+
try {
|
|
846
|
+
return await findLeftoverAgentSessions(worktrees, agentNames);
|
|
847
|
+
} catch (error) {
|
|
848
|
+
printFailures([
|
|
849
|
+
`could not check for leftover agent sessions: ${errorMessage(error)}`,
|
|
850
|
+
]);
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Anything but an explicit "stop" refuses: the orchestrator never kills on
|
|
856
|
+
// its own, and never without a terminal to ask.
|
|
857
|
+
async function leftoverChoice(): Promise<"stop" | "refuse"> {
|
|
858
|
+
if (!process.stdin.isTTY) {
|
|
859
|
+
console.log(
|
|
860
|
+
"no terminal to choose whether to stop them; refusing to start. Stop the sessions (or ask the human) before launching",
|
|
861
|
+
);
|
|
862
|
+
return "refuse";
|
|
863
|
+
}
|
|
864
|
+
const prompt = createPrompt({ input: process.stdin, output: process.stdout });
|
|
865
|
+
try {
|
|
866
|
+
const answer = await prompt.question(
|
|
867
|
+
"stop them, or refuse to start? [stop/refuse] (default: refuse) ",
|
|
868
|
+
);
|
|
869
|
+
return /^stop( them)?$/i.test(answer.trim()) ? "stop" : "refuse";
|
|
870
|
+
} finally {
|
|
871
|
+
prompt.close();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function survivorsAfterStop(pids: number[]): Promise<number[]> {
|
|
876
|
+
const deadline = Date.now() + leftoverStopTimeoutMs;
|
|
877
|
+
let alive = pids.filter(isProcessAlive);
|
|
878
|
+
while (alive.length > 0 && Date.now() < deadline) {
|
|
879
|
+
await delay(leftoverPollIntervalMs);
|
|
880
|
+
alive = alive.filter(isProcessAlive);
|
|
881
|
+
}
|
|
882
|
+
return alive;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function signalLeftovers(pids: number[]): string[] {
|
|
886
|
+
return pids.flatMap((pid) => {
|
|
887
|
+
try {
|
|
888
|
+
killProcessGroupByPid(pid, "SIGTERM");
|
|
889
|
+
return [];
|
|
890
|
+
} catch (error) {
|
|
891
|
+
return [`could not stop pid ${pid}: ${errorMessage(error)}`];
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function stoppedLeftovers(sessions: LeftoverSession[]): Promise<boolean> {
|
|
897
|
+
const pids = sessions.map((session) => session.pid);
|
|
898
|
+
const signalFailures = signalLeftovers(pids);
|
|
899
|
+
const survivors = await survivorsAfterStop(pids);
|
|
900
|
+
const failures = [
|
|
901
|
+
...signalFailures,
|
|
902
|
+
...survivors.map(
|
|
903
|
+
(pid) =>
|
|
904
|
+
`pid ${pid} still running ${leftoverStopTimeoutMs / 1000}s after SIGTERM — not starting`,
|
|
905
|
+
),
|
|
906
|
+
];
|
|
907
|
+
printFailures(failures);
|
|
908
|
+
if (failures.length > 0) return false;
|
|
909
|
+
console.log("leftover agent sessions stopped");
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Runs after the lanes resolve and before the results print, only once
|
|
914
|
+
// every other check passed, so the sessions of another active run (whose
|
|
915
|
+
// lanes already fail the pre-flight) are never offered for stopping.
|
|
916
|
+
async function leftoverSessionsCleared(lanes: TitledLane[]): Promise<boolean> {
|
|
917
|
+
const sessions = await scannedLeftovers(lanes);
|
|
918
|
+
if (sessions === null) return false;
|
|
919
|
+
if (sessions.length === 0) return true;
|
|
920
|
+
printLeftovers(sessions);
|
|
921
|
+
const choice = await leftoverChoice();
|
|
922
|
+
if (choice === "refuse") {
|
|
923
|
+
console.log("leftover agent sessions: chose to refuse to start");
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
console.log("leftover agent sessions: chose to stop them");
|
|
927
|
+
return stoppedLeftovers(sessions);
|
|
928
|
+
}
|
|
929
|
+
|
|
790
930
|
// Without a terminal nobody can answer: fail closed so preflight never
|
|
791
931
|
// passes without a confirmation, and the caller asks the human instead.
|
|
792
932
|
async function confirmedUncommittedChanges(): Promise<boolean> {
|
|
@@ -1026,7 +1166,8 @@ function laneReport(run: LaneRun, body: string): string {
|
|
|
1026
1166
|
return [
|
|
1027
1167
|
body,
|
|
1028
1168
|
`worktree: ${run.worktree}`,
|
|
1029
|
-
`branch: ${
|
|
1169
|
+
`branch: ${run.branch}`,
|
|
1170
|
+
...(isPrototype(run.lane.labels) ? [prototypeNote(run.lane.issue)] : []),
|
|
1030
1171
|
].join("\n\n");
|
|
1031
1172
|
}
|
|
1032
1173
|
|
|
@@ -1088,7 +1229,7 @@ async function runLane(
|
|
|
1088
1229
|
setState(run, "started");
|
|
1089
1230
|
saveRunningResult(run);
|
|
1090
1231
|
try {
|
|
1091
|
-
ensureWorktree(run.worktree, run.
|
|
1232
|
+
ensureWorktree(run.worktree, run.branch);
|
|
1092
1233
|
} catch (error) {
|
|
1093
1234
|
run.tail.push(
|
|
1094
1235
|
`could not create worktree ${run.worktree}: ${errorMessage(error)}`,
|
|
@@ -1135,10 +1276,12 @@ function combinedResult(runs: LaneRun[]): string {
|
|
|
1135
1276
|
}
|
|
1136
1277
|
|
|
1137
1278
|
function newLaneRun(lane: TitledLane, agent: AgentName): LaneRun {
|
|
1279
|
+
const { path: worktree, branch } = laneWorktreeAndBranchFor(lane);
|
|
1138
1280
|
return {
|
|
1139
1281
|
lane,
|
|
1140
1282
|
agent,
|
|
1141
|
-
worktree
|
|
1283
|
+
worktree,
|
|
1284
|
+
branch,
|
|
1142
1285
|
state: "started",
|
|
1143
1286
|
stage: laneStartedLabel,
|
|
1144
1287
|
tail: [],
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ChildProcess, execFile } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import type { Readable } from "node:stream";
|
|
3
5
|
import { finished } from "node:stream/promises";
|
|
4
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -51,3 +53,161 @@ export function killProcessGroup(
|
|
|
51
53
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
// Signals a process group by its leader's pid. Never pid 0 or 1: -0 and -1
|
|
58
|
+
// would reach the caller's own group or every process the user may signal.
|
|
59
|
+
export function killProcessGroupByPid(
|
|
60
|
+
pid: number,
|
|
61
|
+
signal: NodeJS.Signals,
|
|
62
|
+
): void {
|
|
63
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) {
|
|
64
|
+
throw new Error(`refusing to signal invalid pid ${pid}`);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
process.kill(-pid, signal);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Signal 0 only checks: ESRCH means gone, EPERM means alive but not ours.
|
|
74
|
+
export function isProcessAlive(pid: number): boolean {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- leftover agent sessions
|
|
84
|
+
|
|
85
|
+
export interface LeftoverSession {
|
|
86
|
+
pid: number;
|
|
87
|
+
agent: string;
|
|
88
|
+
cwd: string | null;
|
|
89
|
+
worktree: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CommandResult {
|
|
93
|
+
exitCode: number;
|
|
94
|
+
stdout: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Resolves with the exit code when the command ran; rejects only when it
|
|
98
|
+
// could not run at all (e.g. ENOENT). Always an argument array, never a shell.
|
|
99
|
+
export type CommandRunner = (
|
|
100
|
+
command: string,
|
|
101
|
+
args: string[],
|
|
102
|
+
) => Promise<CommandResult>;
|
|
103
|
+
|
|
104
|
+
interface AgentProcess {
|
|
105
|
+
pid: number;
|
|
106
|
+
agent: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const maxCommandOutputBytes = 16 * 1024 * 1024;
|
|
110
|
+
const processLinePattern = /^\s*(\d+)\s+(.+)$/;
|
|
111
|
+
|
|
112
|
+
export const runCommand: CommandRunner = (command, args) =>
|
|
113
|
+
new Promise((resolve, reject) => {
|
|
114
|
+
execFile(
|
|
115
|
+
command,
|
|
116
|
+
args,
|
|
117
|
+
{ encoding: "utf-8", maxBuffer: maxCommandOutputBytes },
|
|
118
|
+
(error, stdout) => {
|
|
119
|
+
const exitCode = (error as { code?: unknown } | null)?.code;
|
|
120
|
+
if (error && typeof exitCode !== "number") reject(error);
|
|
121
|
+
else resolve({ exitCode: error ? (exitCode as number) : 0, stdout });
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function agentProcessFrom(
|
|
127
|
+
line: string,
|
|
128
|
+
agentNames: readonly string[],
|
|
129
|
+
): AgentProcess | null {
|
|
130
|
+
const match = processLinePattern.exec(line);
|
|
131
|
+
if (!match) return null;
|
|
132
|
+
const pid = Number(match[1]);
|
|
133
|
+
// macOS reports comm as the full executable path.
|
|
134
|
+
const agent = path.basename(match[2].trim());
|
|
135
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) return null;
|
|
136
|
+
return agentNames.includes(agent) ? { pid, agent } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function agentProcesses(
|
|
140
|
+
agentNames: readonly string[],
|
|
141
|
+
run: CommandRunner,
|
|
142
|
+
): Promise<AgentProcess[]> {
|
|
143
|
+
const { exitCode, stdout } = await run("ps", ["-eo", "pid=,comm="]);
|
|
144
|
+
if (exitCode !== 0) throw new Error(`ps exited with code ${exitCode}`);
|
|
145
|
+
return stdout
|
|
146
|
+
.split("\n")
|
|
147
|
+
.map((line) => agentProcessFrom(line, agentNames))
|
|
148
|
+
.filter((found): found is AgentProcess => found !== null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A non-zero lsof exit means this one process is unreadable (it vanished or
|
|
152
|
+
// belongs to someone else): null, so the caller fails closed and reports it.
|
|
153
|
+
// lsof that cannot run at all rejects, so the whole check fails.
|
|
154
|
+
async function workingDirectory(
|
|
155
|
+
pid: number,
|
|
156
|
+
run: CommandRunner,
|
|
157
|
+
): Promise<string | null> {
|
|
158
|
+
const { exitCode, stdout } = await run("lsof", [
|
|
159
|
+
"-a",
|
|
160
|
+
"-d",
|
|
161
|
+
"cwd",
|
|
162
|
+
"-p",
|
|
163
|
+
String(pid),
|
|
164
|
+
"-Fn",
|
|
165
|
+
]);
|
|
166
|
+
if (exitCode !== 0) return null;
|
|
167
|
+
const nameLine = stdout.split("\n").find((line) => line.startsWith("n/"));
|
|
168
|
+
return nameLine ? nameLine.slice(1) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function canonicalPath(target: string): string {
|
|
172
|
+
try {
|
|
173
|
+
return realpathSync(target);
|
|
174
|
+
} catch {
|
|
175
|
+
return path.resolve(target);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function containingWorktree(cwd: string, worktrees: string[]): string | null {
|
|
180
|
+
const resolved = path.resolve(cwd);
|
|
181
|
+
return (
|
|
182
|
+
worktrees.find(
|
|
183
|
+
(worktree) =>
|
|
184
|
+
resolved === worktree || resolved.startsWith(worktree + path.sep),
|
|
185
|
+
) ?? null
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function leftoverSession(
|
|
190
|
+
found: AgentProcess,
|
|
191
|
+
worktrees: string[],
|
|
192
|
+
run: CommandRunner,
|
|
193
|
+
): Promise<LeftoverSession | null> {
|
|
194
|
+
const cwd = await workingDirectory(found.pid, run);
|
|
195
|
+
if (cwd === null) return { ...found, cwd: null, worktree: null };
|
|
196
|
+
const worktree = containingWorktree(cwd, worktrees);
|
|
197
|
+
return worktree === null ? null : { ...found, cwd, worktree };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Agent sessions still running inside one of the worktrees. Stateless: a
|
|
201
|
+
// process-name and working-directory scan. Throws when the scan cannot run.
|
|
202
|
+
export async function findLeftoverAgentSessions(
|
|
203
|
+
worktrees: string[],
|
|
204
|
+
agentNames: readonly string[],
|
|
205
|
+
run: CommandRunner = runCommand,
|
|
206
|
+
): Promise<LeftoverSession[]> {
|
|
207
|
+
const canonicalWorktrees = worktrees.map(canonicalPath);
|
|
208
|
+
const candidates = await agentProcesses(agentNames, run);
|
|
209
|
+
const sessions = await Promise.all(
|
|
210
|
+
candidates.map((found) => leftoverSession(found, canonicalWorktrees, run)),
|
|
211
|
+
);
|
|
212
|
+
return sessions.filter((session): session is LeftoverSession => session !== null);
|
|
213
|
+
}
|
|
@@ -10,6 +10,7 @@ export interface Issue {
|
|
|
10
10
|
number: number;
|
|
11
11
|
title: string;
|
|
12
12
|
body: string;
|
|
13
|
+
labels: string[];
|
|
13
14
|
dependsOn: number[];
|
|
14
15
|
externalDependsOn: number[];
|
|
15
16
|
}
|
|
@@ -18,6 +19,7 @@ interface RawIssue {
|
|
|
18
19
|
number: number;
|
|
19
20
|
title: string;
|
|
20
21
|
body: string | null;
|
|
22
|
+
labels?: { name?: unknown }[] | null;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export const plansDir = ".yaawn/plans";
|
|
@@ -133,11 +135,20 @@ function parseDependsOn(body: string): number[] {
|
|
|
133
135
|
return [...line.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
// Only string label names count; anything else gh might return is dropped.
|
|
139
|
+
function rawLabelNames(labels: RawIssue["labels"]): string[] {
|
|
140
|
+
if (!Array.isArray(labels)) return [];
|
|
141
|
+
return labels
|
|
142
|
+
.map((label) => label?.name)
|
|
143
|
+
.filter((name): name is string => typeof name === "string");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toIssue({ number, title, body, labels }: RawIssue): Issue {
|
|
137
147
|
return {
|
|
138
148
|
number,
|
|
139
149
|
title,
|
|
140
150
|
body: body ?? "",
|
|
151
|
+
labels: rawLabelNames(labels),
|
|
141
152
|
dependsOn: parseDependsOn(body ?? ""),
|
|
142
153
|
externalDependsOn: [],
|
|
143
154
|
};
|
|
@@ -148,7 +159,7 @@ const fetchIssueArgs = (issue: number): string[] => [
|
|
|
148
159
|
"view",
|
|
149
160
|
String(issue),
|
|
150
161
|
"--json",
|
|
151
|
-
"number,title,body",
|
|
162
|
+
"number,title,body,labels",
|
|
152
163
|
];
|
|
153
164
|
|
|
154
165
|
const parseIssue = (output: string): Issue => toIssue(JSON.parse(output) as RawIssue);
|