nawabari 0.3.0 → 0.5.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/README.md +136 -7
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +333 -40
- package/dist/cli.js.map +1 -1
- package/dist/contract.js +50 -0
- package/dist/contract.js.map +1 -1
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +4 -1
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/sandbox-launcher.d.ts +35 -0
- package/dist/domain/sandbox-launcher.js +679 -0
- package/dist/domain/sandbox-launcher.js.map +1 -0
- package/dist/domain/sandbox.d.ts +190 -0
- package/dist/domain/sandbox.js +363 -0
- package/dist/domain/sandbox.js.map +1 -0
- package/dist/domain/session-backend.d.ts +2 -1
- package/dist/domain/session-backend.js +71 -1
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +54 -0
- package/dist/domain/session.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/git.d.ts +25 -0
- package/dist/git.js +75 -0
- package/dist/git.js.map +1 -1
- package/dist/registry/atomic.d.ts +24 -0
- package/dist/registry/atomic.js +35 -2
- package/dist/registry/atomic.js.map +1 -1
- package/dist/registry/errors.d.ts +1 -1
- package/dist/registry/errors.js +1 -0
- package/dist/registry/errors.js.map +1 -1
- package/dist/session-registry.d.ts +120 -0
- package/dist/session-registry.js +456 -92
- package/dist/session-registry.js.map +1 -1
- package/package.json +4 -3
package/dist/session-registry.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readGitPathStats, readCurrentHead, resolveRepositoryContext, verifyPhysicalExecutionContext, } from "./git.js";
|
|
3
|
+
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
|
|
4
4
|
import { SessionRegistryError } from "./errors.js";
|
|
5
5
|
import { generateSessionId, isSessionId } from "./session-id.js";
|
|
6
|
+
import { isPostRenameFailure, writeJsonAtomicallySync } from "./registry/atomic.js";
|
|
6
7
|
import { RegistryLockError, RepositoryLock } from "./registry/lock.js";
|
|
7
8
|
import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput, canonicalizeConcretePath, claimError, claimsConflict, claimsOverlap, cloneResourceClaim, compareCodePointStrings, createResourceClaim, isResourceClaimMode, LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION, resourceClaimConflictsWithAccess, resourceMatchesClaim, RESOURCE_CLAIM_SCHEMA_VERSION, sortResourceClaims, } from "./resource-claims.js";
|
|
8
9
|
import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
|
|
@@ -16,9 +17,13 @@ export const REGISTRY_LOCK_FILE_NAME = "session-registry.lock";
|
|
|
16
17
|
export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
|
|
17
18
|
export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
|
|
18
19
|
export const RECONCILIATION_SCHEMA_VERSION = 1;
|
|
20
|
+
export const SESSION_DIAGNOSTIC_SCHEMA_VERSION = 1;
|
|
19
21
|
const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
|
|
20
22
|
/** Bounds a caller-declared commit-message pattern before it is compiled. */
|
|
21
23
|
export const MAX_MESSAGE_PATTERN_LENGTH = 512;
|
|
24
|
+
function isCloseSessionOptions(value) {
|
|
25
|
+
return typeof value === "object" && value !== null;
|
|
26
|
+
}
|
|
22
27
|
export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
|
|
23
28
|
export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
|
|
24
29
|
const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
|
|
@@ -166,7 +171,7 @@ export class SessionRegistry {
|
|
|
166
171
|
const current = state.claims.filter((claim) => claim.sessionId === sessionId);
|
|
167
172
|
const currentById = new Map(current.map((claim) => [claim.claimId, claim]));
|
|
168
173
|
const timestamp = toTimestamp(this.clock());
|
|
169
|
-
const next = this.validateRequestedClaims(requested.map((input) => createResourceClaim(input, owner, timestamp)), owner, state.claims.filter((claim) => claim.sessionId !== sessionId));
|
|
174
|
+
const next = this.validateRequestedClaims(requested.map((input) => createResourceClaim(input, owner, timestamp)), owner, state.claims.filter((claim) => claim.sessionId !== sessionId), state.sessions);
|
|
170
175
|
const nextIds = new Set(next.map((claim) => claim.claimId));
|
|
171
176
|
const released = current.filter((claim) => !nextIds.has(claim.claimId));
|
|
172
177
|
const added = next.filter((claim) => !currentById.has(claim.claimId));
|
|
@@ -312,13 +317,40 @@ export class SessionRegistry {
|
|
|
312
317
|
return cloneSessionRecord(record);
|
|
313
318
|
}
|
|
314
319
|
catch (error) {
|
|
315
|
-
if (gitProvisioned) {
|
|
320
|
+
if (gitProvisioned && this.absenceProvenAfterProvisioningFailure(error, sessionId)) {
|
|
316
321
|
rollbackProvisionedResources(this.git, this.repository.worktreePath, resources);
|
|
317
322
|
}
|
|
318
323
|
throw classifyProvisioningFailure(error, this.git, this.repository.worktreePath, resources);
|
|
319
324
|
}
|
|
320
325
|
});
|
|
321
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Decide whether it is safe to destroy the just-created worktree/branch
|
|
329
|
+
* after a provisioning failure. Only an ordinary (non-durability-uncertain)
|
|
330
|
+
* failure, or a durability-uncertain failure whose registry re-read
|
|
331
|
+
* positively proves the session record absent, licenses a rollback.
|
|
332
|
+
*
|
|
333
|
+
* A durability-uncertain write means the rename may have already
|
|
334
|
+
* committed this session as the registry's visible ownership record:
|
|
335
|
+
* rolling back the worktree/branch would then strand a registry entry
|
|
336
|
+
* pointing at resources we just deleted. Absence must be *proven*, not
|
|
337
|
+
* merely unconfirmed — if the reconciliation read itself cannot complete
|
|
338
|
+
* (I/O failure, corrupt registry state, or any other exception), that is
|
|
339
|
+
* not proof of absence, so no rollback happens and the original
|
|
340
|
+
* durability-uncertain outcome propagates unchanged.
|
|
341
|
+
*/
|
|
342
|
+
absenceProvenAfterProvisioningFailure(error, sessionId) {
|
|
343
|
+
const durabilityUncertain = error instanceof SessionRegistryError && error.code === "REGISTRY_DURABILITY_UNCERTAIN";
|
|
344
|
+
if (!durabilityUncertain) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
return !this.readUnsafe().some((candidate) => candidate.sessionId === sessionId);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
322
354
|
provisionSession(options = {}) {
|
|
323
355
|
return this.provision(options);
|
|
324
356
|
}
|
|
@@ -410,18 +442,46 @@ export class SessionRegistry {
|
|
|
410
442
|
git: this.git,
|
|
411
443
|
});
|
|
412
444
|
if (this.protectedWorktree(this.repository.worktreePath, physical.worktrees)) {
|
|
413
|
-
throw
|
|
414
|
-
worktree: this.repository.worktreePath,
|
|
415
|
-
});
|
|
445
|
+
throw this.protectedExecutionContextError("PROTECTED_WORKTREE", "The current worktree is protected", { worktree: this.repository.worktreePath }, requestedSessionId);
|
|
416
446
|
}
|
|
417
447
|
if (this.protectedBranch(physical.branchName, physical.worktrees)) {
|
|
418
|
-
throw
|
|
419
|
-
branch: physical.branchName,
|
|
420
|
-
});
|
|
448
|
+
throw this.protectedExecutionContextError("PROTECTED_BRANCH", "The current branch is protected", { branch: physical.branchName }, requestedSessionId);
|
|
421
449
|
}
|
|
422
450
|
const session = this.resolveSessionOwnership(physical, requestedSessionId);
|
|
423
451
|
return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
|
|
424
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Distinguish the current (protected) command execution context from the
|
|
455
|
+
* referenced/target session, so a caller does not have to separately
|
|
456
|
+
* re-discover where the operation should actually run. `phase: "execution"`
|
|
457
|
+
* marks this live-command rejection distinctly from the cleanup-time
|
|
458
|
+
* PROTECTED_WORKTREE/PROTECTED_BRANCH block, which `safeActionsForCode`
|
|
459
|
+
* and `recoveryHintsForCode` key on for a different, non-mutating safe
|
|
460
|
+
* action set.
|
|
461
|
+
*/
|
|
462
|
+
protectedExecutionContextError(code, message, contextDetails, requestedSessionId) {
|
|
463
|
+
const target = requestedSessionId === null
|
|
464
|
+
? undefined
|
|
465
|
+
: this.readUnsafe().find((record) => record.sessionId === requestedSessionId);
|
|
466
|
+
const details = {
|
|
467
|
+
...contextDetails,
|
|
468
|
+
phase: "execution",
|
|
469
|
+
...(requestedSessionId === null ? {} : { requestedSessionId }),
|
|
470
|
+
...(target === undefined
|
|
471
|
+
? {}
|
|
472
|
+
: {
|
|
473
|
+
targetWorktree: target.worktreePath,
|
|
474
|
+
targetBranch: target.branchName,
|
|
475
|
+
targetState: target.state,
|
|
476
|
+
...(target.label === undefined ? {} : { targetLabel: target.label }),
|
|
477
|
+
}),
|
|
478
|
+
};
|
|
479
|
+
return new SessionRegistryError(code, message, {
|
|
480
|
+
...details,
|
|
481
|
+
safeActions: [...safeActionsForCode(code, details)],
|
|
482
|
+
recoveryHints: recoveryHintsForCode(code, details),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
425
485
|
resolveSessionOwnership(physical, requestedSessionId) {
|
|
426
486
|
const records = this.readUnsafe();
|
|
427
487
|
const currentRecords = records.filter((record) => ACTIVE_STATES.has(record.state) && samePath(record.worktreeId, physical.worktreePath));
|
|
@@ -628,12 +688,11 @@ export class SessionRegistry {
|
|
|
628
688
|
const conflictingClaim = activeClaims.find((claim) => claim.sessionId !== currentRecord.sessionId &&
|
|
629
689
|
resourceClaimConflictsWithAccess(claim, resource, requiredAccess));
|
|
630
690
|
if (conflictingClaim !== undefined) {
|
|
691
|
+
const conflictDetails = resourceClaimConflictDetails({ resource, mode: requiredAccess }, conflictingClaim, state.sessions);
|
|
631
692
|
return deniedOperation("RESOURCE_CLAIM_CONFLICT", verified, {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
ownerResource: conflictingClaim.resource,
|
|
636
|
-
ownerMode: conflictingClaim.mode,
|
|
693
|
+
...conflictDetails,
|
|
694
|
+
safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
|
|
695
|
+
recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
|
|
637
696
|
});
|
|
638
697
|
}
|
|
639
698
|
if (ownClaims.length > 0) {
|
|
@@ -1033,6 +1092,32 @@ export class SessionRegistry {
|
|
|
1033
1092
|
assessCleanup(sessionIdOrOptions) {
|
|
1034
1093
|
return this.cleanupDecision(sessionIdOrOptions);
|
|
1035
1094
|
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Side-effect-free close/cleanup readiness diagnostic for one session. This
|
|
1097
|
+
* observes the exact same authoritative Git/session/claim truth `close()`
|
|
1098
|
+
* uses (including the #123 non-ancestry integration proof contract when
|
|
1099
|
+
* `integratedRevision` evidence is supplied) without mutating any session,
|
|
1100
|
+
* claim, Git, or registry state.
|
|
1101
|
+
*/
|
|
1102
|
+
diagnose(sessionIdOrOptions) {
|
|
1103
|
+
return this.withLock(() => {
|
|
1104
|
+
const state = this.readStateUnsafe();
|
|
1105
|
+
const requestedSessionId = isCloseSessionOptions(sessionIdOrOptions)
|
|
1106
|
+
? (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id)
|
|
1107
|
+
: sessionIdOrOptions;
|
|
1108
|
+
const sessionId = requestedSessionId ?? this.resolveOwnerSession(state.sessions).sessionId;
|
|
1109
|
+
assertSessionId(sessionId);
|
|
1110
|
+
const record = state.sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1111
|
+
if (record === undefined) {
|
|
1112
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
1113
|
+
}
|
|
1114
|
+
const integratedRevision = isCloseSessionOptions(sessionIdOrOptions)
|
|
1115
|
+
? (sessionIdOrOptions.integratedRevision ?? sessionIdOrOptions.integrated_revision ?? null)
|
|
1116
|
+
: null;
|
|
1117
|
+
const evidence = integratedRevision === null || integratedRevision === undefined ? undefined : { integratedRevision };
|
|
1118
|
+
return this.diagnoseUnsafe(record, state, evidence);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1036
1121
|
/**
|
|
1037
1122
|
* Reconcile registry ownership with Git's physical worktree inventory.
|
|
1038
1123
|
* This is deliberately diagnostic-only: no stale metadata is repaired and
|
|
@@ -1095,12 +1180,16 @@ export class SessionRegistry {
|
|
|
1095
1180
|
/** Close one session only after its ownership and recoverability are proven safe. */
|
|
1096
1181
|
close(sessionIdOrOptions) {
|
|
1097
1182
|
return this.withLock(() => {
|
|
1098
|
-
const sessionId =
|
|
1183
|
+
const sessionId = isCloseSessionOptions(sessionIdOrOptions)
|
|
1099
1184
|
? (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id)
|
|
1100
1185
|
: sessionIdOrOptions;
|
|
1101
1186
|
const selectedSessionId = sessionId ?? this.resolveCurrentSession().sessionId;
|
|
1102
1187
|
assertSessionId(selectedSessionId);
|
|
1103
|
-
|
|
1188
|
+
const integratedRevision = isCloseSessionOptions(sessionIdOrOptions)
|
|
1189
|
+
? (sessionIdOrOptions.integratedRevision ?? sessionIdOrOptions.integrated_revision ?? null)
|
|
1190
|
+
: null;
|
|
1191
|
+
const evidence = integratedRevision === null || integratedRevision === undefined ? undefined : { integratedRevision };
|
|
1192
|
+
return this.closeUnsafe(selectedSessionId, evidence);
|
|
1104
1193
|
});
|
|
1105
1194
|
}
|
|
1106
1195
|
closeSession(sessionIdOrOptions) {
|
|
@@ -1254,7 +1343,7 @@ export class SessionRegistry {
|
|
|
1254
1343
|
const candidates = requested.map((input) => createResourceClaim(input, owner, timestamp));
|
|
1255
1344
|
const existing = state.claims.filter((claim) => claim.sessionId !== owner.sessionId);
|
|
1256
1345
|
const current = state.claims.filter((claim) => claim.sessionId === owner.sessionId);
|
|
1257
|
-
const added = this.validateRequestedClaims(candidates, owner, [...existing, ...current]);
|
|
1346
|
+
const added = this.validateRequestedClaims(candidates, owner, [...existing, ...current], state.sessions);
|
|
1258
1347
|
const nextClaims = sortResourceClaims([
|
|
1259
1348
|
...state.claims,
|
|
1260
1349
|
...added.filter((candidate) => !current.some((claim) => claim.claimId === candidate.claimId)),
|
|
@@ -1266,7 +1355,7 @@ export class SessionRegistry {
|
|
|
1266
1355
|
added: added.filter((candidate) => !current.some((claim) => claim.claimId === candidate.claimId)),
|
|
1267
1356
|
};
|
|
1268
1357
|
}
|
|
1269
|
-
validateRequestedClaims(candidates, owner, existing) {
|
|
1358
|
+
validateRequestedClaims(candidates, owner, existing, sessions = []) {
|
|
1270
1359
|
for (const candidate of candidates) {
|
|
1271
1360
|
const exact = existing.find((claim) => claim.claimId === candidate.claimId);
|
|
1272
1361
|
if (exact !== undefined) {
|
|
@@ -1299,21 +1388,19 @@ export class SessionRegistry {
|
|
|
1299
1388
|
});
|
|
1300
1389
|
}
|
|
1301
1390
|
if (claimsConflict(candidate, current)) {
|
|
1391
|
+
const conflictDetails = resourceClaimConflictDetails(candidate, current, sessions);
|
|
1302
1392
|
throw claimError("RESOURCE_CLAIM_CONFLICT", "Resource claim conflicts with an active session claim", {
|
|
1303
1393
|
claimId: candidate.claimId,
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
ownerClaimId: current.claimId,
|
|
1308
|
-
ownerResource: current.resource,
|
|
1309
|
-
ownerMode: current.mode,
|
|
1394
|
+
...conflictDetails,
|
|
1395
|
+
safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
|
|
1396
|
+
recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
|
|
1310
1397
|
});
|
|
1311
1398
|
}
|
|
1312
1399
|
}
|
|
1313
1400
|
}
|
|
1314
1401
|
return candidates;
|
|
1315
1402
|
}
|
|
1316
|
-
closeUnsafe(sessionId) {
|
|
1403
|
+
closeUnsafe(sessionId, evidence) {
|
|
1317
1404
|
const state = this.readStateUnsafe();
|
|
1318
1405
|
const records = state.sessions;
|
|
1319
1406
|
const record = records.find((candidate) => candidate.sessionId === sessionId);
|
|
@@ -1330,15 +1417,22 @@ export class SessionRegistry {
|
|
|
1330
1417
|
idempotent: true,
|
|
1331
1418
|
};
|
|
1332
1419
|
}
|
|
1333
|
-
|
|
1420
|
+
let resources;
|
|
1421
|
+
try {
|
|
1422
|
+
resources = this.inspectCleanupResources(record, undefined, evidence);
|
|
1423
|
+
}
|
|
1424
|
+
catch (error) {
|
|
1425
|
+
throw enrichCloseBlockerError(error);
|
|
1426
|
+
}
|
|
1334
1427
|
const closingRecord = record.state === "closing" ? record : transitionSessionState(record, "closing", this.clock);
|
|
1335
1428
|
let closingRecords = replaceRecord(records, closingRecord);
|
|
1336
1429
|
validateRecords(closingRecords, this.repository.repositoryId);
|
|
1337
1430
|
this.writeUnsafe(closingRecords, state.claims);
|
|
1338
1431
|
let worktreeRemoved = false;
|
|
1339
1432
|
let branchRemoved = false;
|
|
1433
|
+
let integrationProof;
|
|
1340
1434
|
if (resources.removeWorktree) {
|
|
1341
|
-
const revalidated = this.inspectCleanupResources(record);
|
|
1435
|
+
const revalidated = this.inspectCleanupResources(record, undefined, evidence);
|
|
1342
1436
|
assertSameCleanupObservation(resources, revalidated, "worktree-remove");
|
|
1343
1437
|
if (revalidated.removeWorktree) {
|
|
1344
1438
|
removeSessionWorktree(this.git, revalidated.gitCwd, record.worktreePath);
|
|
@@ -1349,13 +1443,18 @@ export class SessionRegistry {
|
|
|
1349
1443
|
// no longer exist. Reobserve Git from the stable integration/peer cwd that
|
|
1350
1444
|
// was selected during the authoritative preflight.
|
|
1351
1445
|
const branchWorktrees = listGitWorktrees(this.git, resources.gitCwd);
|
|
1352
|
-
const branchResources = this.inspectCleanupResources(record, branchWorktrees);
|
|
1446
|
+
const branchResources = this.inspectCleanupResources(record, branchWorktrees, evidence);
|
|
1353
1447
|
if (branchResources.branchPresent && branchResources.removeBranch) {
|
|
1354
|
-
const branchRevalidated = this.inspectCleanupResources(record, listGitWorktrees(this.git, branchResources.gitCwd));
|
|
1448
|
+
const branchRevalidated = this.inspectCleanupResources(record, listGitWorktrees(this.git, branchResources.gitCwd), evidence);
|
|
1355
1449
|
assertSameCleanupObservation(branchResources, branchRevalidated, "branch-remove");
|
|
1356
1450
|
if (branchRevalidated.branchPresent && branchRevalidated.removeBranch) {
|
|
1357
|
-
|
|
1451
|
+
// Git's own "-d" safety check is ancestry-only and cannot see a
|
|
1452
|
+
// tree-equivalence proof. Force deletion is only ever selected after
|
|
1453
|
+
// Nawabari has independently re-derived that proof itself.
|
|
1454
|
+
const forceDelete = branchRevalidated.integrationProof?.method === "tree-equivalence";
|
|
1455
|
+
removeSessionBranch(this.git, branchRevalidated.gitCwd, record.branchName, forceDelete);
|
|
1358
1456
|
branchRemoved = true;
|
|
1457
|
+
integrationProof = branchRevalidated.integrationProof;
|
|
1359
1458
|
}
|
|
1360
1459
|
}
|
|
1361
1460
|
const closedRecord = transitionSessionState(closingRecord, "closed", this.clock);
|
|
@@ -1367,9 +1466,10 @@ export class SessionRegistry {
|
|
|
1367
1466
|
worktreeRemoved,
|
|
1368
1467
|
branchRemoved,
|
|
1369
1468
|
idempotent: false,
|
|
1469
|
+
...(integrationProof === undefined ? {} : { integrationProof }),
|
|
1370
1470
|
};
|
|
1371
1471
|
}
|
|
1372
|
-
inspectCleanupResources(record, observedWorktrees = listGitWorktrees(this.git, this.repository.worktreePath)) {
|
|
1472
|
+
inspectCleanupResources(record, observedWorktrees = listGitWorktrees(this.git, this.repository.worktreePath), evidence) {
|
|
1373
1473
|
const git = this.git;
|
|
1374
1474
|
const worktrees = observedWorktrees;
|
|
1375
1475
|
const gitCwd = worktrees.find((worktree) => !worktree.prunable && !samePath(worktree.worktreePath, record.worktreePath))
|
|
@@ -1410,7 +1510,12 @@ export class SessionRegistry {
|
|
|
1410
1510
|
assertNoRecoverableStashes(git, gitCwd, record.branchName);
|
|
1411
1511
|
const worktreeProtection = this.protectedWorktree(record.worktreePath, worktrees);
|
|
1412
1512
|
const branchProtection = this.protectedBranch(record.branchName, worktrees, gitCwd);
|
|
1413
|
-
|
|
1513
|
+
let removeBranch = false;
|
|
1514
|
+
let integrationProof;
|
|
1515
|
+
if (branchPresent && !branchProtection) {
|
|
1516
|
+
integrationProof = this.branchIsReachableFromIntegration(record, gitCwd, evidence);
|
|
1517
|
+
removeBranch = true;
|
|
1518
|
+
}
|
|
1414
1519
|
return {
|
|
1415
1520
|
gitCwd,
|
|
1416
1521
|
physicalState: worktreeState.kind,
|
|
@@ -1421,6 +1526,7 @@ export class SessionRegistry {
|
|
|
1421
1526
|
branchPresent,
|
|
1422
1527
|
removeWorktree: registeredWorktree !== undefined && !worktreeProtection,
|
|
1423
1528
|
removeBranch,
|
|
1529
|
+
integrationProof,
|
|
1424
1530
|
};
|
|
1425
1531
|
}
|
|
1426
1532
|
cleanupDecisionUnsafe(record, state) {
|
|
@@ -1451,6 +1557,80 @@ export class SessionRegistry {
|
|
|
1451
1557
|
recoveryHints: Object.freeze(recoveryHints),
|
|
1452
1558
|
});
|
|
1453
1559
|
}
|
|
1560
|
+
diagnoseUnsafe(record, state, evidence) {
|
|
1561
|
+
const claims = state.claims.filter((claim) => claim.sessionId === record.sessionId).map(cloneResourceClaim);
|
|
1562
|
+
// Used only to evaluate staleness below; deliberately not part of the
|
|
1563
|
+
// returned diagnostic so repeated inspection of unchanged state is
|
|
1564
|
+
// byte-identical, not just semantically equivalent.
|
|
1565
|
+
const now = toTimestamp(this.clock());
|
|
1566
|
+
const integrationEvidence = {
|
|
1567
|
+
supplied: evidence !== undefined,
|
|
1568
|
+
...(evidence === undefined ? {} : { integratedRevision: evidence.integratedRevision }),
|
|
1569
|
+
};
|
|
1570
|
+
if (record.state === "closed") {
|
|
1571
|
+
return Object.freeze({
|
|
1572
|
+
schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
|
|
1573
|
+
operation: "diagnostic",
|
|
1574
|
+
repositoryId: this.repository.repositoryId,
|
|
1575
|
+
worktreePath: record.worktreePath,
|
|
1576
|
+
branchName: record.branchName,
|
|
1577
|
+
session: cloneSessionRecord(record),
|
|
1578
|
+
claims: Object.freeze(claims),
|
|
1579
|
+
physicalState: "closed",
|
|
1580
|
+
closeReadiness: "ready",
|
|
1581
|
+
cleanupReadiness: "ready",
|
|
1582
|
+
resultState: "complete",
|
|
1583
|
+
idempotent: true,
|
|
1584
|
+
blockers: Object.freeze([]),
|
|
1585
|
+
safeActions: Object.freeze([]),
|
|
1586
|
+
integrationEvidence: Object.freeze(integrationEvidence),
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
let physicalState = "unavailable";
|
|
1590
|
+
let blockers = [];
|
|
1591
|
+
let integrationProof;
|
|
1592
|
+
let staleCandidate = false;
|
|
1593
|
+
try {
|
|
1594
|
+
const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
|
|
1595
|
+
physicalState = inspectWorktreeState(record.worktreePath, worktrees).kind;
|
|
1596
|
+
staleCandidate = isStaleCandidate(record, now, this.staleAfterMs, worktrees);
|
|
1597
|
+
const resources = this.inspectCleanupResources(record, worktrees, evidence);
|
|
1598
|
+
integrationProof = resources.integrationProof;
|
|
1599
|
+
}
|
|
1600
|
+
catch (error) {
|
|
1601
|
+
blockers = [toDiagnosticBlocker(error)];
|
|
1602
|
+
}
|
|
1603
|
+
const closeReadiness = closeReadinessForBlockers(blockers);
|
|
1604
|
+
const cleanupReadiness = closeReadiness !== "ready" ? closeReadiness : staleCandidate ? "ready" : "not_due";
|
|
1605
|
+
const resultState = resultStateForDiagnostic(closeReadiness, blockers);
|
|
1606
|
+
const safeActions = blockers.length > 0
|
|
1607
|
+
? sortStrings(Array.from(new Set(blockers.flatMap((blocker) => blocker.safeActions))))
|
|
1608
|
+
: closeReadiness === "ready"
|
|
1609
|
+
? cleanupReadiness === "ready"
|
|
1610
|
+
? ["close-session", "run-garbage-collect"]
|
|
1611
|
+
: ["close-session"]
|
|
1612
|
+
: [];
|
|
1613
|
+
return Object.freeze({
|
|
1614
|
+
schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
|
|
1615
|
+
operation: "diagnostic",
|
|
1616
|
+
repositoryId: this.repository.repositoryId,
|
|
1617
|
+
worktreePath: record.worktreePath,
|
|
1618
|
+
branchName: record.branchName,
|
|
1619
|
+
session: cloneSessionRecord(record),
|
|
1620
|
+
claims: Object.freeze(claims),
|
|
1621
|
+
physicalState,
|
|
1622
|
+
closeReadiness,
|
|
1623
|
+
cleanupReadiness,
|
|
1624
|
+
resultState,
|
|
1625
|
+
idempotent: false,
|
|
1626
|
+
blockers: Object.freeze(blockers),
|
|
1627
|
+
safeActions: Object.freeze(safeActions),
|
|
1628
|
+
integrationEvidence: Object.freeze({
|
|
1629
|
+
...integrationEvidence,
|
|
1630
|
+
...(integrationProof === undefined ? {} : { proof: integrationProof }),
|
|
1631
|
+
}),
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1454
1634
|
assertCleanupAllowed(record, state) {
|
|
1455
1635
|
const decision = this.cleanupDecisionUnsafe(record, state);
|
|
1456
1636
|
if (decision.allowed)
|
|
@@ -1538,7 +1718,15 @@ export class SessionRegistry {
|
|
|
1538
1718
|
];
|
|
1539
1719
|
return protectedBranchIds.includes(normalizeBranchId(branchName));
|
|
1540
1720
|
}
|
|
1541
|
-
|
|
1721
|
+
/**
|
|
1722
|
+
* Prove a session branch is safe to reclaim. Ordinary ancestry is the
|
|
1723
|
+
* cheap/default path. When ancestry fails, a caller may supply bounded
|
|
1724
|
+
* non-ancestry integration evidence (e.g. a squash/rebase-merge commit);
|
|
1725
|
+
* Nawabari never trusts that assertion blindly and instead independently
|
|
1726
|
+
* re-derives a deterministic, byte-exact Git tree-object equivalence proof
|
|
1727
|
+
* before treating the branch as safely integrated.
|
|
1728
|
+
*/
|
|
1729
|
+
branchIsReachableFromIntegration(record, gitCwd = this.repository.worktreePath, evidence) {
|
|
1542
1730
|
const git = this.git;
|
|
1543
1731
|
const worktrees = listGitWorktrees(git, gitCwd);
|
|
1544
1732
|
const defaultBranchName = resolveDefaultBranchName(git, gitCwd, worktrees, this.defaultBranchName);
|
|
@@ -1546,27 +1734,79 @@ export class SessionRegistry {
|
|
|
1546
1734
|
throw new SessionRegistryError("RECOVERABLE_COMMITS", `Cannot prove that commits on ${record.branchName} are safely retained: no integration branch is known`, { branch: record.branchName, recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS") });
|
|
1547
1735
|
}
|
|
1548
1736
|
if (normalizeBranchId(defaultBranchName) === record.branchId)
|
|
1549
|
-
return
|
|
1737
|
+
return { method: "ancestor" };
|
|
1550
1738
|
try {
|
|
1551
1739
|
git.run(["merge-base", "--is-ancestor", record.branchId, normalizeBranchId(defaultBranchName)], gitCwd);
|
|
1552
|
-
return
|
|
1740
|
+
return { method: "ancestor" };
|
|
1553
1741
|
}
|
|
1554
1742
|
catch (error) {
|
|
1555
|
-
if (isExpectedGitLookupFailure(error))
|
|
1556
|
-
throw
|
|
1743
|
+
if (!isExpectedGitLookupFailure(error))
|
|
1744
|
+
throw error;
|
|
1745
|
+
if (evidence !== undefined) {
|
|
1746
|
+
const proof = this.proveNonAncestryIntegration(record, gitCwd, defaultBranchName, evidence);
|
|
1747
|
+
if (proof !== undefined)
|
|
1748
|
+
return proof;
|
|
1749
|
+
throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} could not be proven integrated via the supplied non-ancestry evidence`, {
|
|
1557
1750
|
branch: record.branchName,
|
|
1558
1751
|
integrationBranch: defaultBranchName,
|
|
1752
|
+
proofMethod: "tree-equivalence",
|
|
1753
|
+
proofResult: "unproven",
|
|
1754
|
+
integratedRevision: evidence.integratedRevision,
|
|
1559
1755
|
recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS"),
|
|
1560
1756
|
}, error);
|
|
1561
1757
|
}
|
|
1758
|
+
throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} are not proven reachable from ${defaultBranchName}`, {
|
|
1759
|
+
branch: record.branchName,
|
|
1760
|
+
integrationBranch: defaultBranchName,
|
|
1761
|
+
proofMethod: "ancestry",
|
|
1762
|
+
proofResult: "unproven",
|
|
1763
|
+
recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS"),
|
|
1764
|
+
}, error);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Independently verify caller-supplied non-ancestry integration evidence.
|
|
1769
|
+
* The caller's assertion only selects which revision to compare; the
|
|
1770
|
+
* proof itself is re-derived from local Git truth. For every path the
|
|
1771
|
+
* session branch touched relative to its own base, the exact tree-object
|
|
1772
|
+
* identity (file mode, object type, and content-addressed blob/tree SHA)
|
|
1773
|
+
* at the branch head must match the same path's identity at the supplied
|
|
1774
|
+
* integrated revision — both absent, or byte-identical. Blob SHAs are
|
|
1775
|
+
* content hashes, so this can never mistake a whitespace-only or other
|
|
1776
|
+
* textual difference for equivalence the way a patch/diff comparison
|
|
1777
|
+
* could; it is exact Git tree semantics, not textual normalization.
|
|
1778
|
+
* Returns undefined, never false, so a missing proof always falls through
|
|
1779
|
+
* to a fail-closed RECOVERABLE_COMMITS at the call site.
|
|
1780
|
+
*/
|
|
1781
|
+
proveNonAncestryIntegration(record, gitCwd, defaultBranchName, evidence) {
|
|
1782
|
+
const git = this.git;
|
|
1783
|
+
const integratedRevision = resolveBaseRef(git, gitCwd, evidence.integratedRevision).revision;
|
|
1784
|
+
let branchBase;
|
|
1785
|
+
try {
|
|
1786
|
+
branchBase = git.run(["merge-base", record.branchId, normalizeBranchId(defaultBranchName)], gitCwd);
|
|
1787
|
+
}
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
if (isExpectedGitLookupFailure(error))
|
|
1790
|
+
return undefined;
|
|
1562
1791
|
throw error;
|
|
1563
1792
|
}
|
|
1793
|
+
const changedPaths = readChangedPathNames(git, gitCwd, branchBase, record.branchId);
|
|
1794
|
+
if (changedPaths.length === 0)
|
|
1795
|
+
return undefined;
|
|
1796
|
+
const branchStates = readTreePathStates(git, gitCwd, record.branchId, changedPaths);
|
|
1797
|
+
const integratedStates = readTreePathStates(git, gitCwd, integratedRevision, changedPaths);
|
|
1798
|
+
const proven = changedPaths.every((changedPath) => treeEntriesEqual(branchStates.get(changedPath), integratedStates.get(changedPath)));
|
|
1799
|
+
return proven ? { method: "tree-equivalence", integratedRevision } : undefined;
|
|
1564
1800
|
}
|
|
1565
1801
|
resolveProvisioningResources(options, sessionId) {
|
|
1566
1802
|
const git = this.git;
|
|
1567
1803
|
const worktrees = listGitWorktrees(git, this.repository.worktreePath);
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1804
|
+
const effectiveRoot = options.worktreePath !== undefined
|
|
1805
|
+
? this.worktreeRoot
|
|
1806
|
+
: options.worktreeRoot !== undefined
|
|
1807
|
+
? resolveManagedWorktreeRoot(options.worktreeRoot)
|
|
1808
|
+
: this.worktreeRoot;
|
|
1809
|
+
const requestedWorktreePath = resolveProvisionedWorktreePath(options.worktreePath ?? path.join(effectiveRoot, `${path.basename(this.repository.worktreePath)}-${sessionId}`), effectiveRoot);
|
|
1570
1810
|
const defaultWorktreePath = worktrees.find((worktree) => !worktree.prunable)?.worktreePath ?? this.repository.worktreePath;
|
|
1571
1811
|
const configuredProtectedWorktrees = [
|
|
1572
1812
|
...this.protectedWorktreePaths,
|
|
@@ -1643,6 +1883,15 @@ export class SessionRegistry {
|
|
|
1643
1883
|
readUnsafe() {
|
|
1644
1884
|
return this.readStateUnsafe().sessions;
|
|
1645
1885
|
}
|
|
1886
|
+
/**
|
|
1887
|
+
* The single authoritative persistence path for session/claim registry
|
|
1888
|
+
* state, sharing its durability policy with `registry/atomic.ts`: the
|
|
1889
|
+
* temporary file and target directory are fsynced around an atomic
|
|
1890
|
+
* rename, and only known unsupported-directory-fsync conditions are
|
|
1891
|
+
* tolerated. A failure observed after rename is reported as a distinct
|
|
1892
|
+
* durability-uncertain outcome rather than an ordinary write failure,
|
|
1893
|
+
* since the renamed document may already be the one readers observe.
|
|
1894
|
+
*/
|
|
1646
1895
|
writeUnsafe(records, claims) {
|
|
1647
1896
|
const registry = {
|
|
1648
1897
|
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
@@ -1651,24 +1900,19 @@ export class SessionRegistry {
|
|
|
1651
1900
|
claims_schema_version: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
1652
1901
|
claims: sortResourceClaims(claims).map((claim) => toPersistedResourceClaim(claim, this.repository.repositoryId)),
|
|
1653
1902
|
};
|
|
1654
|
-
const contents = `${JSON.stringify(registry, null, 2)}\n`;
|
|
1655
|
-
const temporaryPath = `${this.paths.registry}.tmp-${process.pid}-${generateSessionId()}`;
|
|
1656
|
-
let descriptor;
|
|
1657
1903
|
try {
|
|
1658
|
-
|
|
1659
|
-
descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
1660
|
-
fs.writeFileSync(descriptor, contents, "utf8");
|
|
1661
|
-
fs.fsyncSync(descriptor);
|
|
1662
|
-
fs.closeSync(descriptor);
|
|
1663
|
-
descriptor = undefined;
|
|
1664
|
-
fs.renameSync(temporaryPath, this.paths.registry);
|
|
1665
|
-
syncDirectory(this.paths.directory);
|
|
1904
|
+
writeJsonAtomicallySync(this.paths.registry, registry);
|
|
1666
1905
|
}
|
|
1667
1906
|
catch (error) {
|
|
1668
|
-
if (
|
|
1669
|
-
|
|
1907
|
+
if (isPostRenameFailure(error)) {
|
|
1908
|
+
throw new SessionRegistryError("REGISTRY_DURABILITY_UNCERTAIN", `Registry rename to ${this.paths.registry} may have already committed, but durable persistence could not be proven`, {
|
|
1909
|
+
path: this.paths.registry,
|
|
1910
|
+
recoveryHints: [
|
|
1911
|
+
"Re-read the registry to check whether the mutation is already visible before retrying.",
|
|
1912
|
+
"Do not assume this operation did not happen.",
|
|
1913
|
+
],
|
|
1914
|
+
}, error);
|
|
1670
1915
|
}
|
|
1671
|
-
unlinkQuietly(temporaryPath);
|
|
1672
1916
|
throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not atomically write ${this.paths.registry}`, {
|
|
1673
1917
|
path: this.paths.registry,
|
|
1674
1918
|
}, error);
|
|
@@ -1763,6 +2007,32 @@ function inspectWorktreeState(worktreePath, worktrees) {
|
|
|
1763
2007
|
function replaceRecord(records, replacement) {
|
|
1764
2008
|
return records.map((record) => (record.sessionId === replacement.sessionId ? replacement : record));
|
|
1765
2009
|
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Bounded, deterministic RESOURCE_CLAIM_CONFLICT evidence: the blocking
|
|
2012
|
+
* session's canonical identity plus the exact conflicting claim/overlap.
|
|
2013
|
+
* Every RESOURCE_CLAIM_CONFLICT throw site builds its details through this
|
|
2014
|
+
* one function so a caller never has to run a second repository-wide scan
|
|
2015
|
+
* just to identify the owner.
|
|
2016
|
+
*/
|
|
2017
|
+
function resourceClaimConflictDetails(requested, owner, sessions) {
|
|
2018
|
+
const ownerSession = sessions.find((record) => record.sessionId === owner.sessionId);
|
|
2019
|
+
return {
|
|
2020
|
+
resource: requested.resource,
|
|
2021
|
+
mode: requested.mode,
|
|
2022
|
+
ownerSessionId: owner.sessionId,
|
|
2023
|
+
ownerClaimId: owner.claimId,
|
|
2024
|
+
ownerResource: owner.resource,
|
|
2025
|
+
ownerMode: owner.mode,
|
|
2026
|
+
...(ownerSession === undefined
|
|
2027
|
+
? {}
|
|
2028
|
+
: {
|
|
2029
|
+
ownerWorktree: ownerSession.worktreePath,
|
|
2030
|
+
ownerBranch: ownerSession.branchName,
|
|
2031
|
+
ownerState: ownerSession.state,
|
|
2032
|
+
...(ownerSession.label === undefined ? {} : { ownerLabel: ownerSession.label }),
|
|
2033
|
+
}),
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
1766
2036
|
function transitionSessionState(record, state, clock) {
|
|
1767
2037
|
const clockTimestamp = toTimestamp(clock());
|
|
1768
2038
|
const updatedAt = new Date(Math.max(Date.parse(record.updatedAt), Date.parse(clockTimestamp))).toISOString();
|
|
@@ -1777,14 +2047,17 @@ function ownershipMismatch(record, message, details = {}) {
|
|
|
1777
2047
|
...(details.recoveryHints === undefined ? { recoveryHints: recoveryHintsForCode("OWNERSHIP_MISMATCH") } : {}),
|
|
1778
2048
|
});
|
|
1779
2049
|
}
|
|
1780
|
-
function recoveryHintsForCode(code) {
|
|
2050
|
+
function recoveryHintsForCode(code, details = {}) {
|
|
1781
2051
|
switch (code) {
|
|
1782
2052
|
case "DIRTY_WORKTREE":
|
|
1783
2053
|
return ["Preserve, commit, or explicitly recover tracked and untracked changes, then retry cleanup."];
|
|
1784
2054
|
case "NESTED_REPOSITORY":
|
|
1785
2055
|
return ["Inspect and explicitly preserve or remove the nested repository before retrying cleanup."];
|
|
1786
2056
|
case "RECOVERABLE_COMMITS":
|
|
1787
|
-
return [
|
|
2057
|
+
return [
|
|
2058
|
+
"Retain or merge the session branch commits into an authoritative local branch before retrying cleanup.",
|
|
2059
|
+
"If the branch was integrated via squash/rebase merge, retry close with an --integrated-revision proof instead.",
|
|
2060
|
+
];
|
|
1788
2061
|
case "RECOVERABLE_STASHES":
|
|
1789
2062
|
return ["Inspect and explicitly apply, export, or drop the session stash before retrying cleanup."];
|
|
1790
2063
|
case "MISSING_WORKTREE":
|
|
@@ -1796,7 +2069,17 @@ function recoveryHintsForCode(code) {
|
|
|
1796
2069
|
return ["Restore a stable, observable Git/worktree state and rerun reconciliation before cleanup."];
|
|
1797
2070
|
case "PROTECTED_WORKTREE":
|
|
1798
2071
|
case "PROTECTED_BRANCH":
|
|
1799
|
-
return
|
|
2072
|
+
return details.phase === "execution"
|
|
2073
|
+
? [
|
|
2074
|
+
"Run this command from the managed session worktree it targets, or pass --session to select it explicitly.",
|
|
2075
|
+
"Use `session list` or `status` to find the managed worktree for the intended session.",
|
|
2076
|
+
]
|
|
2077
|
+
: ["Use the owner of the protected integration resource; do not delete it as a session resource."];
|
|
2078
|
+
case "RESOURCE_CLAIM_CONFLICT":
|
|
2079
|
+
return [
|
|
2080
|
+
"Inspect the blocking session's conflicting claim, then either wait for its release or coordinate with its owner.",
|
|
2081
|
+
"Do not force-release another session's claim; claim release is scoped to the owning session only.",
|
|
2082
|
+
];
|
|
1800
2083
|
case "STALE_REGISTRY":
|
|
1801
2084
|
return ["Reconcile the lifecycle record under the repository mutation lock before retrying cleanup."];
|
|
1802
2085
|
default:
|
|
@@ -1817,6 +2100,117 @@ function toCleanupBlocker(error) {
|
|
|
1817
2100
|
recoveryHints: Object.freeze(recoveryHints),
|
|
1818
2101
|
});
|
|
1819
2102
|
}
|
|
2103
|
+
function toDiagnosticBlocker(error) {
|
|
2104
|
+
const blocker = toCleanupBlocker(error);
|
|
2105
|
+
return Object.freeze({
|
|
2106
|
+
...blocker,
|
|
2107
|
+
safeActions: Object.freeze(safeActionsForCode(blocker.code, blocker.details)),
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Stable, kebab-case next-action identifiers per blocker code. This is the
|
|
2112
|
+
* one place that maps a registry blocker to actionable next steps, so a
|
|
2113
|
+
* later actionable-error surface (e.g. for RESOURCE_CLAIM_CONFLICT) can
|
|
2114
|
+
* extend the same table instead of diverging from it.
|
|
2115
|
+
*/
|
|
2116
|
+
function safeActionsForCode(code, details) {
|
|
2117
|
+
switch (code) {
|
|
2118
|
+
case "DIRTY_WORKTREE":
|
|
2119
|
+
return ["commit-or-discard-changes", "retain-session"];
|
|
2120
|
+
case "NESTED_REPOSITORY":
|
|
2121
|
+
return ["resolve-nested-repository", "retain-session"];
|
|
2122
|
+
case "RECOVERABLE_COMMITS":
|
|
2123
|
+
return details.proofMethod === "tree-equivalence"
|
|
2124
|
+
? ["retain-session", "supply-different-integrated-revision"]
|
|
2125
|
+
: ["provide-integration-evidence", "merge-or-retain-branch", "retain-session"];
|
|
2126
|
+
case "RECOVERABLE_STASHES":
|
|
2127
|
+
return ["apply-or-export-stash", "retain-session"];
|
|
2128
|
+
case "MISSING_WORKTREE":
|
|
2129
|
+
return ["run-garbage-collect"];
|
|
2130
|
+
case "OWNERSHIP_MISMATCH":
|
|
2131
|
+
case "DUPLICATE_WORKTREE_OWNERSHIP":
|
|
2132
|
+
case "DUPLICATE_BRANCH_OWNERSHIP":
|
|
2133
|
+
return ["reconcile-ownership", "retain-session"];
|
|
2134
|
+
case "PROTECTED_WORKTREE":
|
|
2135
|
+
case "PROTECTED_BRANCH":
|
|
2136
|
+
return details.phase === "execution"
|
|
2137
|
+
? ["run-from-managed-session-worktree", "select-target-session-explicitly"]
|
|
2138
|
+
: ["retain-session"];
|
|
2139
|
+
case "RESOURCE_CLAIM_CONFLICT":
|
|
2140
|
+
return ["inspect-blocking-session", "wait-for-conflicting-claim-release", "retain-session"];
|
|
2141
|
+
case "GIT_STATE_AMBIGUOUS":
|
|
2142
|
+
case "PHYSICAL_OBSERVATION_UNAVAILABLE":
|
|
2143
|
+
case "REPOSITORY_IDENTITY_AMBIGUOUS":
|
|
2144
|
+
case "WORKTREE_IDENTITY_AMBIGUOUS":
|
|
2145
|
+
case "GIT_COMMAND_FAILED":
|
|
2146
|
+
case "GIT_SPAWN_FAILED":
|
|
2147
|
+
case "GIT_TIMEOUT":
|
|
2148
|
+
case "GIT_OUTPUT_LIMIT":
|
|
2149
|
+
return ["stabilize-repository-state", "retry-diagnostic"];
|
|
2150
|
+
case "STALE_REGISTRY":
|
|
2151
|
+
case "RECONCILIATION_DRIFT":
|
|
2152
|
+
return ["retry-diagnostic"];
|
|
2153
|
+
default:
|
|
2154
|
+
return ["retry-diagnostic"];
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function isAmbiguousObservationCode(code) {
|
|
2158
|
+
return (code === "GIT_STATE_AMBIGUOUS" ||
|
|
2159
|
+
code === "PHYSICAL_OBSERVATION_UNAVAILABLE" ||
|
|
2160
|
+
code === "REPOSITORY_IDENTITY_AMBIGUOUS" ||
|
|
2161
|
+
code === "WORKTREE_IDENTITY_AMBIGUOUS" ||
|
|
2162
|
+
code === "GIT_COMMAND_FAILED" ||
|
|
2163
|
+
code === "GIT_SPAWN_FAILED" ||
|
|
2164
|
+
code === "GIT_TIMEOUT" ||
|
|
2165
|
+
code === "GIT_OUTPUT_LIMIT");
|
|
2166
|
+
}
|
|
2167
|
+
/** #123 non-ancestry integration: RECOVERABLE_COMMITS with no ancestry proof and no evidence attempted yet. */
|
|
2168
|
+
function isExternalEvidenceEligible(blocker) {
|
|
2169
|
+
return blocker.code === "RECOVERABLE_COMMITS" && blocker.details.proofMethod === "ancestry";
|
|
2170
|
+
}
|
|
2171
|
+
function closeReadinessForBlockers(blockers) {
|
|
2172
|
+
const blocker = blockers[0];
|
|
2173
|
+
if (blocker === undefined)
|
|
2174
|
+
return "ready";
|
|
2175
|
+
if (isAmbiguousObservationCode(blocker.code))
|
|
2176
|
+
return "ambiguous";
|
|
2177
|
+
if (isExternalEvidenceEligible(blocker))
|
|
2178
|
+
return "external_evidence_required";
|
|
2179
|
+
return "blocked";
|
|
2180
|
+
}
|
|
2181
|
+
function resultStateForDiagnostic(closeReadiness, blockers) {
|
|
2182
|
+
if (closeReadiness === "ambiguous")
|
|
2183
|
+
return "ambiguous";
|
|
2184
|
+
if (closeReadiness === "external_evidence_required")
|
|
2185
|
+
return "external_evidence_required";
|
|
2186
|
+
const blocker = blockers[0];
|
|
2187
|
+
if (blocker !== undefined && (blocker.code === "STALE_REGISTRY" || blocker.code === "RECONCILIATION_DRIFT")) {
|
|
2188
|
+
return "stale";
|
|
2189
|
+
}
|
|
2190
|
+
return "complete";
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Enrich a direct `close()` rejection with the exact same #124 diagnostic
|
|
2194
|
+
* classification `session inspect` would report for the identical blocked
|
|
2195
|
+
* state (readiness, result state, stable safe actions), so a raw close
|
|
2196
|
+
* failure and the diagnostic authority can never drift apart. This never
|
|
2197
|
+
* mutates session/claim/Git/worktree state; it only reclassifies an error
|
|
2198
|
+
* that `inspectCleanupResources` already raised before any mutation ran.
|
|
2199
|
+
*/
|
|
2200
|
+
function enrichCloseBlockerError(error) {
|
|
2201
|
+
if (!(error instanceof SessionRegistryError))
|
|
2202
|
+
throw error;
|
|
2203
|
+
const blocker = toDiagnosticBlocker(error);
|
|
2204
|
+
const closeReadiness = closeReadinessForBlockers([blocker]);
|
|
2205
|
+
const resultState = resultStateForDiagnostic(closeReadiness, [blocker]);
|
|
2206
|
+
return new SessionRegistryError(error.code, error.message, {
|
|
2207
|
+
...blocker.details,
|
|
2208
|
+
safeActions: [...blocker.safeActions],
|
|
2209
|
+
recoveryHints: [...blocker.recoveryHints],
|
|
2210
|
+
closeReadiness,
|
|
2211
|
+
resultState,
|
|
2212
|
+
}, error.cause);
|
|
2213
|
+
}
|
|
1820
2214
|
function toGarbageCollectBlocked(sessionId, error) {
|
|
1821
2215
|
const blocker = toCleanupBlocker(error);
|
|
1822
2216
|
return Object.freeze({
|
|
@@ -2246,8 +2640,8 @@ function removeSessionWorktree(git, cwd, worktreePath) {
|
|
|
2246
2640
|
const force = !fs.existsSync(worktreePath);
|
|
2247
2641
|
git.run(["worktree", "remove", ...(force ? ["--force"] : []), worktreePath], cwd);
|
|
2248
2642
|
}
|
|
2249
|
-
function removeSessionBranch(git, cwd, branchName) {
|
|
2250
|
-
git.run(["branch", "-d", "--", branchName], cwd);
|
|
2643
|
+
function removeSessionBranch(git, cwd, branchName, force = false) {
|
|
2644
|
+
git.run(["branch", force ? "-D" : "-d", "--", branchName], cwd);
|
|
2251
2645
|
}
|
|
2252
2646
|
function generateUniqueSessionId(records, idGenerator) {
|
|
2253
2647
|
for (let attempt = 0; attempt < MAX_ID_GENERATION_ATTEMPTS; attempt += 1) {
|
|
@@ -2739,10 +3133,12 @@ function validateRegistryClaims(records, claims, expectedRepositoryId) {
|
|
|
2739
3133
|
}
|
|
2740
3134
|
if (!claimsConflict(current, prior))
|
|
2741
3135
|
continue;
|
|
3136
|
+
const conflictDetails = resourceClaimConflictDetails(current, prior, records);
|
|
2742
3137
|
throw claimError("RESOURCE_CLAIM_CONFLICT", "Persisted claims contain an unresolved conflict", {
|
|
2743
3138
|
claimId: current.claimId,
|
|
2744
|
-
|
|
2745
|
-
|
|
3139
|
+
...conflictDetails,
|
|
3140
|
+
safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
|
|
3141
|
+
recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
|
|
2746
3142
|
});
|
|
2747
3143
|
}
|
|
2748
3144
|
}
|
|
@@ -2992,36 +3388,4 @@ function toSessionRegistryLockError(error, lockPath) {
|
|
|
2992
3388
|
}
|
|
2993
3389
|
return new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not operate on ${lockPath}`, { path: lockPath }, error);
|
|
2994
3390
|
}
|
|
2995
|
-
function closeQuietly(descriptor) {
|
|
2996
|
-
try {
|
|
2997
|
-
fs.closeSync(descriptor);
|
|
2998
|
-
}
|
|
2999
|
-
catch {
|
|
3000
|
-
// The original operation's error is more useful than a best-effort close error.
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
function unlinkQuietly(filePath) {
|
|
3004
|
-
try {
|
|
3005
|
-
fs.unlinkSync(filePath);
|
|
3006
|
-
}
|
|
3007
|
-
catch {
|
|
3008
|
-
// A missing temporary file is already the desired state.
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
|
-
function syncDirectory(directory) {
|
|
3012
|
-
let descriptor;
|
|
3013
|
-
try {
|
|
3014
|
-
descriptor = fs.openSync(directory, "r");
|
|
3015
|
-
fs.fsyncSync(descriptor);
|
|
3016
|
-
}
|
|
3017
|
-
catch {
|
|
3018
|
-
// Directory fsync is not available on every supported filesystem. The
|
|
3019
|
-
// file itself was fsynced before rename, so continue on that limitation.
|
|
3020
|
-
}
|
|
3021
|
-
finally {
|
|
3022
|
-
if (descriptor !== undefined) {
|
|
3023
|
-
closeQuietly(descriptor);
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
}
|
|
3027
3391
|
//# sourceMappingURL=session-registry.js.map
|