@north-light/crouter 0.3.175 → 0.3.177
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/dist/api/client.d.ts +4 -4
- package/dist/api/client.js +2 -2
- package/dist/api/dto/lifecycle.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +7 -1
- package/dist/api/dto/profiles.d.ts +3 -0
- package/dist/api/dto/reports.d.ts +1 -1
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/config.js +5 -5
- package/dist/clients/attach/input/clipboard-image.js +2 -2
- package/dist/clients/attach/input/ref-autocomplete.js +1 -1
- package/dist/clients/attach/input/titled-editor.d.ts +12 -13
- package/dist/clients/attach/input/titled-editor.js +14 -15
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -1
- package/dist/clients/attach/overlays/dialogs.js +2 -2
- package/dist/clients/attach/overlays/mcp.js +5 -12
- package/dist/clients/attach/render/chat-view.d.ts +1 -1
- package/dist/clients/attach/render/chat-view.js +2 -2
- package/dist/clients/attach/render/crtr-output.d.ts +3 -3
- package/dist/clients/attach/render/crtr-output.js +25 -13
- package/dist/clients/attach/render/group-recap.js +7 -0
- package/dist/clients/attach/session/context.js +3 -3
- package/dist/clients/attach/session/editor-inventory.js +6 -6
- package/dist/clients/attach/session/input-wiring.js +1 -1
- package/dist/clients/attach/session/teardown.js +7 -9
- package/dist/clients/attach/slash/dispatch.d.ts +1 -2
- package/dist/clients/attach/slash/dispatch.js +4 -8
- package/dist/clients/attach/viewer.js +536 -566
- package/dist/clients/inbox/controller.js +3 -2
- package/dist/clients/inbox/deck-adapter.js +3 -2
- package/dist/clients/surfaces/host.d.ts +1 -1
- package/dist/commands/api-client.d.ts +12 -13
- package/dist/commands/api-client.js +15 -16
- package/dist/commands/chord.js +1 -2
- package/dist/commands/human/queue.js +6 -5
- package/dist/commands/memory/find.js +3 -3
- package/dist/commands/node/create.js +7 -3
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node-lifecycle-revive.d.ts +1 -1
- package/dist/commands/node-lifecycle-revive.js +3 -3
- package/dist/commands/profile/list.js +3 -1
- package/dist/commands/profile/new.js +4 -1
- package/dist/commands/profile/project.js +23 -9
- package/dist/commands/profile/show.js +3 -1
- package/dist/commands/profile.js +2 -2
- package/dist/commands/push.js +1 -1
- package/dist/commands/surface/node/placement.d.ts +1 -1
- package/dist/commands/surface/node/placement.js +1 -1
- package/dist/commands/surface-edit.js +1 -4
- package/dist/commands/sys/logs.js +4 -2
- package/dist/commands/sys/panels/panel.d.ts +3 -3
- package/dist/commands/sys/panels/panel.js +3 -3
- package/dist/commands/sys/setup-core.js +1 -7
- package/dist/commands/sys/sync-deps.js +1 -3
- package/dist/commands/sys/sync-project-guidance.js +2 -11
- package/dist/commands/sys/sysprompt.js +1 -3
- package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +1 -1
- package/dist/core/__tests__/helpers/harness.d.ts +1 -1
- package/dist/core/__tests__/helpers/harness.js +1 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
- package/dist/core/canvas/boot.js +1 -1
- package/dist/core/canvas/browse/app.js +2 -2
- package/dist/core/canvas/canvas.d.ts +3 -3
- package/dist/core/canvas/canvas.js +3 -3
- package/dist/core/canvas/daemon-owner.js +1 -2
- package/dist/core/canvas/db.js +7 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/index.js +2 -2
- package/dist/core/canvas/pid.d.ts +12 -14
- package/dist/core/canvas/pid.js +16 -20
- package/dist/core/canvas/remote-transport.js +2 -2
- package/dist/core/canvas/status-glyph.d.ts +1 -1
- package/dist/core/canvas/status-glyph.js +1 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/command-manifests/manifest.js +4 -6
- package/dist/core/command-manifests/schema.js +10 -12
- package/dist/core/command-plugins/transport/exec-invoke.js +5 -7
- package/dist/core/command-plugins/transport/http-invoke.js +14 -8
- package/dist/core/command.js +3 -1
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +6 -29
- package/dist/core/events/read.js +1 -3
- package/dist/core/exclusive-lock.d.ts +14 -0
- package/dist/core/exclusive-lock.js +124 -0
- package/dist/core/fault-classifier.js +1 -3
- package/dist/core/fs-utils.d.ts +7 -0
- package/dist/core/fs-utils.js +29 -2
- package/dist/core/host-exports/export.d.ts +4 -5
- package/dist/core/host-exports/export.js +4 -5
- package/dist/core/human/claim.js +4 -2
- package/dist/core/human/convention.d.ts +0 -11
- package/dist/core/human/convention.js +1 -131
- package/dist/core/human/scan.js +5 -4
- package/dist/core/io.d.ts +23 -0
- package/dist/core/io.js +60 -0
- package/dist/core/preview-registry.d.ts +10 -5
- package/dist/core/preview-registry.js +136 -2537
- package/dist/core/profiles/manifest.d.ts +13 -2
- package/dist/core/profiles/manifest.js +58 -49
- package/dist/core/review/store.d.ts +4 -1
- package/dist/core/review/store.js +10 -0
- package/dist/core/review/types.d.ts +1 -1
- package/dist/core/review/types.js +1 -1
- package/dist/core/runtime/branded-host.js +6 -84
- package/dist/core/runtime/broker/frame-dispatch.js +4 -4
- package/dist/core/runtime/broker-cli.js +1 -1
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/broker-protocol.d.ts +9 -10
- package/dist/core/runtime/broker-protocol.js +9 -10
- package/dist/core/runtime/broker-sdk.d.ts +1 -1
- package/dist/core/runtime/broker.js +6 -6
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/host.js +14 -14
- package/dist/core/runtime/launch.d.ts +1 -1
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/lifecycle.js +16 -21
- package/dist/core/runtime/node-read.js +2 -3
- package/dist/core/runtime/package-health.js +1 -8
- package/dist/core/runtime/persona.js +2 -2
- package/dist/core/runtime/pi-vendored.d.ts +4 -2
- package/dist/core/runtime/pi-vendored.js +5 -16
- package/dist/core/runtime/promote.js +1 -1
- package/dist/core/runtime/reopen.js +2 -2
- package/dist/core/runtime/revive-all.d.ts +3 -3
- package/dist/core/runtime/revive-all.js +6 -6
- package/dist/core/runtime/revive.js +1 -1
- package/dist/core/runtime/spawn.d.ts +1 -2
- package/dist/core/runtime/spawn.js +2 -3
- package/dist/core/runtime/structured-output.js +4 -6
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/runtime/tmux-driver.d.ts +2 -2
- package/dist/core/runtime/tmux-driver.js +3 -5
- package/dist/core/runtime/tool-group-summary.js +5 -4
- package/dist/core/runtime/warm-pool.d.ts +9 -8
- package/dist/core/runtime/warm-pool.js +58 -62
- package/dist/core/scope.js +2 -9
- package/dist/core/self-update.js +3 -3
- package/dist/core/spawn.d.ts +0 -1
- package/dist/core/spawn.js +1 -4
- package/dist/core/substrate/index.d.ts +2 -1
- package/dist/core/substrate/index.js +2 -1
- package/dist/core/substrate/on-read-node.d.ts +7 -0
- package/dist/core/substrate/on-read-node.js +37 -0
- package/dist/core/substrate/on-read.d.ts +7 -5
- package/dist/core/substrate/on-read.js +16 -32
- package/dist/core/termrender/termrender.js +16 -96
- package/dist/core/wake.js +2 -2
- package/dist/core/worktree.d.ts +3 -3
- package/dist/core/worktree.js +16 -107
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/broker-ops.js +1 -2
- package/dist/daemon/api/handlers/messages.js +2 -2
- package/dist/daemon/api/handlers/nodes.js +33 -10
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/handlers/review-comments.js +5 -44
- package/dist/daemon/api/handlers/reviews.js +5 -53
- package/dist/daemon/api/handlers/validate.d.ts +23 -0
- package/dist/daemon/api/handlers/validate.js +58 -0
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/cron-run.js +7 -7
- package/dist/daemon/human/finish.js +3 -2
- package/dist/daemon/manage.d.ts +6 -6
- package/dist/daemon/manage.js +15 -16
- package/dist/daemon/review/finish.js +1 -10
- package/dist/pi-extensions/canvas-context-intro.js +1 -14
- package/dist/pi-extensions/canvas-doc-substrate.js +2 -2
- package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
- package/dist/pi-extensions/canvas-preview-result.js +60 -0
- package/dist/pi-extensions/canvas-review-boundary.js +1 -14
- package/dist/pi-extensions/truncate.d.ts +5 -0
- package/dist/pi-extensions/truncate.js +14 -0
- package/dist/shared/predicates.d.ts +2 -0
- package/dist/shared/predicates.js +4 -0
- package/dist/shared/shell-quote.d.ts +3 -0
- package/dist/shared/shell-quote.js +5 -0
- package/dist/shared/tool-groups.d.ts +4 -2
- package/dist/shared/tool-groups.js +3 -3
- package/dist/types.d.ts +8 -5
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/on-read-pure.d.ts +0 -7
- package/dist/core/substrate/on-read-pure.js +0 -272
package/dist/core/worktree.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { createHash
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { gitSync } from './git.js';
|
|
5
5
|
import { crtrHome } from './canvas/paths.js';
|
|
6
6
|
import { getNode, updateNode } from './canvas/index.js';
|
|
7
7
|
import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
|
|
8
|
+
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
9
|
+
import { shellQuote } from '../shared/shell-quote.js';
|
|
8
10
|
export class WorktreeError extends Error {
|
|
9
11
|
code;
|
|
10
12
|
next;
|
|
@@ -67,113 +69,23 @@ function checkoutPathForBranch(repoRoot, branch) {
|
|
|
67
69
|
return null;
|
|
68
70
|
}
|
|
69
71
|
const WORKTREE_LOCK_WAIT_MS = 30_000;
|
|
70
|
-
const WORKTREE_LOCK_POLL_MS = 20;
|
|
71
72
|
const WORKTREE_CLEANUP_TIMEOUT_MS = 5_000;
|
|
72
|
-
function sleepSync(ms) {
|
|
73
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
74
|
-
}
|
|
75
|
-
/** Serialize worktree mutation for one repository across crtr processes. Git's
|
|
76
|
-
* own worktree metadata lock is intentionally short-lived and rejects a second
|
|
77
|
-
* add instead of waiting, so callers take this transaction lock first. */
|
|
78
73
|
function commonGitDir(repoRoot) {
|
|
79
74
|
const commonDir = runGit(repoRoot, ['rev-parse', '--git-common-dir'], 'git_common_dir_failed', 'Inspect the repository git metadata, then retry.');
|
|
80
75
|
return realpathSync(resolve(repoRoot, commonDir));
|
|
81
76
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return false;
|
|
86
|
-
try {
|
|
87
|
-
process.kill(pid, 0);
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
return err.code !== 'ESRCH';
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
function reclaimDeadLock(lockPath, incumbent) {
|
|
95
|
-
// A hard-link claim elects exactly one reclaimer without moving lockPath.
|
|
96
|
-
// Its inode remains the stale lock even if a new owner appears later.
|
|
97
|
-
const claimPath = `${lockPath}.reclaim`;
|
|
98
|
-
try {
|
|
99
|
-
linkSync(lockPath, claimPath);
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// A crashed reclaimer can leave its claim behind. Removing that claim is
|
|
103
|
-
// safe: it names only the already-dead incumbent, and any live reclaimer
|
|
104
|
-
// then loses authority because its inode check below fails.
|
|
105
|
-
try {
|
|
106
|
-
unlinkSync(claimPath);
|
|
107
|
-
}
|
|
108
|
-
catch { /* claimant is still resolving it */ }
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const lock = statSync(lockPath);
|
|
113
|
-
const claim = statSync(claimPath);
|
|
114
|
-
if (lock.ino === claim.ino && readFileSync(claimPath, 'utf8') === incumbent && !ownerIsAlive(incumbent)) {
|
|
115
|
-
unlinkSync(lockPath);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
catch { /* owner or another reclaimer changed state; leave it alone */ }
|
|
119
|
-
finally {
|
|
120
|
-
try {
|
|
121
|
-
unlinkSync(claimPath);
|
|
122
|
-
}
|
|
123
|
-
catch { /* a later reclaimer will clear this stale claim */ }
|
|
124
|
-
}
|
|
125
|
-
}
|
|
77
|
+
/** Serialize worktree mutation for one repository across crtr processes. Git's
|
|
78
|
+
* own worktree metadata lock is intentionally short-lived and rejects a second
|
|
79
|
+
* add instead of waiting, so callers take this transaction lock first. */
|
|
126
80
|
function withRepositoryWorktreeLock(repoRoot, action) {
|
|
127
81
|
const commonDir = commonGitDir(repoRoot);
|
|
128
82
|
const lockRoot = `${crtrHome()}/worktree-locks`;
|
|
129
|
-
const lockName = createHash('sha256').update(commonDir).digest('hex');
|
|
130
|
-
const lockPath = `${lockRoot}/${lockName}`;
|
|
131
|
-
const owner = `${process.pid}:${randomUUID()}`;
|
|
132
|
-
const tempPath = `${lockPath}.${owner}.tmp`;
|
|
133
83
|
mkdirSync(lockRoot, { recursive: true });
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
linkSync(tempPath, lockPath);
|
|
141
|
-
unlinkSync(tempPath);
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
try {
|
|
146
|
-
unlinkSync(tempPath);
|
|
147
|
-
}
|
|
148
|
-
catch { /* process crash leaves harmless temp */ }
|
|
149
|
-
if (err.code !== 'EEXIST')
|
|
150
|
-
throw err;
|
|
151
|
-
let incumbent;
|
|
152
|
-
try {
|
|
153
|
-
incumbent = readFileSync(lockPath, 'utf8');
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (!ownerIsAlive(incumbent))
|
|
159
|
-
reclaimDeadLock(lockPath, incumbent);
|
|
160
|
-
if (Date.now() >= deadline) {
|
|
161
|
-
throw new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.');
|
|
162
|
-
}
|
|
163
|
-
sleepSync(WORKTREE_LOCK_POLL_MS);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
return action();
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
// Token-checking release cannot unlock a replacement owner's lock.
|
|
171
|
-
try {
|
|
172
|
-
if (readFileSync(lockPath, 'utf8') === owner)
|
|
173
|
-
unlinkSync(lockPath);
|
|
174
|
-
}
|
|
175
|
-
catch { /* already reclaimed after a process-level failure */ }
|
|
176
|
-
}
|
|
84
|
+
const lockPath = `${lockRoot}/${createHash('sha256').update(commonDir).digest('hex')}`;
|
|
85
|
+
return withExclusiveDirectoryLock(lockPath, action, {
|
|
86
|
+
timeoutMs: WORKTREE_LOCK_WAIT_MS,
|
|
87
|
+
timeoutError: () => new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.'),
|
|
88
|
+
});
|
|
177
89
|
}
|
|
178
90
|
function registeredWorktree(repoRoot, path, branch) {
|
|
179
91
|
const list = gitSync(['worktree', 'list', '--porcelain'], repoRoot);
|
|
@@ -226,7 +138,7 @@ function rollbackManagedWorktreeLocked(wt, pathWasAbsent) {
|
|
|
226
138
|
export function rollbackManagedWorktree(wt) {
|
|
227
139
|
withRepositoryWorktreeLock(wt.repo_root, () => rollbackManagedWorktreeLocked(wt, false));
|
|
228
140
|
}
|
|
229
|
-
//
|
|
141
|
+
// Every crtr process auto-initializes a project scope's
|
|
230
142
|
// `.crouter/config.json` (`ensureProjectScope` → `ensureScopeInitialized`)
|
|
231
143
|
// the first time it runs against a project root that has no ancestor
|
|
232
144
|
// `.crouter/` yet — which every fresh managed worktree checkout is, unless
|
|
@@ -446,9 +358,6 @@ let worktreeCleanupRunner = defaultWorktreeCleanupRunner;
|
|
|
446
358
|
export function setWorktreeCleanupRunnerForTest(runner) {
|
|
447
359
|
worktreeCleanupRunner = runner ?? defaultWorktreeCleanupRunner;
|
|
448
360
|
}
|
|
449
|
-
function shellQuote(value) {
|
|
450
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
451
|
-
}
|
|
452
361
|
function isValidRegisteredCheckout(output, wt) {
|
|
453
362
|
let path = null;
|
|
454
363
|
let prunable = false;
|
|
@@ -696,11 +605,11 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
|
|
|
696
605
|
branch_deleted: true,
|
|
697
606
|
};
|
|
698
607
|
}
|
|
699
|
-
/** `push final`'s escape hatch
|
|
608
|
+
/** `push final`'s escape hatch: a managed worktree that has
|
|
700
609
|
* nothing to land is auto-dropped instead of blocking on an explicit
|
|
701
610
|
* `node worktree close` — one the caller may have been told NOT to run
|
|
702
|
-
* because a manager already landed the same change another way
|
|
703
|
-
* that never had anything to close in the first place (
|
|
611
|
+
* because a manager already landed the same change another way, or
|
|
612
|
+
* that never had anything to close in the first place (a read-only
|
|
704
613
|
* review node). Returns null and changes nothing when the worktree is not
|
|
705
614
|
* PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
|
|
706
615
|
* concurrent mutation invalidates that proof before the destructive delete
|
|
@@ -49,7 +49,7 @@ function refuseUpgrade(socket, status, statusText, code, message) {
|
|
|
49
49
|
`connection: close\r\n\r\n` +
|
|
50
50
|
body;
|
|
51
51
|
// `end(payload)` flushes the full body THEN sends FIN — a bare
|
|
52
|
-
// `write`+`destroy` can truncate the body for a slow remote peer
|
|
52
|
+
// `write`+`destroy` can truncate the body for a slow remote peer.
|
|
53
53
|
try {
|
|
54
54
|
socket.end(payload);
|
|
55
55
|
}
|
|
@@ -254,8 +254,7 @@ function settleBroker(nodeId, request) {
|
|
|
254
254
|
return node.intent === 'refresh' ? { action: 'shutdown' } : { action: 'stay_dormant' };
|
|
255
255
|
}
|
|
256
256
|
const signals = { pushedFinal: request.pushedFinal, askedHuman: request.askedHuman };
|
|
257
|
-
//
|
|
258
|
-
// stop guard. They are terminal broker consequences, not Pi decisions.
|
|
257
|
+
// Two current-state outcomes: terminal broker consequences, not Pi decisions.
|
|
259
258
|
if (signals.pushedFinal || node.status === 'done') {
|
|
260
259
|
clearFault(nodeId);
|
|
261
260
|
if (focusOf(nodeId) !== null)
|
|
@@ -86,7 +86,7 @@ function parseSchema(raw) {
|
|
|
86
86
|
function label(text) {
|
|
87
87
|
return text.split('\n')[0].slice(0, 120);
|
|
88
88
|
}
|
|
89
|
-
/** The
|
|
89
|
+
/** The fail-loud rejection for a deferred immediate message aimed at a
|
|
90
90
|
* target with no natural cycle ahead of it (done/canceled/finalized). Thrown as
|
|
91
91
|
* an InputError so `toErrorBody` round-trips the FULL structured payload
|
|
92
92
|
* (code+message+next) into `ApiError.details`, and the CLI's shared
|
|
@@ -207,7 +207,7 @@ async function handleMessage(ctx) {
|
|
|
207
207
|
data: { situational: true, situationalOnly: true },
|
|
208
208
|
});
|
|
209
209
|
};
|
|
210
|
-
//
|
|
210
|
+
// Deferred guard: a done/canceled/finalized target has no natural
|
|
211
211
|
// cycle ahead of it, so a deferred entry would sit unseen forever — reject
|
|
212
212
|
// fail-loud BEFORE any side effect, atomically with the append (the read and
|
|
213
213
|
// the write share one canvas write-lock). Non-deferred tiers deliver directly.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `{id}` is the target node.
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
10
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
11
12
|
import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
|
|
12
13
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
@@ -29,6 +30,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
|
|
|
29
30
|
import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
|
|
30
31
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
31
32
|
import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
|
|
33
|
+
import { profileHome } from '../../../core/profiles/manifest.js';
|
|
32
34
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
33
35
|
import { getCron } from '../../../core/canvas/crons.js';
|
|
34
36
|
import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
|
|
@@ -85,11 +87,31 @@ function parseCreateBody(body) {
|
|
|
85
87
|
}
|
|
86
88
|
return b;
|
|
87
89
|
}
|
|
90
|
+
/** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
|
|
91
|
+
* managed child inherits its spawner's cwd (which is what keeps a worktree
|
|
92
|
+
* node's whole subtree inside that worktree); else the node runs in its
|
|
93
|
+
* profile's home. The request's own `cwd` — the caller's directory — is only
|
|
94
|
+
* the last resort, for a profile that has no home (notably root).
|
|
95
|
+
*
|
|
96
|
+
* This is the profile→cwd direction: `cwd` selected the profile a moment ago,
|
|
97
|
+
* and the profile now supplies where the node runs. Collapsing every create
|
|
98
|
+
* under a profile onto one directory is also what keeps the warm pool to one
|
|
99
|
+
* spare per profile instead of one per directory anyone happened to spawn from. */
|
|
100
|
+
function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
101
|
+
if (req.pin_cwd !== undefined && req.pin_cwd !== '')
|
|
102
|
+
return resolve(req.pin_cwd);
|
|
103
|
+
if (parent != null) {
|
|
104
|
+
const spawner = getNode(parent);
|
|
105
|
+
if (spawner !== null)
|
|
106
|
+
return spawner.cwd;
|
|
107
|
+
}
|
|
108
|
+
return profileHome(profileId) ?? contextCwd;
|
|
109
|
+
}
|
|
88
110
|
/** Build the immediate spawn recipe. */
|
|
89
|
-
function buildRecipe(req, parent) {
|
|
111
|
+
function buildRecipe(req, parent, cwd) {
|
|
90
112
|
const recipe = {
|
|
91
113
|
kind: req.kind,
|
|
92
|
-
cwd
|
|
114
|
+
cwd,
|
|
93
115
|
prompt: req.prompt ?? '',
|
|
94
116
|
};
|
|
95
117
|
if (req.mode !== undefined)
|
|
@@ -122,7 +144,7 @@ function buildRecipe(req, parent) {
|
|
|
122
144
|
* forked conversation, a managed worktree, a caller-pinned node id, a spine
|
|
123
145
|
* parent — disqualifies it. `kind`, `mode`, `model`, `name`, `situational_context`
|
|
124
146
|
* and `output_schema` are all applied at claim time and so do not. */
|
|
125
|
-
function warmRequestFor(req) {
|
|
147
|
+
function warmRequestFor(req, cwd, profileId) {
|
|
126
148
|
if (!warmPoolEnabled())
|
|
127
149
|
return null;
|
|
128
150
|
if (req.prefer_warm !== true)
|
|
@@ -136,8 +158,8 @@ function warmRequestFor(req) {
|
|
|
136
158
|
return {
|
|
137
159
|
kind: req.kind,
|
|
138
160
|
mode: (req.mode ?? 'base'),
|
|
139
|
-
cwd
|
|
140
|
-
|
|
161
|
+
cwd,
|
|
162
|
+
profileId,
|
|
141
163
|
model: req.model ?? null,
|
|
142
164
|
situationalContext: req.situational_context ?? null,
|
|
143
165
|
name: req.name ?? null,
|
|
@@ -184,8 +206,9 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
184
206
|
// Resolve and validate the exact launch recipe before the warm pool can claim
|
|
185
207
|
// a spare (or the cold path can create a worktree/node). The registry belongs
|
|
186
208
|
// to this daemon, never to the possibly-remote CLI caller.
|
|
187
|
-
const
|
|
188
|
-
const profileId = await resolveProfileId(req.profile, parent ?? null,
|
|
209
|
+
const contextCwd = req.cwd ?? process.cwd();
|
|
210
|
+
const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
|
|
211
|
+
const cwd = resolveNodeCwd(req, parent, profileId, contextCwd);
|
|
189
212
|
const launch = buildLaunchSpec(req.kind, (req.mode ?? 'base'), {
|
|
190
213
|
lifecycle: req.root === true ? 'resident' : 'terminal',
|
|
191
214
|
hasManager: req.root !== true,
|
|
@@ -197,7 +220,7 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
197
220
|
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
198
221
|
// engine boot. A miss is not a failure — it falls through to the cold
|
|
199
222
|
// spawn below and mints the spare that serves the NEXT create. ---
|
|
200
|
-
const warm = warmRequestFor(req);
|
|
223
|
+
const warm = warmRequestFor(req, cwd, profileId);
|
|
201
224
|
if (warm !== null) {
|
|
202
225
|
const claimed = await claimWarmNode(warm);
|
|
203
226
|
refillWarmPool(warm);
|
|
@@ -207,7 +230,7 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
207
230
|
return { status: 201, body: detailOf(claimed) };
|
|
208
231
|
}
|
|
209
232
|
}
|
|
210
|
-
const recipe = buildRecipe(req, parent);
|
|
233
|
+
const recipe = buildRecipe(req, parent, cwd);
|
|
211
234
|
recipe.readiness = req.root === true ? 'await' : 'launch';
|
|
212
235
|
const result = await spawnChild(recipe);
|
|
213
236
|
if (schema !== null)
|
|
@@ -334,7 +357,7 @@ function handleRevive(ctx) {
|
|
|
334
357
|
const body = ctx.body;
|
|
335
358
|
const resume = body?.resume ?? true;
|
|
336
359
|
const reopen = body?.reopen ?? false;
|
|
337
|
-
//
|
|
360
|
+
// Reopen gate, enforced server-side (the latch clear is a canvas write the CLI
|
|
338
361
|
// caller cannot reach): --reopen CAS-clears a spent `final_report`; the
|
|
339
362
|
// no-reopen path refuses a still-latched node (`node_finalized`). reviveNode
|
|
340
363
|
// keeps its own belt-and-suspenders latch guard under the launch lock.
|
|
@@ -42,7 +42,7 @@ async function handlePush(ctx) {
|
|
|
42
42
|
const before = req.tier === 'final' ? getNode(id)?.status : undefined;
|
|
43
43
|
// `final` with an OPEN managed worktree, BEFORE the push/transition (spec
|
|
44
44
|
// §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
|
|
45
|
-
// worktree is auto-dropped
|
|
45
|
+
// worktree is auto-dropped so a read-only or already-landed node
|
|
46
46
|
// can finish without an explicit `node worktree close`; any real unlanded
|
|
47
47
|
// work instead blocks with the parity `open_managed_worktree` error,
|
|
48
48
|
// round-tripped via CrtrError.details so the CLI renders the same InputError
|
|
@@ -2,45 +2,23 @@
|
|
|
2
2
|
// input and derive authorship; review/comment state remains in the review
|
|
3
3
|
// services and comment delivery remains a post-commit best effort.
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { ApiError } from '../../../api/index.js';
|
|
6
5
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
7
6
|
import { isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
8
7
|
import { createComment, deleteComment, editComment, getComment, listCommentEvents, listComments, listReviewEvents, reopenComment, resolveComment, } from '../../../core/review/comments.js';
|
|
9
8
|
import { withCanvasWrite } from '../../../core/canvas/db.js';
|
|
10
9
|
import { captureAnchorText, requireCurrentDocumentLocked } from '../../../core/review/document.js';
|
|
11
10
|
import { applyRangeBatch } from '../../../core/review/remap.js';
|
|
12
|
-
import {
|
|
11
|
+
import { getReviewByCompanion, requireVisibleReview } from '../../../core/review/store.js';
|
|
13
12
|
import { ReviewOperationError } from '../../../core/review/types.js';
|
|
14
13
|
import { notifyComment } from '../../review/comment-notify.js';
|
|
15
14
|
import { toReviewCommentDTO, toReviewCommentEventDTO } from '../map.js';
|
|
15
|
+
import { invalidRequest as rejectRequest, isRecord, objectBody, requireParam, requiredString, } from './validate.js';
|
|
16
16
|
const COMMENT_ID = /^[a-f0-9]{32}$/;
|
|
17
17
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
18
18
|
const COMMENT_STATUSES = new Set(['open', 'resolved', 'deleted', 'all']);
|
|
19
19
|
const DELIVER_MODES = new Set(['wake', 'quiet']);
|
|
20
20
|
function invalidRequest(violations) {
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
function isRecord(value) {
|
|
24
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
-
}
|
|
26
|
-
function objectBody(body, allowed, violations) {
|
|
27
|
-
if (!isRecord(body)) {
|
|
28
|
-
violations.push({ field: 'body', problem: 'must be an object', received: body });
|
|
29
|
-
return {};
|
|
30
|
-
}
|
|
31
|
-
for (const key of Object.keys(body)) {
|
|
32
|
-
if (!allowed.includes(key))
|
|
33
|
-
violations.push({ field: key, problem: 'is not allowed', received: body[key] });
|
|
34
|
-
}
|
|
35
|
-
return body;
|
|
36
|
-
}
|
|
37
|
-
function requiredString(body, field, violations) {
|
|
38
|
-
const value = body[field];
|
|
39
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
40
|
-
violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
return value;
|
|
21
|
+
rejectRequest('review comment request', violations);
|
|
44
22
|
}
|
|
45
23
|
function optionalSha256(body, field, violations) {
|
|
46
24
|
const value = body[field];
|
|
@@ -135,27 +113,10 @@ function anchorFrom(value, field, violations) {
|
|
|
135
113
|
};
|
|
136
114
|
}
|
|
137
115
|
function requireReviewId(ctx, violations) {
|
|
138
|
-
|
|
139
|
-
if (reviewId === undefined || !isSafeNodeId(reviewId)) {
|
|
140
|
-
violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
return reviewId;
|
|
116
|
+
return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
|
|
144
117
|
}
|
|
145
118
|
function requireCommentId(ctx, violations) {
|
|
146
|
-
|
|
147
|
-
if (commentId === undefined || !COMMENT_ID.test(commentId)) {
|
|
148
|
-
violations.push({ field: 'comment_id', problem: 'must be a 32-character lowercase hexadecimal id', ...(commentId === undefined ? {} : { received: commentId }) });
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
return commentId;
|
|
152
|
-
}
|
|
153
|
-
function requireVisibleReview(reviewId) {
|
|
154
|
-
const review = getReview(reviewId);
|
|
155
|
-
if (review === null || review.state === 'binding') {
|
|
156
|
-
throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
|
|
157
|
-
}
|
|
158
|
-
return review;
|
|
119
|
+
return requireParam(ctx, 'comment_id', 'must be a 32-character lowercase hexadecimal id', (v) => COMMENT_ID.test(v), violations);
|
|
159
120
|
}
|
|
160
121
|
function requireOpenReview(reviewId) {
|
|
161
122
|
const review = requireVisibleReview(reviewId);
|
|
@@ -7,70 +7,22 @@ import { isSafeNodeId, reviewResultPath } from '../../../core/canvas/paths.js';
|
|
|
7
7
|
import { responsePath, reviewPath } from '../../../core/human/convention.js';
|
|
8
8
|
import { ticketDir } from '../../../core/human/root.js';
|
|
9
9
|
import { createReview } from '../../../core/review/birth.js';
|
|
10
|
-
import { readAnchorBase,
|
|
10
|
+
import { readAnchorBase, getReviewByBridge, listReviews, requireVisibleReview } from '../../../core/review/store.js';
|
|
11
11
|
import { ReviewOperationError } from '../../../core/review/types.js';
|
|
12
12
|
import { cancelReview, submitReviewApproval } from '../../review/finish.js';
|
|
13
13
|
import { toFeedbackResultDTO, toReviewDTO } from '../map.js';
|
|
14
|
+
import { invalidRequest as rejectRequest, objectBody, optionalString, requireParam, requiredString, } from './validate.js';
|
|
14
15
|
const REVIEW_STATES = new Set(['open', 'approved', 'canceled']);
|
|
15
16
|
const CREATE_FIELDS = ['origin_kind', 'origin_node_id', 'file', 'idempotency_key', 'subtitle', 'output_path'];
|
|
16
17
|
const CANCEL_FIELDS = ['reason', 'actor'];
|
|
17
18
|
function invalidRequest(violations) {
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
function isRecord(value) {
|
|
21
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
-
}
|
|
23
|
-
function objectBody(body, allowed, violations) {
|
|
24
|
-
if (!isRecord(body)) {
|
|
25
|
-
violations.push({ field: 'body', problem: 'must be an object', received: body });
|
|
26
|
-
return {};
|
|
27
|
-
}
|
|
28
|
-
for (const key of Object.keys(body)) {
|
|
29
|
-
if (!allowed.includes(key))
|
|
30
|
-
violations.push({ field: key, problem: 'is not allowed', received: body[key] });
|
|
31
|
-
}
|
|
32
|
-
return body;
|
|
33
|
-
}
|
|
34
|
-
function requiredString(body, field, violations) {
|
|
35
|
-
const value = body[field];
|
|
36
|
-
if (typeof value !== 'string' || value === '') {
|
|
37
|
-
violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
return value;
|
|
41
|
-
}
|
|
42
|
-
function optionalString(body, field, violations) {
|
|
43
|
-
const value = body[field];
|
|
44
|
-
if (value === undefined)
|
|
45
|
-
return undefined;
|
|
46
|
-
if (typeof value !== 'string' || value === '') {
|
|
47
|
-
violations.push({ field, problem: 'must be a non-empty string', received: value });
|
|
48
|
-
return undefined;
|
|
49
|
-
}
|
|
50
|
-
return value;
|
|
19
|
+
rejectRequest('review request', violations);
|
|
51
20
|
}
|
|
52
21
|
function requireReviewId(ctx, violations) {
|
|
53
|
-
|
|
54
|
-
if (reviewId === undefined || !isSafeNodeId(reviewId)) {
|
|
55
|
-
violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
return reviewId;
|
|
22
|
+
return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
|
|
59
23
|
}
|
|
60
24
|
function requireBridgeNodeId(ctx, violations) {
|
|
61
|
-
|
|
62
|
-
if (bridgeNodeId === undefined || !isSafeNodeId(bridgeNodeId)) {
|
|
63
|
-
violations.push({ field: 'bridge_node_id', problem: 'must be a safe node id', ...(bridgeNodeId === undefined ? {} : { received: bridgeNodeId }) });
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
return bridgeNodeId;
|
|
67
|
-
}
|
|
68
|
-
function requireVisibleReview(reviewId) {
|
|
69
|
-
const review = getReview(reviewId);
|
|
70
|
-
if (review === null || review.state === 'binding') {
|
|
71
|
-
throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
|
|
72
|
-
}
|
|
73
|
-
return review;
|
|
25
|
+
return requireParam(ctx, 'bridge_node_id', 'must be a safe node id', isSafeNodeId, violations);
|
|
74
26
|
}
|
|
75
27
|
function approvedResultPath(review) {
|
|
76
28
|
if (review.origin_kind === 'inline')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isRecord } from '../../../shared/predicates.js';
|
|
2
|
+
import type { RequestContext } from '../router.js';
|
|
3
|
+
export { isRecord };
|
|
4
|
+
export type Violation = {
|
|
5
|
+
field: string;
|
|
6
|
+
problem: string;
|
|
7
|
+
received?: unknown;
|
|
8
|
+
};
|
|
9
|
+
/** Reject the request with every violation collected so far. `subject` names
|
|
10
|
+
* what was being parsed, e.g. "review comment request". */
|
|
11
|
+
export declare function invalidRequest(subject: string, violations: Violation[]): never;
|
|
12
|
+
/** Narrow a request body to an object and reject any field outside `allowed`.
|
|
13
|
+
* A non-object body yields `{}` so the caller's field reads all miss and
|
|
14
|
+
* report their own violations rather than throwing. */
|
|
15
|
+
export declare function objectBody(body: unknown, allowed: readonly string[], violations: Violation[]): Record<string, unknown>;
|
|
16
|
+
/** A required field that must be a string carrying at least one non-whitespace
|
|
17
|
+
* character — whitespace alone is not a value. */
|
|
18
|
+
export declare function requiredString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
|
|
19
|
+
/** `requiredString`'s rule when present; absence is allowed. */
|
|
20
|
+
export declare function optionalString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
|
|
21
|
+
/** A path parameter that must be present and satisfy `accepts`. `problem`
|
|
22
|
+
* describes the shape the caller failed to supply. */
|
|
23
|
+
export declare function requireParam(ctx: RequestContext, field: string, problem: string, accepts: (value: string) => boolean, violations: Violation[]): string | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared wire-request validation for the daemon's `/v1` handlers.
|
|
2
|
+
//
|
|
3
|
+
// A handler collects `Violation`s while parsing a request, then calls
|
|
4
|
+
// `invalidRequest` once so a caller sees every problem with its body in a
|
|
5
|
+
// single 400 rather than one per round trip.
|
|
6
|
+
import { ApiError } from '../../../api/index.js';
|
|
7
|
+
import { isRecord } from '../../../shared/predicates.js';
|
|
8
|
+
export { isRecord };
|
|
9
|
+
/** Reject the request with every violation collected so far. `subject` names
|
|
10
|
+
* what was being parsed, e.g. "review comment request". */
|
|
11
|
+
export function invalidRequest(subject, violations) {
|
|
12
|
+
throw new ApiError(400, 'invalid_request', `${subject} is invalid`, { violations });
|
|
13
|
+
}
|
|
14
|
+
/** Narrow a request body to an object and reject any field outside `allowed`.
|
|
15
|
+
* A non-object body yields `{}` so the caller's field reads all miss and
|
|
16
|
+
* report their own violations rather than throwing. */
|
|
17
|
+
export function objectBody(body, allowed, violations) {
|
|
18
|
+
if (!isRecord(body)) {
|
|
19
|
+
violations.push({ field: 'body', problem: 'must be an object', received: body });
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
for (const key of Object.keys(body)) {
|
|
23
|
+
if (!allowed.includes(key))
|
|
24
|
+
violations.push({ field: key, problem: 'is not allowed', received: body[key] });
|
|
25
|
+
}
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
/** A required field that must be a string carrying at least one non-whitespace
|
|
29
|
+
* character — whitespace alone is not a value. */
|
|
30
|
+
export function requiredString(body, field, violations) {
|
|
31
|
+
const value = body[field];
|
|
32
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
33
|
+
violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/** `requiredString`'s rule when present; absence is allowed. */
|
|
39
|
+
export function optionalString(body, field, violations) {
|
|
40
|
+
const value = body[field];
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
44
|
+
violations.push({ field, problem: 'must be a non-empty string', received: value });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/** A path parameter that must be present and satisfy `accepts`. `problem`
|
|
50
|
+
* describes the shape the caller failed to supply. */
|
|
51
|
+
export function requireParam(ctx, field, problem, accepts, violations) {
|
|
52
|
+
const value = ctx.params[field];
|
|
53
|
+
if (value === undefined || !accepts(value)) {
|
|
54
|
+
violations.push({ field, problem, ...(value === undefined ? {} : { received: value }) });
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
package/dist/daemon/api/map.js
CHANGED
package/dist/daemon/cron-run.js
CHANGED
|
@@ -377,8 +377,7 @@ async function disposeSettledRun(c, ctx, outOfBand, selfCanceled = false) {
|
|
|
377
377
|
}
|
|
378
378
|
/** The shared delivering tail of `always` and a CHANGED `on-change`: push
|
|
379
379
|
* stdout to the armed sink, or take the failure path when the sink is
|
|
380
|
-
* undeliverable.
|
|
381
|
-
* switch fallthrough.) */
|
|
380
|
+
* undeliverable. */
|
|
382
381
|
async function deliverOrEscalate(c, ctx, outOfBand, prefix) {
|
|
383
382
|
if (ctx.stdoutHead.trim() === '') {
|
|
384
383
|
return { delivered: `${prefix}empty stdout — nothing delivered`, paused: false };
|
|
@@ -464,8 +463,9 @@ export function recoverStaleCronLeases(now, options = {}) {
|
|
|
464
463
|
}
|
|
465
464
|
/** Wait for a SIGTERMed stale-lease run to actually die, escalating to SIGKILL
|
|
466
465
|
* if it does not — the same bound `killWithEscalation` gives a live timeout.
|
|
467
|
-
* Recovery
|
|
468
|
-
* signal-trapping command
|
|
466
|
+
* Recovery must not record the run as finished the instant it signals: a
|
|
467
|
+
* signal-trapping command can still be running and still producing side
|
|
468
|
+
* effects. */
|
|
469
469
|
async function awaitLeasedRunDeath(c) {
|
|
470
470
|
const deadline = Date.now() + CRON_KILL_GRACE_MS;
|
|
471
471
|
while (Date.now() < deadline) {
|
|
@@ -477,7 +477,7 @@ async function awaitLeasedRunDeath(c) {
|
|
|
477
477
|
}
|
|
478
478
|
/** Settle a stale lease whose process can no longer be observed: the daemon
|
|
479
479
|
* life that owned this run ended (SIGKILL, host reboot) and nothing recorded
|
|
480
|
-
* the outcome. Releasing the lease
|
|
480
|
+
* the outcome. Releasing the lease and nothing else is wrong twice.
|
|
481
481
|
*
|
|
482
482
|
* 1. The run must leave a RECORD. The run log's whole justification is "it
|
|
483
483
|
* silently stopped working Tuesday"; a hole exactly where the crash
|
|
@@ -799,8 +799,8 @@ export function executeCron(c, opts) {
|
|
|
799
799
|
}
|
|
800
800
|
catch (err) {
|
|
801
801
|
// Unparseable recurrence. This row can never be scheduled again as
|
|
802
|
-
// written — but DELETING it
|
|
803
|
-
//
|
|
802
|
+
// written — but DELETING it would destroy the user's config AND its
|
|
803
|
+
// whole run history with no record and no escalation, the
|
|
804
804
|
// one outcome the spec has no vocabulary for. Take the failure path
|
|
805
805
|
// instead: kill the just-started run and hand `close` a process error, so
|
|
806
806
|
// the row PAUSES and an escalation node arrives holding the parse error.
|
|
@@ -3,7 +3,8 @@ import { ApiError } from '../../api/index.js';
|
|
|
3
3
|
import { getNode, subscribersOf } from '../../core/canvas/canvas.js';
|
|
4
4
|
import { ticketDir } from '../../core/human/root.js';
|
|
5
5
|
import { cancelTicket, projectReviewOutput, readTicketResult, takeoverAndCompleteDeck, } from '../../core/human/tickets.js';
|
|
6
|
-
import {
|
|
6
|
+
import { responsePath } from '../../core/human/convention.js';
|
|
7
|
+
import { readJsonOrNull } from '../../core/fs-utils.js';
|
|
7
8
|
import { appendInbox } from '../../core/feed/inbox.js';
|
|
8
9
|
import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
|
|
9
10
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
@@ -67,7 +68,7 @@ export async function cancelHumanTicket(nodeId, opts) {
|
|
|
67
68
|
*/
|
|
68
69
|
export async function deliverTerminalResult(nodeId) {
|
|
69
70
|
const dir = ticketDir(nodeId);
|
|
70
|
-
const run =
|
|
71
|
+
const run = readJsonOrNull(join(dir, 'run.json'));
|
|
71
72
|
if (run?.mode === 'notify')
|
|
72
73
|
return false;
|
|
73
74
|
const result = readTicketResult(dir);
|