ultra-builder-pro-cli 0.25.1 → 0.26.0
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/contexts/TEMPLATE.md +87 -0
- package/.ultra-template/decisions/.gitkeep +1 -0
- package/.ultra-template/evidence/.gitkeep +1 -0
- package/.ultra-template/north-star.md +46 -0
- package/.ultra-template/research/.gitkeep +1 -0
- package/.ultra-template/specs/discovery.md +2 -2
- package/.ultra-template/tasks.json +3 -0
- package/.ultra-template/test-report.json +27 -0
- package/CHANGELOG.md +40 -0
- package/README.md +217 -579
- package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
- package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
- package/adapters/_shared/file-ops.cjs +76 -2
- package/adapters/_shared/host-profile.cjs +97 -0
- package/adapters/_shared/plugin-core.cjs +204 -0
- package/adapters/_shared/runtime-assets.cjs +31 -96
- package/adapters/claude.js +92 -291
- package/adapters/codex.js +309 -842
- package/adapters/grok.js +143 -1469
- package/adapters/kimi.js +124 -1053
- package/adapters/opencode.js +230 -742
- package/bin/delegate-worker.cjs +323 -0
- package/bin/delegate.cjs +456 -0
- package/bin/install.js +38 -11
- package/docs/ARCHITECTURE.md +122 -216
- package/docs/ARTIFACT-AUTHORITY.md +90 -268
- package/docs/DECISIONS.md +75 -87
- package/docs/PHILOSOPHY.md +177 -0
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +62 -39
- package/docs/RUNTIME-COMPAT-MATRIX.md +99 -156
- package/docs/SKILL-AUTHORING.md +94 -73
- package/docs/WORKFLOW-LIFECYCLE.md +93 -189
- package/hooks/README.md +17 -37
- package/hooks/_common.py +113 -0
- package/hooks/adapters/codex.py +9 -16
- package/hooks/adapters/grok.py +5 -5
- package/hooks/adapters/kimi.py +8 -14
- package/hooks/block_dangerous_commands.py +95 -0
- package/hooks/compact_context.py +65 -0
- package/hooks/mid_workflow_recall.py +25 -0
- package/hooks/post_edit_guard.py +105 -0
- package/hooks/session_context.py +30 -0
- package/package.json +17 -52
- package/skills/ultra-change/SKILL.md +97 -50
- package/skills/ultra-change/references/change-contract.md +40 -1
- package/skills/ultra-delegate/SKILL.md +82 -0
- package/skills/ultra-delegate/references/delegation-contract.md +51 -0
- package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
- package/skills/ultra-deliver/SKILL.md +61 -42
- package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
- package/skills/ultra-dev/SKILL.md +96 -62
- package/skills/ultra-dev/references/debugging.md +22 -0
- package/skills/ultra-domain-modeling/SKILL.md +79 -0
- package/skills/ultra-grilling/SKILL.md +79 -0
- package/skills/ultra-init/SKILL.md +94 -37
- package/skills/ultra-init/references/brownfield-adoption.md +33 -83
- package/skills/ultra-init/scripts/init_project.cjs +84 -0
- package/skills/ultra-plan/SKILL.md +88 -68
- package/skills/ultra-research/SKILL.md +62 -48
- package/skills/ultra-research/references/00-problem-validation.md +5 -5
- package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
- package/skills/ultra-research/references/02-market-assessment.md +5 -4
- package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
- package/skills/ultra-research/references/04-product-strategy.md +3 -3
- package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
- package/skills/ultra-research/references/10-user-personas.md +3 -2
- package/skills/ultra-research/references/11-user-scenarios.md +3 -3
- package/skills/ultra-research/references/20-user-stories.md +4 -3
- package/skills/ultra-research/references/21-features-scope.md +3 -2
- package/skills/ultra-research/references/22-success-metrics.md +3 -3
- package/skills/ultra-research/references/30-architecture-context.md +3 -3
- package/skills/ultra-research/references/31-solution-strategy.md +3 -3
- package/skills/ultra-research/references/32-building-blocks.md +3 -3
- package/skills/ultra-research/references/40-deployment.md +3 -3
- package/skills/ultra-research/references/41-quality-risks.md +3 -3
- package/skills/ultra-research/references/99-synthesis.md +7 -7
- package/skills/ultra-review/SKILL.md +59 -32
- package/skills/ultra-review/references/code.md +17 -0
- package/skills/ultra-review/references/comments.md +12 -0
- package/skills/ultra-review/references/design.md +12 -0
- package/skills/ultra-review/references/errors.md +13 -0
- package/skills/ultra-review/references/spec.md +13 -0
- package/skills/ultra-review/references/tests.md +12 -0
- package/skills/ultra-review/references/worker-packet.md +40 -0
- package/skills/ultra-review/scripts/review_wait.py +26 -15
- package/skills/ultra-status/SKILL.md +61 -36
- package/skills/ultra-tdd/SKILL.md +81 -0
- package/skills/ultra-tdd/references/templates/README.md +20 -0
- package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
- package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
- package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
- package/skills/ultra-tdd/references/test-execution.md +18 -0
- package/skills/ultra-test/SKILL.md +70 -30
- package/skills/ultra-test/references/export-syntax.md +18 -0
- package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
- package/skills/ultra-think/SKILL.md +49 -27
- package/skills/ultra-think/references/autonomy-boundary.md +48 -0
- package/skills/ultra-think/references/interaction-boundary.md +18 -33
- package/.ultra-template/docs/research/README.md +0 -55
- package/.ultra-template/reports/templates/delivery-report.json +0 -17
- package/.ultra-template/reports/templates/test-report.json +0 -18
- package/.ultra-template/tasks/tasks.json +0 -15
- package/.ultra-template/templates/task-context.md +0 -19
- package/adapters/_shared/codex-assets.cjs +0 -725
- package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
- package/adapters/_shared/interaction-contract.cjs +0 -129
- package/adapters/_shared/path-rewrite.cjs +0 -79
- package/adapters/_shared/runtime-native.cjs +0 -132
- package/adapters/_shared/settings-merge.cjs +0 -76
- package/agents/code-reviewer.md +0 -42
- package/agents/debugger.md +0 -40
- package/agents/review-code.md +0 -49
- package/agents/review-comments.md +0 -38
- package/agents/review-coordinator.md +0 -56
- package/agents/review-design.md +0 -40
- package/agents/review-errors.md +0 -43
- package/agents/review-spec.md +0 -39
- package/agents/review-tests.md +0 -40
- package/agents/tdd-runner.md +0 -38
- package/bin/orchestrator.js +0 -383
- package/commands/ultra-change.md +0 -10
- package/commands/ultra-deliver.md +0 -10
- package/commands/ultra-dev.md +0 -10
- package/commands/ultra-doctor.md +0 -10
- package/commands/ultra-init.md +0 -10
- package/commands/ultra-plan.md +0 -10
- package/commands/ultra-research.md +0 -10
- package/commands/ultra-review.md +0 -10
- package/commands/ultra-status.md +0 -10
- package/commands/ultra-test.md +0 -10
- package/commands/ultra-think.md +0 -10
- package/docs/AGENT-CONTEXT.md +0 -191
- package/docs/COMMIT-HASH-BACKFILL.md +0 -51
- package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
- package/docs/ROADMAP.md +0 -101
- package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
- package/hooks/active_task_context.py +0 -152
- package/hooks/context_envelope.py +0 -100
- package/hooks/health_check.py +0 -196
- package/hooks/pre_stop_check.py +0 -66
- package/hooks/runtime_paths.py +0 -396
- package/hooks/subagent_tracker.py +0 -88
- package/hooks/workflow_checkpoint.py +0 -76
- package/hooks/workflow_context.py +0 -48
- package/hooks/workflow_resume.py +0 -84
- package/mcp-server/hook-context.cjs +0 -38
- package/mcp-server/hook-event.cjs +0 -34
- package/mcp-server/lib/archive-journal.cjs +0 -1119
- package/mcp-server/lib/archive-mutation-worker.py +0 -365
- package/mcp-server/lib/artifact-registry.cjs +0 -1232
- package/mcp-server/lib/baseline-workflow.cjs +0 -1193
- package/mcp-server/lib/canonical-json.cjs +0 -19
- package/mcp-server/lib/change-authority.cjs +0 -26
- package/mcp-server/lib/change-packet.cjs +0 -790
- package/mcp-server/lib/change-workflow.cjs +0 -65
- package/mcp-server/lib/context-envelope.cjs +0 -581
- package/mcp-server/lib/context-paths.cjs +0 -142
- package/mcp-server/lib/decision-records.cjs +0 -423
- package/mcp-server/lib/delivery-transaction.cjs +0 -471
- package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
- package/mcp-server/lib/doctor.cjs +0 -627
- package/mcp-server/lib/git-bootstrap.cjs +0 -651
- package/mcp-server/lib/init-project.cjs +0 -1021
- package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
- package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
- package/mcp-server/lib/managed-file-write.cjs +0 -172
- package/mcp-server/lib/plan-checkpoint.cjs +0 -177
- package/mcp-server/lib/plan-publication-worker.cjs +0 -830
- package/mcp-server/lib/plan-store.cjs +0 -794
- package/mcp-server/lib/prd-parser.cjs +0 -83
- package/mcp-server/lib/pricing.cjs +0 -44
- package/mcp-server/lib/project-context.cjs +0 -178
- package/mcp-server/lib/projector.cjs +0 -386
- package/mcp-server/lib/provider-refs.cjs +0 -42
- package/mcp-server/lib/runtime-paths.cjs +0 -1714
- package/mcp-server/lib/runtime-state.cjs +0 -222
- package/mcp-server/lib/safe-project-file.cjs +0 -385
- package/mcp-server/lib/stage-checkpoints.cjs +0 -463
- package/mcp-server/lib/state-authority.cjs +0 -95
- package/mcp-server/lib/state-db.cjs +0 -1580
- package/mcp-server/lib/state-ops.cjs +0 -1428
- package/mcp-server/lib/task-contract.cjs +0 -404
- package/mcp-server/lib/task-expander.cjs +0 -105
- package/mcp-server/lib/task-ledger.cjs +0 -1964
- package/mcp-server/lib/telemetry.cjs +0 -85
- package/mcp-server/lib/topo.cjs +0 -131
- package/mcp-server/lib/ultra-facade.cjs +0 -2684
- package/mcp-server/lib/worker-packet.cjs +0 -358
- package/mcp-server/server.cjs +0 -437
- package/orchestrator/auto-merge.cjs +0 -128
- package/orchestrator/daemon.cjs +0 -264
- package/orchestrator/dispatch-rules.cjs +0 -78
- package/orchestrator/parallel-orchestrator.cjs +0 -903
- package/orchestrator/planner/plan-builder.cjs +0 -125
- package/orchestrator/recovery.cjs +0 -188
- package/orchestrator/session-close-journal-worker.cjs +0 -5
- package/orchestrator/session-close-journal.cjs +0 -1014
- package/orchestrator/session-runner.cjs +0 -1153
- package/orchestrator/worktree-manager.cjs +0 -282
- package/settings.json +0 -7
- package/skills/cc-collab/SKILL.md +0 -42
- package/skills/code-review-expert/SKILL.md +0 -43
- package/skills/code-review-expert/references/correctness-reliability.md +0 -30
- package/skills/code-review-expert/references/design-boundaries.md +0 -26
- package/skills/code-review-expert/references/removal-plan.md +0 -24
- package/skills/codex-collab/SKILL.md +0 -49
- package/skills/codex-collab/evals/evals.json +0 -35
- package/skills/integration-rules/SKILL.md +0 -32
- package/skills/security-rules/SKILL.md +0 -38
- package/skills/testing-rules/SKILL.md +0 -32
- package/skills/ultra-doctor/SKILL.md +0 -41
- package/skills/ultra-plan/references/semantic-preflight.md +0 -20
- package/skills/ultra-verify/SKILL.md +0 -46
- package/skills/ultra-verify/evals/evals.json +0 -46
- package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
- package/skills/ultra-verify/references/evidence-status.md +0 -21
- package/skills/ultra-verify/scripts/verify_wait.py +0 -94
- package/spec/cli-protocol.md +0 -102
- package/spec/mcp-tools.yaml +0 -312
- package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
- package/spec/schemas/change-delta.v1.schema.json +0 -153
- package/spec/schemas/command-manifest.schema.json +0 -29
- package/spec/schemas/context-file.v4.5.schema.json +0 -54
- package/spec/schemas/delivery-report.v1.schema.json +0 -66
- package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
- package/spec/schemas/mcp-tools.schema.json +0 -136
- package/spec/schemas/skill-manifest.schema.json +0 -24
- package/spec/schemas/state-db.sql +0 -641
- package/spec/schemas/task-ledger.v2.schema.json +0 -296
- package/spec/schemas/tasks.v4.5.schema.json +0 -96
- package/spec/schemas/test-report.v1.schema.json +0 -225
- package/ultra-tools/cli.cjs +0 -129
- package/ultra-tools/commands/db.cjs +0 -226
- package/ultra-tools/commands/legacy-memory.cjs +0 -159
- package/ultra-tools/commands/migrate.cjs +0 -1386
- package/ultra-tools/commands/session.cjs +0 -213
- package/ultra-tools/commands/status.cjs +0 -559
- package/ultra-tools/commands/system.cjs +0 -498
- package/ultra-tools/commands/task.cjs +0 -105
|
@@ -1,1153 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 4.5.1 — standard session unit runner.
|
|
4
|
-
// Session = one task/runtime lease + independent git worktree + state.db
|
|
5
|
-
// lease/heartbeat. A caller may attach a worker process or let the active host
|
|
6
|
-
// operate directly in the returned worktree.
|
|
7
|
-
//
|
|
8
|
-
// This module is the single session/worktree execution layer shared by MCP
|
|
9
|
-
// session.* tools and the optional orchestrator child-process path.
|
|
10
|
-
// Callers share a single open state-db connection (WAL supports multi-writer).
|
|
11
|
-
|
|
12
|
-
const fs = require('node:fs');
|
|
13
|
-
const path = require('node:path');
|
|
14
|
-
const { spawn, execFileSync } = require('node:child_process');
|
|
15
|
-
const { randomUUID } = require('node:crypto');
|
|
16
|
-
|
|
17
|
-
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
18
|
-
const checkpoints = require('../mcp-server/lib/stage-checkpoints.cjs');
|
|
19
|
-
const workerPackets = require('../mcp-server/lib/worker-packet.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');
|
|
23
|
-
|
|
24
|
-
const EXPECTED_EXECUTION_GATE_CODES = new Set([
|
|
25
|
-
'TASK_EXECUTION_CONTRACT_INCOMPLETE',
|
|
26
|
-
'WORKFLOW_TASK_NOT_EXECUTABLE',
|
|
27
|
-
'TASK_DEPENDENCIES_INCOMPLETE',
|
|
28
|
-
'WORKFLOW_PLAN_NOT_COMPLETED',
|
|
29
|
-
'WORKFLOW_PLAN_TASK_SET_STALE',
|
|
30
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
31
|
-
'WORKFLOW_PLAN_CONTEXT_STALE',
|
|
32
|
-
'WORKFLOW_IMPLEMENTATION_CONTEXT_REQUIRED',
|
|
33
|
-
'WORKFLOW_IMPLEMENTATION_CONTEXT_STALE',
|
|
34
|
-
'TASK_NOT_EXECUTABLE',
|
|
35
|
-
'PLAN_CHECKPOINT_REQUIRED',
|
|
36
|
-
'PLAN_TASK_CONTRACT_MISSING',
|
|
37
|
-
'PLAN_TASK_CONTRACT_STALE',
|
|
38
|
-
'IMPLEMENTATION_CONTEXT_REQUIRED',
|
|
39
|
-
'IMPLEMENTATION_CONTEXT_STALE',
|
|
40
|
-
'PLAN_CONTEXT_STALE',
|
|
41
|
-
'WORKER_PACKET_REQUIRED',
|
|
42
|
-
'CONTEXT_REQUIRED_REF_STALE',
|
|
43
|
-
'ADMISSION_DENIED',
|
|
44
|
-
'ACTIVE_SESSION_LEASE_CONFLICT',
|
|
45
|
-
'SESSION_CLOSE_PENDING',
|
|
46
|
-
'CIRCUIT_TRIPPED',
|
|
47
|
-
'LEGACY_RUNTIME_CONFLICT',
|
|
48
|
-
'LEGACY_RUNTIME_MIGRATION_FAILED',
|
|
49
|
-
'RUNTIME_ADMISSION_ROLLBACK_FAILED',
|
|
50
|
-
'RUNTIME_STATE_CONFLICT',
|
|
51
|
-
'RUNTIME_STATE_MIGRATION_FAILED',
|
|
52
|
-
'RUNTIME_STATE_NOT_QUIESCENT',
|
|
53
|
-
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
54
|
-
'SESSION_CLOSE_JOURNAL_CONFLICT',
|
|
55
|
-
'SESSION_CLOSE_CONFLICT',
|
|
56
|
-
]);
|
|
57
|
-
|
|
58
|
-
class SessionRunnerError extends Error {
|
|
59
|
-
constructor(code, message, { cause, details } = {}) {
|
|
60
|
-
super(message);
|
|
61
|
-
this.name = 'SessionRunnerError';
|
|
62
|
-
this.code = code;
|
|
63
|
-
if (cause) this.cause = cause;
|
|
64
|
-
if (details !== undefined) this.details = details;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function findExpectedExecutionGate(error) {
|
|
69
|
-
const seen = new Set();
|
|
70
|
-
let current = error;
|
|
71
|
-
while (current && (typeof current === 'object' || typeof current === 'function')) {
|
|
72
|
-
if (seen.has(current)) return null;
|
|
73
|
-
seen.add(current);
|
|
74
|
-
if (EXPECTED_EXECUTION_GATE_CODES.has(current.code)) return current;
|
|
75
|
-
current = current.cause;
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isExpectedExecutionGate(error) {
|
|
81
|
-
return Boolean(findExpectedExecutionGate(error));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function assertSessionId(sid) {
|
|
85
|
-
if (typeof sid !== 'string'
|
|
86
|
-
|| sid.length === 0
|
|
87
|
-
|| sid.length > 128
|
|
88
|
-
|| sid === '.'
|
|
89
|
-
|| sid === '..'
|
|
90
|
-
|| path.isAbsolute(sid)
|
|
91
|
-
|| !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sid)) {
|
|
92
|
-
throw new SessionRunnerError(
|
|
93
|
-
'WORKTREE_SCOPE_INVALID',
|
|
94
|
-
`session id is not a safe worktree leaf: ${String(sid)}`,
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
return sid;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree = true } = {}) {
|
|
101
|
-
const managedBase = runtimePaths.validateProjectLayout(
|
|
102
|
-
repoRoot, { env: {}, validateRuntimeTree },
|
|
103
|
-
).worktreesDir;
|
|
104
|
-
const stat = fs.lstatSync(managedBase, { throwIfNoEntry: false });
|
|
105
|
-
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
|
|
106
|
-
throw new SessionRunnerError(
|
|
107
|
-
'WORKTREE_SCOPE_INVALID',
|
|
108
|
-
`managed worktree root must be a real directory: ${managedBase}`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return managedBase;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function assertManagedWorktreePath(repoRoot, candidate, {
|
|
115
|
-
sid = null,
|
|
116
|
-
validateRuntimeTree = true,
|
|
117
|
-
} = {}) {
|
|
118
|
-
if (!repoRoot || typeof candidate !== 'string' || !candidate) {
|
|
119
|
-
throw new SessionRunnerError(
|
|
120
|
-
'WORKTREE_SCOPE_INVALID',
|
|
121
|
-
'repoRoot and a managed worktree path are required',
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
const expectedSid = assertSessionId(sid === null ? path.basename(candidate) : sid);
|
|
125
|
-
const managedBase = assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree });
|
|
126
|
-
if (String(candidate).split(/[\\/]+/).includes('..')) {
|
|
127
|
-
throw new SessionRunnerError(
|
|
128
|
-
'WORKTREE_SCOPE_INVALID',
|
|
129
|
-
`worktree path may not contain traversal segments: ${candidate}`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
const resolved = path.resolve(repoRoot, candidate);
|
|
133
|
-
if (path.basename(resolved) !== expectedSid) {
|
|
134
|
-
throw new SessionRunnerError(
|
|
135
|
-
'WORKTREE_SCOPE_INVALID',
|
|
136
|
-
`worktree path must be the exact managed child for session ${expectedSid}: ${candidate}`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
const candidateStat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
140
|
-
if (candidateStat && (candidateStat.isSymbolicLink() || !candidateStat.isDirectory())) {
|
|
141
|
-
throw new SessionRunnerError(
|
|
142
|
-
'WORKTREE_SCOPE_INVALID',
|
|
143
|
-
`managed worktree path must be a real directory: ${candidate}`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
const physicalManaged = physicalCandidate(managedBase);
|
|
147
|
-
const physicalResolved = physicalCandidate(resolved);
|
|
148
|
-
const physicalRelative = path.relative(physicalManaged, physicalResolved);
|
|
149
|
-
if (physicalRelative !== expectedSid) {
|
|
150
|
-
throw new SessionRunnerError(
|
|
151
|
-
'WORKTREE_SCOPE_INVALID',
|
|
152
|
-
`worktree path must resolve to the exact managed child ${expectedSid}: ${candidate}`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
return resolved;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
|
|
159
|
-
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
160
|
-
sid: path.basename(worktreePath),
|
|
161
|
-
});
|
|
162
|
-
fs.mkdirSync(path.dirname(managedPath), { recursive: true });
|
|
163
|
-
try {
|
|
164
|
-
execFileSync('git', ['worktree', 'add', '--detach', managedPath, ref], {
|
|
165
|
-
cwd: repoRoot, stdio: 'pipe',
|
|
166
|
-
});
|
|
167
|
-
} catch (err) {
|
|
168
|
-
throw new SessionRunnerError('WORKTREE_FAILED', `git worktree add failed: ${err.stderr ? err.stderr.toString().trim() : err.message}`, { cause: err });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function gitWorktreeRemove(repoRoot, worktreePath) {
|
|
173
|
-
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
174
|
-
sid: path.basename(worktreePath),
|
|
175
|
-
validateRuntimeTree: false,
|
|
176
|
-
});
|
|
177
|
-
try {
|
|
178
|
-
execFileSync('git', ['worktree', 'remove', managedPath], {
|
|
179
|
-
cwd: repoRoot, stdio: 'pipe',
|
|
180
|
-
});
|
|
181
|
-
} catch (error) {
|
|
182
|
-
throw new SessionRunnerError(
|
|
183
|
-
'WORKTREE_FAILED',
|
|
184
|
-
`git worktree remove failed: ${
|
|
185
|
-
error.stderr ? error.stderr.toString().trim() : error.message
|
|
186
|
-
}`,
|
|
187
|
-
{ cause: error },
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function gitWorktreeRegistry(repoRoot) {
|
|
193
|
-
try {
|
|
194
|
-
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
195
|
-
cwd: repoRoot,
|
|
196
|
-
encoding: 'utf8',
|
|
197
|
-
stdio: 'pipe',
|
|
198
|
-
});
|
|
199
|
-
return new Set(output
|
|
200
|
-
.split(/\r?\n/)
|
|
201
|
-
.filter((line) => line.startsWith('worktree '))
|
|
202
|
-
.map((line) => physicalCandidate(line.slice('worktree '.length))));
|
|
203
|
-
} catch (error) {
|
|
204
|
-
throw new SessionRunnerError(
|
|
205
|
-
'WORKTREE_FAILED',
|
|
206
|
-
`git worktree registry read failed: ${
|
|
207
|
-
error.stderr ? error.stderr.toString().trim() : error.message
|
|
208
|
-
}`,
|
|
209
|
-
{ cause: error },
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function removeExactPrunableWorktreeRegistry(repoRoot, managedPath) {
|
|
215
|
-
let reported;
|
|
216
|
-
try {
|
|
217
|
-
reported = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
218
|
-
cwd: repoRoot,
|
|
219
|
-
encoding: 'utf8',
|
|
220
|
-
stdio: 'pipe',
|
|
221
|
-
}).trim();
|
|
222
|
-
} catch (error) {
|
|
223
|
-
throw new SessionRunnerError(
|
|
224
|
-
'WORKTREE_FAILED',
|
|
225
|
-
`cannot resolve Git common directory: ${
|
|
226
|
-
error.stderr ? error.stderr.toString().trim() : error.message
|
|
227
|
-
}`,
|
|
228
|
-
{ cause: error },
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
const commonDir = fs.realpathSync(
|
|
232
|
-
path.isAbsolute(reported) ? reported : path.resolve(repoRoot, reported),
|
|
233
|
-
);
|
|
234
|
-
const registryRoot = path.join(commonDir, 'worktrees');
|
|
235
|
-
const rootStat = fs.lstatSync(registryRoot, { throwIfNoEntry: false });
|
|
236
|
-
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
237
|
-
throw new SessionRunnerError(
|
|
238
|
-
'WORKTREE_FAILED',
|
|
239
|
-
`Git still registers ${managedPath}, but its exact worktree metadata root is unavailable`,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
const expected = path.resolve(managedPath);
|
|
243
|
-
const matches = [];
|
|
244
|
-
for (const entry of fs.readdirSync(registryRoot, { withFileTypes: true })) {
|
|
245
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
246
|
-
const adminDir = path.join(registryRoot, entry.name);
|
|
247
|
-
const gitdirFile = path.join(adminDir, 'gitdir');
|
|
248
|
-
const gitdirStat = fs.lstatSync(gitdirFile, { throwIfNoEntry: false });
|
|
249
|
-
if (!gitdirStat?.isFile() || gitdirStat.isSymbolicLink()) continue;
|
|
250
|
-
const gitdir = fs.readFileSync(gitdirFile, 'utf8').trim();
|
|
251
|
-
if (!gitdir) continue;
|
|
252
|
-
const recorded = path.dirname(
|
|
253
|
-
path.isAbsolute(gitdir) ? path.resolve(gitdir) : path.resolve(adminDir, gitdir),
|
|
254
|
-
);
|
|
255
|
-
if (recorded === expected) matches.push(adminDir);
|
|
256
|
-
}
|
|
257
|
-
if (matches.length !== 1) {
|
|
258
|
-
throw new SessionRunnerError(
|
|
259
|
-
'WORKTREE_FAILED',
|
|
260
|
-
`cannot identify one exact Git registry entry for removed worktree ${managedPath}`,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
fs.rmSync(matches[0], { recursive: true, force: false });
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function reconcileRemovedWorktree(repoRoot, worktreePath, { sid } = {}) {
|
|
267
|
-
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
268
|
-
sid: sid || path.basename(worktreePath),
|
|
269
|
-
validateRuntimeTree: false,
|
|
270
|
-
});
|
|
271
|
-
const registryKey = physicalCandidate(managedPath);
|
|
272
|
-
let registry = gitWorktreeRegistry(repoRoot);
|
|
273
|
-
if (fs.existsSync(managedPath)) {
|
|
274
|
-
gitWorktreeRemove(repoRoot, managedPath);
|
|
275
|
-
} else if (registry.has(registryKey)) {
|
|
276
|
-
try {
|
|
277
|
-
execFileSync('git', ['worktree', 'remove', '--force', managedPath], {
|
|
278
|
-
cwd: repoRoot,
|
|
279
|
-
stdio: 'pipe',
|
|
280
|
-
});
|
|
281
|
-
} catch {
|
|
282
|
-
removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
registry = gitWorktreeRegistry(repoRoot);
|
|
286
|
-
if (!fs.existsSync(managedPath) && registry.has(registryKey)) {
|
|
287
|
-
removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
|
|
288
|
-
registry = gitWorktreeRegistry(repoRoot);
|
|
289
|
-
}
|
|
290
|
-
if (registry.has(registryKey)) {
|
|
291
|
-
throw new SessionRunnerError(
|
|
292
|
-
'WORKTREE_FAILED',
|
|
293
|
-
`Git still registers the managed worktree after removal: ${managedPath}`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
return managedPath;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function authorityLinkIsIgnored(worktreePath) {
|
|
300
|
-
try {
|
|
301
|
-
execFileSync(
|
|
302
|
-
'git',
|
|
303
|
-
['check-ignore', '--quiet', '--no-index', '--', '.ultra/.runtime'],
|
|
304
|
-
{ cwd: worktreePath, stdio: 'ignore' },
|
|
305
|
-
);
|
|
306
|
-
return true;
|
|
307
|
-
} catch (error) {
|
|
308
|
-
if (error.status === 1) return false;
|
|
309
|
-
throw new SessionRunnerError(
|
|
310
|
-
'WORKTREE_AUTHORITY_LINK_FAILED',
|
|
311
|
-
`cannot verify the worktree Ultra ignore rule: ${error.message}`,
|
|
312
|
-
{ cause: error },
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function ensureAuthorityLinkIgnored(worktreePath) {
|
|
318
|
-
if (authorityLinkIsIgnored(worktreePath)) return { changed: false, path: null };
|
|
319
|
-
let output;
|
|
320
|
-
try {
|
|
321
|
-
output = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], {
|
|
322
|
-
cwd: worktreePath,
|
|
323
|
-
encoding: 'utf8',
|
|
324
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
325
|
-
}).trim();
|
|
326
|
-
} catch (error) {
|
|
327
|
-
throw new SessionRunnerError(
|
|
328
|
-
'WORKTREE_AUTHORITY_LINK_FAILED',
|
|
329
|
-
`cannot locate the repository-local exclude file: ${error.message}`,
|
|
330
|
-
{ cause: error },
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
const excludePath = path.isAbsolute(output)
|
|
334
|
-
? output
|
|
335
|
-
: path.resolve(worktreePath, output);
|
|
336
|
-
let stat = null;
|
|
337
|
-
try { stat = fs.lstatSync(excludePath); }
|
|
338
|
-
catch (error) {
|
|
339
|
-
if (error.code !== 'ENOENT') {
|
|
340
|
-
throw new SessionRunnerError(
|
|
341
|
-
'WORKTREE_AUTHORITY_LINK_FAILED',
|
|
342
|
-
`cannot inspect ${excludePath}: ${error.message}`,
|
|
343
|
-
{ cause: error },
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (stat && (stat.isSymbolicLink() || !stat.isFile())) {
|
|
348
|
-
throw new SessionRunnerError(
|
|
349
|
-
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
350
|
-
`refusing to update unsafe Git exclude path: ${excludePath}`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
const before = stat ? fs.readFileSync(excludePath, 'utf8') : '';
|
|
354
|
-
const prefix = before && !before.endsWith('\n') ? `${before}\n` : before;
|
|
355
|
-
const separator = prefix && !prefix.endsWith('\n\n') ? '\n' : '';
|
|
356
|
-
try {
|
|
357
|
-
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
358
|
-
fs.writeFileSync(
|
|
359
|
-
excludePath,
|
|
360
|
-
`${prefix}${separator}# Ultra Builder Pro shared session runtime\n/.ultra/.runtime\n`,
|
|
361
|
-
);
|
|
362
|
-
if (!authorityLinkIsIgnored(worktreePath)) {
|
|
363
|
-
throw new Error('Git still does not ignore the session authority link');
|
|
364
|
-
}
|
|
365
|
-
} catch (error) {
|
|
366
|
-
try {
|
|
367
|
-
if (stat) fs.writeFileSync(excludePath, before);
|
|
368
|
-
else fs.rmSync(excludePath, { force: true });
|
|
369
|
-
} catch { /* best effort */ }
|
|
370
|
-
throw new SessionRunnerError(
|
|
371
|
-
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
372
|
-
`cannot establish a safe local ignore rule for .ultra/.runtime: ${error.message}`,
|
|
373
|
-
{ cause: error },
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
return { changed: true, path: excludePath };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function linkAuthorityIntoWorktree(repoRoot, worktreePath, {
|
|
380
|
-
authority: preadmittedAuthority = null,
|
|
381
|
-
} = {}) {
|
|
382
|
-
let authority = preadmittedAuthority;
|
|
383
|
-
if (!authority) {
|
|
384
|
-
try {
|
|
385
|
-
authority = runtimePaths.ensureRuntimeState(repoRoot, {
|
|
386
|
-
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
387
|
-
});
|
|
388
|
-
} catch (error) {
|
|
389
|
-
throw new SessionRunnerError(
|
|
390
|
-
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
391
|
-
`cannot establish the project Ultra storage boundary: ${error.message}`,
|
|
392
|
-
{ cause: error },
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
const authorityDir = authority.runtimeDir;
|
|
397
|
-
const stateDbPath = authority.stateDbPath;
|
|
398
|
-
const worktreeUltraDir = path.join(worktreePath, '.ultra');
|
|
399
|
-
const linkPath = path.join(worktreeUltraDir, '.runtime');
|
|
400
|
-
if (!fs.existsSync(stateDbPath)) {
|
|
401
|
-
throw new SessionRunnerError(
|
|
402
|
-
'WORKTREE_AUTHORITY_MISSING',
|
|
403
|
-
`central Ultra authority is missing at ${stateDbPath}`,
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
ensureAuthorityLinkIgnored(worktreePath);
|
|
407
|
-
try {
|
|
408
|
-
fs.mkdirSync(worktreeUltraDir, { recursive: true });
|
|
409
|
-
if (fs.existsSync(linkPath) || fs.lstatSync(linkPath, { throwIfNoEntry: false })) {
|
|
410
|
-
throw new SessionRunnerError(
|
|
411
|
-
'WORKTREE_AUTHORITY_CONFLICT',
|
|
412
|
-
`worktree already contains an .ultra/.runtime entry at ${linkPath}`,
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
fs.symlinkSync(
|
|
416
|
-
process.platform === 'win32' ? authorityDir : path.relative(worktreeUltraDir, authorityDir),
|
|
417
|
-
linkPath,
|
|
418
|
-
process.platform === 'win32' ? 'junction' : 'dir',
|
|
419
|
-
);
|
|
420
|
-
if (fs.realpathSync(path.join(linkPath, 'state.db')) !== fs.realpathSync(stateDbPath)) {
|
|
421
|
-
throw new Error('linked state.db does not resolve to the central authority');
|
|
422
|
-
}
|
|
423
|
-
try {
|
|
424
|
-
gitBootstrap.assertUltraStorageBoundary(worktreePath);
|
|
425
|
-
} catch (error) {
|
|
426
|
-
fs.rmSync(linkPath, { force: true });
|
|
427
|
-
throw new SessionRunnerError(
|
|
428
|
-
'WORKTREE_STORAGE_BOUNDARY_STALE',
|
|
429
|
-
`selected worktree revision does not preserve Ultra semantic artifacts: ${error.message}`,
|
|
430
|
-
{ cause: error },
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
return { authorityDir, stateDbPath, linkPath };
|
|
434
|
-
} catch (error) {
|
|
435
|
-
throw error instanceof SessionRunnerError ? error : new SessionRunnerError(
|
|
436
|
-
'WORKTREE_AUTHORITY_LINK_FAILED',
|
|
437
|
-
`cannot link the worktree to central Ultra authority: ${error.message}`,
|
|
438
|
-
{ cause: error },
|
|
439
|
-
);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function worktreeRemovalBlocker(repoRoot, worktreePath) {
|
|
444
|
-
const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
|
|
445
|
-
sid: path.basename(worktreePath),
|
|
446
|
-
});
|
|
447
|
-
if (!fs.existsSync(managedPath)) return null;
|
|
448
|
-
try {
|
|
449
|
-
const status = execFileSync('git', ['status', '--porcelain=v1'], {
|
|
450
|
-
cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
451
|
-
}).trim();
|
|
452
|
-
if (status) return 'worktree contains uncommitted changes';
|
|
453
|
-
const sessionHead = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
454
|
-
cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
455
|
-
}).trim();
|
|
456
|
-
const repositoryHead = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
457
|
-
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
458
|
-
}).trim();
|
|
459
|
-
execFileSync(
|
|
460
|
-
'git', ['merge-base', '--is-ancestor', sessionHead, repositoryHead],
|
|
461
|
-
{ cwd: repoRoot, stdio: ['ignore', 'ignore', 'ignore'] },
|
|
462
|
-
);
|
|
463
|
-
return null;
|
|
464
|
-
} catch (error) {
|
|
465
|
-
if (error.status === 1) return 'worktree commit has not been integrated into the current checkout';
|
|
466
|
-
return `worktree integration could not be verified: ${error.message}`;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function changeTaskIntegrationBlocker(db, task, worktreePath) {
|
|
471
|
-
if (!task?.change_id) return null;
|
|
472
|
-
let worktreeHead;
|
|
473
|
-
try {
|
|
474
|
-
worktreeHead = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
475
|
-
cwd: worktreePath,
|
|
476
|
-
encoding: 'utf8',
|
|
477
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
478
|
-
}).trim();
|
|
479
|
-
} catch (error) {
|
|
480
|
-
return {
|
|
481
|
-
code: 'WORKTREE_HEAD_UNAVAILABLE',
|
|
482
|
-
message: `cannot verify task worktree HEAD: ${error.message}`,
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
if (!task.completion_commit || task.completion_commit !== worktreeHead) {
|
|
486
|
-
return {
|
|
487
|
-
code: 'TASK_COMPLETION_COMMIT_STALE',
|
|
488
|
-
message: `task ${task.id} completion commit does not match worktree HEAD`,
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
const dev = checkpoints.currentCheckpoint(
|
|
492
|
-
db,
|
|
493
|
-
'dev',
|
|
494
|
-
{ task_id: task.id },
|
|
495
|
-
{ includeDraft: false },
|
|
496
|
-
);
|
|
497
|
-
if (!dev || checkpoints.checkpointDigest(dev) !== dev.digest) {
|
|
498
|
-
return {
|
|
499
|
-
code: 'DEV_CHECKPOINT_NOT_ACCEPTED',
|
|
500
|
-
message: `task ${task.id} has no current accepted Dev checkpoint`,
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
const review = checkpoints.currentCheckpoint(
|
|
504
|
-
db,
|
|
505
|
-
'review',
|
|
506
|
-
{ task_id: task.id },
|
|
507
|
-
{ includeDraft: false },
|
|
508
|
-
);
|
|
509
|
-
if (!review || checkpoints.checkpointDigest(review) !== review.digest) {
|
|
510
|
-
return {
|
|
511
|
-
code: 'REVIEW_CHECKPOINT_NOT_ACCEPTED',
|
|
512
|
-
message: `task ${task.id} has no current accepted Review checkpoint`,
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function physicalCandidate(candidate) {
|
|
519
|
-
let existing = candidate;
|
|
520
|
-
while (!fs.lstatSync(existing, { throwIfNoEntry: false })) {
|
|
521
|
-
const parent = path.dirname(existing);
|
|
522
|
-
if (parent === existing) break;
|
|
523
|
-
existing = parent;
|
|
524
|
-
}
|
|
525
|
-
const physicalExisting = fs.realpathSync(existing);
|
|
526
|
-
return path.resolve(physicalExisting, path.relative(existing, candidate));
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function resolveWorktreeBase(repoRoot, requestedBase) {
|
|
530
|
-
const managedBase = runtimePaths.pathsFor(repoRoot).worktreesDir;
|
|
531
|
-
const resolved = requestedBase
|
|
532
|
-
? path.resolve(repoRoot, requestedBase)
|
|
533
|
-
: managedBase;
|
|
534
|
-
const physicalManaged = physicalCandidate(managedBase);
|
|
535
|
-
const physicalResolved = physicalCandidate(resolved);
|
|
536
|
-
if (resolved !== path.resolve(managedBase) || physicalResolved !== physicalManaged) {
|
|
537
|
-
throw new SessionRunnerError(
|
|
538
|
-
'WORKTREE_SCOPE_INVALID',
|
|
539
|
-
`worktree_base must be the canonical managed root ${managedBase}`,
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
return resolved;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function mintSessionId() {
|
|
546
|
-
return `sess-${randomUUID().slice(0, 8)}`;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function assertSessionTaskReady(db, repoRoot, taskId) {
|
|
550
|
-
const task = ops.readTask(db, taskId);
|
|
551
|
-
if (!task) return null; // admissionCheck reports the canonical TASK_NOT_FOUND error.
|
|
552
|
-
if (!['pending', 'in_progress', 'blocked'].includes(task.status) || task.stale) {
|
|
553
|
-
throw new SessionRunnerError(
|
|
554
|
-
'TASK_NOT_EXECUTABLE',
|
|
555
|
-
`task ${task.id} is not executable from status ${task.status}${task.stale ? ' (stale)' : ''}`,
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
return task;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function assertKernelSessionTaskReady(db, repoRoot, taskId, packetDigest) {
|
|
562
|
-
const task = ops.readTask(db, taskId);
|
|
563
|
-
if (!task) {
|
|
564
|
-
throw new SessionRunnerError('TASK_NOT_FOUND', `task ${taskId} not found`);
|
|
565
|
-
}
|
|
566
|
-
if (!['pending', 'in_progress', 'blocked'].includes(task.status) || task.stale) {
|
|
567
|
-
throw new SessionRunnerError(
|
|
568
|
-
'TASK_NOT_EXECUTABLE',
|
|
569
|
-
`task ${task.id} is not executable from status ${task.status}${task.stale ? ' (stale)' : ''}`,
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
const packet = packetDigest
|
|
573
|
-
? db.prepare(
|
|
574
|
-
`SELECT id FROM worker_packets
|
|
575
|
-
WHERE packet_digest = ? AND scope_type = 'task' AND scope_id = ?
|
|
576
|
-
AND status IN ('pending', 'assigned')`,
|
|
577
|
-
).get(packetDigest, task.id)
|
|
578
|
-
: null;
|
|
579
|
-
if (!packet) {
|
|
580
|
-
throw new SessionRunnerError(
|
|
581
|
-
'WORKER_PACKET_REQUIRED',
|
|
582
|
-
`task ${task.id} requires the exact Worker Packet prepared for this session`,
|
|
583
|
-
);
|
|
584
|
-
}
|
|
585
|
-
workerPackets.readWorkerPacket(db, packet.id, { rootDir: repoRoot });
|
|
586
|
-
return task;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function admissionCheck(db, repoRoot, taskId, options = {}) {
|
|
590
|
-
assertSessionTaskReady(db, repoRoot, taskId);
|
|
591
|
-
const pendingClose = closeJournal.findForTask(repoRoot, taskId);
|
|
592
|
-
if (pendingClose) {
|
|
593
|
-
return {
|
|
594
|
-
can_spawn: false,
|
|
595
|
-
conflict: {
|
|
596
|
-
sid: pendingClose.sid,
|
|
597
|
-
status: 'closing',
|
|
598
|
-
heartbeat_age_ms: 0,
|
|
599
|
-
},
|
|
600
|
-
recommended_action: 'recover_close',
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
return ops.admissionCheck(db, taskId, options);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function startSessionProcess({
|
|
607
|
-
db,
|
|
608
|
-
repoRoot,
|
|
609
|
-
sid,
|
|
610
|
-
command,
|
|
611
|
-
args = [],
|
|
612
|
-
env = {},
|
|
613
|
-
stdio = 'ignore',
|
|
614
|
-
authority = null,
|
|
615
|
-
}) {
|
|
616
|
-
if (!db || !repoRoot || !sid || typeof command !== 'string' || !command.trim()) {
|
|
617
|
-
throw new SessionRunnerError(
|
|
618
|
-
'VALIDATION_ERROR',
|
|
619
|
-
'db, repoRoot, sid, and an executable command are required',
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
if (!Array.isArray(args)) {
|
|
623
|
-
throw new SessionRunnerError('VALIDATION_ERROR', 'session process args must be an array');
|
|
624
|
-
}
|
|
625
|
-
const session = ops.readSession(db, sid);
|
|
626
|
-
if (!session) throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
|
|
627
|
-
if (session.status !== 'running' || session.pid !== null) {
|
|
628
|
-
throw new SessionRunnerError(
|
|
629
|
-
'SESSION_NOT_STARTABLE',
|
|
630
|
-
`session ${sid} cannot start from status=${session.status}, pid=${String(session.pid)}`,
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
const worktreePath = assertManagedWorktreePath(repoRoot, session.worktree_path, { sid });
|
|
634
|
-
const resolvedAuthority = authority || runtimePaths.ensureRuntimeState(repoRoot, {
|
|
635
|
-
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
636
|
-
});
|
|
637
|
-
let proc;
|
|
638
|
-
try {
|
|
639
|
-
proc = spawn(command, args, {
|
|
640
|
-
cwd: worktreePath,
|
|
641
|
-
env: {
|
|
642
|
-
...process.env,
|
|
643
|
-
...env,
|
|
644
|
-
UBP_SESSION_ID: sid,
|
|
645
|
-
UBP_TASK_ID: session.task_id,
|
|
646
|
-
UBP_RUNTIME: session.runtime,
|
|
647
|
-
UBP_WORKTREE: worktreePath,
|
|
648
|
-
UBP_ARTIFACT_DIR: session.artifact_dir,
|
|
649
|
-
UBP_DB_PATH: resolvedAuthority.stateDbPath,
|
|
650
|
-
UBP_ROOT_DIR: worktreePath,
|
|
651
|
-
UBP_AUTHORITY_ROOT: repoRoot,
|
|
652
|
-
},
|
|
653
|
-
stdio,
|
|
654
|
-
detached: false,
|
|
655
|
-
});
|
|
656
|
-
if (Number.isInteger(proc.pid)) {
|
|
657
|
-
ops.updateSession(db, sid, { pid: proc.pid });
|
|
658
|
-
}
|
|
659
|
-
return proc;
|
|
660
|
-
} catch (error) {
|
|
661
|
-
try { if (proc && !proc.killed) proc.kill('SIGTERM'); } catch { /* best effort */ }
|
|
662
|
-
try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
|
|
663
|
-
throw error;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function spawnSession({
|
|
668
|
-
db,
|
|
669
|
-
repoRoot,
|
|
670
|
-
task_id,
|
|
671
|
-
runtime,
|
|
672
|
-
authority: preadmittedAuthority = null,
|
|
673
|
-
takeover = false,
|
|
674
|
-
command = null,
|
|
675
|
-
args = [],
|
|
676
|
-
env = {},
|
|
677
|
-
stdio = 'ignore',
|
|
678
|
-
worktreeRef = 'HEAD',
|
|
679
|
-
worktree_base = null,
|
|
680
|
-
lease_seconds = 1800,
|
|
681
|
-
mark_task_started = false,
|
|
682
|
-
takeover_timeout_ms = 5000,
|
|
683
|
-
takeover_poll_ms = 25,
|
|
684
|
-
kernel_mode = false,
|
|
685
|
-
packet_digest = null,
|
|
686
|
-
}) {
|
|
687
|
-
if (!db) throw new SessionRunnerError('VALIDATION_ERROR', 'db handle required');
|
|
688
|
-
if (!repoRoot) throw new SessionRunnerError('VALIDATION_ERROR', 'repoRoot required');
|
|
689
|
-
if (!task_id) throw new SessionRunnerError('VALIDATION_ERROR', 'task_id required');
|
|
690
|
-
if (!runtime) throw new SessionRunnerError('VALIDATION_ERROR', 'runtime required');
|
|
691
|
-
const worktreeBase = resolveWorktreeBase(repoRoot, worktree_base);
|
|
692
|
-
|
|
693
|
-
// 1. Admission check
|
|
694
|
-
const verdict = ops.admissionCheck(db, task_id);
|
|
695
|
-
// Circuit breaker trumps takeover — tripped tasks require explicit reset.
|
|
696
|
-
if (verdict.recommended_action === 'blocked_by_breaker') {
|
|
697
|
-
const err = new SessionRunnerError(
|
|
698
|
-
'CIRCUIT_TRIPPED',
|
|
699
|
-
`task ${task_id} is tripped by circuit breaker; call resetCircuitBreaker before spawning`,
|
|
700
|
-
);
|
|
701
|
-
err.verdict = verdict;
|
|
702
|
-
throw err;
|
|
703
|
-
}
|
|
704
|
-
// Validate workflow authority before takeover can terminate the prior worker
|
|
705
|
-
// or any filesystem mutation creates a new worktree.
|
|
706
|
-
if (kernel_mode) assertKernelSessionTaskReady(db, repoRoot, task_id, packet_digest);
|
|
707
|
-
else assertSessionTaskReady(db, repoRoot, task_id);
|
|
708
|
-
let authority = preadmittedAuthority;
|
|
709
|
-
if (!authority) {
|
|
710
|
-
try {
|
|
711
|
-
authority = runtimePaths.ensureRuntimeState(repoRoot, {
|
|
712
|
-
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
|
|
713
|
-
});
|
|
714
|
-
} catch (error) {
|
|
715
|
-
throw new SessionRunnerError(
|
|
716
|
-
'WORKTREE_AUTHORITY_NOT_IGNORED',
|
|
717
|
-
`cannot establish the project Ultra storage boundary: ${error.message}`,
|
|
718
|
-
{ cause: error },
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
const pendingClose = closeJournal.findForTask(repoRoot, task_id);
|
|
723
|
-
if (pendingClose) {
|
|
724
|
-
throw new SessionRunnerError(
|
|
725
|
-
'SESSION_CLOSE_PENDING',
|
|
726
|
-
`task ${task_id} has an unfinished close intent for session ${pendingClose.sid}; recover it before spawning or taking over`,
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
if (!verdict.can_spawn && !takeover) {
|
|
730
|
-
const err = new SessionRunnerError(
|
|
731
|
-
'ADMISSION_DENIED',
|
|
732
|
-
`active session exists for task ${task_id} (${verdict.conflict && verdict.conflict.sid}); recommended=${verdict.recommended_action}`,
|
|
733
|
-
);
|
|
734
|
-
err.verdict = verdict;
|
|
735
|
-
throw err;
|
|
736
|
-
}
|
|
737
|
-
if (!verdict.can_spawn && takeover && verdict.conflict) {
|
|
738
|
-
const priorSession = ops.readSession(db, verdict.conflict.sid);
|
|
739
|
-
if (priorSession?.pid) {
|
|
740
|
-
try { process.kill(priorSession.pid, 'SIGTERM'); }
|
|
741
|
-
catch (error) {
|
|
742
|
-
if (error.code !== 'ESRCH') {
|
|
743
|
-
throw new SessionRunnerError(
|
|
744
|
-
'TAKEOVER_FAILED',
|
|
745
|
-
`cannot terminate prior session ${verdict.conflict.sid}: ${error.message}`,
|
|
746
|
-
);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
if (!waitForProcessExit(priorSession.pid, {
|
|
750
|
-
timeoutMs: takeover_timeout_ms,
|
|
751
|
-
pollMs: takeover_poll_ms,
|
|
752
|
-
})) {
|
|
753
|
-
throw new SessionRunnerError(
|
|
754
|
-
'TAKEOVER_FAILED',
|
|
755
|
-
`prior session ${verdict.conflict.sid} process ${priorSession.pid} did not exit; its lease remains authoritative`,
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
ops.updateSession(db, verdict.conflict.sid, { status: 'crashed' });
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// 2. Mint sid + paths
|
|
763
|
-
const sid = mintSessionId();
|
|
764
|
-
const worktree_path = path.join(worktreeBase, sid);
|
|
765
|
-
const artifact_dir = path.join(runtimePaths.pathsFor(repoRoot).sessionsDir, sid);
|
|
766
|
-
|
|
767
|
-
// 3. Git worktree (throws WORKTREE_FAILED on failure)
|
|
768
|
-
gitWorktreeAdd(repoRoot, worktree_path, worktreeRef);
|
|
769
|
-
try {
|
|
770
|
-
authority = linkAuthorityIntoWorktree(repoRoot, worktree_path, { authority });
|
|
771
|
-
fs.mkdirSync(artifact_dir, { recursive: true });
|
|
772
|
-
} catch (error) {
|
|
773
|
-
try {
|
|
774
|
-
fs.rmSync(path.join(worktree_path, '.ultra', '.runtime'), { force: true });
|
|
775
|
-
} catch { /* best effort */ }
|
|
776
|
-
gitWorktreeRemove(repoRoot, worktree_path);
|
|
777
|
-
throw error;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// 4. Persist the task/worktree binding before any worker can run a hook or
|
|
781
|
-
// resolve UBP_DB_PATH. This is the anti-spoof authority hand-off.
|
|
782
|
-
let session;
|
|
783
|
-
try {
|
|
784
|
-
session = ops.createSession(db, {
|
|
785
|
-
sid,
|
|
786
|
-
task_id,
|
|
787
|
-
runtime,
|
|
788
|
-
pid: null,
|
|
789
|
-
worktree_path,
|
|
790
|
-
artifact_dir,
|
|
791
|
-
lease_seconds,
|
|
792
|
-
});
|
|
793
|
-
} catch (err) {
|
|
794
|
-
// state.db insert failed — no worker has started; roll back the worktree.
|
|
795
|
-
gitWorktreeRemove(repoRoot, worktree_path);
|
|
796
|
-
try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
797
|
-
throw err;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Automated dispatch must publish the running task state before the worker
|
|
801
|
-
// can observe or complete it. Manual host-owned sessions retain the caller's
|
|
802
|
-
// task state unless they explicitly opt into this ordering contract.
|
|
803
|
-
try {
|
|
804
|
-
if (mark_task_started && ops.readTask(db, task_id)?.status === 'pending') {
|
|
805
|
-
ops.updateTaskStatus(db, task_id, 'in_progress');
|
|
806
|
-
}
|
|
807
|
-
} catch (error) {
|
|
808
|
-
try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
|
|
809
|
-
try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
|
|
810
|
-
try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
811
|
-
throw error;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// 5. Spawn child process only after the durable binding exists. The active
|
|
815
|
-
// host may also consume the returned worktree without a child process.
|
|
816
|
-
let proc = null;
|
|
817
|
-
try {
|
|
818
|
-
if (command) {
|
|
819
|
-
proc = startSessionProcess({
|
|
820
|
-
db,
|
|
821
|
-
repoRoot,
|
|
822
|
-
sid,
|
|
823
|
-
command,
|
|
824
|
-
args,
|
|
825
|
-
env,
|
|
826
|
-
stdio,
|
|
827
|
-
authority,
|
|
828
|
-
});
|
|
829
|
-
session = ops.readSession(db, sid);
|
|
830
|
-
}
|
|
831
|
-
} catch (error) {
|
|
832
|
-
ops.updateSession(db, sid, { status: 'crashed' });
|
|
833
|
-
try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
|
|
834
|
-
throw error;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
return {
|
|
838
|
-
sid,
|
|
839
|
-
worktree_path,
|
|
840
|
-
artifact_dir,
|
|
841
|
-
authority_db_path: authority.stateDbPath,
|
|
842
|
-
lease_expires_at: session.lease_expires_at,
|
|
843
|
-
pid: proc ? proc.pid : null,
|
|
844
|
-
process: proc,
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function processIsExecuting(pid) {
|
|
849
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
850
|
-
try {
|
|
851
|
-
process.kill(pid, 0);
|
|
852
|
-
} catch (error) {
|
|
853
|
-
if (error.code === 'ESRCH') return false;
|
|
854
|
-
if (error.code !== 'EPERM') throw error;
|
|
855
|
-
}
|
|
856
|
-
try {
|
|
857
|
-
const state = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
858
|
-
encoding: 'utf8',
|
|
859
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
860
|
-
}).trim();
|
|
861
|
-
return Boolean(state) && !state.startsWith('Z');
|
|
862
|
-
} catch {
|
|
863
|
-
// A failed ps lookup after signal 0 normally means the process vanished.
|
|
864
|
-
try {
|
|
865
|
-
process.kill(pid, 0);
|
|
866
|
-
return true;
|
|
867
|
-
} catch (error) {
|
|
868
|
-
return error.code !== 'ESRCH';
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function waitForProcessExit(pid, {
|
|
874
|
-
timeoutMs = 5000,
|
|
875
|
-
pollMs = 25,
|
|
876
|
-
} = {}) {
|
|
877
|
-
const deadline = Date.now() + timeoutMs;
|
|
878
|
-
while (processIsExecuting(pid)) {
|
|
879
|
-
if (Date.now() >= deadline) return false;
|
|
880
|
-
Atomics.wait(
|
|
881
|
-
new Int32Array(new SharedArrayBuffer(4)),
|
|
882
|
-
0,
|
|
883
|
-
0,
|
|
884
|
-
Math.min(pollMs, Math.max(1, deadline - Date.now())),
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
return true;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function closeSession({
|
|
891
|
-
db,
|
|
892
|
-
repoRoot,
|
|
893
|
-
sid,
|
|
894
|
-
}, {
|
|
895
|
-
status = 'completed',
|
|
896
|
-
remove_worktree = false,
|
|
897
|
-
kill_signal = 'SIGTERM',
|
|
898
|
-
autoMerge = false,
|
|
899
|
-
mergeBaseBranch = 'main',
|
|
900
|
-
kill_timeout_ms = 5000,
|
|
901
|
-
kill_poll_ms = 25,
|
|
902
|
-
} = {}) {
|
|
903
|
-
if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
|
|
904
|
-
if (!closeJournal.TERMINAL_STATUSES.has(status)) {
|
|
905
|
-
throw new SessionRunnerError(
|
|
906
|
-
'VALIDATION_ERROR',
|
|
907
|
-
`session close status must be completed or crashed, got ${String(status)}`,
|
|
908
|
-
);
|
|
909
|
-
}
|
|
910
|
-
const session = ops.readSession(db, sid);
|
|
911
|
-
if (!session) {
|
|
912
|
-
throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
|
|
913
|
-
}
|
|
914
|
-
if (closeJournal.TERMINAL_STATUSES.has(session.status)) {
|
|
915
|
-
if (session.status !== status) {
|
|
916
|
-
throw new SessionRunnerError(
|
|
917
|
-
'SESSION_TERMINAL_CONFLICT',
|
|
918
|
-
`session ${sid} is already ${session.status} and cannot be closed as ${status}`,
|
|
919
|
-
);
|
|
920
|
-
}
|
|
921
|
-
// A terminal row may contain a legacy PID that has since been reused by an
|
|
922
|
-
// unrelated process. Terminal authority is idempotent: never signal that
|
|
923
|
-
// PID, and clear it before reconciling any durable close intent.
|
|
924
|
-
if (session.pid !== null) ops.updateSession(db, sid, { pid: null });
|
|
925
|
-
const intent = repoRoot ? closeJournal.read(repoRoot, sid) : null;
|
|
926
|
-
if (intent) {
|
|
927
|
-
if (intent.requested_status !== status
|
|
928
|
-
|| intent.task_id !== session.task_id
|
|
929
|
-
|| path.resolve(intent.worktree_path) !== path.resolve(session.worktree_path)) {
|
|
930
|
-
throw new SessionRunnerError(
|
|
931
|
-
'SESSION_CLOSE_JOURNAL_CONFLICT',
|
|
932
|
-
`session ${sid} terminal state conflicts with its durable close intent`,
|
|
933
|
-
);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
if ((intent || remove_worktree) && repoRoot && session.worktree_path) {
|
|
937
|
-
assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
|
|
938
|
-
if (fs.existsSync(session.worktree_path)) {
|
|
939
|
-
const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
|
|
940
|
-
if (blocker) {
|
|
941
|
-
throw new SessionRunnerError(
|
|
942
|
-
'WORKTREE_NOT_INTEGRATED',
|
|
943
|
-
`refusing to remove ${session.worktree_path}: ${blocker}`,
|
|
944
|
-
);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
if (!intent) {
|
|
948
|
-
closeJournal.prepare(repoRoot, {
|
|
949
|
-
sid,
|
|
950
|
-
task_id: session.task_id,
|
|
951
|
-
requested_status: status,
|
|
952
|
-
worktree_path: session.worktree_path,
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
try {
|
|
956
|
-
reconcileRemovedWorktree(repoRoot, session.worktree_path, { sid: session.sid });
|
|
957
|
-
closeJournal.update(repoRoot, sid, {
|
|
958
|
-
phase: 'worktree_removed',
|
|
959
|
-
error: null,
|
|
960
|
-
});
|
|
961
|
-
closeJournal.discard(repoRoot, sid);
|
|
962
|
-
} catch (error) {
|
|
963
|
-
closeJournal.update(repoRoot, sid, {
|
|
964
|
-
phase: 'recovery_failed',
|
|
965
|
-
error: error.message,
|
|
966
|
-
});
|
|
967
|
-
throw error;
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
return {
|
|
971
|
-
sid,
|
|
972
|
-
status,
|
|
973
|
-
worktree_preserved: Boolean(
|
|
974
|
-
session.worktree_path && fs.existsSync(session.worktree_path),
|
|
975
|
-
),
|
|
976
|
-
};
|
|
977
|
-
}
|
|
978
|
-
if ((remove_worktree || autoMerge) && session.worktree_path) {
|
|
979
|
-
assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
|
|
980
|
-
}
|
|
981
|
-
let closeIntentPrepared = false;
|
|
982
|
-
if (remove_worktree && !autoMerge && repoRoot && session.worktree_path) {
|
|
983
|
-
closeJournal.prepare(repoRoot, {
|
|
984
|
-
sid,
|
|
985
|
-
task_id: session.task_id,
|
|
986
|
-
requested_status: status,
|
|
987
|
-
worktree_path: session.worktree_path,
|
|
988
|
-
});
|
|
989
|
-
closeIntentPrepared = true;
|
|
990
|
-
}
|
|
991
|
-
if (session.pid) {
|
|
992
|
-
try {
|
|
993
|
-
process.kill(session.pid, kill_signal);
|
|
994
|
-
} catch (error) {
|
|
995
|
-
if (error.code !== 'ESRCH') {
|
|
996
|
-
throw new SessionRunnerError(
|
|
997
|
-
'SESSION_TERMINATION_FAILED',
|
|
998
|
-
`cannot signal session ${sid} process ${session.pid}: ${error.message}`,
|
|
999
|
-
{ cause: error },
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
if (!waitForProcessExit(session.pid, {
|
|
1004
|
-
timeoutMs: kill_timeout_ms,
|
|
1005
|
-
pollMs: kill_poll_ms,
|
|
1006
|
-
})) {
|
|
1007
|
-
if (closeIntentPrepared) {
|
|
1008
|
-
closeJournal.update(repoRoot, sid, {
|
|
1009
|
-
phase: 'worker_running',
|
|
1010
|
-
error: `process ${session.pid} did not exit after ${kill_timeout_ms}ms`,
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
throw new SessionRunnerError(
|
|
1014
|
-
'SESSION_TERMINATION_TIMEOUT',
|
|
1015
|
-
`session ${sid} process ${session.pid} did not exit after ${kill_timeout_ms}ms; lease and worktree were preserved`,
|
|
1016
|
-
);
|
|
1017
|
-
}
|
|
1018
|
-
// The owned worker is gone. Clear its reusable OS identifier even if a
|
|
1019
|
-
// later merge or worktree gate keeps this close intent recoverable.
|
|
1020
|
-
ops.updateSession(db, sid, { pid: null });
|
|
1021
|
-
}
|
|
1022
|
-
// Phase 8B.4 — opt-in auto-merge. Conflict preserves worktree for the
|
|
1023
|
-
// human; clean/no-op merges fall through to normal removal below.
|
|
1024
|
-
let mergeResult;
|
|
1025
|
-
let effectiveRemove = remove_worktree;
|
|
1026
|
-
if (autoMerge && status === 'completed' && repoRoot && session.worktree_path) {
|
|
1027
|
-
const task = session.task_id ? ops.readTask(db, session.task_id) : null;
|
|
1028
|
-
if (!task || task.status !== 'completed') {
|
|
1029
|
-
mergeResult = {
|
|
1030
|
-
merged: false,
|
|
1031
|
-
reason: 'task_not_completed',
|
|
1032
|
-
task_status: task?.status || null,
|
|
1033
|
-
};
|
|
1034
|
-
effectiveRemove = false;
|
|
1035
|
-
} else {
|
|
1036
|
-
const integrationBlocker = changeTaskIntegrationBlocker(
|
|
1037
|
-
db,
|
|
1038
|
-
task,
|
|
1039
|
-
session.worktree_path,
|
|
1040
|
-
);
|
|
1041
|
-
if (integrationBlocker) {
|
|
1042
|
-
mergeResult = {
|
|
1043
|
-
merged: false,
|
|
1044
|
-
reason: 'workflow_gates_open',
|
|
1045
|
-
blocker: integrationBlocker.code,
|
|
1046
|
-
message: integrationBlocker.message,
|
|
1047
|
-
};
|
|
1048
|
-
effectiveRemove = false;
|
|
1049
|
-
} else {
|
|
1050
|
-
const autoMergeMod = require('./auto-merge.cjs');
|
|
1051
|
-
mergeResult = autoMergeMod.autoMerge({
|
|
1052
|
-
repoRoot,
|
|
1053
|
-
worktreePath: session.worktree_path,
|
|
1054
|
-
baseBranch: mergeBaseBranch,
|
|
1055
|
-
sid,
|
|
1056
|
-
task_id: session.task_id,
|
|
1057
|
-
db,
|
|
1058
|
-
});
|
|
1059
|
-
effectiveRemove = Boolean(
|
|
1060
|
-
mergeResult.merged || mergeResult.reason === 'no_changes',
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
if (effectiveRemove && repoRoot && session.worktree_path) {
|
|
1066
|
-
const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
|
|
1067
|
-
if (blocker) {
|
|
1068
|
-
throw new SessionRunnerError(
|
|
1069
|
-
'WORKTREE_NOT_INTEGRATED',
|
|
1070
|
-
`refusing to remove ${session.worktree_path}: ${blocker}`,
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
if (!closeIntentPrepared) {
|
|
1074
|
-
closeJournal.prepare(repoRoot, {
|
|
1075
|
-
sid,
|
|
1076
|
-
task_id: session.task_id,
|
|
1077
|
-
requested_status: status,
|
|
1078
|
-
worktree_path: session.worktree_path,
|
|
1079
|
-
});
|
|
1080
|
-
closeIntentPrepared = true;
|
|
1081
|
-
}
|
|
1082
|
-
try {
|
|
1083
|
-
gitWorktreeRemove(repoRoot, session.worktree_path);
|
|
1084
|
-
closeJournal.update(repoRoot, sid, {
|
|
1085
|
-
phase: 'worktree_removed',
|
|
1086
|
-
error: null,
|
|
1087
|
-
});
|
|
1088
|
-
} catch (error) {
|
|
1089
|
-
closeJournal.update(repoRoot, sid, {
|
|
1090
|
-
phase: 'removal_failed',
|
|
1091
|
-
error: error.message,
|
|
1092
|
-
});
|
|
1093
|
-
throw error;
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
ops.updateSession(db, sid, { status });
|
|
1097
|
-
if (effectiveRemove && repoRoot && session.worktree_path) {
|
|
1098
|
-
closeJournal.discard(repoRoot, sid);
|
|
1099
|
-
}
|
|
1100
|
-
const out = {
|
|
1101
|
-
sid,
|
|
1102
|
-
status,
|
|
1103
|
-
worktree_preserved: Boolean(
|
|
1104
|
-
session.worktree_path && fs.existsSync(session.worktree_path),
|
|
1105
|
-
),
|
|
1106
|
-
};
|
|
1107
|
-
if (mergeResult) out.merge = mergeResult;
|
|
1108
|
-
return out;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function attachHeartbeat(db, sid, { intervalMs = 30000 } = {}) {
|
|
1112
|
-
if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
|
|
1113
|
-
const timer = setInterval(() => {
|
|
1114
|
-
try { ops.heartbeatSession(db, sid); }
|
|
1115
|
-
catch (_err) { clearInterval(timer); }
|
|
1116
|
-
}, intervalMs);
|
|
1117
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
1118
|
-
return () => clearInterval(timer);
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
module.exports = {
|
|
1122
|
-
SessionRunnerError,
|
|
1123
|
-
EXPECTED_EXECUTION_GATE_CODES,
|
|
1124
|
-
findExpectedExecutionGate,
|
|
1125
|
-
isExpectedExecutionGate,
|
|
1126
|
-
spawnSession,
|
|
1127
|
-
startSessionProcess,
|
|
1128
|
-
closeSession,
|
|
1129
|
-
attachHeartbeat,
|
|
1130
|
-
admissionCheck,
|
|
1131
|
-
assertSessionTaskReady,
|
|
1132
|
-
assertKernelSessionTaskReady,
|
|
1133
|
-
// exposed for tests
|
|
1134
|
-
_internal: {
|
|
1135
|
-
gitWorktreeAdd,
|
|
1136
|
-
gitWorktreeRemove,
|
|
1137
|
-
gitWorktreeRegistry,
|
|
1138
|
-
reconcileRemovedWorktree,
|
|
1139
|
-
mintSessionId,
|
|
1140
|
-
authorityLinkIsIgnored,
|
|
1141
|
-
ensureAuthorityLinkIgnored,
|
|
1142
|
-
linkAuthorityIntoWorktree,
|
|
1143
|
-
worktreeRemovalBlocker,
|
|
1144
|
-
changeTaskIntegrationBlocker,
|
|
1145
|
-
processIsExecuting,
|
|
1146
|
-
waitForProcessExit,
|
|
1147
|
-
closeJournal,
|
|
1148
|
-
resolveWorktreeBase,
|
|
1149
|
-
assertSessionId,
|
|
1150
|
-
assertManagedWorktreeRoot,
|
|
1151
|
-
assertManagedWorktreePath,
|
|
1152
|
-
},
|
|
1153
|
-
};
|