@nowcrew/daemon 0.5.32 → 0.5.33
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 +58 -0
- package/dist/computer-service.js +30 -1
- package/dist/daemon-update-controller.js +64 -0
- package/dist/daemon-update-eligibility.js +80 -0
- package/dist/daemon-updater.js +61 -0
- package/dist/host-execution-coordinator.js +23 -0
- package/dist/machine-info.js +3 -2
- package/dist/prompt.js +12 -4
- package/dist/serve.js +42 -2
- package/dist/shared-execution-slots.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,62 @@ crew-daemon upgrade
|
|
|
66
66
|
crew-daemon upgrade --profile work
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
## Managed Self-Update
|
|
70
|
+
|
|
71
|
+
An owner or admin can update an eligible outdated daemon from the computer detail view. The HTTP request
|
|
72
|
+
returns immediately; the server records the exact registry release, dispatches it to the daemon, and the
|
|
73
|
+
Web UI polls the durable `pending -> installing -> restarting -> completed` state. Completion is recorded
|
|
74
|
+
only after the native service reconnects and reports that exact version.
|
|
75
|
+
|
|
76
|
+
Existing installations need one manual bootstrap to a release containing managed self-update:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
crew-daemon upgrade --profile work
|
|
80
|
+
crew-daemon status --profile work
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The daemon advertises `daemon_update_v1` only when all of these conditions hold:
|
|
84
|
+
|
|
85
|
+
| Requirement | Supported shape |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Platform | macOS LaunchAgent or Linux systemd user service |
|
|
88
|
+
| Entrypoint | global `@nowcrew/daemon/dist/main.js`, not npx or source/tsx |
|
|
89
|
+
| Startup | `crew-daemon serve --profile <name>` through the installed service |
|
|
90
|
+
| Profiles | exactly one installed daemon service using the global package |
|
|
91
|
+
| Service | the selected profile is currently running |
|
|
92
|
+
| Install root | the current user can write the global npm modules root |
|
|
93
|
+
|
|
94
|
+
Windows, npx/source runs, stopped or uninstalled services, multiple installed profiles, and read-only
|
|
95
|
+
global package roots remain manually upgradeable. These shapes do not show an update button.
|
|
96
|
+
|
|
97
|
+
Before replacing files, the daemon refuses new work, requires zero local active or queued work, and
|
|
98
|
+
non-blockingly acquires every host execution slot. It then installs only the server-selected exact
|
|
99
|
+
`x.y.z` version with npm, verifies the installed package version, and schedules the existing native
|
|
100
|
+
service restart from a detached helper. No package name, registry, command, or free-form argument comes
|
|
101
|
+
from the browser control message.
|
|
102
|
+
|
|
103
|
+
Failures are recoverable and keep a bounded code in the machine record:
|
|
104
|
+
|
|
105
|
+
| Code | Operator action |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `runtime_busy` | let active/queued Agent work finish, then retry |
|
|
108
|
+
| `dispatch_unavailable` | restore the daemon connection, then retry |
|
|
109
|
+
| `ineligible` | re-run `status` and the eligibility checks above; upgrade manually if needed |
|
|
110
|
+
| `install_failed` | fix npm/network/write access; the old process keeps serving, then retry |
|
|
111
|
+
| `version_mismatch` | inspect the global npm installation and install the intended version manually |
|
|
112
|
+
| `restart_failed` | run `crew-daemon restart --profile work`; the package may already be updated |
|
|
113
|
+
| `update_in_progress` | wait for the active request to reach a terminal state |
|
|
114
|
+
| `update_timeout` | inspect `crew-daemon status --profile work`, restart if needed, then retry |
|
|
115
|
+
|
|
116
|
+
Remote downgrade is intentionally unsupported. To roll back, install a known exact release and restart
|
|
117
|
+
the service manually:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm install --global --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-x.y.z>
|
|
121
|
+
crew-daemon restart --profile work
|
|
122
|
+
crew-daemon status --profile work
|
|
123
|
+
```
|
|
124
|
+
|
|
69
125
|
## Execution Boundary
|
|
70
126
|
|
|
71
127
|
The server selects the machine and sends a validated `execution:start` containing:
|
|
@@ -215,6 +271,8 @@ failure matrix, rollout limits, and the server-native follow-up design.
|
|
|
215
271
|
## Code Map
|
|
216
272
|
|
|
217
273
|
- `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
|
|
274
|
+
- `src/daemon-update-eligibility.ts`, `src/daemon-updater.ts`, and `src/daemon-update-controller.ts`:
|
|
275
|
+
managed-update eligibility, exact npm replacement, host-wide admission barrier, and restart handoff.
|
|
218
276
|
- `src/execution-runner.ts`: spec admission and lifecycle reporting.
|
|
219
277
|
- `src/shared-execution-slots.ts` and `src/runtime-startup-gate.ts`: machine concurrency and startup FIFO.
|
|
220
278
|
- `src/local-executor.ts` and `src/execution-supervisor.ts`: runtime process boundary.
|
package/dist/computer-service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { dirname, resolve } from "node:path";
|
|
@@ -273,6 +273,35 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
const RESTART_HELPER_SOURCE = String.raw `
|
|
277
|
+
const { spawnSync } = require("node:child_process");
|
|
278
|
+
const spec = JSON.parse(process.argv[1]);
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
if (spec.platform === "darwin") {
|
|
281
|
+
spawnSync("launchctl", ["bootout", spec.target], { stdio: "ignore", shell: false });
|
|
282
|
+
const started = spawnSync("launchctl", ["bootstrap", spec.domain, spec.descriptorPath], { stdio: "ignore", shell: false });
|
|
283
|
+
process.exit(started.status === 0 ? 0 : 1);
|
|
284
|
+
}
|
|
285
|
+
const restarted = spawnSync("systemctl", ["--user", "restart", spec.id], { stdio: "ignore", shell: false });
|
|
286
|
+
process.exit(restarted.status === 0 ? 0 : 1);
|
|
287
|
+
}, 250);
|
|
288
|
+
`;
|
|
289
|
+
export function scheduleServiceRestart(spec, spawnDetached = spawn) {
|
|
290
|
+
if (spec.platform === "win32")
|
|
291
|
+
throw new Error("Windows detached restart is not supported");
|
|
292
|
+
if (spec.descriptorPath === null)
|
|
293
|
+
throw new Error("service descriptor is required for restart");
|
|
294
|
+
const payload = spec.platform === "darwin"
|
|
295
|
+
? {
|
|
296
|
+
platform: spec.platform,
|
|
297
|
+
target: `${spec.managerDomain}/${spec.id}`,
|
|
298
|
+
domain: spec.managerDomain,
|
|
299
|
+
descriptorPath: spec.descriptorPath,
|
|
300
|
+
}
|
|
301
|
+
: { platform: spec.platform, id: spec.id };
|
|
302
|
+
const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: "ignore", shell: false });
|
|
303
|
+
child.unref();
|
|
304
|
+
}
|
|
276
305
|
export async function serviceStatus(spec, runner = systemCommandRunner) {
|
|
277
306
|
if (spec.platform === "win32") {
|
|
278
307
|
const task = await windowsTaskState(spec, runner);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const DaemonUpdateMessageSchema = z.object({
|
|
3
|
+
type: z.literal("daemon:update"),
|
|
4
|
+
updateId: z.string().uuid(),
|
|
5
|
+
targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
6
|
+
}).strict();
|
|
7
|
+
export function createDaemonUpdateController(deps) {
|
|
8
|
+
const handled = new Set();
|
|
9
|
+
let running = null;
|
|
10
|
+
const failed = (updateId, errorCode) => {
|
|
11
|
+
deps.sendStatus({ type: "daemon:update-status", updateId, status: "failed", errorCode });
|
|
12
|
+
};
|
|
13
|
+
const execute = async (message) => {
|
|
14
|
+
const eligibility = await deps.eligibility();
|
|
15
|
+
if (!eligibility.eligible) {
|
|
16
|
+
failed(message.updateId, "ineligible");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const installed = await deps.install({
|
|
20
|
+
targetVersion: message.targetVersion,
|
|
21
|
+
packageRoot: eligibility.packageRoot,
|
|
22
|
+
onInstalling: () => {
|
|
23
|
+
deps.sendStatus({ type: "daemon:update-status", updateId: message.updateId, status: "installing" });
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!installed.ok) {
|
|
27
|
+
failed(message.updateId, installed.errorCode);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
deps.sendStatus({ type: "daemon:update-status", updateId: message.updateId, status: "restarting" });
|
|
31
|
+
try {
|
|
32
|
+
deps.scheduleRestart(eligibility.serviceSpec);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await installed.release();
|
|
36
|
+
failed(message.updateId, "restart_failed");
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
handle: async (input) => {
|
|
41
|
+
const parsed = DaemonUpdateMessageSchema.safeParse(input);
|
|
42
|
+
if (!parsed.success)
|
|
43
|
+
return false;
|
|
44
|
+
const message = parsed.data;
|
|
45
|
+
if (handled.has(message.updateId))
|
|
46
|
+
return true;
|
|
47
|
+
if (running !== null) {
|
|
48
|
+
if (running.id === message.updateId)
|
|
49
|
+
await running.done;
|
|
50
|
+
else
|
|
51
|
+
failed(message.updateId, "update_in_progress");
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const done = execute(message).finally(() => {
|
|
55
|
+
handled.add(message.updateId);
|
|
56
|
+
if (running?.id === message.updateId)
|
|
57
|
+
running = null;
|
|
58
|
+
});
|
|
59
|
+
running = { id: message.updateId, done };
|
|
60
|
+
await done;
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { daemonHome, listProfiles } from "./computer-profile.js";
|
|
6
|
+
import { builtDaemonEntry, isGlobalDaemonEntry } from "./computer-cli.js";
|
|
7
|
+
import { buildServiceSpec, serviceStatus, systemCommandRunner, } from "./computer-service.js";
|
|
8
|
+
function defaults() {
|
|
9
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
10
|
+
return {
|
|
11
|
+
platform: process.platform,
|
|
12
|
+
profileHome: daemonHome(),
|
|
13
|
+
userHome: homedir(),
|
|
14
|
+
uid: process.getuid?.(),
|
|
15
|
+
nodePath: process.execPath,
|
|
16
|
+
entryPath: builtDaemonEntry(),
|
|
17
|
+
resolveGlobalNodeModules: async () => {
|
|
18
|
+
const result = await systemCommandRunner(npmCommand, ["root", "--global"]);
|
|
19
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
20
|
+
throw new Error(result.stderr.trim() || "global npm root unavailable");
|
|
21
|
+
}
|
|
22
|
+
return result.stdout.trim();
|
|
23
|
+
},
|
|
24
|
+
listProfiles,
|
|
25
|
+
serviceStatus,
|
|
26
|
+
assertWritable: (path) => access(path, constants.W_OK),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function detectDaemonUpdateEligibility(profileName, overrides = {}) {
|
|
30
|
+
const deps = { ...defaults(), ...overrides };
|
|
31
|
+
if (deps.platform !== "darwin" && deps.platform !== "linux") {
|
|
32
|
+
return { eligible: false, reason: "unsupported_platform" };
|
|
33
|
+
}
|
|
34
|
+
if (!profileName)
|
|
35
|
+
return { eligible: false, reason: "profile_required" };
|
|
36
|
+
let globalNodeModules;
|
|
37
|
+
try {
|
|
38
|
+
globalNodeModules = await deps.resolveGlobalNodeModules();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { eligible: false, reason: "global_install_required" };
|
|
42
|
+
}
|
|
43
|
+
if (!isGlobalDaemonEntry(deps.entryPath, globalNodeModules)) {
|
|
44
|
+
return { eligible: false, reason: "global_install_required" };
|
|
45
|
+
}
|
|
46
|
+
const profiles = await deps.listProfiles(deps.profileHome);
|
|
47
|
+
const installed = [];
|
|
48
|
+
for (const profile of profiles) {
|
|
49
|
+
const spec = buildServiceSpec({
|
|
50
|
+
platform: deps.platform,
|
|
51
|
+
profile,
|
|
52
|
+
userHome: deps.userHome,
|
|
53
|
+
uid: deps.uid,
|
|
54
|
+
nodePath: deps.nodePath,
|
|
55
|
+
entryPath: deps.entryPath,
|
|
56
|
+
profileHome: deps.profileHome,
|
|
57
|
+
});
|
|
58
|
+
const status = await deps.serviceStatus(spec);
|
|
59
|
+
if (status.installed)
|
|
60
|
+
installed.push({ profile, spec, running: status.running });
|
|
61
|
+
}
|
|
62
|
+
const current = installed.find((entry) => entry.profile === profileName);
|
|
63
|
+
if (!current?.running)
|
|
64
|
+
return { eligible: false, reason: "service_not_running" };
|
|
65
|
+
if (installed.length !== 1)
|
|
66
|
+
return { eligible: false, reason: "multiple_managed_profiles" };
|
|
67
|
+
try {
|
|
68
|
+
await deps.assertWritable(globalNodeModules);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { eligible: false, reason: "global_root_not_writable" };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
eligible: true,
|
|
75
|
+
profileName,
|
|
76
|
+
globalNodeModules,
|
|
77
|
+
packageRoot: resolve(globalNodeModules, "@nowcrew", "daemon"),
|
|
78
|
+
serviceSpec: current.spec,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { systemCommandRunner } from "./computer-service.js";
|
|
4
|
+
const RELEASED_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
5
|
+
async function readPackageVersion(packageRoot) {
|
|
6
|
+
const body = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
|
7
|
+
return typeof body.version === "string" ? body.version : "";
|
|
8
|
+
}
|
|
9
|
+
export async function installExactDaemonUpdate(input) {
|
|
10
|
+
if (!RELEASED_VERSION_RE.test(input.targetVersion)) {
|
|
11
|
+
return { ok: false, errorCode: "ineligible" };
|
|
12
|
+
}
|
|
13
|
+
const local = input.localSlots.tryAcquireExclusive();
|
|
14
|
+
if (local === null)
|
|
15
|
+
return { ok: false, errorCode: "runtime_busy" };
|
|
16
|
+
const host = await input.hostCoordinator.tryAcquireExclusiveExecution();
|
|
17
|
+
if (host === null) {
|
|
18
|
+
local.release();
|
|
19
|
+
return { ok: false, errorCode: "runtime_busy" };
|
|
20
|
+
}
|
|
21
|
+
let released = false;
|
|
22
|
+
const release = async () => {
|
|
23
|
+
if (released)
|
|
24
|
+
return;
|
|
25
|
+
released = true;
|
|
26
|
+
await host.release();
|
|
27
|
+
local.release();
|
|
28
|
+
};
|
|
29
|
+
const runner = input.runner ?? systemCommandRunner;
|
|
30
|
+
try {
|
|
31
|
+
await input.onInstalling?.();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
await release();
|
|
35
|
+
return { ok: false, errorCode: "install_failed" };
|
|
36
|
+
}
|
|
37
|
+
const result = await runner(process.platform === "win32" ? "npm.cmd" : "npm", [
|
|
38
|
+
"install",
|
|
39
|
+
"--global",
|
|
40
|
+
"--ignore-scripts",
|
|
41
|
+
"--no-audit",
|
|
42
|
+
"--no-fund",
|
|
43
|
+
`@nowcrew/daemon@${input.targetVersion}`,
|
|
44
|
+
]).catch(() => null);
|
|
45
|
+
if (result === null || result.exitCode !== 0) {
|
|
46
|
+
await release();
|
|
47
|
+
return { ok: false, errorCode: "install_failed" };
|
|
48
|
+
}
|
|
49
|
+
let installedVersion = "";
|
|
50
|
+
try {
|
|
51
|
+
installedVersion = await (input.readInstalledVersion ?? readPackageVersion)(input.packageRoot);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Version verification is authoritative; unreadable package metadata is a mismatch.
|
|
55
|
+
}
|
|
56
|
+
if (installedVersion !== input.targetVersion) {
|
|
57
|
+
await release();
|
|
58
|
+
return { ok: false, errorCode: "version_mismatch" };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, release };
|
|
61
|
+
}
|
|
@@ -185,6 +185,28 @@ export function createHostExecutionCoordinator(options = {}) {
|
|
|
185
185
|
return {
|
|
186
186
|
reserveExecution: (prerequisite) => tracked(reservation(prerequisite, acquireExecution)),
|
|
187
187
|
reserveStartup: (prerequisite) => tracked(reservation(prerequisite, acquireStartup)),
|
|
188
|
+
tryAcquireExclusiveExecution: async () => {
|
|
189
|
+
const leases = [];
|
|
190
|
+
for (let slot = 0; slot < executionSlots; slot += 1) {
|
|
191
|
+
const lease = await leaseFor(join(root, "slots", String(slot)));
|
|
192
|
+
if (lease === null) {
|
|
193
|
+
await Promise.all(leases.map((owned) => owned.close()));
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
leases.push(lease);
|
|
197
|
+
}
|
|
198
|
+
let releasePromise = null;
|
|
199
|
+
return {
|
|
200
|
+
release: () => {
|
|
201
|
+
if (releasePromise !== null)
|
|
202
|
+
return releasePromise;
|
|
203
|
+
releasePromise = Promise.all(leases.map((lease) => lease.close())).then(() => undefined);
|
|
204
|
+
pendingReleases.add(releasePromise);
|
|
205
|
+
void releasePromise.then(() => pendingReleases.delete(releasePromise), () => pendingReleases.delete(releasePromise));
|
|
206
|
+
return releasePromise;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
},
|
|
188
210
|
drain: async () => {
|
|
189
211
|
while (pendingReleases.size > 0)
|
|
190
212
|
await Promise.all([...pendingReleases]);
|
|
@@ -216,6 +238,7 @@ export function hostCoordinatedSlotManager(local, host) {
|
|
|
216
238
|
};
|
|
217
239
|
},
|
|
218
240
|
snapshot: local.snapshot,
|
|
241
|
+
tryAcquireExclusive: local.tryAcquireExclusive,
|
|
219
242
|
};
|
|
220
243
|
}
|
|
221
244
|
export function hostCoordinatedStartupGate(local, host) {
|
package/dist/machine-info.js
CHANGED
|
@@ -101,9 +101,10 @@ async function listAgentHandles(agentsRoot) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
export async function collectMachineHello(agentsRoot, executionLimits, runtimePlatform = process.platform, dependencies = {}) {
|
|
104
|
-
const [runtimes, agentHandles] = await Promise.all([
|
|
104
|
+
const [runtimes, agentHandles, additionalCapabilities] = await Promise.all([
|
|
105
105
|
(dependencies.detectInstalled ?? detectRuntimes)(),
|
|
106
106
|
listAgentHandles(agentsRoot),
|
|
107
|
+
dependencies.additionalCapabilities?.() ?? Promise.resolve([]),
|
|
107
108
|
]);
|
|
108
109
|
const backend = executionBackendCapability(runtimePlatform, dependencies.jobObjectProbe);
|
|
109
110
|
const executionRuntimes = backend.supported
|
|
@@ -116,7 +117,7 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
|
|
|
116
117
|
daemonVersion: daemonVersion(),
|
|
117
118
|
runtimes,
|
|
118
119
|
executionRuntimes,
|
|
119
|
-
capabilities: DAEMON_CAPABILITIES,
|
|
120
|
+
capabilities: [...DAEMON_CAPABILITIES, ...additionalCapabilities],
|
|
120
121
|
...(backend.supported ? {
|
|
121
122
|
executionProtocol: EXECUTION_PROTOCOL,
|
|
122
123
|
executionLimits: Object.freeze({
|
package/dist/prompt.js
CHANGED
|
@@ -11,6 +11,14 @@ export const WORKLOG_INJECT_CAP = 8000;
|
|
|
11
11
|
export const MEMORY_INJECT_CAP = 6000;
|
|
12
12
|
const WORKLOG_HEAD = 4800;
|
|
13
13
|
const WORKLOG_TAIL = 3000;
|
|
14
|
+
const EXTERNAL_RESULT_READABILITY = `
|
|
15
|
+
|
|
16
|
+
## 外部 IM 结果可读性(CRITICAL)
|
|
17
|
+
- 本节只约束通过 reply-origin 或 notify-bound-im 外发的正文;NowWork 内部确认、进度和协作消息保持原有表达方式。
|
|
18
|
+
- 前三行依次说清结论、影响和需要谁做什么;不要用背景铺垫或复述任务开场。
|
|
19
|
+
- 除非用户明确要求完整明细,正文最多三个短小节、最多六个要点;总结日志、命令输出和长表格,不要整段粘贴。
|
|
20
|
+
- 有足够重点时,只加粗三到五处真正决定性的数字、最终状态、风险、截止时间、负责人或行动项;不足三处时宁缺毋滥。加粗范围要短,不要整段加粗,不要把每个数字都加粗,不得强化未经验证的判断。
|
|
21
|
+
- 使用标题、列表、引用和加粗形成无颜色也清楚的层级;不得输出 \`<font>\` 或其它未确认可用于企微流式消息的 HTML 标色标签。`;
|
|
14
22
|
export function capWorkLogForInject(workLog, cap = WORKLOG_INJECT_CAP) {
|
|
15
23
|
if (workLog.length <= cap)
|
|
16
24
|
return workLog;
|
|
@@ -142,17 +150,17 @@ ${taskAndScheduleCommands}`;
|
|
|
142
150
|
const externalReplyRule = scheduled
|
|
143
151
|
? ctx.scheduledExternalNotificationPolicy === "agent_decides"
|
|
144
152
|
? `
|
|
145
|
-
- **绑定会话通知由你选择**:只有本轮完整结果确实值得打扰绑定会话时,运行 \`crew message notify-bound-im --channel ${ctx.channelId}\`
|
|
153
|
+
- **绑定会话通知由你选择**:只有本轮完整结果确实值得打扰绑定会话时,运行 \`crew message notify-bound-im --channel ${ctx.channelId}\` 一次,然后仍只返回一个完整最终报告。该命令只记录本轮决策,不会自行发消息,也不能指定收件人。${EXTERNAL_RESULT_READABILITY}`
|
|
146
154
|
: ""
|
|
147
155
|
: ctx.wakeOrigin === "wecom"
|
|
148
156
|
? `
|
|
149
|
-
- **本轮来自企微,结束本轮前必须给出一条完整回复**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。最终只用一次 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数)发送有实质内容的完整结果;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread
|
|
157
|
+
- **本轮来自企微,结束本轮前必须给出一条完整回复**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。最终只用一次 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数)发送有实质内容的完整结果;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread 来源和机器人绑定,并在本轮未形成可交付内容时发送统一兜底回复。${EXTERNAL_RESULT_READABILITY}`
|
|
150
158
|
: ctx.wakeOrigin
|
|
151
159
|
? `
|
|
152
|
-
- **本轮来自${ctx.wakeOrigin === "feishu" ? "飞书" : ctx.wakeOrigin},结束本轮前必须明确选择外部回复决策**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。完整结果确实要回复外部会话时,用 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数);判断无需回复时,用 \`crew message skip-origin --reason "简短原因"\`。两者必须选择一个;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread
|
|
160
|
+
- **本轮来自${ctx.wakeOrigin === "feishu" ? "飞书" : ctx.wakeOrigin},结束本轮前必须明确选择外部回复决策**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。完整结果确实要回复外部会话时,用 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数);判断无需回复时,用 \`crew message skip-origin --reason "简短原因"\`。两者必须选择一个;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread 来源和机器人绑定。${EXTERNAL_RESULT_READABILITY}`
|
|
153
161
|
: `
|
|
154
162
|
- **本轮是 NowWork 内部唤醒**:普通 \`crew message send\` 只写入 NowWork。内部唤醒不得使用 \`--reply-origin\`,该参数只回答直接触发本轮的企微原消息。
|
|
155
|
-
- **绑定会话主动通知**:用户明确要求同步,或最终结果有实质结论、变更或需群用户行动的阻塞时,才用一次 \`crew message send --notify-bound-im\`(同时带当前 channel/thread/content 参数)请求通知当前频道绑定的外部会话。Server 会校验绑定 owner 授权、绑定 Agent 和单轮边界;不能指定收件人。确认、进度、中间结果、无变化和重复内容一律留在 NowWork
|
|
163
|
+
- **绑定会话主动通知**:用户明确要求同步,或最终结果有实质结论、变更或需群用户行动的阻塞时,才用一次 \`crew message send --notify-bound-im\`(同时带当前 channel/thread/content 参数)请求通知当前频道绑定的外部会话。Server 会校验绑定 owner 授权、绑定 Agent 和单轮边界;不能指定收件人。确认、进度、中间结果、无变化和重复内容一律留在 NowWork。${EXTERNAL_RESULT_READABILITY}`;
|
|
156
164
|
const interactiveTaskRules = scheduled ? "" : `
|
|
157
165
|
- **毫不相关的新任务才另起线程**:只有要处理的事**和当前线程毫不相关**(或用户明确要求新建)时,才用 \`crew task create --new-thread --title "…"\`——系统另起一个子线程(parent=当前线程)绑新 task;之后这件事的回复要发到**这个新子线程**里。能不拆就不拆。
|
|
158
166
|
- 任务状态流:\`todo → in_progress → in_review → done\`。claim 后用 \`crew task update\` 推进:开工→in_progress、完成待验收→in_review、人类确认后→done。只有 assignee 能改自己任务的状态。
|
package/dist/serve.js
CHANGED
|
@@ -31,6 +31,10 @@ import { createCompletionRetransmitter } from "./completion-retransmitter.js";
|
|
|
31
31
|
import { createAgentMemoryBridge } from "./agent-memory/bridge.js";
|
|
32
32
|
import { createRuntimeStartupGate } from "./runtime-startup-gate.js";
|
|
33
33
|
import { createHostExecutionCoordinator, hostCoordinatedSlotManager, hostCoordinatedStartupGate, } from "./host-execution-coordinator.js";
|
|
34
|
+
import { detectDaemonUpdateEligibility, } from "./daemon-update-eligibility.js";
|
|
35
|
+
import { createDaemonUpdateController } from "./daemon-update-controller.js";
|
|
36
|
+
import { installExactDaemonUpdate } from "./daemon-updater.js";
|
|
37
|
+
import { scheduleServiceRestart } from "./computer-service.js";
|
|
34
38
|
// normalize.ts 的活动种类 → activity 枚举
|
|
35
39
|
const ACTIVITY_MAP = {
|
|
36
40
|
init: "working", text: "thinking", reading: "reading", sending: "sending",
|
|
@@ -66,7 +70,8 @@ export function serve(config, opts = {}) {
|
|
|
66
70
|
let detectedExecutionRuntimes = [];
|
|
67
71
|
let runtimeFacts = null;
|
|
68
72
|
const hostCoordinator = opts.execution?.hostCoordinator ?? createHostExecutionCoordinator();
|
|
69
|
-
const
|
|
73
|
+
const localSlots = createSharedSlotManager(config.executionLimits);
|
|
74
|
+
const sharedSlots = hostCoordinatedSlotManager(localSlots, hostCoordinator);
|
|
70
75
|
const runtimeStartupGate = hostCoordinatedStartupGate(createRuntimeStartupGate(config.executionLimits), hostCoordinator);
|
|
71
76
|
const knownExecutionHashes = new Map();
|
|
72
77
|
const executionReservations = new Map();
|
|
@@ -74,6 +79,24 @@ export function serve(config, opts = {}) {
|
|
|
74
79
|
let executionFrameQueue = Promise.resolve();
|
|
75
80
|
const cancellations = new Map();
|
|
76
81
|
const legacyRuns = new Map();
|
|
82
|
+
const updateEligibility = opts.update?.eligibility
|
|
83
|
+
?? (() => detectDaemonUpdateEligibility(opts.profileName));
|
|
84
|
+
const updateController = createDaemonUpdateController({
|
|
85
|
+
eligibility: updateEligibility,
|
|
86
|
+
install: opts.update?.install ?? ((input) => installExactDaemonUpdate({
|
|
87
|
+
...input,
|
|
88
|
+
localSlots,
|
|
89
|
+
hostCoordinator,
|
|
90
|
+
})),
|
|
91
|
+
scheduleRestart: opts.update?.scheduleRestart ?? scheduleServiceRestart,
|
|
92
|
+
sendStatus: (frame) => {
|
|
93
|
+
try {
|
|
94
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
95
|
+
ws.send(JSON.stringify(frame));
|
|
96
|
+
}
|
|
97
|
+
catch { /* reconnect/timeout reconciliation handles a lost status frame */ }
|
|
98
|
+
},
|
|
99
|
+
});
|
|
77
100
|
const safeExecutionSend = (frame) => {
|
|
78
101
|
try {
|
|
79
102
|
if (ws?.readyState !== WebSocket.OPEN)
|
|
@@ -192,7 +215,16 @@ export function serve(config, opts = {}) {
|
|
|
192
215
|
// 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
|
|
193
216
|
void drainSpool();
|
|
194
217
|
// 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
|
|
195
|
-
const helloPromise = collectMachineHello(config.agentsRoot, config.executionLimits
|
|
218
|
+
const helloPromise = collectMachineHello(config.agentsRoot, config.executionLimits, process.platform, {
|
|
219
|
+
additionalCapabilities: async () => {
|
|
220
|
+
try {
|
|
221
|
+
return (await updateEligibility()).eligible ? ["daemon_update_v1"] : [];
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
196
228
|
runtimeFacts = helloPromise.then((hello) => hello.executionRuntimes, (error) => {
|
|
197
229
|
dslog("execution.runtime_detection_failed", "runtime 探测失败", {
|
|
198
230
|
level: "ERROR", error_message: error.message,
|
|
@@ -250,6 +282,14 @@ export function serve(config, opts = {}) {
|
|
|
250
282
|
if (typeof decoded !== "object" || decoded === null)
|
|
251
283
|
return;
|
|
252
284
|
const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
|
|
285
|
+
if (rawType === "daemon:update") {
|
|
286
|
+
void updateController.handle(decoded).catch((error) => {
|
|
287
|
+
dslog("daemon.update_failed", "daemon 更新处理失败", {
|
|
288
|
+
level: "ERROR", error_message: error.message,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
253
293
|
if (rawType.startsWith("execution:")) {
|
|
254
294
|
const parsedExecution = ServerToDaemonExecutionFrameSchema.safeParse(decoded);
|
|
255
295
|
if (!parsedExecution.success) {
|
|
@@ -2,6 +2,7 @@ export function createSharedSlotManager(limits) {
|
|
|
2
2
|
const activeByHandle = new Map();
|
|
3
3
|
const queue = [];
|
|
4
4
|
let activeTotal = 0;
|
|
5
|
+
let exclusive = false;
|
|
5
6
|
const queuedFor = (handle) => queue.filter((entry) => !entry.released && entry.handle === handle).length;
|
|
6
7
|
const promote = () => {
|
|
7
8
|
while (activeTotal < limits.maxParallelTotal) {
|
|
@@ -28,6 +29,15 @@ export function createSharedSlotManager(limits) {
|
|
|
28
29
|
activeTotal,
|
|
29
30
|
queuedTotal: queue.length,
|
|
30
31
|
};
|
|
32
|
+
if (exclusive) {
|
|
33
|
+
return {
|
|
34
|
+
accepted: false,
|
|
35
|
+
facts,
|
|
36
|
+
ready: Promise.resolve(),
|
|
37
|
+
isQueued: () => false,
|
|
38
|
+
release: () => { },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
31
41
|
const canStartImmediately = activeTotal < limits.maxParallelTotal
|
|
32
42
|
&& activeForAgent < limits.maxParallelPerAgent
|
|
33
43
|
&& queue.length === 0;
|
|
@@ -79,5 +89,20 @@ export function createSharedSlotManager(limits) {
|
|
|
79
89
|
};
|
|
80
90
|
},
|
|
81
91
|
snapshot: () => ({ activeTotal, queuedTotal: queue.length }),
|
|
92
|
+
tryAcquireExclusive: () => {
|
|
93
|
+
if (exclusive || activeTotal !== 0 || queue.length !== 0)
|
|
94
|
+
return null;
|
|
95
|
+
exclusive = true;
|
|
96
|
+
let released = false;
|
|
97
|
+
return {
|
|
98
|
+
release: () => {
|
|
99
|
+
if (released)
|
|
100
|
+
return;
|
|
101
|
+
released = true;
|
|
102
|
+
exclusive = false;
|
|
103
|
+
promote();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
82
107
|
};
|
|
83
108
|
}
|