@detergent-software/conductor 3.0.0-dev.1 → 3.0.0-dev.10
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 +72 -0
- package/README.md +45 -18
- package/build/broker-cli.js +19 -21
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +66 -92
- package/build/broker.js.map +1 -1
- package/build/claude.js +50 -7
- package/build/claude.js.map +1 -1
- package/build/cli.js +12 -22
- package/build/cli.js.map +1 -1
- package/build/code-host.js +10 -8
- package/build/code-host.js.map +1 -1
- package/build/config.js +10 -0
- package/build/config.js.map +1 -1
- package/build/console.js +26 -0
- package/build/console.js.map +1 -1
- package/build/daemon.js +13 -6
- package/build/daemon.js.map +1 -1
- package/build/help.js +10 -12
- package/build/help.js.map +1 -1
- package/build/in-process.js +3 -3
- package/build/in-process.js.map +1 -1
- package/build/inbox.js +39 -4
- package/build/inbox.js.map +1 -1
- package/build/lib/providers/index.js +1 -1
- package/build/lib/providers/index.js.map +1 -1
- package/build/lib/providers/npm/stage.js +14 -4
- package/build/lib/providers/npm/stage.js.map +1 -1
- package/build/local-files.js +95 -0
- package/build/local-files.js.map +1 -0
- package/build/manager.js +144 -27
- package/build/manager.js.map +1 -1
- package/build/npm-reads.js +194 -0
- package/build/npm-reads.js.map +1 -0
- package/build/plugin.js +10 -0
- package/build/plugin.js.map +1 -1
- package/build/profile.js +55 -19
- package/build/profile.js.map +1 -1
- package/build/project-check-cli.js +4 -1
- package/build/project-check-cli.js.map +1 -1
- package/build/project-check.js +69 -22
- package/build/project-check.js.map +1 -1
- package/build/rules-service.js +22 -4
- package/build/rules-service.js.map +1 -1
- package/build/scheduler.js +11 -1
- package/build/scheduler.js.map +1 -1
- package/build/server.js +0 -19
- package/build/server.js.map +1 -1
- package/build/spend.js +4 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +11 -10
- package/build/store.js.map +1 -1
- package/build/tracker-reads-cli.js +2 -4
- package/build/tracker-reads-cli.js.map +1 -1
- package/build/upgrade.js +16 -1
- package/build/upgrade.js.map +1 -1
- package/build/watchers/ado.js +3 -2
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/github.js +3 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/npm.js +6 -4
- package/build/watchers/npm.js.map +1 -1
- package/build/worktrees.js +182 -38
- package/build/worktrees.js.map +1 -1
- package/package.json +2 -2
- package/plugin/skills/conductor-console/SKILL.md +1 -1
- package/plugin/skills/work-item/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
Releases are tagged `conductor-v<version>` and staged on npm by `atk-conductor-release` (`pipelines/release-conductor.yml`), which writes each section below. `scripts/check-changelog.mjs` fails when a tag has no section here.
|
|
4
4
|
|
|
5
|
+
## [3.0.0-dev.10](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.9&targetVersion=GTconductor-v3.0.0-dev.10) (2026-10-17)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **conductor:** follow a new session id once it stays listed 5 s (ATK-574) ([32b86ea](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/32b86ea40bc93e92736a9447aee3a1e0e9572879))
|
|
10
|
+
* **conductor:** follow a new session id only once two listings show it (ATK-574) ([5e8b65a](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/5e8b65ad625fdbfa57566bc1b2b7d4adaa55b186))
|
|
11
|
+
|
|
12
|
+
## [3.0.0-dev.9](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.8&targetVersion=GTconductor-v3.0.0-dev.9) (2026-10-17)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **conductor:** hold launches only for a profile that doesn't read (ATK-573) ([2032a41](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/2032a41daafc9fc85ccfcc594fd99520e8d38d49))
|
|
17
|
+
* **conductor:** name the conductor a profile needs and hold its launches (ATK-573) ([b8eb37e](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/b8eb37e13bdb94ab4c9ca1e9cf46495f65e13f7b))
|
|
18
|
+
|
|
19
|
+
## [3.0.0-dev.8](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.7&targetVersion=GTconductor-v3.0.0-dev.8) (2026-10-17)
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* **conductor:** open the console tab while CONDUCTOR_HOME is set (ATK-572) ([68e7131](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/68e713197bca1c45a93a1dc122293253ffe5c952))
|
|
24
|
+
|
|
25
|
+
## [3.0.0-dev.7](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.6&targetVersion=GTconductor-v3.0.0-dev.7) (2026-10-17)
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **conductor:** check Claude Code's workspace trust before a launch (ATK-571) ([fab9457](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/fab94576638b713f913198a9a394a1e08a5709ac))
|
|
30
|
+
|
|
31
|
+
### Bug Fixes
|
|
32
|
+
|
|
33
|
+
* **conductor:** skip the trust probe behind a skills fail (ATK-571) ([a3614a5](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/a3614a5f5c35c04991b002de6a459fdd3159d32e))
|
|
34
|
+
* **conductor:** trust probe review fixes (ATK-571) ([c9d1a11](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/c9d1a11578dde0c92559bfce8c1b255a7f7ca8f7))
|
|
35
|
+
|
|
36
|
+
## [3.0.0-dev.6](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.5&targetVersion=GTconductor-v3.0.0-dev.6) (2026-10-17)
|
|
37
|
+
|
|
38
|
+
### Bug Fixes
|
|
39
|
+
|
|
40
|
+
* **conductor:** pick npm's proxy as npm does, and follow redirects (ATK-561) ([76db548](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/76db5481a3920fdf3e1ea97c773b158837249142))
|
|
41
|
+
* **conductor:** read npm with npm's own registry and proxy settings (ATK-561) ([cd5dacf](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/cd5dacf50c5a76c0a08fcec7d628210cd2637d42))
|
|
42
|
+
|
|
43
|
+
## [3.0.0-dev.5](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.4&targetVersion=GTconductor-v3.0.0-dev.5) (2026-10-17)
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
|
|
47
|
+
* **conductor:** start the console without the caller's Claude session (ATK-559) ([767279a](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/767279a80281dbc4774329b2f9d6aa06d0ccdf78))
|
|
48
|
+
|
|
49
|
+
## [3.0.0-dev.4](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.3&targetVersion=GTconductor-v3.0.0-dev.4) (2026-10-17)
|
|
50
|
+
|
|
51
|
+
### Features
|
|
52
|
+
|
|
53
|
+
* **conductor:** make worktrees built in, with named ports (ATK-526) ([a899350](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/a899350fb4606f67a4de7af2a50d3e6d616ccead))
|
|
54
|
+
* **conductor:** take its ports in dev-ports, document built-in worktrees (ATK-526) ([1e19523](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/1e19523fa9ca8252f9cd5a518edd2b66b7899a2a))
|
|
55
|
+
|
|
56
|
+
### Bug Fixes
|
|
57
|
+
|
|
58
|
+
* **conductor:** harden built-in worktrees from the review and probe (ATK-526) ([900f495](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/900f49504b3291083dd3fa5f64f7efc11d28ecc2))
|
|
59
|
+
* **conductor:** never delete a worktree that may hold work (ATK-526) ([f2114b1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/f2114b1fb0e31c7ecad8d215fae6174ccfeee6b8))
|
|
60
|
+
|
|
61
|
+
### Documentation
|
|
62
|
+
|
|
63
|
+
* **conductor:** describe the hardened worktree launch and retire (ATK-526) ([be45c3c](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/be45c3ca98c51f404d77279e4d34b03bedf3ea71))
|
|
64
|
+
|
|
65
|
+
## [3.0.0-dev.3](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.2&targetVersion=GTconductor-v3.0.0-dev.3) (2026-10-17)
|
|
66
|
+
|
|
67
|
+
### Bug Fixes
|
|
68
|
+
|
|
69
|
+
* **conductor:** read the upgrade's dist-tag fresh from npm (ATK-558) ([4b31619](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/4b31619a6d3ebd6380751c993eada457259a1346))
|
|
70
|
+
|
|
71
|
+
## [3.0.0-dev.2](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.0.0-dev.1&targetVersion=GTconductor-v3.0.0-dev.2) (2026-10-17)
|
|
72
|
+
|
|
73
|
+
### Documentation
|
|
74
|
+
|
|
75
|
+
* **conductor:** say the first console asks to trust its folder (ATK-525) ([8d9e37d](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/8d9e37de5b37807bfab5fb7a94903be868513d6a))
|
|
76
|
+
|
|
5
77
|
## [3.0.0-dev.1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v2.2.0-dev.3&targetVersion=GTconductor-v3.0.0-dev.1) (2026-10-17)
|
|
6
78
|
|
|
7
79
|
### ⚠ BREAKING CHANGES
|
package/README.md
CHANGED
|
@@ -80,8 +80,7 @@ conduct runs <sha> --requeue <pipeline>
|
|
|
80
80
|
queue the item's merge's cancelled run again on the base (see Worker verbs)
|
|
81
81
|
conduct train [<project>] [--dry-run]
|
|
82
82
|
merge the waiting skill publishes into one registry PR (see Skill trains)
|
|
83
|
-
conduct ports [<path>] [--
|
|
84
|
-
a checkout's dev ports from the port broker, or every worktree's (see Broker)
|
|
83
|
+
conduct ports [<path>] [--json] a conducted worktree's dev ports from the port broker, or every worktree's (see Broker)
|
|
85
84
|
conduct lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
|
|
86
85
|
a lock every project shares, such as chrome (see Broker)
|
|
87
86
|
conduct events [--since <id>] [--follow] [--console]
|
|
@@ -100,11 +99,27 @@ A `<worker>` is its item (`ATK-309`) or its name (`probe-ATK-309`).
|
|
|
100
99
|
|
|
101
100
|
The session manager owns the `claude --bg` sessions it launches, and only those:
|
|
102
101
|
|
|
103
|
-
- **launch:** the worktree comes first,
|
|
102
|
+
- **launch:** the worktree comes first, made by the daemon itself (design note v2, section 2; ATK-526), with no hooks:
|
|
103
|
+
1. the broker gives it a port for each name in the profile's `ports` (see Broker);
|
|
104
|
+
2. `git worktree add` makes `<root>/.claude/worktrees/<name>` on `worktree-<name>` from `origin/<base>`, fetched first (after `git worktree prune`, and on Windows with `core.longpaths` on). A worktree git still reads there is reused, its own changes and all; one with a `.git` git can't read is never deleted, and fails the launch. A `worktree-<name>` branch that is left is checked out again, with any commits it kept;
|
|
105
|
+
3. with `localFilesFrom` (an absolute path to a checkout the operator keeps), the gitignored files there that match its `.worktreeinclude` are copied in, as they are now;
|
|
106
|
+
4. the profile's `setup` commands run in order in the worktree, through the shell, with `CONDUCTOR_PORT_<NAME>` (such as `CONDUCTOR_PORT_WEB=41000`), `CONDUCTOR_ITEM`, `CONDUCTOR_PROJECT` and `CONDUCTOR_WORKTREE` set. They build its gitignored files from what the repo commits (ATK's: `pnpm install`, `atk setup --tool claude-code --yes`, `node scripts/dev-ports.mjs assign`, `atk install`). The first that fails fails the launch, with the end of its output. So does a setup that leaves changes git sees, such as an `.atk-lock.json` that `atk install` rewrote: the worker would take them for its own;
|
|
107
|
+
5. the ports go into the worktree's `.claude/settings.local.json` as `env`, merged into what the file had. A `claude --bg` session doesn't inherit the environment it is started from, but Claude Code applies that file's `env` to every session in the folder and to each resume;
|
|
108
|
+
6. the profile's work and verify skills must be there: a `conductor:<skill>` in the plugin, any other in the worktree's `.claude/skills` or `~/.claude/skills`.
|
|
109
|
+
|
|
110
|
+
A launch that fails removes the worktree it made when it is clean, with its branch while that still has the launch's name; a worktree that was there before the launch stays.
|
|
111
|
+
|
|
112
|
+
Then `claude --bg -n <name> --plugin-dir <home>/plugin --disallowedTools EnterPlanMode ExitPlanMode --permission-mode auto -- <prompt>` runs with its cwd in the worktree (see Plugin), never `--worktree`. A profile with `"workers": { "tab": true }` also gets a Windows Terminal tab attached to the session.
|
|
104
113
|
- **park:** a worker with a wait (`POST /waits`) is stopped once `claude agents` shows its turn has ended. Its status becomes `parked`, and its tab closes.
|
|
105
114
|
- **resume:** when a wait resolves, the daemon lets the stop settle (`sessions.settleSeconds`). It checks the process has exited, then runs `claude --bg --resume <session> -- "[conductor] event: …"`. The session keeps its name, model, permission mode and disallowed tools. A resume of a session that still runs starts a copy; the daemon stops and removes the copy, then tries again.
|
|
106
115
|
- **compact:** a parked worker with no resolved wait, whose context the rules find too big (the default `compact-on-park`: past `sessions.compactTokens`, 300k), is resumed with `/compact <what to keep>` once its stop has settled (`worker.compacting`). It runs until its transcript shows the compaction's boundary (`worker.compacted`, with the context before it and the conversation it kept), then parks again on its waits, which resume it as usual. Each turn of a session re-reads its whole context from cache, so a worker left to grow pays for it every turn (ATK-454 reached 883k). A resume that starts a copy is tried again on a later sweep (`worker.compact-deferred`), as a resume is. A compaction whose resume fails, or whose turn ends with no boundary 10 minutes on, logs `worker.compact-failed`, which raises a `context` item in the inbox. The worker still parks, and the daemon doesn't try it again until it restarts or the session compacts some other way: any boundary in its transcript, Claude Code's own included, logs `worker.compacted` and resolves that item. A worker whose context is unknown is never compacted, so even a rule with no threshold can't compact it on every park.
|
|
107
|
-
- **retire:** refuses
|
|
116
|
+
- **retire:** refuses a worker whose turn still runs, unless `--force`, and never retires a session that is still starting. What happens to the worktree depends on the worker's PR (design note v2, section 2):
|
|
117
|
+
- **merged** (read from the code host): it refuses uncommitted or unpushed work unless `--force`, then stops the session, removes the worktree and deletes its local branch, and runs `claude rm`;
|
|
118
|
+
- **no PR, and nothing uncommitted or unpushed** (a canceled item, a worker retired before its first commit): as merged, with nothing to lose;
|
|
119
|
+
- **not merged** (abandoned or closed, or no PR with work in it): it stops and removes the session and frees the ports, and keeps the worktree and its branch, so nothing the PR didn't land is lost. `worker.retired` names it under `kept`, which raises one `failed` inbox item with the path, the branch and the commands that remove them. A PR the project's code host can't read at all counts as not merged;
|
|
120
|
+
- **unread** (the code host didn't answer): it refuses, as a refusal that passes on its own, and the scheduler tries again.
|
|
121
|
+
|
|
122
|
+
A probe's worktree and a `--force` retire's always go. The removal moves the worktree aside, has git forget it, deletes the branch and then the directory with Node, which unlinks pnpm's junctions without following them (`git worktree remove` leaves them on Windows). The delete is asynchronous, so the API goes on answering through a minute of `node_modules`. With the directory gone already, it deletes the `worktree-<name>` branch only when none of its commits is unpushed. If the stop fails, nothing is removed.
|
|
108
123
|
- **restarts:** a launch the daemon was stopped in adopts its session if one was started, and otherwise fails and removes its clean worktree.
|
|
109
124
|
- **attach:** a parked worker is resumed with no event text, so it wakes idle and keeps running when its tab closes. An attached worker isn't parked until it registers a new wait, or until its turn has been over `sessions.idleMinutes` (`worker.detached`). The daemon can't see the tab, so this also delivers a wait that resolved while it was attached. A wait that resolves after the attach, on a worker whose transcript shows no turn since then, is delivered at once: the worker is detached (`reason: wait resolved`), parked, which closes its tab, and resumed with the event. A worker someone woke outside the daemon counts as attached since its stop, so the turn that woke it counts. One that took a turn since the attach (or has no transcript to tell), or whose wait resolved before the attach, keeps it until the idle release, logs `worker.resume-held` (`reason: attached`) once, and `conduct status` shows it as `idle, holding resolved waits for the attached session`.
|
|
110
125
|
- **park failures:** a session whose process outlives `claude stop` by 30 s logs one `worker.park-failed` per spell, which raises a `stall` in the inbox (see Inbox), and the park is tried again every 5 minutes.
|
|
@@ -388,13 +403,13 @@ The scheduler moves conduct-atk's tick into the daemon (design note, section 8).
|
|
|
388
403
|
|
|
389
404
|
## Skill bumps reach the main checkout
|
|
390
405
|
|
|
391
|
-
A worker's worktree branches from `origin/<base>` and installs its own pins. The main checkout is different: it keeps the pins it was last pulled at,
|
|
406
|
+
A worker's worktree branches from `origin/<base>` and its `setup` installs its own pins. The main checkout is different: it keeps the pins it was last pulled at, for the sessions people start there. The console's skill doesn't: it comes with the conductor plugin (see Plugin). So the daemon checks each conducted project that has an `.atk-lock.json`, at most every `checkout.intervalMinutes` (10), until the daemon owns its clones (design note v2, step 6):
|
|
392
407
|
|
|
393
408
|
1. It runs `git fetch origin <base>` in the main checkout and compares the pins (asset type, name and version) at `HEAD` with those at `origin/<base>`. When they match and nothing is left to install, nothing more happens.
|
|
394
409
|
2. When they differ, it records the pins as pending (a `checkout.pending` event). With the main checkout on `<base>` and no local changes to `.atk-lock.json`, it then runs `git merge --ff-only origin/<base>`.
|
|
395
410
|
3. Once `HEAD` has the pins, whether by this pull or yours, it runs `atk install` for the pending assets. A `checkout.updated` event lists each `name old → new`. An install that fails or leaves drift stays pending, and the next check tries it again, in this daemon or the next.
|
|
396
411
|
|
|
397
|
-
The checkout isn't touched in these cases: it is on another branch, its lockfile has local changes, or git refuses the fast-forward. Instead one `operator` item gives the commands (`git -C <root> pull --ff-only`, `atk install -P <root>`). The same item stays open while an install leaves drift, and it resolves itself once the pins are installed. A worktree create waits for a check that is running, and a check waits while one of the project's workers is launching, so
|
|
412
|
+
The checkout isn't touched in these cases: it is on another branch, its lockfile has local changes, or git refuses the fast-forward. Instead one `operator` item gives the commands (`git -C <root> pull --ff-only`, `atk install -P <root>`). The same item stays open while an install leaves drift, and it resolves itself once the pins are installed. A worktree create waits for a check that is running, and a check waits while one of the project's workers is launching, so the two never run git in the main checkout at once. A failed fetch is only logged. `checkout.mode` is `update` (the default), `notify` (the item only, with no git or atk run) or `off`.
|
|
398
413
|
|
|
399
414
|
## Skill trains
|
|
400
415
|
|
|
@@ -542,14 +557,11 @@ Everything goes through the project's gh account (`projects.<name>.github.user`,
|
|
|
542
557
|
|
|
543
558
|
The broker shares what every project on the machine uses (ATK-318).
|
|
544
559
|
|
|
545
|
-
**Ports.** Each profile
|
|
546
|
-
- A
|
|
547
|
-
-
|
|
548
|
-
- A
|
|
549
|
-
-
|
|
550
|
-
- A project's dev scripts read the assignment with `--json`, as ATK's `scripts/dev-ports.mjs assign` does. They keep their own scheme when it exits 1 (`not brokered`): no daemon, an older one, or a checkout in no registered project.
|
|
551
|
-
- The broker knows only the slots it gave out. A slot a script picked on its own while no daemon answered stays unknown until that worktree asks the broker; until then only the listening probe keeps the slot clear. So `--prefer` is the slot the script would pick itself (`dev-ports.mjs` passes the checkout's own slot or its own free-slot scan's). The preferred slot is taken without the listening probe, because the checkout's own servers may be what listens there.
|
|
552
|
-
- A stored assignment follows its project's profile: when the base ports or `maxSlot` change, the worktree moves, its old slot first.
|
|
560
|
+
**Ports.** Each profile names the ports a worktree's dev servers need, such as ATK's `"ports": ["web", "api"]` (design note v2, section 2; ATK-526).
|
|
561
|
+
- A launch gives the worktree one port per name from the machine's pool, `ports.first` to `ports.last` in config.json (41000 to 41999): each the pool's first port that no other worktree of any project holds, nothing listens on (IPv4 or IPv6 loopback), and that binds, which also skips a range Windows reserves. They reach `setup` and the worker as `CONDUCTOR_PORT_<NAME>` (see Workers).
|
|
562
|
+
- The pool sits away from the usual dev ports and their slot schemes, such as ATK's 3000+N and 7071+N for a person's own worktrees, so those never take a port a worker was given.
|
|
563
|
+
- A worktree keeps its ports while the profile names the same ports; a changed list gives it new ones. Retiring the worker frees them, and so does a worktree whose directory is gone (at the next assignment).
|
|
564
|
+
- `conduct ports` lists the assignments, and `conduct ports <path>` gives one worktree's (`--json` for either). Only a launch assigns. `conduct project check` previews the ports a new worktree would get, saving nothing.
|
|
553
565
|
|
|
554
566
|
**Locks.** `conduct lock acquire chrome` takes the lock every project's verify runs share. It answers the work-issue helper's JSON (`acquired`, `heldBy`, `since`, `refreshed`; `released` for a release; `verdict` for a status).
|
|
555
567
|
- The owner is `--owner`, else the item of the worker whose worktree this is.
|
|
@@ -662,7 +674,11 @@ conduct install-startup
|
|
|
662
674
|
conduct project add <path to the project's main checkout>
|
|
663
675
|
```
|
|
664
676
|
|
|
665
|
-
`conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout. After that the daemon takes the whole profile from disk when it starts and each time the file is saved, so a `git pull` that changes `slots` or `skills.work` needs no `conduct project add`. An older daemon refuses a profile with fields it doesn't know, so upgrade the daemon before a profile that uses new ones lands.
|
|
677
|
+
`conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout. After that the daemon takes the whole profile from disk when it starts and each time the file is saved, so a `git pull` that changes `slots` or `skills.work` needs no `conduct project add`. An older daemon refuses a profile with fields it doesn't know, so upgrade the daemon before a profile that uses new ones lands.
|
|
678
|
+
|
|
679
|
+
A profile that uses new fields says which conductor reads them: `"conductor": "3.0.0-dev.9"`, the version that first read the newest of them (a `-dev.N` while it is only on `dev`; a stable minimum such as `3.0.0` is newer than every `3.0.0-dev.N`, as semver orders them). Every conductor from the one that knows this key (ATK-573) checks it before the rest of the profile, and an older one refuses the file with one line instead of a list of fields: `conductor: needs 3.0.0-dev.9 or newer; this is 3.0.0-dev.7: run \`conduct upgrade --tag dev\`` (`--tag latest` for a stable minimum). The worker verbs that read the profile in a worktree (`conduct state`, `conduct branch`, `conduct search`, a lock with no daemon) say the same and exit 1, and the daemon's inbox item carries the line and the upgrade command. While a project's profile on disk doesn't read (a minimum above this conductor, a schema problem, another protocol), the scheduler launches none of the project's items: each waits in `conduct queue` as `profile refused: <its first problem>`, since a new worktree would hold the same file and its verbs would refuse it. A profile that reads but whose rules are refused, or that renames the project, keeps the last good profile and launches as before. Workers already running carry on, and launches resume on the first tick after the file loads.
|
|
680
|
+
|
|
681
|
+
After an `--allow-downgrade` to a version without those fields, the stored copy no longer validates either: the project shows `the stored profile no longer validates` and isn't conducted until its file, saved without them, is read again.
|
|
666
682
|
|
|
667
683
|
Then `conduct project check <name>` says whether the project can be conducted, before its first launch finds out. Each check prints one line, `ok`, `warn`, `fail` or `skip`, with a fix under a warn or a fail, and the command exits 1 when any check fails (`--json` prints the same as data):
|
|
668
684
|
|
|
@@ -670,13 +686,24 @@ Then `conduct project check <name>` says whether the project can be conducted, b
|
|
|
670
686
|
- **auth:** which credential each provider the project uses signs in with (project token, machine token or CLI login; see Accounts), probed as the daemon would. For each that isn't the project's own token, a detail line gives the scopes, the `cmdkey` line and the config key that would scope it; that is advice, and the check passes. A token that is there but refused, or no credential at all, fails.
|
|
671
687
|
- **tracker:** the tracker's login answers, and the queue reads (Linear: the team, and whether any of its projects has a priority; Boards: the saved query; GitHub Issues: the labelled issues).
|
|
672
688
|
- **code-host** and **ci:** the code host's login answers and the base branch exists; each release's `pipeline` names exactly one of the repo's pipelines.
|
|
673
|
-
- **worktree:** the launch's
|
|
689
|
+
- **worktree:** the launch's worktree steps on a throwaway `conduct-check-<time>`: `git worktree add` from `origin/<base>`, `localFilesFrom`'s files, the ports the broker would give, and `setup`. Then the profile's skills must be there after it, and a `warn` says when git doesn't ignore `.claude/settings.local.json`, where the ports go. A `claude --bg` with no prompt then checks that Claude Code would start a session there: it starts idle, costs no tokens and is removed at once. Claude Code keeps workspace trust per repository, so a root nobody has opened in Claude Code fails here with the one step that trusts it, `claude` run in the root once with its trust prompt accepted (ATK-571). It is removed with its branch, whatever happened. The one check that writes; `--no-worktree` skips it, since setup can take a while (ATK's runs `pnpm install`).
|
|
674
690
|
- **skills:** `skills.work` is there and declares `protocol: 2` in its SKILL.md frontmatter (worker protocol 2), `skills.verify` is there, and `atk audit` finds the installed ones unchanged; a missing alias is a `warn`. A skill named `conductor:<skill>` is read from this package's plugin and isn't audited, and beside a plugin work skill the aliases only name older notes' headings, so they aren't checked; any other is read from the main checkout.
|
|
675
|
-
- **ports:** the
|
|
691
|
+
- **ports:** the machine's pool has a free port for each of the profile's `ports`, saving nothing; `skip` for a profile with none.
|
|
676
692
|
- **health:** each `health` URL answers; a `warn` at most, since an environment can be down.
|
|
677
693
|
|
|
678
694
|
With the daemon running it checks through the API, as the daemon's own accounts; without it, here, as the machine's.
|
|
679
695
|
|
|
696
|
+
A new worktree's local files come from the profile (design note v2, section 2; see Workers):
|
|
697
|
+
|
|
698
|
+
```jsonc
|
|
699
|
+
// The dev ports a worktree needs, by name: CONDUCTOR_PORT_WEB and CONDUCTOR_PORT_API.
|
|
700
|
+
"ports": ["web", "api"],
|
|
701
|
+
// Run in order in each new worktree; they build its gitignored files from what the repo commits.
|
|
702
|
+
"setup": ["pnpm install --frozen-lockfile", "node scripts/dev-ports.mjs assign"],
|
|
703
|
+
// Optional: copy the .worktreeinclude files of a checkout you keep, for local files made by hand.
|
|
704
|
+
"localFilesFrom": "C:\\Users\\you\\src\\project"
|
|
705
|
+
```
|
|
706
|
+
|
|
680
707
|
The profile's optional `work` section is for the project's work skill; the daemon only checks it:
|
|
681
708
|
|
|
682
709
|
```jsonc
|
|
@@ -718,7 +745,7 @@ conduct console # a tab in Windows Terminal (window "conductor"), in ~/
|
|
|
718
745
|
conduct console --here # the same session in this terminal
|
|
719
746
|
```
|
|
720
747
|
|
|
721
|
-
It writes the plugin copy if none exists yet (see Plugin), then runs `claude --plugin-dir <home>/plugin -n conductor --permission-mode auto -- /conductor:conductor-console` with `CONDUCTOR_CONSOLE=1`.
|
|
748
|
+
It writes the plugin copy if none exists yet (see Plugin), then runs `claude --plugin-dir <home>/plugin -n conductor --permission-mode auto -- /conductor:conductor-console` with `CONDUCTOR_CONSOLE=1`. The first console in that folder asks Claude Code to trust it once, and the mod's band and the console's stream start only after you accept; background sessions there run with no prompt (ATK-525). The skill starts the daemon if it isn't running (`conduct start`, detached, so it doesn't end with the session), then handles what waits.
|
|
722
749
|
|
|
723
750
|
**Escalations.** A rule's `escalate console "<ask>"` appends `console.escalated` (the default `conductor-handoff` does it for a worker's `Handoff · conductor` note). The mod wakes the model with `[conductor] escalation #<id> on <item>: <ask>`. The console does the job and reports back with `conduct escalations done <id> "<what it found or did>"`, which logs `console.handled` with the escalation as its cause, so `conduct why <that event>` leads to the rule. With `--ask`, the finding also goes in the inbox as a `console` item, pushed like any other, for something only you can do. `conduct escalations` lists those not yet handled, from the last 14 days, including the ones raised while no console was open; the console reads it when it starts.
|
|
724
751
|
|
package/build/broker-cli.js
CHANGED
|
@@ -7,7 +7,8 @@ import { itemFor } from "./code-host-cli.js";
|
|
|
7
7
|
import { ConductorError } from "./errors.js";
|
|
8
8
|
import { fileLock } from "./file-lock.js";
|
|
9
9
|
import { usageError } from "./help.js";
|
|
10
|
-
import {
|
|
10
|
+
import { samePath } from "./paths.js";
|
|
11
|
+
import { PROFILE_FILE, profileProblem, readProfile } from "./profile.js";
|
|
11
12
|
import { lockSchema, portAssignmentSchema } from "./store.js";
|
|
12
13
|
import { daemonWorker, gitToplevel } from "./wait.js";
|
|
13
14
|
// How often `conduct lock acquire --wait` asks again, as the work-issue helper's lock does.
|
|
@@ -69,13 +70,14 @@ export async function lockCommand(paths, action, name, options, io) {
|
|
|
69
70
|
export function lockLine(lock) {
|
|
70
71
|
return ` ${lock.name} ${lock.holder} since ${new Date(lock.since).toLocaleString()}${lock.verdict === 'stale' ? ' (stale: free to take over)' : ''}`;
|
|
71
72
|
}
|
|
72
|
-
// `conduct ports [<
|
|
73
|
+
// `conduct ports [<checkout>]`: every worktree's dev ports from the broker, or one worktree's. Only a
|
|
74
|
+
// launch assigns them, so this only reads.
|
|
73
75
|
export async function portsCommand(paths, path, options, io) {
|
|
76
|
+
const answer = await ask(paths, { method: 'GET', path: '/ports' });
|
|
77
|
+
if (!answer.ok)
|
|
78
|
+
return notBrokered(answer.why);
|
|
79
|
+
const assignments = z.object({ assignments: z.array(portAssignmentSchema) }).parse(answer.body).assignments;
|
|
74
80
|
if (path === undefined) {
|
|
75
|
-
const answer = await ask(paths, { method: 'GET', path: '/ports' });
|
|
76
|
-
if (!answer.ok)
|
|
77
|
-
return notBrokered(answer.why);
|
|
78
|
-
const assignments = z.object({ assignments: z.array(portAssignmentSchema) }).parse(answer.body).assignments;
|
|
79
81
|
if (options.json)
|
|
80
82
|
io.out(JSON.stringify(assignments, null, 2));
|
|
81
83
|
else if (assignments.length === 0)
|
|
@@ -84,23 +86,21 @@ export async function portsCommand(paths, path, options, io) {
|
|
|
84
86
|
assignments.forEach((a) => io.out(portsLine(a)));
|
|
85
87
|
return 0;
|
|
86
88
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return notBrokered(answer.why);
|
|
94
|
-
const { assignment } = z.object({ assignment: portAssignmentSchema }).parse(answer.body);
|
|
89
|
+
const worktree = resolve(io.cwd, path);
|
|
90
|
+
const assignment = assignments.find((a) => samePath(a.worktree, worktree));
|
|
91
|
+
if (!assignment)
|
|
92
|
+
throw new ConductorError(`${worktree} has no ports from the conductor`, [
|
|
93
|
+
'a conducted worktree gets them when its worker launches; `conduct ports` lists them all',
|
|
94
|
+
]);
|
|
95
95
|
io.out(options.json ? JSON.stringify(assignment, null, 2) : portsLine(assignment));
|
|
96
96
|
return 0;
|
|
97
97
|
}
|
|
98
|
-
// One line per
|
|
98
|
+
// One line per worktree: project, each port, then where.
|
|
99
99
|
export function portsLine(a) {
|
|
100
100
|
const ports = Object.entries(a.ports)
|
|
101
101
|
.map(([name, port]) => `${name} ${port}`)
|
|
102
102
|
.join(' · ');
|
|
103
|
-
return `${a.project}
|
|
103
|
+
return `${a.project} ${ports} ${a.worktree}`;
|
|
104
104
|
}
|
|
105
105
|
// The body of a 200 answer, or why nothing was brokered.
|
|
106
106
|
async function ask(paths, request) {
|
|
@@ -134,8 +134,8 @@ async function lockHere(action, name, options, io, why) {
|
|
|
134
134
|
try {
|
|
135
135
|
profile = readProfile(checkout).profile;
|
|
136
136
|
}
|
|
137
|
-
catch {
|
|
138
|
-
return notBrokered(`${why}, and ${checkout} has no readable ${PROFILE_FILE}`);
|
|
137
|
+
catch (error) {
|
|
138
|
+
return notBrokered(`${why}, and ${checkout} has no readable ${PROFILE_FILE}: ${profileProblem(error)}`);
|
|
139
139
|
}
|
|
140
140
|
const given = options.owner?.trim();
|
|
141
141
|
// Status needs no owner; the others take the checkout's item, and refuse without one.
|
|
@@ -158,9 +158,7 @@ async function lockHere(action, name, options, io, why) {
|
|
|
158
158
|
io.out(JSON.stringify(answer, null, 2));
|
|
159
159
|
return 0;
|
|
160
160
|
}
|
|
161
|
-
// Nothing brokered: none runs, an older one has no broker, or it refused
|
|
162
|
-
// registered project, no free slot). The caller keeps its own scheme, as ATK's
|
|
163
|
-
// scripts/dev-ports.mjs does on any exit but 0.
|
|
161
|
+
// Nothing brokered: none runs, an older one has no broker, or it refused.
|
|
164
162
|
function notBrokered(why) {
|
|
165
163
|
throw new ConductorError(`not brokered: ${why}`, ['nothing was brokered']);
|
|
166
164
|
}
|
package/build/broker-cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broker-cli.js","sourceRoot":"","sources":["../src/broker-cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAoB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"broker-cli.js","sourceRoot":"","sources":["../src/broker-cli.ts"],"names":[],"mappings":"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"}
|
package/build/broker.js
CHANGED
|
@@ -1,83 +1,65 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { connect } from 'node:net';
|
|
2
|
+
import { connect, createServer } from 'node:net';
|
|
3
3
|
import { ConductorError } from "./errors.js";
|
|
4
4
|
import { samePath } from "./paths.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
// server ports, so two projects' worktrees never serve on the same port, and named locks such as
|
|
10
|
-
// chrome, so verify skills of different projects take turns with the one browser.
|
|
5
|
+
import { ACTIVE_STATUSES } from "./store.js";
|
|
6
|
+
// The broker shares what every project on the machine uses (design note, sections 3 and 12; v2,
|
|
7
|
+
// section 2): dev server ports, so two worktrees of any projects never serve on the same port, and
|
|
8
|
+
// named locks such as chrome, so verify skills of different projects take turns with the one browser.
|
|
11
9
|
// A holder keeps a lock by acquiring it again; one it hasn't acquired for this long is free to take
|
|
12
10
|
// over. The same rule as the work-issue helper's file lock.
|
|
13
11
|
export const LOCK_STALE_MS = 30 * 60_000;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// One assignment at a time: each waits on port probes, and two at once could pick the same slot.
|
|
12
|
+
export function broker({ exists = existsSync, free = portFree, now = () => new Date(), pool, store, }) {
|
|
13
|
+
// One assignment at a time: each waits on port probes, and two at once could pick the same port.
|
|
17
14
|
let assigning = Promise.resolve();
|
|
18
|
-
|
|
15
|
+
const serially = (task) => {
|
|
16
|
+
const next = assigning.then(task);
|
|
17
|
+
assigning = next.catch(() => { });
|
|
18
|
+
return next;
|
|
19
|
+
};
|
|
20
|
+
// Frees the ports of worktrees whose directory is gone, such as one /worktree-gc removed. Not a
|
|
21
|
+
// worker's in flight: a launch takes its ports before `git worktree add` makes the directory.
|
|
19
22
|
const sweep = () => {
|
|
20
23
|
for (const held of store.portAssignments())
|
|
21
|
-
if (!
|
|
24
|
+
if (!live(held))
|
|
22
25
|
freePorts(store, held.worktree, 'worktree gone');
|
|
23
26
|
return store.portAssignments();
|
|
24
27
|
};
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const live = (held) => exists(held.worktree) ||
|
|
29
|
+
store.listWorkers(ACTIVE_STATUSES).some((w) => w.worktree !== null && samePath(w.worktree, held.worktree));
|
|
30
|
+
// A port per name: the pool's first ones that no assignment in `held` has and that are free now.
|
|
31
|
+
const pick = async (names, held) => {
|
|
32
|
+
const taken = new Set(held.flatMap((a) => Object.values(a.ports)));
|
|
33
|
+
const ports = {};
|
|
34
|
+
let port = pool.first;
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
while (port <= pool.last && (taken.has(port) || !(await free(port))))
|
|
37
|
+
port++;
|
|
38
|
+
if (port > pool.last) {
|
|
39
|
+
throw new ConductorError(`no free port left in ${pool.first}-${pool.last} for "${name}"`, [
|
|
40
|
+
'`conduct ports` lists the assignments, and a removed worktree frees its ports; ports.first and ports.last in config.json set the pool',
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
ports[name] = port++;
|
|
44
|
+
}
|
|
45
|
+
return ports;
|
|
46
|
+
};
|
|
47
|
+
const assign = async (worktree, project, names) => {
|
|
48
|
+
const held = sweep().find((a) => samePath(a.worktree, worktree));
|
|
38
49
|
if (held) {
|
|
39
|
-
|
|
40
|
-
if (held.slot <= profile.ports.maxSlot && samePorts(held.ports, slotPorts(profile, held.slot)))
|
|
50
|
+
if (sameNames(held.ports, names))
|
|
41
51
|
return held;
|
|
42
52
|
freePorts(store, held.worktree, 'profile changed');
|
|
43
|
-
prefer ??= held.slot;
|
|
44
53
|
}
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
const assignment = store.savePorts({
|
|
49
|
-
store.appendEvent({ data: {
|
|
54
|
+
const ports = await pick(names, store.portAssignments());
|
|
55
|
+
if (names.length === 0)
|
|
56
|
+
return { assignedAt: now().toISOString(), ports, project, worktree };
|
|
57
|
+
const assignment = store.savePorts({ ports, project, worktree });
|
|
58
|
+
store.appendEvent({ data: { ports, worktree }, kind: 'ports.assigned', project });
|
|
50
59
|
return assignment;
|
|
51
60
|
};
|
|
52
|
-
// The first slot of 1..maxSlot whose ports nobody has and nothing listens on, `prefer` first.
|
|
53
|
-
// `held`: the worktrees' assignments.
|
|
54
|
-
const freeSlot = async (profile, prefer, held) => {
|
|
55
|
-
// Every main checkout's ports, and every worktree's.
|
|
56
|
-
const taken = new Set([
|
|
57
|
-
...store.listProjects().flatMap((p) => (p.profile ? Object.values(basePorts(p.profile)) : [])),
|
|
58
|
-
...held.flatMap((a) => Object.values(a.ports)),
|
|
59
|
-
]);
|
|
60
|
-
const slots = Array.from({ length: profile.ports.maxSlot }, (_, i) => i + 1);
|
|
61
|
-
const order = prefer !== undefined && slots.includes(prefer) ? [prefer, ...slots.filter((s) => s !== prefer)] : slots;
|
|
62
|
-
for (const slot of order) {
|
|
63
|
-
const ports = slotPorts(profile, slot);
|
|
64
|
-
const values = Object.values(ports);
|
|
65
|
-
if (values.some((port) => port > MAX_PORT || taken.has(port)))
|
|
66
|
-
continue;
|
|
67
|
-
// The preferred slot is the one the checkout already serves on (its own scheme gave it), so
|
|
68
|
-
// its own dev servers may be what listens there.
|
|
69
|
-
if (slot !== prefer && (await Promise.all(values.map(listening))).some(Boolean))
|
|
70
|
-
continue;
|
|
71
|
-
return { ports, slot };
|
|
72
|
-
}
|
|
73
|
-
return null;
|
|
74
|
-
};
|
|
75
61
|
return {
|
|
76
|
-
assignPorts: (
|
|
77
|
-
const next = assigning.then(() => assign(path, prefer));
|
|
78
|
-
assigning = next.catch(() => { });
|
|
79
|
-
return next;
|
|
80
|
-
},
|
|
62
|
+
assignPorts: (worktree, project, names) => serially(() => assign(worktree, project, names)),
|
|
81
63
|
lock: (name, action, owner) => {
|
|
82
64
|
const current = store.getLock(name);
|
|
83
65
|
const verdict = lockVerdict(current, owner, now());
|
|
@@ -113,19 +95,9 @@ export function broker({ exists = existsSync, listening = portListening, now = (
|
|
|
113
95
|
},
|
|
114
96
|
locks: () => store.listLocks(),
|
|
115
97
|
portAssignments: sweep,
|
|
116
|
-
// After the assignments in flight, so it sees the
|
|
98
|
+
// After the assignments in flight, so it sees the ports they took. It writes nothing: a gone
|
|
117
99
|
// worktree's ports count as free, as the next sweep makes them.
|
|
118
|
-
previewPorts: (
|
|
119
|
-
const next = assigning.then(async () => {
|
|
120
|
-
const held = store.portAssignments().filter((a) => exists(a.worktree));
|
|
121
|
-
const free = await freeSlot(profile, undefined, held);
|
|
122
|
-
if (free === null)
|
|
123
|
-
throw noSlot(profile, `a new ${profile.name} worktree`);
|
|
124
|
-
return free;
|
|
125
|
-
});
|
|
126
|
-
assigning = next.catch(() => { });
|
|
127
|
-
return next;
|
|
128
|
-
},
|
|
100
|
+
previewPorts: (names) => serially(() => pick(names, store.portAssignments().filter(live))),
|
|
129
101
|
};
|
|
130
102
|
}
|
|
131
103
|
// Frees the ports of a worktree (a retired worker's, or one whose directory is gone).
|
|
@@ -135,7 +107,7 @@ export function freePorts(store, worktree, reason) {
|
|
|
135
107
|
continue;
|
|
136
108
|
store.deletePorts(held.worktree);
|
|
137
109
|
store.appendEvent({
|
|
138
|
-
data: { ports: held.ports, reason,
|
|
110
|
+
data: { ports: held.ports, reason, worktree: held.worktree },
|
|
139
111
|
kind: 'ports.freed',
|
|
140
112
|
project: held.project,
|
|
141
113
|
});
|
|
@@ -149,6 +121,24 @@ export function lockVerdict(lock, owner, now) {
|
|
|
149
121
|
const since = Date.parse(lock.since);
|
|
150
122
|
return Number.isNaN(since) || now.getTime() - since > LOCK_STALE_MS ? 'stale' : 'held';
|
|
151
123
|
}
|
|
124
|
+
// True when the port can be bound on IPv4 loopback: not in use, and not in a range the OS reserves
|
|
125
|
+
// (Windows excludes ranges for Hyper-V and WSL, where a dev server would fail with EACCES).
|
|
126
|
+
export async function portBinds(port) {
|
|
127
|
+
return new Promise((done) => {
|
|
128
|
+
const server = createServer();
|
|
129
|
+
server.once('error', () => done(false));
|
|
130
|
+
server.listen({ exclusive: true, host: '127.0.0.1', port }, () => server.close(() => done(true)));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// The environment that hands a worktree its ports: CONDUCTOR_PORT_<NAME> for each, such as
|
|
134
|
+
// CONDUCTOR_PORT_WEB=41000.
|
|
135
|
+
export function portEnv(ports) {
|
|
136
|
+
return Object.fromEntries(Object.entries(ports).map(([name, port]) => [`CONDUCTOR_PORT_${name.toUpperCase()}`, String(port)]));
|
|
137
|
+
}
|
|
138
|
+
// Nothing listens on the port, on either loopback, and it binds.
|
|
139
|
+
export async function portFree(port) {
|
|
140
|
+
return !(await portListening(port)) && (await portBinds(port));
|
|
141
|
+
}
|
|
152
142
|
// True when something accepts connections on the port, on IPv4 or IPv6 loopback.
|
|
153
143
|
export async function portListening(port) {
|
|
154
144
|
const probe = (host) => new Promise((done) => {
|
|
@@ -172,23 +162,7 @@ export function releaseLocks(store, holder, reason) {
|
|
|
172
162
|
store.appendEvent({ data: { holder, reason }, kind: 'lock.released', subject: lock.name });
|
|
173
163
|
}
|
|
174
164
|
}
|
|
175
|
-
function
|
|
176
|
-
return
|
|
177
|
-
'`conduct ports` lists the assignments; a removed worktree frees its slot',
|
|
178
|
-
]);
|
|
179
|
-
}
|
|
180
|
-
function profileOf(project) {
|
|
181
|
-
if (project.profile)
|
|
182
|
-
return project.profile;
|
|
183
|
-
throw new ConductorError(`project ${project.name}'s stored profile is invalid: ${project.problem ?? 'unknown'}`, [
|
|
184
|
-
`the daemon reads ${project.profilePath} again when it starts or the file is saved`,
|
|
185
|
-
]);
|
|
186
|
-
}
|
|
187
|
-
function samePorts(a, b) {
|
|
188
|
-
return Object.keys(a).length === Object.keys(b).length && Object.entries(b).every(([name, port]) => a[name] === port);
|
|
189
|
-
}
|
|
190
|
-
// A worktree's ports at slot N: each of the profile's base ports + N.
|
|
191
|
-
function slotPorts(profile, slot) {
|
|
192
|
-
return Object.fromEntries(Object.entries(basePorts(profile)).map(([name, port]) => [name, port + slot]));
|
|
165
|
+
function sameNames(ports, names) {
|
|
166
|
+
return Object.keys(ports).length === names.length && names.every((name) => Object.hasOwn(ports, name));
|
|
193
167
|
}
|
|
194
168
|
//# sourceMappingURL=broker.js.map
|