omp-conductor 0.5.0 → 0.5.2
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 +42 -25
- package/package.json +1 -1
- package/src/failure-class.ts +49 -17
- package/src/omp.ts +5 -3
- package/src/store.ts +22 -8
- package/src/verbs/server.ts +0 -8
- package/src/verbs/socket.ts +27 -68
- package/src/worktree.ts +5 -3
package/README.md
CHANGED
|
@@ -149,21 +149,27 @@ be present. If you run omp, it is.
|
|
|
149
149
|
Also required on the host:
|
|
150
150
|
|
|
151
151
|
- `bun`: the CLI and the daemon run on it (`Bun.serve` backs `/healthz`).
|
|
152
|
-
- **A model credential
|
|
153
|
-
environment and `$HOME` unmodified, so a
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
-
|
|
158
|
-
`
|
|
159
|
-
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
- **A model credential the *daemon's own account* can reach.** Sessions are child
|
|
153
|
+
processes of the daemon and inherit its environment and `$HOME` unmodified, so a
|
|
154
|
+
session authenticates with exactly what the daemon authenticates with — nothing
|
|
155
|
+
is injected and nothing is scrubbed. Any shape the harness itself understands
|
|
156
|
+
works, including the ordinary one:
|
|
157
|
+
- an **OAuth login** already recorded for that account (`omp` login state under
|
|
158
|
+
its `~/.omp/agent`). This is the common case and needs no configuration.
|
|
159
|
+
- a model **API key** in the daemon's environment (`ANTHROPIC_API_KEY`,
|
|
160
|
+
`OPENAI_API_KEY`, …). Note systemd starts the service with a clean
|
|
161
|
+
environment, so it has to be an `Environment=` line on the unit, not something
|
|
162
|
+
exported in your shell.
|
|
163
|
+
- the harness's **auth broker**, configured in that account's
|
|
164
|
+
`~/.omp/agent/config.yml`.
|
|
165
|
+
|
|
166
|
+
The account matters more than the shape: a login recorded under a *different*
|
|
167
|
+
account is invisible to the service. A unit running `User=fleet` cannot see
|
|
168
|
+
`root`'s login, and workers then die at turn 0 with `No model selected`. Such a
|
|
169
|
+
run is classified `env-start-failure` and charges **neither** the failure budget
|
|
170
|
+
nor a continuation — an environment fault is not a failed implementation, and a
|
|
171
|
+
run that recorded no turn, no commit and no error did not attempt anything — but
|
|
172
|
+
nothing dispatches successfully until the credential is reachable.
|
|
167
173
|
- `gh`, already authenticated: every tracker operation shells out to it, so the
|
|
168
174
|
daemon never handles a GitHub token itself.
|
|
169
175
|
- `git`: mirrors and worktrees.
|
|
@@ -2149,18 +2155,29 @@ on run X asking to merge run Y's PR is not merely wrong, it is unexpressible.
|
|
|
2149
2155
|
|
|
2150
2156
|
```
|
|
2151
2157
|
<state dir>/verbs/ daemon-owned, mode 0711
|
|
2152
|
-
run-7-9a783d877d422b9e.sock 0600, run 7
|
|
2153
|
-
run-9-1c40e2a5b6d3f018.sock 0600, run 9
|
|
2154
|
-
orchestrator-4b1f...c2.sock 0600,
|
|
2158
|
+
run-7-9a783d877d422b9e.sock 0600, bound for run 7
|
|
2159
|
+
run-9-1c40e2a5b6d3f018.sock 0600, bound for run 9
|
|
2160
|
+
orchestrator-4b1f...c2.sock 0600, the orchestrator's
|
|
2155
2161
|
```
|
|
2156
2162
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2163
|
+
**What these modes buy, and what they do not.** They keep every *other local
|
|
2164
|
+
account* out: `0711` on the parent is traversable but not listable, so no other
|
|
2165
|
+
user can enumerate the fleet's sockets, the suffixes are unguessable, and only
|
|
2166
|
+
the daemon's uid can connect to a `0600` socket at all.
|
|
2167
|
+
|
|
2168
|
+
They are **not** a boundary between runs. Sessions are child processes of the
|
|
2169
|
+
daemon running as its own uid, so a session matches the owner class on all of
|
|
2170
|
+
these: it could list the directory and connect to a sibling's socket. Each run is
|
|
2171
|
+
*handed* its own path and nothing else, which is a convention the run has no
|
|
2172
|
+
reason to break — not an enforcement. What makes breaking it visible is the
|
|
2173
|
+
[ledger](#the-ledger): every call is recorded with the channel it
|
|
2174
|
+
arrived on, so a worker calling on another run's socket is in the record.
|
|
2175
|
+
|
|
2176
|
+
Closing that properly needs the sessions to be different OS principals. A
|
|
2177
|
+
per-run credential boundary that did exactly this shipped and was removed in
|
|
2178
|
+
0.5.0 — it worked, and the cost was that it also hid the operator's own model
|
|
2179
|
+
credential from every session, so nothing could start. It is not worth
|
|
2180
|
+
re-litigating without solving that first.
|
|
2164
2181
|
|
|
2165
2182
|
Before binding, the daemon verifies every component of the path is owned by
|
|
2166
2183
|
itself (or root), free of symlinks, and unwritable by anyone else; a failed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/failure-class.ts
CHANGED
|
@@ -65,10 +65,11 @@ function normalise(state: string): string {
|
|
|
65
65
|
/**
|
|
66
66
|
* Harness start failures, matched on the text it prints.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* A small closed list rather than "any error at turn 0": a run that ended at
|
|
69
|
+
* turn 0 with an error nobody has named is exactly what `unknown` is for, and
|
|
70
|
+
* declaring that an environment fault would waive an attempt that may have been
|
|
71
|
+
* genuinely spent. A mirror fetch that failed is not a session that never
|
|
72
|
+
* started.
|
|
72
73
|
*/
|
|
73
74
|
const START_FAILURE_SIGNATURES = [
|
|
74
75
|
"no model selected",
|
|
@@ -90,6 +91,42 @@ export function startFailure(lastError: string | undefined): string | undefined
|
|
|
90
91
|
return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Evidence that this run never started, or `undefined` when something did happen.
|
|
96
|
+
*
|
|
97
|
+
* Two shapes, and the second is the one the reference fleet actually produced.
|
|
98
|
+
*
|
|
99
|
+
* 1. Turn zero with a recognised harness start error — the session said why.
|
|
100
|
+
* 2. Turn zero, **no artifacts and no error text at all**. The row records
|
|
101
|
+
* nothing: no turn, no head, no salvage, not even a message. On the reference
|
|
102
|
+
* fleet every worker died at turn 0 with `No model selected`, and that string
|
|
103
|
+
* went into the tier-1 escalation rather than into `lastError` — so the rows
|
|
104
|
+
* for issues 373, 359, 343 and 319 carry no error whatsoever and were logged
|
|
105
|
+
* `unknown`, each charging an implementation attempt (#152).
|
|
106
|
+
*
|
|
107
|
+
* Shape 2 is a deliberate judgement, not a loosening. There is no evidence an
|
|
108
|
+
* attempt was spent, so charging one is charging for a run whose only trace is
|
|
109
|
+
* that it existed; and the two ways of being wrong are not symmetric — being
|
|
110
|
+
* generous grants an issue one more attempt a human will see, while being strict
|
|
111
|
+
* leaves it permanently undispatchable for a fault that was never in its code.
|
|
112
|
+
* A turn-0 run carrying an *unrecognised* error still reads `unknown`, because
|
|
113
|
+
* there the row does say something happened.
|
|
114
|
+
*
|
|
115
|
+
* Shared with the store's one-time repair so history and future agree.
|
|
116
|
+
*/
|
|
117
|
+
export function neverStarted(
|
|
118
|
+
run: Pick<RunRecord, "turns" | "lastError" | "prUrl" | "headSha" | "salvageSha">,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
if (run.turns !== 0) return undefined;
|
|
121
|
+
const detail = startFailure(run.lastError);
|
|
122
|
+
if (detail !== undefined) return `the session never started: ${detail}`;
|
|
123
|
+
const produced = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
124
|
+
if (!produced && run.lastError === undefined) {
|
|
125
|
+
return "the session never started: no turns, no commit and no error were recorded";
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
93
130
|
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
94
131
|
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
95
132
|
|
|
@@ -113,19 +150,14 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
113
150
|
};
|
|
114
151
|
}
|
|
115
152
|
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
cls: "env-start-failure",
|
|
126
|
-
recovery: "escalate",
|
|
127
|
-
evidence: `the session never started: ${detail}`,
|
|
128
|
-
};
|
|
153
|
+
// A run that never started is the most classifiable failure there is, and the
|
|
154
|
+
// least deserving of an implementation attempt: the session did not get as far
|
|
155
|
+
// as reading the issue. See {@link neverStarted} for the two shapes and why the
|
|
156
|
+
// second one counts.
|
|
157
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
158
|
+
const evidence = neverStarted(run);
|
|
159
|
+
if (evidence !== undefined) {
|
|
160
|
+
return { cls: "env-start-failure", recovery: "escalate", evidence };
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
163
|
|
package/src/omp.ts
CHANGED
|
@@ -428,9 +428,11 @@ export interface CreateSessionOptions {
|
|
|
428
428
|
*/
|
|
429
429
|
export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
|
|
430
430
|
const owned = opts.socketPath === undefined;
|
|
431
|
-
// 0711, not 0700: searchable
|
|
432
|
-
//
|
|
433
|
-
//
|
|
431
|
+
// 0711, not 0700: searchable but not listable, which keeps *other local
|
|
432
|
+
// accounts* from enumerating this directory. It is not a restraint on the
|
|
433
|
+
// session — that runs as this process's own uid, matches the owner class and
|
|
434
|
+
// therefore has `rwx` here regardless. Same reasoning, and the same limit, as
|
|
435
|
+
// the run workspace parent.
|
|
434
436
|
const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
|
|
435
437
|
const socketPath = opts.socketPath ?? join(socketDir, "s");
|
|
436
438
|
mkdirSync(socketDir, { recursive: true, mode: 0o711 });
|
package/src/store.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { dirname, join } from "node:path";
|
|
|
15
15
|
|
|
16
16
|
import { stateDir } from "./config.ts";
|
|
17
17
|
import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { neverStarted } from "./failure-class.ts";
|
|
19
19
|
import type {
|
|
20
20
|
DecisionDraft,
|
|
21
21
|
FailureClass,
|
|
@@ -693,18 +693,32 @@ export function openStore(dbPath: string): Store {
|
|
|
693
693
|
// charged, and an issue sitting at its cap for a fault that was never in its
|
|
694
694
|
// code stays undispatchable forever.
|
|
695
695
|
//
|
|
696
|
-
// Narrow by construction
|
|
697
|
-
//
|
|
696
|
+
// Narrow by construction, and narrow through the CLASSIFIER's own predicate
|
|
697
|
+
// rather than a second copy of the rule: {@link neverStarted}. A class of
|
|
698
698
|
// exactly `unknown` — a row a human or a later release classified as anything
|
|
699
|
-
// else is left alone
|
|
700
|
-
//
|
|
699
|
+
// else is left alone — plus zero turns and either a recognised start error or
|
|
700
|
+
// nothing recorded at all. A turn-0 row carrying an unrecognised error (a
|
|
701
|
+
// failed mirror fetch, say) is untouched. Idempotent for free: afterwards these
|
|
702
|
+
// rows read `env-start-failure`, which the WHERE no longer matches.
|
|
701
703
|
const misread = db
|
|
702
704
|
.query<
|
|
703
|
-
{ id: string; lastError: string | null },
|
|
705
|
+
{ id: string; lastError: string | null; prUrl: string | null; headSha: string | null; salvageSha: string | null },
|
|
704
706
|
[]
|
|
705
|
-
>(
|
|
707
|
+
>(
|
|
708
|
+
`SELECT id, lastError, prUrl, headSha, salvageSha FROM runs
|
|
709
|
+
WHERE failureClass = 'unknown' AND turns = 0`,
|
|
710
|
+
)
|
|
706
711
|
.all()
|
|
707
|
-
.filter(
|
|
712
|
+
.filter(
|
|
713
|
+
(row) =>
|
|
714
|
+
neverStarted({
|
|
715
|
+
turns: 0,
|
|
716
|
+
...(row.lastError === null ? {} : { lastError: row.lastError }),
|
|
717
|
+
...(row.prUrl === null ? {} : { prUrl: row.prUrl }),
|
|
718
|
+
...(row.headSha === null ? {} : { headSha: row.headSha }),
|
|
719
|
+
...(row.salvageSha === null ? {} : { salvageSha: row.salvageSha }),
|
|
720
|
+
}) !== undefined,
|
|
721
|
+
);
|
|
708
722
|
if (misread.length > 0) {
|
|
709
723
|
const reclassify = db.query<unknown, [string]>(
|
|
710
724
|
`UPDATE runs SET failureClass = 'env-start-failure' WHERE id = ?`,
|
package/src/verbs/server.ts
CHANGED
|
@@ -51,7 +51,6 @@ import {
|
|
|
51
51
|
secureBoundSocket,
|
|
52
52
|
socketFd,
|
|
53
53
|
unlinkStaleSocket,
|
|
54
|
-
traversalProblem,
|
|
55
54
|
validateSocketPath,
|
|
56
55
|
type PeerReader,
|
|
57
56
|
type SocketOwnership,
|
|
@@ -935,13 +934,6 @@ export interface ListenOptions {
|
|
|
935
934
|
daemonUid?: number;
|
|
936
935
|
/** Injected in tests; the real one chowns, which needs privilege. */
|
|
937
936
|
secure?: typeof secureBoundSocket;
|
|
938
|
-
/**
|
|
939
|
-
* Injected in tests for the same reason `secure` is: the traversal rule is
|
|
940
|
-
* about the *deployment's* directory chain, and a test that needs a bound
|
|
941
|
-
* socket in a developer's `0750` home is not the case it is asserting. The
|
|
942
|
-
* rule itself is pinned directly in `socket.test.ts`.
|
|
943
|
-
*/
|
|
944
|
-
traversal?: typeof traversalProblem;
|
|
945
937
|
}
|
|
946
938
|
|
|
947
939
|
/**
|
package/src/verbs/socket.ts
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The authenticated local transport the mutation verbs ride on (#126).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* *
|
|
7
|
-
*
|
|
4
|
+
* **What the permission layout does and does not buy, stated plainly.** Every
|
|
5
|
+
* session is a child process running as the daemon's own uid. So the modes here
|
|
6
|
+
* keep *other local accounts* out; they do not restrain a session, because a
|
|
7
|
+
* session matches the owner class and owner bits are `rwx`. A session that
|
|
8
|
+
* wanted to list the socket directory, unlink a sibling's socket and bind an
|
|
9
|
+
* impostor in its place could do so, and nothing in this file would stop it.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - the parent being non-writable is what stops a run unlinking a sibling's
|
|
14
|
-
* socket or binding an impostor listener in its place. A per-run directory
|
|
15
|
-
* owned by the run principal would hand back exactly that power, and is why
|
|
16
|
-
* the obvious layout is not used here.
|
|
11
|
+
* That is not a hole this file is hiding — it is the consequence of running
|
|
12
|
+
* sessions as the daemon. What restrains a session is the mechanical worktree
|
|
13
|
+
* gate on its structured tools plus the fact that publication only happens
|
|
14
|
+
* through the daemon, and what makes a bad push *visible* is the verb ledger.
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
* {@link validateSocketPath} refuses to bind under a path anyone else could
|
|
20
|
-
* have tampered with, and {@link peerVerdict} compares the connecting uid the
|
|
21
|
-
* kernel reports against the uid the daemon allocated for that run.
|
|
16
|
+
* So the layout is:
|
|
22
17
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
* - the parent directory is daemon-owned and mode **`0711`** — searchable so a
|
|
19
|
+
* caller reaches a socket it can already name, not listable and not writable
|
|
20
|
+
* by any account other than the daemon's;
|
|
21
|
+
* - each socket is `0600`, so only the daemon's uid can connect at all;
|
|
22
|
+
* - the per-socket suffix is random, so the path is not guessable by a foreign
|
|
23
|
+
* account that cannot list the directory.
|
|
24
|
+
*
|
|
25
|
+
* Two further rules make that hold rather than merely describe it:
|
|
26
|
+
* {@link validateSocketPath} refuses to bind under a path anyone else could have
|
|
27
|
+
* tampered with, and {@link peerVerdict} compares the connecting uid the kernel
|
|
28
|
+
* reports against the daemon's own.
|
|
29
|
+
*
|
|
30
|
+
* Every decision in this file is a pure function of `lstat` results, so the
|
|
31
|
+
* adversarial cases are testable without root and without a second uid.
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
import { dlopen, FFIType, ptr, suffix } from "bun:ffi";
|
|
@@ -31,10 +37,10 @@ import { chmodSync, chownSync, lstatSync, mkdirSync, rmSync } from "node:fs";
|
|
|
31
37
|
import type { Stats } from "node:fs";
|
|
32
38
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
33
39
|
|
|
34
|
-
/** Searchable
|
|
40
|
+
/** Searchable, but not listable or writable by any account but the daemon's. */
|
|
35
41
|
export const VERB_DIR_MODE = 0o711;
|
|
36
42
|
|
|
37
|
-
/**
|
|
43
|
+
/** Only the daemon's uid may connect. The parent's `0711` keeps that reachable. */
|
|
38
44
|
export const VERB_SOCKET_MODE = 0o600;
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -196,53 +202,6 @@ export function validateSocketPath(
|
|
|
196
202
|
return undefined;
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
/**
|
|
200
|
-
* The first path component a *foreign* uid could not search through, or
|
|
201
|
-
* `undefined` when the whole chain is traversable.
|
|
202
|
-
*
|
|
203
|
-
* Checked rather than assumed, because this is the failure the layout in this
|
|
204
|
-
* file is most likely to hit in the field and the least likely to be noticed:
|
|
205
|
-
* connecting to a socket needs `+x` on **every** component, and the daemon's
|
|
206
|
-
* own state directory is `0700`. A run principal would then be refused at the
|
|
207
|
-
* state directory rather than at the socket, with an `EACCES` that names a
|
|
208
|
-
* directory nobody was thinking about.
|
|
209
|
-
*
|
|
210
|
-
* Only meaningful when the run has a distinct principal. Under one uid the
|
|
211
|
-
* daemon is the caller, so `0700` is traversable by definition — which is
|
|
212
|
-
* exactly why this cannot be left to be discovered on the first fleet that
|
|
213
|
-
* turns per-run principals on.
|
|
214
|
-
*
|
|
215
|
-
* Deliberately reported rather than repaired: the fix is `chmod o+x` on a
|
|
216
|
-
* directory that may be the operator's home, and a daemon that silently
|
|
217
|
-
* widened `$HOME` would be trading a legible refusal for a surprise.
|
|
218
|
-
*/
|
|
219
|
-
export function traversalProblem(
|
|
220
|
-
path: string,
|
|
221
|
-
lstat: (p: string) => Stats = lstatSync,
|
|
222
|
-
): SocketPathProblem | undefined {
|
|
223
|
-
for (const component of ancestors(path)) {
|
|
224
|
-
let stat: Stats;
|
|
225
|
-
try {
|
|
226
|
-
stat = lstat(component);
|
|
227
|
-
} catch {
|
|
228
|
-
return {
|
|
229
|
-
component,
|
|
230
|
-
fault: "missing",
|
|
231
|
-
message: `verb socket parent ${component} does not exist or cannot be read`,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
if ((stat.mode & 0o001) === 0) {
|
|
235
|
-
return {
|
|
236
|
-
component,
|
|
237
|
-
fault: "not-searchable",
|
|
238
|
-
message:
|
|
239
|
-
`verb socket parent ${component} is mode ${(stat.mode & 0o7777).toString(8)}, which a run principal ` +
|
|
240
|
-
`cannot search through. Connecting needs +x on every component. Run: chmod o+x ${component}`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return undefined;
|
|
245
|
-
}
|
|
246
205
|
|
|
247
206
|
/**
|
|
248
207
|
* Remove a stale socket at `path`, and only ever one the daemon itself placed.
|
package/src/worktree.ts
CHANGED
|
@@ -312,9 +312,11 @@ export async function addRunRepo(
|
|
|
312
312
|
branch: string,
|
|
313
313
|
): Promise<{ path: string; reattached: boolean }> {
|
|
314
314
|
const mirrorPath = await ensureMirror(repo, mirrorRoot);
|
|
315
|
-
// 0711: searchable
|
|
316
|
-
//
|
|
317
|
-
//
|
|
315
|
+
// 0711: searchable but not listable, so no *other local account* can
|
|
316
|
+
// enumerate the fleet's checkouts. It does not stop a session reaching a
|
|
317
|
+
// sibling's tree: sessions run as the daemon's own uid, so the owner bits are
|
|
318
|
+
// theirs. What confines a worker to its own checkout is the mechanical
|
|
319
|
+
// worktree gate on its tools, not this mode.
|
|
318
320
|
mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
|
|
319
321
|
|
|
320
322
|
const runRepo = worktreePathFor(workspaceRoot, issue);
|