@crouton-kit/crouter 0.3.40 → 0.3.42
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/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +720 -747
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
- package/dist/core/hearth/providers/blaxel-home.js +0 -405
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { gitSync } from './git.js';
|
|
3
|
+
import { crtrHome } from './canvas/paths.js';
|
|
4
|
+
import { getNode, updateNode } from './canvas/index.js';
|
|
5
|
+
export class WorktreeError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
next;
|
|
8
|
+
detail;
|
|
9
|
+
constructor(code, message, next, detail) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'WorktreeError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.next = next;
|
|
14
|
+
this.detail = detail;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function runGit(cwd, args, code, next) {
|
|
18
|
+
const res = gitSync(args, cwd);
|
|
19
|
+
if (res.status !== 0) {
|
|
20
|
+
const detail = (res.stderr.trim() || res.stdout.trim()).trim();
|
|
21
|
+
throw new WorktreeError(code, `git ${args.join(' ')} failed`, next, detail);
|
|
22
|
+
}
|
|
23
|
+
return res.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
export function managedWorktreesRoot() {
|
|
26
|
+
return `${crtrHome()}/worktrees`;
|
|
27
|
+
}
|
|
28
|
+
export function managedWorktreePath(nodeId) {
|
|
29
|
+
return `${managedWorktreesRoot()}/${nodeId}`;
|
|
30
|
+
}
|
|
31
|
+
export function hasOpenManagedWorktree(node) {
|
|
32
|
+
return node?.managed_worktree?.state === 'open';
|
|
33
|
+
}
|
|
34
|
+
export function openManagedWorktreeForNode(nodeId) {
|
|
35
|
+
const node = getNode(nodeId);
|
|
36
|
+
const wt = node?.managed_worktree;
|
|
37
|
+
return wt?.state === 'open' ? wt : null;
|
|
38
|
+
}
|
|
39
|
+
function repoRootFor(cwd) {
|
|
40
|
+
return runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd pointing inside a git repository.');
|
|
41
|
+
}
|
|
42
|
+
function requireOriginMain(repoRoot) {
|
|
43
|
+
return runGit(repoRoot, ['rev-parse', 'origin/main'], 'missing_origin_main', 'Managed worktrees land by rebasing onto origin/main. Fetch/create origin/main, then retry.');
|
|
44
|
+
}
|
|
45
|
+
export function createManagedWorktree(cwd, nodeId) {
|
|
46
|
+
const repoRoot = repoRootFor(cwd);
|
|
47
|
+
const baseSha = requireOriginMain(repoRoot);
|
|
48
|
+
const path = managedWorktreePath(nodeId);
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
throw new WorktreeError('worktree_path_exists', `managed worktree path already exists: ${path}`, 'Remove the stale path or choose a fresh node id.');
|
|
51
|
+
}
|
|
52
|
+
const branch = `crtr/${nodeId}`;
|
|
53
|
+
runGit(repoRoot, ['worktree', 'add', '-b', branch, path, 'origin/main'], 'worktree_add_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
|
|
54
|
+
return {
|
|
55
|
+
state: 'open',
|
|
56
|
+
path,
|
|
57
|
+
branch,
|
|
58
|
+
repo_root: repoRoot,
|
|
59
|
+
base_ref: 'origin/main',
|
|
60
|
+
base_sha: baseSha,
|
|
61
|
+
created: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function statusPorcelain(cwd) {
|
|
65
|
+
return runGit(cwd, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
|
|
66
|
+
}
|
|
67
|
+
function currentBranch(cwd) {
|
|
68
|
+
return runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
|
|
69
|
+
}
|
|
70
|
+
function gitPath(cwd, logicalPath) {
|
|
71
|
+
return runGit(cwd, ['rev-parse', '--git-path', logicalPath], 'git_path_failed', 'Inspect the worktree manually, then retry.');
|
|
72
|
+
}
|
|
73
|
+
export function isRebaseInProgress(cwd) {
|
|
74
|
+
return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
|
|
75
|
+
}
|
|
76
|
+
export function closeManagedWorktree(nodeId) {
|
|
77
|
+
const node = getNode(nodeId);
|
|
78
|
+
if (node === null) {
|
|
79
|
+
throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Run this from a live node, or pass a real node id once that command supports it.');
|
|
80
|
+
}
|
|
81
|
+
const wt = openManagedWorktreeForNode(nodeId);
|
|
82
|
+
if (wt === null) {
|
|
83
|
+
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(wt.path)) {
|
|
86
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, 'Ask the human with `crtr human ask` before changing node state; the checkout disappeared outside crouter.');
|
|
87
|
+
}
|
|
88
|
+
if (isRebaseInProgress(wt.path)) {
|
|
89
|
+
throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`.`);
|
|
90
|
+
}
|
|
91
|
+
const dirty = statusPorcelain(wt.path);
|
|
92
|
+
if (dirty !== '') {
|
|
93
|
+
throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit the work in ${wt.path} (or ask the human with \`crtr human ask\` if you do not know whether to keep it), then rerun \`crtr worktree close\`.`);
|
|
94
|
+
}
|
|
95
|
+
const branch = currentBranch(wt.path);
|
|
96
|
+
if (branch !== wt.branch) {
|
|
97
|
+
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
98
|
+
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 human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr worktree close\`.`);
|
|
99
|
+
}
|
|
100
|
+
runGit(wt.path, ['fetch', 'origin', 'main'], 'fetch_failed', 'Network/remote state blocked landing. Ask the human with `crtr human ask` if this is not a transient git/network issue.');
|
|
101
|
+
const rebase = gitSync(['rebase', 'origin/main'], wt.path);
|
|
102
|
+
if (rebase.status !== 0) {
|
|
103
|
+
const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
|
|
104
|
+
throw new WorktreeError('rebase_failed', 'rebase onto origin/main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
|
|
105
|
+
}
|
|
106
|
+
const push = gitSync(['push', 'origin', 'HEAD:main'], wt.path);
|
|
107
|
+
if (push.status !== 0) {
|
|
108
|
+
const detail = (push.stderr.trim() || push.stdout.trim()).trim();
|
|
109
|
+
throw new WorktreeError('push_main_failed', 'could not fast-forward/push origin/main from this worktree', 'Do not force-push main. Ask the human with `crtr human ask` how to land this managed worktree.', detail);
|
|
110
|
+
}
|
|
111
|
+
// The push landing IS the point of no return: persist `state: 'closed'`
|
|
112
|
+
// IMMEDIATELY, before reading the landed sha or attempting any cleanup below.
|
|
113
|
+
// Worktree removal and branch deletion are best-effort from here on — their
|
|
114
|
+
// failure is cosmetic (a stray checkout / local ref) and must never re-strand
|
|
115
|
+
// `state: 'open'` behind a dead end that permanently blocks push-final.
|
|
116
|
+
updateNode(nodeId, { managed_worktree: { ...wt, state: 'closed', closed: new Date().toISOString() } });
|
|
117
|
+
const pushedSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'pushed_sha_failed', 'The branch landed and the node is already marked closed, but crouter could not read the landed commit SHA. Inspect the checkout manually to confirm the landed commit; no further close action is needed.');
|
|
118
|
+
const remove = gitSync(['worktree', 'remove', wt.path], wt.repo_root);
|
|
119
|
+
const worktreeRemoved = remove.status === 0;
|
|
120
|
+
if (worktreeRemoved && existsSync(wt.path)) {
|
|
121
|
+
// `git worktree remove` can leave the (now empty) mount dir behind; tidy it best-effort.
|
|
122
|
+
try {
|
|
123
|
+
rmdirSync(wt.path);
|
|
124
|
+
}
|
|
125
|
+
catch { /* non-empty or busy — leave it */ }
|
|
126
|
+
}
|
|
127
|
+
// wt.path IS the calling node's own pi-process cwd — a process can never chdir
|
|
128
|
+
// itself out of a directory that vanishes out from under it, so once removal
|
|
129
|
+
// deletes it every subsequent bash/chdir in this node fails permanently and the
|
|
130
|
+
// mandated post-close `crtr push final` can never run. Recreate it as an empty
|
|
131
|
+
// tombstone directory so the path still resolves; the branch is already landed
|
|
132
|
+
// and `state: 'closed'` already persisted above, so this is purely about keeping
|
|
133
|
+
// the node's cwd alive, not about worktree bookkeeping.
|
|
134
|
+
if (!existsSync(wt.path)) {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(wt.path, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
catch { /* best-effort; leaves cwd broken only if this also fails */ }
|
|
139
|
+
}
|
|
140
|
+
const worktreeRemoveError = worktreeRemoved
|
|
141
|
+
? undefined
|
|
142
|
+
: `local worktree checkout at ${wt.path} landed but could not be removed automatically (${(remove.stderr.trim() || remove.stdout.trim()).trim()}); run \`git -C ${wt.repo_root} worktree remove ${wt.path}\` after confirming it is safe to delete.`;
|
|
143
|
+
const branchDelete = gitSync(['branch', '-d', wt.branch], wt.repo_root);
|
|
144
|
+
const branchDeleted = branchDelete.status === 0;
|
|
145
|
+
const branchDeleteError = branchDeleted
|
|
146
|
+
? undefined
|
|
147
|
+
: `local branch ${wt.branch} landed but could not be deleted automatically; run \`git -C ${wt.repo_root} branch -d ${wt.branch}\` after confirming it is merged.`;
|
|
148
|
+
return {
|
|
149
|
+
node_id: nodeId,
|
|
150
|
+
branch: wt.branch,
|
|
151
|
+
worktree_path: wt.path,
|
|
152
|
+
pushed_sha: pushedSha,
|
|
153
|
+
worktree_removed: worktreeRemoved,
|
|
154
|
+
...(worktreeRemoveError !== undefined ? { worktree_remove_error: worktreeRemoveError } : {}),
|
|
155
|
+
branch_deleted: branchDeleted,
|
|
156
|
+
...(branchDeleteError !== undefined ? { branch_delete_error: branchDeleteError } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
package/dist/daemon/crtrd.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type NodeRow } from '../core/canvas/index.js';
|
|
1
|
+
import { type NodeRow, type NodeMeta } from '../core/canvas/index.js';
|
|
2
2
|
import { FAULT_QUIET_MS } from '../core/runtime/fault.js';
|
|
3
3
|
export { FAULT_QUIET_MS };
|
|
4
4
|
import { isPidAlive } from '../core/canvas/pid.js';
|
|
@@ -35,14 +35,15 @@ export declare function wedgeVerdict(input: {
|
|
|
35
35
|
quietForMs: number | null;
|
|
36
36
|
cpuPercent: number | null;
|
|
37
37
|
}): WedgeVerdict;
|
|
38
|
-
/** Pure: sum CPU% across a process's entire tree (itself + every descendant).
|
|
39
|
-
* Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
|
|
40
|
-
* DFS's from `rootPid` collecting root + all descendants; malformed/blank
|
|
41
|
-
* lines are skipped. Returns `null` if `rootPid` never appears in the output
|
|
42
|
-
* (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
|
|
43
|
-
* “low”) or the output has no parsable rows. Exported for direct unit testing
|
|
44
|
-
* — no process access of its own, mirroring wedgeVerdict's purity. */
|
|
45
38
|
export declare function sumTreeCpu(psOutput: string, rootPid: number): number | null;
|
|
39
|
+
/** Pure (issue #119): does `rootPid` have ANY descendant in this ps snapshot
|
|
40
|
+
* — a runaway subprocess to kill — or does the broker stand ALONE in its own
|
|
41
|
+
* tree (nothing to kill; the engine itself stalled)? Shares `parsePsTree`
|
|
42
|
+
* with `sumTreeCpu` so the two facts the wedge remediation needs come from
|
|
43
|
+
* one `ps` sample. Null when `rootPid` isn't in the sample at all (can't
|
|
44
|
+
* tell — same "unknown, never treated as a fact" stance `sumTreeCpu` takes on
|
|
45
|
+
* a missing root). Exported for direct unit testing. */
|
|
46
|
+
export declare function treeHasDescendants(psOutput: string, rootPid: number): boolean | null;
|
|
46
47
|
export type LivenessVerdict = 'leave' | 'pending' | 'revive';
|
|
47
48
|
/** Decide what to do with a node whose engine pid is DEAD, from how long it's
|
|
48
49
|
* been dead. Pure — the time/revive side effects live in handleNodeLiveness;
|
|
@@ -51,6 +52,14 @@ export type LivenessVerdict = 'leave' | 'pending' | 'revive';
|
|
|
51
52
|
* upstream by handleNodeLiveness.)
|
|
52
53
|
* deadFor: ms since first observed dead, or null on the first observation. */
|
|
53
54
|
export declare function livenessVerdict(piPidAlive: boolean | null, deadFor: number | null): LivenessVerdict;
|
|
55
|
+
/** Whether a crash-retry of a dead/stranded engine should resume STRICTLY
|
|
56
|
+
* (true) or retry the SAME launch attempt's cycling mode (false). Mirrors
|
|
57
|
+
* reviveNode's own cycle_pending flag (set true right before every cycling
|
|
58
|
+
* launch, cleared the instant session_start confirms boot) — so a cycling
|
|
59
|
+
* revive that died pre-session_start is retried AS a cycle (re-appending the
|
|
60
|
+
* marker + kickoff), never stranded as a strict resume into an empty marker
|
|
61
|
+
* branch. Pure so it's unit-testable without a fabricated node. */
|
|
62
|
+
export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null): boolean;
|
|
54
63
|
export declare const DEAD_REAP_GRACE_MS: number;
|
|
55
64
|
/** Read the pid stored in the pidfile, or null if absent / malformed. */
|
|
56
65
|
export declare function readPidfile(): number | null;
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
// • pid dead + intent==='refresh' → fresh respawn (node asked to yield).
|
|
19
19
|
// • pid dead + intent==='idle-release' → dormant by choice; the second pass
|
|
20
20
|
// revives (resume) when its inbox gains an unseen entry.
|
|
21
|
-
// • pid dead + any other intent → grace-revive RESUME on the saved session
|
|
21
|
+
// • pid dead + any other intent → grace-revive RESUME on the saved session,
|
|
22
|
+
// UNLESS the dead engine's last launch attempt was itself an in-flight
|
|
23
|
+
// cycle (cycle_pending) — then retry it AS a cycle instead of stranding it
|
|
24
|
+
// as a strict resume into an empty marker branch.
|
|
22
25
|
//
|
|
23
26
|
// §H refresh-authority: a stale in-process stophook (pi extensions never reload,
|
|
24
27
|
// so a days-old resident carries an old hook) can leave intent='refresh' on a
|
|
@@ -38,14 +41,16 @@
|
|
|
38
41
|
// daemon keeps the live broker parked and retries it via the inbox-based
|
|
39
42
|
// fault-recovery pass so the ordinary dead-pid crash path never needs an
|
|
40
43
|
// out-of-band launch.
|
|
41
|
-
// dead-pid crash path grace-revives RESUME on the saved session
|
|
44
|
+
// dead-pid crash path grace-revives RESUME on the saved session (or, for a
|
|
45
|
+
// crash mid-cycle, retries AS a cycle — see cycle_pending above) — the manual
|
|
42
46
|
// recovery (kill broker + canvas revive) that fixes these by hand.
|
|
43
47
|
//
|
|
44
48
|
// Single-instance guarantee
|
|
45
49
|
// A PID file prevents double-runs. On start, if the file exists and the
|
|
46
|
-
// recorded pid is alive, we refuse to start (exit 0).
|
|
47
|
-
// CRTR_PIDFILE to keep this process-local on recreate; otherwise it
|
|
48
|
-
// to crtrHome()/crtrd.pid. On stop (SIGINT/SIGTERM/exit) we remove
|
|
50
|
+
// recorded pid is alive, we refuse to start (exit 0). Remote/guest deployments
|
|
51
|
+
// set CRTR_PIDFILE to keep this process-local on recreate; otherwise it
|
|
52
|
+
// defaults to crtrHome()/crtrd.pid. On stop (SIGINT/SIGTERM/exit) we remove
|
|
53
|
+
// the file.
|
|
49
54
|
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, statSync, } from 'node:fs';
|
|
50
55
|
import { dirname, join } from 'node:path';
|
|
51
56
|
import { homedir } from 'node:os';
|
|
@@ -55,7 +60,7 @@ import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTr
|
|
|
55
60
|
import { fullName } from '../core/canvas/labels.js';
|
|
56
61
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
57
62
|
import { isBusy, busySince } from '../core/runtime/busy.js';
|
|
58
|
-
import { FAULT_QUIET_MS, recordFault } from '../core/runtime/fault.js';
|
|
63
|
+
import { FAULT_QUIET_MS, readFault, recordFault } from '../core/runtime/fault.js';
|
|
59
64
|
import { fanDoctrineWake } from '../core/runtime/close.js';
|
|
60
65
|
import { logger } from '../core/log.js';
|
|
61
66
|
export { FAULT_QUIET_MS };
|
|
@@ -220,12 +225,19 @@ function handleYieldStall(row, pid, now) {
|
|
|
220
225
|
// with no wake and no visible fault marker (`node inspect list --hanging`
|
|
221
226
|
// returns nothing — there's no fault recorded, just silence).
|
|
222
227
|
//
|
|
223
|
-
// Detection
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
228
|
+
// Detection always NOTIFIES (fans a doctrine wake + records a `daemon→node`/
|
|
229
|
+
// 'wedged' fault so the node shows ⌘ “wedged · needs you” on the canvas graph
|
|
230
|
+
// views, same surface `node inspect list --hanging` already reads). Recovery
|
|
231
|
+
// then branches on whether the broker's process TREE has a descendant:
|
|
232
|
+
// • descendants present → a runaway SUBPROCESS is the likely cause; the
|
|
233
|
+
// daemon does NOT touch the engine — kill the subprocess (not the node);
|
|
234
|
+
// see `crtr memory read wedged-child-on-runaway-bash`.
|
|
235
|
+
// • no descendants (the broker pid stands alone — e.g. the engine itself
|
|
236
|
+
// stalled after a model_change, with nothing to kill) → the daemon
|
|
237
|
+
// performs the ONLY remaining remediation itself: SIGTERM the broker, the
|
|
238
|
+
// same on-demand kick `canvas revive --now` does, so its own dead-pid
|
|
239
|
+
// crash-grace path (REVIVE_GRACE_MS) resumes it on the saved session with
|
|
240
|
+
// no lost context.
|
|
229
241
|
//
|
|
230
242
|
// Two corroborating signals, both generous, so a routine long-running bash/
|
|
231
243
|
// build/test tool call is never mistaken for a wedge:
|
|
@@ -256,11 +268,14 @@ function handleYieldStall(row, pid, now) {
|
|
|
256
268
|
// ACCEPTED residual: a legitimate subprocess blocked on silent I/O (e.g. a
|
|
257
269
|
// 20-minute download emitting no output, a long `sleep`) reads ~0% tree
|
|
258
270
|
// CPU and is indistinguishable from a real wedge at the OS level — no CPU
|
|
259
|
-
// signal can tell a hung child from an I/O-blocked one. This is accepted
|
|
260
|
-
//
|
|
261
|
-
// of
|
|
262
|
-
// minutes of total silence with near-zero tree CPU is itself worth
|
|
263
|
-
// surfacing either way.
|
|
271
|
+
// signal can tell a hung child from an I/O-blocked one. This is accepted:
|
|
272
|
+
// that case still HAS a descendant, so the daemon only notifies (never
|
|
273
|
+
// kills) — the cost of the rare false positive is one doctrine wake, and
|
|
274
|
+
// 20+ minutes of total silence with near-zero tree CPU is itself worth
|
|
275
|
+
// surfacing either way. Only the no-descendants branch ever acts, and
|
|
276
|
+
// SIGTERM-then-resume can't lose context (the saved session is resumed
|
|
277
|
+
// unchanged), so a false positive there costs one unnecessary restart, not
|
|
278
|
+
// lost work.
|
|
264
279
|
// ---------------------------------------------------------------------------
|
|
265
280
|
// Generous: routine long tool calls settle well under this; only a turn with
|
|
266
281
|
// zero observed engine progress for 20+ minutes is even a wedge CANDIDATE.
|
|
@@ -295,6 +310,15 @@ export function wedgeVerdict(input) {
|
|
|
295
310
|
* repeat notice), which is the same tradeoff `notifiedTriggerFailures` and
|
|
296
311
|
* `unhealthySince` already accept. */
|
|
297
312
|
const wedgeNotifiedAt = new Map();
|
|
313
|
+
/** Fatal-provider-fault notify latch (issue #94), keyed on node id → the
|
|
314
|
+
* `fault.since` we already fanned a wake for. A fatal fault (dead OAuth grant,
|
|
315
|
+
* bad key, protocol) has NO retry consumer, so an errored turn just leaves the
|
|
316
|
+
* node `active` with a live-but-dormant broker and, until now, woke nobody —
|
|
317
|
+
* the manager waiting on it hung and later scheduled wakes silently re-failed.
|
|
318
|
+
* Deduped on `fault.since` so one episode fans exactly one wake; a later
|
|
319
|
+
* episode (fresh `since`) or a cleared fault re-arms. In-memory like every
|
|
320
|
+
* other latch here. */
|
|
321
|
+
const fatalFaultNotifiedAt = new Map();
|
|
298
322
|
/** Pure: sum CPU% across a process's entire tree (itself + every descendant).
|
|
299
323
|
* Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
|
|
300
324
|
* DFS's from `rootPid` collecting root + all descendants; malformed/blank
|
|
@@ -302,7 +326,7 @@ const wedgeNotifiedAt = new Map();
|
|
|
302
326
|
* (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
|
|
303
327
|
* “low”) or the output has no parsable rows. Exported for direct unit testing
|
|
304
328
|
* — no process access of its own, mirroring wedgeVerdict's purity. */
|
|
305
|
-
|
|
329
|
+
function parsePsTree(psOutput) {
|
|
306
330
|
const byPid = new Map(); // pid → pcpu
|
|
307
331
|
const childrenOf = new Map(); // ppid → child pids
|
|
308
332
|
for (const line of psOutput.split('\n')) {
|
|
@@ -324,6 +348,10 @@ export function sumTreeCpu(psOutput, rootPid) {
|
|
|
324
348
|
else
|
|
325
349
|
siblings.push(pid);
|
|
326
350
|
}
|
|
351
|
+
return { byPid, childrenOf };
|
|
352
|
+
}
|
|
353
|
+
export function sumTreeCpu(psOutput, rootPid) {
|
|
354
|
+
const { byPid, childrenOf } = parsePsTree(psOutput);
|
|
327
355
|
if (!byPid.has(rootPid))
|
|
328
356
|
return null;
|
|
329
357
|
let total = 0;
|
|
@@ -341,19 +369,39 @@ export function sumTreeCpu(psOutput, rootPid) {
|
|
|
341
369
|
}
|
|
342
370
|
return total;
|
|
343
371
|
}
|
|
372
|
+
/** Pure (issue #119): does `rootPid` have ANY descendant in this ps snapshot
|
|
373
|
+
* — a runaway subprocess to kill — or does the broker stand ALONE in its own
|
|
374
|
+
* tree (nothing to kill; the engine itself stalled)? Shares `parsePsTree`
|
|
375
|
+
* with `sumTreeCpu` so the two facts the wedge remediation needs come from
|
|
376
|
+
* one `ps` sample. Null when `rootPid` isn't in the sample at all (can't
|
|
377
|
+
* tell — same "unknown, never treated as a fact" stance `sumTreeCpu` takes on
|
|
378
|
+
* a missing root). Exported for direct unit testing. */
|
|
379
|
+
export function treeHasDescendants(psOutput, rootPid) {
|
|
380
|
+
const { byPid, childrenOf } = parsePsTree(psOutput);
|
|
381
|
+
if (!byPid.has(rootPid))
|
|
382
|
+
return null;
|
|
383
|
+
return (childrenOf.get(rootPid) ?? []).length > 0;
|
|
384
|
+
}
|
|
344
385
|
/** Sample the broker's WHOLE PROCESS TREE — the broker pid plus every
|
|
345
|
-
* descendant
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
|
|
386
|
+
* descendant — via ONE `ps` shell-out, yielding both signals the wedge path
|
|
387
|
+
* needs: the summed tree CPU% (corroborates the heartbeat silence) and
|
|
388
|
+
* whether the tree has any descendant at all (issue #119 — decides whether
|
|
389
|
+
* remediation is "kill the subprocess" or "kick the broker"). Null on ANY
|
|
390
|
+
* failure (the shell-out itself failing, `ps` missing, or the root pid
|
|
391
|
+
* absent/output unparseable) — wedgeVerdict treats a null cpuPercent exactly
|
|
392
|
+
* like “too high”, so a broken probe can only ever suppress a false fire,
|
|
393
|
+
* never cause one; a null sample also means handleWedgeDetection never
|
|
394
|
+
* attempts the SIGTERM kick on unconfirmed information. */
|
|
395
|
+
function readTreeSample(pid) {
|
|
352
396
|
try {
|
|
353
397
|
const r = spawnSync('ps', ['-ax', '-o', 'pid=,ppid=,pcpu='], { encoding: 'utf8', timeout: 2000 });
|
|
354
398
|
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
355
399
|
return null;
|
|
356
|
-
|
|
400
|
+
const cpuPercent = sumTreeCpu(r.stdout, pid);
|
|
401
|
+
const hasDescendants = treeHasDescendants(r.stdout, pid);
|
|
402
|
+
if (cpuPercent === null || hasDescendants === null)
|
|
403
|
+
return null;
|
|
404
|
+
return { cpuPercent, hasDescendants };
|
|
357
405
|
}
|
|
358
406
|
catch {
|
|
359
407
|
return null;
|
|
@@ -372,7 +420,8 @@ function handleWedgeDetection(id, pid, now) {
|
|
|
372
420
|
}
|
|
373
421
|
const since = busySince(id);
|
|
374
422
|
const quietForMs = since === null ? null : now - since;
|
|
375
|
-
const
|
|
423
|
+
const sample = quietForMs !== null && quietForMs >= WEDGE_QUIET_MS ? readTreeSample(pid) : null;
|
|
424
|
+
const cpuPercent = sample?.cpuPercent ?? null;
|
|
376
425
|
const verdict = wedgeVerdict({ busy: true, quietForMs, cpuPercent });
|
|
377
426
|
if (verdict !== 'wedged')
|
|
378
427
|
return;
|
|
@@ -383,11 +432,43 @@ function handleWedgeDetection(id, pid, now) {
|
|
|
383
432
|
const meta = getNode(id);
|
|
384
433
|
const name = meta !== null ? fullName(meta) : id;
|
|
385
434
|
const minutes = Math.round((quietForMs ?? WEDGE_QUIET_MS) / 60_000);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
435
|
+
// A null sample can't reach here — wedgeVerdict only fires 'wedged' on a
|
|
436
|
+
// confirmed near-zero cpuPercent, which requires a non-null sample — so
|
|
437
|
+
// `sample.hasDescendants` is always defined at this point; the `?? true`
|
|
438
|
+
// is a type-level fallback only, biased toward the SAFER branch (notify,
|
|
439
|
+
// don't SIGTERM) if that invariant is ever violated.
|
|
440
|
+
const hasDescendants = sample?.hasDescendants ?? true;
|
|
441
|
+
let label;
|
|
442
|
+
if (hasDescendants) {
|
|
443
|
+
// A runaway subprocess is the likely cause — the daemon does not touch the
|
|
444
|
+
// engine; the sanctioned recovery is manual (issue #110).
|
|
445
|
+
label =
|
|
446
|
+
`Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
|
|
447
|
+
`${minutes}+ minutes and its process is near-idle. It will NOT recover on its own — read ` +
|
|
448
|
+
`\`crtr memory read wedged-child-on-runaway-bash\` and kill the runaway SUBPROCESS (not this ` +
|
|
449
|
+
`node) so its turn can resume with no lost context.`;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// No descendant to kill — the engine itself stalled (e.g. mid model_change)
|
|
453
|
+
// with nothing a human could kill. The daemon performs the same kick
|
|
454
|
+
// `canvas revive --now` does on demand: SIGTERM the broker, then its own
|
|
455
|
+
// dead-pid crash-grace path (REVIVE_GRACE_MS) resumes it on the saved
|
|
456
|
+
// session (issue #119).
|
|
457
|
+
label =
|
|
458
|
+
`Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
|
|
459
|
+
`${minutes}+ minutes, its process is near-idle, AND it has no subprocess to kill (the engine ` +
|
|
460
|
+
`itself stalled). Kicking it now: SIGTERM to the broker — the daemon's own crash-grace path ` +
|
|
461
|
+
`resumes it on the saved session within ~${Math.round(REVIVE_GRACE_MS / 1000)}s, no lost context. ` +
|
|
462
|
+
`If it comes back idle rather than resuming its turn, nudge it (see \`crtr memory read ` +
|
|
463
|
+
`wedged-child-on-runaway-bash\`).`;
|
|
464
|
+
try {
|
|
465
|
+
process.kill(pid, 'SIGTERM');
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
/* already gone — the dead-pid path picks it up on the very next tick regardless */
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
process.stderr.write(`[crtrd] wedged ${id} (busy ${minutes}+ min with no engine progress, cpu~0%, descendants=${hasDescendants})${hasDescendants ? '' : ' — kicked'}\n`);
|
|
391
472
|
try {
|
|
392
473
|
recordFault(id, {
|
|
393
474
|
link: 'daemon→node',
|
|
@@ -406,6 +487,52 @@ function handleWedgeDetection(id, pid, now) {
|
|
|
406
487
|
child: id,
|
|
407
488
|
quiet_ms: quietForMs,
|
|
408
489
|
cpu_percent: cpuPercent,
|
|
490
|
+
kicked: !hasDescendants,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
/* best-effort, mirrors every other fan-out in this file */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** Enact fatal-provider-fault notification for a LIVE node (issue #94; called
|
|
498
|
+
* from the pid-alive branch of handleNodeLiveness). A FATAL fault (auth /
|
|
499
|
+
* protocol / other on `pi→provider`) has no auto-retry consumer — the errored
|
|
500
|
+
* turn already ended, so the wedge detector (busy-only) never fires and the
|
|
501
|
+
* node sits `active` forever with a dormant broker. The daemon owns the
|
|
502
|
+
* analogous wedge fan, so it owns this too: fan ONE doctrine wake per fault
|
|
503
|
+
* episode telling subscribers the child will not self-recover (and, for auth,
|
|
504
|
+
* to re-authenticate so its next wake succeeds). Detection only — the node is
|
|
505
|
+
* deliberately NOT terminalized: the auth-reload fan + next wake are its real
|
|
506
|
+
* recovery path after a re-login. */
|
|
507
|
+
function handleFatalFault(id, now) {
|
|
508
|
+
void now;
|
|
509
|
+
const fault = readFault(id);
|
|
510
|
+
if (fault === null) {
|
|
511
|
+
fatalFaultNotifiedAt.delete(id); // fault cleared — next episode re-arms
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (fault.retry.disposition !== 'fatal' || fault.link !== 'pi→provider')
|
|
515
|
+
return;
|
|
516
|
+
if (fatalFaultNotifiedAt.get(id) === fault.since)
|
|
517
|
+
return; // already fanned THIS episode
|
|
518
|
+
fatalFaultNotifiedAt.set(id, fault.since);
|
|
519
|
+
const meta = getNode(id);
|
|
520
|
+
const name = meta !== null ? fullName(meta) : id;
|
|
521
|
+
const isAuth = fault.kind === 'auth';
|
|
522
|
+
const firstLine = (fault.message.split('\n')[0] ?? '').slice(0, 200);
|
|
523
|
+
const label = `Child hit a fatal provider fault — ${name} (${id}) got a non-retryable ${fault.kind} error and will NOT recover on its own; ` +
|
|
524
|
+
`it stays alive but every wake re-fails until the fault clears. ` +
|
|
525
|
+
(isAuth
|
|
526
|
+
? `Its provider credential is dead — re-authenticate (\`/login\`, or re-auth crtr); the child recovers on its next wake once the auth reloads. `
|
|
527
|
+
: ``) +
|
|
528
|
+
`Error: ${firstLine}`;
|
|
529
|
+
process.stderr.write(`[crtrd] fatal-fault ${id} (${fault.kind}, ${fault.link}) — fanning notice to subscribers\n`);
|
|
530
|
+
try {
|
|
531
|
+
fanDoctrineWake(id, subscribersOf(id), label, {
|
|
532
|
+
reason: 'child-fault-fatal',
|
|
533
|
+
child: id,
|
|
534
|
+
kind: fault.kind,
|
|
535
|
+
message: firstLine,
|
|
409
536
|
});
|
|
410
537
|
}
|
|
411
538
|
catch {
|
|
@@ -497,6 +624,16 @@ export function livenessVerdict(piPidAlive, deadFor) {
|
|
|
497
624
|
return 'pending';
|
|
498
625
|
return 'revive';
|
|
499
626
|
}
|
|
627
|
+
/** Whether a crash-retry of a dead/stranded engine should resume STRICTLY
|
|
628
|
+
* (true) or retry the SAME launch attempt's cycling mode (false). Mirrors
|
|
629
|
+
* reviveNode's own cycle_pending flag (set true right before every cycling
|
|
630
|
+
* launch, cleared the instant session_start confirms boot) — so a cycling
|
|
631
|
+
* revive that died pre-session_start is retried AS a cycle (re-appending the
|
|
632
|
+
* marker + kickoff), never stranded as a strict resume into an empty marker
|
|
633
|
+
* branch. Pure so it's unit-testable without a fabricated node. */
|
|
634
|
+
export function retryResumeMode(meta) {
|
|
635
|
+
return meta?.cycle_pending !== true;
|
|
636
|
+
}
|
|
500
637
|
/** The ONE liveness path for EVERY node. Every node runs on a detached headless
|
|
501
638
|
* broker; a tmux pane is only a viewer and is never consulted here — liveness is
|
|
502
639
|
* the recorded engine pid (signal-0) alone. Reuses the existing supervision
|
|
@@ -523,7 +660,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
|
|
|
523
660
|
* pi_pid right after launch) → leave through a boot grace; a healthy engine
|
|
524
661
|
* re-records its pid well within it. If the grace ELAPSES with still no pid,
|
|
525
662
|
* the relaunch died after the pid-clear → grace-revive RESUME on the saved
|
|
526
|
-
* session
|
|
663
|
+
* session, UNLESS that dead relaunch was itself a cycling attempt
|
|
664
|
+
* (cycle_pending) — then retry it AS a cycle instead (retryResumeMode), so
|
|
665
|
+
* it doesn't strand in an empty marker branch (else the node strands
|
|
666
|
+
* 'active' with no engine forever).
|
|
527
667
|
* • pid null + pi_session_id null (NEVER booted) → normally the sub-second SDK
|
|
528
668
|
* boot gap, but a broker that throws BEFORE session_start records no pid and
|
|
529
669
|
* no session ever — so after a boot grace with STILL nothing, crash +
|
|
@@ -533,7 +673,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
|
|
|
533
673
|
* • pid dead + intent==='idle-release' → dormant by choice; leave, the second
|
|
534
674
|
* pass revives (resume) on the next unseen inbox entry.
|
|
535
675
|
* • pid dead + any other intent (a crash) → grace-revive RESUME on the saved
|
|
536
|
-
* session (livenessVerdict → REVIVE_GRACE_MS → unhealthySince)
|
|
676
|
+
* session (livenessVerdict → REVIVE_GRACE_MS → unhealthySince), UNLESS the
|
|
677
|
+
* dead engine's last launch attempt was itself a cycling revive that never
|
|
678
|
+
* reached session_start (cycle_pending) — then retry it AS a cycle instead
|
|
679
|
+
* (retryResumeMode). */
|
|
537
680
|
async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
538
681
|
const id = row.node_id;
|
|
539
682
|
const pid = row.pi_pid;
|
|
@@ -545,6 +688,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
545
688
|
unhealthySince.delete(id);
|
|
546
689
|
handleYieldStall(row, pid, now);
|
|
547
690
|
handleWedgeDetection(id, pid, now);
|
|
691
|
+
handleFatalFault(id, now);
|
|
548
692
|
return;
|
|
549
693
|
}
|
|
550
694
|
if (pid == null) {
|
|
@@ -594,9 +738,12 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
594
738
|
unhealthySince.delete(id);
|
|
595
739
|
if (meta.pi_session_id != null) {
|
|
596
740
|
// Grace elapsed with a session but still no pid → a relaunch that died after
|
|
597
|
-
// the pid-clear, before re-record. Resume it on the saved session
|
|
741
|
+
// the pid-clear, before re-record. Resume it on the saved session — unless
|
|
742
|
+
// that dead relaunch was itself a cycling attempt (cycle_pending), in which
|
|
743
|
+
// case retry it AS a cycle (retryResumeMode) instead of stranding it as a
|
|
744
|
+
// strict resume into an empty marker branch.
|
|
598
745
|
process.stderr.write(`[crtrd] revive ${id} (stranded relaunch — pid never re-recorded)\n`);
|
|
599
|
-
reviveNode(id, { resume:
|
|
746
|
+
reviveNode(id, { resume: retryResumeMode(meta) });
|
|
600
747
|
revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
|
|
601
748
|
return;
|
|
602
749
|
}
|
|
@@ -629,9 +776,13 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
629
776
|
unhealthySince.delete(id);
|
|
630
777
|
return;
|
|
631
778
|
}
|
|
632
|
-
// Any other intent → a crash: grace-revive RESUME on the saved session
|
|
633
|
-
//
|
|
634
|
-
//
|
|
779
|
+
// Any other intent → a crash: grace-revive RESUME on the saved session —
|
|
780
|
+
// unless the dead engine's last launch attempt was itself a cycling revive
|
|
781
|
+
// that never reached session_start (cycle_pending), in which case retry it AS
|
|
782
|
+
// a cycle instead of stranding it as a strict resume into an empty marker
|
|
783
|
+
// branch. reviveNode clears pi_pid until the fresh engine re-records it, so
|
|
784
|
+
// the next tick won't re-fire on this stale pid.
|
|
785
|
+
const meta = getNode(id);
|
|
635
786
|
const since = unhealthySince.get(id);
|
|
636
787
|
const verdict = livenessVerdict(false, since === undefined ? null : now - since);
|
|
637
788
|
if (verdict === 'pending') {
|
|
@@ -641,7 +792,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
641
792
|
}
|
|
642
793
|
unhealthySince.delete(id);
|
|
643
794
|
process.stderr.write(`[crtrd] revive ${id} (engine dead, intent=${String(row.intent)})\n`);
|
|
644
|
-
reviveNode(id, { resume:
|
|
795
|
+
reviveNode(id, { resume: retryResumeMode(meta) });
|
|
645
796
|
revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
|
|
646
797
|
}
|
|
647
798
|
/** Fail loud for a drifted/broken trigger (design §6.6/Q5): wake the ARMER
|
package/dist/index.d.ts
CHANGED
|
@@ -17,3 +17,6 @@ export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-
|
|
|
17
17
|
export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
|
|
18
18
|
export type { FrameDecoderCaps, BrokerSnapshot, ClientRole, WelcomeFrame, ControlChangedFrame, AckFrame, ErrorFrame, ExtensionUIRequestFrame, BrokerToClient, HelloFrame, PromptFrame, SteerFrame, FollowUpFrame, AbortFrame, RequestControlFrame, ReleaseControlFrame, ByeFrame, ShutdownFrame, SetModelFrame, CycleModelFrame, SetThinkingLevelFrame, SetAutoRetryFrame, SetAutoCompactionFrame, CompactFrame, NewSessionFrame, SwitchSessionFrame, ForkFrame, SetSessionNameFrame, GetCommandsFrame, NavigateTreeFrame, ReloadFrame, ExportFrame, ClientToBroker, RpcExtensionUIRequest, RpcExtensionUIResponse, ExtensionUIResponseFrame, } from './core/runtime/broker-protocol.js';
|
|
19
19
|
export type { SessionStats } from '@earendil-works/pi-coding-agent';
|
|
20
|
+
export { general } from './core/errors.js';
|
|
21
|
+
export type { CrtrError } from './core/errors.js';
|
|
22
|
+
export { nowIso } from './core/fs-utils.js';
|
package/dist/index.js
CHANGED
|
@@ -25,3 +25,10 @@ export { readTelemetry, readContextTokens } from './core/canvas/telemetry.js';
|
|
|
25
25
|
export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-socket.js';
|
|
26
26
|
// ── Broker wire protocol (codec + frame/protocol types) ──────────────────
|
|
27
27
|
export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
|
|
28
|
+
// ── Utility exports ───────────────────────────────────────────────────────
|
|
29
|
+
// Small side-effect-free helpers consumed by external callers that assemble
|
|
30
|
+
// on top of crtr primitives (e.g. a guest-side process reading crtr's error
|
|
31
|
+
// shape and timestamp convention). Intentionally narrow: only `general` (not
|
|
32
|
+
// `CrtrError`/`notFound`/`usage`/the other error helpers) and `nowIso`.
|
|
33
|
+
export { general } from './core/errors.js';
|
|
34
|
+
export { nowIso } from './core/fs-utils.js';
|