@north-light/crouter 0.3.234 → 0.3.236

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.
Files changed (70) hide show
  1. package/dist/api/dto/nodes.d.ts +15 -3
  2. package/dist/clients/attach/viewer.js +398 -398
  3. package/dist/commands/__tests__/node-new.test.d.ts +1 -0
  4. package/dist/commands/__tests__/node-new.test.js +58 -0
  5. package/dist/commands/attention.js +1 -1
  6. package/dist/commands/cron.js +5 -5
  7. package/dist/commands/human.js +1 -1
  8. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
  9. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
  10. package/dist/commands/memory/delete.js +1 -1
  11. package/dist/commands/memory/edit.js +6 -3
  12. package/dist/commands/memory/list.d.ts +4 -0
  13. package/dist/commands/memory/list.js +20 -14
  14. package/dist/commands/memory/move.js +1 -1
  15. package/dist/commands/memory/shared.d.ts +1 -0
  16. package/dist/commands/memory/shared.js +13 -1
  17. package/dist/commands/memory/write.js +4 -1
  18. package/dist/commands/node/create.js +3 -0
  19. package/dist/commands/node/inspect.js +11 -4
  20. package/dist/commands/node/lifecycle.js +2 -1
  21. package/dist/commands/node/message.js +8 -4
  22. package/dist/commands/node/subscription.js +5 -1
  23. package/dist/commands/node/wait.js +5 -1
  24. package/dist/commands/pkg/plugin-manage.js +1 -1
  25. package/dist/commands/pkg.js +1 -0
  26. package/dist/commands/profile/rename.js +1 -1
  27. package/dist/commands/sys/config.js +1 -0
  28. package/dist/commands/sys.js +1 -0
  29. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  30. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  31. package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
  32. package/dist/core/__tests__/cli-help-contract.test.js +150 -0
  33. package/dist/core/__tests__/helpers/harness.js +1 -0
  34. package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
  35. package/dist/core/__tests__/seam/dormancy-release.test.js +70 -1
  36. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
  37. package/dist/core/canvas/canvas.js +11 -1
  38. package/dist/core/canvas/meta-file.js +18 -27
  39. package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
  40. package/dist/core/canvas/remote-canvas-source.js +9 -1
  41. package/dist/core/canvas/types.d.ts +2 -2
  42. package/dist/core/command-manifests/registry.d.ts +2 -0
  43. package/dist/core/command-plugins/compose.js +4 -2
  44. package/dist/core/command-plugins/discovery.d.ts +1 -0
  45. package/dist/core/command-plugins/discovery.js +1 -1
  46. package/dist/core/command.js +2 -2
  47. package/dist/core/fault-classifier.js +8 -0
  48. package/dist/core/pagination.d.ts +2 -0
  49. package/dist/core/pagination.js +2 -1
  50. package/dist/core/runtime/broker/fault-retry.js +6 -0
  51. package/dist/core/runtime/broker/rebind.js +50 -0
  52. package/dist/core/runtime/launch-target.js +1 -1
  53. package/dist/core/runtime/launch.d.ts +2 -2
  54. package/dist/core/runtime/launch.js +17 -4
  55. package/dist/core/runtime/spawn.d.ts +5 -13
  56. package/dist/core/runtime/spawn.js +4 -10
  57. package/dist/core/runtime/stop-guard.js +28 -16
  58. package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
  59. package/dist/core/substrate/frontmatter-validation.js +1 -1
  60. package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
  61. package/dist/daemon/api/handlers/messages.js +12 -12
  62. package/dist/daemon/api/handlers/nodes.js +13 -4
  63. package/dist/daemon/api/handlers/worktree.js +6 -22
  64. package/dist/daemon/api/map.d.ts +4 -2
  65. package/dist/daemon/api/map.js +22 -2
  66. package/dist/daemon/reconcilers/broker-supervision.js +1 -1
  67. package/dist/pi-extensions/canvas-bash-valve.js +4 -3
  68. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  69. package/package.json +1 -1
  70. package/runtime.lock.json +2 -2
@@ -1,9 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { test } from 'node:test';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
3
6
  import { toErrorBody } from '../map.js';
4
7
  import { ApiError } from '../../../api/errors.js';
5
8
  import { apiErrorToCliError, InputError } from '../../../core/io.js';
6
9
  import { brokerLaunchFailed } from '../../../core/errors.js';
10
+ import { createManagedWorktree } from '../../../core/worktree.js';
7
11
  // Regression for crouter#521: choosing the root profile on a virgin machine
8
12
  // died with a bare `<error code="internal">\ninternal error` and ZERO cause —
9
13
  // the front-door `createNode({root:true})` boot failure was thrown as a plain
@@ -41,6 +45,29 @@ test('an unanticipated 500 surfaces its cause instead of a causeless "internal e
41
45
  // ...and the next-hint is the crtr-bug guidance, not "adjust the call".
42
46
  assert.match(cli.payload.next, /crtr bug/);
43
47
  });
48
+ // Regression for crouter#811: a `crtr node new --worktree` against a cwd that is
49
+ // not inside a git repository reached the caller as `500 internal`, hiding the
50
+ // domain error the worktree layer already raised for exactly that input.
51
+ test('a non-git cwd round-trips the worktree layer\'s own code + next-hint, never a 500', () => {
52
+ const outside = mkdtempSync(join(tmpdir(), 'crtr-nongit-'));
53
+ try {
54
+ let thrown;
55
+ try {
56
+ createManagedWorktree(outside, 'node-that-needs-a-repo');
57
+ }
58
+ catch (err) {
59
+ thrown = err;
60
+ }
61
+ assert.ok(thrown !== undefined, 'a worktree create outside a repository must fail');
62
+ assert.equal(toErrorBody(thrown).status, 400);
63
+ const cli = roundTrip(thrown);
64
+ assert.equal(cli.payload.error, 'not_git_repo');
65
+ assert.match(cli.payload.next, /--cwd/);
66
+ }
67
+ finally {
68
+ rmSync(outside, { recursive: true, force: true });
69
+ }
70
+ });
44
71
  test('a 500 body carries only the bounded first line, never a multi-line stack', () => {
45
72
  const withStack = new Error('top cause of the failure');
46
73
  withStack.stack = 'Error: top cause of the failure\n at foo (bar.js:1:1)\n at baz (qux.js:2:2)';
@@ -309,18 +309,18 @@ async function handleMessage(ctx) {
309
309
  // nothing will ever bring back on its own, and a deferred entry appended to it
310
310
  // would wait forever. Reopen widens the WAKE-CAPABLE tiers to a finalized
311
311
  // target; deferred stays rejected, and the rejection names those tiers.
312
- let entry;
313
- if (tier === 'deferred') {
314
- const outcome = withFreshTerminalGuard(id, hasNoNaturalCycle, deliverImmediate);
315
- if (outcome.kind === 'missing')
316
- throw notFound(`unknown node: ${id}`, { received: id });
317
- if (outcome.kind === 'terminal')
318
- throw deferredNoNaturalCycle(id, outcome.status);
319
- entry = outcome.result;
320
- }
321
- else {
322
- entry = deliverImmediate();
323
- }
312
+ const deliver = () => {
313
+ if (tier === 'deferred') {
314
+ const outcome = withFreshTerminalGuard(id, hasNoNaturalCycle, deliverImmediate);
315
+ if (outcome.kind === 'missing')
316
+ throw notFound(`unknown node: ${id}`, { received: id });
317
+ if (outcome.kind === 'terminal')
318
+ throw deferredNoNaturalCycle(id, outcome.status);
319
+ return outcome.result;
320
+ }
321
+ return deliverImmediate();
322
+ };
323
+ const entry = deliver();
324
324
  // A wake-capable tier revives a dormant target so its inbox-watcher delivers
325
325
  // this; deferred never wakes — it rides the target's next natural cycle.
326
326
  let revived = false;
@@ -32,6 +32,8 @@ import { renameWindow } from '../../../core/runtime/placement.js';
32
32
  import { buildLaunchSpecAsync, isProviderPinnedModelToken, normalizeModel, readMergedLaunchConfigAsync } from '../../../core/runtime/launch.js';
33
33
  import { modelRequestFromConfig } from '../../../core/model-routes.js';
34
34
  import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js';
35
+ import { readFault } from '../../../core/runtime/fault.js';
36
+ import { activeFaultForDisplay } from '../../../core/canvas/render-source.js';
35
37
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
36
38
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
37
39
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
@@ -62,7 +64,17 @@ function edgesFor(id) {
62
64
  }
63
65
  /** Re-read a node fresh and project its detail DTO (post-action response). */
64
66
  function detailOf(id) {
65
- return toNodeDetailDTO(requireMeta(id), edgesFor(id));
67
+ const meta = requireMeta(id);
68
+ const fault = activeFaultForDisplay(meta, readFault(id));
69
+ return {
70
+ ...toNodeDetailDTO(meta, edgesFor(id)),
71
+ fault: fault === null ? null : {
72
+ link: fault.link,
73
+ kind: fault.kind,
74
+ retry: { disposition: fault.retry.disposition },
75
+ since: fault.since,
76
+ },
77
+ };
66
78
  }
67
79
  /** Count context + report files under a node (the context-roster `files`). */
68
80
  function countFiles(id) {
@@ -96,9 +108,6 @@ function parseCreateBody(body) {
96
108
  rootLifecycle !== 'terminal') {
97
109
  throw usage("root_lifecycle must be 'resident' or 'terminal'");
98
110
  }
99
- if (rootLifecycle !== undefined && b['root'] !== true) {
100
- throw usage('root_lifecycle is only valid with root: true');
101
- }
102
111
  return b;
103
112
  }
104
113
  /** Build the immediate spawn recipe. */
@@ -8,32 +8,16 @@
8
8
  //
9
9
  // Error parity: `closeManagedWorktree` throws `WorktreeError` with a SPECIFIC
10
10
  // code (`dirty_worktree`, `rebase_failed`, `unknown_node`, `no_managed_worktree`,
11
- // …) plus a `next` remediation and an optional `detail`. We rethrow it as a
12
- // `CrtrError` carrying that exact code + `details.{next,received}`, which
13
- // survive `toErrorBody` — so the CLI leaf can reconstruct the original
14
- // agent-facing contract instead of collapsing to a generic status code.
15
- import { closeManagedWorktree, WorktreeError } from '../../../core/worktree.js';
16
- import { CrtrError } from '../../../core/errors.js';
17
- import { ExitCode } from '../../../types.js';
18
- function worktreeErrorToCrtrError(err) {
19
- const exitCode = err.code === 'unknown_node' ? ExitCode.NOT_FOUND : ExitCode.GENERAL;
20
- const details = { next: err.next };
21
- if (err.detail !== undefined)
22
- details['received'] = err.detail;
23
- return new CrtrError(err.code, err.message, exitCode, details);
24
- }
11
+ // …) plus a `next` remediation and an optional `detail`. The error map projects
12
+ // all of that onto `CrtrError` for every route that can raise it — so the CLI
13
+ // leaf can reconstruct the original agent-facing contract instead of collapsing
14
+ // to a generic status code.
15
+ import { closeManagedWorktree } from '../../../core/worktree.js';
25
16
  function handleWorktreeClose(ctx) {
26
17
  const id = ctx.params['id'];
27
18
  // No requireMeta: closeManagedWorktree already throws the precise
28
19
  // `unknown_node` / `no_managed_worktree` WorktreeError for a bad id.
29
- try {
30
- return { status: 200, body: closeManagedWorktree(id) };
31
- }
32
- catch (err) {
33
- if (err instanceof WorktreeError)
34
- throw worktreeErrorToCrtrError(err);
35
- throw err;
36
- }
20
+ return { status: 200, body: closeManagedWorktree(id) };
37
21
  }
38
22
  export const worktreeRoutes = [
39
23
  { method: 'POST', pattern: '/v1/nodes/:id/worktree/close', handler: handleWorktreeClose },
@@ -94,6 +94,8 @@ export declare const REVIEW_ERROR_MAP: Readonly<Record<ReviewErrorCode, {
94
94
  * `finalization_forbidden`, or 404 `node_not_found`;
95
95
  * - a `NodeIdConflictError` (a `--node-id`/`node_id` spawn at an id that
96
96
  * already exists) → 409 `node_id_exists`;
97
+ * - a `WorktreeError` → its own code at 400 (404 for `unknown_node`), with
98
+ * `next` + git `detail` preserved as CrtrError details;
97
99
  * - `reviveNode`'s finalization-latch throw → 409 `already_finalized`;
98
100
  * - an "unknown node" throw (reviveNode / transition / getNode-null handler) →
99
101
  * 404 `node_not_found`;
@@ -104,7 +106,7 @@ export declare const REVIEW_ERROR_MAP: Readonly<Record<ReviewErrorCode, {
104
106
  * The recognized plain-`Error` cases match on the throw sites' messages (the
105
107
  * only signal those sites carry); the source sites are named above so a message
106
108
  * change there is caught by the A-11 tests. */
107
- export declare function errorToStatus(err: unknown): {
109
+ export declare function errorToStatus(thrown: unknown): {
108
110
  status: number;
109
111
  code: string;
110
112
  };
@@ -115,7 +117,7 @@ export declare function errorToStatus(err: unknown): {
115
117
  * `internal error`: it surfaces the bounded cause (`boundedCause`) so a caller
116
118
  * is never left with a causeless internal, while the router logs the full
117
119
  * error (with stack) server-side. */
118
- export declare function toErrorBody(err: unknown): {
120
+ export declare function toErrorBody(thrown: unknown): {
119
121
  status: number;
120
122
  body: ErrorBody;
121
123
  };
@@ -16,6 +16,7 @@ import { FinalizationError } from '../../core/feed/feed.js';
16
16
  import { NodeIdConflictError } from '../../core/runtime/nodes.js';
17
17
  import { readContextTokens } from '../../core/canvas/telemetry.js';
18
18
  import { CrtrError } from '../../core/errors.js';
19
+ import { WorktreeError } from '../../core/worktree.js';
19
20
  import { ExitCode } from '../../types.js';
20
21
  import { sinkDisplay } from '../cron-sink.js';
21
22
  import { responsePath } from '../../core/human/convention.js';
@@ -430,6 +431,8 @@ export const REVIEW_ERROR_MAP = Object.freeze({
430
431
  * `finalization_forbidden`, or 404 `node_not_found`;
431
432
  * - a `NodeIdConflictError` (a `--node-id`/`node_id` spawn at an id that
432
433
  * already exists) → 409 `node_id_exists`;
434
+ * - a `WorktreeError` → its own code at 400 (404 for `unknown_node`), with
435
+ * `next` + git `detail` preserved as CrtrError details;
433
436
  * - `reviveNode`'s finalization-latch throw → 409 `already_finalized`;
434
437
  * - an "unknown node" throw (reviveNode / transition / getNode-null handler) →
435
438
  * 404 `node_not_found`;
@@ -440,7 +443,8 @@ export const REVIEW_ERROR_MAP = Object.freeze({
440
443
  * The recognized plain-`Error` cases match on the throw sites' messages (the
441
444
  * only signal those sites carry); the source sites are named above so a message
442
445
  * change there is caught by the A-11 tests. */
443
- export function errorToStatus(err) {
446
+ export function errorToStatus(thrown) {
447
+ const err = normalizeThrow(thrown);
444
448
  // An already-shaped API-style throw (e.g. a re-thrown ApiError) — trust it.
445
449
  if (typeof err === 'object' &&
446
450
  err !== null &&
@@ -479,6 +483,21 @@ export function errorToStatus(err) {
479
483
  }
480
484
  return { status: 500, code: 'internal' };
481
485
  }
486
+ /** A `WorktreeError` is bad input or a repository-state conflict, never a daemon
487
+ * fault, so it is projected onto the `CrtrError` contract: its specific code,
488
+ * its `next` remediation, and its git `detail` all survive to the caller
489
+ * instead of collapsing into an opaque 500. The details carry the FULL
490
+ * `{error, message, next}` payload so `apiErrorToCliError` reconstructs the
491
+ * worktree contract verbatim rather than degrading to `invalid_request`. */
492
+ function normalizeThrow(err) {
493
+ if (!(err instanceof WorktreeError))
494
+ return err;
495
+ const exitCode = err.code === 'unknown_node' ? ExitCode.NOT_FOUND : ExitCode.GENERAL;
496
+ const details = { error: err.code, message: err.message, next: err.next };
497
+ if (err.detail !== undefined)
498
+ details['received'] = err.detail;
499
+ return new CrtrError(err.code, err.message, exitCode, details);
500
+ }
482
501
  /** Remove the store row from a terminal-conflict detail before it reaches the wire.
483
502
  * Only the approved `ReviewDTO` fields cross this boundary; all other review
484
503
  * errors retain their explicitly specified internal detail object. */
@@ -539,7 +558,8 @@ function boundedCause(err) {
539
558
  * `internal error`: it surfaces the bounded cause (`boundedCause`) so a caller
540
559
  * is never left with a causeless internal, while the router logs the full
541
560
  * error (with stack) server-side. */
542
- export function toErrorBody(err) {
561
+ export function toErrorBody(thrown) {
562
+ const err = normalizeThrow(thrown);
543
563
  const { status, code } = errorToStatus(err);
544
564
  if (status >= 500) {
545
565
  return { status, body: { error: { code, message: boundedCause(err) } } };
@@ -550,7 +550,7 @@ export class BrokerSupervisionReconciler {
550
550
  ? `Child failed — ${name} (${id}) exceeded its model context window and is now dead. ` +
551
551
  `Shrink the task or loaded context, or choose a larger-context model before spawning it again. Error: ${firstLine}`
552
552
  : `Child hit a fatal provider fault — ${name} (${id}) got a non-retryable ${fault.kind} error and will NOT recover on its own; ` +
553
- `it stays alive but every wake re-fails until the fault clears. ` +
553
+ `it stays alive, and waking it clears the fault as soon as one turn completes — a wake that hits the same cause fails again. ` +
554
554
  (isAuth
555
555
  ? `Its provider credential is dead — re-authenticate (\`/login\`, or re-auth crtr); the child recovers on its next wake once the auth reloads. `
556
556
  : ``) +
@@ -29,6 +29,7 @@ import { spawn } from 'node:child_process';
29
29
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
30
30
  import { homedir } from 'node:os';
31
31
  import { dirname } from 'node:path';
32
+ import { performance } from 'node:perf_hooks';
32
33
  import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
33
34
  import { readConfig } from '../core/config.js';
34
35
  import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
@@ -237,7 +238,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
237
238
  let offset = 0;
238
239
  let settled = false;
239
240
  let pgid;
240
- const startedAt = Date.now();
241
+ const startedAt = performance.now();
241
242
  const valveMs = resolveValveMs();
242
243
  let interval;
243
244
  const teardown = () => {
@@ -255,7 +256,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
255
256
  act();
256
257
  };
257
258
  function finishBackground(auto = false) {
258
- const elapsed = formatBashElapsed(Date.now() - startedAt);
259
+ const elapsed = formatBashElapsed(performance.now() - startedAt);
259
260
  const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
260
261
  `It will interrupt you with a message when it exits. ` +
261
262
  `Watch: tail -f ${paths.jobLog} ; stop: kill -- -${pgid}]`;
@@ -344,7 +345,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
344
345
  // time out or delete the directory during that in-progress exit.
345
346
  if (existsSync(paths.jobExit))
346
347
  return;
347
- const elapsedMs = Date.now() - startedAt;
348
+ const elapsedMs = performance.now() - startedAt;
348
349
  if (timeout !== undefined && timeout > 0) {
349
350
  if (elapsedMs >= timeout * 1000) {
350
351
  if (pgid)
@@ -14,6 +14,7 @@
14
14
  import { envNodeId, envProfileId, envWatcherDebounceMs, envWatcherTickMs } from '../shared/env.js';
15
15
  import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
16
16
  import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
17
+ import { captureGoalIfAbsent, generateAndCommitInitialName } from './broker-local.js';
17
18
  import { emitEvent } from '../core/events/emit.js';
18
19
  import { errorClassFromError } from '../core/events/errors.js';
19
20
  import { operationIdContext } from '../core/events/operation-id.js';
@@ -795,6 +796,17 @@ class CanvasInboxWatcher {
795
796
  for (const entry of delivery.entries)
796
797
  requeueEntryIds.add(entry.entry_id);
797
798
  }
799
+ else if (delivery.kind === 'human') {
800
+ // A person's own words, delivered through the durable inbox because the
801
+ // broker was cold when they arrived (crtrd's revive path, not pi's live
802
+ // socket). pi never fires its own `input` event for this path, so
803
+ // canvas-goal-capture's first-message hooks never run — mirror both here
804
+ // so an inbox-delivered first message names the node and survives a
805
+ // fresh revive exactly like a live-socket one. Both helpers are
806
+ // idempotent (no-op once a goal/description already exists).
807
+ captureGoalIfAbsent(nodeId, delivery.text);
808
+ generateAndCommitInitialName(nodeId, delivery.text);
809
+ }
798
810
  }
799
811
  return { inactive: false, requeueEntryIds };
800
812
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.234",
3
+ "version": "0.3.236",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.234",
3
+ "version": "0.3.236",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.234",
9
+ "version": "0.3.236",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {