ultra-builder-pro-cli 0.20.0 → 0.21.1
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/.ultra-template/docs/research/README.md +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +54 -24
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +36 -3
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +30 -28
- package/bin/orchestrator.js +64 -24
- package/docs/AGENT-CONTEXT.md +74 -43
- package/docs/ARCHITECTURE.md +63 -31
- package/docs/ARTIFACT-AUTHORITY.md +234 -0
- package/docs/DECISIONS.md +10 -4
- package/docs/LEGACY-CLI-CROSSWALK.md +6 -4
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
- package/docs/ROADMAP.md +19 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +24 -13
- package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
- package/docs/WORKFLOW-LIFECYCLE.md +70 -39
- package/hooks/README.md +6 -5
- package/hooks/active_task_context.py +97 -12
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1126 -0
- package/mcp-server/lib/baseline-workflow.cjs +9 -4
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +111 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +345 -3
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +373 -11
- package/mcp-server/lib/git-bootstrap.cjs +459 -47
- package/mcp-server/lib/init-project.cjs +190 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +174 -40
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-db.cjs +263 -9
- package/mcp-server/lib/state-ops.cjs +248 -49
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +610 -58
- package/mcp-server/server.cjs +278 -33
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +26 -8
- package/skills/ultra-deliver/SKILL.md +23 -11
- package/skills/ultra-dev/SKILL.md +33 -6
- package/skills/ultra-doctor/SKILL.md +6 -2
- package/skills/ultra-init/SKILL.md +4 -2
- package/skills/ultra-plan/SKILL.md +49 -16
- package/skills/ultra-research/SKILL.md +30 -27
- package/skills/ultra-review/SKILL.md +9 -5
- package/skills/ultra-status/SKILL.md +9 -4
- package/skills/ultra-test/SKILL.md +10 -5
- package/skills/ultra-think/SKILL.md +6 -2
- package/skills/ultra-think/references/decision-dialogue.md +17 -6
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +18 -7
- package/spec/mcp-tools.yaml +502 -44
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +50 -13
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +995 -80
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +32 -6
- package/ultra-tools/commands/system.cjs +129 -22
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -16,7 +16,10 @@ const { randomUUID } = require('node:crypto');
|
|
|
16
16
|
|
|
17
17
|
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
18
18
|
const workflows = require('../mcp-server/lib/workflow-state.cjs');
|
|
19
|
-
const
|
|
19
|
+
const contextSpine = require('../mcp-server/lib/context-spine.cjs');
|
|
20
|
+
const runtimePaths = require('../mcp-server/lib/runtime-paths.cjs');
|
|
21
|
+
const gitBootstrap = require('../mcp-server/lib/git-bootstrap.cjs');
|
|
22
|
+
const closeJournal = require('./session-close-journal.cjs');
|
|
20
23
|
|
|
21
24
|
const EXPECTED_EXECUTION_GATE_CODES = new Set([
|
|
22
25
|
'TASK_EXECUTION_CONTRACT_INCOMPLETE',
|
|
@@ -25,6 +28,22 @@ const EXPECTED_EXECUTION_GATE_CODES = new Set([
|
|
|
25
28
|
'WORKFLOW_PLAN_NOT_COMPLETED',
|
|
26
29
|
'WORKFLOW_PLAN_TASK_SET_STALE',
|
|
27
30
|
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
31
|
+
'WORKFLOW_PLAN_CONTEXT_STALE',
|
|
32
|
+
'WORKFLOW_IMPLEMENTATION_CONTEXT_REQUIRED',
|
|
33
|
+
'WORKFLOW_IMPLEMENTATION_CONTEXT_STALE',
|
|
34
|
+
'ADMISSION_DENIED',
|
|
35
|
+
'ACTIVE_SESSION_LEASE_CONFLICT',
|
|
36
|
+
'SESSION_CLOSE_PENDING',
|
|
37
|
+
'CIRCUIT_TRIPPED',
|
|
38
|
+
'LEGACY_RUNTIME_CONFLICT',
|
|
39
|
+
'LEGACY_RUNTIME_MIGRATION_FAILED',
|
|
40
|
+
'RUNTIME_ADMISSION_ROLLBACK_FAILED',
|
|
41
|
+
'RUNTIME_STATE_CONFLICT',
|
|
42
|
+
'RUNTIME_STATE_MIGRATION_FAILED',
|
|
43
|
+
'RUNTIME_STATE_NOT_QUIESCENT',
|
|
44
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
45
|
+
'SESSION_CLOSE_JOURNAL_CONFLICT',
|
|
46
|
+
'SESSION_CLOSE_CONFLICT',
|
|
28
47
|
]);
|
|
29
48
|
|
|
30
49
|
class SessionRunnerError extends Error {
|
|
@@ -36,14 +55,103 @@ class SessionRunnerError extends Error {
|
|
|
36
55
|
}
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
function findExpectedExecutionGate(error) {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
let current = error;
|
|
61
|
+
while (current && (typeof current === 'object' || typeof current === 'function')) {
|
|
62
|
+
if (seen.has(current)) return null;
|
|
63
|
+
seen.add(current);
|
|
64
|
+
if (EXPECTED_EXECUTION_GATE_CODES.has(current.code)) return current;
|
|
65
|
+
current = current.cause;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
function isExpectedExecutionGate(error) {
|
|
40
|
-
return Boolean(
|
|
71
|
+
return Boolean(findExpectedExecutionGate(error));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertSessionId(sid) {
|
|
75
|
+
if (typeof sid !== 'string'
|
|
76
|
+
|| sid.length === 0
|
|
77
|
+
|| sid.length > 128
|
|
78
|
+
|| sid === '.'
|
|
79
|
+
|| sid === '..'
|
|
80
|
+
|| path.isAbsolute(sid)
|
|
81
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sid)) {
|
|
82
|
+
throw new SessionRunnerError(
|
|
83
|
+
'WORKTREE_SCOPE_INVALID',
|
|
84
|
+
`session id is not a safe worktree leaf: ${String(sid)}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return sid;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree = true } = {}) {
|
|
91
|
+
const managedBase = runtimePaths.validateProjectLayout(
|
|
92
|
+
repoRoot, { env: {}, validateRuntimeTree },
|
|
93
|
+
).worktreesDir;
|
|
94
|
+
const stat = fs.lstatSync(managedBase, { throwIfNoEntry: false });
|
|
95
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
|
|
96
|
+
throw new SessionRunnerError(
|
|
97
|
+
'WORKTREE_SCOPE_INVALID',
|
|
98
|
+
`managed worktree root must be a real directory: ${managedBase}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return managedBase;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertManagedWorktreePath(repoRoot, candidate, {
|
|
105
|
+
sid = null,
|
|
106
|
+
validateRuntimeTree = true,
|
|
107
|
+
} = {}) {
|
|
108
|
+
if (!repoRoot || typeof candidate !== 'string' || !candidate) {
|
|
109
|
+
throw new SessionRunnerError(
|
|
110
|
+
'WORKTREE_SCOPE_INVALID',
|
|
111
|
+
'repoRoot and a managed worktree path are required',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const expectedSid = assertSessionId(sid === null ? path.basename(candidate) : sid);
|
|
115
|
+
const managedBase = assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree });
|
|
116
|
+
if (String(candidate).split(/[\\/]+/).includes('..')) {
|
|
117
|
+
throw new SessionRunnerError(
|
|
118
|
+
'WORKTREE_SCOPE_INVALID',
|
|
119
|
+
`worktree path may not contain traversal segments: ${candidate}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const resolved = path.resolve(repoRoot, candidate);
|
|
123
|
+
if (path.basename(resolved) !== expectedSid) {
|
|
124
|
+
throw new SessionRunnerError(
|
|
125
|
+
'WORKTREE_SCOPE_INVALID',
|
|
126
|
+
`worktree path must be the exact managed child for session ${expectedSid}: ${candidate}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const candidateStat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
130
|
+
if (candidateStat && (candidateStat.isSymbolicLink() || !candidateStat.isDirectory())) {
|
|
131
|
+
throw new SessionRunnerError(
|
|
132
|
+
'WORKTREE_SCOPE_INVALID',
|
|
133
|
+
`managed worktree path must be a real directory: ${candidate}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const physicalManaged = physicalCandidate(managedBase);
|
|
137
|
+
const physicalResolved = physicalCandidate(resolved);
|
|
138
|
+
const physicalRelative = path.relative(physicalManaged, physicalResolved);
|
|
139
|
+
if (physicalRelative !== expectedSid) {
|
|
140
|
+
throw new SessionRunnerError(
|
|
141
|
+
'WORKTREE_SCOPE_INVALID',
|
|
142
|
+
`worktree path must resolve to the exact managed child ${expectedSid}: ${candidate}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return resolved;
|
|
41
146
|
}
|
|
42
147
|
|
|
43
148
|
function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
|
|
44
|
-
|
|
149
|
+
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
150
|
+
sid: path.basename(worktreePath),
|
|
151
|
+
});
|
|
152
|
+
fs.mkdirSync(path.dirname(managedPath), { recursive: true });
|
|
45
153
|
try {
|
|
46
|
-
execFileSync('git', ['worktree', 'add', '--detach',
|
|
154
|
+
execFileSync('git', ['worktree', 'add', '--detach', managedPath, ref], {
|
|
47
155
|
cwd: repoRoot, stdio: 'pipe',
|
|
48
156
|
});
|
|
49
157
|
} catch (err) {
|
|
@@ -52,23 +160,137 @@ function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
|
|
|
52
160
|
}
|
|
53
161
|
|
|
54
162
|
function gitWorktreeRemove(repoRoot, worktreePath) {
|
|
163
|
+
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
164
|
+
sid: path.basename(worktreePath),
|
|
165
|
+
validateRuntimeTree: false,
|
|
166
|
+
});
|
|
55
167
|
try {
|
|
56
|
-
execFileSync('git', ['worktree', 'remove',
|
|
168
|
+
execFileSync('git', ['worktree', 'remove', managedPath], {
|
|
57
169
|
cwd: repoRoot, stdio: 'pipe',
|
|
58
170
|
});
|
|
59
|
-
} catch (
|
|
60
|
-
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new SessionRunnerError(
|
|
173
|
+
'WORKTREE_FAILED',
|
|
174
|
+
`git worktree remove failed: ${
|
|
175
|
+
error.stderr ? error.stderr.toString().trim() : error.message
|
|
176
|
+
}`,
|
|
177
|
+
{ cause: error },
|
|
178
|
+
);
|
|
61
179
|
}
|
|
62
|
-
|
|
63
|
-
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function gitWorktreeRegistry(repoRoot) {
|
|
183
|
+
try {
|
|
184
|
+
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
185
|
+
cwd: repoRoot,
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
stdio: 'pipe',
|
|
188
|
+
});
|
|
189
|
+
return new Set(output
|
|
190
|
+
.split(/\r?\n/)
|
|
191
|
+
.filter((line) => line.startsWith('worktree '))
|
|
192
|
+
.map((line) => physicalCandidate(line.slice('worktree '.length))));
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new SessionRunnerError(
|
|
195
|
+
'WORKTREE_FAILED',
|
|
196
|
+
`git worktree registry read failed: ${
|
|
197
|
+
error.stderr ? error.stderr.toString().trim() : error.message
|
|
198
|
+
}`,
|
|
199
|
+
{ cause: error },
|
|
200
|
+
);
|
|
64
201
|
}
|
|
65
202
|
}
|
|
66
203
|
|
|
204
|
+
function removeExactPrunableWorktreeRegistry(repoRoot, managedPath) {
|
|
205
|
+
let reported;
|
|
206
|
+
try {
|
|
207
|
+
reported = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
208
|
+
cwd: repoRoot,
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
stdio: 'pipe',
|
|
211
|
+
}).trim();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new SessionRunnerError(
|
|
214
|
+
'WORKTREE_FAILED',
|
|
215
|
+
`cannot resolve Git common directory: ${
|
|
216
|
+
error.stderr ? error.stderr.toString().trim() : error.message
|
|
217
|
+
}`,
|
|
218
|
+
{ cause: error },
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const commonDir = fs.realpathSync(
|
|
222
|
+
path.isAbsolute(reported) ? reported : path.resolve(repoRoot, reported),
|
|
223
|
+
);
|
|
224
|
+
const registryRoot = path.join(commonDir, 'worktrees');
|
|
225
|
+
const rootStat = fs.lstatSync(registryRoot, { throwIfNoEntry: false });
|
|
226
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
227
|
+
throw new SessionRunnerError(
|
|
228
|
+
'WORKTREE_FAILED',
|
|
229
|
+
`Git still registers ${managedPath}, but its exact worktree metadata root is unavailable`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const expected = path.resolve(managedPath);
|
|
233
|
+
const matches = [];
|
|
234
|
+
for (const entry of fs.readdirSync(registryRoot, { withFileTypes: true })) {
|
|
235
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
236
|
+
const adminDir = path.join(registryRoot, entry.name);
|
|
237
|
+
const gitdirFile = path.join(adminDir, 'gitdir');
|
|
238
|
+
const gitdirStat = fs.lstatSync(gitdirFile, { throwIfNoEntry: false });
|
|
239
|
+
if (!gitdirStat?.isFile() || gitdirStat.isSymbolicLink()) continue;
|
|
240
|
+
const gitdir = fs.readFileSync(gitdirFile, 'utf8').trim();
|
|
241
|
+
if (!gitdir) continue;
|
|
242
|
+
const recorded = path.dirname(
|
|
243
|
+
path.isAbsolute(gitdir) ? path.resolve(gitdir) : path.resolve(adminDir, gitdir),
|
|
244
|
+
);
|
|
245
|
+
if (recorded === expected) matches.push(adminDir);
|
|
246
|
+
}
|
|
247
|
+
if (matches.length !== 1) {
|
|
248
|
+
throw new SessionRunnerError(
|
|
249
|
+
'WORKTREE_FAILED',
|
|
250
|
+
`cannot identify one exact Git registry entry for removed worktree ${managedPath}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
fs.rmSync(matches[0], { recursive: true, force: false });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function reconcileRemovedWorktree(repoRoot, worktreePath, { sid } = {}) {
|
|
257
|
+
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
258
|
+
sid: sid || path.basename(worktreePath),
|
|
259
|
+
validateRuntimeTree: false,
|
|
260
|
+
});
|
|
261
|
+
const registryKey = physicalCandidate(managedPath);
|
|
262
|
+
let registry = gitWorktreeRegistry(repoRoot);
|
|
263
|
+
if (fs.existsSync(managedPath)) {
|
|
264
|
+
gitWorktreeRemove(repoRoot, managedPath);
|
|
265
|
+
} else if (registry.has(registryKey)) {
|
|
266
|
+
try {
|
|
267
|
+
execFileSync('git', ['worktree', 'remove', '--force', managedPath], {
|
|
268
|
+
cwd: repoRoot,
|
|
269
|
+
stdio: 'pipe',
|
|
270
|
+
});
|
|
271
|
+
} catch {
|
|
272
|
+
removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
registry = gitWorktreeRegistry(repoRoot);
|
|
276
|
+
if (!fs.existsSync(managedPath) && registry.has(registryKey)) {
|
|
277
|
+
removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
|
|
278
|
+
registry = gitWorktreeRegistry(repoRoot);
|
|
279
|
+
}
|
|
280
|
+
if (registry.has(registryKey)) {
|
|
281
|
+
throw new SessionRunnerError(
|
|
282
|
+
'WORKTREE_FAILED',
|
|
283
|
+
`Git still registers the managed worktree after removal: ${managedPath}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return managedPath;
|
|
287
|
+
}
|
|
288
|
+
|
|
67
289
|
function authorityLinkIsIgnored(worktreePath) {
|
|
68
290
|
try {
|
|
69
291
|
execFileSync(
|
|
70
292
|
'git',
|
|
71
|
-
['check-ignore', '--quiet', '--no-index', '--', '.ultra'],
|
|
293
|
+
['check-ignore', '--quiet', '--no-index', '--', '.ultra/.runtime'],
|
|
72
294
|
{ cwd: worktreePath, stdio: 'ignore' },
|
|
73
295
|
);
|
|
74
296
|
return true;
|
|
@@ -125,7 +347,7 @@ function ensureAuthorityLinkIgnored(worktreePath) {
|
|
|
125
347
|
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
126
348
|
fs.writeFileSync(
|
|
127
349
|
excludePath,
|
|
128
|
-
`${prefix}${separator}# Ultra Builder Pro session
|
|
350
|
+
`${prefix}${separator}# Ultra Builder Pro shared session runtime\n/.ultra/.runtime\n`,
|
|
129
351
|
);
|
|
130
352
|
if (!authorityLinkIsIgnored(worktreePath)) {
|
|
131
353
|
throw new Error('Git still does not ignore the session authority link');
|
|
@@ -137,17 +359,34 @@ function ensureAuthorityLinkIgnored(worktreePath) {
|
|
|
137
359
|
} catch { /* best effort */ }
|
|
138
360
|
throw new SessionRunnerError(
|
|
139
361
|
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
140
|
-
`cannot establish a safe local ignore rule for .ultra: ${error.message}`,
|
|
362
|
+
`cannot establish a safe local ignore rule for .ultra/.runtime: ${error.message}`,
|
|
141
363
|
{ cause: error },
|
|
142
364
|
);
|
|
143
365
|
}
|
|
144
366
|
return { changed: true, path: excludePath };
|
|
145
367
|
}
|
|
146
368
|
|
|
147
|
-
function linkAuthorityIntoWorktree(repoRoot, worktreePath
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
369
|
+
function linkAuthorityIntoWorktree(repoRoot, worktreePath, {
|
|
370
|
+
authority: preadmittedAuthority = null,
|
|
371
|
+
} = {}) {
|
|
372
|
+
let authority = preadmittedAuthority;
|
|
373
|
+
if (!authority) {
|
|
374
|
+
try {
|
|
375
|
+
authority = runtimePaths.ensureRuntimeState(repoRoot, {
|
|
376
|
+
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw new SessionRunnerError(
|
|
380
|
+
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
381
|
+
`cannot establish the project Ultra storage boundary: ${error.message}`,
|
|
382
|
+
{ cause: error },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const authorityDir = authority.runtimeDir;
|
|
387
|
+
const stateDbPath = authority.stateDbPath;
|
|
388
|
+
const worktreeUltraDir = path.join(worktreePath, '.ultra');
|
|
389
|
+
const linkPath = path.join(worktreeUltraDir, '.runtime');
|
|
151
390
|
if (!fs.existsSync(stateDbPath)) {
|
|
152
391
|
throw new SessionRunnerError(
|
|
153
392
|
'WORKTREE_AUTHORITY_MISSING',
|
|
@@ -156,20 +395,31 @@ function linkAuthorityIntoWorktree(repoRoot, worktreePath) {
|
|
|
156
395
|
}
|
|
157
396
|
ensureAuthorityLinkIgnored(worktreePath);
|
|
158
397
|
try {
|
|
398
|
+
fs.mkdirSync(worktreeUltraDir, { recursive: true });
|
|
159
399
|
if (fs.existsSync(linkPath) || fs.lstatSync(linkPath, { throwIfNoEntry: false })) {
|
|
160
400
|
throw new SessionRunnerError(
|
|
161
401
|
'WORKTREE_AUTHORITY_CONFLICT',
|
|
162
|
-
`worktree already contains an .ultra entry at ${linkPath}`,
|
|
402
|
+
`worktree already contains an .ultra/.runtime entry at ${linkPath}`,
|
|
163
403
|
);
|
|
164
404
|
}
|
|
165
405
|
fs.symlinkSync(
|
|
166
|
-
process.platform === 'win32' ? authorityDir : path.relative(
|
|
406
|
+
process.platform === 'win32' ? authorityDir : path.relative(worktreeUltraDir, authorityDir),
|
|
167
407
|
linkPath,
|
|
168
408
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
169
409
|
);
|
|
170
410
|
if (fs.realpathSync(path.join(linkPath, 'state.db')) !== fs.realpathSync(stateDbPath)) {
|
|
171
411
|
throw new Error('linked state.db does not resolve to the central authority');
|
|
172
412
|
}
|
|
413
|
+
try {
|
|
414
|
+
gitBootstrap.assertUltraStorageBoundary(worktreePath);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
fs.rmSync(linkPath, { force: true });
|
|
417
|
+
throw new SessionRunnerError(
|
|
418
|
+
'WORKTREE_STORAGE_BOUNDARY_STALE',
|
|
419
|
+
`selected worktree revision does not preserve Ultra semantic artifacts: ${error.message}`,
|
|
420
|
+
{ cause: error },
|
|
421
|
+
);
|
|
422
|
+
}
|
|
173
423
|
return { authorityDir, stateDbPath, linkPath };
|
|
174
424
|
} catch (error) {
|
|
175
425
|
throw error instanceof SessionRunnerError ? error : new SessionRunnerError(
|
|
@@ -181,14 +431,17 @@ function linkAuthorityIntoWorktree(repoRoot, worktreePath) {
|
|
|
181
431
|
}
|
|
182
432
|
|
|
183
433
|
function worktreeRemovalBlocker(repoRoot, worktreePath) {
|
|
184
|
-
|
|
434
|
+
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
435
|
+
sid: path.basename(worktreePath),
|
|
436
|
+
});
|
|
437
|
+
if (!fs.existsSync(managedPath)) return null;
|
|
185
438
|
try {
|
|
186
439
|
const status = execFileSync('git', ['status', '--porcelain=v1'], {
|
|
187
|
-
cwd:
|
|
440
|
+
cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
188
441
|
}).trim();
|
|
189
442
|
if (status) return 'worktree contains uncommitted changes';
|
|
190
443
|
const sessionHead = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
191
|
-
cwd:
|
|
444
|
+
cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
445
|
}).trim();
|
|
193
446
|
const repositoryHead = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
194
447
|
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -253,15 +506,28 @@ function changeTaskIntegrationBlocker(db, task, worktreePath) {
|
|
|
253
506
|
return null;
|
|
254
507
|
}
|
|
255
508
|
|
|
509
|
+
function physicalCandidate(candidate) {
|
|
510
|
+
let existing = candidate;
|
|
511
|
+
while (!fs.lstatSync(existing, { throwIfNoEntry: false })) {
|
|
512
|
+
const parent = path.dirname(existing);
|
|
513
|
+
if (parent === existing) break;
|
|
514
|
+
existing = parent;
|
|
515
|
+
}
|
|
516
|
+
const physicalExisting = fs.realpathSync(existing);
|
|
517
|
+
return path.resolve(physicalExisting, path.relative(existing, candidate));
|
|
518
|
+
}
|
|
519
|
+
|
|
256
520
|
function resolveWorktreeBase(repoRoot, requestedBase) {
|
|
257
|
-
const managedBase =
|
|
521
|
+
const managedBase = runtimePaths.pathsFor(repoRoot).worktreesDir;
|
|
258
522
|
const resolved = requestedBase
|
|
259
523
|
? path.resolve(repoRoot, requestedBase)
|
|
260
524
|
: managedBase;
|
|
261
|
-
|
|
525
|
+
const physicalManaged = physicalCandidate(managedBase);
|
|
526
|
+
const physicalResolved = physicalCandidate(resolved);
|
|
527
|
+
if (resolved !== path.resolve(managedBase) || physicalResolved !== physicalManaged) {
|
|
262
528
|
throw new SessionRunnerError(
|
|
263
529
|
'WORKTREE_SCOPE_INVALID',
|
|
264
|
-
`worktree_base must
|
|
530
|
+
`worktree_base must be the canonical managed root ${managedBase}`,
|
|
265
531
|
);
|
|
266
532
|
}
|
|
267
533
|
return resolved;
|
|
@@ -295,9 +561,126 @@ function assertSessionTaskReady(db, repoRoot, taskId) {
|
|
|
295
561
|
);
|
|
296
562
|
}
|
|
297
563
|
workflows.assertCurrentPlan(db, task.change_id, repoRoot);
|
|
564
|
+
const implementationContext = contextSpine.validateContextSnapshot(db, {
|
|
565
|
+
change_id: task.change_id,
|
|
566
|
+
task_id: task.id,
|
|
567
|
+
role: 'implement',
|
|
568
|
+
gate: 'implementation',
|
|
569
|
+
}, {
|
|
570
|
+
rootDir: repoRoot,
|
|
571
|
+
require_current_checkout: true,
|
|
572
|
+
});
|
|
573
|
+
if (!implementationContext.snapshot) {
|
|
574
|
+
throw new workflows.WorkflowStateError(
|
|
575
|
+
'WORKFLOW_IMPLEMENTATION_CONTEXT_REQUIRED',
|
|
576
|
+
`task ${task.id} requires a ready implementation Context before session admission`,
|
|
577
|
+
{ blockers: implementationContext.blockers },
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (implementationContext.blockers.length > 0) {
|
|
581
|
+
throw new workflows.WorkflowStateError(
|
|
582
|
+
'WORKFLOW_IMPLEMENTATION_CONTEXT_STALE',
|
|
583
|
+
`task ${task.id} implementation Context is stale`,
|
|
584
|
+
{ blockers: implementationContext.blockers },
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const planningContext = contextSpine.validateContextSnapshot(db, {
|
|
588
|
+
change_id: task.change_id,
|
|
589
|
+
task_id: null,
|
|
590
|
+
role: 'plan',
|
|
591
|
+
gate: 'planning',
|
|
592
|
+
}, {
|
|
593
|
+
rootDir: repoRoot,
|
|
594
|
+
require_current_checkout: true,
|
|
595
|
+
});
|
|
596
|
+
if (!planningContext.snapshot || planningContext.blockers.length > 0) {
|
|
597
|
+
throw new workflows.WorkflowStateError(
|
|
598
|
+
'WORKFLOW_PLAN_CONTEXT_STALE',
|
|
599
|
+
`change ${task.change_id} planning Context is stale at session admission`,
|
|
600
|
+
{ blockers: planningContext.blockers },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
298
603
|
return task;
|
|
299
604
|
}
|
|
300
605
|
|
|
606
|
+
function admissionCheck(db, repoRoot, taskId, options = {}) {
|
|
607
|
+
assertSessionTaskReady(db, repoRoot, taskId);
|
|
608
|
+
const pendingClose = closeJournal.findForTask(repoRoot, taskId);
|
|
609
|
+
if (pendingClose) {
|
|
610
|
+
return {
|
|
611
|
+
can_spawn: false,
|
|
612
|
+
conflict: {
|
|
613
|
+
sid: pendingClose.sid,
|
|
614
|
+
status: 'closing',
|
|
615
|
+
heartbeat_age_ms: 0,
|
|
616
|
+
},
|
|
617
|
+
recommended_action: 'recover_close',
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return ops.admissionCheck(db, taskId, options);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function startSessionProcess({
|
|
624
|
+
db,
|
|
625
|
+
repoRoot,
|
|
626
|
+
sid,
|
|
627
|
+
command,
|
|
628
|
+
args = [],
|
|
629
|
+
env = {},
|
|
630
|
+
stdio = 'ignore',
|
|
631
|
+
authority = null,
|
|
632
|
+
}) {
|
|
633
|
+
if (!db || !repoRoot || !sid || typeof command !== 'string' || !command.trim()) {
|
|
634
|
+
throw new SessionRunnerError(
|
|
635
|
+
'VALIDATION_ERROR',
|
|
636
|
+
'db, repoRoot, sid, and an executable command are required',
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (!Array.isArray(args)) {
|
|
640
|
+
throw new SessionRunnerError('VALIDATION_ERROR', 'session process args must be an array');
|
|
641
|
+
}
|
|
642
|
+
const session = ops.readSession(db, sid);
|
|
643
|
+
if (!session) throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
|
|
644
|
+
if (session.status !== 'running' || session.pid !== null) {
|
|
645
|
+
throw new SessionRunnerError(
|
|
646
|
+
'SESSION_NOT_STARTABLE',
|
|
647
|
+
`session ${sid} cannot start from status=${session.status}, pid=${String(session.pid)}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const worktreePath = assertManagedWorktreePath(repoRoot, session.worktree_path, { sid });
|
|
651
|
+
const resolvedAuthority = authority || runtimePaths.ensureRuntimeState(repoRoot, {
|
|
652
|
+
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
653
|
+
});
|
|
654
|
+
let proc;
|
|
655
|
+
try {
|
|
656
|
+
proc = spawn(command, args, {
|
|
657
|
+
cwd: worktreePath,
|
|
658
|
+
env: {
|
|
659
|
+
...process.env,
|
|
660
|
+
...env,
|
|
661
|
+
UBP_SESSION_ID: sid,
|
|
662
|
+
UBP_TASK_ID: session.task_id,
|
|
663
|
+
UBP_RUNTIME: session.runtime,
|
|
664
|
+
UBP_WORKTREE: worktreePath,
|
|
665
|
+
UBP_ARTIFACT_DIR: session.artifact_dir,
|
|
666
|
+
UBP_DB_PATH: resolvedAuthority.stateDbPath,
|
|
667
|
+
UBP_ROOT_DIR: worktreePath,
|
|
668
|
+
UBP_AUTHORITY_ROOT: repoRoot,
|
|
669
|
+
},
|
|
670
|
+
stdio,
|
|
671
|
+
detached: false,
|
|
672
|
+
});
|
|
673
|
+
if (Number.isInteger(proc.pid)) {
|
|
674
|
+
ops.updateSession(db, sid, { pid: proc.pid });
|
|
675
|
+
}
|
|
676
|
+
return proc;
|
|
677
|
+
} catch (error) {
|
|
678
|
+
try { if (proc && !proc.killed) proc.kill('SIGTERM'); } catch { /* best effort */ }
|
|
679
|
+
try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
301
684
|
function spawnSession({
|
|
302
685
|
db,
|
|
303
686
|
repoRoot,
|
|
@@ -311,6 +694,9 @@ function spawnSession({
|
|
|
311
694
|
worktreeRef = 'HEAD',
|
|
312
695
|
worktree_base = null,
|
|
313
696
|
lease_seconds = 1800,
|
|
697
|
+
mark_task_started = false,
|
|
698
|
+
takeover_timeout_ms = 5000,
|
|
699
|
+
takeover_poll_ms = 25,
|
|
314
700
|
}) {
|
|
315
701
|
if (!db) throw new SessionRunnerError('VALIDATION_ERROR', 'db handle required');
|
|
316
702
|
if (!repoRoot) throw new SessionRunnerError('VALIDATION_ERROR', 'repoRoot required');
|
|
@@ -332,6 +718,25 @@ function spawnSession({
|
|
|
332
718
|
// Validate workflow authority before takeover can terminate the prior worker
|
|
333
719
|
// or any filesystem mutation creates a new worktree.
|
|
334
720
|
assertSessionTaskReady(db, repoRoot, task_id);
|
|
721
|
+
let authority;
|
|
722
|
+
try {
|
|
723
|
+
authority = runtimePaths.ensureRuntimeState(repoRoot, {
|
|
724
|
+
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
725
|
+
});
|
|
726
|
+
} catch (error) {
|
|
727
|
+
throw new SessionRunnerError(
|
|
728
|
+
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
729
|
+
`cannot establish the project Ultra storage boundary: ${error.message}`,
|
|
730
|
+
{ cause: error },
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const pendingClose = closeJournal.findForTask(repoRoot, task_id);
|
|
734
|
+
if (pendingClose) {
|
|
735
|
+
throw new SessionRunnerError(
|
|
736
|
+
'SESSION_CLOSE_PENDING',
|
|
737
|
+
`task ${task_id} has an unfinished close intent for session ${pendingClose.sid}; recover it before spawning or taking over`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
335
740
|
if (!verdict.can_spawn && !takeover) {
|
|
336
741
|
const err = new SessionRunnerError(
|
|
337
742
|
'ADMISSION_DENIED',
|
|
@@ -352,6 +757,15 @@ function spawnSession({
|
|
|
352
757
|
);
|
|
353
758
|
}
|
|
354
759
|
}
|
|
760
|
+
if (!waitForProcessExit(priorSession.pid, {
|
|
761
|
+
timeoutMs: takeover_timeout_ms,
|
|
762
|
+
pollMs: takeover_poll_ms,
|
|
763
|
+
})) {
|
|
764
|
+
throw new SessionRunnerError(
|
|
765
|
+
'TAKEOVER_FAILED',
|
|
766
|
+
`prior session ${verdict.conflict.sid} process ${priorSession.pid} did not exit; its lease remains authoritative`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
355
769
|
}
|
|
356
770
|
ops.updateSession(db, verdict.conflict.sid, { status: 'crashed' });
|
|
357
771
|
}
|
|
@@ -359,61 +773,78 @@ function spawnSession({
|
|
|
359
773
|
// 2. Mint sid + paths
|
|
360
774
|
const sid = mintSessionId();
|
|
361
775
|
const worktree_path = path.join(worktreeBase, sid);
|
|
362
|
-
const artifact_dir = path.join(repoRoot
|
|
776
|
+
const artifact_dir = path.join(runtimePaths.pathsFor(repoRoot).sessionsDir, sid);
|
|
363
777
|
|
|
364
778
|
// 3. Git worktree (throws WORKTREE_FAILED on failure)
|
|
365
779
|
gitWorktreeAdd(repoRoot, worktree_path, worktreeRef);
|
|
366
|
-
let authority;
|
|
367
780
|
try {
|
|
368
|
-
authority = linkAuthorityIntoWorktree(repoRoot, worktree_path);
|
|
781
|
+
authority = linkAuthorityIntoWorktree(repoRoot, worktree_path, { authority });
|
|
369
782
|
fs.mkdirSync(artifact_dir, { recursive: true });
|
|
370
783
|
} catch (error) {
|
|
784
|
+
try {
|
|
785
|
+
fs.rmSync(path.join(worktree_path, '.ultra', '.runtime'), { force: true });
|
|
786
|
+
} catch { /* best effort */ }
|
|
371
787
|
gitWorktreeRemove(repoRoot, worktree_path);
|
|
372
788
|
throw error;
|
|
373
789
|
}
|
|
374
790
|
|
|
375
|
-
// 4.
|
|
376
|
-
|
|
377
|
-
if (command) {
|
|
378
|
-
proc = spawn(command, args, {
|
|
379
|
-
cwd: worktree_path,
|
|
380
|
-
env: {
|
|
381
|
-
...process.env,
|
|
382
|
-
...env,
|
|
383
|
-
UBP_SESSION_ID: sid,
|
|
384
|
-
UBP_TASK_ID: task_id,
|
|
385
|
-
UBP_RUNTIME: runtime,
|
|
386
|
-
UBP_WORKTREE: worktree_path,
|
|
387
|
-
UBP_ARTIFACT_DIR: artifact_dir,
|
|
388
|
-
UBP_DB_PATH: authority.stateDbPath,
|
|
389
|
-
UBP_ROOT_DIR: worktree_path,
|
|
390
|
-
UBP_AUTHORITY_ROOT: repoRoot,
|
|
391
|
-
},
|
|
392
|
-
stdio,
|
|
393
|
-
detached: false,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// 5. Write session record
|
|
791
|
+
// 4. Persist the task/worktree binding before any worker can run a hook or
|
|
792
|
+
// resolve UBP_DB_PATH. This is the anti-spoof authority hand-off.
|
|
398
793
|
let session;
|
|
399
794
|
try {
|
|
400
795
|
session = ops.createSession(db, {
|
|
401
796
|
sid,
|
|
402
797
|
task_id,
|
|
403
798
|
runtime,
|
|
404
|
-
pid:
|
|
799
|
+
pid: null,
|
|
405
800
|
worktree_path,
|
|
406
801
|
artifact_dir,
|
|
407
802
|
lease_seconds,
|
|
408
803
|
});
|
|
409
804
|
} catch (err) {
|
|
410
|
-
// state.db insert failed — roll back worktree
|
|
411
|
-
if (proc && !proc.killed) { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } }
|
|
805
|
+
// state.db insert failed — no worker has started; roll back the worktree.
|
|
412
806
|
gitWorktreeRemove(repoRoot, worktree_path);
|
|
413
807
|
try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
414
808
|
throw err;
|
|
415
809
|
}
|
|
416
810
|
|
|
811
|
+
// Automated dispatch must publish the running task state before the worker
|
|
812
|
+
// can observe or complete it. Manual host-owned sessions retain the caller's
|
|
813
|
+
// task state unless they explicitly opt into this ordering contract.
|
|
814
|
+
try {
|
|
815
|
+
if (mark_task_started && ops.readTask(db, task_id)?.status === 'pending') {
|
|
816
|
+
ops.updateTaskStatus(db, task_id, 'in_progress');
|
|
817
|
+
}
|
|
818
|
+
} catch (error) {
|
|
819
|
+
try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
|
|
820
|
+
try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
|
|
821
|
+
try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// 5. Spawn child process only after the durable binding exists. The active
|
|
826
|
+
// host may also consume the returned worktree without a child process.
|
|
827
|
+
let proc = null;
|
|
828
|
+
try {
|
|
829
|
+
if (command) {
|
|
830
|
+
proc = startSessionProcess({
|
|
831
|
+
db,
|
|
832
|
+
repoRoot,
|
|
833
|
+
sid,
|
|
834
|
+
command,
|
|
835
|
+
args,
|
|
836
|
+
env,
|
|
837
|
+
stdio,
|
|
838
|
+
authority,
|
|
839
|
+
});
|
|
840
|
+
session = ops.readSession(db, sid);
|
|
841
|
+
}
|
|
842
|
+
} catch (error) {
|
|
843
|
+
ops.updateSession(db, sid, { status: 'crashed' });
|
|
844
|
+
try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
|
|
845
|
+
throw error;
|
|
846
|
+
}
|
|
847
|
+
|
|
417
848
|
return {
|
|
418
849
|
sid,
|
|
419
850
|
worktree_path,
|
|
@@ -425,6 +856,48 @@ function spawnSession({
|
|
|
425
856
|
};
|
|
426
857
|
}
|
|
427
858
|
|
|
859
|
+
function processIsExecuting(pid) {
|
|
860
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
861
|
+
try {
|
|
862
|
+
process.kill(pid, 0);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
if (error.code === 'ESRCH') return false;
|
|
865
|
+
if (error.code !== 'EPERM') throw error;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
const state = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
869
|
+
encoding: 'utf8',
|
|
870
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
871
|
+
}).trim();
|
|
872
|
+
return Boolean(state) && !state.startsWith('Z');
|
|
873
|
+
} catch {
|
|
874
|
+
// A failed ps lookup after signal 0 normally means the process vanished.
|
|
875
|
+
try {
|
|
876
|
+
process.kill(pid, 0);
|
|
877
|
+
return true;
|
|
878
|
+
} catch (error) {
|
|
879
|
+
return error.code !== 'ESRCH';
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function waitForProcessExit(pid, {
|
|
885
|
+
timeoutMs = 5000,
|
|
886
|
+
pollMs = 25,
|
|
887
|
+
} = {}) {
|
|
888
|
+
const deadline = Date.now() + timeoutMs;
|
|
889
|
+
while (processIsExecuting(pid)) {
|
|
890
|
+
if (Date.now() >= deadline) return false;
|
|
891
|
+
Atomics.wait(
|
|
892
|
+
new Int32Array(new SharedArrayBuffer(4)),
|
|
893
|
+
0,
|
|
894
|
+
0,
|
|
895
|
+
Math.min(pollMs, Math.max(1, deadline - Date.now())),
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
|
|
428
901
|
function closeSession({
|
|
429
902
|
db,
|
|
430
903
|
repoRoot,
|
|
@@ -433,34 +906,129 @@ function closeSession({
|
|
|
433
906
|
status = 'completed',
|
|
434
907
|
remove_worktree = false,
|
|
435
908
|
kill_signal = 'SIGTERM',
|
|
436
|
-
mineSkill = false,
|
|
437
|
-
skillsRoot = null,
|
|
438
909
|
autoMerge = false,
|
|
439
910
|
mergeBaseBranch = 'main',
|
|
911
|
+
kill_timeout_ms = 5000,
|
|
912
|
+
kill_poll_ms = 25,
|
|
440
913
|
} = {}) {
|
|
441
914
|
if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
|
|
915
|
+
if (!closeJournal.TERMINAL_STATUSES.has(status)) {
|
|
916
|
+
throw new SessionRunnerError(
|
|
917
|
+
'VALIDATION_ERROR',
|
|
918
|
+
`session close status must be completed or crashed, got ${String(status)}`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
442
921
|
const session = ops.readSession(db, sid);
|
|
443
922
|
if (!session) {
|
|
444
923
|
throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
|
|
445
924
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
if (blocker) {
|
|
925
|
+
if (closeJournal.TERMINAL_STATUSES.has(session.status)) {
|
|
926
|
+
if (session.status !== status) {
|
|
449
927
|
throw new SessionRunnerError(
|
|
450
|
-
'
|
|
451
|
-
`
|
|
928
|
+
'SESSION_TERMINAL_CONFLICT',
|
|
929
|
+
`session ${sid} is already ${session.status} and cannot be closed as ${status}`,
|
|
452
930
|
);
|
|
453
931
|
}
|
|
932
|
+
// A terminal row may contain a legacy PID that has since been reused by an
|
|
933
|
+
// unrelated process. Terminal authority is idempotent: never signal that
|
|
934
|
+
// PID, and clear it before reconciling any durable close intent.
|
|
935
|
+
if (session.pid !== null) ops.updateSession(db, sid, { pid: null });
|
|
936
|
+
const intent = repoRoot ? closeJournal.read(repoRoot, sid) : null;
|
|
937
|
+
if (intent) {
|
|
938
|
+
if (intent.requested_status !== status
|
|
939
|
+
|| intent.task_id !== session.task_id
|
|
940
|
+
|| path.resolve(intent.worktree_path) !== path.resolve(session.worktree_path)) {
|
|
941
|
+
throw new SessionRunnerError(
|
|
942
|
+
'SESSION_CLOSE_JOURNAL_CONFLICT',
|
|
943
|
+
`session ${sid} terminal state conflicts with its durable close intent`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if ((intent || remove_worktree) && repoRoot && session.worktree_path) {
|
|
948
|
+
assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
|
|
949
|
+
if (fs.existsSync(session.worktree_path)) {
|
|
950
|
+
const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
|
|
951
|
+
if (blocker) {
|
|
952
|
+
throw new SessionRunnerError(
|
|
953
|
+
'WORKTREE_NOT_INTEGRATED',
|
|
954
|
+
`refusing to remove ${session.worktree_path}: ${blocker}`,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (!intent) {
|
|
959
|
+
closeJournal.prepare(repoRoot, {
|
|
960
|
+
sid,
|
|
961
|
+
task_id: session.task_id,
|
|
962
|
+
requested_status: status,
|
|
963
|
+
worktree_path: session.worktree_path,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
reconcileRemovedWorktree(repoRoot, session.worktree_path, { sid: session.sid });
|
|
968
|
+
closeJournal.update(repoRoot, sid, {
|
|
969
|
+
phase: 'worktree_removed',
|
|
970
|
+
error: null,
|
|
971
|
+
});
|
|
972
|
+
closeJournal.discard(repoRoot, sid);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
closeJournal.update(repoRoot, sid, {
|
|
975
|
+
phase: 'recovery_failed',
|
|
976
|
+
error: error.message,
|
|
977
|
+
});
|
|
978
|
+
throw error;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
sid,
|
|
983
|
+
status,
|
|
984
|
+
worktree_preserved: Boolean(
|
|
985
|
+
session.worktree_path && fs.existsSync(session.worktree_path),
|
|
986
|
+
),
|
|
987
|
+
};
|
|
454
988
|
}
|
|
455
|
-
if (session.
|
|
456
|
-
|
|
457
|
-
|
|
989
|
+
if ((remove_worktree || autoMerge) && session.worktree_path) {
|
|
990
|
+
assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
|
|
991
|
+
}
|
|
992
|
+
let closeIntentPrepared = false;
|
|
993
|
+
if (remove_worktree && !autoMerge && repoRoot && session.worktree_path) {
|
|
994
|
+
closeJournal.prepare(repoRoot, {
|
|
995
|
+
sid,
|
|
996
|
+
task_id: session.task_id,
|
|
997
|
+
requested_status: status,
|
|
998
|
+
worktree_path: session.worktree_path,
|
|
999
|
+
});
|
|
1000
|
+
closeIntentPrepared = true;
|
|
458
1001
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1002
|
+
if (session.pid) {
|
|
1003
|
+
try {
|
|
1004
|
+
process.kill(session.pid, kill_signal);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
if (error.code !== 'ESRCH') {
|
|
1007
|
+
throw new SessionRunnerError(
|
|
1008
|
+
'SESSION_TERMINATION_FAILED',
|
|
1009
|
+
`cannot signal session ${sid} process ${session.pid}: ${error.message}`,
|
|
1010
|
+
{ cause: error },
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (!waitForProcessExit(session.pid, {
|
|
1015
|
+
timeoutMs: kill_timeout_ms,
|
|
1016
|
+
pollMs: kill_poll_ms,
|
|
1017
|
+
})) {
|
|
1018
|
+
if (closeIntentPrepared) {
|
|
1019
|
+
closeJournal.update(repoRoot, sid, {
|
|
1020
|
+
phase: 'worker_running',
|
|
1021
|
+
error: `process ${session.pid} did not exit after ${kill_timeout_ms}ms`,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
throw new SessionRunnerError(
|
|
1025
|
+
'SESSION_TERMINATION_TIMEOUT',
|
|
1026
|
+
`session ${sid} process ${session.pid} did not exit after ${kill_timeout_ms}ms; lease and worktree were preserved`,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
// The owned worker is gone. Clear its reusable OS identifier even if a
|
|
1030
|
+
// later merge or worktree gate keeps this close intent recoverable.
|
|
1031
|
+
ops.updateSession(db, sid, { pid: null });
|
|
464
1032
|
}
|
|
465
1033
|
// Phase 8B.4 — opt-in auto-merge. Conflict preserves worktree for the
|
|
466
1034
|
// human; clean/no-op merges fall through to normal removal below.
|
|
@@ -505,9 +1073,40 @@ function closeSession({
|
|
|
505
1073
|
}
|
|
506
1074
|
}
|
|
507
1075
|
}
|
|
1076
|
+
if (effectiveRemove && repoRoot && session.worktree_path) {
|
|
1077
|
+
const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
|
|
1078
|
+
if (blocker) {
|
|
1079
|
+
throw new SessionRunnerError(
|
|
1080
|
+
'WORKTREE_NOT_INTEGRATED',
|
|
1081
|
+
`refusing to remove ${session.worktree_path}: ${blocker}`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
if (!closeIntentPrepared) {
|
|
1085
|
+
closeJournal.prepare(repoRoot, {
|
|
1086
|
+
sid,
|
|
1087
|
+
task_id: session.task_id,
|
|
1088
|
+
requested_status: status,
|
|
1089
|
+
worktree_path: session.worktree_path,
|
|
1090
|
+
});
|
|
1091
|
+
closeIntentPrepared = true;
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
gitWorktreeRemove(repoRoot, session.worktree_path);
|
|
1095
|
+
closeJournal.update(repoRoot, sid, {
|
|
1096
|
+
phase: 'worktree_removed',
|
|
1097
|
+
error: null,
|
|
1098
|
+
});
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
closeJournal.update(repoRoot, sid, {
|
|
1101
|
+
phase: 'removal_failed',
|
|
1102
|
+
error: error.message,
|
|
1103
|
+
});
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
508
1107
|
ops.updateSession(db, sid, { status });
|
|
509
1108
|
if (effectiveRemove && repoRoot && session.worktree_path) {
|
|
510
|
-
|
|
1109
|
+
closeJournal.discard(repoRoot, sid);
|
|
511
1110
|
}
|
|
512
1111
|
const out = {
|
|
513
1112
|
sid,
|
|
@@ -533,21 +1132,32 @@ function attachHeartbeat(db, sid, { intervalMs = 30000 } = {}) {
|
|
|
533
1132
|
module.exports = {
|
|
534
1133
|
SessionRunnerError,
|
|
535
1134
|
EXPECTED_EXECUTION_GATE_CODES,
|
|
1135
|
+
findExpectedExecutionGate,
|
|
536
1136
|
isExpectedExecutionGate,
|
|
537
1137
|
spawnSession,
|
|
1138
|
+
startSessionProcess,
|
|
538
1139
|
closeSession,
|
|
539
1140
|
attachHeartbeat,
|
|
1141
|
+
admissionCheck,
|
|
540
1142
|
assertSessionTaskReady,
|
|
541
1143
|
// exposed for tests
|
|
542
1144
|
_internal: {
|
|
543
1145
|
gitWorktreeAdd,
|
|
544
1146
|
gitWorktreeRemove,
|
|
1147
|
+
gitWorktreeRegistry,
|
|
1148
|
+
reconcileRemovedWorktree,
|
|
545
1149
|
mintSessionId,
|
|
546
1150
|
authorityLinkIsIgnored,
|
|
547
1151
|
ensureAuthorityLinkIgnored,
|
|
548
1152
|
linkAuthorityIntoWorktree,
|
|
549
1153
|
worktreeRemovalBlocker,
|
|
550
1154
|
changeTaskIntegrationBlocker,
|
|
1155
|
+
processIsExecuting,
|
|
1156
|
+
waitForProcessExit,
|
|
1157
|
+
closeJournal,
|
|
551
1158
|
resolveWorktreeBase,
|
|
1159
|
+
assertSessionId,
|
|
1160
|
+
assertManagedWorktreeRoot,
|
|
1161
|
+
assertManagedWorktreePath,
|
|
552
1162
|
},
|
|
553
1163
|
};
|