@north-light/crouter 0.3.249 → 0.3.251
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 +1 -1
- package/dist/api/__tests__/integration/client.test.js +28 -7
- package/dist/api/client.d.ts +1 -1
- package/dist/api/client.js +2 -2
- package/dist/api/dto/lifecycle.d.ts +3 -0
- package/dist/api/dto/messages.d.ts +4 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/reports.d.ts +7 -2
- package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
- package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
- package/dist/clients/attach/session/chrome-refresh.js +1 -0
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
- package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
- package/dist/clients/inbox/controller.js +32 -5
- package/dist/clients/inbox/tui/ansi.d.ts +3 -1
- package/dist/clients/inbox/tui/ansi.js +14 -10
- package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
- package/dist/commands/api-client.d.ts +2 -2
- package/dist/commands/api-client.js +5 -5
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/node/create.js +3 -1
- package/dist/commands/node/inspect.js +1 -0
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node/message.js +6 -4
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/push.js +36 -37
- package/dist/commands/sys/daemon.js +21 -8
- package/dist/commands/sys/setup-core.js +2 -2
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
- package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
- package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +100 -1
- package/dist/core/__tests__/child-death-wake.test.js +11 -7
- package/dist/core/__tests__/collectible.test.d.ts +1 -0
- package/dist/core/__tests__/collectible.test.js +88 -0
- package/dist/core/__tests__/context-intro.test.js +4 -41
- package/dist/core/__tests__/daemon-boot.test.js +30 -14
- package/dist/core/__tests__/daemon-wedge.test.js +25 -19
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -2
- package/dist/core/__tests__/human-deliver.test.js +5 -5
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
- package/dist/core/__tests__/integration/revive.test.js +48 -0
- package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
- package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
- package/dist/core/__tests__/migration.test.js +30 -0
- package/dist/core/__tests__/passive-subscription.test.js +39 -1
- package/dist/core/__tests__/prune.test.js +12 -3
- package/dist/core/__tests__/relaunch-root.test.js +1 -1
- package/dist/core/__tests__/respawn-throttle.test.js +16 -146
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
- package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
- package/dist/core/canvas/canvas.d.ts +9 -15
- package/dist/core/canvas/canvas.js +41 -47
- package/dist/core/canvas/crons.d.ts +5 -8
- package/dist/core/canvas/crons.js +5 -8
- package/dist/core/canvas/history.d.ts +5 -0
- package/dist/core/canvas/history.js +12 -1
- package/dist/core/canvas/migrations.js +58 -1
- package/dist/core/canvas/pid.d.ts +1 -1
- package/dist/core/canvas/pid.js +5 -2
- package/dist/core/canvas/types.d.ts +4 -0
- package/dist/core/feed/feed.d.ts +4 -0
- package/dist/core/feed/feed.js +13 -8
- package/dist/core/feed/inbox.d.ts +2 -1
- package/dist/core/feed/inbox.js +22 -3
- package/dist/core/git.d.ts +3 -0
- package/dist/core/git.js +3 -1
- package/dist/core/human/feedback-companion.js +1 -0
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/companion.js +1 -0
- package/dist/core/review/realize.js +1 -0
- package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
- package/dist/core/runtime/broker/auth-reload.js +9 -3
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
- package/dist/core/runtime/broker/engine-drive.js +39 -23
- package/dist/core/runtime/broker/event-projection.d.ts +4 -0
- package/dist/core/runtime/broker/event-projection.js +43 -23
- package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
- package/dist/core/runtime/broker/fault-retry.js +94 -8
- package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
- package/dist/core/runtime/broker/frame-dispatch.js +4 -1
- package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
- package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
- package/dist/core/runtime/broker/inbox.js +4 -0
- package/dist/core/runtime/broker/rebind.js +18 -0
- package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
- package/dist/core/runtime/broker/turn-admission.js +31 -0
- package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
- package/dist/core/runtime/broker/turn-ignition.js +25 -15
- package/dist/core/runtime/broker.js +21 -2
- package/dist/core/runtime/close.d.ts +11 -9
- package/dist/core/runtime/close.js +23 -19
- package/dist/core/runtime/fault.d.ts +21 -0
- package/dist/core/runtime/fault.js +129 -19
- package/dist/core/runtime/fleet.d.ts +0 -6
- package/dist/core/runtime/host.d.ts +7 -1
- package/dist/core/runtime/host.js +20 -2
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +24 -1
- package/dist/core/runtime/placement.d.ts +6 -6
- package/dist/core/runtime/placement.js +15 -8
- package/dist/core/runtime/reset.js +2 -2
- package/dist/core/runtime/revive.js +40 -4
- package/dist/core/runtime/spawn.d.ts +3 -0
- package/dist/core/runtime/spawn.js +1 -0
- package/dist/core/runtime/warm-pool.d.ts +4 -0
- package/dist/core/runtime/warm-pool.js +2 -0
- package/dist/core/worktree.d.ts +4 -3
- package/dist/core/worktree.js +73 -27
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
- package/dist/daemon/api/handlers/bash-jobs.js +2 -0
- package/dist/daemon/api/handlers/broker-ops.js +23 -2
- package/dist/daemon/api/handlers/human.js +1 -1
- package/dist/daemon/api/handlers/messages.js +11 -2
- package/dist/daemon/api/handlers/nodes.js +13 -1
- package/dist/daemon/api/handlers/reports.js +14 -6
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/api/server.d.ts +9 -12
- package/dist/daemon/api/server.js +30 -19
- package/dist/daemon/companion-retire.js +3 -9
- package/dist/daemon/cron/sinks.js +7 -2
- package/dist/daemon/crtrd.js +4 -1
- package/dist/daemon/fleet.d.ts +2 -16
- package/dist/daemon/fleet.js +63 -150
- package/dist/daemon/human/finish.js +2 -0
- package/dist/daemon/manage.d.ts +11 -0
- package/dist/daemon/manage.js +32 -3
- package/dist/daemon/messaging/node-message.js +7 -1
- package/dist/daemon/reconcilers/bash-deadline.js +2 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
- package/dist/daemon/reconcilers/broker-supervision.js +22 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
- package/dist/daemon/reconcilers/live-obligation.js +18 -6
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
- package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
- package/dist/pi-extensions/canvas-context-intro.js +4 -76
- package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
- package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
- package/dist/pi-extensions/canvas-review-boundary.js +4 -29
- package/dist/pi-extensions/canvas-stophook.js +1 -6
- package/dist/shared/birth-announcement.d.ts +12 -0
- package/dist/shared/birth-announcement.js +25 -0
- package/dist/shared/generated-context.d.ts +8 -4
- package/dist/shared/generated-context.js +15 -9
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
- package/dist/pi-extensions/truncate.d.ts +0 -5
- package/dist/pi-extensions/truncate.js +0 -14
package/dist/core/worktree.js
CHANGED
|
@@ -51,9 +51,17 @@ function resolveLandableBase(repoRoot, baseRef) {
|
|
|
51
51
|
// syntax, which could turn a SHA or a `^{commit}` suffix into a non-landable
|
|
52
52
|
// base even though no local branch by that exact name exists.
|
|
53
53
|
const exists = gitSync(['show-ref', '--verify', '--quiet', ref], repoRoot);
|
|
54
|
-
|
|
54
|
+
// `show-ref --verify --quiet` reserves exit 1 for an absent ref. gitSync
|
|
55
|
+
// preserves that numeric status for compatibility, but marks spawn failures,
|
|
56
|
+
// timeouts, and signal exits so they cannot masquerade as an absent branch.
|
|
57
|
+
if (exists.status === 1 && exists.executionFailed !== true) {
|
|
55
58
|
throw new WorktreeError('missing_base_branch', `managed worktree base '${baseRef}' does not name an existing local branch`, 'The base must be a local branch that landing can fast-forward — freshen your local branch from origin first (rebase-not-merge), then base on it.');
|
|
56
59
|
}
|
|
60
|
+
if (exists.status !== 0) {
|
|
61
|
+
const stderr = exists.stderr.trim() || '<empty>';
|
|
62
|
+
const execution = exists.processError === undefined ? '' : `\nprocess error: ${exists.processError}`;
|
|
63
|
+
throw new WorktreeError('base_branch_inspection_failed', `could not inspect local base branch '${baseRef}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${stderr}${execution}`);
|
|
64
|
+
}
|
|
57
65
|
return runGit(repoRoot, ['rev-parse', '--verify', ref], 'base_branch_resolve_failed', 'Inspect the local base branch, then retry.');
|
|
58
66
|
}
|
|
59
67
|
/** Path of the worktree where a local branch is currently checked out, or null
|
|
@@ -238,16 +246,23 @@ export function isRebaseInProgress(cwd) {
|
|
|
238
246
|
return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
|
|
239
247
|
}
|
|
240
248
|
/** The ONE authoritative closed-worktree state transition, shared by both
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
+
* closing paths (explicit `closeManagedWorktreeLocked` land, and the clean
|
|
250
|
+
* `autoDropCleanManagedWorktreeLocked` push-final drop): persist
|
|
251
|
+
* `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
|
|
252
|
+
* node's launch `cwd` to the durable base checkout. The caller broker stays
|
|
253
|
+
* alive and is Pi's sole transcript writer, so its session header cannot be
|
|
254
|
+
* safely rewritten here; clear the session coordinates and let the later
|
|
255
|
+
* revive launch fresh instead. Both closing paths MUST go through this helper
|
|
256
|
+
* instead of hand-rolling the `updateNode` call, so neither can drift out of
|
|
257
|
+
* sync. */
|
|
249
258
|
function persistClosedWorktreeState(nodeId, wt, cleanup = 'pending') {
|
|
250
|
-
|
|
259
|
+
const node = getNode(nodeId);
|
|
260
|
+
const sessionInvalidated = node?.pi_session_id != null || node?.pi_session_file != null;
|
|
261
|
+
updateNode(nodeId, {
|
|
262
|
+
cwd: wt.repo_root,
|
|
263
|
+
...(sessionInvalidated ? { pi_session_id: null, pi_session_file: null } : {}),
|
|
264
|
+
managed_worktree: { ...wt, state: 'closed', cleanup, closed: new Date().toISOString() },
|
|
265
|
+
});
|
|
251
266
|
}
|
|
252
267
|
function deferredCleanupResult(wt) {
|
|
253
268
|
const remove = `git -C ${shellQuote(wt.repo_root)} worktree remove ${shellQuote(wt.path)}`;
|
|
@@ -301,7 +316,29 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
301
316
|
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
302
317
|
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
|
|
303
318
|
}
|
|
304
|
-
|
|
319
|
+
let baseSha;
|
|
320
|
+
try {
|
|
321
|
+
// `repo_root` is the checkout that created this child, and it can vanish
|
|
322
|
+
// when that parent finishes first. The live managed checkout still names
|
|
323
|
+
// the shared repository and is the authority for every close operation.
|
|
324
|
+
baseSha = resolveLandableBase(wt.path, wt.base_ref);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// A deleted parent base cannot receive a fast-forward. A clean child at
|
|
328
|
+
// its recorded creation SHA has nothing to land, however, so conclude its
|
|
329
|
+
// deferred cleanup without guessing where work should go.
|
|
330
|
+
const head = runGit(wt.path, ['rev-parse', 'HEAD'], 'landed_sha_failed', 'Inspect the worktree checkout manually, then rerun `crtr node worktree close`.');
|
|
331
|
+
if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== wt.base_sha)
|
|
332
|
+
throw error;
|
|
333
|
+
persistClosedWorktreeState(nodeId, wt, 'pending');
|
|
334
|
+
return {
|
|
335
|
+
node_id: nodeId,
|
|
336
|
+
branch: wt.branch,
|
|
337
|
+
worktree_path: wt.path,
|
|
338
|
+
landed_sha: wt.base_sha,
|
|
339
|
+
...deferredCleanupResult(wt),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
305
342
|
const rebase = gitSync(['rebase', baseSha], wt.path);
|
|
306
343
|
if (rebase.status !== 0) {
|
|
307
344
|
const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
|
|
@@ -312,11 +349,11 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
312
349
|
// captured baseSha — landing is purely local, nothing is ever pushed to
|
|
313
350
|
// origin. Both landing paths require the base to still be exactly at that
|
|
314
351
|
// captured tip, so neither can overwrite a concurrent base change.
|
|
315
|
-
const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.
|
|
352
|
+
const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.path);
|
|
316
353
|
if (fastForward.status !== 0) {
|
|
317
354
|
throw new WorktreeError('land_base_failed', `rebased worktree is not a fast-forward of local base branch '${wt.base_ref}'`, 'Ask the user with `crtr human send` how to land this managed worktree.', (fastForward.stderr.trim() || fastForward.stdout.trim()).trim());
|
|
318
355
|
}
|
|
319
|
-
const baseCheckout = checkoutPathForBranch(wt.
|
|
356
|
+
const baseCheckout = checkoutPathForBranch(wt.path, wt.base_ref);
|
|
320
357
|
if (baseCheckout !== null) {
|
|
321
358
|
const checkoutHead = gitSync(['rev-parse', 'HEAD'], baseCheckout);
|
|
322
359
|
const actualHead = checkoutHead.stdout.trim();
|
|
@@ -329,7 +366,7 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
329
366
|
}
|
|
330
367
|
const land = baseCheckout !== null
|
|
331
368
|
? gitSync(['merge', '--ff-only', landedSha], baseCheckout)
|
|
332
|
-
: gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.
|
|
369
|
+
: gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.path);
|
|
333
370
|
if (land.status !== 0) {
|
|
334
371
|
const detail = (land.stderr.trim() || land.stdout.trim()).trim();
|
|
335
372
|
throw new WorktreeError('land_base_failed', `could not fast-forward local base branch '${wt.base_ref}' to this worktree`, baseCheckout !== null
|
|
@@ -356,7 +393,10 @@ export function closeManagedWorktree(nodeId) {
|
|
|
356
393
|
// repository to lock; every state-mutating close path runs under the lock.
|
|
357
394
|
if (wt === null)
|
|
358
395
|
return closeManagedWorktreeLocked(nodeId);
|
|
359
|
-
|
|
396
|
+
// An absent checkout has no live Git directory; its separate recovery proof
|
|
397
|
+
// still relies on the recorded repository path. Every live close locks from
|
|
398
|
+
// the child checkout instead of the parent that created it.
|
|
399
|
+
return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
|
|
360
400
|
}
|
|
361
401
|
const ABANDON_AUTHOR_NAME = 'crtr-abandon';
|
|
362
402
|
const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
|
|
@@ -519,9 +559,10 @@ export function missingWorktreeRecoveryHint(wt) {
|
|
|
519
559
|
* be a git-ancestor of (or equal to) the CURRENT tip of its recorded base
|
|
520
560
|
* branch — i.e. landing it would be a pure no-op. Any ambiguity (missing
|
|
521
561
|
* path, mid-rebase, wrong branch, an unresolvable base, or a real ancestry
|
|
522
|
-
* divergence) returns null
|
|
523
|
-
*
|
|
524
|
-
*
|
|
562
|
+
* divergence) returns null. A missing base is the one exception: equality
|
|
563
|
+
* between the live HEAD and recorded creation SHA proves no work was added.
|
|
564
|
+
* This function only ever proves a no-op land from live Git state, never on
|
|
565
|
+
* a heuristic guess, so real unlanded work always falls through to the explicit-close guard. The locked
|
|
525
566
|
* path re-checks this predicate before recording deferred cleanup; production
|
|
526
567
|
* code must always go through `autoDropCleanManagedWorktree`. */
|
|
527
568
|
export function proveCleanEmptyManagedWorktree(wt) {
|
|
@@ -537,18 +578,23 @@ export function proveCleanEmptyManagedWorktree(wt) {
|
|
|
537
578
|
const status = gitSync(['status', '--porcelain'], wt.path);
|
|
538
579
|
if (status.status !== 0 || status.stdout.trim() !== '')
|
|
539
580
|
return null;
|
|
540
|
-
let baseSha;
|
|
541
|
-
try {
|
|
542
|
-
baseSha = resolveLandableBase(wt.repo_root, wt.base_ref);
|
|
543
|
-
}
|
|
544
|
-
catch {
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
581
|
const head = gitSync(['rev-parse', 'HEAD'], wt.path);
|
|
548
582
|
if (head.status !== 0)
|
|
549
583
|
return null;
|
|
550
584
|
const branchSha = head.stdout.trim();
|
|
551
|
-
|
|
585
|
+
let baseSha;
|
|
586
|
+
try {
|
|
587
|
+
baseSha = resolveLandableBase(wt.path, wt.base_ref);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
// A parent-first close can remove the recorded base branch. Equality with
|
|
591
|
+
// the durable creation SHA proves this clean child never added work.
|
|
592
|
+
if (error instanceof WorktreeError && error.code === 'missing_base_branch' && branchSha === wt.base_sha) {
|
|
593
|
+
return { branchSha, baseSha: wt.base_sha };
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
const ancestor = gitSync(['merge-base', '--is-ancestor', branchSha, baseSha], wt.path);
|
|
552
598
|
if (ancestor.status !== 0)
|
|
553
599
|
return null;
|
|
554
600
|
return { branchSha, baseSha };
|
|
@@ -612,7 +658,7 @@ export function autoDropCleanManagedWorktree(nodeId) {
|
|
|
612
658
|
const pathAbsent = !existsSync(wt.path);
|
|
613
659
|
if (pathAbsent ? proveAbsentPathManagedWorktree(wt) === null : proveCleanEmptyManagedWorktree(wt) === null)
|
|
614
660
|
return null;
|
|
615
|
-
return withRepositoryWorktreeLock(wt.repo_root, () => {
|
|
661
|
+
return withRepositoryWorktreeLock(pathAbsent ? wt.repo_root : wt.path, () => {
|
|
616
662
|
// Re-fetch inside the lock: state may have changed since the caller's
|
|
617
663
|
// hasOpenManagedWorktree check ran outside it.
|
|
618
664
|
const locked = openManagedWorktreeForNode(nodeId);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Regression for NL-159 / #571: the daemon's pidfile/ownership claim must not
|
|
2
|
+
// survive a failed mandatory Unix API bind. A directory at the socket path
|
|
3
|
+
// deterministically makes `listen()` emit EADDRINUSE without touching crtrd's
|
|
4
|
+
// real canvas or runtime homes.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
10
|
+
import { claimDaemonOwnership, releaseDaemonOwnership } from '../../../core/canvas/daemon-owner.js';
|
|
11
|
+
import { resetEventSourceForTests } from '../../../core/events/source.js';
|
|
12
|
+
import { resetFleetForTests } from '../../../core/runtime/fleet.js';
|
|
13
|
+
import { runDaemon } from '../../crtrd.js';
|
|
14
|
+
import { spawnDaemon } from '../../manage.js';
|
|
15
|
+
const isolatedEnvKeys = [
|
|
16
|
+
'HOME',
|
|
17
|
+
'CRTR_HOME',
|
|
18
|
+
'CRTR_PIDFILE',
|
|
19
|
+
'CRTR_RUNTIME_HOME',
|
|
20
|
+
'XDG_CACHE_HOME',
|
|
21
|
+
'XDG_CONFIG_HOME',
|
|
22
|
+
'XDG_DATA_HOME',
|
|
23
|
+
'PI_CODING_AGENT_DIR',
|
|
24
|
+
];
|
|
25
|
+
test('a Unix socket bind failure rejects readiness and releases daemon ownership', async () => {
|
|
26
|
+
const root = mkdtempSync('/tmp/crtr-api-bind-failure-');
|
|
27
|
+
const home = join(root, 'home');
|
|
28
|
+
const canvasHome = join(root, 'canvas');
|
|
29
|
+
const socketPath = join(canvasHome, 'crtrd.sock');
|
|
30
|
+
const previousCwd = process.cwd();
|
|
31
|
+
const previousEnv = new Map(isolatedEnvKeys.map((key) => [key, process.env[key]]));
|
|
32
|
+
mkdirSync(home, { recursive: true });
|
|
33
|
+
mkdirSync(socketPath, { recursive: true });
|
|
34
|
+
try {
|
|
35
|
+
process.chdir(home);
|
|
36
|
+
process.env['HOME'] = home;
|
|
37
|
+
process.env['CRTR_HOME'] = canvasHome;
|
|
38
|
+
process.env['CRTR_PIDFILE'] = join(canvasHome, 'crtrd.pid');
|
|
39
|
+
process.env['CRTR_RUNTIME_HOME'] = join(root, 'runtime');
|
|
40
|
+
process.env['XDG_CACHE_HOME'] = join(root, 'cache');
|
|
41
|
+
process.env['XDG_CONFIG_HOME'] = join(root, 'config');
|
|
42
|
+
process.env['XDG_DATA_HOME'] = join(root, 'data');
|
|
43
|
+
process.env['PI_CODING_AGENT_DIR'] = join(home, '.pi', 'agent');
|
|
44
|
+
closeDb();
|
|
45
|
+
resetEventSourceForTests();
|
|
46
|
+
resetFleetForTests();
|
|
47
|
+
await assert.rejects(() => runDaemon(), (error) => error.code === 'EADDRINUSE', 'the mandatory API socket bind must reject startup');
|
|
48
|
+
const pidfile = process.env['CRTR_PIDFILE'];
|
|
49
|
+
assert.equal(existsSync(pidfile), false, 'failed readiness removes the pidfile mirror');
|
|
50
|
+
assert.equal(existsSync(socketPath), true, 'the pre-existing directory was never mistaken for a serving socket');
|
|
51
|
+
assert.match(readFileSync(join(canvasHome, 'crtrd.log'), 'utf8'), /api\.server\.failed/, 'the bind failure is recorded for cold-start diagnostics');
|
|
52
|
+
writeFileSync(pidfile, String(process.pid));
|
|
53
|
+
assert.deepEqual(await spawnDaemon(), {
|
|
54
|
+
started: false,
|
|
55
|
+
existing_pid: process.pid,
|
|
56
|
+
running: true,
|
|
57
|
+
serving: false,
|
|
58
|
+
next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
|
|
59
|
+
}, 'start distinguishes a live non-serving pidfile owner from a serving daemon');
|
|
60
|
+
rmSync(pidfile, { force: true });
|
|
61
|
+
const retryClaim = claimDaemonOwnership('post-api-bind-failure');
|
|
62
|
+
assert.equal(retryClaim.claimed, true, 'failed readiness releases the authoritative ownership claim');
|
|
63
|
+
if (retryClaim.claimed) {
|
|
64
|
+
assert.equal(releaseDaemonOwnership(retryClaim.claim).released, true, 'the test-only retry claim releases cleanly');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
closeDb();
|
|
69
|
+
resetEventSourceForTests();
|
|
70
|
+
resetFleetForTests();
|
|
71
|
+
process.chdir(previousCwd);
|
|
72
|
+
for (const key of isolatedEnvKeys) {
|
|
73
|
+
const previous = previousEnv.get(key);
|
|
74
|
+
if (previous === undefined)
|
|
75
|
+
delete process.env[key];
|
|
76
|
+
else
|
|
77
|
+
process.env[key] = previous;
|
|
78
|
+
}
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { createNode, getNode, openDb } from '../../../core/canvas/index.js';
|
|
6
|
+
import { armCron, getCron } from '../../../core/canvas/crons.js';
|
|
6
7
|
import { closeDb } from '../../../core/canvas/db.js';
|
|
7
8
|
import { viewSocketPath } from '../../../core/canvas/paths.js';
|
|
8
9
|
import { readInboxSince } from '../../../core/feed/inbox.js';
|
|
@@ -98,6 +99,43 @@ function assertReopened(id) {
|
|
|
98
99
|
assert.equal(meta?.lifecycle, 'resident');
|
|
99
100
|
assert.equal(meta?.final_report, null);
|
|
100
101
|
}
|
|
102
|
+
function armDeadline(id) {
|
|
103
|
+
armCron({
|
|
104
|
+
cron_id: `${id}-deadline`,
|
|
105
|
+
name: `${id} deadline`,
|
|
106
|
+
command: 'echo stale deadline',
|
|
107
|
+
fire_at: new Date(Date.now() + 60_000).toISOString(),
|
|
108
|
+
recur: null,
|
|
109
|
+
tz: null,
|
|
110
|
+
expires_at: null,
|
|
111
|
+
anchor_node: id,
|
|
112
|
+
cancel_on_wake: true,
|
|
113
|
+
created_by: null,
|
|
114
|
+
cwd: home,
|
|
115
|
+
env_json: null,
|
|
116
|
+
profile: null,
|
|
117
|
+
scope: 'profile',
|
|
118
|
+
run_timeout_s: 30,
|
|
119
|
+
overlap: 'skip',
|
|
120
|
+
on_output: 'silent',
|
|
121
|
+
sink: '{}',
|
|
122
|
+
tier: 'normal',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
test('live interactive delivery clears the target deadline', async () => {
|
|
126
|
+
const id = 'live-deadline';
|
|
127
|
+
createNode(node(id, 'terminal', 'active', process.pid));
|
|
128
|
+
armDeadline(id);
|
|
129
|
+
const broker = await startLiveBroker(id);
|
|
130
|
+
try {
|
|
131
|
+
const result = await sendMessage(id, { body: 'wake before deadline', delivery: 'interactive' });
|
|
132
|
+
assert.equal(result.body.delivered_via, 'engine');
|
|
133
|
+
assert.equal(getCron(`${id}-deadline`), null, 'the accepted engine delivery won the deadline race');
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await broker.close();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
101
139
|
test('reopen makes live terminal, finalized terminal, and parked targets resident and unlatched before delivery', async () => {
|
|
102
140
|
const liveId = 'live-terminal';
|
|
103
141
|
createNode(node(liveId, 'terminal', 'active', process.pid));
|
|
@@ -117,6 +117,7 @@ test('A3: messages append inbox.jsonl; final report writes + notifies subscriber
|
|
|
117
117
|
assert.ok(res.notified.includes(A), `final push notifies the spawn-subscribed manager A (notified=${JSON.stringify(res.notified)})`);
|
|
118
118
|
assert.deepEqual(res.transitioned, { from: 'active', to: 'done' }, 'row transitioned active→done');
|
|
119
119
|
assert.equal(h.node(B)?.status, 'done', 'row status is done after final');
|
|
120
|
+
await assert.rejects(() => client.pushReport(B, { tier: 'final', delivery_tier: 'deferred', body: 'invalid tiered final' }), (error) => error instanceof ApiError && error.code === 'usage' && error.message === 'delivery_tier is only valid for update reports', 'the API rejects a delivery tier on final before it can alter final delivery');
|
|
120
121
|
});
|
|
121
122
|
// A4 — WS attach reconstructs the same settled broker snapshot as a direct
|
|
122
123
|
// ViewSocketClient; ?revive=0 on a dormant node → 409 without launching a broker
|
|
@@ -30,6 +30,7 @@ import { registerPush } from '../../../../commands/push.js';
|
|
|
30
30
|
import { registerNode } from '../../../../commands/node.js';
|
|
31
31
|
import { createNode, getNode } from '../../../../core/canvas/canvas.js';
|
|
32
32
|
import { createManagedWorktree } from '../../../../core/worktree.js';
|
|
33
|
+
import { readInboxSince } from '../../../../core/feed/inbox.js';
|
|
33
34
|
import { gitSync } from '../../../../core/git.js';
|
|
34
35
|
import { InputError } from '../../../../core/io.js';
|
|
35
36
|
// A terminal fabricated node (no broker needed) that OWNS a managed worktree —
|
|
@@ -72,11 +73,11 @@ function pushLeaf(tier) {
|
|
|
72
73
|
// Drive the leaf as the given caller node — sets CRTR_NODE_ID (client-side
|
|
73
74
|
// identity resolution stays in the CLI process, per the B-5 scope) around the
|
|
74
75
|
// run, restoring it after.
|
|
75
|
-
async function runPushAs(caller, tier, body) {
|
|
76
|
+
async function runPushAs(caller, tier, body, input = {}) {
|
|
76
77
|
const prev = process.env['CRTR_NODE_ID'];
|
|
77
78
|
process.env['CRTR_NODE_ID'] = caller;
|
|
78
79
|
try {
|
|
79
|
-
return (await pushLeaf(tier).run({ body }));
|
|
80
|
+
return (await pushLeaf(tier).run({ body, ...input }));
|
|
80
81
|
}
|
|
81
82
|
finally {
|
|
82
83
|
if (prev === undefined)
|
|
@@ -151,6 +152,16 @@ test('`crtr push final` from a child writes a report, notifies the subscribed pa
|
|
|
151
152
|
// — the re-homed guard only engages for a managed-worktree owner.
|
|
152
153
|
assert.equal('worktree_auto_dropped' in out, false, 'a normal node final is unaffected by the worktree guard');
|
|
153
154
|
});
|
|
155
|
+
test('`crtr push update --tier deferred` fans a deferred pointer while preserving an update report', async () => {
|
|
156
|
+
const parent = h.spawnRoot('leaf-api-parity deferred parent');
|
|
157
|
+
const child = (await client.createNode({ kind: 'developer', parent, prompt: 'deferred worker' })).node_id;
|
|
158
|
+
await h.awaitBoot(child);
|
|
159
|
+
const out = await runPushAs(child, 'update', 'deferred progress', { tier: 'deferred' });
|
|
160
|
+
assert.ok(out['notified'].includes(parent), 'the subscribed parent receives the report pointer');
|
|
161
|
+
const entry = readInboxSince(parent).find((candidate) => candidate.from === child && candidate.label === 'deferred progress');
|
|
162
|
+
assert.equal(entry?.kind, 'update', 'the stored report remains an update');
|
|
163
|
+
assert.equal(entry?.tier, 'deferred', 'the requested delivery tier reaches the parent');
|
|
164
|
+
});
|
|
154
165
|
// Re-homed worktree guard/auto-drop (ambiguity-2 follow-up): the pre-B-5 CLI
|
|
155
166
|
// `push final` worktree behavior now lives SERVER-side in handlers/reports.ts,
|
|
156
167
|
// exercised end-to-end through the real push.ts leaf.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { appendInbox } from '../../../core/feed/inbox.js';
|
|
5
5
|
import { contextDir, isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
6
6
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
7
|
+
import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
|
|
7
8
|
import { isBrokerLive } from '../../../core/runtime/model-swap.js';
|
|
8
9
|
import { assertNotFinalized } from '../../../core/runtime/reopen.js';
|
|
9
10
|
import { reviveNode } from '../../../core/runtime/revive.js';
|
|
@@ -74,6 +75,7 @@ async function handleStop(ctx) {
|
|
|
74
75
|
label: body.split('\n')[0].slice(0, 120),
|
|
75
76
|
data: { body },
|
|
76
77
|
});
|
|
78
|
+
cancelCronsOnWake(nodeId);
|
|
77
79
|
const node = getNode(nodeId);
|
|
78
80
|
if (node !== null && !isBrokerLive(node)) {
|
|
79
81
|
try {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Broker-originated canvas operations. The broker sends Pi lifecycle facts;
|
|
2
2
|
// crtrd validates and commits their durable effects while the handler stays
|
|
3
3
|
// strictly canvas-write-only — no broker launch, kill, or view.sock round trip.
|
|
4
|
-
import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
|
|
4
|
+
import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, recordSettledTurn, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
|
|
5
|
+
import { isPidAlive } from '../../../core/canvas/pid.js';
|
|
5
6
|
import { editorLabel } from '../../../core/canvas/labels.js';
|
|
6
7
|
import { assembleNodeSubject } from '../../../core/substrate/subject.js';
|
|
7
8
|
import { withCanvasWrite } from '../../../core/canvas/db.js';
|
|
@@ -13,7 +14,7 @@ import { focusOf, tearDownNode } from '../../../core/runtime/placement.js';
|
|
|
13
14
|
import { hasAttachedBrokerViewers } from '../../../core/runtime/broker/client-registry.js';
|
|
14
15
|
import { handleNewSession } from '../../../core/runtime/reset.js';
|
|
15
16
|
import { evaluateStop } from '../../../core/runtime/stop-guard.js';
|
|
16
|
-
import { clearFault } from '../../../core/runtime/fault.js';
|
|
17
|
+
import { clearFault, recordFault } from '../../../core/runtime/fault.js';
|
|
17
18
|
import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
|
|
18
19
|
import { completeCompanionRequestedSubmit } from '../../review/finish.js';
|
|
19
20
|
import { consumePendingPark } from '../../park-pending.js';
|
|
@@ -187,6 +188,25 @@ function handleSessionBound(ctx) {
|
|
|
187
188
|
const sessionFile = request.sessionFile === null ? null : canonicalSessionFile(request.sessionFile);
|
|
188
189
|
const result = withCanvasWrite(() => {
|
|
189
190
|
const before = requireNode(nodeId);
|
|
191
|
+
// A transcript is one broker's identity. Check ownership before every
|
|
192
|
+
// session-bound mutation: a live owner wins, and the claimant must stop
|
|
193
|
+
// rather than overwrite its own process-scoped identity onto that file.
|
|
194
|
+
const owner = sessionFile === null ? null : findNodeBySessionFile(sessionFile);
|
|
195
|
+
if (owner !== null && owner.node_id !== nodeId) {
|
|
196
|
+
if (isPidAlive(owner.pi_pid)) {
|
|
197
|
+
recordFault(nodeId, {
|
|
198
|
+
link: 'daemon→node',
|
|
199
|
+
op: 'session-file-ownership',
|
|
200
|
+
kind: 'protocol',
|
|
201
|
+
retry: { disposition: 'fatal' },
|
|
202
|
+
message: `refusing to bind ${sessionFile}: it is owned by live node ${owner.node_id}`,
|
|
203
|
+
});
|
|
204
|
+
return { action: 'shutdown' };
|
|
205
|
+
}
|
|
206
|
+
// The prior owner is dead, so transfer the durable coordinate before the
|
|
207
|
+
// claimant writes it; the UNIQUE projection and both meta files agree.
|
|
208
|
+
updateNode(owner.node_id, { pi_session_id: null, pi_session_file: null });
|
|
209
|
+
}
|
|
190
210
|
// The broker reports the markers present in Pi's current branch; crtrd owns
|
|
191
211
|
// the review binding and decides whether that branch belongs to this node.
|
|
192
212
|
const reviewId = before.review_binding?.review_id;
|
|
@@ -247,6 +267,7 @@ function settleBroker(nodeId, request) {
|
|
|
247
267
|
return { action: 'stay_dormant' };
|
|
248
268
|
throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
|
|
249
269
|
}
|
|
270
|
+
recordSettledTurn(nodeId, new Date().toISOString());
|
|
250
271
|
if (request.stopReason === 'quit') {
|
|
251
272
|
if ((node.status === 'active' || node.status === 'idle') && node.intent === null) {
|
|
252
273
|
transition(nodeId, 'finish');
|
|
@@ -37,7 +37,7 @@ function handleCreateBridge(ctx) {
|
|
|
37
37
|
throw new ApiError(400, 'invalid_request', 'bridge `parent` must be a node id or null');
|
|
38
38
|
}
|
|
39
39
|
try {
|
|
40
|
-
const node = spawnNode({ kind: 'human', parent, cwd, name, lifecycle: 'terminal' });
|
|
40
|
+
const node = spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
|
|
41
41
|
return { status: 200, body: { node_id: node.node_id, name: node.name } };
|
|
42
42
|
}
|
|
43
43
|
catch (error) {
|
|
@@ -8,6 +8,7 @@ import { isBrokerLive } from '../../../core/runtime/model-swap.js';
|
|
|
8
8
|
import { deliverLive, interruptLive } from '../../../core/runtime/interactive-deliver.js';
|
|
9
9
|
import { isProfilePaused } from '../../../core/profiles/manifest.js';
|
|
10
10
|
import { getNode, withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
|
|
11
|
+
import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
|
|
11
12
|
import { assertNotFinalized, assertFinalizedForReopen, commitReopen, commitReopenResident, } from '../../../core/runtime/reopen.js';
|
|
12
13
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
13
14
|
import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
@@ -249,6 +250,7 @@ async function handleMessage(ctx) {
|
|
|
249
250
|
if (hasSidecar)
|
|
250
251
|
appendSituationalContext(id, sidecar);
|
|
251
252
|
await deliverLive(id, req.body, [...(hasSidecar ? [sidecar] : []), ...oneShotCards]);
|
|
253
|
+
cancelCronsOnWake(id);
|
|
252
254
|
const body = {
|
|
253
255
|
node_id: id,
|
|
254
256
|
delivered: true,
|
|
@@ -323,11 +325,17 @@ async function handleMessage(ctx) {
|
|
|
323
325
|
return deliverImmediate();
|
|
324
326
|
};
|
|
325
327
|
const entry = deliver();
|
|
328
|
+
// Deferred mail normally waits for a natural cycle, but a frozen row already
|
|
329
|
+
// owes a thaw; consume its deadline when that durable future wake arrives.
|
|
330
|
+
if (tier !== 'deferred' || meta.frozen_at !== null)
|
|
331
|
+
cancelCronsOnWake(id);
|
|
326
332
|
// A wake-capable tier revives a dormant target so its inbox-watcher delivers
|
|
327
|
-
// this; deferred never wakes — it rides the target's next natural cycle.
|
|
333
|
+
// this; deferred never wakes — it rides the target's next natural cycle. The
|
|
334
|
+
// APPENDED entry's tier decides, because a terminal target's deferred was
|
|
335
|
+
// already raised to a wake-capable tier by the append.
|
|
328
336
|
let revived = false;
|
|
329
337
|
let frozen = false;
|
|
330
|
-
if (tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
|
|
338
|
+
if (entry.tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
|
|
331
339
|
try {
|
|
332
340
|
// The entry is already durable, so a wake denied for capacity is only
|
|
333
341
|
// delayed: the row freezes and the tick relaunches it — at which point
|
|
@@ -346,6 +354,7 @@ async function handleMessage(ctx) {
|
|
|
346
354
|
revived,
|
|
347
355
|
...(frozen ? { not_revived_reason: 'capacity_frozen' } : {}),
|
|
348
356
|
delivered_at: entry.ts,
|
|
357
|
+
...(entry.tier === tier ? {} : { delivered_tier: entry.tier }),
|
|
349
358
|
...(req.delivery === 'interactive' ? { delivered_via: 'inbox' } : {}),
|
|
350
359
|
};
|
|
351
360
|
return { status: 201, body };
|
|
@@ -102,6 +102,9 @@ function parseCreateBody(body) {
|
|
|
102
102
|
if (b['description'] !== undefined && (typeof b['description'] !== 'string' || b['description'].trim() === '')) {
|
|
103
103
|
throw usage('description must be a non-empty string when provided');
|
|
104
104
|
}
|
|
105
|
+
if (b['creator'] !== undefined && (typeof b['creator'] !== 'string' || b['creator'].trim() === '')) {
|
|
106
|
+
throw usage('creator must be a non-empty node id when provided');
|
|
107
|
+
}
|
|
105
108
|
const rootLifecycle = b['root_lifecycle'];
|
|
106
109
|
if (rootLifecycle !== undefined &&
|
|
107
110
|
rootLifecycle !== 'resident' &&
|
|
@@ -125,6 +128,8 @@ function buildRecipe(req, parent, cwd) {
|
|
|
125
128
|
recipe.description = req.description;
|
|
126
129
|
if (parent !== undefined)
|
|
127
130
|
recipe.parent = parent;
|
|
131
|
+
if (req.creator !== undefined)
|
|
132
|
+
recipe.creator = req.creator;
|
|
128
133
|
if (req.root === true)
|
|
129
134
|
recipe.root = true;
|
|
130
135
|
if (req.root_lifecycle !== undefined) {
|
|
@@ -174,6 +179,7 @@ function warmRequestFor(req, cwd, profileId) {
|
|
|
174
179
|
model: req.model ?? null,
|
|
175
180
|
situationalContext: req.situational_context ?? null,
|
|
176
181
|
name: req.name ?? null,
|
|
182
|
+
creator: req.creator ?? null,
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
185
|
export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
|
|
@@ -508,7 +514,13 @@ function handleClose(ctx) {
|
|
|
508
514
|
// `finish` selects the root disposition: finalize to `done` (browse `x`) vs the
|
|
509
515
|
// default cancel.
|
|
510
516
|
const body = ctx.body;
|
|
511
|
-
|
|
517
|
+
if (body?.caller_pid !== undefined && (!Number.isInteger(body.caller_pid) || body.caller_pid <= 0)) {
|
|
518
|
+
throw usage('caller_pid must be a positive integer when provided');
|
|
519
|
+
}
|
|
520
|
+
const result = closeNode(id, {
|
|
521
|
+
rootEvent: body?.finish === true ? 'finish' : 'cancel',
|
|
522
|
+
callerPid: body?.caller_pid,
|
|
523
|
+
});
|
|
512
524
|
return { status: 200, body: toCloseResultDTO(result) };
|
|
513
525
|
}
|
|
514
526
|
async function handleRecycle(ctx) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Report handlers (spec §6.4, §7.4). `POST /nodes/{id}/reports` is the single
|
|
2
|
-
// push route —
|
|
3
|
-
//
|
|
2
|
+
// push route — `tier` is the report kind and update-only `delivery_tier` sets
|
|
3
|
+
// subscriber delivery urgency; `final` drives the server-side lifecycle
|
|
4
|
+
// transition inside feed `push()` (the sanctioned path). `GET
|
|
4
5
|
// /nodes/{id}/reports` lists the node's stored report files.
|
|
5
6
|
//
|
|
6
7
|
// `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
|
|
@@ -24,14 +25,21 @@ function parsePushBody(body) {
|
|
|
24
25
|
throw usage('a push report requires a JSON body { tier, body }');
|
|
25
26
|
}
|
|
26
27
|
const tier = body.tier;
|
|
28
|
+
const deliveryTier = body.delivery_tier;
|
|
27
29
|
const text = body.body;
|
|
28
30
|
if (tier !== 'update' && tier !== 'urgent' && tier !== 'final') {
|
|
29
31
|
throw usage(`invalid report tier: ${String(tier)} (expected update|urgent|final)`);
|
|
30
32
|
}
|
|
33
|
+
if (deliveryTier !== undefined && deliveryTier !== 'deferred' && deliveryTier !== 'normal' && deliveryTier !== 'urgent') {
|
|
34
|
+
throw usage(`invalid delivery_tier: ${String(deliveryTier)} (expected deferred|normal|urgent)`);
|
|
35
|
+
}
|
|
36
|
+
if (tier === 'final' && deliveryTier !== undefined) {
|
|
37
|
+
throw usage('delivery_tier is only valid for update reports');
|
|
38
|
+
}
|
|
31
39
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
32
40
|
throw usage('a push report requires a non-empty body');
|
|
33
41
|
}
|
|
34
|
-
return { tier, body: text };
|
|
42
|
+
return { tier, ...(deliveryTier === undefined ? {} : { delivery_tier: deliveryTier }), body: text };
|
|
35
43
|
}
|
|
36
44
|
async function handlePush(ctx) {
|
|
37
45
|
const id = ctx.params['id'];
|
|
@@ -68,7 +76,7 @@ async function handlePush(ctx) {
|
|
|
68
76
|
}
|
|
69
77
|
autoDroppedPath = dropped.worktree_path;
|
|
70
78
|
}
|
|
71
|
-
const result = await pushOrRethrow(id, req
|
|
79
|
+
const result = await pushOrRethrow(id, req);
|
|
72
80
|
const transitioned = req.tier === 'final' && before !== undefined
|
|
73
81
|
? { from: before, to: getNode(id)?.status ?? 'done' }
|
|
74
82
|
: undefined;
|
|
@@ -83,9 +91,9 @@ async function handlePush(ctx) {
|
|
|
83
91
|
* survives (a bare 409 would otherwise collapse to the generic `conflict`
|
|
84
92
|
* code). `unknown_node` is unreachable here (`requireNode` guards above), so
|
|
85
93
|
* it falls through as the generic 404. */
|
|
86
|
-
async function pushOrRethrow(id,
|
|
94
|
+
async function pushOrRethrow(id, req) {
|
|
87
95
|
try {
|
|
88
|
-
return await push(id, { kind: tier, body });
|
|
96
|
+
return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }) });
|
|
89
97
|
}
|
|
90
98
|
catch (err) {
|
|
91
99
|
if (err instanceof FinalizationError && err.code === 'already_finalized') {
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -119,6 +119,7 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
119
119
|
pane: meta.pane ?? null,
|
|
120
120
|
final_report: meta.final_report ?? null,
|
|
121
121
|
finalized_at: meta.finalized_at ?? null,
|
|
122
|
+
creator: meta.creator ?? null,
|
|
122
123
|
edges: toNodeEdgesDTO(meta, edges),
|
|
123
124
|
paths: toNodePathsDTO(meta.node_id),
|
|
124
125
|
};
|
|
@@ -4,20 +4,17 @@ export interface CreateApiServerOptions {
|
|
|
4
4
|
tcp?: string;
|
|
5
5
|
}
|
|
6
6
|
export interface ApiServer {
|
|
7
|
+
/** Settles only after the mandatory Unix socket binds; rejects on its bind failure. */
|
|
8
|
+
ready: Promise<void>;
|
|
7
9
|
/** Tear down both listeners and unlink the socket file. Double-close safe. */
|
|
8
10
|
close(): Promise<void>;
|
|
9
11
|
}
|
|
10
|
-
/** Create and start the crtrd API server
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
* bind failure or a post-listen accept error is emitted and swallowed — it must
|
|
14
|
-
* NEVER become an unhandled 'error' that crashes the daemon.
|
|
12
|
+
/** Create and start the crtrd API server. Returns immediately with a `close()`
|
|
13
|
+
* handle and a `ready` promise for the mandatory Unix socket bind. TCP remains
|
|
14
|
+
* opt-in, and its bind or post-listen accept errors are emitted but non-fatal.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* functions (the standard way to bind one logical API to two transports). The
|
|
21
|
-
* Router and attach bridge are process-wide singletons, so both servers route
|
|
22
|
-
* into identical state. */
|
|
16
|
+
* A Node `net.Server`/`http.Server` binds exactly ONE handle, so the two
|
|
17
|
+
* listeners are separate `http.Server` instances sharing request and upgrade
|
|
18
|
+
* handlers. The Router and attach bridge are process-wide singletons, so both
|
|
19
|
+
* servers route into identical state. */
|
|
23
20
|
export declare function createApiServer(opts?: CreateApiServerOptions): ApiServer;
|