@nowcrew/daemon 0.5.19 → 0.5.21

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.
@@ -1,6 +1,7 @@
1
1
  import { mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
2
+ import { renameSync } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
4
5
  import { z } from "zod";
5
6
  const OwnerSchema = z.object({
6
7
  pid: z.number().int().positive(),
@@ -8,9 +9,15 @@ const OwnerSchema = z.object({
8
9
  token: z.string().uuid(),
9
10
  }).strict();
10
11
  export class JournalLockedError extends Error {
11
- constructor(message) {
12
+ journalPath;
13
+ ownerPid;
14
+ constructor(message, diagnostics = {}) {
12
15
  super(message);
13
16
  this.name = "JournalLockedError";
17
+ if (diagnostics.journalPath !== undefined)
18
+ this.journalPath = diagnostics.journalPath;
19
+ if (diagnostics.ownerPid !== undefined)
20
+ this.ownerPid = diagnostics.ownerPid;
14
21
  }
15
22
  }
16
23
  export class JournalLockCorruptionError extends Error {
@@ -24,6 +31,7 @@ export const defaultJournalLockFileSystem = {
24
31
  readFile: (path) => readFile(path, "utf8"),
25
32
  readdir,
26
33
  rename,
34
+ renameSync,
27
35
  rm,
28
36
  rmdir,
29
37
  stat,
@@ -44,6 +52,141 @@ export function createJournalLeaseRegistry() {
44
52
  return { leases: new Map() };
45
53
  }
46
54
  const codeOf = (error) => error instanceof Error && "code" in error ? error.code : undefined;
55
+ const ownerFileName = (token) => `owner.${token}.json`;
56
+ const releasedLockName = (token) => `.journal.released.${token}.lock`;
57
+ const RELEASED_LOCK_PATTERN = /^\.journal\.released\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.lock$/i;
58
+ const MAX_RELEASED_LOCK_CLEANUP = 8;
59
+ const throwIfAborted = (signal) => {
60
+ if (signal?.aborted)
61
+ throw signal.reason ?? new Error("Journal close aborted");
62
+ };
63
+ function awaitAbortable(operation, signal) {
64
+ if (signal === undefined)
65
+ return operation;
66
+ throwIfAborted(signal);
67
+ return new Promise((resolveOperation, rejectOperation) => {
68
+ let settled = false;
69
+ const settle = (continuation) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ signal.removeEventListener("abort", onAbort);
74
+ continuation();
75
+ };
76
+ const onAbort = () => settle(() => {
77
+ rejectOperation(signal.reason ?? new Error("Journal close aborted"));
78
+ });
79
+ signal.addEventListener("abort", onAbort, { once: true });
80
+ if (signal.aborted)
81
+ onAbort();
82
+ operation.then((value) => settle(() => resolveOperation(value)), (error) => settle(() => rejectOperation(error)));
83
+ });
84
+ }
85
+ async function parseLockOwner(lockDirectory, fileName, fileSystem) {
86
+ const path = join(lockDirectory, fileName);
87
+ try {
88
+ const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(path)));
89
+ if (fileName !== ownerFileName(owner.token))
90
+ throw new Error("owner token does not match filename");
91
+ return owner;
92
+ }
93
+ catch (error) {
94
+ throw new JournalLockCorruptionError(path, error);
95
+ }
96
+ }
97
+ export async function inspectJournalLock(options) {
98
+ const fileSystem = options.fileSystem ?? defaultJournalLockFileSystem;
99
+ const lockDirectory = join(options.directory, ".journal.lock");
100
+ const orphanGraceMs = options.orphanGraceMs ?? 30_000;
101
+ const now = options.now ?? (() => new Date());
102
+ const missingDuringOwnerRead = (error) => codeOf(error) === "ENOENT"
103
+ || (error instanceof Error && codeOf(error.cause) === "ENOENT");
104
+ for (let attempt = 0; attempt < 2; attempt += 1) {
105
+ let names;
106
+ try {
107
+ names = (await fileSystem.readdir(lockDirectory)).map(String).sort();
108
+ }
109
+ catch (error) {
110
+ if (codeOf(error) === "ENOENT") {
111
+ return { status: "unlocked", detail: "lock directory does not exist" };
112
+ }
113
+ return { status: "corrupt", detail: `lock directory cannot be read: ${error.message}` };
114
+ }
115
+ if (names.length === 0) {
116
+ try {
117
+ const lockStat = await fileSystem.stat(lockDirectory);
118
+ const ageMs = now().valueOf() - lockStat.mtimeMs;
119
+ return ageMs < orphanGraceMs
120
+ ? { status: "installing", detail: "lock owner installation is in progress" }
121
+ : { status: "stale", detail: "empty lock directory exceeded the owner installation grace period" };
122
+ }
123
+ catch (error) {
124
+ if (codeOf(error) === "ENOENT" && attempt === 0)
125
+ continue;
126
+ if (codeOf(error) === "ENOENT") {
127
+ return { status: "unlocked", detail: "lock directory disappeared during inspection" };
128
+ }
129
+ return { status: "corrupt", detail: `empty lock directory cannot be inspected: ${error.message}` };
130
+ }
131
+ }
132
+ if (names.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(names[0])) {
133
+ return { status: "corrupt", detail: "lock directory must contain one owner" };
134
+ }
135
+ let owner;
136
+ try {
137
+ owner = await parseLockOwner(lockDirectory, names[0], fileSystem);
138
+ }
139
+ catch (error) {
140
+ if (attempt === 0 && missingDuringOwnerRead(error))
141
+ continue;
142
+ return { status: "corrupt", detail: error.message };
143
+ }
144
+ let inspection;
145
+ try {
146
+ const identity = await options.inspectIdentity(owner.pid);
147
+ inspection = identity === owner.processIdentity
148
+ ? {
149
+ status: "owned",
150
+ ownerPid: owner.pid,
151
+ ownerAlive: true,
152
+ detail: `owned by live process ${owner.pid}`,
153
+ }
154
+ : {
155
+ status: "stale",
156
+ ownerPid: owner.pid,
157
+ ownerAlive: false,
158
+ detail: `owner process ${owner.pid} is absent or its identity changed`,
159
+ };
160
+ }
161
+ catch (error) {
162
+ inspection = {
163
+ status: "corrupt",
164
+ ownerPid: owner.pid,
165
+ detail: `owner process cannot be inspected: ${error.message}`,
166
+ };
167
+ }
168
+ try {
169
+ const confirmedOwner = await parseLockOwner(lockDirectory, names[0], fileSystem);
170
+ const confirmedNames = (await fileSystem.readdir(lockDirectory)).map(String).sort();
171
+ const unchanged = JSON.stringify(confirmedOwner) === JSON.stringify(owner)
172
+ && JSON.stringify(confirmedNames) === JSON.stringify(names);
173
+ if (!unchanged && attempt === 0)
174
+ continue;
175
+ if (!unchanged)
176
+ return { status: "corrupt", detail: "lock snapshot changed during bounded inspection" };
177
+ return inspection;
178
+ }
179
+ catch (error) {
180
+ if (attempt === 0 && missingDuringOwnerRead(error))
181
+ continue;
182
+ if (missingDuringOwnerRead(error)) {
183
+ return { status: "corrupt", detail: "lock snapshot remained unstable after bounded inspection" };
184
+ }
185
+ return { status: "corrupt", detail: error.message };
186
+ }
187
+ }
188
+ return { status: "corrupt", detail: "lock snapshot remained unstable after bounded inspection" };
189
+ }
47
190
  function processLeaseMap(registry) {
48
191
  return registry.leases;
49
192
  }
@@ -52,21 +195,29 @@ export function createJournalLease(options) {
52
195
  const registry = processLeaseMap(options.registry ?? defaultRegistry);
53
196
  const now = options.now ?? (() => new Date());
54
197
  const orphanGraceMs = options.orphanGraceMs ?? 30_000;
198
+ const journalPath = resolve(options.directory);
55
199
  const lockDirectory = join(options.directory, ".journal.lock");
56
200
  let attached = false;
57
201
  let status = "open";
58
- const ownerFileName = (token) => `owner.${token}.json`;
59
- const parseOwner = async (fileName) => {
60
- const path = join(lockDirectory, fileName);
202
+ const cleanupReleasedDirectory = async (path) => {
61
203
  try {
62
- const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(path)));
63
- if (fileName !== ownerFileName(owner.token))
64
- throw new Error("owner token does not match filename");
65
- return owner;
204
+ await fileSystem.rm(path, { recursive: true, force: true });
205
+ await options.syncDirectory(options.directory);
66
206
  }
67
- catch (error) {
68
- throw new JournalLockCorruptionError(path, error);
207
+ catch { /* released tombstones never block ownership or reverse a committed close */ }
208
+ };
209
+ const cleanupReleasedDirectories = async () => {
210
+ let names;
211
+ try {
212
+ names = (await fileSystem.readdir(options.directory)).map(String).sort();
69
213
  }
214
+ catch {
215
+ return;
216
+ }
217
+ await Promise.all(names
218
+ .filter((name) => RELEASED_LOCK_PATTERN.test(name))
219
+ .slice(0, MAX_RELEASED_LOCK_CLEANUP)
220
+ .map((name) => cleanupReleasedDirectory(join(options.directory, name))));
70
221
  };
71
222
  const readOwner = async () => {
72
223
  let names;
@@ -81,7 +232,7 @@ export function createJournalLease(options) {
81
232
  if (names.length === 0) {
82
233
  const lockStat = await fileSystem.stat(lockDirectory);
83
234
  if (now().valueOf() - lockStat.mtimeMs < orphanGraceMs) {
84
- throw new JournalLockedError("Execution journal lock owner installation is in progress");
235
+ throw new JournalLockedError("Execution journal lock owner installation is in progress", { journalPath });
85
236
  }
86
237
  try {
87
238
  await fileSystem.rmdir(lockDirectory);
@@ -96,7 +247,7 @@ export function createJournalLease(options) {
96
247
  if (names.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(names[0])) {
97
248
  throw new JournalLockCorruptionError(lockDirectory, new Error("lock directory must contain one owner"));
98
249
  }
99
- return { owner: await parseOwner(names[0]), fileName: names[0] };
250
+ return { owner: await parseLockOwner(lockDirectory, names[0], fileSystem), fileName: names[0] };
100
251
  };
101
252
  const validateInstalledOwner = async (lease) => {
102
253
  const observed = await readOwner();
@@ -151,9 +302,6 @@ export function createJournalLease(options) {
151
302
  lockDirectorySynced: false,
152
303
  executionsDirectorySynced: false,
153
304
  releasing: false,
154
- ownerRemoved: false,
155
- lockDirectoryRemoved: false,
156
- releaseSynced: false,
157
305
  };
158
306
  registry.set(options.directory, lease);
159
307
  attached = true;
@@ -169,7 +317,7 @@ export function createJournalLease(options) {
169
317
  continue;
170
318
  const identity = await options.inspectIdentity(observed.owner.pid);
171
319
  if (identity === observed.owner.processIdentity) {
172
- throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`);
320
+ throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`, { journalPath, ownerPid: observed.owner.pid });
173
321
  }
174
322
  await options.hooks?.beforeRemoveObservedOwner?.(observed.owner);
175
323
  try {
@@ -196,37 +344,48 @@ export function createJournalLease(options) {
196
344
  }
197
345
  };
198
346
  const acquire = async () => {
199
- if (status !== "open")
200
- throw new JournalLockedError(`Journal lease is ${status}`);
347
+ if (status !== "open") {
348
+ throw new JournalLockedError(`Journal lease is ${status}`, { journalPath });
349
+ }
201
350
  if (attached) {
202
351
  const lease = registry.get(options.directory);
203
- if (lease === undefined)
204
- throw new JournalLockedError("In-process journal lease is missing");
352
+ if (lease === undefined) {
353
+ throw new JournalLockedError("In-process journal lease is missing", { journalPath });
354
+ }
205
355
  await finishInstall(lease);
206
356
  return;
207
357
  }
208
358
  const existing = registry.get(options.directory);
209
359
  if (existing !== undefined) {
210
- if (existing.releasing)
211
- throw new JournalLockedError("Journal lease release is pending");
360
+ if (existing.releasing) {
361
+ throw new JournalLockedError("Journal lease release is pending", {
362
+ journalPath,
363
+ ownerPid: existing.owner.pid,
364
+ });
365
+ }
212
366
  await finishInstall(existing);
213
367
  existing.refs += 1;
214
368
  attached = true;
215
369
  return;
216
370
  }
371
+ void cleanupReleasedDirectories();
217
372
  await install();
218
373
  };
219
- const close = async () => {
374
+ const close = async (closeOptions = {}) => {
220
375
  if (status === "closed")
221
376
  return;
377
+ const signal = closeOptions.signal;
378
+ throwIfAborted(signal);
222
379
  if (!attached) {
223
380
  status = "closed";
224
381
  return;
225
382
  }
226
383
  const lease = registry.get(options.directory);
227
- if (lease === undefined)
228
- throw new JournalLockedError("In-process journal lease is missing");
384
+ if (lease === undefined) {
385
+ throw new JournalLockedError("In-process journal lease is missing", { journalPath });
386
+ }
229
387
  if (status === "open" && lease.refs > 1) {
388
+ throwIfAborted(signal);
230
389
  lease.refs -= 1;
231
390
  attached = false;
232
391
  status = "closed";
@@ -234,28 +393,28 @@ export function createJournalLease(options) {
234
393
  }
235
394
  status = "closing";
236
395
  lease.releasing = true;
237
- if (!lease.ownerRemoved) {
238
- await fileSystem.unlink(join(lockDirectory, lease.ownerFile));
239
- lease.ownerRemoved = true;
240
- }
241
- if (!lease.lockDirectoryRemoved) {
242
- await fileSystem.rmdir(lockDirectory);
243
- lease.lockDirectoryRemoved = true;
244
- }
245
- if (!lease.releaseSynced) {
246
- await options.syncDirectory(options.directory);
247
- lease.releaseSynced = true;
248
- }
396
+ await awaitAbortable((async () => {
397
+ await validateInstalledOwner(lease);
398
+ await options.hooks?.beforeReleaseOwner?.(lease.owner, {
399
+ ...(signal === undefined ? {} : { signal }),
400
+ });
401
+ await validateInstalledOwner(lease);
402
+ })(), signal);
403
+ throwIfAborted(signal);
404
+ const releasedDirectory = join(options.directory, releasedLockName(lease.owner.token));
405
+ fileSystem.renameSync(lockDirectory, releasedDirectory);
249
406
  lease.refs -= 1;
250
407
  registry.delete(options.directory);
251
408
  attached = false;
252
409
  status = "closed";
410
+ void cleanupReleasedDirectory(releasedDirectory);
253
411
  };
254
412
  return {
255
413
  acquire,
256
414
  assertUsable: () => {
257
- if (status !== "open")
258
- throw new JournalLockedError(`Execution journal lease is ${status}`);
415
+ if (status !== "open") {
416
+ throw new JournalLockedError(`Execution journal lease is ${status}`, { journalPath });
417
+ }
259
418
  },
260
419
  close,
261
420
  };
@@ -558,7 +558,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
558
558
  return { kind: "created", ...entry };
559
559
  });
560
560
  },
561
- startGuarded: async (executionId, processStartedAt, startDormant) => {
561
+ startGuarded: async (executionId, processStartedAt, startDormant, hooks) => {
562
562
  validateExecutionId(executionId);
563
563
  TimestampSchema.parse(processStartedAt);
564
564
  return serialized(async () => {
@@ -567,9 +567,21 @@ export function createExecutionJournal(agentsRoot, options = {}) {
567
567
  return { kind: "existing", entry: await confirmDurable(entry) };
568
568
  }
569
569
  const dormant = await startDormant();
570
+ let abortPromise = null;
571
+ const abort = () => {
572
+ if (abortPromise === null) {
573
+ try {
574
+ abortPromise = Promise.resolve(dormant.abort());
575
+ }
576
+ catch (error) {
577
+ abortPromise = Promise.reject(error);
578
+ }
579
+ }
580
+ return abortPromise;
581
+ };
570
582
  const abortWith = async (error) => {
571
583
  try {
572
- await dormant.abort();
584
+ await abort();
573
585
  }
574
586
  catch (abortError) {
575
587
  throw new AggregateError([error, abortError], "Dormant runtime abort failed");
@@ -591,6 +603,9 @@ export function createExecutionJournal(agentsRoot, options = {}) {
591
603
  });
592
604
  await writeRecord(updated);
593
605
  try {
606
+ const gate = hooks?.beforeRelease?.({ entry: updated, handle: dormant, abort });
607
+ if (gate !== undefined)
608
+ await gate;
594
609
  await dormant.release();
595
610
  }
596
611
  catch (error) {
@@ -671,8 +686,31 @@ export function createExecutionJournal(agentsRoot, options = {}) {
671
686
  }
672
687
  }),
673
688
  prune: async () => serialized(pruneInternal),
674
- close: async () => {
675
- await runSerialized(directory, lease.close);
689
+ close: async (closeOptions = {}) => {
690
+ const signal = closeOptions.signal;
691
+ let started = false;
692
+ const closing = runSerialized(directory, async () => {
693
+ started = true;
694
+ if (signal?.aborted)
695
+ throw signal.reason ?? new Error("Journal close aborted");
696
+ await lease.close(closeOptions);
697
+ });
698
+ if (signal === undefined)
699
+ return closing;
700
+ await new Promise((resolveClose, rejectClose) => {
701
+ const settle = (settler) => {
702
+ signal.removeEventListener("abort", onAbort);
703
+ settler();
704
+ };
705
+ const onAbort = () => {
706
+ if (!started)
707
+ settle(() => rejectClose(signal.reason ?? new Error("Journal close aborted")));
708
+ };
709
+ signal.addEventListener("abort", onAbort, { once: true });
710
+ if (signal.aborted)
711
+ onAbort();
712
+ closing.then(() => settle(resolveClose), (error) => settle(() => rejectClose(error)));
713
+ });
676
714
  },
677
715
  };
678
716
  }
@@ -122,6 +122,7 @@ export const ExecutionStartSchema = z.object({
122
122
  threadId: z.string().min(1).optional(),
123
123
  wakeMessageId: z.string().min(1).optional(),
124
124
  externalResponseSessionId: ExecutionIdSchema.optional(),
125
+ answerStream: z.boolean().optional(),
125
126
  attachments: z.array(ExecutionAttachmentSchema).max(20).optional(),
126
127
  }).strict(),
127
128
  reporting: z.object({
@@ -222,6 +223,7 @@ const RawExecutionCompletedSchema = z.object({
222
223
  model: z.string().optional(),
223
224
  resumed: z.boolean(),
224
225
  finalText: z.string().optional(),
226
+ externalAnswer: z.string().min(1).optional(),
225
227
  boundImDecision: z.enum(["notify", "silent"]).optional(),
226
228
  usage: ExecutionUsageSchema.optional(),
227
229
  startedAt: TimestampSchema,
@@ -0,0 +1,95 @@
1
+ import { join, resolve } from "node:path";
2
+ import { JournalLockedError } from "./execution-journal.js";
3
+ import { DaemonAlreadyRunningError } from "./daemon-startup-error.js";
4
+ export async function reconcileExecutionJournal(journal, dependencies) {
5
+ try {
6
+ await journal.reconcileAfterRestart();
7
+ }
8
+ catch (error) {
9
+ const agentsRoot = resolve(dependencies.agentsRoot);
10
+ const errorRecord = typeof error === "object" && error !== null
11
+ ? error
12
+ : {};
13
+ const journalPath = typeof errorRecord.journalPath === "string"
14
+ ? resolve(errorRecord.journalPath)
15
+ : join(agentsRoot, ".crew", "executions");
16
+ const ownerPid = typeof errorRecord.ownerPid === "number" ? errorRecord.ownerPid : undefined;
17
+ const errorType = error instanceof Error ? error.name : typeof error;
18
+ const errorMessage = error instanceof Error ? error.message : String(error);
19
+ const alreadyRunning = error instanceof JournalLockedError && ownerPid !== undefined;
20
+ const diagnostics = {
21
+ server_url: dependencies.serverUrl,
22
+ agents_root: agentsRoot,
23
+ journal_path: journalPath,
24
+ owner_pid: ownerPid,
25
+ ...(dependencies.profileName === undefined ? {} : { profile_name: dependencies.profileName }),
26
+ error_type: errorType,
27
+ error_message: errorMessage,
28
+ };
29
+ const failureEvent = alreadyRunning ? "daemon.already_running" : "execution.recovery_failed";
30
+ const failureMessage = alreadyRunning ? "daemon 已在运行" : "execution journal 恢复失败";
31
+ const failureLevel = alreadyRunning ? "WARN" : "ERROR";
32
+ dependencies.log(failureEvent, failureMessage, {
33
+ level: failureLevel,
34
+ ...diagnostics,
35
+ });
36
+ dependencies.writeStderr(`${JSON.stringify({
37
+ level: failureLevel,
38
+ event_type: failureEvent,
39
+ message: failureMessage,
40
+ ...diagnostics,
41
+ })}\n`);
42
+ const reportCleanupFailure = (stage, cleanupError) => {
43
+ const cleanupErrorType = cleanupError instanceof Error ? cleanupError.name : typeof cleanupError;
44
+ const cleanupErrorMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
45
+ const cleanupDiagnostics = {
46
+ server_url: dependencies.serverUrl,
47
+ agents_root: agentsRoot,
48
+ journal_path: journalPath,
49
+ owner_pid: ownerPid,
50
+ cleanup_stage: stage,
51
+ error_type: cleanupErrorType,
52
+ error_message: cleanupErrorMessage,
53
+ ...(alreadyRunning
54
+ ? { startup_error_type: errorType, startup_error_message: errorMessage }
55
+ : { recovery_error_type: errorType, recovery_error_message: errorMessage }),
56
+ };
57
+ const cleanupEvent = alreadyRunning
58
+ ? "daemon.already_running_cleanup_failed"
59
+ : "execution.recovery_cleanup_failed";
60
+ const cleanupMessage = alreadyRunning
61
+ ? "daemon 重复启动后的清理失败"
62
+ : "execution journal 恢复失败后的清理失败";
63
+ dependencies.log(cleanupEvent, cleanupMessage, { level: "ERROR", ...cleanupDiagnostics });
64
+ dependencies.writeStderr(`${JSON.stringify({
65
+ level: "ERROR",
66
+ event_type: cleanupEvent,
67
+ message: cleanupMessage,
68
+ ...cleanupDiagnostics,
69
+ })}\n`);
70
+ };
71
+ try {
72
+ await dependencies.flush();
73
+ }
74
+ catch (cleanupError) {
75
+ reportCleanupFailure("slog_flush", cleanupError);
76
+ }
77
+ try {
78
+ await journal.close();
79
+ }
80
+ catch (cleanupError) {
81
+ reportCleanupFailure("journal_close", cleanupError);
82
+ }
83
+ if (alreadyRunning) {
84
+ throw new DaemonAlreadyRunningError({
85
+ ownerPid,
86
+ agentsRoot,
87
+ journalPath,
88
+ serverUrl: dependencies.serverUrl,
89
+ ...(dependencies.profileName === undefined ? {} : { profileName: dependencies.profileName }),
90
+ cause: error,
91
+ });
92
+ }
93
+ throw error;
94
+ }
95
+ }