@fitlab-ai/agent-infra 0.11.4 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/internal-cli.js +0 -5
- package/dist/lib/decide.js +4 -41
- package/dist/lib/git/branch-worktree.js +45 -0
- package/dist/lib/internal/cli-route-inventory.js +0 -3
- package/dist/lib/internal/task-activity.js +7 -40
- package/dist/lib/internal/task-event.js +4 -37
- package/dist/lib/internal/task-finalization.js +9 -2
- package/dist/lib/internal/task-ledger.js +5 -43
- package/dist/lib/internal/task-lifecycle.js +1 -1
- package/dist/lib/internal/task-operation-registry.js +1 -4
- package/dist/lib/internal/task-review.js +5 -37
- package/dist/lib/internal/task-verify.js +3 -47
- package/dist/lib/internal/task-warning.js +4 -40
- package/dist/lib/merge.js +62 -10
- package/dist/lib/platform/github-provider.js +9 -14
- package/dist/lib/platform/github-release-notes.js +80 -11
- package/dist/lib/platform/none-provider.js +1 -1
- package/dist/lib/platform/pr-summary.js +4 -23
- package/dist/lib/platform/provider-contract.d.ts +39 -16
- package/dist/lib/platform/provider-contract.js +1 -1
- package/dist/lib/platform/provider-validation.js +83 -13
- package/dist/lib/platform/release-notes.js +7 -8
- package/dist/lib/platform/verification-sync.js +12 -49
- package/dist/lib/sandbox/capture.js +1 -1
- package/dist/lib/sandbox/commands/create.js +46 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/start.js +1 -1
- package/dist/lib/sandbox/config.js +10 -0
- package/dist/lib/sandbox/control/client.js +4 -1
- package/dist/lib/sandbox/control/executor.js +12 -4
- package/dist/lib/sandbox/control/lifecycle.js +18 -13
- package/dist/lib/sandbox/control/protocol.js +2 -1
- package/dist/lib/sandbox/removal.js +15 -8
- package/dist/lib/sandbox/task-resolver.js +62 -2
- package/dist/lib/task/activity-intent.js +1 -2
- package/dist/lib/task/artifact-operations.js +36 -2
- package/dist/lib/task/artifact-schema.js +7 -7
- package/dist/lib/task/control-authority.js +9 -166
- package/dist/lib/task/decision-intents.js +2 -4
- package/dist/lib/task/events.js +7 -28
- package/dist/lib/task/finalization-handoff.js +92 -0
- package/dist/lib/task/finalization.js +123 -12
- package/dist/lib/task/intermediate-cleanup.js +9 -148
- package/dist/lib/task/ledger-intents.js +3 -6
- package/dist/lib/task/lifecycle.js +50 -11
- package/dist/lib/task/local-artifact-finalization.js +13 -28
- package/dist/lib/task/resolve-ref.js +3 -3
- package/dist/lib/task/review-command.js +1 -2
- package/dist/lib/task/review-finalization.js +1 -2
- package/dist/lib/task/snapshot.js +6 -6
- package/dist/lib/task/verification-engine.js +16 -11
- package/dist/lib/task/verification.js +1 -13
- package/dist/lib/task/workflow-warning-intents.js +2 -4
- package/dist/lib/task/write.js +122 -3
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/decide.ts +5 -35
- package/lib/git/branch-worktree.ts +41 -0
- package/lib/internal/cli-route-inventory.ts +0 -2
- package/lib/internal/task-activity.ts +6 -34
- package/lib/internal/task-event.ts +4 -37
- package/lib/internal/task-finalization.ts +9 -2
- package/lib/internal/task-ledger.ts +5 -38
- package/lib/internal/task-lifecycle.ts +2 -2
- package/lib/internal/task-operation-registry.ts +1 -4
- package/lib/internal/task-review.ts +5 -31
- package/lib/internal/task-verify.ts +3 -39
- package/lib/internal/task-warning.ts +3 -32
- package/lib/merge.ts +59 -10
- package/lib/platform/github-provider.ts +11 -18
- package/lib/platform/github-release-notes.ts +89 -14
- package/lib/platform/none-provider.ts +1 -1
- package/lib/platform/pr-summary.ts +4 -25
- package/lib/platform/provider-contract.ts +38 -12
- package/lib/platform/provider-validation.ts +83 -12
- package/lib/platform/release-notes.ts +11 -12
- package/lib/platform/verification-sync.ts +13 -53
- package/lib/sandbox/capture.ts +1 -1
- package/lib/sandbox/commands/create.ts +52 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/start.ts +1 -1
- package/lib/sandbox/config.ts +12 -0
- package/lib/sandbox/control/client.ts +5 -1
- package/lib/sandbox/control/executor.ts +13 -4
- package/lib/sandbox/control/lifecycle.ts +22 -13
- package/lib/sandbox/control/protocol.ts +3 -2
- package/lib/sandbox/removal.ts +23 -7
- package/lib/sandbox/task-resolver.ts +51 -2
- package/lib/task/activity-intent.ts +1 -2
- package/lib/task/artifact-operations.ts +58 -2
- package/lib/task/artifact-schema.ts +9 -7
- package/lib/task/control-authority.ts +14 -188
- package/lib/task/decision-intents.ts +2 -4
- package/lib/task/events.ts +8 -27
- package/lib/task/finalization-handoff.ts +104 -0
- package/lib/task/finalization.ts +120 -12
- package/lib/task/intermediate-cleanup.ts +9 -125
- package/lib/task/ledger-intents.ts +3 -6
- package/lib/task/lifecycle.ts +45 -13
- package/lib/task/local-artifact-finalization.ts +16 -35
- package/lib/task/resolve-ref.ts +3 -3
- package/lib/task/review-command.ts +2 -5
- package/lib/task/review-finalization.ts +1 -4
- package/lib/task/snapshot.ts +6 -6
- package/lib/task/verification-engine.ts +20 -10
- package/lib/task/verification.ts +1 -15
- package/lib/task/workflow-warning-intents.ts +2 -4
- package/lib/task/write.ts +116 -5
- package/package.json +1 -1
- package/templates/.agents/rules/evidence-reporting.en.md +1 -2
- package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
- package/templates/.agents/skills/analyze-task/SKILL.en.md +11 -2
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -2
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
- package/templates/.agents/skills/code-task/reference/report-template.en.md +6 -6
- package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +6 -6
- package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +2 -2
- package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +2 -2
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +1 -3
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +1 -3
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
- package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +3 -3
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +3 -3
- package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -3
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -3
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +3 -3
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +3 -3
- package/templates/.agents/skills/review-pr/config/verify.en.json +3 -1
- package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +3 -1
- package/templates/.agents/skills/review-pr/reference/report-template.en.md +3 -3
- package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +3 -3
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +2 -2
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +2 -2
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
- package/dist/lib/internal/task-override.js +0 -138
- package/dist/lib/task/artifact-recovery.js +0 -476
- package/dist/lib/task/artifact-repair-intent.js +0 -181
- package/dist/lib/task/guard-override.js +0 -31
- package/dist/lib/task/human-override.js +0 -1259
- package/lib/internal/task-override.ts +0 -144
- package/lib/task/artifact-recovery.ts +0 -572
- package/lib/task/artifact-repair-intent.ts +0 -227
- package/lib/task/guard-override.ts +0 -50
- package/lib/task/human-override.ts +0 -1522
package/lib/task/events.ts
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
|
|
5
|
+
import { resolveBranchWorktree } from '../git/branch-worktree.ts';
|
|
6
|
+
|
|
5
7
|
import { appendActivityEntry, locateActivityLog, pairEntries, startedBackedRows } from './activity-log.ts';
|
|
6
8
|
import {
|
|
7
9
|
buildArtifactLinkSection,
|
|
@@ -32,7 +34,6 @@ import type { OrchestrationStageCompletion } from './orchestration.ts';
|
|
|
32
34
|
import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
|
|
33
35
|
import { captureTaskWriteMetadata, writeTask } from './write.ts';
|
|
34
36
|
import type { TaskOperationSummary, TaskWriteErrorCode, TaskWriteOptions } from './write.ts';
|
|
35
|
-
import { allowsManualOverride } from './guard-override.ts';
|
|
36
37
|
import { validateLocalArtifact } from './local-artifact-finalization.ts';
|
|
37
38
|
import type { LocalArtifactFamily } from './local-artifact-finalization.ts';
|
|
38
39
|
import { buildLifecycleFacts, canStart, effectiveReworkTarget } from './capabilities.ts';
|
|
@@ -81,7 +82,6 @@ type TaskEventErrorCode =
|
|
|
81
82
|
| ArtifactErrorCode | TaskWriteErrorCode;
|
|
82
83
|
type TaskEventRequest = {
|
|
83
84
|
taskRef: string; event: TaskEventName | string; agent: string; dryRun?: boolean; orchestrated?: boolean;
|
|
84
|
-
overrideTicket?: string; overrideTarget?: string; overrideScope?: string;
|
|
85
85
|
initiator?: TriggerInitiator; requestId?: string; reasonCode?: TriggerReason;
|
|
86
86
|
sourceFinding?: string; sourceArtifact?: string; sourceSha256?: string;
|
|
87
87
|
round?: number; question?: number; artifact?: string; fixFor?: string; implementationInput?: string;
|
|
@@ -111,22 +111,7 @@ type TaskEventResult = {
|
|
|
111
111
|
|
|
112
112
|
function branchWorktree(repoRoot: string, branch: string): string | null {
|
|
113
113
|
if (!branch) return repoRoot;
|
|
114
|
-
|
|
115
|
-
const records = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
116
|
-
cwd: repoRoot,
|
|
117
|
-
encoding: 'utf8',
|
|
118
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
119
|
-
}).trim().split(/\r?\n\r?\n/);
|
|
120
|
-
const expectedRef = `refs/heads/${branch}`;
|
|
121
|
-
for (const record of records) {
|
|
122
|
-
const worktree = /^worktree (.+)$/m.exec(record)?.[1];
|
|
123
|
-
const branchRef = /^branch (.+)$/m.exec(record)?.[1];
|
|
124
|
-
if (worktree && branchRef === expectedRef) return worktree;
|
|
125
|
-
}
|
|
126
|
-
} catch {
|
|
127
|
-
// A missing worktree must not anchor a review to an unrelated repository.
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
114
|
+
return resolveBranchWorktree(repoRoot, branch);
|
|
130
115
|
}
|
|
131
116
|
|
|
132
117
|
function approvedCleanReviewedCommit(reviewContent: string, verdict: Verdict | undefined, repoRoot: string, branch: string): string | null {
|
|
@@ -148,7 +133,7 @@ function approvedCleanReviewedCommit(reviewContent: string, verdict: Verdict | u
|
|
|
148
133
|
}
|
|
149
134
|
}
|
|
150
135
|
|
|
151
|
-
const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun', '
|
|
136
|
+
const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun', 'initiator', 'requestId', 'reasonCode', 'sourceFinding', 'sourceArtifact', 'sourceSha256']);
|
|
152
137
|
const SCHEMAS: Record<TaskEventName, { required?: string[]; optional?: string[] }> = {
|
|
153
138
|
'analyze.started': { optional: ['round'] },
|
|
154
139
|
'analyze.awaiting-input': { required: ['question'] },
|
|
@@ -758,8 +743,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
758
743
|
if (invalid) return failed(request, invalid);
|
|
759
744
|
const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
|
|
760
745
|
if (!resolved.ok) return failed(request, { code: resolved.code, message: resolved.message }, { taskId: resolved.taskId });
|
|
761
|
-
|
|
762
|
-
if (resolved.state !== 'active' && !stateOverride) return failed(request, { code: 'TASK_STATE_MISMATCH', message: `task ${resolved.taskId} is ${resolved.state}, expected active` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
|
|
746
|
+
if (resolved.state !== 'active') return failed(request, { code: 'TASK_STATE_MISMATCH', message: `task ${resolved.taskId} is ${resolved.state}, expected active` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
|
|
763
747
|
let content: string;
|
|
764
748
|
try { content = fs.readFileSync(resolved.taskMdPath, 'utf8'); }
|
|
765
749
|
catch (error) { return failed(request, { code: 'TASK_READ_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
|
|
@@ -851,7 +835,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
851
835
|
}
|
|
852
836
|
return successNoOp(normalized, resolved.taskId, resolved.taskMdPath, currentStep, eventIdentity, row.started, frontmatter, artifactContext);
|
|
853
837
|
}
|
|
854
|
-
if (eventIdentity.phase === 'completed' && !row?.started && completedRows.length === 0
|
|
838
|
+
if (eventIdentity.phase === 'completed' && !row?.started && completedRows.length === 0) return failed(normalized, { code: 'EVENT_START_MISSING', message: 'completion requires a started event or an earlier completed attempt' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
855
839
|
if (eventIdentity.phase === 'completed') {
|
|
856
840
|
const validated = validateCompletedArtifact(resolved.taskDir, FAMILY[eventIdentity.family].artifact, normalized.artifact!, normalized.round);
|
|
857
841
|
if (!validated.ok) return failed(normalized, validated.error, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
@@ -918,12 +902,9 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
918
902
|
const facts = buildLifecycleFacts(resolved.taskDir, content, resolved.state);
|
|
919
903
|
if (!facts.ok) return failed(normalized, { code: 'EVENT_TRANSITION_INVALID', message: `${facts.code}: ${facts.message}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
920
904
|
const capability = canStart(lifecycleAction(eventIdentity.family), facts.facts, trigger);
|
|
921
|
-
const capabilityOverride = capability.reasonCode === 'TASK_NOT_ACTIVE'
|
|
922
|
-
? stateOverride
|
|
923
|
-
: allowsManualOverride(options.manualOverride, 'task-event', 'EVENT_TRANSITION_INVALID');
|
|
924
905
|
const safetyFailure = capability.reasonCode === 'TASK_NOT_ACTIVE'
|
|
925
906
|
|| capability.reasonCode === 'LIFECYCLE_EXECUTION_OPEN';
|
|
926
|
-
if (!capability.allowed && (normalized.initiator === 'orchestrator' || safetyFailure)
|
|
907
|
+
if (!capability.allowed && (normalized.initiator === 'orchestrator' || safetyFailure)) {
|
|
927
908
|
const code = capability.reasonCode === 'INVALIDATION_INCOMPLETE' ? 'TASK_INVALIDATION_BLOCKED' : 'EVENT_TRANSITION_INVALID';
|
|
928
909
|
return failed(normalized, { code, message: `${capability.reasonCode}: ${capability.evidence.join(', ')}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
929
910
|
}
|
|
@@ -1078,7 +1059,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
1078
1059
|
mutations.push({ kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: section.heading, body });
|
|
1079
1060
|
const sourceCompletion = eventIdentity.phase === 'completed'
|
|
1080
1061
|
&& ['analyze', 'plan', 'code'].includes(eventIdentity.family);
|
|
1081
|
-
const result = writeTask({ taskRef: normalized.taskRef, expectedState:
|
|
1062
|
+
const result = writeTask({ taskRef: normalized.taskRef, expectedState: 'active', dryRun: normalized.dryRun, mutations }, { ...options, invalidationContext: sourceCompletion ? 'source-completion' : 'standard', metadataProvider: () => metadata });
|
|
1082
1063
|
if (result.status === 'failed') return failed(normalized, result.error, { taskId: result.taskId, taskMdPath: result.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, timestamp: result.timestamp, agentInfraVersion: result.agentInfraVersion, operations: result.operations, artifactContext });
|
|
1083
1064
|
if (!normalized.dryRun && orchestrationCompletion) {
|
|
1084
1065
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { TaskFinalizationReceipt } from './finalization.ts';
|
|
6
|
+
|
|
7
|
+
const HANDOFF_VERSION = 1 as const;
|
|
8
|
+
|
|
9
|
+
type TaskFinalizationHandoff = Readonly<{
|
|
10
|
+
version: typeof HANDOFF_VERSION;
|
|
11
|
+
taskId: string;
|
|
12
|
+
binding: Readonly<{ generation: string; requestId: string }>;
|
|
13
|
+
receipt: TaskFinalizationReceipt;
|
|
14
|
+
receiptSha256: string;
|
|
15
|
+
}>;
|
|
16
|
+
type ReadHandoff = Readonly<{ receipt: TaskFinalizationReceipt; cleanup: () => void }>;
|
|
17
|
+
const MAX_HANDOFF_BYTES = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
function digest(value: unknown): string {
|
|
20
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function finalizationHandoffPath(directory: string, taskId: string): string {
|
|
24
|
+
return path.join(directory, '.agent-infra', 'task-finalization', `${taskId}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function publishTaskFinalizationHandoff(
|
|
28
|
+
directory: string,
|
|
29
|
+
receipt: TaskFinalizationReceipt,
|
|
30
|
+
binding: Readonly<{ generation: string; requestId: string }>
|
|
31
|
+
): string {
|
|
32
|
+
if (receipt.taskId === '' || receipt.controlBinding?.generation !== binding.generation
|
|
33
|
+
|| receipt.controlBinding.requestId !== binding.requestId) {
|
|
34
|
+
throw new Error('TASK_FINALIZATION_HANDOFF_BINDING_INVALID');
|
|
35
|
+
}
|
|
36
|
+
const envelope: TaskFinalizationHandoff = {
|
|
37
|
+
version: HANDOFF_VERSION, taskId: receipt.taskId, binding, receipt, receiptSha256: digest(receipt)
|
|
38
|
+
};
|
|
39
|
+
const target = finalizationHandoffPath(directory, receipt.taskId);
|
|
40
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
41
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
fs.writeFileSync(temporary, `${JSON.stringify(envelope)}\n`, { mode: 0o600, flag: 'wx' });
|
|
43
|
+
try { fs.renameSync(temporary, target); } catch (error) {
|
|
44
|
+
try { fs.unlinkSync(temporary); } catch { /* preserve the primary error */ }
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return digest(envelope);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readTaskFinalizationHandoff(
|
|
51
|
+
directory: string,
|
|
52
|
+
taskId: string,
|
|
53
|
+
binding: Readonly<{ generation: string; requestId: string }>,
|
|
54
|
+
handoffSha256: string
|
|
55
|
+
): ReadHandoff {
|
|
56
|
+
const file = finalizationHandoffPath(directory, taskId);
|
|
57
|
+
const initial = fs.lstatSync(file);
|
|
58
|
+
if (!initial.isFile() || initial.isSymbolicLink() || initial.size > MAX_HANDOFF_BYTES) {
|
|
59
|
+
throw new Error('TASK_FINALIZATION_HANDOFF_INVALID');
|
|
60
|
+
}
|
|
61
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
62
|
+
let content: string;
|
|
63
|
+
try {
|
|
64
|
+
const opened = fs.fstatSync(descriptor);
|
|
65
|
+
if (!opened.isFile() || opened.dev !== initial.dev || opened.ino !== initial.ino
|
|
66
|
+
|| opened.size > MAX_HANDOFF_BYTES) throw new Error('TASK_FINALIZATION_HANDOFF_INVALID');
|
|
67
|
+
const bytes = Buffer.alloc(MAX_HANDOFF_BYTES + 1);
|
|
68
|
+
const length = fs.readSync(descriptor, bytes, 0, bytes.length, 0);
|
|
69
|
+
if (length !== opened.size || length > MAX_HANDOFF_BYTES) throw new Error('TASK_FINALIZATION_HANDOFF_INVALID');
|
|
70
|
+
content = bytes.toString('utf8', 0, length);
|
|
71
|
+
const after = fs.fstatSync(descriptor);
|
|
72
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
73
|
+
throw new Error('TASK_FINALIZATION_HANDOFF_INVALID');
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
fs.closeSync(descriptor);
|
|
77
|
+
}
|
|
78
|
+
const envelope = JSON.parse(content) as Partial<TaskFinalizationHandoff>;
|
|
79
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
|
|
80
|
+
|| Object.keys(envelope).sort().join('\0') !== ['binding', 'receipt', 'receiptSha256', 'taskId', 'version'].join('\0')
|
|
81
|
+
|| envelope.version !== HANDOFF_VERSION || envelope.taskId !== taskId
|
|
82
|
+
|| !envelope.binding || typeof envelope.binding !== 'object' || Array.isArray(envelope.binding)
|
|
83
|
+
|| Object.keys(envelope.binding).sort().join('\0') !== ['generation', 'requestId'].join('\0')
|
|
84
|
+
|| envelope.binding.generation !== binding.generation
|
|
85
|
+
|| envelope.binding.requestId !== binding.requestId || !envelope.receipt
|
|
86
|
+
|| envelope.receipt.controlBinding?.generation !== binding.generation
|
|
87
|
+
|| envelope.receipt.controlBinding.requestId !== binding.requestId
|
|
88
|
+
|| typeof envelope.receiptSha256 !== 'string' || envelope.receiptSha256 !== digest(envelope.receipt)
|
|
89
|
+
|| digest(envelope) !== handoffSha256) {
|
|
90
|
+
throw new Error('TASK_FINALIZATION_HANDOFF_INVALID');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
receipt: envelope.receipt,
|
|
94
|
+
cleanup: () => {
|
|
95
|
+
try {
|
|
96
|
+
const current = fs.lstatSync(file);
|
|
97
|
+
if (current.isFile() && !current.isSymbolicLink()
|
|
98
|
+
&& current.dev === initial.dev && current.ino === initial.ino) fs.unlinkSync(file);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
package/lib/task/finalization.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { taskIssueIdentity } from '../platform/task-identities.ts';
|
|
|
10
10
|
import { parseTaskFrontmatter } from './frontmatter.ts';
|
|
11
11
|
import {
|
|
12
12
|
applyTaskLifecycle,
|
|
13
|
+
inspectTaskLifecycleProgress,
|
|
13
14
|
type TaskLifecycleOptions,
|
|
14
15
|
type TaskLifecycleRequest,
|
|
15
16
|
type TaskLifecycleResult
|
|
@@ -25,6 +26,9 @@ import {
|
|
|
25
26
|
type OperationWarning,
|
|
26
27
|
type OperationWarningSeverity
|
|
27
28
|
} from './operation-outcome.ts';
|
|
29
|
+
import { readTaskFinalizationHandoff } from './finalization-handoff.ts';
|
|
30
|
+
import { readSandboxControlManifest } from '../sandbox/control/lifecycle.ts';
|
|
31
|
+
import { validateSandboxControlIdentity } from '../sandbox/control/identity-sentinel.ts';
|
|
28
32
|
|
|
29
33
|
const RECEIPT_VERSION = 4 as const;
|
|
30
34
|
const FINALIZATION_STEPS = ['lifecycle', 'task-comment', 'verification', 'summary'] as const;
|
|
@@ -55,11 +59,14 @@ type TaskFinalizationRequest = Readonly<{
|
|
|
55
59
|
taskRef: string;
|
|
56
60
|
intent: 'complete';
|
|
57
61
|
agent: string;
|
|
62
|
+
handoffSha256?: string;
|
|
58
63
|
}>;
|
|
59
64
|
|
|
60
65
|
type TaskFinalizationOptions = Readonly<{
|
|
61
66
|
repoRoot: string;
|
|
62
67
|
controlBinding?: Readonly<{ generation: string; requestId: string }>;
|
|
68
|
+
handoffDirectory?: string;
|
|
69
|
+
manifestPath?: string;
|
|
63
70
|
metadataProvider?: TaskLifecycleOptions['metadataProvider'];
|
|
64
71
|
lifecycle?: typeof applyTaskLifecycle;
|
|
65
72
|
backfill?: typeof backfillCompletionComments;
|
|
@@ -198,20 +205,32 @@ function validateReceipt(value: unknown, taskId: string): TaskFinalizationReceip
|
|
|
198
205
|
const receipt = value as Record<string, unknown>;
|
|
199
206
|
const controlBinding = receipt.controlBinding as Record<string, unknown> | null | undefined;
|
|
200
207
|
const states = ['pending', 'done', 'skipped'];
|
|
208
|
+
const requiredKeys = [
|
|
209
|
+
'version', 'taskId', 'intent', 'receiptId', 'revision', 'lifecycle', 'taskComment',
|
|
210
|
+
'verification', 'summary', 'warningProjection', 'warnings', 'updatedAt', 'lastError'
|
|
211
|
+
];
|
|
212
|
+
if (receipt.controlBinding !== undefined) requiredKeys.push('controlBinding');
|
|
201
213
|
if (
|
|
214
|
+
Object.keys(receipt).sort().join('\0') !== requiredKeys.sort().join('\0')
|
|
215
|
+
||
|
|
202
216
|
receipt.version !== RECEIPT_VERSION || receipt.taskId !== taskId || receipt.intent !== 'complete'
|
|
203
217
|
|| typeof receipt.receiptId !== 'string' || receipt.receiptId.length === 0
|
|
204
218
|
|| !Number.isSafeInteger(receipt.revision) || Number(receipt.revision) < 0
|
|
205
|
-
|| !states.includes(
|
|
206
|
-
|| !states.includes(
|
|
207
|
-
|| !states.includes(
|
|
208
|
-
|| !states.includes(
|
|
209
|
-
|| !['pending', 'done'].includes(
|
|
219
|
+
|| !states.includes(receipt.lifecycle as string)
|
|
220
|
+
|| !states.includes(receipt.taskComment as string)
|
|
221
|
+
|| !states.includes(receipt.verification as string)
|
|
222
|
+
|| !states.includes(receipt.summary as string)
|
|
223
|
+
|| !['pending', 'done'].includes(receipt.warningProjection as string)
|
|
210
224
|
|| !Array.isArray(receipt.warnings) || receipt.warnings.some((warning) => !validWarning(warning))
|
|
211
|
-
|| typeof receipt.updatedAt !== 'string'
|
|
212
|
-
|| (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)
|
|
225
|
+
|| typeof receipt.updatedAt !== 'string' || receipt.updatedAt.length === 0
|
|
226
|
+
|| (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)
|
|
227
|
+
|| Object.keys(receipt.lastError).sort().join('\0') !== ['code', 'message', 'retryable'].join('\0')
|
|
228
|
+
|| typeof (receipt.lastError as Record<string, unknown>).code !== 'string'
|
|
229
|
+
|| typeof (receipt.lastError as Record<string, unknown>).message !== 'string'
|
|
230
|
+
|| typeof (receipt.lastError as Record<string, unknown>).retryable !== 'boolean'))
|
|
213
231
|
|| (receipt.controlBinding !== undefined && (!controlBinding || typeof controlBinding !== 'object'
|
|
214
232
|
|| Array.isArray(controlBinding)
|
|
233
|
+
|| Object.keys(controlBinding).sort().join('\0') !== ['generation', 'requestId'].join('\0')
|
|
215
234
|
|| typeof controlBinding.generation !== 'string' || controlBinding.generation.length === 0
|
|
216
235
|
|| typeof controlBinding.requestId !== 'string' || !/^[a-f0-9-]{16,64}$/u.test(controlBinding.requestId)))
|
|
217
236
|
) throw new Error('receipt schema is invalid');
|
|
@@ -243,6 +262,14 @@ function writeReceipt(repoRoot: string, receipt: TaskFinalizationReceipt): void
|
|
|
243
262
|
}
|
|
244
263
|
}
|
|
245
264
|
|
|
265
|
+
export function bindTaskFinalizationReceipt(
|
|
266
|
+
repoRoot: string, taskId: string, controlBinding: Readonly<{ generation: string; requestId: string }>
|
|
267
|
+
): TaskFinalizationReceipt {
|
|
268
|
+
const receipt = readReceipt(path.resolve(repoRoot), taskId);
|
|
269
|
+
if (!receipt) throw new Error('TASK_FINALIZATION_PREPARATION_REQUIRED');
|
|
270
|
+
return updateReceipt(path.resolve(repoRoot), receipt, { controlBinding, lastError: null });
|
|
271
|
+
}
|
|
272
|
+
|
|
246
273
|
function updateReceipt(
|
|
247
274
|
repoRoot: string,
|
|
248
275
|
receipt: TaskFinalizationReceipt,
|
|
@@ -798,10 +825,16 @@ async function prepareUnderLock(
|
|
|
798
825
|
} catch (error) {
|
|
799
826
|
return failed(taskId, errorOf(error, 'TASK_FINALIZATION_RECEIPT_INVALID'));
|
|
800
827
|
}
|
|
801
|
-
if (options.controlBinding && (
|
|
802
|
-
receipt.controlBinding
|
|
803
|
-
|
|
804
|
-
|
|
828
|
+
if (options.controlBinding && (receipt.controlBinding?.generation !== options.controlBinding.generation
|
|
829
|
+
|| receipt.controlBinding.requestId !== options.controlBinding.requestId)) {
|
|
830
|
+
const state = resolveTaskRef(taskId, { repoRoot });
|
|
831
|
+
if (!(receipt.lifecycle === 'done' && state.ok && state.state === 'completed')) {
|
|
832
|
+
return failed(taskId, {
|
|
833
|
+
code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
|
|
834
|
+
message: 'canonical receipt is not bound to the current sandbox request', retryable: false
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
805
838
|
|
|
806
839
|
const preflightState = resolveTaskRef(taskId, { repoRoot });
|
|
807
840
|
if (options.preflight && preflightState.ok && preflightState.state === 'active') {
|
|
@@ -1043,13 +1076,88 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
|
|
|
1043
1076
|
if (!resolved.ok) return failed(resolved.taskId, { code: resolved.code, message: resolved.message, retryable: false });
|
|
1044
1077
|
try {
|
|
1045
1078
|
return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.commit', () => {
|
|
1046
|
-
|
|
1079
|
+
if (options.controlBinding && options.manifestPath) {
|
|
1080
|
+
try {
|
|
1081
|
+
const manifest = readSandboxControlManifest(options.manifestPath);
|
|
1082
|
+
const identity = validateSandboxControlIdentity({
|
|
1083
|
+
publicStatusDir: manifest.publicStatusDir,
|
|
1084
|
+
root: path.dirname(path.resolve(options.manifestPath)),
|
|
1085
|
+
mode: manifest.mode,
|
|
1086
|
+
taskId: manifest.taskId,
|
|
1087
|
+
generation: manifest.generation,
|
|
1088
|
+
controlRootId: manifest.controlRootId
|
|
1089
|
+
});
|
|
1090
|
+
if (identity.state !== 'valid' || manifest.taskId !== resolved.taskId
|
|
1091
|
+
|| manifest.generation !== options.controlBinding.generation
|
|
1092
|
+
|| fs.realpathSync.native(manifest.repoRoot) !== fs.realpathSync.native(repoRoot)) {
|
|
1093
|
+
throw new Error('current sandbox manifest does not match finalization request');
|
|
1094
|
+
}
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return failed(resolved.taskId, {
|
|
1097
|
+
code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
|
|
1098
|
+
message: error instanceof Error ? error.message : String(error), retryable: false
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
let receipt = readReceipt(repoRoot, resolved.taskId);
|
|
1103
|
+
if (!receipt && options.controlBinding && options.handoffDirectory && request.handoffSha256) {
|
|
1104
|
+
try {
|
|
1105
|
+
const handoff = readTaskFinalizationHandoff(
|
|
1106
|
+
options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
|
|
1107
|
+
);
|
|
1108
|
+
receipt = validateReceipt(handoff.receipt, resolved.taskId);
|
|
1109
|
+
if (receipt.lifecycle !== 'pending') throw new Error('handoff lifecycle must be pending');
|
|
1110
|
+
if (inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent) === 'unknown') {
|
|
1111
|
+
throw new Error('TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE');
|
|
1112
|
+
}
|
|
1113
|
+
writeReceipt(repoRoot, receipt);
|
|
1114
|
+
try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
return failed(resolved.taskId, {
|
|
1117
|
+
code: 'TASK_FINALIZATION_HANDOFF_INVALID',
|
|
1118
|
+
message: error instanceof Error ? error.message : String(error), retryable: false
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1047
1122
|
if (!receipt) return failed(resolved.taskId, {
|
|
1048
1123
|
code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation receipt is unavailable', retryable: true
|
|
1049
1124
|
});
|
|
1050
1125
|
if (receipt.lifecycle === 'done' && resolved.state === 'completed') {
|
|
1051
1126
|
return terminalResult(resolved.taskId, receipt, { lifecycle: { status: 'no-op', changed: false, error: null } }, false);
|
|
1052
1127
|
}
|
|
1128
|
+
if (options.controlBinding) {
|
|
1129
|
+
const proof = inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent);
|
|
1130
|
+
const oldBinding = receipt.controlBinding;
|
|
1131
|
+
if (!oldBinding || oldBinding.generation !== options.controlBinding.generation || proof === 'unknown') {
|
|
1132
|
+
return failed(resolved.taskId, {
|
|
1133
|
+
code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
|
|
1134
|
+
message: 'canonical receipt has no valid host recovery proof', retryable: false
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
if (oldBinding.requestId !== options.controlBinding.requestId && proof === 'not-started') {
|
|
1138
|
+
if (receipt.lifecycle !== 'pending' || !options.handoffDirectory || !request.handoffSha256) {
|
|
1139
|
+
return failed(resolved.taskId, {
|
|
1140
|
+
code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
|
|
1141
|
+
message: 'pending receipt cannot be rebound without a current handoff', retryable: false
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const handoff = readTaskFinalizationHandoff(
|
|
1146
|
+
options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
|
|
1147
|
+
);
|
|
1148
|
+
const nextReceipt = validateReceipt(handoff.receipt, resolved.taskId);
|
|
1149
|
+
if (nextReceipt.receiptId !== receipt.receiptId || nextReceipt.intent !== receipt.intent
|
|
1150
|
+
|| nextReceipt.lifecycle !== 'pending') throw new Error('handoff is not continuous with canonical receipt');
|
|
1151
|
+
receipt = updateReceipt(repoRoot, receipt, { controlBinding: options.controlBinding });
|
|
1152
|
+
try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
return failed(resolved.taskId, {
|
|
1155
|
+
code: 'TASK_FINALIZATION_HANDOFF_INVALID',
|
|
1156
|
+
message: error instanceof Error ? error.message : String(error), retryable: false
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1053
1161
|
if (receipt.taskComment === 'pending' || receipt.verification === 'pending' || receipt.summary === 'pending' || receipt.warningProjection === 'pending') {
|
|
1054
1162
|
return failed(resolved.taskId, {
|
|
1055
1163
|
code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation has pending external steps', retryable: true
|
|
@@ -14,26 +14,14 @@ import {
|
|
|
14
14
|
readTaskFinalizationReceipt,
|
|
15
15
|
type TaskFinalizationReceipt
|
|
16
16
|
} from './finalization.ts';
|
|
17
|
-
import {
|
|
18
|
-
semanticDigest,
|
|
19
|
-
sha256Content
|
|
20
|
-
} from './local-artifact-finalization.ts';
|
|
21
|
-
import { artifactRecoveryRoot, readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
|
|
22
|
-
import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
|
|
23
|
-
import { parseArtifactName } from './artifact-name.ts';
|
|
24
|
-
import type { ArtifactSchemaFamily } from './artifact-schema.ts';
|
|
25
17
|
import { enumerateAllTaskDirs, type TaskWorkspaceState } from './resolve-ref.ts';
|
|
26
18
|
import type { TaskControlBindingEvidence } from './finalization-state.ts';
|
|
27
19
|
|
|
28
20
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
29
|
-
const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|review-analysis|plan|review-plan|code|review-code)-(.+\.md)\.json$/;
|
|
30
21
|
const COMMIT_INTENT_RE = /^(TASK-\d{8}-\d{6})\.json$/;
|
|
31
|
-
const AUXILIARY_ROOTS = [
|
|
32
|
-
'.local-artifact-finalization-intents',
|
|
33
|
-
'.task-commit-intents'
|
|
34
|
-
] as const;
|
|
22
|
+
const AUXILIARY_ROOTS = ['.task-commit-intents'] as const;
|
|
35
23
|
|
|
36
|
-
type CleanupKind = '
|
|
24
|
+
type CleanupKind = 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
|
|
37
25
|
type CleanupDisposition = 'planned' | 'protected' | 'skipped' | 'deleted' | 'failed';
|
|
38
26
|
type FileIdentity = Readonly<{ dev: string; ino: string; size: number; mtimeMs: number }>;
|
|
39
27
|
|
|
@@ -153,47 +141,6 @@ function receiptGate(
|
|
|
153
141
|
return null;
|
|
154
142
|
}
|
|
155
143
|
|
|
156
|
-
function canonicalLocalIntentPath(taskId: string, family: string, artifact: string): string {
|
|
157
|
-
return `${taskId}-${family}-${artifact}.json`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function localIntentCandidate(
|
|
161
|
-
repoRoot: string,
|
|
162
|
-
task: TaskRecord | undefined,
|
|
163
|
-
filePath: string,
|
|
164
|
-
taskId: string,
|
|
165
|
-
family: ArtifactSchemaFamily,
|
|
166
|
-
artifact: string,
|
|
167
|
-
identity: FileIdentity,
|
|
168
|
-
options: ScanOptions
|
|
169
|
-
): IntermediateCleanupItem {
|
|
170
|
-
const protectedReason = taskGate(task);
|
|
171
|
-
if (protectedReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', protectedReason, identity);
|
|
172
|
-
if (path.basename(filePath) !== canonicalLocalIntentPath(taskId, family, artifact)
|
|
173
|
-
|| !parseArtifactName(artifact)?.family || parseArtifactName(artifact)!.family !== family) {
|
|
174
|
-
return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity);
|
|
175
|
-
}
|
|
176
|
-
let intent: ArtifactRecoveryIntent | null;
|
|
177
|
-
try { intent = readArtifactRecoveryIntent(repoRoot, taskId, family, artifact); }
|
|
178
|
-
catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_SCHEMA_INVALID', identity); }
|
|
179
|
-
if (!intent || intent.state !== 'consumed') {
|
|
180
|
-
return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_STATE_PROTECTED', identity);
|
|
181
|
-
}
|
|
182
|
-
const artifactPath = path.join(task!.taskDir, artifact);
|
|
183
|
-
if (safeLstat(task!.taskDir, artifactPath, 'file') === null) {
|
|
184
|
-
return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_IDENTITY_MISMATCH', identity);
|
|
185
|
-
}
|
|
186
|
-
let content: string;
|
|
187
|
-
try { content = fs.readFileSync(artifactPath, 'utf8'); }
|
|
188
|
-
catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_UNREADABLE', identity); }
|
|
189
|
-
if (intent.finalArtifactSha256 !== sha256Content(content) || intent.finalSemanticDigest !== semanticDigest(content)) {
|
|
190
|
-
return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_DIGEST_MISMATCH', identity);
|
|
191
|
-
}
|
|
192
|
-
const receiptReason = receiptGate(repoRoot, task!, options);
|
|
193
|
-
if (receiptReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', receiptReason, identity);
|
|
194
|
-
return item('LFAI-CONSUMED', taskId, filePath, 'planned', 'LFAI_CONSUMED_AND_VERIFIED', identity);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
144
|
function commitIntentCandidate(
|
|
198
145
|
repoRoot: string,
|
|
199
146
|
task: TaskRecord | undefined,
|
|
@@ -258,23 +205,12 @@ function readAuxiliaryCandidates(
|
|
|
258
205
|
candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH'));
|
|
259
206
|
continue;
|
|
260
207
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (!
|
|
264
|
-
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
candidates.push(localIntentCandidate(
|
|
268
|
-
repoRoot, tasks.get(match[1]!), filePath, match[1]!, match[2] as 'analysis' | 'plan' | 'code', match[3]!, identity, options
|
|
269
|
-
));
|
|
270
|
-
} else {
|
|
271
|
-
const match = COMMIT_INTENT_RE.exec(entry.name);
|
|
272
|
-
if (!match || (selected && !selected.has(match[1]!))) {
|
|
273
|
-
if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
|
|
208
|
+
const match = COMMIT_INTENT_RE.exec(entry.name);
|
|
209
|
+
if (!match || (selected && !selected.has(match[1]!))) {
|
|
210
|
+
if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
|
|
211
|
+
continue;
|
|
277
212
|
}
|
|
213
|
+
candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
|
|
278
214
|
}
|
|
279
215
|
if (entries.length === 0) {
|
|
280
216
|
candidates.push(item('EMPTY-AUX-PARENT', null, root, 'planned', 'EMPTY_CANONICAL_AUXILIARY_ROOT', rootIdentity));
|
|
@@ -307,56 +243,7 @@ function scanIntermediateCleanup(repoRoot: string, options: ScanOptions = {}): I
|
|
|
307
243
|
return buildIntermediateCleanupReport(candidates, true);
|
|
308
244
|
}
|
|
309
245
|
|
|
310
|
-
function
|
|
311
|
-
if (candidate.kind !== 'LFAI-CONSUMED' || !candidate.taskId) return true;
|
|
312
|
-
if (!task) return false;
|
|
313
|
-
const match = LOCAL_INTENT_RE.exec(path.basename(candidate.path));
|
|
314
|
-
if (!match) return false;
|
|
315
|
-
let intent: ReturnType<typeof readArtifactRecoveryIntent>;
|
|
316
|
-
try { intent = readArtifactRecoveryIntent(repoRoot, candidate.taskId, match[2] as ArtifactSchemaFamily, match[3]!); }
|
|
317
|
-
catch { return false; }
|
|
318
|
-
if (!intent || intent.state !== 'consumed') return false;
|
|
319
|
-
|
|
320
|
-
const recovery = artifactRecoveryRoot(task.taskDir, intent.recoveryOperationId);
|
|
321
|
-
let stat: fs.Stats | null = null;
|
|
322
|
-
try { stat = fs.lstatSync(recovery); } catch (error) {
|
|
323
|
-
if (errorCode(error) === 'ENOENT') return true;
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
327
|
-
let entries: fs.Dirent[];
|
|
328
|
-
try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
|
|
329
|
-
for (const entry of entries) {
|
|
330
|
-
if (entry.isSymbolicLink()) return false;
|
|
331
|
-
if (entry.isFile()) {
|
|
332
|
-
if (!['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
if (!entry.isDirectory() || entry.name !== 'generations') return false;
|
|
336
|
-
let generations: fs.Dirent[];
|
|
337
|
-
try { generations = fs.readdirSync(path.join(recovery, entry.name), { withFileTypes: true }); } catch { return false; }
|
|
338
|
-
if (generations.some((generation) => generation.isSymbolicLink()
|
|
339
|
-
|| !generation.isFile() || !/^[a-f0-9]{64}\.md$/u.test(generation.name))) return false;
|
|
340
|
-
}
|
|
341
|
-
try {
|
|
342
|
-
for (const entry of entries) {
|
|
343
|
-
const target = path.join(recovery, entry.name);
|
|
344
|
-
if (entry.isFile()) fs.unlinkSync(target);
|
|
345
|
-
else {
|
|
346
|
-
for (const generation of fs.readdirSync(target, { withFileTypes: true })) fs.unlinkSync(path.join(target, generation.name));
|
|
347
|
-
fs.rmdirSync(target);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
fs.rmdirSync(recovery);
|
|
351
|
-
const recoveryRoot = path.dirname(recovery);
|
|
352
|
-
if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
|
|
353
|
-
return true;
|
|
354
|
-
} catch (error) {
|
|
355
|
-
return errorCode(error) === 'ENOENT';
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, task?: TaskRecord): IntermediateCleanupItem {
|
|
246
|
+
function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem): IntermediateCleanupItem {
|
|
360
247
|
if (candidate.disposition !== 'planned') return candidate;
|
|
361
248
|
const root = candidate.kind === 'EMPTY-AUX-PARENT'
|
|
362
249
|
? path.dirname(path.dirname(candidate.path))
|
|
@@ -373,9 +260,6 @@ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, t
|
|
|
373
260
|
const actual = safeLstat(root, candidate.path, candidate.kind === 'EMPTY-AUX-PARENT' ? 'directory' : 'file');
|
|
374
261
|
if (!actual) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
|
|
375
262
|
if (!sameFileIdentity(expected, actual)) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
|
|
376
|
-
if (!cleanupConsumedRecoveryFiles(repoRoot, task, candidate)) {
|
|
377
|
-
return { ...candidate, disposition: 'protected', reason: 'RECOVERY_STAGING_NOT_SAFE_TO_REMOVE' };
|
|
378
|
-
}
|
|
379
263
|
try {
|
|
380
264
|
if (candidate.kind === 'EMPTY-AUX-PARENT') {
|
|
381
265
|
if (fs.readdirSync(candidate.path).length !== 0) return { ...candidate, disposition: 'protected', reason: 'AUXILIARY_ROOT_NOT_EMPTY' };
|
|
@@ -434,7 +318,7 @@ function cleanupIntermediateUnderRemovalCoordinator(
|
|
|
434
318
|
if (initialCandidate.disposition !== 'planned') continue;
|
|
435
319
|
const current = refreshedByKey.get(intermediateCleanupKey(initialCandidate));
|
|
436
320
|
items[index] = current
|
|
437
|
-
? removeCandidate(repoRoot, current
|
|
321
|
+
? removeCandidate(repoRoot, current)
|
|
438
322
|
: { ...initialCandidate, disposition: 'protected', reason: 'CANDIDATE_NO_LONGER_ELIGIBLE' };
|
|
439
323
|
}
|
|
440
324
|
};
|
|
@@ -14,7 +14,6 @@ import type { LedgerRow, ReviewStage } from './ledger.ts';
|
|
|
14
14
|
import { resolveTaskRef } from './resolve-ref.ts';
|
|
15
15
|
import { writeTask } from './write.ts';
|
|
16
16
|
import type { TaskMutation, TaskOperationSummary, TaskWriteOptions } from './write.ts';
|
|
17
|
-
import { allowsManualOverride } from './guard-override.ts';
|
|
18
17
|
import { parseLegacyReworkIntentDocument, parseReworkIntentDocument, reworkIntentMutation, upsertReworkIntent } from './rework-intent.ts';
|
|
19
18
|
import type { ReworkClassification, ReworkIntent, ReworkTarget } from './rework-intent.ts';
|
|
20
19
|
import { extractSection, extractSubSection } from './sections.ts';
|
|
@@ -149,8 +148,7 @@ function mapWrite(intent: LedgerIntent, entityId: string, before: LedgerRow | nu
|
|
|
149
148
|
function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {}): LedgerIntentResult {
|
|
150
149
|
const resolved = resolveTaskRef(intent.taskRef, { repoRoot: options.repoRoot });
|
|
151
150
|
if (!resolved.ok) return failed(intent, resolved.code, resolved.message, resolved.taskId);
|
|
152
|
-
|
|
153
|
-
if (resolved.state !== 'active' && !stateOverride) return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
|
|
151
|
+
if (resolved.state !== 'active') return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
|
|
154
152
|
let content: string;
|
|
155
153
|
let rows: LedgerRow[];
|
|
156
154
|
try {
|
|
@@ -353,8 +351,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
|
|
|
353
351
|
? new Set(['confirmed', 'open', 'needs-human-decision'])
|
|
354
352
|
: before.status === 'cannot-judge' ? new Set(['open', 'needs-human-decision']) : new Set<string>();
|
|
355
353
|
const sameRoundMinorClose = before.status === 'open' && before.severity === 'minor' && intent.status === 'closed';
|
|
356
|
-
if (
|
|
357
|
-
&& !allowsManualOverride(options.manualOverride, 'ledger-intent', 'LEDGER_TRANSITION_INVALID')) {
|
|
354
|
+
if (!sameRoundMinorClose && !allowed.has(intent.status)) {
|
|
358
355
|
return failed(intent, 'LEDGER_TRANSITION_INVALID', `finding '${intent.id}' cannot transition from ${before.status} to ${intent.status}`, resolved.taskId, intent.id);
|
|
359
356
|
}
|
|
360
357
|
after = { ...before, status: intent.status, evidence };
|
|
@@ -388,7 +385,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
|
|
|
388
385
|
}
|
|
389
386
|
|
|
390
387
|
const result = writeTask({
|
|
391
|
-
taskRef: intent.taskRef, expectedState:
|
|
388
|
+
taskRef: intent.taskRef, expectedState: 'active',
|
|
392
389
|
mutations: [rowMutation(after), ...(implementationMutation ? [implementationMutation] : [])],
|
|
393
390
|
dryRun: 'dryRun' in intent ? intent.dryRun : false
|
|
394
391
|
}, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
|