@nowcrew/daemon 0.6.102-beta.3 → 0.6.102-beta.5
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 +20 -16
- package/dist/automatic-daemon-installer.js +15 -11
- package/dist/automatic-service-capture.js +27 -0
- package/dist/computer-main.js +0 -0
- package/dist/execution-telemetry-journal.js +26 -14
- package/dist/execution-telemetry-sender.js +151 -0
- package/dist/main.js +0 -0
- package/dist/managed-service-lifecycle.js +10 -0
- package/dist/project-skills/execution-projection.js +4 -5
- package/dist/project-skills/reconciler.js +8 -9
- package/dist/project-skills/scanner.js +9 -2
- package/dist/project-skills/source-directory.js +13 -0
- package/dist/serve.js +12 -11
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -434,23 +434,27 @@ path-free inventory and logical `(projectId, skillName)` bindings. The local pat
|
|
|
434
434
|
while an add command is forwarded to the selected online Computer; it is not written to the workspace
|
|
435
435
|
database, structured logs, inventory frames, or responses. Project Skills are disabled on Windows.
|
|
436
436
|
|
|
437
|
-
|
|
437
|
+
Skill entries may be real directories or directory symlinks, including relative, absolute, chained,
|
|
438
|
+
and shared directories outside the project. Only direct, non-hidden entries are scanned. Broken,
|
|
439
|
+
cyclic, inaccessible, and non-directory links count as invalid Skills without hiding healthy entries;
|
|
440
|
+
the existing frontmatter, size, name-conflict, and inventory limits apply to linked Skills too.
|
|
441
|
+
|
|
442
|
+
Each execution projects its binding set into a private Runtime discovery directory:
|
|
438
443
|
|
|
439
444
|
```text
|
|
440
|
-
<agentsRoot>/<handle>/.
|
|
441
|
-
<agentsRoot>/<handle>/.crew/claude-skills/.claude/skills/<name> # Claude symlink
|
|
445
|
+
<agentsRoot>/<handle>/.crew/project-skills/exec-<executionId>/runtime/.claude/skills/<name>
|
|
442
446
|
```
|
|
443
447
|
|
|
444
|
-
The
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
+
The scanner retains the entry path. Binding checks and execution startup resolve its current real
|
|
449
|
+
directory and check for `SKILL.md`; missing sources warn and are omitted without blocking other work.
|
|
450
|
+
Each execution links to that resolved directory, so retargeting an entry affects subsequent executions
|
|
451
|
+
while active executions keep their original target. Source file edits remain live. The projection is
|
|
452
|
+
removed after the runtime process tree stops; source directories and user-created links are preserved.
|
|
453
|
+
These are ordinary writable symlinks, not snapshots or a sandbox: an Agent running as the daemon user
|
|
454
|
+
can modify the source Skill through them. That write-through risk is intentionally accepted.
|
|
448
455
|
|
|
449
|
-
Codex
|
|
450
|
-
|
|
451
|
-
root and adds it to Codex `project_root_markers`, so upward discovery reaches `.agents/skills` without
|
|
452
|
-
changing the task cwd. Claude receives
|
|
453
|
-
`--add-dir <agentsRoot>/<handle>/.crew/claude-skills`; Claude treats those Skills as `projectSettings`,
|
|
456
|
+
Codex registers the execution's `runtime/.claude/skills` through `skills/extraRoots/set`. Claude receives
|
|
457
|
+
the execution's `runtime` directory through `--add-dir`; Claude treats those Skills as `projectSettings`,
|
|
454
458
|
so a machine policy that disables project settings also disables this source even when the projection is
|
|
455
459
|
healthy.
|
|
456
460
|
|
|
@@ -467,10 +471,10 @@ scope is merged. The policy version in Codex's session fingerprint cold-starts o
|
|
|
467
471
|
once, then subsequent runs can resume normally. Explicit `shell_environment_policy.set` entries, shell
|
|
468
472
|
startup scripts and Agent notes remain separate configuration; this change does not correct them.
|
|
469
473
|
|
|
470
|
-
Binding changes
|
|
471
|
-
complete binding set for every Agent assigned to that Computer, including empty sets
|
|
472
|
-
|
|
473
|
-
|
|
474
|
+
Binding changes are checked immediately when the Computer is online. On reconnect the server sends the
|
|
475
|
+
complete binding set for every Agent assigned to that Computer, including empty sets. Each execution
|
|
476
|
+
uses its server-snapshotted binding set and revalidates local source paths immediately before Runtime
|
|
477
|
+
launch. Skill file content changes
|
|
474
478
|
are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
|
|
475
479
|
a rescan. Web descriptions remain at the last inventory value until that rescan.
|
|
476
480
|
|
|
@@ -6,7 +6,8 @@ import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenS
|
|
|
6
6
|
import { buildServiceSpec, captureExactService, hardenWindowsProfile, replaceExactService, restoreExactService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, } from "./computer-service.js";
|
|
7
7
|
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
8
8
|
import { buildManagedServiceRecord, installManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
|
|
9
|
-
import {
|
|
9
|
+
import { readManagedServiceRegistry, } from "./managed-service-registry.js";
|
|
10
|
+
import { captureManagedServiceForSetup } from "./automatic-service-capture.js";
|
|
10
11
|
import { resolveAutomaticInstallTarget, } from "./automatic-install-target.js";
|
|
11
12
|
import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
12
13
|
import { daemonVersion } from "./machine-info.js";
|
|
@@ -80,7 +81,6 @@ function defaultDependencies() {
|
|
|
80
81
|
},
|
|
81
82
|
captureService: async (spec, target) => {
|
|
82
83
|
if (platform !== "win32") {
|
|
83
|
-
managedRecords = await readManagedServiceRegistry({ userHome, localHome: localHomeForTarget(target, platform) });
|
|
84
84
|
const entryPath = spec.daemonCommand[1];
|
|
85
85
|
const installation = daemonGlobalInstallation(entryPath, platform);
|
|
86
86
|
if (installation === null) {
|
|
@@ -94,14 +94,13 @@ function defaultDependencies() {
|
|
|
94
94
|
entryPath,
|
|
95
95
|
...installation,
|
|
96
96
|
});
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
97
|
+
const captured = await captureManagedServiceForSetup({
|
|
98
|
+
spec, record: candidate, runner,
|
|
99
|
+
registry: { userHome, localHome: localHomeForTarget(target, platform) },
|
|
100
|
+
});
|
|
101
|
+
managedRecords = captured.managedRecords;
|
|
102
|
+
previousManagedRecord = captured.previousManagedRecord;
|
|
103
|
+
return captured.snapshot;
|
|
105
104
|
}
|
|
106
105
|
return captureExactService(spec, runner);
|
|
107
106
|
},
|
|
@@ -175,7 +174,12 @@ function defaultDependencies() {
|
|
|
175
174
|
? localHome
|
|
176
175
|
: localHomeForNpmPrefix(previousManagedRecord.npmPrefix, platform);
|
|
177
176
|
const current = (await readManagedServiceRegistry({ userHome, localHome: restoreLocalHome })).find((record) => record.serviceId === spec.id);
|
|
178
|
-
|
|
177
|
+
const onlyHashChanged = current !== undefined && previousManagedRecord !== undefined
|
|
178
|
+
&& JSON.stringify({ ...current, descriptorSha256: previousManagedRecord.descriptorSha256 })
|
|
179
|
+
=== JSON.stringify(previousManagedRecord);
|
|
180
|
+
// Preflight may have captured the exact descriptor while leaving a stale
|
|
181
|
+
// registry hash untouched. Let installManagedService reconcile it in place.
|
|
182
|
+
if (current !== undefined && !onlyHashChanged && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
|
|
179
183
|
await uninstallManagedService({
|
|
180
184
|
spec,
|
|
181
185
|
record: current,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { captureExactService } from "./computer-service.js";
|
|
2
|
+
import { ManagedServiceConflictError, ManagedServiceRegistryError, managedServiceConflict, managedServiceIdentityMatches, readManagedServiceRegistry, } from "./managed-service-registry.js";
|
|
3
|
+
/** Read-only preflight; keep the record needed to restore the captured service. */
|
|
4
|
+
export async function captureManagedServiceForSetup(input) {
|
|
5
|
+
const managedRecords = await readManagedServiceRegistry(input.registry);
|
|
6
|
+
const { generation: _generation, createdAt: _createdAt, ...expected } = input.record;
|
|
7
|
+
const registered = managedRecords.find((record) => managedServiceIdentityMatches(record, {
|
|
8
|
+
...expected, descriptorSha256: record.descriptorSha256,
|
|
9
|
+
}));
|
|
10
|
+
if (registered === undefined) {
|
|
11
|
+
const conflict = managedServiceConflict(managedRecords, input.record);
|
|
12
|
+
if (conflict !== null) {
|
|
13
|
+
throw new ManagedServiceConflictError(conflict.resource, input.record, conflict.owner);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const snapshot = await captureExactService(input.spec, input.runner);
|
|
17
|
+
if (registered !== undefined && registered.descriptorSha256 !== expected.descriptorSha256
|
|
18
|
+
&& (!snapshot.installed || !snapshot.exact)) {
|
|
19
|
+
throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match the requested identity`);
|
|
20
|
+
}
|
|
21
|
+
// A stale hash may be reconciled later under the registry lock. Rollback must
|
|
22
|
+
// refer to the descriptor we actually captured, not the stale registry hash.
|
|
23
|
+
const previousManagedRecord = registered === undefined ? undefined : {
|
|
24
|
+
...registered, descriptorSha256: expected.descriptorSha256,
|
|
25
|
+
};
|
|
26
|
+
return { managedRecords, previousManagedRecord, snapshot };
|
|
27
|
+
}
|
package/dist/computer-main.js
CHANGED
|
File without changes
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { access, open, mkdir, readdir, readFile, rename, unlink } from "node:fs/promises";
|
|
1
|
+
import { access, open, mkdir, readdir, readFile, rename, unlink, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { DaemonToServerExecutionFrameSchema, } from "./execution-protocol.js";
|
|
5
5
|
const MAX_PENDING_FILES = 4_096;
|
|
6
|
+
const MAX_TELEMETRY_FRAME_BYTES = 256 * 1_024;
|
|
6
7
|
const fileName = (frame) => `${frame.executionId}.${frame.type === "execution:activity" ? "activity" : "console"}.${frame.seq}.json`;
|
|
7
8
|
export class ExecutionTelemetryJournal {
|
|
8
9
|
directory;
|
|
@@ -10,6 +11,10 @@ export class ExecutionTelemetryJournal {
|
|
|
10
11
|
this.directory = join(agentsRoot, ".execution-telemetry");
|
|
11
12
|
}
|
|
12
13
|
async append(frame) {
|
|
14
|
+
const serialized = JSON.stringify(frame);
|
|
15
|
+
if (Buffer.byteLength(serialized) > MAX_TELEMETRY_FRAME_BYTES) {
|
|
16
|
+
throw new Error("execution telemetry frame exceeds 256 KiB");
|
|
17
|
+
}
|
|
13
18
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
14
19
|
const path = join(this.directory, fileName(frame));
|
|
15
20
|
if (await access(path).then(() => true, () => false))
|
|
@@ -22,7 +27,7 @@ export class ExecutionTelemetryJournal {
|
|
|
22
27
|
let handle;
|
|
23
28
|
try {
|
|
24
29
|
handle = await open(tempPath, "wx", 0o600);
|
|
25
|
-
await handle.writeFile(
|
|
30
|
+
await handle.writeFile(serialized, "utf8");
|
|
26
31
|
await handle.sync();
|
|
27
32
|
await handle.close();
|
|
28
33
|
handle = undefined;
|
|
@@ -43,29 +48,36 @@ export class ExecutionTelemetryJournal {
|
|
|
43
48
|
throw error;
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
|
-
async replay() {
|
|
51
|
+
async *replay(onInvalid) {
|
|
47
52
|
const names = await readdir(this.directory).catch((error) => {
|
|
48
53
|
if (error.code === "ENOENT")
|
|
49
54
|
return [];
|
|
50
55
|
throw error;
|
|
51
56
|
});
|
|
52
|
-
const
|
|
53
|
-
for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
57
|
+
for (const name of names.filter((entry) => entry.endsWith(".json")).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))) {
|
|
54
58
|
let raw;
|
|
55
59
|
try {
|
|
56
|
-
|
|
60
|
+
const path = join(this.directory, name);
|
|
61
|
+
if ((await stat(path)).size > MAX_TELEMETRY_FRAME_BYTES) {
|
|
62
|
+
throw new Error("execution telemetry frame exceeds 256 KiB");
|
|
63
|
+
}
|
|
64
|
+
raw = JSON.parse(await readFile(path, "utf8"));
|
|
65
|
+
const parsed = DaemonToServerExecutionFrameSchema.safeParse(raw);
|
|
66
|
+
if (!parsed.success || (parsed.data.type !== "execution:activity" && parsed.data.type !== "execution:console")) {
|
|
67
|
+
throw new Error("invalid execution telemetry frame");
|
|
68
|
+
}
|
|
69
|
+
yield parsed.data;
|
|
57
70
|
}
|
|
58
71
|
catch (error) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
// ACK 可与目录扫描并发删除条目。
|
|
73
|
+
if (error.code === "ENOENT")
|
|
74
|
+
continue;
|
|
75
|
+
const invalid = new Error(`invalid execution telemetry journal entry: ${name}`, { cause: error });
|
|
76
|
+
if (!onInvalid)
|
|
77
|
+
throw invalid;
|
|
78
|
+
onInvalid(invalid);
|
|
64
79
|
}
|
|
65
|
-
frames.push(parsed.data);
|
|
66
80
|
}
|
|
67
|
-
return frames.sort((left, right) => left.at.localeCompare(right.at)
|
|
68
|
-
|| left.type.localeCompare(right.type) || left.seq - right.seq);
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
export const createExecutionTelemetryJournal = (agentsRoot) => new ExecutionTelemetryJournal(agentsRoot);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { dslog } from "./slog.js";
|
|
2
|
+
import {} from "./execution-telemetry-journal.js";
|
|
3
|
+
const MAX_IN_FLIGHT = 16;
|
|
4
|
+
const MAX_IN_FLIGHT_BYTES = 512 * 1_024;
|
|
5
|
+
const ACK_TIMEOUT_MS = 30_000;
|
|
6
|
+
const keyOf = (executionId, kind, seq) => `${executionId}.${kind}.${seq}`;
|
|
7
|
+
/** 复用 journal 与 event-ack。窗口只保留游标,帧内容按需从磁盘读取。 */
|
|
8
|
+
export class ExecutionTelemetrySender {
|
|
9
|
+
journal;
|
|
10
|
+
onError;
|
|
11
|
+
inFlight = new Map();
|
|
12
|
+
generation = 0;
|
|
13
|
+
running = false;
|
|
14
|
+
requested = false;
|
|
15
|
+
windowBlocked = false;
|
|
16
|
+
lastProgress = 0;
|
|
17
|
+
lastReportAt = 0;
|
|
18
|
+
lastErrorAt = -Infinity;
|
|
19
|
+
sentFrames = 0;
|
|
20
|
+
acknowledgedFrames = 0;
|
|
21
|
+
send;
|
|
22
|
+
timer;
|
|
23
|
+
constructor(journal, onError) {
|
|
24
|
+
this.journal = journal;
|
|
25
|
+
this.onError = onError;
|
|
26
|
+
}
|
|
27
|
+
start(send, onTimeout) {
|
|
28
|
+
this.stop();
|
|
29
|
+
this.send = send;
|
|
30
|
+
this.sentFrames = 0;
|
|
31
|
+
this.acknowledgedFrames = 0;
|
|
32
|
+
this.lastReportAt = Date.now();
|
|
33
|
+
this.logState("execution.telemetry_connected", "遥测补传连接已建立");
|
|
34
|
+
this.timer = setInterval(() => {
|
|
35
|
+
// 单个失败帧不能打断仍在正常确认的连接;只有整个窗口停滞才重连。
|
|
36
|
+
if (this.windowBlocked && Date.now() - this.lastProgress >= ACK_TIMEOUT_MS) {
|
|
37
|
+
this.logState("execution.telemetry_stalled", "遥测确认窗口停滞,准备重连", {
|
|
38
|
+
level: "WARN", stalled_ms: Date.now() - this.lastProgress,
|
|
39
|
+
});
|
|
40
|
+
this.stop();
|
|
41
|
+
onTimeout();
|
|
42
|
+
}
|
|
43
|
+
else if (this.inFlight.size > 0 && Date.now() - this.lastReportAt >= ACK_TIMEOUT_MS) {
|
|
44
|
+
this.lastReportAt = Date.now();
|
|
45
|
+
this.logState("execution.telemetry_progress", "遥测确认窗口状态");
|
|
46
|
+
}
|
|
47
|
+
}, 1_000);
|
|
48
|
+
this.timer.unref();
|
|
49
|
+
this.notify();
|
|
50
|
+
}
|
|
51
|
+
stop() {
|
|
52
|
+
if (this.send)
|
|
53
|
+
this.logState("execution.telemetry_disconnected", "遥测发送停止,未确认帧保留在 journal");
|
|
54
|
+
this.generation += 1;
|
|
55
|
+
this.send = undefined;
|
|
56
|
+
this.requested = false;
|
|
57
|
+
this.windowBlocked = false;
|
|
58
|
+
if (this.timer !== undefined)
|
|
59
|
+
clearInterval(this.timer);
|
|
60
|
+
this.timer = undefined;
|
|
61
|
+
this.inFlight.clear();
|
|
62
|
+
}
|
|
63
|
+
async acknowledge(executionId, kind, seq) {
|
|
64
|
+
const key = keyOf(executionId, kind, seq);
|
|
65
|
+
const entry = this.inFlight.get(key);
|
|
66
|
+
if (!entry)
|
|
67
|
+
return;
|
|
68
|
+
await this.journal.acknowledge(executionId, kind, seq);
|
|
69
|
+
// 重连会清空窗口;条目身份也能挡住旧连接和重复 ACK 的异步完成。
|
|
70
|
+
if (this.inFlight.get(key) !== entry)
|
|
71
|
+
return;
|
|
72
|
+
this.inFlight.delete(key);
|
|
73
|
+
this.acknowledgedFrames += 1;
|
|
74
|
+
this.lastProgress = Date.now();
|
|
75
|
+
this.notify();
|
|
76
|
+
}
|
|
77
|
+
notify() {
|
|
78
|
+
if (!this.send)
|
|
79
|
+
return;
|
|
80
|
+
this.requested = true;
|
|
81
|
+
if (!this.running)
|
|
82
|
+
void this.pump();
|
|
83
|
+
}
|
|
84
|
+
get bytes() {
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
for (const entry of this.inFlight.values())
|
|
87
|
+
bytes += entry.bytes;
|
|
88
|
+
return bytes;
|
|
89
|
+
}
|
|
90
|
+
logState(event, message, fields = {}) {
|
|
91
|
+
dslog(event, message, {
|
|
92
|
+
generation: this.generation, in_flight_frames: this.inFlight.size,
|
|
93
|
+
in_flight_bytes: this.bytes, sent_frames: this.sentFrames,
|
|
94
|
+
acknowledged_frames: this.acknowledgedFrames, window_blocked: this.windowBlocked, ...fields,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
reportError(error) {
|
|
98
|
+
// 同一个坏文件会被多次扫描;不要让错误日志形成新的无界积压。
|
|
99
|
+
if (Date.now() - this.lastErrorAt < ACK_TIMEOUT_MS)
|
|
100
|
+
return;
|
|
101
|
+
this.lastErrorAt = Date.now();
|
|
102
|
+
this.onError(error);
|
|
103
|
+
}
|
|
104
|
+
async pump() {
|
|
105
|
+
this.running = true;
|
|
106
|
+
const generation = this.generation;
|
|
107
|
+
try {
|
|
108
|
+
while (this.send && generation === this.generation && this.requested) {
|
|
109
|
+
this.requested = false;
|
|
110
|
+
let blocked = false;
|
|
111
|
+
// 本轮读取中的文件可能刚收到 ACK;不能把已删除游标的旧读取结果再发出去。
|
|
112
|
+
const alreadySent = new Set(this.inFlight.keys());
|
|
113
|
+
for await (const frame of this.journal.replay((error) => this.reportError(error))) {
|
|
114
|
+
if (!this.send || generation !== this.generation)
|
|
115
|
+
return;
|
|
116
|
+
const key = keyOf(frame.executionId, frame.type === "execution:activity" ? "activity" : "console", frame.seq);
|
|
117
|
+
if (alreadySent.has(key) || this.inFlight.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame));
|
|
120
|
+
if (this.inFlight.size >= MAX_IN_FLIGHT || this.bytes + bytes > MAX_IN_FLIGHT_BYTES) {
|
|
121
|
+
blocked = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (this.inFlight.size === 0)
|
|
125
|
+
this.lastProgress = Date.now();
|
|
126
|
+
this.inFlight.set(key, { bytes });
|
|
127
|
+
if (!this.send(frame)) {
|
|
128
|
+
this.inFlight.delete(key);
|
|
129
|
+
this.logState("execution.telemetry_send_failed", "遥测发送失败,帧保留在 journal", { level: "WARN" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.sentFrames += 1;
|
|
133
|
+
}
|
|
134
|
+
if (!this.send || generation !== this.generation)
|
|
135
|
+
return;
|
|
136
|
+
blocked ||= this.inFlight.size >= MAX_IN_FLIGHT || this.bytes >= MAX_IN_FLIGHT_BYTES;
|
|
137
|
+
if (blocked && !this.windowBlocked)
|
|
138
|
+
this.lastProgress = Date.now();
|
|
139
|
+
this.windowBlocked = blocked;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
this.reportError(error);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
this.running = false;
|
|
147
|
+
if (this.send && this.requested)
|
|
148
|
+
this.notify();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/main.js
CHANGED
|
File without changes
|
|
@@ -103,6 +103,16 @@ export async function installManagedService(input) {
|
|
|
103
103
|
&& record.daemonHome === input.record.daemonHome);
|
|
104
104
|
if (registered !== undefined && sameManagedResource(registered, input.record)) {
|
|
105
105
|
const current = await readServiceDescriptor(input.spec);
|
|
106
|
+
// Descriptor replacement and registry commit can be interrupted separately.
|
|
107
|
+
// Adopt only the exact requested descriptor with all resource fields intact.
|
|
108
|
+
if (current === requestedDescriptor) {
|
|
109
|
+
assertRecordDescribesSpec(input);
|
|
110
|
+
await ensureServiceEnabled(input.spec, input.runner);
|
|
111
|
+
await replace(services.map((record) => record === registered
|
|
112
|
+
? { ...record, descriptorSha256: expectedDescriptorHash }
|
|
113
|
+
: record));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
106
116
|
if (current === null || serviceDescriptorSha256(current) !== registered.descriptorSha256) {
|
|
107
117
|
throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
|
|
108
118
|
}
|
|
@@ -85,11 +85,10 @@ export async function materializeExecutionProjection(input) {
|
|
|
85
85
|
continue;
|
|
86
86
|
let projected;
|
|
87
87
|
try {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!sourceExists)
|
|
88
|
+
// 源路径已由 reconciler 解析;拒绝解析后消失、变成文件或被替换为链接的目录。
|
|
89
|
+
// POSIX symlink() 本身允许断链,不能用“创建链接成功”代替源目录检查。
|
|
90
|
+
const source = await lstat(record.sourcePath).catch(() => null);
|
|
91
|
+
if (!source?.isDirectory() || source.isSymbolicLink())
|
|
93
92
|
throw new ExecutionProjectionError("link_failed");
|
|
94
93
|
projected = await project(record.sourcePath, join(root.codexSkillRoot, record.skillName), platform);
|
|
95
94
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Skill 解析 + 执行编排。
|
|
3
|
-
* reconcile: server
|
|
3
|
+
* reconcile: server 同步绑定时只读取"哪些 skill 在本机可用",不写入投影。
|
|
4
4
|
* prepareAndLaunch: 为这一次 execution 建独占投影目录 → 启动 runtime → 进程树确认退出后删除。
|
|
5
5
|
* 没有共享目录,所以没有锁、租约、manifest、GC。ownership 未确认时目录保留,交给启动 sweep。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
8
|
import { join, resolve, sep } from "node:path";
|
|
9
9
|
import { ExecutionProjectionError, materializeExecutionProjection, removeExecutionProjection, } from "./execution-projection.js";
|
|
10
10
|
import { projectSkillResolutionRecords } from "./scanner.js";
|
|
11
|
+
import { resolveProjectSkillDirectory } from "./source-directory.js";
|
|
11
12
|
import { compareProjectSkillRefs, } from "./types.js";
|
|
12
13
|
export class ProjectProjectionError extends Error {
|
|
13
14
|
code;
|
|
@@ -46,16 +47,14 @@ const canonicalConflictFreeBindings = (bindings) => {
|
|
|
46
47
|
}
|
|
47
48
|
return Object.freeze(unique);
|
|
48
49
|
};
|
|
49
|
-
/**
|
|
50
|
+
/** 每次从扫描入口解析真实目录,固定本次执行的链接目标;不可用则 warning,不阻断。 */
|
|
50
51
|
const actualResolutionRecords = async (bindings, projects, platform) => Object.freeze(await Promise.all(projectSkillResolutionRecords(bindings, projects, platform).map(async (record) => {
|
|
51
52
|
if (record.mode === "missing" || record.sourcePath === null)
|
|
52
53
|
return record;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (source?.isDirectory() === true && !source.isSymbolicLink() && skillFile?.isFile() === true) {
|
|
58
|
-
return record;
|
|
54
|
+
const directory = await resolveProjectSkillDirectory(record.sourcePath);
|
|
55
|
+
const skillFile = directory === null ? null : await stat(join(directory, "SKILL.md")).catch(() => null);
|
|
56
|
+
if (directory !== null && skillFile?.isFile() === true) {
|
|
57
|
+
return Object.freeze({ ...record, sourcePath: directory });
|
|
59
58
|
}
|
|
60
59
|
return Object.freeze({
|
|
61
60
|
projectId: record.projectId,
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { compareProjectSkillRefs, isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
|
|
4
4
|
import { parseSkillFrontmatter } from "../skill-frontmatter.js";
|
|
5
5
|
import { normalizeProjectSkillResolutionRecords } from "./resolution-records.js";
|
|
6
|
+
import { resolveProjectSkillDirectory } from "./source-directory.js";
|
|
6
7
|
const unavailable = (projectId, scannedAt, errorCode) => Object.freeze({
|
|
7
8
|
inventory: Object.freeze({
|
|
8
9
|
projectId,
|
|
@@ -45,10 +46,15 @@ export async function scanProject(project, now = () => new Date()) {
|
|
|
45
46
|
let invalidSkillCount = 0;
|
|
46
47
|
const candidates = [];
|
|
47
48
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
48
|
-
if (entry.name.startsWith(".") || !entry.isDirectory())
|
|
49
|
+
if (entry.name.startsWith(".") || (!entry.isDirectory() && !entry.isSymbolicLink()))
|
|
49
50
|
continue;
|
|
50
51
|
const sourcePath = join(skillsRoot, entry.name);
|
|
51
|
-
const
|
|
52
|
+
const directory = await resolveProjectSkillDirectory(sourcePath);
|
|
53
|
+
if (directory === null) {
|
|
54
|
+
invalidSkillCount += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const skillPath = join(directory, "SKILL.md");
|
|
52
58
|
const skillStat = await stat(skillPath).catch(() => null);
|
|
53
59
|
if (!skillStat?.isFile() || skillStat.size > MAX_PROJECT_SKILL_FILE_BYTES) {
|
|
54
60
|
invalidSkillCount += 1;
|
|
@@ -70,6 +76,7 @@ export async function scanProject(project, now = () => new Date()) {
|
|
|
70
76
|
invalidSkillCount += 1;
|
|
71
77
|
continue;
|
|
72
78
|
}
|
|
79
|
+
// 保留入口:执行前重新解析,才能识别链接删除或改指。
|
|
73
80
|
candidates.push(Object.freeze({ name, description, sourcePath }));
|
|
74
81
|
}
|
|
75
82
|
const counts = new Map();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
/** 跟随用户放置的源链接;只在本次操作中使用真实目录,不把它缓存为扫描入口。 */
|
|
3
|
+
export async function resolveProjectSkillDirectory(sourcePath) {
|
|
4
|
+
try {
|
|
5
|
+
const directory = await realpath(sourcePath);
|
|
6
|
+
const info = await lstat(directory);
|
|
7
|
+
return info.isDirectory() && !info.isSymbolicLink() ? directory : null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// 断链、循环或权限不足只使这个 Skill 不可用,不影响其它 Skill。
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExecutionTelemetrySender } from "./execution-telemetry-sender.js";
|
|
1
2
|
/** 常驻控制面桥接;按请求拉起本地 runtime。 */
|
|
2
3
|
import { handleAgentEnvironmentFrame } from "./agent-environment-control.js";
|
|
3
4
|
import { handleMachineEnvironmentFrame } from "./machine-environment-control.js";
|
|
@@ -80,6 +81,11 @@ export function serve(config, opts = {}) {
|
|
|
80
81
|
const agentMemory = opts.execution?.dependencies?.agentMemory
|
|
81
82
|
?? (config.agentMemory === null ? undefined : createAgentMemoryBridge(config.agentMemory));
|
|
82
83
|
const executionTelemetry = createExecutionTelemetryJournal(config.agentsRoot);
|
|
84
|
+
const telemetrySender = new ExecutionTelemetrySender(executionTelemetry, (error) => {
|
|
85
|
+
dslog("execution.telemetry_replay_failed", "execution telemetry 重放失败", {
|
|
86
|
+
level: "ERROR", error_message: error.message,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
83
89
|
const executeProtocol = opts.execution?.runExecution ?? runExecution;
|
|
84
90
|
const localHome = opts.localHome ?? resolveDaemonLocalStateRootFromEnv();
|
|
85
91
|
const hostCoordinator = opts.execution?.hostCoordinator
|
|
@@ -234,6 +240,8 @@ export function serve(config, opts = {}) {
|
|
|
234
240
|
if (frame.type === "execution:activity" || frame.type === "execution:console") {
|
|
235
241
|
try {
|
|
236
242
|
await executionTelemetry.append(frame);
|
|
243
|
+
telemetrySender.notify();
|
|
244
|
+
return;
|
|
237
245
|
}
|
|
238
246
|
catch (error) {
|
|
239
247
|
dslog("execution.telemetry_journal_failed", "execution telemetry 持久化失败", {
|
|
@@ -254,11 +262,6 @@ export function serve(config, opts = {}) {
|
|
|
254
262
|
completionRetransmitter.track(entry.completion);
|
|
255
263
|
}
|
|
256
264
|
};
|
|
257
|
-
const replayExecutionTelemetry = async () => {
|
|
258
|
-
const frames = await executionTelemetry.replay();
|
|
259
|
-
for (const frame of frames)
|
|
260
|
-
safeExecutionSend(frame);
|
|
261
|
-
};
|
|
262
265
|
const snapshotEntry = (entry) => {
|
|
263
266
|
if ((entry.state === "completed" || entry.state === "interrupted") && entry.completion !== null) {
|
|
264
267
|
return { executionId: entry.executionId, state: entry.state, completion: entry.completion, updatedAt: entry.updatedAt };
|
|
@@ -374,11 +377,7 @@ export function serve(config, opts = {}) {
|
|
|
374
377
|
completionRetransmitter.resume();
|
|
375
378
|
}
|
|
376
379
|
});
|
|
377
|
-
|
|
378
|
-
dslog("execution.telemetry_replay_failed", "execution telemetry 重放失败", {
|
|
379
|
-
level: "ERROR", error_message: error.message,
|
|
380
|
-
});
|
|
381
|
-
});
|
|
380
|
+
telemetrySender.start((frame) => ws === openedSocket && safeExecutionSend(frame), () => openedSocket.close(1013, "telemetry ACK timeout"));
|
|
382
381
|
});
|
|
383
382
|
connectionSocket.on("message", async (data) => {
|
|
384
383
|
if (stopped || ws !== connectionSocket)
|
|
@@ -450,7 +449,7 @@ export function serve(config, opts = {}) {
|
|
|
450
449
|
return;
|
|
451
450
|
}
|
|
452
451
|
if (frame.type === "execution:event-ack") {
|
|
453
|
-
await
|
|
452
|
+
await telemetrySender.acknowledge(frame.executionId, frame.kind, frame.seq).catch((error) => {
|
|
454
453
|
dslog("execution.telemetry_ack_failed", "execution telemetry ACK 处理失败", {
|
|
455
454
|
level: "ERROR", execution_id: frame.executionId,
|
|
456
455
|
error_message: error.message,
|
|
@@ -1263,6 +1262,7 @@ export function serve(config, opts = {}) {
|
|
|
1263
1262
|
// server 能力随连接失效;下个连接的 ready 帧重新声明(防降级重连后沿用过期能力)。
|
|
1264
1263
|
serverCapabilities = new Set();
|
|
1265
1264
|
completionRetransmitter.pause();
|
|
1265
|
+
telemetrySender.stop();
|
|
1266
1266
|
// 4001 = 控制面应用级「鉴权失败」关闭码(见 server control-plane.ts)。即使上面的
|
|
1267
1267
|
// error 帧因 close 抢先而丢失,也能据关闭码识别这是凭证失效——退避拉满,不再每秒热循环。
|
|
1268
1268
|
if (reportDaemonConnectionClose(code, log))
|
|
@@ -1323,6 +1323,7 @@ export function serve(config, opts = {}) {
|
|
|
1323
1323
|
stopped = true;
|
|
1324
1324
|
concurrencyTelemetry.stop();
|
|
1325
1325
|
completionRetransmitter.stop();
|
|
1326
|
+
telemetrySender.stop();
|
|
1326
1327
|
const deadline = createShutdownDeadline(shutdownTimeoutMs);
|
|
1327
1328
|
if (reconnectTimer !== null) {
|
|
1328
1329
|
clearTimeout(reconnectTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.6.102-beta.
|
|
3
|
+
"version": "0.6.102-beta.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,17 +18,9 @@
|
|
|
18
18
|
"publishConfig": {
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
23
|
-
"build": "tsc -p tsconfig.json",
|
|
24
|
-
"prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
|
|
25
|
-
"test": "vitest run",
|
|
26
|
-
"test:release-critical": "vitest run test/automatic-daemon-installer.test.ts test/automatic-install-target.test.ts test/automatic-service-migration.test.ts test/computer-cli.test.ts test/computer-service.test.ts test/computer-setup-client.test.ts test/computer-setup-orchestration.test.ts test/computer-setup.test.ts test/daemon-release-version.test.ts test/daemon-update-controller.test.ts test/daemon-update-eligibility.test.ts test/daemon-updater.test.ts test/execution-protocol.test.ts test/machine-info.test.ts --maxWorkers=2 --minWorkers=1",
|
|
27
|
-
"typecheck": "tsc --noEmit"
|
|
28
|
-
},
|
|
29
21
|
"dependencies": {
|
|
30
22
|
"@agentclientprotocol/sdk": "1.2.1",
|
|
31
|
-
"@nowcrew/cli": "0.4.
|
|
23
|
+
"@nowcrew/cli": "0.4.19-beta.1",
|
|
32
24
|
"cross-spawn": "^7.0.6",
|
|
33
25
|
"semver": "7.8.5",
|
|
34
26
|
"smol-toml": "1.8.0",
|
|
@@ -47,5 +39,12 @@
|
|
|
47
39
|
"tsx": "^4.19.0",
|
|
48
40
|
"typescript": "^5.6.0",
|
|
49
41
|
"vitest": "^2.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:release-critical": "vitest run test/automatic-daemon-installer.test.ts test/automatic-install-target.test.ts test/automatic-service-migration.test.ts test/computer-cli.test.ts test/computer-service.test.ts test/computer-setup-client.test.ts test/computer-setup-orchestration.test.ts test/computer-setup.test.ts test/daemon-release-version.test.ts test/daemon-update-controller.test.ts test/daemon-update-eligibility.test.ts test/daemon-updater.test.ts test/execution-protocol.test.ts test/machine-info.test.ts --maxWorkers=2 --minWorkers=1",
|
|
48
|
+
"typecheck": "tsc --noEmit"
|
|
50
49
|
}
|
|
51
|
-
}
|
|
50
|
+
}
|