pi-roundtable-coding 0.27.0 → 0.28.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/CHANGELOG.md +16 -0
- package/README.md +38 -10
- package/package.json +3 -3
- package/src/coding-desk.ts +429 -84
- package/src/coding-plugin.ts +105 -28
- package/src/index.ts +14 -2
- package/src/pi-coding-worker.ts +40 -4
- package/src/repo-shelf.ts +212 -1
- package/src/worker-entry.ts +1 -0
package/src/coding-plugin.ts
CHANGED
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
} from "pi-roundtable/kit";
|
|
23
23
|
import { Type } from "typebox";
|
|
24
24
|
import {
|
|
25
|
+
type CodingAutonomy,
|
|
25
26
|
CodingDesk,
|
|
26
27
|
type CodingJob,
|
|
27
28
|
type CodingLimits,
|
|
29
|
+
type CodingModes,
|
|
28
30
|
type CodingResult,
|
|
29
31
|
type CodingThreadText,
|
|
30
32
|
type CodingWorker,
|
|
@@ -76,6 +78,12 @@ export interface CodingPresentation {
|
|
|
76
78
|
direct: boolean,
|
|
77
79
|
): { post: string; result: string };
|
|
78
80
|
taskStarted?(job: CodingJob, skippedSkills: string[]): string;
|
|
81
|
+
/** The repo_task result for a task waiting for a worker slot, at `position` (from 1) in the queue. */
|
|
82
|
+
taskQueued?(
|
|
83
|
+
job: CodingJob,
|
|
84
|
+
position: number,
|
|
85
|
+
skippedSkills: string[],
|
|
86
|
+
): string;
|
|
79
87
|
}
|
|
80
88
|
export interface CodingOptions {
|
|
81
89
|
shelfDir: string;
|
|
@@ -122,6 +130,18 @@ export interface CodingOptions {
|
|
|
122
130
|
workerPackages?: string[];
|
|
123
131
|
agentDir?: string;
|
|
124
132
|
timeoutMs?: number;
|
|
133
|
+
/** Workers running at once on the host; default 4. More tasks wait in a queue. */
|
|
134
|
+
maxWorkers?: number;
|
|
135
|
+
/** Tasks waiting for a worker slot, first in first out; one more is refused. Default 16. */
|
|
136
|
+
maxQueued?: number;
|
|
137
|
+
/**
|
|
138
|
+
* The conversations' modes, such as `context.services.find(MODES)`: a worker then follows the
|
|
139
|
+
* yolo and long-running modes of the conversation whose owner turn started it, read live.
|
|
140
|
+
* Without them a worker asks a card for every held call and stops at `timeoutMs`.
|
|
141
|
+
*/
|
|
142
|
+
modes?: CodingModes;
|
|
143
|
+
/** The host's resolved `autonomy` limits, which the worker's yolo and long-running modes follow. */
|
|
144
|
+
autonomy?: CodingAutonomy;
|
|
125
145
|
/** Optional additional worker rules; linked host rules also apply. */
|
|
126
146
|
holds?: HoldCheck;
|
|
127
147
|
/** A different Git host or an offline clone implementation. */
|
|
@@ -131,6 +151,35 @@ export interface CodingOptions {
|
|
|
131
151
|
/** Override report delivery, for example to schedule a background conversation turn. */
|
|
132
152
|
onResult?: (result: CodingResult) => Promise<void>;
|
|
133
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* The worker's host hold check: the plugin's extra rules, else the linked host rules, with the
|
|
156
|
+
* classes of every check that holds a call so yolo mode can judge it; a check that holds a call
|
|
157
|
+
* without telling its classes leaves the call unclassed.
|
|
158
|
+
*/
|
|
159
|
+
function workerHolds(
|
|
160
|
+
extra: HoldCheck | undefined,
|
|
161
|
+
linked: () => HoldCheck,
|
|
162
|
+
): HoldCheck {
|
|
163
|
+
const check: HoldCheck = (tool, input, scope) =>
|
|
164
|
+
extra?.(tool, input, scope) ?? linked()(tool, input, scope);
|
|
165
|
+
check.classesOf = (tool, input, scope) => {
|
|
166
|
+
const found: string[] = [];
|
|
167
|
+
for (const holds of extra ? [extra, linked()] : [linked()]) {
|
|
168
|
+
if (holds(tool, input, scope) === undefined) continue;
|
|
169
|
+
const classes = holds.classesOf?.(tool, input, scope);
|
|
170
|
+
if (!classes?.length) return undefined;
|
|
171
|
+
found.push(...classes);
|
|
172
|
+
}
|
|
173
|
+
return found.length ? [...new Set(found)] : undefined;
|
|
174
|
+
};
|
|
175
|
+
return check;
|
|
176
|
+
}
|
|
177
|
+
function checkCount(name: string, value: number | undefined, least: number) {
|
|
178
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < least))
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${name} must be a whole number of at least ${least}; got ${value}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
134
183
|
async function refusal<T>(operation: () => Promise<T>): Promise<T> {
|
|
135
184
|
try {
|
|
136
185
|
return await operation();
|
|
@@ -150,6 +199,18 @@ export function coding(options: CodingOptions) {
|
|
|
150
199
|
checkLimit("limits.heldEntries", options.limits?.heldEntries);
|
|
151
200
|
checkLimit("limits.heldChars", options.limits?.heldChars);
|
|
152
201
|
checkLimit("diagnosticChars", options.diagnosticChars);
|
|
202
|
+
checkCount("maxWorkers", options.maxWorkers, 1);
|
|
203
|
+
checkCount("maxQueued", options.maxQueued, 0);
|
|
204
|
+
const longRunning = options.autonomy?.longRunningMaxMs;
|
|
205
|
+
if (
|
|
206
|
+
longRunning !== undefined &&
|
|
207
|
+
(!Number.isFinite(longRunning) ||
|
|
208
|
+
longRunning <= 0 ||
|
|
209
|
+
longRunning > 2_147_483_647)
|
|
210
|
+
)
|
|
211
|
+
throw new Error(
|
|
212
|
+
"autonomy.longRunningMaxMs must be positive, finite and at most 2147483647.",
|
|
213
|
+
);
|
|
153
214
|
} catch (error) {
|
|
154
215
|
throw new PluginError(
|
|
155
216
|
error instanceof Error ? error.message : String(error),
|
|
@@ -185,14 +246,20 @@ export function coding(options: CodingOptions) {
|
|
|
185
246
|
prompt: options.workerPrompt,
|
|
186
247
|
blockText: options.workerBlockText,
|
|
187
248
|
diagnosticChars: options.diagnosticChars,
|
|
188
|
-
holds: (
|
|
189
|
-
options.holds?.(tool, input, scope) ??
|
|
190
|
-
context.sessions().holds(tool, input, scope),
|
|
249
|
+
holds: workerHolds(options.holds, () => context.sessions().holds),
|
|
191
250
|
});
|
|
192
251
|
const desk = new CodingDesk({
|
|
193
252
|
shelf,
|
|
194
253
|
worker,
|
|
195
254
|
timeoutMs: options.timeoutMs,
|
|
255
|
+
...(options.maxWorkers !== undefined
|
|
256
|
+
? { maxWorkers: options.maxWorkers }
|
|
257
|
+
: {}),
|
|
258
|
+
...(options.maxQueued !== undefined
|
|
259
|
+
? { maxQueued: options.maxQueued }
|
|
260
|
+
: {}),
|
|
261
|
+
...(options.modes ? { modes: options.modes } : {}),
|
|
262
|
+
...(options.autonomy ? { autonomy: options.autonomy } : {}),
|
|
196
263
|
limits: options.limits,
|
|
197
264
|
logger: context.logger,
|
|
198
265
|
threads: options.threads,
|
|
@@ -215,23 +282,8 @@ export function coding(options: CodingOptions) {
|
|
|
215
282
|
});
|
|
216
283
|
}),
|
|
217
284
|
});
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
repo: string,
|
|
221
|
-
operation: () => Promise<T>,
|
|
222
|
-
): Promise<T> {
|
|
223
|
-
desk.checkIdle(repo);
|
|
224
|
-
if (shipping.has(repo))
|
|
225
|
-
throw new AgentError(
|
|
226
|
-
"A report or push is already in progress for this repository.",
|
|
227
|
-
);
|
|
228
|
-
shipping.add(repo);
|
|
229
|
-
try {
|
|
230
|
-
return await operation();
|
|
231
|
-
} finally {
|
|
232
|
-
shipping.delete(repo);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
285
|
+
const ship = <T>(repo: string, operation: () => Promise<T>) =>
|
|
286
|
+
desk.ship(repo, operation);
|
|
235
287
|
context.services.provide(CODING, { shelf, desk });
|
|
236
288
|
// Whether the principal holds the owner role; no identity service, or a failed lookup,
|
|
237
289
|
// tells of no one holding it.
|
|
@@ -427,7 +479,7 @@ export function coding(options: CodingOptions) {
|
|
|
427
479
|
minTier: "owner",
|
|
428
480
|
description: describe(
|
|
429
481
|
"repo_task",
|
|
430
|
-
"Start
|
|
482
|
+
"Start a background Pi coding worker on a clone. Each worker has its own worktree and branch, so several may work on one repository; when it finishes, its commits are rebased onto the clone and fast-forwarded, and a conflict keeps the branch and is reported. When every worker slot is taken the task waits in a queue. The worker reads repository instructions, edits, checks and commits; shipping uses a separate report and approval. Report returns to this channel.",
|
|
431
483
|
),
|
|
432
484
|
parameters: Type.Object({
|
|
433
485
|
repo: repoSchema("repo_task"),
|
|
@@ -447,7 +499,7 @@ export function coding(options: CodingOptions) {
|
|
|
447
499
|
throw new AgentError(
|
|
448
500
|
"the host's own turns, such as a report's, start no coding tasks; ask the owner to start it",
|
|
449
501
|
);
|
|
450
|
-
if (shipping
|
|
502
|
+
if (desk.shipping(repo))
|
|
451
503
|
throw new AgentError(
|
|
452
504
|
"A report or push is in progress for this repository.",
|
|
453
505
|
);
|
|
@@ -472,23 +524,48 @@ export function coding(options: CodingOptions) {
|
|
|
472
524
|
model: options.model,
|
|
473
525
|
thinking: options.thinking ?? "medium",
|
|
474
526
|
};
|
|
475
|
-
if (shipping
|
|
527
|
+
if (desk.shipping(repo))
|
|
476
528
|
throw new AgentError(
|
|
477
529
|
"A report or push is in progress for this repository.",
|
|
478
530
|
);
|
|
531
|
+
const { speaker } = turn;
|
|
479
532
|
const job = await desk.start({
|
|
480
533
|
...run,
|
|
534
|
+
// Its modes follow the conversation this turn runs in, as the turn's own would.
|
|
535
|
+
...(speaker
|
|
536
|
+
? {
|
|
537
|
+
starter: {
|
|
538
|
+
channel: turn.channel,
|
|
539
|
+
speaker: {
|
|
540
|
+
id: speaker.id,
|
|
541
|
+
principalId: speaker.principalId,
|
|
542
|
+
...(speaker.name ? { name: speaker.name } : {}),
|
|
543
|
+
tier: speaker.tier,
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
: {}),
|
|
481
548
|
repo,
|
|
482
549
|
task,
|
|
483
550
|
skillFiles: set?.skills.map((skill) => skill.file) ?? [],
|
|
484
551
|
skillNames: set?.skills.map((skill) => skill.name) ?? [],
|
|
485
552
|
});
|
|
553
|
+
const skipped = set?.missing.map((item) => item.name) ?? [];
|
|
554
|
+
const skippedText = skipped.length
|
|
555
|
+
? ` Unavailable implicit skills skipped: ${skipped.join(", ")}.`
|
|
556
|
+
: "";
|
|
557
|
+
if (job.queuePosition !== undefined)
|
|
558
|
+
return (
|
|
559
|
+
options.presentation?.taskQueued?.(
|
|
560
|
+
job,
|
|
561
|
+
job.queuePosition,
|
|
562
|
+
skipped,
|
|
563
|
+
) ??
|
|
564
|
+
`Queued coding task #${job.id} in ${repo} at position ${job.queuePosition}: every worker slot is taken, and it starts on ${job.model} when one frees. Its report returns to ${job.channel}.${skippedText}`
|
|
565
|
+
);
|
|
486
566
|
return (
|
|
487
|
-
options.presentation?.taskStarted?.(
|
|
488
|
-
|
|
489
|
-
set?.missing.map((item) => item.name) ?? [],
|
|
490
|
-
) ??
|
|
491
|
-
`Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to ${job.channel}.${set?.missing.length ? ` Unavailable implicit skills skipped: ${set.missing.map((item) => item.name).join(", ")}.` : ""}`
|
|
567
|
+
options.presentation?.taskStarted?.(job, skipped) ??
|
|
568
|
+
`Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to ${job.channel}.${skippedText}`
|
|
492
569
|
);
|
|
493
570
|
}),
|
|
494
571
|
}),
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
export type {
|
|
2
|
+
CodingAutonomy,
|
|
2
3
|
CodingDeskOptions,
|
|
3
4
|
CodingJob,
|
|
4
5
|
CodingLimits,
|
|
6
|
+
CodingModes,
|
|
5
7
|
CodingProgress,
|
|
6
8
|
CodingResult,
|
|
9
|
+
CodingSpeaker,
|
|
10
|
+
CodingStart,
|
|
11
|
+
CodingStarter,
|
|
7
12
|
CodingThreadText,
|
|
8
13
|
CodingWorker,
|
|
9
14
|
HeldCallAnswer,
|
|
10
15
|
} from "./coding-desk.ts";
|
|
11
|
-
export { CodingDesk, codingReport } from "./coding-desk.ts";
|
|
16
|
+
export { CodingDesk, codingReport, integrationText } from "./coding-desk.ts";
|
|
12
17
|
export type {
|
|
13
18
|
CodingOptions,
|
|
14
19
|
CodingPresentation,
|
|
@@ -23,9 +28,16 @@ export { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
|
23
28
|
export type {
|
|
24
29
|
ChangeReport,
|
|
25
30
|
CloneCommand,
|
|
31
|
+
Integration,
|
|
26
32
|
RepoState,
|
|
27
33
|
RepoSummary,
|
|
34
|
+
WorkerTree,
|
|
35
|
+
} from "./repo-shelf.ts";
|
|
36
|
+
export {
|
|
37
|
+
ghClone,
|
|
38
|
+
RepoShelf,
|
|
39
|
+
reportPost,
|
|
40
|
+
WORKTREES_DIR,
|
|
28
41
|
} from "./repo-shelf.ts";
|
|
29
|
-
export { ghClone, RepoShelf, reportPost } from "./repo-shelf.ts";
|
|
30
42
|
export { REPO_TOOLS } from "./repo-tools.ts";
|
|
31
43
|
export { CodingWorkerFailure } from "./worker-failure.ts";
|
package/src/pi-coding-worker.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { HeldCall, HoldCheck } from "pi-roundtable";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AgentError,
|
|
6
|
+
canonicalJson,
|
|
7
|
+
holdChain,
|
|
8
|
+
shellHoldRule,
|
|
9
|
+
} from "pi-roundtable/kit";
|
|
5
10
|
import type {
|
|
6
11
|
CodingJob,
|
|
7
12
|
CodingProgress,
|
|
@@ -55,6 +60,28 @@ function isMessage(value: unknown): value is WorkerMessage {
|
|
|
55
60
|
);
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
/** The worker's own shell rule as a check, for the classes of the calls it holds. */
|
|
64
|
+
const SHELL_CHECK: HoldCheck = holdChain([shellHoldRule]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The hold classes of a held call, from every check that held it, as the host's gate records them
|
|
68
|
+
* for yolo mode; undefined when a holding check tells none, which leaves the call unclassed.
|
|
69
|
+
*/
|
|
70
|
+
function classesOf(
|
|
71
|
+
tool: string,
|
|
72
|
+
input: Record<string, unknown>,
|
|
73
|
+
context: { workspace: string; scratchDir?: string },
|
|
74
|
+
holding: readonly HoldCheck[],
|
|
75
|
+
): string[] | undefined {
|
|
76
|
+
const found: string[] = [];
|
|
77
|
+
for (const check of holding) {
|
|
78
|
+
const classes = check.classesOf?.(tool, input, context);
|
|
79
|
+
if (!classes?.length) return undefined;
|
|
80
|
+
found.push(...classes);
|
|
81
|
+
}
|
|
82
|
+
return found.length ? [...new Set(found)] : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
/** A fresh Bun process per task; only the host decides whether a held call may run. */
|
|
59
86
|
export class PiCodingWorker implements CodingWorker {
|
|
60
87
|
readonly #options: PiCodingWorkerOptions;
|
|
@@ -149,15 +176,24 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
149
176
|
? { scratchDir: this.#options.scratchDir }
|
|
150
177
|
: {}),
|
|
151
178
|
};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
// Every check is asked once, so the classes cover each reason the call is held.
|
|
180
|
+
const shell = shellHoldRule.describe(tool, input, context);
|
|
181
|
+
const { holds } = this.#options;
|
|
182
|
+
const host = holds?.(tool, input, context);
|
|
183
|
+
const action = shell ?? host;
|
|
155
184
|
if (!signal.aborted) {
|
|
185
|
+
const holdClasses = action
|
|
186
|
+
? classesOf(tool, input, context, [
|
|
187
|
+
...(shell === undefined ? [] : [SHELL_CHECK]),
|
|
188
|
+
...(host === undefined || !holds ? [] : [holds]),
|
|
189
|
+
])
|
|
190
|
+
: undefined;
|
|
156
191
|
answer = action
|
|
157
192
|
? await review({
|
|
158
193
|
tool,
|
|
159
194
|
input: canonicalJson(input),
|
|
160
195
|
action,
|
|
196
|
+
...(holdClasses ? { holdClasses } : {}),
|
|
161
197
|
})
|
|
162
198
|
: "approved";
|
|
163
199
|
if (action && answer !== "approved")
|
package/src/repo-shelf.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readSync,
|
|
11
11
|
realpathSync,
|
|
12
12
|
renameSync,
|
|
13
|
+
rmSync,
|
|
13
14
|
} from "node:fs";
|
|
14
15
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
15
16
|
import { AgentError, checkRepoName, scrubDiagnostic } from "pi-roundtable/kit";
|
|
@@ -54,6 +55,36 @@ export interface RepoState {
|
|
|
54
55
|
|
|
55
56
|
export type CloneCommand = (repo: string, dir: string) => Promise<void>;
|
|
56
57
|
|
|
58
|
+
/** A coding worker's own checkout: a linked worktree on its own branch, cut from the clone's HEAD. */
|
|
59
|
+
export interface WorkerTree {
|
|
60
|
+
repo: string;
|
|
61
|
+
/** The worktree's real path, outside the shelf's list of managed repositories. */
|
|
62
|
+
dir: string;
|
|
63
|
+
/** The worker's branch, such as `worker/3`. */
|
|
64
|
+
branch: string;
|
|
65
|
+
/** The clone's full HEAD the branch was cut from. */
|
|
66
|
+
base: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What bringing a worker's branch back into its clone did. */
|
|
70
|
+
export type Integration =
|
|
71
|
+
/** The branch had no commits beyond the clone; the worktree and branch are gone. */
|
|
72
|
+
| { status: "nothing" }
|
|
73
|
+
/** Rebased onto the clone's branch and fast-forwarded it; the worktree and branch are gone. */
|
|
74
|
+
| { status: "merged"; branch: string; commits: string[] }
|
|
75
|
+
/** The rebase stopped on these files; it was aborted, and the branch and worktree are kept. */
|
|
76
|
+
| { status: "conflict"; branch: string; dir: string; files: string[] }
|
|
77
|
+
/** The clone or the worktree had uncommitted changes, or the clone moved; nothing was touched. */
|
|
78
|
+
| {
|
|
79
|
+
status: "refused";
|
|
80
|
+
branch: string;
|
|
81
|
+
dir: string;
|
|
82
|
+
reason: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The folder under the shelf that holds worker worktrees; the shelf lists no repository there. */
|
|
86
|
+
export const WORKTREES_DIR = ".worktrees";
|
|
87
|
+
|
|
57
88
|
const GIT_TIMEOUT_MS = 60_000;
|
|
58
89
|
const MAX_STAT_FILES = 40;
|
|
59
90
|
const MAX_SUMMARY_CHARS = 120;
|
|
@@ -218,7 +249,7 @@ export class RepoShelf {
|
|
|
218
249
|
if (!existsSync(this.dir)) return [];
|
|
219
250
|
// pi-lens-ignore: no-sort-without-comparator — the names are strings, and code-unit order gives the same list on every machine
|
|
220
251
|
return readdirSync(this.dir, { withFileTypes: true })
|
|
221
|
-
.filter((owner) => owner.isDirectory())
|
|
252
|
+
.filter((owner) => owner.isDirectory() && !owner.name.startsWith("."))
|
|
222
253
|
.flatMap((owner) =>
|
|
223
254
|
readdirSync(join(this.dir, owner.name), {
|
|
224
255
|
withFileTypes: true,
|
|
@@ -235,6 +266,7 @@ export class RepoShelf {
|
|
|
235
266
|
/** A managed repository's folder, or an error naming the managed ones. */
|
|
236
267
|
dirOf(repo: string): string {
|
|
237
268
|
checkRepoName(repo);
|
|
269
|
+
refuseHidden(repo);
|
|
238
270
|
const dir = join(this.dir, repo);
|
|
239
271
|
if (!existsSync(join(dir, ".git")))
|
|
240
272
|
throw new AgentError(
|
|
@@ -262,6 +294,7 @@ export class RepoShelf {
|
|
|
262
294
|
/** Clones a repository into the shelf (behavior 2). */
|
|
263
295
|
async add(repo: string): Promise<string> {
|
|
264
296
|
checkRepoName(repo);
|
|
297
|
+
refuseHidden(repo);
|
|
265
298
|
const dir = join(this.dir, repo);
|
|
266
299
|
if (existsSync(dir) || this.#adding.has(repo))
|
|
267
300
|
throw new AgentError(`${repo} is already present or being cloned.`);
|
|
@@ -398,6 +431,176 @@ export class RepoShelf {
|
|
|
398
431
|
return log ? log.split("\n") : [];
|
|
399
432
|
}
|
|
400
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Cuts a worktree for coding job `id` from the clone's HEAD, on a fresh branch `worker/<id>`
|
|
436
|
+
* (with a suffix when an earlier run kept one of that name), under `<shelf>/.worktrees/`.
|
|
437
|
+
*/
|
|
438
|
+
async addWorktree(repo: string, id: number): Promise<WorkerTree> {
|
|
439
|
+
const clone = this.dirOf(repo);
|
|
440
|
+
const base = await this.#git(clone, "rev-parse", "HEAD");
|
|
441
|
+
const parent = join(this.dir, WORKTREES_DIR, repo);
|
|
442
|
+
mkdirSync(parent, { recursive: true });
|
|
443
|
+
const rel = relative(realpathSync(this.dir), realpathSync(parent));
|
|
444
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
445
|
+
throw new AgentError("The worktree folder escapes the shelf.");
|
|
446
|
+
for (let attempt = 1; ; attempt++) {
|
|
447
|
+
const name = attempt === 1 ? `${id}` : `${id}-${attempt}`;
|
|
448
|
+
const branch = `worker/${name}`;
|
|
449
|
+
const dir = join(parent, name);
|
|
450
|
+
const taken =
|
|
451
|
+
existsSync(dir) ||
|
|
452
|
+
(
|
|
453
|
+
await run([
|
|
454
|
+
"git",
|
|
455
|
+
"-C",
|
|
456
|
+
clone,
|
|
457
|
+
"rev-parse",
|
|
458
|
+
"--verify",
|
|
459
|
+
"--quiet",
|
|
460
|
+
`refs/heads/${branch}`,
|
|
461
|
+
])
|
|
462
|
+
).code === 0;
|
|
463
|
+
if (taken) continue;
|
|
464
|
+
await this.#git(
|
|
465
|
+
clone,
|
|
466
|
+
"worktree",
|
|
467
|
+
"add",
|
|
468
|
+
"--quiet",
|
|
469
|
+
"-b",
|
|
470
|
+
branch,
|
|
471
|
+
dir,
|
|
472
|
+
base,
|
|
473
|
+
);
|
|
474
|
+
return { repo, dir: realpathSync(dir), branch, base };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Rebases the worker's branch onto the clone's current HEAD inside its worktree and
|
|
480
|
+
* fast-forwards the clone, then removes the worktree and branch. The clone is never left
|
|
481
|
+
* mid-rebase: a conflict aborts the rebase in the worktree and keeps both. A clone or worktree
|
|
482
|
+
* with uncommitted changes is not touched. The caller serializes integrations of one clone.
|
|
483
|
+
*/
|
|
484
|
+
async integrate(tree: WorkerTree): Promise<Integration> {
|
|
485
|
+
const clone = this.dirOf(tree.repo);
|
|
486
|
+
const kept = { branch: tree.branch, dir: tree.dir };
|
|
487
|
+
const dirty = await this.#git(clone, "status", "--porcelain");
|
|
488
|
+
if (dirty)
|
|
489
|
+
return {
|
|
490
|
+
status: "refused",
|
|
491
|
+
...kept,
|
|
492
|
+
reason: `the clone has uncommitted changes, so it was left as it is:\n${dirty}`,
|
|
493
|
+
};
|
|
494
|
+
const head = await this.#git(clone, "rev-parse", "HEAD");
|
|
495
|
+
const ahead = Number(
|
|
496
|
+
await this.#git(clone, "rev-list", "--count", `${head}..${tree.branch}`),
|
|
497
|
+
);
|
|
498
|
+
if (ahead === 0) {
|
|
499
|
+
await this.removeWorktree(tree);
|
|
500
|
+
return { status: "nothing" };
|
|
501
|
+
}
|
|
502
|
+
const treeDir = this.#treeDir(tree);
|
|
503
|
+
const treeDirty = await this.#git(treeDir, "status", "--porcelain");
|
|
504
|
+
if (treeDirty)
|
|
505
|
+
return {
|
|
506
|
+
status: "refused",
|
|
507
|
+
...kept,
|
|
508
|
+
reason: `the worker left uncommitted changes in its worktree:\n${treeDirty}`,
|
|
509
|
+
};
|
|
510
|
+
const rebase = await run(["git", "-C", treeDir, "rebase", "--quiet", head]);
|
|
511
|
+
if (rebase.code !== 0) {
|
|
512
|
+
const conflicted = await run([
|
|
513
|
+
"git",
|
|
514
|
+
"-C",
|
|
515
|
+
treeDir,
|
|
516
|
+
"diff",
|
|
517
|
+
"--name-only",
|
|
518
|
+
"--diff-filter=U",
|
|
519
|
+
]);
|
|
520
|
+
await run(["git", "-C", treeDir, "rebase", "--abort"]);
|
|
521
|
+
return {
|
|
522
|
+
status: "conflict",
|
|
523
|
+
...kept,
|
|
524
|
+
files: conflicted.out ? conflicted.out.split("\n") : [],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const merge = await run([
|
|
528
|
+
"git",
|
|
529
|
+
"-C",
|
|
530
|
+
clone,
|
|
531
|
+
"merge",
|
|
532
|
+
"--ff-only",
|
|
533
|
+
"--quiet",
|
|
534
|
+
tree.branch,
|
|
535
|
+
]);
|
|
536
|
+
if (merge.code !== 0)
|
|
537
|
+
return {
|
|
538
|
+
status: "refused",
|
|
539
|
+
...kept,
|
|
540
|
+
reason: `the clone could not fast-forward to the branch: ${scrubDiagnostic(merge.err, this.#diagnosticChars) || `exit ${merge.code}`}`,
|
|
541
|
+
};
|
|
542
|
+
const log = await this.#git(
|
|
543
|
+
clone,
|
|
544
|
+
"log",
|
|
545
|
+
"--format=%h %s",
|
|
546
|
+
"--abbrev=12",
|
|
547
|
+
`${head}..HEAD`,
|
|
548
|
+
);
|
|
549
|
+
await this.removeWorktree(tree);
|
|
550
|
+
return {
|
|
551
|
+
status: "merged",
|
|
552
|
+
branch: tree.branch,
|
|
553
|
+
commits: log ? log.split("\n") : [],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A worker worktree's state, as `state` reads a clone's. */
|
|
558
|
+
async treeState(tree: WorkerTree): Promise<RepoState> {
|
|
559
|
+
const dir = this.#treeDir(tree);
|
|
560
|
+
const status = await this.#git(dir, "status", "--porcelain");
|
|
561
|
+
return {
|
|
562
|
+
branch: await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD"),
|
|
563
|
+
head: await this.#git(dir, "rev-parse", "--short=12", "HEAD"),
|
|
564
|
+
uncommitted: status ? status.split("\n") : [],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** `<short sha> <subject>` of each commit on the worker's branch after its base, newest first. */
|
|
569
|
+
async treeCommits(tree: WorkerTree): Promise<string[]> {
|
|
570
|
+
const log = await this.#git(
|
|
571
|
+
this.#treeDir(tree),
|
|
572
|
+
"log",
|
|
573
|
+
"--format=%h %s",
|
|
574
|
+
"--abbrev=12",
|
|
575
|
+
`${tree.base}..HEAD`,
|
|
576
|
+
);
|
|
577
|
+
return log ? log.split("\n") : [];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Removes a worker's worktree and its branch; what is already gone is skipped. */
|
|
581
|
+
async removeWorktree(tree: WorkerTree): Promise<void> {
|
|
582
|
+
const clone = this.dirOf(tree.repo);
|
|
583
|
+
this.#treeDir(tree, false);
|
|
584
|
+
await run(["git", "-C", clone, "worktree", "remove", "--force", tree.dir]);
|
|
585
|
+
if (existsSync(tree.dir))
|
|
586
|
+
rmSync(tree.dir, { recursive: true, force: true });
|
|
587
|
+
await run(["git", "-C", clone, "worktree", "prune"]);
|
|
588
|
+
await run(["git", "-C", clone, "branch", "-D", "--quiet", tree.branch]);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** A worktree path, refused unless it lies in this repository's worktree folder. */
|
|
592
|
+
#treeDir(tree: WorkerTree, mustExist = true): string {
|
|
593
|
+
const parent = resolve(this.dir, WORKTREES_DIR, tree.repo);
|
|
594
|
+
const path = mustExist ? realpathSync(tree.dir) : resolve(tree.dir);
|
|
595
|
+
const root = existsSync(parent) ? realpathSync(parent) : parent;
|
|
596
|
+
const rel = relative(root, path);
|
|
597
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes("/"))
|
|
598
|
+
throw new AgentError(
|
|
599
|
+
"The worktree lies outside the shelf's worktree folder.",
|
|
600
|
+
);
|
|
601
|
+
return path;
|
|
602
|
+
}
|
|
603
|
+
|
|
401
604
|
async #summary(repo: string, fetch: boolean): Promise<RepoSummary> {
|
|
402
605
|
const dir = this.dirOf(repo);
|
|
403
606
|
const summary = summaryOf(dir);
|
|
@@ -499,6 +702,14 @@ export class RepoShelf {
|
|
|
499
702
|
}
|
|
500
703
|
}
|
|
501
704
|
|
|
705
|
+
/** Owners starting with a dot are the shelf's own folders, such as its worktrees. */
|
|
706
|
+
function refuseHidden(repo: string): void {
|
|
707
|
+
if (repo.startsWith("."))
|
|
708
|
+
throw new AgentError(
|
|
709
|
+
`"${repo}" is not a repository: owners starting with a dot are reserved for the shelf.`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
502
713
|
/** The report as posted for the owner. */
|
|
503
714
|
export function reportPost(report: ChangeReport, direct = false): string {
|
|
504
715
|
return [
|
package/src/worker-entry.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function codingWorkerPrompt(dir: string): string {
|
|
|
34
34
|
return [
|
|
35
35
|
`You are a coding worker in ${dir}. The next message is your approved task. Read this repository's AGENTS.md.`,
|
|
36
36
|
"Work with the shell and file tools available. Complete and verify the contract, stage files by name and commit using repository conventions, without tool attribution.",
|
|
37
|
+
"You work in your own worktree on its own branch: commit there and stay on it, without switching branches or rebasing; the host merges your branch back when you finish.",
|
|
37
38
|
"Do not push or ship. The calling agent reviews your commits and requests the owner's approval through repo_change_report and repo_push.",
|
|
38
39
|
"Risky calls wait for the owner's approval. An approved call runs; a declined or held call must not be retried or worked around.",
|
|
39
40
|
"Resolve decisions settled by the task or repository. Put remaining owner decisions and held actions in your report instead of guessing.",
|